@phnx-labs/agents-cli 1.20.73 → 1.20.76

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 (209) hide show
  1. package/CHANGELOG.md +989 -0
  2. package/README.md +36 -10
  3. package/dist/bin/agents +0 -0
  4. package/dist/commands/apply.js +21 -3
  5. package/dist/commands/attach.d.ts +10 -0
  6. package/dist/commands/attach.js +41 -0
  7. package/dist/commands/computer.js +3 -3
  8. package/dist/commands/detach-core.d.ts +46 -0
  9. package/dist/commands/detach-core.js +61 -0
  10. package/dist/commands/detach.d.ts +5 -0
  11. package/dist/commands/detach.js +171 -0
  12. package/dist/commands/doctor.js +31 -1
  13. package/dist/commands/exec.js +31 -3
  14. package/dist/commands/export.d.ts +11 -0
  15. package/dist/commands/export.js +215 -0
  16. package/dist/commands/feed.d.ts +7 -1
  17. package/dist/commands/feed.js +47 -4
  18. package/dist/commands/go.d.ts +9 -2
  19. package/dist/commands/go.js +16 -6
  20. package/dist/commands/harness.d.ts +14 -0
  21. package/dist/commands/harness.js +145 -0
  22. package/dist/commands/import.js +87 -9
  23. package/dist/commands/inspect.js +5 -2
  24. package/dist/commands/profiles.d.ts +20 -0
  25. package/dist/commands/profiles.js +68 -21
  26. package/dist/commands/repo.js +14 -0
  27. package/dist/commands/routines.js +15 -2
  28. package/dist/commands/secrets.js +44 -21
  29. package/dist/commands/sessions-browser.js +1 -1
  30. package/dist/commands/sessions-export.js +19 -5
  31. package/dist/commands/sessions-migrate.d.ts +29 -0
  32. package/dist/commands/sessions-migrate.js +596 -0
  33. package/dist/commands/sessions-picker.js +16 -1
  34. package/dist/commands/sessions.d.ts +36 -13
  35. package/dist/commands/sessions.js +210 -40
  36. package/dist/commands/setup-computer.js +2 -2
  37. package/dist/commands/setup.js +6 -1
  38. package/dist/commands/ssh.js +71 -35
  39. package/dist/commands/status.js +10 -2
  40. package/dist/commands/versions.js +51 -11
  41. package/dist/commands/view.d.ts +5 -0
  42. package/dist/commands/view.js +99 -31
  43. package/dist/commands/watchdog.d.ts +7 -2
  44. package/dist/commands/watchdog.js +73 -57
  45. package/dist/index.js +59 -7
  46. package/dist/lib/activity.d.ts +14 -4
  47. package/dist/lib/activity.js +17 -5
  48. package/dist/lib/actor.d.ts +50 -0
  49. package/dist/lib/actor.js +166 -0
  50. package/dist/lib/agent-spec/provider.js +2 -1
  51. package/dist/lib/agent-spec/resolve.js +19 -5
  52. package/dist/lib/agent-spec/types.d.ts +9 -1
  53. package/dist/lib/agents.d.ts +56 -0
  54. package/dist/lib/agents.js +133 -7
  55. package/dist/lib/cloud/codex.d.ts +2 -0
  56. package/dist/lib/cloud/codex.js +14 -3
  57. package/dist/lib/cloud/session-index.d.ts +32 -0
  58. package/dist/lib/cloud/session-index.js +58 -0
  59. package/dist/lib/cloud/store.d.ts +7 -0
  60. package/dist/lib/cloud/store.js +25 -0
  61. package/dist/lib/config-transfer.d.ts +16 -0
  62. package/dist/lib/config-transfer.js +60 -0
  63. package/dist/lib/daemon.js +18 -2
  64. package/dist/lib/devices/connect.d.ts +15 -1
  65. package/dist/lib/devices/connect.js +15 -1
  66. package/dist/lib/devices/fleet.d.ts +21 -0
  67. package/dist/lib/devices/fleet.js +26 -0
  68. package/dist/lib/devices/health-report.d.ts +38 -0
  69. package/dist/lib/devices/health-report.js +214 -0
  70. package/dist/lib/devices/health.js +4 -1
  71. package/dist/lib/devices/reachability.d.ts +31 -0
  72. package/dist/lib/devices/reachability.js +40 -0
  73. package/dist/lib/devices/registry.d.ts +33 -0
  74. package/dist/lib/devices/registry.js +37 -0
  75. package/dist/lib/devices/resolve-target.d.ts +15 -27
  76. package/dist/lib/devices/resolve-target.js +63 -102
  77. package/dist/lib/devices/sync.d.ts +18 -0
  78. package/dist/lib/devices/sync.js +23 -1
  79. package/dist/lib/devices/tailscale.d.ts +3 -0
  80. package/dist/lib/devices/tailscale.js +1 -0
  81. package/dist/lib/events.d.ts +1 -1
  82. package/dist/lib/exec.d.ts +66 -0
  83. package/dist/lib/exec.js +195 -6
  84. package/dist/lib/export.d.ts +72 -0
  85. package/dist/lib/export.js +269 -0
  86. package/dist/lib/feed-post.d.ts +63 -0
  87. package/dist/lib/feed-post.js +204 -0
  88. package/dist/lib/fleet/apply.d.ts +32 -2
  89. package/dist/lib/fleet/apply.js +97 -10
  90. package/dist/lib/fleet/types.d.ts +11 -0
  91. package/dist/lib/fs-walk.d.ts +13 -0
  92. package/dist/lib/fs-walk.js +16 -7
  93. package/dist/lib/hooks.d.ts +1 -0
  94. package/dist/lib/hooks.js +156 -0
  95. package/dist/lib/hosts/dispatch.d.ts +7 -0
  96. package/dist/lib/hosts/dispatch.js +2 -0
  97. package/dist/lib/hosts/passthrough.js +2 -0
  98. package/dist/lib/hosts/reconnect.d.ts +78 -0
  99. package/dist/lib/hosts/reconnect.js +127 -0
  100. package/dist/lib/hosts/registry.d.ts +75 -14
  101. package/dist/lib/hosts/registry.js +205 -30
  102. package/dist/lib/hosts/remote-cmd.js +1 -0
  103. package/dist/lib/hosts/run-target.d.ts +13 -5
  104. package/dist/lib/hosts/run-target.js +26 -10
  105. package/dist/lib/hosts/session-index.d.ts +18 -1
  106. package/dist/lib/hosts/session-index.js +37 -2
  107. package/dist/lib/hosts/session-marker.d.ts +33 -0
  108. package/dist/lib/hosts/session-marker.js +51 -0
  109. package/dist/lib/hosts/tasks.d.ts +8 -4
  110. package/dist/lib/import.d.ts +25 -0
  111. package/dist/lib/import.js +107 -2
  112. package/dist/lib/isolation-boundary-report.d.ts +10 -0
  113. package/dist/lib/isolation-boundary-report.js +35 -0
  114. package/dist/lib/menubar/install-menubar.d.ts +20 -0
  115. package/dist/lib/menubar/install-menubar.js +45 -1
  116. package/dist/lib/migrate.d.ts +16 -0
  117. package/dist/lib/migrate.js +36 -0
  118. package/dist/lib/models.d.ts +27 -0
  119. package/dist/lib/models.js +54 -1
  120. package/dist/lib/overdue.d.ts +7 -2
  121. package/dist/lib/overdue.js +18 -11
  122. package/dist/lib/picker.js +4 -1
  123. package/dist/lib/platform/process.d.ts +17 -0
  124. package/dist/lib/platform/process.js +70 -0
  125. package/dist/lib/profiles-presets.js +9 -7
  126. package/dist/lib/profiles.d.ts +31 -0
  127. package/dist/lib/profiles.js +70 -0
  128. package/dist/lib/project-resources.js +20 -5
  129. package/dist/lib/pty-server.d.ts +2 -10
  130. package/dist/lib/pty-server.js +4 -38
  131. package/dist/lib/registry.d.ts +1 -1
  132. package/dist/lib/registry.js +48 -8
  133. package/dist/lib/rotate.d.ts +18 -0
  134. package/dist/lib/rotate.js +28 -0
  135. package/dist/lib/routines.d.ts +26 -0
  136. package/dist/lib/routines.js +19 -2
  137. package/dist/lib/runner.js +104 -7
  138. package/dist/lib/secrets/agent.d.ts +66 -1
  139. package/dist/lib/secrets/agent.js +154 -90
  140. package/dist/lib/secrets/bundles.d.ts +34 -0
  141. package/dist/lib/secrets/bundles.js +179 -19
  142. package/dist/lib/secrets/index.d.ts +15 -1
  143. package/dist/lib/secrets/index.js +21 -3
  144. package/dist/lib/secrets/rc-hygiene.d.ts +55 -0
  145. package/dist/lib/secrets/rc-hygiene.js +154 -0
  146. package/dist/lib/secrets/remote.d.ts +7 -4
  147. package/dist/lib/secrets/remote.js +7 -4
  148. package/dist/lib/secrets/sync-commands.d.ts +21 -0
  149. package/dist/lib/secrets/sync-commands.js +21 -0
  150. package/dist/lib/self-update.d.ts +23 -2
  151. package/dist/lib/self-update.js +86 -5
  152. package/dist/lib/session/active.d.ts +114 -7
  153. package/dist/lib/session/active.js +323 -52
  154. package/dist/lib/session/cloud.js +2 -1
  155. package/dist/lib/session/db.d.ts +48 -0
  156. package/dist/lib/session/db.js +238 -14
  157. package/dist/lib/session/detached.d.ts +30 -0
  158. package/dist/lib/session/detached.js +92 -0
  159. package/dist/lib/session/discover.d.ts +465 -2
  160. package/dist/lib/session/discover.js +1648 -468
  161. package/dist/lib/session/fork.js +2 -1
  162. package/dist/lib/session/hook-sessions.d.ts +43 -0
  163. package/dist/lib/session/hook-sessions.js +135 -0
  164. package/dist/lib/session/linear.d.ts +6 -0
  165. package/dist/lib/session/linear.js +55 -0
  166. package/dist/lib/session/migrate-targets.d.ts +65 -0
  167. package/dist/lib/session/migrate-targets.js +94 -0
  168. package/dist/lib/session/migrations.d.ts +37 -0
  169. package/dist/lib/session/migrations.js +60 -0
  170. package/dist/lib/session/parse.d.ts +18 -0
  171. package/dist/lib/session/parse.js +130 -30
  172. package/dist/lib/session/pid-registry.d.ts +34 -2
  173. package/dist/lib/session/pid-registry.js +49 -2
  174. package/dist/lib/session/render.d.ts +7 -2
  175. package/dist/lib/session/render.js +22 -10
  176. package/dist/lib/session/short-id.d.ts +17 -0
  177. package/dist/lib/session/short-id.js +20 -0
  178. package/dist/lib/session/state.js +6 -2
  179. package/dist/lib/shims.d.ts +27 -1
  180. package/dist/lib/shims.js +88 -6
  181. package/dist/lib/sqlite.d.ts +3 -2
  182. package/dist/lib/sqlite.js +27 -4
  183. package/dist/lib/staleness/detectors/commands.js +1 -1
  184. package/dist/lib/staleness/detectors/workflows.js +13 -2
  185. package/dist/lib/staleness/writers/commands.js +7 -7
  186. package/dist/lib/staleness/writers/workflows.d.ts +4 -2
  187. package/dist/lib/startup/command-registry.d.ts +2 -0
  188. package/dist/lib/startup/command-registry.js +5 -0
  189. package/dist/lib/state.d.ts +1 -1
  190. package/dist/lib/state.js +15 -36
  191. package/dist/lib/teams/agents.d.ts +19 -10
  192. package/dist/lib/teams/agents.js +40 -39
  193. package/dist/lib/types.d.ts +48 -2
  194. package/dist/lib/uninstall.js +1 -45
  195. package/dist/lib/usage.d.ts +33 -1
  196. package/dist/lib/usage.js +172 -12
  197. package/dist/lib/versions.d.ts +22 -2
  198. package/dist/lib/versions.js +98 -10
  199. package/dist/lib/watchdog/log.d.ts +43 -0
  200. package/dist/lib/watchdog/log.js +69 -0
  201. package/dist/lib/watchdog/routine.d.ts +44 -0
  202. package/dist/lib/watchdog/routine.js +69 -0
  203. package/dist/lib/watchdog/runner.d.ts +51 -7
  204. package/dist/lib/watchdog/runner.js +239 -64
  205. package/dist/lib/watchdog/watchdog.d.ts +1 -1
  206. package/dist/lib/watchdog/watchdog.js +31 -16
  207. package/dist/lib/workflows.d.ts +16 -0
  208. package/dist/lib/workflows.js +110 -1
  209. package/package.json +1 -1
@@ -28,7 +28,7 @@ import { deleteKeychainToken, getKeychainToken, getKeychainTokens, hasKeychainTo
28
28
  import { fileStore } from './filestore.js';
29
29
  import { getVaultSession, vaultDeleteItem, vaultExists, vaultGetItems, vaultGetItem, vaultHasItem, vaultListItems, vaultSetItems, vaultSetItem, } from './vault.js';
30
30
  import { emit } from '../events.js';
31
- import { readMeta } from '../state.js';
31
+ import { readMeta, getHelpersDir } from '../state.js';
32
32
  import { assertNameActiveInResourceProfile, filterNamesForActiveResourceProfile } from '../resource-profiles.js';
33
33
  import { agentGetSync, agentAutoLoadSync, agentGetMetaSync, agentAutoLoadMetaSync, agentEvictSync, secretsAgentAutoEnabled, secretsHoldMs } from './agent.js';
34
34
  import { loadSession, deleteSession } from './session-store.js';
@@ -243,6 +243,44 @@ export function bundleExists(name) {
243
243
  validateBundleName(name);
244
244
  return itemStore(bundleBackend(name)).has(bundleMetaItem(name));
245
245
  }
246
+ /**
247
+ * Thrown by `readBundle` for the one state `readBundleIfDecryptable` may treat
248
+ * as "present but permanently unreadable": file-store ciphertext on disk that
249
+ * will not decrypt with the passphrase in effect (lost/rotated key or a tampered
250
+ * store). It is deliberately narrow — a bundle that is merely *locked for this
251
+ * run* (headless macOS without `AGENTS_SECRETS_PASSPHRASE`, or a vault that is
252
+ * not logged in) is recoverable and must not be collapsed into this.
253
+ */
254
+ export class BundleUndecryptableError extends Error {
255
+ constructor(message) {
256
+ super(message);
257
+ this.name = 'BundleUndecryptableError';
258
+ }
259
+ }
260
+ /**
261
+ * Read a bundle, or return null when its metadata is present but genuinely
262
+ * cannot be decrypted — a lost or rotated file-store passphrase, or a tampered
263
+ * file store (signalled by `BundleUndecryptableError`).
264
+ *
265
+ * Deleting such a bundle is the only way out of that state, and deletion needs
266
+ * no plaintext, so it must not be gated behind a successful decrypt. Every other
267
+ * failure rethrows: a genuinely missing bundle ("not found"), and — critically —
268
+ * a bundle that is only *temporarily locked* for this run (headless macOS with no
269
+ * `AGENTS_SECRETS_PASSPHRASE`, or a not-logged-in vault). Collapsing that
270
+ * recoverable "set the env / log in" state into "unreadable, safe to delete"
271
+ * would let `secrets delete <name> --yes` silently destroy a perfectly healthy
272
+ * bundle from a cron/launchd run that merely forgot to export the passphrase.
273
+ */
274
+ export function readBundleIfDecryptable(name) {
275
+ try {
276
+ return readBundle(name);
277
+ }
278
+ catch (err) {
279
+ if (err instanceof BundleUndecryptableError)
280
+ return null;
281
+ throw err;
282
+ }
283
+ }
246
284
  export function readBundle(name) {
247
285
  validateBundleName(name);
248
286
  const backend = bundleBackend(name);
@@ -258,7 +296,7 @@ export function readBundle(name) {
258
296
  // A file-backed bundle whose metadata is on disk but fails to decrypt is a
259
297
  // wrong-passphrase error, not a missing bundle — surface that clearly.
260
298
  if (backend === 'file' && fileStore.has(bundleMetaItem(name))) {
261
- throw new Error(`Bundle '${name}': failed to decrypt — wrong AGENTS_SECRETS_PASSPHRASE or tampered file store. (${err.message})`);
299
+ throw new BundleUndecryptableError(`Bundle '${name}': failed to decrypt — wrong AGENTS_SECRETS_PASSPHRASE or tampered file store. (${err.message})`);
262
300
  }
263
301
  if (vaultExists() && !getVaultSession().loggedIn) {
264
302
  throw new Error(`Synced secrets are locked. Run: agents login`);
@@ -429,18 +467,40 @@ function finishBundleWrite(bundle, opts) {
429
467
  }
430
468
  export function writeBundle(bundle, opts = {}) {
431
469
  const prepared = prepareBundleWrite(bundle);
432
- // A `never` bundle's metadata is stored without the biometry ACL too, so
433
- // `view` and the metadata half of a read resolve silently — the whole point
434
- // of the tier. On an un-updated pinned helper this write fails loudly (the
435
- // no-ACL command is missing) rather than silently landing an ACL'd item.
436
- itemStore(prepared.backend).set(prepared.metadataItem, prepared.metadataJson, { noAcl: bundle.policy === 'never' });
470
+ // Bundle metadata (name, description, policy, var names + refs, and any
471
+ // non-sensitive `--value` literals) is stored WITHOUT the biometry ACL at
472
+ // EVERY tier. It is non-sensitive by contract the real secret values live in
473
+ // separate agents-cli.secrets.* items that keep the bundle's policy ACL so a
474
+ // no-ACL metadata item is what lets `secrets list` and crabbox's `agents
475
+ // devices list` enumerate bundles with no Touch ID prompt (RUSH-1759). On an
476
+ // un-updated pinned helper this write fails loudly (the no-ACL command is
477
+ // missing) rather than silently landing an ACL'd item.
478
+ itemStore(prepared.backend).set(prepared.metadataItem, prepared.metadataJson, { noAcl: true });
437
479
  finishBundleWrite(bundle, opts);
438
480
  }
439
481
  export function writeBundleWithItems(bundle, items, opts = {}) {
440
482
  const prepared = prepareBundleWrite(bundle);
441
- const batch = new Map(items);
442
- batch.set(prepared.metadataItem, prepared.metadataJson);
443
- itemStore(prepared.backend).setBatch(batch, { noAcl: bundle.policy === 'never' });
483
+ const store = itemStore(prepared.backend);
484
+ if (prepared.backend === 'keychain') {
485
+ // Only the keychain backend has a biometry ACL. Secret VALUE items carry the
486
+ // bundle's policy ACL (`never` ⇒ no-ACL); the metadata item is ALWAYS no-ACL
487
+ // (see writeBundle). The two must NOT ride one batch flag — a single noAcl
488
+ // over both would either strip biometry off the real secrets or re-ACL the
489
+ // metadata. Write the values first, then the metadata last: bundle discovery
490
+ // keys on the metadata item's presence, so metadata-last means a partial
491
+ // write reads as "no bundle yet", never as a bundle with missing values.
492
+ if (items.size > 0) {
493
+ store.setBatch(new Map(items), { noAcl: bundle.policy === 'never' });
494
+ }
495
+ store.set(prepared.metadataItem, prepared.metadataJson, { noAcl: true });
496
+ }
497
+ else {
498
+ // file / vault: no ACL concept (noAcl is ignored), so one batched write is
499
+ // both correct and cheaper — e.g. a single age re-encrypt for the vault.
500
+ const batch = new Map(items);
501
+ batch.set(prepared.metadataItem, prepared.metadataJson);
502
+ store.setBatch(batch, { noAcl: bundle.policy === 'never' });
503
+ }
444
504
  finishBundleWrite(bundle, opts);
445
505
  }
446
506
  export function deleteBundle(name) {
@@ -503,6 +563,79 @@ function parseBundleMeta(nameHint, json, backend) {
503
563
  }
504
564
  return bundle;
505
565
  }
566
+ // Sentinel marking the one-time RUSH-1759 metadata-ACL heal as done. Lives under
567
+ // the regenerable helpers dir (same tree as the secrets-agent runtime state), so
568
+ // a cache wipe just re-runs the heal — harmless, since it is idempotent.
569
+ const METADATA_NOACL_SENTINEL = 'bundles-metadata-noacl-healed';
570
+ function metadataNoAclSentinelPath() {
571
+ return path.join(getHelpersDir(), 'secrets-agent', METADATA_NOACL_SENTINEL);
572
+ }
573
+ function bundleMetadataAclHealed() {
574
+ try {
575
+ return fs.existsSync(metadataNoAclSentinelPath());
576
+ }
577
+ catch {
578
+ return false;
579
+ }
580
+ }
581
+ function markBundleMetadataAclHealed() {
582
+ try {
583
+ const file = metadataNoAclSentinelPath();
584
+ fs.mkdirSync(path.dirname(file), { recursive: true });
585
+ fs.writeFileSync(file, '', 'utf8');
586
+ }
587
+ catch {
588
+ // Best effort — a missing sentinel just means the (idempotent) heal re-runs
589
+ // on the next broker-miss listing.
590
+ }
591
+ }
592
+ /**
593
+ * Re-write already-read keychain bundle metadata items WITHOUT the biometry ACL.
594
+ * `metaJsonByName` maps bundle name → the exact metadata JSON listBundles just
595
+ * batch-read, so writing it back only flips the ACL (via the helper's
596
+ * delete-then-add `set-no-acl`) — contents and updated_at are preserved and no
597
+ * extra keychain read is issued. Exported for tests. Returns the count healed.
598
+ */
599
+ export function healKeychainBundleMetadata(metaJsonByName) {
600
+ let healed = 0;
601
+ for (const [name, json] of metaJsonByName) {
602
+ try {
603
+ // Cleartext meta-item name → hashed by keychainStore.set (#316) to the same
604
+ // service the read enumerated, so this overwrites the existing item in
605
+ // place, no-ACL. A per-item failure (e.g. a pinned helper without
606
+ // set-no-acl) must not abort the rest.
607
+ keychainStore.set(bundleMetaItem(name), json, { noAcl: true });
608
+ healed++;
609
+ }
610
+ catch {
611
+ /* keep healing the remaining items */
612
+ }
613
+ }
614
+ return healed;
615
+ }
616
+ /**
617
+ * One-time driver around healKeychainBundleMetadata (RUSH-1759). macOS + real
618
+ * keychain only — libsecret/CredMan have no biometry ACL to shed, and a test
619
+ * backend has no real keychain — and gated by a sentinel so it runs at most
620
+ * once. Best-effort: a heal failure never breaks bundle listing.
621
+ */
622
+ function healKeychainBundleMetadataAclOnce(metaJsonByName) {
623
+ if (metaJsonByName.size === 0)
624
+ return;
625
+ if (process.platform !== 'darwin')
626
+ return;
627
+ if (isKeychainBackendOverridden())
628
+ return;
629
+ if (bundleMetadataAclHealed())
630
+ return;
631
+ try {
632
+ if (healKeychainBundleMetadata(metaJsonByName) > 0)
633
+ markBundleMetadataAclHealed();
634
+ }
635
+ catch {
636
+ /* never let a heal failure break `secrets list` */
637
+ }
638
+ }
506
639
  export function listBundles() {
507
640
  const out = [];
508
641
  // Keychain-backed bundles: batch all metadata reads behind ONE Touch ID
@@ -558,6 +691,7 @@ export function listBundles() {
558
691
  else {
559
692
  const fetched = getKeychainTokens(keychainServices);
560
693
  const keychainBundles = [];
694
+ const metaJsonByName = new Map();
561
695
  for (const service of keychainServices) {
562
696
  const json = fetched.get(service);
563
697
  if (json === undefined)
@@ -566,8 +700,10 @@ export function listBundles() {
566
700
  ? service.slice(BUNDLE_META_PREFIX.length)
567
701
  : undefined;
568
702
  const bundle = parseBundleMeta(nameHint, json, 'keychain');
569
- if (bundle)
703
+ if (bundle) {
570
704
  keychainBundles.push(bundle);
705
+ metaJsonByName.set(bundle.name, json);
706
+ }
571
707
  }
572
708
  for (const bundle of keychainBundles)
573
709
  out.push(bundle);
@@ -578,6 +714,13 @@ export function listBundles() {
578
714
  if (useAgent && keychainBundles.length > 0) {
579
715
  agentAutoLoadMetaSync(nameSetHash, keychainBundles, secretsHoldMs());
580
716
  }
717
+ // One-time RUSH-1759 heal: bundles written before the metadata-no-ACL
718
+ // change carry an ACL'd metadata item, so this fresh read (a broker miss)
719
+ // popped Touch ID just to enumerate them. Re-home each metadata item
720
+ // no-ACL now — reusing the JSON we just read, so the heal adds no extra
721
+ // prompt — and every later enumeration is silent. Runs at most once (a
722
+ // sentinel under the regenerable helpers dir).
723
+ healKeychainBundleMetadataAclOnce(metaJsonByName);
581
724
  }
582
725
  }
583
726
  }
@@ -977,15 +1120,32 @@ export function readAndResolveBundleEnv(name, opts = {}) {
977
1120
  return filtered;
978
1121
  }
979
1122
  }
980
- // Only keychain-backed bundles can pop a Touch ID prompt and are the only ones
981
- // the broker ever holds. A file-backed bundle resolves via passphrase with no
982
- // prompt, so agentOnly must never block it the broker never holds file
983
- // bundles, so the throw would fire unconditionally and break a legitimate read.
1123
+ // The headless guard exists only to keep a Touch ID sheet off the interactive
1124
+ // user's screen so it must block ONLY reads that can actually raise one.
1125
+ // A file-backed bundle resolves via passphrase with no prompt (handled below,
1126
+ // never held by the broker). A `never`/no-ACL keychain bundle is written
1127
+ // WITHOUT the biometry access control at every tier (see writeBundle), so its
1128
+ // reads are fully silent (no Touch ID, no broker) — exactly like a file
1129
+ // bundle, and exactly the "automation-only" tier a headless daemon reads
1130
+ // (daemon.ts:readDaemonClaudeOAuthToken). Learning the policy here is itself
1131
+ // prompt-less: metadata items are written no-ACL at every tier, so readBundle
1132
+ // reads them silently. An unreadable metadata read is treated as prompt-risky
1133
+ // (throw) — the safe default.
984
1134
  if (opts.agentOnly && backend === 'keychain') {
985
- throw new Error(`Secrets bundle '${name}' is not unlocked in the secrets agent, and this is a ` +
986
- `headless/background process that must not raise a Touch ID prompt on the ` +
987
- `interactive user's screen. Run 'agents secrets unlock ${name}' in a terminal ` +
988
- `first, or set AGENTS_SECRETS_NO_PROMPT=0 to force an interactive prompt.`);
1135
+ let noAclBundle = false;
1136
+ try {
1137
+ noAclBundle = bundlePolicy(readBundle(name)) === 'never';
1138
+ }
1139
+ catch {
1140
+ noAclBundle = false;
1141
+ }
1142
+ if (!noAclBundle) {
1143
+ throw new Error(`Secrets bundle '${name}' is not unlocked in the secrets agent, and this is a ` +
1144
+ `headless/background process that must not raise a Touch ID prompt on the ` +
1145
+ `interactive user's screen. Run 'agents secrets unlock ${name}' in a terminal ` +
1146
+ `first, or set AGENTS_SECRETS_NO_PROMPT=0 to force an interactive prompt.`);
1147
+ }
1148
+ // never/no-ACL ⇒ fall through to the prompt-less keychain read below.
989
1149
  }
990
1150
  if (backend === 'file')
991
1151
  assertFileBackendUsable(name);
@@ -187,7 +187,21 @@ export declare function rekeyStatus(): {
187
187
  * count is then meaningless. Only probeable once the key record exists. */
188
188
  enumerationOk: boolean;
189
189
  };
190
- /** Check if a keychain/keyring item exists. Never prompts for biometry. */
190
+ /**
191
+ * Check if a keychain/keyring item exists. Never prompts for biometry.
192
+ *
193
+ * Throws when the item cannot be reached — on macOS, when the signed helper is
194
+ * unavailable. That is deliberate: this primitive gates destructive writes as
195
+ * well as reads. Through `bundleExists()` it guards the `--force` overwrite
196
+ * checks in `agents secrets create` (`../../commands/secrets.ts`), the
197
+ * bundle-rename purge (`./bundles.ts`), the pull-rollback bookkeeping
198
+ * (`./sync.ts`), and the reuse-never-overwrite rule for `R2_SYNC_ENC_KEY`
199
+ * (`../session/sync/provision.ts`). A false "absent" silently disarms every one
200
+ * of them, so an unreachable keychain must fail loudly rather than answer "no".
201
+ *
202
+ * Tests needing this path without a helper install a backend via
203
+ * `setKeychainBackendForTest()`, which short-circuits on the next line.
204
+ */
191
205
  export declare function hasKeychainToken(item: string): boolean;
192
206
  /**
193
207
  * Retrieve a secret value from the keychain/keyring. Throws if not found.
@@ -481,8 +481,12 @@ export function computeRekeyPlan(services, values, key) {
481
481
  try {
482
482
  const parsed = JSON.parse(value);
483
483
  if (parsed && typeof parsed === 'object') {
484
- const tier = parsed.tier;
485
- noAcl = tier === 'none' || tier === 'never';
484
+ // Bundle metadata is non-sensitive by contract and stored no-ACL at
485
+ // EVERY tier (matches writeBundle in bundles.ts), so `secrets list` /
486
+ // crabbox's `agents devices list` enumerate bundles with no Touch ID
487
+ // (RUSH-1759). Re-home metadata no-ACL regardless of the bundle's policy;
488
+ // the real secret values (second loop) still carry their per-bundle ACL.
489
+ noAcl = true;
486
490
  payload = JSON.stringify({ ...parsed, name });
487
491
  }
488
492
  }
@@ -669,7 +673,21 @@ export function rekeyStatus() {
669
673
  enumerationOk,
670
674
  };
671
675
  }
672
- /** Check if a keychain/keyring item exists. Never prompts for biometry. */
676
+ /**
677
+ * Check if a keychain/keyring item exists. Never prompts for biometry.
678
+ *
679
+ * Throws when the item cannot be reached — on macOS, when the signed helper is
680
+ * unavailable. That is deliberate: this primitive gates destructive writes as
681
+ * well as reads. Through `bundleExists()` it guards the `--force` overwrite
682
+ * checks in `agents secrets create` (`../../commands/secrets.ts`), the
683
+ * bundle-rename purge (`./bundles.ts`), the pull-rollback bookkeeping
684
+ * (`./sync.ts`), and the reuse-never-overwrite rule for `R2_SYNC_ENC_KEY`
685
+ * (`../session/sync/provision.ts`). A false "absent" silently disarms every one
686
+ * of them, so an unreachable keychain must fail loudly rather than answer "no".
687
+ *
688
+ * Tests needing this path without a helper install a backend via
689
+ * `setKeychainBackendForTest()`, which short-circuits on the next line.
690
+ */
673
691
  export function hasKeychainToken(item) {
674
692
  item = prepareServiceName(item);
675
693
  if (backend)
@@ -0,0 +1,55 @@
1
+ /**
2
+ * Detect credentials exported from shell rc files.
3
+ *
4
+ * A secret exported from `~/.zshenv`, `~/.zshrc`, `~/.bashrc`, `~/.profile`, …
5
+ * is inherited by every process the login shell spawns and is readable from
6
+ * `/proc/<pid>/environ` by any same-user process — the master passphrase leak
7
+ * behind RUSH-1968. `.zshenv` in particular is sourced by *every* zsh
8
+ * invocation, including non-interactive `ssh host 'cmd'`, so the value lands in
9
+ * the environment of essentially everything the box runs.
10
+ *
11
+ * The keychain-backed store (`agents secrets`) is the sanctioned home for
12
+ * credentials; the file-store master passphrase belongs in the off-env 0600 key
13
+ * file (`~/.agents/.secrets-key/passphrase`), never a shell rc export.
14
+ *
15
+ * This scanner powers a warn-level advisory in `agents doctor`. It reads only
16
+ * the exported variable *name* and its line number — never the value — so a
17
+ * finding can be printed, logged, or shipped without leaking the secret.
18
+ */
19
+ /** Shell rc files sourced at login/startup, in the order a report lists them. */
20
+ export declare const RC_FILENAMES: readonly [".zshenv", ".zprofile", ".zshrc", ".zlogin", ".bash_profile", ".bash_login", ".bashrc", ".profile", ".kshrc"];
21
+ /**
22
+ * A single credential-shaped export found in an rc file. Carries the variable
23
+ * NAME and location only — the value is never captured.
24
+ */
25
+ export interface RcSecretFinding {
26
+ /** Basename of the rc file, e.g. `.zshenv`. */
27
+ file: string;
28
+ /** 1-based line number of the export. */
29
+ line: number;
30
+ /** The exported variable name (e.g. `AGENTS_SECRETS_PASSPHRASE`). Never the value. */
31
+ name: string;
32
+ /** The file-store master passphrase gets called out separately — it is the highest-severity case. */
33
+ isMasterPassphrase: boolean;
34
+ }
35
+ /** True if a variable name looks like it holds a credential value. */
36
+ export declare function isCredentialName(name: string): boolean;
37
+ /**
38
+ * Scan one rc file's contents for credential-shaped exports. Pure — takes the
39
+ * text, returns findings (names + line numbers only, never values). A trailing
40
+ * `# comment` is stripped before matching so a commented-out export is ignored;
41
+ * the variable name always precedes any inline `#`, so stripping never loses it.
42
+ */
43
+ export declare function scanRcExports(basename: string, content: string): RcSecretFinding[];
44
+ /**
45
+ * Scan the current user's shell rc files. Reads each file that exists under
46
+ * `homeDir` (default `os.homedir()`) and aggregates the findings. Missing or
47
+ * unreadable files are skipped silently — an advisory, not a hard requirement.
48
+ */
49
+ export declare function scanUserRcFiles(homeDir?: string): RcSecretFinding[];
50
+ /**
51
+ * Build the advisory lines for `agents doctor` from a set of findings. Returns
52
+ * `[]` when there is nothing to report. The first line is the headline; the
53
+ * rest are indented detail. Names only — never values.
54
+ */
55
+ export declare function rcSecretWarningLines(findings: RcSecretFinding[]): string[];
@@ -0,0 +1,154 @@
1
+ /**
2
+ * Detect credentials exported from shell rc files.
3
+ *
4
+ * A secret exported from `~/.zshenv`, `~/.zshrc`, `~/.bashrc`, `~/.profile`, …
5
+ * is inherited by every process the login shell spawns and is readable from
6
+ * `/proc/<pid>/environ` by any same-user process — the master passphrase leak
7
+ * behind RUSH-1968. `.zshenv` in particular is sourced by *every* zsh
8
+ * invocation, including non-interactive `ssh host 'cmd'`, so the value lands in
9
+ * the environment of essentially everything the box runs.
10
+ *
11
+ * The keychain-backed store (`agents secrets`) is the sanctioned home for
12
+ * credentials; the file-store master passphrase belongs in the off-env 0600 key
13
+ * file (`~/.agents/.secrets-key/passphrase`), never a shell rc export.
14
+ *
15
+ * This scanner powers a warn-level advisory in `agents doctor`. It reads only
16
+ * the exported variable *name* and its line number — never the value — so a
17
+ * finding can be printed, logged, or shipped without leaking the secret.
18
+ */
19
+ import * as fs from 'fs';
20
+ import * as os from 'os';
21
+ import * as path from 'path';
22
+ /** Shell rc files sourced at login/startup, in the order a report lists them. */
23
+ export const RC_FILENAMES = [
24
+ '.zshenv',
25
+ '.zprofile',
26
+ '.zshrc',
27
+ '.zlogin',
28
+ '.bash_profile',
29
+ '.bash_login',
30
+ '.bashrc',
31
+ '.profile',
32
+ '.kshrc',
33
+ ];
34
+ /** The file-store master key. Its own resolution prefers an off-env 0600 file, so
35
+ * a shell-rc export is always wrong once the store exists (RUSH-1968). */
36
+ const MASTER_PASSPHRASE = 'AGENTS_SECRETS_PASSPHRASE';
37
+ /**
38
+ * Last `_`-delimited segment values that mark a variable as credential-shaped.
39
+ * Matched against the FINAL segment (segment equality, not substring) so
40
+ * `X_API_KEY` fires on `KEY` while `SOME_KEY_PATH` (ends `PATH`) and `MONKEY`
41
+ * (single segment, not equal to `KEY`) do not.
42
+ */
43
+ const CREDENTIAL_SEGMENTS = new Set([
44
+ 'PASSPHRASE',
45
+ 'PASSWORD',
46
+ 'PASSWD',
47
+ 'SECRET',
48
+ 'SECRETS',
49
+ 'TOKEN',
50
+ 'KEY',
51
+ 'APIKEY',
52
+ 'PAT',
53
+ 'CREDENTIAL',
54
+ 'CREDENTIALS',
55
+ ]);
56
+ /**
57
+ * Final segments that look credential-shaped but name a file path, socket, or
58
+ * config knob rather than a secret value — excluded to cut false positives.
59
+ * Applied to the whole name's final segment before the credential check.
60
+ */
61
+ const BENIGN_FINAL_SEGMENTS = new Set([
62
+ 'PATH',
63
+ 'FILE',
64
+ 'DIR',
65
+ 'SOCK',
66
+ 'SOCKET',
67
+ 'ID',
68
+ 'NAME',
69
+ 'PARALLELISM', // TOKENIZERS_PARALLELISM and friends
70
+ ]);
71
+ /** True if a variable name looks like it holds a credential value. */
72
+ export function isCredentialName(name) {
73
+ if (name === MASTER_PASSPHRASE)
74
+ return true;
75
+ const segments = name.toUpperCase().split('_');
76
+ const last = segments[segments.length - 1];
77
+ if (BENIGN_FINAL_SEGMENTS.has(last))
78
+ return false;
79
+ return CREDENTIAL_SEGMENTS.has(last);
80
+ }
81
+ // Matches an env assignment that puts a value in the environment:
82
+ // export NAME=... (sh/bash/zsh)
83
+ // typeset -x NAME=... / declare -x NAME=... (bash/zsh export-attribute form)
84
+ const EXPORT_RE = /^\s*(?:export\s+|(?:typeset|declare)\s+-[A-Za-z]*x[A-Za-z]*\s+)([A-Za-z_][A-Za-z0-9_]*)\s*=/;
85
+ /**
86
+ * Scan one rc file's contents for credential-shaped exports. Pure — takes the
87
+ * text, returns findings (names + line numbers only, never values). A trailing
88
+ * `# comment` is stripped before matching so a commented-out export is ignored;
89
+ * the variable name always precedes any inline `#`, so stripping never loses it.
90
+ */
91
+ export function scanRcExports(basename, content) {
92
+ const out = [];
93
+ const lines = content.split('\n');
94
+ for (let i = 0; i < lines.length; i++) {
95
+ const code = lines[i].replace(/#.*$/, '');
96
+ const m = EXPORT_RE.exec(code);
97
+ if (!m)
98
+ continue;
99
+ const name = m[1];
100
+ if (!isCredentialName(name))
101
+ continue;
102
+ out.push({
103
+ file: basename,
104
+ line: i + 1,
105
+ name,
106
+ isMasterPassphrase: name === MASTER_PASSPHRASE,
107
+ });
108
+ }
109
+ return out;
110
+ }
111
+ /**
112
+ * Scan the current user's shell rc files. Reads each file that exists under
113
+ * `homeDir` (default `os.homedir()`) and aggregates the findings. Missing or
114
+ * unreadable files are skipped silently — an advisory, not a hard requirement.
115
+ */
116
+ export function scanUserRcFiles(homeDir = os.homedir()) {
117
+ const out = [];
118
+ for (const name of RC_FILENAMES) {
119
+ const fp = path.join(homeDir, name);
120
+ let content;
121
+ try {
122
+ content = fs.readFileSync(fp, 'utf8');
123
+ }
124
+ catch {
125
+ continue; // absent or unreadable — nothing to report
126
+ }
127
+ out.push(...scanRcExports(name, content));
128
+ }
129
+ return out;
130
+ }
131
+ /**
132
+ * Build the advisory lines for `agents doctor` from a set of findings. Returns
133
+ * `[]` when there is nothing to report. The first line is the headline; the
134
+ * rest are indented detail. Names only — never values.
135
+ */
136
+ export function rcSecretWarningLines(findings) {
137
+ if (findings.length === 0)
138
+ return [];
139
+ const lines = [];
140
+ const master = findings.filter((f) => f.isMasterPassphrase);
141
+ const others = findings.filter((f) => !f.isMasterPassphrase);
142
+ const total = findings.length;
143
+ lines.push(`${total} credential-shaped export${total === 1 ? '' : 's'} found in shell rc files — ` +
144
+ `readable from /proc/<pid>/environ by any same-user process.`);
145
+ for (const f of master) {
146
+ lines.push(`${f.file}:${f.line} ${f.name} — the file-store master key. Move it off-env to ` +
147
+ `~/.agents/.secrets-key/passphrase (chmod 600) and delete the export.`);
148
+ }
149
+ for (const f of others) {
150
+ lines.push(`${f.file}:${f.line} ${f.name} — move to \`agents secrets\` and inject via \`agents secrets exec\`.`);
151
+ }
152
+ lines.push('Rule: no credentials in env vars or shell config. Use the keychain-backed store.');
153
+ return lines;
154
+ }
@@ -34,11 +34,14 @@ import { type SshExecResult } from '../ssh-exec.js';
34
34
  */
35
35
  export declare function isDangerousRemoteEnvKey(name: string): boolean;
36
36
  /**
37
- * Resolve a `--host` value to an ssh target string. Tries the `agents hosts`
38
- * registry first (enrolled name ssh-config alias / `user@host`); on a miss,
39
- * treats the value as a raw ssh target and validates it against injection.
37
+ * Resolve a `--host` value to an ssh target STRING for the remote-secrets path.
38
+ * Delegates to the single host/device resolver (`resolveHost`, RUSH-1967) so a
39
+ * name here dials the exact same box `run --host` does; on a miss, treats the
40
+ * value as a raw ssh target and validates it against injection. Named distinctly
41
+ * from `../devices/resolve-target.ts` (which returns richer shapes) so importing
42
+ * the wrong one can't silently change which machine you dial.
40
43
  */
41
- export declare function resolveSshTarget(nameOrAlias: string): Promise<string>;
44
+ export declare function resolveHostSshTarget(nameOrAlias: string): Promise<string>;
42
45
  /**
43
46
  * Merge `--host <single>` and `--hosts <a,b,c>` into an ordered, de-duplicated
44
47
  * list. Both flags compose; either alone works. Empty when neither is set.
@@ -59,11 +59,14 @@ function osForTarget(target, lookupName) {
59
59
  return byName ?? resolveRemoteOsSync(target.split('@').pop() ?? target);
60
60
  }
61
61
  /**
62
- * Resolve a `--host` value to an ssh target string. Tries the `agents hosts`
63
- * registry first (enrolled name ssh-config alias / `user@host`); on a miss,
64
- * treats the value as a raw ssh target and validates it against injection.
62
+ * Resolve a `--host` value to an ssh target STRING for the remote-secrets path.
63
+ * Delegates to the single host/device resolver (`resolveHost`, RUSH-1967) so a
64
+ * name here dials the exact same box `run --host` does; on a miss, treats the
65
+ * value as a raw ssh target and validates it against injection. Named distinctly
66
+ * from `../devices/resolve-target.ts` (which returns richer shapes) so importing
67
+ * the wrong one can't silently change which machine you dial.
65
68
  */
66
- export async function resolveSshTarget(nameOrAlias) {
69
+ export async function resolveHostSshTarget(nameOrAlias) {
67
70
  const host = await resolveHost(nameOrAlias);
68
71
  if (host)
69
72
  return sshTargetFor(host);
@@ -0,0 +1,21 @@
1
+ /**
2
+ * The argv tokens for the synchronous secrets-broker clients: the tokens
3
+ * `agentGetSync`/`agentReachableSync`/`agentEvictSync` spawn, and the tokens
4
+ * `index.ts` dispatches on before commander.
5
+ *
6
+ * This is a LEAF module — it imports nothing, so the CLI entrypoint can bind
7
+ * these statically without dragging the secrets graph (and its `state.js` /
8
+ * `install-helper.js` / `session-store.js` imports) into every invocation. That
9
+ * is the whole reason the file exists: with the tokens in `agent.ts`, `index.ts`
10
+ * had to repeat them as string literals, and the two could silently drift —
11
+ * a drift with no visible failure mode, because the CLI answers "unknown
12
+ * command", the client reads the non-zero exit as "broker down", and every
13
+ * secret read falls back to a Touch ID prompt. Exactly the bug this subsystem
14
+ * was fixed for. Sharing one binding makes that divergence a type error rather
15
+ * than something a test has to notice.
16
+ *
17
+ * Same leaf discipline, and same motivation, as `lib/cli-entry.ts`.
18
+ */
19
+ export declare const SYNC_GET_CMD = "__secrets-get";
20
+ export declare const SYNC_PING_CMD = "__secrets-ping";
21
+ export declare const SYNC_LOCK_CMD = "__secrets-lock";
@@ -0,0 +1,21 @@
1
+ /**
2
+ * The argv tokens for the synchronous secrets-broker clients: the tokens
3
+ * `agentGetSync`/`agentReachableSync`/`agentEvictSync` spawn, and the tokens
4
+ * `index.ts` dispatches on before commander.
5
+ *
6
+ * This is a LEAF module — it imports nothing, so the CLI entrypoint can bind
7
+ * these statically without dragging the secrets graph (and its `state.js` /
8
+ * `install-helper.js` / `session-store.js` imports) into every invocation. That
9
+ * is the whole reason the file exists: with the tokens in `agent.ts`, `index.ts`
10
+ * had to repeat them as string literals, and the two could silently drift —
11
+ * a drift with no visible failure mode, because the CLI answers "unknown
12
+ * command", the client reads the non-zero exit as "broker down", and every
13
+ * secret read falls back to a Touch ID prompt. Exactly the bug this subsystem
14
+ * was fixed for. Sharing one binding makes that divergence a type error rather
15
+ * than something a test has to notice.
16
+ *
17
+ * Same leaf discipline, and same motivation, as `lib/cli-entry.ts`.
18
+ */
19
+ export const SYNC_GET_CMD = '__secrets-get';
20
+ export const SYNC_PING_CMD = '__secrets-ping';
21
+ export const SYNC_LOCK_CMD = '__secrets-lock';