@phnx-labs/agents-cli 1.22.53 → 1.22.54

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 (134) hide show
  1. package/CHANGELOG.md +190 -0
  2. package/README.md +41 -8
  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/secrets.js +33 -14
  25. package/dist/commands/sessions.d.ts +20 -12
  26. package/dist/commands/sessions.js +64 -20
  27. package/dist/commands/setup-accounts.d.ts +8 -0
  28. package/dist/commands/setup-accounts.js +47 -0
  29. package/dist/commands/setup.d.ts +1 -1
  30. package/dist/commands/setup.js +11 -2
  31. package/dist/commands/share.d.ts +52 -3
  32. package/dist/commands/share.js +262 -18
  33. package/dist/commands/ssh.d.ts +7 -0
  34. package/dist/commands/ssh.js +18 -2
  35. package/dist/commands/status.js +14 -0
  36. package/dist/commands/view.d.ts +3 -1
  37. package/dist/commands/view.js +5 -4
  38. package/dist/lib/account-registry.js +15 -3
  39. package/dist/lib/accounting/rotate.d.ts +20 -6
  40. package/dist/lib/accounting/rotate.js +38 -7
  41. package/dist/lib/accounting/usage.d.ts +37 -1
  42. package/dist/lib/accounting/usage.js +71 -6
  43. package/dist/lib/agent-spec/agents.d.ts +5 -2
  44. package/dist/lib/agent-spec/agents.js +25 -7
  45. package/dist/lib/analytics/mix-commands.js +12 -6
  46. package/dist/lib/auth-mint.d.ts +150 -0
  47. package/dist/lib/auth-mint.js +434 -0
  48. package/dist/lib/browser/remote-control.d.ts +9 -7
  49. package/dist/lib/browser/remote-control.js +9 -7
  50. package/dist/lib/claude-account-token.d.ts +10 -0
  51. package/dist/lib/claude-account-token.js +14 -4
  52. package/dist/lib/config-drift.d.ts +37 -0
  53. package/dist/lib/config-drift.js +72 -0
  54. package/dist/lib/daemon/auth-sync-service.d.ts +19 -0
  55. package/dist/lib/daemon/auth-sync-service.js +34 -0
  56. package/dist/lib/daemon/daemon.js +30 -4
  57. package/dist/lib/daemon-services.d.ts +1 -1
  58. package/dist/lib/daemon-services.js +5 -0
  59. package/dist/lib/device-config.d.ts +3 -3
  60. package/dist/lib/device-config.js +5 -5
  61. package/dist/lib/devices/connect.d.ts +26 -0
  62. package/dist/lib/devices/connect.js +48 -1
  63. package/dist/lib/devices/doctor-findings.d.ts +5 -1
  64. package/dist/lib/devices/doctor-findings.js +19 -1
  65. package/dist/lib/exec.d.ts +28 -0
  66. package/dist/lib/exec.js +73 -7
  67. package/dist/lib/feed/feed.d.ts +1 -1
  68. package/dist/lib/feed/feed.js +23 -1
  69. package/dist/lib/feed-broadcast.js +1 -1
  70. package/dist/lib/fleet/apply.d.ts +11 -0
  71. package/dist/lib/fleet/apply.js +23 -3
  72. package/dist/lib/fleet/auth-sync.js +5 -3
  73. package/dist/lib/help.d.ts +9 -0
  74. package/dist/lib/help.js +29 -1
  75. package/dist/lib/hosts/passthrough.d.ts +1 -10
  76. package/dist/lib/hosts/passthrough.js +1 -13
  77. package/dist/lib/installations/versions.js +9 -1
  78. package/dist/lib/linux-userns.d.ts +58 -0
  79. package/dist/lib/linux-userns.js +116 -0
  80. package/dist/lib/memory.d.ts +26 -0
  81. package/dist/lib/memory.js +80 -1
  82. package/dist/lib/monitors/config.d.ts +11 -0
  83. package/dist/lib/monitors/config.js +8 -0
  84. package/dist/lib/monitors/engine.js +8 -1
  85. package/dist/lib/monitors/state.d.ts +37 -1
  86. package/dist/lib/monitors/state.js +79 -4
  87. package/dist/lib/permissions-registry.d.ts +2 -0
  88. package/dist/lib/permissions-registry.js +116 -14
  89. package/dist/lib/permissions.d.ts +5 -3
  90. package/dist/lib/permissions.js +25 -27
  91. package/dist/lib/profiles.d.ts +8 -7
  92. package/dist/lib/profiles.js +12 -0
  93. package/dist/lib/project-key.d.ts +9 -0
  94. package/dist/lib/project-key.js +11 -0
  95. package/dist/lib/secrets/bundles.d.ts +35 -0
  96. package/dist/lib/secrets/bundles.js +78 -1
  97. package/dist/lib/secrets/push.d.ts +3 -8
  98. package/dist/lib/secrets/push.js +18 -14
  99. package/dist/lib/secrets/remote.d.ts +9 -18
  100. package/dist/lib/secrets/remote.js +11 -26
  101. package/dist/lib/secrets/reserved-sync.d.ts +65 -0
  102. package/dist/lib/secrets/reserved-sync.js +129 -0
  103. package/dist/lib/self-heal/checks/hook-manifest.d.ts +2 -0
  104. package/dist/lib/self-heal/checks/hook-manifest.js +56 -0
  105. package/dist/lib/self-heal/registry.js +4 -0
  106. package/dist/lib/self-heal/types.d.ts +1 -1
  107. package/dist/lib/session/active.js +1 -4
  108. package/dist/lib/session/db.d.ts +39 -4
  109. package/dist/lib/session/db.js +130 -29
  110. package/dist/lib/session/discover.d.ts +32 -4
  111. package/dist/lib/session/discover.js +119 -25
  112. package/dist/lib/session/insights.d.ts +14 -0
  113. package/dist/lib/session/insights.js +25 -2
  114. package/dist/lib/session/linear.js +1 -1
  115. package/dist/lib/session/shell-programs.d.ts +17 -0
  116. package/dist/lib/session/shell-programs.js +21 -0
  117. package/dist/lib/session/state.js +2 -1
  118. package/dist/lib/session/stream-render.js +2 -1
  119. package/dist/lib/session/tool-calls.js +2 -5
  120. package/dist/lib/session/trajectory-html.js +2 -1
  121. package/dist/lib/session/trajectory.js +3 -12
  122. package/dist/lib/session/types.d.ts +8 -0
  123. package/dist/lib/share/publish.d.ts +53 -5
  124. package/dist/lib/share/publish.js +99 -17
  125. package/dist/lib/share/worker-template.js +582 -57
  126. package/dist/lib/startup/root-command.js +2 -1
  127. package/dist/lib/state.d.ts +16 -0
  128. package/dist/lib/state.js +178 -46
  129. package/dist/lib/sync-status.d.ts +4 -0
  130. package/dist/lib/sync-status.js +3 -0
  131. package/dist/lib/traces/classify.js +24 -19
  132. package/dist/lib/usage-refresh.js +2 -1
  133. package/dist/lib/view-types.d.ts +2 -0
  134. package/package.json +2 -1
@@ -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
+ }
@@ -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
@@ -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.
@@ -22,9 +24,9 @@
22
24
  * as a fast-fail CLI-side check so a refused `start` never auto-creates a profile.
23
25
  */
24
26
  import { getConfigValue } from '../device-config.js';
25
- /** Env marker set on every remote `agents` invocation by `buildRemoteAgentsInvocation`. */
27
+ /** Env marker set on every remote `agents` invocation by `buildRemoteAgentsInvocation` and `markFleetRemote`. */
26
28
  export const FLEET_REMOTE_ENV = 'AGENTS_FLEET_REMOTE';
27
- /** True when this process was dispatched to this machine by a fleet `--device` run. */
29
+ /** True when this process was dispatched to this machine by a fleet `--device` or `agents ssh … agents browser` run. */
28
30
  export function isFleetRemoteInvocation(env = process.env) {
29
31
  return env[FLEET_REMOTE_ENV] === '1';
30
32
  }
@@ -1,3 +1,13 @@
1
+ /**
2
+ * Reserved FILE-BASED secrets bundle holding long-lived, non-rotating Claude
3
+ * setup-tokens. Usage/probe reads authenticate with these instead of Claude
4
+ * Code's ACL-bound login item, so they never pop Touch ID. Keyed strictly
5
+ * per-account (`CLAUDE_CODE_OAUTH_TOKEN_<slug>` from the account email) — never a
6
+ * bare key, so one account's token can't be misapplied to another in a
7
+ * multi-account fleet.
8
+ */
9
+ /** Alias of the secrets-layer reserved name so mint/seed shares one source of truth. */
10
+ export declare const AUTH_BUNDLE = "auth";
1
11
  /** True only for a clean, single-line `sk-ant-oat01-…` token — see {@link SETUP_TOKEN_RE}. */
2
12
  export declare function isValidClaudeSetupToken(value: string): boolean;
3
13
  /** The per-account key an email maps to inside the `auth` bundle. */
@@ -1,7 +1,7 @@
1
1
  import * as fs from 'fs';
2
2
  import * as os from 'os';
3
3
  import * as path from 'path';
4
- import { bundleBackend, bundleExists, readAndResolveBundleEnv } from './secrets/bundles.js';
4
+ import { AUTH_BUNDLE_NAME, ReservedBundleWrongBackendError, bundleBackend, bundleExists, readAndResolveBundleEnv, } from './secrets/bundles.js';
5
5
  import { fileStoreItemPath } from './secrets/filestore.js';
6
6
  import { secretsKeychainItem } from './secrets/index.js';
7
7
  /**
@@ -12,7 +12,8 @@ import { secretsKeychainItem } from './secrets/index.js';
12
12
  * bare key, so one account's token can't be misapplied to another in a
13
13
  * multi-account fleet.
14
14
  */
15
- const AUTH_BUNDLE = 'auth';
15
+ /** Alias of the secrets-layer reserved name so mint/seed shares one source of truth. */
16
+ export const AUTH_BUNDLE = AUTH_BUNDLE_NAME;
16
17
  /**
17
18
  * A well-formed Claude OAuth setup-token: the `sk-ant-oat01-` prefix followed by
18
19
  * token-safe characters only, on a single line. `claude setup-token` mints exactly
@@ -82,8 +83,15 @@ export function resolveClaudeSetupToken(home) {
82
83
  const email = readClaudeAccountEmail(home);
83
84
  if (!email)
84
85
  return null;
85
- if (!bundleExists(AUTH_BUNDLE) || bundleBackend(AUTH_BUNDLE) !== 'file')
86
+ if (!bundleExists(AUTH_BUNDLE))
86
87
  return null;
88
+ const backend = bundleBackend(AUTH_BUNDLE);
89
+ if (backend !== 'file') {
90
+ // SEC-GAP-3: a keychain/vault-backed `auth` used to return null here, so
91
+ // usage/probe fell through to the interactive login (Touch ID) with no
92
+ // hint that the seeded setup-token was being ignored.
93
+ throw new ReservedBundleWrongBackendError(AUTH_BUNDLE, backend);
94
+ }
87
95
  const cacheKey = home ?? os.homedir();
88
96
  const item = secretsKeychainItem(AUTH_BUNDLE, claudeAccountTokenKey(email));
89
97
  const credentialPath = fileStoreItemPath(item);
@@ -110,7 +118,9 @@ export function resolveClaudeSetupToken(home) {
110
118
  // than return a token whose current file fingerprint was never observed.
111
119
  return null;
112
120
  }
113
- catch {
121
+ catch (err) {
122
+ if (err instanceof ReservedBundleWrongBackendError)
123
+ throw err;
114
124
  return null;
115
125
  }
116
126
  }
@@ -0,0 +1,37 @@
1
+ /**
2
+ * Config drift: has THIS box drained its device-scoped state into its own
3
+ * `devices/<host>/agents.yaml`, or is it still carrying per-box state in the
4
+ * shared top-level `agents.yaml`?
5
+ *
6
+ * PHNX-3315 P1 heals a frozen top-level header and drains the central `browser:`
7
+ * tombstone; P2 folds `fleet.discovery` / `fleet.ignored`, the `hosts:` registry,
8
+ * and device-scoped native `accounts:` out of central and into the device doc.
9
+ * Each fold-and-delete migration is idempotent and runs on config access / daemon
10
+ * boot / install — so a converged box shows NO drift. A box that has NOT run the
11
+ * fold yet (stale checkout, never re-synced) still holds those blocks centrally,
12
+ * and until now that was INVISIBLE: `agents sync status` only reported per-agent
13
+ * "N missing", so an un-drained box was discovered the hard way — as a mystery
14
+ * `agents repo pull` conflict when two boxes rewrote the same shared map.
15
+ *
16
+ * This detector is READ-ONLY and must NOT trigger the migration (that would drain
17
+ * the very leak it is trying to surface): it reads the raw top-level user file
18
+ * directly, never `readMeta()` (system-merged) and never the migration hook.
19
+ */
20
+ export interface ConfigDrift {
21
+ /** Top-level `agents.yaml` header != the current META_HEADER (the P1 case). */
22
+ staleHeader: boolean;
23
+ /**
24
+ * Labels of central blocks that should have folded into this box's device doc
25
+ * but still linger — the P1 `browser` tombstone and the P2 `fleet` / `hosts` /
26
+ * `accounts` device-scoped writers. Empty on a drained box.
27
+ */
28
+ centralLeaks: string[];
29
+ }
30
+ /**
31
+ * Inspect this box's on-disk state for config drift. Pure read — no migration, no
32
+ * writes. Mirrors the gather step of {@link migrateDeviceConfigStores} (plus the
33
+ * browser tombstone) so a leak here is exactly what a fold would drain.
34
+ */
35
+ export declare function detectConfigDrift(): ConfigDrift;
36
+ /** True when either drift class is present. */
37
+ export declare function hasConfigDrift(d: ConfigDrift): boolean;
@@ -0,0 +1,72 @@
1
+ /**
2
+ * Config drift: has THIS box drained its device-scoped state into its own
3
+ * `devices/<host>/agents.yaml`, or is it still carrying per-box state in the
4
+ * shared top-level `agents.yaml`?
5
+ *
6
+ * PHNX-3315 P1 heals a frozen top-level header and drains the central `browser:`
7
+ * tombstone; P2 folds `fleet.discovery` / `fleet.ignored`, the `hosts:` registry,
8
+ * and device-scoped native `accounts:` out of central and into the device doc.
9
+ * Each fold-and-delete migration is idempotent and runs on config access / daemon
10
+ * boot / install — so a converged box shows NO drift. A box that has NOT run the
11
+ * fold yet (stale checkout, never re-synced) still holds those blocks centrally,
12
+ * and until now that was INVISIBLE: `agents sync status` only reported per-agent
13
+ * "N missing", so an un-drained box was discovered the hard way — as a mystery
14
+ * `agents repo pull` conflict when two boxes rewrote the same shared map.
15
+ *
16
+ * This detector is READ-ONLY and must NOT trigger the migration (that would drain
17
+ * the very leak it is trying to surface): it reads the raw top-level user file
18
+ * directly, never `readMeta()` (system-merged) and never the migration hook.
19
+ */
20
+ import { hasStaleMetaHeader, readTopLevelUserMeta } from './state.js';
21
+ function isMap(v) {
22
+ return !!v && typeof v === 'object' && !Array.isArray(v);
23
+ }
24
+ /**
25
+ * Inspect this box's on-disk state for config drift. Pure read — no migration, no
26
+ * writes. Mirrors the gather step of {@link migrateDeviceConfigStores} (plus the
27
+ * browser tombstone) so a leak here is exactly what a fold would drain.
28
+ */
29
+ export function detectConfigDrift() {
30
+ const staleHeader = hasStaleMetaHeader();
31
+ const raw = readTopLevelUserMeta();
32
+ const centralLeaks = [];
33
+ if (raw) {
34
+ // P1: the leftover central `browser:` tombstone (should fold into deviceBrowser).
35
+ if (isMap(raw.browser) && Object.keys(raw.browser).length > 0) {
36
+ centralLeaks.push('browser');
37
+ }
38
+ // P2: fleet.discovery / fleet.ignored, and the short-lived per-device config
39
+ // store under fleet.devices.<name>.config — all fold into the device doc.
40
+ const fleet = isMap(raw.fleet) ? raw.fleet : undefined;
41
+ if (fleet) {
42
+ if (isMap(fleet.discovery) && Object.keys(fleet.discovery).length > 0) {
43
+ centralLeaks.push('fleet.discovery');
44
+ }
45
+ if (Array.isArray(fleet.ignored) && fleet.ignored.length > 0) {
46
+ centralLeaks.push('fleet.ignored');
47
+ }
48
+ if (isMap(fleet.devices)) {
49
+ const anyDeviceConfig = Object.values(fleet.devices).some((ov) => isMap(ov) && isMap(ov.config) && Object.keys(ov.config).length > 0);
50
+ if (anyDeviceConfig)
51
+ centralLeaks.push('fleet.devices.*.config');
52
+ }
53
+ }
54
+ // P2: the central `hosts:` registry (should fold into deviceHosts).
55
+ if (isMap(raw.hosts) && Object.keys(raw.hosts).length > 0) {
56
+ centralLeaks.push('hosts');
57
+ }
58
+ // P2: central native accounts marked scope:'device' — identity PII that
59
+ // belongs in the device doc, off the git-tracked shared file. Fleet-shared /
60
+ // version-scoped identities stay central and are NOT a leak.
61
+ const accounts = isMap(raw.accounts) ? raw.accounts : undefined;
62
+ const native = accounts && isMap(accounts.native) ? accounts.native : undefined;
63
+ if (native && Object.values(native).some((a) => isMap(a) && a.scope === 'device')) {
64
+ centralLeaks.push('accounts (device-scoped)');
65
+ }
66
+ }
67
+ return { staleHeader, centralLeaks };
68
+ }
69
+ /** True when either drift class is present. */
70
+ export function hasConfigDrift(d) {
71
+ return d.staleHeader || d.centralLeaks.length > 0;
72
+ }