@phnx-labs/agents-cli 1.20.17 → 1.20.19

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 (66) 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 +343 -16
  11. package/dist/commands/sessions.js +4 -0
  12. package/dist/index.js +4 -0
  13. package/dist/lib/budget/config.d.ts +9 -0
  14. package/dist/lib/budget/config.js +115 -0
  15. package/dist/lib/budget/enforce.d.ts +94 -0
  16. package/dist/lib/budget/enforce.js +151 -0
  17. package/dist/lib/budget/ledger.d.ts +61 -0
  18. package/dist/lib/budget/ledger.js +107 -0
  19. package/dist/lib/budget/preflight.d.ts +110 -0
  20. package/dist/lib/budget/preflight.js +200 -0
  21. package/dist/lib/checkpoint.d.ts +54 -0
  22. package/dist/lib/checkpoint.js +56 -0
  23. package/dist/lib/cloud/rush.js +18 -0
  24. package/dist/lib/exec.d.ts +36 -0
  25. package/dist/lib/exec.js +192 -4
  26. package/dist/lib/git.d.ts +18 -0
  27. package/dist/lib/git.js +67 -4
  28. package/dist/lib/loop.d.ts +145 -0
  29. package/dist/lib/loop.js +330 -0
  30. package/dist/lib/mcp.d.ts +7 -0
  31. package/dist/lib/mcp.js +24 -0
  32. package/dist/lib/models.d.ts +11 -0
  33. package/dist/lib/models.js +21 -0
  34. package/dist/lib/plugins.js +5 -2
  35. package/dist/lib/pricing/cost.d.ts +46 -0
  36. package/dist/lib/pricing/cost.js +71 -0
  37. package/dist/lib/pricing/index.d.ts +8 -0
  38. package/dist/lib/pricing/index.js +8 -0
  39. package/dist/lib/pricing/prices.json +138 -0
  40. package/dist/lib/pricing/table.d.ts +17 -0
  41. package/dist/lib/pricing/table.js +73 -0
  42. package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
  43. package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
  44. package/dist/lib/secrets/agent.d.ts +147 -0
  45. package/dist/lib/secrets/agent.js +500 -0
  46. package/dist/lib/secrets/bundles.d.ts +58 -7
  47. package/dist/lib/secrets/bundles.js +264 -75
  48. package/dist/lib/secrets/filestore.d.ts +82 -0
  49. package/dist/lib/secrets/filestore.js +295 -0
  50. package/dist/lib/secrets/linux.d.ts +6 -24
  51. package/dist/lib/secrets/linux.js +22 -265
  52. package/dist/lib/session/db.d.ts +40 -0
  53. package/dist/lib/session/db.js +84 -2
  54. package/dist/lib/session/discover.d.ts +2 -0
  55. package/dist/lib/session/discover.js +126 -2
  56. package/dist/lib/session/render.d.ts +2 -0
  57. package/dist/lib/session/render.js +1 -1
  58. package/dist/lib/session/types.d.ts +4 -0
  59. package/dist/lib/teams/agents.d.ts +32 -0
  60. package/dist/lib/teams/agents.js +66 -3
  61. package/dist/lib/teams/api.js +20 -0
  62. package/dist/lib/teams/parsers.js +16 -4
  63. package/dist/lib/types.d.ts +48 -0
  64. package/dist/lib/workflows.d.ts +56 -0
  65. package/dist/lib/workflows.js +72 -5
  66. package/package.json +2 -1
@@ -0,0 +1,500 @@
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 with a hidden subcommand, so a side-by-side dev
65
+ * build spawns its own helpers rather than the registry-installed one. We always
66
+ * go 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 as
68
+ * `node dist/index.js`, no +x), so spawning it directly EACCES'd.
69
+ */
70
+ function cliSpawn(sub) {
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, ...sub] };
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: sub };
82
+ }
83
+ function brokerSpawn() {
84
+ return cliSpawn(['secrets', '_agent-run']);
85
+ }
86
+ // ─── Broker server (runs in the detached `secrets _agent-run` process) ───────
87
+ /**
88
+ * Pure request handler over the in-memory store. Extracted so the store
89
+ * semantics (lazy expiry on get/status, lock-one vs lock-all, load TTL) are
90
+ * unit-testable with a controlled `now`, without a socket or a spawned process.
91
+ * Mutates `store` in place; returns the wire response.
92
+ */
93
+ export function handleAgentRequest(store, req, now = Date.now()) {
94
+ switch (req.cmd) {
95
+ case 'ping':
96
+ return { ok: true, cmd: 'ping', version: PROTOCOL_VERSION };
97
+ case 'get': {
98
+ const e = store.get(req.name);
99
+ if (!e || now >= e.expiresAt) {
100
+ if (e)
101
+ store.delete(req.name); // drop expired on read
102
+ return { ok: true, cmd: 'get', hit: false };
103
+ }
104
+ return { ok: true, cmd: 'get', hit: true, bundle: e.bundle, env: e.env };
105
+ }
106
+ case 'load':
107
+ store.set(req.name, { bundle: req.bundle, env: req.env, expiresAt: now + req.ttlMs });
108
+ return { ok: true, cmd: 'load' };
109
+ case 'lock': {
110
+ if (req.name) {
111
+ return { ok: true, cmd: 'lock', wiped: store.delete(req.name) ? 1 : 0 };
112
+ }
113
+ const wiped = store.size;
114
+ store.clear();
115
+ return { ok: true, cmd: 'lock', wiped };
116
+ }
117
+ case 'status': {
118
+ const entries = [];
119
+ for (const [name, e] of store) {
120
+ if (now >= e.expiresAt)
121
+ continue;
122
+ entries.push({ name, expiresAt: e.expiresAt, keyCount: Object.keys(e.env).length });
123
+ }
124
+ return { ok: true, cmd: 'status', entries };
125
+ }
126
+ }
127
+ }
128
+ /**
129
+ * Run the broker in the foreground. Spawned detached by ensureAgentRunning via
130
+ * `agents secrets _agent-run`. Holds the store in memory, serves the socket,
131
+ * sweeps expired entries, wipes on screen-lock/sleep, and self-exits when idle.
132
+ */
133
+ export async function runSecretsAgent() {
134
+ if (!onDarwin())
135
+ return; // nothing to broker without biometry prompts
136
+ // Single-instance guard: O_EXCL pid file. If a live broker already holds it,
137
+ // exit quietly — the existing one keeps serving.
138
+ const pidFile = pidPath();
139
+ try {
140
+ const fd = fs.openSync(pidFile, fs.constants.O_CREAT | fs.constants.O_EXCL | fs.constants.O_WRONLY);
141
+ fs.writeSync(fd, String(process.pid));
142
+ fs.closeSync(fd);
143
+ }
144
+ catch (err) {
145
+ if (err?.code === 'EEXIST') {
146
+ const holder = parseInt(fs.readFileSync(pidFile, 'utf-8').trim(), 10);
147
+ if (!isNaN(holder) && isAlive(holder))
148
+ return; // another broker is live
149
+ // Stale pid — reclaim it.
150
+ try {
151
+ fs.unlinkSync(pidFile);
152
+ }
153
+ catch { /* race; fall through */ }
154
+ fs.writeFileSync(pidFile, String(process.pid));
155
+ }
156
+ else {
157
+ throw err;
158
+ }
159
+ }
160
+ const store = new Map();
161
+ // emptySince tracks the last moment the store held something; the sweep exits
162
+ // the process once it's been empty for IDLE_EXIT_MS so no idle broker lingers.
163
+ let emptySince = Date.now();
164
+ const sock = socketPath();
165
+ try {
166
+ fs.unlinkSync(sock);
167
+ }
168
+ catch { /* no stale socket */ }
169
+ const sweep = () => {
170
+ const now = Date.now();
171
+ for (const [name, e] of store)
172
+ if (now >= e.expiresAt)
173
+ store.delete(name);
174
+ if (store.size === 0) {
175
+ if (now - emptySince >= IDLE_EXIT_MS)
176
+ shutdown(0);
177
+ }
178
+ else {
179
+ emptySince = now;
180
+ }
181
+ };
182
+ const handle = (req) => {
183
+ const resp = handleAgentRequest(store, req);
184
+ if (store.size > 0)
185
+ emptySince = Date.now();
186
+ return resp;
187
+ };
188
+ const server = net.createServer((conn) => {
189
+ conn.setEncoding('utf-8');
190
+ let buf = '';
191
+ conn.on('data', (chunk) => {
192
+ buf += chunk;
193
+ let nl;
194
+ while ((nl = buf.indexOf('\n')) >= 0) {
195
+ const line = buf.slice(0, nl);
196
+ buf = buf.slice(nl + 1);
197
+ if (!line.trim())
198
+ continue;
199
+ let resp;
200
+ try {
201
+ resp = handle(JSON.parse(line));
202
+ }
203
+ catch (err) {
204
+ resp = { ok: false, error: err.message };
205
+ }
206
+ conn.write(JSON.stringify(resp) + '\n');
207
+ }
208
+ });
209
+ conn.on('error', () => { });
210
+ });
211
+ let watcher = null;
212
+ let sweepTimer = null;
213
+ let shuttingDown = false;
214
+ const shutdown = (code) => {
215
+ if (shuttingDown)
216
+ return;
217
+ shuttingDown = true;
218
+ store.clear();
219
+ if (sweepTimer)
220
+ clearInterval(sweepTimer);
221
+ try {
222
+ watcher?.kill();
223
+ }
224
+ catch { /* already gone */ }
225
+ try {
226
+ server.close();
227
+ }
228
+ catch { /* not listening */ }
229
+ try {
230
+ fs.unlinkSync(sock);
231
+ }
232
+ catch { /* gone */ }
233
+ try {
234
+ if (parseInt(fs.readFileSync(pidFile, 'utf-8').trim(), 10) === process.pid)
235
+ fs.unlinkSync(pidFile);
236
+ }
237
+ catch { /* gone */ }
238
+ process.exit(code);
239
+ };
240
+ process.on('SIGTERM', () => shutdown(0));
241
+ process.on('SIGINT', () => shutdown(0));
242
+ await new Promise((resolve, reject) => {
243
+ server.once('error', reject);
244
+ server.listen(sock, () => {
245
+ try {
246
+ fs.chmodSync(sock, 0o600);
247
+ }
248
+ catch { /* dir 0700 already gates it */ }
249
+ resolve();
250
+ });
251
+ });
252
+ sweepTimer = setInterval(sweep, SWEEP_INTERVAL_MS);
253
+ // Auto-lock on screen-lock / sleep. The signed helper emits LOCK / SLEEP
254
+ // lines; on any of them we wipe everything. If the installed helper predates
255
+ // watch-lock (exits non-zero immediately), we fall back to TTL-only and log
256
+ // nothing — the unlock already warned when lock_on_sleep couldn't be armed.
257
+ try {
258
+ watcher = spawn(getKeychainHelperPath(), ['watch-lock'], { stdio: ['ignore', 'pipe', 'ignore'] });
259
+ watcher.stdout?.setEncoding('utf-8');
260
+ watcher.stdout?.on('data', (chunk) => {
261
+ if (/\b(LOCK|SLEEP)\b/.test(chunk)) {
262
+ store.clear();
263
+ emptySince = Date.now();
264
+ }
265
+ });
266
+ watcher.on('error', () => { watcher = null; });
267
+ }
268
+ catch {
269
+ watcher = null;
270
+ }
271
+ }
272
+ // ─── Client ──────────────────────────────────────────────────────────────────
273
+ /** Open the socket, send one request, resolve the one response. Async path —
274
+ * used by the unlock/lock/status commands, which already run in async actions. */
275
+ function request(req, timeoutMs = 2000) {
276
+ return new Promise((resolve) => {
277
+ const conn = net.createConnection(socketPath());
278
+ let buf = '';
279
+ let done = false;
280
+ const finish = (r) => {
281
+ if (done)
282
+ return;
283
+ done = true;
284
+ clearTimeout(timer);
285
+ try {
286
+ conn.destroy();
287
+ }
288
+ catch { /* already closed */ }
289
+ resolve(r);
290
+ };
291
+ const timer = setTimeout(() => finish(null), timeoutMs);
292
+ conn.on('error', () => finish(null));
293
+ conn.on('connect', () => conn.write(JSON.stringify(req) + '\n'));
294
+ conn.setEncoding('utf-8');
295
+ conn.on('data', (chunk) => {
296
+ buf += chunk;
297
+ const nl = buf.indexOf('\n');
298
+ if (nl < 0)
299
+ return;
300
+ try {
301
+ finish(JSON.parse(buf.slice(0, nl)));
302
+ }
303
+ catch {
304
+ finish(null);
305
+ }
306
+ });
307
+ });
308
+ }
309
+ /** True if a broker socket exists at all. Cheap; gates the sync read so the
310
+ * never-unlocked path stays a single stat. */
311
+ export function agentSocketExists() {
312
+ return onDarwin() && fs.existsSync(socketPath());
313
+ }
314
+ /**
315
+ * Inline node program for the synchronous read fast-path. `readAndResolveBundleEnv`
316
+ * is synchronous and called synchronously everywhere, so we can't await a socket
317
+ * round-trip — but spawning the full CLI to do it would load every command. This
318
+ * minimal `node -e` client connects, asks for one bundle, prints the resolved
319
+ * {bundle, env} as JSON, and exits 0 (hit) / 3 (miss or agent down). argv after
320
+ * -e: [execPath, <socket>, <name>].
321
+ */
322
+ const SYNC_GET_PROGRAM = `
323
+ const net = require('net');
324
+ const sock = process.argv[1], name = process.argv[2];
325
+ const c = net.createConnection(sock);
326
+ let buf = '';
327
+ const miss = () => { try { c.destroy(); } catch (e) {} process.exit(3); };
328
+ const timer = setTimeout(miss, 2000);
329
+ c.on('error', miss);
330
+ c.on('connect', () => c.write(JSON.stringify({ cmd: 'get', name }) + '\\n'));
331
+ c.setEncoding('utf-8');
332
+ c.on('data', (d) => {
333
+ buf += d;
334
+ const nl = buf.indexOf('\\n');
335
+ if (nl < 0) return;
336
+ clearTimeout(timer);
337
+ let r; try { r = JSON.parse(buf.slice(0, nl)); } catch (e) { return miss(); }
338
+ try { c.destroy(); } catch (e) {}
339
+ if (r && r.ok && r.hit) { process.stdout.write(JSON.stringify({ bundle: r.bundle, env: r.env })); process.exit(0); }
340
+ process.exit(3);
341
+ });
342
+ `;
343
+ /**
344
+ * Synchronous read for the hot path. Returns the cached resolved bundle, or
345
+ * null if the agent isn't running / doesn't hold this bundle / anything fails
346
+ * (soft — caller falls through to the real keychain). macOS only.
347
+ */
348
+ export function agentGetSync(name) {
349
+ if (!agentSocketExists())
350
+ return null;
351
+ const r = spawnSync(process.execPath, ['-e', SYNC_GET_PROGRAM, socketPath(), name], {
352
+ encoding: 'utf-8',
353
+ timeout: 3000,
354
+ });
355
+ if (r.status !== 0 || !r.stdout)
356
+ return null;
357
+ try {
358
+ const o = JSON.parse(r.stdout);
359
+ if (!o || typeof o !== 'object' || !o.env)
360
+ return null;
361
+ return { bundle: o.bundle, env: o.env };
362
+ }
363
+ catch {
364
+ return null;
365
+ }
366
+ }
367
+ /** True when `secrets.agent.auto` is enabled in agents.yaml. Best-effort; a
368
+ * missing/unreadable meta reads as off. */
369
+ export function secretsAgentAutoEnabled() {
370
+ try {
371
+ return readMeta().secrets?.agent?.auto === true;
372
+ }
373
+ catch {
374
+ return false;
375
+ }
376
+ }
377
+ /**
378
+ * Fire-and-forget: populate the broker with a freshly-resolved bundle so the
379
+ * NEXT process reads it without a prompt. Used by the auto-cache path after a
380
+ * real keychain read of a `session`-tier bundle. Adds no latency to the caller
381
+ * — it spawns a detached `secrets _agent-load` worker (passing the resolved env
382
+ * over stdin, never argv) and returns immediately.
383
+ *
384
+ * The worker reuses the robust `ensureAgentRunning` path (spawn-then-ping with a
385
+ * generous budget) rather than a tight inline retry loop: under heavy load the
386
+ * broker is itself a cold-starting full CLI and can take several seconds to bind
387
+ * the socket, so a short fixed budget would give up before it's ready and the
388
+ * cache would silently never populate. Best-effort; never throws. macOS only.
389
+ */
390
+ export function agentAutoLoadSync(name, bundle, env, ttlMs) {
391
+ if (!onDarwin())
392
+ return;
393
+ try {
394
+ const { cmd, args } = cliSpawn(['secrets', '_agent-load']);
395
+ const worker = spawn(cmd, args, { stdio: ['pipe', 'ignore', 'ignore'], detached: true });
396
+ worker.stdin?.write(JSON.stringify({ name, bundle, env, ttlMs }));
397
+ worker.stdin?.end();
398
+ worker.unref();
399
+ }
400
+ catch {
401
+ // best-effort: the next read just pops Touch ID as it would today
402
+ }
403
+ }
404
+ /**
405
+ * Body of the hidden `secrets _agent-load` worker. Reads one `{name, bundle,
406
+ * env, ttlMs}` payload from stdin, ensures the broker is up (robust, generous
407
+ * budget), and loads the bundle into it. Detached from the originating read, so
408
+ * its latency is invisible — which is why it can afford a long ensure budget.
409
+ */
410
+ export async function runAgentLoadFromStdin() {
411
+ if (!onDarwin())
412
+ return;
413
+ const chunks = [];
414
+ for await (const chunk of process.stdin)
415
+ chunks.push(chunk);
416
+ let payload;
417
+ try {
418
+ payload = JSON.parse(Buffer.concat(chunks).toString('utf-8'));
419
+ }
420
+ catch {
421
+ return; // malformed payload — nothing to load
422
+ }
423
+ if (!payload || !payload.name || !payload.bundle || !payload.env)
424
+ return;
425
+ // Generous budget: the broker is a cold-starting full CLI; under load it can
426
+ // take several seconds to bind. We're detached, so waiting costs nothing.
427
+ if (!(await ensureAgentRunning(20000)))
428
+ return;
429
+ await agentLoad(payload.name, payload.bundle, payload.env, payload.ttlMs ?? DEFAULT_TTL_MS);
430
+ }
431
+ /** Store a resolved bundle in the broker. Returns false on transport failure. */
432
+ export async function agentLoad(name, bundle, env, ttlMs) {
433
+ const r = await request({ cmd: 'load', name, bundle, env, ttlMs });
434
+ return r?.ok === true && r.cmd === 'load';
435
+ }
436
+ /** Wipe one bundle (or all if name omitted) from the broker. Returns the count
437
+ * wiped, or 0 when no broker is running. */
438
+ export async function agentLock(name) {
439
+ const r = await request({ cmd: 'lock', name });
440
+ return r?.ok === true && r.cmd === 'lock' ? r.wiped : 0;
441
+ }
442
+ /** List currently-unlocked bundles, or [] when no broker is running. */
443
+ export async function agentStatus() {
444
+ const r = await request({ cmd: 'status' });
445
+ return r?.ok === true && r.cmd === 'status' ? r.entries : [];
446
+ }
447
+ /** Is a broker live and speaking our protocol version? */
448
+ async function agentPing() {
449
+ if (!agentSocketExists())
450
+ return false;
451
+ const r = await request({ cmd: 'ping' });
452
+ return r?.ok === true && r.cmd === 'ping' && r.version === PROTOCOL_VERSION;
453
+ }
454
+ /**
455
+ * Ensure a broker is running and reachable, spawning one detached if not.
456
+ * Returns true once the socket answers a ping. On protocol-version skew, kills
457
+ * the stale broker and respawns. macOS only.
458
+ */
459
+ export async function ensureAgentRunning(timeoutMs = 5000) {
460
+ if (!onDarwin())
461
+ return false;
462
+ if (await agentPing())
463
+ return true;
464
+ // Socket exists but ping failed → stale/old broker. Kill it before respawn.
465
+ const stalePid = (() => {
466
+ try {
467
+ return parseInt(fs.readFileSync(pidPath(), 'utf-8').trim(), 10);
468
+ }
469
+ catch {
470
+ return NaN;
471
+ }
472
+ })();
473
+ if (!isNaN(stalePid) && isAlive(stalePid)) {
474
+ try {
475
+ process.kill(stalePid, 'SIGTERM');
476
+ }
477
+ catch { /* already dead */ }
478
+ }
479
+ try {
480
+ fs.unlinkSync(socketPath());
481
+ }
482
+ catch { /* gone */ }
483
+ try {
484
+ fs.unlinkSync(pidPath());
485
+ }
486
+ catch { /* gone */ }
487
+ const { cmd, args } = brokerSpawn();
488
+ const child = spawn(cmd, args, {
489
+ stdio: 'ignore',
490
+ detached: true,
491
+ });
492
+ child.unref();
493
+ const deadline = Date.now() + timeoutMs;
494
+ while (Date.now() < deadline) {
495
+ if (await agentPing())
496
+ return true;
497
+ await new Promise((r) => setTimeout(r, 100));
498
+ }
499
+ return false;
500
+ }
@@ -1,17 +1,32 @@
1
1
  /**
2
- * Secret bundles — named sets of keychain-backed environment variables.
2
+ * Secret bundles — named sets of environment variables backed by a secret store.
3
3
  *
4
- * Bundle metadata (name, description, vars map) is stored in the macOS
5
- * Keychain as a JSON blob under `agents-cli.bundles.<name>`. Secret values
6
- * live one per keychain item under `agents-cli.secrets.<bundle>.<key>`.
7
- * Every item is device-local and gated by Touch ID / device passcode — see
8
- * src/lib/secrets/index.ts for the access-control story. Nothing about
9
- * secrets ever lives in plaintext on disk.
4
+ * Bundle metadata (name, description, vars map) is stored as a JSON blob under
5
+ * `agents-cli.bundles.<name>`; secret values live one per item under
6
+ * `agents-cli.secrets.<bundle>.<key>`. Two backends carry those items:
7
+ *
8
+ * - `keychain` (default): the macOS Keychain (device-local, Touch ID / device
9
+ * passcode gated) or Linux libsecret see src/lib/secrets/index.ts.
10
+ * - `file`: an AES-256-GCM encrypted-file store keyed by a passphrase
11
+ * (src/lib/secrets/filestore.ts). Opt-in, for headless / remote runs where
12
+ * no biometry prompt can be satisfied (e.g. a release on a remote Mac over
13
+ * SSH). The item-name scheme is identical, so the only difference is where
14
+ * bytes land. A file-backed bundle is discovered by the presence of its
15
+ * metadata item in the file store.
10
16
  *
11
17
  * Cross-machine sync is handled by src/lib/secrets/sync.ts via an explicit
12
18
  * encrypted export/import flow; the bundle layer is sync-agnostic.
13
19
  */
14
20
  import { type BundleValue, type SecretRef } from './index.js';
21
+ /** Which store carries a bundle's items. */
22
+ export type SecretsBackend = 'keychain' | 'file';
23
+ /**
24
+ * Discover a bundle's backend by location: a file-backed bundle's metadata
25
+ * item exists in the encrypted-file store. This is a plain file-existence
26
+ * check — no passphrase, no Touch ID — so it sidesteps the chicken-and-egg of
27
+ * "read metadata to learn where metadata lives." Absent ⇒ keychain.
28
+ */
29
+ export declare function bundleBackend(name: string): SecretsBackend;
15
30
  /** Allowed values for a secret's `type` metadata field. */
16
31
  export declare const SECRET_TYPES: readonly ["api-key", "token", "password", "url", "database-url", "ssh-key", "certificate", "webhook", "note"];
17
32
  export type SecretType = typeof SECRET_TYPES[number];
@@ -23,11 +38,25 @@ export interface VarMeta {
23
38
  /** Singular freeform note. */
24
39
  note?: string;
25
40
  }
41
+ /**
42
+ * How a bundle interacts with the macOS secrets-agent:
43
+ * - `biometry` (default): only an explicit `agents secrets unlock` populates the
44
+ * agent; every other read pops Touch ID. Use for high-value bundles you want
45
+ * to confirm each session.
46
+ * - `session`: eligible for the agent — `unlock`, and (when `secrets.agent.auto`
47
+ * is enabled) the first real keychain read auto-loads it so concurrent runs
48
+ * read it silently.
49
+ */
50
+ export type SecretsTier = 'biometry' | 'session';
26
51
  /** A named set of environment variable definitions backed by various secret providers. */
27
52
  export interface SecretsBundle {
28
53
  name: string;
29
54
  description?: string;
30
55
  allow_exec?: boolean;
56
+ /** Which store carries this bundle's items. Absent ⇒ `keychain` (the default). */
57
+ backend?: SecretsBackend;
58
+ /** Secrets-agent interaction tier. Absent ⇒ `biometry` (the safe default). */
59
+ tier?: SecretsTier;
31
60
  /** ISO 8601 UTC timestamp. Set once on the first writeBundle() for a bundle. */
32
61
  created_at?: string;
33
62
  /** ISO 8601 UTC timestamp. Refreshed on every writeBundle(). */
@@ -61,6 +90,8 @@ export declare function validateSecretType(t: string): asserts t is SecretType;
61
90
  export declare function validateExpiresFutureDated(iso: string): void;
62
91
  export declare function bundleExists(name: string): boolean;
63
92
  export declare function readBundle(name: string): SecretsBundle;
93
+ /** The effective tier of a bundle (absent ⇒ `biometry`). */
94
+ export declare function bundleTier(bundle: SecretsBundle): SecretsTier;
64
95
  export declare function writeBundle(bundle: SecretsBundle): void;
65
96
  export declare function deleteBundle(name: string): boolean;
66
97
  export declare function listBundles(): SecretsBundle[];
@@ -80,6 +111,13 @@ export interface ResolveBundleOptions {
80
111
  * about to read the bundle.
81
112
  */
82
113
  caller?: string;
114
+ /**
115
+ * Skip the secrets-agent fast-path and read straight from the keychain
116
+ * (popping Touch ID). Set by callers that must NOT serve a cached snapshot —
117
+ * `unlock` (which populates the agent in the first place) and any flow that
118
+ * needs live values. Also honored via AGENTS_SECRETS_NO_AGENT=1.
119
+ */
120
+ noAgent?: boolean;
83
121
  }
84
122
  export declare function resolveBundleEnv(bundle: SecretsBundle, _opts?: ResolveBundleOptions): Record<string, string>;
85
123
  /**
@@ -135,6 +173,19 @@ export interface RenameOptions {
135
173
  * a safe no-op for the source items.
136
174
  */
137
175
  export declare function renameBundle(oldName: string, newName: string, opts?: RenameOptions): void;
176
+ /**
177
+ * The store (keychain or encrypted file) that carries a bundle's items. The
178
+ * CLI uses this to read/write/delete per-key items (built with
179
+ * secretsKeychainItem) in the same store as the bundle's metadata, for `add` /
180
+ * `import` / `remove` / `delete`. Pass the bundle's resolved backend
181
+ * (`bundle.backend ?? 'keychain'`).
182
+ */
183
+ export declare function bundleItemStore(backend: SecretsBackend | undefined): {
184
+ set(item: string, value: string): void;
185
+ delete(item: string): boolean;
186
+ get(item: string): string;
187
+ has(item: string): boolean;
188
+ };
138
189
  export declare function keychainItemsForBundle(bundle: SecretsBundle): Array<{
139
190
  key: string;
140
191
  item: string;