@phnx-labs/agents-cli 1.22.1 → 1.22.4

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 (38) hide show
  1. package/CHANGELOG.md +66 -0
  2. package/README.md +2 -0
  3. package/dist/bin/agents +0 -0
  4. package/dist/commands/cloud.js +1 -1
  5. package/dist/commands/exec.js +1 -1
  6. package/dist/commands/projects.js +25 -1
  7. package/dist/commands/secrets.js +9 -5
  8. package/dist/commands/teams.js +2 -2
  9. package/dist/commands/watchdog.js +64 -5
  10. package/dist/lib/crabbox/cli.d.ts +2 -0
  11. package/dist/lib/crabbox/cli.js +42 -14
  12. package/dist/lib/menubar/MenubarHelper.app/Contents/CodeResources +0 -0
  13. package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
  14. package/dist/lib/projects.d.ts +15 -0
  15. package/dist/lib/projects.js +12 -0
  16. package/dist/lib/rotate.d.ts +16 -1
  17. package/dist/lib/rotate.js +33 -7
  18. package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
  19. package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
  20. package/dist/lib/secrets/bundles.d.ts +25 -30
  21. package/dist/lib/secrets/bundles.js +84 -60
  22. package/dist/lib/secrets/headless.d.ts +39 -0
  23. package/dist/lib/secrets/headless.js +63 -0
  24. package/dist/lib/secrets/index.d.ts +10 -0
  25. package/dist/lib/secrets/index.js +90 -6
  26. package/dist/lib/secrets/read-backoff.d.ts +27 -0
  27. package/dist/lib/secrets/read-backoff.js +64 -0
  28. package/dist/lib/secrets/session-store.js +6 -4
  29. package/dist/lib/secrets/vault.js +3 -1
  30. package/dist/lib/share/config.d.ts +14 -6
  31. package/dist/lib/share/config.js +23 -5
  32. package/dist/lib/types.d.ts +10 -0
  33. package/dist/lib/usage.js +3 -1
  34. package/dist/lib/watchdog/rotate.d.ts +218 -0
  35. package/dist/lib/watchdog/rotate.js +378 -0
  36. package/dist/lib/watchdog/runner.d.ts +33 -1
  37. package/dist/lib/watchdog/runner.js +303 -0
  38. package/package.json +1 -1
@@ -279,37 +279,12 @@ export declare function assertRemoteBundleFlagsUnsupported(bundleName: string, h
279
279
  export declare function resolveBundleEnv(bundle: SecretsBundle, _opts?: ResolveBundleOptions): Record<string, string>;
280
280
  /**
281
281
  * True when the current process is a background / non-interactive context that
282
- * must NEVER raise a Keychain biometry prompt on the interactive user's screen —
283
- * a prompt nobody is watching. Two signals, either sufficient:
284
- * - `AGENTS_RUNTIME` is `headless`, `teams`, or `terminal` — i.e. ANY agent
285
- * launch, interactive included, and inherited by everything spawned beneath
286
- * one (set on the child env by `agents run --headless`, scheduled routines,
287
- * teammates, and interactive runs — see exec.ts:430, runner.ts,
288
- * teams/agents.ts).
289
- * - neither stdin nor stdout is a TTY (a detached/backgrounded task whose
290
- * stdio is redirected to a log — e.g. a release script run in the
291
- * background as `( ... ) >log 2>&1 </dev/null`).
292
- * `AGENTS_SECRETS_NO_PROMPT=1` forces headless-safe; `=0` force-allows a prompt
293
- * even in a non-TTY context. An `eval "$(agents secrets export X)"` typed in a
294
- * PLAIN shell has no AGENTS_RUNTIME, so it is not classified headless and still
295
- * prompts. Run beneath an agent it inherits AGENTS_RUNTIME and resolves
296
- * broker-only — the agent, not the human, is the caller there.
297
- *
298
- * Only **macOS keychain** reads pop an interactive Touch ID sheet — the secrets
299
- * broker itself is a no-op off darwin (see agent.ts), and libsecret (Linux) /
300
- * the Windows credential store resolve without any prompt. So off-darwin this
301
- * ALWAYS returns false: forcing broker-only there would break every headless
302
- * Linux/Windows read (CI, `agents run --headless`, routines, the Linux-driven
303
- * release flow) for no benefit — there is no prompt to suppress.
304
- *
305
- * A read in a macOS headless context resolves broker-only (agentOnly) and fails
306
- * fast with an actionable error instead of hijacking Touch ID. This generalizes
307
- * the per-caller broker-only pattern used across the headless secrets readers.
282
+ * must NEVER raise a Keychain biometry prompt on the interactive user's screen.
283
+ * Re-exported from ./headless.js — the detector lives there so the raw-read
284
+ * path in index.ts can share it without a bundles↔index import cycle. See that
285
+ * module for the full contract.
308
286
  */
309
- export declare function isHeadlessSecretsContext(env?: NodeJS.ProcessEnv, platform?: NodeJS.Platform, tty?: {
310
- stdin?: boolean;
311
- stdout?: boolean;
312
- }): boolean;
287
+ export { isHeadlessSecretsContext } from './headless.js';
313
288
  /**
314
289
  * Read a bundle's metadata AND resolve its env in a single Touch ID prompt.
315
290
  *
@@ -344,6 +319,26 @@ export interface RotateOptions {
344
319
  * unless `clearMeta` or a `meta` patch is supplied.
345
320
  */
346
321
  export declare function rotateBundleSecret(bundle: SecretsBundle, key: string, opts: RotateOptions): void;
322
+ /**
323
+ * Reconcile a bundle's keychain-backed VALUE items to its CURRENT policy, then
324
+ * write the (always no-ACL) metadata.
325
+ *
326
+ * `writeBundle` only rewrites the metadata item, so a policy change alone leaves
327
+ * every value item carrying the ACL it was created with — and macOS gates each
328
+ * read on the ITEM's ACL, not the bundle's declared tier (spec SEC-19). Without
329
+ * this reconcile, `agents secrets policy <b> never` reports "silent" while the
330
+ * still-ACL'd value keeps popping Touch ID on every read, forever.
331
+ *
332
+ * hold/always -> never strips the biometry ACL (helper `set-no-acl`: delete+add)
333
+ * never -> hold/always re-attaches it (helper `set`)
334
+ *
335
+ * The current values are read in ONE batch, so the reconcile costs at most a
336
+ * single Touch ID — the last prompt a hold->never bundle will ever raise (a
337
+ * never->* flip reads silently, since the items are already no-ACL). No-op on the
338
+ * ACL to write for non-keychain backends (file/vault have no biometry concept),
339
+ * and a metadata-only write when the bundle has no keychain-backed values.
340
+ */
341
+ export declare function reAclBundleItems(bundle: SecretsBundle): void;
347
342
  /** Options for renameBundle. */
348
343
  export interface RenameOptions {
349
344
  /** When true, overwrite an existing destination bundle (purges its keychain items first). */
@@ -271,7 +271,14 @@ export function readBundle(name) {
271
271
  assertVaultBackendUsable(name);
272
272
  let json;
273
273
  try {
274
- json = itemStore(backend).get(bundleMetaItem(name));
274
+ // Bundle metadata carries no biometry ACL (SEC-4), so this read is silent
275
+ // even in a headless context — attest that to the raw-read storm guard so
276
+ // a headless `readBundle` never trips the fail-fast. (A legacy
277
+ // pre-metadata-heal ACL'd metadata item can still prompt once; it heals on
278
+ // the next interactive read.)
279
+ json = backend === 'keychain'
280
+ ? getKeychainToken(bundleMetaItem(name), { silentNoAcl: true })
281
+ : itemStore(backend).get(bundleMetaItem(name));
275
282
  }
276
283
  catch (err) {
277
284
  // A file-backed bundle whose metadata is on disk but fails to decrypt is a
@@ -669,7 +676,13 @@ export function listBundles() {
669
676
  out.push(bundle);
670
677
  }
671
678
  else {
672
- const fetched = getKeychainTokens(keychainServices);
679
+ // Metadata enumeration must stay silent in ANY context (SEC-11):
680
+ // bundle metadata items are no-ACL by contract (SEC-4), so attest that
681
+ // to the raw-read storm guard — a headless `listBundles` (session
682
+ // start, crabbox env, devices fan-out) must never fail fast on the
683
+ // guard nor pop a sheet. (A legacy pre-heal ACL'd metadata item can
684
+ // still prompt once; it heals on the next interactive scan.)
685
+ const fetched = getKeychainTokens(keychainServices, { silentNoAcl: true });
673
686
  const keychainBundles = [];
674
687
  const metaJsonByName = new Map();
675
688
  for (const service of keychainServices) {
@@ -958,8 +971,14 @@ export function resolveBundleEnv(bundle, _opts = {}) {
958
971
  }
959
972
  }
960
973
  const store = itemStore(bundle.backend ?? 'keychain');
974
+ // keychainStore.getBatch IS getKeychainTokens — call it directly so a
975
+ // `never`-policy bundle (no biometry ACL on its items) attests `silentNoAcl`
976
+ // and stays readable in a headless context, while an ACL'd policy hits the
977
+ // raw-read storm guard and fails fast there.
961
978
  const fetched = keychainItemsToFetch.length > 0
962
- ? store.getBatch(keychainItemsToFetch)
979
+ ? (bundle.backend ?? 'keychain') === 'keychain'
980
+ ? getKeychainTokens(keychainItemsToFetch, { silentNoAcl: bundlePolicy(bundle) === 'never' })
981
+ : store.getBatch(keychainItemsToFetch)
963
982
  : new Map();
964
983
  const env = {};
965
984
  const owners = new Map();
@@ -998,61 +1017,12 @@ export function resolveBundleEnv(bundle, _opts = {}) {
998
1017
  }
999
1018
  /**
1000
1019
  * True when the current process is a background / non-interactive context that
1001
- * must NEVER raise a Keychain biometry prompt on the interactive user's screen —
1002
- * a prompt nobody is watching. Two signals, either sufficient:
1003
- * - `AGENTS_RUNTIME` is `headless`, `teams`, or `terminal` — i.e. ANY agent
1004
- * launch, interactive included, and inherited by everything spawned beneath
1005
- * one (set on the child env by `agents run --headless`, scheduled routines,
1006
- * teammates, and interactive runs — see exec.ts:430, runner.ts,
1007
- * teams/agents.ts).
1008
- * - neither stdin nor stdout is a TTY (a detached/backgrounded task whose
1009
- * stdio is redirected to a log — e.g. a release script run in the
1010
- * background as `( ... ) >log 2>&1 </dev/null`).
1011
- * `AGENTS_SECRETS_NO_PROMPT=1` forces headless-safe; `=0` force-allows a prompt
1012
- * even in a non-TTY context. An `eval "$(agents secrets export X)"` typed in a
1013
- * PLAIN shell has no AGENTS_RUNTIME, so it is not classified headless and still
1014
- * prompts. Run beneath an agent it inherits AGENTS_RUNTIME and resolves
1015
- * broker-only — the agent, not the human, is the caller there.
1016
- *
1017
- * Only **macOS keychain** reads pop an interactive Touch ID sheet — the secrets
1018
- * broker itself is a no-op off darwin (see agent.ts), and libsecret (Linux) /
1019
- * the Windows credential store resolve without any prompt. So off-darwin this
1020
- * ALWAYS returns false: forcing broker-only there would break every headless
1021
- * Linux/Windows read (CI, `agents run --headless`, routines, the Linux-driven
1022
- * release flow) for no benefit — there is no prompt to suppress.
1023
- *
1024
- * A read in a macOS headless context resolves broker-only (agentOnly) and fails
1025
- * fast with an actionable error instead of hijacking Touch ID. This generalizes
1026
- * the per-caller broker-only pattern used across the headless secrets readers.
1020
+ * must NEVER raise a Keychain biometry prompt on the interactive user's screen.
1021
+ * Re-exported from ./headless.js — the detector lives there so the raw-read
1022
+ * path in index.ts can share it without a bundles↔index import cycle. See that
1023
+ * module for the full contract.
1027
1024
  */
1028
- export function isHeadlessSecretsContext(env = process.env, platform = process.platform,
1029
- // Injected so the TTY branch below is testable: it is the branch that decides a
1030
- // plain human shell still prompts, which is this guard's entire safety argument,
1031
- // and reading process.* directly made it unreachable from a test.
1032
- tty = { stdin: process.stdin.isTTY, stdout: process.stdout.isTTY }) {
1033
- if (platform !== 'darwin')
1034
- return false; // no biometry prompt to suppress off-darwin
1035
- const override = env.AGENTS_SECRETS_NO_PROMPT;
1036
- if (override === '1')
1037
- return true;
1038
- if (override === '0')
1039
- return false;
1040
- // Every AGENT-LAUNCH runtime resolves broker-only, interactive included.
1041
- // `terminal` was missing, which made an agent terminal the one launch path
1042
- // still allowed to pop Touch ID: exec.ts sets AGENTS_RUNTIME='terminal' for an
1043
- // interactive run (exec.ts:430), that fell through to the TTY check below, and
1044
- // a TTY meant "a human is watching, so prompting is fine". It is not fine —
1045
- // opening a terminal is not a request to authenticate, and a launch that needs
1046
- // a locked bundle should say so and point at `agents secrets unlock`, not grab
1047
- // the fingerprint sensor. AGENTS_RUNTIME is INHERITED by everything spawned under
1048
- // an agent, so `agents secrets export` run beneath one resolves broker-only too —
1049
- // correctly: there the agent, not the human, is the caller. A plain shell carries
1050
- // no AGENTS_RUNTIME, so a person running it themselves still gets the sheet.
1051
- const runtime = env.AGENTS_RUNTIME;
1052
- if (runtime === 'headless' || runtime === 'teams' || runtime === 'terminal')
1053
- return true;
1054
- return !tty.stdin && !tty.stdout;
1055
- }
1025
+ export { isHeadlessSecretsContext } from './headless.js';
1056
1026
  /**
1057
1027
  * Read a bundle's metadata AND resolve its env in a single Touch ID prompt.
1058
1028
  *
@@ -1142,13 +1112,16 @@ export function readAndResolveBundleEnv(name, opts = {}) {
1142
1112
  // guard never fires, and they still get their prompt. No caller passes this flag;
1143
1113
  // it remains the seam for a future unlock path that wants the sheet on purpose.
1144
1114
  const interactiveUnlock = opts.interactiveUnlock ?? false;
1115
+ // A `never`-policy bundle's items carry no biometry ACL, so once the policy
1116
+ // check below proves that, the batch read is silent even in a headless
1117
+ // context — attest it to the raw-read storm guard via `silentNoAcl`.
1118
+ let verifiedNoAclBundle = false;
1145
1119
  if (opts.agentOnly && backend === 'keychain' && !interactiveUnlock) {
1146
- let noAclBundle = false;
1147
1120
  try {
1148
- noAclBundle = bundlePolicy(readBundle(name)) === 'never';
1121
+ verifiedNoAclBundle = bundlePolicy(readBundle(name)) === 'never';
1149
1122
  }
1150
1123
  catch { /* fail closed */ }
1151
- if (!noAclBundle) {
1124
+ if (!verifiedNoAclBundle) {
1152
1125
  throw new Error(`Secrets bundle '${name}' is not unlocked in the secrets agent. ` +
1153
1126
  `Run 'agents secrets unlock ${name}' in a terminal first — an agent launch ` +
1154
1127
  `never raises a Touch ID sheet on its own.`);
@@ -1184,6 +1157,7 @@ export function readAndResolveBundleEnv(name, opts = {}) {
1184
1157
  duration: opts.duration || humanUnlockDuration(secretsHoldMs()),
1185
1158
  defaultPolicy: secretsDefaultPolicy(),
1186
1159
  forceDuration: Boolean(opts.duration),
1160
+ silentNoAcl: verifiedNoAclBundle,
1187
1161
  })
1188
1162
  : store.getBatch([...new Set([metaItem, ...secretItems])]);
1189
1163
  const json = fetched.get(metaItem);
@@ -1376,6 +1350,56 @@ export function rotateBundleSecret(bundle, key, opts) {
1376
1350
  }
1377
1351
  writeBundle(bundle);
1378
1352
  }
1353
+ /**
1354
+ * Reconcile a bundle's keychain-backed VALUE items to its CURRENT policy, then
1355
+ * write the (always no-ACL) metadata.
1356
+ *
1357
+ * `writeBundle` only rewrites the metadata item, so a policy change alone leaves
1358
+ * every value item carrying the ACL it was created with — and macOS gates each
1359
+ * read on the ITEM's ACL, not the bundle's declared tier (spec SEC-19). Without
1360
+ * this reconcile, `agents secrets policy <b> never` reports "silent" while the
1361
+ * still-ACL'd value keeps popping Touch ID on every read, forever.
1362
+ *
1363
+ * hold/always -> never strips the biometry ACL (helper `set-no-acl`: delete+add)
1364
+ * never -> hold/always re-attaches it (helper `set`)
1365
+ *
1366
+ * The current values are read in ONE batch, so the reconcile costs at most a
1367
+ * single Touch ID — the last prompt a hold->never bundle will ever raise (a
1368
+ * never->* flip reads silently, since the items are already no-ACL). No-op on the
1369
+ * ACL to write for non-keychain backends (file/vault have no biometry concept),
1370
+ * and a metadata-only write when the bundle has no keychain-backed values.
1371
+ */
1372
+ export function reAclBundleItems(bundle) {
1373
+ if ((bundle.backend ?? 'keychain') !== 'keychain') {
1374
+ // No biometry ACL off the keychain backend — only metadata needs persisting.
1375
+ writeBundle(bundle);
1376
+ return;
1377
+ }
1378
+ const store = itemStore('keychain');
1379
+ // keychainItemsForBundle already returns ONLY keychain-backed value items
1380
+ // (via parseBundleValue), so no extra ref-shape filtering here.
1381
+ const entries = keychainItemsForBundle(bundle);
1382
+ if (entries.length === 0) {
1383
+ // Literal/ref-only bundle: nothing to re-ACL, just refresh metadata.
1384
+ writeBundle(bundle);
1385
+ return;
1386
+ }
1387
+ // One batched read = at most one Touch ID for the whole reconcile.
1388
+ const values = store.getBatch(entries.map((e) => e.item));
1389
+ const rewrite = new Map();
1390
+ for (const { item } of entries) {
1391
+ const value = values.get(item);
1392
+ // A key present in metadata but with no readable value item is real
1393
+ // corruption, not something to silently skip (no fallbacks — fail loud).
1394
+ if (value === undefined) {
1395
+ throw new Error(`Cannot change policy for '${bundle.name}': a keychain value is missing or unreadable. Rotate that key, then retry.`);
1396
+ }
1397
+ rewrite.set(item, value);
1398
+ }
1399
+ // writeBundleWithItems re-stores each value with { noAcl: policy === 'never' }
1400
+ // and the metadata no-ACL (metadata-last), and evicts any broker-held copy.
1401
+ writeBundleWithItems(bundle, rewrite);
1402
+ }
1379
1403
  /**
1380
1404
  * Rename a bundle: move metadata + every keychain-backed value to a new name.
1381
1405
  *
@@ -0,0 +1,39 @@
1
+ /**
2
+ * The headless-context detector shared by every secrets read path that could
3
+ * raise a macOS Touch ID sheet: bundle resolution (bundles.ts) and raw item
4
+ * reads (index.ts). It lives in its own module so index.ts can use it without
5
+ * importing bundles.ts (which already imports index.ts).
6
+ */
7
+ /**
8
+ * True when the current process is a background / non-interactive context that
9
+ * must NEVER raise a Keychain biometry prompt on the interactive user's screen —
10
+ * a prompt nobody is watching. Two signals, either sufficient:
11
+ * - `AGENTS_RUNTIME` is `headless`, `teams`, or `terminal` — i.e. ANY agent
12
+ * launch, interactive included, and inherited by everything spawned beneath
13
+ * one (set on the child env by `agents run --headless`, scheduled routines,
14
+ * teammates, and interactive runs — see exec.ts:430, runner.ts,
15
+ * teams/agents.ts).
16
+ * - neither stdin nor stdout is a TTY (a detached/backgrounded task whose
17
+ * stdio is redirected to a log — e.g. a release script run in the
18
+ * background as `( ... ) >log 2>&1 </dev/null`).
19
+ * `AGENTS_SECRETS_NO_PROMPT=1` forces headless-safe; `=0` force-allows a prompt
20
+ * even in a non-TTY context. An `eval "$(agents secrets export X)"` typed in a
21
+ * PLAIN shell has no AGENTS_RUNTIME, so it is not classified headless and still
22
+ * prompts. Run beneath an agent it inherits AGENTS_RUNTIME and resolves
23
+ * broker-only — the agent, not the human, is the caller there.
24
+ *
25
+ * Only **macOS keychain** reads pop an interactive Touch ID sheet — the secrets
26
+ * broker itself is a no-op off darwin (see agent.ts), and libsecret (Linux) /
27
+ * the Windows credential store resolve without any prompt. So off-darwin this
28
+ * ALWAYS returns false: forcing broker-only there would break every headless
29
+ * Linux/Windows read (CI, `agents run --headless`, routines, the Linux-driven
30
+ * release flow) for no benefit — there is no prompt to suppress.
31
+ *
32
+ * A read in a macOS headless context resolves broker-only (agentOnly) and fails
33
+ * fast with an actionable error instead of hijacking Touch ID. This generalizes
34
+ * the per-caller broker-only pattern used across the headless secrets readers.
35
+ */
36
+ export declare function isHeadlessSecretsContext(env?: NodeJS.ProcessEnv, platform?: NodeJS.Platform, tty?: {
37
+ stdin?: boolean;
38
+ stdout?: boolean;
39
+ }): boolean;
@@ -0,0 +1,63 @@
1
+ /**
2
+ * The headless-context detector shared by every secrets read path that could
3
+ * raise a macOS Touch ID sheet: bundle resolution (bundles.ts) and raw item
4
+ * reads (index.ts). It lives in its own module so index.ts can use it without
5
+ * importing bundles.ts (which already imports index.ts).
6
+ */
7
+ /**
8
+ * True when the current process is a background / non-interactive context that
9
+ * must NEVER raise a Keychain biometry prompt on the interactive user's screen —
10
+ * a prompt nobody is watching. Two signals, either sufficient:
11
+ * - `AGENTS_RUNTIME` is `headless`, `teams`, or `terminal` — i.e. ANY agent
12
+ * launch, interactive included, and inherited by everything spawned beneath
13
+ * one (set on the child env by `agents run --headless`, scheduled routines,
14
+ * teammates, and interactive runs — see exec.ts:430, runner.ts,
15
+ * teams/agents.ts).
16
+ * - neither stdin nor stdout is a TTY (a detached/backgrounded task whose
17
+ * stdio is redirected to a log — e.g. a release script run in the
18
+ * background as `( ... ) >log 2>&1 </dev/null`).
19
+ * `AGENTS_SECRETS_NO_PROMPT=1` forces headless-safe; `=0` force-allows a prompt
20
+ * even in a non-TTY context. An `eval "$(agents secrets export X)"` typed in a
21
+ * PLAIN shell has no AGENTS_RUNTIME, so it is not classified headless and still
22
+ * prompts. Run beneath an agent it inherits AGENTS_RUNTIME and resolves
23
+ * broker-only — the agent, not the human, is the caller there.
24
+ *
25
+ * Only **macOS keychain** reads pop an interactive Touch ID sheet — the secrets
26
+ * broker itself is a no-op off darwin (see agent.ts), and libsecret (Linux) /
27
+ * the Windows credential store resolve without any prompt. So off-darwin this
28
+ * ALWAYS returns false: forcing broker-only there would break every headless
29
+ * Linux/Windows read (CI, `agents run --headless`, routines, the Linux-driven
30
+ * release flow) for no benefit — there is no prompt to suppress.
31
+ *
32
+ * A read in a macOS headless context resolves broker-only (agentOnly) and fails
33
+ * fast with an actionable error instead of hijacking Touch ID. This generalizes
34
+ * the per-caller broker-only pattern used across the headless secrets readers.
35
+ */
36
+ export function isHeadlessSecretsContext(env = process.env, platform = process.platform,
37
+ // Injected so the TTY branch below is testable: it is the branch that decides a
38
+ // plain human shell still prompts, which is this guard's entire safety argument,
39
+ // and reading process.* directly made it unreachable from a test.
40
+ tty = { stdin: process.stdin.isTTY, stdout: process.stdout.isTTY }) {
41
+ if (platform !== 'darwin')
42
+ return false; // no biometry prompt to suppress off-darwin
43
+ const override = env.AGENTS_SECRETS_NO_PROMPT;
44
+ if (override === '1')
45
+ return true;
46
+ if (override === '0')
47
+ return false;
48
+ // Every AGENT-LAUNCH runtime resolves broker-only, interactive included.
49
+ // `terminal` was missing, which made an agent terminal the one launch path
50
+ // still allowed to pop Touch ID: exec.ts sets AGENTS_RUNTIME='terminal' for an
51
+ // interactive run (exec.ts:430), that fell through to the TTY check below, and
52
+ // a TTY meant "a human is watching, so prompting is fine". It is not fine —
53
+ // opening a terminal is not a request to authenticate, and a launch that needs
54
+ // a locked bundle should say so and point at `agents secrets unlock`, not grab
55
+ // the fingerprint sensor. AGENTS_RUNTIME is INHERITED by everything spawned under
56
+ // an agent, so `agents secrets export` run beneath one resolves broker-only too —
57
+ // correctly: there the agent, not the human, is the caller. A plain shell carries
58
+ // no AGENTS_RUNTIME, so a person running it themselves still gets the sheet.
59
+ const runtime = env.AGENTS_RUNTIME;
60
+ if (runtime === 'headless' || runtime === 'teams' || runtime === 'terminal')
61
+ return true;
62
+ return !tty.stdin && !tty.stdout;
63
+ }
@@ -217,7 +217,17 @@ export interface KeychainReadContext {
217
217
  duration?: string;
218
218
  defaultPolicy?: 'hold' | 'always' | 'never';
219
219
  forceDuration?: boolean;
220
+ /**
221
+ * The caller attests the item(s) carry NO biometry ACL (it wrote them with
222
+ * `setKeychainToken(..., { noAcl: true })`, or they are bundle metadata /
223
+ * `never`-policy bundle items, which are no-ACL by contract) — so the read
224
+ * is silent even when no one is at the screen. Skips the headless fail-fast
225
+ * and the back-off memo. Never pass this for an ACL-protected item: that
226
+ * re-opens the background Touch ID storm the guard exists to stop.
227
+ */
228
+ silentNoAcl?: boolean;
220
229
  }
230
+ export declare function setKeychainHeadlessDetectorForTest(detector: (() => boolean) | null): void;
221
231
  export declare function keychainOperationPrompt(context?: KeychainReadContext): string;
222
232
  export declare function getKeychainToken(item: string, context?: KeychainReadContext): string;
223
233
  /**
@@ -29,6 +29,8 @@ import * as os from 'os';
29
29
  import * as path from 'path';
30
30
  import { linuxBackend, usesFileFallback as linuxUsesFileFallback, importNativeSecretToolItems } from './linux.js';
31
31
  import { windowsBackend, usesFileFallback as windowsUsesFileFallback, importNativeCredManItems } from './windows.js';
32
+ import { isHeadlessSecretsContext } from './headless.js';
33
+ import { KEYCHAIN_READ_BACKOFF_TTL_MS, clearKeychainReadBackoff, isKeychainReadBackedOff, noteKeychainReadFailure, } from './read-backoff.js';
32
34
  import { getKeychainHelperPath } from './install-helper.js';
33
35
  import { deriveShortId } from '../session/short-id.js';
34
36
  const SERVICE_PREFIX = 'agents-cli';
@@ -236,10 +238,11 @@ function parseHmacKeyRecord(raw) {
236
238
  function readHmacKeyRecord() {
237
239
  // HMAC_KEY_ITEM is exempt from the transform, so this routes to the helper
238
240
  // (or the test backend) under its literal name. The item is no-ACL, so the
239
- // read is silent.
241
+ // read is silent — attest that to the storm guard so a headless hashed-name
242
+ // resolution never trips the fail-fast.
240
243
  let raw;
241
244
  try {
242
- raw = getKeychainToken(HMAC_KEY_ITEM);
245
+ raw = getKeychainToken(HMAC_KEY_ITEM, { silentNoAcl: true });
243
246
  }
244
247
  catch {
245
248
  return null;
@@ -707,6 +710,56 @@ export function hasKeychainToken(item) {
707
710
  stdio: ['ignore', 'pipe', 'pipe'],
708
711
  }).status === 0;
709
712
  }
713
+ /**
714
+ * The detector the raw-read storm guard consults, overridable so tests on any
715
+ * platform exercise the fail-fast path — the real detector self-gates to
716
+ * darwin (the only platform with a Touch ID sheet to suppress), which would
717
+ * make the throw unreachable from a Linux CI run. Same parameterization
718
+ * argument as the injected env/platform/tty on isHeadlessSecretsContext.
719
+ */
720
+ let rawReadHeadlessDetector = () => isHeadlessSecretsContext();
721
+ export function setKeychainHeadlessDetectorForTest(detector) {
722
+ rawReadHeadlessDetector = detector ?? (() => isHeadlessSecretsContext());
723
+ }
724
+ /**
725
+ * The raw-read storm guard, consulted by every getKeychainToken /
726
+ * getKeychainTokens read that could reach a prompting keychain query. Two
727
+ * fail-fast gates, both skipped for `silentNoAcl` (provably prompt-free)
728
+ * reads:
729
+ *
730
+ * 1. Headless fail-fast. A non-interactive process (AGENTS_RUNTIME set, or
731
+ * no TTY — see isHeadlessSecretsContext) must NEVER raise a Touch ID sheet
732
+ * on the interactive user's screen: the sheet has no one to answer it, a
733
+ * polling caller re-raises it every few seconds, and a cancel just feeds
734
+ * the next poll. Throw an actionable error naming the item instead.
735
+ * 2. Back-off. A read whose prompt recently failed or was cancelled is
736
+ * suppressed for KEYCHAIN_READ_BACKOFF_TTL_MS so an interactive-context
737
+ * poller (TTY but unwatched — a tmux pane, a VS Code task terminal) can't
738
+ * storm sheets either. A successful read or write clears the memo.
739
+ *
740
+ * Placement note: this runs BEFORE the platform branches so the back-off memo
741
+ * is honored identically everywhere, and the headless gate is a no-op off
742
+ * darwin (the detector returns false there) — Linux/Windows reads never
743
+ * prompt, so there is nothing to guard.
744
+ */
745
+ function assertRawKeychainReadAllowed(key, context, label) {
746
+ if (context.silentNoAcl)
747
+ return;
748
+ const what = label ?? `Keychain item '${key}'`;
749
+ if (rawReadHeadlessDetector()) {
750
+ const hint = context.bundle
751
+ ? `Run 'agents secrets unlock ${context.bundle}' in a terminal first`
752
+ : `Provision a prompt-free credential for headless use (a file-based setup token, or an item stored without the biometry ACL), ` +
753
+ `or read it once from an interactive terminal`;
754
+ throw new Error(`${what} requires Touch ID, but this process is non-interactive — ` +
755
+ `a prompt would appear on screen with no one to answer it. ${hint}.`);
756
+ }
757
+ if (isKeychainReadBackedOff(key)) {
758
+ throw new Error(`${what} is in read back-off: a Touch ID prompt for it failed or was cancelled within the last ` +
759
+ `${Math.round(KEYCHAIN_READ_BACKOFF_TTL_MS / 60000)} minutes, and retrying is suppressed so a polling caller can't storm prompts. ` +
760
+ `Read it once interactively or wait out the back-off.`);
761
+ }
762
+ }
710
763
  export function keychainOperationPrompt(context = {}) {
711
764
  const agent = context.agent || 'Agents CLI';
712
765
  const bundle = context.bundle ? ` the '${context.bundle}' bundle` : ' secrets';
@@ -724,6 +777,7 @@ export function getKeychainToken(item, context = {}) {
724
777
  item = prepareServiceName(item, { autoRekey: true });
725
778
  if (backend)
726
779
  return backend.get(item);
780
+ assertRawKeychainReadAllowed(requested, context);
727
781
  assertSupportedPlatform();
728
782
  if (isLinux())
729
783
  return linuxBackend.get(item);
@@ -735,8 +789,10 @@ export function getKeychainToken(item, context = {}) {
735
789
  });
736
790
  if (sec.status === 0) {
737
791
  const token = sec.stdout?.toString().trim();
738
- if (token)
792
+ if (token) {
793
+ clearKeychainReadBackoff(requested);
739
794
  return token;
795
+ }
740
796
  }
741
797
  throw new Error(`Keychain item '${requested}' not found.`);
742
798
  }
@@ -749,17 +805,24 @@ export function getKeychainToken(item, context = {}) {
749
805
  },
750
806
  stdio: ['ignore', 'pipe', 'pipe'],
751
807
  });
808
+ // Exit 1 is a plain miss — no prompt was raised, so it opens no back-off.
752
809
  if (result.status === 1)
753
810
  throw new Error(`Keychain item '${requested}' not found.`);
754
- if (result.status === 4)
755
- throw new Error(`Touch ID cancelled while reading '${requested}'.`);
756
811
  if (result.status !== 0) {
812
+ // A cancel (4) or helper failure after a prompted read: open the back-off
813
+ // window BEFORE throwing, so the next poll of the same item fails fast
814
+ // instead of re-raising the sheet.
815
+ if (!context.silentNoAcl)
816
+ noteKeychainReadFailure(requested);
817
+ if (result.status === 4)
818
+ throw new Error(`Touch ID cancelled while reading '${requested}'.`);
757
819
  const msg = result.stderr?.toString().trim();
758
820
  throw new Error(msg || `Failed to read keychain item '${requested}'.`);
759
821
  }
760
822
  const token = result.stdout?.toString();
761
823
  if (!token)
762
824
  throw new Error(`Keychain item '${requested}' exists but is empty.`);
825
+ clearKeychainReadBackoff(requested);
763
826
  return token;
764
827
  }
765
828
  /**
@@ -798,6 +861,13 @@ export function getKeychainTokens(items, context = {}) {
798
861
  }
799
862
  return result;
800
863
  }
864
+ // One back-off memo covers the whole batch: the batch raises at most one
865
+ // prompt, so a cancel/failure suppresses retrying the same batch, keyed by
866
+ // the requested names (never the storage hashes) for a stable identity. The
867
+ // guard runs before the platform branches so the headless fail-fast is
868
+ // exercisable on any platform (the real detector self-gates to darwin).
869
+ const backoffKey = `batch:${items.join('\n')}`;
870
+ assertRawKeychainReadAllowed(backoffKey, context, `Batch read of ${items.length} keychain item(s)`);
801
871
  assertSupportedPlatform();
802
872
  if (isLinux()) {
803
873
  for (const storage of storageItems) {
@@ -830,6 +900,8 @@ export function getKeychainTokens(items, context = {}) {
830
900
  },
831
901
  stdio: ['ignore', 'pipe', 'pipe'],
832
902
  });
903
+ if (child.status !== 0 && !context.silentNoAcl)
904
+ noteKeychainReadFailure(backoffKey);
833
905
  if (child.status === 4) {
834
906
  throw new Error(`Touch ID cancelled while reading ${items.length} keychain item(s).`);
835
907
  }
@@ -837,6 +909,7 @@ export function getKeychainTokens(items, context = {}) {
837
909
  const msg = child.stderr?.toString().trim();
838
910
  throw new Error(msg || `Failed to batch-read ${items.length} keychain items.`);
839
911
  }
912
+ clearKeychainReadBackoff(backoffKey);
840
913
  const out = child.stdout?.toString() ?? '';
841
914
  parseBatchRecords(out, record);
842
915
  return result;
@@ -917,6 +990,7 @@ export function setKeychainToken(item, value, opts) {
917
990
  // resolve the storage name.
918
991
  if (/[\x00=\r\n]/.test(item))
919
992
  throw new Error('Secret item name contains invalid characters.');
993
+ const requested = item;
920
994
  item = prepareServiceName(item, { autoRekey: true });
921
995
  if (backend) {
922
996
  backend.set(item, value, opts);
@@ -968,6 +1042,7 @@ export function setKeychainToken(item, value, opts) {
968
1042
  const msg = sec.stderr?.toString().trim();
969
1043
  throw new Error(msg || `Failed to write keychain item '${item}'.`);
970
1044
  }
1045
+ clearKeychainReadBackoff(requested);
971
1046
  return;
972
1047
  }
973
1048
  const bin = getKeychainHelperPath();
@@ -989,9 +1064,13 @@ export function setKeychainToken(item, value, opts) {
989
1064
  }
990
1065
  throw new Error(msg || `Failed to write keychain item '${item}'.`);
991
1066
  }
1067
+ // A successful write supersedes any open back-off: the item is known-good
1068
+ // now, so the next read must not be suppressed by a stale failure memo.
1069
+ clearKeychainReadBackoff(requested);
992
1070
  }
993
1071
  /** Delete a keychain/keyring item. Returns true if it existed. Never prompts for biometry. */
994
1072
  export function deleteKeychainToken(item) {
1073
+ const requested = item;
995
1074
  item = prepareServiceName(item);
996
1075
  if (backend)
997
1076
  return backend.delete(item);
@@ -1001,9 +1080,14 @@ export function deleteKeychainToken(item) {
1001
1080
  if (isWindows())
1002
1081
  return windowsBackend.delete(item);
1003
1082
  const bin = getKeychainHelperPath();
1004
- return spawnSync(bin, ['delete', item, os.userInfo().username], {
1083
+ const deleted = spawnSync(bin, ['delete', item, os.userInfo().username], {
1005
1084
  stdio: ['ignore', 'pipe', 'pipe'],
1006
1085
  }).status === 0;
1086
+ // A deleted item must fail its next read as plain "not found", not with a
1087
+ // stale back-off error left over from a pre-delete cancel.
1088
+ if (deleted)
1089
+ clearKeychainReadBackoff(requested);
1090
+ return deleted;
1007
1091
  }
1008
1092
  /**
1009
1093
  * True when the active keychain backend transparently routes reads/writes to
@@ -0,0 +1,27 @@
1
+ /**
2
+ * Negative memo for failed/cancelled macOS keychain reads (the "back-off").
3
+ *
4
+ * A cancelled Touch ID sheet is the user saying "not now" — but a polling
5
+ * caller (the Factory extension host's `agents view` loop, a watch script)
6
+ * retries the same read a few seconds later and pops the sheet again, forever.
7
+ * The headless guard (index.ts `assertRawKeychainReadAllowed`) covers the
8
+ * no-TTY case; this memo covers a context that CAN prompt but just had its
9
+ * prompt cancelled or fail: the next read of the same item within the TTL
10
+ * throws the back-off error instead of re-prompting. Any successful read (or
11
+ * write) of the item clears the memo.
12
+ *
13
+ * Stored as regenerable state under `~/.agents/.cache/keychain-read-backoff/`,
14
+ * one file per item (filename is a hash of the item name; the file carries no
15
+ * secret material — a name and a deadline only). All operations are
16
+ * best-effort: a lost memo costs at most one extra prompt, never a read.
17
+ */
18
+ /** How long a failed/cancelled read suppresses retries of the same item. */
19
+ export declare const KEYCHAIN_READ_BACKOFF_TTL_MS: number;
20
+ /** Test seam: point the memo at a temp dir so tests never touch the real cache. */
21
+ export declare function setKeychainReadBackoffDirForTest(dir: string | null): void;
22
+ /** True while `key` is inside the back-off window opened by a failed/cancelled read. */
23
+ export declare function isKeychainReadBackedOff(key: string, now?: number): boolean;
24
+ /** Open (or refresh) the back-off window for `key` after a failed/cancelled read. */
25
+ export declare function noteKeychainReadFailure(key: string, now?: number): void;
26
+ /** Clear the memo: a successful read or write of the item resets the back-off. */
27
+ export declare function clearKeychainReadBackoff(key: string): void;