@phnx-labs/agents-cli 1.20.50 → 1.20.52

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 (124) hide show
  1. package/CHANGELOG.md +40 -0
  2. package/dist/commands/browser-picker.js +1 -18
  3. package/dist/commands/browser.js +215 -7
  4. package/dist/commands/cloud.js +7 -25
  5. package/dist/commands/computer.d.ts +1 -0
  6. package/dist/commands/computer.js +129 -8
  7. package/dist/commands/events.d.ts +1 -1
  8. package/dist/commands/events.js +2 -3
  9. package/dist/commands/exec.js +66 -8
  10. package/dist/commands/factory.js +9 -4
  11. package/dist/commands/feed.d.ts +9 -0
  12. package/dist/commands/feed.js +69 -0
  13. package/dist/commands/inspect.js +1 -11
  14. package/dist/commands/logs.d.ts +5 -1
  15. package/dist/commands/logs.js +248 -3
  16. package/dist/commands/mcp.js +9 -6
  17. package/dist/commands/message.js +1 -4
  18. package/dist/commands/profiles.js +1 -18
  19. package/dist/commands/repo.js +33 -14
  20. package/dist/commands/resource-view.d.ts +1 -0
  21. package/dist/commands/resource-view.js +5 -17
  22. package/dist/commands/secrets.d.ts +23 -0
  23. package/dist/commands/secrets.js +174 -70
  24. package/dist/commands/sessions-picker.js +1 -18
  25. package/dist/commands/sessions.js +6 -8
  26. package/dist/commands/teams-picker.js +1 -32
  27. package/dist/commands/teams.js +5 -27
  28. package/dist/commands/tmux.js +1 -3
  29. package/dist/commands/view.js +1 -9
  30. package/dist/commands/worktree.js +1 -4
  31. package/dist/index.js +6 -2
  32. package/dist/lib/agents.d.ts +0 -4
  33. package/dist/lib/agents.js +20 -33
  34. package/dist/lib/auto-dispatch-linear.d.ts +18 -0
  35. package/dist/lib/auto-dispatch-linear.js +107 -0
  36. package/dist/lib/auto-dispatch-provider.d.ts +10 -0
  37. package/dist/lib/auto-dispatch-provider.js +25 -0
  38. package/dist/lib/auto-dispatch.d.ts +87 -0
  39. package/dist/lib/auto-dispatch.js +142 -0
  40. package/dist/lib/browser/cdp.js +11 -2
  41. package/dist/lib/browser/drivers/ssh.d.ts +28 -10
  42. package/dist/lib/browser/drivers/ssh.js +57 -18
  43. package/dist/lib/browser/login-detection.d.ts +94 -0
  44. package/dist/lib/browser/login-detection.js +274 -0
  45. package/dist/lib/browser/profiles.d.ts +17 -8
  46. package/dist/lib/browser/profiles.js +27 -8
  47. package/dist/lib/browser/refs.js +1 -5
  48. package/dist/lib/browser/secret-ref.d.ts +10 -0
  49. package/dist/lib/browser/secret-ref.js +14 -0
  50. package/dist/lib/browser/service.js +14 -12
  51. package/dist/lib/cli-resources.d.ts +0 -2
  52. package/dist/lib/cli-resources.js +30 -13
  53. package/dist/lib/cloud/rush.d.ts +15 -24
  54. package/dist/lib/cloud/rush.js +7 -32
  55. package/dist/lib/crabbox/cli.js +4 -1
  56. package/dist/lib/crabbox/lease.d.ts +6 -0
  57. package/dist/lib/crabbox/lease.js +40 -10
  58. package/dist/lib/crabbox/runtimes.d.ts +38 -1
  59. package/dist/lib/crabbox/runtimes.js +98 -5
  60. package/dist/lib/daemon.d.ts +12 -9
  61. package/dist/lib/daemon.js +73 -17
  62. package/dist/lib/events.d.ts +31 -5
  63. package/dist/lib/events.js +288 -101
  64. package/dist/lib/exec.js +32 -14
  65. package/dist/lib/feed.d.ts +56 -0
  66. package/dist/lib/feed.js +251 -0
  67. package/dist/lib/format.d.ts +38 -0
  68. package/dist/lib/format.js +108 -0
  69. package/dist/lib/git.d.ts +21 -0
  70. package/dist/lib/git.js +92 -0
  71. package/dist/lib/hooks/cache.d.ts +9 -2
  72. package/dist/lib/hooks/cache.js +220 -8
  73. package/dist/lib/hooks.js +24 -10
  74. package/dist/lib/hosts/passthrough.js +1 -0
  75. package/dist/lib/platform/exec.d.ts +4 -1
  76. package/dist/lib/platform/exec.js +8 -2
  77. package/dist/lib/resources.d.ts +0 -8
  78. package/dist/lib/resources.js +0 -10
  79. package/dist/lib/rotate.js +2 -0
  80. package/dist/lib/runner.js +10 -2
  81. package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
  82. package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
  83. package/dist/lib/secrets/agent.d.ts +21 -0
  84. package/dist/lib/secrets/agent.js +63 -1
  85. package/dist/lib/secrets/bundles.d.ts +33 -1
  86. package/dist/lib/secrets/bundles.js +38 -8
  87. package/dist/lib/secrets/icloud-import.d.ts +70 -0
  88. package/dist/lib/secrets/icloud-import.js +173 -0
  89. package/dist/lib/secrets/index.d.ts +36 -0
  90. package/dist/lib/secrets/index.js +99 -9
  91. package/dist/lib/secrets/remote.js +1 -1
  92. package/dist/lib/secrets/sync.js +1 -1
  93. package/dist/lib/session/active.d.ts +11 -1
  94. package/dist/lib/session/active.js +3 -0
  95. package/dist/lib/session/db.d.ts +1 -4
  96. package/dist/lib/session/db.js +20 -25
  97. package/dist/lib/session/discover.d.ts +2 -2
  98. package/dist/lib/session/discover.js +60 -48
  99. package/dist/lib/session/parse.js +35 -34
  100. package/dist/lib/session/render.d.ts +7 -3
  101. package/dist/lib/session/render.js +15 -9
  102. package/dist/lib/session/state.d.ts +55 -0
  103. package/dist/lib/session/state.js +100 -11
  104. package/dist/lib/session/types.d.ts +9 -0
  105. package/dist/lib/shims.d.ts +9 -3
  106. package/dist/lib/shims.js +44 -8
  107. package/dist/lib/ssh-tunnel.d.ts +33 -2
  108. package/dist/lib/ssh-tunnel.js +94 -7
  109. package/dist/lib/staleness/types.d.ts +0 -1
  110. package/dist/lib/startup/command-registry.d.ts +1 -0
  111. package/dist/lib/startup/command-registry.js +2 -0
  112. package/dist/lib/state.d.ts +2 -0
  113. package/dist/lib/state.js +25 -8
  114. package/dist/lib/teams/agents.js +6 -3
  115. package/dist/lib/types.d.ts +24 -1
  116. package/dist/lib/versions.d.ts +0 -26
  117. package/dist/lib/versions.js +2 -145
  118. package/dist/lib/warn-unpushed.d.ts +40 -0
  119. package/dist/lib/warn-unpushed.js +128 -0
  120. package/dist/lib/whats-new.d.ts +5 -3
  121. package/dist/lib/whats-new.js +25 -5
  122. package/package.json +3 -1
  123. package/dist/lib/resources/index.d.ts +0 -53
  124. package/dist/lib/resources/index.js +0 -76
@@ -1,3 +1,4 @@
1
+ import { truncate } from '../format.js';
1
2
  /** Snapshot the stable descriptor for every ref in a freshly-built node map. */
2
3
  export function describeRefs(nodeMap) {
3
4
  return Array.from(nodeMap.values()).map((n) => ({
@@ -175,11 +176,6 @@ export async function getRefs(cdp, sessionId, opts = {}) {
175
176
  }
176
177
  return { refs: lines.join('\n'), nodeMap, opts: { interactive, limit } };
177
178
  }
178
- function truncate(s, max) {
179
- if (s.length <= max)
180
- return s;
181
- return s.slice(0, max - 1) + '…';
182
- }
183
179
  export async function resolveRefToCoords(cdp, sessionId, nodeMap, ref) {
184
180
  const node = nodeMap.get(ref);
185
181
  if (!node)
@@ -0,0 +1,10 @@
1
+ /**
2
+ * Parse a `<bundle>/<KEY>` reference (optionally `secret:<bundle>/<KEY>`) used by
3
+ * `agents browser type --secret` to feed a credential from an `agents secrets`
4
+ * bundle into a page WITHOUT the value ever crossing stdout or the transcript.
5
+ * Returns null on a malformed ref.
6
+ */
7
+ export declare function parseSecretRef(ref: string): {
8
+ bundle: string;
9
+ key: string;
10
+ } | null;
@@ -0,0 +1,14 @@
1
+ /**
2
+ * Parse a `<bundle>/<KEY>` reference (optionally `secret:<bundle>/<KEY>`) used by
3
+ * `agents browser type --secret` to feed a credential from an `agents secrets`
4
+ * bundle into a page WITHOUT the value ever crossing stdout or the transcript.
5
+ * Returns null on a malformed ref.
6
+ */
7
+ export function parseSecretRef(ref) {
8
+ const body = ref.startsWith('secret:') ? ref.slice('secret:'.length) : ref;
9
+ const slash = body.indexOf('/');
10
+ // Need a non-empty bundle before the slash and a non-empty key after it.
11
+ if (slash <= 0 || slash >= body.length - 1)
12
+ return null;
13
+ return { bundle: body.slice(0, slash), key: body.slice(slash + 1) };
14
+ }
@@ -8,7 +8,7 @@ import { getProfile, getProfileRuntimeDir, getBrowserRuntimeDir, listProfiles, e
8
8
  import { killChrome, getRunningChromeInfo, launchBrowser, allocatePort } from './chrome.js';
9
9
  import { connectLocal } from './drivers/local.js';
10
10
  import { connectSSH, shellQuote } from './drivers/ssh.js';
11
- import { clearProfileRuntime } from './runtime-state.js';
11
+ import { clearProfileRuntime, listProfileCacheDirs, readProfileRuntimeMeta, isProcessAlive } from './runtime-state.js';
12
12
  import { resolveDomainSkill } from './domain-skills.js';
13
13
  import { generateTaskId, generateShortId, generateTaskName, } from './types.js';
14
14
  import { getRefs, resolveRefToCoords, describeRefs, healRef } from './refs.js';
@@ -415,6 +415,7 @@ export class BrowserService {
415
415
  killChrome(conn.pid);
416
416
  conn.cleanup?.();
417
417
  this.connections.delete(profileName);
418
+ clearProfileRuntime(profileName);
418
419
  }
419
420
  return { ok: true, profile: profileName };
420
421
  }
@@ -442,17 +443,18 @@ export class BrowserService {
442
443
  killChrome(conn.pid);
443
444
  conn.cleanup?.();
444
445
  this.connections.delete(key);
445
- }
446
- const runtimeDir = getProfileRuntimeDir(profileName);
447
- const pidFile = path.join(runtimeDir, 'pid');
448
- const portFile = path.join(runtimeDir, 'port');
449
- if (fs.existsSync(pidFile)) {
450
- const pid = parseInt(fs.readFileSync(pidFile, 'utf-8').trim(), 10);
451
- killChrome(pid);
452
- fs.unlinkSync(pidFile);
453
- }
454
- if (fs.existsSync(portFile)) {
455
- fs.unlinkSync(portFile);
446
+ clearProfileRuntime(key);
447
+ }
448
+ // Kill stale processes and clean runtime dirs for every composite and
449
+ // fork entry belonging to this profile (including `.N` forks left by
450
+ // earlier daemon sessions that the connection loop above didn't cover).
451
+ for (const dir of listProfileCacheDirs(profileName)) {
452
+ const dirName = path.basename(dir);
453
+ const meta = readProfileRuntimeMeta(dirName);
454
+ if (meta?.pid && meta.pid !== 0 && isProcessAlive(meta.pid, meta.command)) {
455
+ killChrome(meta.pid);
456
+ }
457
+ clearProfileRuntime(dirName);
456
458
  }
457
459
  }
458
460
  async navigate(taskId, url, profileName) {
@@ -133,5 +133,3 @@ export declare function listCliStatus(cwd?: string): {
133
133
  statuses: CliStatus[];
134
134
  errors: CliManifestError[];
135
135
  };
136
- /** Names of CLIs that are declared but not currently installed on the host. */
137
- export declare function getMissingClis(cwd?: string): CliManifest[];
@@ -21,6 +21,7 @@ import * as path from 'path';
21
21
  import { spawnSync } from 'child_process';
22
22
  import * as yaml from 'yaml';
23
23
  import { listResources, resolveResource } from './resources.js';
24
+ import { composeWin32CommandLine } from './platform/index.js';
24
25
  // ─── Validation primitives ───────────────────────────────────────────────────
25
26
  /** Token allowed inside `check:` strings — letters, digits, underscore, dot, slash, dash. */
26
27
  const SAFE_CHECK_TOKEN = /^[a-zA-Z0-9_./-]+$/;
@@ -253,19 +254,27 @@ export function resolveCliManifest(name, cwd) {
253
254
  // ─── Host detection ──────────────────────────────────────────────────────────
254
255
  /**
255
256
  * Return true if a command resolves on the current PATH. Uses POSIX `command -v`
256
- * via spawn argv (no shell); results are cached for the lifetime of the process.
257
+ * (or `where` on Windows) via spawn argv (no shell); results are cached for the
258
+ * lifetime of the process.
257
259
  */
258
260
  const cmdExistsCache = new Map();
259
261
  export function hasCommand(cmd) {
260
262
  if (cmdExistsCache.has(cmd))
261
263
  return cmdExistsCache.get(cmd);
262
- // `command` is a shell builtin on most POSIX shells; invoking `sh -c 'command -v X'`
263
- // with X as an *argument* (not interpolated) is the safe path. `cmd` may be passed
264
- // by callers that haven't validated it, so we route via argv to neutralize metas.
265
- const result = spawnSync('sh', ['-c', 'command -v "$1" >/dev/null 2>&1', '_', cmd], {
266
- stdio: 'ignore',
267
- });
268
- const ok = result.status === 0;
264
+ let ok;
265
+ if (process.platform === 'win32') {
266
+ // `sh` only exists when Git Bash is installed; `where` is the native PATH
267
+ // probe (resolves .exe/.cmd/.bat via PATHEXT). Argv keeps `cmd` uninterpolated.
268
+ ok = spawnSync('where', [cmd], { stdio: 'ignore' }).status === 0;
269
+ }
270
+ else {
271
+ // `command` is a shell builtin on most POSIX shells; invoking `sh -c 'command -v X'`
272
+ // with X as an *argument* (not interpolated) is the safe path. `cmd` may be passed
273
+ // by callers that haven't validated it, so we route via argv to neutralize metas.
274
+ ok = spawnSync('sh', ['-c', 'command -v "$1" >/dev/null 2>&1', '_', cmd], {
275
+ stdio: 'ignore',
276
+ }).status === 0;
277
+ }
269
278
  cmdExistsCache.set(cmd, ok);
270
279
  return ok;
271
280
  }
@@ -280,7 +289,19 @@ export function isCliInstalled(manifest) {
280
289
  return hasCommand(c.cmd);
281
290
  }
282
291
  const result = spawnSync(c.cmd, c.args, { stdio: 'ignore', timeout: 10_000 });
283
- return result.status === 0;
292
+ if (result.status === 0)
293
+ return true;
294
+ // On Windows the PATH entry point is often a `.cmd`/`.bat` shim (npm installs,
295
+ // script installers), which Node refuses to spawn without a shell (ENOENT /
296
+ // EINVAL). A failed *spawn* — not a failed run — gets one retry through the
297
+ // shell, as a single pre-composed line (DEP0190-safe). Check tokens are
298
+ // SAFE_CHECK_TOKEN-validated at parse time, so the line cannot inject.
299
+ if (process.platform === 'win32' && result.error) {
300
+ const line = composeWin32CommandLine(c.cmd, c.args);
301
+ const retry = spawnSync(line, { stdio: 'ignore', timeout: 10_000, shell: true });
302
+ return retry.status === 0;
303
+ }
304
+ return false;
284
305
  }
285
306
  // ─── Method selection ────────────────────────────────────────────────────────
286
307
  /**
@@ -480,7 +501,3 @@ export function listCliStatus(cwd) {
480
501
  }));
481
502
  return { statuses, errors };
482
503
  }
483
- /** Names of CLIs that are declared but not currently installed on the host. */
484
- export function getMissingClis(cwd) {
485
- return listCliStatus(cwd).statuses.filter((s) => !s.installed).map((s) => s.manifest);
486
- }
@@ -29,6 +29,21 @@ export interface AccountTokenEntry {
29
29
  /** Stringified OAuth credentials JSON (Mac: keychain blob; Linux: .credentials.json). */
30
30
  credentials_json: string;
31
31
  }
32
+ /**
33
+ * Read the raw OAuth credentials for one Claude version. On Mac, prefer the
34
+ * Keychain blob (canonical). On Linux/CI, fall back to `.claude/.credentials.json`
35
+ * inside the version home (where the Linux Claude CLI stores its OAuth).
36
+ *
37
+ * Returns null when no credentials are findable — caller treats as "version
38
+ * is installed but not signed in" and skips it from the manifest.
39
+ *
40
+ * NOTE: the darwin branch returns the UNWRAPPED oauth sub-object (what the Rush
41
+ * server re-wraps). It is NOT the shape Claude Code reads back from
42
+ * `.credentials.json`. The lease exporter (crabbox/runtimes.ts) therefore only
43
+ * reuses the Linux `.credentials.json` branch here and reads the wrapped raw
44
+ * Keychain payload itself on darwin.
45
+ */
46
+ export declare function readClaudeCredentialsBlob(home: string): Promise<string | null>;
32
47
  /**
33
48
  * Build a manifest of the user's local Claude installations to send on every
34
49
  * cloud dispatch. The manifest is the contract the server uses to detect when
@@ -75,30 +90,6 @@ export declare function buildDispatchBody(input: {
75
90
  */
76
91
  images?: ImageAttachment[] | null;
77
92
  }): Record<string, unknown>;
78
- /** A single account registered in Rush Cloud's multi-account rotation pool. */
79
- export interface RemoteAccount {
80
- id: string;
81
- provider: string;
82
- email: string | null;
83
- subscription_type: string | null;
84
- five_hour_pct: number | null;
85
- seven_day_pct: number | null;
86
- usage_fetched_at: string | null;
87
- created_at: string;
88
- }
89
- /** Fetch all Claude accounts in this user's Rush Cloud rotation pool (no tokens). */
90
- export declare function listRemoteAccounts(): Promise<RemoteAccount[]>;
91
- /**
92
- * Register a CLAUDE_CODE_OAUTH_TOKEN with Rush Cloud's rotation pool.
93
- * The server validates the token against the Anthropic usage API and stores it
94
- * encrypted in Vault. Returns the account metadata (no token).
95
- */
96
- export declare function addRemoteAccount(provider: string, pastedToken: string): Promise<RemoteAccount & {
97
- five_hour_pct: number | null;
98
- seven_day_pct: number | null;
99
- }>;
100
- /** Remove a Claude account from Rush Cloud's rotation pool by its ID. */
101
- export declare function removeRemoteAccount(id: string): Promise<void>;
102
93
  export declare class RushCloudProvider implements CloudProvider {
103
94
  id: "rush";
104
95
  name: string;
@@ -170,8 +170,14 @@ function parsePromptCode(body) {
170
170
  *
171
171
  * Returns null when no credentials are findable — caller treats as "version
172
172
  * is installed but not signed in" and skips it from the manifest.
173
+ *
174
+ * NOTE: the darwin branch returns the UNWRAPPED oauth sub-object (what the Rush
175
+ * server re-wraps). It is NOT the shape Claude Code reads back from
176
+ * `.credentials.json`. The lease exporter (crabbox/runtimes.ts) therefore only
177
+ * reuses the Linux `.credentials.json` branch here and reads the wrapped raw
178
+ * Keychain payload itself on darwin.
173
179
  */
174
- async function readClaudeCredentialsBlob(home) {
180
+ export async function readClaudeCredentialsBlob(home) {
175
181
  if (process.platform === 'darwin') {
176
182
  const oauth = await loadClaudeOauth(home);
177
183
  if (oauth && oauth.accessToken) {
@@ -294,37 +300,6 @@ export function buildDispatchBody(input) {
294
300
  }
295
301
  return body;
296
302
  }
297
- /** Fetch all Claude accounts in this user's Rush Cloud rotation pool (no tokens). */
298
- export async function listRemoteAccounts() {
299
- const token = readToken();
300
- const res = await api('GET', '/api/v1/cloud-accounts', token);
301
- if (!res.ok) {
302
- throw new Error(`Failed to list accounts (${res.status}): ${sanitizeErrorBody(await res.text())}`);
303
- }
304
- const data = await res.json();
305
- return data.accounts ?? [];
306
- }
307
- /**
308
- * Register a CLAUDE_CODE_OAUTH_TOKEN with Rush Cloud's rotation pool.
309
- * The server validates the token against the Anthropic usage API and stores it
310
- * encrypted in Vault. Returns the account metadata (no token).
311
- */
312
- export async function addRemoteAccount(provider, pastedToken) {
313
- const token = readToken();
314
- const res = await api('POST', '/api/v1/cloud-accounts', token, { provider, token: pastedToken });
315
- if (!res.ok) {
316
- throw new Error(`Failed to add account (${res.status}): ${sanitizeErrorBody(await res.text())}`);
317
- }
318
- return await res.json();
319
- }
320
- /** Remove a Claude account from Rush Cloud's rotation pool by its ID. */
321
- export async function removeRemoteAccount(id) {
322
- const token = readToken();
323
- const res = await api('DELETE', `/api/v1/cloud-accounts/${encodeURIComponent(id)}`, token);
324
- if (!res.ok) {
325
- throw new Error(`Failed to remove account (${res.status}): ${sanitizeErrorBody(await res.text())}`);
326
- }
327
- }
328
303
  export class RushCloudProvider {
329
304
  id = 'rush';
330
305
  name = 'Rush Cloud';
@@ -209,7 +209,10 @@ export function crabboxRunScript(slug, script, opts = {}) {
209
209
  /** Release the lease / delete the box. Best-effort; never throws. */
210
210
  export function crabboxStop(slug, opts = {}) {
211
211
  try {
212
- const r = spawnSync('crabbox', ['stop', '--id', slug], { encoding: 'utf-8', env: crabboxEnv(opts) });
212
+ // Positional target: crabbox's stop subcommand has no --id flag (unlike
213
+ // status/run/ssh) — `stop --id <slug>` dies with "flag provided but not
214
+ // defined: -id" and the box leaks past the run it was leased for.
215
+ const r = spawnSync('crabbox', ['stop', slug], { encoding: 'utf-8', env: crabboxEnv(opts) });
213
216
  return r.status === 0;
214
217
  }
215
218
  catch {
@@ -26,6 +26,12 @@ export interface LeaseRunOptions {
26
26
  onData?: (s: string) => void;
27
27
  /** Keep the box after the run instead of stopping it. */
28
28
  keep?: boolean;
29
+ /**
30
+ * Raw wrapped Claude OAuth payload (from `resolveClaudeCredentialsBlob`), written
31
+ * to `~/.claude/.credentials.json` on the box. The command layer resolves it
32
+ * (after consent) so this module stays free of Keychain I/O and unit-testable.
33
+ */
34
+ claudeCredentialsJson?: string | null;
29
35
  }
30
36
  export interface LeaseRunResult {
31
37
  box: CrabboxBox;
@@ -7,35 +7,65 @@
7
7
  * `--script-stdin` body so the token contents never touch argv.
8
8
  */
9
9
  import { crabboxWarmup, crabboxWaitReady, crabboxRunScript, crabboxStop } from './cli.js';
10
- import { buildCredentialScript } from './runtimes.js';
10
+ import { buildCredentialScript, CLAUDE_TOKEN_REMOTE } from './runtimes.js';
11
11
  /** POSIX single-quote for safe embedding in the generated bootstrap script. */
12
12
  function q(s) {
13
13
  return "'" + s.replace(/'/g, "'\\''") + "'";
14
14
  }
15
+ /**
16
+ * Bash snippet that guarantees `agents` is runnable on the box. Fresh crabbox
17
+ * images ship without node, and the box user may not own the global npm prefix,
18
+ * so everything installs user-level under ~/.local (node from the official
19
+ * latest-v22.x tarball to satisfy engines.node >=22.5.0). Exits 96 with a
20
+ * diagnostic when the CLI still isn't runnable — a silent `|| true` here used
21
+ * to surface only as `agents: command not found` deep in the script.
22
+ */
23
+ const ENSURE_AGENTS_CLI = [
24
+ 'export PATH="$HOME/.local/bin:$PATH"',
25
+ 'if ! command -v node >/dev/null 2>&1; then',
26
+ ' case "$(uname -m)" in aarch64|arm64) narch=arm64;; *) narch=x64;; esac',
27
+ ' nver=$(curl -fsSL https://nodejs.org/dist/latest-v22.x/ | grep -oE "v22\\.[0-9]+\\.[0-9]+" | head -1)',
28
+ ' mkdir -p "$HOME/.local"',
29
+ ' curl -fsSL "https://nodejs.org/dist/latest-v22.x/node-$nver-linux-$narch.tar.xz" | tar -xJ -C "$HOME/.local" --strip-components=1',
30
+ 'fi',
31
+ 'if ! command -v agents >/dev/null 2>&1; then',
32
+ ' npm config set prefix "$HOME/.local" >/dev/null 2>&1 || true',
33
+ ' npm install -g @phnx-labs/agents-cli >/dev/null 2>&1',
34
+ 'fi',
35
+ 'if ! command -v agents >/dev/null 2>&1; then',
36
+ ' echo "lease bootstrap: agents-cli install failed (node: $(command -v node || echo missing))" >&2',
37
+ ' exit 96',
38
+ 'fi',
39
+ // Same first-run guard the hosts bootstrap uses (hosts/ready.ts) — a fresh
40
+ // install refuses `agents run` with "agents-cli is not set up" until setup ran.
41
+ 'if [ ! -d "$HOME/.agents/.system" ]; then agents setup >/dev/null 2>&1 || true; fi',
42
+ ].join('\n');
15
43
  /**
16
44
  * Build the single bootstrap script run on the box: ensure agents-cli, install
17
45
  * the picked runtime CLIs, write their credentials, run the agent, then shred
18
46
  * the credential files. Best-effort install steps never abort the run.
19
47
  */
20
48
  export function buildBootstrapScript(opts) {
21
- const credScript = buildCredentialScript(opts.runtimes, opts.detected);
49
+ const credScript = buildCredentialScript(opts.runtimes, opts.detected, {
50
+ claudeCredentialsJson: opts.claudeCredentialsJson,
51
+ });
22
52
  const runParts = ['agents', 'run', q(opts.agent), q(opts.prompt), '--quiet'];
23
53
  if (opts.mode)
24
54
  runParts.push('--mode', q(opts.mode));
25
55
  if (opts.model)
26
56
  runParts.push('--model', q(opts.model));
27
57
  // Credential files to shred after the run (home-level paths written above).
28
- const shred = opts.runtimes
29
- .map((id) => {
30
- const cred = { claude: '.claude.json', codex: '.codex/auth.json', gemini: '.gemini/google_accounts.json', grok: '.grok/auth.json' }[id];
31
- return cred ? `rm -f "$HOME/${cred}" 2>/dev/null || true` : '';
32
- })
33
- .filter(Boolean)
34
- .join('\n');
58
+ // Runs regardless of --keep-box (it's in the box body, not teardown), so a kept
59
+ // box still loses the token after the run — minimizing the credential window.
60
+ const shredPaths = opts.runtimes.flatMap((id) => {
61
+ const paths = { claude: ['.claude.json', CLAUDE_TOKEN_REMOTE], codex: ['.codex/auth.json'], gemini: ['.gemini/google_accounts.json'], grok: ['.grok/auth.json'] }[id];
62
+ return paths ?? [];
63
+ });
64
+ const shred = shredPaths.map((p) => `rm -f "$HOME/${p}" 2>/dev/null || true`).join('\n');
35
65
  const installRuntimes = opts.runtimes.map((id) => `agents add ${q(id)} >/dev/null 2>&1 || true`).join('\n');
36
66
  return [
37
67
  'set -uo pipefail',
38
- 'if ! command -v agents >/dev/null 2>&1; then npm install -g @phnx-labs/agents-cli >/dev/null 2>&1 || true; fi',
68
+ ENSURE_AGENTS_CLI,
39
69
  installRuntimes,
40
70
  credScript,
41
71
  `${runParts.join(' ')}`,
@@ -47,11 +47,48 @@ export declare function pickRuntimes(detected: DetectedRuntime[], prompt?: (choi
47
47
  checked: boolean;
48
48
  disabled: boolean | string;
49
49
  }[]) => Promise<AgentId[]>): Promise<AgentId[]>;
50
+ /**
51
+ * Where Claude Code reads its OAuth token on the box. `.claude.json` (the file
52
+ * LEASE_RUNTIMES copies) is config/account-metadata ONLY — the actual token
53
+ * lives here, so without it the box boots "Not logged in".
54
+ */
55
+ export declare const CLAUDE_TOKEN_REMOTE = ".claude/.credentials.json";
56
+ /**
57
+ * The RAW wrapped Claude credential payload (`{"claudeAiOauth":{…}}`) to write to
58
+ * the box's `~/.claude/.credentials.json`, or null if no signed-in token is found.
59
+ *
60
+ * On macOS the token is in the login Keychain, read SILENTLY via
61
+ * `getKeychainToken` (the `/usr/bin/security … -w` path — Claude's item trusts it,
62
+ * no Touch ID). A default native install uses the bare `Claude Code-credentials`
63
+ * service; an agents-cli managed install (where `~/.claude` symlinks into a
64
+ * versioned home) uses a hash-suffixed service, so we try the bare service first,
65
+ * then enumerate installed version homes (preferring the account whose email
66
+ * matches `preferEmail`, so the token matches the `.claude.json` config we copy).
67
+ * Off macOS the local Claude CLI stores the token in `.credentials.json` already —
68
+ * reuse the rush.ts Linux branch verbatim.
69
+ *
70
+ * The reader/service/version helpers are injected so unit tests never touch the
71
+ * real Keychain.
72
+ */
73
+ export declare function resolveClaudeCredentialsBlob(opts?: {
74
+ preferEmail?: string | null;
75
+ readItem?: (service: string) => string;
76
+ service?: (home?: string) => string;
77
+ listVersions?: () => string[];
78
+ versionHome?: (version: string) => string;
79
+ accountEmail?: (home: string) => Promise<string | null>;
80
+ }): Promise<string | null>;
50
81
  /**
51
82
  * Build a bash snippet that writes each picked runtime's token file to the box's
52
83
  * home-level config path (0600), from the token contents read locally. Returns
53
84
  * `''` when no runtimes were selected. The snippet is meant to be embedded in
54
85
  * the `--script-stdin` body (never argv).
86
+ *
87
+ * `extras.claudeCredentialsJson` (the raw wrapped payload from
88
+ * `resolveClaudeCredentialsBlob`) is written to `~/.claude/.credentials.json` in
89
+ * ADDITION to claude's `.claude.json` config — without it the box is "Not logged in".
55
90
  */
56
- export declare function buildCredentialScript(picked: AgentId[], detected: DetectedRuntime[]): string;
91
+ export declare function buildCredentialScript(picked: AgentId[], detected: DetectedRuntime[], extras?: {
92
+ claudeCredentialsJson?: string | null;
93
+ }): string;
57
94
  export {};
@@ -16,6 +16,10 @@ import * as os from 'os';
16
16
  import * as path from 'path';
17
17
  import * as fs from 'fs';
18
18
  import { getAccountInfo } from '../agents.js';
19
+ import { getKeychainToken } from '../secrets/index.js';
20
+ import { getClaudeKeychainService } from '../usage.js';
21
+ import { listInstalledVersions, getVersionHomePath } from '../versions.js';
22
+ import { readClaudeCredentialsBlob } from '../cloud/rush.js';
19
23
  export const LEASE_RUNTIMES = [
20
24
  { id: 'claude', label: 'Claude Code', localCandidates: ['.claude/.claude.json', '.claude.json'], remote: '.claude.json' },
21
25
  { id: 'codex', label: 'Codex CLI', localCandidates: ['.codex/auth.json'], remote: '.codex/auth.json' },
@@ -79,15 +83,102 @@ export async function pickRuntimes(detected, prompt) {
79
83
  // token's contents effectively impossible, so the quoted heredoc can never be
80
84
  // closed early by the credential body.
81
85
  const CRED_EOF = 'AGENTS_LEASE_CRED_EOF_9f3c1a7b5e2d4068';
86
+ /**
87
+ * Where Claude Code reads its OAuth token on the box. `.claude.json` (the file
88
+ * LEASE_RUNTIMES copies) is config/account-metadata ONLY — the actual token
89
+ * lives here, so without it the box boots "Not logged in".
90
+ */
91
+ export const CLAUDE_TOKEN_REMOTE = '.claude/.credentials.json';
92
+ /** True when `s` parses to a Claude keychain payload with an OAuth access token. */
93
+ function isClaudeCredentialsBlob(s) {
94
+ try {
95
+ const p = JSON.parse(s);
96
+ return typeof p?.claudeAiOauth?.accessToken === 'string';
97
+ }
98
+ catch {
99
+ return false;
100
+ }
101
+ }
102
+ /**
103
+ * The RAW wrapped Claude credential payload (`{"claudeAiOauth":{…}}`) to write to
104
+ * the box's `~/.claude/.credentials.json`, or null if no signed-in token is found.
105
+ *
106
+ * On macOS the token is in the login Keychain, read SILENTLY via
107
+ * `getKeychainToken` (the `/usr/bin/security … -w` path — Claude's item trusts it,
108
+ * no Touch ID). A default native install uses the bare `Claude Code-credentials`
109
+ * service; an agents-cli managed install (where `~/.claude` symlinks into a
110
+ * versioned home) uses a hash-suffixed service, so we try the bare service first,
111
+ * then enumerate installed version homes (preferring the account whose email
112
+ * matches `preferEmail`, so the token matches the `.claude.json` config we copy).
113
+ * Off macOS the local Claude CLI stores the token in `.credentials.json` already —
114
+ * reuse the rush.ts Linux branch verbatim.
115
+ *
116
+ * The reader/service/version helpers are injected so unit tests never touch the
117
+ * real Keychain.
118
+ */
119
+ export async function resolveClaudeCredentialsBlob(opts) {
120
+ const readItem = opts?.readItem ?? getKeychainToken;
121
+ const service = opts?.service ?? getClaudeKeychainService;
122
+ const listVersions = opts?.listVersions ?? (() => listInstalledVersions('claude'));
123
+ const versionHome = opts?.versionHome ?? ((v) => getVersionHomePath('claude', v));
124
+ const accountEmail = opts?.accountEmail ?? (async (home) => (await getAccountInfo('claude', home)).email);
125
+ const tryRead = (svc) => {
126
+ try {
127
+ const raw = readItem(svc).trim();
128
+ return isClaudeCredentialsBlob(raw) ? raw : null;
129
+ }
130
+ catch {
131
+ return null;
132
+ }
133
+ };
134
+ if (process.platform === 'darwin') {
135
+ // 1) Bare service — the default native (non-managed) install.
136
+ const bare = tryRead(service(undefined));
137
+ if (bare)
138
+ return bare;
139
+ // 2) Managed installs — hash-suffixed service keyed to each version home.
140
+ // Prefer the version whose account email matches the copied config.
141
+ let homes;
142
+ try {
143
+ homes = listVersions().map(versionHome);
144
+ }
145
+ catch {
146
+ homes = [];
147
+ }
148
+ if (opts?.preferEmail) {
149
+ const scored = await Promise.all(homes.map(async (home) => ({ home, match: (await accountEmail(home).catch(() => null)) === opts.preferEmail })));
150
+ homes = [...scored.filter((s) => s.match), ...scored.filter((s) => !s.match)].map((s) => s.home);
151
+ }
152
+ for (const home of homes) {
153
+ const hit = tryRead(service(home));
154
+ if (hit)
155
+ return hit;
156
+ }
157
+ return null;
158
+ }
159
+ // Off darwin: the local Claude CLI already stores the wrapped blob on disk.
160
+ const home = process.env.AGENTS_REAL_HOME || os.homedir();
161
+ return readClaudeCredentialsBlob(home);
162
+ }
82
163
  /**
83
164
  * Build a bash snippet that writes each picked runtime's token file to the box's
84
165
  * home-level config path (0600), from the token contents read locally. Returns
85
166
  * `''` when no runtimes were selected. The snippet is meant to be embedded in
86
167
  * the `--script-stdin` body (never argv).
168
+ *
169
+ * `extras.claudeCredentialsJson` (the raw wrapped payload from
170
+ * `resolveClaudeCredentialsBlob`) is written to `~/.claude/.credentials.json` in
171
+ * ADDITION to claude's `.claude.json` config — without it the box is "Not logged in".
87
172
  */
88
- export function buildCredentialScript(picked, detected) {
173
+ export function buildCredentialScript(picked, detected, extras) {
89
174
  const byId = new Map(detected.map((d) => [d.id, d]));
90
175
  const parts = [];
176
+ const writeFile = (remote, contents) => {
177
+ const dir = path.posix.dirname(remote);
178
+ const mkdir = dir && dir !== '.' ? `mkdir -p "$HOME/${dir}"\n` : '';
179
+ return (`${mkdir}cat > "$HOME/${remote}" <<'${CRED_EOF}'\n${contents}${contents.endsWith('\n') ? '' : '\n'}${CRED_EOF}\n` +
180
+ `chmod 600 "$HOME/${remote}"`);
181
+ };
91
182
  for (const id of picked) {
92
183
  const d = byId.get(id);
93
184
  const cred = LEASE_RUNTIMES.find((c) => c.id === id);
@@ -100,10 +191,12 @@ export function buildCredentialScript(picked, detected) {
100
191
  catch {
101
192
  continue;
102
193
  }
103
- const dir = path.posix.dirname(cred.remote);
104
- const mkdir = dir && dir !== '.' ? `mkdir -p "$HOME/${dir}"\n` : '';
105
- parts.push(`${mkdir}cat > "$HOME/${cred.remote}" <<'${CRED_EOF}'\n${contents}${contents.endsWith('\n') ? '' : '\n'}${CRED_EOF}\n` +
106
- `chmod 600 "$HOME/${cred.remote}"`);
194
+ parts.push(writeFile(cred.remote, contents));
195
+ // For claude the file above is config/state only; the OAuth token is a
196
+ // second artifact without it the box comes up "Not logged in".
197
+ if (id === 'claude' && extras?.claudeCredentialsJson) {
198
+ parts.push(writeFile(CLAUDE_TOKEN_REMOTE, extras.claudeCredentialsJson));
199
+ }
107
200
  }
108
201
  return parts.join('\n');
109
202
  }
@@ -54,13 +54,15 @@ export declare function runDaemon(): Promise<void>;
54
54
  /**
55
55
  * Read the long-lived Claude OAuth token (from `claude setup-token`) that the
56
56
  * user stored under the `claude` secrets bundle. Resolves the bundle the same
57
- * way `agents run --secrets` does, so the token is found whether it was stored
58
- * keychain-backed or as a literal. Returns null when the bundle/key isn't
59
- * configured, the Keychain read is cancelled, or the platform has no keychain —
60
- * the daemon then behaves exactly as before (relying on the interactive OAuth
61
- * session). Never throws: a misconfigured token must not block daemon startup.
57
+ * way `agents run --secrets` does. Interactive starts may prompt Keychain;
58
+ * headless auto-starts are broker-only and return null unless the user already
59
+ * unlocked the bundle in the secrets agent. That keeps a background browser
60
+ * command from hanging on an unseen biometric prompt. Never throws: an absent
61
+ * token leaves the daemon on its existing interactive OAuth session.
62
62
  */
63
- export declare function readDaemonClaudeOAuthToken(): string | null;
63
+ export declare function readDaemonClaudeOAuthToken(opts?: {
64
+ allowPrompt?: boolean;
65
+ }): string | null;
64
66
  /**
65
67
  * Write a launchd plist or systemd unit with owner-only permissions atomically.
66
68
  *
@@ -72,9 +74,10 @@ export declare function readDaemonClaudeOAuthToken(): string | null;
72
74
  */
73
75
  export declare function writeOwnerOnlyServiceManifest(filePath: string, content: string): void;
74
76
  /** Generate a macOS launchd plist for auto-starting the daemon. */
75
- export declare function generateLaunchdPlist(): string;
77
+ export declare function generateLaunchdPlist(oauthToken?: string | null): string;
76
78
  /** Generate a Linux systemd user unit for auto-starting the daemon. */
77
- export declare function generateSystemdUnit(): string;
79
+ export declare function generateSystemdUnit(oauthToken?: string | null): string;
80
+ export declare function getAgentsBinPath(): string;
78
81
  /** Start the daemon via launchd, systemd, or as a detached process. */
79
82
  export declare function startDaemon(): {
80
83
  pid: number | null;
@@ -102,7 +105,7 @@ export declare function ensureDaemonStarted(): {
102
105
  * the daemon then passes it to every routine run it spawns. An already-set
103
106
  * value (e.g. inherited from launchd) is left untouched.
104
107
  */
105
- export declare function buildDetachedDaemonEnv(baseEnv?: NodeJS.ProcessEnv): NodeJS.ProcessEnv;
108
+ export declare function buildDetachedDaemonEnv(baseEnv?: NodeJS.ProcessEnv, oauthToken?: string | null): NodeJS.ProcessEnv;
106
109
  /**
107
110
  * Resolve how to launch the daemon: `node <entry> daemon _run`, matching the
108
111
  * exact form that works under a direct `daemon _run`.