@phnx-labs/agents-cli 1.22.57 → 1.22.59

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 (152) hide show
  1. package/CHANGELOG.md +294 -0
  2. package/README.md +29 -0
  3. package/dist/bootstrap.js +39 -1
  4. package/dist/commands/accounts.js +7 -3
  5. package/dist/commands/apply.js +10 -2
  6. package/dist/commands/fork.d.ts +23 -10
  7. package/dist/commands/fork.js +115 -58
  8. package/dist/commands/monitors.js +198 -23
  9. package/dist/commands/prune.js +5 -3
  10. package/dist/commands/routines.d.ts +8 -0
  11. package/dist/commands/routines.js +57 -3
  12. package/dist/commands/routines.test-fixture.js +5 -0
  13. package/dist/commands/send.d.ts +2 -1
  14. package/dist/commands/send.js +7 -5
  15. package/dist/commands/sessions-picker.d.ts +11 -0
  16. package/dist/commands/sessions-picker.js +16 -0
  17. package/dist/commands/sessions-stats.js +37 -5
  18. package/dist/commands/sessions.js +40 -5
  19. package/dist/commands/share.d.ts +14 -0
  20. package/dist/commands/share.js +43 -2
  21. package/dist/commands/ssh.js +12 -1
  22. package/dist/commands/status.js +1 -1
  23. package/dist/commands/sync.js +83 -7
  24. package/dist/commands/traces.js +7 -0
  25. package/dist/commands/versions.js +12 -4
  26. package/dist/commands/view.js +7 -2
  27. package/dist/index.d.ts +1 -1
  28. package/dist/index.js +6 -1
  29. package/dist/lib/account-registry.d.ts +5 -1
  30. package/dist/lib/account-registry.js +47 -14
  31. package/dist/lib/accounting/capacity.d.ts +18 -7
  32. package/dist/lib/accounting/capacity.js +19 -8
  33. package/dist/lib/accounting/usage-sync.d.ts +29 -1
  34. package/dist/lib/accounting/usage-sync.js +76 -2
  35. package/dist/lib/accounting/usage.js +7 -1
  36. package/dist/lib/auth-mint.d.ts +11 -1
  37. package/dist/lib/auth-mint.js +21 -6
  38. package/dist/lib/auto-pull-worker.js +7 -2
  39. package/dist/lib/browser/ipc.d.ts +8 -0
  40. package/dist/lib/browser/ipc.js +87 -0
  41. package/dist/lib/browser/service.d.ts +19 -0
  42. package/dist/lib/browser/service.js +96 -11
  43. package/dist/lib/browser/sessions-list.js +10 -1
  44. package/dist/lib/cloud/rush.d.ts +7 -0
  45. package/dist/lib/cloud/rush.js +29 -1
  46. package/dist/lib/daemon/daemon.d.ts +22 -0
  47. package/dist/lib/daemon/daemon.js +39 -0
  48. package/dist/lib/daemon/runner.d.ts +3 -0
  49. package/dist/lib/daemon/runner.js +86 -45
  50. package/dist/lib/daemon/session-index-service.js +9 -1
  51. package/dist/lib/daemon/usage-sync-service.d.ts +3 -3
  52. package/dist/lib/daemon/usage-sync-service.js +14 -8
  53. package/dist/lib/daemon-services.js +1 -1
  54. package/dist/lib/daemon-ticks.d.ts +15 -0
  55. package/dist/lib/daemon-ticks.js +26 -0
  56. package/dist/lib/device-config.d.ts +5 -1
  57. package/dist/lib/device-config.js +2 -2
  58. package/dist/lib/devices/connect.d.ts +17 -8
  59. package/dist/lib/devices/connect.js +31 -14
  60. package/dist/lib/devices/health.js +5 -1
  61. package/dist/lib/devices/pool.d.ts +25 -2
  62. package/dist/lib/devices/pool.js +32 -2
  63. package/dist/lib/devices/stats-cache.d.ts +0 -6
  64. package/dist/lib/devices/stats-cache.js +2 -9
  65. package/dist/lib/doctor-diff.d.ts +14 -0
  66. package/dist/lib/doctor-diff.js +120 -9
  67. package/dist/lib/fleet/manifest.d.ts +17 -0
  68. package/dist/lib/fleet/manifest.js +26 -0
  69. package/dist/lib/git.d.ts +38 -0
  70. package/dist/lib/git.js +58 -0
  71. package/dist/lib/hooks/install.d.ts +27 -11
  72. package/dist/lib/hooks/install.js +42 -17
  73. package/dist/lib/hosts/ready.d.ts +8 -0
  74. package/dist/lib/hosts/ready.js +13 -2
  75. package/dist/lib/hosts/reconnect.d.ts +52 -203
  76. package/dist/lib/hosts/reconnect.js +64 -284
  77. package/dist/lib/installations/migrate.d.ts +6 -120
  78. package/dist/lib/installations/migrate.js +27 -259
  79. package/dist/lib/installations/shims.d.ts +13 -95
  80. package/dist/lib/installations/shims.js +22 -139
  81. package/dist/lib/installations/store.js +1 -1
  82. package/dist/lib/installations/versions.d.ts +43 -133
  83. package/dist/lib/installations/versions.js +94 -206
  84. package/dist/lib/monitors/config.d.ts +71 -3
  85. package/dist/lib/monitors/config.js +100 -12
  86. package/dist/lib/monitors/pid-watch.d.ts +35 -0
  87. package/dist/lib/monitors/pid-watch.js +45 -0
  88. package/dist/lib/monitors/remote.d.ts +18 -0
  89. package/dist/lib/monitors/remote.js +11 -0
  90. package/dist/lib/permissions.js +7 -2
  91. package/dist/lib/plugins/plugins.d.ts +17 -3
  92. package/dist/lib/plugins/plugins.js +84 -9
  93. package/dist/lib/plugins/skills.d.ts +8 -1
  94. package/dist/lib/plugins/skills.js +18 -2
  95. package/dist/lib/pty-server.d.ts +14 -0
  96. package/dist/lib/pty-server.js +49 -5
  97. package/dist/lib/refresh.d.ts +9 -0
  98. package/dist/lib/refresh.js +3 -1
  99. package/dist/lib/routine-readiness.d.ts +15 -1
  100. package/dist/lib/routine-readiness.js +41 -0
  101. package/dist/lib/sandbox.d.ts +4 -1
  102. package/dist/lib/sandbox.js +30 -1
  103. package/dist/lib/secrets/agent.d.ts +80 -225
  104. package/dist/lib/secrets/agent.js +139 -401
  105. package/dist/lib/secrets/bundles.d.ts +73 -222
  106. package/dist/lib/secrets/bundles.js +168 -467
  107. package/dist/lib/secrets/drivers/rush.js +5 -0
  108. package/dist/lib/secrets/reaper.d.ts +28 -70
  109. package/dist/lib/secrets/reaper.js +30 -85
  110. package/dist/lib/secrets/remote.d.ts +42 -129
  111. package/dist/lib/secrets/remote.js +55 -173
  112. package/dist/lib/self-heal/checks/install-staging.d.ts +4 -0
  113. package/dist/lib/self-heal/checks/install-staging.js +96 -0
  114. package/dist/lib/self-heal/registry.js +2 -0
  115. package/dist/lib/self-heal/types.d.ts +1 -1
  116. package/dist/lib/self-update.d.ts +65 -0
  117. package/dist/lib/self-update.js +138 -0
  118. package/dist/lib/session/active.d.ts +13 -1
  119. package/dist/lib/session/active.js +2 -0
  120. package/dist/lib/session/cloud.js +5 -0
  121. package/dist/lib/session/db.d.ts +51 -6
  122. package/dist/lib/session/db.js +266 -20
  123. package/dist/lib/session/fork.d.ts +45 -26
  124. package/dist/lib/session/fork.js +32 -95
  125. package/dist/lib/session/tool-calls.d.ts +43 -1
  126. package/dist/lib/session/tool-calls.js +74 -44
  127. package/dist/lib/session/tool-store.d.ts +33 -2
  128. package/dist/lib/session/tool-store.js +56 -3
  129. package/dist/lib/smart-launch.d.ts +6 -0
  130. package/dist/lib/smart-launch.js +5 -2
  131. package/dist/lib/staleness/writers/plugins.js +5 -2
  132. package/dist/lib/staleness/writers/sources.d.ts +5 -0
  133. package/dist/lib/staleness/writers/sources.js +2 -1
  134. package/dist/lib/staleness/writers/subagents.js +13 -3
  135. package/dist/lib/state.d.ts +7 -4
  136. package/dist/lib/state.js +7 -4
  137. package/dist/lib/subagents.js +8 -2
  138. package/dist/lib/sync-status.d.ts +22 -0
  139. package/dist/lib/sync-status.js +27 -0
  140. package/dist/lib/sync-umbrella.d.ts +9 -0
  141. package/dist/lib/sync-umbrella.js +21 -2
  142. package/dist/lib/teams/scheduler.d.ts +10 -0
  143. package/dist/lib/teams/scheduler.js +8 -0
  144. package/dist/lib/traces/insights.d.ts +47 -14
  145. package/dist/lib/traces/insights.js +92 -21
  146. package/dist/lib/traces/phenotype.d.ts +23 -3
  147. package/dist/lib/traces/phenotype.js +72 -24
  148. package/dist/lib/traces/sync.d.ts +128 -6
  149. package/dist/lib/traces/sync.js +294 -35
  150. package/dist/lib/traces/worker-template.js +154 -1
  151. package/dist/lib/view-types.d.ts +12 -0
  152. package/package.json +2 -2
@@ -1,24 +1,8 @@
1
1
  /**
2
2
  * Secret bundles — named sets of environment variables backed by a secret store.
3
- *
4
- * Bundle metadata (name, description, vars map) is stored as a JSON blob under
5
- * `agents-cli.bundles.<name>`; secret values live one per item under
6
- * `agents-cli.secrets.<bundle>.<key>`. Two backends carry those items:
7
- *
8
- * - `keychain` (default): the macOS Keychain (device-local, Touch ID / device
9
- * passcode gated) or Linux libsecret — see src/lib/secrets/index.ts.
10
- * - `file`: an AES-256-GCM encrypted-file store keyed by a passphrase
11
- * (src/lib/secrets/filestore.ts). Opt-in, for headless / remote runs where
12
- * no biometry prompt can be satisfied (e.g. a release on a remote Mac over
13
- * SSH). The item-name scheme is identical, so the only difference is where
14
- * bytes land. A file-backed bundle is discovered by the presence of its
15
- * metadata item in the file store.
16
- * - `vault`: a single age-encrypted ~/.agents/vault.age file unlocked by
17
- * `agents secrets vault unlock`; intended for user-managed cross-machine file sync.
18
- *
19
- * Server-backed cross-machine sync is handled by src/lib/secrets/sync.ts via
20
- * an explicit encrypted export/import flow; the bundle layer also supports the
21
- * local vault backend for user-managed file sync.
3
+ * Metadata lives under `agents-cli.bundles.<name>`; values under
4
+ * `agents-cli.secrets.<bundle>.<key>`. Backends: `keychain` (default),
5
+ * `file` (headless/passphrase), and `vault` (age-encrypted, user-synced).
22
6
  */
23
7
  import * as fs from 'fs';
24
8
  import * as os from 'os';
@@ -47,11 +31,9 @@ const keychainStore = {
47
31
  delete: deleteKeychainToken,
48
32
  list: listKeychainItems,
49
33
  };
50
- // The file store auto-provisions a stable machine-local passphrase on EVERY
51
- // platform (macOS included) — a 0600 key file, encryption-at-rest with the same
52
- // posture as an SSH key — so `agents secrets` "just works" with no passphrase to
53
- // set, type, or remember, and no Touch ID. Set AGENTS_SECRETS_PASSPHRASE to opt
54
- // into an off-disk key.
34
+ // File store auto-provisions a machine-local 0600 key on every platform,
35
+ // so `agents secrets` works headless without a passphrase. Override with
36
+ // AGENTS_SECRETS_PASSPHRASE.
55
37
  const fileItemStore = {
56
38
  has: (item) => fileStore.has(item),
57
39
  get: (item) => fileStore.get(item),
@@ -87,10 +69,8 @@ function itemStore(backend) {
87
69
  return keychainStore;
88
70
  }
89
71
  /**
90
- * Discover a bundle's backend by location: a file-backed bundle's metadata
91
- * item exists in the encrypted-file store. This is a plain file-existence
92
- * check — no passphrase, no Touch ID — so it sidesteps the chicken-and-egg of
93
- * "read metadata to learn where metadata lives." Absent ⇒ keychain.
72
+ * Discover a bundle's backend by location. File store is checked first (a plain
73
+ * existence test, no passphrase); absent/locked vault falls back to keychain.
94
74
  */
95
75
  export function bundleBackend(name) {
96
76
  const item = BUNDLE_META_PREFIX + name;
@@ -125,7 +105,7 @@ export const SECRET_TYPES = [
125
105
  'webhook',
126
106
  'note',
127
107
  ];
128
- /** Minimum gap between last_used updates so the keychain isn't written on every secrets injection. */
108
+ /** Throttle last_used writes so the keychain isn't touched on every injection. */
129
109
  const LAST_USED_THROTTLE_MS = 60_000;
130
110
  export const BUNDLE_NAME_PATTERN = /^[a-z0-9][a-z0-9\-_.]{0,48}$/i;
131
111
  export const ENV_KEY_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/;
@@ -138,11 +118,8 @@ export const RESERVED_ENV_NAMES = new Set([
138
118
  'TMPDIR', 'TMP', 'TEMP', 'LOGNAME', 'UID', 'EUID', 'HOSTNAME',
139
119
  ]);
140
120
  /**
141
- * The reserved FILE-BACKED bundle that holds long-lived Claude setup-tokens.
142
- * Usage/probe reads authenticate with these instead of the ACL-bound login item,
143
- * so they never pop Touch ID and they can cross the fleet. A keychain- or
144
- * vault-backed bundle of this name is a misconfiguration: the consumer used to
145
- * return null (SEC-GAP-3) and silently fall through to Touch ID.
121
+ * Reserved FILE-BACKED bundle for long-lived setup tokens. Must be file-backed;
122
+ * a keychain/vault `auth` bundle is a misconfiguration and is ignored.
146
123
  */
147
124
  export const AUTH_BUNDLE_NAME = 'auth';
148
125
  export const AUTH_BUNDLE_BACKEND = 'file';
@@ -163,7 +140,7 @@ export class ReservedBundleWrongBackendError extends Error {
163
140
  this.backend = backend;
164
141
  }
165
142
  }
166
- /** Fail loud when `name` is reserved and `backend` is not the required one. */
143
+ /** Fail loud when a reserved bundle is on the wrong backend. */
167
144
  export function assertReservedBundleBackend(name, backend) {
168
145
  if (!isReservedBundleName(name))
169
146
  return;
@@ -172,21 +149,33 @@ export function assertReservedBundleBackend(name, backend) {
172
149
  }
173
150
  }
174
151
  /**
175
- * Presence + backend of the reserved `auth` bundle. `ok` is true when the
176
- * bundle is absent (nothing to fix) or present and file-backed.
152
+ * Check the reserved `auth` bundle. `ok` is true when absent or file-backed.
153
+ *
154
+ * Read-only status probe (`agents doctor`, `agents fleet apply`) — unlike the
155
+ * destructive-write guards `bundleExists()`/`hasKeychainToken()` document
156
+ * themselves as failing loud for, a diagnostic MUST NOT crash the whole
157
+ * command because ONE optional finding could not reach the keychain (e.g. the
158
+ * macOS Keychain helper source is unavailable — PHNX-3385). Treat an
159
+ * unreachable keychain the same as "bundle absent": nothing to warn about.
177
160
  */
178
161
  export function inspectReservedAuthBundle() {
179
- if (!bundleExists(AUTH_BUNDLE_NAME)) {
162
+ let exists;
163
+ try {
164
+ exists = bundleExists(AUTH_BUNDLE_NAME);
165
+ }
166
+ catch {
167
+ return { exists: false, backend: null, ok: true };
168
+ }
169
+ if (!exists) {
180
170
  return { exists: false, backend: null, ok: true };
181
171
  }
182
172
  const backend = bundleBackend(AUTH_BUNDLE_NAME);
183
173
  return { exists: true, backend, ok: backend === AUTH_BUNDLE_BACKEND };
184
174
  }
185
175
  /**
186
- * After a file-backed import, actually decrypt the keys and fail if any are
187
- * unreadable. Import used to print "Imported N key(s)" from the write tally
188
- * alone — ciphertext sealed under a forwarded AGENTS_SECRETS_PASSPHRASE that
189
- * the destination daemon does not hold still counted as success.
176
+ * After a file-backed import, verify the keys actually decrypt. Import used to
177
+ * report success for ciphertext sealed under a forwarded passphrase this process
178
+ * does not hold.
190
179
  */
191
180
  export function assertFileBundleDecryptable(name, keys) {
192
181
  if (keys.length === 0)
@@ -273,9 +262,8 @@ export function validateSecretType(t) {
273
262
  }
274
263
  }
275
264
  /**
276
- * Validate an `expires` value. Accepts strict 'YYYY-MM-DD' only and rejects
277
- * any date <= now. We compare against end-of-day UTC for the chosen date so
278
- * "today" is treated as past (per spec).
265
+ * Validate a future `expires` date. Accepts strict 'YYYY-MM-DD'; end-of-day UTC
266
+ * means "today" is past.
279
267
  */
280
268
  export function validateExpiresFutureDated(iso) {
281
269
  if (!/^\d{4}-\d{2}-\d{2}$/.test(iso)) {
@@ -296,12 +284,9 @@ export function bundleExists(name) {
296
284
  return itemStore(bundleBackend(name)).has(bundleMetaItem(name));
297
285
  }
298
286
  /**
299
- * Thrown by `readBundle` for the one state `readBundleIfDecryptable` may treat
300
- * as "present but permanently unreadable": file-store ciphertext on disk that
301
- * will not decrypt with the passphrase in effect (lost/rotated key or a tampered
302
- * store). It is deliberately narrow — a bundle that is merely *locked for this
303
- * run* (headless macOS without `AGENTS_SECRETS_PASSPHRASE`, or a vault that is
304
- * not logged in) is recoverable and must not be collapsed into this.
287
+ * Thrown for file-store ciphertext that will not decrypt (lost/rotated key or
288
+ * tampered store). Narrow: temporarily-locked bundles are recoverable and must
289
+ * not be collapsed here.
305
290
  */
306
291
  export class BundleUndecryptableError extends Error {
307
292
  constructor(message) {
@@ -310,18 +295,9 @@ export class BundleUndecryptableError extends Error {
310
295
  }
311
296
  }
312
297
  /**
313
- * Read a bundle, or return null when its metadata is present but genuinely
314
- * cannot be decrypted — a lost or rotated file-store passphrase, or a tampered
315
- * file store (signalled by `BundleUndecryptableError`).
316
- *
317
- * Deleting such a bundle is the only way out of that state, and deletion needs
318
- * no plaintext, so it must not be gated behind a successful decrypt. Every other
319
- * failure rethrows: a genuinely missing bundle ("not found"), and — critically —
320
- * a bundle that is only *temporarily locked* for this run (headless macOS with no
321
- * `AGENTS_SECRETS_PASSPHRASE`, or a not-logged-in vault). Collapsing that
322
- * recoverable "set the env / log in" state into "unreadable, safe to delete"
323
- * would let `secrets delete <name> --yes` silently destroy a perfectly healthy
324
- * bundle from a cron/launchd run that merely forgot to export the passphrase.
298
+ * Read a bundle, returning null only when metadata is present but permanently
299
+ * unreadable (`BundleUndecryptableError`). Other failures — including temporary
300
+ * lockout — rethrow so a healthy bundle is never deleted by mistake.
325
301
  */
326
302
  export function readBundleIfDecryptable(name) {
327
303
  try {
@@ -340,37 +316,28 @@ export function readBundle(name) {
340
316
  assertVaultBackendUsable(name);
341
317
  let json;
342
318
  try {
343
- // Bundle metadata carries no biometry ACL (SEC-4), so this read is silent
344
- // even in a headless context — attest that to the raw-read storm guard so
345
- // a headless `readBundle` never trips the fail-fast. (A legacy
346
- // pre-metadata-heal ACL'd metadata item can still prompt once; it heals on
347
- // the next interactive read.)
319
+ // Metadata is no-ACL by contract; attest silentNoAcl so headless reads don't
320
+ // trip the raw-read storm guard. A legacy ACL'd item may prompt once, then heals.
348
321
  json = backend === 'keychain'
349
322
  ? getKeychainToken(bundleMetaItem(name), { silentNoAcl: true })
350
323
  : itemStore(backend).get(bundleMetaItem(name));
351
324
  }
352
325
  catch (err) {
353
- // A file-backed bundle whose metadata is on disk but fails to decrypt is a
354
- // wrong-passphrase error, not a missing bundle — surface that clearly.
326
+ // File-backed metadata that fails to decrypt is a wrong-passphrase error.
355
327
  if (backend === 'file' && fileStore.has(bundleMetaItem(name))) {
356
328
  throw new BundleUndecryptableError(`Bundle '${name}': failed to decrypt — wrong AGENTS_SECRETS_PASSPHRASE or tampered file store. (${err.message})`);
357
329
  }
358
330
  if (vaultExists() && !getVaultSession().loggedIn) {
359
331
  throw new Error(`Synced secrets are locked. Run: agents secrets vault unlock`);
360
332
  }
361
- // Distinguish a genuinely-absent bundle from a present-but-unreadable one
362
- // (a locked login keychain, or a legacy ACL'd metadata item before first
363
- // unlock). `has` counts an unreadable item as present, so a metadata item
364
- // that exists but could not be read must not report as "not found" — an
365
- // existence answer and a read answer may not contradict (RUSH-2253).
333
+ // A present-but-unreadable keychain item must not report "not found".
366
334
  if (backend === 'keychain') {
367
335
  let present;
368
336
  try {
369
337
  present = hasKeychainToken(bundleMetaItem(name));
370
338
  }
371
339
  catch (probeErr) {
372
- // Keychain unreachable (RUSH-2235 fail-loud): neither absent nor
373
- // add-the-key — surface the reachability failure, not a false absence.
340
+ // Keychain unreachable — fail loud rather than report false absence.
374
341
  throw new Error(`Secrets bundle '${name}': ${probeErr.message}`);
375
342
  }
376
343
  if (present) {
@@ -390,9 +357,7 @@ export function readBundle(name) {
390
357
  if (!parsed || typeof parsed !== 'object') {
391
358
  throw new Error(`Bundle '${name}' is malformed.`);
392
359
  }
393
- // Unknown fields on the JSON (e.g. legacy sync flags) are silently dropped
394
- // here; the SecretsBundle shape is the only source of truth. `backend` is
395
- // authoritative from location discovery, not the persisted field.
360
+ // Drop unknown fields; `backend` is authoritative from location discovery.
396
361
  const bundle = {
397
362
  name,
398
363
  description: parsed.description,
@@ -418,12 +383,7 @@ export function readBundle(name) {
418
383
  }
419
384
  return bundle;
420
385
  }
421
- /** Normalize the persisted prompt policy. The on-disk `tier` key uses legacy
422
- * tokens for cross-version compatibility: `session` ⇒ `hold`, `biometry` ⇒ an
423
- * explicit `always`. An absent token ⇒ undefined, which resolves to the
424
- * configured default policy (`hold`). Persisting an explicit `always` as the
425
- * legacy `biometry` token keeps older CLIs correct — they don't know `hold`,
426
- * read `biometry` as undefined, and fall back to their own always default. */
386
+ /** Normalize the persisted `tier` token to the current policy vocabulary. */
427
387
  function parsePolicy(raw) {
428
388
  if (raw === 'hold' || raw === 'daily' || raw === 'session')
429
389
  return 'hold';
@@ -433,11 +393,7 @@ function parsePolicy(raw) {
433
393
  return 'never';
434
394
  return undefined;
435
395
  }
436
- /** The default prompt policy applied to bundles without an explicit per-bundle
437
- * policy. Configurable via `secrets.policy` in agents.yaml; `hold` (one Touch ID
438
- * per hold window — `secrets.agent.holdMs`, 7d by default) unless the user
439
- * explicitly opts back into prompt-every-time with `always`. Best-effort: an
440
- * unreadable config falls back to the `hold` default. */
396
+ /** Default policy for bundles without an explicit one (`secrets.policy`). */
441
397
  export function secretsDefaultPolicy() {
442
398
  try {
443
399
  return readMeta().secrets?.policy === 'always' ? 'always' : 'hold';
@@ -446,17 +402,14 @@ export function secretsDefaultPolicy() {
446
402
  return 'hold';
447
403
  }
448
404
  }
449
- /** The effective prompt policy of a bundle (absent ⇒ the configured default). */
405
+ /** Effective prompt policy of a bundle. */
450
406
  export function bundlePolicy(bundle) {
451
407
  return bundle.policy ?? secretsDefaultPolicy();
452
408
  }
453
409
  /**
454
- * Whether a bundle write should evict the broker-held copy. Pure + exported
455
- * for regression coverage. Skips when the writer opted out (stampLastUsed),
456
- * when the broker integration is disabled (AGENTS_SECRETS_NO_AGENT — the same
457
- * kill-switch the read fast-path honors), or when a test keychain backend is
458
- * installed (an in-memory backend has no real keychain behind it, and a test
459
- * writing bundle 'prod' must never evict the user's real 'prod' unlock).
410
+ * Whether a bundle write should evict the broker-held copy. Exported for tests.
411
+ * Skips when the writer opted out, the broker is disabled, or a test backend is
412
+ * active (so tests don't evict the user's real unlocks).
460
413
  */
461
414
  export function shouldEvictAfterBundleWrite(skipRequested, noAgentEnv, backendOverridden) {
462
415
  if (skipRequested)
@@ -476,7 +429,7 @@ function prepareBundleWrite(bundle) {
476
429
  for (const key of Object.keys(bundle.vars)) {
477
430
  validateEnvKey(key);
478
431
  }
479
- // Strip empty/all-undefined meta entries so the JSON stays tidy.
432
+ // Strip empty meta entries so the JSON stays tidy.
480
433
  let meta;
481
434
  if (bundle.meta) {
482
435
  for (const [key, m] of Object.entries(bundle.meta)) {
@@ -494,27 +447,18 @@ function prepareBundleWrite(bundle) {
494
447
  }
495
448
  }
496
449
  }
497
- // Stamp timestamps on the bundle so callers see what got persisted. created_at
498
- // is sticky — once set we never overwrite it, including on legacy bundles
499
- // that already carry one. updated_at always advances.
450
+ // created_at is sticky; updated_at always advances.
500
451
  const now = new Date().toISOString();
501
452
  if (!bundle.created_at)
502
453
  bundle.created_at = now;
503
454
  bundle.updated_at = now;
504
455
  const payload = {
505
- // The bundle's own name, persisted since #316: with hashed service names
506
- // the keychain item name is opaque, so listBundles recovers the display
507
- // name from this field. Older CLIs drop unknown fields on read — safe.
456
+ // Persist the display name so hashed keychain items remain listable.
508
457
  name: bundle.name,
509
458
  description: bundle.description,
510
459
  allow_exec: bundle.allow_exec ? true : undefined,
511
460
  backend: backend === 'keychain' ? undefined : backend,
512
- // Wire format: persist the policy under the legacy `tier` token so older CLI
513
- // versions on other synced machines keep reading it — `hold`⇒`session`,
514
- // explicit `always`⇒`biometry`, `never`⇒`none`. An absent policy omits the
515
- // token entirely and resolves to the configured default (`hold`) on read.
516
- // An older CLI that doesn't know `none` reads it as undefined and falls back
517
- // to its own default — safe, since it also lacks the no-ACL write path.
461
+ // Legacy wire token for cross-version sync.
518
462
  tier: bundle.policy === 'hold' ? 'session'
519
463
  : bundle.policy === 'always' ? 'biometry'
520
464
  : bundle.policy === 'never' ? 'none'
@@ -533,25 +477,17 @@ function prepareBundleWrite(bundle) {
533
477
  }
534
478
  function finishBundleWrite(bundle, opts) {
535
479
  emit('secrets.set', { module: 'secrets', bundle: bundle.name });
536
- // A broker-held snapshot predates this write; evict it so the next read
537
- // re-resolves from the keychain instead of serving stale values.
480
+ // Evict the broker-held snapshot and durable session so the next read resolves fresh.
538
481
  if (shouldEvictAfterBundleWrite(Boolean(opts.skipBrokerEviction), process.env.AGENTS_SECRETS_NO_AGENT, isKeychainBackendOverridden())) {
539
482
  agentEvictSync(bundle.name);
540
- // Also drop any durable session snapshot, or a broker restart would rehydrate
541
- // the stale env after a rotate/rename (session-store.ts).
542
483
  deleteSession(bundle.name);
543
484
  }
544
485
  }
545
486
  export function writeBundle(bundle, opts = {}) {
546
487
  const prepared = prepareBundleWrite(bundle);
547
- // Bundle metadata (name, description, policy, var names + refs, and any
548
- // non-sensitive `--value` literals) is stored WITHOUT the biometry ACL at
549
- // EVERY tier. It is non-sensitive by contract — the real secret values live in
550
- // separate agents-cli.secrets.* items that keep the bundle's policy ACL — so a
551
- // no-ACL metadata item is what lets `secrets list` and crabbox's `agents
552
- // devices list` enumerate bundles with no Touch ID prompt (RUSH-1759). On an
553
- // un-updated pinned helper this write fails loudly (the no-ACL command is
554
- // missing) rather than silently landing an ACL'd item.
488
+ // Metadata is non-sensitive by contract and stored no-ACL so `secrets list`
489
+ // can enumerate without Touch ID. A pinned helper without the no-ACL command
490
+ // fails loudly rather than silently landing an ACL'd item.
555
491
  itemStore(prepared.backend).set(prepared.metadataItem, prepared.metadataJson, { noAcl: true });
556
492
  if (prepared.backend === 'keychain')
557
493
  addBundleToMetaIndex(bundle.name);
@@ -561,13 +497,9 @@ export function writeBundleWithItems(bundle, items, opts = {}) {
561
497
  const prepared = prepareBundleWrite(bundle);
562
498
  const store = itemStore(prepared.backend);
563
499
  if (prepared.backend === 'keychain') {
564
- // Only the keychain backend has a biometry ACL. Secret VALUE items carry the
565
- // bundle's policy ACL (`never` ⇒ no-ACL); the metadata item is ALWAYS no-ACL
566
- // (see writeBundle). The two must NOT ride one batch flag — a single noAcl
567
- // over both would either strip biometry off the real secrets or re-ACL the
568
- // metadata. Write the values first, then the metadata last: bundle discovery
569
- // keys on the metadata item's presence, so metadata-last means a partial
570
- // write reads as "no bundle yet", never as a bundle with missing values.
500
+ // Keychain values carry the policy ACL; metadata is always no-ACL. They cannot
501
+ // share one batch flag, and metadata is written last so partial writes read as
502
+ // "no bundle yet".
571
503
  if (items.size > 0) {
572
504
  store.setBatch(new Map(items), { noAcl: bundle.policy === 'never' });
573
505
  }
@@ -575,8 +507,7 @@ export function writeBundleWithItems(bundle, items, opts = {}) {
575
507
  addBundleToMetaIndex(bundle.name);
576
508
  }
577
509
  else {
578
- // file / vault: no ACL concept (noAcl is ignored), so one batched write is
579
- // both correct and cheaper — e.g. a single age re-encrypt for the vault.
510
+ // File/vault have no ACL; one batched write is cheaper.
580
511
  const batch = new Map(items);
581
512
  batch.set(prepared.metadataItem, prepared.metadataJson);
582
513
  store.setBatch(batch, { noAcl: bundle.policy === 'never' });
@@ -599,16 +530,9 @@ export function deleteBundle(name) {
599
530
  return deleted;
600
531
  }
601
532
  /**
602
- * Parse a stored metadata JSON blob into a SecretsBundle, applying the lenient
603
- * posture listBundles wants (skip malformed / invalid-key bundles rather than
604
- * throw). `backend` is authoritative from where the item was found. Returns
605
- * null to skip.
606
- *
607
- * `nameHint` is the name recovered from a cleartext service name (Linux, the
608
- * file store, pre-re-key items) — authoritative when present, and the only
609
- * source for legacy metadata that predates the persisted `name` field. With
610
- * hashed service names (macOS, #316) the hint is undefined and the name comes
611
- * from the JSON payload written by writeBundle.
533
+ * Parse a stored metadata JSON blob into a SecretsBundle, skipping malformed
534
+ * bundles. `backend` is authoritative. `nameHint` is cleartext (Linux/file/
535
+ * legacy); hashed keychain items recover the name from the persisted payload.
612
536
  */
613
537
  function parseBundleMeta(nameHint, json, backend) {
614
538
  let parsed;
@@ -646,9 +570,8 @@ function parseBundleMeta(nameHint, json, backend) {
646
570
  }
647
571
  return bundle;
648
572
  }
649
- // Sentinel marking the one-time RUSH-1759 metadata-ACL heal as done. Lives under
650
- // the regenerable helpers dir (same tree as the secrets-agent runtime state), so
651
- // a cache wipe just re-runs the heal — harmless, since it is idempotent.
573
+ // Sentinel for the one-time metadata-ACL heal. Lives in the regenerable helpers
574
+ // dir, so a cache wipe re-runs it harmlessly.
652
575
  const METADATA_NOACL_SENTINEL = 'bundles-metadata-noacl-healed';
653
576
  function metadataNoAclSentinelPath() {
654
577
  return path.join(getHelpersDir(), 'secrets-agent', METADATA_NOACL_SENTINEL);
@@ -668,46 +591,26 @@ function markBundleMetadataAclHealed() {
668
591
  fs.writeFileSync(file, '', 'utf8');
669
592
  }
670
593
  catch {
671
- // Best effort — a missing sentinel just means the (idempotent) heal re-runs
672
- // on the next broker-miss listing.
673
- }
674
- }
675
- // ── No-ACL bundle-metadata name index (kills the enumeration Touch ID storm) ──
676
- // listBundles cannot ask the keychain for "just the metadata items": with hashed
677
- // service names (#316) the metadata names are opaque (`agents-cli.h.<ns>.m`), so
678
- // listKeychainItems(BUNDLE_META_PREFIX) falls back to a BROAD `agents-cli.` scan
679
- // that also MATCHES the ACL'd secret VALUE items. On some machines macOS
680
- // evaluates those value ACLs during that attributes-only scan and pops a generic
681
- // "Agents CLI needs to authenticate" sheet — on EVERY launch (session-title
682
- // generation, `agents devices list`, every agent run), because listBundles runs
683
- // on essentially every secrets touch. Neither UIFail nor LAContext can list the
684
- // no-ACL items while skipping the ACL'd ones (both return nothing), so the fix is
685
- // to NOT do the broad scan: keep a per-machine index of the metadata items'
686
- // STORAGE names in the regenerable helpers dir and read THAT (a silent file read)
687
- // instead. The index holds opaque hashes only — no cleartext bundle names, so it
688
- // leaks nothing #316 didn't already. It self-heals: absent/unbuilt → listBundles
689
- // rebuilds it from the one-time broad scan; a stale entry only makes `secrets
690
- // list` cosmetically incomplete and never affects a resolve-by-name (which
691
- // computes the hashed name directly, never through this index).
594
+ // Best effort — missing sentinel just re-runs the idempotent heal later.
595
+ }
596
+ }
597
+ // No-ACL bundle-metadata name index. With hashed service names (#316), listing
598
+ // metadata falls back to a broad `agents-cli.` scan that matches ACL'd secret
599
+ // values and pops Touch ID. We keep a per-machine index of opaque metadata
600
+ // storage names in the regenerable helpers dir to avoid that scan. Stale entries
601
+ // only make `secrets list` cosmetically incomplete; resolve-by-name never uses it.
692
602
  function bundleMetaIndexPath() {
693
- // Test-only override (mirrors AGENTS_DAEMON_DIR): redirect to a fork-private
694
- // temp so unit tests never touch the real helpers dir. Never set in prod.
603
+ // Test-only redirect so the suite never touches the real helpers dir.
695
604
  return (process.env.AGENTS_SECRETS_META_INDEX_FILE ||
696
605
  path.join(getHelpersDir(), 'secrets-agent', 'bundle-meta-index.json'));
697
606
  }
698
- // Changes iff the service-name hashing key changes (the #316 re-key, or a
699
- // cleartext<->hashed transition). Stamped into the index so an index built under
700
- // an OLD key reads as absent and is rebuilt — otherwise a re-key would leave
701
- // stale hashed names that resolve to nothing and make every bundle "vanish".
607
+ // Fingerprint invalidates the index when the hashing key changes (#316 re-key),
608
+ // so stale storage names don't make bundles "vanish".
702
609
  function metaIndexFingerprint() {
703
610
  return keychainServiceAlias(`${BUNDLE_META_PREFIX}meta-index-fingerprint`);
704
611
  }
705
612
  function readBundleMetaIndex() {
706
- // A test-installed in-memory keychain is NOT the real store this index mirrors;
707
- // reading (and later writing) the real ~/.agents index from a mock-backend test
708
- // would leak fixture bundle names into a developer's live cache. Same guard the
709
- // sibling healKeychainBundleMetadataAclOnce uses — treat the index as absent so
710
- // listBundles falls back to the (mock) scan.
613
+ // Never read/write the real index from a mock-backend test.
711
614
  if (isKeychainBackendOverridden())
712
615
  return null;
713
616
  try {
@@ -729,24 +632,18 @@ function writeBundleMetaIndex(services) {
729
632
  const file = bundleMetaIndexPath();
730
633
  fs.mkdirSync(path.dirname(file), { recursive: true });
731
634
  const payload = { fp: metaIndexFingerprint(), services: [...new Set(services)].sort() };
732
- // Atomic write (unique temp + rename) so a concurrent reader/writer never
733
- // sees a half-written file. The read-modify-write in add/remove can still
734
- // race two concurrent BUNDLE mutations and drop an entry, but that only makes
735
- // a `secrets list` cosmetically incomplete (never a resolve-by-name) and
736
- // self-heals on the next rebuild — an acceptable trade for rare bundle edits.
635
+ // Atomic write. Concurrent edits may still race and drop an entry, but that
636
+ // only makes `secrets list` cosmetically incomplete and self-heals.
737
637
  const tmp = `${file}.${process.pid}.tmp`;
738
638
  fs.writeFileSync(tmp, JSON.stringify(payload), 'utf8');
739
639
  fs.renameSync(tmp, file);
740
640
  }
741
641
  catch {
742
- // Best effort — a missing index just means listBundles rebuilds it from the
743
- // one-time broad scan on the next enumeration.
642
+ // Best effort — listBundles rebuilds from a broad scan on the next enumeration.
744
643
  }
745
644
  }
746
- // Append a metadata item's STORAGE name to an ALREADY-BUILT index. No-op when the
747
- // index has not been built yet (null): never create a one-entry index that would
748
- // hide every OTHER bundle — listBundles builds the complete index on its first
749
- // scan, and this newly-written bundle is included in that scan.
645
+ // Append a storage name to an already-built index. No-op when null: a one-entry
646
+ // index would hide every other bundle until the next rebuild.
750
647
  function addBundleToMetaIndex(name) {
751
648
  const cur = readBundleMetaIndex();
752
649
  if (cur === null)
@@ -771,20 +668,16 @@ export const __metaIndexForTest = {
771
668
  remove: removeBundleFromMetaIndex,
772
669
  };
773
670
  /**
774
- * Re-write already-read keychain bundle metadata items WITHOUT the biometry ACL.
775
- * `metaJsonByName` maps bundle name → the exact metadata JSON listBundles just
776
- * batch-read, so writing it back only flips the ACL (via the helper's
777
- * delete-then-add `set-no-acl`) — contents and updated_at are preserved and no
778
- * extra keychain read is issued. Exported for tests. Returns the count healed.
671
+ * Re-write keychain metadata items without the biometry ACL. The caller supplies
672
+ * the JSON already read, so contents are preserved and no extra read is issued.
673
+ * Exported for tests.
779
674
  */
780
675
  export function healKeychainBundleMetadata(metaJsonByName) {
781
676
  let healed = 0;
782
677
  for (const [name, json] of metaJsonByName) {
783
678
  try {
784
- // Cleartext meta-item name → hashed by keychainStore.set (#316) to the same
785
- // service the read enumerated, so this overwrites the existing item in
786
- // place, no-ACL. A per-item failure (e.g. a pinned helper without
787
- // set-no-acl) must not abort the rest.
679
+ // keychainStore.set hashes the cleartext name back to the same service,
680
+ // overwriting in place no-ACL. Per-item failures must not abort the rest.
788
681
  keychainStore.set(bundleMetaItem(name), json, { noAcl: true });
789
682
  healed++;
790
683
  }
@@ -795,10 +688,8 @@ export function healKeychainBundleMetadata(metaJsonByName) {
795
688
  return healed;
796
689
  }
797
690
  /**
798
- * One-time driver around healKeychainBundleMetadata (RUSH-1759). macOS + real
799
- * keychain only — libsecret/CredMan have no biometry ACL to shed, and a test
800
- * backend has no real keychain — and gated by a sentinel so it runs at most
801
- * once. Best-effort: a heal failure never breaks bundle listing.
691
+ * One-time driver for healKeychainBundleMetadata. macOS + real keychain only;
692
+ * gated by a sentinel. Best-effort: a heal failure never breaks listing.
802
693
  */
803
694
  export function healKeychainBundleMetadataAclOnce(metaJsonByName) {
804
695
  if (metaJsonByName.size === 0)
@@ -819,17 +710,8 @@ export function healKeychainBundleMetadataAclOnce(metaJsonByName) {
819
710
  }
820
711
  export function listBundles() {
821
712
  const out = [];
822
- // Keychain-backed bundles: batch all metadata reads behind ONE Touch ID
823
- // prompt instead of N. Bundle metadata items carry user-presence ACLs (same
824
- // as secret values), so a naive loop over readBundle() spawns a fresh
825
- // LAContext per item — meaning N biometric prompts for `secrets list`.
826
- //
827
- // SKIP this entirely when the keychain backend is routing to the encrypted
828
- // file store (Linux headless / locked-collection fallback): there,
829
- // listKeychainItems() returns the SAME items the file enumeration below
830
- // reads, so running both would list every file-backed bundle twice — once
831
- // mislabeled `keychain`, once correctly `[file]`. Under the fallback the
832
- // file store is the single source of truth, so the block below covers all.
713
+ // Batch keychain metadata reads behind one prompt. Skip when the keychain
714
+ // backend is routing to the file fallback to avoid listing file bundles twice.
833
715
  if (!keychainUsesFileFallback()) {
834
716
  let keychainServices = [];
835
717
  // Prefer the no-ACL metadata-name index (a silent file read) over the broad
@@ -857,19 +739,9 @@ export function listBundles() {
857
739
  // pre-re-key items) still carry the name; it's kept as the parse hint so
858
740
  // legacy metadata without the persisted `name` field keeps listing.
859
741
  if (keychainServices.length > 0) {
860
- // Daily-policy fast-path (macOS). Bundle metadata items are biometry-gated,
861
- // so the getKeychainTokens batch below pops Touch ID on every `secrets
862
- // list` — the broker/`daily` mechanism only ever covered value reads, not
863
- // this listing. Serve a broker-cached metadata snapshot when one is held,
864
- // so only the first list per ~7d prompts. The cache key is a hash of the
865
- // current keychain name-set (enumerated silently above): add / remove /
866
- // rename a bundle and the key changes, so the stale snapshot is never
867
- // served. A same-name metadata edit (e.g. `secrets policy <b> always`)
868
- // does NOT change the key, so the POLICY column in `secrets list` can lag
869
- // by up to the hold window (~7d) until the next name-set change or `lock`.
870
- // This is cosmetic only — enforcement always reads the bundle's live
871
- // policy (readBundle), never this snapshot, and `secrets view <b>` shows
872
- // the fresh value immediately. Values are never cached here; metadata only.
742
+ // Serve a broker-cached metadata snapshot so only the first list per hold
743
+ // window prompts. Cache key is the name-set hash; policy edits can lag
744
+ // cosmetically, but enforcement always reads live policy. Values are not cached.
873
745
  const useAgent = process.env.AGENTS_SECRETS_NO_AGENT !== '1' &&
874
746
  !isKeychainBackendOverridden() &&
875
747
  secretsAgentAutoEnabled();
@@ -883,12 +755,8 @@ export function listBundles() {
883
755
  out.push(bundle);
884
756
  }
885
757
  else {
886
- // Metadata enumeration must stay silent in ANY context (SEC-11):
887
- // bundle metadata items are no-ACL by contract (SEC-4), so attest that
888
- // to the raw-read storm guard — a headless `listBundles` (session
889
- // start, crabbox env, devices fan-out) must never fail fast on the
890
- // guard nor pop a sheet. (A legacy pre-heal ACL'd metadata item can
891
- // still prompt once; it heals on the next interactive scan.)
758
+ // Metadata is no-ACL by contract; attest silentNoAcl so headless listing
759
+ // doesn't fail fast or pop a sheet.
892
760
  const fetched = getKeychainTokens(keychainServices, { silentNoAcl: true });
893
761
  const keychainBundles = [];
894
762
  for (const service of keychainServices) {
@@ -905,19 +773,14 @@ export function listBundles() {
905
773
  }
906
774
  for (const bundle of keychainBundles)
907
775
  out.push(bundle);
908
- // Populate the broker for the rest of the hold window (fire-and-forget).
909
- // Same configurable cap as the value read-path — otherwise `secrets list`
910
- // would keep serving a stale metadata snapshot for 7d even when the user
911
- // capped the hold at 24h via secrets.agent.holdMs.
776
+ // Populate the broker using the same hold cap as value reads.
912
777
  if (useAgent && keychainBundles.length > 0) {
913
778
  agentAutoLoadMetaSync(nameSetHash, keychainBundles, secretsHoldMs());
914
779
  }
915
780
  }
916
781
  }
917
782
  }
918
- // File-backed bundles live in the encrypted-file store. Enumeration is a
919
- // silent directory listing; only decryption needs the passphrase, so a
920
- // `secrets list` without one still shows the names (values stay sealed).
783
+ // File-backed bundles: enumeration is silent; only decryption needs the passphrase.
921
784
  let fileServices = [];
922
785
  try {
923
786
  fileServices = fileStore.list(BUNDLE_META_PREFIX);
@@ -934,8 +797,7 @@ export function listBundles() {
934
797
  json = fileItemStore.get(bundleMetaItem(name));
935
798
  }
936
799
  catch {
937
- // No passphrase (or wrong one): surface the bundle by name so it isn't
938
- // invisible, with empty vars. `agents secrets view` reports the error.
800
+ // No passphrase: surface the name with empty vars so it isn't invisible.
939
801
  out.push({ name, backend: 'file', vars: {} });
940
802
  continue;
941
803
  }
@@ -984,19 +846,10 @@ export function describeBundle(bundle) {
984
846
  }
985
847
  return out;
986
848
  }
987
- // Bump `last_used` and persist it, but no more than once per throttle window
988
- // so we don't pay a keychain write on every agent run. Failures are swallowed —
989
- // usage tracking is never allowed to break secret resolution.
990
- // Set AGENTS_NO_USAGE_TRACK=1 to disable the stamp entirely (used by tests).
991
- //
992
- // The passed bundle is often the BROKER'S snapshot (this fires on every broker
993
- // hit), which can be stale — a detached auto-load captured before a mutating
994
- // write can land after its eviction. Persisting that snapshot wholesale used to
995
- // write its whole `vars` map back over the authoritative store, resurrecting
996
- // removed keys (or, for a since-deleted bundle, the bundle itself). The stamp
997
- // is telemetry: it re-reads the store's own current copy (bundle metadata is a
998
- // silent no-ACL read) and writes ONLY the timestamp onto that.
999
- // Exported for regression coverage, like shouldEvictAfterBundleWrite.
849
+ // Bump `last_used` at most once per throttle window. The passed bundle is often
850
+ // the broker's snapshot, which can be stale, so re-read the authoritative
851
+ // metadata and write ONLY the timestamp. Failures are swallowed.
852
+ // Set AGENTS_NO_USAGE_TRACK=1 to disable entirely.
1000
853
  export function stampLastUsed(bundle) {
1001
854
  if (process.env.AGENTS_NO_USAGE_TRACK)
1002
855
  return;
@@ -1010,10 +863,8 @@ export function stampLastUsed(bundle) {
1010
863
  const fresh = readBundle(bundle.name); // throws if the bundle is gone — swallowed below
1011
864
  const stamp = new Date(nowMs).toISOString();
1012
865
  fresh.last_used = stamp;
1013
- // Keep the caller's (possibly broker-held) copy throttling correctly.
1014
866
  bundle.last_used = stamp;
1015
- // skipBrokerEviction: this stamp fires on every broker HIT; letting it
1016
- // evict would make the cache destroy itself on first use.
867
+ // Stamping fires on every broker hit; evicting would destroy the cache.
1017
868
  writeBundle(fresh, { skipBrokerEviction: true });
1018
869
  }
1019
870
  catch {
@@ -1021,9 +872,7 @@ export function stampLastUsed(bundle) {
1021
872
  }
1022
873
  }
1023
874
  /**
1024
- * Abort if any of the selected keys has an `expires` date in the past.
1025
- * Bundle-level expiry is not a concept today (expiry is per-key via `meta`),
1026
- * so we iterate only the per-key meta entries.
875
+ * Abort if any selected key's per-key `expires` date is in the past.
1027
876
  */
1028
877
  function assertNotExpired(bundle, selectedKeys, allowExpired) {
1029
878
  if (allowExpired)
@@ -1045,9 +894,7 @@ function assertNotExpired(bundle, selectedKeys, allowExpired) {
1045
894
  }
1046
895
  }
1047
896
  /**
1048
- * Resolve the requested key subset against a bundle's `vars` map. Throws a
1049
- * fail-loud error listing available keys if any requested key is absent. When
1050
- * `requested` is undefined or empty, every key in the bundle is selected.
897
+ * Select the requested key subset, failing loud if any key is absent.
1051
898
  */
1052
899
  function selectRequestedKeys(bundle, requested) {
1053
900
  const req = requested?.length ? requested : undefined;
@@ -1122,34 +969,21 @@ export function canCacheResolvedEnv(bundle, selectedKeys, keyMode) {
1122
969
  return true;
1123
970
  }
1124
971
  /**
1125
- * Apply the --keys subset + expiry gate to an already-resolved snapshot from
1126
- * the secrets-agent fast-path. The agent stores either a full unlock or a
1127
- * scoped lease env, so a naive fast-path return could silently defeat --keys
1128
- * and inject expired values. Mirrors the slow-path pre-checks in `resolveBundleEnv` /
1129
- * `readAndResolveBundleEnv` and returns a new env whose keys match the subset.
1130
- *
1131
- * Exported for tests; production callers reach it via the fast-path branch in
1132
- * `readAndResolveBundleEnv`.
972
+ * Apply --keys and --allow-expired to a broker snapshot so the fast path
973
+ * mirrors the slow path's gates. Exported for tests.
1133
974
  */
1134
975
  export function filterAgentHitBySubsetAndExpiry(hit, opts) {
1135
976
  const selectedKeys = selectRequestedKeys(hit.bundle, opts.keys);
1136
977
  assertNotExpired(hit.bundle, [...selectedKeys], opts.allowExpired ?? false);
1137
978
  const env = projectResolvedEnv(hit.bundle, hit.env, selectedKeys, opts.keyMode);
1138
- // When no subset/projection was requested, return the cached env untouched —
1139
- // same reference the agent handed back, so no per-call allocation on the hot path.
979
+ // Return the cached reference unchanged when no subset/projection was applied.
1140
980
  if (env === hit.env)
1141
981
  return hit;
1142
982
  return { bundle: hit.bundle, env };
1143
983
  }
1144
984
  /**
1145
- * Guard for remote-bundle callers (`bundle@host` / `--device`) — the SSH
1146
- * resolver in `remoteResolveEnv` does not thread --keys or --allow-expired
1147
- * yet. Silently applying them would inject the full remote env or an expired
1148
- * value, defeating the least-privilege intent, so we fail loud.
1149
- *
1150
- * Exported so `agents run --secrets bundle@host` and `agents secrets exec
1151
- * --device` share the exact same error text; the tests exercise this helper
1152
- * directly instead of driving the whole CLI.
985
+ * Fail loud when remote bundle resolution is asked for flags the SSH resolver
986
+ * does not yet thread. Exported so callers share the same error text.
1153
987
  */
1154
988
  export function assertRemoteBundleFlagsUnsupported(bundleName, host, opts, flagLabels) {
1155
989
  const hasKeys = Array.isArray(opts.keys) && opts.keys.length > 0;
@@ -1159,24 +993,9 @@ export function assertRemoteBundleFlagsUnsupported(bundleName, host, opts, flagL
1159
993
  `Drop the flag or resolve the bundle locally.`);
1160
994
  }
1161
995
  /**
1162
- * A declared `keychain:` ref resolved to NO value in the batch read. Classify
1163
- * genuinely-absent vs present-but-unreadable before choosing the error, so a
1164
- * read can never contradict what `agents secrets view` reports (RUSH-2248,
1165
- * RUSH-2253). `view`'s "stored" badge comes from `hasKeychainToken` — the exact
1166
- * existence probe used here — which counts a biometry-ACL'd or locked-keychain
1167
- * item (`errSecInteractionNotAllowed`) as present. So:
1168
- *
1169
- * - present ⇒ the item exists but this context could not read it (keychain
1170
- * locked, or Touch ID not granted). Report HOW to unlock; NEVER
1171
- * "add the key", whose remediation (`secrets add`) would overwrite a good
1172
- * secret.
1173
- * - absent ⇒ genuinely not stored on this machine — the honest "not found"
1174
- * with the `secrets add` remediation.
1175
- * - probe throws ⇒ the keychain itself is unreachable (RUSH-2235 fail-loud):
1176
- * neither absent nor add-the-key — surface the reachability failure verbatim.
1177
- *
1178
- * Only the keychain backend has a locked/biometry state; a file/vault miss is
1179
- * genuinely absent.
996
+ * Build the right error for a missing `keychain:` ref. Classifies
997
+ * present-but-unreadable (locked/denied) vs genuinely absent so `view` and reads
998
+ * stay consistent. Only keychain has a locked state; file/vault misses are absent.
1180
999
  */
1181
1000
  function missingBundleKeychainItemError(bundleName, key, item, backendKind) {
1182
1001
  if (backendKind === 'keychain') {
@@ -1198,17 +1017,9 @@ function missingBundleKeychainItemError(bundleName, key, item, backendKind) {
1198
1017
  `Run: agents secrets add ${bundleName} ${key}`);
1199
1018
  }
1200
1019
  /**
1201
- * Resolve every selected key of an already-read bundle into a flat env map,
1202
- * given a pre-fetched keychain batch. The single per-key resolution loop shared
1203
- * by `resolveBundleEnv` and `readAndResolveBundleEnv` so the keychain lookup and
1204
- * the missing-item classification can never diverge again (RUSH-2252: the two
1205
- * paths drifted — one did the hashed-alias fallback lookup and one did not, and
1206
- * only one classified a missing item honestly).
1207
- *
1208
- * The keychain lookup tries the cleartext name first (Linux / file store), then
1209
- * its hashed storage alias (macOS with #316 hashing active) — the batch keys its
1210
- * results by the names it was ASKED for, which for the metadata + declared keys
1211
- * is the cleartext form and for an enumerated leftover is the hashed form.
1020
+ * Shared per-key resolver for `resolveBundleEnv` and `readAndResolveBundleEnv`.
1021
+ * Looks up keychain items by cleartext name then hashed alias; classifies misses
1022
+ * consistently so the two paths cannot diverge.
1212
1023
  */
1213
1024
  function assembleBundleEnv(bundle, selectedKeys, parsedByKey, fetched, keyMode, backendKind) {
1214
1025
  const env = {};
@@ -1243,16 +1054,10 @@ function assembleBundleEnv(bundle, selectedKeys, parsedByKey, fetched, keyMode,
1243
1054
  }
1244
1055
  return env;
1245
1056
  }
1246
- // Walk the bundle and produce a flat env map. Every keychain: ref is gathered
1247
- // into a single batch read so macOS shows ONE Touch ID prompt for the whole
1248
- // bundle — including the metadata fetch that already happened in readBundle
1249
- // (the helper's auth context survives across separate invocations only via
1250
- // the per-process LAContext, so we still get one prompt for the batch even
1251
- // if metadata triggered an earlier one). Literals/env/file/exec refs are
1252
- // resolved inline and never reach the keychain.
1057
+ // Resolve the bundle into a flat env map, batching keychain refs into one read
1058
+ // so macOS shows one Touch ID prompt. Literals/env/file/exec refs resolve inline.
1253
1059
  export function resolveBundleEnv(bundle, _opts = {}) {
1254
1060
  stampLastUsed(bundle);
1255
- // Key-subset validation and expiry pre-check.
1256
1061
  const selectedKeys = selectRequestedKeys(bundle, _opts.keys);
1257
1062
  assertNotExpired(bundle, [...selectedKeys], _opts.allowExpired ?? false);
1258
1063
  const parsedByKey = new Map();
@@ -1267,18 +1072,15 @@ export function resolveBundleEnv(bundle, _opts = {}) {
1267
1072
  }
1268
1073
  }
1269
1074
  const store = itemStore(bundle.backend ?? 'keychain');
1270
- // keychainStore.getBatch IS getKeychainTokens — call it directly so a
1271
- // `never`-policy bundle (no biometry ACL on its items) attests `silentNoAcl`
1272
- // and stays readable in a headless context, while an ACL'd policy hits the
1273
- // raw-read storm guard and fails fast there.
1075
+ // Direct getKeychainTokens so `never`-policy bundles attest silentNoAcl in
1076
+ // headless contexts, while ACL'd bundles fail fast.
1274
1077
  const fetched = keychainItemsToFetch.length > 0
1275
1078
  ? (bundle.backend ?? 'keychain') === 'keychain'
1276
1079
  ? getKeychainTokens(keychainItemsToFetch, { silentNoAcl: bundlePolicy(bundle) === 'never' })
1277
1080
  : store.getBatch(keychainItemsToFetch)
1278
1081
  : new Map();
1279
1082
  const env = assembleBundleEnv(bundle, selectedKeys, parsedByKey, fetched, _opts.keyMode, bundle.backend ?? 'keychain');
1280
- // `caller` is intentionally unused; see ResolveBundleOptions.
1281
- void _opts.caller;
1083
+ void _opts.caller; // informational only
1282
1084
  return env;
1283
1085
  }
1284
1086
  /**
@@ -1291,32 +1093,17 @@ export function resolveBundleEnv(bundle, _opts = {}) {
1291
1093
  export { isHeadlessSecretsContext, isAgentInvocationContext } from './headless.js';
1292
1094
  /**
1293
1095
  * Read a bundle's metadata AND resolve its env in a single Touch ID prompt.
1294
- *
1295
- * `readBundle` + `resolveBundleEnv` issued two separate `LAContext` calls
1296
- * (metadata read via `get-auth`, then secret values via `get-batch`) which
1297
- * surfaced as two consecutive Touch ID prompts. macOS does not honor
1298
- * "Always Allow" for items protected with `kSecAttrAccessControl`+biometry,
1299
- * so caching at the OS level was never an option. This collapses both reads
1300
- * into one `get-batch` call: we enumerate the bundle's secret items first
1301
- * (silent — `list` returns attrs only and does not trigger biometry) and
1302
- * include the metadata item in the same batch. One prompt, correctly scoped
1303
- * to the bundle name and caller.
1096
+ * `readBundle` + `resolveBundleEnv` used to issue two LAContext calls (two
1097
+ * prompts). This collapses them into one batch that includes the metadata item.
1304
1098
  */
1305
1099
  export function readAndResolveBundleEnv(name, opts = {}) {
1306
1100
  validateBundleName(name);
1307
1101
  assertNameActiveInResourceProfile('secrets', name);
1308
1102
  const backend = bundleBackend(name);
1309
- // Fast-path: if the secrets-agent holds this bundle (user ran
1310
- // `agents secrets unlock <name>`), return the cached snapshot with no Touch
1311
- // ID. Soft — any failure falls through to the real keychain read below. macOS
1312
- // / keychain only — the agent exists to dedup Touch ID prompts, and a
1313
- // file-backed bundle has none to dedup. The never-unlocked path is a single
1314
- // stat (agentSocketExists) so it costs nothing when the agent isn't running.
1103
+ // Fast-path: broker-held snapshot ⇒ no Touch ID. Soft: any failure falls
1104
+ // through to the real keychain read. macOS/keychain only.
1315
1105
  if (backend === 'keychain' && !opts.noAgent && process.env.AGENTS_SECRETS_NO_AGENT !== '1') {
1316
- // The scope this reader asks under. Falls back to the GLOBAL scope, not to a
1317
- // literal `'cli'` harness — the broker and the durable store both resolve
1318
- // own-harness → global (bundleScopeChain), so an unscoped unlock is visible
1319
- // here whether this process was launched by an agent or typed in a terminal.
1106
+ // Falls back to GLOBAL so an unscoped unlock is visible in any harness.
1320
1107
  const harness = opts.agent || process.env.AGENTS_AGENT_NAME || GLOBAL_HARNESS;
1321
1108
  const hit = agentGetSync(name, harness);
1322
1109
  if (hit) {
@@ -1325,10 +1112,7 @@ export function readAndResolveBundleEnv(name, opts = {}) {
1325
1112
  emitSecretAudit({ event: 'secrets.lease-denied', bundle: name, operation: opts.caller, source: 'agent', status: 'error', keys: denied, keyCount: denied.length, agent: harness, error: 'key outside lease scope' });
1326
1113
  throw new Error(`Secret lease '${hit.lease?.id}' does not grant key(s): ${denied.join(', ')}`);
1327
1114
  }
1328
- // The agent stores a full unlock or a scoped lease env. Apply the same subset filter and
1329
- // expiry gate as the slow path — without this, `--secrets-keys X` would
1330
- // silently inject every key and an expired key would flow through after
1331
- // the first cache-populating run.
1115
+ // Apply the same subset and expiry gates as the slow path.
1332
1116
  const filtered = filterAgentHitBySubsetAndExpiry(hit, opts);
1333
1117
  stampLastUsed(filtered.bundle);
1334
1118
  emitSecretAudit({
@@ -1342,12 +1126,8 @@ export function readAndResolveBundleEnv(name, opts = {}) {
1342
1126
  });
1343
1127
  return filtered;
1344
1128
  }
1345
- // Durable-session fallback (Correction B). After a daemon restart / agents-cli
1346
- // upgrade the broker RAM is empty, so the fast-path above misses — but the
1347
- // unlock persisted a no-ACL session item (session-store.ts) that reads with NO
1348
- // Touch ID. Serve from it and re-warm the broker, so a warm bundle stays warm
1349
- // across restart — this fixes BOTH the interactive re-prompt and the headless
1350
- // throw below (which now fires only when there is genuinely no session).
1129
+ // Durable-session fallback: after restart the broker RAM is empty, but a
1130
+ // no-ACL session item lets us re-warm the broker without Touch ID.
1351
1131
  const resolved = resolveSession(name, Date.now(), harness);
1352
1132
  if (resolved) {
1353
1133
  const session = resolved.entry;
@@ -1358,14 +1138,9 @@ export function readAndResolveBundleEnv(name, opts = {}) {
1358
1138
  }
1359
1139
  const filtered = filterAgentHitBySubsetAndExpiry({ bundle: session.bundle, env: session.env }, opts);
1360
1140
  stampLastUsed(filtered.bundle);
1361
- // Re-warm the broker with the remaining TTL so later reads hit RAM and
1362
- // `agents secrets status` is honest. Re-warm under the scope the grant was
1363
- // MADE in (resolved.harness), never the asking scope — re-warming a global
1364
- // grant as `claude` would silently narrow it for every other harness.
1365
- // No snapshotAt: the session's bundle was read at unlock time, not now —
1366
- // claiming freshness here would defeat the broker's eviction tombstones.
1367
- // An undated load is accepted (legacy behavior); the durable-session
1368
- // staleness window itself is a known, separate concern.
1141
+ // Re-warm under the scope the grant was made in so a global grant isn't
1142
+ // narrowed to the asking harness. No snapshotAt: the session bundle predates
1143
+ // this read; claiming freshness would defeat eviction tombstones.
1369
1144
  agentAutoLoadSync(name, session.bundle, session.env, Math.max(1, session.expiresAt - Date.now()), resolved.harness, session.lease);
1370
1145
  emitSecretAudit({
1371
1146
  event: 'secrets.get',
@@ -1379,12 +1154,8 @@ export function readAndResolveBundleEnv(name, opts = {}) {
1379
1154
  return filtered;
1380
1155
  }
1381
1156
  }
1382
- // Never/no-ACL bundles remain prompt-free regardless. Every ordinary caller
1383
- // sets agentOnly; only the unlock handler opts into interactive authentication.
1384
1157
  const interactiveUnlock = opts.interactiveUnlock ?? false;
1385
- // A `never`-policy bundle's items carry no biometry ACL, so once the policy
1386
- // check below proves that, the batch read is silent even in a headless
1387
- // context — attest it to the raw-read storm guard via `silentNoAcl`.
1158
+ // A `never`-policy bundle is prompt-free; attest silentNoAcl once verified.
1388
1159
  let verifiedNoAclBundle = false;
1389
1160
  if (opts.agentOnly && backend === 'keychain' && !interactiveUnlock && !keychainAgentOnlyBypassForTest) {
1390
1161
  try {
@@ -1397,9 +1168,8 @@ export function readAndResolveBundleEnv(name, opts = {}) {
1397
1168
  `never raises a Touch ID sheet on its own.`);
1398
1169
  }
1399
1170
  }
1400
- // If the secrets broker is explicitly disabled and this bundle would otherwise
1401
- // fall through to a keychain read (Touch ID prompt), fail loud now. Never-policy
1402
- // bundles are verified below and remain silent; vault/file backends are unaffected.
1171
+ // Fail loud when the broker is disabled and this would otherwise prompt.
1172
+ // Never-policy bundles remain silent; vault/file are unaffected.
1403
1173
  if (backend === 'keychain' && !verifiedNoAclBundle && !isSecretsBrokerEnabled() && process.env.AGENTS_SECRETS_NO_AGENT !== '1') {
1404
1174
  throw new Error(`Secrets broker is disabled — re-enable with 'agents daemon services enable secrets-broker'. ` +
1405
1175
  `If you meant to read directly from the keychain, set AGENTS_SECRETS_NO_AGENT=1.`);
@@ -1410,11 +1180,9 @@ export function readAndResolveBundleEnv(name, opts = {}) {
1410
1180
  const metaItem = bundleMetaItem(name);
1411
1181
  const bundleSecretPrefix = `${SECRETS_ITEM_PREFIX}${name}.`;
1412
1182
  let enumeratedSecretItems = [];
1413
- // Agent launches must never enumerate the macOS Keychain: a per-bundle
1414
- // prefix becomes a broad `agents-cli.` scan after service-name hashing and
1415
- // macOS evaluates unrelated biometry ACLs during that scan (RUSH-2440).
1416
- // Interactive reads retain the existing enumeration side of the union so
1417
- // legacy/aliased items keep their established behavior.
1183
+ // Agent-only launches must not enumerate the keychain: hashed names turn a
1184
+ // per-bundle prefix into a broad scan that evaluates unrelated ACLs (RUSH-2440).
1185
+ // Interactive reads keep the legacy enumeration path.
1418
1186
  if (backend !== 'keychain' || !opts.agentOnly) {
1419
1187
  try {
1420
1188
  enumeratedSecretItems = store.list(bundleSecretPrefix);
@@ -1426,16 +1194,11 @@ export function readAndResolveBundleEnv(name, opts = {}) {
1426
1194
  const reason = opts.caller
1427
1195
  ? `read ${name} secrets (for ${opts.caller})`
1428
1196
  : `read ${name} secrets`;
1429
- // Captured BEFORE the first keychain read: if a mutating write evicts this
1430
- // bundle while the read is in flight, the broker's tombstone must beat this
1431
- // snapshot, so the conservative (earliest) timestamp is the correct one.
1197
+ // Capture snapshotAt before the first read so broker eviction tombstones beat
1198
+ // any concurrent load.
1432
1199
  const snapshotAt = Date.now();
1433
- // Fetch metadata first (it's always no-ACL), then derive secret item names
1434
- // from its declared keys instead of enumerating. This eliminates the broad
1435
- // Keychain scan that triggered Touch ID on every run (RUSH-2440). The
1436
- // metadata is authoritative for declared keys; undeclared keys (union with
1437
- // legacy/orphaned items) are not supported in agent-only mode and would
1438
- // fail the agentOnly gate anyway.
1200
+ // Fetch metadata (always no-ACL) and derive secret item names from declared
1201
+ // keys, eliminating the broad keychain scan that triggered Touch ID (RUSH-2440).
1439
1202
  const metaFetched = backend === 'keychain'
1440
1203
  ? getKeychainTokens([metaItem], { silentNoAcl: true })
1441
1204
  : store.getBatch([...new Set([metaItem, ...enumeratedSecretItems])]);
@@ -1456,9 +1219,8 @@ export function readAndResolveBundleEnv(name, opts = {}) {
1456
1219
  if (!parsed || typeof parsed !== 'object') {
1457
1220
  throw new Error(`Bundle '${name}' is malformed.`);
1458
1221
  }
1459
- // Compute exact storage names from declared keychain references. The env key
1460
- // and stored item name may differ (`TOKEN=keychain:actual-token`), so deriving
1461
- // item names from Object.keys(vars) would silently read the wrong secret.
1222
+ // Derive exact storage names from declared keychain refs. The env key and
1223
+ // stored item name may differ, so vars keys alone would read the wrong secret.
1462
1224
  const declaredSecretItems = [];
1463
1225
  if (parsed.vars && typeof parsed.vars === 'object') {
1464
1226
  for (const raw of Object.values(parsed.vars)) {
@@ -1469,15 +1231,11 @@ export function readAndResolveBundleEnv(name, opts = {}) {
1469
1231
  }
1470
1232
  }
1471
1233
  const secretItems = [...new Set([...enumeratedSecretItems, ...declaredSecretItems])];
1472
- // Now fetch both metadata and secret values in one batch.
1473
- // The interactive path keeps the enumeration + declared-item union; the
1474
- // agent-only path contains declared items only and therefore never scans.
1234
+ // Fetch metadata and secret values in one batch.
1475
1235
  const fetched = backend === 'keychain'
1476
1236
  ? getKeychainTokens([...new Set([metaItem, ...secretItems])], {
1477
1237
  agent: opts.agent || process.env.AGENTS_AGENT_NAME || 'Agents CLI',
1478
1238
  bundle: name,
1479
- // The session that triggered the read, so a Touch ID prompt is
1480
- // attributable when several agents run at once. Exported by exec.ts.
1481
1239
  sessionId: process.env.AGENT_SESSION_ID || process.env.AGENTS_SESSION_ID,
1482
1240
  reason: opts.caller ? `to ${opts.caller}` : reason,
1483
1241
  duration: opts.duration || humanUnlockDuration(secretsHoldMs()),
@@ -1485,16 +1243,13 @@ export function readAndResolveBundleEnv(name, opts = {}) {
1485
1243
  forceDuration: Boolean(opts.duration),
1486
1244
  silentNoAcl: verifiedNoAclBundle,
1487
1245
  })
1488
- // File/vault enumeration is complete and prompt-free. Reuse its initial
1489
- // batch so metadata-first Keychain safety does not double-decrypt every
1490
- // file-backed credential read.
1246
+ // File/vault: reuse the initial batch to avoid double-decrypting.
1491
1247
  : metaFetched;
1492
1248
  const bundle = {
1493
1249
  name,
1494
1250
  description: parsed.description,
1495
1251
  allow_exec: Boolean(parsed.allow_exec),
1496
1252
  backend: backend === 'keychain' ? undefined : backend,
1497
- // Legacy wire key: the policy is persisted under `tier` (`session` == `hold`).
1498
1253
  policy: parsePolicy(parsed.tier),
1499
1254
  vars: parsed.vars && typeof parsed.vars === 'object' ? parsed.vars : {},
1500
1255
  };
@@ -1509,7 +1264,6 @@ export function readAndResolveBundleEnv(name, opts = {}) {
1509
1264
  for (const key of Object.keys(bundle.vars)) {
1510
1265
  validateEnvKey(key);
1511
1266
  }
1512
- // Key-subset validation and expiry pre-check (mirrors resolveBundleEnv logic).
1513
1267
  const selectedKeys = selectRequestedKeys(bundle, opts.keys);
1514
1268
  assertNotExpired(bundle, [...selectedKeys], opts.allowExpired ?? false);
1515
1269
  stampLastUsed(bundle);
@@ -1544,19 +1298,10 @@ export function readAndResolveBundleEnv(name, opts = {}) {
1544
1298
  });
1545
1299
  };
1546
1300
  try {
1547
- // Shared per-key resolver: same keychain lookup (cleartext name, then hashed
1548
- // storage alias) and same missing-item classification as resolveBundleEnv, so
1549
- // the two paths can never diverge again (RUSH-2252, RUSH-2253).
1550
1301
  const env = assembleBundleEnv(bundle, selectedKeys, parsedByKey, fetched, opts.keyMode, backend);
1551
1302
  emitReadAudit('success');
1552
- // Auto-cache: this was a real keychain read (the agent fast-path returned
1553
- // earlier on a hit). If the bundle opts into the `daily` policy and the user
1554
- // enabled `secrets.agent.auto`, populate the broker so the next concurrent
1555
- // run reads silently. Skipped when noAgent (e.g. `unlock`, which loads the
1556
- // agent itself). When a broker is already up this warms it synchronously
1557
- // (bounded ~3s) so `daily` reliably sticks; only a cold-start broker uses the
1558
- // detached fire-and-forget path (see agentAutoLoadSync). The costly Touch ID
1559
- // prompt already happened, so the bounded wait is invisible.
1303
+ // Auto-cache into the broker so the next read is silent. Synchronous warm
1304
+ // when a broker is already up; cold-start uses the detached path.
1560
1305
  if (backend === 'keychain' &&
1561
1306
  !opts.noAgent &&
1562
1307
  process.env.AGENTS_SECRETS_NO_AGENT !== '1' &&
@@ -1587,9 +1332,8 @@ export function keychainRef(key) {
1587
1332
  return `keychain:${key}`;
1588
1333
  }
1589
1334
  /**
1590
- * Rotate a keychain-backed secret in `bundle`. Errors if `key` is not present
1591
- * in the bundle (use `add` to introduce a new key). Preserves existing meta
1592
- * unless `clearMeta` or a `meta` patch is supplied.
1335
+ * Rotate a keychain-backed secret. Errors if the key is absent; preserves meta
1336
+ * unless cleared or patched.
1593
1337
  */
1594
1338
  export function rotateBundleSecret(bundle, key, opts) {
1595
1339
  validateBundleName(bundle.name);
@@ -1598,8 +1342,7 @@ export function rotateBundleSecret(bundle, key, opts) {
1598
1342
  throw new Error(`Key '${key}' not in bundle '${bundle.name}'. Use 'agents secrets add' to add a new key.`);
1599
1343
  }
1600
1344
  const raw = bundle.vars[key];
1601
- // We only rotate keychain-backed values. Literals/refs aren't "secrets" in
1602
- // the same sense — pivot the user back to add/remove.
1345
+ // Only keychain-backed values are rotated.
1603
1346
  if (typeof raw !== 'string' || !raw.startsWith('keychain:')) {
1604
1347
  throw new Error(`Key '${key}' in bundle '${bundle.name}' is not keychain-backed; cannot rotate.`);
1605
1348
  }
@@ -1626,68 +1369,39 @@ export function rotateBundleSecret(bundle, key, opts) {
1626
1369
  writeBundle(bundle);
1627
1370
  }
1628
1371
  /**
1629
- * Reconcile a bundle's keychain-backed VALUE items to its CURRENT policy, then
1630
- * write the (always no-ACL) metadata.
1631
- *
1632
- * `writeBundle` only rewrites the metadata item, so a policy change alone leaves
1633
- * every value item carrying the ACL it was created with — and macOS gates each
1634
- * read on the ITEM's ACL, not the bundle's declared tier (spec SEC-19). Without
1635
- * this reconcile, `agents secrets policy <b> never` reports "silent" while the
1636
- * still-ACL'd value keeps popping Touch ID on every read, forever.
1637
- *
1638
- * hold/always -> never strips the biometry ACL (helper `set-no-acl`: delete+add)
1639
- * never -> hold/always re-attaches it (helper `set`)
1640
- *
1641
- * The current values are read in ONE batch, so the reconcile costs at most a
1642
- * single Touch ID — the last prompt a hold->never bundle will ever raise (a
1643
- * never->* flip reads silently, since the items are already no-ACL). No-op on the
1644
- * ACL to write for non-keychain backends (file/vault have no biometry concept),
1645
- * and a metadata-only write when the bundle has no keychain-backed values.
1372
+ * Reconcile keychain value items to the bundle's current policy. macOS gates
1373
+ * reads on each item's ACL, not the bundle's declared policy, so a policy change
1374
+ * alone would leave stale ACLs. hold/always → never strips ACL; never → *
1375
+ * re-attaches it. Non-keychain backends no-op.
1646
1376
  */
1647
1377
  export function reAclBundleItems(bundle) {
1648
1378
  if ((bundle.backend ?? 'keychain') !== 'keychain') {
1649
- // No biometry ACL off the keychain backend — only metadata needs persisting.
1650
1379
  writeBundle(bundle);
1651
1380
  return;
1652
1381
  }
1653
1382
  const store = itemStore('keychain');
1654
- // keychainItemsForBundle already returns ONLY keychain-backed value items
1655
- // (via parseBundleValue), so no extra ref-shape filtering here.
1656
1383
  const entries = keychainItemsForBundle(bundle);
1657
1384
  if (entries.length === 0) {
1658
- // Literal/ref-only bundle: nothing to re-ACL, just refresh metadata.
1659
1385
  writeBundle(bundle);
1660
1386
  return;
1661
1387
  }
1662
- // One batched read = at most one Touch ID for the whole reconcile.
1388
+ // One batch read ⇒ at most one Touch ID for the whole reconcile.
1663
1389
  const values = store.getBatch(entries.map((e) => e.item));
1664
1390
  const rewrite = new Map();
1665
1391
  for (const { item } of entries) {
1666
1392
  const value = values.get(item);
1667
- // A key present in metadata but with no readable value item is real
1668
- // corruption, not something to silently skip (no fallbacks — fail loud).
1393
+ // A declared key with no readable value is corruption — fail loud.
1669
1394
  if (value === undefined) {
1670
1395
  throw new Error(`Cannot change policy for '${bundle.name}': a keychain value is missing or unreadable. Rotate that key, then retry.`);
1671
1396
  }
1672
1397
  rewrite.set(item, value);
1673
1398
  }
1674
- // writeBundleWithItems re-stores each value with { noAcl: policy === 'never' }
1675
- // and the metadata no-ACL (metadata-last), and evicts any broker-held copy.
1399
+ // writeBundleWithItems applies the correct noAcl flag and evicts the broker.
1676
1400
  writeBundleWithItems(bundle, rewrite);
1677
1401
  }
1678
1402
  /**
1679
- * Rename a bundle: move metadata + every keychain-backed value to a new name.
1680
- *
1681
- * Sequence is ordered so the source stays intact if anything in the copy
1682
- * phase fails:
1683
- * 1) read source, validate dest
1684
- * 2) purge dest if --force, refuse otherwise
1685
- * 3) copy each keychain value source -> dest
1686
- * 4) write new bundle metadata
1687
- * 5) delete the old per-key keychain items + old metadata
1688
- *
1689
- * Steps 1-4 are reversible. If 5 partially fails, running `rename` again is
1690
- * a safe no-op for the source items.
1403
+ * Rename a bundle: copy metadata + keychain values to the new name, then delete
1404
+ * the source. Steps are ordered so a copy-phase failure leaves the source intact.
1691
1405
  */
1692
1406
  export function renameBundle(oldName, newName, opts = {}) {
1693
1407
  validateBundleName(oldName);
@@ -1699,8 +1413,6 @@ export function renameBundle(oldName, newName, opts = {}) {
1699
1413
  throw new Error(`Bundle '${oldName}' not found.`);
1700
1414
  }
1701
1415
  const source = readBundle(oldName);
1702
- // Rename stays within the source's backend. The store carries both the
1703
- // per-key secret items and (via writeBundle/deleteBundle) the metadata.
1704
1416
  const store = itemStore(source.backend ?? 'keychain');
1705
1417
  if (bundleExists(newName)) {
1706
1418
  if (!opts.force) {
@@ -1713,8 +1425,7 @@ export function renameBundle(oldName, newName, opts = {}) {
1713
1425
  }
1714
1426
  deleteBundle(newName);
1715
1427
  }
1716
- // Copy phase: read old item, write new item. Old items stay in place
1717
- // until step 5 so a partial failure here leaves the source intact.
1428
+ // Copy to the new name, leaving old items in place until cleanup.
1718
1429
  const sourceItems = keychainItemsForBundle(source);
1719
1430
  for (const { key, item: oldItem } of sourceItems) {
1720
1431
  const raw = source.vars[key];
@@ -1725,8 +1436,6 @@ export function renameBundle(oldName, newName, opts = {}) {
1725
1436
  const value = store.get(oldItem);
1726
1437
  store.set(newItem, value, { noAcl: bundlePolicy(source) === 'never' });
1727
1438
  }
1728
- // writeBundle preserves source.created_at, refreshes updated_at, and keeps
1729
- // the source backend (spread carries source.backend).
1730
1439
  const renamed = { ...source, name: newName };
1731
1440
  writeBundle(renamed);
1732
1441
  // Cleanup: delete the old per-key items, then the old metadata.
@@ -1737,24 +1446,17 @@ export function renameBundle(oldName, newName, opts = {}) {
1737
1446
  emit('secrets.rename', { module: 'secrets', from: oldName, to: newName });
1738
1447
  }
1739
1448
  /**
1740
- * The store (keychain or encrypted file) that carries a bundle's items. The
1741
- * CLI uses this to read/write/delete per-key items (built with
1742
- * secretsKeychainItem) in the same store as the bundle's metadata, for `add` /
1743
- * `import` / `remove` / `delete`. Pass the bundle's resolved backend
1744
- * (`bundle.backend ?? 'keychain'`).
1449
+ * The item store (keychain or encrypted file) for a bundle's per-key secrets.
1450
+ * Pass the resolved backend (`bundle.backend ?? 'keychain'`).
1745
1451
  */
1746
1452
  export function bundleItemStore(backend, opts) {
1747
1453
  const store = itemStore(backend ?? 'keychain');
1748
- // `never`-policy bundles write their per-key values without the biometry ACL
1749
- // (same rationale as the metadata write in writeBundle). Wrap `set` so every
1750
- // value the add/import paths write inherits the no-ACL flag; reads, deletes,
1751
- // and existence checks are ACL-independent and pass through untouched.
1454
+ // `never`-policy bundles write per-key values without the biometry ACL.
1752
1455
  if (opts?.noAcl) {
1753
1456
  return { ...store, set: (item, value) => store.set(item, value, { noAcl: true }) };
1754
1457
  }
1755
1458
  return store;
1756
1459
  }
1757
- // Iterate all keychain-backed keys in a bundle for cleanup on rm/unset.
1758
1460
  export function keychainItemsForBundle(bundle) {
1759
1461
  const items = [];
1760
1462
  for (const [key, raw] of Object.entries(bundle.vars)) {
@@ -1765,7 +1467,6 @@ export function keychainItemsForBundle(bundle) {
1765
1467
  }
1766
1468
  return items;
1767
1469
  }
1768
- // Parse a dotenv string into key=value pairs, preserving last-wins on duplicates.
1769
1470
  export function parseDotenv(content) {
1770
1471
  const out = {};
1771
1472
  for (const raw of content.split('\n')) {