@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 +20 -0
- package/dist/bin/agents +0 -0
- package/dist/lib/crabbox/cli.d.ts +2 -0
- package/dist/lib/crabbox/cli.js +42 -14
- package/dist/lib/menubar/MenubarHelper.app/Contents/CodeResources +0 -0
- package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
- package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
- package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
- package/dist/lib/secrets/bundles.d.ts +5 -30
- package/dist/lib/secrets/bundles.js +34 -60
- package/dist/lib/secrets/headless.d.ts +39 -0
- package/dist/lib/secrets/headless.js +63 -0
- package/dist/lib/secrets/index.d.ts +10 -0
- package/dist/lib/secrets/index.js +90 -6
- package/dist/lib/secrets/read-backoff.d.ts +27 -0
- package/dist/lib/secrets/read-backoff.js +64 -0
- package/dist/lib/secrets/session-store.js +6 -4
- package/dist/lib/secrets/vault.js +3 -1
- package/dist/lib/usage.js +3 -1
- package/package.json +1 -1
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;
|
package/dist/lib/crabbox/cli.js
CHANGED
|
@@ -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
|
-
|
|
159
|
-
|
|
160
|
-
|
|
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;
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
@@ -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
|
-
*
|
|
284
|
-
*
|
|
285
|
-
*
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
?
|
|
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
|
-
*
|
|
1003
|
-
*
|
|
1004
|
-
*
|
|
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
|
|
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
|
-
|
|
1121
|
+
verifiedNoAclBundle = bundlePolicy(readBundle(name)) === 'never';
|
|
1149
1122
|
}
|
|
1150
1123
|
catch { /* fail closed */ }
|
|
1151
|
-
if (!
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
"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",
|