@phnx-labs/agents-cli 1.22.53 → 1.22.55

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.
Files changed (166) hide show
  1. package/CHANGELOG.md +208 -0
  2. package/README.md +59 -9
  3. package/dist/bootstrap.js +55 -154
  4. package/dist/cli/command-registry.d.ts +5 -0
  5. package/dist/cli/command-registry.js +8 -1
  6. package/dist/commands/accounts.js +219 -173
  7. package/dist/commands/apply.js +6 -3
  8. package/dist/commands/auth-mint.d.ts +8 -0
  9. package/dist/commands/auth-mint.js +96 -0
  10. package/dist/commands/auth.js +5 -1
  11. package/dist/commands/browser.js +1 -1
  12. package/dist/commands/cost.js +8 -2
  13. package/dist/commands/daemon.js +2 -2
  14. package/dist/commands/doctor.js +6 -1
  15. package/dist/commands/exec.js +10 -8
  16. package/dist/commands/focus.d.ts +1 -0
  17. package/dist/commands/focus.js +2 -2
  18. package/dist/commands/go.d.ts +5 -4
  19. package/dist/commands/go.js +7 -7
  20. package/dist/commands/insights.js +9 -0
  21. package/dist/commands/monitors.js +85 -30
  22. package/dist/commands/output.js +8 -2
  23. package/dist/commands/repo.js +18 -0
  24. package/dist/commands/routines.js +31 -2
  25. package/dist/commands/secrets.js +33 -14
  26. package/dist/commands/sessions.d.ts +20 -12
  27. package/dist/commands/sessions.js +64 -20
  28. package/dist/commands/setup-accounts.d.ts +8 -0
  29. package/dist/commands/setup-accounts.js +47 -0
  30. package/dist/commands/setup.d.ts +1 -1
  31. package/dist/commands/setup.js +11 -2
  32. package/dist/commands/share.d.ts +79 -3
  33. package/dist/commands/share.js +347 -18
  34. package/dist/commands/ssh.d.ts +7 -0
  35. package/dist/commands/ssh.js +18 -2
  36. package/dist/commands/status.js +14 -0
  37. package/dist/commands/view.d.ts +11 -1
  38. package/dist/commands/view.js +35 -7
  39. package/dist/lib/account-registry.js +15 -3
  40. package/dist/lib/accounting/rotate.d.ts +20 -6
  41. package/dist/lib/accounting/rotate.js +38 -7
  42. package/dist/lib/accounting/usage.d.ts +68 -1
  43. package/dist/lib/accounting/usage.js +116 -10
  44. package/dist/lib/agent-spec/agents.d.ts +5 -2
  45. package/dist/lib/agent-spec/agents.js +25 -7
  46. package/dist/lib/analytics/mix-commands.js +12 -6
  47. package/dist/lib/auth-mint.d.ts +150 -0
  48. package/dist/lib/auth-mint.js +434 -0
  49. package/dist/lib/browser/cdp.d.ts +1 -1
  50. package/dist/lib/browser/cdp.js +1 -1
  51. package/dist/lib/browser/ffmpeg.d.ts +12 -0
  52. package/dist/lib/browser/ffmpeg.js +184 -0
  53. package/dist/lib/browser/remote-control.d.ts +9 -7
  54. package/dist/lib/browser/remote-control.js +9 -7
  55. package/dist/lib/browser/service.js +119 -25
  56. package/dist/lib/claude-account-token.d.ts +10 -0
  57. package/dist/lib/claude-account-token.js +14 -4
  58. package/dist/lib/config-drift.d.ts +37 -0
  59. package/dist/lib/config-drift.js +72 -0
  60. package/dist/lib/daemon/auth-sync-service.d.ts +19 -0
  61. package/dist/lib/daemon/auth-sync-service.js +34 -0
  62. package/dist/lib/daemon/browser-task-reap-service.d.ts +14 -0
  63. package/dist/lib/daemon/browser-task-reap-service.js +26 -0
  64. package/dist/lib/daemon/daemon.js +87 -176
  65. package/dist/lib/daemon/heartbeat-service.d.ts +13 -0
  66. package/dist/lib/daemon/heartbeat-service.js +26 -0
  67. package/dist/lib/daemon/monitor-engine-service.d.ts +9 -5
  68. package/dist/lib/daemon/monitor-engine-service.js +15 -7
  69. package/dist/lib/daemon/runner.d.ts +15 -0
  70. package/dist/lib/daemon/runner.js +23 -0
  71. package/dist/lib/daemon/secrets-broker-service.d.ts +5 -4
  72. package/dist/lib/daemon/secrets-broker-service.js +17 -32
  73. package/dist/lib/daemon/service.d.ts +2 -2
  74. package/dist/lib/daemon/service.js +1 -1
  75. package/dist/lib/daemon/session-state-service.d.ts +21 -0
  76. package/dist/lib/daemon/session-state-service.js +34 -0
  77. package/dist/lib/daemon/supervisor.d.ts +17 -7
  78. package/dist/lib/daemon/supervisor.js +87 -14
  79. package/dist/lib/daemon/tmux-reap-service.d.ts +11 -0
  80. package/dist/lib/daemon/tmux-reap-service.js +28 -0
  81. package/dist/lib/daemon/webhook-receiver-service.d.ts +9 -0
  82. package/dist/lib/daemon/webhook-receiver-service.js +17 -0
  83. package/dist/lib/daemon-services.d.ts +1 -1
  84. package/dist/lib/daemon-services.js +25 -0
  85. package/dist/lib/device-config.d.ts +3 -3
  86. package/dist/lib/device-config.js +5 -5
  87. package/dist/lib/devices/connect.d.ts +26 -0
  88. package/dist/lib/devices/connect.js +48 -1
  89. package/dist/lib/devices/doctor-findings.d.ts +5 -1
  90. package/dist/lib/devices/doctor-findings.js +19 -1
  91. package/dist/lib/devices/harness-inventory.js +5 -2
  92. package/dist/lib/exec.d.ts +28 -0
  93. package/dist/lib/exec.js +73 -7
  94. package/dist/lib/feed/feed.d.ts +1 -1
  95. package/dist/lib/feed/feed.js +23 -1
  96. package/dist/lib/feed-broadcast.js +1 -1
  97. package/dist/lib/fleet/apply.d.ts +11 -0
  98. package/dist/lib/fleet/apply.js +23 -3
  99. package/dist/lib/fleet/auth-sync.js +5 -3
  100. package/dist/lib/help.d.ts +9 -0
  101. package/dist/lib/help.js +29 -1
  102. package/dist/lib/hosts/passthrough.d.ts +1 -10
  103. package/dist/lib/hosts/passthrough.js +1 -13
  104. package/dist/lib/installations/versions.js +9 -1
  105. package/dist/lib/linux-userns.d.ts +58 -0
  106. package/dist/lib/linux-userns.js +116 -0
  107. package/dist/lib/memory.d.ts +26 -0
  108. package/dist/lib/memory.js +80 -1
  109. package/dist/lib/monitors/config.d.ts +11 -0
  110. package/dist/lib/monitors/config.js +8 -0
  111. package/dist/lib/monitors/engine.d.ts +5 -1
  112. package/dist/lib/monitors/engine.js +13 -4
  113. package/dist/lib/monitors/state.d.ts +37 -1
  114. package/dist/lib/monitors/state.js +79 -4
  115. package/dist/lib/permissions-registry.d.ts +2 -0
  116. package/dist/lib/permissions-registry.js +116 -14
  117. package/dist/lib/permissions.d.ts +5 -3
  118. package/dist/lib/permissions.js +25 -27
  119. package/dist/lib/profiles.d.ts +8 -7
  120. package/dist/lib/profiles.js +12 -0
  121. package/dist/lib/project-key.d.ts +9 -0
  122. package/dist/lib/project-key.js +11 -0
  123. package/dist/lib/scheduling/routines.d.ts +47 -0
  124. package/dist/lib/scheduling/routines.js +70 -1
  125. package/dist/lib/secrets/bundles.d.ts +35 -0
  126. package/dist/lib/secrets/bundles.js +78 -1
  127. package/dist/lib/secrets/push.d.ts +3 -8
  128. package/dist/lib/secrets/push.js +18 -14
  129. package/dist/lib/secrets/remote.d.ts +9 -18
  130. package/dist/lib/secrets/remote.js +11 -26
  131. package/dist/lib/secrets/reserved-sync.d.ts +65 -0
  132. package/dist/lib/secrets/reserved-sync.js +129 -0
  133. package/dist/lib/self-heal/checks/hook-manifest.d.ts +2 -0
  134. package/dist/lib/self-heal/checks/hook-manifest.js +56 -0
  135. package/dist/lib/self-heal/registry.js +4 -0
  136. package/dist/lib/self-heal/types.d.ts +1 -1
  137. package/dist/lib/session/active.js +1 -4
  138. package/dist/lib/session/db.d.ts +41 -5
  139. package/dist/lib/session/db.js +132 -30
  140. package/dist/lib/session/discover.d.ts +32 -4
  141. package/dist/lib/session/discover.js +119 -25
  142. package/dist/lib/session/insights.d.ts +14 -0
  143. package/dist/lib/session/insights.js +25 -2
  144. package/dist/lib/session/linear.js +1 -1
  145. package/dist/lib/session/shell-programs.d.ts +17 -0
  146. package/dist/lib/session/shell-programs.js +21 -0
  147. package/dist/lib/session/state.js +2 -1
  148. package/dist/lib/session/stream-render.js +2 -1
  149. package/dist/lib/session/tool-calls.js +2 -5
  150. package/dist/lib/session/trajectory-html.js +2 -1
  151. package/dist/lib/session/trajectory.js +3 -12
  152. package/dist/lib/session/types.d.ts +8 -0
  153. package/dist/lib/share/capture.js +11 -2
  154. package/dist/lib/share/publish.d.ts +56 -5
  155. package/dist/lib/share/publish.js +126 -18
  156. package/dist/lib/share/worker-template.d.ts +3 -12
  157. package/dist/lib/share/worker-template.js +860 -59
  158. package/dist/lib/startup/root-command.js +2 -1
  159. package/dist/lib/state.d.ts +16 -0
  160. package/dist/lib/state.js +178 -46
  161. package/dist/lib/sync-status.d.ts +4 -0
  162. package/dist/lib/sync-status.js +3 -0
  163. package/dist/lib/traces/classify.js +24 -19
  164. package/dist/lib/usage-refresh.js +2 -1
  165. package/dist/lib/view-types.d.ts +7 -0
  166. package/package.json +9 -2
@@ -0,0 +1,434 @@
1
+ import { resolveAgentName } from './agents.js';
2
+ import { AUTH_BUNDLE, claudeAccountTokenKey, isValidClaudeSetupToken, readClaudeAccountEmail, } from './claude-account-token.js';
3
+ import { addAccount, findAccount, readAccountRegistry, setAccountSecret } from './account-registry.js';
4
+ import { bundleBackend, bundleExists, keychainRef, readBundle, rotateBundleSecret, writeBundleWithItems, } from './secrets/bundles.js';
5
+ import { secretsKeychainItem } from './secrets/index.js';
6
+ import { getBinaryPath, getGlobalDefault, getVersionHomePath, listInstalledVersions } from './installations/versions.js';
7
+ import { shellQuote } from './ssh-exec.js';
8
+ import { defaultPtyDriver, } from './fleet/remote-login.js';
9
+ import { showUrl } from './open-url.js';
10
+ import { loadDevices } from './devices/registry.js';
11
+ import { isSelfHost } from './devices/self-host.js';
12
+ import { pushBundleToHost } from './secrets/push.js';
13
+ import { assertCredentialTransportHostPinned, resolveHostSshTarget } from './secrets/remote.js';
14
+ import { resolveRemoteOsSync } from './hosts/remote-os.js';
15
+ /** Well-formed Claude setup-token as it appears inside a TTY blob. */
16
+ export const CLAUDE_SETUP_TOKEN_CAPTURE_RE = /sk-ant-oat01-[A-Za-z0-9_-]+/;
17
+ const ACCOUNT_NAME_RE = /^[a-zA-Z0-9][a-zA-Z0-9._-]*$/;
18
+ const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
19
+ /**
20
+ * Harnesses that expose an interactive setup-token mint. Native device-code
21
+ * login (codex/droid/kimi/grok) stays on `agents fleet login`; API keys stay
22
+ * on `agents accounts add`. Adding a harness here without a real mint command
23
+ * is a lying table — do not.
24
+ */
25
+ export const MINT_FLOWS = {
26
+ claude: {
27
+ harness: 'claude',
28
+ provider: 'anthropic',
29
+ auth: 'setup-token',
30
+ mintArgs: ['setup-token'],
31
+ // Authorize URL printed by `claude setup-token` before it waits for a code.
32
+ verificationUrlRegex: /(https:\/\/[^\s"'<>]+)/i,
33
+ tokenCapture: CLAUDE_SETUP_TOKEN_CAPTURE_RE,
34
+ },
35
+ };
36
+ export function listMintableHarnesses() {
37
+ return Object.keys(MINT_FLOWS);
38
+ }
39
+ export function getMintFlow(harnessRaw) {
40
+ const harness = resolveAgentName(harnessRaw);
41
+ if (!harness) {
42
+ throw new Error(`Unknown harness '${harnessRaw}'. Interactive setup-token mint is implemented for: ${listMintableHarnesses().join(', ')}.`);
43
+ }
44
+ const flow = MINT_FLOWS[harness];
45
+ if (flow)
46
+ return flow;
47
+ throw new Error(unmintableMessage(harness));
48
+ }
49
+ export function unmintableMessage(harness) {
50
+ return [
51
+ `Cannot mint a setup-token for '${harness}'.`,
52
+ `Interactive setup-token mint is implemented for: ${listMintableHarnesses().join(', ')}.`,
53
+ `Device-code native login is \`agents fleet login --agent ${harness}\`.`,
54
+ `API-key / pasted-token accounts use \`agents accounts add <name> --provider <provider> --auth api-key\`.`,
55
+ ].join(' ');
56
+ }
57
+ /** Strip CSI / Fe ANSI so a #1767 TTY blob can be scanned for a real token. */
58
+ export function stripAnsi(text) {
59
+ return text.replace(/\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])/g, '');
60
+ }
61
+ /**
62
+ * Pull a single well-formed Claude setup-token out of a (possibly ANSI-wrapped)
63
+ * screen. Returns null when none is present. Two distinct tokens fail loud —
64
+ * guessing which one to seed is how a banner fragment becomes an auth header.
65
+ */
66
+ export function extractClaudeSetupToken(screen) {
67
+ const text = stripAnsi(screen);
68
+ const matches = text.match(new RegExp(CLAUDE_SETUP_TOKEN_CAPTURE_RE.source, 'g')) ?? [];
69
+ const unique = [...new Set(matches.filter(isValidClaudeSetupToken))];
70
+ if (unique.length === 0)
71
+ return null;
72
+ if (unique.length > 1) {
73
+ throw new Error(`Screen contained ${unique.length} distinct setup-tokens; refusing to guess. Re-run mint and capture a single sk-ant-oat01- token.`);
74
+ }
75
+ return unique[0];
76
+ }
77
+ /** First https URL on the screen, trailing punctuation stripped. */
78
+ export function extractMintUrl(screen, flow) {
79
+ const text = stripAnsi(screen);
80
+ const m = text.match(flow.verificationUrlRegex);
81
+ if (!m)
82
+ return undefined;
83
+ const raw = (m[1] ?? m[0]).trim();
84
+ return raw.replace(/[.,;:]+$/, '') || undefined;
85
+ }
86
+ export function isEmail(value) {
87
+ return EMAIL_RE.test(value.trim());
88
+ }
89
+ /** Account-name slug of an email (`ada@example.com` → `ada-at-example.com`). */
90
+ export function accountNameFromEmail(email) {
91
+ const slug = email
92
+ .trim()
93
+ .toLowerCase()
94
+ .replace(/@/g, '-at-')
95
+ .replace(/[^a-z0-9._-]/g, '-');
96
+ if (!ACCOUNT_NAME_RE.test(slug)) {
97
+ throw new Error(`Cannot derive an account name from email '${email}'. Pass --account <name>.`);
98
+ }
99
+ return slug;
100
+ }
101
+ export function assertValidSetupToken(token) {
102
+ const cleaned = token.trim();
103
+ if (!isValidClaudeSetupToken(cleaned)) {
104
+ throw new Error('Not a Claude setup-token (expected a single-line sk-ant-oat01-… value). The #1767 capture stored the TTY banner as the token — that is not a token. Re-run `claude setup-token` and paste only the sk-ant-oat01- line.');
105
+ }
106
+ return cleaned;
107
+ }
108
+ /**
109
+ * Resolve the named account + the email that keys the reserved `auth` bundle.
110
+ * `--account` that looks like an email is the email; a name needs `--email` or
111
+ * a locally signed-in `.claude.json`. Missing email fails loud — we must not
112
+ * fall back to a bare shared key (that is the multi-account mix-up).
113
+ */
114
+ export function resolveMintIdentity(input) {
115
+ const accountRaw = input.account?.trim();
116
+ const emailRaw = input.email?.trim();
117
+ const fromHome = readClaudeAccountEmail(input.home);
118
+ let email;
119
+ let accountName;
120
+ if (emailRaw) {
121
+ if (!isEmail(emailRaw))
122
+ throw new Error(`--email '${emailRaw}' is not an email address.`);
123
+ email = emailRaw;
124
+ }
125
+ if (accountRaw) {
126
+ if (isEmail(accountRaw)) {
127
+ if (email && email.toLowerCase() !== accountRaw.toLowerCase()) {
128
+ throw new Error(`--account '${accountRaw}' and --email '${email}' name different emails.`);
129
+ }
130
+ email = accountRaw;
131
+ accountName = accountNameFromEmail(accountRaw);
132
+ }
133
+ else {
134
+ if (!ACCOUNT_NAME_RE.test(accountRaw)) {
135
+ throw new Error(`Account name '${accountRaw}' must start with a letter or number and contain only letters, numbers, dot, underscore, or dash.`);
136
+ }
137
+ accountName = accountRaw;
138
+ }
139
+ }
140
+ if (!email)
141
+ email = fromHome ?? undefined;
142
+ if (!email) {
143
+ throw new Error("Cannot key a per-account setup-token without an email. Pass --account <email>, or --email, or sign in locally first so this version home's .claude.json has oauthAccount.emailAddress.");
144
+ }
145
+ if (!accountName)
146
+ accountName = accountNameFromEmail(email);
147
+ return { accountName, email };
148
+ }
149
+ /**
150
+ * Write (or rotate) the reserved FILE-BASED `auth` bundle's per-account key.
151
+ * Usage/probe ignores a keychain- or vault-backed bundle of this name, so a
152
+ * wrong backend fails loud instead of looking like a successful mint.
153
+ */
154
+ export function seedReservedAuthToken(email, token) {
155
+ const cleaned = assertValidSetupToken(token);
156
+ const key = claudeAccountTokenKey(email);
157
+ if (bundleExists(AUTH_BUNDLE)) {
158
+ const backend = bundleBackend(AUTH_BUNDLE);
159
+ if (backend !== 'file') {
160
+ throw new Error(`Reserved bundle '${AUTH_BUNDLE}' exists with backend '${backend}', but usage/probe only reads a FILE-backed auth bundle. Recreate it with: agents secrets create ${AUTH_BUNDLE} --backend file --policy never --i-understand --force`);
161
+ }
162
+ const bundle = readBundle(AUTH_BUNDLE);
163
+ if (key in bundle.vars) {
164
+ rotateBundleSecret(bundle, key, { newValue: cleaned, meta: { type: 'token' } });
165
+ return { key };
166
+ }
167
+ const item = secretsKeychainItem(AUTH_BUNDLE, key);
168
+ bundle.vars[key] = keychainRef(key);
169
+ if (!bundle.meta)
170
+ bundle.meta = {};
171
+ bundle.meta[key] = { type: 'token' };
172
+ writeBundleWithItems(bundle, new Map([[item, cleaned]]));
173
+ return { key };
174
+ }
175
+ const item = secretsKeychainItem(AUTH_BUNDLE, key);
176
+ const bundle = {
177
+ name: AUTH_BUNDLE,
178
+ backend: 'file',
179
+ policy: 'never',
180
+ description: 'Reserved per-account Claude setup-tokens for unattended usage/probe (never copy native OAuth).',
181
+ vars: { [key]: keychainRef(key) },
182
+ meta: { [key]: { type: 'token' } },
183
+ };
184
+ writeBundleWithItems(bundle, new Map([[item, cleaned]]));
185
+ return { key };
186
+ }
187
+ /**
188
+ * Create or rotate the named provider account that `agents run --account` and
189
+ * `agents accounts sync` consume. Existing account of a different kind fails
190
+ * loud rather than silently overwriting an API key with a setup-token.
191
+ */
192
+ export function seedNamedAccount(name, token, flow) {
193
+ const cleaned = assertValidSetupToken(token);
194
+ const existing = findAccount(name);
195
+ if (!existing)
196
+ return addAccount(name, flow.provider, flow.auth, cleaned);
197
+ if (existing.provider !== flow.provider || existing.auth !== flow.auth) {
198
+ throw new Error(`Account '${name}' already exists as ${existing.provider} ${existing.auth}; not overwriting it with a ${flow.provider} ${flow.auth}. Choose a different --account name.`);
199
+ }
200
+ setAccountSecret(name, cleaned);
201
+ return existing;
202
+ }
203
+ /** Stdout progress for the mint path. `--json` / quiet: drop it (stderr errors stay). */
204
+ function emitMintProgress(line, json) {
205
+ if (!json)
206
+ console.log(line);
207
+ }
208
+ /**
209
+ * Drive `claude setup-token` in a PTY: scrape the authorize URL, open it,
210
+ * optionally paste `--code`, then capture the token with the #1767 guard.
211
+ * Tears the session down on the way out (success, timeout, or throw).
212
+ */
213
+ export async function driveSetupTokenMint(command, flow, opts = {}) {
214
+ const driver = opts.driver ?? defaultPtyDriver();
215
+ const initialDelayMs = opts.drive?.initialDelayMs ?? 1500;
216
+ const pollMs = opts.drive?.pollMs ?? 500;
217
+ const timeoutMs = opts.drive?.timeoutMs ?? 180_000;
218
+ const json = opts.json === true;
219
+ const openUrl = opts.openUrl ?? (async (url) => {
220
+ const shown = await showUrl(url);
221
+ if (shown.via === 'none') {
222
+ // stderr: `--json` stdout stays parseable; the operator still gets the URL.
223
+ console.error(`Could not open a browser — open this yourself:\n ${url}`);
224
+ }
225
+ });
226
+ const id = await driver.start();
227
+ let opened;
228
+ let wroteCode = false;
229
+ let askedCode = false;
230
+ try {
231
+ await driver.exec(id, command);
232
+ await sleep(initialDelayMs);
233
+ const deadline = Date.now() + timeoutMs;
234
+ for (;;) {
235
+ const { screen, exited } = await driver.screen(id);
236
+ const url = extractMintUrl(screen, flow);
237
+ const token = extractClaudeSetupToken(screen);
238
+ if (token) {
239
+ const capturedUrl = url ?? opened;
240
+ if (capturedUrl)
241
+ emitMintProgress(`Authorize URL: ${capturedUrl}`, json);
242
+ return { token, url: capturedUrl, sessionId: id };
243
+ }
244
+ if (url && url !== opened) {
245
+ opened = url;
246
+ await openUrl(url);
247
+ }
248
+ if (url && !wroteCode && !askedCode) {
249
+ const code = opts.code ?? await opts.readCode?.();
250
+ askedCode = true;
251
+ if (code?.trim()) {
252
+ await driver.write(id, `${code.trim()}\r`);
253
+ wroteCode = true;
254
+ }
255
+ }
256
+ if (exited) {
257
+ throw new Error(opened
258
+ ? `Mint process exited before printing a setup-token. Authorize URL was ${opened}. Re-run and complete the browser step, or seed with --token-stdin.`
259
+ : 'Mint process exited before printing an authorize URL or a setup-token.');
260
+ }
261
+ if (Date.now() >= deadline) {
262
+ throw new Error(opened
263
+ ? `Timed out waiting for a setup-token after opening ${opened}. Paste the code with --code, or seed an already-minted token with --token-stdin.`
264
+ : 'Timed out waiting for `claude setup-token` to print an authorize URL.');
265
+ }
266
+ await sleep(pollMs);
267
+ }
268
+ }
269
+ catch (e) {
270
+ await driver.stop(id).catch(() => { });
271
+ throw e;
272
+ }
273
+ finally {
274
+ await driver.stop(id).catch(() => { });
275
+ }
276
+ }
277
+ export function buildMintCommand(flow, bin, home) {
278
+ if (!flow.mintArgs) {
279
+ throw new Error(`Harness '${flow.harness}' has no interactive mint command.`);
280
+ }
281
+ const args = flow.mintArgs.map(shellQuote).join(' ');
282
+ return `HOME=${shellQuote(home)} ${shellQuote(bin)} ${args}`;
283
+ }
284
+ export function resolveMintInstallation(harness) {
285
+ const installed = listInstalledVersions(harness);
286
+ const version = getGlobalDefault(harness) ?? installed[installed.length - 1];
287
+ if (!version) {
288
+ throw new Error(`No installed version of ${harness}. Install one with: agents add ${harness}`);
289
+ }
290
+ return {
291
+ version,
292
+ bin: getBinaryPath(harness, version),
293
+ home: getVersionHomePath(harness, version),
294
+ };
295
+ }
296
+ /**
297
+ * End-to-end mint: resolve identity, obtain a token (stdin or PTY drive),
298
+ * seed the named account + reserved auth bundle, optionally sync the fleet.
299
+ * Never returns or logs the token.
300
+ */
301
+ export async function mintAndSeed(input) {
302
+ const flow = getMintFlow(input.harness);
303
+ const json = input.json === true;
304
+ // An injected PTY driver is a complete substitute for exec'ing the local
305
+ // binary, so the install lookup is only required on the real drive path.
306
+ const install = input.token
307
+ ? null
308
+ : input.hooks?.driver
309
+ ? { bin: flow.harness, home: process.env.HOME ?? '/tmp' }
310
+ : resolveMintInstallation(flow.harness);
311
+ const identity = resolveMintIdentity({
312
+ account: input.account,
313
+ email: input.email,
314
+ home: install?.home,
315
+ });
316
+ let token;
317
+ if (input.token !== undefined) {
318
+ token = assertValidSetupToken(input.token);
319
+ }
320
+ else {
321
+ const command = buildMintCommand(flow, install.bin, install.home);
322
+ const openUrl = input.open === false
323
+ ? async (url) => {
324
+ emitMintProgress(`Authorize: ${url}`, json);
325
+ }
326
+ : input.hooks?.openUrl;
327
+ const driven = await driveSetupTokenMint(command, flow, {
328
+ driver: input.hooks?.driver,
329
+ openUrl,
330
+ readCode: input.hooks?.readCode,
331
+ drive: input.hooks?.drive,
332
+ code: input.code,
333
+ json,
334
+ });
335
+ token = driven.token;
336
+ }
337
+ const existing = findAccount(identity.accountName);
338
+ const account = seedNamedAccount(identity.accountName, token, flow);
339
+ const { key } = seedReservedAuthToken(identity.email, token);
340
+ const targets = await resolveSyncTargets(input.fleet === true, input.devices ?? []);
341
+ const fleet = [];
342
+ for (const device of targets) {
343
+ fleet.push(await syncMintedBundles(account.name, device));
344
+ }
345
+ const failed = fleet.filter((row) => !row.ok);
346
+ if (failed.length) {
347
+ throw new Error(`Minted locally but fleet sync failed for: ${failed.map((row) => `${row.device} (${row.message})`).join('; ')}. Retry: agents accounts sync ${account.name} <device>`);
348
+ }
349
+ return {
350
+ harness: flow.harness,
351
+ account: account.name,
352
+ email: identity.email,
353
+ authBundleKey: key,
354
+ rotated: Boolean(existing),
355
+ fleet,
356
+ };
357
+ }
358
+ export async function resolveSyncTargets(fleet, devices) {
359
+ const named = [...new Set(devices.map((d) => d.trim()).filter(Boolean))];
360
+ if (!fleet && named.length === 0)
361
+ return [];
362
+ const registry = await loadDevices();
363
+ const known = Object.keys(registry);
364
+ if (named.length) {
365
+ // A self host in --device is skipped, not an error; an unknown name is.
366
+ const unknown = named.filter((d) => !known.includes(d) && !isSelfHost(d));
367
+ if (unknown.length) {
368
+ throw new Error(`Unknown device${unknown.length === 1 ? '' : 's'} '${unknown.join("', '")}'. Register with \`agents devices\` or omit --device.`);
369
+ }
370
+ }
371
+ const fromFleet = fleet
372
+ ? known.filter((name) => !isSelfHost(name))
373
+ : [];
374
+ const union = [...new Set([...fromFleet, ...named.filter((d) => !isSelfHost(d))])];
375
+ return union;
376
+ }
377
+ async function syncMintedBundles(accountName, device) {
378
+ try {
379
+ const sshTarget = await resolveHostSshTarget(device);
380
+ assertCredentialTransportHostPinned(sshTarget);
381
+ const remoteBackend = resolveRemoteOsSync(device) === 'win32' ? 'keychain' : 'file';
382
+ const account = findAccount(accountName);
383
+ if (!account)
384
+ throw new Error(`Unknown provider account '${accountName}'.`);
385
+ const accountPush = pushBundleToHost(accountName, device, {
386
+ remoteBackend,
387
+ force: true,
388
+ operation: 'accounts mint --fleet',
389
+ policyNever: true,
390
+ agentOnly: false,
391
+ literalValues: {
392
+ ACCOUNT_ID: account.id,
393
+ PROVIDER: account.provider,
394
+ AUTH_TYPE: account.auth,
395
+ ...(account.baseUrl ? { BASE_URL: account.baseUrl } : {}),
396
+ },
397
+ });
398
+ if (!accountPush.ok)
399
+ throw new Error(accountPush.message);
400
+ if (bundleExists(AUTH_BUNDLE)) {
401
+ const authPush = pushBundleToHost(AUTH_BUNDLE, device, {
402
+ remoteBackend: 'file',
403
+ force: true,
404
+ operation: 'accounts mint --fleet',
405
+ policyNever: true,
406
+ agentOnly: true,
407
+ });
408
+ if (!authPush.ok)
409
+ throw new Error(authPush.message);
410
+ }
411
+ return { device, ok: true, message: `${accountPush.keyCount} keys` };
412
+ }
413
+ catch (err) {
414
+ return { device, ok: false, message: err instanceof Error ? err.message : String(err) };
415
+ }
416
+ }
417
+ /** True when a Claude setup-token is already seeded on this box (setup status). */
418
+ export function hasMintedSetupToken() {
419
+ const records = Object.values(readAccountRegistry().accounts);
420
+ const setup = records.filter((a) => a.auth === 'setup-token' && a.provider === 'anthropic');
421
+ if (setup.length) {
422
+ return { ready: true, detail: `${setup.length} Claude setup-token account${setup.length === 1 ? '' : 's'}` };
423
+ }
424
+ if (bundleExists(AUTH_BUNDLE) && bundleBackend(AUTH_BUNDLE) === 'file') {
425
+ const bundle = readBundle(AUTH_BUNDLE);
426
+ const keys = Object.keys(bundle.vars).filter((k) => k.startsWith('CLAUDE_CODE_OAUTH_TOKEN_'));
427
+ if (keys.length)
428
+ return { ready: true, detail: `reserved auth bundle (${keys.length} account key${keys.length === 1 ? '' : 's'})` };
429
+ }
430
+ return { ready: false, detail: 'no Claude setup-token minted — agents accounts mint claude' };
431
+ }
432
+ function sleep(ms) {
433
+ return new Promise((resolve) => setTimeout(resolve, ms));
434
+ }
@@ -3,7 +3,7 @@ export interface CDPPipeTransport {
3
3
  read: Readable;
4
4
  write: Writable;
5
5
  }
6
- type EventHandler = (params: Record<string, unknown>) => void;
6
+ type EventHandler = (params: Record<string, unknown>, sessionId?: string) => void;
7
7
  export declare function registerPipeTransport(transport: CDPPipeTransport): string;
8
8
  export declare class CDPClient {
9
9
  private ws;
@@ -146,7 +146,7 @@ export class CDPClient {
146
146
  const handlers = this.eventHandlers.get(msg.method);
147
147
  if (handlers) {
148
148
  for (const handler of handlers) {
149
- handler(msg.params || {});
149
+ handler(msg.params || {}, msg.sessionId);
150
150
  }
151
151
  }
152
152
  }
@@ -0,0 +1,12 @@
1
+ type Platform = NodeJS.Platform;
2
+ export declare function ffmpegManualCommand(platform: Platform, osRelease?: string): string;
3
+ export declare function resolveFfmpeg(options?: {
4
+ env?: NodeJS.ProcessEnv;
5
+ home?: string;
6
+ platform?: Platform;
7
+ osRelease?: string;
8
+ /** Override canonical executable directories, primarily for isolated verification. */
9
+ executableDirs?: string[];
10
+ }): Promise<string>;
11
+ export declare function resetFfmpegResolverForTest(): void;
12
+ export {};
@@ -0,0 +1,184 @@
1
+ import { execFile } from 'node:child_process';
2
+ import fs from 'node:fs';
3
+ import os from 'node:os';
4
+ import path from 'node:path';
5
+ import { promisify } from 'node:util';
6
+ const execFileAsync = promisify(execFile);
7
+ let resolvedFfmpeg;
8
+ function canonicalDirs(platform) {
9
+ if (platform === 'darwin')
10
+ return ['/opt/homebrew/bin', '/usr/local/bin'];
11
+ if (platform === 'linux')
12
+ return ['/usr/local/bin', '/usr/bin', '/snap/bin'];
13
+ return [];
14
+ }
15
+ function pathCandidates(env, platform, platformDirs) {
16
+ const names = platform === 'win32' ? ['ffmpeg.exe'] : ['ffmpeg'];
17
+ const dirs = [...new Set([...(env.PATH ?? '').split(path.delimiter).filter(Boolean), ...platformDirs])];
18
+ return dirs.flatMap((dir) => names.map((name) => path.join(dir, name)));
19
+ }
20
+ function managedCandidates(home, platform) {
21
+ const name = platform === 'win32' ? 'ffmpeg.exe' : 'ffmpeg';
22
+ return [
23
+ path.join(home, '.agents', '.cache', 'bin', name),
24
+ path.join(home, '.agents', '.cache', 'ffmpeg', 'bin', name),
25
+ path.join(home, '.agents', '.cache', 'ffmpeg', 'node_modules', 'ffmpeg-static', name),
26
+ ];
27
+ }
28
+ async function isUsable(binary) {
29
+ try {
30
+ await execFileAsync(binary, ['-version'], { timeout: 10_000, windowsHide: true });
31
+ return true;
32
+ }
33
+ catch {
34
+ return false;
35
+ }
36
+ }
37
+ async function firstUsable(candidates) {
38
+ for (const candidate of candidates) {
39
+ if (fs.existsSync(candidate) && await isUsable(candidate))
40
+ return candidate;
41
+ }
42
+ return undefined;
43
+ }
44
+ function commandOnPath(name, env, platform, platformDirs) {
45
+ const names = platform === 'win32' ? [`${name}.exe`, `${name}.cmd`, name] : [name];
46
+ const dirs = [...new Set([...(env.PATH ?? '').split(path.delimiter).filter(Boolean), ...platformDirs])];
47
+ for (const dir of dirs) {
48
+ for (const candidate of names.map((entry) => path.join(dir, entry))) {
49
+ try {
50
+ fs.accessSync(candidate, platform === 'win32' ? fs.constants.F_OK : fs.constants.X_OK);
51
+ return candidate;
52
+ }
53
+ catch { /* keep looking */ }
54
+ }
55
+ }
56
+ return undefined;
57
+ }
58
+ export function ffmpegManualCommand(platform, osRelease = '') {
59
+ if (platform === 'darwin')
60
+ return 'brew install ffmpeg';
61
+ if (platform === 'win32')
62
+ return 'winget install Gyan.FFmpeg';
63
+ if (/\b(ID|ID_LIKE)=(?:"[^"]*\b)?(?:ubuntu|debian)\b/m.test(osRelease)) {
64
+ return 'sudo apt-get update && sudo apt-get install -y ffmpeg';
65
+ }
66
+ return 'install ffmpeg with your system package manager';
67
+ }
68
+ function readOsRelease() {
69
+ try {
70
+ return fs.readFileSync('/etc/os-release', 'utf8');
71
+ }
72
+ catch {
73
+ return '';
74
+ }
75
+ }
76
+ async function installManagedFfmpeg(home, env, platform, platformDirs) {
77
+ const npm = commandOnPath('npm', env, platform, platformDirs);
78
+ if (!npm)
79
+ return undefined;
80
+ const prefix = path.join(home, '.agents', '.cache', 'ffmpeg');
81
+ await fs.promises.mkdir(prefix, { recursive: true });
82
+ await execFileAsync(npm, [
83
+ 'install', '--prefix', prefix, '--no-save', '--no-audit', '--no-fund',
84
+ 'ffmpeg-static@5.2.0',
85
+ ], { env, timeout: 10 * 60_000, windowsHide: true });
86
+ return firstUsable(managedCandidates(home, platform));
87
+ }
88
+ function installAttempt(platform, env, osRelease, platformDirs) {
89
+ if (platform === 'darwin') {
90
+ const brew = commandOnPath('brew', env, platform, platformDirs);
91
+ return brew ? [{ command: brew, args: ['install', 'ffmpeg'] }] : undefined;
92
+ }
93
+ if (platform === 'win32') {
94
+ const winget = commandOnPath('winget', env, platform, platformDirs);
95
+ return winget ? [{ command: winget, args: ['install', '--id', 'Gyan.FFmpeg', '--exact', '--accept-source-agreements', '--accept-package-agreements'] }] : undefined;
96
+ }
97
+ if (platform !== 'linux')
98
+ return undefined;
99
+ if (/\b(ID|ID_LIKE)=(?:"[^"]*\b)?(?:ubuntu|debian)\b/m.test(osRelease)) {
100
+ const apt = commandOnPath('apt-get', env, platform, platformDirs);
101
+ if (!apt)
102
+ return undefined;
103
+ if (typeof process.getuid === 'function' && process.getuid() === 0) {
104
+ return [
105
+ { command: apt, args: ['update'] },
106
+ { command: apt, args: ['install', '-y', 'ffmpeg'] },
107
+ ];
108
+ }
109
+ const sudo = commandOnPath('sudo', env, platform, platformDirs);
110
+ return sudo ? [
111
+ { command: sudo, args: ['-n', apt, 'update'] },
112
+ { command: sudo, args: ['-n', apt, 'install', '-y', 'ffmpeg'] },
113
+ ] : undefined;
114
+ }
115
+ const managers = [
116
+ ['apk', ['add', 'ffmpeg']],
117
+ ['dnf', ['install', '-y', 'ffmpeg']],
118
+ ['yum', ['install', '-y', 'ffmpeg']],
119
+ ['pacman', ['-S', '--noconfirm', 'ffmpeg']],
120
+ ['zypper', ['--non-interactive', 'install', 'ffmpeg']],
121
+ ];
122
+ for (const [name, args] of managers) {
123
+ const command = commandOnPath(name, env, platform, platformDirs);
124
+ if (command)
125
+ return [{ command, args }];
126
+ }
127
+ return undefined;
128
+ }
129
+ export async function resolveFfmpeg(options = {}) {
130
+ if (resolvedFfmpeg)
131
+ return resolvedFfmpeg;
132
+ resolvedFfmpeg = (async () => {
133
+ const env = options.env ?? process.env;
134
+ const home = options.home ?? os.homedir();
135
+ const platform = options.platform ?? os.platform();
136
+ const osRelease = options.osRelease ?? readOsRelease();
137
+ const executableDirs = options.executableDirs ?? canonicalDirs(platform);
138
+ const candidates = [...pathCandidates(env, platform, executableDirs), ...managedCandidates(home, platform)];
139
+ const existing = await firstUsable(candidates);
140
+ if (existing)
141
+ return existing;
142
+ const attempt = installAttempt(platform, env, osRelease, executableDirs);
143
+ let failure = 'no supported automatic installer was found';
144
+ if (attempt) {
145
+ try {
146
+ for (const step of attempt) {
147
+ await execFileAsync(step.command, step.args, {
148
+ env,
149
+ timeout: 10 * 60_000,
150
+ windowsHide: true,
151
+ });
152
+ }
153
+ const installed = await firstUsable([...pathCandidates(env, platform, executableDirs), ...managedCandidates(home, platform)]);
154
+ if (installed)
155
+ return installed;
156
+ failure = `${path.basename(attempt.at(-1).command)} completed but ffmpeg is still unavailable`;
157
+ }
158
+ catch (error) {
159
+ failure = `${path.basename(attempt.at(-1).command)} failed: ${error.message}`;
160
+ }
161
+ }
162
+ try {
163
+ const managed = await installManagedFfmpeg(home, env, platform, executableDirs);
164
+ if (managed)
165
+ return managed;
166
+ failure += '; managed npm installation was unavailable';
167
+ }
168
+ catch (error) {
169
+ failure += `; managed npm installation failed: ${error.message}`;
170
+ }
171
+ const manual = ffmpegManualCommand(platform, osRelease);
172
+ throw new Error(`ffmpeg is required for browser recording and automatic installation failed (${failure}). Run \`${manual}\`, then retry.`);
173
+ })();
174
+ try {
175
+ return await resolvedFfmpeg;
176
+ }
177
+ catch (error) {
178
+ resolvedFfmpeg = undefined;
179
+ throw error;
180
+ }
181
+ }
182
+ export function resetFfmpegResolverForTest() {
183
+ resolvedFfmpeg = undefined;
184
+ }
@@ -2,11 +2,13 @@
2
2
  * Consent gate for driving THIS machine's browser from another fleet machine.
3
3
  *
4
4
  * `browser --device <device>` routes a browser command to `<device>` over SSH (the
5
- * fleet passthrough) and runs `agents browser ...` there. Every such remote
6
- * invocation carries the {@link FLEET_REMOTE_ENV} marker, set once at the fleet
7
- * dispatch site (`maybeRunOnHost`). A machine only accepts being driven when its
8
- * owner has opted in via `agents browser remote-control on` (the device-scope
9
- * `browser.remote-control` config key, never synced).
5
+ * fleet passthrough) and runs `agents browser ...` there. `agents ssh <device>
6
+ * agents browser …` is the same remote drive through the ssh wrapper. Every such
7
+ * remote invocation carries the {@link FLEET_REMOTE_ENV} marker, set at the fleet
8
+ * dispatch site (`maybeRunOnHost` / `streamAgentsOnHost`) and on the ssh wrapper
9
+ * (`buildSshInvocation` via `markFleetRemote`). A machine only accepts being driven
10
+ * when its owner has opted in via `agents browser remote-control on` (the
11
+ * device-scope `browser.remote-control` config key, never synced).
10
12
  *
11
13
  * Local invocations (no marker) are never gated — this only governs cross-machine
12
14
  * drives. Read-only queries are not gated.
@@ -21,9 +23,9 @@
21
23
  * the attach paths ungated (RUSH-3064). {@link assertRemoteControlAllowed} remains
22
24
  * as a fast-fail CLI-side check so a refused `start` never auto-creates a profile.
23
25
  */
24
- /** Env marker set on every remote `agents` invocation by `buildRemoteAgentsInvocation`. */
26
+ /** Env marker set on every remote `agents` invocation by `buildRemoteAgentsInvocation` and `markFleetRemote`. */
25
27
  export declare const FLEET_REMOTE_ENV = "AGENTS_FLEET_REMOTE";
26
- /** True when this process was dispatched to this machine by a fleet `--device` run. */
28
+ /** True when this process was dispatched to this machine by a fleet `--device` or `agents ssh … agents browser` run. */
27
29
  export declare function isFleetRemoteInvocation(env?: NodeJS.ProcessEnv): boolean;
28
30
  /**
29
31
  * Whether this machine allows other fleet machines to drive its browser. Reads