@phnx-labs/agents-cli 1.20.74 → 1.20.77

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 (231) hide show
  1. package/CHANGELOG.md +1055 -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 +18 -0
  13. package/dist/commands/exec.js +83 -13
  14. package/dist/commands/feed.d.ts +7 -1
  15. package/dist/commands/feed.js +47 -4
  16. package/dist/commands/go.d.ts +9 -2
  17. package/dist/commands/go.js +16 -6
  18. package/dist/commands/harness.d.ts +14 -0
  19. package/dist/commands/harness.js +145 -0
  20. package/dist/commands/import.js +38 -8
  21. package/dist/commands/inspect.js +5 -2
  22. package/dist/commands/profiles.d.ts +20 -0
  23. package/dist/commands/profiles.js +68 -21
  24. package/dist/commands/repo.js +14 -0
  25. package/dist/commands/routines.js +286 -15
  26. package/dist/commands/secrets.js +102 -37
  27. package/dist/commands/sessions-browser.js +12 -2
  28. package/dist/commands/sessions-export.js +19 -5
  29. package/dist/commands/sessions-migrate.d.ts +29 -0
  30. package/dist/commands/sessions-migrate.js +596 -0
  31. package/dist/commands/sessions-picker.d.ts +14 -1
  32. package/dist/commands/sessions-picker.js +184 -16
  33. package/dist/commands/sessions.d.ts +56 -14
  34. package/dist/commands/sessions.js +322 -62
  35. package/dist/commands/setup-computer.js +2 -2
  36. package/dist/commands/ssh.d.ts +13 -0
  37. package/dist/commands/ssh.js +110 -37
  38. package/dist/commands/status.js +10 -2
  39. package/dist/commands/versions.js +15 -6
  40. package/dist/commands/view.d.ts +5 -0
  41. package/dist/commands/view.js +24 -4
  42. package/dist/commands/watchdog.d.ts +7 -2
  43. package/dist/commands/watchdog.js +73 -57
  44. package/dist/index.js +59 -7
  45. package/dist/lib/activity.d.ts +17 -6
  46. package/dist/lib/activity.js +466 -40
  47. package/dist/lib/actor.d.ts +50 -0
  48. package/dist/lib/actor.js +166 -0
  49. package/dist/lib/agent-spec/provider.js +2 -1
  50. package/dist/lib/agent-spec/resolve.js +19 -5
  51. package/dist/lib/agent-spec/types.d.ts +9 -1
  52. package/dist/lib/agents.d.ts +21 -0
  53. package/dist/lib/agents.js +70 -6
  54. package/dist/lib/cloud/codex.d.ts +2 -0
  55. package/dist/lib/cloud/codex.js +14 -3
  56. package/dist/lib/cloud/session-index.d.ts +32 -0
  57. package/dist/lib/cloud/session-index.js +58 -0
  58. package/dist/lib/cloud/store.d.ts +7 -0
  59. package/dist/lib/cloud/store.js +25 -0
  60. package/dist/lib/config-transfer.js +4 -0
  61. package/dist/lib/daemon.d.ts +10 -1
  62. package/dist/lib/daemon.js +114 -11
  63. package/dist/lib/devices/connect.d.ts +15 -1
  64. package/dist/lib/devices/connect.js +15 -1
  65. package/dist/lib/devices/fleet.d.ts +37 -1
  66. package/dist/lib/devices/fleet.js +36 -2
  67. package/dist/lib/devices/health-report.d.ts +38 -0
  68. package/dist/lib/devices/health-report.js +214 -0
  69. package/dist/lib/devices/health.js +4 -1
  70. package/dist/lib/devices/reachability.d.ts +31 -0
  71. package/dist/lib/devices/reachability.js +40 -0
  72. package/dist/lib/devices/registry.d.ts +33 -0
  73. package/dist/lib/devices/registry.js +37 -0
  74. package/dist/lib/devices/resolve-target.d.ts +15 -27
  75. package/dist/lib/devices/resolve-target.js +63 -102
  76. package/dist/lib/devices/sync.d.ts +18 -0
  77. package/dist/lib/devices/sync.js +23 -1
  78. package/dist/lib/devices/tailscale.d.ts +3 -0
  79. package/dist/lib/devices/tailscale.js +1 -0
  80. package/dist/lib/events.d.ts +1 -1
  81. package/dist/lib/exec.d.ts +82 -0
  82. package/dist/lib/exec.js +218 -6
  83. package/dist/lib/feed-post.d.ts +63 -0
  84. package/dist/lib/feed-post.js +204 -0
  85. package/dist/lib/feed.d.ts +7 -1
  86. package/dist/lib/feed.js +96 -6
  87. package/dist/lib/fleet/apply.d.ts +32 -2
  88. package/dist/lib/fleet/apply.js +97 -10
  89. package/dist/lib/fleet/types.d.ts +11 -0
  90. package/dist/lib/fs-walk.d.ts +13 -0
  91. package/dist/lib/fs-walk.js +16 -7
  92. package/dist/lib/heal.d.ts +7 -4
  93. package/dist/lib/heal.js +10 -22
  94. package/dist/lib/hooks.d.ts +1 -0
  95. package/dist/lib/hooks.js +156 -0
  96. package/dist/lib/hosts/dispatch.d.ts +16 -0
  97. package/dist/lib/hosts/dispatch.js +26 -4
  98. package/dist/lib/hosts/passthrough.js +9 -2
  99. package/dist/lib/hosts/reconnect.d.ts +78 -0
  100. package/dist/lib/hosts/reconnect.js +127 -0
  101. package/dist/lib/hosts/registry.d.ts +75 -14
  102. package/dist/lib/hosts/registry.js +205 -30
  103. package/dist/lib/hosts/remote-cmd.d.ts +11 -0
  104. package/dist/lib/hosts/remote-cmd.js +32 -8
  105. package/dist/lib/hosts/remote-session-id.d.ts +45 -0
  106. package/dist/lib/hosts/remote-session-id.js +84 -0
  107. package/dist/lib/hosts/run-target.d.ts +17 -5
  108. package/dist/lib/hosts/run-target.js +30 -10
  109. package/dist/lib/hosts/session-index.d.ts +18 -1
  110. package/dist/lib/hosts/session-index.js +40 -5
  111. package/dist/lib/hosts/session-marker.d.ts +33 -0
  112. package/dist/lib/hosts/session-marker.js +51 -0
  113. package/dist/lib/hosts/tasks.d.ts +8 -4
  114. package/dist/lib/import.d.ts +2 -0
  115. package/dist/lib/import.js +35 -2
  116. package/dist/lib/menubar/install-menubar.d.ts +29 -0
  117. package/dist/lib/menubar/install-menubar.js +59 -1
  118. package/dist/lib/menubar/notify-desktop.d.ts +44 -0
  119. package/dist/lib/menubar/notify-desktop.js +78 -0
  120. package/dist/lib/migrate.d.ts +16 -0
  121. package/dist/lib/migrate.js +36 -0
  122. package/dist/lib/models.d.ts +27 -0
  123. package/dist/lib/models.js +54 -1
  124. package/dist/lib/overdue.d.ts +6 -2
  125. package/dist/lib/overdue.js +10 -36
  126. package/dist/lib/picker.js +4 -1
  127. package/dist/lib/platform/process.d.ts +17 -0
  128. package/dist/lib/platform/process.js +70 -0
  129. package/dist/lib/profiles-presets.js +9 -7
  130. package/dist/lib/profiles.d.ts +31 -0
  131. package/dist/lib/profiles.js +70 -0
  132. package/dist/lib/pty-server.d.ts +2 -10
  133. package/dist/lib/pty-server.js +4 -38
  134. package/dist/lib/registry.d.ts +1 -1
  135. package/dist/lib/registry.js +48 -8
  136. package/dist/lib/rotate.d.ts +18 -0
  137. package/dist/lib/rotate.js +28 -0
  138. package/dist/lib/routine-notify.d.ts +76 -0
  139. package/dist/lib/routine-notify.js +190 -0
  140. package/dist/lib/routines-placement.d.ts +38 -0
  141. package/dist/lib/routines-placement.js +79 -0
  142. package/dist/lib/routines-project.d.ts +97 -0
  143. package/dist/lib/routines-project.js +349 -0
  144. package/dist/lib/routines.d.ts +94 -0
  145. package/dist/lib/routines.js +93 -9
  146. package/dist/lib/runner.d.ts +12 -2
  147. package/dist/lib/runner.js +242 -20
  148. package/dist/lib/sandbox.js +10 -0
  149. package/dist/lib/secrets/account-token.d.ts +20 -0
  150. package/dist/lib/secrets/account-token.js +64 -0
  151. package/dist/lib/secrets/agent.d.ts +74 -4
  152. package/dist/lib/secrets/agent.js +181 -107
  153. package/dist/lib/secrets/bundles.d.ts +54 -6
  154. package/dist/lib/secrets/bundles.js +230 -34
  155. package/dist/lib/secrets/index.d.ts +26 -3
  156. package/dist/lib/secrets/index.js +42 -12
  157. package/dist/lib/secrets/rc-hygiene.d.ts +55 -0
  158. package/dist/lib/secrets/rc-hygiene.js +154 -0
  159. package/dist/lib/secrets/remote.d.ts +7 -4
  160. package/dist/lib/secrets/remote.js +7 -4
  161. package/dist/lib/secrets/session-store.d.ts +6 -2
  162. package/dist/lib/secrets/session-store.js +65 -15
  163. package/dist/lib/secrets/sync-commands.d.ts +21 -0
  164. package/dist/lib/secrets/sync-commands.js +21 -0
  165. package/dist/lib/secrets/unlock-hints.d.ts +27 -0
  166. package/dist/lib/secrets/unlock-hints.js +36 -0
  167. package/dist/lib/self-update.d.ts +23 -2
  168. package/dist/lib/self-update.js +86 -5
  169. package/dist/lib/session/active.d.ts +124 -7
  170. package/dist/lib/session/active.js +390 -56
  171. package/dist/lib/session/cloud.js +2 -1
  172. package/dist/lib/session/db.d.ts +54 -0
  173. package/dist/lib/session/db.js +320 -19
  174. package/dist/lib/session/detached.d.ts +30 -0
  175. package/dist/lib/session/detached.js +92 -0
  176. package/dist/lib/session/discover.d.ts +478 -3
  177. package/dist/lib/session/discover.js +1740 -472
  178. package/dist/lib/session/fork.js +2 -1
  179. package/dist/lib/session/hook-sessions.d.ts +43 -0
  180. package/dist/lib/session/hook-sessions.js +135 -0
  181. package/dist/lib/session/linear.d.ts +6 -0
  182. package/dist/lib/session/linear.js +55 -0
  183. package/dist/lib/session/migrate-targets.d.ts +65 -0
  184. package/dist/lib/session/migrate-targets.js +94 -0
  185. package/dist/lib/session/migrations.d.ts +37 -0
  186. package/dist/lib/session/migrations.js +60 -0
  187. package/dist/lib/session/parse.d.ts +18 -0
  188. package/dist/lib/session/parse.js +141 -32
  189. package/dist/lib/session/pid-registry.d.ts +34 -2
  190. package/dist/lib/session/pid-registry.js +49 -2
  191. package/dist/lib/session/prompt.d.ts +7 -0
  192. package/dist/lib/session/prompt.js +37 -0
  193. package/dist/lib/session/provenance.d.ts +7 -0
  194. package/dist/lib/session/render.d.ts +7 -2
  195. package/dist/lib/session/render.js +40 -26
  196. package/dist/lib/session/short-id.d.ts +17 -0
  197. package/dist/lib/session/short-id.js +20 -0
  198. package/dist/lib/session/state.d.ts +4 -0
  199. package/dist/lib/session/state.js +99 -13
  200. package/dist/lib/session/types.d.ts +25 -1
  201. package/dist/lib/session/types.js +8 -0
  202. package/dist/lib/shims.d.ts +1 -1
  203. package/dist/lib/shims.js +20 -6
  204. package/dist/lib/sqlite.d.ts +3 -2
  205. package/dist/lib/sqlite.js +27 -4
  206. package/dist/lib/staleness/detectors/commands.js +1 -1
  207. package/dist/lib/staleness/detectors/workflows.js +13 -2
  208. package/dist/lib/staleness/writers/commands.js +7 -7
  209. package/dist/lib/staleness/writers/workflows.d.ts +4 -2
  210. package/dist/lib/startup/command-registry.d.ts +1 -0
  211. package/dist/lib/startup/command-registry.js +3 -0
  212. package/dist/lib/state.d.ts +8 -4
  213. package/dist/lib/state.js +21 -37
  214. package/dist/lib/teams/agents.d.ts +19 -10
  215. package/dist/lib/teams/agents.js +40 -39
  216. package/dist/lib/types.d.ts +47 -2
  217. package/dist/lib/usage.d.ts +33 -1
  218. package/dist/lib/usage.js +172 -12
  219. package/dist/lib/versions.d.ts +9 -2
  220. package/dist/lib/versions.js +37 -8
  221. package/dist/lib/watchdog/log.d.ts +43 -0
  222. package/dist/lib/watchdog/log.js +69 -0
  223. package/dist/lib/watchdog/routine.d.ts +44 -0
  224. package/dist/lib/watchdog/routine.js +69 -0
  225. package/dist/lib/watchdog/runner.d.ts +51 -7
  226. package/dist/lib/watchdog/runner.js +239 -64
  227. package/dist/lib/watchdog/watchdog.d.ts +1 -1
  228. package/dist/lib/watchdog/watchdog.js +31 -16
  229. package/dist/lib/workflows.d.ts +16 -0
  230. package/dist/lib/workflows.js +110 -1
  231. package/package.json +1 -1
@@ -114,6 +114,32 @@ export declare function validateSecretType(t: string): asserts t is SecretType;
114
114
  */
115
115
  export declare function validateExpiresFutureDated(iso: string): void;
116
116
  export declare function bundleExists(name: string): boolean;
117
+ /**
118
+ * Thrown by `readBundle` for the one state `readBundleIfDecryptable` may treat
119
+ * as "present but permanently unreadable": file-store ciphertext on disk that
120
+ * will not decrypt with the passphrase in effect (lost/rotated key or a tampered
121
+ * store). It is deliberately narrow — a bundle that is merely *locked for this
122
+ * run* (headless macOS without `AGENTS_SECRETS_PASSPHRASE`, or a vault that is
123
+ * not logged in) is recoverable and must not be collapsed into this.
124
+ */
125
+ export declare class BundleUndecryptableError extends Error {
126
+ constructor(message: string);
127
+ }
128
+ /**
129
+ * Read a bundle, or return null when its metadata is present but genuinely
130
+ * cannot be decrypted — a lost or rotated file-store passphrase, or a tampered
131
+ * file store (signalled by `BundleUndecryptableError`).
132
+ *
133
+ * Deleting such a bundle is the only way out of that state, and deletion needs
134
+ * no plaintext, so it must not be gated behind a successful decrypt. Every other
135
+ * failure rethrows: a genuinely missing bundle ("not found"), and — critically —
136
+ * a bundle that is only *temporarily locked* for this run (headless macOS with no
137
+ * `AGENTS_SECRETS_PASSPHRASE`, or a not-logged-in vault). Collapsing that
138
+ * recoverable "set the env / log in" state into "unreadable, safe to delete"
139
+ * would let `secrets delete <name> --yes` silently destroy a perfectly healthy
140
+ * bundle from a cron/launchd run that merely forgot to export the passphrase.
141
+ */
142
+ export declare function readBundleIfDecryptable(name: string): SecretsBundle | null;
117
143
  export declare function readBundle(name: string): SecretsBundle;
118
144
  /** The default prompt policy applied to bundles without an explicit per-bundle
119
145
  * policy. Configurable via `secrets.policy` in agents.yaml; `daily` (one Touch
@@ -147,6 +173,14 @@ export declare function shouldEvictAfterBundleWrite(skipRequested: boolean, noAg
147
173
  export declare function writeBundle(bundle: SecretsBundle, opts?: WriteBundleOptions): void;
148
174
  export declare function writeBundleWithItems(bundle: SecretsBundle, items: Map<string, string>, opts?: WriteBundleOptions): void;
149
175
  export declare function deleteBundle(name: string): boolean;
176
+ /**
177
+ * Re-write already-read keychain bundle metadata items WITHOUT the biometry ACL.
178
+ * `metaJsonByName` maps bundle name → the exact metadata JSON listBundles just
179
+ * batch-read, so writing it back only flips the ACL (via the helper's
180
+ * delete-then-add `set-no-acl`) — contents and updated_at are preserved and no
181
+ * extra keychain read is issued. Exported for tests. Returns the count healed.
182
+ */
183
+ export declare function healKeychainBundleMetadata(metaJsonByName: Map<string, string>): number;
150
184
  export declare function listBundles(): SecretsBundle[];
151
185
  export interface BundleEntryInfo {
152
186
  key: string;
@@ -164,6 +198,12 @@ export interface ResolveBundleOptions {
164
198
  * about to read the bundle.
165
199
  */
166
200
  caller?: string;
201
+ /** Harness type whose unlock may be reused (claude, codex, kimi, ...). */
202
+ agent?: string;
203
+ /** Human duration rendered in the Touch ID prompt. */
204
+ duration?: string;
205
+ /** Explicitly permit this agent request to raise interactive authentication. */
206
+ interactiveUnlock?: boolean;
167
207
  /**
168
208
  * Skip the secrets-agent fast-path and read straight from the keychain
169
209
  * (popping Touch ID). Set by callers that must NOT serve a cached snapshot —
@@ -238,15 +278,19 @@ export declare function resolveBundleEnv(bundle: SecretsBundle, _opts?: ResolveB
238
278
  * True when the current process is a background / non-interactive context that
239
279
  * must NEVER raise a Keychain biometry prompt on the interactive user's screen —
240
280
  * a prompt nobody is watching. Two signals, either sufficient:
241
- * - `AGENTS_RUNTIME` is `headless` or `teams` (set on the child env by
242
- * `agents run --headless`, scheduled routines, and teammates see
243
- * exec.ts:resolveInteractive, runner.ts, teams/agents.ts).
281
+ * - `AGENTS_RUNTIME` is `headless`, `teams`, or `terminal` i.e. ANY agent
282
+ * launch, interactive included, and inherited by everything spawned beneath
283
+ * one (set on the child env by `agents run --headless`, scheduled routines,
284
+ * teammates, and interactive runs — see exec.ts:430, runner.ts,
285
+ * teams/agents.ts).
244
286
  * - neither stdin nor stdout is a TTY (a detached/backgrounded task whose
245
287
  * stdio is redirected to a log — e.g. a release script run in the
246
288
  * background as `( ... ) >log 2>&1 </dev/null`).
247
289
  * `AGENTS_SECRETS_NO_PROMPT=1` forces headless-safe; `=0` force-allows a prompt
248
- * even in a non-TTY context. An interactive `eval "$(agents secrets export X)"`
249
- * keeps its terminal stdin, so it is NOT classified headless and still prompts.
290
+ * even in a non-TTY context. An `eval "$(agents secrets export X)"` typed in a
291
+ * PLAIN shell has no AGENTS_RUNTIME, so it is not classified headless and still
292
+ * prompts. Run beneath an agent it inherits AGENTS_RUNTIME and resolves
293
+ * broker-only — the agent, not the human, is the caller there.
250
294
  *
251
295
  * Only **macOS keychain** reads pop an interactive Touch ID sheet — the secrets
252
296
  * broker itself is a no-op off darwin (see agent.ts), and libsecret (Linux) /
@@ -259,7 +303,10 @@ export declare function resolveBundleEnv(bundle: SecretsBundle, _opts?: ResolveB
259
303
  * fast with an actionable error instead of hijacking Touch ID. This generalizes
260
304
  * the per-caller pattern already used by the daemon (daemon.ts:readDaemonClaudeOAuthToken).
261
305
  */
262
- export declare function isHeadlessSecretsContext(env?: NodeJS.ProcessEnv, platform?: NodeJS.Platform): boolean;
306
+ export declare function isHeadlessSecretsContext(env?: NodeJS.ProcessEnv, platform?: NodeJS.Platform, tty?: {
307
+ stdin?: boolean;
308
+ stdout?: boolean;
309
+ }): boolean;
263
310
  /**
264
311
  * Read a bundle's metadata AND resolve its env in a single Touch ID prompt.
265
312
  *
@@ -277,6 +324,7 @@ export declare function readAndResolveBundleEnv(name: string, opts?: ResolveBund
277
324
  bundle: SecretsBundle;
278
325
  env: Record<string, string>;
279
326
  };
327
+ export declare function humanUnlockDuration(ms: number): string;
280
328
  export declare function keychainRef(key: string): string;
281
329
  /** Options for rotateBundleSecret. */
282
330
  export interface RotateOptions {
@@ -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
  }
@@ -877,15 +1020,19 @@ export function resolveBundleEnv(bundle, _opts = {}) {
877
1020
  * True when the current process is a background / non-interactive context that
878
1021
  * must NEVER raise a Keychain biometry prompt on the interactive user's screen —
879
1022
  * a prompt nobody is watching. Two signals, either sufficient:
880
- * - `AGENTS_RUNTIME` is `headless` or `teams` (set on the child env by
881
- * `agents run --headless`, scheduled routines, and teammates see
882
- * exec.ts:resolveInteractive, runner.ts, teams/agents.ts).
1023
+ * - `AGENTS_RUNTIME` is `headless`, `teams`, or `terminal` i.e. ANY agent
1024
+ * launch, interactive included, and inherited by everything spawned beneath
1025
+ * one (set on the child env by `agents run --headless`, scheduled routines,
1026
+ * teammates, and interactive runs — see exec.ts:430, runner.ts,
1027
+ * teams/agents.ts).
883
1028
  * - neither stdin nor stdout is a TTY (a detached/backgrounded task whose
884
1029
  * stdio is redirected to a log — e.g. a release script run in the
885
1030
  * background as `( ... ) >log 2>&1 </dev/null`).
886
1031
  * `AGENTS_SECRETS_NO_PROMPT=1` forces headless-safe; `=0` force-allows a prompt
887
- * even in a non-TTY context. An interactive `eval "$(agents secrets export X)"`
888
- * keeps its terminal stdin, so it is NOT classified headless and still prompts.
1032
+ * even in a non-TTY context. An `eval "$(agents secrets export X)"` typed in a
1033
+ * PLAIN shell has no AGENTS_RUNTIME, so it is not classified headless and still
1034
+ * prompts. Run beneath an agent it inherits AGENTS_RUNTIME and resolves
1035
+ * broker-only — the agent, not the human, is the caller there.
889
1036
  *
890
1037
  * Only **macOS keychain** reads pop an interactive Touch ID sheet — the secrets
891
1038
  * broker itself is a no-op off darwin (see agent.ts), and libsecret (Linux) /
@@ -898,7 +1045,11 @@ export function resolveBundleEnv(bundle, _opts = {}) {
898
1045
  * fast with an actionable error instead of hijacking Touch ID. This generalizes
899
1046
  * the per-caller pattern already used by the daemon (daemon.ts:readDaemonClaudeOAuthToken).
900
1047
  */
901
- export function isHeadlessSecretsContext(env = process.env, platform = process.platform) {
1048
+ export function isHeadlessSecretsContext(env = process.env, platform = process.platform,
1049
+ // Injected so the TTY branch below is testable: it is the branch that decides a
1050
+ // plain human shell still prompts, which is this guard's entire safety argument,
1051
+ // and reading process.* directly made it unreachable from a test.
1052
+ tty = { stdin: process.stdin.isTTY, stdout: process.stdout.isTTY }) {
902
1053
  if (platform !== 'darwin')
903
1054
  return false; // no biometry prompt to suppress off-darwin
904
1055
  const override = env.AGENTS_SECRETS_NO_PROMPT;
@@ -906,10 +1057,21 @@ export function isHeadlessSecretsContext(env = process.env, platform = process.p
906
1057
  return true;
907
1058
  if (override === '0')
908
1059
  return false;
1060
+ // Every AGENT-LAUNCH runtime resolves broker-only, interactive included.
1061
+ // `terminal` was missing, which made an agent terminal the one launch path
1062
+ // still allowed to pop Touch ID: exec.ts sets AGENTS_RUNTIME='terminal' for an
1063
+ // interactive run (exec.ts:430), that fell through to the TTY check below, and
1064
+ // a TTY meant "a human is watching, so prompting is fine". It is not fine —
1065
+ // opening a terminal is not a request to authenticate, and a launch that needs
1066
+ // a locked bundle should say so and point at `agents secrets unlock`, not grab
1067
+ // the fingerprint sensor. AGENTS_RUNTIME is INHERITED by everything spawned under
1068
+ // an agent, so `agents secrets export` run beneath one resolves broker-only too —
1069
+ // correctly: there the agent, not the human, is the caller. A plain shell carries
1070
+ // no AGENTS_RUNTIME, so a person running it themselves still gets the sheet.
909
1071
  const runtime = env.AGENTS_RUNTIME;
910
- if (runtime === 'headless' || runtime === 'teams')
1072
+ if (runtime === 'headless' || runtime === 'teams' || runtime === 'terminal')
911
1073
  return true;
912
- return !process.stdin.isTTY && !process.stdout.isTTY;
1074
+ return !tty.stdin && !tty.stdout;
913
1075
  }
914
1076
  /**
915
1077
  * Read a bundle's metadata AND resolve its env in a single Touch ID prompt.
@@ -935,7 +1097,8 @@ export function readAndResolveBundleEnv(name, opts = {}) {
935
1097
  // file-backed bundle has none to dedup. The never-unlocked path is a single
936
1098
  // stat (agentSocketExists) so it costs nothing when the agent isn't running.
937
1099
  if (backend === 'keychain' && !opts.noAgent && process.env.AGENTS_SECRETS_NO_AGENT !== '1') {
938
- const hit = agentGetSync(name);
1100
+ const harness = opts.agent || process.env.AGENTS_AGENT_NAME || 'cli';
1101
+ const hit = agentGetSync(name, harness);
939
1102
  if (hit) {
940
1103
  // The agent stores the FULL bundle env. Apply the same subset filter and
941
1104
  // expiry gate as the slow path — without this, `--secrets-keys X` would
@@ -959,13 +1122,13 @@ export function readAndResolveBundleEnv(name, opts = {}) {
959
1122
  // Touch ID. Serve from it and re-warm the broker, so a warm bundle stays warm
960
1123
  // across restart — this fixes BOTH the interactive re-prompt and the headless
961
1124
  // throw below (which now fires only when there is genuinely no session).
962
- const session = loadSession(name);
1125
+ const session = loadSession(name, Date.now(), harness);
963
1126
  if (session) {
964
1127
  const filtered = filterAgentHitBySubsetAndExpiry({ bundle: session.bundle, env: session.env }, opts);
965
1128
  stampLastUsed(filtered.bundle);
966
1129
  // Re-warm the broker with the remaining TTL so later reads hit RAM and
967
1130
  // `agents secrets status` is honest. Best-effort; no-ops off darwin.
968
- agentAutoLoadSync(name, session.bundle, session.env, Math.max(1, session.expiresAt - Date.now()));
1131
+ agentAutoLoadSync(name, session.bundle, session.env, Math.max(1, session.expiresAt - Date.now()), harness);
969
1132
  emit('secrets.get', {
970
1133
  module: 'secrets',
971
1134
  bundle: name,
@@ -977,15 +1140,30 @@ export function readAndResolveBundleEnv(name, opts = {}) {
977
1140
  return filtered;
978
1141
  }
979
1142
  }
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.
984
- 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.`);
1143
+ // Never/no-ACL bundles remain prompt-free regardless. No agent launch harness,
1144
+ // teammate, routine, or the always-on daemon may raise the sheet itself.
1145
+ // Explicit opt-in ONLY a deliberate NARROWING of the agent-triggered approval
1146
+ // added in RUSH-2032 (b99796f8 removed this throw so an agent could raise the
1147
+ // sheet itself; 4eeada68 generalized the daemon rule into `!interactiveUnlock`).
1148
+ // That default true whenever an agent name was present was the spec, not a
1149
+ // bug. It is unwanted: each keychain read runs in its own helper process, so the
1150
+ // biometric assertion never reuses and one agent launch meant one sheet per
1151
+ // bundle. `agentOnly` decides alone now; a human in a plain shell carries no
1152
+ // AGENTS_RUNTIME, so isHeadlessSecretsContext() is false, agentOnly is false, the
1153
+ // guard never fires, and they still get their prompt. No caller passes this flag;
1154
+ // it remains the seam for a future unlock path that wants the sheet on purpose.
1155
+ const interactiveUnlock = opts.interactiveUnlock ?? false;
1156
+ if (opts.agentOnly && backend === 'keychain' && !interactiveUnlock) {
1157
+ let noAclBundle = false;
1158
+ try {
1159
+ noAclBundle = bundlePolicy(readBundle(name)) === 'never';
1160
+ }
1161
+ catch { /* fail closed */ }
1162
+ if (!noAclBundle) {
1163
+ throw new Error(`Secrets bundle '${name}' is not unlocked in the secrets agent. ` +
1164
+ `Run 'agents secrets unlock ${name}' in a terminal first — an agent launch ` +
1165
+ `never raises a Touch ID sheet on its own.`);
1166
+ }
989
1167
  }
990
1168
  if (backend === 'file')
991
1169
  assertFileBackendUsable(name);
@@ -1004,12 +1182,20 @@ export function readAndResolveBundleEnv(name, opts = {}) {
1004
1182
  const reason = opts.caller
1005
1183
  ? `read ${name} secrets (for ${opts.caller})`
1006
1184
  : `read ${name} secrets`;
1007
- void reason;
1008
1185
  // secretItems are storage names as enumerated (opaque hashed names on macOS
1009
1186
  // with #316 hashing active, cleartext elsewhere); metaItem is cleartext and
1010
1187
  // hashed inside getBatch. Deduped because the hashed enumeration spans the
1011
1188
  // bundle's whole namespace.
1012
- const fetched = store.getBatch([...new Set([metaItem, ...secretItems])]);
1189
+ const fetched = backend === 'keychain'
1190
+ ? getKeychainTokens([...new Set([metaItem, ...secretItems])], {
1191
+ agent: opts.agent || process.env.AGENTS_AGENT_NAME || 'Agents CLI',
1192
+ bundle: name,
1193
+ reason: opts.caller ? `to ${opts.caller}` : reason,
1194
+ duration: opts.duration || humanUnlockDuration(secretsHoldMs()),
1195
+ defaultPolicy: secretsDefaultPolicy(),
1196
+ forceDuration: Boolean(opts.duration),
1197
+ })
1198
+ : store.getBatch([...new Set([metaItem, ...secretItems])]);
1013
1199
  const json = fetched.get(metaItem);
1014
1200
  if (json === undefined) {
1015
1201
  // For a file-backed bundle the metadata item is on disk (that's how
@@ -1137,7 +1323,7 @@ export function readAndResolveBundleEnv(name, opts = {}) {
1137
1323
  bundlePolicy(bundle) === 'daily' &&
1138
1324
  secretsAgentAutoEnabled() &&
1139
1325
  canCacheResolvedEnv(bundle, selectedKeys, opts.keyMode)) {
1140
- agentAutoLoadSync(name, bundle, env, secretsHoldMs());
1326
+ agentAutoLoadSync(name, bundle, env, secretsHoldMs(), opts.agent || process.env.AGENTS_AGENT_NAME || 'cli');
1141
1327
  }
1142
1328
  return { bundle, env };
1143
1329
  }
@@ -1146,6 +1332,16 @@ export function readAndResolveBundleEnv(name, opts = {}) {
1146
1332
  throw err;
1147
1333
  }
1148
1334
  }
1335
+ export function humanUnlockDuration(ms) {
1336
+ const days = Math.round(ms / (24 * 60 * 60 * 1000));
1337
+ if (days >= 1)
1338
+ return `${days} day${days === 1 ? '' : 's'}`;
1339
+ const hours = Math.round(ms / (60 * 60 * 1000));
1340
+ if (hours >= 1)
1341
+ return `${hours} hour${hours === 1 ? '' : 's'}`;
1342
+ const minutes = Math.max(1, Math.round(ms / (60 * 1000)));
1343
+ return `${minutes} minute${minutes === 1 ? '' : 's'}`;
1344
+ }
1149
1345
  // Build a keychain ref expression from a bundle+key pair, for storage in the bundle metadata.
1150
1346
  export function keychainRef(key) {
1151
1347
  return `keychain:${key}`;
@@ -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.
@@ -196,7 +210,16 @@ export declare function hasKeychainToken(item: string): boolean;
196
210
  * call in the same process). For bundles, prefer getKeychainTokens() so a
197
211
  * single biometric prompt covers every key in the batch.
198
212
  */
199
- export declare function getKeychainToken(item: string): string;
213
+ export interface KeychainReadContext {
214
+ agent?: string;
215
+ bundle?: string;
216
+ reason?: string;
217
+ duration?: string;
218
+ defaultPolicy?: 'daily' | 'always' | 'never';
219
+ forceDuration?: boolean;
220
+ }
221
+ export declare function keychainOperationPrompt(context?: KeychainReadContext): string;
222
+ export declare function getKeychainToken(item: string, context?: KeychainReadContext): string;
200
223
  /**
201
224
  * Batch-read multiple keychain items behind a single Touch ID prompt. The
202
225
  * macOS helper holds one LAContext for its whole process: the first protected
@@ -207,7 +230,7 @@ export declare function getKeychainToken(item: string): string;
207
230
  * On Linux or when a test backend is installed, falls back to individual
208
231
  * lookups — no biometric prompt path on those platforms.
209
232
  */
210
- export declare function getKeychainTokens(items: string[]): Map<string, string>;
233
+ export declare function getKeychainTokens(items: string[], context?: KeychainReadContext): Map<string, string>;
211
234
  /** Store or update a secret value in the keychain/keyring. Device-local;
212
235
  * biometry-gated on macOS. `opts.noAcl` (the `never` prompt-policy) writes our
213
236
  * item WITHOUT the biometry access control so later reads are fully silent — it
@@ -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)
@@ -689,14 +707,14 @@ export function hasKeychainToken(item) {
689
707
  stdio: ['ignore', 'pipe', 'pipe'],
690
708
  }).status === 0;
691
709
  }
692
- /**
693
- * Retrieve a secret value from the keychain/keyring. Throws if not found.
694
- *
695
- * On macOS this triggers Touch ID (or reuses an assertion held by an earlier
696
- * call in the same process). For bundles, prefer getKeychainTokens() so a
697
- * single biometric prompt covers every key in the batch.
698
- */
699
- export function getKeychainToken(item) {
710
+ export function keychainOperationPrompt(context = {}) {
711
+ const agent = context.agent || 'Agents CLI';
712
+ const bundle = context.bundle ? ` the '${context.bundle}' bundle` : ' secrets';
713
+ const duration = context.duration ? ` for ${context.duration}` : '';
714
+ const reason = context.reason ? ` ${context.reason}` : '';
715
+ return `${agent} is requesting to unlock${bundle}${duration}${reason}.`;
716
+ }
717
+ export function getKeychainToken(item, context = {}) {
700
718
  // Errors keep the requested (human-readable) name; the storage name may be
701
719
  // an opaque hash.
702
720
  const requested = item;
@@ -721,6 +739,11 @@ export function getKeychainToken(item) {
721
739
  }
722
740
  const bin = getKeychainHelperPath();
723
741
  const result = spawnSync(bin, ['get', item, os.userInfo().username], {
742
+ env: {
743
+ ...process.env,
744
+ AGENTS_KEYCHAIN_PROMPT: keychainOperationPrompt(context),
745
+ AGENTS_KEYCHAIN_PROMPT_BASE: keychainOperationPrompt({ ...context, duration: undefined }),
746
+ },
724
747
  stdio: ['ignore', 'pipe', 'pipe'],
725
748
  });
726
749
  if (result.status === 1)
@@ -746,7 +769,7 @@ export function getKeychainToken(item) {
746
769
  * On Linux or when a test backend is installed, falls back to individual
747
770
  * lookups — no biometric prompt path on those platforms.
748
771
  */
749
- export function getKeychainTokens(items) {
772
+ export function getKeychainTokens(items, context = {}) {
750
773
  const result = new Map();
751
774
  if (items.length === 0)
752
775
  return result;
@@ -793,6 +816,13 @@ export function getKeychainTokens(items) {
793
816
  }
794
817
  const bin = getKeychainHelperPath();
795
818
  const child = spawnSync(bin, ['get-batch', os.userInfo().username, ...storageItems], {
819
+ env: {
820
+ ...process.env,
821
+ AGENTS_KEYCHAIN_PROMPT: keychainOperationPrompt(context),
822
+ AGENTS_KEYCHAIN_PROMPT_BASE: keychainOperationPrompt({ ...context, duration: undefined }),
823
+ AGENTS_KEYCHAIN_DEFAULT_POLICY: context.defaultPolicy || 'daily',
824
+ AGENTS_KEYCHAIN_FORCE_DURATION: context.forceDuration ? '1' : '0',
825
+ },
796
826
  stdio: ['ignore', 'pipe', 'pipe'],
797
827
  });
798
828
  if (child.status === 4) {