@phnx-labs/agents-cli 1.22.3 → 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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,25 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.22.4
4
+
5
+ - **Background processes no longer storm macOS Touch ID sheets (secrets-touchid-storm).**
6
+ Raw keychain item reads — a profile's provider token on `agents run <profile>`,
7
+ the Claude OAuth read behind `agents view`, any `getKeychainToken` caller — now
8
+ fail fast with an actionable error naming the item when the process is
9
+ non-interactive (an agent runtime, or a TTY-less background spawn like the
10
+ Factory extension host's `agents view` poll), instead of raising a sheet nobody
11
+ is watching. A cancelled or failed interactive read opens a 5-minute back-off
12
+ memo (`~/.agents/.cache/keychain-read-backoff/`) so a polling caller can't
13
+ re-prompt every few seconds; any successful read or write clears it. Reads that
14
+ are prompt-free by construction (bundle metadata, `never`-policy bundles, the
15
+ unlock session store, the OAuth token cache) attest their no-ACL write and are
16
+ unaffected. crabbox's tailscale key is now read at most once per process
17
+ instead of on every `crabboxEnv` call (list/wait/spawn/stop). Source:
18
+ `apps/cli/src/lib/secrets/index.ts`, `apps/cli/src/lib/secrets/headless.ts`,
19
+ `apps/cli/src/lib/secrets/read-backoff.ts`,
20
+ `apps/cli/src/lib/secrets/bundles.ts`, `apps/cli/src/lib/crabbox/cli.ts`,
21
+ `apps/cli/docs/specifications.md` (SEC-13, SEC-27).
22
+
3
23
  ## 1.22.3
4
24
 
5
25
  - **Menubar home-base self-test accepts `MenubarHelper-universal`.** The
package/dist/bin/agents CHANGED
Binary file
@@ -83,6 +83,8 @@ export declare function pickTailscaleBundleFromList(bundles: SecretsBundle[]): {
83
83
  name: string;
84
84
  key: string;
85
85
  } | undefined;
86
+ /** Test seam: drop every crabboxEnv secrets memo so each test resolves fresh. */
87
+ export declare function resetCrabboxSecretsMemosForTest(): void;
86
88
  /** A resolved lease bundle: its name, plus (auto-detect only) the exact keys to inject. */
87
89
  export interface ResolvedLeaseBundle {
88
90
  name: string;
@@ -67,6 +67,42 @@ function resolveTailscaleBundleMemo() {
67
67
  }
68
68
  return tailscaleBundleMemo.value;
69
69
  }
70
+ /**
71
+ * Process-lifetime memo for the RESOLVED tailscale key value, next to the pick
72
+ * memo above. `crabboxEnv` runs several times per lease (list/wait/spawn/stop),
73
+ * and the single-key subset read (`keys: [ts.key]`) is rejected by
74
+ * `canCacheResolvedEnv` for broker auto-cache — so without this memo a
75
+ * non-broker-held tailscale bundle re-read the keychain on EVERY call (and,
76
+ * pre-guard, could pop a Touch ID sheet each time). One read per process,
77
+ * success or failure (a failed read memoizes as undefined: the catch below
78
+ * already degrades to a public-network lease, retrying mid-process only
79
+ * repeats the same failure).
80
+ */
81
+ let tailscaleValueMemo;
82
+ function resolveTailscaleKeyValueMemo(ts) {
83
+ if (!tailscaleValueMemo) {
84
+ let value;
85
+ try {
86
+ const { env } = readAndResolveBundleEnv(ts.name, {
87
+ caller: 'agents run --lease (crabbox tailscale)',
88
+ keys: [ts.key],
89
+ agentOnly: isHeadlessSecretsContext(),
90
+ });
91
+ value = env[ts.key];
92
+ }
93
+ catch {
94
+ /* best-effort — tailscale is opt-in plumbing, never blocks a public lease */
95
+ }
96
+ tailscaleValueMemo = { value };
97
+ }
98
+ return tailscaleValueMemo.value;
99
+ }
100
+ /** Test seam: drop every crabboxEnv secrets memo so each test resolves fresh. */
101
+ export function resetCrabboxSecretsMemosForTest() {
102
+ tailscaleBundleMemo = undefined;
103
+ tailscaleValueMemo = undefined;
104
+ leaseBundleMemo = undefined;
105
+ }
70
106
  /**
71
107
  * The secrets bundle to feed crabbox, resolved in priority order:
72
108
  * 1. `AGENTS_LEASE_SECRETS_BUNDLE` env var — explicit, no keychain
@@ -151,23 +187,15 @@ export function crabboxEnv(opts) {
151
187
  // declares a tailscale auth key, when the ambient env doesn't already set one.
152
188
  // Best-effort and opt-in — a missing/unreadable tailscale bundle never fails a
153
189
  // public-network lease; `crabboxWarmup({ netMode: 'tailscale' })` is what decides
154
- // whether the key is actually used.
190
+ // whether the key is actually used. The resolved value is memoized per process
191
+ // (see resolveTailscaleKeyValueMemo) so repeated crabboxEnv calls read the
192
+ // keychain at most once.
155
193
  if (!out.CRABBOX_TAILSCALE_AUTH_KEY) {
156
194
  const ts = resolveTailscaleBundleMemo();
157
195
  if (ts) {
158
- try {
159
- const { env } = readAndResolveBundleEnv(ts.name, {
160
- caller: 'agents run --lease (crabbox tailscale)',
161
- keys: [ts.key],
162
- agentOnly: isHeadlessSecretsContext(),
163
- });
164
- const value = env[ts.key];
165
- if (value)
166
- out.CRABBOX_TAILSCALE_AUTH_KEY = value;
167
- }
168
- catch {
169
- /* best-effort — tailscale is opt-in plumbing, never blocks a public lease */
170
- }
196
+ const value = resolveTailscaleKeyValueMemo(ts);
197
+ if (value)
198
+ out.CRABBOX_TAILSCALE_AUTH_KEY = value;
171
199
  }
172
200
  }
173
201
  return out;
@@ -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
  *
@@ -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);
@@ -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;
@@ -0,0 +1,64 @@
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
+ import { createHash } from 'node:crypto';
19
+ import * as fs from 'fs';
20
+ import * as os from 'os';
21
+ import * as path from 'path';
22
+ /** How long a failed/cancelled read suppresses retries of the same item. */
23
+ export const KEYCHAIN_READ_BACKOFF_TTL_MS = 5 * 60 * 1000;
24
+ let dirOverride = null;
25
+ /** Test seam: point the memo at a temp dir so tests never touch the real cache. */
26
+ export function setKeychainReadBackoffDirForTest(dir) {
27
+ dirOverride = dir;
28
+ }
29
+ function backoffDir() {
30
+ return dirOverride ?? path.join(os.homedir(), '.agents', '.cache', 'keychain-read-backoff');
31
+ }
32
+ function backoffFile(key) {
33
+ const digest = createHash('sha256').update(key).digest('hex').slice(0, 24);
34
+ return path.join(backoffDir(), `${digest}.json`);
35
+ }
36
+ /** True while `key` is inside the back-off window opened by a failed/cancelled read. */
37
+ export function isKeychainReadBackedOff(key, now = Date.now()) {
38
+ try {
39
+ const parsed = JSON.parse(fs.readFileSync(backoffFile(key), 'utf8'));
40
+ return typeof parsed.until === 'number' && parsed.until > now;
41
+ }
42
+ catch {
43
+ return false; // absent or malformed memo → no back-off
44
+ }
45
+ }
46
+ /** Open (or refresh) the back-off window for `key` after a failed/cancelled read. */
47
+ export function noteKeychainReadFailure(key, now = Date.now()) {
48
+ try {
49
+ fs.mkdirSync(backoffDir(), { recursive: true, mode: 0o700 });
50
+ fs.writeFileSync(backoffFile(key), JSON.stringify({ item: key, until: now + KEYCHAIN_READ_BACKOFF_TTL_MS }), { mode: 0o600 });
51
+ }
52
+ catch {
53
+ /* best-effort — the cache dir is regenerable; a lost memo costs one prompt */
54
+ }
55
+ }
56
+ /** Clear the memo: a successful read or write of the item resets the back-off. */
57
+ export function clearKeychainReadBackoff(key) {
58
+ try {
59
+ fs.rmSync(backoffFile(key), { force: true });
60
+ }
61
+ catch {
62
+ /* best-effort */
63
+ }
64
+ }
@@ -89,7 +89,8 @@ function sessionBlobItem(name, harness) {
89
89
  /** Read the session index by its fixed name. `{bundles:{}}` when absent/unreadable. */
90
90
  export function readIndex() {
91
91
  try {
92
- const raw = getKeychainToken(SESSION_INDEX_ITEM);
92
+ // Written no-ACL (writeIndex below) — attest so a headless resolve stays silent.
93
+ const raw = getKeychainToken(SESSION_INDEX_ITEM, { silentNoAcl: true });
93
94
  const parsed = JSON.parse(raw);
94
95
  if (parsed && typeof parsed === 'object' && parsed.bundles)
95
96
  return parsed;
@@ -145,7 +146,8 @@ export function loadSession(name, now = Date.now(), harness = GLOBAL_HARNESS) {
145
146
  if (!shouldPersist())
146
147
  return null;
147
148
  try {
148
- const raw = getKeychainToken(sessionBlobItem(name, harness));
149
+ // Written no-ACL (saveSession) — attest so a headless resolve stays silent.
150
+ const raw = getKeychainToken(sessionBlobItem(name, harness), { silentNoAcl: true });
149
151
  const entry = JSON.parse(raw);
150
152
  if (!entry || typeof entry !== 'object' || !entry.bundle || !entry.env)
151
153
  return null;
@@ -227,7 +229,7 @@ export function rehydrateSessions(now = Date.now()) {
227
229
  if (key.includes(':'))
228
230
  continue;
229
231
  try {
230
- const raw = getKeychainToken(`${SESSION_ITEM_PREFIX}${key}`);
232
+ const raw = getKeychainToken(`${SESSION_ITEM_PREFIX}${key}`, { silentNoAcl: true });
231
233
  const legacy = JSON.parse(raw);
232
234
  setKeychainToken(sessionBlobItem(key, GLOBAL_HARNESS), JSON.stringify({ ...legacy, harness: GLOBAL_HARNESS }), { noAcl: true });
233
235
  deleteKeychainToken(`${SESSION_ITEM_PREFIX}${key}`);
@@ -265,7 +267,7 @@ export function rehydrateSessions(now = Date.now()) {
265
267
  continue;
266
268
  }
267
269
  try {
268
- const raw = getKeychainToken(sessionBlobItem(bundleName, 'cli'));
270
+ const raw = getKeychainToken(sessionBlobItem(bundleName, 'cli'), { silentNoAcl: true });
269
271
  const legacy = JSON.parse(raw);
270
272
  setKeychainToken(sessionBlobItem(bundleName, GLOBAL_HARNESS), JSON.stringify({ ...legacy, harness: GLOBAL_HARNESS }), { noAcl: true });
271
273
  deleteKeychainToken(sessionBlobItem(bundleName, 'cli'));
@@ -209,7 +209,9 @@ export function clearVaultKey() {
209
209
  export function getVaultSession() {
210
210
  let raw;
211
211
  try {
212
- raw = getKeychainToken(VAULT_SESSION_ITEM);
212
+ // Written no-ACL (cacheVaultKey) — the vault session probe runs inside bundleBackend
213
+ // for EVERY bundle op, headless included, so it must stay silent.
214
+ raw = getKeychainToken(VAULT_SESSION_ITEM, { silentNoAcl: true });
213
215
  }
214
216
  catch {
215
217
  return { loggedIn: false };
package/dist/lib/usage.js CHANGED
@@ -1246,7 +1246,9 @@ function claudeOauthCacheActive() {
1246
1246
  * or the token itself has expired — in which case the stale entry is dropped. */
1247
1247
  function readCachedClaudeOauth(service) {
1248
1248
  try {
1249
- const entry = JSON.parse(getKeychainToken(claudeOauthCacheItem(service)));
1249
+ // The cache item is written no-ACL (writeCachedClaudeOauth) — its whole purpose is
1250
+ // serving the token prompt-free, so attest that to the raw-read storm guard.
1251
+ const entry = JSON.parse(getKeychainToken(claudeOauthCacheItem(service), { silentNoAcl: true }));
1250
1252
  if (!entry || typeof entry.accessToken !== 'string' || !entry.accessToken)
1251
1253
  return null;
1252
1254
  const now = Date.now();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@phnx-labs/agents-cli",
3
- "version": "1.22.3",
3
+ "version": "1.22.4",
4
4
  "description": "One CLI for all your AI coding agents - versions, config, cloud dispatch, sessions, and teams (now with first-class Grok Build CLI support)",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",