@phnx-labs/agents-cli 1.22.23 → 1.22.25

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 (143) hide show
  1. package/CHANGELOG.md +359 -0
  2. package/README.md +19 -8
  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/cloud.js +9 -5
  7. package/dist/commands/defaults.js +2 -0
  8. package/dist/commands/doctor.d.ts +24 -0
  9. package/dist/commands/doctor.js +105 -9
  10. package/dist/commands/exec.js +99 -44
  11. package/dist/commands/feed.d.ts +23 -0
  12. package/dist/commands/feed.js +65 -2
  13. package/dist/commands/focus.d.ts +34 -3
  14. package/dist/commands/focus.js +301 -57
  15. package/dist/commands/go.d.ts +14 -1
  16. package/dist/commands/go.js +49 -5
  17. package/dist/commands/insights.js +6 -2
  18. package/dist/commands/inspect.js +39 -5
  19. package/dist/commands/menubar.js +14 -1
  20. package/dist/commands/models.js +1 -0
  21. package/dist/commands/modes.d.ts +12 -0
  22. package/dist/commands/modes.js +147 -0
  23. package/dist/commands/routines.d.ts +3 -0
  24. package/dist/commands/routines.js +70 -47
  25. package/dist/commands/run-cloud.js +1 -1
  26. package/dist/commands/secrets-sync.js +11 -13
  27. package/dist/commands/secrets.d.ts +2 -0
  28. package/dist/commands/secrets.js +54 -17
  29. package/dist/commands/sessions-browser.d.ts +36 -1
  30. package/dist/commands/sessions-browser.js +165 -19
  31. package/dist/commands/sessions-resume.d.ts +2 -0
  32. package/dist/commands/sessions-resume.js +28 -12
  33. package/dist/commands/sessions.d.ts +23 -1
  34. package/dist/commands/sessions.js +58 -61
  35. package/dist/commands/setup-secrets.js +1 -1
  36. package/dist/commands/setup-watchdog.js +5 -10
  37. package/dist/commands/setup.js +1 -1
  38. package/dist/commands/sync.js +246 -42
  39. package/dist/commands/teams.js +3 -2
  40. package/dist/commands/view.js +2 -0
  41. package/dist/commands/watchdog.d.ts +3 -4
  42. package/dist/commands/watchdog.js +26 -66
  43. package/dist/index.js +38 -2
  44. package/dist/lib/agent-modes.d.ts +49 -0
  45. package/dist/lib/agent-modes.js +70 -0
  46. package/dist/lib/agents.js +126 -21
  47. package/dist/lib/cloud/cursor.d.ts +79 -0
  48. package/dist/lib/cloud/cursor.js +228 -0
  49. package/dist/lib/cloud/registry.js +2 -0
  50. package/dist/lib/cloud/types.d.ts +7 -2
  51. package/dist/lib/cloud/types.js +14 -0
  52. package/dist/lib/crabbox/cli.d.ts +2 -2
  53. package/dist/lib/crabbox/config.d.ts +7 -8
  54. package/dist/lib/crabbox/config.js +14 -14
  55. package/dist/lib/crabbox/lease.d.ts +11 -4
  56. package/dist/lib/crabbox/lease.js +40 -8
  57. package/dist/lib/crabbox/setup-copy.d.ts +5 -0
  58. package/dist/lib/crabbox/setup-copy.js +17 -1
  59. package/dist/lib/daemon.js +27 -1
  60. package/dist/lib/device-config.js +7 -0
  61. package/dist/lib/devices/doctor-findings.d.ts +7 -1
  62. package/dist/lib/devices/doctor-findings.js +40 -1
  63. package/dist/lib/doctor-diff.d.ts +3 -0
  64. package/dist/lib/doctor-diff.js +15 -13
  65. package/dist/lib/event-stream.d.ts +3 -1
  66. package/dist/lib/event-stream.js +14 -1
  67. package/dist/lib/events.d.ts +9 -0
  68. package/dist/lib/events.js +58 -0
  69. package/dist/lib/exec.d.ts +15 -3
  70. package/dist/lib/exec.js +112 -19
  71. package/dist/lib/feed-outcome.d.ts +3 -0
  72. package/dist/lib/feed-outcome.js +18 -10
  73. package/dist/lib/feed.d.ts +4 -0
  74. package/dist/lib/hooks/cache.js +36 -3
  75. package/dist/lib/hooks.d.ts +19 -7
  76. package/dist/lib/hooks.js +88 -38
  77. package/dist/lib/hosts/passthrough.d.ts +21 -0
  78. package/dist/lib/hosts/passthrough.js +39 -12
  79. package/dist/lib/hosts/session-index.d.ts +4 -0
  80. package/dist/lib/hosts/session-index.js +7 -0
  81. package/dist/lib/manifest.d.ts +12 -2
  82. package/dist/lib/manifest.js +60 -5
  83. package/dist/lib/mcp.js +5 -1
  84. package/dist/lib/menubar/MenubarHelper.app/Contents/CodeResources +0 -0
  85. package/dist/lib/menubar/MenubarHelper.app/Contents/Info.plist +5 -1
  86. package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
  87. package/dist/lib/menubar/MenubarHelper.app/Contents/Resources/AppIcon.icns +0 -0
  88. package/dist/lib/menubar/MenubarHelper.app/Contents/_CodeSignature/CodeResources +15 -2
  89. package/dist/lib/menubar/install-menubar.d.ts +52 -2
  90. package/dist/lib/menubar/install-menubar.js +128 -6
  91. package/dist/lib/menubar/snapshot.d.ts +15 -0
  92. package/dist/lib/menubar/snapshot.js +40 -0
  93. package/dist/lib/plugins.js +13 -1
  94. package/dist/lib/refresh.d.ts +5 -0
  95. package/dist/lib/refresh.js +37 -33
  96. package/dist/lib/resource-inventory.d.ts +79 -0
  97. package/dist/lib/resource-inventory.js +122 -0
  98. package/dist/lib/resources/mcp.js +3 -0
  99. package/dist/lib/resources.js +8 -5
  100. package/dist/lib/routine-process-cleanup.d.ts +9 -0
  101. package/dist/lib/routine-process-cleanup.js +73 -0
  102. package/dist/lib/run-defaults.d.ts +2 -0
  103. package/dist/lib/run-defaults.js +23 -2
  104. package/dist/lib/runner.js +27 -22
  105. package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
  106. package/dist/lib/secrets/Agents CLI.app/Contents/Info.plist +2 -0
  107. package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
  108. package/dist/lib/secrets/Agents CLI.app/Contents/Resources/AppIcon.icns +0 -0
  109. package/dist/lib/secrets/Agents CLI.app/Contents/_CodeSignature/CodeResources +13 -1
  110. package/dist/lib/secrets/bundles.js +158 -85
  111. package/dist/lib/secrets/filestore.d.ts +7 -3
  112. package/dist/lib/secrets/filestore.js +25 -8
  113. package/dist/lib/secrets/index.js +34 -8
  114. package/dist/lib/secrets/rc-hygiene.d.ts +14 -0
  115. package/dist/lib/secrets/rc-hygiene.js +14 -1
  116. package/dist/lib/secrets/reaper.d.ts +10 -0
  117. package/dist/lib/secrets/reaper.js +50 -15
  118. package/dist/lib/secrets/sync-passphrase.d.ts +27 -0
  119. package/dist/lib/secrets/sync-passphrase.js +78 -0
  120. package/dist/lib/session/active.d.ts +4 -0
  121. package/dist/lib/session/db.js +8 -2
  122. package/dist/lib/session/recovery.d.ts +37 -0
  123. package/dist/lib/session/recovery.js +95 -0
  124. package/dist/lib/session/remote-list.d.ts +2 -0
  125. package/dist/lib/session/remote-list.js +1 -0
  126. package/dist/lib/session/session-cache.d.ts +4 -4
  127. package/dist/lib/session/session-cache.js +4 -4
  128. package/dist/lib/shims.d.ts +2 -2
  129. package/dist/lib/shims.js +45 -6
  130. package/dist/lib/signin-badge.js +2 -0
  131. package/dist/lib/startup/command-registry.d.ts +16 -0
  132. package/dist/lib/startup/command-registry.js +44 -0
  133. package/dist/lib/sync-umbrella.d.ts +5 -0
  134. package/dist/lib/sync-umbrella.js +5 -4
  135. package/dist/lib/teams/agents.js +1 -1
  136. package/dist/lib/teams/parsers.d.ts +1 -1
  137. package/dist/lib/tmux/session.d.ts +4 -2
  138. package/dist/lib/tmux/session.js +5 -5
  139. package/dist/lib/types.d.ts +3 -1
  140. package/dist/lib/versions.js +16 -1
  141. package/dist/lib/watchdog/service.d.ts +17 -0
  142. package/dist/lib/watchdog/service.js +39 -0
  143. package/package.json +1 -1
@@ -31,7 +31,8 @@ import { backgroundSpawnOptions, killTree } from './platform/process.js';
31
31
  import lockfile from 'proper-lockfile';
32
32
  import { ensureLockTarget } from './fs-atomic.js';
33
33
  import { walkForFiles } from './fs-walk.js';
34
- import { getBinaryPath, isVersionInstalled, resolveVersion } from './versions.js';
34
+ import { getBinaryPath, isVersionInstalled, resolveVersion, getVersionHomePath } from './versions.js';
35
+ import { resolveClaudeSetupToken } from './claude-account-token.js';
35
36
  import { getConfiguredRunStrategy, resolveRunVersion, resolveAccountVersion, rotationFailoverChain, readinessFromCandidate, formatNoHealthyAccountError, } from './rotate.js';
36
37
  import { readAuthHealth, isDeadVerdict } from './auth-health.js';
37
38
  import { machineId } from './machine-id.js';
@@ -51,7 +52,7 @@ function activeRoutineRun(config) {
51
52
  const runs = listRuns(config.name);
52
53
  for (let i = runs.length - 1; i >= 0; i--) {
53
54
  const run = runs[i];
54
- if (run.status !== 'running')
55
+ if (!['running', 'failed', 'timeout'].includes(run.status))
55
56
  continue;
56
57
  if (run.pid && isPidOurs(run.pid, run.spawnedAt))
57
58
  return run;
@@ -234,10 +235,10 @@ export function buildJobCommand(config, resolvedPrompt) {
234
235
  appendModelAndReasoning(cmd, config);
235
236
  }
236
237
  if (config.agent === 'cursor') {
237
- // cursor-agent supports --plan, but the capability registry has not been
238
- // upgraded yet. RUSH-2101 tracks adding that read-only mode after PR #1721.
239
- const cursorMode = resolveHeadlessMode('cursor', mode, false, `routine ${config.name}`);
240
- if (cursorMode === 'skip') {
238
+ if (mode === 'plan') {
239
+ cmd.push('--plan');
240
+ }
241
+ else if (mode === 'skip') {
241
242
  cmd.push('-f');
242
243
  }
243
244
  else {
@@ -537,22 +538,26 @@ export function buildRoutineSpawnEnv(baseEnv, agent, version, timezone, overlayH
537
538
  if (v !== undefined)
538
539
  out[k] = v;
539
540
  }
540
- // A routine authenticates through the pinned account's own CLAUDE_CONFIG_DIR
541
- // login on THIS box (buildExecEnv points it at the per-account version home).
542
- // Claude Code's interactive session refreshes itself per-device; keeping the
543
- // daemon out of the credential entirely is what avoids the fleet-wide rotation
544
- // logout a shared/rotating token was the cause, not the fix.
545
- //
546
- // Injecting a token was already ruled out, but INHERITING one was not:
547
- // buildExecEnv spreads the ambient process.env (exec.ts) and sanitizeProcessEnv
548
- // only strips loader/interpreter vars, never credentials. So on any box whose
549
- // daemon environment happens to carry CLAUDE_CODE_OAUTH_TOKEN, every routine
550
- // spawn silently ran on that one shared rotating token instead of the host's
551
- // own login the exact fleet-wide-logout path, arriving by inheritance rather
552
- // than injection. CI never caught it because CI has no token to inherit; a
553
- // provisioned box does. Drop it here so a routine always uses the login of the
554
- // machine it runs on.
555
- 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;
556
561
  if (agent === 'cursor' && overlayHome) {
557
562
  // prepareJobHome links this host's Cursor auth file here. Pin XDG_CONFIG_HOME
558
563
  // to the overlay so an ambient value cannot bypass the routine sandbox.
@@ -8,6 +8,8 @@
8
8
  <string>Agents CLI</string>
9
9
  <key>CFBundleDisplayName</key>
10
10
  <string>Agents CLI</string>
11
+ <key>CFBundleIconFile</key>
12
+ <string>AppIcon</string>
11
13
  <key>CFBundleExecutable</key>
12
14
  <string>Agents CLI</string>
13
15
  <key>CFBundlePackageType</key>
@@ -3,9 +3,21 @@
3
3
  <plist version="1.0">
4
4
  <dict>
5
5
  <key>files</key>
6
- <dict/>
6
+ <dict>
7
+ <key>Resources/AppIcon.icns</key>
8
+ <data>
9
+ DFq5H08EkhgWIC3UvGMR9B58BZw=
10
+ </data>
11
+ </dict>
7
12
  <key>files2</key>
8
13
  <dict>
14
+ <key>Resources/AppIcon.icns</key>
15
+ <dict>
16
+ <key>hash2</key>
17
+ <data>
18
+ mBSjM6jlvN7J1jQowsvqTl7CHM0pur0e6qsHMuzk5k0=
19
+ </data>
20
+ </dict>
9
21
  <key>embedded.provisionprofile</key>
10
22
  <dict>
11
23
  <key>hash2</key>
@@ -55,19 +55,7 @@ const keychainStore = {
55
55
  const fileItemStore = {
56
56
  has: (item) => fileStore.has(item),
57
57
  get: (item) => fileStore.get(item),
58
- getBatch: (items) => {
59
- const out = new Map();
60
- for (const item of items) {
61
- try {
62
- out.set(item, fileStore.get(item));
63
- }
64
- catch {
65
- // Missing/undecryptable item — absent from the map, mirroring
66
- // getKeychainTokens (caller decides whether that's an error).
67
- }
68
- }
69
- return out;
70
- },
58
+ getBatch: (items) => fileStore.getBatch(items),
71
59
  set: (item, value) => fileStore.set(item, value),
72
60
  setBatch: (items) => {
73
61
  for (const [item, value] of items) {
@@ -294,6 +282,26 @@ export function readBundle(name) {
294
282
  if (vaultExists() && !getVaultSession().loggedIn) {
295
283
  throw new Error(`Synced secrets are locked. Run: agents login`);
296
284
  }
285
+ // Distinguish a genuinely-absent bundle from a present-but-unreadable one
286
+ // (a locked login keychain, or a legacy ACL'd metadata item before first
287
+ // unlock). `has` counts an unreadable item as present, so a metadata item
288
+ // that exists but could not be read must not report as "not found" — an
289
+ // existence answer and a read answer may not contradict (RUSH-2253).
290
+ if (backend === 'keychain') {
291
+ let present;
292
+ try {
293
+ present = hasKeychainToken(bundleMetaItem(name));
294
+ }
295
+ catch (probeErr) {
296
+ // Keychain unreachable (RUSH-2235 fail-loud): neither absent nor
297
+ // add-the-key — surface the reachability failure, not a false absence.
298
+ throw new Error(`Secrets bundle '${name}': ${probeErr.message}`);
299
+ }
300
+ if (present) {
301
+ throw new Error(`Secrets bundle '${name}' is present but its metadata could not be read — the keychain is locked. ` +
302
+ `Unlock it (log in, or reboot then log in) and retry. (${err.message})`);
303
+ }
304
+ }
297
305
  throw new Error(`Secrets bundle '${name}' not found.`);
298
306
  }
299
307
  let parsed;
@@ -1060,6 +1068,91 @@ export function assertRemoteBundleFlagsUnsupported(bundleName, host, opts, flagL
1060
1068
  throw new Error(`Bundle '${bundleName}@${host}': ${flagLabels.keysFlag} and ${flagLabels.allowExpiredFlag} are not supported for remote (bundle@host) bundles yet. ` +
1061
1069
  `Drop the flag or resolve the bundle locally.`);
1062
1070
  }
1071
+ /**
1072
+ * A declared `keychain:` ref resolved to NO value in the batch read. Classify
1073
+ * genuinely-absent vs present-but-unreadable before choosing the error, so a
1074
+ * read can never contradict what `agents secrets view` reports (RUSH-2248,
1075
+ * RUSH-2253). `view`'s "stored" badge comes from `hasKeychainToken` — the exact
1076
+ * existence probe used here — which counts a biometry-ACL'd or locked-keychain
1077
+ * item (`errSecInteractionNotAllowed`) as present. So:
1078
+ *
1079
+ * - present ⇒ the item exists but this context could not read it (keychain
1080
+ * locked, or Touch ID not granted). Report HOW to unlock; NEVER
1081
+ * "add the key", whose remediation (`secrets add`) would overwrite a good
1082
+ * secret.
1083
+ * - absent ⇒ genuinely not stored on this machine — the honest "not found"
1084
+ * with the `secrets add` remediation.
1085
+ * - probe throws ⇒ the keychain itself is unreachable (RUSH-2235 fail-loud):
1086
+ * neither absent nor add-the-key — surface the reachability failure verbatim.
1087
+ *
1088
+ * Only the keychain backend has a locked/biometry state; a file/vault miss is
1089
+ * genuinely absent.
1090
+ */
1091
+ function missingBundleKeychainItemError(bundleName, key, item, backendKind) {
1092
+ if (backendKind === 'keychain') {
1093
+ let present;
1094
+ try {
1095
+ present = hasKeychainToken(item);
1096
+ }
1097
+ catch (err) {
1098
+ return new Error(`Bundle '${bundleName}' key '${key}': ${err.message}`);
1099
+ }
1100
+ if (present) {
1101
+ return new Error(`Bundle '${bundleName}' key '${key}': stored item '${item}' is present but could not be read — ` +
1102
+ `the keychain is locked or Touch ID was not granted for this read. ` +
1103
+ `Run: agents secrets unlock ${bundleName} (or read it once at an interactive terminal so Touch ID can be granted). ` +
1104
+ `Do NOT run 'agents secrets add' — the secret is already stored and adding would overwrite it.`);
1105
+ }
1106
+ }
1107
+ return new Error(`Bundle '${bundleName}' key '${key}': stored item '${item}' not found. ` +
1108
+ `Run: agents secrets add ${bundleName} ${key}`);
1109
+ }
1110
+ /**
1111
+ * Resolve every selected key of an already-read bundle into a flat env map,
1112
+ * given a pre-fetched keychain batch. The single per-key resolution loop shared
1113
+ * by `resolveBundleEnv` and `readAndResolveBundleEnv` so the keychain lookup and
1114
+ * the missing-item classification can never diverge again (RUSH-2252: the two
1115
+ * paths drifted — one did the hashed-alias fallback lookup and one did not, and
1116
+ * only one classified a missing item honestly).
1117
+ *
1118
+ * The keychain lookup tries the cleartext name first (Linux / file store), then
1119
+ * its hashed storage alias (macOS with #316 hashing active) — the batch keys its
1120
+ * results by the names it was ASKED for, which for the metadata + declared keys
1121
+ * is the cleartext form and for an enumerated leftover is the hashed form.
1122
+ */
1123
+ function assembleBundleEnv(bundle, selectedKeys, parsedByKey, fetched, keyMode, backendKind) {
1124
+ const env = {};
1125
+ const owners = new Map();
1126
+ for (const [key] of Object.entries(bundle.vars)) {
1127
+ if (!selectedKeys.has(key))
1128
+ continue;
1129
+ const parsed = parsedByKey.get(key);
1130
+ if ('literal' in parsed) {
1131
+ assignResolvedEnvValue(env, bundle, key, parsed.literal, keyMode, owners);
1132
+ continue;
1133
+ }
1134
+ if (parsed.ref.provider === 'keychain') {
1135
+ const item = secretsKeychainItem(bundle.name, parsed.ref.value);
1136
+ const value = fetched.get(item) ?? fetched.get(keychainServiceAlias(item));
1137
+ if (value === undefined) {
1138
+ throw missingBundleKeychainItemError(bundle.name, key, item, backendKind);
1139
+ }
1140
+ assignResolvedEnvValue(env, bundle, key, value, keyMode, owners);
1141
+ continue;
1142
+ }
1143
+ try {
1144
+ const value = resolveRef(parsed.ref, {
1145
+ allowExec: bundle.allow_exec,
1146
+ keychainItemFor: (shortId) => secretsKeychainItem(bundle.name, shortId),
1147
+ });
1148
+ assignResolvedEnvValue(env, bundle, key, value, keyMode, owners);
1149
+ }
1150
+ catch (err) {
1151
+ throw new Error(`Bundle '${bundle.name}' key '${key}': ${err.message}`);
1152
+ }
1153
+ }
1154
+ return env;
1155
+ }
1063
1156
  // Walk the bundle and produce a flat env map. Every keychain: ref is gathered
1064
1157
  // into a single batch read so macOS shows ONE Touch ID prompt for the whole
1065
1158
  // bundle — including the metadata fetch that already happened in readBundle
@@ -1093,37 +1186,7 @@ export function resolveBundleEnv(bundle, _opts = {}) {
1093
1186
  ? getKeychainTokens(keychainItemsToFetch, { silentNoAcl: bundlePolicy(bundle) === 'never' })
1094
1187
  : store.getBatch(keychainItemsToFetch)
1095
1188
  : 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
- }
1189
+ const env = assembleBundleEnv(bundle, selectedKeys, parsedByKey, fetched, _opts.keyMode, bundle.backend ?? 'keychain');
1127
1190
  // `caller` is intentionally unused; see ResolveBundleOptions.
1128
1191
  void _opts.caller;
1129
1192
  return env;
@@ -1265,13 +1328,6 @@ export function readAndResolveBundleEnv(name, opts = {}) {
1265
1328
  : store.getBatch([...new Set([metaItem, ...secretItems])]);
1266
1329
  const json = fetched.get(metaItem);
1267
1330
  if (json === undefined) {
1268
- // For a file-backed bundle the metadata item is on disk (that's how
1269
- // bundleBackend resolved to 'file'); a missing decrypt means the wrong
1270
- // passphrase, not a missing bundle. getBatch swallowed the decrypt error,
1271
- // so distinguish here rather than report a misleading "not found".
1272
- if (backend === 'file' && fileStore.has(metaItem)) {
1273
- throw new Error(`Bundle '${name}': failed to decrypt — wrong AGENTS_SECRETS_PASSPHRASE or tampered file store.`);
1274
- }
1275
1331
  if (vaultExists() && !getVaultSession().loggedIn) {
1276
1332
  throw new Error(`Synced secrets are locked. Run: agents login`);
1277
1333
  }
@@ -1327,6 +1383,53 @@ export function readAndResolveBundleEnv(name, opts = {}) {
1327
1383
  }
1328
1384
  const keys = [...selectedKeys].sort();
1329
1385
  keychainKeys.sort();
1386
+ // RUSH-2252: complete the read set from the bundle's DECLARED keys, not only
1387
+ // from the enumeration above. `store.list()` derives the batch by enumerating
1388
+ // the bundle's namespace, and that enumeration is lossy by construction — the
1389
+ // macOS helper's `list` omits every biometry-ACL'd item
1390
+ // (`kSecUseAuthenticationUISkip`) and skips the whole data-protection pass when
1391
+ // the keychain is locked, so a `hold`-policy bundle's value items never appear
1392
+ // and a present secret reads as "not found" (RUSH-2248). A declared key whose
1393
+ // item did not enumerate is therefore absent from `fetched`; read those exact
1394
+ // items directly — a point read DOES evaluate the ACL, so it triggers Touch ID
1395
+ // and returns the value the enumeration could not see. The enumeration still
1396
+ // earns its place (it catches hashed/aliased storage names and stale leftovers
1397
+ // not named in the metadata), so this is a UNION, not a replacement.
1398
+ //
1399
+ // One Touch ID sheet is preserved: the items missing from `fetched` are exactly
1400
+ // the ACL'd ones the enumeration dropped, so the first batch (metadata plus any
1401
+ // no-ACL / `never` items) raised no sheet, and this second batch raises the
1402
+ // single sheet that covers all of them. When the enumeration is healthy every
1403
+ // declared item is already in `fetched`, `missingDeclared` is empty, and this
1404
+ // is skipped entirely — zero behavior change and no extra spawn on the hot path.
1405
+ if (backend === 'keychain') {
1406
+ const missingDeclared = [];
1407
+ for (const key of keychainKeys) {
1408
+ const p = parsedByKey.get(key);
1409
+ if (!('ref' in p) || p.ref.provider !== 'keychain')
1410
+ continue;
1411
+ const item = secretsKeychainItem(bundle.name, p.ref.value);
1412
+ if (fetched.get(item) === undefined && fetched.get(keychainServiceAlias(item)) === undefined) {
1413
+ missingDeclared.push(item);
1414
+ }
1415
+ }
1416
+ if (missingDeclared.length > 0) {
1417
+ const declaredFetched = getKeychainTokens([...new Set(missingDeclared)], {
1418
+ agent: opts.agent || process.env.AGENTS_AGENT_NAME || 'Agents CLI',
1419
+ bundle: name,
1420
+ sessionId: process.env.AGENT_SESSION_ID || process.env.AGENTS_SESSION_ID,
1421
+ reason: opts.caller ? `to ${opts.caller}` : reason,
1422
+ duration: opts.duration || humanUnlockDuration(secretsHoldMs()),
1423
+ // The policy is known now (metadata parsed), so the prompt names the real
1424
+ // duration instead of the pre-read default the first batch had to guess.
1425
+ defaultPolicy: bundlePolicy(bundle),
1426
+ forceDuration: Boolean(opts.duration),
1427
+ silentNoAcl: verifiedNoAclBundle,
1428
+ });
1429
+ for (const [k, v] of declaredFetched)
1430
+ fetched.set(k, v);
1431
+ }
1432
+ }
1330
1433
  const emitReadAudit = (status, err) => {
1331
1434
  emitSecretAudit({
1332
1435
  event: 'secrets.get',
@@ -1342,40 +1445,10 @@ export function readAndResolveBundleEnv(name, opts = {}) {
1342
1445
  });
1343
1446
  };
1344
1447
  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
- }
1448
+ // Shared per-key resolver: same keychain lookup (cleartext name, then hashed
1449
+ // storage alias) and same missing-item classification as resolveBundleEnv, so
1450
+ // the two paths can never diverge again (RUSH-2252, RUSH-2253).
1451
+ const env = assembleBundleEnv(bundle, selectedKeys, parsedByKey, fetched, opts.keyMode, backend);
1379
1452
  emitReadAudit('success');
1380
1453
  // Auto-cache: this was a real keychain read (the agent fast-path returned
1381
1454
  // 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
  */
@@ -51,6 +53,7 @@ export declare function encryptForFallback(plaintext: string, passphrase: string
51
53
  export declare function decryptForFallback(enc: EncFile, passphrase: string): string;
52
54
  declare function fileHas(item: string): boolean;
53
55
  declare function fileGet(item: string): string;
56
+ declare function fileGetBatch(items: string[]): Map<string, string>;
54
57
  declare function fileSet(item: string, value: string): void;
55
58
  declare function fileDelete(item: string): boolean;
56
59
  declare function fileList(prefix: string): string[];
@@ -61,6 +64,7 @@ export declare function fileStoreHasItems(): boolean;
61
64
  export declare const fileStore: {
62
65
  has: typeof fileHas;
63
66
  get: typeof fileGet;
67
+ getBatch: typeof fileGetBatch;
64
68
  set: typeof fileSet;
65
69
  delete: typeof fileDelete;
66
70
  list: typeof fileList;
@@ -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
  */
@@ -233,6 +237,18 @@ function fileGet(item) {
233
237
  throw new Error(`Failed to decrypt '${item}'. Wrong AGENTS_SECRETS_PASSPHRASE or tampered file.`);
234
238
  }
235
239
  }
240
+ function fileGetBatch(items) {
241
+ const out = new Map();
242
+ for (const item of items) {
243
+ // A missing file is an absent item. Any error reading an existing file,
244
+ // especially an AES-GCM authentication failure, is a broken store and must
245
+ // stop the caller rather than silently produce an incomplete environment.
246
+ if (!fileHas(item))
247
+ continue;
248
+ out.set(item, fileGet(item));
249
+ }
250
+ return out;
251
+ }
236
252
  function fileSet(item, value) {
237
253
  ensureFileDir();
238
254
  // Under the store lock: a write must not interleave with a rotation's swap.
@@ -279,6 +295,7 @@ export function fileStoreHasItems() {
279
295
  export const fileStore = {
280
296
  has: fileHas,
281
297
  get: fileGet,
298
+ getBatch: fileGetBatch,
282
299
  set: fileSet,
283
300
  delete: fileDelete,
284
301
  list: fileList,
@@ -781,9 +781,23 @@ export function hasKeychainToken(item) {
781
781
  }, KEYCHAIN_SILENT_TIMEOUT_MS).status === 0;
782
782
  }
783
783
  const bin = getKeychainHelperPath();
784
- return spawnKeychainHelper(bin, ['has', item, os.userInfo().username], {
784
+ const r = spawnKeychainHelper(bin, ['has', item, os.userInfo().username], {
785
785
  stdio: ['ignore', 'pipe', 'pipe'],
786
- }, KEYCHAIN_SILENT_TIMEOUT_MS).status === 0;
786
+ }, KEYCHAIN_SILENT_TIMEOUT_MS);
787
+ // The helper exits 0 = present (incl. errSecInteractionNotAllowed — a locked or
788
+ // biometry-ACL'd item still EXISTS), 1 = genuinely absent. Any other outcome
789
+ // (bad args, helper failure, spawn error) means the keychain could not be
790
+ // reached — and a false "absent" silently disarms every destructive-write guard
791
+ // that calls this (see the docblock), so it MUST fail loud rather than answer
792
+ // "no" (RUSH-2235). Timeouts already throw inside spawnKeychainHelper.
793
+ if (r.status === 0)
794
+ return true;
795
+ if (r.status === 1)
796
+ return false;
797
+ const stderr = r.stderr?.toString().trim();
798
+ throw new Error(stderr ||
799
+ `keychain existence check for '${item}' failed (helper exit ${r.status ?? 'null'}` +
800
+ `${r.error ? `: ${r.error.message}` : ''}) — keychain unreachable, not a proven absence.`);
787
801
  }
788
802
  /**
789
803
  * The detector the raw-read storm guard consults, overridable so tests on any
@@ -1175,14 +1189,26 @@ export function deleteKeychainToken(item) {
1175
1189
  if (isWindows())
1176
1190
  return windowsBackend.delete(item);
1177
1191
  const bin = getKeychainHelperPath();
1178
- const deleted = spawnKeychainHelper(bin, ['delete', item, os.userInfo().username], {
1192
+ const r = spawnKeychainHelper(bin, ['delete', item, os.userInfo().username], {
1179
1193
  stdio: ['ignore', 'pipe', 'pipe'],
1180
- }, KEYCHAIN_SILENT_TIMEOUT_MS).status === 0;
1181
- // A deleted item must fail its next read as plain "not found", not with a
1182
- // stale back-off error left over from a pre-delete cancel.
1183
- if (deleted)
1194
+ }, KEYCHAIN_SILENT_TIMEOUT_MS);
1195
+ // The helper exits 0 = an item was removed from at least one keychain, 1 =
1196
+ // nothing to delete (genuinely absent). Any other outcome means the keychain
1197
+ // could not be reached; like hasKeychainToken this must fail loud rather than
1198
+ // report a false "nothing was there" (RUSH-2235) — a swallowed failure lets a
1199
+ // rename/purge believe it cleared a name it did not.
1200
+ if (r.status === 0) {
1201
+ // A deleted item must fail its next read as plain "not found", not with a
1202
+ // stale back-off error left over from a pre-delete cancel.
1184
1203
  clearKeychainReadBackoff(requested);
1185
- return deleted;
1204
+ return true;
1205
+ }
1206
+ if (r.status === 1)
1207
+ return false;
1208
+ const stderr = r.stderr?.toString().trim();
1209
+ throw new Error(stderr ||
1210
+ `keychain delete for '${item}' failed (helper exit ${r.status ?? 'null'}` +
1211
+ `${r.error ? `: ${r.error.message}` : ''}) — keychain unreachable, deletion unproven.`);
1186
1212
  }
1187
1213
  /**
1188
1214
  * True when the active keychain backend transparently routes reads/writes to
@@ -32,6 +32,20 @@ export interface RcSecretFinding {
32
32
  /** The file-store master passphrase gets called out separately — it is the highest-severity case. */
33
33
  isMasterPassphrase: boolean;
34
34
  }
35
+ /** The file-store master key. Its own resolution prefers an off-env 0600 file, so
36
+ * a shell-rc export is always wrong once the store exists (RUSH-1968). */
37
+ export declare const MASTER_PASSPHRASE = "AGENTS_SECRETS_PASSPHRASE";
38
+ /**
39
+ * True when the file-store master key is live in THIS process's environment.
40
+ *
41
+ * The scanner above reads FILES, and that leaves a hole: a value inherited by a
42
+ * long-lived process outlives the rc line that set it, so deleting the export
43
+ * makes `scanRcExports` report clean while every shell, editor and agent started
44
+ * beforehand still carries the key and passes it to everything it spawns.
45
+ * Returns a boolean — never the value — so a finding built from it can be
46
+ * printed, logged, or shipped without leaking the secret.
47
+ */
48
+ export declare function masterPassphraseInEnv(): boolean;
35
49
  /** True if a variable name looks like it holds a credential value. */
36
50
  export declare function isCredentialName(name: string): boolean;
37
51
  /**
@@ -33,7 +33,20 @@ export const RC_FILENAMES = [
33
33
  ];
34
34
  /** The file-store master key. Its own resolution prefers an off-env 0600 file, so
35
35
  * a shell-rc export is always wrong once the store exists (RUSH-1968). */
36
- const MASTER_PASSPHRASE = 'AGENTS_SECRETS_PASSPHRASE';
36
+ export const MASTER_PASSPHRASE = 'AGENTS_SECRETS_PASSPHRASE';
37
+ /**
38
+ * True when the file-store master key is live in THIS process's environment.
39
+ *
40
+ * The scanner above reads FILES, and that leaves a hole: a value inherited by a
41
+ * long-lived process outlives the rc line that set it, so deleting the export
42
+ * makes `scanRcExports` report clean while every shell, editor and agent started
43
+ * beforehand still carries the key and passes it to everything it spawns.
44
+ * Returns a boolean — never the value — so a finding built from it can be
45
+ * printed, logged, or shipped without leaking the secret.
46
+ */
47
+ export function masterPassphraseInEnv() {
48
+ return (process.env[MASTER_PASSPHRASE] ?? '').length > 0;
49
+ }
37
50
  /**
38
51
  * Last `_`-delimited segment values that mark a variable as credential-shaped.
39
52
  * Matched against the FINAL segment (segment equality, not substring) so
@@ -68,6 +68,16 @@ export interface ReapPlan {
68
68
  * not match the helper, or whose parent is no longer in the snapshot.
69
69
  */
70
70
  export declare function planKeychainReap(snapshots: KeychainProcessSnapshot[], now: number, prevCandidates: ReadonlyMap<number, StuckParentCandidate>): ReapPlan;
71
+ /**
72
+ * Parse macOS `ps -o etime=` elapsed time into whole seconds.
73
+ *
74
+ * BSD `etime` renders as `[[dd-]hh:]mm:ss` (e.g. `05:03`, `01:02:03`,
75
+ * `14-04:10:52`). This is the portable keyword: `etimes` (raw seconds) is a
76
+ * GNU/Linux procps extension that macOS `ps` rejects with a non-zero exit, so
77
+ * the reaper — which only ever runs on darwin — must read `etime`.
78
+ * Returns null for an unparseable value so the caller drops the row.
79
+ */
80
+ export declare function parseEtimeToSeconds(raw: string): number | null;
71
81
  /** Test seam: reset the persisted candidate state. */
72
82
  export declare function resetKeychainReaperCandidatesForTest(): void;
73
83
  /**