@phnx-labs/agents-cli 1.20.76 → 1.20.78

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 (119) hide show
  1. package/CHANGELOG.md +264 -0
  2. package/README.md +2 -0
  3. package/dist/bin/agents +0 -0
  4. package/dist/commands/doctor.d.ts +9 -0
  5. package/dist/commands/doctor.js +143 -9
  6. package/dist/commands/exec.js +87 -19
  7. package/dist/commands/repo.js +8 -0
  8. package/dist/commands/routines.js +275 -15
  9. package/dist/commands/secrets.js +65 -17
  10. package/dist/commands/sessions-browser.d.ts +45 -3
  11. package/dist/commands/sessions-browser.js +128 -12
  12. package/dist/commands/sessions-export.d.ts +3 -0
  13. package/dist/commands/sessions-export.js +13 -10
  14. package/dist/commands/sessions-picker.d.ts +28 -1
  15. package/dist/commands/sessions-picker.js +222 -15
  16. package/dist/commands/sessions.d.ts +117 -4
  17. package/dist/commands/sessions.js +350 -62
  18. package/dist/commands/ssh.d.ts +13 -0
  19. package/dist/commands/ssh.js +48 -3
  20. package/dist/index.js +5 -6
  21. package/dist/lib/activity.d.ts +5 -4
  22. package/dist/lib/activity.js +450 -36
  23. package/dist/lib/agents.d.ts +0 -21
  24. package/dist/lib/agents.js +0 -37
  25. package/dist/lib/auto-pull.d.ts +16 -8
  26. package/dist/lib/auto-pull.js +23 -28
  27. package/dist/lib/cloud/session-index.js +2 -2
  28. package/dist/lib/daemon.d.ts +9 -32
  29. package/dist/lib/daemon.js +70 -84
  30. package/dist/lib/devices/fleet-divergence.d.ts +101 -0
  31. package/dist/lib/devices/fleet-divergence.js +188 -0
  32. package/dist/lib/devices/fleet-inventory.d.ts +19 -0
  33. package/dist/lib/devices/fleet-inventory.js +57 -0
  34. package/dist/lib/devices/fleet.d.ts +16 -1
  35. package/dist/lib/devices/fleet.js +10 -2
  36. package/dist/lib/devices/health-report.d.ts +10 -2
  37. package/dist/lib/devices/health-report.js +32 -1
  38. package/dist/lib/events.d.ts +1 -1
  39. package/dist/lib/exec.d.ts +16 -0
  40. package/dist/lib/exec.js +27 -4
  41. package/dist/lib/feed.d.ts +7 -1
  42. package/dist/lib/feed.js +96 -6
  43. package/dist/lib/git.d.ts +13 -0
  44. package/dist/lib/git.js +102 -4
  45. package/dist/lib/heal.d.ts +7 -4
  46. package/dist/lib/heal.js +10 -22
  47. package/dist/lib/hosts/dispatch.d.ts +9 -0
  48. package/dist/lib/hosts/dispatch.js +24 -4
  49. package/dist/lib/hosts/passthrough.js +7 -2
  50. package/dist/lib/hosts/remote-cmd.d.ts +11 -0
  51. package/dist/lib/hosts/remote-cmd.js +33 -8
  52. package/dist/lib/hosts/remote-session-id.d.ts +45 -0
  53. package/dist/lib/hosts/remote-session-id.js +84 -0
  54. package/dist/lib/hosts/run-target.d.ts +4 -0
  55. package/dist/lib/hosts/run-target.js +5 -1
  56. package/dist/lib/hosts/session-index.js +3 -3
  57. package/dist/lib/menubar/MenubarHelper.app/Contents/Info.plist +2 -0
  58. package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
  59. package/dist/lib/menubar/MenubarHelper.app/Contents/Resources/AppIcon.icns +0 -0
  60. package/dist/lib/menubar/MenubarHelper.app/Contents/_CodeSignature/CodeResources +15 -2
  61. package/dist/lib/menubar/install-menubar.d.ts +9 -0
  62. package/dist/lib/menubar/install-menubar.js +14 -0
  63. package/dist/lib/menubar/notify-desktop.d.ts +44 -0
  64. package/dist/lib/menubar/notify-desktop.js +78 -0
  65. package/dist/lib/overdue.d.ts +4 -5
  66. package/dist/lib/overdue.js +8 -41
  67. package/dist/lib/routine-notify.d.ts +76 -0
  68. package/dist/lib/routine-notify.js +190 -0
  69. package/dist/lib/routines-placement.d.ts +38 -0
  70. package/dist/lib/routines-placement.js +79 -0
  71. package/dist/lib/routines-project.d.ts +97 -0
  72. package/dist/lib/routines-project.js +349 -0
  73. package/dist/lib/routines.d.ts +68 -0
  74. package/dist/lib/routines.js +74 -7
  75. package/dist/lib/runner.d.ts +12 -2
  76. package/dist/lib/runner.js +156 -28
  77. package/dist/lib/sandbox.js +0 -6
  78. package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
  79. package/dist/lib/secrets/Agents CLI.app/Contents/Info.plist +2 -0
  80. package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
  81. package/dist/lib/secrets/Agents CLI.app/Contents/Resources/AppIcon.icns +0 -0
  82. package/dist/lib/secrets/Agents CLI.app/Contents/_CodeSignature/CodeResources +13 -1
  83. package/dist/lib/secrets/agent.d.ts +11 -4
  84. package/dist/lib/secrets/agent.js +46 -24
  85. package/dist/lib/secrets/bundles.d.ts +21 -7
  86. package/dist/lib/secrets/bundles.js +82 -38
  87. package/dist/lib/secrets/index.d.ts +11 -2
  88. package/dist/lib/secrets/index.js +21 -9
  89. package/dist/lib/secrets/scope.d.ts +26 -0
  90. package/dist/lib/secrets/scope.js +29 -0
  91. package/dist/lib/secrets/session-store.d.ts +16 -2
  92. package/dist/lib/secrets/session-store.js +118 -15
  93. package/dist/lib/secrets/unlock-hints.d.ts +27 -0
  94. package/dist/lib/secrets/unlock-hints.js +36 -0
  95. package/dist/lib/session/active.d.ts +17 -0
  96. package/dist/lib/session/active.js +105 -7
  97. package/dist/lib/session/db.d.ts +43 -0
  98. package/dist/lib/session/db.js +191 -6
  99. package/dist/lib/session/digest.js +126 -21
  100. package/dist/lib/session/discover.d.ts +28 -3
  101. package/dist/lib/session/discover.js +110 -14
  102. package/dist/lib/session/origin-machine.d.ts +18 -0
  103. package/dist/lib/session/origin-machine.js +34 -0
  104. package/dist/lib/session/parse.js +11 -2
  105. package/dist/lib/session/prompt.d.ts +7 -0
  106. package/dist/lib/session/prompt.js +37 -0
  107. package/dist/lib/session/provenance.d.ts +7 -0
  108. package/dist/lib/session/render.js +18 -16
  109. package/dist/lib/session/state.d.ts +5 -0
  110. package/dist/lib/session/state.js +94 -12
  111. package/dist/lib/session/sync/config.js +2 -2
  112. package/dist/lib/session/types.d.ts +25 -1
  113. package/dist/lib/session/types.js +8 -0
  114. package/dist/lib/smart-launch.d.ts +86 -0
  115. package/dist/lib/smart-launch.js +172 -0
  116. package/dist/lib/state.d.ts +7 -3
  117. package/dist/lib/state.js +20 -5
  118. package/dist/lib/types.d.ts +10 -0
  119. package/package.json +1 -1
@@ -31,7 +31,8 @@ import { emit } from '../events.js';
31
31
  import { readMeta, getHelpersDir } from '../state.js';
32
32
  import { assertNameActiveInResourceProfile, filterNamesForActiveResourceProfile } from '../resource-profiles.js';
33
33
  import { agentGetSync, agentAutoLoadSync, agentGetMetaSync, agentAutoLoadMetaSync, agentEvictSync, secretsAgentAutoEnabled, secretsHoldMs } from './agent.js';
34
- import { loadSession, deleteSession } from './session-store.js';
34
+ import { GLOBAL_HARNESS } from './scope.js';
35
+ import { resolveSession, deleteSession } from './session-store.js';
35
36
  import { createHash } from 'node:crypto';
36
37
  const keychainStore = {
37
38
  has: hasKeychainToken,
@@ -1020,15 +1021,19 @@ export function resolveBundleEnv(bundle, _opts = {}) {
1020
1021
  * True when the current process is a background / non-interactive context that
1021
1022
  * must NEVER raise a Keychain biometry prompt on the interactive user's screen —
1022
1023
  * a prompt nobody is watching. Two signals, either sufficient:
1023
- * - `AGENTS_RUNTIME` is `headless` or `teams` (set on the child env by
1024
- * `agents run --headless`, scheduled routines, and teammates see
1025
- * exec.ts:resolveInteractive, runner.ts, teams/agents.ts).
1024
+ * - `AGENTS_RUNTIME` is `headless`, `teams`, or `terminal` i.e. ANY agent
1025
+ * launch, interactive included, and inherited by everything spawned beneath
1026
+ * one (set on the child env by `agents run --headless`, scheduled routines,
1027
+ * teammates, and interactive runs — see exec.ts:430, runner.ts,
1028
+ * teams/agents.ts).
1026
1029
  * - neither stdin nor stdout is a TTY (a detached/backgrounded task whose
1027
1030
  * stdio is redirected to a log — e.g. a release script run in the
1028
1031
  * background as `( ... ) >log 2>&1 </dev/null`).
1029
1032
  * `AGENTS_SECRETS_NO_PROMPT=1` forces headless-safe; `=0` force-allows a prompt
1030
- * even in a non-TTY context. An interactive `eval "$(agents secrets export X)"`
1031
- * keeps its terminal stdin, so it is NOT classified headless and still prompts.
1033
+ * even in a non-TTY context. An `eval "$(agents secrets export X)"` typed in a
1034
+ * PLAIN shell has no AGENTS_RUNTIME, so it is not classified headless and still
1035
+ * prompts. Run beneath an agent it inherits AGENTS_RUNTIME and resolves
1036
+ * broker-only — the agent, not the human, is the caller there.
1032
1037
  *
1033
1038
  * Only **macOS keychain** reads pop an interactive Touch ID sheet — the secrets
1034
1039
  * broker itself is a no-op off darwin (see agent.ts), and libsecret (Linux) /
@@ -1039,9 +1044,13 @@ export function resolveBundleEnv(bundle, _opts = {}) {
1039
1044
  *
1040
1045
  * A read in a macOS headless context resolves broker-only (agentOnly) and fails
1041
1046
  * fast with an actionable error instead of hijacking Touch ID. This generalizes
1042
- * the per-caller pattern already used by the daemon (daemon.ts:readDaemonClaudeOAuthToken).
1047
+ * the per-caller broker-only pattern used across the headless secrets readers.
1043
1048
  */
1044
- export function isHeadlessSecretsContext(env = process.env, platform = process.platform) {
1049
+ export function isHeadlessSecretsContext(env = process.env, platform = process.platform,
1050
+ // Injected so the TTY branch below is testable: it is the branch that decides a
1051
+ // plain human shell still prompts, which is this guard's entire safety argument,
1052
+ // and reading process.* directly made it unreachable from a test.
1053
+ tty = { stdin: process.stdin.isTTY, stdout: process.stdout.isTTY }) {
1045
1054
  if (platform !== 'darwin')
1046
1055
  return false; // no biometry prompt to suppress off-darwin
1047
1056
  const override = env.AGENTS_SECRETS_NO_PROMPT;
@@ -1049,10 +1058,21 @@ export function isHeadlessSecretsContext(env = process.env, platform = process.p
1049
1058
  return true;
1050
1059
  if (override === '0')
1051
1060
  return false;
1061
+ // Every AGENT-LAUNCH runtime resolves broker-only, interactive included.
1062
+ // `terminal` was missing, which made an agent terminal the one launch path
1063
+ // still allowed to pop Touch ID: exec.ts sets AGENTS_RUNTIME='terminal' for an
1064
+ // interactive run (exec.ts:430), that fell through to the TTY check below, and
1065
+ // a TTY meant "a human is watching, so prompting is fine". It is not fine —
1066
+ // opening a terminal is not a request to authenticate, and a launch that needs
1067
+ // a locked bundle should say so and point at `agents secrets unlock`, not grab
1068
+ // the fingerprint sensor. AGENTS_RUNTIME is INHERITED by everything spawned under
1069
+ // an agent, so `agents secrets export` run beneath one resolves broker-only too —
1070
+ // correctly: there the agent, not the human, is the caller. A plain shell carries
1071
+ // no AGENTS_RUNTIME, so a person running it themselves still gets the sheet.
1052
1072
  const runtime = env.AGENTS_RUNTIME;
1053
- if (runtime === 'headless' || runtime === 'teams')
1073
+ if (runtime === 'headless' || runtime === 'teams' || runtime === 'terminal')
1054
1074
  return true;
1055
- return !process.stdin.isTTY && !process.stdout.isTTY;
1075
+ return !tty.stdin && !tty.stdout;
1056
1076
  }
1057
1077
  /**
1058
1078
  * Read a bundle's metadata AND resolve its env in a single Touch ID prompt.
@@ -1078,7 +1098,12 @@ export function readAndResolveBundleEnv(name, opts = {}) {
1078
1098
  // file-backed bundle has none to dedup. The never-unlocked path is a single
1079
1099
  // stat (agentSocketExists) so it costs nothing when the agent isn't running.
1080
1100
  if (backend === 'keychain' && !opts.noAgent && process.env.AGENTS_SECRETS_NO_AGENT !== '1') {
1081
- const hit = agentGetSync(name);
1101
+ // The scope this reader asks under. Falls back to the GLOBAL scope, not to a
1102
+ // literal `'cli'` harness — the broker and the durable store both resolve
1103
+ // own-harness → global (bundleScopeChain), so an unscoped unlock is visible
1104
+ // here whether this process was launched by an agent or typed in a terminal.
1105
+ const harness = opts.agent || process.env.AGENTS_AGENT_NAME || GLOBAL_HARNESS;
1106
+ const hit = agentGetSync(name, harness);
1082
1107
  if (hit) {
1083
1108
  // The agent stores the FULL bundle env. Apply the same subset filter and
1084
1109
  // expiry gate as the slow path — without this, `--secrets-keys X` would
@@ -1102,13 +1127,16 @@ export function readAndResolveBundleEnv(name, opts = {}) {
1102
1127
  // Touch ID. Serve from it and re-warm the broker, so a warm bundle stays warm
1103
1128
  // across restart — this fixes BOTH the interactive re-prompt and the headless
1104
1129
  // throw below (which now fires only when there is genuinely no session).
1105
- const session = loadSession(name);
1106
- if (session) {
1130
+ const resolved = resolveSession(name, Date.now(), harness);
1131
+ if (resolved) {
1132
+ const session = resolved.entry;
1107
1133
  const filtered = filterAgentHitBySubsetAndExpiry({ bundle: session.bundle, env: session.env }, opts);
1108
1134
  stampLastUsed(filtered.bundle);
1109
1135
  // Re-warm the broker with the remaining TTL so later reads hit RAM and
1110
- // `agents secrets status` is honest. Best-effort; no-ops off darwin.
1111
- agentAutoLoadSync(name, session.bundle, session.env, Math.max(1, session.expiresAt - Date.now()));
1136
+ // `agents secrets status` is honest. Re-warm under the scope the grant was
1137
+ // MADE in (resolved.harness), never the asking scope — re-warming a global
1138
+ // grant as `claude` would silently narrow it for every other harness.
1139
+ agentAutoLoadSync(name, session.bundle, session.env, Math.max(1, session.expiresAt - Date.now()), resolved.harness);
1112
1140
  emit('secrets.get', {
1113
1141
  module: 'secrets',
1114
1142
  bundle: name,
@@ -1120,32 +1148,30 @@ export function readAndResolveBundleEnv(name, opts = {}) {
1120
1148
  return filtered;
1121
1149
  }
1122
1150
  }
1123
- // The headless guard exists only to keep a Touch ID sheet off the interactive
1124
- // user's screen so it must block ONLY reads that can actually raise one.
1125
- // A file-backed bundle resolves via passphrase with no prompt (handled below,
1126
- // never held by the broker). A `never`/no-ACL keychain bundle is written
1127
- // WITHOUT the biometry access control at every tier (see writeBundle), so its
1128
- // reads are fully silent (no Touch ID, no broker)exactly like a file
1129
- // bundle, and exactly the "automation-only" tier a headless daemon reads
1130
- // (daemon.ts:readDaemonClaudeOAuthToken). Learning the policy here is itself
1131
- // prompt-less: metadata items are written no-ACL at every tier, so readBundle
1132
- // reads them silently. An unreadable metadata read is treated as prompt-risky
1133
- // (throw) the safe default.
1134
- if (opts.agentOnly && backend === 'keychain') {
1151
+ // Never/no-ACL bundles remain prompt-free regardless. No agent launch harness,
1152
+ // teammate, routine, or the always-on daemon may raise the sheet itself.
1153
+ // Explicit opt-in ONLY a deliberate NARROWING of the agent-triggered approval
1154
+ // added in RUSH-2032 (b99796f8 removed this throw so an agent could raise the
1155
+ // sheet itself; 4eeada68 generalized the daemon rule into `!interactiveUnlock`).
1156
+ // That default true whenever an agent name was present was the spec, not a
1157
+ // bug. It is unwanted: each keychain read runs in its own helper process, so the
1158
+ // biometric assertion never reuses and one agent launch meant one sheet per
1159
+ // bundle. `agentOnly` decides alone now; a human in a plain shell carries no
1160
+ // AGENTS_RUNTIME, so isHeadlessSecretsContext() is false, agentOnly is false, the
1161
+ // guard never fires, and they still get their prompt. No caller passes this flag;
1162
+ // it remains the seam for a future unlock path that wants the sheet on purpose.
1163
+ const interactiveUnlock = opts.interactiveUnlock ?? false;
1164
+ if (opts.agentOnly && backend === 'keychain' && !interactiveUnlock) {
1135
1165
  let noAclBundle = false;
1136
1166
  try {
1137
1167
  noAclBundle = bundlePolicy(readBundle(name)) === 'never';
1138
1168
  }
1139
- catch {
1140
- noAclBundle = false;
1141
- }
1169
+ catch { /* fail closed */ }
1142
1170
  if (!noAclBundle) {
1143
- throw new Error(`Secrets bundle '${name}' is not unlocked in the secrets agent, and this is a ` +
1144
- `headless/background process that must not raise a Touch ID prompt on the ` +
1145
- `interactive user's screen. Run 'agents secrets unlock ${name}' in a terminal ` +
1146
- `first, or set AGENTS_SECRETS_NO_PROMPT=0 to force an interactive prompt.`);
1171
+ throw new Error(`Secrets bundle '${name}' is not unlocked in the secrets agent. ` +
1172
+ `Run 'agents secrets unlock ${name}' in a terminal first an agent launch ` +
1173
+ `never raises a Touch ID sheet on its own.`);
1147
1174
  }
1148
- // never/no-ACL ⇒ fall through to the prompt-less keychain read below.
1149
1175
  }
1150
1176
  if (backend === 'file')
1151
1177
  assertFileBackendUsable(name);
@@ -1164,12 +1190,20 @@ export function readAndResolveBundleEnv(name, opts = {}) {
1164
1190
  const reason = opts.caller
1165
1191
  ? `read ${name} secrets (for ${opts.caller})`
1166
1192
  : `read ${name} secrets`;
1167
- void reason;
1168
1193
  // secretItems are storage names as enumerated (opaque hashed names on macOS
1169
1194
  // with #316 hashing active, cleartext elsewhere); metaItem is cleartext and
1170
1195
  // hashed inside getBatch. Deduped because the hashed enumeration spans the
1171
1196
  // bundle's whole namespace.
1172
- const fetched = store.getBatch([...new Set([metaItem, ...secretItems])]);
1197
+ const fetched = backend === 'keychain'
1198
+ ? getKeychainTokens([...new Set([metaItem, ...secretItems])], {
1199
+ agent: opts.agent || process.env.AGENTS_AGENT_NAME || 'Agents CLI',
1200
+ bundle: name,
1201
+ reason: opts.caller ? `to ${opts.caller}` : reason,
1202
+ duration: opts.duration || humanUnlockDuration(secretsHoldMs()),
1203
+ defaultPolicy: secretsDefaultPolicy(),
1204
+ forceDuration: Boolean(opts.duration),
1205
+ })
1206
+ : store.getBatch([...new Set([metaItem, ...secretItems])]);
1173
1207
  const json = fetched.get(metaItem);
1174
1208
  if (json === undefined) {
1175
1209
  // For a file-backed bundle the metadata item is on disk (that's how
@@ -1297,7 +1331,7 @@ export function readAndResolveBundleEnv(name, opts = {}) {
1297
1331
  bundlePolicy(bundle) === 'daily' &&
1298
1332
  secretsAgentAutoEnabled() &&
1299
1333
  canCacheResolvedEnv(bundle, selectedKeys, opts.keyMode)) {
1300
- agentAutoLoadSync(name, bundle, env, secretsHoldMs());
1334
+ agentAutoLoadSync(name, bundle, env, secretsHoldMs(), opts.agent || process.env.AGENTS_AGENT_NAME || GLOBAL_HARNESS);
1301
1335
  }
1302
1336
  return { bundle, env };
1303
1337
  }
@@ -1306,6 +1340,16 @@ export function readAndResolveBundleEnv(name, opts = {}) {
1306
1340
  throw err;
1307
1341
  }
1308
1342
  }
1343
+ export function humanUnlockDuration(ms) {
1344
+ const days = Math.round(ms / (24 * 60 * 60 * 1000));
1345
+ if (days >= 1)
1346
+ return `${days} day${days === 1 ? '' : 's'}`;
1347
+ const hours = Math.round(ms / (60 * 60 * 1000));
1348
+ if (hours >= 1)
1349
+ return `${hours} hour${hours === 1 ? '' : 's'}`;
1350
+ const minutes = Math.max(1, Math.round(ms / (60 * 1000)));
1351
+ return `${minutes} minute${minutes === 1 ? '' : 's'}`;
1352
+ }
1309
1353
  // Build a keychain ref expression from a bundle+key pair, for storage in the bundle metadata.
1310
1354
  export function keychainRef(key) {
1311
1355
  return `keychain:${key}`;
@@ -210,7 +210,16 @@ export declare function hasKeychainToken(item: string): boolean;
210
210
  * call in the same process). For bundles, prefer getKeychainTokens() so a
211
211
  * single biometric prompt covers every key in the batch.
212
212
  */
213
- export declare function getKeychainToken(item: string): string;
213
+ export interface KeychainReadContext {
214
+ agent?: string;
215
+ bundle?: string;
216
+ reason?: string;
217
+ duration?: string;
218
+ defaultPolicy?: 'daily' | 'always' | 'never';
219
+ forceDuration?: boolean;
220
+ }
221
+ export declare function keychainOperationPrompt(context?: KeychainReadContext): string;
222
+ export declare function getKeychainToken(item: string, context?: KeychainReadContext): string;
214
223
  /**
215
224
  * Batch-read multiple keychain items behind a single Touch ID prompt. The
216
225
  * macOS helper holds one LAContext for its whole process: the first protected
@@ -221,7 +230,7 @@ export declare function getKeychainToken(item: string): string;
221
230
  * On Linux or when a test backend is installed, falls back to individual
222
231
  * lookups — no biometric prompt path on those platforms.
223
232
  */
224
- export declare function getKeychainTokens(items: string[]): Map<string, string>;
233
+ export declare function getKeychainTokens(items: string[], context?: KeychainReadContext): Map<string, string>;
225
234
  /** Store or update a secret value in the keychain/keyring. Device-local;
226
235
  * biometry-gated on macOS. `opts.noAcl` (the `never` prompt-policy) writes our
227
236
  * item WITHOUT the biometry access control so later reads are fully silent — it
@@ -707,14 +707,14 @@ export function hasKeychainToken(item) {
707
707
  stdio: ['ignore', 'pipe', 'pipe'],
708
708
  }).status === 0;
709
709
  }
710
- /**
711
- * Retrieve a secret value from the keychain/keyring. Throws if not found.
712
- *
713
- * On macOS this triggers Touch ID (or reuses an assertion held by an earlier
714
- * call in the same process). For bundles, prefer getKeychainTokens() so a
715
- * single biometric prompt covers every key in the batch.
716
- */
717
- export function getKeychainToken(item) {
710
+ export function keychainOperationPrompt(context = {}) {
711
+ const agent = context.agent || 'Agents CLI';
712
+ const bundle = context.bundle ? ` the '${context.bundle}' bundle` : ' secrets';
713
+ const duration = context.duration ? ` for ${context.duration}` : '';
714
+ const reason = context.reason ? ` ${context.reason}` : '';
715
+ return `${agent} is requesting to unlock${bundle}${duration}${reason}.`;
716
+ }
717
+ export function getKeychainToken(item, context = {}) {
718
718
  // Errors keep the requested (human-readable) name; the storage name may be
719
719
  // an opaque hash.
720
720
  const requested = item;
@@ -739,6 +739,11 @@ export function getKeychainToken(item) {
739
739
  }
740
740
  const bin = getKeychainHelperPath();
741
741
  const result = spawnSync(bin, ['get', item, os.userInfo().username], {
742
+ env: {
743
+ ...process.env,
744
+ AGENTS_KEYCHAIN_PROMPT: keychainOperationPrompt(context),
745
+ AGENTS_KEYCHAIN_PROMPT_BASE: keychainOperationPrompt({ ...context, duration: undefined }),
746
+ },
742
747
  stdio: ['ignore', 'pipe', 'pipe'],
743
748
  });
744
749
  if (result.status === 1)
@@ -764,7 +769,7 @@ export function getKeychainToken(item) {
764
769
  * On Linux or when a test backend is installed, falls back to individual
765
770
  * lookups — no biometric prompt path on those platforms.
766
771
  */
767
- export function getKeychainTokens(items) {
772
+ export function getKeychainTokens(items, context = {}) {
768
773
  const result = new Map();
769
774
  if (items.length === 0)
770
775
  return result;
@@ -811,6 +816,13 @@ export function getKeychainTokens(items) {
811
816
  }
812
817
  const bin = getKeychainHelperPath();
813
818
  const child = spawnSync(bin, ['get-batch', os.userInfo().username, ...storageItems], {
819
+ env: {
820
+ ...process.env,
821
+ AGENTS_KEYCHAIN_PROMPT: keychainOperationPrompt(context),
822
+ AGENTS_KEYCHAIN_PROMPT_BASE: keychainOperationPrompt({ ...context, duration: undefined }),
823
+ AGENTS_KEYCHAIN_DEFAULT_POLICY: context.defaultPolicy || 'daily',
824
+ AGENTS_KEYCHAIN_FORCE_DURATION: context.forceDuration ? '1' : '0',
825
+ },
814
826
  stdio: ['ignore', 'pipe', 'pipe'],
815
827
  });
816
828
  if (child.status === 4) {
@@ -0,0 +1,26 @@
1
+ /**
2
+ * Harness scoping for an unlocked bundle — the shared vocabulary of the broker
3
+ * (agent.ts), the durable session store (session-store.ts), and the read path
4
+ * (bundles.ts).
5
+ *
6
+ * A grant is stored under a scope and read under a scope; the two must agree or
7
+ * the bundle is invisible. `agents secrets unlock --for <agent>` exists to NARROW
8
+ * a grant to one harness, so an unlock without it is global by definition.
9
+ *
10
+ * This module deliberately has NO imports: agent.ts and session-store.ts already
11
+ * import each other, and hanging the scope constants off either one would close
12
+ * that cycle — under ESM a cyclic `const` read can land in the temporal dead zone
13
+ * and throw at runtime even though tsc is happy.
14
+ */
15
+ /**
16
+ * Scope of an unlock that was not narrowed with `--for`: readable by every
17
+ * harness. Not a valid harness name, so it can never collide with one.
18
+ */
19
+ export declare const GLOBAL_HARNESS = "*";
20
+ /**
21
+ * Scopes a reader consults, most specific first: its own harness, then the global
22
+ * grant. This is the resolution order of the scoped-grant model — a narrow
23
+ * `--for claude` unlock stays claude-only while an unscoped unlock serves
24
+ * everyone — not a fallback papering over a miss.
25
+ */
26
+ export declare function bundleScopeChain(harness: string | undefined): string[];
@@ -0,0 +1,29 @@
1
+ /**
2
+ * Harness scoping for an unlocked bundle — the shared vocabulary of the broker
3
+ * (agent.ts), the durable session store (session-store.ts), and the read path
4
+ * (bundles.ts).
5
+ *
6
+ * A grant is stored under a scope and read under a scope; the two must agree or
7
+ * the bundle is invisible. `agents secrets unlock --for <agent>` exists to NARROW
8
+ * a grant to one harness, so an unlock without it is global by definition.
9
+ *
10
+ * This module deliberately has NO imports: agent.ts and session-store.ts already
11
+ * import each other, and hanging the scope constants off either one would close
12
+ * that cycle — under ESM a cyclic `const` read can land in the temporal dead zone
13
+ * and throw at runtime even though tsc is happy.
14
+ */
15
+ /**
16
+ * Scope of an unlock that was not narrowed with `--for`: readable by every
17
+ * harness. Not a valid harness name, so it can never collide with one.
18
+ */
19
+ export const GLOBAL_HARNESS = '*';
20
+ /**
21
+ * Scopes a reader consults, most specific first: its own harness, then the global
22
+ * grant. This is the resolution order of the scoped-grant model — a narrow
23
+ * `--for claude` unlock stays claude-only while an unscoped unlock serves
24
+ * everyone — not a fallback papering over a miss.
25
+ */
26
+ export function bundleScopeChain(harness) {
27
+ const own = harness || GLOBAL_HARNESS;
28
+ return own === GLOBAL_HARNESS ? [GLOBAL_HARNESS] : [own, GLOBAL_HARNESS];
29
+ }
@@ -38,12 +38,14 @@ export interface SessionEntry {
38
38
  expiresAt: number;
39
39
  /** true only for `--durable` unlocks — survives SLEEP. */
40
40
  sleepPersist: boolean;
41
+ harness?: string;
41
42
  }
42
43
  /** Metadata for one held bundle, kept in the index so we can rehydrate / prune
43
44
  * without reading every blob. */
44
45
  export interface SessionIndexMeta {
45
46
  expiresAt: number;
46
47
  sleepPersist: boolean;
48
+ harness?: string;
47
49
  }
48
50
  export interface SessionIndex {
49
51
  bundles: Record<string, SessionIndexMeta>;
@@ -72,10 +74,22 @@ export declare function readIndex(): SessionIndex;
72
74
  export declare function writeIndex(index: SessionIndex): void;
73
75
  /** Persist one unlocked bundle: write its blob no-ACL and record it in the index. */
74
76
  export declare function saveSession(name: string, entry: SessionEntry): void;
77
+ /**
78
+ * Read a session honoring the scope chain: the caller's own harness first, then
79
+ * the global grant (see bundleScopeChain in scope.ts). This is the durable-store
80
+ * twin of the broker's `get` — both must resolve identically, or a bundle would
81
+ * be readable from RAM but not after a restart.
82
+ */
83
+ export declare function resolveSession(name: string, now?: number, harness?: string): {
84
+ entry: SessionEntry;
85
+ harness: string;
86
+ } | null;
75
87
  /** Read one session blob by known name. Null when absent/expired/malformed. */
76
- export declare function loadSession(name: string, now?: number): SessionEntry | null;
88
+ export declare function loadSession(name: string, now?: number, harness?: string): SessionEntry | null;
89
+ /** Remove every persisted harness grant for one bundle. */
90
+ export declare function deleteBundleSessions(name: string): void;
77
91
  /** Delete one bundle's session blob and prune it from the index. */
78
- export declare function deleteSession(name: string): void;
92
+ export declare function deleteSession(name: string, harness?: string): void;
79
93
  /** Delete every session blob + the index (for `secrets lock --all`). */
80
94
  export declare function deleteAllSessions(): void;
81
95
  /** Rehydrate every unexpired session into `[name, entry]` pairs for the broker to
@@ -25,6 +25,7 @@
25
25
  * persistence is an optimization, not a correctness dependency.
26
26
  */
27
27
  import { getKeychainToken, setKeychainToken, deleteKeychainToken, isKeychainBackendOverridden } from './index.js';
28
+ import { GLOBAL_HARNESS, bundleScopeChain } from './scope.js';
28
29
  /** Prefix for all durable session items (device-local, no-ACL). */
29
30
  export const SESSION_ITEM_PREFIX = 'agents-cli.session.';
30
31
  /** Fixed-name index item — the ONLY thing we ever need to find without a known
@@ -82,8 +83,8 @@ export function removeEntry(index, name) {
82
83
  function shouldPersist() {
83
84
  return process.platform === 'darwin' || isKeychainBackendOverridden();
84
85
  }
85
- function sessionBlobItem(name) {
86
- return `${SESSION_ITEM_PREFIX}${name}`;
86
+ function sessionBlobItem(name, harness) {
87
+ return `${SESSION_ITEM_PREFIX}${harness}.${name}`;
87
88
  }
88
89
  /** Read the session index by its fixed name. `{bundles:{}}` when absent/unreadable. */
89
90
  export function readIndex() {
@@ -116,24 +117,40 @@ export function saveSession(name, entry) {
116
117
  if (!shouldPersist())
117
118
  return;
118
119
  try {
119
- setKeychainToken(sessionBlobItem(name), JSON.stringify(entry), { noAcl: true });
120
- writeIndex(upsertEntry(readIndex(), name, { expiresAt: entry.expiresAt, sleepPersist: entry.sleepPersist }));
120
+ const harness = entry.harness || GLOBAL_HARNESS;
121
+ const key = `${harness}:${name}`;
122
+ setKeychainToken(sessionBlobItem(name, harness), JSON.stringify({ ...entry, harness }), { noAcl: true });
123
+ writeIndex(upsertEntry(readIndex(), key, { expiresAt: entry.expiresAt, sleepPersist: entry.sleepPersist, harness }));
121
124
  }
122
125
  catch {
123
126
  /* best-effort — persistence is an optimization */
124
127
  }
125
128
  }
129
+ /**
130
+ * Read a session honoring the scope chain: the caller's own harness first, then
131
+ * the global grant (see bundleScopeChain in scope.ts). This is the durable-store
132
+ * twin of the broker's `get` — both must resolve identically, or a bundle would
133
+ * be readable from RAM but not after a restart.
134
+ */
135
+ export function resolveSession(name, now = Date.now(), harness = GLOBAL_HARNESS) {
136
+ for (const scope of bundleScopeChain(harness)) {
137
+ const entry = loadSession(name, now, scope);
138
+ if (entry)
139
+ return { entry, harness: scope };
140
+ }
141
+ return null;
142
+ }
126
143
  /** Read one session blob by known name. Null when absent/expired/malformed. */
127
- export function loadSession(name, now = Date.now()) {
144
+ export function loadSession(name, now = Date.now(), harness = GLOBAL_HARNESS) {
128
145
  if (!shouldPersist())
129
146
  return null;
130
147
  try {
131
- const raw = getKeychainToken(sessionBlobItem(name));
148
+ const raw = getKeychainToken(sessionBlobItem(name, harness));
132
149
  const entry = JSON.parse(raw);
133
150
  if (!entry || typeof entry !== 'object' || !entry.bundle || !entry.env)
134
151
  return null;
135
152
  if (now >= entry.expiresAt) {
136
- deleteSession(name); // drop expired on read, mirroring the broker's get handler
153
+ deleteSession(name, harness); // drop expired on read
137
154
  return null;
138
155
  }
139
156
  return entry;
@@ -142,13 +159,32 @@ export function loadSession(name, now = Date.now()) {
142
159
  return null;
143
160
  }
144
161
  }
162
+ /** Remove every persisted harness grant for one bundle. */
163
+ export function deleteBundleSessions(name) {
164
+ if (!shouldPersist())
165
+ return;
166
+ const index = readIndex();
167
+ let next = index;
168
+ for (const [key, meta] of Object.entries(index.bundles)) {
169
+ const scopedName = key.includes(':') ? key.split(':').slice(1).join(':') : key;
170
+ if (scopedName !== name)
171
+ continue;
172
+ const harness = meta.harness || GLOBAL_HARNESS;
173
+ try {
174
+ deleteKeychainToken(key.includes(':') ? sessionBlobItem(name, harness) : `${SESSION_ITEM_PREFIX}${name}`);
175
+ }
176
+ catch { /* keep going */ }
177
+ next = removeEntry(next, key);
178
+ }
179
+ writeIndex(next);
180
+ }
145
181
  /** Delete one bundle's session blob and prune it from the index. */
146
- export function deleteSession(name) {
182
+ export function deleteSession(name, harness = GLOBAL_HARNESS) {
147
183
  if (!shouldPersist())
148
184
  return;
149
185
  try {
150
- deleteKeychainToken(sessionBlobItem(name));
151
- writeIndex(removeEntry(readIndex(), name));
186
+ deleteKeychainToken(sessionBlobItem(name, harness));
187
+ writeIndex(removeEntry(readIndex(), `${harness}:${name}`));
152
188
  }
153
189
  catch {
154
190
  /* best-effort */
@@ -160,8 +196,12 @@ export function deleteAllSessions() {
160
196
  return;
161
197
  try {
162
198
  for (const name of Object.keys(readIndex().bundles)) {
199
+ const meta = readIndex().bundles[name];
200
+ const bundleName = name.includes(':') ? name.split(':').slice(1).join(':') : name;
163
201
  try {
164
- deleteKeychainToken(sessionBlobItem(name));
202
+ deleteKeychainToken(name.includes(':')
203
+ ? sessionBlobItem(bundleName, meta.harness || GLOBAL_HARNESS)
204
+ : `${SESSION_ITEM_PREFIX}${bundleName}`);
165
205
  }
166
206
  catch { /* keep going */ }
167
207
  }
@@ -180,19 +220,81 @@ export function rehydrateSessions(now = Date.now()) {
180
220
  const out = [];
181
221
  try {
182
222
  const index = readIndex();
223
+ // One-time source migration from the pre-harness layout. Move each legacy
224
+ // bundle-name index/blob to the explicit cli scope, then delete the old blob.
225
+ let migratedIndex = index;
226
+ for (const [key, meta] of Object.entries(index.bundles)) {
227
+ if (key.includes(':'))
228
+ continue;
229
+ try {
230
+ const raw = getKeychainToken(`${SESSION_ITEM_PREFIX}${key}`);
231
+ const legacy = JSON.parse(raw);
232
+ setKeychainToken(sessionBlobItem(key, GLOBAL_HARNESS), JSON.stringify({ ...legacy, harness: GLOBAL_HARNESS }), { noAcl: true });
233
+ deleteKeychainToken(`${SESSION_ITEM_PREFIX}${key}`);
234
+ migratedIndex = upsertEntry(removeEntry(migratedIndex, key), `${GLOBAL_HARNESS}:${key}`, {
235
+ expiresAt: meta.expiresAt,
236
+ sleepPersist: meta.sleepPersist,
237
+ harness: GLOBAL_HARNESS,
238
+ });
239
+ }
240
+ catch { /* malformed/absent legacy entry is pruned below */ }
241
+ }
242
+ // Second source migration: `cli`-scoped grants predate the global scope. `cli`
243
+ // was never a harness — it was the default when no AGENTS_AGENT_NAME was set,
244
+ // i.e. "unlocked from a terminal for general use", which is exactly the global
245
+ // grant. Rewriting them means an unlock a user already paid Touch ID for keeps
246
+ // working after upgrade instead of silently becoming unreadable to every agent.
247
+ for (const [key, meta] of Object.entries(migratedIndex.bundles)) {
248
+ if (!key.startsWith('cli:'))
249
+ continue;
250
+ const bundleName = key.slice('cli:'.length);
251
+ const globalKey = `${GLOBAL_HARNESS}:${bundleName}`;
252
+ // A global grant for this bundle can already exist: the user re-runs
253
+ // `unlock` on the new code (which writes the global scope) before the
254
+ // broker restarts to run this migration. The existing global grant is the
255
+ // NEWER one, so the stale `cli` entry is discarded, never merged over it —
256
+ // overwriting would restore a superseded token and, if the stale entry had
257
+ // expired, hand its expiry to the fresh grant so the prune below deletes a
258
+ // valid unlock outright.
259
+ if (migratedIndex.bundles[globalKey]) {
260
+ try {
261
+ deleteKeychainToken(sessionBlobItem(bundleName, 'cli'));
262
+ }
263
+ catch { /* already gone */ }
264
+ migratedIndex = removeEntry(migratedIndex, key);
265
+ continue;
266
+ }
267
+ try {
268
+ const raw = getKeychainToken(sessionBlobItem(bundleName, 'cli'));
269
+ const legacy = JSON.parse(raw);
270
+ setKeychainToken(sessionBlobItem(bundleName, GLOBAL_HARNESS), JSON.stringify({ ...legacy, harness: GLOBAL_HARNESS }), { noAcl: true });
271
+ deleteKeychainToken(sessionBlobItem(bundleName, 'cli'));
272
+ migratedIndex = upsertEntry(removeEntry(migratedIndex, key), globalKey, {
273
+ expiresAt: meta.expiresAt,
274
+ sleepPersist: meta.sleepPersist,
275
+ harness: GLOBAL_HARNESS,
276
+ });
277
+ }
278
+ catch { /* malformed/absent entry is pruned below */ }
279
+ }
280
+ writeIndex(migratedIndex);
281
+ index.bundles = migratedIndex.bundles;
183
282
  const { survivors, expiredNames } = pruneExpired(index, now);
184
283
  for (const name of expiredNames) {
284
+ const meta = index.bundles[name];
185
285
  try {
186
- deleteKeychainToken(sessionBlobItem(name));
286
+ deleteKeychainToken(sessionBlobItem(name.split(':').slice(1).join(':'), meta.harness || GLOBAL_HARNESS));
187
287
  }
188
288
  catch { /* keep going */ }
189
289
  }
190
290
  if (expiredNames.length)
191
291
  writeIndex(survivors);
192
292
  for (const name of selectRehydratable(survivors, now)) {
193
- const entry = loadSession(name, now);
293
+ const meta = survivors.bundles[name];
294
+ const bundleName = name.split(':').slice(1).join(':');
295
+ const entry = loadSession(bundleName, now, meta.harness || GLOBAL_HARNESS);
194
296
  if (entry)
195
- out.push({ name, entry });
297
+ out.push({ name: bundleName, entry });
196
298
  }
197
299
  }
198
300
  catch {
@@ -208,8 +310,9 @@ export function pruneSessionsOnSleep() {
208
310
  try {
209
311
  const { survivors, deletedNames } = pruneOnSleep(readIndex());
210
312
  for (const name of deletedNames) {
313
+ const meta = readIndex().bundles[name];
211
314
  try {
212
- deleteKeychainToken(sessionBlobItem(name));
315
+ deleteKeychainToken(sessionBlobItem(name.split(':').slice(1).join(':'), meta.harness || GLOBAL_HARNESS));
213
316
  }
214
317
  catch { /* keep going */ }
215
318
  }
@@ -0,0 +1,27 @@
1
+ /** The subset of a `secrets.get` event this heuristic reads. */
2
+ export interface SecretGetRecord {
3
+ /** Bundle name the read targeted. */
4
+ bundle?: string;
5
+ /** Where the value came from: `agent` (broker hit) / `session` (durable
6
+ * unlock) are SILENT; anything else hit the keychain and thus prompted. */
7
+ source?: string;
8
+ }
9
+ export interface PromptedBundle {
10
+ name: string;
11
+ /** Number of prompting (keychain) reads in the window. */
12
+ count: number;
13
+ }
14
+ /**
15
+ * From recent `secrets.get` records, find bundles read often enough via the
16
+ * keychain (i.e. NOT served silently by the broker or a durable session) to be
17
+ * worth unlocking once. A bundle currently `held` is already silent, so it is
18
+ * excluded; the caller further drops `never`/no-ACL bundles (which never prompt,
19
+ * so unlocking them is a no-op).
20
+ *
21
+ * @param records recent `secrets.get` events (any order)
22
+ * @param held bundle names the broker currently holds (silent reads)
23
+ * @param opts.minReads minimum prompting reads to surface a bundle (default 3)
24
+ */
25
+ export declare function frequentlyPromptedBundles(records: SecretGetRecord[], held: Set<string>, opts?: {
26
+ minReads?: number;
27
+ }): PromptedBundle[];