@phnx-labs/agents-cli 1.20.16 → 1.20.18

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 (75) hide show
  1. package/CHANGELOG.md +19 -0
  2. package/README.md +1 -1
  3. package/dist/commands/budget.d.ts +14 -0
  4. package/dist/commands/budget.js +137 -0
  5. package/dist/commands/cost.d.ts +12 -0
  6. package/dist/commands/cost.js +139 -0
  7. package/dist/commands/exec.d.ts +20 -0
  8. package/dist/commands/exec.js +382 -5
  9. package/dist/commands/secrets.d.ts +15 -0
  10. package/dist/commands/secrets.js +250 -4
  11. package/dist/commands/sessions.js +4 -0
  12. package/dist/commands/sync.d.ts +10 -3
  13. package/dist/commands/sync.js +72 -9
  14. package/dist/index.js +4 -0
  15. package/dist/lib/budget/config.d.ts +9 -0
  16. package/dist/lib/budget/config.js +115 -0
  17. package/dist/lib/budget/enforce.d.ts +94 -0
  18. package/dist/lib/budget/enforce.js +151 -0
  19. package/dist/lib/budget/ledger.d.ts +61 -0
  20. package/dist/lib/budget/ledger.js +107 -0
  21. package/dist/lib/budget/preflight.d.ts +110 -0
  22. package/dist/lib/budget/preflight.js +200 -0
  23. package/dist/lib/checkpoint.d.ts +54 -0
  24. package/dist/lib/checkpoint.js +56 -0
  25. package/dist/lib/cloud/rush.js +18 -0
  26. package/dist/lib/exec.d.ts +36 -0
  27. package/dist/lib/exec.js +192 -4
  28. package/dist/lib/git.d.ts +18 -0
  29. package/dist/lib/git.js +67 -4
  30. package/dist/lib/hooks.js +12 -0
  31. package/dist/lib/loop.d.ts +145 -0
  32. package/dist/lib/loop.js +330 -0
  33. package/dist/lib/mcp.d.ts +7 -0
  34. package/dist/lib/mcp.js +24 -0
  35. package/dist/lib/models.d.ts +11 -0
  36. package/dist/lib/models.js +21 -0
  37. package/dist/lib/plugin-marketplace.js +16 -6
  38. package/dist/lib/plugins.js +5 -2
  39. package/dist/lib/pricing/cost.d.ts +46 -0
  40. package/dist/lib/pricing/cost.js +71 -0
  41. package/dist/lib/pricing/index.d.ts +8 -0
  42. package/dist/lib/pricing/index.js +8 -0
  43. package/dist/lib/pricing/prices.json +138 -0
  44. package/dist/lib/pricing/table.d.ts +17 -0
  45. package/dist/lib/pricing/table.js +73 -0
  46. package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
  47. package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
  48. package/dist/lib/secrets/agent.d.ts +134 -0
  49. package/dist/lib/secrets/agent.js +501 -0
  50. package/dist/lib/secrets/bundles.d.ts +21 -0
  51. package/dist/lib/secrets/bundles.js +43 -0
  52. package/dist/lib/secrets/drivers/rush.d.ts +14 -0
  53. package/dist/lib/secrets/drivers/rush.js +84 -0
  54. package/dist/lib/secrets/linux.js +88 -10
  55. package/dist/lib/secrets/sync-backend.d.ts +48 -0
  56. package/dist/lib/secrets/sync-backend.js +13 -0
  57. package/dist/lib/secrets/sync.d.ts +15 -23
  58. package/dist/lib/secrets/sync.js +31 -66
  59. package/dist/lib/session/db.d.ts +40 -0
  60. package/dist/lib/session/db.js +84 -2
  61. package/dist/lib/session/discover.d.ts +2 -0
  62. package/dist/lib/session/discover.js +126 -2
  63. package/dist/lib/session/render.d.ts +2 -0
  64. package/dist/lib/session/render.js +1 -1
  65. package/dist/lib/session/types.d.ts +4 -0
  66. package/dist/lib/sync-umbrella.d.ts +76 -0
  67. package/dist/lib/sync-umbrella.js +125 -0
  68. package/dist/lib/teams/agents.d.ts +32 -0
  69. package/dist/lib/teams/agents.js +66 -3
  70. package/dist/lib/teams/api.js +20 -0
  71. package/dist/lib/teams/parsers.js +16 -4
  72. package/dist/lib/types.d.ts +48 -0
  73. package/dist/lib/workflows.d.ts +56 -0
  74. package/dist/lib/workflows.js +72 -5
  75. package/package.json +2 -1
@@ -0,0 +1,501 @@
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.
5
+ *
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, auto-lock
20
+ * on screen-lock / sleep, and `agents secrets lock`. Nothing ever touches disk.
21
+ *
22
+ * macOS only: Linux libsecret has no biometry prompt, so there's nothing to
23
+ * deduplicate — every entry point here no-ops off darwin.
24
+ */
25
+ import * as net from 'net';
26
+ import * as fs from 'fs';
27
+ import * as path from 'path';
28
+ import { spawn, spawnSync, execFileSync } from 'child_process';
29
+ import { getHelpersDir, readMeta } from '../state.js';
30
+ import { isAlive } from '../platform/process.js';
31
+ import { getKeychainHelperPath } from './install-helper.js';
32
+ /** Bumped when the wire protocol changes; a client that pings a mismatched
33
+ * server kills and respawns it rather than talking a stale dialect. */
34
+ const PROTOCOL_VERSION = 1;
35
+ /** Default lifetime of an unlocked bundle when `--ttl` is not given. */
36
+ export const DEFAULT_TTL_MS = 24 * 60 * 60 * 1000; // 24h
37
+ /** After the store goes empty (all bundles locked or expired) for this long,
38
+ * the broker exits so no idle process lingers holding a socket. */
39
+ const IDLE_EXIT_MS = 5 * 60 * 1000; // 5m
40
+ /** How often the broker sweeps expired entries. */
41
+ const SWEEP_INTERVAL_MS = 30 * 1000;
42
+ function onDarwin() {
43
+ return process.platform === 'darwin';
44
+ }
45
+ /** Broker runtime dir under the regenerable cache, locked to the user (0700).
46
+ * AGENTS_SECRETS_AGENT_DIR overrides the location — a test seam so the suite can
47
+ * run a real broker on a temp socket without touching the user's real dir. */
48
+ function agentDir() {
49
+ const dir = process.env.AGENTS_SECRETS_AGENT_DIR || path.join(getHelpersDir(), 'secrets-agent');
50
+ fs.mkdirSync(dir, { recursive: true });
51
+ try {
52
+ fs.chmodSync(dir, 0o700);
53
+ }
54
+ catch { /* best effort */ }
55
+ return dir;
56
+ }
57
+ function socketPath() {
58
+ return path.join(agentDir(), 'agent.sock');
59
+ }
60
+ function pidPath() {
61
+ return path.join(agentDir(), 'agent.pid');
62
+ }
63
+ /**
64
+ * Argv for re-invoking THIS cli to run the broker, so a side-by-side dev build
65
+ * spawns its own broker rather than the registry-installed one. We always go
66
+ * through `process.execPath` (the node binary) with the JS entrypoint as the
67
+ * first arg — the entrypoint isn't reliably executable in dev builds (invoked
68
+ * as `node dist/index.js`, no +x), so spawning it directly EACCES'd.
69
+ */
70
+ function brokerSpawn() {
71
+ const argv1 = process.argv[1];
72
+ const entry = argv1 && fs.existsSync(argv1) ? argv1 : null;
73
+ if (entry)
74
+ return { cmd: process.execPath, args: [entry, 'secrets', '_agent-run'] };
75
+ // No resolvable entrypoint (unusual) — fall back to the PATH shim.
76
+ let bin = 'agents';
77
+ try {
78
+ bin = execFileSync('which', ['agents'], { encoding: 'utf-8' }).trim();
79
+ }
80
+ catch { /* default */ }
81
+ return { cmd: bin, args: ['secrets', '_agent-run'] };
82
+ }
83
+ // ─── Broker server (runs in the detached `secrets _agent-run` process) ───────
84
+ /**
85
+ * Pure request handler over the in-memory store. Extracted so the store
86
+ * semantics (lazy expiry on get/status, lock-one vs lock-all, load TTL) are
87
+ * unit-testable with a controlled `now`, without a socket or a spawned process.
88
+ * Mutates `store` in place; returns the wire response.
89
+ */
90
+ export function handleAgentRequest(store, req, now = Date.now()) {
91
+ switch (req.cmd) {
92
+ case 'ping':
93
+ return { ok: true, cmd: 'ping', version: PROTOCOL_VERSION };
94
+ case 'get': {
95
+ const e = store.get(req.name);
96
+ if (!e || now >= e.expiresAt) {
97
+ if (e)
98
+ store.delete(req.name); // drop expired on read
99
+ return { ok: true, cmd: 'get', hit: false };
100
+ }
101
+ return { ok: true, cmd: 'get', hit: true, bundle: e.bundle, env: e.env };
102
+ }
103
+ case 'load':
104
+ store.set(req.name, { bundle: req.bundle, env: req.env, expiresAt: now + req.ttlMs });
105
+ return { ok: true, cmd: 'load' };
106
+ case 'lock': {
107
+ if (req.name) {
108
+ return { ok: true, cmd: 'lock', wiped: store.delete(req.name) ? 1 : 0 };
109
+ }
110
+ const wiped = store.size;
111
+ store.clear();
112
+ return { ok: true, cmd: 'lock', wiped };
113
+ }
114
+ case 'status': {
115
+ const entries = [];
116
+ for (const [name, e] of store) {
117
+ if (now >= e.expiresAt)
118
+ continue;
119
+ entries.push({ name, expiresAt: e.expiresAt, keyCount: Object.keys(e.env).length });
120
+ }
121
+ return { ok: true, cmd: 'status', entries };
122
+ }
123
+ }
124
+ }
125
+ /**
126
+ * Run the broker in the foreground. Spawned detached by ensureAgentRunning via
127
+ * `agents secrets _agent-run`. Holds the store in memory, serves the socket,
128
+ * sweeps expired entries, wipes on screen-lock/sleep, and self-exits when idle.
129
+ */
130
+ export async function runSecretsAgent() {
131
+ if (!onDarwin())
132
+ return; // nothing to broker without biometry prompts
133
+ // Single-instance guard: O_EXCL pid file. If a live broker already holds it,
134
+ // exit quietly — the existing one keeps serving.
135
+ const pidFile = pidPath();
136
+ try {
137
+ const fd = fs.openSync(pidFile, fs.constants.O_CREAT | fs.constants.O_EXCL | fs.constants.O_WRONLY);
138
+ fs.writeSync(fd, String(process.pid));
139
+ fs.closeSync(fd);
140
+ }
141
+ catch (err) {
142
+ if (err?.code === 'EEXIST') {
143
+ const holder = parseInt(fs.readFileSync(pidFile, 'utf-8').trim(), 10);
144
+ if (!isNaN(holder) && isAlive(holder))
145
+ return; // another broker is live
146
+ // Stale pid — reclaim it.
147
+ try {
148
+ fs.unlinkSync(pidFile);
149
+ }
150
+ catch { /* race; fall through */ }
151
+ fs.writeFileSync(pidFile, String(process.pid));
152
+ }
153
+ else {
154
+ throw err;
155
+ }
156
+ }
157
+ const store = new Map();
158
+ // emptySince tracks the last moment the store held something; the sweep exits
159
+ // the process once it's been empty for IDLE_EXIT_MS so no idle broker lingers.
160
+ let emptySince = Date.now();
161
+ const sock = socketPath();
162
+ try {
163
+ fs.unlinkSync(sock);
164
+ }
165
+ catch { /* no stale socket */ }
166
+ const sweep = () => {
167
+ const now = Date.now();
168
+ for (const [name, e] of store)
169
+ if (now >= e.expiresAt)
170
+ store.delete(name);
171
+ if (store.size === 0) {
172
+ if (now - emptySince >= IDLE_EXIT_MS)
173
+ shutdown(0);
174
+ }
175
+ else {
176
+ emptySince = now;
177
+ }
178
+ };
179
+ const handle = (req) => {
180
+ const resp = handleAgentRequest(store, req);
181
+ if (store.size > 0)
182
+ emptySince = Date.now();
183
+ return resp;
184
+ };
185
+ const server = net.createServer((conn) => {
186
+ conn.setEncoding('utf-8');
187
+ let buf = '';
188
+ conn.on('data', (chunk) => {
189
+ buf += chunk;
190
+ let nl;
191
+ while ((nl = buf.indexOf('\n')) >= 0) {
192
+ const line = buf.slice(0, nl);
193
+ buf = buf.slice(nl + 1);
194
+ if (!line.trim())
195
+ continue;
196
+ let resp;
197
+ try {
198
+ resp = handle(JSON.parse(line));
199
+ }
200
+ catch (err) {
201
+ resp = { ok: false, error: err.message };
202
+ }
203
+ conn.write(JSON.stringify(resp) + '\n');
204
+ }
205
+ });
206
+ conn.on('error', () => { });
207
+ });
208
+ let watcher = null;
209
+ let sweepTimer = null;
210
+ let shuttingDown = false;
211
+ const shutdown = (code) => {
212
+ if (shuttingDown)
213
+ return;
214
+ shuttingDown = true;
215
+ store.clear();
216
+ if (sweepTimer)
217
+ clearInterval(sweepTimer);
218
+ try {
219
+ watcher?.kill();
220
+ }
221
+ catch { /* already gone */ }
222
+ try {
223
+ server.close();
224
+ }
225
+ catch { /* not listening */ }
226
+ try {
227
+ fs.unlinkSync(sock);
228
+ }
229
+ catch { /* gone */ }
230
+ try {
231
+ if (parseInt(fs.readFileSync(pidFile, 'utf-8').trim(), 10) === process.pid)
232
+ fs.unlinkSync(pidFile);
233
+ }
234
+ catch { /* gone */ }
235
+ process.exit(code);
236
+ };
237
+ process.on('SIGTERM', () => shutdown(0));
238
+ process.on('SIGINT', () => shutdown(0));
239
+ await new Promise((resolve, reject) => {
240
+ server.once('error', reject);
241
+ server.listen(sock, () => {
242
+ try {
243
+ fs.chmodSync(sock, 0o600);
244
+ }
245
+ catch { /* dir 0700 already gates it */ }
246
+ resolve();
247
+ });
248
+ });
249
+ sweepTimer = setInterval(sweep, SWEEP_INTERVAL_MS);
250
+ // Auto-lock on screen-lock / sleep. The signed helper emits LOCK / SLEEP
251
+ // lines; on any of them we wipe everything. If the installed helper predates
252
+ // watch-lock (exits non-zero immediately), we fall back to TTL-only and log
253
+ // nothing — the unlock already warned when lock_on_sleep couldn't be armed.
254
+ try {
255
+ watcher = spawn(getKeychainHelperPath(), ['watch-lock'], { stdio: ['ignore', 'pipe', 'ignore'] });
256
+ watcher.stdout?.setEncoding('utf-8');
257
+ watcher.stdout?.on('data', (chunk) => {
258
+ if (/\b(LOCK|SLEEP)\b/.test(chunk)) {
259
+ store.clear();
260
+ emptySince = Date.now();
261
+ }
262
+ });
263
+ watcher.on('error', () => { watcher = null; });
264
+ }
265
+ catch {
266
+ watcher = null;
267
+ }
268
+ }
269
+ // ─── Client ──────────────────────────────────────────────────────────────────
270
+ /** Open the socket, send one request, resolve the one response. Async path —
271
+ * used by the unlock/lock/status commands, which already run in async actions. */
272
+ function request(req, timeoutMs = 2000) {
273
+ return new Promise((resolve) => {
274
+ const conn = net.createConnection(socketPath());
275
+ let buf = '';
276
+ let done = false;
277
+ const finish = (r) => {
278
+ if (done)
279
+ return;
280
+ done = true;
281
+ clearTimeout(timer);
282
+ try {
283
+ conn.destroy();
284
+ }
285
+ catch { /* already closed */ }
286
+ resolve(r);
287
+ };
288
+ const timer = setTimeout(() => finish(null), timeoutMs);
289
+ conn.on('error', () => finish(null));
290
+ conn.on('connect', () => conn.write(JSON.stringify(req) + '\n'));
291
+ conn.setEncoding('utf-8');
292
+ conn.on('data', (chunk) => {
293
+ buf += chunk;
294
+ const nl = buf.indexOf('\n');
295
+ if (nl < 0)
296
+ return;
297
+ try {
298
+ finish(JSON.parse(buf.slice(0, nl)));
299
+ }
300
+ catch {
301
+ finish(null);
302
+ }
303
+ });
304
+ });
305
+ }
306
+ /** True if a broker socket exists at all. Cheap; gates the sync read so the
307
+ * never-unlocked path stays a single stat. */
308
+ export function agentSocketExists() {
309
+ return onDarwin() && fs.existsSync(socketPath());
310
+ }
311
+ /**
312
+ * Inline node program for the synchronous read fast-path. `readAndResolveBundleEnv`
313
+ * is synchronous and called synchronously everywhere, so we can't await a socket
314
+ * round-trip — but spawning the full CLI to do it would load every command. This
315
+ * minimal `node -e` client connects, asks for one bundle, prints the resolved
316
+ * {bundle, env} as JSON, and exits 0 (hit) / 3 (miss or agent down). argv after
317
+ * -e: [execPath, <socket>, <name>].
318
+ */
319
+ const SYNC_GET_PROGRAM = `
320
+ const net = require('net');
321
+ const sock = process.argv[1], name = process.argv[2];
322
+ const c = net.createConnection(sock);
323
+ let buf = '';
324
+ const miss = () => { try { c.destroy(); } catch (e) {} process.exit(3); };
325
+ const timer = setTimeout(miss, 2000);
326
+ c.on('error', miss);
327
+ c.on('connect', () => c.write(JSON.stringify({ cmd: 'get', name }) + '\\n'));
328
+ c.setEncoding('utf-8');
329
+ c.on('data', (d) => {
330
+ buf += d;
331
+ const nl = buf.indexOf('\\n');
332
+ if (nl < 0) return;
333
+ clearTimeout(timer);
334
+ let r; try { r = JSON.parse(buf.slice(0, nl)); } catch (e) { return miss(); }
335
+ try { c.destroy(); } catch (e) {}
336
+ if (r && r.ok && r.hit) { process.stdout.write(JSON.stringify({ bundle: r.bundle, env: r.env })); process.exit(0); }
337
+ process.exit(3);
338
+ });
339
+ `;
340
+ /**
341
+ * Synchronous read for the hot path. Returns the cached resolved bundle, or
342
+ * null if the agent isn't running / doesn't hold this bundle / anything fails
343
+ * (soft — caller falls through to the real keychain). macOS only.
344
+ */
345
+ export function agentGetSync(name) {
346
+ if (!agentSocketExists())
347
+ return null;
348
+ const r = spawnSync(process.execPath, ['-e', SYNC_GET_PROGRAM, socketPath(), name], {
349
+ encoding: 'utf-8',
350
+ timeout: 3000,
351
+ });
352
+ if (r.status !== 0 || !r.stdout)
353
+ return null;
354
+ try {
355
+ const o = JSON.parse(r.stdout);
356
+ if (!o || typeof o !== 'object' || !o.env)
357
+ return null;
358
+ return { bundle: o.bundle, env: o.env };
359
+ }
360
+ catch {
361
+ return null;
362
+ }
363
+ }
364
+ /** True when `secrets.agent.auto` is enabled in agents.yaml. Best-effort; a
365
+ * missing/unreadable meta reads as off. */
366
+ export function secretsAgentAutoEnabled() {
367
+ try {
368
+ return readMeta().secrets?.agent?.auto === true;
369
+ }
370
+ catch {
371
+ return false;
372
+ }
373
+ }
374
+ /**
375
+ * Inline node program that loads one bundle into the broker, started detached
376
+ * from the hot path. Reads the JSON payload from stdin (so secret values never
377
+ * appear in argv / `ps`), retries the socket for a few seconds to absorb a
378
+ * cold-started agent, sends the load, and exits. argv after -e: [execPath, <socket>].
379
+ */
380
+ const DETACHED_LOAD_PROGRAM = `
381
+ const net = require('net');
382
+ const sock = process.argv[1];
383
+ let input = '';
384
+ process.stdin.setEncoding('utf-8');
385
+ process.stdin.on('data', (d) => { input += d; });
386
+ process.stdin.on('end', () => {
387
+ let payload; try { payload = JSON.parse(input); } catch (e) { process.exit(1); }
388
+ let attempts = 0;
389
+ const tryConnect = () => {
390
+ const c = net.createConnection(sock);
391
+ c.on('connect', () => {
392
+ c.write(JSON.stringify({ cmd: 'load', name: payload.name, bundle: payload.bundle, env: payload.env, ttlMs: payload.ttlMs }) + '\\n');
393
+ });
394
+ c.setEncoding('utf-8');
395
+ c.on('data', () => { try { c.destroy(); } catch (e) {} process.exit(0); });
396
+ c.on('error', () => {
397
+ try { c.destroy(); } catch (e) {}
398
+ if (++attempts >= 30) process.exit(1);
399
+ setTimeout(tryConnect, 100);
400
+ });
401
+ };
402
+ tryConnect();
403
+ });
404
+ `;
405
+ /**
406
+ * Fire-and-forget: populate the broker with a freshly-resolved bundle so the
407
+ * NEXT process reads it without a prompt. Used by the auto-cache path after a
408
+ * real keychain read of a `session`-tier bundle. Adds no latency to the caller
409
+ * — it spawns the agent (if needed) and a detached loader, both unref'd, then
410
+ * returns immediately. Entirely best-effort; never throws. macOS only.
411
+ */
412
+ export function agentAutoLoadSync(name, bundle, env, ttlMs) {
413
+ if (!onDarwin())
414
+ return;
415
+ try {
416
+ if (!agentSocketExists()) {
417
+ const { cmd, args } = brokerSpawn();
418
+ spawn(cmd, args, { stdio: 'ignore', detached: true }).unref();
419
+ }
420
+ const loader = spawn(process.execPath, ['-e', DETACHED_LOAD_PROGRAM, socketPath()], {
421
+ stdio: ['pipe', 'ignore', 'ignore'],
422
+ detached: true,
423
+ });
424
+ loader.stdin?.write(JSON.stringify({ name, bundle, env, ttlMs }));
425
+ loader.stdin?.end();
426
+ loader.unref();
427
+ }
428
+ catch {
429
+ // best-effort: the next read just pops Touch ID as it would today
430
+ }
431
+ }
432
+ /** Store a resolved bundle in the broker. Returns false on transport failure. */
433
+ export async function agentLoad(name, bundle, env, ttlMs) {
434
+ const r = await request({ cmd: 'load', name, bundle, env, ttlMs });
435
+ return r?.ok === true && r.cmd === 'load';
436
+ }
437
+ /** Wipe one bundle (or all if name omitted) from the broker. Returns the count
438
+ * wiped, or 0 when no broker is running. */
439
+ export async function agentLock(name) {
440
+ const r = await request({ cmd: 'lock', name });
441
+ return r?.ok === true && r.cmd === 'lock' ? r.wiped : 0;
442
+ }
443
+ /** List currently-unlocked bundles, or [] when no broker is running. */
444
+ export async function agentStatus() {
445
+ const r = await request({ cmd: 'status' });
446
+ return r?.ok === true && r.cmd === 'status' ? r.entries : [];
447
+ }
448
+ /** Is a broker live and speaking our protocol version? */
449
+ async function agentPing() {
450
+ if (!agentSocketExists())
451
+ return false;
452
+ const r = await request({ cmd: 'ping' });
453
+ return r?.ok === true && r.cmd === 'ping' && r.version === PROTOCOL_VERSION;
454
+ }
455
+ /**
456
+ * Ensure a broker is running and reachable, spawning one detached if not.
457
+ * Returns true once the socket answers a ping. On protocol-version skew, kills
458
+ * the stale broker and respawns. macOS only.
459
+ */
460
+ export async function ensureAgentRunning(timeoutMs = 5000) {
461
+ if (!onDarwin())
462
+ return false;
463
+ if (await agentPing())
464
+ return true;
465
+ // Socket exists but ping failed → stale/old broker. Kill it before respawn.
466
+ const stalePid = (() => {
467
+ try {
468
+ return parseInt(fs.readFileSync(pidPath(), 'utf-8').trim(), 10);
469
+ }
470
+ catch {
471
+ return NaN;
472
+ }
473
+ })();
474
+ if (!isNaN(stalePid) && isAlive(stalePid)) {
475
+ try {
476
+ process.kill(stalePid, 'SIGTERM');
477
+ }
478
+ catch { /* already dead */ }
479
+ }
480
+ try {
481
+ fs.unlinkSync(socketPath());
482
+ }
483
+ catch { /* gone */ }
484
+ try {
485
+ fs.unlinkSync(pidPath());
486
+ }
487
+ catch { /* gone */ }
488
+ const { cmd, args } = brokerSpawn();
489
+ const child = spawn(cmd, args, {
490
+ stdio: 'ignore',
491
+ detached: true,
492
+ });
493
+ child.unref();
494
+ const deadline = Date.now() + timeoutMs;
495
+ while (Date.now() < deadline) {
496
+ if (await agentPing())
497
+ return true;
498
+ await new Promise((r) => setTimeout(r, 100));
499
+ }
500
+ return false;
501
+ }
@@ -23,11 +23,23 @@ export interface VarMeta {
23
23
  /** Singular freeform note. */
24
24
  note?: string;
25
25
  }
26
+ /**
27
+ * How a bundle interacts with the macOS secrets-agent:
28
+ * - `biometry` (default): only an explicit `agents secrets unlock` populates the
29
+ * agent; every other read pops Touch ID. Use for high-value bundles you want
30
+ * to confirm each session.
31
+ * - `session`: eligible for the agent — `unlock`, and (when `secrets.agent.auto`
32
+ * is enabled) the first real keychain read auto-loads it so concurrent runs
33
+ * read it silently.
34
+ */
35
+ export type SecretsTier = 'biometry' | 'session';
26
36
  /** A named set of environment variable definitions backed by various secret providers. */
27
37
  export interface SecretsBundle {
28
38
  name: string;
29
39
  description?: string;
30
40
  allow_exec?: boolean;
41
+ /** Secrets-agent interaction tier. Absent ⇒ `biometry` (the safe default). */
42
+ tier?: SecretsTier;
31
43
  /** ISO 8601 UTC timestamp. Set once on the first writeBundle() for a bundle. */
32
44
  created_at?: string;
33
45
  /** ISO 8601 UTC timestamp. Refreshed on every writeBundle(). */
@@ -61,6 +73,8 @@ export declare function validateSecretType(t: string): asserts t is SecretType;
61
73
  export declare function validateExpiresFutureDated(iso: string): void;
62
74
  export declare function bundleExists(name: string): boolean;
63
75
  export declare function readBundle(name: string): SecretsBundle;
76
+ /** The effective tier of a bundle (absent ⇒ `biometry`). */
77
+ export declare function bundleTier(bundle: SecretsBundle): SecretsTier;
64
78
  export declare function writeBundle(bundle: SecretsBundle): void;
65
79
  export declare function deleteBundle(name: string): boolean;
66
80
  export declare function listBundles(): SecretsBundle[];
@@ -80,6 +94,13 @@ export interface ResolveBundleOptions {
80
94
  * about to read the bundle.
81
95
  */
82
96
  caller?: string;
97
+ /**
98
+ * Skip the secrets-agent fast-path and read straight from the keychain
99
+ * (popping Touch ID). Set by callers that must NOT serve a cached snapshot —
100
+ * `unlock` (which populates the agent in the first place) and any flow that
101
+ * needs live values. Also honored via AGENTS_SECRETS_NO_AGENT=1.
102
+ */
103
+ noAgent?: boolean;
83
104
  }
84
105
  export declare function resolveBundleEnv(bundle: SecretsBundle, _opts?: ResolveBundleOptions): Record<string, string>;
85
106
  /**
@@ -17,6 +17,7 @@ import * as path from 'path';
17
17
  import * as yaml from 'yaml';
18
18
  import { deleteKeychainToken, getKeychainToken, getKeychainTokens, hasKeychainToken, listKeychainItems, parseBundleValue, resolveRef, secretsKeychainItem, setKeychainToken, } from './index.js';
19
19
  import { emit } from '../events.js';
20
+ import { agentGetSync, agentAutoLoadSync, secretsAgentAutoEnabled, DEFAULT_TTL_MS } from './agent.js';
20
21
  /** Allowed values for a secret's `type` metadata field. */
21
22
  export const SECRET_TYPES = [
22
23
  'api-key',
@@ -142,6 +143,7 @@ export function readBundle(name) {
142
143
  name,
143
144
  description: parsed.description,
144
145
  allow_exec: Boolean(parsed.allow_exec),
146
+ tier: parseTier(parsed.tier),
145
147
  vars: parsed.vars && typeof parsed.vars === 'object' ? parsed.vars : {},
146
148
  };
147
149
  if (typeof parsed.created_at === 'string')
@@ -158,6 +160,14 @@ export function readBundle(name) {
158
160
  }
159
161
  return bundle;
160
162
  }
163
+ /** Normalize a persisted `tier` value; anything but `session` ⇒ default tier. */
164
+ function parseTier(raw) {
165
+ return raw === 'session' ? 'session' : undefined;
166
+ }
167
+ /** The effective tier of a bundle (absent ⇒ `biometry`). */
168
+ export function bundleTier(bundle) {
169
+ return bundle.tier ?? 'biometry';
170
+ }
161
171
  export function writeBundle(bundle) {
162
172
  validateBundleName(bundle.name);
163
173
  for (const key of Object.keys(bundle.vars)) {
@@ -191,6 +201,7 @@ export function writeBundle(bundle) {
191
201
  const payload = {
192
202
  description: bundle.description,
193
203
  allow_exec: bundle.allow_exec ? true : undefined,
204
+ tier: bundle.tier === 'session' ? 'session' : undefined,
194
205
  created_at: bundle.created_at,
195
206
  updated_at: bundle.updated_at,
196
207
  last_used: bundle.last_used,
@@ -249,6 +260,7 @@ export function listBundles() {
249
260
  name,
250
261
  description: parsed.description,
251
262
  allow_exec: Boolean(parsed.allow_exec),
263
+ tier: parseTier(parsed.tier),
252
264
  vars: parsed.vars && typeof parsed.vars === 'object' ? parsed.vars : {},
253
265
  };
254
266
  if (typeof parsed.created_at === 'string')
@@ -375,6 +387,25 @@ export function resolveBundleEnv(bundle, _opts = {}) {
375
387
  */
376
388
  export function readAndResolveBundleEnv(name, opts = {}) {
377
389
  validateBundleName(name);
390
+ // Fast-path: if the secrets-agent holds this bundle (user ran
391
+ // `agents secrets unlock <name>`), return the cached snapshot with no Touch
392
+ // ID. Soft — any failure falls through to the real keychain read below. macOS
393
+ // only; the never-unlocked path is a single stat (agentSocketExists) so it
394
+ // costs nothing when the agent isn't running.
395
+ if (!opts.noAgent && process.env.AGENTS_SECRETS_NO_AGENT !== '1') {
396
+ const hit = agentGetSync(name);
397
+ if (hit) {
398
+ stampLastUsed(hit.bundle);
399
+ emit('secrets.get', {
400
+ bundle: name,
401
+ caller: opts.caller,
402
+ status: 'success',
403
+ source: 'agent',
404
+ keyCount: Object.keys(hit.env).length,
405
+ });
406
+ return hit;
407
+ }
408
+ }
378
409
  const metaItem = bundleMetaItem(name);
379
410
  const bundleSecretPrefix = `${SECRETS_ITEM_PREFIX}${name}.`;
380
411
  let secretItems;
@@ -407,6 +438,7 @@ export function readAndResolveBundleEnv(name, opts = {}) {
407
438
  name,
408
439
  description: parsed.description,
409
440
  allow_exec: Boolean(parsed.allow_exec),
441
+ tier: parseTier(parsed.tier),
410
442
  vars: parsed.vars && typeof parsed.vars === 'object' ? parsed.vars : {},
411
443
  };
412
444
  if (typeof parsed.created_at === 'string')
@@ -476,6 +508,17 @@ export function readAndResolveBundleEnv(name, opts = {}) {
476
508
  }
477
509
  }
478
510
  emitReadAudit('success');
511
+ // Auto-cache: this was a real keychain read (the agent fast-path returned
512
+ // earlier on a hit). If the bundle opts into the session tier and the user
513
+ // enabled `secrets.agent.auto`, populate the broker in the background so the
514
+ // next concurrent run reads silently. Skipped when noAgent (e.g. `unlock`,
515
+ // which loads the agent itself). Fire-and-forget — never blocks this read.
516
+ if (!opts.noAgent &&
517
+ process.env.AGENTS_SECRETS_NO_AGENT !== '1' &&
518
+ bundleTier(bundle) === 'session' &&
519
+ secretsAgentAutoEnabled()) {
520
+ agentAutoLoadSync(name, bundle, env, DEFAULT_TTL_MS);
521
+ }
479
522
  return { bundle, env };
480
523
  }
481
524
  catch (err) {
@@ -0,0 +1,14 @@
1
+ /**
2
+ * Rush `SyncBackend` driver — the original (and currently default) transport
3
+ * for `agents secrets push/pull`. Talks to api.prix.dev and authenticates with
4
+ * the session token written by `rush login` (`~/.rush/user.yaml`).
5
+ *
6
+ * This is the ONE place in the secrets module allowed to reference Rush
7
+ * (api.prix.dev / ~/.rush). It is an opt-in driver kept for backwards
8
+ * compatibility with bundles already pushed to Rush; `sync.ts` selects it as
9
+ * the default but the transport seam (`SyncBackend`) lets other backends drop
10
+ * in without touching the crypto or push/pull logic.
11
+ */
12
+ import type { SyncBackend } from '../sync-backend.js';
13
+ /** The Rush transport. Plaintext never reaches here — only ciphertext envelopes. */
14
+ export declare const rushSyncBackend: SyncBackend;