@phnx-labs/agents-cli 1.22.22 → 1.22.23

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 (91) hide show
  1. package/CHANGELOG.md +209 -0
  2. package/README.md +8 -2
  3. package/dist/commands/doctor.js +15 -7
  4. package/dist/commands/exec.js +20 -6
  5. package/dist/commands/focus.d.ts +76 -4
  6. package/dist/commands/focus.js +219 -40
  7. package/dist/commands/fork.d.ts +20 -2
  8. package/dist/commands/fork.js +91 -64
  9. package/dist/commands/go.d.ts +25 -0
  10. package/dist/commands/go.js +63 -2
  11. package/dist/commands/harness-wizard.d.ts +206 -0
  12. package/dist/commands/harness-wizard.js +403 -0
  13. package/dist/commands/harness.d.ts +12 -0
  14. package/dist/commands/harness.js +97 -101
  15. package/dist/commands/resume.js +10 -3
  16. package/dist/commands/secrets.js +25 -30
  17. package/dist/commands/sessions-resume.d.ts +21 -3
  18. package/dist/commands/sessions-resume.js +50 -11
  19. package/dist/commands/sessions.d.ts +81 -5
  20. package/dist/commands/sessions.js +325 -66
  21. package/dist/commands/watchdog.js +13 -2
  22. package/dist/lib/agents.d.ts +1 -1
  23. package/dist/lib/agents.js +132 -0
  24. package/dist/lib/codex-policy.d.ts +17 -0
  25. package/dist/lib/codex-policy.js +48 -0
  26. package/dist/lib/crabbox/lease.d.ts +25 -0
  27. package/dist/lib/crabbox/lease.js +62 -0
  28. package/dist/lib/daemon.js +70 -0
  29. package/dist/lib/exec.d.ts +4 -0
  30. package/dist/lib/exec.js +88 -54
  31. package/dist/lib/feed-broadcast.d.ts +1 -20
  32. package/dist/lib/feed-broadcast.js +31 -1
  33. package/dist/lib/hooks.js +12 -2
  34. package/dist/lib/mcp.js +44 -0
  35. package/dist/lib/menubar/MenubarHelper.app/Contents/Info.plist +1 -5
  36. package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
  37. package/dist/lib/menubar/MenubarHelper.app/Contents/_CodeSignature/CodeResources +2 -15
  38. package/dist/lib/models.d.ts +0 -5
  39. package/dist/lib/models.js +48 -0
  40. package/dist/lib/plugin-marketplace.js +9 -0
  41. package/dist/lib/pricing/prices.json +119 -92
  42. package/dist/lib/pricing/table.js +13 -0
  43. package/dist/lib/remote-agents-json.d.ts +29 -1
  44. package/dist/lib/remote-agents-json.js +47 -10
  45. package/dist/lib/resources/mcp.js +2 -0
  46. package/dist/lib/resources/permissions.js +3 -0
  47. package/dist/lib/resources/types.d.ts +2 -1
  48. package/dist/lib/runner.js +28 -19
  49. package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
  50. package/dist/lib/secrets/Agents CLI.app/Contents/Info.plist +0 -2
  51. package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
  52. package/dist/lib/secrets/Agents CLI.app/Contents/_CodeSignature/CodeResources +1 -13
  53. package/dist/lib/secrets/index.d.ts +15 -1
  54. package/dist/lib/secrets/index.js +118 -49
  55. package/dist/lib/secrets/reaper.d.ts +87 -0
  56. package/dist/lib/secrets/reaper.js +184 -0
  57. package/dist/lib/secrets/remote.d.ts +29 -0
  58. package/dist/lib/secrets/remote.js +37 -1
  59. package/dist/lib/session/active.d.ts +36 -1
  60. package/dist/lib/session/active.js +60 -19
  61. package/dist/lib/session/actor-sidecar.d.ts +14 -0
  62. package/dist/lib/session/actor-sidecar.js +67 -4
  63. package/dist/lib/session/db.d.ts +1 -1
  64. package/dist/lib/session/db.js +32 -1
  65. package/dist/lib/session/discover.js +168 -0
  66. package/dist/lib/session/parse.d.ts +10 -0
  67. package/dist/lib/session/parse.js +98 -0
  68. package/dist/lib/session/remote-list.d.ts +10 -1
  69. package/dist/lib/session/remote-list.js +2 -8
  70. package/dist/lib/session/remote.d.ts +57 -3
  71. package/dist/lib/session/remote.js +90 -26
  72. package/dist/lib/session/resume-command.d.ts +6 -0
  73. package/dist/lib/session/resume-command.js +8 -0
  74. package/dist/lib/session/session-cache.d.ts +173 -0
  75. package/dist/lib/session/session-cache.js +399 -0
  76. package/dist/lib/session/types.d.ts +1 -1
  77. package/dist/lib/session/types.js +1 -1
  78. package/dist/lib/session/width.d.ts +1 -1
  79. package/dist/lib/session/width.js +12 -2
  80. package/dist/lib/shims.d.ts +2 -2
  81. package/dist/lib/shims.js +40 -5
  82. package/dist/lib/staleness/writers/hooks.js +1 -1
  83. package/dist/lib/types.d.ts +1 -1
  84. package/dist/lib/usage.d.ts +13 -0
  85. package/dist/lib/usage.js +215 -0
  86. package/dist/lib/versions.js +13 -2
  87. package/package.json +1 -1
  88. package/dist/bin/agents +0 -0
  89. package/dist/lib/menubar/MenubarHelper.app/Contents/CodeResources +0 -0
  90. package/dist/lib/menubar/MenubarHelper.app/Contents/Resources/AppIcon.icns +0 -0
  91. package/dist/lib/secrets/Agents CLI.app/Contents/Resources/AppIcon.icns +0 -0
@@ -36,6 +36,57 @@ import { deriveShortId } from '../session/short-id.js';
36
36
  const SERVICE_PREFIX = 'agents-cli';
37
37
  export const SECRETS_ITEM_PREFIX = `${SERVICE_PREFIX}.secrets.`;
38
38
  const BUNDLES_ITEM_PREFIX = `${SERVICE_PREFIX}.bundles.`;
39
+ /** Timeout for keychain-helper verbs that never raise a user prompt. */
40
+ const KEYCHAIN_SILENT_TIMEOUT_MS = 8_000;
41
+ /** Timeout for verbs that may raise Touch ID / password auth UI. */
42
+ const KEYCHAIN_INTERACTIVE_TIMEOUT_MS = 60_000;
43
+ /**
44
+ * Thrown when a keychain helper / security spawnSync is killed because it
45
+ * exceeded its timeout. A wedged coreauthd / LocalAuthentication dialog can hang
46
+ * the parent forever; this makes the failure explicit and arms the read back-off.
47
+ */
48
+ export class KeychainHelperTimeoutError extends Error {
49
+ bin;
50
+ args;
51
+ constructor(bin, args) {
52
+ super(`keychain helper timed out (${bin} ${args.join(' ')}) — keychain locked / ` +
53
+ `LocalAuthentication unresponsive — retry; if it persists, lock/unlock the screen or reboot`);
54
+ this.bin = bin;
55
+ this.args = args;
56
+ this.name = 'KeychainHelperTimeoutError';
57
+ }
58
+ }
59
+ let keychainDaemonBootEnabled = true;
60
+ let keychainDaemonBootAttempted = false;
61
+ /** Test seam: suppress the side-effect daemon boot in unit tests. */
62
+ export function setKeychainDaemonBootForTest(enabled) {
63
+ keychainDaemonBootEnabled = enabled;
64
+ }
65
+ /**
66
+ * Single wrapper for every keychain-helper (and /usr/bin/security) spawnSync.
67
+ * Applies a hard timeout + SIGKILL so a wedged coreauthd can never hang the
68
+ * parent process. Throws {@link KeychainHelperTimeoutError} when the child is
69
+ * killed by the timeout. Also boots the daemon once per process so the reaper
70
+ * can clean up any stuck helpers this or prior invocations left behind.
71
+ */
72
+ function spawnKeychainHelper(bin, args, opts, timeoutMs) {
73
+ if (keychainDaemonBootEnabled && !keychainDaemonBootAttempted) {
74
+ keychainDaemonBootAttempted = true;
75
+ // Fire-and-forget: daemon start must not block the foreground secrets op.
76
+ import('../daemon.js')
77
+ .then(({ ensureDaemonStarted }) => ensureDaemonStarted())
78
+ .catch(() => { });
79
+ }
80
+ const result = spawnSync(bin, args, { ...opts, timeout: timeoutMs, killSignal: 'SIGKILL' });
81
+ if (result.signal) {
82
+ throw new KeychainHelperTimeoutError(bin, args);
83
+ }
84
+ return result;
85
+ }
86
+ /** Test seam: exercise the timeout wrapper with an arbitrary binary. */
87
+ export function spawnKeychainHelperForTest(bin, args, opts, timeoutMs) {
88
+ return spawnKeychainHelper(bin, args, opts, timeoutMs);
89
+ }
39
90
  const REF_PATTERN = /^(keychain|env|file|exec):(.+)$/s;
40
91
  /** Parse a bundle value into either a literal string or a typed secret ref. */
41
92
  export function parseBundleValue(raw) {
@@ -725,14 +776,14 @@ export function hasKeychainToken(item) {
725
776
  if (isWindows())
726
777
  return windowsBackend.has(item);
727
778
  if (!isOurItem(item)) {
728
- return spawnSync('/usr/bin/security', ['find-generic-password', '-a', os.userInfo().username, '-s', item], {
779
+ return spawnKeychainHelper('/usr/bin/security', ['find-generic-password', '-a', os.userInfo().username, '-s', item], {
729
780
  stdio: ['ignore', 'ignore', 'ignore'],
730
- }).status === 0;
781
+ }, KEYCHAIN_SILENT_TIMEOUT_MS).status === 0;
731
782
  }
732
783
  const bin = getKeychainHelperPath();
733
- return spawnSync(bin, ['has', item, os.userInfo().username], {
784
+ return spawnKeychainHelper(bin, ['has', item, os.userInfo().username], {
734
785
  stdio: ['ignore', 'pipe', 'pipe'],
735
- }).status === 0;
786
+ }, KEYCHAIN_SILENT_TIMEOUT_MS).status === 0;
736
787
  }
737
788
  /**
738
789
  * The detector the raw-read storm guard consults, overridable so tests on any
@@ -808,9 +859,9 @@ export function getKeychainToken(item, context = {}) {
808
859
  if (isWindows())
809
860
  return windowsBackend.get(item);
810
861
  if (!isOurItem(item)) {
811
- const sec = spawnSync('/usr/bin/security', ['find-generic-password', '-a', os.userInfo().username, '-s', item, '-w'], {
862
+ const sec = spawnKeychainHelper('/usr/bin/security', ['find-generic-password', '-a', os.userInfo().username, '-s', item, '-w'], {
812
863
  stdio: ['ignore', 'pipe', 'pipe'],
813
- });
864
+ }, KEYCHAIN_SILENT_TIMEOUT_MS);
814
865
  if (sec.status === 0) {
815
866
  const token = sec.stdout?.toString().trim();
816
867
  if (token) {
@@ -821,15 +872,24 @@ export function getKeychainToken(item, context = {}) {
821
872
  throw new Error(`Keychain item '${requested}' not found.`);
822
873
  }
823
874
  const bin = getKeychainHelperPath();
824
- const result = spawnSync(bin, ['get', item, os.userInfo().username], {
825
- env: {
826
- ...process.env,
827
- AGENTS_KEYCHAIN_PROMPT: keychainOperationPrompt(context),
828
- AGENTS_KEYCHAIN_PROMPT_BASE: keychainOperationPrompt({ ...context, duration: undefined }),
829
- AGENTS_KEYCHAIN_SKIP_AUTH_UI: context.silentNoAcl ? '1' : '0',
830
- },
831
- stdio: ['ignore', 'pipe', 'pipe'],
832
- });
875
+ let result;
876
+ try {
877
+ result = spawnKeychainHelper(bin, ['get', item, os.userInfo().username], {
878
+ env: {
879
+ ...process.env,
880
+ AGENTS_KEYCHAIN_PROMPT: keychainOperationPrompt(context),
881
+ AGENTS_KEYCHAIN_PROMPT_BASE: keychainOperationPrompt({ ...context, duration: undefined }),
882
+ AGENTS_KEYCHAIN_SKIP_AUTH_UI: context.silentNoAcl ? '1' : '0',
883
+ },
884
+ stdio: ['ignore', 'pipe', 'pipe'],
885
+ }, KEYCHAIN_INTERACTIVE_TIMEOUT_MS);
886
+ }
887
+ catch (err) {
888
+ if (err instanceof KeychainHelperTimeoutError && !context.silentNoAcl) {
889
+ noteKeychainReadFailure(requested);
890
+ }
891
+ throw err;
892
+ }
833
893
  // Exit 1 is a plain miss — no prompt was raised, so it opens no back-off.
834
894
  if (result.status === 1)
835
895
  throw new Error(`Keychain item '${requested}' not found.`);
@@ -913,19 +973,28 @@ export function getKeychainTokens(items, context = {}) {
913
973
  return result;
914
974
  }
915
975
  const bin = getKeychainHelperPath();
916
- const child = spawnSync(bin, ['get-batch', os.userInfo().username, ...storageItems], {
917
- env: {
918
- ...process.env,
919
- AGENTS_KEYCHAIN_PROMPT: keychainOperationPrompt(context),
920
- AGENTS_KEYCHAIN_PROMPT_BASE: keychainOperationPrompt({ ...context, duration: undefined }),
921
- AGENTS_KEYCHAIN_SKIP_AUTH_UI: context.silentNoAcl ? '1' : '0',
922
- // The signed helper's own vocabulary is unchanged (it predates the rename
923
- // and ships as a separately-versioned binary), so map to its legacy token.
924
- AGENTS_KEYCHAIN_DEFAULT_POLICY: (context.defaultPolicy ?? 'hold') === 'hold' ? 'daily' : context.defaultPolicy,
925
- AGENTS_KEYCHAIN_FORCE_DURATION: context.forceDuration ? '1' : '0',
926
- },
927
- stdio: ['ignore', 'pipe', 'pipe'],
928
- });
976
+ let child;
977
+ try {
978
+ child = spawnKeychainHelper(bin, ['get-batch', os.userInfo().username, ...storageItems], {
979
+ env: {
980
+ ...process.env,
981
+ AGENTS_KEYCHAIN_PROMPT: keychainOperationPrompt(context),
982
+ AGENTS_KEYCHAIN_PROMPT_BASE: keychainOperationPrompt({ ...context, duration: undefined }),
983
+ AGENTS_KEYCHAIN_SKIP_AUTH_UI: context.silentNoAcl ? '1' : '0',
984
+ // The signed helper's own vocabulary is unchanged (it predates the rename
985
+ // and ships as a separately-versioned binary), so map to its legacy token.
986
+ AGENTS_KEYCHAIN_DEFAULT_POLICY: (context.defaultPolicy ?? 'hold') === 'hold' ? 'daily' : context.defaultPolicy,
987
+ AGENTS_KEYCHAIN_FORCE_DURATION: context.forceDuration ? '1' : '0',
988
+ },
989
+ stdio: ['ignore', 'pipe', 'pipe'],
990
+ }, KEYCHAIN_INTERACTIVE_TIMEOUT_MS);
991
+ }
992
+ catch (err) {
993
+ if (err instanceof KeychainHelperTimeoutError && !context.silentNoAcl) {
994
+ noteKeychainReadFailure(backoffKey);
995
+ }
996
+ throw err;
997
+ }
929
998
  if (child.status !== 0 && !context.silentNoAcl)
930
999
  noteKeychainReadFailure(backoffKey);
931
1000
  if (child.status === 4) {
@@ -1063,7 +1132,7 @@ export function setKeychainToken(item, value, opts) {
1063
1132
  // controlling terminal, so readpassphrase always falls back to our piped
1064
1133
  // stdin. Without it, an interactive `agents view` (refreshing+saving a Claude
1065
1134
  // OAuth token) or `agents secrets add` pops a keychain password sheet.
1066
- const sec = spawnSync('/usr/bin/security', buildAddGenericPasswordArgs(os.userInfo().username, item), buildAddGenericPasswordSpawnOptions(value));
1135
+ const sec = spawnKeychainHelper('/usr/bin/security', buildAddGenericPasswordArgs(os.userInfo().username, item), buildAddGenericPasswordSpawnOptions(value), KEYCHAIN_SILENT_TIMEOUT_MS);
1067
1136
  if (sec.status !== 0) {
1068
1137
  const msg = sec.stderr?.toString().trim();
1069
1138
  throw new Error(msg || `Failed to write keychain item '${item}'.`);
@@ -1076,10 +1145,10 @@ export function setKeychainToken(item, value, opts) {
1076
1145
  // re-notarized helper; an older pinned helper dies with "Unknown command:
1077
1146
  // set-no-acl" (exit 2), surfaced below — never a silent ACL'd downgrade.
1078
1147
  const helperCmd = opts?.noAcl ? 'set-no-acl' : 'set';
1079
- const result = spawnSync(bin, [helperCmd, item, os.userInfo().username], {
1148
+ const result = spawnKeychainHelper(bin, [helperCmd, item, os.userInfo().username], {
1080
1149
  input: value,
1081
1150
  stdio: ['pipe', 'pipe', 'pipe'],
1082
- });
1151
+ }, KEYCHAIN_SILENT_TIMEOUT_MS);
1083
1152
  if (result.status !== 0) {
1084
1153
  const msg = result.stderr?.toString().trim();
1085
1154
  if (opts?.noAcl && /unknown command/i.test(msg ?? '')) {
@@ -1106,9 +1175,9 @@ export function deleteKeychainToken(item) {
1106
1175
  if (isWindows())
1107
1176
  return windowsBackend.delete(item);
1108
1177
  const bin = getKeychainHelperPath();
1109
- const deleted = spawnSync(bin, ['delete', item, os.userInfo().username], {
1178
+ const deleted = spawnKeychainHelper(bin, ['delete', item, os.userInfo().username], {
1110
1179
  stdio: ['ignore', 'pipe', 'pipe'],
1111
- }).status === 0;
1180
+ }, KEYCHAIN_SILENT_TIMEOUT_MS).status === 0;
1112
1181
  // A deleted item must fail its next read as plain "not found", not with a
1113
1182
  // stale back-off error left over from a pre-delete cancel.
1114
1183
  if (deleted)
@@ -1149,9 +1218,9 @@ export function listKeychainItems(prefix) {
1149
1218
  if (isWindows())
1150
1219
  return apply(windowsBackend.list(mapped.prefix));
1151
1220
  const bin = getKeychainHelperPath();
1152
- const result = spawnSync(bin, ['list', mapped.prefix], {
1221
+ const result = spawnKeychainHelper(bin, ['list', mapped.prefix], {
1153
1222
  stdio: ['ignore', 'pipe', 'pipe'],
1154
- });
1223
+ }, KEYCHAIN_SILENT_TIMEOUT_MS);
1155
1224
  if (result.status !== 0) {
1156
1225
  const msg = result.stderr?.toString().trim();
1157
1226
  throw new Error(msg || `Failed to enumerate keychain items with prefix '${prefix}'.`);
@@ -1176,9 +1245,9 @@ export function listLegacyKeychainItems(prefix) {
1176
1245
  if (isLinux())
1177
1246
  return [];
1178
1247
  const bin = getKeychainHelperPath();
1179
- const result = spawnSync(bin, ['list-legacy', prefix], {
1248
+ const result = spawnKeychainHelper(bin, ['list-legacy', prefix], {
1180
1249
  stdio: ['ignore', 'pipe', 'pipe'],
1181
- });
1250
+ }, KEYCHAIN_SILENT_TIMEOUT_MS);
1182
1251
  if (result.status !== 0) {
1183
1252
  const msg = result.stderr?.toString().trim();
1184
1253
  throw new Error(msg || `Failed to enumerate legacy keychain items with prefix '${prefix}'.`);
@@ -1209,9 +1278,9 @@ export function listSyncedKeychainItems(prefix) {
1209
1278
  if (isLinux() || isWindows())
1210
1279
  return [];
1211
1280
  const bin = getKeychainHelperPath();
1212
- const result = spawnSync(bin, ['list-synced', prefix], {
1281
+ const result = spawnKeychainHelper(bin, ['list-synced', prefix], {
1213
1282
  stdio: ['ignore', 'pipe', 'pipe'],
1214
- });
1283
+ }, KEYCHAIN_SILENT_TIMEOUT_MS);
1215
1284
  if (result.status !== 0) {
1216
1285
  const msg = result.stderr?.toString().trim();
1217
1286
  throw new Error(msg || `Failed to enumerate iCloud keychain items with prefix '${prefix}'.`);
@@ -1237,9 +1306,9 @@ export function getSyncedKeychainTokens(items) {
1237
1306
  if (isLinux() || isWindows())
1238
1307
  return result;
1239
1308
  const bin = getKeychainHelperPath();
1240
- const child = spawnSync(bin, ['get-batch-synced', os.userInfo().username, ...items], {
1309
+ const child = spawnKeychainHelper(bin, ['get-batch-synced', os.userInfo().username, ...items], {
1241
1310
  stdio: ['ignore', 'pipe', 'pipe'],
1242
- });
1311
+ }, KEYCHAIN_INTERACTIVE_TIMEOUT_MS);
1243
1312
  if (child.status === 4) {
1244
1313
  throw new Error(`Auth cancelled while reading ${items.length} iCloud keychain item(s).`);
1245
1314
  }
@@ -1265,9 +1334,9 @@ export function deleteSyncedKeychainItem(item) {
1265
1334
  if (isLinux() || isWindows())
1266
1335
  return false;
1267
1336
  const bin = getKeychainHelperPath();
1268
- return spawnSync(bin, ['delete-synced', item, os.userInfo().username], {
1337
+ return spawnKeychainHelper(bin, ['delete-synced', item, os.userInfo().username], {
1269
1338
  stdio: ['ignore', 'pipe', 'pipe'],
1270
- }).status === 0;
1339
+ }, KEYCHAIN_SILENT_TIMEOUT_MS).status === 0;
1271
1340
  }
1272
1341
  /**
1273
1342
  * One-time upgrade for a keychain item that was written by a previous helper
@@ -1286,9 +1355,9 @@ export function migrateKeychainItem(item) {
1286
1355
  if (isWindows())
1287
1356
  return windowsBackend.has(item);
1288
1357
  const bin = getKeychainHelperPath();
1289
- const result = spawnSync(bin, ['migrate-acl', item, os.userInfo().username], {
1358
+ const result = spawnKeychainHelper(bin, ['migrate-acl', item, os.userInfo().username], {
1290
1359
  stdio: ['ignore', 'pipe', 'pipe'],
1291
- });
1360
+ }, KEYCHAIN_INTERACTIVE_TIMEOUT_MS);
1292
1361
  if (result.status === 0)
1293
1362
  return true;
1294
1363
  if (result.status === 1)
@@ -1310,9 +1379,9 @@ export function listOrphanedKeychainItems(prefix) {
1310
1379
  if (isLinux() || isWindows())
1311
1380
  return [];
1312
1381
  const bin = getKeychainHelperPath();
1313
- const result = spawnSync(bin, ['list-orphans', prefix, os.userInfo().username], {
1382
+ const result = spawnKeychainHelper(bin, ['list-orphans', prefix, os.userInfo().username], {
1314
1383
  stdio: ['ignore', 'pipe', 'pipe'],
1315
- });
1384
+ }, KEYCHAIN_SILENT_TIMEOUT_MS);
1316
1385
  if (result.status !== 0) {
1317
1386
  const msg = result.stderr?.toString().trim();
1318
1387
  throw new Error(msg || `Failed to enumerate orphaned keychain items with prefix '${prefix}'.`);
@@ -1368,9 +1437,9 @@ export function migrateOrphanedKeychainItems(prefix) {
1368
1437
  if (isLinux() || isWindows())
1369
1438
  return [];
1370
1439
  const bin = getKeychainHelperPath();
1371
- const result = spawnSync(bin, ['migrate-orphans', prefix, os.userInfo().username], {
1440
+ const result = spawnKeychainHelper(bin, ['migrate-orphans', prefix, os.userInfo().username], {
1372
1441
  stdio: ['ignore', 'pipe', 'pipe'],
1373
- });
1442
+ }, KEYCHAIN_INTERACTIVE_TIMEOUT_MS);
1374
1443
  if (result.status === 4)
1375
1444
  throw new Error('Touch ID cancelled during orphan migration.');
1376
1445
  if (result.status !== 0) {
@@ -0,0 +1,87 @@
1
+ /**
2
+ * Reaper for orphaned / wedged keychain-helper processes.
3
+ *
4
+ * macOS keychain-helper calls are synchronous spawnSync invocations. When
5
+ * coreauthd / LocalAuthentication hangs, the helper process (and sometimes its
6
+ * parent `agents` process) can pile up forever. This module detects two classes
7
+ * of stale process and kills them.
8
+ *
9
+ * The design splits cleanly into:
10
+ * - a pure planner ({@link planKeychainReap}) that is unit-testable without a
11
+ * real `ps` shell, and
12
+ * - an impure driver ({@link reapOrphanedKeychainProcesses}) that shells `ps`
13
+ * once per tick and executes kills through {@link killTree}.
14
+ */
15
+ /** Elapsed grace before a helper whose parent exited is considered an orphan. */
16
+ export declare const ORPHAN_GRACE_SEC = 30;
17
+ /** Elapsed grace before a helper with a live parent is considered stuck. */
18
+ export declare const STUCK_GRACE_SEC = 90;
19
+ /** Snapshot of one process row from `ps`, fed into the pure planner. */
20
+ export interface KeychainProcessSnapshot {
21
+ pid: number;
22
+ ppid: number;
23
+ /** Elapsed seconds since the process started. */
24
+ elapsedSec: number;
25
+ /**
26
+ * Stable start-time fingerprint from {@link captureProcessStartTime}.
27
+ * `null` means "could not capture" — the planner must fail closed.
28
+ */
29
+ startTime: string | null;
30
+ /** True when this process's executable path matches the installed helper. */
31
+ isHelper: boolean;
32
+ }
33
+ /**
34
+ * Tracked state for a stuck `agents` parent that has a live helper child.
35
+ * Keyed by parent PID. The two-sweep debounce avoids acting on a racy `ps`
36
+ * snapshot; the `stage` field drives child-first kill then parent escalation.
37
+ */
38
+ export interface StuckParentCandidate {
39
+ pid: number;
40
+ startTime: string | null;
41
+ helperPid: number;
42
+ helperStartTime: string | null;
43
+ firstSeenAt: number;
44
+ stage: 'watch' | 'escalate';
45
+ }
46
+ /** Result of one planning pass. */
47
+ export interface ReapPlan {
48
+ /** PIDs that should be killed this tick. */
49
+ kill: number[];
50
+ /** Candidates to carry forward to the next sweep. */
51
+ nextCandidates: Map<number, StuckParentCandidate>;
52
+ }
53
+ /**
54
+ * Pure predicate: decide which processes to kill given a `ps`-like snapshot.
55
+ *
56
+ * Mirrors the shape of {@link isExpiredPoolStray} in `lib/crabbox/lease.ts`:
57
+ * a side-effect-free classifier that the impure driver shells `ps` for. Two
58
+ * conservative reap classes:
59
+ *
60
+ * 1. Orphaned helper: PPID == 1, path-matches the helper, alive longer than
61
+ * {@link ORPHAN_GRACE_SEC}.
62
+ * 2. Stuck `agents` parent: helper child alive longer than
63
+ * {@link STUCK_GRACE_SEC}. Recorded on first sight, child killed on the
64
+ * second consecutive sweep with the same PID + startTime, parent killed on
65
+ * the third sweep if the helper child is still present.
66
+ *
67
+ * Never reaps a process whose start time could not be captured, whose path does
68
+ * not match the helper, or whose parent is no longer in the snapshot.
69
+ */
70
+ export declare function planKeychainReap(snapshots: KeychainProcessSnapshot[], now: number, prevCandidates: ReadonlyMap<number, StuckParentCandidate>): ReapPlan;
71
+ /** Test seam: reset the persisted candidate state. */
72
+ export declare function resetKeychainReaperCandidatesForTest(): void;
73
+ /**
74
+ * Impure driver: snapshot all processes once with `ps`, plan the reap, then
75
+ * execute kills through {@link killTree}.
76
+ *
77
+ * Path-matching uses the full helper path (proc_pidpath-style), not just the
78
+ * executable name, so an unrelated binary named "Agents CLI" is never targeted.
79
+ *
80
+ * Returns on non-darwin platforms without shelling anything — the helper only
81
+ * exists on macOS.
82
+ */
83
+ export declare function reapOrphanedKeychainProcesses(): {
84
+ reaped: number;
85
+ details: string[];
86
+ plan: ReapPlan;
87
+ };
@@ -0,0 +1,184 @@
1
+ /**
2
+ * Reaper for orphaned / wedged keychain-helper processes.
3
+ *
4
+ * macOS keychain-helper calls are synchronous spawnSync invocations. When
5
+ * coreauthd / LocalAuthentication hangs, the helper process (and sometimes its
6
+ * parent `agents` process) can pile up forever. This module detects two classes
7
+ * of stale process and kills them.
8
+ *
9
+ * The design splits cleanly into:
10
+ * - a pure planner ({@link planKeychainReap}) that is unit-testable without a
11
+ * real `ps` shell, and
12
+ * - an impure driver ({@link reapOrphanedKeychainProcesses}) that shells `ps`
13
+ * once per tick and executes kills through {@link killTree}.
14
+ */
15
+ import { execFileSync } from 'child_process';
16
+ import { killTree, captureProcessStartTime } from '../platform/process.js';
17
+ import { getKeychainHelperPath } from './install-helper.js';
18
+ /** Elapsed grace before a helper whose parent exited is considered an orphan. */
19
+ export const ORPHAN_GRACE_SEC = 30;
20
+ /** Elapsed grace before a helper with a live parent is considered stuck. */
21
+ export const STUCK_GRACE_SEC = 90;
22
+ /**
23
+ * Pure predicate: decide which processes to kill given a `ps`-like snapshot.
24
+ *
25
+ * Mirrors the shape of {@link isExpiredPoolStray} in `lib/crabbox/lease.ts`:
26
+ * a side-effect-free classifier that the impure driver shells `ps` for. Two
27
+ * conservative reap classes:
28
+ *
29
+ * 1. Orphaned helper: PPID == 1, path-matches the helper, alive longer than
30
+ * {@link ORPHAN_GRACE_SEC}.
31
+ * 2. Stuck `agents` parent: helper child alive longer than
32
+ * {@link STUCK_GRACE_SEC}. Recorded on first sight, child killed on the
33
+ * second consecutive sweep with the same PID + startTime, parent killed on
34
+ * the third sweep if the helper child is still present.
35
+ *
36
+ * Never reaps a process whose start time could not be captured, whose path does
37
+ * not match the helper, or whose parent is no longer in the snapshot.
38
+ */
39
+ export function planKeychainReap(snapshots, now, prevCandidates) {
40
+ const pidMap = new Map(snapshots.map((s) => [s.pid, s]));
41
+ const kill = [];
42
+ const nextCandidates = new Map();
43
+ for (const s of snapshots) {
44
+ if (!s.isHelper)
45
+ continue;
46
+ if (s.ppid === 1) {
47
+ // Orphaned helper: parent is init/launchd. Fail closed if we can't prove
48
+ // the process identity with a start-time fingerprint.
49
+ if (s.elapsedSec <= ORPHAN_GRACE_SEC)
50
+ continue;
51
+ if (s.startTime == null)
52
+ continue;
53
+ kill.push(s.pid);
54
+ continue;
55
+ }
56
+ // Stuck agents: a helper with a live parent that has been wedged for longer
57
+ // than the interactive timeout. The parent must still exist in the snapshot.
58
+ if (s.elapsedSec <= STUCK_GRACE_SEC)
59
+ continue;
60
+ const parent = pidMap.get(s.ppid);
61
+ if (!parent)
62
+ continue;
63
+ if (s.startTime == null || parent.startTime == null)
64
+ continue;
65
+ const prev = prevCandidates.get(parent.pid);
66
+ if (prev &&
67
+ prev.helperPid === s.pid &&
68
+ prev.helperStartTime === s.startTime &&
69
+ prev.startTime === parent.startTime) {
70
+ if (prev.stage === 'watch') {
71
+ // Second sweep: kill the child first so the parent's spawnSync returns.
72
+ // Keep the parent candidate at stage 'escalate' for the next sweep.
73
+ kill.push(s.pid);
74
+ nextCandidates.set(parent.pid, { ...prev, stage: 'escalate' });
75
+ continue;
76
+ }
77
+ // Third sweep: child did not free the parent, so the parent itself is wedged.
78
+ kill.push(parent.pid);
79
+ continue;
80
+ }
81
+ // First sight of this (parent, helper) pair — record it and wait.
82
+ nextCandidates.set(parent.pid, {
83
+ pid: parent.pid,
84
+ startTime: parent.startTime,
85
+ helperPid: s.pid,
86
+ helperStartTime: s.startTime,
87
+ firstSeenAt: now,
88
+ stage: 'watch',
89
+ });
90
+ }
91
+ return { kill, nextCandidates };
92
+ }
93
+ /**
94
+ * Parse one `ps` output line.
95
+ *
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.
99
+ */
100
+ function parsePsLine(line) {
101
+ const trimmed = line.trim();
102
+ if (!trimmed)
103
+ return null;
104
+ const m = trimmed.match(/^(\d+)\s+(\d+)\s+(\d+)\s+(.*)$/);
105
+ if (!m)
106
+ return null;
107
+ const pid = parseInt(m[1], 10);
108
+ const ppid = parseInt(m[2], 10);
109
+ const elapsedSec = parseInt(m[3], 10);
110
+ const command = m[4];
111
+ if (isNaN(pid) || isNaN(ppid) || isNaN(elapsedSec))
112
+ return null;
113
+ return { pid, ppid, elapsedSec, command };
114
+ }
115
+ /** Module-state for the two-sweep stuck-parent debounce. */
116
+ let stuckParentCandidates = new Map();
117
+ /** Test seam: reset the persisted candidate state. */
118
+ export function resetKeychainReaperCandidatesForTest() {
119
+ stuckParentCandidates = new Map();
120
+ }
121
+ /**
122
+ * Impure driver: snapshot all processes once with `ps`, plan the reap, then
123
+ * execute kills through {@link killTree}.
124
+ *
125
+ * Path-matching uses the full helper path (proc_pidpath-style), not just the
126
+ * executable name, so an unrelated binary named "Agents CLI" is never targeted.
127
+ *
128
+ * Returns on non-darwin platforms without shelling anything — the helper only
129
+ * exists on macOS.
130
+ */
131
+ export function reapOrphanedKeychainProcesses() {
132
+ const details = [];
133
+ if (process.platform !== 'darwin') {
134
+ return { reaped: 0, details, plan: { kill: [], nextCandidates: new Map() } };
135
+ }
136
+ let helperPath;
137
+ try {
138
+ helperPath = getKeychainHelperPath();
139
+ }
140
+ catch (err) {
141
+ return { reaped: 0, details: [`helper path resolution failed: ${err.message}`], plan: { kill: [], nextCandidates: new Map() } };
142
+ }
143
+ let out;
144
+ try {
145
+ out = execFileSync('ps', ['-ax', '-o', 'pid=,ppid=,etimes=,command='], {
146
+ encoding: 'utf-8',
147
+ stdio: ['ignore', 'pipe', 'ignore'],
148
+ });
149
+ }
150
+ catch (err) {
151
+ return { reaped: 0, details: [`ps failed: ${err.message}`], plan: { kill: [], nextCandidates: new Map() } };
152
+ }
153
+ const snapshots = [];
154
+ for (const line of out.split('\n')) {
155
+ const parsed = parsePsLine(line);
156
+ if (!parsed)
157
+ continue;
158
+ const { pid, ppid, elapsedSec, command } = parsed;
159
+ // Exact path-match: the helper invocation's command line begins with the
160
+ // absolute helper path, followed by a space and its arguments (or nothing).
161
+ const isHelper = command === helperPath || command.startsWith(`${helperPath} `);
162
+ snapshots.push({
163
+ pid,
164
+ ppid,
165
+ elapsedSec,
166
+ startTime: captureProcessStartTime(pid),
167
+ isHelper,
168
+ });
169
+ }
170
+ const plan = planKeychainReap(snapshots, Date.now(), stuckParentCandidates);
171
+ stuckParentCandidates = plan.nextCandidates;
172
+ for (const pid of plan.kill) {
173
+ if (pid === process.pid)
174
+ continue; // never self-terminate the reaper
175
+ try {
176
+ killTree(pid);
177
+ details.push(`killed pid ${pid}`);
178
+ }
179
+ catch (err) {
180
+ details.push(`failed to kill pid ${pid}: ${err.message}`);
181
+ }
182
+ }
183
+ return { reaped: plan.kill.length, details, plan };
184
+ }
@@ -177,3 +177,32 @@ export declare function keychainWriteFailureMessage(host: string, bundle: string
177
177
  export declare function verifyRemoteKeychainPush(target: string, bundle: string, pushedKeys: string[], opts?: {
178
178
  osLookupName?: string;
179
179
  }): RemoteKeychainWriteVerification;
180
+ /**
181
+ * The `bash -lc` command + stdin payload that drives a **file-backed** remote
182
+ * import for `secrets export --host … --remote-backend file`.
183
+ *
184
+ * The file store is passphrase-free by default: with `AGENTS_SECRETS_PASSPHRASE`
185
+ * unset the remote `agents secrets import --backend file` auto-provisions the
186
+ * remote's own machine-local key (0600 under `~/.agents/.secrets-key/`), so its
187
+ * reads are HEADLESS — no passphrase, no Touch ID. A passphrase is therefore
188
+ * OPTIONAL and only forwarded when the operator sets one locally (opt-in, e.g. to
189
+ * key the bundle off-disk under a shared secret):
190
+ *
191
+ * - **No passphrase** → the remote runs `import … --backend file` directly with
192
+ * ONLY the .env on stdin. No `read`/`export AGENTS_SECRETS_PASSPHRASE`
193
+ * prologue, so `AGENTS_SECRETS_PASSPHRASE` stays UNSET on the remote → the
194
+ * machine-local key path → headless reads.
195
+ * - **Passphrase set** → forward it as the FIRST stdin line, consumed by
196
+ * `IFS= read -r` (so it never lands in argv / `ps` / remote shell history),
197
+ * then the .env. The remote then keys the bundle under that shared passphrase.
198
+ *
199
+ * Pure — no I/O — so the exact command string and stdin ordering are unit-testable
200
+ * against the SSH boundary the same way `remoteSecretsRaw` is.
201
+ */
202
+ export declare function buildRemoteFileImportCommand(bundle: string, dotenv: string, opts?: {
203
+ passphrase?: string;
204
+ force?: boolean;
205
+ }): {
206
+ remoteCmd: string;
207
+ input: string;
208
+ };
@@ -15,7 +15,7 @@
15
15
  * file-backend passphrase travels over ssh stdin (first line) so it never lands
16
16
  * in argv / `ps` / remote shell history. Nothing is persisted locally.
17
17
  */
18
- import { sshExec, sshStream, assertValidSshTarget } from '../ssh-exec.js';
18
+ import { sshExec, sshStream, assertValidSshTarget, shellQuote } from '../ssh-exec.js';
19
19
  import { resolveHost } from '../hosts/registry.js';
20
20
  import { emitSecretAudit } from './audit.js';
21
21
  import { sshTargetFor } from '../hosts/types.js';
@@ -348,3 +348,39 @@ export function verifyRemoteKeychainPush(target, bundle, pushedKeys, opts = {})
348
348
  }
349
349
  return evaluateKeychainWriteVerification(pushedKeys, { ok: true, keys });
350
350
  }
351
+ /**
352
+ * The `bash -lc` command + stdin payload that drives a **file-backed** remote
353
+ * import for `secrets export --host … --remote-backend file`.
354
+ *
355
+ * The file store is passphrase-free by default: with `AGENTS_SECRETS_PASSPHRASE`
356
+ * unset the remote `agents secrets import --backend file` auto-provisions the
357
+ * remote's own machine-local key (0600 under `~/.agents/.secrets-key/`), so its
358
+ * reads are HEADLESS — no passphrase, no Touch ID. A passphrase is therefore
359
+ * OPTIONAL and only forwarded when the operator sets one locally (opt-in, e.g. to
360
+ * key the bundle off-disk under a shared secret):
361
+ *
362
+ * - **No passphrase** → the remote runs `import … --backend file` directly with
363
+ * ONLY the .env on stdin. No `read`/`export AGENTS_SECRETS_PASSPHRASE`
364
+ * prologue, so `AGENTS_SECRETS_PASSPHRASE` stays UNSET on the remote → the
365
+ * machine-local key path → headless reads.
366
+ * - **Passphrase set** → forward it as the FIRST stdin line, consumed by
367
+ * `IFS= read -r` (so it never lands in argv / `ps` / remote shell history),
368
+ * then the .env. The remote then keys the bundle under that shared passphrase.
369
+ *
370
+ * Pure — no I/O — so the exact command string and stdin ordering are unit-testable
371
+ * against the SSH boundary the same way `remoteSecretsRaw` is.
372
+ */
373
+ export function buildRemoteFileImportCommand(bundle, dotenv, opts = {}) {
374
+ const force = opts.force ? ' --force' : '';
375
+ const importCmd = `agents secrets import ${shellQuote(bundle)} --from - --backend file${force}`;
376
+ const passphrase = opts.passphrase ?? '';
377
+ if (passphrase) {
378
+ // Opt-in shared passphrase: read it off the FIRST stdin line, export it, then
379
+ // let `import --from -` read the .env remainder.
380
+ const remoteAgents = `IFS= read -r AGENTS_SECRETS_PASSPHRASE; export AGENTS_SECRETS_PASSPHRASE; ${importCmd}`;
381
+ return { remoteCmd: `bash -lc ${shellQuote(remoteAgents)}`, input: `${passphrase}\n${dotenv}` };
382
+ }
383
+ // No passphrase: NO prologue — AGENTS_SECRETS_PASSPHRASE stays unset on the
384
+ // remote, so the file store falls back to its machine-local key (headless).
385
+ return { remoteCmd: `bash -lc ${shellQuote(importCmd)}`, input: dotenv };
386
+ }