@phnx-labs/agents-cli 1.22.23 → 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 (88) hide show
  1. package/CHANGELOG.md +193 -0
  2. package/README.md +5 -4
  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 +5 -0
  8. package/dist/commands/exec.js +80 -28
  9. package/dist/commands/feed.d.ts +18 -0
  10. package/dist/commands/feed.js +44 -1
  11. package/dist/commands/focus.d.ts +34 -3
  12. package/dist/commands/focus.js +300 -56
  13. package/dist/commands/go.d.ts +14 -1
  14. package/dist/commands/go.js +49 -5
  15. package/dist/commands/insights.js +6 -2
  16. package/dist/commands/inspect.js +39 -5
  17. package/dist/commands/menubar.js +6 -1
  18. package/dist/commands/models.js +1 -0
  19. package/dist/commands/modes.d.ts +12 -0
  20. package/dist/commands/modes.js +147 -0
  21. package/dist/commands/secrets-sync.js +11 -13
  22. package/dist/commands/secrets.d.ts +2 -0
  23. package/dist/commands/secrets.js +54 -17
  24. package/dist/commands/sessions-browser.d.ts +35 -0
  25. package/dist/commands/sessions-browser.js +140 -14
  26. package/dist/commands/sessions-resume.d.ts +2 -0
  27. package/dist/commands/sessions-resume.js +26 -11
  28. package/dist/commands/sessions.d.ts +10 -0
  29. package/dist/commands/sessions.js +34 -59
  30. package/dist/commands/setup-secrets.js +1 -1
  31. package/dist/commands/sync.js +246 -42
  32. package/dist/commands/view.js +2 -0
  33. package/dist/index.js +2 -1
  34. package/dist/lib/agent-modes.d.ts +49 -0
  35. package/dist/lib/agent-modes.js +70 -0
  36. package/dist/lib/doctor-diff.d.ts +3 -0
  37. package/dist/lib/doctor-diff.js +15 -13
  38. package/dist/lib/event-stream.d.ts +3 -1
  39. package/dist/lib/event-stream.js +14 -1
  40. package/dist/lib/exec.d.ts +12 -0
  41. package/dist/lib/exec.js +88 -9
  42. package/dist/lib/hooks/cache.js +36 -3
  43. package/dist/lib/hooks.d.ts +19 -7
  44. package/dist/lib/hooks.js +88 -38
  45. package/dist/lib/hosts/session-index.d.ts +4 -0
  46. package/dist/lib/hosts/session-index.js +7 -0
  47. package/dist/lib/manifest.d.ts +12 -2
  48. package/dist/lib/manifest.js +60 -5
  49. package/dist/lib/menubar/MenubarHelper.app/Contents/CodeResources +0 -0
  50. package/dist/lib/menubar/MenubarHelper.app/Contents/Info.plist +5 -1
  51. package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
  52. package/dist/lib/menubar/MenubarHelper.app/Contents/Resources/AppIcon.icns +0 -0
  53. package/dist/lib/menubar/MenubarHelper.app/Contents/_CodeSignature/CodeResources +15 -2
  54. package/dist/lib/menubar/install-menubar.d.ts +52 -2
  55. package/dist/lib/menubar/install-menubar.js +128 -6
  56. package/dist/lib/refresh.d.ts +5 -0
  57. package/dist/lib/refresh.js +37 -33
  58. package/dist/lib/resource-inventory.d.ts +79 -0
  59. package/dist/lib/resource-inventory.js +122 -0
  60. package/dist/lib/resources.js +8 -5
  61. package/dist/lib/run-defaults.d.ts +2 -0
  62. package/dist/lib/run-defaults.js +23 -2
  63. package/dist/lib/runner.js +22 -17
  64. package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
  65. package/dist/lib/secrets/Agents CLI.app/Contents/Info.plist +2 -0
  66. package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
  67. package/dist/lib/secrets/Agents CLI.app/Contents/Resources/AppIcon.icns +0 -0
  68. package/dist/lib/secrets/Agents CLI.app/Contents/_CodeSignature/CodeResources +13 -1
  69. package/dist/lib/secrets/bundles.js +157 -65
  70. package/dist/lib/secrets/filestore.d.ts +5 -3
  71. package/dist/lib/secrets/filestore.js +12 -8
  72. package/dist/lib/secrets/index.js +34 -8
  73. package/dist/lib/secrets/reaper.d.ts +10 -0
  74. package/dist/lib/secrets/reaper.js +50 -15
  75. package/dist/lib/secrets/sync-passphrase.d.ts +27 -0
  76. package/dist/lib/secrets/sync-passphrase.js +78 -0
  77. package/dist/lib/session/recovery.d.ts +37 -0
  78. package/dist/lib/session/recovery.js +95 -0
  79. package/dist/lib/shims.d.ts +2 -2
  80. package/dist/lib/shims.js +24 -5
  81. package/dist/lib/startup/command-registry.d.ts +1 -0
  82. package/dist/lib/startup/command-registry.js +2 -0
  83. package/dist/lib/sync-umbrella.d.ts +5 -0
  84. package/dist/lib/sync-umbrella.js +5 -4
  85. package/dist/lib/tmux/session.d.ts +4 -2
  86. package/dist/lib/tmux/session.js +5 -5
  87. package/dist/lib/types.d.ts +2 -0
  88. package/package.json +1 -1
@@ -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
  */
@@ -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
@@ -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
  /**
@@ -90,25 +90,47 @@ export function planKeychainReap(snapshots, now, prevCandidates) {
90
90
  }
91
91
  return { kill, nextCandidates };
92
92
  }
93
+ /**
94
+ * Parse macOS `ps -o etime=` elapsed time into whole seconds.
95
+ *
96
+ * BSD `etime` renders as `[[dd-]hh:]mm:ss` (e.g. `05:03`, `01:02:03`,
97
+ * `14-04:10:52`). This is the portable keyword: `etimes` (raw seconds) is a
98
+ * GNU/Linux procps extension that macOS `ps` rejects with a non-zero exit, so
99
+ * the reaper — which only ever runs on darwin — must read `etime`.
100
+ * Returns null for an unparseable value so the caller drops the row.
101
+ */
102
+ export function parseEtimeToSeconds(raw) {
103
+ const m = raw.match(/^(?:(\d+)-)?(?:(\d+):)?(\d+):(\d+)$/);
104
+ if (!m)
105
+ return null;
106
+ const days = m[1] ? parseInt(m[1], 10) : 0;
107
+ const hours = m[2] ? parseInt(m[2], 10) : 0;
108
+ const mins = parseInt(m[3], 10);
109
+ const secs = parseInt(m[4], 10);
110
+ if ([days, hours, mins, secs].some(isNaN))
111
+ return null;
112
+ return ((days * 24 + hours) * 60 + mins) * 60 + secs;
113
+ }
93
114
  /**
94
115
  * Parse one `ps` output line.
95
116
  *
96
- * Expected format from `ps -ax -o pid=,ppid=,etimes=,command=`:
97
- * "<pid> <ppid> <etimes> <command...>"
98
- * The command field is the remainder of the line and may contain spaces.
117
+ * Expected format from `ps -ax -o pid=,ppid=,etime=,command=`:
118
+ * "<pid> <ppid> <etime> <command...>"
119
+ * where `<etime>` is BSD elapsed time (`[[dd-]hh:]mm:ss`). The command field is
120
+ * the remainder of the line and may contain spaces.
99
121
  */
100
122
  function parsePsLine(line) {
101
123
  const trimmed = line.trim();
102
124
  if (!trimmed)
103
125
  return null;
104
- const m = trimmed.match(/^(\d+)\s+(\d+)\s+(\d+)\s+(.*)$/);
126
+ const m = trimmed.match(/^(\d+)\s+(\d+)\s+(\S+)\s+(.*)$/);
105
127
  if (!m)
106
128
  return null;
107
129
  const pid = parseInt(m[1], 10);
108
130
  const ppid = parseInt(m[2], 10);
109
- const elapsedSec = parseInt(m[3], 10);
131
+ const elapsedSec = parseEtimeToSeconds(m[3]);
110
132
  const command = m[4];
111
- if (isNaN(pid) || isNaN(ppid) || isNaN(elapsedSec))
133
+ if (isNaN(pid) || isNaN(ppid) || elapsedSec == null)
112
134
  return null;
113
135
  return { pid, ppid, elapsedSec, command };
114
136
  }
@@ -142,7 +164,7 @@ export function reapOrphanedKeychainProcesses() {
142
164
  }
143
165
  let out;
144
166
  try {
145
- out = execFileSync('ps', ['-ax', '-o', 'pid=,ppid=,etimes=,command='], {
167
+ out = execFileSync('ps', ['-ax', '-o', 'pid=,ppid=,etime=,command='], {
146
168
  encoding: 'utf-8',
147
169
  stdio: ['ignore', 'pipe', 'ignore'],
148
170
  });
@@ -150,7 +172,7 @@ export function reapOrphanedKeychainProcesses() {
150
172
  catch (err) {
151
173
  return { reaped: 0, details: [`ps failed: ${err.message}`], plan: { kill: [], nextCandidates: new Map() } };
152
174
  }
153
- const snapshots = [];
175
+ const rows = [];
154
176
  for (const line of out.split('\n')) {
155
177
  const parsed = parsePsLine(line);
156
178
  if (!parsed)
@@ -159,14 +181,27 @@ export function reapOrphanedKeychainProcesses() {
159
181
  // Exact path-match: the helper invocation's command line begins with the
160
182
  // absolute helper path, followed by a space and its arguments (or nothing).
161
183
  const isHelper = command === helperPath || command.startsWith(`${helperPath} `);
162
- snapshots.push({
163
- pid,
164
- ppid,
165
- elapsedSec,
166
- startTime: captureProcessStartTime(pid),
167
- isHelper,
168
- });
184
+ rows.push({ pid, ppid, elapsedSec, isHelper, startTime: null });
185
+ }
186
+ const rowByPid = new Map(rows.map((r) => [r.pid, r]));
187
+ for (const row of rows) {
188
+ if (!row.isHelper)
189
+ continue;
190
+ row.startTime = captureProcessStartTime(row.pid);
191
+ if (row.ppid !== 1 && row.elapsedSec > STUCK_GRACE_SEC) {
192
+ const parent = rowByPid.get(row.ppid);
193
+ if (parent && parent.startTime === null) {
194
+ parent.startTime = captureProcessStartTime(row.ppid);
195
+ }
196
+ }
169
197
  }
198
+ const snapshots = rows.map((r) => ({
199
+ pid: r.pid,
200
+ ppid: r.ppid,
201
+ elapsedSec: r.elapsedSec,
202
+ startTime: r.startTime,
203
+ isHelper: r.isHelper,
204
+ }));
170
205
  const plan = planKeychainReap(snapshots, Date.now(), stuckParentCandidates);
171
206
  stuckParentCandidates = plan.nextCandidates;
172
207
  for (const pid of plan.kill) {
@@ -0,0 +1,27 @@
1
+ /** The current variable for transport/sync passphrases. */
2
+ export declare const SYNC_PASSPHRASE_ENV = "AGENTS_SYNC_PASSPHRASE";
3
+ /** The file-store master key, honoured for sync only as a deprecated fallback. */
4
+ export declare const LEGACY_PASSPHRASE_ENV = "AGENTS_SECRETS_PASSPHRASE";
5
+ /** Reset the one-shot warning latches. Tests only — production never re-warns. */
6
+ export declare function resetSyncPassphraseWarnings(): void;
7
+ /** Where a resolved sync passphrase came from, so callers can report honestly. */
8
+ export type SyncPassphraseSource = 'sync-env' | 'legacy-env' | null;
9
+ export interface ResolvedSyncPassphrase {
10
+ value: string | null;
11
+ source: SyncPassphraseSource;
12
+ }
13
+ /**
14
+ * Read the transport passphrase from the environment, preferring the current
15
+ * variable and falling back to the deprecated one with a single warning.
16
+ * Returns `{ value: null }` when neither is set — the caller decides whether to
17
+ * prompt (TTY) or fail (headless). Never prompts, never throws.
18
+ */
19
+ export declare function resolveSyncPassphraseFromEnv(): ResolvedSyncPassphrase;
20
+ /**
21
+ * The one-shot reminder that an env-sourced passphrase is readable by other
22
+ * same-user processes. Separate from the deprecation notice above so a caller
23
+ * using the CURRENT variable still gets the readability warning exactly once.
24
+ */
25
+ export declare function warnEnvPassphraseReadableOnce(): void;
26
+ /** The message a headless caller shows when no passphrase is available. */
27
+ export declare function missingSyncPassphraseMessage(): string;
@@ -0,0 +1,78 @@
1
+ /**
2
+ * The passphrase that seals a bundle for TRANSPORT — `secrets push`/`pull` and
3
+ * the portable `export --to-file` / `import --from-file` envelope.
4
+ *
5
+ * This is deliberately a DIFFERENT secret from the file store's master key
6
+ * (`AGENTS_SECRETS_PASSPHRASE`, `filestore.ts:getPassphrase`). The two were one
7
+ * variable, and that overload is what put a master key into `~/.zshenv` on seven
8
+ * worker boxes (RUSH-1968): the file store stopped needing a passphrase once it
9
+ * auto-provisioned a machine-local key, but headless `push`/`pull` still hard-
10
+ * failed without one, so an operator exported the master key fleet-wide to make
11
+ * sync work — handing every same-user process the key to the whole store.
12
+ *
13
+ * Splitting them means a box that only needs headless sync sets
14
+ * `AGENTS_SYNC_PASSPHRASE` and never has the store's master key in its
15
+ * environment at all.
16
+ *
17
+ * Resolution order:
18
+ * 1. `AGENTS_SYNC_PASSPHRASE` — the current name.
19
+ * 2. `AGENTS_SECRETS_PASSPHRASE` — deprecated fallback, warns once.
20
+ * 3. (caller prompts, or fails)
21
+ *
22
+ * The fallback exists so already-scripted CI and release automation keep working
23
+ * across the upgrade; it is not a second supported spelling.
24
+ */
25
+ import chalk from 'chalk';
26
+ /** The current variable for transport/sync passphrases. */
27
+ export const SYNC_PASSPHRASE_ENV = 'AGENTS_SYNC_PASSPHRASE';
28
+ /** The file-store master key, honoured for sync only as a deprecated fallback. */
29
+ export const LEGACY_PASSPHRASE_ENV = 'AGENTS_SECRETS_PASSPHRASE';
30
+ /** Warn at most once per process: a `--all` push over many bundles must not
31
+ * flood stderr with the same notice. */
32
+ let deprecatedVarWarned = false;
33
+ /** Same, for the "this came from an env var at all" readability notice. */
34
+ let envPassphraseWarned = false;
35
+ /** Reset the one-shot warning latches. Tests only — production never re-warns. */
36
+ export function resetSyncPassphraseWarnings() {
37
+ deprecatedVarWarned = false;
38
+ envPassphraseWarned = false;
39
+ }
40
+ /**
41
+ * Read the transport passphrase from the environment, preferring the current
42
+ * variable and falling back to the deprecated one with a single warning.
43
+ * Returns `{ value: null }` when neither is set — the caller decides whether to
44
+ * prompt (TTY) or fail (headless). Never prompts, never throws.
45
+ */
46
+ export function resolveSyncPassphraseFromEnv() {
47
+ const current = process.env[SYNC_PASSPHRASE_ENV];
48
+ if (current)
49
+ return { value: current, source: 'sync-env' };
50
+ const legacy = process.env[LEGACY_PASSPHRASE_ENV];
51
+ if (legacy) {
52
+ if (!deprecatedVarWarned) {
53
+ deprecatedVarWarned = true;
54
+ process.stderr.write(chalk.yellow(`warn: ${LEGACY_PASSPHRASE_ENV} is deprecated for sync — it is the file store's master key, ` +
55
+ `not a transport passphrase. Set ${SYNC_PASSPHRASE_ENV} instead; the old name still works ` +
56
+ 'for now. Keeping the master key in the environment exposes the whole store to every ' +
57
+ 'same-user process (RUSH-1968).\n'));
58
+ }
59
+ return { value: legacy, source: 'legacy-env' };
60
+ }
61
+ return { value: null, source: null };
62
+ }
63
+ /**
64
+ * The one-shot reminder that an env-sourced passphrase is readable by other
65
+ * same-user processes. Separate from the deprecation notice above so a caller
66
+ * using the CURRENT variable still gets the readability warning exactly once.
67
+ */
68
+ export function warnEnvPassphraseReadableOnce() {
69
+ if (envPassphraseWarned)
70
+ return;
71
+ envPassphraseWarned = true;
72
+ process.stderr.write(chalk.yellow('warn: using a sync passphrase from the environment. Env vars are readable by other ' +
73
+ 'same-user processes (/proc, ps, crash dumps, CI logs) — rotate after CI use.\n'));
74
+ }
75
+ /** The message a headless caller shows when no passphrase is available. */
76
+ export function missingSyncPassphraseMessage() {
77
+ return `A sync passphrase is required. Run from a TTY, or set ${SYNC_PASSPHRASE_ENV}.`;
78
+ }
@@ -0,0 +1,37 @@
1
+ import { type RotateCandidate } from '../rotate.js';
2
+ import type { AgentId } from '../types.js';
3
+ import type { SessionMeta } from './types.js';
4
+ export type SessionRecoveryTarget = {
5
+ mode: 'native';
6
+ agent: AgentId;
7
+ version: string;
8
+ reason: string;
9
+ } | {
10
+ mode: 'continue';
11
+ agent: AgentId;
12
+ version: string;
13
+ reason: string;
14
+ };
15
+ export declare class SessionRecoveryError extends Error {
16
+ constructor(message: string);
17
+ }
18
+ /** Canonical origin-device label for every recovery consumer. */
19
+ export declare function sessionOriginDevice(session: Pick<SessionMeta, 'machine'>, self?: string): string;
20
+ /** The peer that must execute recovery, or undefined when this is the origin. */
21
+ export declare function sessionRecoveryPeer(session: Pick<SessionMeta, 'machine'>, selfCheck?: (host: string) => boolean): string | undefined;
22
+ /** Whether an explicit placement names the session's origin device. */
23
+ export declare function sessionRecoveryDestinationMatches(session: Pick<SessionMeta, 'machine'>, requestedHost: string, self?: string): boolean;
24
+ /**
25
+ * Decide how a durable session resumes on the device that owns it.
26
+ *
27
+ * Native resume is legal only in the exact origin version's isolated home and
28
+ * only while that account is healthy. Every other successful path stays on the
29
+ * same harness and uses `/continue`, whose indexed transcript reader can reach
30
+ * retained version trash. No healthy same-harness account is a loud failure.
31
+ */
32
+ export declare function resolveSessionRecoveryFromCandidates(session: SessionMeta, candidates: RotateCandidate[], supportsNative?: (agent: AgentId, version?: string) => boolean): SessionRecoveryTarget;
33
+ export declare function resolveSessionRecovery(session: SessionMeta): Promise<SessionRecoveryTarget>;
34
+ /** Stable self-command used by focus, resume, and attach. The owning device runs
35
+ * the recovery resolver above; callers must not native-resume another version's
36
+ * isolated home themselves. */
37
+ export declare function sessionRecoveryRunArgs(session: Pick<SessionMeta, 'id'>): string[];