@phnx-labs/agents-cli 1.22.57 → 1.22.59
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +294 -0
- package/README.md +29 -0
- package/dist/bootstrap.js +39 -1
- package/dist/commands/accounts.js +7 -3
- package/dist/commands/apply.js +10 -2
- package/dist/commands/fork.d.ts +23 -10
- package/dist/commands/fork.js +115 -58
- package/dist/commands/monitors.js +198 -23
- package/dist/commands/prune.js +5 -3
- package/dist/commands/routines.d.ts +8 -0
- package/dist/commands/routines.js +57 -3
- package/dist/commands/routines.test-fixture.js +5 -0
- package/dist/commands/send.d.ts +2 -1
- package/dist/commands/send.js +7 -5
- package/dist/commands/sessions-picker.d.ts +11 -0
- package/dist/commands/sessions-picker.js +16 -0
- package/dist/commands/sessions-stats.js +37 -5
- package/dist/commands/sessions.js +40 -5
- package/dist/commands/share.d.ts +14 -0
- package/dist/commands/share.js +43 -2
- package/dist/commands/ssh.js +12 -1
- package/dist/commands/status.js +1 -1
- package/dist/commands/sync.js +83 -7
- package/dist/commands/traces.js +7 -0
- package/dist/commands/versions.js +12 -4
- package/dist/commands/view.js +7 -2
- package/dist/index.d.ts +1 -1
- package/dist/index.js +6 -1
- package/dist/lib/account-registry.d.ts +5 -1
- package/dist/lib/account-registry.js +47 -14
- package/dist/lib/accounting/capacity.d.ts +18 -7
- package/dist/lib/accounting/capacity.js +19 -8
- package/dist/lib/accounting/usage-sync.d.ts +29 -1
- package/dist/lib/accounting/usage-sync.js +76 -2
- package/dist/lib/accounting/usage.js +7 -1
- package/dist/lib/auth-mint.d.ts +11 -1
- package/dist/lib/auth-mint.js +21 -6
- package/dist/lib/auto-pull-worker.js +7 -2
- package/dist/lib/browser/ipc.d.ts +8 -0
- package/dist/lib/browser/ipc.js +87 -0
- package/dist/lib/browser/service.d.ts +19 -0
- package/dist/lib/browser/service.js +96 -11
- package/dist/lib/browser/sessions-list.js +10 -1
- package/dist/lib/cloud/rush.d.ts +7 -0
- package/dist/lib/cloud/rush.js +29 -1
- package/dist/lib/daemon/daemon.d.ts +22 -0
- package/dist/lib/daemon/daemon.js +39 -0
- package/dist/lib/daemon/runner.d.ts +3 -0
- package/dist/lib/daemon/runner.js +86 -45
- package/dist/lib/daemon/session-index-service.js +9 -1
- package/dist/lib/daemon/usage-sync-service.d.ts +3 -3
- package/dist/lib/daemon/usage-sync-service.js +14 -8
- package/dist/lib/daemon-services.js +1 -1
- package/dist/lib/daemon-ticks.d.ts +15 -0
- package/dist/lib/daemon-ticks.js +26 -0
- package/dist/lib/device-config.d.ts +5 -1
- package/dist/lib/device-config.js +2 -2
- package/dist/lib/devices/connect.d.ts +17 -8
- package/dist/lib/devices/connect.js +31 -14
- package/dist/lib/devices/health.js +5 -1
- package/dist/lib/devices/pool.d.ts +25 -2
- package/dist/lib/devices/pool.js +32 -2
- package/dist/lib/devices/stats-cache.d.ts +0 -6
- package/dist/lib/devices/stats-cache.js +2 -9
- package/dist/lib/doctor-diff.d.ts +14 -0
- package/dist/lib/doctor-diff.js +120 -9
- package/dist/lib/fleet/manifest.d.ts +17 -0
- package/dist/lib/fleet/manifest.js +26 -0
- package/dist/lib/git.d.ts +38 -0
- package/dist/lib/git.js +58 -0
- package/dist/lib/hooks/install.d.ts +27 -11
- package/dist/lib/hooks/install.js +42 -17
- package/dist/lib/hosts/ready.d.ts +8 -0
- package/dist/lib/hosts/ready.js +13 -2
- package/dist/lib/hosts/reconnect.d.ts +52 -203
- package/dist/lib/hosts/reconnect.js +64 -284
- package/dist/lib/installations/migrate.d.ts +6 -120
- package/dist/lib/installations/migrate.js +27 -259
- package/dist/lib/installations/shims.d.ts +13 -95
- package/dist/lib/installations/shims.js +22 -139
- package/dist/lib/installations/store.js +1 -1
- package/dist/lib/installations/versions.d.ts +43 -133
- package/dist/lib/installations/versions.js +94 -206
- package/dist/lib/monitors/config.d.ts +71 -3
- package/dist/lib/monitors/config.js +100 -12
- package/dist/lib/monitors/pid-watch.d.ts +35 -0
- package/dist/lib/monitors/pid-watch.js +45 -0
- package/dist/lib/monitors/remote.d.ts +18 -0
- package/dist/lib/monitors/remote.js +11 -0
- package/dist/lib/permissions.js +7 -2
- package/dist/lib/plugins/plugins.d.ts +17 -3
- package/dist/lib/plugins/plugins.js +84 -9
- package/dist/lib/plugins/skills.d.ts +8 -1
- package/dist/lib/plugins/skills.js +18 -2
- package/dist/lib/pty-server.d.ts +14 -0
- package/dist/lib/pty-server.js +49 -5
- package/dist/lib/refresh.d.ts +9 -0
- package/dist/lib/refresh.js +3 -1
- package/dist/lib/routine-readiness.d.ts +15 -1
- package/dist/lib/routine-readiness.js +41 -0
- package/dist/lib/sandbox.d.ts +4 -1
- package/dist/lib/sandbox.js +30 -1
- package/dist/lib/secrets/agent.d.ts +80 -225
- package/dist/lib/secrets/agent.js +139 -401
- package/dist/lib/secrets/bundles.d.ts +73 -222
- package/dist/lib/secrets/bundles.js +168 -467
- package/dist/lib/secrets/drivers/rush.js +5 -0
- package/dist/lib/secrets/reaper.d.ts +28 -70
- package/dist/lib/secrets/reaper.js +30 -85
- package/dist/lib/secrets/remote.d.ts +42 -129
- package/dist/lib/secrets/remote.js +55 -173
- package/dist/lib/self-heal/checks/install-staging.d.ts +4 -0
- package/dist/lib/self-heal/checks/install-staging.js +96 -0
- package/dist/lib/self-heal/registry.js +2 -0
- package/dist/lib/self-heal/types.d.ts +1 -1
- package/dist/lib/self-update.d.ts +65 -0
- package/dist/lib/self-update.js +138 -0
- package/dist/lib/session/active.d.ts +13 -1
- package/dist/lib/session/active.js +2 -0
- package/dist/lib/session/cloud.js +5 -0
- package/dist/lib/session/db.d.ts +51 -6
- package/dist/lib/session/db.js +266 -20
- package/dist/lib/session/fork.d.ts +45 -26
- package/dist/lib/session/fork.js +32 -95
- package/dist/lib/session/tool-calls.d.ts +43 -1
- package/dist/lib/session/tool-calls.js +74 -44
- package/dist/lib/session/tool-store.d.ts +33 -2
- package/dist/lib/session/tool-store.js +56 -3
- package/dist/lib/smart-launch.d.ts +6 -0
- package/dist/lib/smart-launch.js +5 -2
- package/dist/lib/staleness/writers/plugins.js +5 -2
- package/dist/lib/staleness/writers/sources.d.ts +5 -0
- package/dist/lib/staleness/writers/sources.js +2 -1
- package/dist/lib/staleness/writers/subagents.js +13 -3
- package/dist/lib/state.d.ts +7 -4
- package/dist/lib/state.js +7 -4
- package/dist/lib/subagents.js +8 -2
- package/dist/lib/sync-status.d.ts +22 -0
- package/dist/lib/sync-status.js +27 -0
- package/dist/lib/sync-umbrella.d.ts +9 -0
- package/dist/lib/sync-umbrella.js +21 -2
- package/dist/lib/teams/scheduler.d.ts +10 -0
- package/dist/lib/teams/scheduler.js +8 -0
- package/dist/lib/traces/insights.d.ts +47 -14
- package/dist/lib/traces/insights.js +92 -21
- package/dist/lib/traces/phenotype.d.ts +23 -3
- package/dist/lib/traces/phenotype.js +72 -24
- package/dist/lib/traces/sync.d.ts +128 -6
- package/dist/lib/traces/sync.js +294 -35
- package/dist/lib/traces/worker-template.js +154 -1
- package/dist/lib/view-types.d.ts +12 -0
- package/package.json +2 -2
|
@@ -284,6 +284,12 @@ const USAGE_BAR_LEN = 10;
|
|
|
284
284
|
const FULL = '\u2588';
|
|
285
285
|
const EMPTY = '\u2591';
|
|
286
286
|
const PARTIAL_BLOCKS = ['', '\u258F', '\u258E', '\u258D', '\u258C', '\u258B', '\u258A', '\u2589'];
|
|
287
|
+
// A window we EXPECTED but have no reading for \u2014 e.g. Claude's 5h "session"
|
|
288
|
+
// window when the account has no usage in the current rolling window, so the
|
|
289
|
+
// usage API returns five_hour.utilization = null and no session bar is written.
|
|
290
|
+
// It must read as neither 0% (EMPTY '\u2591') nor 100% (FULL '\u2588') \u2014 a full block was
|
|
291
|
+
// alarming and looked maxed-out \u2014 so use a dashed row that says "no data".
|
|
292
|
+
const NO_DATA = '\u2504';
|
|
287
293
|
/** Construct the benign no-local-log result without overloading `error`. */
|
|
288
294
|
export function usageNoRecentUsageInfo() {
|
|
289
295
|
return { snapshot: null, error: null, [USAGE_BENIGN_STATE]: 'no-recent-usage' };
|
|
@@ -590,7 +596,7 @@ export function formatUsageSummary(plan, snapshot, planWidth = 3, opts) {
|
|
|
590
596
|
: selected.map((window) => ({ window, shortLabel: window.shortLabel }));
|
|
591
597
|
const windowParts = windowsToRender.map(({ window, shortLabel }, index) => {
|
|
592
598
|
if (!window) {
|
|
593
|
-
const missing = chalk.
|
|
599
|
+
const missing = chalk.dim(`${shortLabel}: ${NO_DATA.repeat(COMPACT_BAR_LEN)} unavailable`);
|
|
594
600
|
return index < windowsToRender.length - 1 ? padToWidth(missing, 20) : missing;
|
|
595
601
|
}
|
|
596
602
|
const bar = renderCompactUsageBar(window.usedPercent);
|
package/dist/lib/auth-mint.d.ts
CHANGED
|
@@ -143,7 +143,17 @@ export interface MintAndSeedResult {
|
|
|
143
143
|
*/
|
|
144
144
|
export declare function mintAndSeed(input: MintAndSeedInput): Promise<MintAndSeedResult>;
|
|
145
145
|
export declare function resolveSyncTargets(fleet: boolean, devices: string[]): Promise<string[]>;
|
|
146
|
-
/**
|
|
146
|
+
/**
|
|
147
|
+
* True when a Claude setup-token is already seeded on this box (setup status).
|
|
148
|
+
*
|
|
149
|
+
* Read-only status probe (`agents setup`, `agents doctor`) — the reserved
|
|
150
|
+
* auth-bundle check below MUST NOT crash the whole command because the
|
|
151
|
+
* keychain could not be reached (e.g. the macOS Keychain helper source is
|
|
152
|
+
* unavailable — PHNX-3385); `bundleExists()`/`hasKeychainToken()` document
|
|
153
|
+
* themselves as failing loud, but that contract is for destructive-write
|
|
154
|
+
* guards, not a diagnostic. An unreachable keychain here just means "cannot
|
|
155
|
+
* confirm the reserved bundle," reported honestly rather than propagated.
|
|
156
|
+
*/
|
|
147
157
|
export declare function hasMintedSetupToken(): {
|
|
148
158
|
ready: boolean;
|
|
149
159
|
detail: string;
|
package/dist/lib/auth-mint.js
CHANGED
|
@@ -414,18 +414,33 @@ async function syncMintedBundles(accountName, device) {
|
|
|
414
414
|
return { device, ok: false, message: err instanceof Error ? err.message : String(err) };
|
|
415
415
|
}
|
|
416
416
|
}
|
|
417
|
-
/**
|
|
417
|
+
/**
|
|
418
|
+
* True when a Claude setup-token is already seeded on this box (setup status).
|
|
419
|
+
*
|
|
420
|
+
* Read-only status probe (`agents setup`, `agents doctor`) — the reserved
|
|
421
|
+
* auth-bundle check below MUST NOT crash the whole command because the
|
|
422
|
+
* keychain could not be reached (e.g. the macOS Keychain helper source is
|
|
423
|
+
* unavailable — PHNX-3385); `bundleExists()`/`hasKeychainToken()` document
|
|
424
|
+
* themselves as failing loud, but that contract is for destructive-write
|
|
425
|
+
* guards, not a diagnostic. An unreachable keychain here just means "cannot
|
|
426
|
+
* confirm the reserved bundle," reported honestly rather than propagated.
|
|
427
|
+
*/
|
|
418
428
|
export function hasMintedSetupToken() {
|
|
419
429
|
const records = Object.values(readAccountRegistry().accounts);
|
|
420
430
|
const setup = records.filter((a) => a.auth === 'setup-token' && a.provider === 'anthropic');
|
|
421
431
|
if (setup.length) {
|
|
422
432
|
return { ready: true, detail: `${setup.length} Claude setup-token account${setup.length === 1 ? '' : 's'}` };
|
|
423
433
|
}
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
434
|
+
try {
|
|
435
|
+
if (bundleExists(AUTH_BUNDLE) && bundleBackend(AUTH_BUNDLE) === 'file') {
|
|
436
|
+
const bundle = readBundle(AUTH_BUNDLE);
|
|
437
|
+
const keys = Object.keys(bundle.vars).filter((k) => k.startsWith('CLAUDE_CODE_OAUTH_TOKEN_'));
|
|
438
|
+
if (keys.length)
|
|
439
|
+
return { ready: true, detail: `reserved auth bundle (${keys.length} account key${keys.length === 1 ? '' : 's'})` };
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
catch (err) {
|
|
443
|
+
return { ready: false, detail: `could not check the reserved auth bundle: ${err.message}` };
|
|
429
444
|
}
|
|
430
445
|
return { ready: false, detail: 'no Claude setup-token minted — agents accounts mint claude' };
|
|
431
446
|
}
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
*/
|
|
11
11
|
import * as fs from 'fs';
|
|
12
12
|
import { simpleGit } from 'simple-git';
|
|
13
|
-
import {
|
|
13
|
+
import { tryAutoPullSystemRepo, isGitRepo } from './git.js';
|
|
14
14
|
import { getSystemAgentsDir, getUserAgentsDir, getEnabledExtraRepos, getFetchCacheDir, } from './state.js';
|
|
15
15
|
import { lockFilePath, statusFilePath, markDetachedSyncComplete, SYNC_LOCK_TTL_MS, } from './auto-pull.js';
|
|
16
16
|
/**
|
|
@@ -99,7 +99,12 @@ async function processTarget(target) {
|
|
|
99
99
|
await notifyRepo(target);
|
|
100
100
|
}
|
|
101
101
|
else {
|
|
102
|
-
|
|
102
|
+
// Verify origin is the EXPECTED system remote before fast-forwarding —
|
|
103
|
+
// the system repo ships hooks that run as shell, so a pull from a
|
|
104
|
+
// repointed origin is RCE. An unexpected origin is refused, not pulled
|
|
105
|
+
// (PHNX-2957). The detached worker has no terminal to warn on; the
|
|
106
|
+
// foreground `agents use` path surfaces the refusal to the operator.
|
|
107
|
+
await tryAutoPullSystemRepo(target.dir);
|
|
103
108
|
}
|
|
104
109
|
}
|
|
105
110
|
else {
|
|
@@ -12,6 +12,14 @@ export declare function getSocketPath(): string;
|
|
|
12
12
|
/** Is the daemon reachable? A real connect probe on every platform — a socket
|
|
13
13
|
* file existing on disk is not proof a daemon is listening on it. */
|
|
14
14
|
export declare function isDaemonReachable(): Promise<boolean>;
|
|
15
|
+
/**
|
|
16
|
+
* Is a *reachable* daemon actually responsive, or is its event loop wedged
|
|
17
|
+
* (PHNX-3411)? Retries {@link probeDaemonResponsive} up to
|
|
18
|
+
* {@link RESPONSIVENESS_PROBE_ATTEMPTS} times so a single transient miss (a GC
|
|
19
|
+
* pause) never condemns a healthy daemon; returns true as soon as any attempt
|
|
20
|
+
* gets a reply, and false only when every attempt fails.
|
|
21
|
+
*/
|
|
22
|
+
export declare function isDaemonResponsive(endpoint?: string, attempts?: number): Promise<boolean>;
|
|
15
23
|
/**
|
|
16
24
|
* Wait until the browser daemon is genuinely reachable, or throw.
|
|
17
25
|
*
|
package/dist/lib/browser/ipc.js
CHANGED
|
@@ -92,6 +92,31 @@ const SOCKET_WAIT_TIMEOUT_MS = 15_000;
|
|
|
92
92
|
* doesn't race a restart it happened to probe mid-flight.
|
|
93
93
|
*/
|
|
94
94
|
const SOCKET_WAIT_STABLE_PROBES = 2;
|
|
95
|
+
/**
|
|
96
|
+
* How long a single {@link probeDaemonResponsive} attempt waits for the daemon to
|
|
97
|
+
* answer a trivial `version` request before giving up (PHNX-3411).
|
|
98
|
+
*
|
|
99
|
+
* A unix-socket `connect` succeeds at the KERNEL level the moment the connection
|
|
100
|
+
* is queued in the listen backlog — it does NOT require the server's event loop
|
|
101
|
+
* to run. So a daemon whose event loop is blocked (e.g. a long synchronous burst
|
|
102
|
+
* on the shared loop) still passes {@link isDaemonReachable}: the socket accepts,
|
|
103
|
+
* but the request is never serviced. The live symptom on zion was exactly this —
|
|
104
|
+
* `browser.sock` accepted every connection while the daemon re-indexed sessions,
|
|
105
|
+
* then never replied, so cross-device browser drives hung or surfaced a confusing
|
|
106
|
+
* bare socket timeout. Requiring an actual reply is the only probe that tells a
|
|
107
|
+
* healthy daemon apart from a wedged one.
|
|
108
|
+
*/
|
|
109
|
+
const RESPONSIVENESS_PROBE_TIMEOUT_MS = 1_500;
|
|
110
|
+
/**
|
|
111
|
+
* Consecutive missed {@link probeDaemonResponsive} attempts before a *reachable*
|
|
112
|
+
* daemon is declared wedged. One missed reply can be a transient GC pause on an
|
|
113
|
+
* otherwise-healthy loop, so a single failure never condemns the daemon; a daemon
|
|
114
|
+
* that cannot answer a trivial `version` request across this whole window has a
|
|
115
|
+
* genuinely blocked event loop. The added latency (attempts × timeout) is paid
|
|
116
|
+
* ONLY on the wedged path — where the alternative was an indefinite hang — while
|
|
117
|
+
* a healthy daemon answers the first attempt in ~1ms.
|
|
118
|
+
*/
|
|
119
|
+
const RESPONSIVENESS_PROBE_ATTEMPTS = 3;
|
|
95
120
|
export class BrowserDaemonNotRunningError extends Error {
|
|
96
121
|
constructor() {
|
|
97
122
|
super(formatBrowserDaemonNotRunningError());
|
|
@@ -140,6 +165,57 @@ function probeDaemon(endpoint, timeoutMs = 500) {
|
|
|
140
165
|
export async function isDaemonReachable() {
|
|
141
166
|
return probeDaemon(getIpcEndpoint());
|
|
142
167
|
}
|
|
168
|
+
/**
|
|
169
|
+
* Can the daemon actually REPLY right now? Opens a connection and requires a
|
|
170
|
+
* parseable response to a `version` request within `timeoutMs`. Unlike
|
|
171
|
+
* {@link probeDaemon} (which resolves on the kernel-level `connect`), this only
|
|
172
|
+
* succeeds when the daemon's event loop is running and services the request — so
|
|
173
|
+
* it is the one probe that distinguishes a healthy daemon from a wedged one whose
|
|
174
|
+
* loop is blocked (PHNX-3411). Resolves false on connect error, timeout, an early
|
|
175
|
+
* close, or an unparseable reply. `version` is the right probe: its handler is
|
|
176
|
+
* trivial and synchronous (never touches the browser), so a slow reply means the
|
|
177
|
+
* loop is blocked, not that a real action is in flight.
|
|
178
|
+
*/
|
|
179
|
+
function probeDaemonResponsive(endpoint, timeoutMs = RESPONSIVENESS_PROBE_TIMEOUT_MS) {
|
|
180
|
+
return new Promise((resolve) => {
|
|
181
|
+
const sock = net.createConnection(endpoint);
|
|
182
|
+
let buffer = '';
|
|
183
|
+
let done = false;
|
|
184
|
+
const finish = (ok) => { if (done)
|
|
185
|
+
return; done = true; clearTimeout(timer); sock.destroy(); resolve(ok); };
|
|
186
|
+
const timer = setTimeout(() => finish(false), timeoutMs);
|
|
187
|
+
sock.on('connect', () => { sock.write(JSON.stringify({ action: 'version' }) + '\n'); });
|
|
188
|
+
sock.on('data', (data) => {
|
|
189
|
+
buffer += data.toString();
|
|
190
|
+
const idx = buffer.indexOf('\n');
|
|
191
|
+
if (idx === -1)
|
|
192
|
+
return;
|
|
193
|
+
try {
|
|
194
|
+
const resp = JSON.parse(buffer.slice(0, idx));
|
|
195
|
+
finish(resp.ok === true);
|
|
196
|
+
}
|
|
197
|
+
catch {
|
|
198
|
+
finish(false);
|
|
199
|
+
}
|
|
200
|
+
});
|
|
201
|
+
sock.on('error', () => finish(false));
|
|
202
|
+
sock.on('close', () => finish(false));
|
|
203
|
+
});
|
|
204
|
+
}
|
|
205
|
+
/**
|
|
206
|
+
* Is a *reachable* daemon actually responsive, or is its event loop wedged
|
|
207
|
+
* (PHNX-3411)? Retries {@link probeDaemonResponsive} up to
|
|
208
|
+
* {@link RESPONSIVENESS_PROBE_ATTEMPTS} times so a single transient miss (a GC
|
|
209
|
+
* pause) never condemns a healthy daemon; returns true as soon as any attempt
|
|
210
|
+
* gets a reply, and false only when every attempt fails.
|
|
211
|
+
*/
|
|
212
|
+
export async function isDaemonResponsive(endpoint = getIpcEndpoint(), attempts = RESPONSIVENESS_PROBE_ATTEMPTS) {
|
|
213
|
+
for (let i = 0; i < attempts; i++) {
|
|
214
|
+
if (await probeDaemonResponsive(endpoint))
|
|
215
|
+
return true;
|
|
216
|
+
}
|
|
217
|
+
return false;
|
|
218
|
+
}
|
|
143
219
|
/**
|
|
144
220
|
* Wait until the browser daemon is genuinely reachable, or throw.
|
|
145
221
|
*
|
|
@@ -1087,6 +1163,17 @@ async function prepareIPC(action, opts) {
|
|
|
1087
1163
|
}
|
|
1088
1164
|
await new Promise((r) => setTimeout(r, 300));
|
|
1089
1165
|
}
|
|
1166
|
+
// The socket accepts connections — but a unix-socket `connect` succeeds at the
|
|
1167
|
+
// kernel level even when the daemon's event loop is blocked and never services
|
|
1168
|
+
// the request (PHNX-3411). Require a real reply before treating the daemon as
|
|
1169
|
+
// usable, so a wedged daemon fails LOUD with an accurate, actionable error
|
|
1170
|
+
// instead of the confusing bare socket timeout / indefinite hang the browser
|
|
1171
|
+
// verb would otherwise hit (including inside reconcileDaemonVersion's own
|
|
1172
|
+
// version probe, which has no response timeout). Skipped for callers that opt
|
|
1173
|
+
// out of auto-start — they want the clean BrowserDaemonNotRunningError instead.
|
|
1174
|
+
if (autoStartDaemon && !(await isDaemonResponsive(getIpcEndpoint()))) {
|
|
1175
|
+
throw new Error(actionable('Browser daemon is running but unresponsive — its event loop is blocked, so it accepts the connection but never replies.', `Endpoint: ${getIpcEndpoint()}`, `Log: ${getDaemonLogPath()}`, 'Next: agents browser stop --daemon (resets the wedged daemon; the next browser command restarts it)'));
|
|
1176
|
+
}
|
|
1090
1177
|
// Before serving a real request, make sure the daemon isn't running stale
|
|
1091
1178
|
// code. Skips the internal `version` probe (avoids recursion) and callers
|
|
1092
1179
|
// that opt out of auto-start. No-ops once reconciled or when versions match.
|
|
@@ -534,6 +534,25 @@ export declare class BrowserService {
|
|
|
534
534
|
* tasks.json entries. Used before identity-based resolution so a daemon
|
|
535
535
|
* restart does not make the caller's tasks invisible.
|
|
536
536
|
*/
|
|
537
|
+
/**
|
|
538
|
+
* Register a freshly-rehydrated connection, unless a concurrent rehydrate won
|
|
539
|
+
* the race and already registered one for this key. `attachRunningProfile`
|
|
540
|
+
* awaits an ssh-tunnel spawn / CDP connect, so two commands landing right
|
|
541
|
+
* after a daemon restart can each build a connection for the same key; the
|
|
542
|
+
* loser must be released instead of silently overwriting the winner (whose
|
|
543
|
+
* `cleanup` would then never run).
|
|
544
|
+
*
|
|
545
|
+
* The subtlety is the SSH tunnel: when the loser's `connectSSH` landed after
|
|
546
|
+
* the winner's tunnel had already bound the local port, `isOwnTunnel` makes it
|
|
547
|
+
* **reuse** that same OS process (`drivers/ssh.ts`) — so both connections carry
|
|
548
|
+
* the same `pid`/`port`. Calling the loser's `cleanup()` then does
|
|
549
|
+
* `tunnel.kill()` on the SHARED process and breaks the winner's live CDP. So
|
|
550
|
+
* when the loser shares the winner's tunnel we close ONLY the loser's own CDP
|
|
551
|
+
* socket and leave the tunnel to the winner; only a loser that spawned its own
|
|
552
|
+
* distinct tunnel gets the full `cleanup()` (else it would leak). Returns the
|
|
553
|
+
* winning connection to use.
|
|
554
|
+
*/
|
|
555
|
+
private registerRehydratedConnection;
|
|
537
556
|
private rehydrateAllFromDisk;
|
|
538
557
|
/**
|
|
539
558
|
* On a RAM miss, scan profile runtime dirs for a tasks.json entry matching
|
|
@@ -2608,9 +2608,45 @@ export class BrowserService {
|
|
|
2608
2608
|
if (port === undefined)
|
|
2609
2609
|
return null;
|
|
2610
2610
|
const host = parsed?.host && parsed.host !== 'localhost' ? parsed.host : 'localhost';
|
|
2611
|
-
// ssh:// endpoints
|
|
2612
|
-
|
|
2613
|
-
|
|
2611
|
+
// ssh:// endpoints: a daemon restart killed this box's tunnel, but the
|
|
2612
|
+
// browser is still running on the FAR side — so the caller's tasks are
|
|
2613
|
+
// valid, only the local hop is gone. Re-establish the tunnel and reconnect
|
|
2614
|
+
// CDP (connectSSH attaches to the already-running remote browser via
|
|
2615
|
+
// isOwnTunnel — it never launches one, so this stays a soft-attach), then
|
|
2616
|
+
// merge the disk tasks. Without this, a remote agent driving a browser host
|
|
2617
|
+
// after a restart got "Unknown browser task" for a tab that was still alive
|
|
2618
|
+
// (PHNX-2663). A failure returns null exactly like the local-CDP path, so
|
|
2619
|
+
// the caller falls through to disk reconcile rather than crashing.
|
|
2620
|
+
if (resolved.target.startsWith('ssh:')) {
|
|
2621
|
+
try {
|
|
2622
|
+
const conn = await connectSSH(resolved.target, profile, key, { persistRemote: true });
|
|
2623
|
+
await this.enableDomains(conn.cdp);
|
|
2624
|
+
const tasks = this.loadTaskState(key);
|
|
2625
|
+
for (const [k, t] of diskTasks) {
|
|
2626
|
+
if (!tasks.has(k))
|
|
2627
|
+
tasks.set(k, t);
|
|
2628
|
+
}
|
|
2629
|
+
return {
|
|
2630
|
+
cdp: conn.cdp,
|
|
2631
|
+
port: conn.port,
|
|
2632
|
+
pid: conn.pid,
|
|
2633
|
+
electron: profile.electron,
|
|
2634
|
+
browserType: profile.browser,
|
|
2635
|
+
targetFilter: resolved.targetFilter ?? profile.targetFilter,
|
|
2636
|
+
key,
|
|
2637
|
+
profile: bare,
|
|
2638
|
+
tasks,
|
|
2639
|
+
sessionCache: new Map(),
|
|
2640
|
+
// Carry the tunnel teardown so removing this connection kills the
|
|
2641
|
+
// ssh hop — otherwise it leaks across the next restart (see the
|
|
2642
|
+
// `cleanup` docblock on ProfileConnection).
|
|
2643
|
+
cleanup: conn.cleanup,
|
|
2644
|
+
};
|
|
2645
|
+
}
|
|
2646
|
+
catch {
|
|
2647
|
+
return null;
|
|
2648
|
+
}
|
|
2649
|
+
}
|
|
2614
2650
|
try {
|
|
2615
2651
|
const { wsUrl, browser } = await discoverBrowserWsUrl(port, host, bare);
|
|
2616
2652
|
verifyBrowserIdentity(browser, profile.browser, port, host);
|
|
@@ -2645,6 +2681,48 @@ export class BrowserService {
|
|
|
2645
2681
|
* tasks.json entries. Used before identity-based resolution so a daemon
|
|
2646
2682
|
* restart does not make the caller's tasks invisible.
|
|
2647
2683
|
*/
|
|
2684
|
+
/**
|
|
2685
|
+
* Register a freshly-rehydrated connection, unless a concurrent rehydrate won
|
|
2686
|
+
* the race and already registered one for this key. `attachRunningProfile`
|
|
2687
|
+
* awaits an ssh-tunnel spawn / CDP connect, so two commands landing right
|
|
2688
|
+
* after a daemon restart can each build a connection for the same key; the
|
|
2689
|
+
* loser must be released instead of silently overwriting the winner (whose
|
|
2690
|
+
* `cleanup` would then never run).
|
|
2691
|
+
*
|
|
2692
|
+
* The subtlety is the SSH tunnel: when the loser's `connectSSH` landed after
|
|
2693
|
+
* the winner's tunnel had already bound the local port, `isOwnTunnel` makes it
|
|
2694
|
+
* **reuse** that same OS process (`drivers/ssh.ts`) — so both connections carry
|
|
2695
|
+
* the same `pid`/`port`. Calling the loser's `cleanup()` then does
|
|
2696
|
+
* `tunnel.kill()` on the SHARED process and breaks the winner's live CDP. So
|
|
2697
|
+
* when the loser shares the winner's tunnel we close ONLY the loser's own CDP
|
|
2698
|
+
* socket and leave the tunnel to the winner; only a loser that spawned its own
|
|
2699
|
+
* distinct tunnel gets the full `cleanup()` (else it would leak). Returns the
|
|
2700
|
+
* winning connection to use.
|
|
2701
|
+
*/
|
|
2702
|
+
registerRehydratedConnection(key, conn) {
|
|
2703
|
+
const existing = this.connections.get(key);
|
|
2704
|
+
if (!existing) {
|
|
2705
|
+
this.connections.set(key, conn);
|
|
2706
|
+
return conn;
|
|
2707
|
+
}
|
|
2708
|
+
const sharesTunnel = conn.pid !== 0 && conn.pid === existing.pid && conn.port === existing.port;
|
|
2709
|
+
if (conn.pid !== 0 && !sharesTunnel && conn.cleanup) {
|
|
2710
|
+
// Distinct tunnel — full teardown so this loser's tunnel doesn't leak.
|
|
2711
|
+
try {
|
|
2712
|
+
conn.cleanup();
|
|
2713
|
+
}
|
|
2714
|
+
catch { /* best effort — the winner stays live */ }
|
|
2715
|
+
}
|
|
2716
|
+
else {
|
|
2717
|
+
// Shared/borrowed tunnel (or a local connection with no tunnel): close
|
|
2718
|
+
// only our own CDP client; killing the tunnel would break the winner.
|
|
2719
|
+
try {
|
|
2720
|
+
conn.cdp.close();
|
|
2721
|
+
}
|
|
2722
|
+
catch { /* best effort */ }
|
|
2723
|
+
}
|
|
2724
|
+
return existing;
|
|
2725
|
+
}
|
|
2648
2726
|
async rehydrateAllFromDisk() {
|
|
2649
2727
|
const runtimeRoot = getBrowserRuntimeDir();
|
|
2650
2728
|
let dirNames = [];
|
|
@@ -2676,7 +2754,11 @@ export class BrowserService {
|
|
|
2676
2754
|
const conn = await this.attachRunningProfile(key, tasks);
|
|
2677
2755
|
if (!conn)
|
|
2678
2756
|
continue;
|
|
2679
|
-
this
|
|
2757
|
+
// A concurrent rehydrate may have registered one for this key while we
|
|
2758
|
+
// awaited the attach; keep the winner and tear our loser's tunnel down.
|
|
2759
|
+
const registered = this.registerRehydratedConnection(key, conn);
|
|
2760
|
+
if (registered !== conn)
|
|
2761
|
+
continue;
|
|
2680
2762
|
try {
|
|
2681
2763
|
await this.applyDefaultDownloadBehavior(conn, key);
|
|
2682
2764
|
}
|
|
@@ -2734,13 +2816,16 @@ export class BrowserService {
|
|
|
2734
2816
|
// CDP down — cannot rehydrate a live connection; caller will error.
|
|
2735
2817
|
continue;
|
|
2736
2818
|
}
|
|
2737
|
-
|
|
2738
|
-
|
|
2739
|
-
|
|
2740
|
-
|
|
2741
|
-
|
|
2742
|
-
|
|
2743
|
-
|
|
2819
|
+
// Keep whichever connection a concurrent rehydrate registered first and
|
|
2820
|
+
// tear our loser's tunnel down instead of leaking it (see the helper).
|
|
2821
|
+
conn = this.registerRehydratedConnection(key, attached);
|
|
2822
|
+
if (conn === attached) {
|
|
2823
|
+
try {
|
|
2824
|
+
await this.applyDefaultDownloadBehavior(conn, key);
|
|
2825
|
+
}
|
|
2826
|
+
catch {
|
|
2827
|
+
// Non-fatal.
|
|
2828
|
+
}
|
|
2744
2829
|
}
|
|
2745
2830
|
}
|
|
2746
2831
|
const task = this.lookupTaskOnConn(conn, taskId) ?? found;
|
|
@@ -21,6 +21,7 @@ import { formatBytes } from '../format.js';
|
|
|
21
21
|
export { formatBytes };
|
|
22
22
|
import * as path from 'path';
|
|
23
23
|
import { getBrowserRuntimeDir, getProfileRuntimeDir } from './profiles.js';
|
|
24
|
+
import { listProfileCacheDirs } from './runtime-state.js';
|
|
24
25
|
import { formatRelativeTime } from '../session/relative-time.js';
|
|
25
26
|
import { getSessionById, listBrowserSessionRecords, pruneToolSessions } from '../session/db.js';
|
|
26
27
|
import { listPidSessionEntries } from '../session/pid-registry.js';
|
|
@@ -99,7 +100,15 @@ export function listProfileArtifacts(profile) {
|
|
|
99
100
|
export function listBrowserSessions(only) {
|
|
100
101
|
let profiles;
|
|
101
102
|
if (only) {
|
|
102
|
-
|
|
103
|
+
// A profile's live tasks/captures may live under a composite runtime dir
|
|
104
|
+
// (`<name>@<device>`, `<name>@endpoint-N`, forks) — NOT the bare `<name>`
|
|
105
|
+
// dir. Resolve `only` to every cache dir that belongs to it, the same rule
|
|
106
|
+
// status()/findTask use (keyBelongsToProfile), so `--profile comet-local`
|
|
107
|
+
// surfaces the real `comet-local@zion` store instead of the empty legacy
|
|
108
|
+
// dir. Keep the requested name when nothing exists on disk yet so a fresh
|
|
109
|
+
// profile still returns its (empty) entry rather than vanishing.
|
|
110
|
+
const dirs = listProfileCacheDirs(only).map((d) => path.basename(d));
|
|
111
|
+
profiles = dirs.length > 0 ? dirs : [only];
|
|
103
112
|
}
|
|
104
113
|
else {
|
|
105
114
|
try {
|
package/dist/lib/cloud/rush.d.ts
CHANGED
|
@@ -5,6 +5,13 @@
|
|
|
5
5
|
* Requires the Rush GitHub App installed on the target repo.
|
|
6
6
|
*/
|
|
7
7
|
import type { CloudProvider, CloudTask, CloudTaskStatus, CloudEvent, DispatchOptions, ProviderCapabilities, ImageAttachment, SkillRef } from './types.js';
|
|
8
|
+
/**
|
|
9
|
+
* Returns true when ~/.rush/user.yaml exists, carries an access_token, and
|
|
10
|
+
* the token has not passed its expires_at timestamp (Unix seconds). A missing
|
|
11
|
+
* expires_at is treated as non-expired so tokens written without an expiry
|
|
12
|
+
* still work. Pass yamlPath to override the default path in tests.
|
|
13
|
+
*/
|
|
14
|
+
export declare function isRushSessionValid(yamlPath?: string): boolean;
|
|
8
15
|
/** One version's entry in the account manifest sent on every dispatch. */
|
|
9
16
|
export interface AccountManifestEntry {
|
|
10
17
|
version: string;
|
package/dist/lib/cloud/rush.js
CHANGED
|
@@ -16,6 +16,29 @@ import { getAccountInfo } from '../agents.js';
|
|
|
16
16
|
import { selectBalancedVersion } from '../accounting/rotate.js';
|
|
17
17
|
const PROXY_BASE = process.env.RUSH_PROXY_BASE ?? 'https://api.prix.dev';
|
|
18
18
|
const USER_YAML = path.join(os.homedir(), '.rush', 'user.yaml');
|
|
19
|
+
/**
|
|
20
|
+
* Returns true when ~/.rush/user.yaml exists, carries an access_token, and
|
|
21
|
+
* the token has not passed its expires_at timestamp (Unix seconds). A missing
|
|
22
|
+
* expires_at is treated as non-expired so tokens written without an expiry
|
|
23
|
+
* still work. Pass yamlPath to override the default path in tests.
|
|
24
|
+
*/
|
|
25
|
+
export function isRushSessionValid(yamlPath = USER_YAML) {
|
|
26
|
+
try {
|
|
27
|
+
if (!fs.existsSync(yamlPath))
|
|
28
|
+
return false;
|
|
29
|
+
const raw = fs.readFileSync(yamlPath, 'utf-8');
|
|
30
|
+
const data = yaml.parse(raw);
|
|
31
|
+
if (!data?.session?.access_token)
|
|
32
|
+
return false;
|
|
33
|
+
const expiresAt = data.session.expires_at;
|
|
34
|
+
if (typeof expiresAt === 'number' && expiresAt <= Date.now() / 1000)
|
|
35
|
+
return false;
|
|
36
|
+
return true;
|
|
37
|
+
}
|
|
38
|
+
catch {
|
|
39
|
+
return false;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
19
42
|
/** Read the Rush session access token from ~/.rush/user.yaml. */
|
|
20
43
|
function readToken() {
|
|
21
44
|
if (!fs.existsSync(USER_YAML)) {
|
|
@@ -27,6 +50,11 @@ function readToken() {
|
|
|
27
50
|
if (!token) {
|
|
28
51
|
throw new Error('No session token in ~/.rush/user.yaml. Run `rush login` first.');
|
|
29
52
|
}
|
|
53
|
+
const expiresAt = data.session?.expires_at;
|
|
54
|
+
if (typeof expiresAt === 'number' && expiresAt <= Date.now() / 1000) {
|
|
55
|
+
const expiredAt = new Date(expiresAt * 1000).toISOString();
|
|
56
|
+
throw new Error(`Rush session expired at ${expiredAt}. Run \`rush login\` to refresh.`);
|
|
57
|
+
}
|
|
30
58
|
return token;
|
|
31
59
|
}
|
|
32
60
|
/** Read the user's email from the Rush session config, if available. */
|
|
@@ -203,7 +231,7 @@ export class RushCloudProvider {
|
|
|
203
231
|
name = 'Rush Cloud';
|
|
204
232
|
capabilities() {
|
|
205
233
|
return {
|
|
206
|
-
available:
|
|
234
|
+
available: isRushSessionValid(),
|
|
207
235
|
dispatch: true,
|
|
208
236
|
status: true,
|
|
209
237
|
list: true,
|
|
@@ -215,6 +215,28 @@ export declare function anchorDaemonCwd(): string | null;
|
|
|
215
215
|
* root is stable (or could not be resolved).
|
|
216
216
|
*/
|
|
217
217
|
export declare function warnEphemeralDaemonRoot(resolveBin?: () => string): string | null;
|
|
218
|
+
/**
|
|
219
|
+
* Test-home tripwire (PHNX-2545). The routines/daemon test suite spawns real
|
|
220
|
+
* `agents __daemon-run` processes against an isolated /tmp HOME. If that HOME
|
|
221
|
+
* override fails to reach the child — an `env: {...process.env}` spawn that
|
|
222
|
+
* forgot to set it, a login shell that reset HOME — the daemon resolves its
|
|
223
|
+
* state dir under the operator's REAL home and its scheduler/watchdog then tick
|
|
224
|
+
* against shared production state. That is the exact leak the ticket reports:
|
|
225
|
+
* real test daemons found alive on a fleet box, each a second live scheduler
|
|
226
|
+
* racing the legitimate one, in violation of the execution-singularity spec.
|
|
227
|
+
*
|
|
228
|
+
* A test that spawns a daemon sets AGENTS_DAEMON_TEST_HOME to the isolated home
|
|
229
|
+
* it provisioned. When that marker is present, this daemon's resolved state dir
|
|
230
|
+
* MUST sit under it; otherwise the daemon refuses to boot — failing loud before
|
|
231
|
+
* it claims an instance, writes a pid, or fires a single tick (the throw is
|
|
232
|
+
* caught in index.ts's `__daemon-run` handler, logged, and exits non-zero) —
|
|
233
|
+
* rather than running in the wrong directory against the real host. In
|
|
234
|
+
* production the marker is never set, so this is a no-op there.
|
|
235
|
+
*
|
|
236
|
+
* `daemonDir`/`testHome` are injectable so the pure guard is unit-testable
|
|
237
|
+
* without spawning a process; the defaults read the live daemon dir and env.
|
|
238
|
+
*/
|
|
239
|
+
export declare function assertTestDaemonHome(daemonDir?: string, testHome?: string | undefined): void;
|
|
218
240
|
export declare function runDaemon(): Promise<void>;
|
|
219
241
|
/**
|
|
220
242
|
* Write a launchd plist or systemd unit with owner-only permissions atomically.
|
|
@@ -705,6 +705,40 @@ export function warnEphemeralDaemonRoot(resolveBin = getAgentsBinPath) {
|
|
|
705
705
|
return null;
|
|
706
706
|
}
|
|
707
707
|
}
|
|
708
|
+
/**
|
|
709
|
+
* Test-home tripwire (PHNX-2545). The routines/daemon test suite spawns real
|
|
710
|
+
* `agents __daemon-run` processes against an isolated /tmp HOME. If that HOME
|
|
711
|
+
* override fails to reach the child — an `env: {...process.env}` spawn that
|
|
712
|
+
* forgot to set it, a login shell that reset HOME — the daemon resolves its
|
|
713
|
+
* state dir under the operator's REAL home and its scheduler/watchdog then tick
|
|
714
|
+
* against shared production state. That is the exact leak the ticket reports:
|
|
715
|
+
* real test daemons found alive on a fleet box, each a second live scheduler
|
|
716
|
+
* racing the legitimate one, in violation of the execution-singularity spec.
|
|
717
|
+
*
|
|
718
|
+
* A test that spawns a daemon sets AGENTS_DAEMON_TEST_HOME to the isolated home
|
|
719
|
+
* it provisioned. When that marker is present, this daemon's resolved state dir
|
|
720
|
+
* MUST sit under it; otherwise the daemon refuses to boot — failing loud before
|
|
721
|
+
* it claims an instance, writes a pid, or fires a single tick (the throw is
|
|
722
|
+
* caught in index.ts's `__daemon-run` handler, logged, and exits non-zero) —
|
|
723
|
+
* rather than running in the wrong directory against the real host. In
|
|
724
|
+
* production the marker is never set, so this is a no-op there.
|
|
725
|
+
*
|
|
726
|
+
* `daemonDir`/`testHome` are injectable so the pure guard is unit-testable
|
|
727
|
+
* without spawning a process; the defaults read the live daemon dir and env.
|
|
728
|
+
*/
|
|
729
|
+
export function assertTestDaemonHome(daemonDir = getDaemonDir(), testHome = process.env.AGENTS_DAEMON_TEST_HOME) {
|
|
730
|
+
if (!testHome)
|
|
731
|
+
return;
|
|
732
|
+
const root = path.resolve(testHome);
|
|
733
|
+
const dir = path.resolve(daemonDir);
|
|
734
|
+
if (dir !== root && !dir.startsWith(root + path.sep)) {
|
|
735
|
+
const msg = `Daemon test-home tripwire (PHNX-2545): AGENTS_DAEMON_TEST_HOME is ${root}, but this ` +
|
|
736
|
+
`daemon's state dir resolved to ${dir} — the isolated HOME override did not reach this ` +
|
|
737
|
+
`__daemon-run child, so it would schedule against the real host. Refusing to start.`;
|
|
738
|
+
log('ERROR', msg);
|
|
739
|
+
throw new Error(msg);
|
|
740
|
+
}
|
|
741
|
+
}
|
|
708
742
|
// ---------------------------------------------------------------------------
|
|
709
743
|
// Module-level periodic maintenance helpers (RUSH-2422)
|
|
710
744
|
//
|
|
@@ -720,6 +754,11 @@ export function warnEphemeralDaemonRoot(resolveBin = getAgentsBinPath) {
|
|
|
720
754
|
// below. runBrokerSelfHeal was moved into SecretsBrokerService (RUSH-3193 P2).
|
|
721
755
|
// ---------------------------------------------------------------------------
|
|
722
756
|
export async function runDaemon() {
|
|
757
|
+
// PHNX-2545 test-home tripwire — FIRST, before this daemon claims an instance,
|
|
758
|
+
// writes a pid, or fires any tick. A test-spawned daemon that lost its isolated
|
|
759
|
+
// HOME override must refuse to run against the operator's real state rather than
|
|
760
|
+
// schedule against the real host. No-op in production (the marker is never set).
|
|
761
|
+
assertTestDaemonHome();
|
|
723
762
|
// Single-instance guard (last-wins, SING-11): a direct `agents __daemon-run`
|
|
724
763
|
// (manual, or a service-manager restart racing a live predecessor) EVICTS the
|
|
725
764
|
// incumbent and becomes the survivor. claimDaemonInstance returns false only
|
|
@@ -113,6 +113,8 @@ export interface RoutineLaunchPlan {
|
|
|
113
113
|
/** False when `account` names a harness-native login rather than a durable credential. */
|
|
114
114
|
forwardAccount?: boolean;
|
|
115
115
|
}
|
|
116
|
+
/** Claude's own local auth check; unlike account metadata it cannot mistake identity for a usable login. */
|
|
117
|
+
export declare function claudeVersionIsAuthenticated(version: string): boolean;
|
|
116
118
|
/**
|
|
117
119
|
* Resolve the version/account chain for a routine the same way `agents run`
|
|
118
120
|
* does: honor an explicit `version:` pin; otherwise use the configured run
|
|
@@ -130,6 +132,7 @@ export declare function resolveRoutineLaunch(config: JobConfig, cwd?: string, de
|
|
|
130
132
|
resolveCredentialAccount?: (name: string, host: AgentId) => {
|
|
131
133
|
env: Record<string, string>;
|
|
132
134
|
};
|
|
135
|
+
claudeVersionIsAuthenticated?: typeof claudeVersionIsAuthenticated;
|
|
133
136
|
}): Promise<RoutineLaunchPlan>;
|
|
134
137
|
/**
|
|
135
138
|
* Rewrite `cmd[0]` to the absolute binary for `agent@version` when installed.
|