@phnx-labs/agents-cli 1.20.64 → 1.20.66

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 (188) hide show
  1. package/CHANGELOG.md +51 -3
  2. package/README.md +156 -3
  3. package/dist/bin/agents +0 -0
  4. package/dist/commands/apply.d.ts +12 -0
  5. package/dist/commands/apply.js +274 -0
  6. package/dist/commands/browser.js +2 -2
  7. package/dist/commands/cloud.js +32 -2
  8. package/dist/commands/doctor.js +4 -1
  9. package/dist/commands/exec.d.ts +48 -0
  10. package/dist/commands/exec.js +159 -49
  11. package/dist/commands/feed.js +25 -11
  12. package/dist/commands/hosts.js +44 -6
  13. package/dist/commands/mcp.js +55 -5
  14. package/dist/commands/monitors.d.ts +12 -0
  15. package/dist/commands/monitors.js +748 -0
  16. package/dist/commands/output.js +2 -2
  17. package/dist/commands/plugins.js +28 -7
  18. package/dist/commands/routines.js +23 -2
  19. package/dist/commands/secrets.d.ts +16 -0
  20. package/dist/commands/secrets.js +215 -64
  21. package/dist/commands/serve.js +31 -0
  22. package/dist/commands/sessions-browser.d.ts +82 -0
  23. package/dist/commands/sessions-browser.js +320 -0
  24. package/dist/commands/sessions-export.js +8 -3
  25. package/dist/commands/sessions.d.ts +17 -0
  26. package/dist/commands/sessions.js +157 -10
  27. package/dist/commands/share.d.ts +2 -0
  28. package/dist/commands/share.js +150 -0
  29. package/dist/commands/ssh.js +54 -3
  30. package/dist/commands/versions.js +7 -3
  31. package/dist/commands/view.d.ts +26 -0
  32. package/dist/commands/view.js +32 -9
  33. package/dist/commands/webhook.js +10 -2
  34. package/dist/index.js +35 -14
  35. package/dist/lib/agents.d.ts +46 -0
  36. package/dist/lib/agents.js +121 -3
  37. package/dist/lib/auto-dispatch-provider.js +7 -2
  38. package/dist/lib/auto-dispatch.d.ts +3 -0
  39. package/dist/lib/auto-dispatch.js +3 -0
  40. package/dist/lib/browser/chrome.js +2 -2
  41. package/dist/lib/cloud/antigravity.js +2 -2
  42. package/dist/lib/cloud/host.d.ts +59 -0
  43. package/dist/lib/cloud/host.js +224 -0
  44. package/dist/lib/cloud/registry.js +4 -0
  45. package/dist/lib/cloud/types.d.ts +6 -4
  46. package/dist/lib/computer-rpc.js +3 -1
  47. package/dist/lib/crabbox/cli.js +5 -1
  48. package/dist/lib/crabbox/runtimes.js +11 -2
  49. package/dist/lib/daemon.d.ts +20 -4
  50. package/dist/lib/daemon.js +62 -19
  51. package/dist/lib/devices/connect.d.ts +18 -1
  52. package/dist/lib/devices/connect.js +10 -2
  53. package/dist/lib/devices/fleet.d.ts +3 -2
  54. package/dist/lib/devices/fleet.js +9 -0
  55. package/dist/lib/devices/known-hosts.d.ts +62 -0
  56. package/dist/lib/devices/known-hosts.js +137 -0
  57. package/dist/lib/devices/registry.d.ts +15 -0
  58. package/dist/lib/devices/registry.js +9 -0
  59. package/dist/lib/exec.d.ts +19 -2
  60. package/dist/lib/exec.js +41 -13
  61. package/dist/lib/fleet/apply.d.ts +63 -0
  62. package/dist/lib/fleet/apply.js +214 -0
  63. package/dist/lib/fleet/auth-sync.d.ts +67 -0
  64. package/dist/lib/fleet/auth-sync.js +142 -0
  65. package/dist/lib/fleet/manifest.d.ts +29 -0
  66. package/dist/lib/fleet/manifest.js +127 -0
  67. package/dist/lib/fleet/types.d.ts +129 -0
  68. package/dist/lib/fleet/types.js +13 -0
  69. package/dist/lib/git.d.ts +27 -0
  70. package/dist/lib/git.js +34 -2
  71. package/dist/lib/hosts/dispatch.d.ts +29 -8
  72. package/dist/lib/hosts/dispatch.js +66 -20
  73. package/dist/lib/hosts/passthrough.js +2 -0
  74. package/dist/lib/hosts/providers/devices.d.ts +27 -0
  75. package/dist/lib/hosts/providers/devices.js +98 -0
  76. package/dist/lib/hosts/registry.d.ts +10 -16
  77. package/dist/lib/hosts/registry.js +17 -50
  78. package/dist/lib/hosts/remote-cmd.d.ts +23 -0
  79. package/dist/lib/hosts/remote-cmd.js +79 -4
  80. package/dist/lib/hosts/run-target.d.ts +84 -0
  81. package/dist/lib/hosts/run-target.js +99 -0
  82. package/dist/lib/hosts/types.d.ts +23 -5
  83. package/dist/lib/hosts/types.js +22 -4
  84. package/dist/lib/linear-autoclose.d.ts +30 -0
  85. package/dist/lib/linear-autoclose.js +22 -0
  86. package/dist/lib/mcp.d.ts +27 -1
  87. package/dist/lib/mcp.js +126 -12
  88. package/dist/lib/monitors/config.d.ts +161 -0
  89. package/dist/lib/monitors/config.js +372 -0
  90. package/dist/lib/monitors/dispatch.d.ts +28 -0
  91. package/dist/lib/monitors/dispatch.js +91 -0
  92. package/dist/lib/monitors/engine.d.ts +61 -0
  93. package/dist/lib/monitors/engine.js +205 -0
  94. package/dist/lib/monitors/sources/command.d.ts +11 -0
  95. package/dist/lib/monitors/sources/command.js +31 -0
  96. package/dist/lib/monitors/sources/device.d.ts +13 -0
  97. package/dist/lib/monitors/sources/device.js +45 -0
  98. package/dist/lib/monitors/sources/file.d.ts +14 -0
  99. package/dist/lib/monitors/sources/file.js +57 -0
  100. package/dist/lib/monitors/sources/http.d.ts +10 -0
  101. package/dist/lib/monitors/sources/http.js +34 -0
  102. package/dist/lib/monitors/sources/index.d.ts +14 -0
  103. package/dist/lib/monitors/sources/index.js +31 -0
  104. package/dist/lib/monitors/sources/poll.d.ts +9 -0
  105. package/dist/lib/monitors/sources/poll.js +9 -0
  106. package/dist/lib/monitors/sources/types.d.ts +18 -0
  107. package/dist/lib/monitors/sources/types.js +9 -0
  108. package/dist/lib/monitors/sources/webhook.d.ts +23 -0
  109. package/dist/lib/monitors/sources/webhook.js +47 -0
  110. package/dist/lib/monitors/sources/ws.d.ts +14 -0
  111. package/dist/lib/monitors/sources/ws.js +45 -0
  112. package/dist/lib/monitors/state.d.ts +69 -0
  113. package/dist/lib/monitors/state.js +144 -0
  114. package/dist/lib/picker.d.ts +53 -0
  115. package/dist/lib/picker.js +214 -1
  116. package/dist/lib/platform/exec.d.ts +16 -0
  117. package/dist/lib/platform/exec.js +17 -0
  118. package/dist/lib/plugins.d.ts +31 -1
  119. package/dist/lib/plugins.js +175 -15
  120. package/dist/lib/redact.d.ts +14 -1
  121. package/dist/lib/redact.js +47 -1
  122. package/dist/lib/remote-agents-json.js +7 -1
  123. package/dist/lib/rotate.d.ts +6 -3
  124. package/dist/lib/rotate.js +0 -1
  125. package/dist/lib/routines.d.ts +16 -0
  126. package/dist/lib/routines.js +19 -0
  127. package/dist/lib/runner.d.ts +1 -0
  128. package/dist/lib/runner.js +102 -9
  129. package/dist/lib/secrets/agent.d.ts +83 -10
  130. package/dist/lib/secrets/agent.js +237 -70
  131. package/dist/lib/secrets/bundles.d.ts +26 -0
  132. package/dist/lib/secrets/bundles.js +59 -8
  133. package/dist/lib/secrets/filestore.d.ts +9 -0
  134. package/dist/lib/secrets/filestore.js +21 -8
  135. package/dist/lib/secrets/index.d.ts +7 -0
  136. package/dist/lib/secrets/index.js +26 -6
  137. package/dist/lib/secrets/mcp.js +4 -2
  138. package/dist/lib/secrets/remote.d.ts +17 -0
  139. package/dist/lib/secrets/remote.js +40 -0
  140. package/dist/lib/self-update.d.ts +20 -0
  141. package/dist/lib/self-update.js +54 -1
  142. package/dist/lib/serve/control.d.ts +95 -0
  143. package/dist/lib/serve/control.js +260 -0
  144. package/dist/lib/serve/server.d.ts +35 -1
  145. package/dist/lib/serve/server.js +106 -76
  146. package/dist/lib/serve/stream.d.ts +43 -0
  147. package/dist/lib/serve/stream.js +116 -0
  148. package/dist/lib/serve/token.d.ts +35 -0
  149. package/dist/lib/serve/token.js +85 -0
  150. package/dist/lib/session/bundle.d.ts +14 -0
  151. package/dist/lib/session/bundle.js +12 -1
  152. package/dist/lib/session/remote-list.js +5 -1
  153. package/dist/lib/session/state.d.ts +7 -25
  154. package/dist/lib/session/state.js +16 -6
  155. package/dist/lib/session/sync/config.js +8 -2
  156. package/dist/lib/session/types.d.ts +30 -0
  157. package/dist/lib/share/capture.d.ts +29 -0
  158. package/dist/lib/share/capture.js +140 -0
  159. package/dist/lib/share/config.d.ts +35 -0
  160. package/dist/lib/share/config.js +100 -0
  161. package/dist/lib/share/og.d.ts +25 -0
  162. package/dist/lib/share/og.js +84 -0
  163. package/dist/lib/share/provision.d.ts +10 -0
  164. package/dist/lib/share/provision.js +91 -0
  165. package/dist/lib/share/publish.d.ts +57 -0
  166. package/dist/lib/share/publish.js +145 -0
  167. package/dist/lib/share/worker-template.d.ts +2 -0
  168. package/dist/lib/share/worker-template.js +82 -0
  169. package/dist/lib/shims.d.ts +13 -0
  170. package/dist/lib/shims.js +42 -2
  171. package/dist/lib/ssh-exec.d.ts +24 -0
  172. package/dist/lib/ssh-exec.js +15 -3
  173. package/dist/lib/ssh-tunnel.d.ts +19 -1
  174. package/dist/lib/ssh-tunnel.js +86 -7
  175. package/dist/lib/startup/command-registry.d.ts +3 -0
  176. package/dist/lib/startup/command-registry.js +6 -0
  177. package/dist/lib/state.d.ts +5 -0
  178. package/dist/lib/state.js +12 -0
  179. package/dist/lib/tmux/session.d.ts +7 -0
  180. package/dist/lib/tmux/session.js +3 -1
  181. package/dist/lib/triggers/webhook.d.ts +18 -0
  182. package/dist/lib/triggers/webhook.js +105 -0
  183. package/dist/lib/types.d.ts +36 -1
  184. package/dist/lib/usage.js +7 -5
  185. package/dist/lib/versions.js +14 -11
  186. package/dist/lib/workflows.d.ts +20 -0
  187. package/dist/lib/workflows.js +24 -0
  188. package/package.json +2 -1
@@ -23,6 +23,7 @@
23
23
  * macOS only: Linux libsecret has no biometry prompt, so there's nothing to
24
24
  * deduplicate — every entry point here no-ops off darwin.
25
25
  */
26
+ import * as net from 'net';
26
27
  import type { SecretsBundle } from './bundles.js';
27
28
  /** Default lifetime of an unlocked bundle when `--ttl` is not given. */
28
29
  export declare const DEFAULT_TTL_MS: number;
@@ -74,6 +75,13 @@ export interface AgentStatusEntry {
74
75
  expiresAt: number;
75
76
  keyCount: number;
76
77
  }
78
+ /**
79
+ * Read the current broker capability token, or null if none is present. Clients
80
+ * read it fresh per request and attach it to every non-ping command; a broker
81
+ * restart mints a new token, so a stale read simply fails authorization and the
82
+ * caller falls back to a direct keychain read (soft, never a hard error).
83
+ */
84
+ export declare function readAgentToken(): string | null;
77
85
  /** True if a legacy standalone-broker launchd plist is still installed. */
78
86
  export declare function secretsAgentServiceInstalled(): boolean;
79
87
  /**
@@ -97,17 +105,21 @@ export type Request = {
97
105
  } | {
98
106
  cmd: 'get';
99
107
  name: string;
108
+ token?: string;
100
109
  } | {
101
110
  cmd: 'load';
102
111
  name: string;
103
112
  bundle: SecretsBundle;
104
113
  env: Record<string, string>;
105
114
  ttlMs: number;
115
+ token?: string;
106
116
  } | {
107
117
  cmd: 'lock';
108
118
  name?: string;
119
+ token?: string;
109
120
  } | {
110
121
  cmd: 'status';
122
+ token?: string;
111
123
  };
112
124
  export type Response = {
113
125
  ok: true;
@@ -164,6 +176,28 @@ export declare function handleAgentRequest(store: Map<string, StoredBundle>, req
164
176
  * has direct regression coverage (the inline stdout handler isn't unit-testable).
165
177
  */
166
178
  export declare function shouldWipeOnWatchEvent(chunk: string): boolean;
179
+ /**
180
+ * Authorization gate applied to every request BEFORE handleAgentRequest touches
181
+ * the store (RUSH-1760). A bare same-UID socket connection is no longer trusted
182
+ * to load/get arbitrary bundle env: each command except the liveness `ping` must
183
+ * carry the per-broker capability token, which lives in a 0600 file inside the
184
+ * 0700 agent dir and so is readable only by the UID that owns the broker.
185
+ *
186
+ * Fail closed: a missing/empty expected token (no token file) rejects every
187
+ * command but ping. `ping` stays unauthenticated — it exposes only the protocol
188
+ * and cli version, and clients need it to detect a reachable broker before they
189
+ * have any reason to read the token. Pure + exported for direct unit testing.
190
+ */
191
+ export declare function isRequestAuthorized(req: Request, expectedToken: string | null): boolean;
192
+ type BrokerConnectionHandler = (conn: net.Socket) => void;
193
+ /**
194
+ * Build the socket `connection` handler shared by both brokers (standalone and
195
+ * daemon-hosted): newline-framed JSON in, one response line out, with the
196
+ * authorization gate (isRequestAuthorized) applied before `handle`. `token`
197
+ * resolves the currently-expected capability token per request so a token
198
+ * rotation on broker restart is picked up without rebuilding the handler.
199
+ */
200
+ export declare function makeConnectionHandler(handle: (req: Request) => Response, token: () => string | null): BrokerConnectionHandler;
167
201
  /**
168
202
  * Run the broker in the foreground. Spawned detached by ensureAgentRunning via
169
203
  * `agents secrets _agent-run`. Holds the store in memory, serves the socket,
@@ -207,6 +241,15 @@ export declare function agentGetSync(name: string): {
207
241
  bundle: SecretsBundle;
208
242
  env: Record<string, string>;
209
243
  } | null;
244
+ /**
245
+ * Synchronous liveness check: is a broker actually LISTENING and answering (not
246
+ * just a lingering socket file)? Used to decide whether the auto-cache may take
247
+ * the synchronous warm path — a dead broker whose socket outlived it (crash,
248
+ * OOM, version-skew teardown) must NOT drag a foreground read through the
249
+ * worker's 20s cold-start budget. A stale socket refuses instantly, so this is
250
+ * fast in both the alive and dead cases. macOS only.
251
+ */
252
+ export declare function agentReachableSync(): boolean;
210
253
  /**
211
254
  * Synchronously evict one bundle from the broker. Called after a mutating
212
255
  * keychain write (add / rotate / remove / rename / delete) so the broker never
@@ -237,25 +280,54 @@ export declare function agentAutoLoadMetaSync(nameSetHash: string, bundles: Secr
237
280
  * per ~7d), so auto-caching is ON by default; opt out with
238
281
  * `secrets.agent.auto: false`. Best-effort; an unreadable meta reads as on. */
239
282
  export declare function secretsAgentAutoEnabled(): boolean;
283
+ /** Minimum / maximum bounds for the configurable hold window. A too-small value
284
+ * would defeat the broker (constant re-prompts); a too-large one pins secrets in
285
+ * memory far longer than intended. */
286
+ export declare const MIN_HOLD_MS: number;
287
+ export declare const MAX_HOLD_MS: number;
288
+ /**
289
+ * How long an unlocked / auto-cached bundle is held before the next read
290
+ * re-prompts. Defaults to DEFAULT_TTL_MS (7d); override with
291
+ * `secrets.agent.holdMs` (milliseconds) in agents.yaml — e.g. 86400000 for a 24h
292
+ * cap. Clamped to [MIN_HOLD_MS, MAX_HOLD_MS] so a typo can neither disable the
293
+ * hold nor pin a secret in memory indefinitely. Best-effort: an unreadable or
294
+ * non-numeric value falls back to the 7d default. Pure except for the meta read.
295
+ */
296
+ export declare function secretsHoldMs(): number;
297
+ /** Pure clamp for a configured `holdMs`: a positive finite number is bounded to
298
+ * [MIN_HOLD_MS, MAX_HOLD_MS]; anything else (absent, 0, negative, NaN, non-number)
299
+ * falls back to the 7d default. Exported for direct unit testing. */
300
+ export declare function clampHoldMs(v: unknown): number;
240
301
  /**
241
302
  * Fire-and-forget: populate the broker with a freshly-resolved bundle so the
242
303
  * NEXT process reads it without a prompt. Used by the auto-cache path after a
243
- * real keychain read of a `daily`-policy bundle. Adds no latency to the caller
244
- * it spawns a detached `secrets _agent-load` worker (passing the resolved env
245
- * over stdin, never argv) and returns immediately.
304
+ * real keychain read of a `daily`-policy bundle, so the NEXT concurrent read is
305
+ * silent. Env travels over stdin, never argv.
306
+ *
307
+ * Reliability (this is what makes `daily` actually "stick"): when a broker is
308
+ * ALREADY listening, warm it SYNCHRONOUSLY with a bounded wait so the bundle is
309
+ * held by the time this process exits. The old detached-only path lost the race
310
+ * under load — a short-lived reader (`agents secrets export`, a release-script
311
+ * loop) exited before the unref'd worker connected, so the cache silently never
312
+ * populated and every read re-prompted despite the `daily` policy. Only when the
313
+ * broker must COLD-START (no socket yet) do we fall back to the detached worker,
314
+ * so a first-ever read never blocks on a multi-second broker boot.
246
315
  *
247
- * The worker reuses the robust `ensureAgentRunning` path (spawn-then-ping with a
248
- * generous budget) rather than a tight inline retry loop: under heavy load the
249
- * broker is itself a cold-starting full CLI and can take several seconds to bind
250
- * the socket, so a short fixed budget would give up before it's ready and the
251
- * cache would silently never populate. Best-effort; never throws. macOS only.
316
+ * The worker reuses the robust `ensureAgentRunning` path (spawn-then-ping) rather
317
+ * than a tight inline retry loop. Best-effort; never throws. macOS only.
252
318
  */
253
319
  export declare function agentAutoLoadSync(name: string, bundle: SecretsBundle, env: Record<string, string>, ttlMs: number): void;
254
320
  /**
255
321
  * Body of the hidden `secrets _agent-load` worker. Reads one `{name, bundle,
256
322
  * env, ttlMs}` payload from stdin, ensures the broker is up (robust, generous
257
- * budget), and loads the bundle into it. Detached from the originating read, so
258
- * its latency is invisible — which is why it can afford a long ensure budget.
323
+ * budget), and loads the bundle into it.
324
+ *
325
+ * Exit code is load-truthful: 0 ONLY when the bundle was actually loaded into a
326
+ * reachable broker; non-zero on any failure (malformed payload, broker couldn't
327
+ * be brought up, or the load transport failed). The synchronous caller
328
+ * (agentAutoLoadSync) relies on this to decide whether to skip the detached
329
+ * fallback — a bare "process exited 0" would otherwise be a false-positive
330
+ * success that silently reintroduces the very re-prompt storm this path fixes.
259
331
  */
260
332
  export declare function runAgentLoadFromStdin(): Promise<void>;
261
333
  /** Store a resolved bundle in the broker. Returns false on transport failure. */
@@ -287,3 +359,4 @@ export declare function agentPing(): Promise<{
287
359
  * gets starved under heavy load, so it's last.
288
360
  */
289
361
  export declare function ensureAgentRunning(timeoutMs?: number): Promise<boolean>;
362
+ export {};
@@ -27,6 +27,7 @@ import * as net from 'net';
27
27
  import * as fs from 'fs';
28
28
  import * as os from 'os';
29
29
  import * as path from 'path';
30
+ import { randomBytes } from 'crypto';
30
31
  import { spawn, spawnSync, execFileSync } from 'child_process';
31
32
  import { getHelpersDir, readMeta } from '../state.js';
32
33
  import { isAlive } from '../platform/process.js';
@@ -109,6 +110,42 @@ function socketPath() {
109
110
  function pidPath() {
110
111
  return path.join(agentDir(), 'agent.pid');
111
112
  }
113
+ /**
114
+ * Path of the per-broker capability token. It lives in the 0700 agent dir and is
115
+ * written 0600, so only the same UID that owns the broker can read it — the file
116
+ * permission IS the authorization boundary. See isRequestAuthorized.
117
+ */
118
+ function tokenPath() {
119
+ return path.join(agentDir(), 'agent.token');
120
+ }
121
+ /**
122
+ * Read the current broker capability token, or null if none is present. Clients
123
+ * read it fresh per request and attach it to every non-ping command; a broker
124
+ * restart mints a new token, so a stale read simply fails authorization and the
125
+ * caller falls back to a direct keychain read (soft, never a hard error).
126
+ */
127
+ export function readAgentToken() {
128
+ try {
129
+ const t = fs.readFileSync(tokenPath(), 'utf-8').trim();
130
+ return t.length > 0 ? t : null;
131
+ }
132
+ catch {
133
+ return null;
134
+ }
135
+ }
136
+ /** Mint + persist a fresh capability token (0600) at socket-bind time. Only the
137
+ * process that actually binds the socket calls this, so a losing starter never
138
+ * clobbers the live owner's token. Returns the token for in-memory comparison. */
139
+ function writeAgentToken() {
140
+ const token = randomBytes(32).toString('hex');
141
+ const fp = tokenPath();
142
+ fs.writeFileSync(fp, token, { mode: 0o600 });
143
+ try {
144
+ fs.chmodSync(fp, 0o600);
145
+ }
146
+ catch { /* dir 0700 already gates it */ }
147
+ return token;
148
+ }
112
149
  /**
113
150
  * Argv for re-invoking THIS cli with a hidden subcommand, so a side-by-side dev
114
151
  * build spawns its own helpers rather than the registry-installed one. We always
@@ -272,6 +309,60 @@ export function handleAgentRequest(store, req, now = Date.now()) {
272
309
  export function shouldWipeOnWatchEvent(chunk) {
273
310
  return /\bSLEEP\b/.test(chunk);
274
311
  }
312
+ /**
313
+ * Authorization gate applied to every request BEFORE handleAgentRequest touches
314
+ * the store (RUSH-1760). A bare same-UID socket connection is no longer trusted
315
+ * to load/get arbitrary bundle env: each command except the liveness `ping` must
316
+ * carry the per-broker capability token, which lives in a 0600 file inside the
317
+ * 0700 agent dir and so is readable only by the UID that owns the broker.
318
+ *
319
+ * Fail closed: a missing/empty expected token (no token file) rejects every
320
+ * command but ping. `ping` stays unauthenticated — it exposes only the protocol
321
+ * and cli version, and clients need it to detect a reachable broker before they
322
+ * have any reason to read the token. Pure + exported for direct unit testing.
323
+ */
324
+ export function isRequestAuthorized(req, expectedToken) {
325
+ if (req.cmd === 'ping')
326
+ return true;
327
+ if (!expectedToken)
328
+ return false;
329
+ return req.token === expectedToken;
330
+ }
331
+ /**
332
+ * Build the socket `connection` handler shared by both brokers (standalone and
333
+ * daemon-hosted): newline-framed JSON in, one response line out, with the
334
+ * authorization gate (isRequestAuthorized) applied before `handle`. `token`
335
+ * resolves the currently-expected capability token per request so a token
336
+ * rotation on broker restart is picked up without rebuilding the handler.
337
+ */
338
+ export function makeConnectionHandler(handle, token) {
339
+ return (conn) => {
340
+ conn.setEncoding('utf-8');
341
+ let buf = '';
342
+ conn.on('data', (chunk) => {
343
+ buf += chunk;
344
+ let nl;
345
+ while ((nl = buf.indexOf('\n')) >= 0) {
346
+ const line = buf.slice(0, nl);
347
+ buf = buf.slice(nl + 1);
348
+ if (!line.trim())
349
+ continue;
350
+ let resp;
351
+ try {
352
+ const req = JSON.parse(line);
353
+ resp = isRequestAuthorized(req, token())
354
+ ? handle(req)
355
+ : { ok: false, error: 'unauthorized' };
356
+ }
357
+ catch (err) {
358
+ resp = { ok: false, error: err.message };
359
+ }
360
+ conn.write(JSON.stringify(resp) + '\n');
361
+ }
362
+ });
363
+ conn.on('error', () => { });
364
+ };
365
+ }
275
366
  /**
276
367
  * Bind the shared broker socket without stealing it from another live owner.
277
368
  * Both the standalone service and daemon-hosted broker use this single path so
@@ -293,6 +384,13 @@ async function bindBrokerSocket(sock, onConnection) {
293
384
  fs.chmodSync(sock, 0o600);
294
385
  }
295
386
  catch { /* dir 0700 already gates it */ }
387
+ // Mint the capability token here — the one moment this process is the
388
+ // confirmed socket owner — so a losing starter never clobbers the live
389
+ // owner's token (RUSH-1760).
390
+ try {
391
+ writeAgentToken();
392
+ }
393
+ catch { /* dir 0700 gates the socket regardless */ }
296
394
  resolve(server);
297
395
  });
298
396
  });
@@ -428,29 +526,7 @@ export async function runSecretsAgent(opts = {}) {
428
526
  emptySince = Date.now();
429
527
  return resp;
430
528
  };
431
- const onConnection = (conn) => {
432
- conn.setEncoding('utf-8');
433
- let buf = '';
434
- conn.on('data', (chunk) => {
435
- buf += chunk;
436
- let nl;
437
- while ((nl = buf.indexOf('\n')) >= 0) {
438
- const line = buf.slice(0, nl);
439
- buf = buf.slice(nl + 1);
440
- if (!line.trim())
441
- continue;
442
- let resp;
443
- try {
444
- resp = handle(JSON.parse(line));
445
- }
446
- catch (err) {
447
- resp = { ok: false, error: err.message };
448
- }
449
- conn.write(JSON.stringify(resp) + '\n');
450
- }
451
- });
452
- conn.on('error', () => { });
453
- };
529
+ const onConnection = makeConnectionHandler(handle, readAgentToken);
454
530
  let server = null;
455
531
  do {
456
532
  try {
@@ -558,29 +634,7 @@ export async function startHostedBroker() {
558
634
  const store = new Map();
559
635
  const sock = socketPath(); // agentDir() creates the 0700 dir as a side effect
560
636
  const handle = (req) => handleAgentRequest(store, req);
561
- const onConn = (conn) => {
562
- conn.setEncoding('utf-8');
563
- let buf = '';
564
- conn.on('data', (chunk) => {
565
- buf += chunk;
566
- let nl;
567
- while ((nl = buf.indexOf('\n')) >= 0) {
568
- const line = buf.slice(0, nl);
569
- buf = buf.slice(nl + 1);
570
- if (!line.trim())
571
- continue;
572
- let resp;
573
- try {
574
- resp = handle(JSON.parse(line));
575
- }
576
- catch (err) {
577
- resp = { ok: false, error: err.message };
578
- }
579
- conn.write(JSON.stringify(resp) + '\n');
580
- }
581
- });
582
- conn.on('error', () => { });
583
- };
637
+ const onConn = makeConnectionHandler(handle, readAgentToken);
584
638
  const server = await bindBrokerSocket(sock, onConn);
585
639
  if (!server)
586
640
  return null;
@@ -632,6 +686,9 @@ export async function startHostedBroker() {
632
686
  /** Open the socket, send one request, resolve the one response. Async path —
633
687
  * used by the unlock/lock/status commands, which already run in async actions. */
634
688
  function request(req, timeoutMs = 2000) {
689
+ // Attach the capability token to every command except ping (the auth gate;
690
+ // RUSH-1760). ping stays tokenless so a client can probe reachability first.
691
+ const authedReq = req.cmd === 'ping' ? req : { ...req, token: readAgentToken() ?? undefined };
635
692
  return new Promise((resolve) => {
636
693
  const conn = net.createConnection(socketPath());
637
694
  let buf = '';
@@ -649,7 +706,7 @@ function request(req, timeoutMs = 2000) {
649
706
  };
650
707
  const timer = setTimeout(() => finish(null), timeoutMs);
651
708
  conn.on('error', () => finish(null));
652
- conn.on('connect', () => conn.write(JSON.stringify(req) + '\n'));
709
+ conn.on('connect', () => conn.write(JSON.stringify(authedReq) + '\n'));
653
710
  conn.setEncoding('utf-8');
654
711
  conn.on('data', (chunk) => {
655
712
  buf += chunk;
@@ -679,14 +736,15 @@ export function agentSocketExists() {
679
736
  * -e: [execPath, <socket>, <name>].
680
737
  */
681
738
  const SYNC_GET_PROGRAM = `
682
- const net = require('net');
683
- const sock = process.argv[1], name = process.argv[2];
739
+ const net = require('net'), fs = require('fs');
740
+ const sock = process.argv[1], name = process.argv[2], tokenPath = process.argv[3];
741
+ let token; try { token = fs.readFileSync(tokenPath, 'utf-8').trim() || undefined; } catch (e) {}
684
742
  const c = net.createConnection(sock);
685
743
  let buf = '';
686
744
  const miss = () => { try { c.destroy(); } catch (e) {} process.exit(3); };
687
745
  const timer = setTimeout(miss, 2000);
688
746
  c.on('error', miss);
689
- c.on('connect', () => c.write(JSON.stringify({ cmd: 'get', name }) + '\\n'));
747
+ c.on('connect', () => c.write(JSON.stringify({ cmd: 'get', name, token }) + '\\n'));
690
748
  c.setEncoding('utf-8');
691
749
  c.on('data', (d) => {
692
750
  buf += d;
@@ -707,7 +765,7 @@ c.on('data', (d) => {
707
765
  export function agentGetSync(name) {
708
766
  if (!agentSocketExists())
709
767
  return null;
710
- const r = spawnSync(process.execPath, ['-e', SYNC_GET_PROGRAM, socketPath(), name], {
768
+ const r = spawnSync(process.execPath, ['-e', SYNC_GET_PROGRAM, socketPath(), name, tokenPath()], {
711
769
  encoding: 'utf-8',
712
770
  timeout: 3000,
713
771
  });
@@ -723,6 +781,46 @@ export function agentGetSync(name) {
723
781
  return null;
724
782
  }
725
783
  }
784
+ /** Inline node program for a synchronous liveness ping. Connects, sends one
785
+ * `{cmd:'ping'}`, exits 0 iff a valid ping response comes back, else 3. A stale
786
+ * socket file with no listener refuses the connection immediately, so this
787
+ * fast-fails without riding any cold-start logic. argv after -e: [execPath, <socket>]. */
788
+ const SYNC_PING_PROGRAM = `
789
+ const net = require('net');
790
+ const sock = process.argv[1];
791
+ const c = net.createConnection(sock);
792
+ let buf = '';
793
+ const dead = () => { try { c.destroy(); } catch (e) {} process.exit(3); };
794
+ const timer = setTimeout(dead, 700);
795
+ c.on('error', dead);
796
+ c.on('connect', () => c.write(JSON.stringify({ cmd: 'ping' }) + '\\n'));
797
+ c.setEncoding('utf-8');
798
+ c.on('data', (d) => {
799
+ buf += d;
800
+ const nl = buf.indexOf('\\n');
801
+ if (nl < 0) return;
802
+ clearTimeout(timer);
803
+ let r; try { r = JSON.parse(buf.slice(0, nl)); } catch (e) { return dead(); }
804
+ try { c.destroy(); } catch (e) {}
805
+ process.exit(r && r.ok && r.cmd === 'ping' ? 0 : 3);
806
+ });
807
+ `;
808
+ /**
809
+ * Synchronous liveness check: is a broker actually LISTENING and answering (not
810
+ * just a lingering socket file)? Used to decide whether the auto-cache may take
811
+ * the synchronous warm path — a dead broker whose socket outlived it (crash,
812
+ * OOM, version-skew teardown) must NOT drag a foreground read through the
813
+ * worker's 20s cold-start budget. A stale socket refuses instantly, so this is
814
+ * fast in both the alive and dead cases. macOS only.
815
+ */
816
+ export function agentReachableSync() {
817
+ if (!onDarwin())
818
+ return false;
819
+ if (!agentSocketExists())
820
+ return false;
821
+ const r = spawnSync(process.execPath, ['-e', SYNC_PING_PROGRAM, socketPath()], { timeout: 1500 });
822
+ return r.status === 0 && !r.error;
823
+ }
726
824
  /**
727
825
  * Inline node program for the synchronous evict path. Mirrors SYNC_GET_PROGRAM:
728
826
  * writeBundle is synchronous and called synchronously everywhere, so a stale
@@ -731,14 +829,15 @@ export function agentGetSync(name) {
731
829
  * argv after -e: [execPath, <socket>, <name>].
732
830
  */
733
831
  const SYNC_LOCK_PROGRAM = `
734
- const net = require('net');
735
- const sock = process.argv[1], name = process.argv[2];
832
+ const net = require('net'), fs = require('fs');
833
+ const sock = process.argv[1], name = process.argv[2], tokenPath = process.argv[3];
834
+ let token; try { token = fs.readFileSync(tokenPath, 'utf-8').trim() || undefined; } catch (e) {}
736
835
  const c = net.createConnection(sock);
737
836
  let buf = '';
738
837
  const down = () => { try { c.destroy(); } catch (e) {} process.exit(3); };
739
838
  const timer = setTimeout(down, 2000);
740
839
  c.on('error', down);
741
- c.on('connect', () => c.write(JSON.stringify({ cmd: 'lock', name }) + '\\n'));
840
+ c.on('connect', () => c.write(JSON.stringify({ cmd: 'lock', name, token }) + '\\n'));
742
841
  c.setEncoding('utf-8');
743
842
  c.on('data', (d) => {
744
843
  buf += d;
@@ -763,7 +862,7 @@ export function agentEvictSync(name) {
763
862
  if (!agentSocketExists())
764
863
  return;
765
864
  try {
766
- spawnSync(process.execPath, ['-e', SYNC_LOCK_PROGRAM, socketPath(), name], { timeout: 3000 });
865
+ spawnSync(process.execPath, ['-e', SYNC_LOCK_PROGRAM, socketPath(), name, tokenPath()], { timeout: 3000 });
767
866
  }
768
867
  catch { /* best-effort */ }
769
868
  }
@@ -817,26 +916,81 @@ export function secretsAgentAutoEnabled() {
817
916
  return true;
818
917
  }
819
918
  }
919
+ /** Minimum / maximum bounds for the configurable hold window. A too-small value
920
+ * would defeat the broker (constant re-prompts); a too-large one pins secrets in
921
+ * memory far longer than intended. */
922
+ export const MIN_HOLD_MS = 60 * 1000; // 1m
923
+ export const MAX_HOLD_MS = 30 * 24 * 60 * 60 * 1000; // 30d
924
+ /**
925
+ * How long an unlocked / auto-cached bundle is held before the next read
926
+ * re-prompts. Defaults to DEFAULT_TTL_MS (7d); override with
927
+ * `secrets.agent.holdMs` (milliseconds) in agents.yaml — e.g. 86400000 for a 24h
928
+ * cap. Clamped to [MIN_HOLD_MS, MAX_HOLD_MS] so a typo can neither disable the
929
+ * hold nor pin a secret in memory indefinitely. Best-effort: an unreadable or
930
+ * non-numeric value falls back to the 7d default. Pure except for the meta read.
931
+ */
932
+ export function secretsHoldMs() {
933
+ try {
934
+ return clampHoldMs(readMeta().secrets?.agent?.holdMs);
935
+ }
936
+ catch {
937
+ return DEFAULT_TTL_MS;
938
+ }
939
+ }
940
+ /** Pure clamp for a configured `holdMs`: a positive finite number is bounded to
941
+ * [MIN_HOLD_MS, MAX_HOLD_MS]; anything else (absent, 0, negative, NaN, non-number)
942
+ * falls back to the 7d default. Exported for direct unit testing. */
943
+ export function clampHoldMs(v) {
944
+ if (typeof v === 'number' && Number.isFinite(v) && v > 0) {
945
+ return Math.min(Math.max(Math.floor(v), MIN_HOLD_MS), MAX_HOLD_MS);
946
+ }
947
+ return DEFAULT_TTL_MS;
948
+ }
820
949
  /**
821
950
  * Fire-and-forget: populate the broker with a freshly-resolved bundle so the
822
951
  * NEXT process reads it without a prompt. Used by the auto-cache path after a
823
- * real keychain read of a `daily`-policy bundle. Adds no latency to the caller
824
- * it spawns a detached `secrets _agent-load` worker (passing the resolved env
825
- * over stdin, never argv) and returns immediately.
952
+ * real keychain read of a `daily`-policy bundle, so the NEXT concurrent read is
953
+ * silent. Env travels over stdin, never argv.
954
+ *
955
+ * Reliability (this is what makes `daily` actually "stick"): when a broker is
956
+ * ALREADY listening, warm it SYNCHRONOUSLY with a bounded wait so the bundle is
957
+ * held by the time this process exits. The old detached-only path lost the race
958
+ * under load — a short-lived reader (`agents secrets export`, a release-script
959
+ * loop) exited before the unref'd worker connected, so the cache silently never
960
+ * populated and every read re-prompted despite the `daily` policy. Only when the
961
+ * broker must COLD-START (no socket yet) do we fall back to the detached worker,
962
+ * so a first-ever read never blocks on a multi-second broker boot.
826
963
  *
827
- * The worker reuses the robust `ensureAgentRunning` path (spawn-then-ping with a
828
- * generous budget) rather than a tight inline retry loop: under heavy load the
829
- * broker is itself a cold-starting full CLI and can take several seconds to bind
830
- * the socket, so a short fixed budget would give up before it's ready and the
831
- * cache would silently never populate. Best-effort; never throws. macOS only.
964
+ * The worker reuses the robust `ensureAgentRunning` path (spawn-then-ping) rather
965
+ * than a tight inline retry loop. Best-effort; never throws. macOS only.
832
966
  */
833
967
  export function agentAutoLoadSync(name, bundle, env, ttlMs) {
834
968
  if (!onDarwin())
835
969
  return;
970
+ const payload = JSON.stringify({ name, bundle, env, ttlMs });
971
+ // Broker actually LISTENING → deterministic synchronous warm (bounded; the read
972
+ // already paid a Touch ID, so <1s here is invisible). We gate on a real liveness
973
+ // ping, NOT mere socket-file existence: a broker that died leaving its socket
974
+ // behind (crash, OOM, or the version-skew teardown in this file) would otherwise
975
+ // drag this FOREGROUND read through the worker's 20s cold-start budget on every
976
+ // read. A dead/stale socket fails the ping fast, so we drop straight to the
977
+ // detached path (which does the cold-start + stale-socket cleanup off the hot
978
+ // path) — restoring "a dead broker costs the foreground read nothing".
979
+ if (agentReachableSync()) {
980
+ try {
981
+ const { cmd, args } = cliSpawn(['secrets', '_agent-load']);
982
+ const r = spawnSync(cmd, args, { input: payload, timeout: 3000, stdio: ['pipe', 'ignore', 'ignore'] });
983
+ if (!r.error && r.status === 0)
984
+ return;
985
+ }
986
+ catch {
987
+ // fall through to the detached best-effort path
988
+ }
989
+ }
836
990
  try {
837
991
  const { cmd, args } = cliSpawn(['secrets', '_agent-load']);
838
992
  const worker = spawn(cmd, args, { stdio: ['pipe', 'ignore', 'ignore'], detached: true });
839
- worker.stdin?.write(JSON.stringify({ name, bundle, env, ttlMs }));
993
+ worker.stdin?.write(payload);
840
994
  worker.stdin?.end();
841
995
  worker.unref();
842
996
  }
@@ -847,8 +1001,14 @@ export function agentAutoLoadSync(name, bundle, env, ttlMs) {
847
1001
  /**
848
1002
  * Body of the hidden `secrets _agent-load` worker. Reads one `{name, bundle,
849
1003
  * env, ttlMs}` payload from stdin, ensures the broker is up (robust, generous
850
- * budget), and loads the bundle into it. Detached from the originating read, so
851
- * its latency is invisible — which is why it can afford a long ensure budget.
1004
+ * budget), and loads the bundle into it.
1005
+ *
1006
+ * Exit code is load-truthful: 0 ONLY when the bundle was actually loaded into a
1007
+ * reachable broker; non-zero on any failure (malformed payload, broker couldn't
1008
+ * be brought up, or the load transport failed). The synchronous caller
1009
+ * (agentAutoLoadSync) relies on this to decide whether to skip the detached
1010
+ * fallback — a bare "process exited 0" would otherwise be a false-positive
1011
+ * success that silently reintroduces the very re-prompt storm this path fixes.
852
1012
  */
853
1013
  export async function runAgentLoadFromStdin() {
854
1014
  if (!onDarwin())
@@ -861,15 +1021,22 @@ export async function runAgentLoadFromStdin() {
861
1021
  payload = JSON.parse(Buffer.concat(chunks).toString('utf-8'));
862
1022
  }
863
1023
  catch {
864
- return; // malformed payload — nothing to load
1024
+ process.exitCode = 1; // malformed payload — nothing loaded
1025
+ return;
865
1026
  }
866
- if (!payload || !payload.name || !payload.bundle || !payload.env)
1027
+ if (!payload || !payload.name || !payload.bundle || !payload.env) {
1028
+ process.exitCode = 1;
867
1029
  return;
1030
+ }
868
1031
  // Generous budget: the broker is a cold-starting full CLI; under load it can
869
1032
  // take several seconds to bind. We're detached, so waiting costs nothing.
870
- if (!(await ensureAgentRunning(20000)))
1033
+ if (!(await ensureAgentRunning(20000))) {
1034
+ process.exitCode = 1; // broker couldn't be brought up — did NOT load
871
1035
  return;
872
- await agentLoad(payload.name, payload.bundle, payload.env, payload.ttlMs ?? DEFAULT_TTL_MS);
1036
+ }
1037
+ const loaded = await agentLoad(payload.name, payload.bundle, payload.env, payload.ttlMs ?? DEFAULT_TTL_MS);
1038
+ if (!loaded)
1039
+ process.exitCode = 1; // transport failed — did NOT load
873
1040
  }
874
1041
  /** Store a resolved bundle in the broker. Returns false on transport failure. */
875
1042
  export async function agentLoad(name, bundle, env, ttlMs) {
@@ -221,6 +221,32 @@ export declare function assertRemoteBundleFlagsUnsupported(bundleName: string, h
221
221
  allowExpiredFlag: string;
222
222
  }): void;
223
223
  export declare function resolveBundleEnv(bundle: SecretsBundle, _opts?: ResolveBundleOptions): Record<string, string>;
224
+ /**
225
+ * True when the current process is a background / non-interactive context that
226
+ * must NEVER raise a Keychain biometry prompt on the interactive user's screen —
227
+ * a prompt nobody is watching. Two signals, either sufficient:
228
+ * - `AGENTS_RUNTIME` is `headless` or `teams` (set on the child env by
229
+ * `agents run --headless`, scheduled routines, and teammates — see
230
+ * exec.ts:resolveInteractive, runner.ts, teams/agents.ts).
231
+ * - neither stdin nor stdout is a TTY (a detached/backgrounded task whose
232
+ * stdio is redirected to a log — e.g. a release script run in the
233
+ * background as `( ... ) >log 2>&1 </dev/null`).
234
+ * `AGENTS_SECRETS_NO_PROMPT=1` forces headless-safe; `=0` force-allows a prompt
235
+ * even in a non-TTY context. An interactive `eval "$(agents secrets export X)"`
236
+ * keeps its terminal stdin, so it is NOT classified headless and still prompts.
237
+ *
238
+ * Only **macOS keychain** reads pop an interactive Touch ID sheet — the secrets
239
+ * broker itself is a no-op off darwin (see agent.ts), and libsecret (Linux) /
240
+ * the Windows credential store resolve without any prompt. So off-darwin this
241
+ * ALWAYS returns false: forcing broker-only there would break every headless
242
+ * Linux/Windows read (CI, `agents run --headless`, routines, the Linux-driven
243
+ * release flow) for no benefit — there is no prompt to suppress.
244
+ *
245
+ * A read in a macOS headless context resolves broker-only (agentOnly) and fails
246
+ * fast with an actionable error instead of hijacking Touch ID. This generalizes
247
+ * the per-caller pattern already used by the daemon (daemon.ts:readDaemonClaudeOAuthToken).
248
+ */
249
+ export declare function isHeadlessSecretsContext(env?: NodeJS.ProcessEnv, platform?: NodeJS.Platform): boolean;
224
250
  /**
225
251
  * Read a bundle's metadata AND resolve its env in a single Touch ID prompt.
226
252
  *