@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
@@ -29,6 +29,8 @@ import * as os from 'os';
29
29
  import * as path from 'path';
30
30
  import { linuxBackend, usesFileFallback as linuxUsesFileFallback, importNativeSecretToolItems } from './linux.js';
31
31
  import { windowsBackend, usesFileFallback as windowsUsesFileFallback, importNativeCredManItems } from './windows.js';
32
+ import { isHeadlessSecretsContext } from './headless.js';
33
+ import { KEYCHAIN_READ_BACKOFF_TTL_MS, clearKeychainReadBackoff, isKeychainReadBackedOff, noteKeychainReadFailure, } from './read-backoff.js';
32
34
  import { getKeychainHelperPath } from './install-helper.js';
33
35
  import { deriveShortId } from '../session/short-id.js';
34
36
  const SERVICE_PREFIX = 'agents-cli';
@@ -236,10 +238,11 @@ function parseHmacKeyRecord(raw) {
236
238
  function readHmacKeyRecord() {
237
239
  // HMAC_KEY_ITEM is exempt from the transform, so this routes to the helper
238
240
  // (or the test backend) under its literal name. The item is no-ACL, so the
239
- // read is silent.
241
+ // read is silent — attest that to the storm guard so a headless hashed-name
242
+ // resolution never trips the fail-fast.
240
243
  let raw;
241
244
  try {
242
- raw = getKeychainToken(HMAC_KEY_ITEM);
245
+ raw = getKeychainToken(HMAC_KEY_ITEM, { silentNoAcl: true });
243
246
  }
244
247
  catch {
245
248
  return null;
@@ -254,6 +257,25 @@ function writeHmacKeyRecord(rec) {
254
257
  setKeychainToken(HMAC_KEY_ITEM, JSON.stringify(rec), { noAcl: true });
255
258
  hashStateCache = null;
256
259
  }
260
+ /**
261
+ * Heal a `hmackey` item that an OLD helper (pre the metadata/hmackey no-ACL
262
+ * migration fix) re-stamped with a biometry ACL. Such an item makes EVERY hashed
263
+ * keychain lookup pop the generic "Agents CLI needs to authenticate" sheet,
264
+ * because the HMAC key is read before every hashed name resolves. The migration
265
+ * fix stopped the re-stamping but never un-stamped an already-damaged item, and
266
+ * nothing else re-stores it once hashing is already active — so it prompts forever.
267
+ *
268
+ * This re-stores the record no-ACL exactly once per machine (guarded by
269
+ * `healedNoAcl`), turning every future read silent. The read that produced `rec`
270
+ * has already happened (and already prompted if it was ACL'd); this only writes.
271
+ * Returns true if it healed. Exported for tests. No-op when already healed.
272
+ */
273
+ export function healHmacKeyNoAclOnce(rec) {
274
+ if (rec.healedNoAcl)
275
+ return false;
276
+ writeHmacKeyRecord({ ...rec, healedNoAcl: true });
277
+ return true;
278
+ }
257
279
  function resolveHashState() {
258
280
  if (forcedTestKey)
259
281
  return { active: true, key: forcedTestKey, record: null };
@@ -401,6 +423,19 @@ function maybeAutoRekey() {
401
423
  return;
402
424
  const st = resolveHashState();
403
425
  if (st.active) {
426
+ // Heal an already-active machine whose hmackey was re-stamped ACL'd by an old
427
+ // helper (its read popped the generic Touch ID sheet on every hashed lookup).
428
+ // Runs once per machine; mutate the local so a later finishPendingDeletes write
429
+ // preserves the healed flag.
430
+ if (st.record && !st.record.healedNoAcl) {
431
+ try {
432
+ healHmacKeyNoAclOnce(st.record);
433
+ st.record.healedNoAcl = true;
434
+ }
435
+ catch {
436
+ /* next process retries */
437
+ }
438
+ }
404
439
  if (st.record?.pendingDeletes?.length) {
405
440
  try {
406
441
  finishPendingDeletes(st.record);
@@ -707,6 +742,56 @@ export function hasKeychainToken(item) {
707
742
  stdio: ['ignore', 'pipe', 'pipe'],
708
743
  }).status === 0;
709
744
  }
745
+ /**
746
+ * The detector the raw-read storm guard consults, overridable so tests on any
747
+ * platform exercise the fail-fast path — the real detector self-gates to
748
+ * darwin (the only platform with a Touch ID sheet to suppress), which would
749
+ * make the throw unreachable from a Linux CI run. Same parameterization
750
+ * argument as the injected env/platform/tty on isHeadlessSecretsContext.
751
+ */
752
+ let rawReadHeadlessDetector = () => isHeadlessSecretsContext();
753
+ export function setKeychainHeadlessDetectorForTest(detector) {
754
+ rawReadHeadlessDetector = detector ?? (() => isHeadlessSecretsContext());
755
+ }
756
+ /**
757
+ * The raw-read storm guard, consulted by every getKeychainToken /
758
+ * getKeychainTokens read that could reach a prompting keychain query. Two
759
+ * fail-fast gates, both skipped for `silentNoAcl` (provably prompt-free)
760
+ * reads:
761
+ *
762
+ * 1. Headless fail-fast. A non-interactive process (AGENTS_RUNTIME set, or
763
+ * no TTY — see isHeadlessSecretsContext) must NEVER raise a Touch ID sheet
764
+ * on the interactive user's screen: the sheet has no one to answer it, a
765
+ * polling caller re-raises it every few seconds, and a cancel just feeds
766
+ * the next poll. Throw an actionable error naming the item instead.
767
+ * 2. Back-off. A read whose prompt recently failed or was cancelled is
768
+ * suppressed for KEYCHAIN_READ_BACKOFF_TTL_MS so an interactive-context
769
+ * poller (TTY but unwatched — a tmux pane, a VS Code task terminal) can't
770
+ * storm sheets either. A successful read or write clears the memo.
771
+ *
772
+ * Placement note: this runs BEFORE the platform branches so the back-off memo
773
+ * is honored identically everywhere, and the headless gate is a no-op off
774
+ * darwin (the detector returns false there) — Linux/Windows reads never
775
+ * prompt, so there is nothing to guard.
776
+ */
777
+ function assertRawKeychainReadAllowed(key, context, label) {
778
+ if (context.silentNoAcl)
779
+ return;
780
+ const what = label ?? `Keychain item '${key}'`;
781
+ if (rawReadHeadlessDetector()) {
782
+ const hint = context.bundle
783
+ ? `Run 'agents secrets unlock ${context.bundle}' in a terminal first`
784
+ : `Provision a prompt-free credential for headless use (a file-based setup token, or an item stored without the biometry ACL), ` +
785
+ `or read it once from an interactive terminal`;
786
+ throw new Error(`${what} requires Touch ID, but this process is non-interactive — ` +
787
+ `a prompt would appear on screen with no one to answer it. ${hint}.`);
788
+ }
789
+ if (isKeychainReadBackedOff(key)) {
790
+ throw new Error(`${what} is in read back-off: a Touch ID prompt for it failed or was cancelled within the last ` +
791
+ `${Math.round(KEYCHAIN_READ_BACKOFF_TTL_MS / 60000)} minutes, and retrying is suppressed so a polling caller can't storm prompts. ` +
792
+ `Read it once interactively or wait out the back-off.`);
793
+ }
794
+ }
710
795
  export function keychainOperationPrompt(context = {}) {
711
796
  const agent = context.agent || 'Agents CLI';
712
797
  const bundle = context.bundle ? ` the '${context.bundle}' bundle` : ' secrets';
@@ -724,6 +809,7 @@ export function getKeychainToken(item, context = {}) {
724
809
  item = prepareServiceName(item, { autoRekey: true });
725
810
  if (backend)
726
811
  return backend.get(item);
812
+ assertRawKeychainReadAllowed(requested, context);
727
813
  assertSupportedPlatform();
728
814
  if (isLinux())
729
815
  return linuxBackend.get(item);
@@ -735,8 +821,10 @@ export function getKeychainToken(item, context = {}) {
735
821
  });
736
822
  if (sec.status === 0) {
737
823
  const token = sec.stdout?.toString().trim();
738
- if (token)
824
+ if (token) {
825
+ clearKeychainReadBackoff(requested);
739
826
  return token;
827
+ }
740
828
  }
741
829
  throw new Error(`Keychain item '${requested}' not found.`);
742
830
  }
@@ -749,17 +837,24 @@ export function getKeychainToken(item, context = {}) {
749
837
  },
750
838
  stdio: ['ignore', 'pipe', 'pipe'],
751
839
  });
840
+ // Exit 1 is a plain miss — no prompt was raised, so it opens no back-off.
752
841
  if (result.status === 1)
753
842
  throw new Error(`Keychain item '${requested}' not found.`);
754
- if (result.status === 4)
755
- throw new Error(`Touch ID cancelled while reading '${requested}'.`);
756
843
  if (result.status !== 0) {
844
+ // A cancel (4) or helper failure after a prompted read: open the back-off
845
+ // window BEFORE throwing, so the next poll of the same item fails fast
846
+ // instead of re-raising the sheet.
847
+ if (!context.silentNoAcl)
848
+ noteKeychainReadFailure(requested);
849
+ if (result.status === 4)
850
+ throw new Error(`Touch ID cancelled while reading '${requested}'.`);
757
851
  const msg = result.stderr?.toString().trim();
758
852
  throw new Error(msg || `Failed to read keychain item '${requested}'.`);
759
853
  }
760
854
  const token = result.stdout?.toString();
761
855
  if (!token)
762
856
  throw new Error(`Keychain item '${requested}' exists but is empty.`);
857
+ clearKeychainReadBackoff(requested);
763
858
  return token;
764
859
  }
765
860
  /**
@@ -798,6 +893,13 @@ export function getKeychainTokens(items, context = {}) {
798
893
  }
799
894
  return result;
800
895
  }
896
+ // One back-off memo covers the whole batch: the batch raises at most one
897
+ // prompt, so a cancel/failure suppresses retrying the same batch, keyed by
898
+ // the requested names (never the storage hashes) for a stable identity. The
899
+ // guard runs before the platform branches so the headless fail-fast is
900
+ // exercisable on any platform (the real detector self-gates to darwin).
901
+ const backoffKey = `batch:${items.join('\n')}`;
902
+ assertRawKeychainReadAllowed(backoffKey, context, `Batch read of ${items.length} keychain item(s)`);
801
903
  assertSupportedPlatform();
802
904
  if (isLinux()) {
803
905
  for (const storage of storageItems) {
@@ -830,6 +932,8 @@ export function getKeychainTokens(items, context = {}) {
830
932
  },
831
933
  stdio: ['ignore', 'pipe', 'pipe'],
832
934
  });
935
+ if (child.status !== 0 && !context.silentNoAcl)
936
+ noteKeychainReadFailure(backoffKey);
833
937
  if (child.status === 4) {
834
938
  throw new Error(`Touch ID cancelled while reading ${items.length} keychain item(s).`);
835
939
  }
@@ -837,6 +941,7 @@ export function getKeychainTokens(items, context = {}) {
837
941
  const msg = child.stderr?.toString().trim();
838
942
  throw new Error(msg || `Failed to batch-read ${items.length} keychain items.`);
839
943
  }
944
+ clearKeychainReadBackoff(backoffKey);
840
945
  const out = child.stdout?.toString() ?? '';
841
946
  parseBatchRecords(out, record);
842
947
  return result;
@@ -917,6 +1022,7 @@ export function setKeychainToken(item, value, opts) {
917
1022
  // resolve the storage name.
918
1023
  if (/[\x00=\r\n]/.test(item))
919
1024
  throw new Error('Secret item name contains invalid characters.');
1025
+ const requested = item;
920
1026
  item = prepareServiceName(item, { autoRekey: true });
921
1027
  if (backend) {
922
1028
  backend.set(item, value, opts);
@@ -968,6 +1074,7 @@ export function setKeychainToken(item, value, opts) {
968
1074
  const msg = sec.stderr?.toString().trim();
969
1075
  throw new Error(msg || `Failed to write keychain item '${item}'.`);
970
1076
  }
1077
+ clearKeychainReadBackoff(requested);
971
1078
  return;
972
1079
  }
973
1080
  const bin = getKeychainHelperPath();
@@ -989,9 +1096,13 @@ export function setKeychainToken(item, value, opts) {
989
1096
  }
990
1097
  throw new Error(msg || `Failed to write keychain item '${item}'.`);
991
1098
  }
1099
+ // A successful write supersedes any open back-off: the item is known-good
1100
+ // now, so the next read must not be suppressed by a stale failure memo.
1101
+ clearKeychainReadBackoff(requested);
992
1102
  }
993
1103
  /** Delete a keychain/keyring item. Returns true if it existed. Never prompts for biometry. */
994
1104
  export function deleteKeychainToken(item) {
1105
+ const requested = item;
995
1106
  item = prepareServiceName(item);
996
1107
  if (backend)
997
1108
  return backend.delete(item);
@@ -1001,9 +1112,14 @@ export function deleteKeychainToken(item) {
1001
1112
  if (isWindows())
1002
1113
  return windowsBackend.delete(item);
1003
1114
  const bin = getKeychainHelperPath();
1004
- return spawnSync(bin, ['delete', item, os.userInfo().username], {
1115
+ const deleted = spawnSync(bin, ['delete', item, os.userInfo().username], {
1005
1116
  stdio: ['ignore', 'pipe', 'pipe'],
1006
1117
  }).status === 0;
1118
+ // A deleted item must fail its next read as plain "not found", not with a
1119
+ // stale back-off error left over from a pre-delete cancel.
1120
+ if (deleted)
1121
+ clearKeychainReadBackoff(requested);
1122
+ return deleted;
1007
1123
  }
1008
1124
  /**
1009
1125
  * True when the active keychain backend transparently routes reads/writes to
@@ -24,7 +24,7 @@
24
24
  * unit-testable in-process.
25
25
  */
26
26
  import * as readline from 'readline';
27
- import { listBundles, readAndResolveBundleEnv, isHeadlessSecretsContext, readBundle, validateBundleName, } from './bundles.js';
27
+ import { listBundles, readAndResolveBundleEnv, readBundle, validateBundleName, } from './bundles.js';
28
28
  /** MCP protocol revision this server negotiates. */
29
29
  export const MCP_PROTOCOL_VERSION = '2024-11-05';
30
30
  /** The single tool this server exposes. */
@@ -68,9 +68,13 @@ export function resolveSecret(bundle, key) {
68
68
  throw new Error(`Key '${key}' not found in bundle '${bundle}'.` +
69
69
  (available.length ? ` Available keys: ${available.join(', ')}.` : ' Bundle has no keys.'));
70
70
  }
71
- // The MCP get_secret tool is typically served by a background/headless agent
72
- // process; resolve broker-only there so it never raises an unwatched prompt.
73
- const { env } = readAndResolveBundleEnv(bundle, { caller: 'secrets-mcp', keys: [key], keyMode: 'storage', agentOnly: isHeadlessSecretsContext() });
71
+ // An MCP `get_secret` tool call is a program asking for a value, never a human
72
+ // at a Touch ID sheet — so the read is always `agentOnly` (SEC-13: never pop
73
+ // biometry on its own). A `never`/no-ACL or broker-held bundle resolves
74
+ // silently; a locked bundle THROWS the actionable "unlock <name>" message,
75
+ // which propagates as the MCP tool error (the caller surfaces it) rather than
76
+ // popping an unanswerable prompt.
77
+ const { env } = readAndResolveBundleEnv(bundle, { caller: 'secrets-mcp', keys: [key], keyMode: 'storage', agentOnly: true });
74
78
  const value = env[key];
75
79
  if (value === undefined) {
76
80
  throw new Error(`Key '${key}' in bundle '${bundle}' could not be resolved.`);
@@ -0,0 +1,27 @@
1
+ /**
2
+ * Negative memo for failed/cancelled macOS keychain reads (the "back-off").
3
+ *
4
+ * A cancelled Touch ID sheet is the user saying "not now" — but a polling
5
+ * caller (the Factory extension host's `agents view` loop, a watch script)
6
+ * retries the same read a few seconds later and pops the sheet again, forever.
7
+ * The headless guard (index.ts `assertRawKeychainReadAllowed`) covers the
8
+ * no-TTY case; this memo covers a context that CAN prompt but just had its
9
+ * prompt cancelled or fail: the next read of the same item within the TTL
10
+ * throws the back-off error instead of re-prompting. Any successful read (or
11
+ * write) of the item clears the memo.
12
+ *
13
+ * Stored as regenerable state under `~/.agents/.cache/keychain-read-backoff/`,
14
+ * one file per item (filename is a hash of the item name; the file carries no
15
+ * secret material — a name and a deadline only). All operations are
16
+ * best-effort: a lost memo costs at most one extra prompt, never a read.
17
+ */
18
+ /** How long a failed/cancelled read suppresses retries of the same item. */
19
+ export declare const KEYCHAIN_READ_BACKOFF_TTL_MS: number;
20
+ /** Test seam: point the memo at a temp dir so tests never touch the real cache. */
21
+ export declare function setKeychainReadBackoffDirForTest(dir: string | null): void;
22
+ /** True while `key` is inside the back-off window opened by a failed/cancelled read. */
23
+ export declare function isKeychainReadBackedOff(key: string, now?: number): boolean;
24
+ /** Open (or refresh) the back-off window for `key` after a failed/cancelled read. */
25
+ export declare function noteKeychainReadFailure(key: string, now?: number): void;
26
+ /** Clear the memo: a successful read or write of the item resets the back-off. */
27
+ export declare function clearKeychainReadBackoff(key: string): void;
@@ -0,0 +1,64 @@
1
+ /**
2
+ * Negative memo for failed/cancelled macOS keychain reads (the "back-off").
3
+ *
4
+ * A cancelled Touch ID sheet is the user saying "not now" — but a polling
5
+ * caller (the Factory extension host's `agents view` loop, a watch script)
6
+ * retries the same read a few seconds later and pops the sheet again, forever.
7
+ * The headless guard (index.ts `assertRawKeychainReadAllowed`) covers the
8
+ * no-TTY case; this memo covers a context that CAN prompt but just had its
9
+ * prompt cancelled or fail: the next read of the same item within the TTL
10
+ * throws the back-off error instead of re-prompting. Any successful read (or
11
+ * write) of the item clears the memo.
12
+ *
13
+ * Stored as regenerable state under `~/.agents/.cache/keychain-read-backoff/`,
14
+ * one file per item (filename is a hash of the item name; the file carries no
15
+ * secret material — a name and a deadline only). All operations are
16
+ * best-effort: a lost memo costs at most one extra prompt, never a read.
17
+ */
18
+ import { createHash } from 'node:crypto';
19
+ import * as fs from 'fs';
20
+ import * as os from 'os';
21
+ import * as path from 'path';
22
+ /** How long a failed/cancelled read suppresses retries of the same item. */
23
+ export const KEYCHAIN_READ_BACKOFF_TTL_MS = 5 * 60 * 1000;
24
+ let dirOverride = null;
25
+ /** Test seam: point the memo at a temp dir so tests never touch the real cache. */
26
+ export function setKeychainReadBackoffDirForTest(dir) {
27
+ dirOverride = dir;
28
+ }
29
+ function backoffDir() {
30
+ return dirOverride ?? path.join(os.homedir(), '.agents', '.cache', 'keychain-read-backoff');
31
+ }
32
+ function backoffFile(key) {
33
+ const digest = createHash('sha256').update(key).digest('hex').slice(0, 24);
34
+ return path.join(backoffDir(), `${digest}.json`);
35
+ }
36
+ /** True while `key` is inside the back-off window opened by a failed/cancelled read. */
37
+ export function isKeychainReadBackedOff(key, now = Date.now()) {
38
+ try {
39
+ const parsed = JSON.parse(fs.readFileSync(backoffFile(key), 'utf8'));
40
+ return typeof parsed.until === 'number' && parsed.until > now;
41
+ }
42
+ catch {
43
+ return false; // absent or malformed memo → no back-off
44
+ }
45
+ }
46
+ /** Open (or refresh) the back-off window for `key` after a failed/cancelled read. */
47
+ export function noteKeychainReadFailure(key, now = Date.now()) {
48
+ try {
49
+ fs.mkdirSync(backoffDir(), { recursive: true, mode: 0o700 });
50
+ fs.writeFileSync(backoffFile(key), JSON.stringify({ item: key, until: now + KEYCHAIN_READ_BACKOFF_TTL_MS }), { mode: 0o600 });
51
+ }
52
+ catch {
53
+ /* best-effort — the cache dir is regenerable; a lost memo costs one prompt */
54
+ }
55
+ }
56
+ /** Clear the memo: a successful read or write of the item resets the back-off. */
57
+ export function clearKeychainReadBackoff(key) {
58
+ try {
59
+ fs.rmSync(backoffFile(key), { force: true });
60
+ }
61
+ catch {
62
+ /* best-effort */
63
+ }
64
+ }
@@ -89,7 +89,8 @@ function sessionBlobItem(name, harness) {
89
89
  /** Read the session index by its fixed name. `{bundles:{}}` when absent/unreadable. */
90
90
  export function readIndex() {
91
91
  try {
92
- const raw = getKeychainToken(SESSION_INDEX_ITEM);
92
+ // Written no-ACL (writeIndex below) — attest so a headless resolve stays silent.
93
+ const raw = getKeychainToken(SESSION_INDEX_ITEM, { silentNoAcl: true });
93
94
  const parsed = JSON.parse(raw);
94
95
  if (parsed && typeof parsed === 'object' && parsed.bundles)
95
96
  return parsed;
@@ -145,7 +146,8 @@ export function loadSession(name, now = Date.now(), harness = GLOBAL_HARNESS) {
145
146
  if (!shouldPersist())
146
147
  return null;
147
148
  try {
148
- const raw = getKeychainToken(sessionBlobItem(name, harness));
149
+ // Written no-ACL (saveSession) — attest so a headless resolve stays silent.
150
+ const raw = getKeychainToken(sessionBlobItem(name, harness), { silentNoAcl: true });
149
151
  const entry = JSON.parse(raw);
150
152
  if (!entry || typeof entry !== 'object' || !entry.bundle || !entry.env)
151
153
  return null;
@@ -227,7 +229,7 @@ export function rehydrateSessions(now = Date.now()) {
227
229
  if (key.includes(':'))
228
230
  continue;
229
231
  try {
230
- const raw = getKeychainToken(`${SESSION_ITEM_PREFIX}${key}`);
232
+ const raw = getKeychainToken(`${SESSION_ITEM_PREFIX}${key}`, { silentNoAcl: true });
231
233
  const legacy = JSON.parse(raw);
232
234
  setKeychainToken(sessionBlobItem(key, GLOBAL_HARNESS), JSON.stringify({ ...legacy, harness: GLOBAL_HARNESS }), { noAcl: true });
233
235
  deleteKeychainToken(`${SESSION_ITEM_PREFIX}${key}`);
@@ -265,7 +267,7 @@ export function rehydrateSessions(now = Date.now()) {
265
267
  continue;
266
268
  }
267
269
  try {
268
- const raw = getKeychainToken(sessionBlobItem(bundleName, 'cli'));
270
+ const raw = getKeychainToken(sessionBlobItem(bundleName, 'cli'), { silentNoAcl: true });
269
271
  const legacy = JSON.parse(raw);
270
272
  setKeychainToken(sessionBlobItem(bundleName, GLOBAL_HARNESS), JSON.stringify({ ...legacy, harness: GLOBAL_HARNESS }), { noAcl: true });
271
273
  deleteKeychainToken(sessionBlobItem(bundleName, 'cli'));
@@ -209,7 +209,9 @@ export function clearVaultKey() {
209
209
  export function getVaultSession() {
210
210
  let raw;
211
211
  try {
212
- raw = getKeychainToken(VAULT_SESSION_ITEM);
212
+ // Written no-ACL (cacheVaultKey) — the vault session probe runs inside bundleBackend
213
+ // for EVERY bundle op, headless included, so it must stay silent.
214
+ raw = getKeychainToken(VAULT_SESSION_ITEM, { silentNoAcl: true });
213
215
  }
214
216
  catch {
215
217
  return { loggedIn: false };
@@ -29,24 +29,25 @@ export interface R2Config {
29
29
  */
30
30
  syncEncKey?: string;
31
31
  }
32
- /** Window after a prompt-bearing resolution failure during which we skip
33
- * re-attempting (and thus re-prompting). SIGHUP / restart bypasses it. */
34
- export declare const RESOLVE_RETRY_COOLDOWN_MS: number;
35
32
  /** Drop the cached resolution so the next call reads the bundle fresh. Called on
36
33
  * daemon SIGHUP (to pick up rotated credentials) and between tests. */
37
34
  export declare function clearR2ConfigCache(): void;
38
35
  /**
39
36
  * Resolve R2 credentials, reading the keychain at most once per process. The
40
- * first call reads (and may prompt for Touch ID); every later call returns the
41
- * memoized result. Throws if the bundle/keys are missing — failures are not
42
- * memoized, but see isSyncConfigured for the re-prompt cooldown.
37
+ * read is `agentOnly` (resolveR2Config), so it never prompts: a `never`/no-ACL or
38
+ * broker-held bundle resolves silently and is memoized; a locked `hold`/`always`
39
+ * bundle throws the actionable "unlock r2.backups" error. Throws (not memoized)
40
+ * when the bundle/keys are missing or locked — isSyncConfigured catches the throw
41
+ * and degrades to no-transport.
43
42
  */
44
43
  export declare function loadR2Config(): R2Config;
45
44
  /**
46
- * True when the sync bundle exists and resolves, without throwing. After a
47
- * prompt-bearing failure (e.g. a cancelled Touch ID) it returns false without
48
- * re-reading the keychain for RESOLVE_RETRY_COOLDOWN_MS, so a dismissed prompt
49
- * does not re-storm every cycle. `now` is injectable for tests.
45
+ * True when the sync bundle exists and resolves, without throwing. A missing OR
46
+ * locked bundle resolves to false (session-sync degrades to no-transport) and,
47
+ * because the `agentOnly` read never prompts, it is re-checked each cycle — so a
48
+ * later `agents secrets add` / `agents secrets unlock r2.backups` is picked up
49
+ * promptly with no daemon restart. `now` is accepted for a stable test signature
50
+ * but no longer gates a cooldown (there is no prompt-bearing failure to back off).
50
51
  */
51
- export declare function isSyncConfigured(now?: number): boolean;
52
+ export declare function isSyncConfigured(_now?: number): boolean;
52
53
  export { machineId, normalizeHost } from '../../machine-id.js';
@@ -11,7 +11,7 @@
11
11
  * actually wired. Credentials come from the `r2.backups` secrets bundle (OS
12
12
  * keychain on macOS, libsecret on Linux) — never from env or disk.
13
13
  */
14
- import { readAndResolveBundleEnv, isHeadlessSecretsContext } from '../../secrets/bundles.js';
14
+ import { readAndResolveBundleEnv } from '../../secrets/bundles.js';
15
15
  /** Secrets bundle holding the R2 credentials. */
16
16
  export const SYNC_BUNDLE = 'r2.backups';
17
17
  /**
@@ -20,13 +20,17 @@ export const SYNC_BUNDLE = 'r2.backups';
20
20
  * without real credentials (no silent fallback).
21
21
  */
22
22
  function resolveR2Config() {
23
- // A headless caller (no TTY — e.g. a routine or SSH-dispatched command) must
24
- // resolve broker-only: isHeadlessSecretsContext() true means a broker miss
25
- // can never pop an unattended Touch ID sheet on the user's screen (the same
26
- // broker-only-when-headless rationale the secrets readers use). Using the
27
- // shared predicate rather than a literal keeps it consistent with the other
28
- // callers and lets any interactive caller of loadR2Config still prompt.
29
- const { env } = readAndResolveBundleEnv(SYNC_BUNDLE, { caller: 'session-transport', agentOnly: isHeadlessSecretsContext() });
23
+ // Session-sync is a BACKGROUND read: the daemon's ~90s cycle (and the ~2-min
24
+ // watchdog) resolve this on their own, never at a human's request — so it must
25
+ // NEVER pop a Touch ID sheet, on the interactive launcher included (SEC-13: an
26
+ // agent launch never raises biometry on its own). The read is always
27
+ // `agentOnly`: a `never`/no-ACL or broker-held `r2.backups` bundle resolves
28
+ // silently; a locked `hold`/`always` bundle THROWS the actionable "unlock
29
+ // r2.backups" message instead of prompting. isSyncConfigured catches that throw
30
+ // and degrades to no-transport (sync disabled) with no prompt and no crash —
31
+ // unlock once (`agents secrets unlock r2.backups`) or set it no-ACL
32
+ // (`agents secrets policy r2.backups never`) for silent zero-friction sync.
33
+ const { env } = readAndResolveBundleEnv(SYNC_BUNDLE, { caller: 'session-transport', agentOnly: true });
30
34
  const accountId = env.R2_ACCOUNT_ID?.trim();
31
35
  const bucket = env.R2_BUCKET_NAME?.trim();
32
36
  const accessKeyId = env.R2_ACCESS_KEY_ID?.trim();
@@ -55,31 +59,32 @@ function resolveR2Config() {
55
59
  };
56
60
  }
57
61
  // ── Resolution cache ────────────────────────────────────────────────────────
58
- // The daemon calls isSyncConfigured() + syncSessions() every ~90s, and each used
59
- // to trigger a fresh read of the biometry-gated `r2.backups` keychain items —
60
- // one Touch ID prompt per gated item, every cycle, forever. We instead resolve
61
- // at most once per process: a success is memoized for the process lifetime
62
- // (cleared on daemon SIGHUP via clearR2ConfigCache), so subsequent cycles never
63
- // touch the keychain again. A *prompt-bearing* failure (cancelled Touch ID, etc.)
64
- // starts a cooldown so a dismissed prompt is not re-issued every cycle. A simply
65
- // absent bundle never prompts, so it is re-checked each cycle (fast pickup when
66
- // the user later adds credentials).
62
+ // The daemon calls isSyncConfigured() + syncSessions() every ~90s. The read is
63
+ // now `agentOnly` (resolveR2Config) so it can NEVER pop Touch ID — a locked bundle
64
+ // throws a cheap, deterministic "unlock r2.backups" error instead. We still resolve
65
+ // at most once per process: a success is memoized for the process lifetime (cleared
66
+ // on daemon SIGHUP via clearR2ConfigCache), so subsequent cycles never touch the
67
+ // keychain again. A failure (absent bundle, or a LOCKED `hold`/`always` bundle) is
68
+ // NOT memoized and never prompts, so it is re-checked each cycle — session-sync
69
+ // degrades to no-transport until the bundle is added / unlocked, then picks it up
70
+ // promptly with no restart.
71
+ //
72
+ // The historical prompt-backoff cooldown (a cancelled Touch ID sheet) is gone: with
73
+ // agentOnly there is no sheet to cancel, so no failure is prompt-bearing and none
74
+ // needs a backoff.
67
75
  let cachedConfig = null;
68
- let lastPromptFailureAt = 0;
69
- /** Window after a prompt-bearing resolution failure during which we skip
70
- * re-attempting (and thus re-prompting). SIGHUP / restart bypasses it. */
71
- export const RESOLVE_RETRY_COOLDOWN_MS = 30 * 60 * 1000; // 30 minutes
72
76
  /** Drop the cached resolution so the next call reads the bundle fresh. Called on
73
77
  * daemon SIGHUP (to pick up rotated credentials) and between tests. */
74
78
  export function clearR2ConfigCache() {
75
79
  cachedConfig = null;
76
- lastPromptFailureAt = 0;
77
80
  }
78
81
  /**
79
82
  * Resolve R2 credentials, reading the keychain at most once per process. The
80
- * first call reads (and may prompt for Touch ID); every later call returns the
81
- * memoized result. Throws if the bundle/keys are missing — failures are not
82
- * memoized, but see isSyncConfigured for the re-prompt cooldown.
83
+ * read is `agentOnly` (resolveR2Config), so it never prompts: a `never`/no-ACL or
84
+ * broker-held bundle resolves silently and is memoized; a locked `hold`/`always`
85
+ * bundle throws the actionable "unlock r2.backups" error. Throws (not memoized)
86
+ * when the bundle/keys are missing or locked — isSyncConfigured catches the throw
87
+ * and degrades to no-transport.
83
88
  */
84
89
  export function loadR2Config() {
85
90
  if (cachedConfig)
@@ -88,26 +93,23 @@ export function loadR2Config() {
88
93
  return cachedConfig;
89
94
  }
90
95
  /**
91
- * True when the sync bundle exists and resolves, without throwing. After a
92
- * prompt-bearing failure (e.g. a cancelled Touch ID) it returns false without
93
- * re-reading the keychain for RESOLVE_RETRY_COOLDOWN_MS, so a dismissed prompt
94
- * does not re-storm every cycle. `now` is injectable for tests.
96
+ * True when the sync bundle exists and resolves, without throwing. A missing OR
97
+ * locked bundle resolves to false (session-sync degrades to no-transport) and,
98
+ * because the `agentOnly` read never prompts, it is re-checked each cycle — so a
99
+ * later `agents secrets add` / `agents secrets unlock r2.backups` is picked up
100
+ * promptly with no daemon restart. `now` is accepted for a stable test signature
101
+ * but no longer gates a cooldown (there is no prompt-bearing failure to back off).
95
102
  */
96
- export function isSyncConfigured(now = Date.now()) {
103
+ export function isSyncConfigured(_now = Date.now()) {
97
104
  if (cachedConfig)
98
105
  return true;
99
- if (lastPromptFailureAt && now - lastPromptFailureAt < RESOLVE_RETRY_COOLDOWN_MS)
100
- return false;
101
106
  try {
102
107
  loadR2Config();
103
108
  return true;
104
109
  }
105
- catch (err) {
106
- // A missing bundle never prompts, so keep re-checking it each cycle (so a
107
- // later `agents secrets add` is picked up quickly). Any other failure may
108
- // have cost a prompt (cancelled Touch ID, keychain error) — back off.
109
- if (!/not found/i.test(err.message))
110
- lastPromptFailureAt = now;
110
+ catch {
111
+ // Absent or locked bundle — never prompted (agentOnly), so no backoff: keep
112
+ // re-checking each cycle for fast pickup once the bundle is added / unlocked.
111
113
  return false;
112
114
  }
113
115
  }
@@ -76,6 +76,9 @@ export function storeWriteToken(token) {
76
76
  export function readWriteTokenFromBundle() {
77
77
  const { env } = readAndResolveBundleEnv(SHARE_BUNDLE, {
78
78
  caller: 'share',
79
+ // Explicit `agents share` command (a human published a file): a headless agent
80
+ // subprocess resolves broker-only, an interactive human may unlock. This is NOT
81
+ // an agent LAUNCH read (that is exec.ts's --secrets injection, always agentOnly).
79
82
  agentOnly: isHeadlessSecretsContext(),
80
83
  });
81
84
  const token = env[SHARE_TOKEN_KEY];
@@ -133,6 +136,7 @@ export function readCloudflareCreds(bundle = DEFAULT_CF_BUNDLE, override) {
133
136
  }
134
137
  const { env } = readAndResolveBundleEnv(bundle, {
135
138
  caller: 'share',
139
+ // Explicit `agents share setup` provisioning read — not an agent launch.
136
140
  agentOnly: isHeadlessSecretsContext(),
137
141
  });
138
142
  const find = (re) => {
@@ -764,6 +764,16 @@ export interface Meta {
764
764
  */
765
765
  isolatedAgents?: Partial<Record<AgentId, string>>;
766
766
  run?: RunConfig;
767
+ /**
768
+ * Cost-tier overrides for `--model cheap|default|best|ultra`. Keyed by the same
769
+ * `<agent>:<version>` selector run.defaults uses (`kimi:*`, `kimi:0.19.2`); each
770
+ * value maps a tier to a concrete model id. Written by `agents models tier set`,
771
+ * never hand-edited. Resolution: exact version selector wins over `<agent>:*`,
772
+ * which wins over the auto-ranking. See lib/model-tier-overrides.ts.
773
+ */
774
+ model?: {
775
+ tiers?: Record<string, Partial<Record<'cheap' | 'default' | 'best' | 'ultra', string>>>;
776
+ };
767
777
  /**
768
778
  * Daemon watchdog config. `rotate` (default `on`) lets the watchdog rotate a
769
779
  * rate-limited session IN PLACE onto a healthy account/harness via
package/dist/lib/usage.js CHANGED
@@ -1246,7 +1246,9 @@ function claudeOauthCacheActive() {
1246
1246
  * or the token itself has expired — in which case the stale entry is dropped. */
1247
1247
  function readCachedClaudeOauth(service) {
1248
1248
  try {
1249
- const entry = JSON.parse(getKeychainToken(claudeOauthCacheItem(service)));
1249
+ // The cache item is written no-ACL (writeCachedClaudeOauth) — its whole purpose is
1250
+ // serving the token prompt-free, so attest that to the raw-read storm guard.
1251
+ const entry = JSON.parse(getKeychainToken(claudeOauthCacheItem(service), { silentNoAcl: true }));
1250
1252
  if (!entry || typeof entry.accessToken !== 'string' || !entry.accessToken)
1251
1253
  return null;
1252
1254
  const now = Date.now();