@phnx-labs/agents-cli 1.20.18 → 1.20.20

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.
@@ -24,6 +24,7 @@
24
24
  */
25
25
  import * as net from 'net';
26
26
  import * as fs from 'fs';
27
+ import * as os from 'os';
27
28
  import * as path from 'path';
28
29
  import { spawn, spawnSync, execFileSync } from 'child_process';
29
30
  import { getHelpersDir, readMeta } from '../state.js';
@@ -61,24 +62,134 @@ function pidPath() {
61
62
  return path.join(agentDir(), 'agent.pid');
62
63
  }
63
64
  /**
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.
65
+ * Argv for re-invoking THIS cli with a hidden subcommand, so a side-by-side dev
66
+ * build spawns its own helpers rather than the registry-installed one. We always
67
+ * go through `process.execPath` (the node binary) with the JS entrypoint as the
68
+ * first arg — the entrypoint isn't reliably executable in dev builds (invoked as
69
+ * `node dist/index.js`, no +x), so spawning it directly EACCES'd.
69
70
  */
70
- function brokerSpawn() {
71
+ function cliSpawn(sub) {
71
72
  const argv1 = process.argv[1];
72
73
  const entry = argv1 && fs.existsSync(argv1) ? argv1 : null;
73
74
  if (entry)
74
- return { cmd: process.execPath, args: [entry, 'secrets', '_agent-run'] };
75
+ return { cmd: process.execPath, args: [entry, ...sub] };
75
76
  // No resolvable entrypoint (unusual) — fall back to the PATH shim.
76
77
  let bin = 'agents';
77
78
  try {
78
79
  bin = execFileSync('which', ['agents'], { encoding: 'utf-8' }).trim();
79
80
  }
80
81
  catch { /* default */ }
81
- return { cmd: bin, args: ['secrets', '_agent-run'] };
82
+ return { cmd: bin, args: sub };
83
+ }
84
+ function brokerSpawn() {
85
+ return cliSpawn(['secrets', '_agent-run']);
86
+ }
87
+ // ─── Persistent launchd service ──────────────────────────────────────────────
88
+ // On a heavily-loaded machine a freshly-spawned broker (a full CLI cold start)
89
+ // can't get scheduled enough CPU to finish booting and bind its socket — so the
90
+ // on-demand model fails exactly when there are many agents (the case we care
91
+ // about). The fix is to run the broker as a launchd user service: started once
92
+ // with RunAtLoad + KeepAlive, it stays up, and every read just connects. The
93
+ // cold start happens once (and launchd retries until it wins), never per-read.
94
+ const SERVICE_LABEL = 'com.phnx-labs.agents-secrets-agent';
95
+ function servicePlistPath() {
96
+ return path.join(os.homedir(), 'Library', 'LaunchAgents', `${SERVICE_LABEL}.plist`);
97
+ }
98
+ /** True if the launchd plist for the persistent broker is installed. */
99
+ export function secretsAgentServiceInstalled() {
100
+ return onDarwin() && fs.existsSync(servicePlistPath());
101
+ }
102
+ function generateServicePlist() {
103
+ const { cmd, args } = cliSpawn(['secrets', '_agent-run', '--service']);
104
+ const progArgs = [cmd, ...args]
105
+ .map((a) => ` <string>${a.replace(/&/g, '&amp;').replace(/</g, '&lt;')}</string>`)
106
+ .join('\n');
107
+ const logPath = path.join(agentDir(), 'service.log');
108
+ const home = os.homedir();
109
+ return `<?xml version="1.0" encoding="UTF-8"?>
110
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
111
+ <plist version="1.0">
112
+ <dict>
113
+ <key>Label</key>
114
+ <string>${SERVICE_LABEL}</string>
115
+ <key>ProgramArguments</key>
116
+ <array>
117
+ ${progArgs}
118
+ </array>
119
+ <key>RunAtLoad</key>
120
+ <true/>
121
+ <key>KeepAlive</key>
122
+ <true/>
123
+ <key>ProcessType</key>
124
+ <string>Interactive</string>
125
+ <key>StandardOutPath</key>
126
+ <string>${logPath}</string>
127
+ <key>StandardErrorPath</key>
128
+ <string>${logPath}</string>
129
+ <key>EnvironmentVariables</key>
130
+ <dict>
131
+ <key>PATH</key>
132
+ <string>/usr/local/bin:/usr/bin:/bin:/opt/homebrew/bin:${home}/.bun/bin</string>
133
+ </dict>
134
+ </dict>
135
+ </plist>`;
136
+ }
137
+ /**
138
+ * Install + start the persistent broker as a launchd user service (idempotent).
139
+ * Writes the plist, bootstraps it into the GUI domain, and waits for the socket.
140
+ * `ProcessType: Interactive` asks launchd to schedule it at foreground priority
141
+ * so it can boot even when the machine is loaded. Returns true once reachable.
142
+ */
143
+ export async function installSecretsAgentService(timeoutMs = 30000) {
144
+ if (!onDarwin())
145
+ return false;
146
+ const plist = servicePlistPath();
147
+ fs.mkdirSync(path.dirname(plist), { recursive: true });
148
+ fs.writeFileSync(plist, generateServicePlist());
149
+ const uid = process.getuid?.() ?? 0;
150
+ // bootstrap is the modern API; fall back to legacy load. Both idempotent-ish.
151
+ try {
152
+ execFileSync('launchctl', ['bootstrap', `gui/${uid}`, plist], { stdio: ['ignore', 'ignore', 'ignore'] });
153
+ }
154
+ catch {
155
+ try {
156
+ execFileSync('launchctl', ['load', '-w', plist], { stdio: ['ignore', 'ignore', 'ignore'] });
157
+ }
158
+ catch { /* may already be loaded */ }
159
+ }
160
+ // kickstart to force an immediate start even if already bootstrapped.
161
+ try {
162
+ execFileSync('launchctl', ['kickstart', '-k', `gui/${uid}/${SERVICE_LABEL}`], { stdio: ['ignore', 'ignore', 'ignore'] });
163
+ }
164
+ catch { /* best effort */ }
165
+ const deadline = Date.now() + timeoutMs;
166
+ while (Date.now() < deadline) {
167
+ if (await agentPing())
168
+ return true;
169
+ await new Promise((r) => setTimeout(r, 200));
170
+ }
171
+ return false;
172
+ }
173
+ /** Stop + remove the persistent broker service, and wipe whatever it held. */
174
+ export async function uninstallSecretsAgentService() {
175
+ if (!onDarwin())
176
+ return;
177
+ await agentLock(); // wipe the in-memory store before tearing down
178
+ const plist = servicePlistPath();
179
+ const uid = process.getuid?.() ?? 0;
180
+ try {
181
+ execFileSync('launchctl', ['bootout', `gui/${uid}/${SERVICE_LABEL}`], { stdio: ['ignore', 'ignore', 'ignore'] });
182
+ }
183
+ catch {
184
+ try {
185
+ execFileSync('launchctl', ['unload', '-w', plist], { stdio: ['ignore', 'ignore', 'ignore'] });
186
+ }
187
+ catch { /* not loaded */ }
188
+ }
189
+ try {
190
+ fs.unlinkSync(plist);
191
+ }
192
+ catch { /* already gone */ }
82
193
  }
83
194
  // ─── Broker server (runs in the detached `secrets _agent-run` process) ───────
84
195
  /**
@@ -127,9 +238,13 @@ export function handleAgentRequest(store, req, now = Date.now()) {
127
238
  * `agents secrets _agent-run`. Holds the store in memory, serves the socket,
128
239
  * sweeps expired entries, wipes on screen-lock/sleep, and self-exits when idle.
129
240
  */
130
- export async function runSecretsAgent() {
241
+ export async function runSecretsAgent(opts = {}) {
131
242
  if (!onDarwin())
132
243
  return; // nothing to broker without biometry prompts
244
+ // When launchd keeps us alive as a persistent service, never idle-exit:
245
+ // exiting would just make launchd cold-start us again, reintroducing the
246
+ // startup-under-load fragility the service exists to avoid.
247
+ const persistent = opts.service === true;
133
248
  // Single-instance guard: O_EXCL pid file. If a live broker already holds it,
134
249
  // exit quietly — the existing one keeps serving.
135
250
  const pidFile = pidPath();
@@ -169,7 +284,7 @@ export async function runSecretsAgent() {
169
284
  if (now >= e.expiresAt)
170
285
  store.delete(name);
171
286
  if (store.size === 0) {
172
- if (now - emptySince >= IDLE_EXIT_MS)
287
+ if (!persistent && now - emptySince >= IDLE_EXIT_MS)
173
288
  shutdown(0);
174
289
  }
175
290
  else {
@@ -371,64 +486,60 @@ export function secretsAgentAutoEnabled() {
371
486
  return false;
372
487
  }
373
488
  }
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
489
  /**
406
490
  * Fire-and-forget: populate the broker with a freshly-resolved bundle so the
407
491
  * NEXT process reads it without a prompt. Used by the auto-cache path after a
408
492
  * 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.
493
+ * — it spawns a detached `secrets _agent-load` worker (passing the resolved env
494
+ * over stdin, never argv) and returns immediately.
495
+ *
496
+ * The worker reuses the robust `ensureAgentRunning` path (spawn-then-ping with a
497
+ * generous budget) rather than a tight inline retry loop: under heavy load the
498
+ * broker is itself a cold-starting full CLI and can take several seconds to bind
499
+ * the socket, so a short fixed budget would give up before it's ready and the
500
+ * cache would silently never populate. Best-effort; never throws. macOS only.
411
501
  */
412
502
  export function agentAutoLoadSync(name, bundle, env, ttlMs) {
413
503
  if (!onDarwin())
414
504
  return;
415
505
  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();
506
+ const { cmd, args } = cliSpawn(['secrets', '_agent-load']);
507
+ const worker = spawn(cmd, args, { stdio: ['pipe', 'ignore', 'ignore'], detached: true });
508
+ worker.stdin?.write(JSON.stringify({ name, bundle, env, ttlMs }));
509
+ worker.stdin?.end();
510
+ worker.unref();
427
511
  }
428
512
  catch {
429
513
  // best-effort: the next read just pops Touch ID as it would today
430
514
  }
431
515
  }
516
+ /**
517
+ * Body of the hidden `secrets _agent-load` worker. Reads one `{name, bundle,
518
+ * env, ttlMs}` payload from stdin, ensures the broker is up (robust, generous
519
+ * budget), and loads the bundle into it. Detached from the originating read, so
520
+ * its latency is invisible — which is why it can afford a long ensure budget.
521
+ */
522
+ export async function runAgentLoadFromStdin() {
523
+ if (!onDarwin())
524
+ return;
525
+ const chunks = [];
526
+ for await (const chunk of process.stdin)
527
+ chunks.push(chunk);
528
+ let payload;
529
+ try {
530
+ payload = JSON.parse(Buffer.concat(chunks).toString('utf-8'));
531
+ }
532
+ catch {
533
+ return; // malformed payload — nothing to load
534
+ }
535
+ if (!payload || !payload.name || !payload.bundle || !payload.env)
536
+ return;
537
+ // Generous budget: the broker is a cold-starting full CLI; under load it can
538
+ // take several seconds to bind. We're detached, so waiting costs nothing.
539
+ if (!(await ensureAgentRunning(20000)))
540
+ return;
541
+ await agentLoad(payload.name, payload.bundle, payload.env, payload.ttlMs ?? DEFAULT_TTL_MS);
542
+ }
432
543
  /** Store a resolved bundle in the broker. Returns false on transport failure. */
433
544
  export async function agentLoad(name, bundle, env, ttlMs) {
434
545
  const r = await request({ cmd: 'load', name, bundle, env, ttlMs });
@@ -453,16 +564,43 @@ async function agentPing() {
453
564
  return r?.ok === true && r.cmd === 'ping' && r.version === PROTOCOL_VERSION;
454
565
  }
455
566
  /**
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.
567
+ * Ensure a broker is running and reachable. Returns true once the socket answers
568
+ * a ping. macOS only.
569
+ *
570
+ * Prefers the persistent launchd service: if it isn't installed we install it
571
+ * (which makes the broker survive for the whole login session, so subsequent
572
+ * reads never cold-start); if it's installed but unreachable we kickstart it.
573
+ * Only when the service path can't be used do we fall back to a one-off detached
574
+ * broker — that's the model that gets starved under heavy load, so it's last.
459
575
  */
460
576
  export async function ensureAgentRunning(timeoutMs = 5000) {
461
577
  if (!onDarwin())
462
578
  return false;
463
579
  if (await agentPing())
464
580
  return true;
465
- // Socket exists but ping failed → stale/old broker. Kill it before respawn.
581
+ // Path 1: the persistent service. installSecretsAgentService is idempotent and
582
+ // waits for the socket; for an already-installed service we kickstart and wait.
583
+ try {
584
+ if (!secretsAgentServiceInstalled()) {
585
+ if (await installSecretsAgentService(Math.max(timeoutMs, 20000)))
586
+ return true;
587
+ }
588
+ else {
589
+ const uid = process.getuid?.() ?? 0;
590
+ try {
591
+ execFileSync('launchctl', ['kickstart', '-k', `gui/${uid}/${SERVICE_LABEL}`], { stdio: ['ignore', 'ignore', 'ignore'] });
592
+ }
593
+ catch { /* may already be running */ }
594
+ const d = Date.now() + timeoutMs;
595
+ while (Date.now() < d) {
596
+ if (await agentPing())
597
+ return true;
598
+ await new Promise((r) => setTimeout(r, 150));
599
+ }
600
+ }
601
+ }
602
+ catch { /* fall through to the one-off spawn */ }
603
+ // Path 2 (fallback): one-off detached broker. Clear a stale socket/pid first.
466
604
  const stalePid = (() => {
467
605
  try {
468
606
  return parseInt(fs.readFileSync(pidPath(), 'utf-8').trim(), 10);
@@ -486,11 +624,7 @@ export async function ensureAgentRunning(timeoutMs = 5000) {
486
624
  }
487
625
  catch { /* gone */ }
488
626
  const { cmd, args } = brokerSpawn();
489
- const child = spawn(cmd, args, {
490
- stdio: 'ignore',
491
- detached: true,
492
- });
493
- child.unref();
627
+ spawn(cmd, args, { stdio: 'ignore', detached: true }).unref();
494
628
  const deadline = Date.now() + timeoutMs;
495
629
  while (Date.now() < deadline) {
496
630
  if (await agentPing())
@@ -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];
@@ -38,6 +53,8 @@ export interface SecretsBundle {
38
53
  name: string;
39
54
  description?: string;
40
55
  allow_exec?: boolean;
56
+ /** Which store carries this bundle's items. Absent ⇒ `keychain` (the default). */
57
+ backend?: SecretsBackend;
41
58
  /** Secrets-agent interaction tier. Absent ⇒ `biometry` (the safe default). */
42
59
  tier?: SecretsTier;
43
60
  /** ISO 8601 UTC timestamp. Set once on the first writeBundle() for a bundle. */
@@ -156,6 +173,19 @@ export interface RenameOptions {
156
173
  * a safe no-op for the source items.
157
174
  */
158
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
+ };
159
189
  export declare function keychainItemsForBundle(bundle: SecretsBundle): Array<{
160
190
  key: string;
161
191
  item: string;