@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.
@@ -1,12 +1,18 @@
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.
@@ -16,8 +22,73 @@ import * as os from 'os';
16
22
  import * as path from 'path';
17
23
  import * as yaml from 'yaml';
18
24
  import { deleteKeychainToken, getKeychainToken, getKeychainTokens, hasKeychainToken, listKeychainItems, parseBundleValue, resolveRef, secretsKeychainItem, setKeychainToken, } from './index.js';
25
+ import { fileStore } from './filestore.js';
19
26
  import { emit } from '../events.js';
20
27
  import { agentGetSync, agentAutoLoadSync, secretsAgentAutoEnabled, DEFAULT_TTL_MS } from './agent.js';
28
+ const keychainStore = {
29
+ has: hasKeychainToken,
30
+ get: getKeychainToken,
31
+ getBatch: getKeychainTokens,
32
+ set: setKeychainToken,
33
+ delete: deleteKeychainToken,
34
+ list: listKeychainItems,
35
+ };
36
+ // The file store auto-provisions a machine-local passphrase on Linux (the
37
+ // existing headless-libsecret fallback) but NEVER on macOS: a file-backed
38
+ // bundle on a Mac must be unlocked with an explicit AGENTS_SECRETS_PASSPHRASE
39
+ // supplied per run, so the box holds ciphertext only. assertFileBackendUsable()
40
+ // enforces that the passphrase is present before we touch the store.
41
+ const FILE_ALLOW_AUTO_PROVISION = process.platform !== 'darwin';
42
+ const fileItemStore = {
43
+ has: (item) => fileStore.has(item),
44
+ get: (item) => fileStore.get(item, { allowAutoProvision: FILE_ALLOW_AUTO_PROVISION }),
45
+ getBatch: (items) => {
46
+ const out = new Map();
47
+ for (const item of items) {
48
+ try {
49
+ out.set(item, fileStore.get(item, { allowAutoProvision: FILE_ALLOW_AUTO_PROVISION }));
50
+ }
51
+ catch {
52
+ // Missing/undecryptable item — absent from the map, mirroring
53
+ // getKeychainTokens (caller decides whether that's an error).
54
+ }
55
+ }
56
+ return out;
57
+ },
58
+ set: (item, value) => fileStore.set(item, value, { allowAutoProvision: FILE_ALLOW_AUTO_PROVISION }),
59
+ delete: (item) => fileStore.delete(item),
60
+ list: (prefix) => fileStore.list(prefix),
61
+ };
62
+ function itemStore(backend) {
63
+ return backend === 'file' ? fileItemStore : keychainStore;
64
+ }
65
+ /**
66
+ * Discover a bundle's backend by location: a file-backed bundle's metadata
67
+ * item exists in the encrypted-file store. This is a plain file-existence
68
+ * check — no passphrase, no Touch ID — so it sidesteps the chicken-and-egg of
69
+ * "read metadata to learn where metadata lives." Absent ⇒ keychain.
70
+ */
71
+ export function bundleBackend(name) {
72
+ return fileStore.has(BUNDLE_META_PREFIX + name) ? 'file' : 'keychain';
73
+ }
74
+ /**
75
+ * Guard a file-backed bundle operation. On macOS the file store must be
76
+ * unlocked with an explicit passphrase (env or interactive prompt) — we refuse
77
+ * to silently auto-provision a machine-local key there, so a remote/headless
78
+ * Mac cannot decrypt on its own. Linux keeps the existing auto-provision
79
+ * behavior, so this is a no-op there.
80
+ */
81
+ function assertFileBackendUsable(name) {
82
+ if (process.platform !== 'darwin')
83
+ return;
84
+ if (process.env.AGENTS_SECRETS_PASSPHRASE && process.env.AGENTS_SECRETS_PASSPHRASE.length > 0)
85
+ return;
86
+ if (process.stdin.isTTY)
87
+ return;
88
+ throw new Error(`File-backed bundle '${name}' needs AGENTS_SECRETS_PASSPHRASE to be set on macOS ` +
89
+ `(no biometry prompt is available headlessly). Set it for this run, e.g.\n` +
90
+ ` AGENTS_SECRETS_PASSPHRASE=… agents secrets exec ${name} -- <command>`);
91
+ }
21
92
  /** Allowed values for a secret's `type` metadata field. */
22
93
  export const SECRET_TYPES = [
23
94
  'api-key',
@@ -116,15 +187,23 @@ function bundleMetaItem(name) {
116
187
  }
117
188
  export function bundleExists(name) {
118
189
  validateBundleName(name);
119
- return hasKeychainToken(bundleMetaItem(name));
190
+ return itemStore(bundleBackend(name)).has(bundleMetaItem(name));
120
191
  }
121
192
  export function readBundle(name) {
122
193
  validateBundleName(name);
194
+ const backend = bundleBackend(name);
195
+ if (backend === 'file')
196
+ assertFileBackendUsable(name);
123
197
  let json;
124
198
  try {
125
- json = getKeychainToken(bundleMetaItem(name));
199
+ json = itemStore(backend).get(bundleMetaItem(name));
126
200
  }
127
- catch {
201
+ catch (err) {
202
+ // A file-backed bundle whose metadata is on disk but fails to decrypt is a
203
+ // wrong-passphrase error, not a missing bundle — surface that clearly.
204
+ if (backend === 'file' && fileStore.has(bundleMetaItem(name))) {
205
+ throw new Error(`Bundle '${name}': failed to decrypt — wrong AGENTS_SECRETS_PASSPHRASE or tampered file store. (${err.message})`);
206
+ }
128
207
  throw new Error(`Secrets bundle '${name}' not found.`);
129
208
  }
130
209
  let parsed;
@@ -138,11 +217,15 @@ export function readBundle(name) {
138
217
  throw new Error(`Bundle '${name}' is malformed.`);
139
218
  }
140
219
  // Unknown fields on the JSON (e.g. legacy sync flags) are silently dropped
141
- // here; the SecretsBundle shape is the only source of truth.
220
+ // here; the SecretsBundle shape is the only source of truth. `backend` is
221
+ // authoritative from location discovery, not the persisted field.
142
222
  const bundle = {
143
223
  name,
144
224
  description: parsed.description,
145
225
  allow_exec: Boolean(parsed.allow_exec),
226
+ // Absent ⇒ keychain (mirrors `tier`); only set when file-backed so a
227
+ // keychain bundle round-trips byte-for-byte.
228
+ backend: backend === 'file' ? 'file' : undefined,
146
229
  tier: parseTier(parsed.tier),
147
230
  vars: parsed.vars && typeof parsed.vars === 'object' ? parsed.vars : {},
148
231
  };
@@ -170,6 +253,9 @@ export function bundleTier(bundle) {
170
253
  }
171
254
  export function writeBundle(bundle) {
172
255
  validateBundleName(bundle.name);
256
+ const backend = bundle.backend ?? 'keychain';
257
+ if (backend === 'file')
258
+ assertFileBackendUsable(bundle.name);
173
259
  for (const key of Object.keys(bundle.vars)) {
174
260
  validateEnvKey(key);
175
261
  }
@@ -201,6 +287,7 @@ export function writeBundle(bundle) {
201
287
  const payload = {
202
288
  description: bundle.description,
203
289
  allow_exec: bundle.allow_exec ? true : undefined,
290
+ backend: backend === 'file' ? 'file' : undefined,
204
291
  tier: bundle.tier === 'session' ? 'session' : undefined,
205
292
  created_at: bundle.created_at,
206
293
  updated_at: bundle.updated_at,
@@ -209,80 +296,109 @@ export function writeBundle(bundle) {
209
296
  meta,
210
297
  };
211
298
  const json = JSON.stringify(payload);
212
- setKeychainToken(bundleMetaItem(bundle.name), json);
299
+ itemStore(backend).set(bundleMetaItem(bundle.name), json);
213
300
  emit('secrets.set', { bundle: bundle.name });
214
301
  }
215
302
  export function deleteBundle(name) {
216
303
  validateBundleName(name);
217
- const deleted = deleteKeychainToken(bundleMetaItem(name));
304
+ const deleted = itemStore(bundleBackend(name)).delete(bundleMetaItem(name));
218
305
  if (deleted) {
219
306
  emit('secrets.delete', { bundle: name });
220
307
  }
221
308
  return deleted;
222
309
  }
310
+ /**
311
+ * Parse a stored metadata JSON blob into a SecretsBundle, applying the lenient
312
+ * posture listBundles wants (skip malformed / invalid-key bundles rather than
313
+ * throw). `backend` is authoritative from where the item was found. Returns
314
+ * null to skip.
315
+ */
316
+ function parseBundleMeta(name, json, backend) {
317
+ let parsed;
318
+ try {
319
+ parsed = JSON.parse(json);
320
+ }
321
+ catch {
322
+ return null;
323
+ }
324
+ if (!parsed || typeof parsed !== 'object')
325
+ return null;
326
+ const bundle = {
327
+ name,
328
+ description: parsed.description,
329
+ allow_exec: Boolean(parsed.allow_exec),
330
+ backend: backend === 'file' ? 'file' : undefined,
331
+ tier: parseTier(parsed.tier),
332
+ vars: parsed.vars && typeof parsed.vars === 'object' ? parsed.vars : {},
333
+ };
334
+ if (typeof parsed.created_at === 'string')
335
+ bundle.created_at = parsed.created_at;
336
+ if (typeof parsed.updated_at === 'string')
337
+ bundle.updated_at = parsed.updated_at;
338
+ if (typeof parsed.last_used === 'string')
339
+ bundle.last_used = parsed.last_used;
340
+ if (parsed.meta && typeof parsed.meta === 'object')
341
+ bundle.meta = parsed.meta;
342
+ for (const key of Object.keys(bundle.vars)) {
343
+ if (!ENV_KEY_PATTERN.test(key))
344
+ return null;
345
+ }
346
+ return bundle;
347
+ }
223
348
  export function listBundles() {
224
- let services;
349
+ const out = [];
350
+ // Keychain-backed bundles: batch all metadata reads behind ONE Touch ID
351
+ // prompt instead of N. Bundle metadata items carry user-presence ACLs (same
352
+ // as secret values), so a naive loop over readBundle() spawns a fresh
353
+ // LAContext per item — meaning N biometric prompts for `secrets list`.
354
+ let keychainServices = [];
225
355
  try {
226
- services = listKeychainItems(BUNDLE_META_PREFIX);
356
+ keychainServices = listKeychainItems(BUNDLE_META_PREFIX);
227
357
  }
228
358
  catch {
229
- return [];
359
+ keychainServices = [];
230
360
  }
231
- const names = services
361
+ const keychainNames = keychainServices
232
362
  .map((s) => s.slice(BUNDLE_META_PREFIX.length))
233
363
  .filter((n) => BUNDLE_NAME_PATTERN.test(n));
234
- if (names.length === 0)
235
- return [];
236
- // Batch all metadata reads behind ONE Touch ID prompt instead of N. Bundle
237
- // metadata items carry user-presence ACLs (same as secret values), so a naive
238
- // loop over readBundle() spawns a fresh LAContext per item — meaning N
239
- // biometric prompts for `secrets list`. Sharing a single context across all
240
- // SecItemCopyMatching calls collapses the prompt to one. Mirrors the pattern
241
- // already used by resolveBundleEnv for runtime secret injection.
242
- const itemsToFetch = names.map(bundleMetaItem);
243
- const fetched = getKeychainTokens(itemsToFetch);
244
- const out = [];
245
- for (const name of names) {
246
- const json = fetched.get(bundleMetaItem(name));
247
- if (json === undefined)
248
- continue;
249
- let parsed;
364
+ if (keychainNames.length > 0) {
365
+ const fetched = getKeychainTokens(keychainNames.map(bundleMetaItem));
366
+ for (const name of keychainNames) {
367
+ const json = fetched.get(bundleMetaItem(name));
368
+ if (json === undefined)
369
+ continue;
370
+ const bundle = parseBundleMeta(name, json, 'keychain');
371
+ if (bundle)
372
+ out.push(bundle);
373
+ }
374
+ }
375
+ // File-backed bundles live in the encrypted-file store. Enumeration is a
376
+ // silent directory listing; only decryption needs the passphrase, so a
377
+ // `secrets list` without one still shows the names (values stay sealed).
378
+ let fileServices = [];
379
+ try {
380
+ fileServices = fileStore.list(BUNDLE_META_PREFIX);
381
+ }
382
+ catch {
383
+ fileServices = [];
384
+ }
385
+ const fileNames = fileServices
386
+ .map((s) => s.slice(BUNDLE_META_PREFIX.length))
387
+ .filter((n) => BUNDLE_NAME_PATTERN.test(n));
388
+ for (const name of fileNames) {
389
+ let json;
250
390
  try {
251
- parsed = JSON.parse(json);
391
+ json = fileItemStore.get(bundleMetaItem(name));
252
392
  }
253
393
  catch {
254
- // Skip malformed bundles; surfaced via `agents secrets view <name>`.
394
+ // No passphrase (or wrong one): surface the bundle by name so it isn't
395
+ // invisible, with empty vars. `agents secrets view` reports the error.
396
+ out.push({ name, backend: 'file', vars: {} });
255
397
  continue;
256
398
  }
257
- if (!parsed || typeof parsed !== 'object')
258
- continue;
259
- const bundle = {
260
- name,
261
- description: parsed.description,
262
- allow_exec: Boolean(parsed.allow_exec),
263
- tier: parseTier(parsed.tier),
264
- vars: parsed.vars && typeof parsed.vars === 'object' ? parsed.vars : {},
265
- };
266
- if (typeof parsed.created_at === 'string')
267
- bundle.created_at = parsed.created_at;
268
- if (typeof parsed.updated_at === 'string')
269
- bundle.updated_at = parsed.updated_at;
270
- if (typeof parsed.last_used === 'string')
271
- bundle.last_used = parsed.last_used;
272
- if (parsed.meta && typeof parsed.meta === 'object')
273
- bundle.meta = parsed.meta;
274
- // Skip bundles with invalid env keys rather than throwing — same lenient
275
- // posture readBundle had via the outer catch.
276
- let valid = true;
277
- for (const key of Object.keys(bundle.vars)) {
278
- if (!ENV_KEY_PATTERN.test(key)) {
279
- valid = false;
280
- break;
281
- }
282
- }
283
- if (!valid)
284
- continue;
285
- out.push(bundle);
399
+ const bundle = parseBundleMeta(name, json, 'file');
400
+ if (bundle)
401
+ out.push(bundle);
286
402
  }
287
403
  return out.sort((a, b) => a.name.localeCompare(b.name));
288
404
  }
@@ -338,8 +454,9 @@ export function resolveBundleEnv(bundle, _opts = {}) {
338
454
  keychainItemsToFetch.push(secretsKeychainItem(bundle.name, parsed.ref.value));
339
455
  }
340
456
  }
457
+ const store = itemStore(bundle.backend ?? 'keychain');
341
458
  const fetched = keychainItemsToFetch.length > 0
342
- ? getKeychainTokens(keychainItemsToFetch)
459
+ ? store.getBatch(keychainItemsToFetch)
343
460
  : new Map();
344
461
  const env = {};
345
462
  for (const [key, raw] of Object.entries(bundle.vars)) {
@@ -352,7 +469,7 @@ export function resolveBundleEnv(bundle, _opts = {}) {
352
469
  const item = secretsKeychainItem(bundle.name, parsed.ref.value);
353
470
  const value = fetched.get(item);
354
471
  if (value === undefined) {
355
- throw new Error(`Bundle '${bundle.name}' key '${key}': keychain item '${item}' not found. ` +
472
+ throw new Error(`Bundle '${bundle.name}' key '${key}': stored item '${item}' not found. ` +
356
473
  `Run: agents secrets add ${bundle.name} ${key}`);
357
474
  }
358
475
  env[key] = value;
@@ -387,12 +504,14 @@ export function resolveBundleEnv(bundle, _opts = {}) {
387
504
  */
388
505
  export function readAndResolveBundleEnv(name, opts = {}) {
389
506
  validateBundleName(name);
507
+ const backend = bundleBackend(name);
390
508
  // Fast-path: if the secrets-agent holds this bundle (user ran
391
509
  // `agents secrets unlock <name>`), return the cached snapshot with no Touch
392
510
  // 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') {
511
+ // / keychain only the agent exists to dedup Touch ID prompts, and a
512
+ // file-backed bundle has none to dedup. The never-unlocked path is a single
513
+ // stat (agentSocketExists) so it costs nothing when the agent isn't running.
514
+ if (backend === 'keychain' && !opts.noAgent && process.env.AGENTS_SECRETS_NO_AGENT !== '1') {
396
515
  const hit = agentGetSync(name);
397
516
  if (hit) {
398
517
  stampLastUsed(hit.bundle);
@@ -406,11 +525,14 @@ export function readAndResolveBundleEnv(name, opts = {}) {
406
525
  return hit;
407
526
  }
408
527
  }
528
+ if (backend === 'file')
529
+ assertFileBackendUsable(name);
530
+ const store = itemStore(backend);
409
531
  const metaItem = bundleMetaItem(name);
410
532
  const bundleSecretPrefix = `${SECRETS_ITEM_PREFIX}${name}.`;
411
533
  let secretItems;
412
534
  try {
413
- secretItems = listKeychainItems(bundleSecretPrefix);
535
+ secretItems = store.list(bundleSecretPrefix);
414
536
  }
415
537
  catch {
416
538
  secretItems = [];
@@ -419,9 +541,16 @@ export function readAndResolveBundleEnv(name, opts = {}) {
419
541
  ? `read ${name} secrets (for ${opts.caller})`
420
542
  : `read ${name} secrets`;
421
543
  void reason;
422
- const fetched = getKeychainTokens([metaItem, ...secretItems]);
544
+ const fetched = store.getBatch([metaItem, ...secretItems]);
423
545
  const json = fetched.get(metaItem);
424
546
  if (json === undefined) {
547
+ // For a file-backed bundle the metadata item is on disk (that's how
548
+ // bundleBackend resolved to 'file'); a missing decrypt means the wrong
549
+ // passphrase, not a missing bundle. getBatch swallowed the decrypt error,
550
+ // so distinguish here rather than report a misleading "not found".
551
+ if (backend === 'file' && fileStore.has(metaItem)) {
552
+ throw new Error(`Bundle '${name}': failed to decrypt — wrong AGENTS_SECRETS_PASSPHRASE or tampered file store.`);
553
+ }
425
554
  throw new Error(`Secrets bundle '${name}' not found.`);
426
555
  }
427
556
  let parsed;
@@ -438,6 +567,7 @@ export function readAndResolveBundleEnv(name, opts = {}) {
438
567
  name,
439
568
  description: parsed.description,
440
569
  allow_exec: Boolean(parsed.allow_exec),
570
+ backend: backend === 'file' ? 'file' : undefined,
441
571
  tier: parseTier(parsed.tier),
442
572
  vars: parsed.vars && typeof parsed.vars === 'object' ? parsed.vars : {},
443
573
  };
@@ -491,7 +621,7 @@ export function readAndResolveBundleEnv(name, opts = {}) {
491
621
  const item = secretsKeychainItem(bundle.name, p.ref.value);
492
622
  const value = fetched.get(item);
493
623
  if (value === undefined) {
494
- throw new Error(`Bundle '${bundle.name}' key '${key}': keychain item '${item}' not found. ` +
624
+ throw new Error(`Bundle '${bundle.name}' key '${key}': stored item '${item}' not found. ` +
495
625
  `Run: agents secrets add ${bundle.name} ${key}`);
496
626
  }
497
627
  env[key] = value;
@@ -513,7 +643,8 @@ export function readAndResolveBundleEnv(name, opts = {}) {
513
643
  // enabled `secrets.agent.auto`, populate the broker in the background so the
514
644
  // next concurrent run reads silently. Skipped when noAgent (e.g. `unlock`,
515
645
  // which loads the agent itself). Fire-and-forget — never blocks this read.
516
- if (!opts.noAgent &&
646
+ if (backend === 'keychain' &&
647
+ !opts.noAgent &&
517
648
  process.env.AGENTS_SECRETS_NO_AGENT !== '1' &&
518
649
  bundleTier(bundle) === 'session' &&
519
650
  secretsAgentAutoEnabled()) {
@@ -549,7 +680,7 @@ export function rotateBundleSecret(bundle, key, opts) {
549
680
  }
550
681
  const shortId = raw.slice('keychain:'.length);
551
682
  const item = secretsKeychainItem(bundle.name, shortId);
552
- setKeychainToken(item, opts.newValue);
683
+ itemStore(bundle.backend ?? 'keychain').set(item, opts.newValue);
553
684
  if (opts.clearMeta) {
554
685
  if (bundle.meta)
555
686
  delete bundle.meta[key];
@@ -593,13 +724,17 @@ export function renameBundle(oldName, newName, opts = {}) {
593
724
  throw new Error(`Bundle '${oldName}' not found.`);
594
725
  }
595
726
  const source = readBundle(oldName);
727
+ // Rename stays within the source's backend. The store carries both the
728
+ // per-key secret items and (via writeBundle/deleteBundle) the metadata.
729
+ const store = itemStore(source.backend ?? 'keychain');
596
730
  if (bundleExists(newName)) {
597
731
  if (!opts.force) {
598
732
  throw new Error(`Bundle '${newName}' already exists. Use --force to overwrite.`);
599
733
  }
600
734
  const dest = readBundle(newName);
735
+ const destStore = itemStore(dest.backend ?? 'keychain');
601
736
  for (const { item } of keychainItemsForBundle(dest)) {
602
- deleteKeychainToken(item);
737
+ destStore.delete(item);
603
738
  }
604
739
  deleteBundle(newName);
605
740
  }
@@ -612,19 +747,30 @@ export function renameBundle(oldName, newName, opts = {}) {
612
747
  continue;
613
748
  const shortId = raw.slice('keychain:'.length);
614
749
  const newItem = secretsKeychainItem(newName, shortId);
615
- const value = getKeychainToken(oldItem);
616
- setKeychainToken(newItem, value);
750
+ const value = store.get(oldItem);
751
+ store.set(newItem, value);
617
752
  }
618
- // writeBundle preserves source.created_at and refreshes updated_at.
753
+ // writeBundle preserves source.created_at, refreshes updated_at, and keeps
754
+ // the source backend (spread carries source.backend).
619
755
  const renamed = { ...source, name: newName };
620
756
  writeBundle(renamed);
621
- // Cleanup: delete the old per-key keychain items, then the old metadata.
757
+ // Cleanup: delete the old per-key items, then the old metadata.
622
758
  for (const { item: oldItem } of sourceItems) {
623
- deleteKeychainToken(oldItem);
759
+ store.delete(oldItem);
624
760
  }
625
761
  deleteBundle(oldName);
626
762
  emit('secrets.rename', { from: oldName, to: newName });
627
763
  }
764
+ /**
765
+ * The store (keychain or encrypted file) that carries a bundle's items. The
766
+ * CLI uses this to read/write/delete per-key items (built with
767
+ * secretsKeychainItem) in the same store as the bundle's metadata, for `add` /
768
+ * `import` / `remove` / `delete`. Pass the bundle's resolved backend
769
+ * (`bundle.backend ?? 'keychain'`).
770
+ */
771
+ export function bundleItemStore(backend) {
772
+ return itemStore(backend ?? 'keychain');
773
+ }
628
774
  // Iterate all keychain-backed keys in a bundle for cleanup on rm/unset.
629
775
  export function keychainItemsForBundle(bundle) {
630
776
  const items = [];
@@ -0,0 +1,82 @@
1
+ /**
2
+ * Passphrase-encrypted file store for secrets — platform-neutral.
3
+ *
4
+ * An AES-256-GCM encrypted-file store under `~/.agents/.cache/secrets/`. The
5
+ * encryption key is scrypt-derived from a passphrase read from
6
+ * `AGENTS_SECRETS_PASSPHRASE` (preferred), a machine-local provisioned key, or
7
+ * a TTY prompt. One `<item>.enc` JSON file per item, mode 0600.
8
+ *
9
+ * Two callers:
10
+ * - Linux (src/lib/secrets/linux.ts): the headless fallback when the default
11
+ * Secret Service collection is locked. Auto-provisions a machine-local
12
+ * passphrase so `agents secrets` works out of the box on a server.
13
+ * - macOS file-backed bundles (src/lib/secrets/bundles.ts): an explicit,
14
+ * opt-in non-biometry backend for headless/remote release runs. The bundle
15
+ * layer guards this path so it only activates with an explicit
16
+ * AGENTS_SECRETS_PASSPHRASE (or TTY) — never the silent machine-local
17
+ * auto-provision — so a remote box holds ciphertext only.
18
+ *
19
+ * The item-name scheme is shared with the keychain backend so a file-backed
20
+ * item and its keychain twin carry identical names:
21
+ * `agents-cli.bundles.<name>` and `agents-cli.secrets.<bundle>.<key>`.
22
+ */
23
+ import type { KeychainBackend } from './index.js';
24
+ export declare function fileDir(): string;
25
+ /** True if a machine-local passphrase has already been provisioned. */
26
+ export declare function machinePassphraseExists(): boolean;
27
+ /**
28
+ * Resolve the passphrase for the encrypted file store.
29
+ *
30
+ * Order: AGENTS_SECRETS_PASSPHRASE > previously-provisioned machine-local key >
31
+ * (interactive) TTY prompt > (headless) auto-provisioned machine-local key.
32
+ *
33
+ * `allowAutoProvision` (default true, used by the Linux fallback) controls the
34
+ * last two steps. macOS file-backed bundles pass `false` so a missing
35
+ * passphrase is a hard, explicit error instead of a silently provisioned
36
+ * on-disk key — the caller (bundles.ts) guards this before we get here.
37
+ */
38
+ export declare function getPassphrase(opts?: {
39
+ allowAutoProvision?: boolean;
40
+ }): string;
41
+ /** Encrypted-file on-disk shape. Exported for tests. */
42
+ export interface EncFile {
43
+ salt: string;
44
+ iv: string;
45
+ authTag: string;
46
+ ciphertext: string;
47
+ }
48
+ /** Encrypt plaintext under a passphrase using AES-256-GCM with a random
49
+ * scrypt salt and a random 96-bit IV. Exported for tests. */
50
+ export declare function encryptForFallback(plaintext: string, passphrase: string): EncFile;
51
+ /** Decrypt an EncFile under a passphrase. Throws on wrong key or tampered
52
+ * ciphertext (auth-tag mismatch). Exported for tests. */
53
+ export declare function decryptForFallback(enc: EncFile, passphrase: string): string;
54
+ declare function fileHas(item: string): boolean;
55
+ declare function fileGet(item: string, opts?: {
56
+ allowAutoProvision?: boolean;
57
+ }): string;
58
+ declare function fileSet(item: string, value: string, opts?: {
59
+ allowAutoProvision?: boolean;
60
+ }): void;
61
+ declare function fileDelete(item: string): boolean;
62
+ declare function fileList(prefix: string): string[];
63
+ /** True if the fallback dir has any committed encrypted items. */
64
+ export declare function fileStoreHasItems(): boolean;
65
+ /** Low-level file-store ops, exported so callers (linux fallback, macOS
66
+ * file-backed bundles) can opt into or out of passphrase auto-provision. */
67
+ export declare const fileStore: {
68
+ has: typeof fileHas;
69
+ get: typeof fileGet;
70
+ set: typeof fileSet;
71
+ delete: typeof fileDelete;
72
+ list: typeof fileList;
73
+ };
74
+ /** File-only KeychainBackend (exported for tests; the Linux backend uses these
75
+ * ops with auto-provision allowed). */
76
+ export declare const fileBackend: KeychainBackend;
77
+ /** Test-only: reset module state (file dir + cached passphrase). */
78
+ export declare function _resetFileStoreForTest(opts?: {
79
+ fileDir?: string | null;
80
+ passphrase?: string | null;
81
+ }): void;
82
+ export {};