@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
@@ -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,7 +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';
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
+ }
20
92
  /** Allowed values for a secret's `type` metadata field. */
21
93
  export const SECRET_TYPES = [
22
94
  'api-key',
@@ -115,15 +187,23 @@ function bundleMetaItem(name) {
115
187
  }
116
188
  export function bundleExists(name) {
117
189
  validateBundleName(name);
118
- return hasKeychainToken(bundleMetaItem(name));
190
+ return itemStore(bundleBackend(name)).has(bundleMetaItem(name));
119
191
  }
120
192
  export function readBundle(name) {
121
193
  validateBundleName(name);
194
+ const backend = bundleBackend(name);
195
+ if (backend === 'file')
196
+ assertFileBackendUsable(name);
122
197
  let json;
123
198
  try {
124
- json = getKeychainToken(bundleMetaItem(name));
199
+ json = itemStore(backend).get(bundleMetaItem(name));
125
200
  }
126
- 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
+ }
127
207
  throw new Error(`Secrets bundle '${name}' not found.`);
128
208
  }
129
209
  let parsed;
@@ -137,11 +217,16 @@ export function readBundle(name) {
137
217
  throw new Error(`Bundle '${name}' is malformed.`);
138
218
  }
139
219
  // Unknown fields on the JSON (e.g. legacy sync flags) are silently dropped
140
- // 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.
141
222
  const bundle = {
142
223
  name,
143
224
  description: parsed.description,
144
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,
229
+ tier: parseTier(parsed.tier),
145
230
  vars: parsed.vars && typeof parsed.vars === 'object' ? parsed.vars : {},
146
231
  };
147
232
  if (typeof parsed.created_at === 'string')
@@ -158,8 +243,19 @@ export function readBundle(name) {
158
243
  }
159
244
  return bundle;
160
245
  }
246
+ /** Normalize a persisted `tier` value; anything but `session` ⇒ default tier. */
247
+ function parseTier(raw) {
248
+ return raw === 'session' ? 'session' : undefined;
249
+ }
250
+ /** The effective tier of a bundle (absent ⇒ `biometry`). */
251
+ export function bundleTier(bundle) {
252
+ return bundle.tier ?? 'biometry';
253
+ }
161
254
  export function writeBundle(bundle) {
162
255
  validateBundleName(bundle.name);
256
+ const backend = bundle.backend ?? 'keychain';
257
+ if (backend === 'file')
258
+ assertFileBackendUsable(bundle.name);
163
259
  for (const key of Object.keys(bundle.vars)) {
164
260
  validateEnvKey(key);
165
261
  }
@@ -191,6 +287,8 @@ export function writeBundle(bundle) {
191
287
  const payload = {
192
288
  description: bundle.description,
193
289
  allow_exec: bundle.allow_exec ? true : undefined,
290
+ backend: backend === 'file' ? 'file' : undefined,
291
+ tier: bundle.tier === 'session' ? 'session' : undefined,
194
292
  created_at: bundle.created_at,
195
293
  updated_at: bundle.updated_at,
196
294
  last_used: bundle.last_used,
@@ -198,79 +296,109 @@ export function writeBundle(bundle) {
198
296
  meta,
199
297
  };
200
298
  const json = JSON.stringify(payload);
201
- setKeychainToken(bundleMetaItem(bundle.name), json);
299
+ itemStore(backend).set(bundleMetaItem(bundle.name), json);
202
300
  emit('secrets.set', { bundle: bundle.name });
203
301
  }
204
302
  export function deleteBundle(name) {
205
303
  validateBundleName(name);
206
- const deleted = deleteKeychainToken(bundleMetaItem(name));
304
+ const deleted = itemStore(bundleBackend(name)).delete(bundleMetaItem(name));
207
305
  if (deleted) {
208
306
  emit('secrets.delete', { bundle: name });
209
307
  }
210
308
  return deleted;
211
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
+ }
212
348
  export function listBundles() {
213
- 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 = [];
214
355
  try {
215
- services = listKeychainItems(BUNDLE_META_PREFIX);
356
+ keychainServices = listKeychainItems(BUNDLE_META_PREFIX);
216
357
  }
217
358
  catch {
218
- return [];
359
+ keychainServices = [];
219
360
  }
220
- const names = services
361
+ const keychainNames = keychainServices
221
362
  .map((s) => s.slice(BUNDLE_META_PREFIX.length))
222
363
  .filter((n) => BUNDLE_NAME_PATTERN.test(n));
223
- if (names.length === 0)
224
- return [];
225
- // Batch all metadata reads behind ONE Touch ID prompt instead of N. Bundle
226
- // metadata items carry user-presence ACLs (same as secret values), so a naive
227
- // loop over readBundle() spawns a fresh LAContext per item — meaning N
228
- // biometric prompts for `secrets list`. Sharing a single context across all
229
- // SecItemCopyMatching calls collapses the prompt to one. Mirrors the pattern
230
- // already used by resolveBundleEnv for runtime secret injection.
231
- const itemsToFetch = names.map(bundleMetaItem);
232
- const fetched = getKeychainTokens(itemsToFetch);
233
- const out = [];
234
- for (const name of names) {
235
- const json = fetched.get(bundleMetaItem(name));
236
- if (json === undefined)
237
- continue;
238
- 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;
239
390
  try {
240
- parsed = JSON.parse(json);
391
+ json = fileItemStore.get(bundleMetaItem(name));
241
392
  }
242
393
  catch {
243
- // 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: {} });
244
397
  continue;
245
398
  }
246
- if (!parsed || typeof parsed !== 'object')
247
- continue;
248
- const bundle = {
249
- name,
250
- description: parsed.description,
251
- allow_exec: Boolean(parsed.allow_exec),
252
- vars: parsed.vars && typeof parsed.vars === 'object' ? parsed.vars : {},
253
- };
254
- if (typeof parsed.created_at === 'string')
255
- bundle.created_at = parsed.created_at;
256
- if (typeof parsed.updated_at === 'string')
257
- bundle.updated_at = parsed.updated_at;
258
- if (typeof parsed.last_used === 'string')
259
- bundle.last_used = parsed.last_used;
260
- if (parsed.meta && typeof parsed.meta === 'object')
261
- bundle.meta = parsed.meta;
262
- // Skip bundles with invalid env keys rather than throwing — same lenient
263
- // posture readBundle had via the outer catch.
264
- let valid = true;
265
- for (const key of Object.keys(bundle.vars)) {
266
- if (!ENV_KEY_PATTERN.test(key)) {
267
- valid = false;
268
- break;
269
- }
270
- }
271
- if (!valid)
272
- continue;
273
- out.push(bundle);
399
+ const bundle = parseBundleMeta(name, json, 'file');
400
+ if (bundle)
401
+ out.push(bundle);
274
402
  }
275
403
  return out.sort((a, b) => a.name.localeCompare(b.name));
276
404
  }
@@ -326,8 +454,9 @@ export function resolveBundleEnv(bundle, _opts = {}) {
326
454
  keychainItemsToFetch.push(secretsKeychainItem(bundle.name, parsed.ref.value));
327
455
  }
328
456
  }
457
+ const store = itemStore(bundle.backend ?? 'keychain');
329
458
  const fetched = keychainItemsToFetch.length > 0
330
- ? getKeychainTokens(keychainItemsToFetch)
459
+ ? store.getBatch(keychainItemsToFetch)
331
460
  : new Map();
332
461
  const env = {};
333
462
  for (const [key, raw] of Object.entries(bundle.vars)) {
@@ -340,7 +469,7 @@ export function resolveBundleEnv(bundle, _opts = {}) {
340
469
  const item = secretsKeychainItem(bundle.name, parsed.ref.value);
341
470
  const value = fetched.get(item);
342
471
  if (value === undefined) {
343
- 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. ` +
344
473
  `Run: agents secrets add ${bundle.name} ${key}`);
345
474
  }
346
475
  env[key] = value;
@@ -375,11 +504,35 @@ export function resolveBundleEnv(bundle, _opts = {}) {
375
504
  */
376
505
  export function readAndResolveBundleEnv(name, opts = {}) {
377
506
  validateBundleName(name);
507
+ const backend = bundleBackend(name);
508
+ // Fast-path: if the secrets-agent holds this bundle (user ran
509
+ // `agents secrets unlock <name>`), return the cached snapshot with no Touch
510
+ // ID. Soft — any failure falls through to the real keychain read below. macOS
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') {
515
+ const hit = agentGetSync(name);
516
+ if (hit) {
517
+ stampLastUsed(hit.bundle);
518
+ emit('secrets.get', {
519
+ bundle: name,
520
+ caller: opts.caller,
521
+ status: 'success',
522
+ source: 'agent',
523
+ keyCount: Object.keys(hit.env).length,
524
+ });
525
+ return hit;
526
+ }
527
+ }
528
+ if (backend === 'file')
529
+ assertFileBackendUsable(name);
530
+ const store = itemStore(backend);
378
531
  const metaItem = bundleMetaItem(name);
379
532
  const bundleSecretPrefix = `${SECRETS_ITEM_PREFIX}${name}.`;
380
533
  let secretItems;
381
534
  try {
382
- secretItems = listKeychainItems(bundleSecretPrefix);
535
+ secretItems = store.list(bundleSecretPrefix);
383
536
  }
384
537
  catch {
385
538
  secretItems = [];
@@ -388,9 +541,16 @@ export function readAndResolveBundleEnv(name, opts = {}) {
388
541
  ? `read ${name} secrets (for ${opts.caller})`
389
542
  : `read ${name} secrets`;
390
543
  void reason;
391
- const fetched = getKeychainTokens([metaItem, ...secretItems]);
544
+ const fetched = store.getBatch([metaItem, ...secretItems]);
392
545
  const json = fetched.get(metaItem);
393
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
+ }
394
554
  throw new Error(`Secrets bundle '${name}' not found.`);
395
555
  }
396
556
  let parsed;
@@ -407,6 +567,8 @@ export function readAndResolveBundleEnv(name, opts = {}) {
407
567
  name,
408
568
  description: parsed.description,
409
569
  allow_exec: Boolean(parsed.allow_exec),
570
+ backend: backend === 'file' ? 'file' : undefined,
571
+ tier: parseTier(parsed.tier),
410
572
  vars: parsed.vars && typeof parsed.vars === 'object' ? parsed.vars : {},
411
573
  };
412
574
  if (typeof parsed.created_at === 'string')
@@ -459,7 +621,7 @@ export function readAndResolveBundleEnv(name, opts = {}) {
459
621
  const item = secretsKeychainItem(bundle.name, p.ref.value);
460
622
  const value = fetched.get(item);
461
623
  if (value === undefined) {
462
- 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. ` +
463
625
  `Run: agents secrets add ${bundle.name} ${key}`);
464
626
  }
465
627
  env[key] = value;
@@ -476,6 +638,18 @@ export function readAndResolveBundleEnv(name, opts = {}) {
476
638
  }
477
639
  }
478
640
  emitReadAudit('success');
641
+ // Auto-cache: this was a real keychain read (the agent fast-path returned
642
+ // earlier on a hit). If the bundle opts into the session tier and the user
643
+ // enabled `secrets.agent.auto`, populate the broker in the background so the
644
+ // next concurrent run reads silently. Skipped when noAgent (e.g. `unlock`,
645
+ // which loads the agent itself). Fire-and-forget — never blocks this read.
646
+ if (backend === 'keychain' &&
647
+ !opts.noAgent &&
648
+ process.env.AGENTS_SECRETS_NO_AGENT !== '1' &&
649
+ bundleTier(bundle) === 'session' &&
650
+ secretsAgentAutoEnabled()) {
651
+ agentAutoLoadSync(name, bundle, env, DEFAULT_TTL_MS);
652
+ }
479
653
  return { bundle, env };
480
654
  }
481
655
  catch (err) {
@@ -506,7 +680,7 @@ export function rotateBundleSecret(bundle, key, opts) {
506
680
  }
507
681
  const shortId = raw.slice('keychain:'.length);
508
682
  const item = secretsKeychainItem(bundle.name, shortId);
509
- setKeychainToken(item, opts.newValue);
683
+ itemStore(bundle.backend ?? 'keychain').set(item, opts.newValue);
510
684
  if (opts.clearMeta) {
511
685
  if (bundle.meta)
512
686
  delete bundle.meta[key];
@@ -550,13 +724,17 @@ export function renameBundle(oldName, newName, opts = {}) {
550
724
  throw new Error(`Bundle '${oldName}' not found.`);
551
725
  }
552
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');
553
730
  if (bundleExists(newName)) {
554
731
  if (!opts.force) {
555
732
  throw new Error(`Bundle '${newName}' already exists. Use --force to overwrite.`);
556
733
  }
557
734
  const dest = readBundle(newName);
735
+ const destStore = itemStore(dest.backend ?? 'keychain');
558
736
  for (const { item } of keychainItemsForBundle(dest)) {
559
- deleteKeychainToken(item);
737
+ destStore.delete(item);
560
738
  }
561
739
  deleteBundle(newName);
562
740
  }
@@ -569,19 +747,30 @@ export function renameBundle(oldName, newName, opts = {}) {
569
747
  continue;
570
748
  const shortId = raw.slice('keychain:'.length);
571
749
  const newItem = secretsKeychainItem(newName, shortId);
572
- const value = getKeychainToken(oldItem);
573
- setKeychainToken(newItem, value);
750
+ const value = store.get(oldItem);
751
+ store.set(newItem, value);
574
752
  }
575
- // 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).
576
755
  const renamed = { ...source, name: newName };
577
756
  writeBundle(renamed);
578
- // Cleanup: delete the old per-key keychain items, then the old metadata.
757
+ // Cleanup: delete the old per-key items, then the old metadata.
579
758
  for (const { item: oldItem } of sourceItems) {
580
- deleteKeychainToken(oldItem);
759
+ store.delete(oldItem);
581
760
  }
582
761
  deleteBundle(oldName);
583
762
  emit('secrets.rename', { from: oldName, to: newName });
584
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
+ }
585
774
  // Iterate all keychain-backed keys in a bundle for cleanup on rm/unset.
586
775
  export function keychainItemsForBundle(bundle) {
587
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 {};