@phnx-labs/agents-cli 1.22.22 → 1.22.24

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 (138) hide show
  1. package/CHANGELOG.md +402 -0
  2. package/README.md +13 -6
  3. package/dist/bin/agents +0 -0
  4. package/dist/commands/attach.d.ts +2 -0
  5. package/dist/commands/attach.js +32 -7
  6. package/dist/commands/defaults.js +2 -0
  7. package/dist/commands/doctor.js +20 -7
  8. package/dist/commands/exec.js +100 -34
  9. package/dist/commands/feed.d.ts +18 -0
  10. package/dist/commands/feed.js +44 -1
  11. package/dist/commands/focus.d.ts +108 -5
  12. package/dist/commands/focus.js +455 -32
  13. package/dist/commands/fork.d.ts +20 -2
  14. package/dist/commands/fork.js +91 -64
  15. package/dist/commands/go.d.ts +39 -1
  16. package/dist/commands/go.js +112 -7
  17. package/dist/commands/harness-wizard.d.ts +206 -0
  18. package/dist/commands/harness-wizard.js +403 -0
  19. package/dist/commands/harness.d.ts +12 -0
  20. package/dist/commands/harness.js +97 -101
  21. package/dist/commands/insights.js +6 -2
  22. package/dist/commands/inspect.js +39 -5
  23. package/dist/commands/menubar.js +6 -1
  24. package/dist/commands/models.js +1 -0
  25. package/dist/commands/modes.d.ts +12 -0
  26. package/dist/commands/modes.js +147 -0
  27. package/dist/commands/resume.js +10 -3
  28. package/dist/commands/secrets-sync.js +11 -13
  29. package/dist/commands/secrets.d.ts +2 -0
  30. package/dist/commands/secrets.js +79 -47
  31. package/dist/commands/sessions-browser.d.ts +35 -0
  32. package/dist/commands/sessions-browser.js +140 -14
  33. package/dist/commands/sessions-resume.d.ts +23 -3
  34. package/dist/commands/sessions-resume.js +73 -19
  35. package/dist/commands/sessions.d.ts +91 -5
  36. package/dist/commands/sessions.js +359 -125
  37. package/dist/commands/setup-secrets.js +1 -1
  38. package/dist/commands/sync.js +246 -42
  39. package/dist/commands/view.js +2 -0
  40. package/dist/commands/watchdog.js +13 -2
  41. package/dist/index.js +2 -1
  42. package/dist/lib/agent-modes.d.ts +49 -0
  43. package/dist/lib/agent-modes.js +70 -0
  44. package/dist/lib/agents.d.ts +1 -1
  45. package/dist/lib/agents.js +132 -0
  46. package/dist/lib/codex-policy.d.ts +17 -0
  47. package/dist/lib/codex-policy.js +48 -0
  48. package/dist/lib/crabbox/lease.d.ts +25 -0
  49. package/dist/lib/crabbox/lease.js +62 -0
  50. package/dist/lib/daemon.js +70 -0
  51. package/dist/lib/doctor-diff.d.ts +3 -0
  52. package/dist/lib/doctor-diff.js +15 -13
  53. package/dist/lib/event-stream.d.ts +3 -1
  54. package/dist/lib/event-stream.js +14 -1
  55. package/dist/lib/exec.d.ts +16 -0
  56. package/dist/lib/exec.js +175 -62
  57. package/dist/lib/feed-broadcast.d.ts +1 -20
  58. package/dist/lib/feed-broadcast.js +31 -1
  59. package/dist/lib/hooks/cache.js +36 -3
  60. package/dist/lib/hooks.d.ts +19 -7
  61. package/dist/lib/hooks.js +100 -40
  62. package/dist/lib/hosts/session-index.d.ts +4 -0
  63. package/dist/lib/hosts/session-index.js +7 -0
  64. package/dist/lib/manifest.d.ts +12 -2
  65. package/dist/lib/manifest.js +60 -5
  66. package/dist/lib/mcp.js +44 -0
  67. package/dist/lib/menubar/MenubarHelper.app/Contents/CodeResources +0 -0
  68. package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
  69. package/dist/lib/menubar/install-menubar.d.ts +52 -2
  70. package/dist/lib/menubar/install-menubar.js +128 -6
  71. package/dist/lib/models.d.ts +0 -5
  72. package/dist/lib/models.js +48 -0
  73. package/dist/lib/plugin-marketplace.js +9 -0
  74. package/dist/lib/pricing/prices.json +119 -92
  75. package/dist/lib/pricing/table.js +13 -0
  76. package/dist/lib/refresh.d.ts +5 -0
  77. package/dist/lib/refresh.js +37 -33
  78. package/dist/lib/remote-agents-json.d.ts +29 -1
  79. package/dist/lib/remote-agents-json.js +47 -10
  80. package/dist/lib/resource-inventory.d.ts +79 -0
  81. package/dist/lib/resource-inventory.js +122 -0
  82. package/dist/lib/resources/mcp.js +2 -0
  83. package/dist/lib/resources/permissions.js +3 -0
  84. package/dist/lib/resources/types.d.ts +2 -1
  85. package/dist/lib/resources.js +8 -5
  86. package/dist/lib/run-defaults.d.ts +2 -0
  87. package/dist/lib/run-defaults.js +23 -2
  88. package/dist/lib/runner.js +50 -36
  89. package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
  90. package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
  91. package/dist/lib/secrets/bundles.js +157 -65
  92. package/dist/lib/secrets/filestore.d.ts +5 -3
  93. package/dist/lib/secrets/filestore.js +12 -8
  94. package/dist/lib/secrets/index.d.ts +15 -1
  95. package/dist/lib/secrets/index.js +148 -53
  96. package/dist/lib/secrets/reaper.d.ts +97 -0
  97. package/dist/lib/secrets/reaper.js +219 -0
  98. package/dist/lib/secrets/remote.d.ts +29 -0
  99. package/dist/lib/secrets/remote.js +37 -1
  100. package/dist/lib/secrets/sync-passphrase.d.ts +27 -0
  101. package/dist/lib/secrets/sync-passphrase.js +78 -0
  102. package/dist/lib/session/active.d.ts +36 -1
  103. package/dist/lib/session/active.js +60 -19
  104. package/dist/lib/session/actor-sidecar.d.ts +14 -0
  105. package/dist/lib/session/actor-sidecar.js +67 -4
  106. package/dist/lib/session/db.d.ts +1 -1
  107. package/dist/lib/session/db.js +32 -1
  108. package/dist/lib/session/discover.js +168 -0
  109. package/dist/lib/session/parse.d.ts +10 -0
  110. package/dist/lib/session/parse.js +98 -0
  111. package/dist/lib/session/recovery.d.ts +37 -0
  112. package/dist/lib/session/recovery.js +95 -0
  113. package/dist/lib/session/remote-list.d.ts +10 -1
  114. package/dist/lib/session/remote-list.js +2 -8
  115. package/dist/lib/session/remote.d.ts +57 -3
  116. package/dist/lib/session/remote.js +90 -26
  117. package/dist/lib/session/resume-command.d.ts +6 -0
  118. package/dist/lib/session/resume-command.js +8 -0
  119. package/dist/lib/session/session-cache.d.ts +173 -0
  120. package/dist/lib/session/session-cache.js +399 -0
  121. package/dist/lib/session/types.d.ts +1 -1
  122. package/dist/lib/session/types.js +1 -1
  123. package/dist/lib/session/width.d.ts +1 -1
  124. package/dist/lib/session/width.js +12 -2
  125. package/dist/lib/shims.d.ts +2 -2
  126. package/dist/lib/shims.js +62 -8
  127. package/dist/lib/staleness/writers/hooks.js +1 -1
  128. package/dist/lib/startup/command-registry.d.ts +1 -0
  129. package/dist/lib/startup/command-registry.js +2 -0
  130. package/dist/lib/sync-umbrella.d.ts +5 -0
  131. package/dist/lib/sync-umbrella.js +5 -4
  132. package/dist/lib/tmux/session.d.ts +4 -2
  133. package/dist/lib/tmux/session.js +5 -5
  134. package/dist/lib/types.d.ts +3 -1
  135. package/dist/lib/usage.d.ts +13 -0
  136. package/dist/lib/usage.js +215 -0
  137. package/dist/lib/versions.js +13 -2
  138. package/package.json +1 -1
@@ -17,6 +17,7 @@ import { readMeta, updateMeta } from './state.js';
17
17
  import { getProjectRunConfigs } from './run-config.js';
18
18
  import chalk from 'chalk';
19
19
  const VERSION_RE = /^(?:\*|latest|(?!.*\.\.)[A-Za-z0-9._+-]{1,64})$/;
20
+ const RUN_EFFORTS = ['low', 'medium', 'high', 'xhigh', 'max', 'auto'];
20
21
  function isAgentId(value) {
21
22
  return value in AGENTS;
22
23
  }
@@ -42,6 +43,12 @@ function normalizeRunDefaults(defaults, selector) {
42
43
  }
43
44
  out.model = defaults.model.trim();
44
45
  }
46
+ if (defaults.effort !== undefined) {
47
+ if (typeof defaults.effort !== 'string' || !RUN_EFFORTS.includes(defaults.effort)) {
48
+ throw new Error('Invalid effort in run.defaults.' + selector + ': use one of: ' + RUN_EFFORTS.join(', ') + '.');
49
+ }
50
+ out.effort = defaults.effort;
51
+ }
45
52
  return out;
46
53
  }
47
54
  export function parseRunDefaultSelector(input) {
@@ -97,6 +104,10 @@ export function resolveRunDefaultsFromConfig(runConfig, agent, version) {
97
104
  resolved.model = wildcard.model;
98
105
  resolved.sources.model = wildcardSelector;
99
106
  }
107
+ if (wildcard?.effort) {
108
+ resolved.effort = wildcard.effort;
109
+ resolved.sources.effort = wildcardSelector;
110
+ }
100
111
  if (exactSelector && defaults[exactSelector]) {
101
112
  const exact = normalizeRunDefaults(defaults[exactSelector], exactSelector);
102
113
  if (exact.mode) {
@@ -107,6 +118,10 @@ export function resolveRunDefaultsFromConfig(runConfig, agent, version) {
107
118
  resolved.model = exact.model;
108
119
  resolved.sources.model = exactSelector;
109
120
  }
121
+ if (exact.effort) {
122
+ resolved.effort = exact.effort;
123
+ resolved.sources.effort = exactSelector;
124
+ }
110
125
  }
111
126
  return resolved;
112
127
  }
@@ -122,6 +137,10 @@ export function resolveRunDefaultsFromConfigs(runConfigs, agent, version) {
122
137
  resolved.model = next.model;
123
138
  resolved.sources.model = next.sources.model;
124
139
  }
140
+ if (next.effort) {
141
+ resolved.effort = next.effort;
142
+ resolved.sources.effort = next.sources.effort;
143
+ }
125
144
  }
126
145
  return resolved;
127
146
  }
@@ -135,6 +154,8 @@ export function formatRunDefaultEntry(entry) {
135
154
  parts.push(`mode ${chalk.white(entry.defaults.mode)}`);
136
155
  if (entry.defaults.model)
137
156
  parts.push(`model ${chalk.white(entry.defaults.model)}`);
157
+ if (entry.defaults.effort)
158
+ parts.push('effort ' + chalk.white(entry.defaults.effort));
138
159
  return `${chalk.cyan(entry.selector.padEnd(22))} ${parts.join(' ')}`;
139
160
  }
140
161
  export function listRunDefaults() {
@@ -149,8 +170,8 @@ export function listRunDefaults() {
149
170
  export function setRunDefault(selectorInput, defaultsInput) {
150
171
  const parsed = parseRunDefaultSelector(selectorInput);
151
172
  const defaults = normalizeRunDefaults(defaultsInput, parsed.selector);
152
- if (!defaults.mode && !defaults.model) {
153
- throw new Error('Set at least one default: --mode <mode> or --model <model>.');
173
+ if (!defaults.mode && !defaults.model && !defaults.effort) {
174
+ throw new Error('Set at least one default: --mode <mode>, --model <model>, or --effort <effort>.');
154
175
  }
155
176
  updateMeta((meta) => {
156
177
  const run = { ...(meta.run ?? {}) };
@@ -22,6 +22,7 @@ import { getRunsDir } from './state.js';
22
22
  import { prepareJobHome, buildSpawnEnv, getJobHomePath } from './sandbox.js';
23
23
  import { resolveModel, buildReasoningFlags } from './models.js';
24
24
  import { createTimer, redactPrompt } from './events.js';
25
+ import { codexEditWritableRoots, codexPolicyArgs } from './codex-policy.js';
25
26
  import { normalizeMode, resolveHeadlessMode, buildExecEnv, detectRateLimit, detectAuthFailure, isAuthFailureFromLog, authFailureReason, } from './exec.js';
26
27
  import { resolveActor } from './actor.js';
27
28
  import { loadTask as loadHostTask } from './hosts/tasks.js';
@@ -30,7 +31,8 @@ import { backgroundSpawnOptions, killTree } from './platform/process.js';
30
31
  import lockfile from 'proper-lockfile';
31
32
  import { ensureLockTarget } from './fs-atomic.js';
32
33
  import { walkForFiles } from './fs-walk.js';
33
- import { getBinaryPath, isVersionInstalled, resolveVersion } from './versions.js';
34
+ import { getBinaryPath, isVersionInstalled, resolveVersion, getVersionHomePath } from './versions.js';
35
+ import { resolveClaudeSetupToken } from './claude-account-token.js';
34
36
  import { getConfiguredRunStrategy, resolveRunVersion, resolveAccountVersion, rotationFailoverChain, readinessFromCandidate, formatNoHealthyAccountError, } from './rotate.js';
35
37
  import { readAuthHealth, isDeadVerdict } from './auth-health.js';
36
38
  import { machineId } from './machine-id.js';
@@ -99,11 +101,12 @@ function terminateRoutineTree(pid) {
99
101
  /** CLI command templates per agent, with {prompt} as a placeholder. */
100
102
  const AGENT_COMMANDS = {
101
103
  claude: ['claude', '-p', '--verbose', '{prompt}', '--output-format', 'stream-json', '--permission-mode', 'plan'],
102
- codex: ['codex', 'exec', '--sandbox', 'workspace-write', '{prompt}', '--json'],
104
+ codex: ['codex', 'exec', '{prompt}', '--json'],
103
105
  gemini: ['gemini', '{prompt}', '--output-format', 'stream-json'],
104
106
  cursor: ['cursor-agent', '-p', '{prompt}', '--output-format', 'stream-json'],
105
107
  kimi: ['kimi', '--prompt', '{prompt}', '--output-format', 'stream-json'],
106
108
  droid: ['droid', 'exec', '{prompt}', '-o', 'stream-json'],
109
+ muse: ['muse', 'exec', '{prompt}', '--json'],
107
110
  };
108
111
  /** Agents the daemon can actually run, derived from the command table above
109
112
  * so the `--agent` help and any validation can never drift from it. */
@@ -137,6 +140,8 @@ const ROUTINE_TRANSCRIPT_SPECS = {
137
140
  { root: ['.kimi-code', 'sessions'], ext: '.jsonl' },
138
141
  ],
139
142
  grok: [{ root: ['.grok', 'sessions'], ext: '.json' }],
143
+ // Muse: ~/.local/share/muse/sessions/YYYY/MM/DD/<uuid>/session.jsonl
144
+ muse: [{ root: ['.local', 'share', 'muse', 'sessions'], ext: '.jsonl' }],
140
145
  };
141
146
  /** Stable working directory for routine children, independent of the daemon's launch cwd. */
142
147
  export function routineSpawnCwd(config, configuredRoot = getProjectRoot()) {
@@ -210,24 +215,14 @@ export function buildJobCommand(config, resolvedPrompt) {
210
215
  appendModelAndReasoning(cmd, config);
211
216
  }
212
217
  if (config.agent === 'codex') {
213
- if (mode === 'plan') {
214
- // The template defaults to workspace-write; plan means read-only.
215
- const sbIndex = cmd.indexOf('--sandbox');
216
- if (sbIndex !== -1)
217
- cmd[sbIndex + 1] = 'read-only';
218
- }
219
- else if (mode === 'edit' || mode === 'auto') {
220
- // Keep the workspace-write sandbox — no approval bypass; only skip drops
221
- // the guardrails. Re-enable network, which workspace-write turns off.
222
- cmd.push('-c', 'sandbox_workspace_write.network_access=true');
223
- }
224
- else if (mode === 'skip') {
225
- // Remove sandbox restriction, just --dangerously-bypass-approvals-and-sandbox
226
- const sbIndex = cmd.indexOf('--sandbox');
227
- if (sbIndex !== -1)
228
- cmd.splice(sbIndex, 2);
229
- cmd.push('--dangerously-bypass-approvals-and-sandbox');
230
- }
218
+ const policyMode = mode === 'plan' || mode === 'skip' ? mode : 'edit';
219
+ const routineRoots = (config.allow?.dirs ?? []).map((dir) => {
220
+ if (dir.startsWith('-')) {
221
+ throw new Error(`allow.dirs entries must not start with '-': ${JSON.stringify(dir)}`);
222
+ }
223
+ return dir.replace(/^~/, os.homedir());
224
+ });
225
+ cmd.push(...codexPolicyArgs(policyMode, [...codexEditWritableRoots(), ...routineRoots]));
231
226
  appendModelAndReasoning(cmd, config);
232
227
  }
233
228
  if (config.agent === 'gemini') {
@@ -277,6 +272,21 @@ export function buildJobCommand(config, resolvedPrompt) {
277
272
  }
278
273
  appendModelAndReasoning(cmd, config);
279
274
  }
275
+ if (config.agent === 'muse') {
276
+ // muse exec: plan ≈ no non-shell writes; auto skips approval prompts but
277
+ // keeps the OS sandbox; skip is --yolo (no approval, no sandbox, trust).
278
+ if (mode === 'plan') {
279
+ cmd.push('--disable-write');
280
+ }
281
+ else if (mode === 'auto') {
282
+ cmd.push('--disable-approval');
283
+ }
284
+ else if (mode === 'skip') {
285
+ cmd.push('--yolo');
286
+ }
287
+ // edit: default on-request approval + sandbox
288
+ appendModelAndReasoning(cmd, config);
289
+ }
280
290
  return cmd;
281
291
  }
282
292
  /**
@@ -528,22 +538,26 @@ export function buildRoutineSpawnEnv(baseEnv, agent, version, timezone, overlayH
528
538
  if (v !== undefined)
529
539
  out[k] = v;
530
540
  }
531
- // A routine authenticates through the pinned account's own CLAUDE_CONFIG_DIR
532
- // login on THIS box (buildExecEnv points it at the per-account version home).
533
- // Claude Code's interactive session refreshes itself per-device; keeping the
534
- // daemon out of the credential entirely is what avoids the fleet-wide rotation
535
- // logout a shared/rotating token was the cause, not the fix.
536
- //
537
- // Injecting a token was already ruled out, but INHERITING one was not:
538
- // buildExecEnv spreads the ambient process.env (exec.ts) and sanitizeProcessEnv
539
- // only strips loader/interpreter vars, never credentials. So on any box whose
540
- // daemon environment happens to carry CLAUDE_CODE_OAUTH_TOKEN, every routine
541
- // spawn silently ran on that one shared rotating token instead of the host's
542
- // own login the exact fleet-wide-logout path, arriving by inheritance rather
543
- // than injection. CI never caught it because CI has no token to inherit; a
544
- // provisioned box does. Drop it here so a routine always uses the login of the
545
- // machine it runs on.
546
- delete out.CLAUDE_CODE_OAUTH_TOKEN;
541
+ // CLAUDE_CODE_OAUTH_TOKEN comes in two flavours, and only one is safe for a
542
+ // routine. KEEP a per-account `claude setup-token` (long-lived, NON-rotating,
543
+ // keyed to this home's own account) that buildExecEnv injected from the reserved
544
+ // `auth` bundle (resolveClaudeSetupToken) that is the durable cure for the
545
+ // single-use-refresh-token revocation storm: a setup-token never rotates, so a
546
+ // scheduled routine can't land on a sibling home's just-rotated-out credential.
547
+ // STRIP an INHERITED ambient value instead: buildExecEnv spreads process.env
548
+ // (exec.ts) and sanitizeProcessEnv leaves credentials, so a daemon env that
549
+ // happens to carry a shared/rotating CLAUDE_CODE_OAUTH_TOKEN would otherwise make
550
+ // every routine run on that one token — the RUSH-1822 fleet-wide-logout path.
551
+ // Distinguish by value: only the resolved setup-token survives.
552
+ // Authoritative: buildExecEnv injects the setup-token but then spreads the caller
553
+ // env over it, so an ambient CLAUDE_CODE_OAUTH_TOKEN would win re-assert here.
554
+ const setupToken = agent === 'claude' && version
555
+ ? resolveClaudeSetupToken(getVersionHomePath('claude', version))
556
+ : null;
557
+ if (setupToken)
558
+ out.CLAUDE_CODE_OAUTH_TOKEN = setupToken;
559
+ else
560
+ delete out.CLAUDE_CODE_OAUTH_TOKEN;
547
561
  if (agent === 'cursor' && overlayHome) {
548
562
  // prepareJobHome links this host's Cursor auth file here. Pin XDG_CONFIG_HOME
549
563
  // to the overlay so an ambient value cannot bypass the routine sandbox.
@@ -294,6 +294,26 @@ export function readBundle(name) {
294
294
  if (vaultExists() && !getVaultSession().loggedIn) {
295
295
  throw new Error(`Synced secrets are locked. Run: agents login`);
296
296
  }
297
+ // Distinguish a genuinely-absent bundle from a present-but-unreadable one
298
+ // (a locked login keychain, or a legacy ACL'd metadata item before first
299
+ // unlock). `has` counts an unreadable item as present, so a metadata item
300
+ // that exists but could not be read must not report as "not found" — an
301
+ // existence answer and a read answer may not contradict (RUSH-2253).
302
+ if (backend === 'keychain') {
303
+ let present;
304
+ try {
305
+ present = hasKeychainToken(bundleMetaItem(name));
306
+ }
307
+ catch (probeErr) {
308
+ // Keychain unreachable (RUSH-2235 fail-loud): neither absent nor
309
+ // add-the-key — surface the reachability failure, not a false absence.
310
+ throw new Error(`Secrets bundle '${name}': ${probeErr.message}`);
311
+ }
312
+ if (present) {
313
+ throw new Error(`Secrets bundle '${name}' is present but its metadata could not be read — the keychain is locked. ` +
314
+ `Unlock it (log in, or reboot then log in) and retry. (${err.message})`);
315
+ }
316
+ }
297
317
  throw new Error(`Secrets bundle '${name}' not found.`);
298
318
  }
299
319
  let parsed;
@@ -1060,6 +1080,91 @@ export function assertRemoteBundleFlagsUnsupported(bundleName, host, opts, flagL
1060
1080
  throw new Error(`Bundle '${bundleName}@${host}': ${flagLabels.keysFlag} and ${flagLabels.allowExpiredFlag} are not supported for remote (bundle@host) bundles yet. ` +
1061
1081
  `Drop the flag or resolve the bundle locally.`);
1062
1082
  }
1083
+ /**
1084
+ * A declared `keychain:` ref resolved to NO value in the batch read. Classify
1085
+ * genuinely-absent vs present-but-unreadable before choosing the error, so a
1086
+ * read can never contradict what `agents secrets view` reports (RUSH-2248,
1087
+ * RUSH-2253). `view`'s "stored" badge comes from `hasKeychainToken` — the exact
1088
+ * existence probe used here — which counts a biometry-ACL'd or locked-keychain
1089
+ * item (`errSecInteractionNotAllowed`) as present. So:
1090
+ *
1091
+ * - present ⇒ the item exists but this context could not read it (keychain
1092
+ * locked, or Touch ID not granted). Report HOW to unlock; NEVER
1093
+ * "add the key", whose remediation (`secrets add`) would overwrite a good
1094
+ * secret.
1095
+ * - absent ⇒ genuinely not stored on this machine — the honest "not found"
1096
+ * with the `secrets add` remediation.
1097
+ * - probe throws ⇒ the keychain itself is unreachable (RUSH-2235 fail-loud):
1098
+ * neither absent nor add-the-key — surface the reachability failure verbatim.
1099
+ *
1100
+ * Only the keychain backend has a locked/biometry state; a file/vault miss is
1101
+ * genuinely absent.
1102
+ */
1103
+ function missingBundleKeychainItemError(bundleName, key, item, backendKind) {
1104
+ if (backendKind === 'keychain') {
1105
+ let present;
1106
+ try {
1107
+ present = hasKeychainToken(item);
1108
+ }
1109
+ catch (err) {
1110
+ return new Error(`Bundle '${bundleName}' key '${key}': ${err.message}`);
1111
+ }
1112
+ if (present) {
1113
+ return new Error(`Bundle '${bundleName}' key '${key}': stored item '${item}' is present but could not be read — ` +
1114
+ `the keychain is locked or Touch ID was not granted for this read. ` +
1115
+ `Run: agents secrets unlock ${bundleName} (or read it once at an interactive terminal so Touch ID can be granted). ` +
1116
+ `Do NOT run 'agents secrets add' — the secret is already stored and adding would overwrite it.`);
1117
+ }
1118
+ }
1119
+ return new Error(`Bundle '${bundleName}' key '${key}': stored item '${item}' not found. ` +
1120
+ `Run: agents secrets add ${bundleName} ${key}`);
1121
+ }
1122
+ /**
1123
+ * Resolve every selected key of an already-read bundle into a flat env map,
1124
+ * given a pre-fetched keychain batch. The single per-key resolution loop shared
1125
+ * by `resolveBundleEnv` and `readAndResolveBundleEnv` so the keychain lookup and
1126
+ * the missing-item classification can never diverge again (RUSH-2252: the two
1127
+ * paths drifted — one did the hashed-alias fallback lookup and one did not, and
1128
+ * only one classified a missing item honestly).
1129
+ *
1130
+ * The keychain lookup tries the cleartext name first (Linux / file store), then
1131
+ * its hashed storage alias (macOS with #316 hashing active) — the batch keys its
1132
+ * results by the names it was ASKED for, which for the metadata + declared keys
1133
+ * is the cleartext form and for an enumerated leftover is the hashed form.
1134
+ */
1135
+ function assembleBundleEnv(bundle, selectedKeys, parsedByKey, fetched, keyMode, backendKind) {
1136
+ const env = {};
1137
+ const owners = new Map();
1138
+ for (const [key] of Object.entries(bundle.vars)) {
1139
+ if (!selectedKeys.has(key))
1140
+ continue;
1141
+ const parsed = parsedByKey.get(key);
1142
+ if ('literal' in parsed) {
1143
+ assignResolvedEnvValue(env, bundle, key, parsed.literal, keyMode, owners);
1144
+ continue;
1145
+ }
1146
+ if (parsed.ref.provider === 'keychain') {
1147
+ const item = secretsKeychainItem(bundle.name, parsed.ref.value);
1148
+ const value = fetched.get(item) ?? fetched.get(keychainServiceAlias(item));
1149
+ if (value === undefined) {
1150
+ throw missingBundleKeychainItemError(bundle.name, key, item, backendKind);
1151
+ }
1152
+ assignResolvedEnvValue(env, bundle, key, value, keyMode, owners);
1153
+ continue;
1154
+ }
1155
+ try {
1156
+ const value = resolveRef(parsed.ref, {
1157
+ allowExec: bundle.allow_exec,
1158
+ keychainItemFor: (shortId) => secretsKeychainItem(bundle.name, shortId),
1159
+ });
1160
+ assignResolvedEnvValue(env, bundle, key, value, keyMode, owners);
1161
+ }
1162
+ catch (err) {
1163
+ throw new Error(`Bundle '${bundle.name}' key '${key}': ${err.message}`);
1164
+ }
1165
+ }
1166
+ return env;
1167
+ }
1063
1168
  // Walk the bundle and produce a flat env map. Every keychain: ref is gathered
1064
1169
  // into a single batch read so macOS shows ONE Touch ID prompt for the whole
1065
1170
  // bundle — including the metadata fetch that already happened in readBundle
@@ -1093,37 +1198,7 @@ export function resolveBundleEnv(bundle, _opts = {}) {
1093
1198
  ? getKeychainTokens(keychainItemsToFetch, { silentNoAcl: bundlePolicy(bundle) === 'never' })
1094
1199
  : store.getBatch(keychainItemsToFetch)
1095
1200
  : new Map();
1096
- const env = {};
1097
- const owners = new Map();
1098
- for (const [key, raw] of Object.entries(bundle.vars)) {
1099
- if (!selectedKeys.has(key))
1100
- continue;
1101
- const parsed = parsedByKey.get(key);
1102
- if ('literal' in parsed) {
1103
- assignResolvedEnvValue(env, bundle, key, parsed.literal, _opts.keyMode, owners);
1104
- continue;
1105
- }
1106
- if (parsed.ref.provider === 'keychain') {
1107
- const item = secretsKeychainItem(bundle.name, parsed.ref.value);
1108
- const value = fetched.get(item);
1109
- if (value === undefined) {
1110
- throw new Error(`Bundle '${bundle.name}' key '${key}': stored item '${item}' not found. ` +
1111
- `Run: agents secrets add ${bundle.name} ${key}`);
1112
- }
1113
- assignResolvedEnvValue(env, bundle, key, value, _opts.keyMode, owners);
1114
- continue;
1115
- }
1116
- try {
1117
- const value = resolveRef(parsed.ref, {
1118
- allowExec: bundle.allow_exec,
1119
- keychainItemFor: (shortId) => secretsKeychainItem(bundle.name, shortId),
1120
- });
1121
- assignResolvedEnvValue(env, bundle, key, value, _opts.keyMode, owners);
1122
- }
1123
- catch (err) {
1124
- throw new Error(`Bundle '${bundle.name}' key '${key}': ${err.message}`);
1125
- }
1126
- }
1201
+ const env = assembleBundleEnv(bundle, selectedKeys, parsedByKey, fetched, _opts.keyMode, bundle.backend ?? 'keychain');
1127
1202
  // `caller` is intentionally unused; see ResolveBundleOptions.
1128
1203
  void _opts.caller;
1129
1204
  return env;
@@ -1327,6 +1402,53 @@ export function readAndResolveBundleEnv(name, opts = {}) {
1327
1402
  }
1328
1403
  const keys = [...selectedKeys].sort();
1329
1404
  keychainKeys.sort();
1405
+ // RUSH-2252: complete the read set from the bundle's DECLARED keys, not only
1406
+ // from the enumeration above. `store.list()` derives the batch by enumerating
1407
+ // the bundle's namespace, and that enumeration is lossy by construction — the
1408
+ // macOS helper's `list` omits every biometry-ACL'd item
1409
+ // (`kSecUseAuthenticationUISkip`) and skips the whole data-protection pass when
1410
+ // the keychain is locked, so a `hold`-policy bundle's value items never appear
1411
+ // and a present secret reads as "not found" (RUSH-2248). A declared key whose
1412
+ // item did not enumerate is therefore absent from `fetched`; read those exact
1413
+ // items directly — a point read DOES evaluate the ACL, so it triggers Touch ID
1414
+ // and returns the value the enumeration could not see. The enumeration still
1415
+ // earns its place (it catches hashed/aliased storage names and stale leftovers
1416
+ // not named in the metadata), so this is a UNION, not a replacement.
1417
+ //
1418
+ // One Touch ID sheet is preserved: the items missing from `fetched` are exactly
1419
+ // the ACL'd ones the enumeration dropped, so the first batch (metadata plus any
1420
+ // no-ACL / `never` items) raised no sheet, and this second batch raises the
1421
+ // single sheet that covers all of them. When the enumeration is healthy every
1422
+ // declared item is already in `fetched`, `missingDeclared` is empty, and this
1423
+ // is skipped entirely — zero behavior change and no extra spawn on the hot path.
1424
+ if (backend === 'keychain') {
1425
+ const missingDeclared = [];
1426
+ for (const key of keychainKeys) {
1427
+ const p = parsedByKey.get(key);
1428
+ if (!('ref' in p) || p.ref.provider !== 'keychain')
1429
+ continue;
1430
+ const item = secretsKeychainItem(bundle.name, p.ref.value);
1431
+ if (fetched.get(item) === undefined && fetched.get(keychainServiceAlias(item)) === undefined) {
1432
+ missingDeclared.push(item);
1433
+ }
1434
+ }
1435
+ if (missingDeclared.length > 0) {
1436
+ const declaredFetched = getKeychainTokens([...new Set(missingDeclared)], {
1437
+ agent: opts.agent || process.env.AGENTS_AGENT_NAME || 'Agents CLI',
1438
+ bundle: name,
1439
+ sessionId: process.env.AGENT_SESSION_ID || process.env.AGENTS_SESSION_ID,
1440
+ reason: opts.caller ? `to ${opts.caller}` : reason,
1441
+ duration: opts.duration || humanUnlockDuration(secretsHoldMs()),
1442
+ // The policy is known now (metadata parsed), so the prompt names the real
1443
+ // duration instead of the pre-read default the first batch had to guess.
1444
+ defaultPolicy: bundlePolicy(bundle),
1445
+ forceDuration: Boolean(opts.duration),
1446
+ silentNoAcl: verifiedNoAclBundle,
1447
+ });
1448
+ for (const [k, v] of declaredFetched)
1449
+ fetched.set(k, v);
1450
+ }
1451
+ }
1330
1452
  const emitReadAudit = (status, err) => {
1331
1453
  emitSecretAudit({
1332
1454
  event: 'secrets.get',
@@ -1342,40 +1464,10 @@ export function readAndResolveBundleEnv(name, opts = {}) {
1342
1464
  });
1343
1465
  };
1344
1466
  try {
1345
- const env = {};
1346
- const owners = new Map();
1347
- for (const [key] of Object.entries(bundle.vars)) {
1348
- if (!selectedKeys.has(key))
1349
- continue;
1350
- const p = parsedByKey.get(key);
1351
- if ('literal' in p) {
1352
- assignResolvedEnvValue(env, bundle, key, p.literal, opts.keyMode, owners);
1353
- continue;
1354
- }
1355
- if (p.ref.provider === 'keychain') {
1356
- const item = secretsKeychainItem(bundle.name, p.ref.value);
1357
- // The batch keys results by the names it was ASKED for: the cleartext
1358
- // metaItem, plus enumerated storage names. Look up the cleartext name
1359
- // first (Linux / file store), then its hashed storage alias (macOS).
1360
- const value = fetched.get(item) ?? fetched.get(keychainServiceAlias(item));
1361
- if (value === undefined) {
1362
- throw new Error(`Bundle '${bundle.name}' key '${key}': stored item '${item}' not found. ` +
1363
- `Run: agents secrets add ${bundle.name} ${key}`);
1364
- }
1365
- assignResolvedEnvValue(env, bundle, key, value, opts.keyMode, owners);
1366
- continue;
1367
- }
1368
- try {
1369
- const value = resolveRef(p.ref, {
1370
- allowExec: bundle.allow_exec,
1371
- keychainItemFor: (shortId) => secretsKeychainItem(bundle.name, shortId),
1372
- });
1373
- assignResolvedEnvValue(env, bundle, key, value, opts.keyMode, owners);
1374
- }
1375
- catch (err) {
1376
- throw new Error(`Bundle '${bundle.name}' key '${key}': ${err.message}`);
1377
- }
1378
- }
1467
+ // Shared per-key resolver: same keychain lookup (cleartext name, then hashed
1468
+ // storage alias) and same missing-item classification as resolveBundleEnv, so
1469
+ // the two paths can never diverge again (RUSH-2252, RUSH-2253).
1470
+ const env = assembleBundleEnv(bundle, selectedKeys, parsedByKey, fetched, opts.keyMode, backend);
1379
1471
  emitReadAudit('success');
1380
1472
  // Auto-cache: this was a real keychain read (the agent fast-path returned
1381
1473
  // earlier on a hit). If the bundle opts into the `daily` policy and the user
@@ -29,9 +29,11 @@ export declare function machinePassphraseExists(): boolean;
29
29
  * Resolve the passphrase for the encrypted file store.
30
30
  *
31
31
  * Order: AGENTS_SECRETS_PASSPHRASE > previously-provisioned machine-local key >
32
- * a freshly auto-provisioned machine-local key. It NEVER prompts and NEVER
33
- * hard-fails — the file store must work on every platform (macOS included)
34
- * without the user setting, typing, or remembering a passphrase. Provisioning
32
+ * legacy co-located key > a freshly auto-provisioned machine-local key. It NEVER
33
+ * prompts, and never fails for WANT of a passphrase — the file store must work on
34
+ * every platform (macOS included) without the user setting, typing, or
35
+ * remembering one. (It can still throw if provisioning cannot write the key file
36
+ * at all; that is a disk/permissions failure, not a missing passphrase.) Provisioning
35
37
  * writes a 0600 key file (encryption-at-rest, same posture as an SSH key); set
36
38
  * AGENTS_SECRETS_PASSPHRASE to opt into an off-disk key.
37
39
  */
@@ -110,11 +110,13 @@ function readMachinePassphrase() {
110
110
  * the keyring is locked and no AGENTS_SECRETS_PASSPHRASE is set.
111
111
  *
112
112
  * Security model: this is encryption-at-rest with the key held in a 0600 file —
113
- * the same posture as an SSH private key, and identical to the common
114
- * "export AGENTS_SECRETS_PASSPHRASE=… in ~/.zshenv (chmod 600)" workaround. The
115
- * keyring (key in a daemon's locked memory) is stronger but is unavailable
116
- * without a graphical/unlocked session. For an off-disk key, set
117
- * AGENTS_SECRETS_PASSPHRASE (it always takes precedence) or unlock the keyring.
113
+ * the same posture as an SSH private key. It is NOT equivalent to the common
114
+ * "export AGENTS_SECRETS_PASSPHRASE=… in ~/.zshenv (chmod 600)" workaround, and
115
+ * is strictly safer: this file is read by the one process that needs it, while
116
+ * a shell-rc export is inherited by every process the login shell spawns and is
117
+ * readable from /proc/<pid>/environ by any same-user process (RUSH-1968; see
118
+ * rc-hygiene.ts). The keyring (key in a daemon's locked memory) is stronger
119
+ * still but is unavailable without a graphical/unlocked session.
118
120
  */
119
121
  function provisionMachinePassphrase() {
120
122
  const existing = readMachinePassphrase();
@@ -145,9 +147,11 @@ function provisionMachinePassphrase() {
145
147
  * Resolve the passphrase for the encrypted file store.
146
148
  *
147
149
  * Order: AGENTS_SECRETS_PASSPHRASE > previously-provisioned machine-local key >
148
- * a freshly auto-provisioned machine-local key. It NEVER prompts and NEVER
149
- * hard-fails — the file store must work on every platform (macOS included)
150
- * without the user setting, typing, or remembering a passphrase. Provisioning
150
+ * legacy co-located key > a freshly auto-provisioned machine-local key. It NEVER
151
+ * prompts, and never fails for WANT of a passphrase — the file store must work on
152
+ * every platform (macOS included) without the user setting, typing, or
153
+ * remembering one. (It can still throw if provisioning cannot write the key file
154
+ * at all; that is a disk/permissions failure, not a missing passphrase.) Provisioning
151
155
  * writes a 0600 key file (encryption-at-rest, same posture as an SSH key); set
152
156
  * AGENTS_SECRETS_PASSPHRASE to opt into an off-disk key.
153
157
  */
@@ -22,10 +22,24 @@
22
22
  * through the explicit export/import flow in src/lib/secrets/sync.ts
23
23
  * rather than the system's cloud-keychain path.
24
24
  */
25
- import { type SpawnSyncOptions } from 'child_process';
25
+ import { type SpawnSyncOptions, type SpawnSyncReturns } from 'child_process';
26
26
  import type { NativeImportReport } from './fallback.js';
27
27
  export type { NativeImportReport, NativeImportResult, NativeImportStatus } from './fallback.js';
28
28
  export declare const SECRETS_ITEM_PREFIX = "agents-cli.secrets.";
29
+ /**
30
+ * Thrown when a keychain helper / security spawnSync is killed because it
31
+ * exceeded its timeout. A wedged coreauthd / LocalAuthentication dialog can hang
32
+ * the parent forever; this makes the failure explicit and arms the read back-off.
33
+ */
34
+ export declare class KeychainHelperTimeoutError extends Error {
35
+ readonly bin: string;
36
+ readonly args: string[];
37
+ constructor(bin: string, args: string[]);
38
+ }
39
+ /** Test seam: suppress the side-effect daemon boot in unit tests. */
40
+ export declare function setKeychainDaemonBootForTest(enabled: boolean): void;
41
+ /** Test seam: exercise the timeout wrapper with an arbitrary binary. */
42
+ export declare function spawnKeychainHelperForTest(bin: string, args: string[], opts: SpawnSyncOptions, timeoutMs: number): SpawnSyncReturns<Buffer>;
29
43
  /** Supported secret resolution backends. */
30
44
  export type SecretProvider = 'keychain' | 'env' | 'file' | 'exec';
31
45
  /** A typed reference to a secret, consisting of a provider and a provider-specific value. */