@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.
- package/CHANGELOG.md +48 -0
- package/dist/bin/agents +0 -0
- package/dist/commands/events.js +8 -0
- package/dist/commands/exec.js +7 -5
- package/dist/commands/inspect.js +18 -5
- package/dist/commands/models.d.ts +1 -1
- package/dist/commands/models.js +68 -9
- package/dist/commands/view.d.ts +12 -0
- package/dist/commands/view.js +2 -0
- package/dist/commands/webhook.js +8 -4
- package/dist/lib/browser/chrome.d.ts +12 -0
- package/dist/lib/browser/chrome.js +27 -11
- package/dist/lib/cloud/antigravity.js +8 -2
- package/dist/lib/crabbox/cli.d.ts +2 -0
- package/dist/lib/crabbox/cli.js +109 -39
- package/dist/lib/event-stream.d.ts +3 -0
- package/dist/lib/event-stream.js +5 -0
- package/dist/lib/events.d.ts +2 -0
- package/dist/lib/events.js +6 -1
- package/dist/lib/feed.d.ts +1 -1
- package/dist/lib/feed.js +19 -0
- package/dist/lib/menubar/MenubarHelper.app/Contents/CodeResources +0 -0
- package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
- package/dist/lib/model-tier-overrides.d.ts +43 -0
- package/dist/lib/model-tier-overrides.js +97 -0
- package/dist/lib/model-tiers.d.ts +13 -7
- package/dist/lib/model-tiers.js +104 -35
- package/dist/lib/models.d.ts +16 -0
- package/dist/lib/models.js +17 -3
- 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 +39 -0
- package/dist/lib/secrets/index.js +122 -6
- package/dist/lib/secrets/mcp.js +8 -4
- 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/session/sync/config.d.ts +12 -11
- package/dist/lib/session/sync/config.js +40 -38
- package/dist/lib/share/config.js +4 -0
- package/dist/lib/types.d.ts +10 -0
- package/dist/lib/usage.js +3 -1
- package/dist/lib/versions.js +35 -0
- package/package.json +1 -1
package/dist/lib/model-tiers.js
CHANGED
|
@@ -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)
|
|
174
|
-
*
|
|
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
|
-
|
|
234
|
+
let base;
|
|
235
|
+
let catalogIds;
|
|
178
236
|
if (agent === 'droid') {
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
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
|
-
|
|
187
|
-
|
|
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
|
-
*
|
|
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
|
-
|
|
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) {
|
package/dist/lib/models.d.ts
CHANGED
|
@@ -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
|
*
|
package/dist/lib/models.js
CHANGED
|
@@ -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 =
|
|
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
|
-
|
|
384
|
-
|
|
396
|
+
const filtered = dropBareLegacyIds([...scanned]);
|
|
397
|
+
if (filtered.length >= 2)
|
|
398
|
+
models = build(filtered);
|
|
385
399
|
}
|
|
386
400
|
return { models, aliases };
|
|
387
401
|
}
|
|
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
|
+
}
|
|
@@ -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
|
/**
|