@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.
- package/CHANGELOG.md +19 -0
- package/README.md +1 -1
- package/dist/commands/budget.d.ts +14 -0
- package/dist/commands/budget.js +137 -0
- package/dist/commands/cost.d.ts +12 -0
- package/dist/commands/cost.js +139 -0
- package/dist/commands/exec.d.ts +20 -0
- package/dist/commands/exec.js +382 -5
- package/dist/commands/secrets.d.ts +15 -0
- package/dist/commands/secrets.js +343 -16
- package/dist/commands/sessions.js +4 -0
- package/dist/index.js +4 -0
- package/dist/lib/budget/config.d.ts +9 -0
- package/dist/lib/budget/config.js +115 -0
- package/dist/lib/budget/enforce.d.ts +94 -0
- package/dist/lib/budget/enforce.js +151 -0
- package/dist/lib/budget/ledger.d.ts +61 -0
- package/dist/lib/budget/ledger.js +107 -0
- package/dist/lib/budget/preflight.d.ts +110 -0
- package/dist/lib/budget/preflight.js +200 -0
- package/dist/lib/checkpoint.d.ts +54 -0
- package/dist/lib/checkpoint.js +56 -0
- package/dist/lib/cloud/rush.js +18 -0
- package/dist/lib/exec.d.ts +36 -0
- package/dist/lib/exec.js +192 -4
- package/dist/lib/git.d.ts +18 -0
- package/dist/lib/git.js +67 -4
- package/dist/lib/loop.d.ts +145 -0
- package/dist/lib/loop.js +330 -0
- package/dist/lib/mcp.d.ts +7 -0
- package/dist/lib/mcp.js +24 -0
- package/dist/lib/models.d.ts +11 -0
- package/dist/lib/models.js +21 -0
- package/dist/lib/plugins.js +5 -2
- package/dist/lib/pricing/cost.d.ts +46 -0
- package/dist/lib/pricing/cost.js +71 -0
- package/dist/lib/pricing/index.d.ts +8 -0
- package/dist/lib/pricing/index.js +8 -0
- package/dist/lib/pricing/prices.json +138 -0
- package/dist/lib/pricing/table.d.ts +17 -0
- package/dist/lib/pricing/table.js +73 -0
- package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
- package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
- package/dist/lib/secrets/agent.d.ts +147 -0
- package/dist/lib/secrets/agent.js +500 -0
- package/dist/lib/secrets/bundles.d.ts +58 -7
- package/dist/lib/secrets/bundles.js +264 -75
- 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/session/db.d.ts +40 -0
- package/dist/lib/session/db.js +84 -2
- package/dist/lib/session/discover.d.ts +2 -0
- package/dist/lib/session/discover.js +126 -2
- package/dist/lib/session/render.d.ts +2 -0
- package/dist/lib/session/render.js +1 -1
- package/dist/lib/session/types.d.ts +4 -0
- package/dist/lib/teams/agents.d.ts +32 -0
- package/dist/lib/teams/agents.js +66 -3
- package/dist/lib/teams/api.js +20 -0
- package/dist/lib/teams/parsers.js +16 -4
- package/dist/lib/types.d.ts +48 -0
- package/dist/lib/workflows.d.ts +56 -0
- package/dist/lib/workflows.js +72 -5
- package/package.json +2 -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,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
|
|
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 =
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
356
|
+
keychainServices = listKeychainItems(BUNDLE_META_PREFIX);
|
|
216
357
|
}
|
|
217
358
|
catch {
|
|
218
|
-
|
|
359
|
+
keychainServices = [];
|
|
219
360
|
}
|
|
220
|
-
const
|
|
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 (
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
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
|
-
|
|
391
|
+
json = fileItemStore.get(bundleMetaItem(name));
|
|
241
392
|
}
|
|
242
393
|
catch {
|
|
243
|
-
//
|
|
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
|
-
|
|
247
|
-
|
|
248
|
-
|
|
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
|
-
?
|
|
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}':
|
|
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 =
|
|
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 =
|
|
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}':
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
573
|
-
|
|
750
|
+
const value = store.get(oldItem);
|
|
751
|
+
store.set(newItem, value);
|
|
574
752
|
}
|
|
575
|
-
// writeBundle preserves source.created_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
|
|
757
|
+
// Cleanup: delete the old per-key items, then the old metadata.
|
|
579
758
|
for (const { item: oldItem } of sourceItems) {
|
|
580
|
-
|
|
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 {};
|