@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.
- package/CHANGELOG.md +10 -0
- package/dist/commands/secrets.js +137 -25
- package/dist/commands/versions.js +7 -1
- package/dist/lib/secrets/agent.d.ts +37 -6
- package/dist/lib/secrets/agent.js +197 -63
- package/dist/lib/secrets/bundles.d.ts +37 -7
- package/dist/lib/secrets/bundles.js +226 -80
- package/dist/lib/secrets/filestore.d.ts +82 -0
- package/dist/lib/secrets/filestore.js +295 -0
- package/dist/lib/secrets/linux.d.ts +6 -24
- package/dist/lib/secrets/linux.js +22 -265
- package/dist/lib/versions.d.ts +20 -0
- package/dist/lib/versions.js +48 -0
- package/package.json +1 -1
|
@@ -1,12 +1,18 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Secret bundles — named sets of
|
|
2
|
+
* Secret bundles — named sets of environment variables backed by a secret store.
|
|
3
3
|
*
|
|
4
|
-
* Bundle metadata (name, description, vars map) is stored
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
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
|
|
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 =
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
356
|
+
keychainServices = listKeychainItems(BUNDLE_META_PREFIX);
|
|
227
357
|
}
|
|
228
358
|
catch {
|
|
229
|
-
|
|
359
|
+
keychainServices = [];
|
|
230
360
|
}
|
|
231
|
-
const
|
|
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 (
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
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
|
-
|
|
391
|
+
json = fileItemStore.get(bundleMetaItem(name));
|
|
252
392
|
}
|
|
253
393
|
catch {
|
|
254
|
-
//
|
|
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
|
-
|
|
258
|
-
|
|
259
|
-
|
|
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
|
-
?
|
|
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}':
|
|
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
|
|
394
|
-
//
|
|
395
|
-
|
|
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 =
|
|
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 =
|
|
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}':
|
|
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 (
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
616
|
-
|
|
750
|
+
const value = store.get(oldItem);
|
|
751
|
+
store.set(newItem, value);
|
|
617
752
|
}
|
|
618
|
-
// writeBundle preserves source.created_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
|
|
757
|
+
// Cleanup: delete the old per-key items, then the old metadata.
|
|
622
758
|
for (const { item: oldItem } of sourceItems) {
|
|
623
|
-
|
|
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 {};
|