@phnx-labs/agents-cli 1.20.51 → 1.20.52

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 (62) hide show
  1. package/CHANGELOG.md +16 -0
  2. package/dist/commands/browser.js +215 -7
  3. package/dist/commands/cloud.js +6 -0
  4. package/dist/commands/events.d.ts +1 -1
  5. package/dist/commands/events.js +2 -3
  6. package/dist/commands/exec.js +17 -2
  7. package/dist/commands/factory.js +8 -0
  8. package/dist/commands/feed.d.ts +9 -0
  9. package/dist/commands/feed.js +69 -0
  10. package/dist/commands/logs.d.ts +5 -1
  11. package/dist/commands/logs.js +248 -3
  12. package/dist/commands/mcp.js +7 -0
  13. package/dist/commands/secrets.d.ts +22 -0
  14. package/dist/commands/secrets.js +173 -42
  15. package/dist/commands/teams.js +4 -0
  16. package/dist/index.js +6 -2
  17. package/dist/lib/browser/login-detection.d.ts +94 -0
  18. package/dist/lib/browser/login-detection.js +274 -0
  19. package/dist/lib/browser/profiles.d.ts +17 -8
  20. package/dist/lib/browser/profiles.js +27 -8
  21. package/dist/lib/browser/secret-ref.d.ts +10 -0
  22. package/dist/lib/browser/secret-ref.js +14 -0
  23. package/dist/lib/browser/service.js +14 -12
  24. package/dist/lib/cloud/rush.d.ts +15 -0
  25. package/dist/lib/cloud/rush.js +7 -1
  26. package/dist/lib/crabbox/lease.d.ts +6 -0
  27. package/dist/lib/crabbox/lease.js +11 -9
  28. package/dist/lib/crabbox/runtimes.d.ts +38 -1
  29. package/dist/lib/crabbox/runtimes.js +98 -5
  30. package/dist/lib/daemon.d.ts +12 -9
  31. package/dist/lib/daemon.js +32 -17
  32. package/dist/lib/events.d.ts +31 -5
  33. package/dist/lib/events.js +288 -101
  34. package/dist/lib/exec.js +1 -0
  35. package/dist/lib/feed.d.ts +56 -0
  36. package/dist/lib/feed.js +251 -0
  37. package/dist/lib/hooks.js +7 -2
  38. package/dist/lib/hosts/passthrough.js +1 -0
  39. package/dist/lib/rotate.js +2 -0
  40. package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
  41. package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
  42. package/dist/lib/secrets/agent.d.ts +21 -0
  43. package/dist/lib/secrets/agent.js +63 -1
  44. package/dist/lib/secrets/bundles.d.ts +33 -1
  45. package/dist/lib/secrets/bundles.js +38 -8
  46. package/dist/lib/secrets/icloud-import.d.ts +70 -0
  47. package/dist/lib/secrets/icloud-import.js +173 -0
  48. package/dist/lib/secrets/index.d.ts +36 -0
  49. package/dist/lib/secrets/index.js +99 -9
  50. package/dist/lib/secrets/remote.js +1 -1
  51. package/dist/lib/secrets/sync.js +1 -1
  52. package/dist/lib/session/discover.js +1 -2
  53. package/dist/lib/session/state.js +13 -1
  54. package/dist/lib/startup/command-registry.d.ts +1 -0
  55. package/dist/lib/startup/command-registry.js +2 -0
  56. package/dist/lib/state.d.ts +2 -0
  57. package/dist/lib/state.js +25 -8
  58. package/dist/lib/teams/agents.js +6 -3
  59. package/dist/lib/types.d.ts +10 -0
  60. package/dist/lib/whats-new.d.ts +5 -3
  61. package/dist/lib/whats-new.js +25 -5
  62. package/package.json +1 -1
@@ -0,0 +1,251 @@
1
+ /**
2
+ * Feed store -- structured block records published by agents waiting on user
3
+ * input (AskUserQuestion). The outbound counterpart to the inbound mailbox:
4
+ * the mailbox delivers messages TO agents; the feed surfaces decisions agents
5
+ * need FROM the user.
6
+ *
7
+ * Layout: <feedDir>/<blockId>.json
8
+ * Each file is one open block -- a question the agent asked. One block per
9
+ * session: a new AskUserQuestion in the same session replaces the previous
10
+ * block (an agent can only ask one question at a time). Removed when the
11
+ * session advances past the block.
12
+ *
13
+ * A block carries enough identity (sessionId, mailboxId, host, runtime) for
14
+ * `agents feed` to aggregate across hosts and for `agents message` to route
15
+ * a reply back to the right agent.
16
+ */
17
+ import * as fs from 'fs';
18
+ import * as path from 'path';
19
+ import * as yaml from 'yaml';
20
+ import { getFeedDir, getUserAgentsDir } from './state.js';
21
+ /**
22
+ * Stable block id for a session. One block per session -- a new question
23
+ * replaces the previous one (the agent can only ask one question at a time).
24
+ */
25
+ export function blockIdForSession(sessionId) {
26
+ const safeSessionId = sessionId.replace(/[^A-Za-z0-9._-]/g, '-');
27
+ return `block-${safeSessionId}`;
28
+ }
29
+ function blockPath(root, blockId) {
30
+ if (!/^[A-Za-z0-9._-]+$/.test(blockId)) {
31
+ throw new Error(`Invalid feed block id: ${blockId}`);
32
+ }
33
+ return path.join(root, `${blockId}.json`);
34
+ }
35
+ /** Atomic write a block record to the feed store. */
36
+ export function publishBlock(block, root) {
37
+ const dir = root ?? getFeedDir();
38
+ fs.mkdirSync(dir, { recursive: true });
39
+ const target = blockPath(dir, block.blockId);
40
+ const tmp = `${target}.${process.pid}.tmp`;
41
+ fs.writeFileSync(tmp, JSON.stringify(block, null, 2), 'utf-8');
42
+ fs.renameSync(tmp, target);
43
+ }
44
+ /** Read all block records. Returns them sorted by stable block filename. */
45
+ export function listBlocks(root) {
46
+ const dir = root ?? getFeedDir();
47
+ let names;
48
+ try {
49
+ names = fs.readdirSync(dir);
50
+ }
51
+ catch {
52
+ return [];
53
+ }
54
+ const blocks = [];
55
+ for (const name of names.filter(n => n.endsWith('.json')).sort()) {
56
+ try {
57
+ const raw = fs.readFileSync(path.join(dir, name), 'utf-8');
58
+ const parsed = JSON.parse(raw);
59
+ if (parsed.blockId && parsed.sessionId && parsed.questions?.length) {
60
+ blocks.push(parsed);
61
+ }
62
+ }
63
+ catch {
64
+ // skip corrupt / partial files
65
+ }
66
+ }
67
+ return blocks;
68
+ }
69
+ /** Remove a block record. Returns true if the file was deleted. */
70
+ export function removeBlock(blockId, root) {
71
+ const dir = root ?? getFeedDir();
72
+ try {
73
+ fs.unlinkSync(blockPath(dir, blockId));
74
+ return true;
75
+ }
76
+ catch {
77
+ return false;
78
+ }
79
+ }
80
+ // ---------------------------------------------------------------------------
81
+ // Hook installation
82
+ // ---------------------------------------------------------------------------
83
+ /**
84
+ * The feed-publish PreToolUse hook script (Python, mirroring 09-mailbox-inject.py).
85
+ * Embedded so it ships with the compiled CLI and can be installed to the
86
+ * CLI-writable user hooks dir without a separate file in the npm tarball.
87
+ */
88
+ export const FEED_PUBLISH_HOOK_SCRIPT = `#!/usr/bin/env python3
89
+ """PreToolUse hook: publish an open-block record when the agent calls
90
+ AskUserQuestion, so \`agents feed\` can aggregate pending decisions.
91
+
92
+ Outbound counterpart to the inbound mailbox-inject hook. Fires only on
93
+ AskUserQuestion (matcher-gated in agents.yaml). Writes one block per session
94
+ to ~/.agents/.history/feed/. A new question replaces the previous block.
95
+
96
+ Sub-agent gate: when the PreToolUse payload carries \`agent_type\`, this is a
97
+ Task/Agent subagent -- skip. Only the top-level agent publishes. Verified on
98
+ Claude Code 2.1.170 (2026-07).
99
+
100
+ Fail-open: ANY error is swallowed so a feed hiccup never blocks a tool call.
101
+ """
102
+ import os
103
+ import sys
104
+ import json
105
+ import re
106
+ import socket
107
+ import tempfile
108
+ from datetime import datetime, timezone
109
+
110
+
111
+ def main():
112
+ raw = sys.stdin.read()
113
+ try:
114
+ payload = json.loads(raw) if raw.strip() else {}
115
+ except Exception:
116
+ return
117
+
118
+ # Sub-agent gate.
119
+ if payload.get("agent_type"):
120
+ return
121
+
122
+ tool_input = payload.get("tool_input", {})
123
+ questions = tool_input.get("questions", [])
124
+ if not questions:
125
+ return
126
+
127
+ session_id = payload.get("session_id", "")
128
+ if not session_id:
129
+ return
130
+
131
+ normalized_questions = []
132
+ for q in questions:
133
+ if not isinstance(q, dict):
134
+ continue
135
+ question = {
136
+ "text": q.get("question", q.get("header", "")),
137
+ "header": q.get("header"),
138
+ "multiSelect": q.get("multiSelect", False),
139
+ }
140
+ raw_opts = q.get("options", [])
141
+ if raw_opts:
142
+ question["options"] = [
143
+ {"label": o.get("label", ""), "description": o.get("description")}
144
+ for o in raw_opts
145
+ if isinstance(o, dict)
146
+ ]
147
+ normalized_questions.append(question)
148
+ if not normalized_questions:
149
+ return
150
+
151
+ # Identity from env (set by agents-cli at spawn).
152
+ mailbox_id = os.path.basename(
153
+ os.environ.get("AGENTS_MAILBOX_DIR", "").rstrip("/")
154
+ ) or session_id
155
+
156
+ hostname = os.environ.get("AGENTS_SYNC_MACHINE_ID") or socket.gethostname()
157
+ host = hostname.split(".")[0].strip().lower()
158
+ host = re.sub(r"[^a-z0-9_-]", "-", host) or "unknown"
159
+
160
+ runtime = os.environ.get("AGENTS_RUNTIME", "headless")
161
+
162
+ safe_session_id = re.sub(r"[^A-Za-z0-9._-]", "-", session_id)
163
+ block_id = f"block-{safe_session_id}"
164
+ block = {
165
+ "blockId": block_id,
166
+ "sessionId": session_id,
167
+ "mailboxId": mailbox_id,
168
+ "host": host,
169
+ "runtime": runtime,
170
+ "ts": datetime.now(timezone.utc).isoformat(),
171
+ "questions": normalized_questions,
172
+ }
173
+
174
+ # Python's expanduser() ignores HOME on Windows, while agents-cli honors a
175
+ # HOME override on every platform. Use the same anchor so hooks and the CLI
176
+ # always read/write one feed store (including temp-home and sandbox runs).
177
+ home = os.environ.get("HOME") or os.path.expanduser("~")
178
+ feed_dir = os.path.join(home, ".agents", ".history", "feed")
179
+ os.makedirs(feed_dir, exist_ok=True)
180
+
181
+ target = os.path.join(feed_dir, f"{block_id}.json")
182
+ fd, tmp = tempfile.mkstemp(dir=feed_dir, suffix=".tmp")
183
+ try:
184
+ with os.fdopen(fd, "w") as f:
185
+ json.dump(block, f, indent=2)
186
+ os.rename(tmp, target)
187
+ except Exception:
188
+ try:
189
+ os.unlink(tmp)
190
+ except Exception:
191
+ pass
192
+
193
+
194
+ if __name__ == "__main__":
195
+ try:
196
+ main()
197
+ except Exception:
198
+ pass # fail open
199
+ `;
200
+ /** Manifest entry for the feed-publish hook, matching the ManifestHook shape. */
201
+ export const FEED_PUBLISH_HOOK_MANIFEST = {
202
+ name: 'feed-publish',
203
+ events: ['PreToolUse'],
204
+ matcher: 'AskUserQuestion',
205
+ script: '10-feed-publish.py',
206
+ timeout: 5,
207
+ };
208
+ /**
209
+ * Install the feed-publish hook script into the user hooks dir and add its
210
+ * manifest entry to the user agents.yaml. The system repo is an auto-pulled,
211
+ * read-only mirror, so runtime-managed hooks must never write there.
212
+ * Idempotent -- skips if the script is already present and up to date.
213
+ */
214
+ export function ensureFeedPublishHook(userAgentsDir = getUserAgentsDir()) {
215
+ try {
216
+ const hooksDir = path.join(userAgentsDir, 'hooks');
217
+ const scriptPath = path.join(hooksDir, '10-feed-publish.py');
218
+ fs.mkdirSync(hooksDir, { recursive: true });
219
+ let installed = false;
220
+ if (!fs.existsSync(scriptPath) || fs.readFileSync(scriptPath, 'utf-8') !== FEED_PUBLISH_HOOK_SCRIPT) {
221
+ const tmpScript = `${scriptPath}.${process.pid}.tmp`;
222
+ fs.writeFileSync(tmpScript, FEED_PUBLISH_HOOK_SCRIPT, { mode: 0o755 });
223
+ fs.renameSync(tmpScript, scriptPath);
224
+ installed = true;
225
+ }
226
+ const agentsYamlPath = path.join(userAgentsDir, 'agents.yaml');
227
+ const yamlDoc = fs.existsSync(agentsYamlPath)
228
+ ? yaml.parseDocument(fs.readFileSync(agentsYamlPath, 'utf-8'))
229
+ : new yaml.Document({});
230
+ if (yamlDoc.errors.length > 0) {
231
+ throw new Error(`Cannot install feed hook: ${agentsYamlPath} is invalid YAML`);
232
+ }
233
+ if (!yamlDoc.getIn(['hooks', 'feed-publish'])) {
234
+ yamlDoc.setIn(['hooks', 'feed-publish'], {
235
+ agents: ['claude'],
236
+ events: ['PreToolUse'],
237
+ matcher: 'AskUserQuestion',
238
+ script: '10-feed-publish.py',
239
+ timeout: 5,
240
+ });
241
+ const tmpYaml = `${agentsYamlPath}.${process.pid}.tmp`;
242
+ fs.writeFileSync(tmpYaml, String(yamlDoc));
243
+ fs.renameSync(tmpYaml, agentsYamlPath);
244
+ installed = true;
245
+ }
246
+ return { installed };
247
+ }
248
+ catch (err) {
249
+ return { installed: false, error: err.message };
250
+ }
251
+ }
package/dist/lib/hooks.js CHANGED
@@ -1028,9 +1028,11 @@ function registerHooksForClaude(versionHome, manifest, resolveScript, managedPre
1028
1028
  const configDir = path.join(versionHome, configDirName);
1029
1029
  const settingsPath = path.join(configDir, 'settings.json');
1030
1030
  let config = {};
1031
+ let existingRaw;
1031
1032
  if (fs.existsSync(settingsPath)) {
1032
1033
  try {
1033
- config = JSON.parse(fs.readFileSync(settingsPath, 'utf-8'));
1034
+ existingRaw = fs.readFileSync(settingsPath, 'utf-8');
1035
+ config = JSON.parse(existingRaw);
1034
1036
  }
1035
1037
  catch {
1036
1038
  errors.push('Failed to parse settings.json');
@@ -1111,7 +1113,10 @@ function registerHooksForClaude(versionHome, manifest, resolveScript, managedPre
1111
1113
  }
1112
1114
  try {
1113
1115
  fs.mkdirSync(configDir, { recursive: true });
1114
- fs.writeFileSync(settingsPath, JSON.stringify(config, null, 2), 'utf-8');
1116
+ const nextRaw = JSON.stringify(config, null, 2);
1117
+ if (existingRaw !== nextRaw) {
1118
+ fs.writeFileSync(settingsPath, nextRaw, 'utf-8');
1119
+ }
1115
1120
  }
1116
1121
  catch (err) {
1117
1122
  errors.push(`Failed to write settings.json: ${err.message}`);
@@ -34,6 +34,7 @@ const REMOTE_PASSTHROUGH = {
34
34
  sync: { nonInteractive: ['--yes'] },
35
35
  teams: {},
36
36
  message: {},
37
+ feed: {},
37
38
  };
38
39
  /** `--no-tty` is stripped like the routing flags but carries no value. */
39
40
  const STRIP_SPECS = [...HOST_ROUTING_SPECS, { long: 'no-tty', takesValue: false }];
@@ -10,6 +10,7 @@ import { getAccountInfo } from './agents.js';
10
10
  import { readMeta, writeMeta, getHelpersDir } from './state.js';
11
11
  import { listInstalledVersions, getVersionHomePath, resolveVersion } from './versions.js';
12
12
  import { getProjectRunConfigs } from './run-config.js';
13
+ import { emit } from './events.js';
13
14
  import { getUsageInfoByIdentity, getUsageLookupKey, deriveUsageStatusFromSnapshot, } from './usage.js';
14
15
  function getRotateDir() {
15
16
  const dir = path.join(getHelpersDir(), 'rotate');
@@ -388,6 +389,7 @@ export async function resolveRunVersion(agent, strategy, cwd = process.cwd()) {
388
389
  }
389
390
  recordRotationPick(agent, rotation.picked.version);
390
391
  }
392
+ emit('rotation.resolved', { module: 'rotate', agent, version: rotation.picked.version, strategy, healthy: rotation.healthy.length, excluded: rotation.excluded.length });
391
393
  return { version: rotation.picked.version, rotation };
392
394
  }
393
395
  return { version: fallback, rotation: null };
@@ -50,6 +50,18 @@ export declare const META_CACHE_PREFIX = "!meta:";
50
50
  * cache on every bump and produced a recurring Touch ID storm.
51
51
  */
52
52
  export declare function shouldSelfHealForUpgrade(persistent: boolean, storeSize: number, runningVersion: string, onDiskVersion: string): boolean;
53
+ /**
54
+ * Client-side twin of shouldSelfHealForUpgrade: whether ensureAgentRunning may
55
+ * tear down a reachable broker whose running version differs from the client's
56
+ * on-disk version. Only while it holds NO real unlocks — tearing down a hot
57
+ * broker wipes every held bundle, so the next read of each one re-prompts for
58
+ * Touch ID. On a machine where installed versions churn (dev builds stamp a
59
+ * fresh 0.0.0-dev.<sha> on every install; an npm copy and a dev copy invoke in
60
+ * turn), an unguarded teardown produced a rolling Touch ID storm — the exact
61
+ * failure #435 fixed on the server side. A hot, protocol-compatible broker
62
+ * keeps serving; its own sweep adopts the new code at the next quiet moment.
63
+ */
64
+ export declare function shouldTeardownVersionSkewedBroker(realHeldBundles: number): boolean;
53
65
  export interface StoredBundle {
54
66
  bundle: SecretsBundle;
55
67
  env: Record<string, string>;
@@ -172,6 +184,15 @@ export declare function agentGetSync(name: string): {
172
184
  bundle: SecretsBundle;
173
185
  env: Record<string, string>;
174
186
  } | null;
187
+ /**
188
+ * Synchronously evict one bundle from the broker. Called after a mutating
189
+ * keychain write (add / rotate / remove / rename / delete) so the broker never
190
+ * keeps serving the pre-write snapshot for up to the ~7d hold — the next read
191
+ * re-resolves from the keychain (one prompt) and re-caches fresh values.
192
+ * Best-effort: no broker, no socket, or any failure is a silent no-op.
193
+ * macOS only.
194
+ */
195
+ export declare function agentEvictSync(name: string): void;
175
196
  /**
176
197
  * Read the cached `secrets list` metadata snapshot for the given keychain
177
198
  * name-set hash, or null on miss / no broker / off-darwin. Reuses the value
@@ -74,6 +74,20 @@ export function shouldSelfHealForUpgrade(persistent, storeSize, runningVersion,
74
74
  return false;
75
75
  return onDiskVersion !== runningVersion;
76
76
  }
77
+ /**
78
+ * Client-side twin of shouldSelfHealForUpgrade: whether ensureAgentRunning may
79
+ * tear down a reachable broker whose running version differs from the client's
80
+ * on-disk version. Only while it holds NO real unlocks — tearing down a hot
81
+ * broker wipes every held bundle, so the next read of each one re-prompts for
82
+ * Touch ID. On a machine where installed versions churn (dev builds stamp a
83
+ * fresh 0.0.0-dev.<sha> on every install; an npm copy and a dev copy invoke in
84
+ * turn), an unguarded teardown produced a rolling Touch ID storm — the exact
85
+ * failure #435 fixed on the server side. A hot, protocol-compatible broker
86
+ * keeps serving; its own sweep adopts the new code at the next quiet moment.
87
+ */
88
+ export function shouldTeardownVersionSkewedBroker(realHeldBundles) {
89
+ return realHeldBundles === 0;
90
+ }
77
91
  function onDarwin() {
78
92
  return process.platform === 'darwin';
79
93
  }
@@ -581,6 +595,50 @@ export function agentGetSync(name) {
581
595
  return null;
582
596
  }
583
597
  }
598
+ /**
599
+ * Inline node program for the synchronous evict path. Mirrors SYNC_GET_PROGRAM:
600
+ * writeBundle is synchronous and called synchronously everywhere, so a stale
601
+ * broker entry must be evicted without awaiting a socket round-trip. Sends one
602
+ * {cmd:'lock', name} and exits 0 (evicted or nothing held) / 3 (agent down).
603
+ * argv after -e: [execPath, <socket>, <name>].
604
+ */
605
+ const SYNC_LOCK_PROGRAM = `
606
+ const net = require('net');
607
+ const sock = process.argv[1], name = process.argv[2];
608
+ const c = net.createConnection(sock);
609
+ let buf = '';
610
+ const down = () => { try { c.destroy(); } catch (e) {} process.exit(3); };
611
+ const timer = setTimeout(down, 2000);
612
+ c.on('error', down);
613
+ c.on('connect', () => c.write(JSON.stringify({ cmd: 'lock', name }) + '\\n'));
614
+ c.setEncoding('utf-8');
615
+ c.on('data', (d) => {
616
+ buf += d;
617
+ const nl = buf.indexOf('\\n');
618
+ if (nl < 0) return;
619
+ clearTimeout(timer);
620
+ try { c.destroy(); } catch (e) {}
621
+ process.exit(0);
622
+ });
623
+ `;
624
+ /**
625
+ * Synchronously evict one bundle from the broker. Called after a mutating
626
+ * keychain write (add / rotate / remove / rename / delete) so the broker never
627
+ * keeps serving the pre-write snapshot for up to the ~7d hold — the next read
628
+ * re-resolves from the keychain (one prompt) and re-caches fresh values.
629
+ * Best-effort: no broker, no socket, or any failure is a silent no-op.
630
+ * macOS only.
631
+ */
632
+ export function agentEvictSync(name) {
633
+ if (!onDarwin())
634
+ return;
635
+ if (!agentSocketExists())
636
+ return;
637
+ try {
638
+ spawnSync(process.execPath, ['-e', SYNC_LOCK_PROGRAM, socketPath(), name], { timeout: 3000 });
639
+ }
640
+ catch { /* best-effort */ }
641
+ }
584
642
  // Key inside the cached entry's env that holds the JSON metadata snapshot.
585
643
  const META_SNAPSHOT_KEY = '__snapshot__';
586
644
  /**
@@ -733,11 +791,15 @@ export async function ensureAgentRunning(timeoutMs = 5000) {
733
791
  // Self-heal: if a broker is reachable but running pre-upgrade code (its
734
792
  // reported version != the version on disk now), tear it down so the paths
735
793
  // below bring up a fresh one on current code. A current, reachable broker is
736
- // accepted immediately.
794
+ // accepted immediately — and so is a version-skewed one that still holds
795
+ // real unlocks (see shouldTeardownVersionSkewedBroker: wiping a hot cache
796
+ // re-prompts Touch ID for every held bundle).
737
797
  const ping = await agentPing();
738
798
  if (ping.reachable) {
739
799
  if (ping.cliVersion === undefined || ping.cliVersion === getCliVersionFresh())
740
800
  return true;
801
+ if (!shouldTeardownVersionSkewedBroker((await agentStatus()).length))
802
+ return true;
741
803
  await teardownStaleBroker();
742
804
  }
743
805
  // Path 1: the persistent service. installSecretsAgentService is idempotent and
@@ -89,6 +89,9 @@ export interface LegacyBundleCandidate {
89
89
  file: string;
90
90
  keys: string[];
91
91
  }
92
+ export declare const BUNDLE_NAME_PATTERN: RegExp;
93
+ export declare const ENV_KEY_PATTERN: RegExp;
94
+ export declare const BUNDLE_META_PREFIX = "agents-cli.bundles.";
92
95
  export declare const RESERVED_ENV_NAMES: Set<string>;
93
96
  export declare function bundleToEnvPrefix(name: string): string;
94
97
  export declare function isReservedEnvName(key: string): boolean;
@@ -114,7 +117,29 @@ export declare function readBundle(name: string): SecretsBundle;
114
117
  export declare function secretsDefaultPolicy(): SecretsPolicy;
115
118
  /** The effective prompt policy of a bundle (absent ⇒ the configured default). */
116
119
  export declare function bundlePolicy(bundle: SecretsBundle): SecretsPolicy;
117
- export declare function writeBundle(bundle: SecretsBundle): void;
120
+ /** Options for writeBundle. */
121
+ export interface WriteBundleOptions {
122
+ /**
123
+ * Skip evicting the bundle from the secrets-agent broker after the write.
124
+ * Only for writers that change nothing the broker serves — today that is
125
+ * stampLastUsed (a usage-telemetry timestamp, fired on every broker HIT):
126
+ * evicting there would make the cache destroy itself on first use. Every
127
+ * mutating writer (add / rotate / remove / rename / policy / import) must
128
+ * leave this unset so a broker-held copy never serves stale values for up
129
+ * to the ~7d hold.
130
+ */
131
+ skipBrokerEviction?: boolean;
132
+ }
133
+ /**
134
+ * Whether a bundle write should evict the broker-held copy. Pure + exported
135
+ * for regression coverage. Skips when the writer opted out (stampLastUsed),
136
+ * when the broker integration is disabled (AGENTS_SECRETS_NO_AGENT — the same
137
+ * kill-switch the read fast-path honors), or when a test keychain backend is
138
+ * installed (an in-memory backend has no real keychain behind it, and a test
139
+ * writing bundle 'prod' must never evict the user's real 'prod' unlock).
140
+ */
141
+ export declare function shouldEvictAfterBundleWrite(skipRequested: boolean, noAgentEnv: string | undefined, backendOverridden: boolean): boolean;
142
+ export declare function writeBundle(bundle: SecretsBundle, opts?: WriteBundleOptions): void;
118
143
  export declare function deleteBundle(name: string): boolean;
119
144
  export declare function listBundles(): SecretsBundle[];
120
145
  export interface BundleEntryInfo {
@@ -140,6 +165,13 @@ export interface ResolveBundleOptions {
140
165
  * needs live values. Also honored via AGENTS_SECRETS_NO_AGENT=1.
141
166
  */
142
167
  noAgent?: boolean;
168
+ /**
169
+ * Resolve only from an already-unlocked secrets-agent snapshot. If the
170
+ * broker has no snapshot, fail before touching Keychain or any other store.
171
+ * Background processes use this to guarantee they never surface a biometric
172
+ * prompt that nobody can answer.
173
+ */
174
+ agentOnly?: boolean;
143
175
  /**
144
176
  * Inject only this subset of keys from the bundle. Keys not in this list are
145
177
  * silently excluded from the returned env map. An error is thrown if any
@@ -25,7 +25,7 @@ import { deleteKeychainToken, getKeychainToken, getKeychainTokens, hasKeychainTo
25
25
  import { fileStore } from './filestore.js';
26
26
  import { emit } from '../events.js';
27
27
  import { readMeta } from '../state.js';
28
- import { agentGetSync, agentAutoLoadSync, agentGetMetaSync, agentAutoLoadMetaSync, secretsAgentAutoEnabled, DEFAULT_TTL_MS } from './agent.js';
28
+ import { agentGetSync, agentAutoLoadSync, agentGetMetaSync, agentAutoLoadMetaSync, agentEvictSync, secretsAgentAutoEnabled, DEFAULT_TTL_MS } from './agent.js';
29
29
  import { createHash } from 'node:crypto';
30
30
  const keychainStore = {
31
31
  has: hasKeychainToken,
@@ -105,9 +105,9 @@ export const SECRET_TYPES = [
105
105
  ];
106
106
  /** Minimum gap between last_used updates so the keychain isn't written on every secrets injection. */
107
107
  const LAST_USED_THROTTLE_MS = 60_000;
108
- const BUNDLE_NAME_PATTERN = /^[a-z0-9][a-z0-9\-_.]{0,48}$/i;
109
- const ENV_KEY_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/;
110
- const BUNDLE_META_PREFIX = 'agents-cli.bundles.';
108
+ export const BUNDLE_NAME_PATTERN = /^[a-z0-9][a-z0-9\-_.]{0,48}$/i;
109
+ export const ENV_KEY_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/;
110
+ export const BUNDLE_META_PREFIX = 'agents-cli.bundles.';
111
111
  const SECRETS_ITEM_PREFIX = 'agents-cli.secrets.';
112
112
  export const RESERVED_ENV_NAMES = new Set([
113
113
  'PATH', 'HOME', 'USER', 'USERNAME', 'SHELL', 'PWD', 'OLDPWD',
@@ -277,7 +277,24 @@ export function secretsDefaultPolicy() {
277
277
  export function bundlePolicy(bundle) {
278
278
  return bundle.policy ?? secretsDefaultPolicy();
279
279
  }
280
- export function writeBundle(bundle) {
280
+ /**
281
+ * Whether a bundle write should evict the broker-held copy. Pure + exported
282
+ * for regression coverage. Skips when the writer opted out (stampLastUsed),
283
+ * when the broker integration is disabled (AGENTS_SECRETS_NO_AGENT — the same
284
+ * kill-switch the read fast-path honors), or when a test keychain backend is
285
+ * installed (an in-memory backend has no real keychain behind it, and a test
286
+ * writing bundle 'prod' must never evict the user's real 'prod' unlock).
287
+ */
288
+ export function shouldEvictAfterBundleWrite(skipRequested, noAgentEnv, backendOverridden) {
289
+ if (skipRequested)
290
+ return false;
291
+ if (noAgentEnv === '1')
292
+ return false;
293
+ if (backendOverridden)
294
+ return false;
295
+ return true;
296
+ }
297
+ export function writeBundle(bundle, opts = {}) {
281
298
  validateBundleName(bundle.name);
282
299
  const backend = bundle.backend ?? 'keychain';
283
300
  if (backend === 'file')
@@ -337,12 +354,20 @@ export function writeBundle(bundle) {
337
354
  // no-ACL command is missing) rather than silently landing an ACL'd item.
338
355
  itemStore(backend).set(bundleMetaItem(bundle.name), json, { noAcl: bundle.policy === 'never' });
339
356
  emit('secrets.set', { module: 'secrets', bundle: bundle.name });
357
+ // A broker-held snapshot predates this write; evict it so the next read
358
+ // re-resolves from the keychain instead of serving stale values.
359
+ if (shouldEvictAfterBundleWrite(Boolean(opts.skipBrokerEviction), process.env.AGENTS_SECRETS_NO_AGENT, isKeychainBackendOverridden())) {
360
+ agentEvictSync(bundle.name);
361
+ }
340
362
  }
341
363
  export function deleteBundle(name) {
342
364
  validateBundleName(name);
343
365
  const deleted = itemStore(bundleBackend(name)).delete(bundleMetaItem(name));
344
366
  if (deleted) {
345
367
  emit('secrets.delete', { module: 'secrets', bundle: name });
368
+ if (shouldEvictAfterBundleWrite(false, process.env.AGENTS_SECRETS_NO_AGENT, isKeychainBackendOverridden())) {
369
+ agentEvictSync(name);
370
+ }
346
371
  }
347
372
  return deleted;
348
373
  }
@@ -513,7 +538,9 @@ function stampLastUsed(bundle) {
513
538
  }
514
539
  try {
515
540
  bundle.last_used = new Date(nowMs).toISOString();
516
- writeBundle(bundle);
541
+ // skipBrokerEviction: this stamp fires on every broker HIT; letting it
542
+ // evict would make the cache destroy itself on first use.
543
+ writeBundle(bundle, { skipBrokerEviction: true });
517
544
  }
518
545
  catch {
519
546
  // Swallow — telemetry must never block secret resolution.
@@ -694,7 +721,7 @@ export function readAndResolveBundleEnv(name, opts = {}) {
694
721
  emit('secrets.get', {
695
722
  module: 'secrets',
696
723
  bundle: name,
697
- caller: opts.caller,
724
+ operation: opts.caller,
698
725
  status: 'success',
699
726
  source: 'agent',
700
727
  keyCount: Object.keys(filtered.env).length,
@@ -702,6 +729,9 @@ export function readAndResolveBundleEnv(name, opts = {}) {
702
729
  return filtered;
703
730
  }
704
731
  }
732
+ if (opts.agentOnly) {
733
+ throw new Error(`Secrets bundle '${name}' is not unlocked in the secrets agent.`);
734
+ }
705
735
  if (backend === 'file')
706
736
  assertFileBackendUsable(name);
707
737
  const store = itemStore(backend);
@@ -784,7 +814,7 @@ export function readAndResolveBundleEnv(name, opts = {}) {
784
814
  emit('secrets.get', {
785
815
  module: 'secrets',
786
816
  bundle: bundle.name,
787
- caller: opts.caller,
817
+ operation: opts.caller,
788
818
  status,
789
819
  keyCount: keys.length,
790
820
  keys,
@@ -0,0 +1,70 @@
1
+ /**
2
+ * Recovery for LEGACY SYNCHRONIZABLE (iCloud Keychain) bundles.
3
+ *
4
+ * The pre-biometry helper era defaulted bundles to iCloud Keychain sync. The
5
+ * device-local cutover (biometry ACL + kSecAttrSynchronizable false on every
6
+ * query) orphaned those items: they still sync back via iCloud Keychain, but
7
+ * neither `secrets list` nor `migrate-acl` can see them. This module powers
8
+ * `agents secrets import --from icloud` — discover the orphaned bundles,
9
+ * re-import them as normal device-local bundles, and optionally purge the
10
+ * iCloud copies.
11
+ *
12
+ * The item-name scheme is the same one the modern store uses (see bundles.ts):
13
+ * metadata under `agents-cli.bundles.<name>`, one value per key under
14
+ * `agents-cli.secrets.<bundle>.<key>`. Env keys can never contain a dot
15
+ * (ENV_KEY_PATTERN), so splitting a secret service at its LAST dot recovers
16
+ * the bundle/key boundary even for dotted bundle names like `hetzner.com`.
17
+ */
18
+ import { type SecretsBackend } from './bundles.js';
19
+ /** One orphaned iCloud bundle, as discovered from the synced item names. */
20
+ export interface SyncedBundleCandidate {
21
+ /** Bundle name derived from the iCloud service names. */
22
+ name: string;
23
+ /** Env keys that have a per-key secret item in the iCloud keychain. */
24
+ keys: string[];
25
+ /** True when an `agents-cli.bundles.<name>` metadata item exists in iCloud. */
26
+ hasMeta: boolean;
27
+ /** Every iCloud service name belonging to this candidate (the purge set). */
28
+ services: string[];
29
+ }
30
+ /**
31
+ * Group raw synced service names into per-bundle candidates. Pure — separated
32
+ * from discovery so the parsing rules are unit-testable without a keychain.
33
+ *
34
+ * A bundle can surface as metadata only (`agents-cli.bundles.<name>`), as
35
+ * secret items only (`agents-cli.secrets.<bundle>.<KEY>` — metadata never
36
+ * synced), or both; all three shapes appear in real iCloud strays, so every
37
+ * one becomes a candidate.
38
+ */
39
+ export declare function groupSyncedServices(services: string[]): SyncedBundleCandidate[];
40
+ /** Enumerate the iCloud keychain and return every orphaned bundle candidate. */
41
+ export declare function discoverSyncedBundles(): SyncedBundleCandidate[];
42
+ export interface ImportSyncedOptions {
43
+ /** Overwrite keys that already exist in the local bundle. */
44
+ force?: boolean;
45
+ /** Store imported values as literals in the bundle metadata (no keychain items). */
46
+ allPlaintext?: boolean;
47
+ /** Backend for a newly created bundle (existing bundles keep theirs). */
48
+ backend?: SecretsBackend;
49
+ /** Delete the iCloud copies of successfully-read items after import. */
50
+ purge?: boolean;
51
+ }
52
+ export interface ImportSyncedResult {
53
+ name: string;
54
+ added: number;
55
+ skipped: number;
56
+ /** Keys whose iCloud value could not be read (left in place, never purged). */
57
+ missing: string[];
58
+ purged: number;
59
+ }
60
+ /**
61
+ * Import one discovered iCloud bundle into the local (device-local) store.
62
+ *
63
+ * Values come from the synced secret items; the synced metadata item, when
64
+ * present, contributes the description, literal vars, and non-keychain refs
65
+ * (env:/file:/exec: refs carry no stored secret, so copying the ref preserves
66
+ * them exactly). Existing local keys are skipped unless `force`. With `purge`,
67
+ * only services whose value was successfully read are deleted from iCloud —
68
+ * an unreadable item is never destroyed.
69
+ */
70
+ export declare function importSyncedBundle(candidate: SyncedBundleCandidate, opts?: ImportSyncedOptions): ImportSyncedResult;