@phnx-labs/agents-cli 1.22.4 → 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 (39) hide show
  1. package/CHANGELOG.md +28 -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.js +72 -30
  15. package/dist/lib/event-stream.d.ts +3 -0
  16. package/dist/lib/event-stream.js +5 -0
  17. package/dist/lib/events.d.ts +2 -0
  18. package/dist/lib/events.js +6 -1
  19. package/dist/lib/feed.d.ts +1 -1
  20. package/dist/lib/feed.js +19 -0
  21. package/dist/lib/menubar/MenubarHelper.app/Contents/CodeResources +0 -0
  22. package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
  23. package/dist/lib/model-tier-overrides.d.ts +43 -0
  24. package/dist/lib/model-tier-overrides.js +97 -0
  25. package/dist/lib/model-tiers.d.ts +13 -7
  26. package/dist/lib/model-tiers.js +104 -35
  27. package/dist/lib/models.d.ts +16 -0
  28. package/dist/lib/models.js +17 -3
  29. package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
  30. package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
  31. package/dist/lib/secrets/index.d.ts +29 -0
  32. package/dist/lib/secrets/index.js +32 -0
  33. package/dist/lib/secrets/mcp.js +8 -4
  34. package/dist/lib/session/sync/config.d.ts +12 -11
  35. package/dist/lib/session/sync/config.js +40 -38
  36. package/dist/lib/share/config.js +4 -0
  37. package/dist/lib/types.d.ts +10 -0
  38. package/dist/lib/versions.js +35 -0
  39. package/package.json +1 -1
package/CHANGELOG.md CHANGED
@@ -1,5 +1,33 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.22.5
4
+
5
+ - **`agents events` can now filter by `--session <id>` and `--bundle <name>` — trace which agent/session triggered a secret access.** Every event already carries the provenance `sessionId`, and secrets events carry the `bundle` in their payload, but neither was queryable: you could see *that* the `share` bundle was read, not *which session* read it. `--session` (wired to the engine's existing `sessionId` filter) and `--bundle` (a new payload filter across both the operational log and the activity stream) close that gap. `agents events --module secrets --bundle share --session <id>` answers "which agent read the share bundle" — the attribution the Touch ID storm investigation needed, since the macOS biometric sheet itself emits no event. Source: `apps/cli/src/lib/event-stream.ts`, `apps/cli/src/commands/events.ts`, `apps/cli/docs/06-observability.md`.
6
+
7
+ - **`agents feed post --blocked` records now survive the agent's next Stop.** The feed-publish hook cleared the per-session block file on every `Stop`/`SessionEnd`/`PostToolUse`, which silently dropped a declared (`--blocked`) block the moment the agent parked it and its turn ended — exactly when the owner still needs to see and answer it. Declared blocks are now exempt from the lifecycle clear and stay in `agents feed` until they are actually answered (a terminal reply or `recordAnswer`); question/notification/approval blocks still clear as before. Source: `apps/cli/src/lib/feed.ts`.
8
+
9
+ - **`agents add grok@latest` no longer lets a second grok account silently displace a first account's install.** Grok's version directories are keyed by upstream release number alone, not by account — so two different grok accounts that both self-update to the same identical release ("latest") were landing on the SAME on-disk `versions/grok/<version>/` directory. The second account's credentials would overwrite the first's in that shared directory, even though `agents view grok` still listed both accounts as separately installed. `installVersion` now detects this before finalizing the install: if the target version's home already has a signed-in account whose identity differs from the account driving the current update, it refuses with a clear error instead of silently corrupting the first account's install. Source: `apps/cli/src/lib/versions.ts`.
10
+
11
+ - **The stray "Agents CLI needs to authenticate to continue" Touch ID sheet now heals itself.** An old keychain helper (before the metadata/hmackey no-ACL migration fix) could re-stamp the internal HMAC-key item (`agents-cli.hmackey`) with a biometry ACL. That item is read *before every hashed keychain lookup*, so a damaged copy popped a generic, context-less Touch ID sheet on nearly any command that touched secrets — `agents devices list`, a background agent, a session hook — at seemingly random times. The migration fix stopped the re-stamping but never un-stamped an already-damaged item, and once hashed naming is active nothing re-stored it, so it prompted forever. Now, on the first read where hashing is active, an un-healed HMAC-key record is re-stored no-ACL exactly once (`healHmacKeyNoAclOnce`, gated by a `healedNoAcl` flag so it never churns the keychain afterward) — one last prompt on the read that heals it, then silent. Existing damaged machines can also fix it immediately with `agents secrets rekey`. Source: `apps/cli/src/lib/secrets/index.ts`.
12
+
13
+ - **`agents inspect` and `view --json` now report isolation honestly.** Found by diffing
14
+ every command's output between an isolated-only and a normal install. `inspect` printed
15
+ the bare-shim path unconditionally, so an isolated copy — which deliberately has no
16
+ shim, that being the guarantee — was shown sitting on the user's PATH; it now reports
17
+ `(none — isolated installs stay off PATH)` and `shim: null` in JSON. `inspect` also
18
+ showed only `default: false` for an isolated copy, hiding that it *was* the selected
19
+ one; it now carries `isolated` and `isolatedDefault`, and the header reads
20
+ `[isolated default]`. `view --json` had no isolation signal at all, so tooling could not
21
+ distinguish a sandboxed copy from one that owns the launcher and real config — its
22
+ version entries gain `isolated` and `isIsolatedDefault`. Source:
23
+ `apps/cli/src/commands/inspect.ts`, `apps/cli/src/commands/view.ts`.
24
+
25
+ - **`agents models` is now a scannable tier menu, and you can override a tier with a command.** The tier map (`cheap|default|best|ultra` → model + `~$/Mtok`) prints for every installed harness by default; the raw model list moved behind `--all`. When the auto-guess is wrong (subscription harnesses with no price signal), pin the right model without hand-editing YAML: `agents models tier set <agent[@version]> <tier> <model>` (e.g. `agents models tier set kimi best kimi-code/k3`), `tier clear`, `tier list`. Overrides live under `model.tiers` in `agents.yaml` (same selector shape as `run.defaults`) and resolve most-specific-first — `<agent>:<version>` → `<agent>:*` → auto; an overridden id a version doesn't ship falls back to auto, and `agents models` marks a pinned tier `[override]`. Ships a curated Kimi ladder (`k2.7-highspeed` < `k2.7-coding` < `k3`) so it's right by default. Also fixes two extraction bugs: bumps the model-catalog cache schema so a freshly-upgraded box re-extracts instead of serving a stale "No models extracted" for 24h, and stops the id-scan from listing bare legacy ids like `claude-opus-4` (#1892). Source: `apps/cli/src/lib/model-tier-overrides.ts`, `apps/cli/src/lib/model-tiers.ts`, `apps/cli/src/commands/models.ts`, `apps/cli/src/lib/models.ts`, `apps/cli/docs/model-tiers.md`.
26
+
27
+ - **`agents run --secrets <bundle>` never raises a Touch ID sheet on launch — even with a tty (a second storm source).** The `--secrets` injection read gated on `isHeadlessSecretsContext()`, which is FALSE for an interactive run — so an `agents run … --interactive` launch (the watchdog fires `agents run auto --interactive` every ~2 min via routine + menu-bar tick) could pop Touch ID for a keychain `hold` bundle, piling up helper sheets. An agent launch must never prompt regardless of tty (SEC-13): the read is now always `agentOnly` — it resolves from the broker (or a no-ACL bundle) and otherwise fails fast naming `agents secrets unlock <bundle>`, matching the behavior the code's own comment already described. Unchanged: the explicit `agents share` / `agents share setup` commands (`readWriteTokenFromBundle`, `readCloudflareCreds`) still honor the interactive/headless gate — those are user-initiated, not agent launches. Source: `apps/cli/src/commands/exec.ts`.
28
+
29
+ - **Background and read-only secret reads never raise Touch ID (SEC-13).** Every non-user-initiated secret read now resolves `agentOnly` — from the secrets broker or a no-ACL bundle — and, on a locked `hold`/`always` keychain bundle, fails fast naming `agents secrets unlock <bundle>` instead of popping a Touch ID sheet on the interactive launcher. This closes the rest of the per-launch prompt storm that #1905 fixed only for `--secrets` injection. Covered: session-sync (`r2.backups`, read on every daemon cycle — degrades to no-transport when locked, re-checked each cycle for fast pickup once unlocked), the `--lease` crabbox provider token (resolved once up front and memoized, so a locked bundle fails loud once and the ready-wait poll never re-issues the read), browser-profile secrets on launch (skipped silently, launch proceeds), cloud dispatch (`cloud:antigravity`, fails loud with the unlock hint), the `webhook serve` receiver, and the `get_secret` MCP tool (throws as the tool error). Unchanged and still interactive-gated: the user-initiated `agents secrets get/export`, `agents browser type`, `agents exec --secrets`, the `ssh` askpass, and the explicit `agents share` / `agents share setup` provisioning reads — a human running those in a plain terminal still gets a prompt. Source: `apps/cli/src/lib/session/sync/config.ts`, `apps/cli/src/lib/crabbox/cli.ts`, `apps/cli/src/lib/browser/chrome.ts`, `apps/cli/src/lib/cloud/antigravity.ts`, `apps/cli/src/lib/secrets/mcp.ts`, `apps/cli/src/commands/webhook.ts`.
30
+
3
31
  ## 1.22.4
4
32
 
5
33
  - **Background processes no longer storm macOS Touch ID sheets (secrets-touchid-storm).**
package/dist/bin/agents CHANGED
Binary file
@@ -192,6 +192,8 @@ export function registerEventsCommand(program) {
192
192
  .option('--command <path>', 'Only this command path — prefix match (e.g. "teams create")')
193
193
  .option('--event <type>', 'Only this typed event (repeatable, e.g. secrets.get, secrets.unlocked, pr.opened)', collect, [])
194
194
  .option('--agent <name>', 'Only events tagged with this agent')
195
+ .option('--session <id>', 'Only events from this session (the provenance sessionId) — e.g. trace which session read a secret')
196
+ .option('--bundle <name>', 'Only events carrying this bundle in their payload — e.g. `--module secrets --bundle share` for every read of the share bundle')
195
197
  .option('--since <time>', 'Only events newer than this (e.g. 2h, 7d, or ISO date)')
196
198
  .option('--audit', 'Operational events only (skip agent activity)')
197
199
  .option('--limit <n>', 'Max records to show; 0 for no cap (default 50)', '50')
@@ -204,6 +206,10 @@ Examples:
204
206
  agents events --audit Operational events only (secrets / teams / ...)
205
207
  agents events --event pr.opened --since 7d
206
208
  agents events --module secrets Every secret accessed or revealed
209
+ agents events --module secrets --bundle share
210
+ Every read of the share bundle (which agent, which session)
211
+ agents events --bundle share --session <id>
212
+ Trace one session's reads of a bundle
207
213
  agents events -f Live tail (operational)
208
214
  agents events --event pr.opened --since 30d --limit 0 --json
209
215
  Every match — use --limit 0 whenever you
@@ -228,6 +234,8 @@ Examples:
228
234
  startDate,
229
235
  eventTypes: options.event && options.event.length ? options.event : undefined,
230
236
  agent: options.agent,
237
+ sessionId: options.session,
238
+ bundle: options.bundle,
231
239
  command: options.command,
232
240
  module: options.module,
233
241
  limit: limit === undefined ? undefined : limit + 1,
@@ -1567,7 +1567,7 @@ export function registerRunCommand(program) {
1567
1567
  await warnUnpushedWork(resumeExec.cwd ?? process.cwd());
1568
1568
  process.exit(resumeExit);
1569
1569
  }
1570
- const [{ buildExecCommand, parseExecEnv, execAgent, runWithFallback, normalizeMode, resolveMode, headlessPlanStallCommand, nativeResume, resolveInteractive, inferredInteractiveWithoutTty }, { ALL_AGENT_IDS, ACCOUNT_INSPECTION_AGENT_IDS, agentLabel, supportsAccountInspection }, { profileExists, resolveProfileForRun }, { readAndResolveBundleEnv, describeBundle, assertRemoteBundleFlagsUnsupported, isHeadlessSecretsContext }, { splitBundleRef, resolveHostSshTarget, remoteResolveEnv }, { getConfiguredRunStrategy, normalizeRunStrategy, resolveRunVersion, rotationFailoverChain, shouldArmRotationFailover, RUN_STRATEGIES, collectHarnessCandidates, pickHarnessWeighted, classifyHarnessCandidates, formatHarnessPickBanner, formatNoHealthyHarnessError, formatNoHealthyAccountError }, { getGlobalDefault, getVersionHomePath, resolveVersion, resolveVersionAlias, ensureAgentRunnable }, { buildDiscoveredPlugin, loadPluginManifest, syncPluginToVersion }, { parseWorkflowFrontmatter, resolveWorkflowRef, resolveAllowedSubagents, pruneStaleWorkflowSubagents, ensureSubagentDispatchTool }, { resolveRunDefaults }, { getMcpServersByName, buildWorkflowMcpConfig }, { supports }, { shareRuntimeEnv },] = await Promise.all([
1570
+ const [{ buildExecCommand, parseExecEnv, execAgent, runWithFallback, normalizeMode, resolveMode, headlessPlanStallCommand, nativeResume, resolveInteractive, inferredInteractiveWithoutTty }, { ALL_AGENT_IDS, ACCOUNT_INSPECTION_AGENT_IDS, agentLabel, supportsAccountInspection }, { profileExists, resolveProfileForRun }, { readAndResolveBundleEnv, describeBundle, assertRemoteBundleFlagsUnsupported }, { splitBundleRef, resolveHostSshTarget, remoteResolveEnv }, { getConfiguredRunStrategy, normalizeRunStrategy, resolveRunVersion, rotationFailoverChain, shouldArmRotationFailover, RUN_STRATEGIES, collectHarnessCandidates, pickHarnessWeighted, classifyHarnessCandidates, formatHarnessPickBanner, formatNoHealthyHarnessError, formatNoHealthyAccountError }, { getGlobalDefault, getVersionHomePath, resolveVersion, resolveVersionAlias, ensureAgentRunnable }, { buildDiscoveredPlugin, loadPluginManifest, syncPluginToVersion }, { parseWorkflowFrontmatter, resolveWorkflowRef, resolveAllowedSubagents, pruneStaleWorkflowSubagents, ensureSubagentDispatchTool }, { resolveRunDefaults }, { getMcpServersByName, buildWorkflowMcpConfig }, { supports }, { shareRuntimeEnv },] = await Promise.all([
1571
1571
  import('../lib/exec.js'),
1572
1572
  import('../lib/agents.js'),
1573
1573
  import('../lib/profiles.js'),
@@ -2239,11 +2239,13 @@ export function registerRunCommand(program) {
2239
2239
  agent,
2240
2240
  keys: secretsKeysSubset,
2241
2241
  allowExpired: options.allowExpired,
2242
- // The harness identity scopes any cached grant. It no longer lets the
2243
- // requesting agent wait for interactive approval — an agent launch
2242
+ // The harness identity scopes any cached grant. An agent launch
2244
2243
  // resolves broker-only and fails fast naming
2245
- // `agents secrets unlock <bundle>` (bundles.ts:interactiveUnlock).
2246
- agentOnly: isHeadlessSecretsContext(),
2244
+ // `agents secrets unlock <bundle>` (bundles.ts:interactiveUnlock) — it
2245
+ // MUST NOT raise a Touch ID sheet regardless of tty (SEC-13). Gating on
2246
+ // isHeadlessSecretsContext() left `--interactive` launches (the watchdog's
2247
+ // `agents run auto --interactive`) able to prompt, piling up helper sheets.
2248
+ agentOnly: true,
2247
2249
  });
2248
2250
  const entries = describeBundle(bundle);
2249
2251
  const counts = {};
@@ -24,7 +24,7 @@ import { supports } from '../lib/capabilities.js';
24
24
  import { resolveConfiguredModel } from '../lib/models.js';
25
25
  import { resolveSingleAgentTarget, AgentSpecError } from '../lib/agent-spec/index.js';
26
26
  import { readMeta, getUserAgentsDir, getSystemAgentsDir, getProjectAgentsDir, getEnabledExtraRepos, } from '../lib/state.js';
27
- import { getVersionHomePath } from '../lib/versions.js';
27
+ import { getVersionHomePath, isVersionIsolated, getIsolatedDefault, } from '../lib/versions.js';
28
28
  import { getShimsDir, getVersionedAliasPath } from '../lib/shims.js';
29
29
  import { getAgentResources, listResources, } from '../lib/resources.js';
30
30
  import { listHookEntriesFromDir } from '../lib/hooks.js';
@@ -560,8 +560,19 @@ async function renderSummary(agent, version, versionHome, options) {
560
560
  const cliState = await getCliState(agent).catch(() => null);
561
561
  const configSymlink = path.join(os.homedir(), `.${agent}`);
562
562
  const configTarget = readSymlinkSafe(configSymlink);
563
- const shimPath = path.join(getShimsDir(), AGENTS[agent].cliCommand);
563
+ // Report the bare shim only when it is actually there. An isolated install
564
+ // deliberately has none — that is the promise — so printing the path
565
+ // unconditionally told the user this copy sits on their PATH when it does not.
566
+ // Same failure mode as `agents view` claiming an isolated copy was `(global)`.
567
+ const shimPathIfPresent = fs.existsSync(path.join(getShimsDir(), AGENTS[agent].cliCommand))
568
+ ? path.join(getShimsDir(), AGENTS[agent].cliCommand)
569
+ : null;
564
570
  const aliasPath = getVersionedAliasPath(agent, version);
571
+ const isolated = isVersionIsolated(agent, version);
572
+ // `default` means the GLOBAL default, which an isolated copy can never be. Report
573
+ // the isolated pointer separately rather than leaving it invisible: it is what a
574
+ // bare `agents run <agent>` resolves to, so "default: false" alone is misleading.
575
+ const isIsolatedDefault = isolated && getIsolatedDefault(agent) === version;
565
576
  const capabilities = collectCapabilities(agent, version);
566
577
  const itemsByKind = options.brief ? null : collectItemsByKind(agent, versionHome);
567
578
  const hookByScript = options.brief ? null : hookManifestByScript(loadCentralHookManifest());
@@ -574,9 +585,11 @@ async function renderSummary(agent, version, versionHome, options) {
574
585
  agent,
575
586
  version,
576
587
  default: isDefault,
588
+ isolated,
589
+ isolatedDefault: isIsolatedDefault,
577
590
  home: versionHome,
578
591
  configSymlink: configTarget ? { from: configSymlink, to: configTarget } : null,
579
- shim: shimPath,
592
+ shim: shimPathIfPresent,
580
593
  alias: aliasPath,
581
594
  strategy,
582
595
  installedShim: cliState?.installed === true ? cliState.path : null,
@@ -590,12 +603,12 @@ async function renderSummary(agent, version, versionHome, options) {
590
603
  // Plain text — model sits right beside the version, same priority (no label).
591
604
  const configuredModel = resolveConfiguredModel(agent, version)?.model;
592
605
  const modelPart = configuredModel ? ' ' + chalk.yellow(configuredModel) : '';
593
- const head = `${chalk.bold(agent)} ${chalk.gray('@')} ${chalk.cyan(version)}${modelPart}${isDefault ? ' ' + chalk.green('[default]') : ''}`;
606
+ const head = `${chalk.bold(agent)} ${chalk.gray('@')} ${chalk.cyan(version)}${modelPart}${isDefault ? ' ' + chalk.green('[default]') : ''}${isolated ? ' ' + chalk.gray(isIsolatedDefault ? '[isolated default]' : '[isolated]') : ''}`;
594
607
  console.log('\n' + head + '\n');
595
608
  const rows = [
596
609
  ['install', versionHome],
597
610
  ['config', configTarget ? `${configSymlink} ${chalk.gray('→')} ${configTarget}` : chalk.gray('(no symlink)')],
598
- ['shim', shimPath],
611
+ ['shim', shimPathIfPresent ?? chalk.gray(isolated ? '(none — isolated installs stay off PATH)' : '(none)')],
599
612
  ['alias', aliasPath],
600
613
  ['strategy', strategy],
601
614
  ];
@@ -6,7 +6,7 @@
6
6
  * each agent's CLI bundle and displays IDs, aliases, and metadata.
7
7
  */
8
8
  import type { Command } from 'commander';
9
- /** Register the hidden `agents models` command. */
9
+ /** Register the `agents models` command + its `tier` override subcommands. */
10
10
  export declare function registerModelsCommand(program: Command): void;
11
11
  export declare function formatModelSourceLine(kind: string, sourcePath: string, width?: number): string;
12
12
  export declare function formatModelAliasLines(parts: string[], width?: number): string[];
@@ -12,6 +12,7 @@ import { resolveAgentName, formatAgentError, agentLabel, } from '../lib/agents.j
12
12
  import { listInstalledVersions, getGlobalDefault, resolveVersion, resolveVersionAlias } from '../lib/versions.js';
13
13
  import { getModelCatalog, locateModelSource } from '../lib/models.js';
14
14
  import { resolveTierMap, MODEL_TIERS } from '../lib/model-tiers.js';
15
+ import { setTierOverride, clearTierOverride, listTierOverrides } from '../lib/model-tier-overrides.js';
15
16
  import { getModelPricing } from '../lib/pricing/index.js';
16
17
  import { terminalWidth, truncateToWidth, stringWidth } from '../lib/session/width.js';
17
18
  import { wrapJoined } from './inspect.js';
@@ -37,14 +38,15 @@ function fallbackPathVersion(agent) {
37
38
  const m = real.match(/\/versions\/([^/]+)\//);
38
39
  return m ? m[1] : 'installed';
39
40
  }
40
- /** Register the hidden `agents models` command. */
41
+ /** Register the `agents models` command + its `tier` override subcommands. */
41
42
  export function registerModelsCommand(program) {
42
- program
43
- .command('models [agentSpec]', { hidden: true })
44
- .description('List models supported by an installed agent version (internal/debug)')
45
- .option('--cloud', 'Show per-cloud IDs (Claude only)')
46
- .option('--reasoning', 'Show reasoning levels per model (Codex only)')
47
- .option('--json', 'Output catalog as JSON')
43
+ const models = program
44
+ .command('models [agentSpec]')
45
+ .description('Show the cost-tier map (cheap|default|best|ultra) for installed harnesses; pin overrides with `tier set`.')
46
+ .option('--all', 'Show the full raw model catalog, not just the tier map')
47
+ .option('--cloud', 'Show per-cloud IDs (Claude only; implies --all)')
48
+ .option('--reasoning', 'Show reasoning levels per model (Codex only; implies --all)')
49
+ .option('--json', 'Output catalog + tiers as JSON')
48
50
  .action(async (agentSpec, options) => {
49
51
  const targets = await resolveTargets(agentSpec);
50
52
  if (targets.length === 0)
@@ -59,13 +61,63 @@ export function registerModelsCommand(program) {
59
61
  console.log(JSON.stringify(out, null, 2));
60
62
  return;
61
63
  }
64
+ const all = options.all || options.cloud || options.reasoning;
62
65
  let printed = 0;
63
66
  for (const { agent, version, isDefault } of targets) {
64
67
  if (printed > 0)
65
68
  console.log();
66
- printCatalog(agent, version, isDefault, options);
69
+ printCatalog(agent, version, isDefault, { ...options, all });
67
70
  printed++;
68
71
  }
72
+ if (!agentSpec && !all) {
73
+ console.log(chalk.gray('\n `agents models <agent>` for one harness · `--all` for the full model list'));
74
+ console.log(chalk.gray(' `agents models tier set <agent> <tier> <model>` to override a tier'));
75
+ }
76
+ });
77
+ // Override subcommands. These WRITE agents.yaml so the user never hand-edits it;
78
+ // resolution is exact `<agent>@<version>` over `<agent>` over the auto guess.
79
+ const tier = models
80
+ .command('tier')
81
+ .description('Override which model a cost tier resolves to (per harness, or per agent@version).');
82
+ tier
83
+ .command('set <selector> <tier> <model>')
84
+ .description('Pin a tier to a model. selector: <agent> or <agent>@<version> (e.g. kimi, kimi@0.19.2).')
85
+ .action((selector, tierName, model) => {
86
+ try {
87
+ const entry = setTierOverride(selector, tierName, model);
88
+ console.log(chalk.green(`✓ ${entry.selector} ${chalk.cyan(tierName.toLowerCase())} → ${chalk.bold(model)}`));
89
+ }
90
+ catch (err) {
91
+ console.error(chalk.red(err.message));
92
+ process.exit(1);
93
+ }
94
+ });
95
+ tier
96
+ .command('clear <selector> [tier]')
97
+ .description('Clear one tier (or all tiers) back to the auto guess.')
98
+ .action((selector, tierName) => {
99
+ try {
100
+ const changed = clearTierOverride(selector, tierName);
101
+ console.log(changed ? chalk.green(`✓ cleared ${selector}${tierName ? ` ${tierName}` : ''}`) : chalk.gray('nothing to clear'));
102
+ }
103
+ catch (err) {
104
+ console.error(chalk.red(err.message));
105
+ process.exit(1);
106
+ }
107
+ });
108
+ tier
109
+ .command('list')
110
+ .description('List all configured tier overrides.')
111
+ .action(() => {
112
+ const entries = listTierOverrides();
113
+ if (entries.length === 0) {
114
+ console.log(chalk.gray('No tier overrides — every tier uses the auto guess. Set one with `agents models tier set`.'));
115
+ return;
116
+ }
117
+ for (const e of entries) {
118
+ const parts = Object.entries(e.tiers).map(([t, m]) => `${chalk.cyan(t)}=${m}`);
119
+ console.log(`${chalk.bold(e.selector.padEnd(18))} ${parts.join(' ')}`);
120
+ }
69
121
  });
70
122
  }
71
123
  /** Resolve the agent spec into one or more (agent, version) pairs to inspect. */
@@ -144,6 +196,12 @@ function printCatalog(agent, version, isDefault, options) {
144
196
  console.log(chalk.yellow(` No models extracted from ${src.kind} at ${src.path}.`));
145
197
  return;
146
198
  }
199
+ // The tier map above is what an agent reads. Keep the raw catalog behind --all
200
+ // so `agents models` stays a scannable menu instead of a 30-id dump.
201
+ if (!options.all) {
202
+ console.log(chalk.gray(` ${catalog.models.length} models · \`agents models ${agent} --all\` for the full list`));
203
+ return;
204
+ }
147
205
  console.log(chalk.gray(formatModelSourceLine(src.kind, shortPath(src.path))));
148
206
  if (Object.keys(catalog.aliases).length > 0) {
149
207
  const parts = Object.entries(catalog.aliases).map(([alias, id]) => `${chalk.cyan(alias)}=${id}`);
@@ -202,7 +260,8 @@ function printTiers(agent, version) {
202
260
  continue;
203
261
  const eff = r.effort ? chalk.gray(` @${r.effort}`) : '';
204
262
  const clamp = r.clampedFrom ? chalk.gray(' (clamped)') : '';
205
- console.log(` ${chalk.cyan(t.padEnd(8))} ${chalk.bold(r.model)}${eff}${priceLabel(r.model)}${clamp}`);
263
+ const over = r.source === 'override' ? chalk.yellow(' [override]') : '';
264
+ console.log(` ${chalk.cyan(t.padEnd(8))} ${chalk.bold(r.model)}${eff}${priceLabel(r.model)}${clamp}${over}`);
206
265
  }
207
266
  console.log();
208
267
  }
@@ -51,6 +51,18 @@ export declare function renderHarnessBlocks(harnesses: ProfileSummary[], install
51
51
  export interface ViewJsonVersion {
52
52
  version: string;
53
53
  isDefault: boolean;
54
+ /**
55
+ * Installed with `--isolated`. The human view has shown this since the isolated
56
+ * tag landed, but JSON carried no signal at all, so tooling could not tell a
57
+ * sandboxed copy from one that owns the user's launcher and real config.
58
+ */
59
+ isolated: boolean;
60
+ /**
61
+ * The isolated copy a bare `agents run <agent>` resolves to. Separate from
62
+ * `isDefault`, which is the GLOBAL default an isolated version can never be —
63
+ * reporting only `isDefault: false` hid the fact that this one is selected.
64
+ */
65
+ isIsolatedDefault: boolean;
54
66
  signedIn: boolean;
55
67
  email: string | null;
56
68
  accountId?: string | null;
@@ -1242,6 +1242,8 @@ async function collectAgentsJson(filterAgentId, resourceSections) {
1242
1242
  const entry = {
1243
1243
  version,
1244
1244
  isDefault: version === globalDefault,
1245
+ isolated: isVersionIsolated(agentId, version),
1246
+ isIsolatedDefault: getIsolatedDefault(agentId) === version,
1245
1247
  signedIn: info.signedIn,
1246
1248
  email: info.email,
1247
1249
  accountId: info.accountId,
@@ -1,6 +1,6 @@
1
1
  import * as path from 'path';
2
2
  import chalk from 'chalk';
3
- import { readAndResolveBundleEnv, isHeadlessSecretsContext } from '../lib/secrets/bundles.js';
3
+ import { readAndResolveBundleEnv } from '../lib/secrets/bundles.js';
4
4
  import { createFileDeliveryStore, startWebhookServer } from '../lib/triggers/webhook.js';
5
5
  import { getRuntimeStateDir } from '../lib/state.js';
6
6
  const DEFAULT_HOST = '127.0.0.1';
@@ -12,9 +12,13 @@ function positiveInt(value, fallback) {
12
12
  function readWebhookSecrets(bundleName) {
13
13
  const { env } = readAndResolveBundleEnv(bundleName, {
14
14
  caller: 'webhook serve',
15
- // `webhook serve` is a long-running background server; when started detached
16
- // (no TTY) it must resolve broker-only rather than pop an unanswerable prompt.
17
- agentOnly: isHeadlessSecretsContext(),
15
+ // `webhook serve` is a long-running background server started to receive
16
+ // signed webhooks, not a human at a Touch ID sheet — so the read is always
17
+ // `agentOnly` (SEC-13: never pop biometry on its own). A `never`/no-ACL or
18
+ // broker-held bundle resolves silently; a locked bundle THROWS the actionable
19
+ // "unlock <name>" message, which propagates and fails the start LOUD rather
20
+ // than popping an unanswerable prompt.
21
+ agentOnly: true,
18
22
  });
19
23
  const secrets = {};
20
24
  if (env.GITHUB_WEBHOOK_SECRET)
@@ -38,6 +38,18 @@ export interface LaunchResult {
38
38
  port: number;
39
39
  wsUrl: string;
40
40
  }
41
+ /**
42
+ * Resolve a browser-profile secrets bundle into an env map for the child, or an
43
+ * EMPTY map when the bundle is absent, locked, or otherwise unreadable — never a
44
+ * throw and never a prompt. Injecting profile secrets on launch is a BACKGROUND
45
+ * read (the agent is spawning the browser, not a human at a Touch ID sheet), so
46
+ * the read is `agentOnly` (SEC-13: never pop biometry on its own): a `never`/no-ACL
47
+ * or broker-held bundle resolves silently; a locked `hold`/`always` bundle throws,
48
+ * and we swallow it so the launch proceeds without those secrets (an agent that
49
+ * needs them can `agents secrets unlock <bundle>`). Exported so the no-prompt
50
+ * behavior is testable on the real keychain path without spawning a browser.
51
+ */
52
+ export declare function resolveProfileSecretsEnv(secrets?: string): Record<string, string>;
41
53
  export declare function launchBrowser(profileName: string, browserType: BrowserType, port: number, options?: ChromeOptions, secrets?: string, customBinary?: string, isElectron?: boolean): Promise<LaunchResult>;
42
54
  export declare function attachToChrome(port: number): Promise<string>;
43
55
  export declare function killChrome(pid: number): void;
@@ -4,7 +4,7 @@ import * as path from 'path';
4
4
  import * as os from 'os';
5
5
  import { getProfileRuntimeDir } from './profiles.js';
6
6
  import { discoverBrowserWsUrl, registerPipeTransport } from './cdp.js';
7
- import { readAndResolveBundleEnv, bundleExists, isHeadlessSecretsContext } from '../secrets/bundles.js';
7
+ import { readAndResolveBundleEnv, bundleExists } from '../secrets/bundles.js';
8
8
  import { writeProfileRuntime, readProfileRuntime } from './runtime-state.js';
9
9
  // Windows install roots. Resolve from the environment (fall back to the usual
10
10
  // defaults) so per-user installs under %LOCALAPPDATA% and 64-bit Program Files
@@ -225,6 +225,29 @@ export function listInstalledBrowsers(platform = os.platform()) {
225
225
  }
226
226
  return found;
227
227
  }
228
+ /**
229
+ * Resolve a browser-profile secrets bundle into an env map for the child, or an
230
+ * EMPTY map when the bundle is absent, locked, or otherwise unreadable — never a
231
+ * throw and never a prompt. Injecting profile secrets on launch is a BACKGROUND
232
+ * read (the agent is spawning the browser, not a human at a Touch ID sheet), so
233
+ * the read is `agentOnly` (SEC-13: never pop biometry on its own): a `never`/no-ACL
234
+ * or broker-held bundle resolves silently; a locked `hold`/`always` bundle throws,
235
+ * and we swallow it so the launch proceeds without those secrets (an agent that
236
+ * needs them can `agents secrets unlock <bundle>`). Exported so the no-prompt
237
+ * behavior is testable on the real keychain path without spawning a browser.
238
+ */
239
+ export function resolveProfileSecretsEnv(secrets) {
240
+ if (!secrets || !bundleExists(secrets))
241
+ return {};
242
+ try {
243
+ const { env } = readAndResolveBundleEnv(secrets, { caller: 'browser profile', agentOnly: true });
244
+ return env;
245
+ }
246
+ catch {
247
+ // Bundle locked or failed to resolve — launch without secrets (no prompt).
248
+ return {};
249
+ }
250
+ }
228
251
  export async function launchBrowser(profileName, browserType, port, options = {}, secrets, customBinary,
229
252
  // `electron: true` distinguishes Notion / VS Code-style apps from
230
253
  // regular Chrome — purely informational, stored in meta.json so the
@@ -279,16 +302,9 @@ isElectron = false) {
279
302
  : []),
280
303
  ...(options.args || []),
281
304
  ];
282
- let env = { ...process.env };
283
- if (secrets && bundleExists(secrets)) {
284
- try {
285
- const { env: bundleEnv } = readAndResolveBundleEnv(secrets, { caller: 'browser profile', agentOnly: isHeadlessSecretsContext() });
286
- env = { ...env, ...bundleEnv };
287
- }
288
- catch {
289
- // Bundle failed to resolve, continue without secrets
290
- }
291
- }
305
+ // Profile secrets: agentOnly (SEC-13) — a locked bundle resolves to an empty map
306
+ // (launch proceeds without them), never a Touch ID prompt. See resolveProfileSecretsEnv.
307
+ const env = { ...process.env, ...resolveProfileSecretsEnv(secrets) };
292
308
  const child = spawn(browserPath, args, {
293
309
  detached: true,
294
310
  stdio: ['ignore', 'pipe', 'pipe', 'pipe', 'pipe'],
@@ -20,7 +20,7 @@
20
20
  * back to GEMINI_API_KEY / GOOGLE_API_KEY in the environment.
21
21
  */
22
22
  import { resolveDispatchRepos, normalizeProviderStatus } from './types.js';
23
- import { readAndResolveBundleEnv, isHeadlessSecretsContext } from '../secrets/bundles.js';
23
+ import { readAndResolveBundleEnv } from '../secrets/bundles.js';
24
24
  const INTERACTIONS_URL = 'https://generativelanguage.googleapis.com/v1beta/interactions';
25
25
  const DEFAULT_MODEL = 'antigravity-preview-05-2026';
26
26
  const KEY_NAMES = ['GEMINI_API_KEY', 'GOOGLE_API_KEY'];
@@ -65,7 +65,13 @@ export class AntigravityCloudProvider {
65
65
  resolveApiKey() {
66
66
  if (this.secretsBundle) {
67
67
  try {
68
- const { env } = readAndResolveBundleEnv(this.secretsBundle, { caller: 'cloud:antigravity', agentOnly: isHeadlessSecretsContext() });
68
+ // Cloud dispatch resolves the key on its own (no human at a sheet), so the
69
+ // read is always `agentOnly` (SEC-13: never pop Touch ID on its own). A
70
+ // locked bundle THROWS the actionable "unlock <name>" message, which the
71
+ // catch below re-raises verbatim — dispatch genuinely needs the key, so it
72
+ // fails LOUD with the unlock hint rather than swallowing it into a wrong
73
+ // path. A `never`/no-ACL or broker-held bundle resolves silently.
74
+ const { env } = readAndResolveBundleEnv(this.secretsBundle, { caller: 'cloud:antigravity', agentOnly: true });
69
75
  for (const k of KEY_NAMES) {
70
76
  if (env[k])
71
77
  return env[k];
@@ -12,7 +12,7 @@
12
12
  * from a secrets bundle when one is configured (see `crabboxEnv`).
13
13
  */
14
14
  import { spawn, spawnSync } from 'child_process';
15
- import { readAndResolveBundleEnv, isHeadlessSecretsContext, listBundles, bundleExists } from '../secrets/bundles.js';
15
+ import { readAndResolveBundleEnv, listBundles, bundleExists } from '../secrets/bundles.js';
16
16
  import { readMeta, writeMeta } from '../state.js';
17
17
  /** Locate the crabbox binary, or throw an actionable error. */
18
18
  export function findCrabbox() {
@@ -73,10 +73,14 @@ function resolveTailscaleBundleMemo() {
73
73
  * and the single-key subset read (`keys: [ts.key]`) is rejected by
74
74
  * `canCacheResolvedEnv` for broker auto-cache — so without this memo a
75
75
  * non-broker-held tailscale bundle re-read the keychain on EVERY call (and,
76
- * pre-guard, could pop a Touch ID sheet each time). One read per process,
77
- * success or failure (a failed read memoizes as undefined: the catch below
78
- * already degrades to a public-network lease, retrying mid-process only
79
- * repeats the same failure).
76
+ * pre-guard, could pop a Touch ID sheet each time). The read is always
77
+ * `agentOnly: true` (broker-only, SEC-13): tailscale is opt-in plumbing, a
78
+ * `--lease` run is headless by contract, and even an interactive `--lease`
79
+ * invocation must never pop an unwatched Touch ID sheet for this best-effort
80
+ * read — a locked bundle degrades silently to a public-network lease via the
81
+ * catch below. One read per process, success or failure (a failed read
82
+ * memoizes as undefined: the catch below already degrades to a public-network
83
+ * lease, retrying mid-process only repeats the same failure).
80
84
  */
81
85
  let tailscaleValueMemo;
82
86
  function resolveTailscaleKeyValueMemo(ts) {
@@ -86,7 +90,7 @@ function resolveTailscaleKeyValueMemo(ts) {
86
90
  const { env } = readAndResolveBundleEnv(ts.name, {
87
91
  caller: 'agents run --lease (crabbox tailscale)',
88
92
  keys: [ts.key],
89
- agentOnly: isHeadlessSecretsContext(),
93
+ agentOnly: true,
90
94
  });
91
95
  value = env[ts.key];
92
96
  }
@@ -102,6 +106,7 @@ export function resetCrabboxSecretsMemosForTest() {
102
106
  tailscaleBundleMemo = undefined;
103
107
  tailscaleValueMemo = undefined;
104
108
  leaseBundleMemo = undefined;
109
+ leaseEnvMemo = undefined;
105
110
  }
106
111
  /**
107
112
  * The secrets bundle to feed crabbox, resolved in priority order:
@@ -150,6 +155,59 @@ function resolveLeaseBundleMemo() {
150
155
  leaseBundleMemo = { value: resolveLeaseBundle() };
151
156
  return leaseBundleMemo.value;
152
157
  }
158
+ /**
159
+ * Process-lifetime memo for the RESOLVED provider-token env, resolved ONCE up
160
+ * front. `crabboxEnv` is called on every `crabboxWaitReady` poll iteration
161
+ * (crabboxWaitReady → crabboxFind → crabboxList → crabboxEnv), so without this
162
+ * memo the lease-token keychain read re-ran every ~5s of the ready wait — the
163
+ * per-poll storm this fix exists to kill. The read is now `agentOnly: true`
164
+ * (SEC-13: a `--lease` run is headless by contract and must never pop an
165
+ * unwatched Touch ID sheet). A locked `hold`/`always` bundle makes that read
166
+ * THROW the actionable "unlock <name>" message; we memoize the thrown error too
167
+ * and re-raise it on every subsequent call, so the failure surfaces loud ONCE
168
+ * up front (crabboxWarmup / the first crabboxList, before any poll loop) and the
169
+ * loop never re-issues the read. A `never`/no-ACL or broker-held bundle resolves
170
+ * silently. `undefined` when no lease bundle is configured (crabbox uses its own
171
+ * `crabbox login`). The memo (success OR failure) is cleared per test by
172
+ * resetCrabboxSecretsMemosForTest.
173
+ */
174
+ let leaseEnvMemo;
175
+ function resolveLeaseEnvMemo(explicitBundle) {
176
+ if (!leaseEnvMemo) {
177
+ const resolved = explicitBundle
178
+ ? { name: explicitBundle }
179
+ : resolveLeaseBundleMemo();
180
+ if (!resolved) {
181
+ leaseEnvMemo = {};
182
+ }
183
+ else {
184
+ try {
185
+ // Auto-detected bundle → inject ONLY the provider token key(s) (least
186
+ // privilege; an unrelated bundle can't leak its other secrets into crabbox).
187
+ // An explicitly-named bundle (env/config or `opts.secretsBundle`) injects
188
+ // whole — the user chose it. Same resolver `agents secrets exec` uses.
189
+ const { env } = readAndResolveBundleEnv(resolved.name, {
190
+ caller: 'agents run --lease (crabbox)',
191
+ keys: resolved.keys,
192
+ // --lease is headless by contract and a locked bundle must fail loud with
193
+ // an unlock hint, NEVER pop a Touch ID sheet — the read cannot be answered
194
+ // in a background lease (SEC-13).
195
+ agentOnly: true,
196
+ });
197
+ leaseEnvMemo = { env };
198
+ }
199
+ catch (e) {
200
+ leaseEnvMemo = {
201
+ error: new Error(`Could not load secrets bundle "${resolved.name}" for crabbox: ${e.message}. ` +
202
+ `Fix the bundle (agents secrets view ${resolved.name}) or unset lease.secretsBundle to use crabbox's own login.`),
203
+ };
204
+ }
205
+ }
206
+ }
207
+ if (leaseEnvMemo.error)
208
+ throw leaseEnvMemo.error;
209
+ return leaseEnvMemo.env;
210
+ }
153
211
  /** Persist `lease.secretsBundle` in agents config so `--lease` needs no env var. */
154
212
  export function setLeaseSecretsBundle(name) {
155
213
  const meta = readMeta();
@@ -159,30 +217,14 @@ export function setLeaseSecretsBundle(name) {
159
217
  /** Build the child env for crabbox, injecting a secrets bundle when configured. */
160
218
  export function crabboxEnv(opts) {
161
219
  const out = { ...process.env };
162
- const resolved = opts.secretsBundle
163
- ? { name: opts.secretsBundle }
164
- : resolveLeaseBundleMemo();
165
- if (resolved) {
166
- try {
167
- // Auto-detected bundle → inject ONLY the provider token key(s) (least
168
- // privilege; an unrelated bundle can't leak its other secrets into crabbox).
169
- // An explicitly-named bundle (env/config or `opts.secretsBundle`) injects
170
- // whole — the user chose it. Same resolver `agents secrets exec` uses.
171
- const { env } = readAndResolveBundleEnv(resolved.name, {
172
- caller: 'agents run --lease (crabbox)',
173
- keys: resolved.keys,
174
- // --lease is headless by contract and crabboxEnv is called several times
175
- // per run (list/wait/spawn/stop) — resolve broker-only so a keychain bundle
176
- // can't pop repeated unwatched Touch ID sheets mid-lease.
177
- agentOnly: isHeadlessSecretsContext(),
178
- });
179
- Object.assign(out, env);
180
- }
181
- catch (e) {
182
- throw new Error(`Could not load secrets bundle "${resolved.name}" for crabbox: ${e.message}. ` +
183
- `Fix the bundle (agents secrets view ${resolved.name}) or unset lease.secretsBundle to use crabbox's own login.`);
184
- }
185
- }
220
+ // The lease provider-token read is resolved ONCE up front and memoized (env or
221
+ // thrown error) for the process — crabboxEnv runs on every crabboxWaitReady
222
+ // poll, so re-reading here was the per-poll storm. A locked bundle re-raises
223
+ // the memoized "unlock <name>" error every call, so the failure surfaces loud
224
+ // on the first crabboxEnv (before any poll loop) and never re-issues the read.
225
+ const leaseEnv = resolveLeaseEnvMemo(opts.secretsBundle);
226
+ if (leaseEnv)
227
+ Object.assign(out, leaseEnv);
186
228
  // Tailscale plumbing (F5): inject CRABBOX_TAILSCALE_AUTH_KEY from a bundle that
187
229
  // declares a tailscale auth key, when the ambient env doesn't already set one.
188
230
  // Best-effort and opt-in — a missing/unreadable tailscale bundle never fails a
@@ -20,6 +20,9 @@ export interface UnifiedQuery {
20
20
  agent?: string;
21
21
  /** Only events stamped with this session id (payload `sessionId`, the provenance floor). */
22
22
  sessionId?: string;
23
+ /** Only events carrying this bundle name in their payload (e.g. secrets events).
24
+ * Combined with `sessionId`, answers "which session read this secrets bundle". */
25
+ bundle?: string;
23
26
  caller?: string;
24
27
  command?: string;
25
28
  module?: string;