@phnx-labs/agents-cli 1.22.56 → 1.22.58

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 (146) hide show
  1. package/CHANGELOG.md +70 -0
  2. package/README.md +4 -4
  3. package/dist/bootstrap.js +11 -2
  4. package/dist/cli/command-registry.d.ts +0 -1
  5. package/dist/cli/command-registry.js +0 -3
  6. package/dist/commands/accounts.js +7 -3
  7. package/dist/commands/apply.js +10 -2
  8. package/dist/commands/exec.js +1 -1
  9. package/dist/commands/fork.d.ts +23 -10
  10. package/dist/commands/fork.js +115 -58
  11. package/dist/commands/hooks.js +4 -4
  12. package/dist/commands/insights.d.ts +7 -5
  13. package/dist/commands/insights.js +16 -9
  14. package/dist/commands/monitors.js +11 -0
  15. package/dist/commands/perf.d.ts +16 -7
  16. package/dist/commands/perf.js +29 -20
  17. package/dist/commands/prune.js +5 -3
  18. package/dist/commands/routines.d.ts +8 -0
  19. package/dist/commands/routines.js +57 -3
  20. package/dist/commands/rules.js +1 -1
  21. package/dist/commands/sessions-picker.d.ts +11 -0
  22. package/dist/commands/sessions-picker.js +16 -0
  23. package/dist/commands/sessions.js +1 -0
  24. package/dist/commands/share.d.ts +14 -0
  25. package/dist/commands/share.js +43 -2
  26. package/dist/commands/ssh.js +24 -14
  27. package/dist/commands/status.js +1 -1
  28. package/dist/commands/sync.js +83 -7
  29. package/dist/commands/traces.js +7 -0
  30. package/dist/commands/trash.d.ts +2 -2
  31. package/dist/commands/trash.js +2 -6
  32. package/dist/commands/versions.d.ts +2 -2
  33. package/dist/commands/versions.js +1 -10
  34. package/dist/commands/view.d.ts +2 -2
  35. package/dist/commands/view.js +7 -6
  36. package/dist/index.d.ts +1 -0
  37. package/dist/index.js +14 -0
  38. package/dist/lib/account-registry.d.ts +5 -1
  39. package/dist/lib/account-registry.js +47 -14
  40. package/dist/lib/accounting/capacity.d.ts +18 -7
  41. package/dist/lib/accounting/capacity.js +19 -8
  42. package/dist/lib/accounting/usage-ingest.d.ts +1 -0
  43. package/dist/lib/accounting/usage-ingest.js +75 -0
  44. package/dist/lib/accounting/usage-sync.d.ts +97 -0
  45. package/dist/lib/accounting/usage-sync.js +203 -0
  46. package/dist/lib/accounting/usage.d.ts +48 -2
  47. package/dist/lib/accounting/usage.js +79 -2
  48. package/dist/lib/agent-spec/agents.js +1 -1
  49. package/dist/lib/analytics/mix-commands.d.ts +8 -7
  50. package/dist/lib/analytics/mix-commands.js +50 -73
  51. package/dist/lib/auth-mint.d.ts +11 -1
  52. package/dist/lib/auth-mint.js +21 -6
  53. package/dist/lib/browser/ipc.d.ts +8 -0
  54. package/dist/lib/browser/ipc.js +87 -0
  55. package/dist/lib/browser/service.d.ts +19 -0
  56. package/dist/lib/browser/service.js +96 -11
  57. package/dist/lib/browser/sessions-list.js +10 -1
  58. package/dist/lib/daemon/daemon.js +5 -0
  59. package/dist/lib/daemon/runner.d.ts +3 -0
  60. package/dist/lib/daemon/runner.js +95 -53
  61. package/dist/lib/daemon/usage-sync-service.d.ts +21 -0
  62. package/dist/lib/daemon/usage-sync-service.js +42 -0
  63. package/dist/lib/daemon-services.d.ts +1 -1
  64. package/dist/lib/daemon-services.js +5 -0
  65. package/dist/lib/device-config.d.ts +17 -6
  66. package/dist/lib/device-config.js +25 -11
  67. package/dist/lib/devices/connect.d.ts +17 -8
  68. package/dist/lib/devices/connect.js +31 -14
  69. package/dist/lib/devices/pool.d.ts +4 -3
  70. package/dist/lib/devices/pool.js +13 -5
  71. package/dist/lib/doctor-diff.js +77 -7
  72. package/dist/lib/exec.d.ts +6 -41
  73. package/dist/lib/exec.js +6 -41
  74. package/dist/lib/fleet/manifest.d.ts +17 -0
  75. package/dist/lib/fleet/manifest.js +26 -0
  76. package/dist/lib/git.d.ts +13 -1
  77. package/dist/lib/git.js +36 -7
  78. package/dist/lib/harness/adapter.d.ts +7 -7
  79. package/dist/lib/harness/adapters/claude.js +3 -2
  80. package/dist/lib/hooks/install.d.ts +27 -11
  81. package/dist/lib/hooks/install.js +42 -17
  82. package/dist/lib/hosts/reconnect.d.ts +52 -203
  83. package/dist/lib/hosts/reconnect.js +64 -284
  84. package/dist/lib/hosts/remote-cmd.d.ts +9 -0
  85. package/dist/lib/hosts/remote-cmd.js +22 -0
  86. package/dist/lib/installations/migrate.d.ts +6 -120
  87. package/dist/lib/installations/migrate.js +27 -259
  88. package/dist/lib/installations/shims.d.ts +13 -95
  89. package/dist/lib/installations/shims.js +22 -139
  90. package/dist/lib/installations/store.js +1 -1
  91. package/dist/lib/installations/versions.d.ts +26 -133
  92. package/dist/lib/installations/versions.js +41 -204
  93. package/dist/lib/perf/db.d.ts +1 -1
  94. package/dist/lib/perf/db.js +1 -1
  95. package/dist/lib/plugins/skills.d.ts +8 -1
  96. package/dist/lib/plugins/skills.js +18 -2
  97. package/dist/lib/refresh.d.ts +9 -0
  98. package/dist/lib/refresh.js +3 -1
  99. package/dist/lib/routine-readiness.d.ts +15 -1
  100. package/dist/lib/routine-readiness.js +41 -0
  101. package/dist/lib/sandbox.d.ts +4 -1
  102. package/dist/lib/sandbox.js +30 -1
  103. package/dist/lib/secrets/agent.d.ts +80 -225
  104. package/dist/lib/secrets/agent.js +139 -401
  105. package/dist/lib/secrets/bundles.d.ts +73 -222
  106. package/dist/lib/secrets/bundles.js +168 -467
  107. package/dist/lib/secrets/reaper.d.ts +28 -70
  108. package/dist/lib/secrets/reaper.js +30 -85
  109. package/dist/lib/secrets/remote.d.ts +42 -129
  110. package/dist/lib/secrets/remote.js +55 -173
  111. package/dist/lib/self-heal/checks/install-staging.d.ts +4 -0
  112. package/dist/lib/self-heal/checks/install-staging.js +96 -0
  113. package/dist/lib/self-heal/registry.js +2 -0
  114. package/dist/lib/self-heal/types.d.ts +1 -1
  115. package/dist/lib/self-update.d.ts +23 -0
  116. package/dist/lib/self-update.js +50 -0
  117. package/dist/lib/session/active.d.ts +16 -32
  118. package/dist/lib/session/active.js +10 -68
  119. package/dist/lib/session/db.d.ts +24 -36
  120. package/dist/lib/session/db.js +143 -44
  121. package/dist/lib/session/discover.d.ts +6 -58
  122. package/dist/lib/session/discover.js +5 -43
  123. package/dist/lib/session/fork.d.ts +45 -26
  124. package/dist/lib/session/fork.js +32 -95
  125. package/dist/lib/session/parse.d.ts +1 -19
  126. package/dist/lib/session/parse.js +2 -15
  127. package/dist/lib/session/tool-calls.d.ts +43 -1
  128. package/dist/lib/session/tool-calls.js +74 -44
  129. package/dist/lib/session/tool-store.d.ts +33 -2
  130. package/dist/lib/session/tool-store.js +56 -3
  131. package/dist/lib/staleness/writers/sources.d.ts +5 -0
  132. package/dist/lib/staleness/writers/sources.js +2 -1
  133. package/dist/lib/startup/command-registry.d.ts +8 -2
  134. package/dist/lib/startup/command-registry.js +12 -4
  135. package/dist/lib/sync-status.d.ts +22 -0
  136. package/dist/lib/sync-status.js +27 -0
  137. package/dist/lib/sync-umbrella.d.ts +9 -0
  138. package/dist/lib/sync-umbrella.js +21 -2
  139. package/dist/lib/traces/insights.d.ts +47 -14
  140. package/dist/lib/traces/insights.js +92 -21
  141. package/dist/lib/traces/phenotype.d.ts +23 -3
  142. package/dist/lib/traces/phenotype.js +72 -24
  143. package/dist/lib/traces/sync.d.ts +15 -0
  144. package/dist/lib/traces/sync.js +104 -19
  145. package/dist/lib/traces/worker-template.js +154 -1
  146. package/package.json +1 -1
@@ -1,27 +1,11 @@
1
1
  /**
2
- * The secrets-agent: a local broker that holds resolved bundle env in memory
3
- * after a single Touch ID unlock, so concurrent agent processes don't each pop
4
- * their own prompt.
2
+ * The secrets-agent: an in-memory broker that holds resolved bundle env after one
3
+ * Touch ID unlock, so concurrent agents don't each prompt.
5
4
  *
6
- * Why this exists: every secret item carries a biometry access control, and
7
- * macOS refuses to cache that across processes — N concurrent `agents run`
8
- * spawns = N Touch ID prompts (see src/lib/secrets/bundles.ts). The Swift
9
- * helper's LAContext only deduplicates reads *within one process*. This broker
10
- * is the ssh-agent answer: `agents secrets unlock <bundle>` decrypts the bundle
11
- * once (one prompt), ships the resolved env here, and every later read returns
12
- * from memory over a user-only Unix socket — no prompt.
13
- *
14
- * Security model (deliberate): while a bundle is unlocked, any same-user
15
- * process that can reach the socket reads it silently. That's strictly the same
16
- * trust boundary the keychain already concedes (docs/secrets.md: the ACL is
17
- * user-presence, not code-identity — any same-user process can pop the prompt
18
- * and read), minus the visible prompt. We bound it with: explicit per-bundle
19
- * opt-in (nothing is held unless you `unlock` it), an absolute TTL (~7d), an
20
- * auto-wipe on sleep / logout, and `agents secrets lock`. A bare screen-lock is
21
- * NOT a wipe (the login password already gates it). Nothing ever touches disk.
22
- *
23
- * macOS only: Linux libsecret has no biometry prompt, so there's nothing to
24
- * deduplicate — every entry point here no-ops off darwin.
5
+ * Security model: while unlocked, any same-user process reaching the socket can
6
+ * read it silently — the same trust boundary the keychain already concedes. We
7
+ * bound it with per-bundle opt-in, a TTL (~7d), auto-wipe on sleep/logout, and
8
+ * explicit lock. Nothing touches disk. Off-darwin the broker is unused.
25
9
  */
26
10
  import * as net from 'net';
27
11
  import * as fs from 'fs';
@@ -42,10 +26,7 @@ import { MAX_LEASE_MS, MIN_LEASE_MS } from './lease.js';
42
26
  import { selectLeasedEnv } from './lease.js';
43
27
  import { emitSecretAudit } from './audit.js';
44
28
  import { isDaemonServiceEnabled } from '../daemon-services.js';
45
- // Re-exported so callers already reaching for agent.js keep one obvious home for
46
- // the scope vocabulary; the definitions live in the leaf module scope.ts because
47
- // agent.ts and session-store.ts import each other and a cyclic `const` read can
48
- // hit the ESM temporal dead zone at runtime.
29
+ // Re-exported from scope.ts to break the agent.ts ↔ session-store.ts import cycle.
49
30
  export { GLOBAL_HARNESS, bundleScopeChain };
50
31
  /** Bumped when the wire protocol changes; a client that pings a mismatched
51
32
  * server kills and respawns it rather than talking a stale dialect. */
@@ -53,24 +34,16 @@ const PROTOCOL_VERSION = 3;
53
34
  /** Default lifetime of an unlocked bundle when `--ttl` is not given. */
54
35
  export const DEFAULT_TTL_MS = 7 * 24 * 60 * 60 * 1000; // 7d
55
36
  /**
56
- * Whether the secrets broker service is enabled in the daemon service config.
57
- * Off-darwin this is irrelevant (the broker is never used), but the helper is
58
- * kept synchronous and safe everywhere so callers can fail loud without a prompt.
37
+ * Whether the secrets broker service is enabled in daemon service config.
59
38
  */
60
39
  export function isSecretsBrokerEnabled() {
61
40
  return isDaemonServiceEnabled('secrets-broker');
62
41
  }
63
42
  /**
64
43
  * Reserved store-key prefix for the `secrets list` metadata snapshot cache.
65
- * The broker holds the resolved bundle-metadata array (names/policy/timestamps,
66
- * NO resolved secret values beyond the literals already in metadata) keyed by a
67
- * hash of the current keychain bundle name-set, so the second and later
68
- * `secrets list` within the hold window read metadata without a Touch ID
69
- * prompt. Keyed by the name-set hash so adding/removing/renaming a bundle
70
- * changes the key and misses the cache automatically — no active invalidation.
71
- * The '!' sentinel can never collide with a real bundle name
72
- * (BUNDLE_NAME_PATTERN requires an alphanumeric first char) and is safe as
73
- * spawnSync argv (unlike a NUL byte); `status` hides these entries.
44
+ * Keyed by a hash of the keychain name-set; adding/removing/renaming a bundle
45
+ * changes the key and invalidates passively. The '!' sentinel cannot collide
46
+ * with a real bundle name and is safe as argv.
74
47
  */
75
48
  export const META_CACHE_PREFIX = '!meta:';
76
49
  /** After the store goes empty (all bundles locked or expired) for this long,
@@ -92,21 +65,9 @@ function delay(ms) {
92
65
  return new Promise((resolve) => setTimeout(resolve, ms));
93
66
  }
94
67
  /**
95
- * Timeouts for the three synchronous broker clients, split across the process
96
- * boundary they now straddle.
97
- *
98
- * `SOCKET_*` bound the socket round-trip inside the spawned `__secrets-*`
99
- * child, and carry over unchanged from the inline `node -e` programs these
100
- * replaced. `SYNC_*` bound the parent's `spawnSync` and must additionally cover
101
- * the child's boot, so each is its socket budget plus ~2s of headroom. A parent
102
- * timeout that fired before the child's own timer would report "broker down"
103
- * for a broker that is merely slow, which costs a Touch ID prompt — so the
104
- * parent budget is deliberately the looser of the two.
105
- *
106
- * Boot cost, measured on the bun-compiled binary on an M-series Mac (median of
107
- * 15, against a live broker): ~96ms via the index.ts intercept, ~165ms if the
108
- * intercept is moved below the startup statements, ~44ms for the `node -e` this
109
- * replaces. The intercept is what keeps the regression modest; see index.ts.
68
+ * Timeouts for the synchronous broker clients. Parent `spawnSync` budgets are
69
+ * looser than socket budgets so a slow child boot isn't misreported as "broker
70
+ * down" (which costs a Touch ID prompt).
110
71
  */
111
72
  const SOCKET_GET_TIMEOUT_MS = 2000;
112
73
  const SOCKET_PING_TIMEOUT_MS = 700;
@@ -114,20 +75,14 @@ const SOCKET_LOCK_TIMEOUT_MS = 2000;
114
75
  const SYNC_GET_TIMEOUT_MS = 4000;
115
76
  const SYNC_PING_TIMEOUT_MS = 2500;
116
77
  const SYNC_LOCK_TIMEOUT_MS = 4000;
117
- // The argv tokens live in a leaf module so index.ts can bind the SAME values
118
- // without importing this one. Re-exported here for callers already reaching for
119
- // agent.js. See sync-commands.ts for why a shared binding rather than matching
120
- // literals: the drift it prevents is silent and costs a Touch ID prompt per read.
78
+ // Re-exported from sync-commands.ts so index.ts can share the same argv tokens
79
+ // without importing this module (would create a cycle/startup cost).
121
80
  export { SYNC_GET_CMD, SYNC_PING_CMD, SYNC_LOCK_CMD } from './sync-commands.js';
122
81
  /**
123
- * Decide whether a persistent broker should self-heal onto freshly-installed
124
- * code (exit so launchd relaunches it). Only when the store is EMPTY: exiting
125
- * with bundles still unlocked wipes them from memory, so the next reader falls
126
- * back to a direct keychain read and re-prompts for Touch ID. Deferring the
127
- * restart until the cache is idle (TTL-expired / slept) means an
128
- * in-place `npm i -g` never wipes a hot cache — the new code is adopted at the
129
- * next quiet moment instead. See #435: rapid repeated upgrades wiped a hot
130
- * cache on every bump and produced a recurring Touch ID storm.
82
+ * Decide whether a persistent broker should exit so launchd relaunches it on
83
+ * freshly-installed code. Only when the store is empty: exiting with held
84
+ * bundles would force a re-prompt. Deferring protects against rapid upgrades
85
+ * wiping the hot cache (#435).
131
86
  */
132
87
  export function shouldSelfHealForUpgrade(persistent, storeSize, runningVersion, onDiskVersion) {
133
88
  if (!persistent)
@@ -139,34 +94,16 @@ export function shouldSelfHealForUpgrade(persistent, storeSize, runningVersion,
139
94
  return onDiskVersion !== runningVersion;
140
95
  }
141
96
  /**
142
- * Client-side twin of shouldSelfHealForUpgrade: whether ensureAgentRunning may
143
- * tear down a reachable broker whose running version differs from the client's
144
- * on-disk version. Only while it holds NO real unlocks — tearing down a hot
145
- * broker wipes every held bundle, so the next read of each one re-prompts for
146
- * Touch ID. On a machine where installed versions churn (dev builds stamp a
147
- * fresh 0.0.0-dev.<sha> on every install; an npm copy and a dev copy invoke in
148
- * turn), an unguarded teardown produced a rolling Touch ID storm — the exact
149
- * failure #435 fixed on the server side. A hot, protocol-compatible broker
150
- * keeps serving; its own sweep adopts the new code at the next quiet moment.
97
+ * Client-side twin: may only tear down a version-skewed broker when it holds
98
+ * no real unlocks, otherwise every held bundle re-prompts (#435).
151
99
  */
152
100
  export function shouldTeardownVersionSkewedBroker(realHeldBundles) {
153
101
  return realHeldBundles === 0;
154
102
  }
155
103
  /**
156
- * Whether a version-skewed client may evict the reachable broker at all. The
157
- * held-bundle gate above is necessary but not sufficient: a broker the always-on
158
- * daemon is hosting must NEVER be client-evicted, even when it holds zero
159
- * unlocks. teardownStaleBroker() recognizes only the standalone broker's
160
- * pidPath() O_EXCL claim (the daemon writes ownerPath(), never pidPath()), so
161
- * evicting a daemon-hosted broker unlinks its socket WITHOUT stopping the daemon;
162
- * the daemon then keeps hostedBroker != null and shouldTakeOverBroker() refuses
163
- * to re-host, orphaning its broker until the daemon restarts while every reader
164
- * falls onto cold one-off brokers that re-prompt Touch ID — the storm. Deferring
165
- * is safe: daemon code-version upgrades are handled by postinstall.js restarting
166
- * it, and agentPing() already gated on PROTOCOL_VERSION, so a code-skewed daemon
167
- * broker is still wire-compatible. Only when NO daemon owns the broker (churning
168
- * dev installs with a dead/absent daemon — the case #435's client twin was built
169
- * for) does the zero-held-bundles teardown apply, exactly as before.
104
+ * Whether a client may evict a version-skewed broker. A daemon-hosted broker
105
+ * must never be client-evicted: the daemon owns the socket via ownerPath(), so
106
+ * unlinking it would orphan the broker until daemon restart (#435).
170
107
  */
171
108
  export function shouldClientEvictSkewedBroker(daemonRunning, realHeldBundles) {
172
109
  if (daemonRunning)
@@ -177,11 +114,8 @@ function onDarwin() {
177
114
  return process.platform === 'darwin';
178
115
  }
179
116
  /**
180
- * Build the broker's in-memory store, REHYDRATED from the durable session store
181
- * (session-store.ts) so an unlock survives a daemon restart / agents-cli upgrade.
182
- * Expired sessions are dropped; `--durable` and default entries alike come back
183
- * (SLEEP already pruned the non-durable ones from the keychain while the broker
184
- * was alive). Empty off darwin or when nothing was held.
117
+ * Build the broker's in-memory store from durable sessions so unlocks survive
118
+ * restart/upgrade. Empty off darwin or when nothing was held.
185
119
  */
186
120
  function rehydrateStore(now = Date.now()) {
187
121
  const store = new Map();
@@ -191,9 +125,7 @@ function rehydrateStore(now = Date.now()) {
191
125
  }
192
126
  return store;
193
127
  }
194
- /** Broker runtime dir under the regenerable cache, locked to the user (0700).
195
- * AGENTS_SECRETS_AGENT_DIR overrides the location — a test seam so the suite can
196
- * run a real broker on a temp socket without touching the user's real dir. */
128
+ /** Broker runtime dir (0700). Override with AGENTS_SECRETS_AGENT_DIR for tests. */
197
129
  function agentDir() {
198
130
  const dir = process.env.AGENTS_SECRETS_AGENT_DIR || path.join(getHelpersDir(), 'secrets-agent');
199
131
  fs.mkdirSync(dir, { recursive: true });
@@ -214,32 +146,23 @@ function pidPath() {
214
146
  return path.join(agentDir(), 'agent.pid');
215
147
  }
216
148
  /**
217
- * Path of the per-broker capability token. It lives in the 0700 agent dir and is
218
- * written 0600, so only the same UID that owns the broker can read it — the file
219
- * permission IS the authorization boundary. See isRequestAuthorized.
149
+ * Per-broker capability token path (0600 inside the 0700 agent dir). The file
150
+ * permission is the authorization boundary.
220
151
  */
221
152
  function tokenPath() {
222
153
  return path.join(agentDir(), 'agent.token');
223
154
  }
224
155
  /**
225
- * Path of the CURRENT SOCKET OWNER's pid.
226
- *
227
- * Deliberately NOT `agent.pid`: that file is the standalone service's O_EXCL
228
- * single-instance claim, and a standalone that loses the socket race stays alive
229
- * and quiescent while holding it (so launchd's KeepAlive doesn't restart-loop
230
- * it), ready to take over if the hosted broker stops. Overloading it as the
231
- * socket-ownership record made the standalone see a live holder and exit
232
- * immediately — the exact restart loop that guard exists to prevent. This file
233
- * answers a different question: which pid is serving the socket right now.
156
+ * Path of the current socket owner's pid. NOT `agent.pid`, which is the
157
+ * standalone service's O_EXCL single-instance claim. Separating them prevents a
158
+ * standby standalone from seeing a live holder and exiting in a restart loop.
234
159
  */
235
160
  function ownerPath() {
236
161
  return path.join(agentDir(), 'agent.owner');
237
162
  }
238
163
  /**
239
- * Read the current broker capability token, or null if none is present. Clients
240
- * read it fresh per request and attach it to every non-ping command; a broker
241
- * restart mints a new token, so a stale read simply fails authorization and the
242
- * caller falls back to a direct keychain read (soft, never a hard error).
164
+ * Read the current broker capability token. A restart mints a new token, so a
165
+ * stale read fails soft and the caller falls back to a direct keychain read.
243
166
  */
244
167
  export function readAgentToken() {
245
168
  try {
@@ -250,9 +173,7 @@ export function readAgentToken() {
250
173
  return null;
251
174
  }
252
175
  }
253
- /** Mint + persist a fresh capability token (0600) at socket-bind time. Only the
254
- * process that actually binds the socket calls this, so a losing starter never
255
- * clobbers the live owner's token. Returns the token for in-memory comparison. */
176
+ /** Mint and persist a fresh capability token (0600) at socket-bind time. */
256
177
  function writeAgentToken() {
257
178
  const token = randomBytes(32).toString('hex');
258
179
  const fp = tokenPath();
@@ -264,15 +185,9 @@ function writeAgentToken() {
264
185
  return token;
265
186
  }
266
187
  /**
267
- * Argv for re-invoking THIS cli with a hidden subcommand, so a side-by-side dev
268
- * build spawns its own helpers rather than the registry-installed one. Routed
269
- * through the shared getCliLaunch so it handles both install shapes — a JS entry
270
- * (`node dist/index.js …`) and a Bun standalone binary (run directly). The old
271
- * hand-rolled `[process.execPath, process.argv[1], …]` broke on standalone builds:
272
- * process.argv[1] is the bun virtual entry `/$bunfs/root/agents` (fs.existsSync
273
- * reports it as present), so it was passed as an argv element and the broker died
274
- * with `unknown command '/$bunfs/root/agents'` — the daemon-hosted broker never
275
- * bound its socket and every unlock reported "Could not start the secrets broker".
188
+ * Argv for re-invoking this CLI with a hidden subcommand. Uses getCliLaunch so
189
+ * both JS-entry and Bun-standalone installs work; the old `process.argv[1]`
190
+ * approach broke on standalone builds with a virtual `/$bunfs/root/agents` path.
276
191
  */
277
192
  function cliSpawn(sub) {
278
193
  const { command, args } = getCliLaunch(sub);
@@ -281,19 +196,12 @@ function cliSpawn(sub) {
281
196
  function brokerSpawn() {
282
197
  return cliSpawn(['secrets', '_agent-run']);
283
198
  }
284
- // ─── Legacy standalone launchd service (retired, #416 step 2) ────────────────
285
- // Earlier versions ran the broker as its own launchd user service
286
- // (com.phnx-labs.agents-secrets-agent, shipped in 1.20.20) so a heavily-loaded
287
- // machine couldn't starve an on-demand cold start. That role now belongs to the
288
- // always-on daemon, which hosts the broker socket-first (#416 step 1) — one
289
- // supervised backbone instead of a second service. The functions below no
290
- // longer INSTALL the standalone service; they only DETECT and RETIRE a plist
291
- // left by an older version so the daemon can take over the socket. The upgrade
292
- // migration (postinstall) and `ensureAgentRunning` both drive the retire path.
199
+ // ─── Legacy standalone launchd service (retired, #416) ───────────────────────
200
+ // The broker is now hosted by the always-on daemon. The functions below only
201
+ // detect and retire a plist left by older versions so the daemon owns the socket.
293
202
  const SERVICE_LABEL = 'com.phnx-labs.agents-secrets-agent';
294
- // LaunchAgents dir is relocatable for tests via AGENTS_SECRETS_LAUNCHAGENTS_DIR.
295
- // A relocated dir is NOT launchd-managed (launchd only bootstraps plists from
296
- // the real ~/Library/LaunchAgents), so retirement there is a pure file removal.
203
+ // LaunchAgents dir. A relocated test dir is not launchd-managed, so retirement
204
+ // there is pure file removal.
297
205
  function launchAgentsDir() {
298
206
  return process.env.AGENTS_SECRETS_LAUNCHAGENTS_DIR || path.join(os.homedir(), 'Library', 'LaunchAgents');
299
207
  }
@@ -305,11 +213,8 @@ export function secretsAgentServiceInstalled() {
305
213
  return onDarwin() && fs.existsSync(servicePlistPath());
306
214
  }
307
215
  /**
308
- * Retire the legacy standalone secrets-agent launchd service: bootout the job
309
- * (falling back to the legacy `unload`) and remove its plist so the always-on
310
- * daemon owns the broker socket. Idempotent and best-effort — a no-op when no
311
- * legacy plist is present. Does NOT wipe held bundles: the booted-out process's
312
- * memory is gone anyway, and the daemon-hosted broker starts fresh.
216
+ * Retire the legacy standalone launchd service so the daemon owns the socket.
217
+ * Idempotent; does not wipe held bundles.
313
218
  */
314
219
  export function retireLegacySecretsAgentService() {
315
220
  if (!onDarwin() || !secretsAgentServiceInstalled())
@@ -342,11 +247,9 @@ export function retireLegacySecretsAgentService() {
342
247
  catch { /* already gone */ }
343
248
  }
344
249
  /**
345
- * Stop the persistent broker for `agents secrets stop`: wipe whatever the broker
346
- * holds (forces Touch ID again on the next read), then retire any legacy
347
- * standalone service. The daemon-hosted broker itself is left running — it is
348
- * the always-on backbone, and stopping it would take down unrelated background
349
- * work (routines, browser IPC, session-sync).
250
+ * Stop the persistent broker for `agents secrets stop`: wipe held bundles, then
251
+ * retire any legacy standalone service. The daemon-hosted broker itself is left
252
+ * running because it backs unrelated background work.
350
253
  */
351
254
  export async function uninstallSecretsAgentService() {
352
255
  if (!onDarwin())
@@ -356,17 +259,9 @@ export async function uninstallSecretsAgentService() {
356
259
  }
357
260
  // ─── Broker server (runs in the detached `secrets _agent-run` process) ───────
358
261
  /**
359
- * Pure request handler over the in-memory store. Extracted so the store
360
- * semantics (lazy expiry on get/status, lock-one vs lock-all, load TTL) are
361
- * unit-testable with a controlled `now`, without a socket or a spawned process.
362
- * Mutates `store` in place; returns the wire response.
363
- */
364
- /**
365
- * Count of real unlocked bundles in the store, excluding the internal
366
- * `secrets list` metadata cache. Used to decide broker "warmth" for self-heal
367
- * and idle-exit: a metadata-only store must read as empty so a disposable list
368
- * cache never blocks an upgrade restart (#435) or an idle one-off broker from
369
- * exiting. Pure + exported for unit testing.
262
+ * Count of real unlocked bundles, excluding the internal `secrets list` metadata
263
+ * cache. A metadata-only store must read as empty so it doesn't block upgrade
264
+ * self-heal or idle-exit (#435). Exported for tests.
370
265
  */
371
266
  export function realBundleCount(store) {
372
267
  let n = 0;
@@ -378,22 +273,17 @@ export function realBundleCount(store) {
378
273
  export function scopedBundleKey(name, harness) {
379
274
  return `${harness}:${name}`;
380
275
  }
276
+ /** Pure request handler over the in-memory store. Exported for tests. */
381
277
  export function handleAgentRequest(store, req, now = Date.now(),
382
- // Eviction tombstones: `lock` records when each bundle was wiped so a load
383
- // whose snapshot predates the eviction (a detached auto-load that raced a
384
- // mutating write) is rejected instead of re-populating the store with a
385
- // pre-mutation copy. Broker-lifetime state, owned by the caller like `store`.
278
+ // Eviction tombstones: reject loads whose snapshot predates the eviction so a
279
+ // detached auto-load can't re-populate stale state.
386
280
  evictedAt = new Map()) {
387
281
  switch (req.cmd) {
388
282
  case 'ping':
389
- // Report the version of the code this broker is RUNNING (getCliVersion
390
- // caches the value from the broker's startup), not the on-disk version.
391
- // A client compares this to its own fresh on-disk read; a mismatch means
392
- // the broker is running pre-upgrade code and should be restarted.
283
+ // Report the running version, not on-disk, so clients detect pre-upgrade brokers.
393
284
  return { ok: true, cmd: 'ping', version: PROTOCOL_VERSION, cliVersion: getCliVersion() };
394
285
  case 'get': {
395
- // Walk own-harness → global so an `--agent` grant wins over a global one and
396
- // an unscoped unlock serves every harness (bundleScopeChain).
286
+ // own-harness → global: scoped grants win, unscoped unlocks serve all.
397
287
  for (const scope of bundleScopeChain(req.harness)) {
398
288
  const key = scopedBundleKey(req.name, scope);
399
289
  const e = store.get(key);
@@ -414,9 +304,7 @@ evictedAt = new Map()) {
414
304
  case 'load': {
415
305
  const evictedTs = evictedAt.get(req.name);
416
306
  if (evictedTs !== undefined && req.snapshotAt !== undefined && req.snapshotAt <= evictedTs) {
417
- // The loader captured its bundle/env before a mutating write evicted
418
- // this name; accepting it would serve (and later re-persist) stale
419
- // state. A load with no snapshotAt (older client) is accepted as before.
307
+ // Reject loads captured before a mutating write evicted this name.
420
308
  return { ok: false, error: `stale load for '${req.name}': snapshot predates an eviction` };
421
309
  }
422
310
  const harness = req.harness || GLOBAL_HARNESS;
@@ -468,28 +356,18 @@ evictedAt = new Map()) {
468
356
  }
469
357
  }
470
358
  /**
471
- * Decide whether a `watch-lock` helper line should wipe the in-memory store.
472
- * The helper emits `LOCK` on screen-lock / screensaver and `SLEEP` on system
473
- * sleep. We wipe on SLEEP only: a bare screen-lock is already gated by the login
474
- * password, and with the ~7d hold, re-authing after every lock would defeat the
475
- * point. Logout needs no line — it tears down the launchd session and kills the
476
- * broker outright. Pure + exported so the LOCK-survives / SLEEP-wipes contract
477
- * has direct regression coverage (the inline stdout handler isn't unit-testable).
359
+ * Wipe the in-memory store only on SLEEP, not screen-lock (already gated by the
360
+ * login password). Exported for regression coverage of the LOCK-survives /
361
+ * SLEEP-wipes contract.
478
362
  */
479
363
  export function shouldWipeOnWatchEvent(chunk) {
480
364
  return /\bSLEEP\b/.test(chunk);
481
365
  }
482
366
  /**
483
- * Authorization gate applied to every request BEFORE handleAgentRequest touches
484
- * the store (RUSH-1760). A bare same-UID socket connection is no longer trusted
485
- * to load/get arbitrary bundle env: each command except the liveness `ping` must
486
- * carry the per-broker capability token, which lives in a 0600 file inside the
487
- * 0700 agent dir and so is readable only by the UID that owns the broker.
488
- *
489
- * Fail closed: a missing/empty expected token (no token file) rejects every
490
- * command but ping. `ping` stays unauthenticated — it exposes only the protocol
491
- * and cli version, and clients need it to detect a reachable broker before they
492
- * have any reason to read the token. Pure + exported for direct unit testing.
367
+ * Authorization gate: every request except `ping` must carry the per-broker
368
+ * capability token from the 0600 token file. `ping` stays unauthenticated so
369
+ * clients can probe reachability before reading the token. Fail closed. Exported
370
+ * for tests.
493
371
  */
494
372
  export function isRequestAuthorized(req, expectedToken) {
495
373
  if (req.cmd === 'ping')
@@ -499,11 +377,9 @@ export function isRequestAuthorized(req, expectedToken) {
499
377
  return req.token === expectedToken;
500
378
  }
501
379
  /**
502
- * Build the socket `connection` handler shared by both brokers (standalone and
503
- * daemon-hosted): newline-framed JSON in, one response line out, with the
504
- * authorization gate (isRequestAuthorized) applied before `handle`. `token`
505
- * resolves the currently-expected capability token per request so a token
506
- * rotation on broker restart is picked up without rebuilding the handler.
380
+ * Build the socket `connection` handler shared by standalone and daemon-hosted
381
+ * brokers. Newline-framed JSON in, one response line out, with per-request token
382
+ * lookup so rotation on restart is picked up.
507
383
  */
508
384
  export function makeConnectionHandler(handle, token) {
509
385
  return (conn) => {
@@ -534,19 +410,12 @@ export function makeConnectionHandler(handle, token) {
534
410
  };
535
411
  }
536
412
  /**
537
- * Whether a live process still owns the broker pid file.
538
- *
539
- * A single missed `agentPing` is NOT proof the owner is dead — the broker is
540
- * single-threaded, so a big `get` or the rehydrate at startup can blow the
541
- * 700ms ping budget while the process is perfectly healthy. Treating that as
542
- * death let a starting broker unlink a live socket and rebind, leaving the old
543
- * process alive holding every unlocked bundle in RAM that no client could reach
544
- * any more (observed live: two brokers, one socket path, two kernel sockets).
545
- * The pid file is the second, independent liveness signal that makes the reclaim
413
+ * Whether a live process still owns the broker pid file. A single missed ping
414
+ * isn't proof of death: the single-threaded broker can blow the ping budget
415
+ * while healthy. The pid file is the second liveness signal that makes reclaim
546
416
  * safe.
547
417
  */
548
- /** Drop our ownership record, but only if it is still ours — never clobber a
549
- * successor that already claimed the socket. */
418
+ /** Drop our ownership record only if it is still ours. */
550
419
  export function releaseBrokerPid() {
551
420
  try {
552
421
  if (parseInt(fs.readFileSync(ownerPath(), 'utf-8').trim(), 10) === process.pid) {
@@ -565,13 +434,8 @@ export function brokerPidAlive() {
565
434
  }
566
435
  }
567
436
  /**
568
- * Bind the shared broker socket without stealing it from another live owner.
569
- * Both the standalone service and daemon-hosted broker use this single path so
570
- * either startup order is safe: a reachable owner wins, while an unreachable
571
- * stale socket is reclaimed once.
572
- *
573
- * "Unreachable" is established with retries plus a pid-file liveness check, never
574
- * a single ping — see {@link brokerPidAlive}.
437
+ * Bind the shared broker socket without stealing it from a live owner. Uses
438
+ * retries plus pid-file liveness, not a single ping, to decide "unreachable".
575
439
  */
576
440
  async function bindBrokerSocket(sock, onConnection) {
577
441
  const listenOnce = () => new Promise((resolve, reject) => {
@@ -588,20 +452,11 @@ async function bindBrokerSocket(sock, onConnection) {
588
452
  fs.chmodSync(sock, 0o600);
589
453
  }
590
454
  catch { /* dir 0700 already gates it */ }
591
- // Mint the capability token here — the one moment this process is the
592
- // confirmed socket owner — so a losing starter never clobbers the live
593
- // owner's token (RUSH-1760).
455
+ // Mint the token and record ownership only once we are the confirmed owner.
594
456
  try {
595
457
  writeAgentToken();
596
458
  }
597
459
  catch { /* dir 0700 gates the socket regardless */ }
598
- // Record ownership at the same moment, for the same reason. The
599
- // daemon-hosted broker deliberately skips the O_EXCL pid-file GUARD (the
600
- // daemon owns its instance), but without an ownership RECORD
601
- // brokerPidAlive() has nothing to read — so a later starter saw an
602
- // ownerless socket and reclaimed the live hosted broker anyway, which is
603
- // the primary configuration and the one that actually orphaned here.
604
- // Writing it from the confirmed owner covers both broker flavours.
605
460
  try {
606
461
  fs.writeFileSync(ownerPath(), String(process.pid));
607
462
  }
@@ -612,17 +467,14 @@ async function bindBrokerSocket(sock, onConnection) {
612
467
  let bound = await listenOnce();
613
468
  if (bound !== 'inuse')
614
469
  return bound;
615
- // Give a busy owner several chances before concluding it is gone. One slow
616
- // ping used to be enough to evict a healthy broker and orphan its RAM.
470
+ // Give a busy owner several chances before concluding it is gone.
617
471
  for (let attempt = 0; attempt < BIND_PROBE_ATTEMPTS; attempt++) {
618
472
  if ((await agentPing()).reachable)
619
473
  return null;
620
474
  if (attempt < BIND_PROBE_ATTEMPTS - 1)
621
475
  await delay(BIND_PROBE_INTERVAL_MS);
622
476
  }
623
- // Silent but alive is still alive: refuse to steal the socket from a process
624
- // that still owns the pid file, and let the caller surface the wedge instead
625
- // of quietly starting a second broker on the same path.
477
+ // Refuse to steal from a process that still owns the pid file.
626
478
  if (brokerPidAlive()) {
627
479
  throw new Error(`Secrets broker socket is held by a live process that is not answering: ${sock}. ` +
628
480
  `Run 'agents secrets lock --all' then retry, or stop the stuck broker.`);
@@ -639,19 +491,16 @@ async function bindBrokerSocket(sock, onConnection) {
639
491
  throw new Error(`Secrets broker socket is in use but unreachable: ${sock}`);
640
492
  }
641
493
  /**
642
- * Run the broker in the foreground. Spawned detached by ensureAgentRunning via
643
- * `agents secrets _agent-run`. Holds the store in memory, serves the socket,
644
- * sweeps expired entries, wipes on sleep, and self-exits when idle.
494
+ * Run the standalone broker in the foreground. Spawned by ensureAgentRunning via
495
+ * `agents secrets _agent-run`. Serves the socket, sweeps expired entries, wipes
496
+ * on sleep, and self-exits when idle.
645
497
  */
646
498
  export async function runSecretsAgent(opts = {}) {
647
499
  if (!onDarwin())
648
500
  return null; // nothing to broker without biometry prompts
649
- // When launchd keeps us alive as a persistent service, never idle-exit:
650
- // exiting would just make launchd cold-start us again, reintroducing the
651
- // startup-under-load fragility the service exists to avoid.
501
+ // Persistent launchd service must never idle-exit; launchd would cold-start again.
652
502
  const persistent = opts.service === true;
653
- // Single-instance guard: O_EXCL pid file. If a live broker already holds it,
654
- // exit quietly — the existing one keeps serving.
503
+ // O_EXCL single-instance guard.
655
504
  const pidFile = pidPath();
656
505
  try {
657
506
  const fd = fs.openSync(pidFile, fs.constants.O_CREAT | fs.constants.O_EXCL | fs.constants.O_WRONLY);
@@ -675,8 +524,7 @@ export async function runSecretsAgent(opts = {}) {
675
524
  }
676
525
  }
677
526
  const store = rehydrateStore();
678
- // emptySince tracks the last moment the store held something; the sweep exits
679
- // the process once it's been empty for IDLE_EXIT_MS so no idle broker lingers.
527
+ // Track when the store last held something so idle brokers exit.
680
528
  let emptySince = Date.now();
681
529
  const sock = socketPath();
682
530
  const releasePid = () => {
@@ -687,9 +535,8 @@ export async function runSecretsAgent(opts = {}) {
687
535
  }
688
536
  catch { /* gone or no longer ours */ }
689
537
  };
690
- // Register lifecycle handlers before socket arbitration. A persistent
691
- // launchd service may spend its whole lifetime as the standby loser, and a
692
- // kickstart/bootout during that wait must still release its pid-file lease.
538
+ // Register lifecycle handlers before socket arbitration so a standby service
539
+ // still releases its pid-file lease on kickstart/bootout.
693
540
  let standbyTimer = null;
694
541
  let cleanupActive = null;
695
542
  let shuttingDown = false;
@@ -715,9 +562,7 @@ export async function runSecretsAgent(opts = {}) {
715
562
  };
716
563
  process.on('SIGTERM', onSigterm);
717
564
  process.on('SIGINT', onSigint);
718
- // Capture the version of the code we're running so the sweep can detect when
719
- // an in-place upgrade has landed and self-heal onto it. getCliVersion caches
720
- // this value for the process lifetime; getCliVersionFresh re-reads on disk.
565
+ // Capture the running version so the sweep can self-heal onto upgraded code.
721
566
  const runningVersion = getCliVersion();
722
567
  // "Warmth" for self-heal / idle-exit counts only real unlocked bundles, NOT
723
568
  // the internal `secrets list` metadata cache (#524). Otherwise a 7d-TTL list
@@ -737,12 +582,10 @@ export async function runSecretsAgent(opts = {}) {
737
582
  }
738
583
  }
739
584
  const live = realBundleCount(store);
740
- // Self-heal onto a newer in-place install — but ONLY while no real unlocks
741
- // are held, so we never wipe live unlocks and force a re-prompt (#435). A
742
- // metadata-only store still self-heals (the list cache is disposable).
585
+ // Self-heal only while no real unlocks are held (#435).
743
586
  if (live === 0 &&
744
587
  shouldSelfHealForUpgrade(persistent, live, runningVersion, getCliVersionFresh())) {
745
- shutdown(0); // KeepAlive relaunches on the new code
588
+ shutdown(0);
746
589
  return;
747
590
  }
748
591
  if (live === 0) {
@@ -772,10 +615,7 @@ export async function runSecretsAgent(opts = {}) {
772
615
  throw err;
773
616
  }
774
617
  if (!server && persistent) {
775
- // launchd KeepAlive would immediately relaunch a persistent loser if it
776
- // returned here. Stay quiescent instead, then claim the socket if the
777
- // daemon-hosted owner goes away. The pid file keeps launchd/manual starts
778
- // from creating additional waiters while this process is standing by.
618
+ // Stay quiescent rather than returning, or launchd KeepAlive would restart-loop.
779
619
  do {
780
620
  await new Promise((resolve) => {
781
621
  standbyTimer = setTimeout(() => {
@@ -793,9 +633,7 @@ export async function runSecretsAgent(opts = {}) {
793
633
  }
794
634
  let watcher = null;
795
635
  let sweepTimer = null;
796
- // Sync cleanup for SIGTERM/SIGINT → process.exit: we cannot await the
797
- // server's 'close' event before exit, so fire-and-forget close + unlink.
798
- // The public close() path below awaits with a bounded timeout (RUSH-2421).
636
+ // Sync cleanup for signal-driven exit: can't await 'close', so fire-and-forget.
799
637
  cleanupActive = () => {
800
638
  store.clear();
801
639
  if (sweepTimer)
@@ -816,14 +654,8 @@ export async function runSecretsAgent(opts = {}) {
816
654
  releasePid();
817
655
  };
818
656
  sweepTimer = setInterval(sweep, SWEEP_INTERVAL_MS);
819
- // Auto-lock on sleep. The signed helper emits LOCK / SLEEP lines; we wipe
820
- // everything on SLEEP (and, implicitly, logout — that tears down the launchd
821
- // session and kills this in-memory broker). A bare screen-lock is deliberately
822
- // NOT a wipe: with the ~7d hold, re-prompting after every lock would defeat the
823
- // point, and a locked screen is already gated by the login password. If the
824
- // installed helper predates watch-lock (exits non-zero immediately), we fall
825
- // back to TTL-only and log nothing — the unlock already warned when
826
- // lock_on_sleep couldn't be armed.
657
+ // Auto-lock on sleep. Wipe on SLEEP; screen-lock alone survives. If the helper
658
+ // predates watch-lock, fall back to TTL-only.
827
659
  try {
828
660
  watcher = spawn(getKeychainHelperPath(), ['watch-lock'], { stdio: ['ignore', 'pipe', 'ignore'] });
829
661
  watcher.stdout?.setEncoding('utf-8');
@@ -831,8 +663,7 @@ export async function runSecretsAgent(opts = {}) {
831
663
  if (shouldWipeOnWatchEvent(chunk)) {
832
664
  store.clear();
833
665
  emptySince = Date.now();
834
- // Split default: delete non-`--durable` durable sessions too, so a default
835
- // unlock re-locks on sleep; `--durable` ones survive (session-store.ts).
666
+ // Default (non-durable) sessions re-lock on sleep; durable ones survive.
836
667
  pruneSessionsOnSleep();
837
668
  }
838
669
  });
@@ -865,38 +696,23 @@ export async function runSecretsAgent(opts = {}) {
865
696
  };
866
697
  }
867
698
  /**
868
- * Host the secrets broker inside the always-on daemon (#416).
869
- *
870
- * Serves the SAME socket and wire protocol as the standalone `runSecretsAgent`
871
- * — so every existing client (`agentGetSync`, `agentPing`, `agentAutoLoadSync`)
872
- * keeps working through the versioned protocol — but it is daemon-safe:
873
- *
874
- * - no pid-file single-instance guard (the daemon owns the instance);
875
- * - no `process.exit`, no SIGTERM/SIGINT handlers, no self-heal/idle-exit
876
- * (those would kill the daemon — the daemon is the always-on backbone and
877
- * manages its own version/lifecycle). The sweep only TTL-evicts.
878
- *
879
- * The caller (`runDaemon`) normally invokes this only when no broker answers
880
- * its initial ping. Binding still arbitrates ownership through the same shared
881
- * path as the standalone service: a live owner wins, while only an unreachable
882
- * stale socket is reclaimed. Returns a handle the daemon closes on shutdown,
883
- * or null off-darwin (nothing to broker without biometry).
699
+ * Host the secrets broker inside the always-on daemon (#416). Serves the same
700
+ * socket/protocol as the standalone broker, but daemon-safe: no pid-file guard,
701
+ * no process.exit/SIG handlers, no self-heal/idle-exit (the daemon owns the
702
+ * lifecycle). Returns null off-darwin.
884
703
  */
885
704
  export async function startHostedBroker() {
886
705
  if (!onDarwin())
887
- return null;
706
+ return null; // nothing to broker without biometry prompts
888
707
  const store = rehydrateStore();
889
- const sock = socketPath(); // agentDir() creates the 0700 dir as a side effect
708
+ const sock = socketPath();
890
709
  const evictedAt = new Map();
891
710
  const handle = (req) => handleAgentRequest(store, req, Date.now(), evictedAt);
892
711
  const onConn = makeConnectionHandler(handle, readAgentToken);
893
712
  const server = await bindBrokerSocket(sock, onConn);
894
713
  if (!server)
895
714
  return null;
896
- // TTL eviction ONLY. Unlike the standalone broker's sweep, there is no
897
- // self-heal-exit or idle-exit here — the daemon is always-on and owns the
898
- // upgrade/lifecycle path; a broker that called process.exit() would take the
899
- // whole daemon down with it.
715
+ // TTL eviction only. The daemon is always-on and owns its own lifecycle.
900
716
  const sweepTimer = setInterval(() => {
901
717
  const now = Date.now();
902
718
  for (const [name, e] of store) {
@@ -909,8 +725,7 @@ export async function startHostedBroker() {
909
725
  }
910
726
  }
911
727
  }, SWEEP_INTERVAL_MS);
912
- // Auto-lock on sleep, same as the standalone broker: the signed helper emits
913
- // LOCK/SLEEP lines; wipe the in-memory store on a wipe-worthy event.
728
+ // Auto-lock on sleep, same as the standalone broker.
914
729
  let watcher = null;
915
730
  try {
916
731
  watcher = spawn(getKeychainHelperPath(), ['watch-lock'], { stdio: ['ignore', 'pipe', 'ignore'] });
@@ -918,7 +733,7 @@ export async function startHostedBroker() {
918
733
  watcher.stdout?.on('data', (chunk) => {
919
734
  if (shouldWipeOnWatchEvent(chunk)) {
920
735
  store.clear();
921
- pruneSessionsOnSleep(); // split default — see runSecretsAgent's handler
736
+ pruneSessionsOnSleep();
922
737
  }
923
738
  });
924
739
  watcher.on('error', () => { watcher = null; });
@@ -927,10 +742,6 @@ export async function startHostedBroker() {
927
742
  watcher = null;
928
743
  }
929
744
  return {
930
- // RUSH-2421: await net.Server's 'close' (bounded) so a caller relying on
931
- // close() cannot proceed past a socket that is not actually released yet.
932
- // The daemon may fire-and-forget this promise; tests and any awaiter get
933
- // the real release boundary.
934
745
  async close() {
935
746
  store.clear();
936
747
  clearInterval(sweepTimer);
@@ -950,10 +761,8 @@ export async function startHostedBroker() {
950
761
  /** How long to wait for net.Server.close()'s 'close' event before giving up. */
951
762
  const SERVER_CLOSE_TIMEOUT_MS = 2_000;
952
763
  /**
953
- * Call Node's net.Server.close() and wait for the 'close' event (or a bounded
954
- * timeout). Without this, close() returned while the listen socket could still
955
- * be held — a successor bind could race EADDRINUSE against a half-closed server
956
- * (RUSH-2421). Pure side-effect helper; never throws.
764
+ * Wait for net.Server.close()'s 'close' event (or a bounded timeout) so a
765
+ * successor bind doesn't race a half-closed socket (RUSH-2421).
957
766
  */
958
767
  export function closeServerBounded(server, timeoutMs = SERVER_CLOSE_TIMEOUT_MS) {
959
768
  return new Promise((resolve) => {
@@ -976,11 +785,9 @@ export function closeServerBounded(server, timeoutMs = SERVER_CLOSE_TIMEOUT_MS)
976
785
  });
977
786
  }
978
787
  // ─── Client ──────────────────────────────────────────────────────────────────
979
- /** Open the socket, send one request, resolve the one response. Async path —
980
- * used by the unlock/lock/status commands, which already run in async actions. */
788
+ /** Open the socket, send one request, resolve the one response. Async path. */
981
789
  function request(req, timeoutMs = 2000) {
982
- // Attach the capability token to every command except ping (the auth gate;
983
- // RUSH-1760). ping stays tokenless so a client can probe reachability first.
790
+ // Attach the capability token to every command except ping.
984
791
  const authedReq = req.cmd === 'ping' ? req : { ...req, token: readAgentToken() ?? undefined };
985
792
  return new Promise((resolve) => {
986
793
  const conn = net.createConnection(socketPath());
@@ -1015,50 +822,24 @@ function request(req, timeoutMs = 2000) {
1015
822
  });
1016
823
  });
1017
824
  }
1018
- /** True if a broker socket exists at all. Cheap; gates the sync read so the
1019
- * never-unlocked path stays a single stat. */
825
+ /** Cheap socket-existence check so the never-unlocked path stays a single stat. */
1020
826
  export function agentSocketExists() {
1021
827
  return onDarwin() && fs.existsSync(socketPath());
1022
828
  }
1023
829
  /**
1024
- * Spawn one of the `__secrets-*` sync clients and return its result.
1025
- *
1026
- * These three call sites used to hand-roll `spawnSync(process.execPath, ['-e',
1027
- * <inline node program>, …])`. That is correct only for a JS install, where
1028
- * `process.execPath` is `node`. Since 1.20.53 the shipped macOS `agents` is a
1029
- * **bun-compiled Mach-O**, where `process.execPath` is the CLI binary itself —
1030
- * so the spawn became `agents -e <program> …`, which commander rejects with
1031
- * `error: unknown option '-e'` and a non-zero exit. Every sync client then took
1032
- * its own failure path (`agentGetSync` → null, `agentReachableSync` → false,
1033
- * `agentEvictSync` → no-op), which reads as "broker down" and falls through to
1034
- * a real keychain read. Net effect on the standalone binary: the broker cache
1035
- * was never hit, so the `daily` policy's one-prompt-per-7d never applied and
1036
- * every bundle read re-popped Touch ID.
1037
- *
1038
- * Same defect class as the broker-launch bug fixed in 1.20.56 (see cliSpawn's
1039
- * doc comment); these three sites were simply never converted. Routing them
1040
- * through `getCliLaunch` fixes both install shapes at once and keeps a single
1041
- * code path.
1042
- *
1043
- * The subcommands are top-level `__secrets-*` tokens intercepted in index.ts
1044
- * BEFORE commander and before the CLI's startup work, exactly like
1045
- * `__daemon-run` and `__vault-age-helper`. That is load-bearing, not cosmetic:
1046
- * a normal command path runs `checkForUpdates()` and `spawnDetachedSync()` on
1047
- * every invocation (index.ts), so registering these as ordinary hidden
1048
- * subcommands would fire an update check and spawn a detached background sync
1049
- * on every cache hit — turning the hot read path into a process fork storm.
830
+ * Spawn one of the `__secrets-*` sync clients via `getCliLaunch`. The old
831
+ * `process.execPath -e` pattern broke on the bun-compiled Mach-O binary
832
+ * (1.20.53); routing through getCliLaunch fixes both install shapes. The
833
+ * subcommands are intercepted in index.ts before commander/startup so a cache
834
+ * hit doesn't fork detached syncs or run update checks.
1050
835
  */
1051
836
  export function syncClientLaunch(sub, agentsBin) {
1052
837
  return agentsBin ? getCliLaunch(sub, agentsBin) : getCliLaunch(sub);
1053
838
  }
1054
839
  function syncClient(sub, timeout) {
1055
- // Soft on every failure: bundles.ts's fast path promises "any failure falls
1056
- // through to the real keychain read below". spawnSync reports most failures
1057
- // via r.error rather than throwing, but getCliLaunch/getAgentsBinPath DO
1058
- // throw on a broken install — a bunfs entry whose execPath is gone, or a
1059
- // browser/computer shim with no sibling index.js. Without this catch those
1060
- // turn a graceful fallback into a crash, and only on already-degraded
1061
- // installs, which is the worst time for it.
840
+ // Soft on every failure: the fast path must fall through to the real keychain
841
+ // read. getCliLaunch can throw on broken installs; crashing there turns a
842
+ // graceful fallback into a failure at the worst time.
1062
843
  try {
1063
844
  const { command, args } = syncClientLaunch(sub);
1064
845
  return spawnSync(command, args, { encoding: 'utf-8', timeout });
@@ -1068,9 +849,8 @@ function syncClient(sub, timeout) {
1068
849
  }
1069
850
  }
1070
851
  /**
1071
- * Synchronous read for the hot path. Returns the cached resolved bundle, or
1072
- * null if the agent isn't running / doesn't hold this bundle / anything fails
1073
- * (soft — caller falls through to the real keychain). macOS only.
852
+ * Synchronous read for the hot path. Returns null on any failure so the caller
853
+ * falls through to the real keychain. macOS only.
1074
854
  */
1075
855
  export function agentGetSync(name, harness = GLOBAL_HARNESS) {
1076
856
  if (!isSecretsBrokerEnabled())
@@ -1091,17 +871,9 @@ export function agentGetSync(name, harness = GLOBAL_HARNESS) {
1091
871
  }
1092
872
  }
1093
873
  /**
1094
- * Last non-empty line of a child's stdout — the payload line.
1095
- *
1096
- * The inline `node -e` client this replaced was a bare node process that could
1097
- * only ever emit its own JSON. The replacement boots the real CLI, so anything
1098
- * the CLI prints on the way up shares that stream. Today the known chatter (the
1099
- * `~/.agents/ is N commits behind` notice) correctly goes to stderr, but a
1100
- * single future stdout write anywhere in startup would make `JSON.parse` throw
1101
- * and turn every cache hit back into a silent miss — i.e. quietly reintroduce
1102
- * the Touch-ID-on-every-read bug this fix closes, with no failing test to catch
1103
- * it. Anchoring on the last line makes the payload the terminator of the
1104
- * stream rather than the whole of it, so preceding chatter is inert.
874
+ * Last non-empty line of a child's stdout — the payload line. Anchors on the
875
+ * terminator so any future CLI startup chatter on stdout doesn't break JSON.parse
876
+ * and silently turn cache hits into misses.
1105
877
  */
1106
878
  export function lastLine(stdout) {
1107
879
  const lines = stdout.split('\n');
@@ -1113,12 +885,9 @@ export function lastLine(stdout) {
1113
885
  return '';
1114
886
  }
1115
887
  /**
1116
- * Synchronous liveness check: is a broker actually LISTENING and answering (not
1117
- * just a lingering socket file)? Used to decide whether the auto-cache may take
1118
- * the synchronous warm path — a dead broker whose socket outlived it (crash,
1119
- * OOM, version-skew teardown) must NOT drag a foreground read through the
1120
- * worker's 20s cold-start budget. A stale socket refuses instantly, so this is
1121
- * fast in both the alive and dead cases. macOS only.
888
+ * Synchronous liveness check: is a broker actually listening? Gates the
889
+ * synchronous warm path so a stale socket doesn't drag a foreground read
890
+ * through a cold-start. macOS only.
1122
891
  */
1123
892
  export function agentReachableSync() {
1124
893
  if (!onDarwin())
@@ -1129,12 +898,8 @@ export function agentReachableSync() {
1129
898
  return r !== null && r.status === 0 && !r.error;
1130
899
  }
1131
900
  /**
1132
- * Synchronously evict one bundle from the broker. Called after a mutating
1133
- * keychain write (add / rotate / remove / rename / delete) so the broker never
1134
- * keeps serving the pre-write snapshot for up to the ~7d hold — the next read
1135
- * re-resolves from the keychain (one prompt) and re-caches fresh values.
1136
- * Best-effort: no broker, no socket, or any failure is a silent no-op.
1137
- * macOS only.
901
+ * Synchronously evict one bundle after a mutating write so the broker doesn't
902
+ * serve a stale snapshot for the hold window. Best-effort silent no-op. macOS only.
1138
903
  */
1139
904
  export function agentEvictSync(name) {
1140
905
  if (!onDarwin())
@@ -1145,51 +910,28 @@ export function agentEvictSync(name) {
1145
910
  syncClient([SYNC_LOCK_CMD, name], SYNC_LOCK_TIMEOUT_MS);
1146
911
  }
1147
912
  // ─── Top-level `__secrets-*` sync-client entrypoints ────────────────────────
1148
- // The bodies behind the three spawns above. Each does one socket round-trip and
1149
- // signals the parent through its exit code, mirroring the inline `node -e`
1150
- // programs these replaced: 0 = hit/alive, 3 = miss/down. Lock deviates twice:
1151
- // it reports 0 even when no broker answers, and 3 for an empty name, which it
1152
- // refuses rather than forwarding as a lock-ALL (see runAgentLockSync).
1153
- // Dispatched from index.ts BEFORE commander and before the startup statements,
1154
- // so a cache hit never runs checkForUpdates() or forks a detached sync.
1155
- /** Body of `__secrets-get <name>`. Prints `{bundle, env}` as JSON on a
1156
- * cache hit. Exit 0 = hit, 3 = miss or broker down. */
913
+ // Dispatched from index.ts before commander/startup so cache hits stay cheap.
914
+ /** Body of `__secrets-get <name>`. Exit 0 = hit, 3 = miss/down. */
1157
915
  export async function runAgentGetSync(name, harness = GLOBAL_HARNESS) {
1158
916
  const r = await request({ cmd: 'get', name, harness }, SOCKET_GET_TIMEOUT_MS);
1159
917
  if (r?.ok === true && r.cmd === 'get' && r.hit) {
1160
- // Trailing newline: the parent reads the LAST line (see lastLine), so the
1161
- // payload must terminate the stream even if anything ever precedes it.
1162
- //
1163
- // Awaited on the write callback, not fire-and-forget: stdout is a pipe here
1164
- // (the parent captures it), pipe writes are asynchronous, and the caller
1165
- // exits the process the moment this resolves. An unflushed write would be
1166
- // truncated on exit — the parent would see a partial or empty payload, treat
1167
- // it as a miss, and fall through to a keychain read, silently costing the
1168
- // Touch ID prompt this whole path exists to avoid.
918
+ // Terminate with a newline and await the write callback; an unflushed pipe
919
+ // write would be truncated on exit and cost a Touch ID prompt.
1169
920
  const payload = JSON.stringify({ bundle: r.bundle, env: r.env, lease: r.lease }) + '\n';
1170
921
  await new Promise((resolve) => { process.stdout.write(payload, () => resolve()); });
1171
922
  return 0;
1172
923
  }
1173
924
  return 3;
1174
925
  }
1175
- /** Body of `__secrets-ping`. Exit 0 = a broker is listening and speaking
1176
- * our protocol, 3 = nothing there. Deliberately does NOT gate on
1177
- * PROTOCOL_VERSION: this only decides whether the auto-cache may take the
1178
- * synchronous warm path, and a version-skewed broker still answers reads. That
1179
- * matches the inline program this replaced. */
926
+ /** Body of `__secrets-ping`. Exit 0 = listening broker, 3 = nothing there.
927
+ * Does not gate on PROTOCOL_VERSION so version-skewed brokers still answer reads. */
1180
928
  export async function runAgentPingSync() {
1181
929
  const r = await request({ cmd: 'ping' }, SOCKET_PING_TIMEOUT_MS);
1182
930
  return r?.ok === true && r.cmd === 'ping' ? 0 : 3;
1183
931
  }
1184
932
  /** Body of `__secrets-lock <name>`. Best-effort evict; exit 0 even when no
1185
- * broker answers, so a missing broker never fails the mutating write that
1186
- * triggered it (agentEvictSync discards this either way).
1187
- *
1188
- * An empty name is refused rather than forwarded: the broker treats a nameless
1189
- * lock as lock-ALL (handleAgentRequest), so a bare `__secrets-lock` would wipe
1190
- * every held bundle and re-prompt Touch ID for each. That was unreachable while
1191
- * this was an inline `node -e` string; as a top-level argv token it is one typo
1192
- * away, and agentEvictSync only ever locks by name. */
933
+ * broker answers. An empty name is refused to avoid accidentally locking ALL
934
+ * bundles. */
1193
935
  export async function runAgentLockSync(name) {
1194
936
  if (!name)
1195
937
  return 3;
@@ -1199,11 +941,9 @@ export async function runAgentLockSync(name) {
1199
941
  // Key inside the cached entry's env that holds the JSON metadata snapshot.
1200
942
  const META_SNAPSHOT_KEY = '__snapshot__';
1201
943
  /**
1202
- * Read the cached `secrets list` metadata snapshot for the given keychain
1203
- * name-set hash, or null on miss / no broker / off-darwin. Reuses the value
1204
- * fast-path socket read (agentGetSync) — no prompt, no wire change. The hash is
1205
- * the cache key: a changed name-set (bundle added/removed/renamed) yields a
1206
- * different key and therefore a clean miss, so the stale set is never served.
944
+ * Read the cached `secrets list` metadata snapshot for a keychain name-set hash.
945
+ * Reuses agentGetSync. The name-set hash is the cache key, so add/remove/rename
946
+ * yields a clean miss.
1207
947
  */
1208
948
  export function agentGetMetaSync(nameSetHash) {
1209
949
  if (!onDarwin())
@@ -1221,11 +961,9 @@ export function agentGetMetaSync(nameSetHash) {
1221
961
  }
1222
962
  }
1223
963
  /**
1224
- * Fire-and-forget: populate the broker with a freshly-read metadata snapshot so
1225
- * the next `secrets list` within the hold window renders without a prompt.
1226
- * Stored as an ordinary entry (placeholder bundle, snapshot in env) under the
1227
- * reserved META_CACHE_PREFIX key; the snapshot travels over stdin to the
1228
- * detached worker (never argv/disk), same as value caching. macOS only.
964
+ * Fire-and-forget: populate the broker with a metadata snapshot so the next
965
+ * `secrets list` within the hold window is prompt-free. Stored under a reserved
966
+ * META_CACHE_PREFIX key; snapshot travels over stdin. macOS only.
1229
967
  */
1230
968
  export function agentAutoLoadMetaSync(nameSetHash, bundles, ttlMs) {
1231
969
  if (!onDarwin())