@phnx-labs/agents-cli 1.19.2 → 1.20.3
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 +140 -0
- package/README.md +72 -12
- package/dist/browser.js +0 -0
- package/dist/commands/browser.js +88 -16
- package/dist/commands/cli.d.ts +14 -0
- package/dist/commands/cli.js +244 -0
- package/dist/commands/cloud.js +1 -1
- package/dist/commands/commands.js +27 -10
- package/dist/commands/computer.js +18 -1
- package/dist/commands/doctor.d.ts +1 -1
- package/dist/commands/doctor.js +2 -2
- package/dist/commands/exec.js +38 -18
- package/dist/commands/factory.d.ts +3 -14
- package/dist/commands/factory.js +3 -3
- package/dist/commands/feedback.d.ts +7 -0
- package/dist/commands/feedback.js +89 -0
- package/dist/commands/helper.d.ts +12 -0
- package/dist/commands/helper.js +87 -0
- package/dist/commands/hooks.js +89 -10
- package/dist/commands/mcp.js +166 -10
- package/dist/commands/packages.js +196 -27
- package/dist/commands/permissions.js +21 -6
- package/dist/commands/plugins.js +11 -4
- package/dist/commands/profiles.d.ts +8 -0
- package/dist/commands/profiles.js +118 -5
- package/dist/commands/prune.js +39 -160
- package/dist/commands/pull.js +58 -5
- package/dist/commands/routines.js +107 -14
- package/dist/commands/rules.js +8 -4
- package/dist/commands/secrets-migrate.d.ts +24 -0
- package/dist/commands/secrets-migrate.js +198 -0
- package/dist/commands/secrets-sync.d.ts +11 -0
- package/dist/commands/secrets-sync.js +155 -0
- package/dist/commands/secrets.js +79 -46
- package/dist/commands/sessions.d.ts +28 -0
- package/dist/commands/sessions.js +98 -33
- package/dist/commands/setup.d.ts +1 -0
- package/dist/commands/setup.js +37 -28
- package/dist/commands/skills.js +25 -8
- package/dist/commands/subagents.js +69 -49
- package/dist/commands/teams.js +61 -10
- package/dist/commands/utils.d.ts +33 -0
- package/dist/commands/utils.js +139 -0
- package/dist/commands/versions.d.ts +4 -3
- package/dist/commands/versions.js +134 -130
- package/dist/commands/view.d.ts +6 -0
- package/dist/commands/view.js +175 -19
- package/dist/commands/workflows.js +29 -6
- package/dist/computer.js +0 -0
- package/dist/index.js +38 -6
- package/dist/lib/acp/client.js +6 -1
- package/dist/lib/acp/harnesses.js +8 -0
- package/dist/lib/agents.d.ts +4 -0
- package/dist/lib/agents.js +125 -34
- package/dist/lib/auto-pull-worker.js +18 -1
- package/dist/lib/browser/cdp.d.ts +8 -1
- package/dist/lib/browser/cdp.js +40 -3
- package/dist/lib/browser/chrome.d.ts +13 -0
- package/dist/lib/browser/chrome.js +46 -3
- package/dist/lib/browser/domain-skills.d.ts +51 -0
- package/dist/lib/browser/domain-skills.js +157 -0
- package/dist/lib/browser/drivers/local.js +45 -4
- package/dist/lib/browser/drivers/ssh.js +2 -2
- package/dist/lib/browser/ipc.d.ts +8 -1
- package/dist/lib/browser/ipc.js +37 -28
- package/dist/lib/browser/profiles.d.ts +16 -3
- package/dist/lib/browser/profiles.js +44 -4
- package/dist/lib/browser/service.d.ts +3 -0
- package/dist/lib/browser/service.js +40 -5
- package/dist/lib/browser/types.d.ts +11 -4
- package/dist/lib/cli-resources.d.ts +137 -0
- package/dist/lib/cli-resources.js +477 -0
- package/dist/lib/cloud/factory.d.ts +1 -1
- package/dist/lib/cloud/factory.js +1 -1
- package/dist/lib/cloud/rush.js +5 -5
- package/dist/lib/command-skills.js +0 -2
- package/dist/lib/computer-rpc.d.ts +3 -0
- package/dist/lib/computer-rpc.js +53 -0
- package/dist/lib/daemon.js +20 -0
- package/dist/lib/events.d.ts +16 -2
- package/dist/lib/events.js +33 -2
- package/dist/lib/exec.d.ts +42 -13
- package/dist/lib/exec.js +127 -33
- package/dist/lib/help.js +11 -5
- package/dist/lib/hooks/cache.d.ts +38 -0
- package/dist/lib/hooks/cache.js +242 -0
- package/dist/lib/hooks/profile.d.ts +33 -0
- package/dist/lib/hooks/profile.js +129 -0
- package/dist/lib/hooks.d.ts +0 -10
- package/dist/lib/hooks.js +246 -11
- package/dist/lib/mcp.d.ts +15 -0
- package/dist/lib/mcp.js +46 -0
- package/dist/lib/migrate.js +1 -1
- package/dist/lib/overdue.d.ts +26 -0
- package/dist/lib/overdue.js +101 -0
- package/dist/lib/permissions.d.ts +13 -0
- package/dist/lib/permissions.js +55 -1
- package/dist/lib/plugin-marketplace.js +1 -1
- package/dist/lib/plugins.js +15 -1
- package/dist/lib/profiles-presets.d.ts +26 -0
- package/dist/lib/profiles-presets.js +216 -0
- package/dist/lib/profiles.d.ts +34 -0
- package/dist/lib/profiles.js +112 -1
- package/dist/lib/resources/mcp.js +37 -0
- package/dist/lib/resources.d.ts +1 -1
- package/dist/lib/rotate.js +10 -4
- package/dist/lib/routines-format.d.ts +47 -0
- package/dist/lib/routines-format.js +194 -0
- package/dist/lib/routines.d.ts +8 -2
- package/dist/lib/routines.js +34 -14
- package/dist/lib/runner.js +83 -15
- package/dist/lib/scheduler.js +8 -1
- 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/Agents CLI.app/Contents/_CodeSignature/CodeResources +1 -9
- package/dist/lib/secrets/bundles.d.ts +34 -17
- package/dist/lib/secrets/bundles.js +210 -36
- package/dist/lib/secrets/index.d.ts +49 -30
- package/dist/lib/secrets/index.js +126 -115
- package/dist/lib/secrets/install-helper.d.ts +45 -0
- package/dist/lib/secrets/install-helper.js +165 -0
- package/dist/lib/secrets/linux.js +4 -4
- package/dist/lib/secrets/sync.d.ts +56 -0
- package/dist/lib/secrets/sync.js +180 -0
- package/dist/lib/session/active.d.ts +8 -0
- package/dist/lib/session/active.js +3 -2
- package/dist/lib/session/db.d.ts +0 -4
- package/dist/lib/session/db.js +0 -26
- package/dist/lib/session/parse.d.ts +1 -0
- package/dist/lib/session/parse.js +44 -0
- package/dist/lib/session/render.js +4 -4
- package/dist/lib/session/types.d.ts +2 -2
- package/dist/lib/session/types.js +1 -1
- package/dist/lib/shims.d.ts +5 -2
- package/dist/lib/shims.js +70 -38
- package/dist/lib/state.d.ts +14 -2
- package/dist/lib/state.js +51 -20
- package/dist/lib/teams/agents.d.ts +5 -4
- package/dist/lib/teams/agents.js +48 -22
- package/dist/lib/teams/api.d.ts +2 -1
- package/dist/lib/teams/api.js +4 -3
- package/dist/lib/teams/parsers.d.ts +1 -1
- package/dist/lib/teams/parsers.js +153 -3
- package/dist/lib/teams/summarizer.js +18 -2
- package/dist/lib/teams/worktree.js +14 -3
- package/dist/lib/types.d.ts +63 -4
- package/dist/lib/types.js +8 -3
- package/dist/lib/usage.d.ts +27 -2
- package/dist/lib/usage.js +100 -17
- package/dist/lib/versions.d.ts +45 -3
- package/dist/lib/versions.js +455 -60
- package/package.json +15 -14
- package/scripts/install-helper.js +97 -0
- package/scripts/postinstall.js +16 -0
- package/dist/lib/secrets/Agents CLI.app/Contents/embedded.provisionprofile +0 -0
- package/npm-shrinkwrap.json +0 -3162
|
@@ -1,21 +1,21 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Secret bundles
|
|
2
|
+
* Secret bundles — named sets of keychain-backed environment variables.
|
|
3
3
|
*
|
|
4
4
|
* Bundle metadata (name, description, vars map) is stored in the macOS
|
|
5
|
-
* Keychain as a JSON blob under `agents-cli.bundles.<name>`.
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
* the
|
|
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.
|
|
9
10
|
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
* `icloud_sync` flag.
|
|
11
|
+
* Cross-machine sync is handled by src/lib/secrets/sync.ts via an explicit
|
|
12
|
+
* encrypted export/import flow; the bundle layer is sync-agnostic.
|
|
13
13
|
*/
|
|
14
14
|
import * as fs from 'fs';
|
|
15
15
|
import * as os from 'os';
|
|
16
16
|
import * as path from 'path';
|
|
17
17
|
import * as yaml from 'yaml';
|
|
18
|
-
import { deleteKeychainToken, getKeychainToken,
|
|
18
|
+
import { deleteKeychainToken, getKeychainToken, getKeychainTokens, hasKeychainToken, listKeychainItems, parseBundleValue, resolveRef, secretsKeychainItem, setKeychainToken, } from './index.js';
|
|
19
19
|
import { emit } from '../events.js';
|
|
20
20
|
/** Allowed values for a secret's `type` metadata field. */
|
|
21
21
|
export const SECRET_TYPES = [
|
|
@@ -34,6 +34,7 @@ const LAST_USED_THROTTLE_MS = 60_000;
|
|
|
34
34
|
const BUNDLE_NAME_PATTERN = /^[a-z0-9][a-z0-9\-_.]{0,48}$/i;
|
|
35
35
|
const ENV_KEY_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
36
36
|
const BUNDLE_META_PREFIX = 'agents-cli.bundles.';
|
|
37
|
+
const SECRETS_ITEM_PREFIX = 'agents-cli.secrets.';
|
|
37
38
|
export const RESERVED_ENV_NAMES = new Set([
|
|
38
39
|
'PATH', 'HOME', 'USER', 'USERNAME', 'SHELL', 'PWD', 'OLDPWD',
|
|
39
40
|
'TERM', 'LANG', 'LC_ALL', 'DISPLAY', 'EDITOR', 'VISUAL',
|
|
@@ -62,6 +63,17 @@ export function isLoaderOrInterpreterEnv(name) {
|
|
|
62
63
|
'CDPATH',
|
|
63
64
|
].includes(upper);
|
|
64
65
|
}
|
|
66
|
+
export function sanitizeProcessEnv(env = process.env) {
|
|
67
|
+
const out = {};
|
|
68
|
+
for (const [k, v] of Object.entries(env)) {
|
|
69
|
+
if (v === undefined)
|
|
70
|
+
continue;
|
|
71
|
+
if (isLoaderOrInterpreterEnv(k))
|
|
72
|
+
continue;
|
|
73
|
+
out[k] = v;
|
|
74
|
+
}
|
|
75
|
+
return out;
|
|
76
|
+
}
|
|
65
77
|
/** Validate a bundle name against the allowed pattern. Throws on invalid input. */
|
|
66
78
|
export function validateBundleName(name) {
|
|
67
79
|
if (!BUNDLE_NAME_PATTERN.test(name)) {
|
|
@@ -124,11 +136,12 @@ export function readBundle(name) {
|
|
|
124
136
|
if (!parsed || typeof parsed !== 'object') {
|
|
125
137
|
throw new Error(`Bundle '${name}' is malformed.`);
|
|
126
138
|
}
|
|
139
|
+
// Unknown fields on the JSON (e.g. legacy sync flags) are silently dropped
|
|
140
|
+
// here; the SecretsBundle shape is the only source of truth.
|
|
127
141
|
const bundle = {
|
|
128
142
|
name,
|
|
129
143
|
description: parsed.description,
|
|
130
144
|
allow_exec: Boolean(parsed.allow_exec),
|
|
131
|
-
icloud_sync: Boolean(parsed.icloud_sync),
|
|
132
145
|
vars: parsed.vars && typeof parsed.vars === 'object' ? parsed.vars : {},
|
|
133
146
|
};
|
|
134
147
|
if (typeof parsed.created_at === 'string')
|
|
@@ -178,7 +191,6 @@ export function writeBundle(bundle) {
|
|
|
178
191
|
const payload = {
|
|
179
192
|
description: bundle.description,
|
|
180
193
|
allow_exec: bundle.allow_exec ? true : undefined,
|
|
181
|
-
icloud_sync: bundle.icloud_sync ? true : undefined,
|
|
182
194
|
created_at: bundle.created_at,
|
|
183
195
|
updated_at: bundle.updated_at,
|
|
184
196
|
last_used: bundle.last_used,
|
|
@@ -186,7 +198,7 @@ export function writeBundle(bundle) {
|
|
|
186
198
|
meta,
|
|
187
199
|
};
|
|
188
200
|
const json = JSON.stringify(payload);
|
|
189
|
-
setKeychainToken(bundleMetaItem(bundle.name), json
|
|
201
|
+
setKeychainToken(bundleMetaItem(bundle.name), json);
|
|
190
202
|
emit('secrets.set', { bundle: bundle.name });
|
|
191
203
|
}
|
|
192
204
|
export function deleteBundle(name) {
|
|
@@ -208,14 +220,57 @@ export function listBundles() {
|
|
|
208
220
|
const names = services
|
|
209
221
|
.map((s) => s.slice(BUNDLE_META_PREFIX.length))
|
|
210
222
|
.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);
|
|
211
233
|
const out = [];
|
|
212
234
|
for (const name of names) {
|
|
235
|
+
const json = fetched.get(bundleMetaItem(name));
|
|
236
|
+
if (json === undefined)
|
|
237
|
+
continue;
|
|
238
|
+
let parsed;
|
|
213
239
|
try {
|
|
214
|
-
|
|
240
|
+
parsed = JSON.parse(json);
|
|
215
241
|
}
|
|
216
242
|
catch {
|
|
217
243
|
// Skip malformed bundles; surfaced via `agents secrets view <name>`.
|
|
244
|
+
continue;
|
|
218
245
|
}
|
|
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);
|
|
219
274
|
}
|
|
220
275
|
return out.sort((a, b) => a.name.localeCompare(b.name));
|
|
221
276
|
}
|
|
@@ -253,31 +308,27 @@ function stampLastUsed(bundle) {
|
|
|
253
308
|
// Swallow — telemetry must never block secret resolution.
|
|
254
309
|
}
|
|
255
310
|
}
|
|
256
|
-
|
|
311
|
+
// Walk the bundle and produce a flat env map. Every keychain: ref is gathered
|
|
312
|
+
// into a single batch read so macOS shows ONE Touch ID prompt for the whole
|
|
313
|
+
// bundle — including the metadata fetch that already happened in readBundle
|
|
314
|
+
// (the helper's auth context survives across separate invocations only via
|
|
315
|
+
// the per-process LAContext, so we still get one prompt for the batch even
|
|
316
|
+
// if metadata triggered an earlier one). Literals/env/file/exec refs are
|
|
317
|
+
// resolved inline and never reach the keychain.
|
|
318
|
+
export function resolveBundleEnv(bundle, _opts = {}) {
|
|
257
319
|
stampLastUsed(bundle);
|
|
258
320
|
const parsedByKey = new Map();
|
|
259
321
|
const keychainItemsToFetch = [];
|
|
260
|
-
const keychainItemToKey = new Map();
|
|
261
322
|
for (const [key, raw] of Object.entries(bundle.vars)) {
|
|
262
323
|
const parsed = parseBundleValue(raw);
|
|
263
324
|
parsedByKey.set(key, parsed);
|
|
264
325
|
if ('ref' in parsed && parsed.ref.provider === 'keychain') {
|
|
265
|
-
|
|
266
|
-
keychainItemsToFetch.push(item);
|
|
267
|
-
keychainItemToKey.set(item, key);
|
|
326
|
+
keychainItemsToFetch.push(secretsKeychainItem(bundle.name, parsed.ref.value));
|
|
268
327
|
}
|
|
269
328
|
}
|
|
270
|
-
// Build the localizedReason shown under the Touch ID prompt. Lowercase verb
|
|
271
|
-
// phrase per Apple HIG — the system prepends "<App> is required to ".
|
|
272
|
-
const reason = opts.caller
|
|
273
|
-
? `read ${bundle.name} secrets (for ${opts.caller})`
|
|
274
|
-
: `read ${bundle.name} secrets`;
|
|
275
|
-
// Single helper invocation, one biometric prompt.
|
|
276
329
|
const fetched = keychainItemsToFetch.length > 0
|
|
277
|
-
?
|
|
330
|
+
? getKeychainTokens(keychainItemsToFetch)
|
|
278
331
|
: new Map();
|
|
279
|
-
// Second pass: assemble env. Keychain values come from the batch; everything
|
|
280
|
-
// else is resolved inline (literals and env/file/exec refs don't prompt).
|
|
281
332
|
const env = {};
|
|
282
333
|
for (const [key, raw] of Object.entries(bundle.vars)) {
|
|
283
334
|
const parsed = parsedByKey.get(key);
|
|
@@ -298,7 +349,6 @@ export function resolveBundleEnv(bundle, opts = {}) {
|
|
|
298
349
|
try {
|
|
299
350
|
env[key] = resolveRef(parsed.ref, {
|
|
300
351
|
allowExec: bundle.allow_exec,
|
|
301
|
-
iCloudSync: bundle.icloud_sync,
|
|
302
352
|
keychainItemFor: (shortId) => secretsKeychainItem(bundle.name, shortId),
|
|
303
353
|
});
|
|
304
354
|
}
|
|
@@ -306,8 +356,133 @@ export function resolveBundleEnv(bundle, opts = {}) {
|
|
|
306
356
|
throw new Error(`Bundle '${bundle.name}' key '${key}': ${err.message}`);
|
|
307
357
|
}
|
|
308
358
|
}
|
|
359
|
+
// `caller` is intentionally unused; see ResolveBundleOptions.
|
|
360
|
+
void _opts.caller;
|
|
309
361
|
return env;
|
|
310
362
|
}
|
|
363
|
+
/**
|
|
364
|
+
* Read a bundle's metadata AND resolve its env in a single Touch ID prompt.
|
|
365
|
+
*
|
|
366
|
+
* `readBundle` + `resolveBundleEnv` issued two separate `LAContext` calls
|
|
367
|
+
* (metadata read via `get-auth`, then secret values via `get-batch`) which
|
|
368
|
+
* surfaced as two consecutive Touch ID prompts. macOS does not honor
|
|
369
|
+
* "Always Allow" for items protected with `kSecAttrAccessControl`+biometry,
|
|
370
|
+
* so caching at the OS level was never an option. This collapses both reads
|
|
371
|
+
* into one `get-batch` call: we enumerate the bundle's secret items first
|
|
372
|
+
* (silent — `list` returns attrs only and does not trigger biometry) and
|
|
373
|
+
* include the metadata item in the same batch. One prompt, correctly scoped
|
|
374
|
+
* to the bundle name and caller.
|
|
375
|
+
*/
|
|
376
|
+
export function readAndResolveBundleEnv(name, opts = {}) {
|
|
377
|
+
validateBundleName(name);
|
|
378
|
+
const metaItem = bundleMetaItem(name);
|
|
379
|
+
const bundleSecretPrefix = `${SECRETS_ITEM_PREFIX}${name}.`;
|
|
380
|
+
let secretItems;
|
|
381
|
+
try {
|
|
382
|
+
secretItems = listKeychainItems(bundleSecretPrefix);
|
|
383
|
+
}
|
|
384
|
+
catch {
|
|
385
|
+
secretItems = [];
|
|
386
|
+
}
|
|
387
|
+
const reason = opts.caller
|
|
388
|
+
? `read ${name} secrets (for ${opts.caller})`
|
|
389
|
+
: `read ${name} secrets`;
|
|
390
|
+
void reason;
|
|
391
|
+
const fetched = getKeychainTokens([metaItem, ...secretItems]);
|
|
392
|
+
const json = fetched.get(metaItem);
|
|
393
|
+
if (json === undefined) {
|
|
394
|
+
throw new Error(`Secrets bundle '${name}' not found.`);
|
|
395
|
+
}
|
|
396
|
+
let parsed;
|
|
397
|
+
try {
|
|
398
|
+
parsed = JSON.parse(json);
|
|
399
|
+
}
|
|
400
|
+
catch {
|
|
401
|
+
throw new Error(`Bundle '${name}' is malformed.`);
|
|
402
|
+
}
|
|
403
|
+
if (!parsed || typeof parsed !== 'object') {
|
|
404
|
+
throw new Error(`Bundle '${name}' is malformed.`);
|
|
405
|
+
}
|
|
406
|
+
const bundle = {
|
|
407
|
+
name,
|
|
408
|
+
description: parsed.description,
|
|
409
|
+
allow_exec: Boolean(parsed.allow_exec),
|
|
410
|
+
vars: parsed.vars && typeof parsed.vars === 'object' ? parsed.vars : {},
|
|
411
|
+
};
|
|
412
|
+
if (typeof parsed.created_at === 'string')
|
|
413
|
+
bundle.created_at = parsed.created_at;
|
|
414
|
+
if (typeof parsed.updated_at === 'string')
|
|
415
|
+
bundle.updated_at = parsed.updated_at;
|
|
416
|
+
if (typeof parsed.last_used === 'string')
|
|
417
|
+
bundle.last_used = parsed.last_used;
|
|
418
|
+
if (parsed.meta && typeof parsed.meta === 'object')
|
|
419
|
+
bundle.meta = parsed.meta;
|
|
420
|
+
for (const key of Object.keys(bundle.vars)) {
|
|
421
|
+
validateEnvKey(key);
|
|
422
|
+
}
|
|
423
|
+
stampLastUsed(bundle);
|
|
424
|
+
const parsedByKey = new Map();
|
|
425
|
+
const keychainKeys = [];
|
|
426
|
+
const kindCounts = {};
|
|
427
|
+
for (const [key, raw] of Object.entries(bundle.vars)) {
|
|
428
|
+
const p = parseBundleValue(raw);
|
|
429
|
+
parsedByKey.set(key, p);
|
|
430
|
+
const kind = 'literal' in p ? 'literal' : p.ref.provider;
|
|
431
|
+
kindCounts[kind] = (kindCounts[kind] ?? 0) + 1;
|
|
432
|
+
if ('ref' in p && p.ref.provider === 'keychain') {
|
|
433
|
+
keychainKeys.push(key);
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
const keys = Object.keys(bundle.vars).sort();
|
|
437
|
+
keychainKeys.sort();
|
|
438
|
+
const emitReadAudit = (status, err) => {
|
|
439
|
+
emit('secrets.get', {
|
|
440
|
+
bundle: bundle.name,
|
|
441
|
+
caller: opts.caller,
|
|
442
|
+
status,
|
|
443
|
+
keyCount: keys.length,
|
|
444
|
+
keys,
|
|
445
|
+
keychainKeys,
|
|
446
|
+
kindCounts,
|
|
447
|
+
error: err instanceof Error ? err.message : (err ? String(err) : undefined),
|
|
448
|
+
});
|
|
449
|
+
};
|
|
450
|
+
try {
|
|
451
|
+
const env = {};
|
|
452
|
+
for (const [key] of Object.entries(bundle.vars)) {
|
|
453
|
+
const p = parsedByKey.get(key);
|
|
454
|
+
if ('literal' in p) {
|
|
455
|
+
env[key] = p.literal;
|
|
456
|
+
continue;
|
|
457
|
+
}
|
|
458
|
+
if (p.ref.provider === 'keychain') {
|
|
459
|
+
const item = secretsKeychainItem(bundle.name, p.ref.value);
|
|
460
|
+
const value = fetched.get(item);
|
|
461
|
+
if (value === undefined) {
|
|
462
|
+
throw new Error(`Bundle '${bundle.name}' key '${key}': keychain item '${item}' not found. ` +
|
|
463
|
+
`Run: agents secrets add ${bundle.name} ${key}`);
|
|
464
|
+
}
|
|
465
|
+
env[key] = value;
|
|
466
|
+
continue;
|
|
467
|
+
}
|
|
468
|
+
try {
|
|
469
|
+
env[key] = resolveRef(p.ref, {
|
|
470
|
+
allowExec: bundle.allow_exec,
|
|
471
|
+
keychainItemFor: (shortId) => secretsKeychainItem(bundle.name, shortId),
|
|
472
|
+
});
|
|
473
|
+
}
|
|
474
|
+
catch (err) {
|
|
475
|
+
throw new Error(`Bundle '${bundle.name}' key '${key}': ${err.message}`);
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
emitReadAudit('success');
|
|
479
|
+
return { bundle, env };
|
|
480
|
+
}
|
|
481
|
+
catch (err) {
|
|
482
|
+
emitReadAudit('error', err);
|
|
483
|
+
throw err;
|
|
484
|
+
}
|
|
485
|
+
}
|
|
311
486
|
// Build a keychain ref expression from a bundle+key pair, for storage in the bundle metadata.
|
|
312
487
|
export function keychainRef(key) {
|
|
313
488
|
return `keychain:${key}`;
|
|
@@ -331,7 +506,7 @@ export function rotateBundleSecret(bundle, key, opts) {
|
|
|
331
506
|
}
|
|
332
507
|
const shortId = raw.slice('keychain:'.length);
|
|
333
508
|
const item = secretsKeychainItem(bundle.name, shortId);
|
|
334
|
-
setKeychainToken(item, opts.newValue
|
|
509
|
+
setKeychainToken(item, opts.newValue);
|
|
335
510
|
if (opts.clearMeta) {
|
|
336
511
|
if (bundle.meta)
|
|
337
512
|
delete bundle.meta[key];
|
|
@@ -362,8 +537,8 @@ export function rotateBundleSecret(bundle, key, opts) {
|
|
|
362
537
|
* 4) write new bundle metadata
|
|
363
538
|
* 5) delete the old per-key keychain items + old metadata
|
|
364
539
|
*
|
|
365
|
-
* Steps 1-4 are reversible. If 5 partially fails
|
|
366
|
-
*
|
|
540
|
+
* Steps 1-4 are reversible. If 5 partially fails, running `rename` again is
|
|
541
|
+
* a safe no-op for the source items.
|
|
367
542
|
*/
|
|
368
543
|
export function renameBundle(oldName, newName, opts = {}) {
|
|
369
544
|
validateBundleName(oldName);
|
|
@@ -381,7 +556,7 @@ export function renameBundle(oldName, newName, opts = {}) {
|
|
|
381
556
|
}
|
|
382
557
|
const dest = readBundle(newName);
|
|
383
558
|
for (const { item } of keychainItemsForBundle(dest)) {
|
|
384
|
-
deleteKeychainToken(item
|
|
559
|
+
deleteKeychainToken(item);
|
|
385
560
|
}
|
|
386
561
|
deleteBundle(newName);
|
|
387
562
|
}
|
|
@@ -394,15 +569,15 @@ export function renameBundle(oldName, newName, opts = {}) {
|
|
|
394
569
|
continue;
|
|
395
570
|
const shortId = raw.slice('keychain:'.length);
|
|
396
571
|
const newItem = secretsKeychainItem(newName, shortId);
|
|
397
|
-
const value = getKeychainToken(oldItem
|
|
398
|
-
setKeychainToken(newItem, value
|
|
572
|
+
const value = getKeychainToken(oldItem);
|
|
573
|
+
setKeychainToken(newItem, value);
|
|
399
574
|
}
|
|
400
575
|
// writeBundle preserves source.created_at and refreshes updated_at.
|
|
401
576
|
const renamed = { ...source, name: newName };
|
|
402
577
|
writeBundle(renamed);
|
|
403
578
|
// Cleanup: delete the old per-key keychain items, then the old metadata.
|
|
404
579
|
for (const { item: oldItem } of sourceItems) {
|
|
405
|
-
deleteKeychainToken(oldItem
|
|
580
|
+
deleteKeychainToken(oldItem);
|
|
406
581
|
}
|
|
407
582
|
deleteBundle(oldName);
|
|
408
583
|
emit('secrets.rename', { from: oldName, to: newName });
|
|
@@ -476,7 +651,6 @@ export async function migrateLegacyBundles(confirmBundle) {
|
|
|
476
651
|
name,
|
|
477
652
|
description: parsed.description,
|
|
478
653
|
allow_exec: Boolean(parsed.allow_exec),
|
|
479
|
-
icloud_sync: Boolean(parsed.icloud_sync),
|
|
480
654
|
vars: parsed.vars && typeof parsed.vars === 'object' ? parsed.vars : {},
|
|
481
655
|
};
|
|
482
656
|
const keys = Object.keys(bundle.vars);
|
|
@@ -1,14 +1,23 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Cross-platform secure credential storage.
|
|
3
3
|
*
|
|
4
|
-
* macOS:
|
|
5
|
-
*
|
|
6
|
-
*
|
|
4
|
+
* macOS: every keychain operation goes through the signed `Agents CLI.app`
|
|
5
|
+
* helper. The helper attaches a biometry-or-passcode access control to every
|
|
6
|
+
* item it writes, so the OS itself gates decryption with Touch ID. A single
|
|
7
|
+
* LAContext lives for the helper's process lifetime, so a batch read pops
|
|
8
|
+
* Touch ID once and reuses the assertion for every item in the same batch.
|
|
9
|
+
* No /usr/bin/security fast path: that path bypasses the helper's ACL,
|
|
10
|
+
* exposes items to the legacy password sheet, and would defeat the model.
|
|
7
11
|
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
+
* Linux: libsecret (GNOME Keyring) via the `secret-tool` CLI. No biometry —
|
|
13
|
+
* items are unlocked when the keyring is open.
|
|
14
|
+
*
|
|
15
|
+
* Windows: not supported.
|
|
16
|
+
*
|
|
17
|
+
* Items are device-local: the biometry access control requires the OS to
|
|
18
|
+
* treat them as bound to this device, so cross-machine propagation goes
|
|
19
|
+
* through the explicit export/import flow in src/lib/secrets/sync.ts
|
|
20
|
+
* rather than the system's cloud-keychain path.
|
|
12
21
|
*/
|
|
13
22
|
/** Supported secret resolution backends. */
|
|
14
23
|
export type SecretProvider = 'keychain' | 'env' | 'file' | 'exec';
|
|
@@ -44,38 +53,50 @@ export declare function secretsKeychainItem(bundle: string, key: string): string
|
|
|
44
53
|
* tests) is destructive and would require an interactive Keychain unlock.
|
|
45
54
|
*/
|
|
46
55
|
export interface KeychainBackend {
|
|
47
|
-
has(item: string
|
|
48
|
-
get(item: string
|
|
49
|
-
set(item: string, value: string
|
|
50
|
-
delete(item: string
|
|
56
|
+
has(item: string): boolean;
|
|
57
|
+
get(item: string): string;
|
|
58
|
+
set(item: string, value: string): void;
|
|
59
|
+
delete(item: string): boolean;
|
|
51
60
|
list(prefix: string): string[];
|
|
52
61
|
}
|
|
53
62
|
/** Install a custom keychain backend (test only). Returns the previous backend so callers can restore. */
|
|
54
63
|
export declare function setKeychainBackendForTest(b: KeychainBackend | null): KeychainBackend | null;
|
|
55
|
-
/** Check if a keychain/keyring item exists. */
|
|
56
|
-
export declare function hasKeychainToken(item: string
|
|
57
|
-
/** Retrieve a secret value from the keychain/keyring. Throws if not found. */
|
|
58
|
-
export declare function getKeychainToken(item: string, sync?: boolean): string;
|
|
64
|
+
/** Check if a keychain/keyring item exists. Never prompts for biometry. */
|
|
65
|
+
export declare function hasKeychainToken(item: string): boolean;
|
|
59
66
|
/**
|
|
60
|
-
*
|
|
61
|
-
* prompt. macOS shows ONE Touch ID prompt and every requested item is
|
|
62
|
-
* unlocked in the same process. Returns a Map keyed by item name. Missing
|
|
63
|
-
* items are absent from the map (caller decides whether that's an error).
|
|
67
|
+
* Retrieve a secret value from the keychain/keyring. Throws if not found.
|
|
64
68
|
*
|
|
65
|
-
*
|
|
66
|
-
*
|
|
67
|
-
*
|
|
69
|
+
* On macOS this triggers Touch ID (or reuses an assertion held by an earlier
|
|
70
|
+
* call in the same process). For bundles, prefer getKeychainTokens() so a
|
|
71
|
+
* single biometric prompt covers every key in the batch.
|
|
72
|
+
*/
|
|
73
|
+
export declare function getKeychainToken(item: string): string;
|
|
74
|
+
/**
|
|
75
|
+
* Batch-read multiple keychain items behind a single Touch ID prompt. The
|
|
76
|
+
* macOS helper holds one LAContext for its whole process: the first protected
|
|
77
|
+
* item triggers Touch ID, every later item in the same invocation reuses the
|
|
78
|
+
* assertion. Missing items are absent from the returned map (caller decides
|
|
79
|
+
* whether that's an error).
|
|
68
80
|
*
|
|
69
81
|
* On Linux or when a test backend is installed, falls back to individual
|
|
70
|
-
*
|
|
82
|
+
* lookups — no biometric prompt path on those platforms.
|
|
71
83
|
*/
|
|
72
|
-
export declare function
|
|
73
|
-
/** Store or update a secret value in the keychain/keyring.
|
|
74
|
-
export declare function setKeychainToken(item: string, value: string
|
|
75
|
-
/** Delete a keychain/keyring item. Returns true if it existed. */
|
|
76
|
-
export declare function deleteKeychainToken(item: string
|
|
84
|
+
export declare function getKeychainTokens(items: string[]): Map<string, string>;
|
|
85
|
+
/** Store or update a secret value in the keychain/keyring. Device-local; biometry-gated on macOS. */
|
|
86
|
+
export declare function setKeychainToken(item: string, value: string): void;
|
|
87
|
+
/** Delete a keychain/keyring item. Returns true if it existed. Never prompts for biometry. */
|
|
88
|
+
export declare function deleteKeychainToken(item: string): boolean;
|
|
77
89
|
/** Enumerate keychain/keyring item names starting with the given prefix. */
|
|
78
90
|
export declare function listKeychainItems(prefix: string): string[];
|
|
91
|
+
/**
|
|
92
|
+
* One-time upgrade for a keychain item that was written by a previous helper
|
|
93
|
+
* generation with a trusted-app ACL. The helper reads the legacy item
|
|
94
|
+
* (which may pop the password sheet once), then deletes and re-adds it with
|
|
95
|
+
* the biometry access control. Returns true if the item was rewritten, false
|
|
96
|
+
* if no item by that name exists. macOS only — Linux backends have no ACL
|
|
97
|
+
* concept, so the call is a no-op there.
|
|
98
|
+
*/
|
|
99
|
+
export declare function migrateKeychainItem(item: string): boolean;
|
|
79
100
|
/** Options controlling how secret refs are resolved. */
|
|
80
101
|
export interface ResolveOptions {
|
|
81
102
|
/** Translate a short keychain ID to a fully namespaced item name. */
|
|
@@ -84,8 +105,6 @@ export interface ResolveOptions {
|
|
|
84
105
|
allowExec?: boolean;
|
|
85
106
|
/** Restrict env: refs to this allowlist. When undefined, any env var may be read. */
|
|
86
107
|
envAllowlist?: string[];
|
|
87
|
-
/** Read keychain refs from the iCloud-synced keychain backend. */
|
|
88
|
-
iCloudSync?: boolean;
|
|
89
108
|
}
|
|
90
109
|
/** Resolve a secret ref to its plaintext value using the appropriate provider. */
|
|
91
110
|
export declare function resolveRef(ref: SecretRef, opts?: ResolveOptions): string;
|