@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.
- package/CHANGELOG.md +66 -0
- package/README.md +2 -0
- package/dist/bin/agents +0 -0
- package/dist/commands/cloud.js +1 -1
- package/dist/commands/exec.js +1 -1
- package/dist/commands/projects.js +25 -1
- package/dist/commands/secrets.js +9 -5
- package/dist/commands/teams.js +2 -2
- package/dist/commands/watchdog.js +64 -5
- 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/projects.d.ts +15 -0
- package/dist/lib/projects.js +12 -0
- package/dist/lib/rotate.d.ts +16 -1
- package/dist/lib/rotate.js +33 -7
- 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 +25 -30
- package/dist/lib/secrets/bundles.js +84 -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/share/config.d.ts +14 -6
- package/dist/lib/share/config.js +23 -5
- package/dist/lib/types.d.ts +10 -0
- package/dist/lib/usage.js +3 -1
- package/dist/lib/watchdog/rotate.d.ts +218 -0
- package/dist/lib/watchdog/rotate.js +378 -0
- package/dist/lib/watchdog/runner.d.ts +33 -1
- package/dist/lib/watchdog/runner.js +303 -0
- 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
|
-
*
|
|
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
|
*
|
|
@@ -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
|
-
|
|
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);
|
|
@@ -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
|
-
|
|
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;
|