@phnx-labs/agents-cli 1.20.18 → 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.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,10 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ **Fix: secrets-agent auto-cache now survives a slow broker cold-start under load**
6
+
7
+ - `secrets.agent.auto` (auto-cache on first read of a `session`-tier bundle) used a fire-and-forget inline loader that gave up connecting to the broker after 3s. But the broker it spawns is itself a full CLI cold-starting; under heavy load (many concurrent agents) that can exceed 3s, so the loader quit before the broker bound and the cache silently never populated — every read kept prompting. The auto-load now runs through a detached `secrets _agent-load` worker that reuses the robust `ensureAgentRunning` path (spawn-then-ping, 20s budget) and loads synchronously, so it reliably populates even when the broker is slow to start. Manual `agents secrets unlock` was always reliable and is unchanged. (secret values still travel over stdin, never argv.)
8
+
5
9
  **`agents secrets unlock`: a secrets-agent that ends Touch ID prompt spam (macOS)**
6
10
 
7
11
  - macOS pops a Touch ID prompt **per bundle, per process** — the biometry assertion is process-local and macOS refuses to cache `kSecAccessControl`+biometry items, so running several agents at once (`agents teams`, parallel `agents run --secrets`) re-prompts once per process. New `agents secrets unlock <bundle>` reads the bundle once (one prompt) and holds the resolved env in a local broker; every later resolution — `agents run`, teammates, browser profiles, the routines daemon — is served from memory over a user-only Unix socket (`~/.agents/.cache/helpers/secrets-agent/`, `0700`) with no prompt. `agents secrets lock` wipes it; `agents secrets status` shows what's held and when it locks. The hold also ends on TTL expiry (default 24h, `--ttl`) and on screen-lock / sleep.
@@ -8,10 +8,10 @@
8
8
  import chalk from 'chalk';
9
9
  import * as fs from 'fs';
10
10
  import { spawnSync } from 'child_process';
11
- import { bundleExists, bundleTier, deleteBundle, describeBundle, keychainItemsForBundle, keychainRef, listBundles, migrateLegacyBundles, parseDotenv, readAndResolveBundleEnv, readBundle, renameBundle, rotateBundleSecret, validateBundleName, validateEnvKey, validateExpiresFutureDated, validateSecretType, writeBundle, } from '../lib/secrets/bundles.js';
12
- import { deleteKeychainToken, getKeychainToken, getKeychainTokens, hasKeychainToken, secretsKeychainItem, setKeychainToken, } from '../lib/secrets/index.js';
11
+ import { bundleExists, bundleItemStore, bundleTier, deleteBundle, describeBundle, keychainItemsForBundle, keychainRef, listBundles, migrateLegacyBundles, parseDotenv, readAndResolveBundleEnv, readBundle, renameBundle, rotateBundleSecret, validateBundleName, validateEnvKey, validateExpiresFutureDated, validateSecretType, writeBundle, } from '../lib/secrets/bundles.js';
12
+ import { getKeychainToken, getKeychainTokens, hasKeychainToken, secretsKeychainItem, setKeychainToken, } from '../lib/secrets/index.js';
13
13
  import { assertOpAvailable, createPasswordItem, deleteItemByTitle, extractSecrets, itemExistsByTitle, listItems, listVaults, } from '../lib/onepassword.js';
14
- import { DEFAULT_TTL_MS, agentLoad, agentLock, agentStatus, ensureAgentRunning, runSecretsAgent, } from '../lib/secrets/agent.js';
14
+ import { DEFAULT_TTL_MS, agentLoad, agentLock, agentStatus, ensureAgentRunning, runAgentLoadFromStdin, runSecretsAgent, } from '../lib/secrets/agent.js';
15
15
  import { parseDuration } from '../lib/hooks/cache.js';
16
16
  import { registerCommandGroups, setHelpSections } from '../lib/help.js';
17
17
  import { isInteractiveTerminal, isPromptCancelled } from './utils.js';
@@ -235,7 +235,11 @@ function renderBundleRow(b) {
235
235
  `${padVisible(created, 9)} ` +
236
236
  `${padVisible(updated, 9)} ` +
237
237
  `${padVisible(used, 7)}`;
238
- return b.description ? `${head} ${chalk.gray(safePrint(b.description))}` : head.trimEnd();
238
+ // Mark file-backed bundles so `list` distinguishes them from keychain ones.
239
+ const tag = b.backend === 'file' ? chalk.magenta('[file] ') : '';
240
+ const desc = b.description ? chalk.gray(safePrint(b.description)) : '';
241
+ const trailer = `${tag}${desc}`.trimEnd();
242
+ return trailer ? `${head} ${trailer}` : head.trimEnd();
239
243
  }
240
244
  /** Colorize a variable source kind (literal, keychain, env, file, exec). */
241
245
  function kindLabel(kind) {
@@ -449,6 +453,8 @@ export function registerSecretsCommands(program) {
449
453
  console.log(chalk.gray(safePrint(bundle.description)));
450
454
  if (bundle.allow_exec)
451
455
  console.log(chalk.yellow('allow_exec: true'));
456
+ if (bundle.backend === 'file')
457
+ console.log(chalk.gray('backend: file (passphrase-encrypted; reads need AGENTS_SECRETS_PASSPHRASE, no Touch ID)'));
452
458
  if (bundleTier(bundle) === 'session')
453
459
  console.log(chalk.gray('tier: session (secrets-agent eligible)'));
454
460
  if (bundle.created_at)
@@ -573,12 +579,14 @@ export function registerSecretsCommands(program) {
573
579
  .option('--description <text>', 'Free-form description')
574
580
  .option('--allow-exec', 'Allow exec: refs in this bundle (off by default)')
575
581
  .option('--tier <tier>', 'secrets-agent tier: biometry (default) or session', 'biometry')
582
+ .option('--backend <backend>', 'storage backend: keychain (default) or file (passphrase-encrypted, headless-readable)', 'keychain')
576
583
  .option('--force', 'Overwrite an existing bundle')
577
584
  .action(async (name, opts) => {
578
585
  try {
579
586
  const resolvedName = name ?? (await promptBundleName());
580
587
  validateBundleName(resolvedName);
581
588
  const tier = parseTierOpt(opts.tier);
589
+ const backend = parseBackendOpt(opts.backend);
582
590
  if (bundleExists(resolvedName) && !opts.force) {
583
591
  console.error(chalk.red(`Bundle '${resolvedName}' already exists. Use --force to overwrite.`));
584
592
  process.exit(1);
@@ -587,11 +595,16 @@ export function registerSecretsCommands(program) {
587
595
  name: resolvedName,
588
596
  description: opts.description,
589
597
  allow_exec: opts.allowExec,
598
+ backend: backend === 'file' ? 'file' : undefined,
590
599
  tier,
591
600
  vars: {},
592
601
  };
593
602
  writeBundle(bundle);
594
- console.log(chalk.green(`Bundle '${resolvedName}' created${tier === 'session' ? ' (tier: session)' : ''}.`));
603
+ const tags = [tier === 'session' ? 'tier: session' : null, backend === 'file' ? 'backend: file' : null].filter(Boolean);
604
+ console.log(chalk.green(`Bundle '${resolvedName}' created${tags.length ? ` (${tags.join(', ')})` : ''}.`));
605
+ if (backend === 'file') {
606
+ console.log(chalk.gray('File-backed: items are AES-256-GCM encrypted under AGENTS_SECRETS_PASSPHRASE (no Touch ID).'));
607
+ }
595
608
  console.log(chalk.gray(`Try: agents secrets add ${resolvedName} MY_KEY`));
596
609
  }
597
610
  catch (err) {
@@ -711,7 +724,7 @@ export function registerSecretsCommands(program) {
711
724
  console.log(chalk.green(`${resolvedBundleName}.${resolvedKey} = <literal>`));
712
725
  return;
713
726
  }
714
- // Default path: keychain-backed.
727
+ // Default path: stored in the bundle's backend (keychain or file).
715
728
  let secretValue;
716
729
  if (opts.valueStdin) {
717
730
  secretValue = readStdinSync();
@@ -722,11 +735,12 @@ export function registerSecretsCommands(program) {
722
735
  secretValue = await promptForSecret(`Enter value for ${resolvedBundleName}.${resolvedKey}`);
723
736
  }
724
737
  const item = secretsKeychainItem(resolvedBundleName, resolvedKey);
725
- setKeychainToken(item, secretValue);
738
+ bundleItemStore(bundle.backend).set(item, secretValue);
726
739
  bundle.vars[resolvedKey] = keychainRef(resolvedKey);
727
740
  applyMeta();
728
741
  writeBundle(bundle);
729
- console.log(chalk.green(`${resolvedBundleName}.${resolvedKey} stored in keychain (${item}).`));
742
+ const where = bundle.backend === 'file' ? 'encrypted file store' : 'keychain';
743
+ console.log(chalk.green(`${resolvedBundleName}.${resolvedKey} stored in ${where} (${item}).`));
730
744
  }
731
745
  catch (err) {
732
746
  if (isPromptCancelled(err))
@@ -831,9 +845,10 @@ Examples:
831
845
  writeBundle(bundle);
832
846
  if (willPurge) {
833
847
  const item = secretsKeychainItem(resolvedBundleName, raw.slice('keychain:'.length));
834
- const removed = deleteKeychainToken(item);
848
+ const removed = bundleItemStore(bundle.backend).delete(item);
835
849
  if (removed) {
836
- console.log(chalk.green(`Removed ${resolvedBundleName}.${resolvedKey} and purged keychain item.`));
850
+ const where = bundle.backend === 'file' ? 'encrypted file item' : 'keychain item';
851
+ console.log(chalk.green(`Removed ${resolvedBundleName}.${resolvedKey} and purged ${where}.`));
837
852
  return;
838
853
  }
839
854
  }
@@ -875,8 +890,9 @@ Examples:
875
890
  }
876
891
  }
877
892
  if (!opts.keepSecrets) {
893
+ const store = bundleItemStore(bundle.backend);
878
894
  for (const { item } of keychainItemsForBundle(bundle)) {
879
- deleteKeychainToken(item);
895
+ store.delete(item);
880
896
  }
881
897
  }
882
898
  const existed = deleteBundle(resolvedName);
@@ -929,11 +945,12 @@ Examples:
929
945
  });
930
946
  cmd
931
947
  .command('import [bundle]')
932
- .description('Import keys from a .env file or a 1Password vault into a bundle. By default every key is stored in keychain.')
948
+ .description('Import keys from a .env file or a 1Password vault into a bundle. The bundle is created if it does not exist. Values are stored in the bundle\'s backend (keychain by default).')
933
949
  .option('--from <path>', 'Path to a .env file')
934
950
  .option('--from-1password', 'Import secrets from a 1Password vault (requires the op CLI)')
935
951
  .option('--vault <name>', '1Password vault name (used with --from-1password)')
936
952
  .option('--all-plaintext', 'Store every imported value as a literal in the bundle metadata (skip keychain item creation)')
953
+ .option('--backend <backend>', 'When creating the bundle: keychain (default) or file (passphrase-encrypted, headless-readable)', 'keychain')
937
954
  .option('--force', 'Overwrite an existing key in the bundle')
938
955
  .action(async (bundleName, opts) => {
939
956
  try {
@@ -944,7 +961,27 @@ Examples:
944
961
  throw new Error('--from and --from-1password are mutually exclusive.');
945
962
  }
946
963
  const resolvedBundleName = bundleName ?? (await pickBundleName('import into'));
947
- const bundle = readBundle(resolvedBundleName);
964
+ const requestedBackend = parseBackendOpt(opts.backend);
965
+ // Read the bundle if it exists (inheriting its backend); otherwise
966
+ // create it with the requested backend so a single `import --backend
967
+ // file` works (this is what `export --to-ssh --remote-backend file`
968
+ // drives on the remote).
969
+ let bundle;
970
+ if (bundleExists(resolvedBundleName)) {
971
+ bundle = readBundle(resolvedBundleName);
972
+ if (requestedBackend === 'file' && bundle.backend !== 'file') {
973
+ throw new Error(`Bundle '${resolvedBundleName}' already exists with a keychain backend; ` +
974
+ `--backend file cannot change it. Delete it first to recreate as file-backed.`);
975
+ }
976
+ }
977
+ else {
978
+ bundle = {
979
+ name: resolvedBundleName,
980
+ backend: requestedBackend === 'file' ? 'file' : undefined,
981
+ vars: {},
982
+ };
983
+ }
984
+ const store = bundleItemStore(bundle.backend);
948
985
  let added = 0;
949
986
  let skipped = 0;
950
987
  if (opts.from1password) {
@@ -962,7 +999,7 @@ Examples:
962
999
  }
963
1000
  else {
964
1001
  const item = secretsKeychainItem(resolvedBundleName, envKey);
965
- setKeychainToken(item, value);
1002
+ store.set(item, value);
966
1003
  bundle.vars[envKey] = keychainRef(envKey);
967
1004
  }
968
1005
  added++;
@@ -986,7 +1023,7 @@ Examples:
986
1023
  }
987
1024
  else {
988
1025
  const item = secretsKeychainItem(resolvedBundleName, key);
989
- setKeychainToken(item, value);
1026
+ store.set(item, value);
990
1027
  bundle.vars[key] = keychainRef(key);
991
1028
  }
992
1029
  added++;
@@ -1010,6 +1047,7 @@ Examples:
1010
1047
  .option('--vault <name>', '1Password vault name (used with --to-1password)')
1011
1048
  .option('--to-ssh', 'Push the bundle to remote machine(s) over SSH via their native agents-cli import')
1012
1049
  .option('--host <target...>', 'SSH target(s) for --to-ssh: host alias or user@host (repeatable)')
1050
+ .option('--remote-backend <backend>', 'Backend for the bundle on the remote: keychain (default) or file (passphrase-encrypted, headless-readable). file forwards AGENTS_SECRETS_PASSPHRASE over stdin.', 'keychain')
1013
1051
  .option('--force', 'Overwrite existing keys/items on the target (used with --to-1password and --to-ssh)')
1014
1052
  .action(async (bundleName, opts) => {
1015
1053
  try {
@@ -1022,22 +1060,51 @@ Examples:
1022
1060
  }
1023
1061
  for (const h of hosts)
1024
1062
  assertValidSshTarget(h);
1063
+ const remoteBackend = parseBackendOpt(opts.remoteBackend);
1064
+ // For a file-backed remote bundle the remote must encrypt at rest with
1065
+ // a passphrase. We forward the LOCAL AGENTS_SECRETS_PASSPHRASE — the
1066
+ // operator unlocks it once on this (trusted, biometry-gated) machine —
1067
+ // and ship it as the FIRST stdin line so it never lands in argv / `ps`
1068
+ // / the remote shell history. The remote `read -r` consumes that line;
1069
+ // `agents secrets import --from /dev/stdin` reads the .env remainder.
1070
+ let remotePassphrase = '';
1071
+ if (remoteBackend === 'file') {
1072
+ remotePassphrase = process.env.AGENTS_SECRETS_PASSPHRASE ?? '';
1073
+ if (!remotePassphrase) {
1074
+ throw new Error('--remote-backend file needs AGENTS_SECRETS_PASSPHRASE set locally to encrypt the ' +
1075
+ 'bundle at rest on the remote. Set it for this command, then unlock it the same way per run.');
1076
+ }
1077
+ }
1025
1078
  const { env } = readAndResolveBundleEnv(resolvedBundleName, { caller: `ssh export` });
1026
1079
  const dotenv = bundleEnvToDotenv(env);
1027
1080
  const keyCount = Object.keys(env).length;
1028
- // Drive the remote's own `agents secrets` CLI so values land in its native
1029
- // backend (Keychain on macOS, libsecret / encrypted-file on Linux). Create
1030
- // the bundle if missing, then import the piped .env. `bash -lc` so the login
1031
- // PATH resolves `agents`; the .env flows over ssh stdin and is never parsed
1032
- // by a remote shell.
1081
+ // Drive the remote's own `agents secrets` CLI so values land in its
1082
+ // chosen backend. `bash -lc` so the login PATH resolves `agents`; the
1083
+ // .env (and, for file, the passphrase) flow over ssh stdin and are
1084
+ // never parsed by a remote shell.
1033
1085
  const force = opts.force ? ' --force' : '';
1034
- const remoteAgents = `agents secrets create ${shellQuote(resolvedBundleName)} >/dev/null 2>&1 || true; ` +
1035
- `agents secrets import ${shellQuote(resolvedBundleName)} --from /dev/stdin${force}`;
1086
+ const backendFlag = remoteBackend === 'file' ? ' --backend file' : '';
1087
+ let remoteAgents;
1088
+ let input;
1089
+ if (remoteBackend === 'file') {
1090
+ // import --backend file auto-creates the file-backed bundle; no
1091
+ // separate `create` needed.
1092
+ remoteAgents =
1093
+ `IFS= read -r AGENTS_SECRETS_PASSPHRASE; export AGENTS_SECRETS_PASSPHRASE; ` +
1094
+ `agents secrets import ${shellQuote(resolvedBundleName)} --from /dev/stdin${backendFlag}${force}`;
1095
+ input = `${remotePassphrase}\n${dotenv}`;
1096
+ }
1097
+ else {
1098
+ remoteAgents =
1099
+ `agents secrets create ${shellQuote(resolvedBundleName)} >/dev/null 2>&1 || true; ` +
1100
+ `agents secrets import ${shellQuote(resolvedBundleName)} --from /dev/stdin${force}`;
1101
+ input = dotenv;
1102
+ }
1036
1103
  const remoteCmd = `bash -lc ${shellQuote(remoteAgents)}`;
1037
1104
  let failures = 0;
1038
1105
  for (const host of hosts) {
1039
1106
  const res = spawnSync('ssh', ['-o', 'BatchMode=yes', host, remoteCmd], {
1040
- input: dotenv,
1107
+ input,
1041
1108
  stdio: ['pipe', 'pipe', 'pipe'],
1042
1109
  encoding: 'utf-8',
1043
1110
  });
@@ -1336,6 +1403,12 @@ Examples:
1336
1403
  .action(async () => {
1337
1404
  await runSecretsAgent();
1338
1405
  });
1406
+ cmd
1407
+ .command('_agent-load', { hidden: true })
1408
+ .description('Detached auto-cache worker: load a bundle from stdin into the broker (internal)')
1409
+ .action(async () => {
1410
+ await runAgentLoadFromStdin();
1411
+ });
1339
1412
  registerSecretsSyncCommands(cmd);
1340
1413
  registerSecretsMigrateAclCommand(cmd);
1341
1414
  }
@@ -1353,6 +1426,14 @@ function parseTierOpt(raw) {
1353
1426
  console.error(chalk.red(`Invalid --tier '${raw}'. Use 'biometry' or 'session'.`));
1354
1427
  process.exit(1);
1355
1428
  }
1429
+ /** Validate a --backend value, exiting with a clear message on a bad one. */
1430
+ function parseBackendOpt(raw) {
1431
+ const v = (raw ?? 'keychain').toLowerCase();
1432
+ if (v === 'keychain' || v === 'file')
1433
+ return v;
1434
+ console.error(chalk.red(`Invalid --backend '${raw}'. Use 'keychain' or 'file'.`));
1435
+ process.exit(1);
1436
+ }
1356
1437
  /** Human-readable "locks in 3 hours" / "locks in 5 minutes" from an epoch-ms expiry. */
1357
1438
  function humanRemaining(expiresAt) {
1358
1439
  const ms = expiresAt - Date.now();
@@ -115,10 +115,23 @@ export declare function secretsAgentAutoEnabled(): boolean;
115
115
  * Fire-and-forget: populate the broker with a freshly-resolved bundle so the
116
116
  * NEXT process reads it without a prompt. Used by the auto-cache path after a
117
117
  * real keychain read of a `session`-tier bundle. Adds no latency to the caller
118
- * — it spawns the agent (if needed) and a detached loader, both unref'd, then
119
- * returns immediately. Entirely best-effort; never throws. macOS only.
118
+ * — it spawns a detached `secrets _agent-load` worker (passing the resolved env
119
+ * over stdin, never argv) and returns immediately.
120
+ *
121
+ * The worker reuses the robust `ensureAgentRunning` path (spawn-then-ping with a
122
+ * generous budget) rather than a tight inline retry loop: under heavy load the
123
+ * broker is itself a cold-starting full CLI and can take several seconds to bind
124
+ * the socket, so a short fixed budget would give up before it's ready and the
125
+ * cache would silently never populate. Best-effort; never throws. macOS only.
120
126
  */
121
127
  export declare function agentAutoLoadSync(name: string, bundle: SecretsBundle, env: Record<string, string>, ttlMs: number): void;
128
+ /**
129
+ * Body of the hidden `secrets _agent-load` worker. Reads one `{name, bundle,
130
+ * env, ttlMs}` payload from stdin, ensures the broker is up (robust, generous
131
+ * budget), and loads the bundle into it. Detached from the originating read, so
132
+ * its latency is invisible — which is why it can afford a long ensure budget.
133
+ */
134
+ export declare function runAgentLoadFromStdin(): Promise<void>;
122
135
  /** Store a resolved bundle in the broker. Returns false on transport failure. */
123
136
  export declare function agentLoad(name: string, bundle: SecretsBundle, env: Record<string, string>, ttlMs: number): Promise<boolean>;
124
137
  /** Wipe one bundle (or all if name omitted) from the broker. Returns the count
@@ -61,24 +61,27 @@ function pidPath() {
61
61
  return path.join(agentDir(), 'agent.pid');
62
62
  }
63
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.
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
69
  */
70
- function brokerSpawn() {
70
+ function cliSpawn(sub) {
71
71
  const argv1 = process.argv[1];
72
72
  const entry = argv1 && fs.existsSync(argv1) ? argv1 : null;
73
73
  if (entry)
74
- return { cmd: process.execPath, args: [entry, 'secrets', '_agent-run'] };
74
+ return { cmd: process.execPath, args: [entry, ...sub] };
75
75
  // No resolvable entrypoint (unusual) — fall back to the PATH shim.
76
76
  let bin = 'agents';
77
77
  try {
78
78
  bin = execFileSync('which', ['agents'], { encoding: 'utf-8' }).trim();
79
79
  }
80
80
  catch { /* default */ }
81
- return { cmd: bin, args: ['secrets', '_agent-run'] };
81
+ return { cmd: bin, args: sub };
82
+ }
83
+ function brokerSpawn() {
84
+ return cliSpawn(['secrets', '_agent-run']);
82
85
  }
83
86
  // ─── Broker server (runs in the detached `secrets _agent-run` process) ───────
84
87
  /**
@@ -371,64 +374,60 @@ export function secretsAgentAutoEnabled() {
371
374
  return false;
372
375
  }
373
376
  }
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
377
  /**
406
378
  * Fire-and-forget: populate the broker with a freshly-resolved bundle so the
407
379
  * NEXT process reads it without a prompt. Used by the auto-cache path after a
408
380
  * 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.
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.
411
389
  */
412
390
  export function agentAutoLoadSync(name, bundle, env, ttlMs) {
413
391
  if (!onDarwin())
414
392
  return;
415
393
  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();
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();
427
399
  }
428
400
  catch {
429
401
  // best-effort: the next read just pops Touch ID as it would today
430
402
  }
431
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
+ }
432
431
  /** Store a resolved bundle in the broker. Returns false on transport failure. */
433
432
  export async function agentLoad(name, bundle, env, ttlMs) {
434
433
  const r = await request({ cmd: 'load', name, bundle, env, ttlMs });
@@ -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;