@phnx-labs/agents-cli 1.22.3 → 1.22.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. package/CHANGELOG.md +48 -0
  2. package/dist/bin/agents +0 -0
  3. package/dist/commands/events.js +8 -0
  4. package/dist/commands/exec.js +7 -5
  5. package/dist/commands/inspect.js +18 -5
  6. package/dist/commands/models.d.ts +1 -1
  7. package/dist/commands/models.js +68 -9
  8. package/dist/commands/view.d.ts +12 -0
  9. package/dist/commands/view.js +2 -0
  10. package/dist/commands/webhook.js +8 -4
  11. package/dist/lib/browser/chrome.d.ts +12 -0
  12. package/dist/lib/browser/chrome.js +27 -11
  13. package/dist/lib/cloud/antigravity.js +8 -2
  14. package/dist/lib/crabbox/cli.d.ts +2 -0
  15. package/dist/lib/crabbox/cli.js +109 -39
  16. package/dist/lib/event-stream.d.ts +3 -0
  17. package/dist/lib/event-stream.js +5 -0
  18. package/dist/lib/events.d.ts +2 -0
  19. package/dist/lib/events.js +6 -1
  20. package/dist/lib/feed.d.ts +1 -1
  21. package/dist/lib/feed.js +19 -0
  22. package/dist/lib/menubar/MenubarHelper.app/Contents/CodeResources +0 -0
  23. package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
  24. package/dist/lib/model-tier-overrides.d.ts +43 -0
  25. package/dist/lib/model-tier-overrides.js +97 -0
  26. package/dist/lib/model-tiers.d.ts +13 -7
  27. package/dist/lib/model-tiers.js +104 -35
  28. package/dist/lib/models.d.ts +16 -0
  29. package/dist/lib/models.js +17 -3
  30. package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
  31. package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
  32. package/dist/lib/secrets/bundles.d.ts +5 -30
  33. package/dist/lib/secrets/bundles.js +34 -60
  34. package/dist/lib/secrets/headless.d.ts +39 -0
  35. package/dist/lib/secrets/headless.js +63 -0
  36. package/dist/lib/secrets/index.d.ts +39 -0
  37. package/dist/lib/secrets/index.js +122 -6
  38. package/dist/lib/secrets/mcp.js +8 -4
  39. package/dist/lib/secrets/read-backoff.d.ts +27 -0
  40. package/dist/lib/secrets/read-backoff.js +64 -0
  41. package/dist/lib/secrets/session-store.js +6 -4
  42. package/dist/lib/secrets/vault.js +3 -1
  43. package/dist/lib/session/sync/config.d.ts +12 -11
  44. package/dist/lib/session/sync/config.js +40 -38
  45. package/dist/lib/share/config.js +4 -0
  46. package/dist/lib/types.d.ts +10 -0
  47. package/dist/lib/usage.js +3 -1
  48. package/dist/lib/versions.js +35 -0
  49. package/package.json +1 -1
@@ -1,11 +1,27 @@
1
1
  import { getModelCatalog } from './models.js';
2
2
  import { getModelPricing } from './pricing/index.js';
3
+ import { resolveTierOverride } from './model-tier-overrides.js';
3
4
  /** The four cross-harness cost tiers, cheapest -> most capable. */
4
5
  export const MODEL_TIERS = ['cheap', 'default', 'best', 'ultra'];
5
6
  /** True if `s` is one of the four tier tokens (not a concrete model id). */
6
7
  export function isTierToken(s) {
7
8
  return !!s && MODEL_TIERS.includes(s);
8
9
  }
10
+ /**
11
+ * Curated tier ladders for harnesses the auto-ranker can't order from names/price
12
+ * (subscription harnesses with no price signal). Each rung is `[tier, matcher]` in
13
+ * cheap -> best order; the newest catalog id matching each rung fills that tier,
14
+ * missing tiers clamp. Extend this table rather than adding per-harness branches.
15
+ */
16
+ const CURATED_LADDERS = {
17
+ // Kimi: K2.7 Highspeed < K2.7 Coding < K3 (the 1M-context default; k3-256k folds
18
+ // into K3). No ultra. The name heuristic can't tell K3 > K2.7, so curate it.
19
+ kimi: [
20
+ { tier: 'cheap', match: /highspeed/i },
21
+ { tier: 'default', match: /for-coding(?!.*highspeed)/i },
22
+ { tier: 'best', match: /(^|[-/])k3\b/i }, // K3 family incl. k3-256k; the plain id represents it
23
+ ],
24
+ };
9
25
  // --- single-model harnesses: the tier is reasoning effort, not a model ---------
10
26
  const TIER_EFFORT = {
11
27
  cheap: 'low',
@@ -169,29 +185,100 @@ function rankCatalog(agent, models) {
169
185
  function rungIndexFor(tierIndex, n) {
170
186
  return n >= 4 ? Math.round((tierIndex / 3) * (n - 1)) : Math.min(tierIndex, n - 1);
171
187
  }
188
+ /** Bucket an ordered (cheap -> dear) rung list onto the four tiers, clamping when < 4. */
189
+ function bucketRungs(rungs) {
190
+ const n = rungs.length;
191
+ const map = {};
192
+ if (n === 0) {
193
+ // Fail-safe: no catalog -> every tier null, caller drops the --model flag.
194
+ for (const t of MODEL_TIERS)
195
+ map[t] = { tier: t, model: null };
196
+ return map;
197
+ }
198
+ // A tier that shares the rung of the tier below has no distinct rung -> mark clamped.
199
+ for (let i = 0; i < MODEL_TIERS.length; i++) {
200
+ const t = MODEL_TIERS[i];
201
+ const idx = rungIndexFor(i, n);
202
+ const shared = i > 0 && rungIndexFor(i - 1, n) === idx;
203
+ map[t] = shared
204
+ ? { tier: t, model: rungs[idx].id, clampedFrom: MODEL_TIERS[i - 1], note: `no distinct ${t} rung; using ${MODEL_TIERS[i - 1]}`, source: 'auto' }
205
+ : { tier: t, model: rungs[idx].id, source: 'auto' };
206
+ }
207
+ return map;
208
+ }
209
+ /** Build a tier map from a curated ladder against a catalog (newest match per rung). */
210
+ function tierizeFromLadder(ladder, models) {
211
+ const usable = models.filter((m) => !PSEUDO.test(m.id));
212
+ const rungs = [];
213
+ for (const rung of ladder) {
214
+ const matches = usable.filter((m) => rung.match.test(m.id));
215
+ if (matches.length === 0)
216
+ continue;
217
+ // Prefer a plain id over a context-size variant (k3 over k3-256k) -- the
218
+ // variant folds into the rung but the plain model represents it -- then newest.
219
+ const plain = matches.filter((m) => !/-\d+[km]\b/i.test(m.id));
220
+ const pool = plain.length ? plain : matches;
221
+ rungs.push({ id: pool.reduce((a, b) => (newer(b.id, a.id) > 0 ? b : a)).id });
222
+ }
223
+ const map = bucketRungs(rungs);
224
+ for (const t of MODEL_TIERS)
225
+ if (map[t].model)
226
+ map[t].source = 'curated';
227
+ return map;
228
+ }
172
229
  /**
173
- * Resolve all four tiers for an (agent, version). The map is what `agents models`
174
- * prints and what `resolveTier` indexes into.
230
+ * Resolve all four tiers for an (agent, version) -- what `agents models` prints and
231
+ * `resolveTier` indexes. Precedence: user override -> curated ladder / auto-ranking.
175
232
  */
176
233
  export function resolveTierMap(agent, version) {
177
- // Droid: curated credit-multiplier map (no live catalog).
234
+ let base;
235
+ let catalogIds;
178
236
  if (agent === 'droid') {
179
- return {
180
- cheap: { tier: 'cheap', model: DROID_TIERS.cheap, note: 'Droid Core 0.55x' },
181
- default: { tier: 'default', model: DROID_TIERS.default, note: 'Droid Core 0.6x' },
182
- best: { tier: 'best', model: DROID_TIERS.best, note: '2x' },
183
- ultra: { tier: 'ultra', model: DROID_TIERS.ultra, clampedFrom: 'best', note: 'capped at 2x (4x models excluded)' },
237
+ // Droid: curated credit-multiplier map (no live catalog to validate against).
238
+ base = {
239
+ cheap: { tier: 'cheap', model: DROID_TIERS.cheap, note: 'Droid Core 0.55x', source: 'curated' },
240
+ default: { tier: 'default', model: DROID_TIERS.default, note: 'Droid Core 0.6x', source: 'curated' },
241
+ best: { tier: 'best', model: DROID_TIERS.best, note: '2x', source: 'curated' },
242
+ ultra: { tier: 'ultra', model: DROID_TIERS.ultra, clampedFrom: 'best', note: 'capped at 2x (4x excluded)', source: 'curated' },
184
243
  };
244
+ catalogIds = null;
185
245
  }
186
- const catalog = getModelCatalog(agent, version);
187
- return tierizeModels(agent, catalog?.models ?? []);
246
+ else {
247
+ const catalog = getModelCatalog(agent, version);
248
+ const models = catalog?.models ?? [];
249
+ const ladder = CURATED_LADDERS[agent];
250
+ base = ladder ? tierizeFromLadder(ladder, models) : tierizeModels(agent, models);
251
+ catalogIds = catalog ? new Set(models.map((m) => m.id)) : null;
252
+ }
253
+ const overrides = resolveTierOverride(agent, version);
254
+ return applyTierOverrides(overrides, `${agent}@${version}`, catalogIds, base);
255
+ }
256
+ /**
257
+ * Apply user overrides on top of the auto/curated map. Pure (takes the resolved
258
+ * override map, no config lookup) so it is directly testable. An overridden id is
259
+ * used only when the version actually ships it (or when there is no catalog to
260
+ * check, e.g. Droid); otherwise the tier keeps its base value with a note.
261
+ */
262
+ export function applyTierOverrides(overrides, label, catalogIds, base) {
263
+ if (Object.keys(overrides).length === 0)
264
+ return base;
265
+ const out = { ...base };
266
+ for (const t of MODEL_TIERS) {
267
+ const id = overrides[t];
268
+ if (!id)
269
+ continue;
270
+ if (!catalogIds || catalogIds.has(id)) {
271
+ out[t] = { tier: t, model: id, source: 'override' };
272
+ }
273
+ else {
274
+ out[t] = { ...base[t], note: `override "${id}" not shipped by ${label}; kept the ${base[t].source ?? 'auto'} pick`, source: base[t].source ?? 'auto' };
275
+ }
276
+ }
277
+ return out;
188
278
  }
189
279
  /**
190
- * Map a harness's catalog models onto the four tiers. Pure (no catalog lookup)
191
- * so it is directly testable with synthetic inputs. Ranks the models, collapses
192
- * variants, buckets onto cheap/default/best/ultra, and clamps absent tiers down
193
- * to the nearest lower one. A single-model harness maps the tiers to reasoning
194
- * effort instead of models.
280
+ * Map a harness's catalog models onto the four tiers. Pure (no catalog lookup) so
281
+ * it is directly testable. A single-model harness maps the tiers to reasoning effort.
195
282
  */
196
283
  export function tierizeModels(agent, models) {
197
284
  const rungs = rankCatalog(agent, models);
@@ -200,28 +287,10 @@ export function tierizeModels(agent, models) {
200
287
  const only = rungs[0].id;
201
288
  const map = {};
202
289
  for (const t of MODEL_TIERS)
203
- map[t] = { tier: t, model: only, effort: TIER_EFFORT[t], note: 'single model — tier maps to reasoning effort' };
204
- return map;
205
- }
206
- const n = rungs.length;
207
- const map = {};
208
- if (n === 0) {
209
- // Fail-safe: no catalog -> every tier null, caller drops the --model flag.
210
- for (const t of MODEL_TIERS)
211
- map[t] = { tier: t, model: null };
290
+ map[t] = { tier: t, model: only, effort: TIER_EFFORT[t], note: 'single model — tier maps to reasoning effort', source: 'auto' };
212
291
  return map;
213
292
  }
214
- // Map each tier onto a rung; a tier that shares the rung of the tier below it
215
- // has no distinct rung of its own, so mark it clamped for an honest display.
216
- for (let i = 0; i < MODEL_TIERS.length; i++) {
217
- const t = MODEL_TIERS[i];
218
- const idx = rungIndexFor(i, n);
219
- const shared = i > 0 && rungIndexFor(i - 1, n) === idx;
220
- map[t] = shared
221
- ? { tier: t, model: rungs[idx].id, clampedFrom: MODEL_TIERS[i - 1], note: `no distinct ${t} rung; using ${MODEL_TIERS[i - 1]}` }
222
- : { tier: t, model: rungs[idx].id };
223
- }
224
- return map;
293
+ return bucketRungs(rungs);
225
294
  }
226
295
  /** Resolve one tier for an (agent, version). Null model => caller drops the flag. */
227
296
  export function resolveTier(agent, version, tier) {
@@ -60,6 +60,22 @@ export interface ModelSource {
60
60
  * Returns null if nothing usable is found.
61
61
  */
62
62
  export declare function locateModelSource(agent: AgentId, version: string): ModelSource | null;
63
+ /**
64
+ * Extract Claude's model catalog from its bundle/binary.
65
+ *
66
+ * Bundle/binary contains:
67
+ * - alias map: {opus:"claude-opus-4-7",sonnet:"claude-sonnet-4-6",haiku:"..."}
68
+ * - per-cloud maps: {firstParty:"claude-opus-4-5-...",bedrock:"...",vertex:"...",...}
69
+ * - constants: {OPUS_ID:"...",OPUS_NAME:"...",SONNET_ID:"...",...}
70
+ */
71
+ /**
72
+ * Drop a bare `claude-<family>-<major>` (e.g. `claude-opus-4`) when a more specific
73
+ * sibling (`claude-opus-4-8`) is present. The bare form is only ever an internal
74
+ * `.includes("claude-opus-4")` prefix-check string in the binary, not a submittable
75
+ * id (issue #1892); a bare id with no sibling (e.g. `claude-sonnet-5`) is a real
76
+ * current model and is kept.
77
+ */
78
+ export declare function dropBareLegacyIds(ids: string[]): string[];
63
79
  /**
64
80
  * Parse `grok models` stdout into a catalog. Exported for unit tests.
65
81
  *
@@ -21,7 +21,7 @@ const CACHE_PATH = getModelsCachePath();
21
21
  * Bump when the extractor logic changes shape in an incompatible way so cached
22
22
  * catalogs from older agents-cli builds are re-extracted.
23
23
  */
24
- const CACHE_SCHEMA_VERSION = 3;
24
+ const CACHE_SCHEMA_VERSION = 4;
25
25
  /**
26
26
  * How long a cached 0-model extraction is trusted before we retry it. Bounds
27
27
  * the self-healing window for a transient failure (mid-install, a broken
@@ -310,6 +310,19 @@ function extractStrings(filePath, minLen = 6) {
310
310
  * - per-cloud maps: {firstParty:"claude-opus-4-5-...",bedrock:"...",vertex:"...",...}
311
311
  * - constants: {OPUS_ID:"...",OPUS_NAME:"...",SONNET_ID:"...",...}
312
312
  */
313
+ /**
314
+ * Drop a bare `claude-<family>-<major>` (e.g. `claude-opus-4`) when a more specific
315
+ * sibling (`claude-opus-4-8`) is present. The bare form is only ever an internal
316
+ * `.includes("claude-opus-4")` prefix-check string in the binary, not a submittable
317
+ * id (issue #1892); a bare id with no sibling (e.g. `claude-sonnet-5`) is a real
318
+ * current model and is kept.
319
+ */
320
+ export function dropBareLegacyIds(ids) {
321
+ return ids.filter((id) => {
322
+ const bareMajor = /^claude-[a-z]+-\d+$/.test(id);
323
+ return !(bareMajor && ids.some((o) => o !== id && o.startsWith(`${id}-`)));
324
+ });
325
+ }
313
326
  function extractClaudeCatalog(text) {
314
327
  const aliases = {};
315
328
  const aliasMapMatch = text.match(/\{opus:"(claude-[^"]+)",sonnet:"(claude-[^"]+)",haiku:"(claude-[^"]+)"\}/);
@@ -380,8 +393,9 @@ function extractClaudeCatalog(text) {
380
393
  let sm;
381
394
  while ((sm = idRe.exec(text)) !== null)
382
395
  scanned.add(sm[0]);
383
- if (scanned.size >= 2)
384
- models = build(scanned);
396
+ const filtered = dropBareLegacyIds([...scanned]);
397
+ if (filtered.length >= 2)
398
+ models = build(filtered);
385
399
  }
386
400
  return { models, aliases };
387
401
  }
@@ -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
+ }
@@ -91,6 +91,21 @@ export declare function setKeychainBackendForTest(b: KeychainBackend | null): Ke
91
91
  * fast-path must not engage. Always false in production (`backend` is null). */
92
92
  export declare function isKeychainBackendOverridden(): boolean;
93
93
  export declare const HMAC_KEY_ITEM = "agents-cli.hmackey";
94
+ interface HmacKeyRecord {
95
+ v: number;
96
+ /** 64 hex chars — the raw HMAC-SHA256 key. */
97
+ k: string;
98
+ /** True once the one-time re-key has moved every cleartext-named item. */
99
+ migrated: boolean;
100
+ /** Old cleartext services whose hashed copies are verified but whose
101
+ * originals are not yet deleted (crash-resume list; deletes are silent). */
102
+ pendingDeletes?: string[];
103
+ /** True once this record has been re-stored no-ACL to heal a hmackey item that
104
+ * an OLD helper (pre the metadata/hmackey no-ACL migration fix) re-stamped with
105
+ * a biometry ACL. Set on the first read that heals it, so the heal runs exactly
106
+ * once per machine and never churns the keychain afterward. */
107
+ healedNoAcl?: boolean;
108
+ }
94
109
  /** Force hashed service names on with a fixed key (test only). Pass null to
95
110
  * restore lazy production resolution. Composes with setKeychainBackendForTest
96
111
  * so unit tests exercise the exact transform production uses. */
@@ -106,6 +121,20 @@ export declare function withRawKeychainServiceNames<T>(fn: () => T): T;
106
121
  * the re-key migration and tests; runtime callers go through the primitives,
107
122
  * which apply this transparently. */
108
123
  export declare function hashedServiceName(item: string, key: Buffer): string;
124
+ /**
125
+ * Heal a `hmackey` item that an OLD helper (pre the metadata/hmackey no-ACL
126
+ * migration fix) re-stamped with a biometry ACL. Such an item makes EVERY hashed
127
+ * keychain lookup pop the generic "Agents CLI needs to authenticate" sheet,
128
+ * because the HMAC key is read before every hashed name resolves. The migration
129
+ * fix stopped the re-stamping but never un-stamped an already-damaged item, and
130
+ * nothing else re-stores it once hashing is already active — so it prompts forever.
131
+ *
132
+ * This re-stores the record no-ACL exactly once per machine (guarded by
133
+ * `healedNoAcl`), turning every future read silent. The read that produced `rec`
134
+ * has already happened (and already prompted if it was ACL'd); this only writes.
135
+ * Returns true if it healed. Exported for tests. No-op when already healed.
136
+ */
137
+ export declare function healHmacKeyNoAclOnce(rec: HmacKeyRecord): boolean;
109
138
  /**
110
139
  * The storage-layer service name for `item`: hashed when hashing is active,
111
140
  * the item itself otherwise. For callers that mix helper-enumerated
@@ -217,7 +246,17 @@ export interface KeychainReadContext {
217
246
  duration?: string;
218
247
  defaultPolicy?: 'hold' | 'always' | 'never';
219
248
  forceDuration?: boolean;
249
+ /**
250
+ * The caller attests the item(s) carry NO biometry ACL (it wrote them with
251
+ * `setKeychainToken(..., { noAcl: true })`, or they are bundle metadata /
252
+ * `never`-policy bundle items, which are no-ACL by contract) — so the read
253
+ * is silent even when no one is at the screen. Skips the headless fail-fast
254
+ * and the back-off memo. Never pass this for an ACL-protected item: that
255
+ * re-opens the background Touch ID storm the guard exists to stop.
256
+ */
257
+ silentNoAcl?: boolean;
220
258
  }
259
+ export declare function setKeychainHeadlessDetectorForTest(detector: (() => boolean) | null): void;
221
260
  export declare function keychainOperationPrompt(context?: KeychainReadContext): string;
222
261
  export declare function getKeychainToken(item: string, context?: KeychainReadContext): string;
223
262
  /**