@phnx-labs/agents-cli 1.19.2 → 1.20.0
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 +67 -0
- package/README.md +69 -9
- 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/commands.js +3 -3
- 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 +3 -3
- package/dist/commands/factory.d.ts +3 -14
- package/dist/commands/factory.js +3 -3
- package/dist/commands/hooks.js +3 -3
- package/dist/commands/plugins.js +11 -4
- package/dist/commands/profiles.js +1 -1
- package/dist/commands/prune.js +39 -160
- package/dist/commands/pull.js +56 -3
- package/dist/commands/routines.js +106 -13
- package/dist/commands/secrets.js +5 -7
- 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 +3 -3
- package/dist/commands/teams.js +13 -0
- package/dist/commands/versions.d.ts +4 -3
- package/dist/commands/versions.js +131 -127
- package/dist/commands/view.js +12 -12
- package/dist/computer.js +0 -0
- package/dist/index.js +34 -6
- package/dist/lib/acp/harnesses.js +8 -0
- package/dist/lib/agents.js +110 -23
- 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 +42 -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 +1 -1
- package/dist/lib/browser/ipc.d.ts +8 -1
- package/dist/lib/browser/ipc.js +37 -28
- package/dist/lib/browser/profiles.d.ts +13 -0
- package/dist/lib/browser/profiles.js +41 -1
- package/dist/lib/browser/service.d.ts +3 -0
- package/dist/lib/browser/service.js +21 -5
- package/dist/lib/browser/types.d.ts +7 -0
- package/dist/lib/cli-resources.d.ts +109 -0
- package/dist/lib/cli-resources.js +255 -0
- 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/exec.d.ts +3 -2
- package/dist/lib/exec.js +44 -9
- package/dist/lib/hooks.js +182 -0
- package/dist/lib/mcp.js +6 -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.js +5 -1
- package/dist/lib/plugin-marketplace.js +1 -1
- package/dist/lib/profiles-presets.js +37 -0
- 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 +35 -0
- package/dist/lib/routines-format.js +173 -0
- package/dist/lib/routines.d.ts +7 -1
- package/dist/lib/routines.js +32 -12
- package/dist/lib/runner.js +19 -5
- 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/bundles.d.ts +22 -1
- package/dist/lib/secrets/bundles.js +234 -36
- package/dist/lib/secrets/index.d.ts +6 -11
- package/dist/lib/secrets/index.js +107 -87
- 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/types.d.ts +1 -1
- package/dist/lib/session/types.js +1 -1
- package/dist/lib/shims.d.ts +1 -1
- package/dist/lib/shims.js +66 -4
- package/dist/lib/state.d.ts +0 -1
- package/dist/lib/state.js +2 -15
- package/dist/lib/teams/agents.js +1 -1
- 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 +6 -3
- package/dist/lib/types.js +6 -3
- package/dist/lib/versions.d.ts +10 -2
- package/dist/lib/versions.js +227 -35
- package/package.json +7 -7
- package/npm-shrinkwrap.json +0 -3162
|
@@ -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',
|
|
@@ -145,7 +146,7 @@ export function readBundle(name) {
|
|
|
145
146
|
}
|
|
146
147
|
return bundle;
|
|
147
148
|
}
|
|
148
|
-
export function writeBundle(bundle) {
|
|
149
|
+
export function writeBundle(bundle, opts = {}) {
|
|
149
150
|
validateBundleName(bundle.name);
|
|
150
151
|
for (const key of Object.keys(bundle.vars)) {
|
|
151
152
|
validateEnvKey(key);
|
|
@@ -187,7 +188,9 @@ export function writeBundle(bundle) {
|
|
|
187
188
|
};
|
|
188
189
|
const json = JSON.stringify(payload);
|
|
189
190
|
setKeychainToken(bundleMetaItem(bundle.name), json, Boolean(bundle.icloud_sync));
|
|
190
|
-
|
|
191
|
+
if (opts.emitEvent !== false) {
|
|
192
|
+
emit('secrets.set', { bundle: bundle.name });
|
|
193
|
+
}
|
|
191
194
|
}
|
|
192
195
|
export function deleteBundle(name) {
|
|
193
196
|
validateBundleName(name);
|
|
@@ -208,14 +211,58 @@ export function listBundles() {
|
|
|
208
211
|
const names = services
|
|
209
212
|
.map((s) => s.slice(BUNDLE_META_PREFIX.length))
|
|
210
213
|
.filter((n) => BUNDLE_NAME_PATTERN.test(n));
|
|
214
|
+
if (names.length === 0)
|
|
215
|
+
return [];
|
|
216
|
+
// Batch all metadata reads behind ONE Touch ID prompt instead of N. Bundle
|
|
217
|
+
// metadata items carry user-presence ACLs (same as secret values), so a naive
|
|
218
|
+
// loop over readBundle() spawns a fresh LAContext per item — meaning N
|
|
219
|
+
// biometric prompts for `secrets list`. Sharing a single context across all
|
|
220
|
+
// SecItemCopyMatching calls collapses the prompt to one. Mirrors the pattern
|
|
221
|
+
// already used by resolveBundleEnv for runtime secret injection.
|
|
222
|
+
const itemsToFetch = names.map(bundleMetaItem);
|
|
223
|
+
const fetched = getKeychainTokensBatch(itemsToFetch, false, 'list secrets bundles');
|
|
211
224
|
const out = [];
|
|
212
225
|
for (const name of names) {
|
|
226
|
+
const json = fetched.get(bundleMetaItem(name));
|
|
227
|
+
if (json === undefined)
|
|
228
|
+
continue;
|
|
229
|
+
let parsed;
|
|
213
230
|
try {
|
|
214
|
-
|
|
231
|
+
parsed = JSON.parse(json);
|
|
215
232
|
}
|
|
216
233
|
catch {
|
|
217
234
|
// Skip malformed bundles; surfaced via `agents secrets view <name>`.
|
|
235
|
+
continue;
|
|
236
|
+
}
|
|
237
|
+
if (!parsed || typeof parsed !== 'object')
|
|
238
|
+
continue;
|
|
239
|
+
const bundle = {
|
|
240
|
+
name,
|
|
241
|
+
description: parsed.description,
|
|
242
|
+
allow_exec: Boolean(parsed.allow_exec),
|
|
243
|
+
icloud_sync: Boolean(parsed.icloud_sync),
|
|
244
|
+
vars: parsed.vars && typeof parsed.vars === 'object' ? parsed.vars : {},
|
|
245
|
+
};
|
|
246
|
+
if (typeof parsed.created_at === 'string')
|
|
247
|
+
bundle.created_at = parsed.created_at;
|
|
248
|
+
if (typeof parsed.updated_at === 'string')
|
|
249
|
+
bundle.updated_at = parsed.updated_at;
|
|
250
|
+
if (typeof parsed.last_used === 'string')
|
|
251
|
+
bundle.last_used = parsed.last_used;
|
|
252
|
+
if (parsed.meta && typeof parsed.meta === 'object')
|
|
253
|
+
bundle.meta = parsed.meta;
|
|
254
|
+
// Skip bundles with invalid env keys rather than throwing — same lenient
|
|
255
|
+
// posture readBundle had via the outer catch.
|
|
256
|
+
let valid = true;
|
|
257
|
+
for (const key of Object.keys(bundle.vars)) {
|
|
258
|
+
if (!ENV_KEY_PATTERN.test(key)) {
|
|
259
|
+
valid = false;
|
|
260
|
+
break;
|
|
261
|
+
}
|
|
218
262
|
}
|
|
263
|
+
if (!valid)
|
|
264
|
+
continue;
|
|
265
|
+
out.push(bundle);
|
|
219
266
|
}
|
|
220
267
|
return out.sort((a, b) => a.name.localeCompare(b.name));
|
|
221
268
|
}
|
|
@@ -247,7 +294,7 @@ function stampLastUsed(bundle) {
|
|
|
247
294
|
}
|
|
248
295
|
try {
|
|
249
296
|
bundle.last_used = new Date(nowMs).toISOString();
|
|
250
|
-
writeBundle(bundle);
|
|
297
|
+
writeBundle(bundle, { emitEvent: false });
|
|
251
298
|
}
|
|
252
299
|
catch {
|
|
253
300
|
// Swallow — telemetry must never block secret resolution.
|
|
@@ -257,56 +304,207 @@ export function resolveBundleEnv(bundle, opts = {}) {
|
|
|
257
304
|
stampLastUsed(bundle);
|
|
258
305
|
const parsedByKey = new Map();
|
|
259
306
|
const keychainItemsToFetch = [];
|
|
260
|
-
const
|
|
307
|
+
const keychainKeys = [];
|
|
308
|
+
const kindCounts = {};
|
|
261
309
|
for (const [key, raw] of Object.entries(bundle.vars)) {
|
|
262
310
|
const parsed = parseBundleValue(raw);
|
|
263
311
|
parsedByKey.set(key, parsed);
|
|
312
|
+
const kind = 'literal' in parsed ? 'literal' : parsed.ref.provider;
|
|
313
|
+
kindCounts[kind] = (kindCounts[kind] ?? 0) + 1;
|
|
264
314
|
if ('ref' in parsed && parsed.ref.provider === 'keychain') {
|
|
265
315
|
const item = secretsKeychainItem(bundle.name, parsed.ref.value);
|
|
266
316
|
keychainItemsToFetch.push(item);
|
|
267
|
-
|
|
317
|
+
keychainKeys.push(key);
|
|
268
318
|
}
|
|
269
319
|
}
|
|
320
|
+
const keys = Object.keys(bundle.vars).sort();
|
|
321
|
+
keychainKeys.sort();
|
|
322
|
+
const emitReadAudit = (status, err) => {
|
|
323
|
+
emit('secrets.get', {
|
|
324
|
+
bundle: bundle.name,
|
|
325
|
+
caller: opts.caller,
|
|
326
|
+
status,
|
|
327
|
+
keyCount: keys.length,
|
|
328
|
+
keys,
|
|
329
|
+
keychainKeys,
|
|
330
|
+
kindCounts,
|
|
331
|
+
error: err instanceof Error ? err.message : (err ? String(err) : undefined),
|
|
332
|
+
});
|
|
333
|
+
};
|
|
270
334
|
// Build the localizedReason shown under the Touch ID prompt. Lowercase verb
|
|
271
335
|
// phrase per Apple HIG — the system prepends "<App> is required to ".
|
|
272
336
|
const reason = opts.caller
|
|
273
337
|
? `read ${bundle.name} secrets (for ${opts.caller})`
|
|
274
338
|
: `read ${bundle.name} secrets`;
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
const
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
339
|
+
try {
|
|
340
|
+
// Single helper invocation, one biometric prompt.
|
|
341
|
+
const fetched = keychainItemsToFetch.length > 0
|
|
342
|
+
? getKeychainTokensBatch(keychainItemsToFetch, bundle.icloud_sync, reason)
|
|
343
|
+
: new Map();
|
|
344
|
+
// Second pass: assemble env. Keychain values come from the batch; everything
|
|
345
|
+
// else is resolved inline (literals and env/file/exec refs don't prompt).
|
|
346
|
+
const env = {};
|
|
347
|
+
for (const [key] of Object.entries(bundle.vars)) {
|
|
348
|
+
const parsed = parsedByKey.get(key);
|
|
349
|
+
if ('literal' in parsed) {
|
|
350
|
+
env[key] = parsed.literal;
|
|
351
|
+
continue;
|
|
352
|
+
}
|
|
353
|
+
if (parsed.ref.provider === 'keychain') {
|
|
354
|
+
const item = secretsKeychainItem(bundle.name, parsed.ref.value);
|
|
355
|
+
const value = fetched.get(item);
|
|
356
|
+
if (value === undefined) {
|
|
357
|
+
throw new Error(`Bundle '${bundle.name}' key '${key}': keychain item '${item}' not found. ` +
|
|
358
|
+
`Run: agents secrets add ${bundle.name} ${key}`);
|
|
359
|
+
}
|
|
360
|
+
env[key] = value;
|
|
361
|
+
continue;
|
|
362
|
+
}
|
|
363
|
+
try {
|
|
364
|
+
env[key] = resolveRef(parsed.ref, {
|
|
365
|
+
allowExec: bundle.allow_exec,
|
|
366
|
+
iCloudSync: bundle.icloud_sync,
|
|
367
|
+
keychainItemFor: (shortId) => secretsKeychainItem(bundle.name, shortId),
|
|
368
|
+
});
|
|
369
|
+
}
|
|
370
|
+
catch (err) {
|
|
371
|
+
throw new Error(`Bundle '${bundle.name}' key '${key}': ${err.message}`);
|
|
294
372
|
}
|
|
295
|
-
env[key] = value;
|
|
296
|
-
continue;
|
|
297
373
|
}
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
374
|
+
emitReadAudit('success');
|
|
375
|
+
return env;
|
|
376
|
+
}
|
|
377
|
+
catch (err) {
|
|
378
|
+
emitReadAudit('error', err);
|
|
379
|
+
throw err;
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
/**
|
|
383
|
+
* Read a bundle's metadata AND resolve its env in a single Touch ID prompt.
|
|
384
|
+
*
|
|
385
|
+
* `readBundle` + `resolveBundleEnv` issued two separate `LAContext` calls
|
|
386
|
+
* (metadata read via `get-auth`, then secret values via `get-batch`) which
|
|
387
|
+
* surfaced as two consecutive Touch ID prompts. macOS does not honor
|
|
388
|
+
* "Always Allow" for items protected with `kSecAttrAccessControl`+biometry,
|
|
389
|
+
* so caching at the OS level was never an option. This collapses both reads
|
|
390
|
+
* into one `get-batch` call: we enumerate the bundle's secret items first
|
|
391
|
+
* (silent — `list` returns attrs only and does not trigger biometry) and
|
|
392
|
+
* include the metadata item in the same batch. One prompt, correctly scoped
|
|
393
|
+
* to the bundle name and caller.
|
|
394
|
+
*/
|
|
395
|
+
export function readAndResolveBundleEnv(name, opts = {}) {
|
|
396
|
+
validateBundleName(name);
|
|
397
|
+
const metaItem = bundleMetaItem(name);
|
|
398
|
+
const bundleSecretPrefix = `${SECRETS_ITEM_PREFIX}${name}.`;
|
|
399
|
+
let secretItems;
|
|
400
|
+
try {
|
|
401
|
+
secretItems = listKeychainItems(bundleSecretPrefix);
|
|
402
|
+
}
|
|
403
|
+
catch {
|
|
404
|
+
secretItems = [];
|
|
405
|
+
}
|
|
406
|
+
const reason = opts.caller
|
|
407
|
+
? `read ${name} secrets (for ${opts.caller})`
|
|
408
|
+
: `read ${name} secrets`;
|
|
409
|
+
// icloud_sync flag is unknown until we parse metadata, but the helper's
|
|
410
|
+
// get-batch uses kSecAttrSynchronizableAny so it finds both synced and
|
|
411
|
+
// device-local items in one shot.
|
|
412
|
+
const fetched = getKeychainTokensBatch([metaItem, ...secretItems], false, reason);
|
|
413
|
+
const json = fetched.get(metaItem);
|
|
414
|
+
if (json === undefined) {
|
|
415
|
+
throw new Error(`Secrets bundle '${name}' not found.`);
|
|
416
|
+
}
|
|
417
|
+
let parsed;
|
|
418
|
+
try {
|
|
419
|
+
parsed = JSON.parse(json);
|
|
420
|
+
}
|
|
421
|
+
catch {
|
|
422
|
+
throw new Error(`Bundle '${name}' is malformed.`);
|
|
423
|
+
}
|
|
424
|
+
if (!parsed || typeof parsed !== 'object') {
|
|
425
|
+
throw new Error(`Bundle '${name}' is malformed.`);
|
|
426
|
+
}
|
|
427
|
+
const bundle = {
|
|
428
|
+
name,
|
|
429
|
+
description: parsed.description,
|
|
430
|
+
allow_exec: Boolean(parsed.allow_exec),
|
|
431
|
+
icloud_sync: Boolean(parsed.icloud_sync),
|
|
432
|
+
vars: parsed.vars && typeof parsed.vars === 'object' ? parsed.vars : {},
|
|
433
|
+
};
|
|
434
|
+
if (typeof parsed.created_at === 'string')
|
|
435
|
+
bundle.created_at = parsed.created_at;
|
|
436
|
+
if (typeof parsed.updated_at === 'string')
|
|
437
|
+
bundle.updated_at = parsed.updated_at;
|
|
438
|
+
if (typeof parsed.last_used === 'string')
|
|
439
|
+
bundle.last_used = parsed.last_used;
|
|
440
|
+
if (parsed.meta && typeof parsed.meta === 'object')
|
|
441
|
+
bundle.meta = parsed.meta;
|
|
442
|
+
for (const key of Object.keys(bundle.vars)) {
|
|
443
|
+
validateEnvKey(key);
|
|
444
|
+
}
|
|
445
|
+
stampLastUsed(bundle);
|
|
446
|
+
const parsedByKey = new Map();
|
|
447
|
+
const keychainKeys = [];
|
|
448
|
+
const kindCounts = {};
|
|
449
|
+
for (const [key, raw] of Object.entries(bundle.vars)) {
|
|
450
|
+
const p = parseBundleValue(raw);
|
|
451
|
+
parsedByKey.set(key, p);
|
|
452
|
+
const kind = 'literal' in p ? 'literal' : p.ref.provider;
|
|
453
|
+
kindCounts[kind] = (kindCounts[kind] ?? 0) + 1;
|
|
454
|
+
if ('ref' in p && p.ref.provider === 'keychain') {
|
|
455
|
+
keychainKeys.push(key);
|
|
304
456
|
}
|
|
305
|
-
|
|
306
|
-
|
|
457
|
+
}
|
|
458
|
+
const keys = Object.keys(bundle.vars).sort();
|
|
459
|
+
keychainKeys.sort();
|
|
460
|
+
const emitReadAudit = (status, err) => {
|
|
461
|
+
emit('secrets.get', {
|
|
462
|
+
bundle: bundle.name,
|
|
463
|
+
caller: opts.caller,
|
|
464
|
+
status,
|
|
465
|
+
keyCount: keys.length,
|
|
466
|
+
keys,
|
|
467
|
+
keychainKeys,
|
|
468
|
+
kindCounts,
|
|
469
|
+
error: err instanceof Error ? err.message : (err ? String(err) : undefined),
|
|
470
|
+
});
|
|
471
|
+
};
|
|
472
|
+
try {
|
|
473
|
+
const env = {};
|
|
474
|
+
for (const [key] of Object.entries(bundle.vars)) {
|
|
475
|
+
const p = parsedByKey.get(key);
|
|
476
|
+
if ('literal' in p) {
|
|
477
|
+
env[key] = p.literal;
|
|
478
|
+
continue;
|
|
479
|
+
}
|
|
480
|
+
if (p.ref.provider === 'keychain') {
|
|
481
|
+
const item = secretsKeychainItem(bundle.name, p.ref.value);
|
|
482
|
+
const value = fetched.get(item);
|
|
483
|
+
if (value === undefined) {
|
|
484
|
+
throw new Error(`Bundle '${bundle.name}' key '${key}': keychain item '${item}' not found. ` +
|
|
485
|
+
`Run: agents secrets add ${bundle.name} ${key}`);
|
|
486
|
+
}
|
|
487
|
+
env[key] = value;
|
|
488
|
+
continue;
|
|
489
|
+
}
|
|
490
|
+
try {
|
|
491
|
+
env[key] = resolveRef(p.ref, {
|
|
492
|
+
allowExec: bundle.allow_exec,
|
|
493
|
+
iCloudSync: bundle.icloud_sync,
|
|
494
|
+
keychainItemFor: (shortId) => secretsKeychainItem(bundle.name, shortId),
|
|
495
|
+
});
|
|
496
|
+
}
|
|
497
|
+
catch (err) {
|
|
498
|
+
throw new Error(`Bundle '${bundle.name}' key '${key}': ${err.message}`);
|
|
499
|
+
}
|
|
307
500
|
}
|
|
501
|
+
emitReadAudit('success');
|
|
502
|
+
return { bundle, env };
|
|
503
|
+
}
|
|
504
|
+
catch (err) {
|
|
505
|
+
emitReadAudit('error', err);
|
|
506
|
+
throw err;
|
|
308
507
|
}
|
|
309
|
-
return env;
|
|
310
508
|
}
|
|
311
509
|
// Build a keychain ref expression from a bundle+key pair, for storage in the bundle metadata.
|
|
312
510
|
export function keychainRef(key) {
|
|
@@ -57,19 +57,14 @@ export declare function hasKeychainToken(item: string, sync?: boolean): boolean;
|
|
|
57
57
|
/** Retrieve a secret value from the keychain/keyring. Throws if not found. */
|
|
58
58
|
export declare function getKeychainToken(item: string, sync?: boolean): string;
|
|
59
59
|
/**
|
|
60
|
-
*
|
|
61
|
-
*
|
|
62
|
-
*
|
|
63
|
-
* items are absent from the map (caller decides whether that's an error).
|
|
60
|
+
* Read multiple keychain items, returning a Map keyed by item name. Missing or
|
|
61
|
+
* unreadable items are simply absent from the map (the caller decides whether a
|
|
62
|
+
* given key was required).
|
|
64
63
|
*
|
|
65
|
-
*
|
|
66
|
-
*
|
|
67
|
-
* a lowercase verb phrase that completes the sentence "X is required to ___".
|
|
68
|
-
*
|
|
69
|
-
* On Linux or when a test backend is installed, falls back to individual
|
|
70
|
-
* `get` calls — no biometric prompt path on those platforms.
|
|
64
|
+
* On macOS this uses the signed helper's `get-batch` command so one LAContext
|
|
65
|
+
* can satisfy all protected item reads for the bundle.
|
|
71
66
|
*/
|
|
72
|
-
export declare function getKeychainTokensBatch(items: string[],
|
|
67
|
+
export declare function getKeychainTokensBatch(items: string[], sync?: boolean, reason?: string): Map<string, string>;
|
|
73
68
|
/** Store or update a secret value in the keychain/keyring. iCloud-synced when sync=true (macOS only). */
|
|
74
69
|
export declare function setKeychainToken(item: string, value: string, sync?: boolean): void;
|
|
75
70
|
/** Delete a keychain/keyring item. Returns true if it existed. */
|
|
@@ -17,6 +17,8 @@ import * as os from 'os';
|
|
|
17
17
|
import * as path from 'path';
|
|
18
18
|
import { linuxBackend } from './linux.js';
|
|
19
19
|
const SERVICE_PREFIX = 'agents-cli';
|
|
20
|
+
const SECRETS_ITEM_PREFIX = `${SERVICE_PREFIX}.secrets.`;
|
|
21
|
+
const BUNDLES_ITEM_PREFIX = `${SERVICE_PREFIX}.bundles.`;
|
|
20
22
|
const REF_PATTERN = /^(keychain|env|file|exec):(.+)$/s;
|
|
21
23
|
/** Parse a bundle value into either a literal string or a typed secret ref. */
|
|
22
24
|
export function parseBundleValue(raw) {
|
|
@@ -37,8 +39,9 @@ export function serializeRef(ref) {
|
|
|
37
39
|
}
|
|
38
40
|
function assertSupportedPlatform() {
|
|
39
41
|
if (process.platform !== 'darwin' && process.platform !== 'linux') {
|
|
40
|
-
throw new Error('
|
|
41
|
-
'
|
|
42
|
+
throw new Error('agents secrets requires macOS Keychain or Linux libsecret.\n' +
|
|
43
|
+
'Windows is not supported — use environment variables or a .env file instead.\n' +
|
|
44
|
+
'WSL2 is supported (libsecret via gnome-keyring).');
|
|
42
45
|
}
|
|
43
46
|
}
|
|
44
47
|
function isLinux() {
|
|
@@ -53,7 +56,10 @@ export function profileKeychainItem(provider) {
|
|
|
53
56
|
}
|
|
54
57
|
/** Build the keychain item name for a secrets-bundle key. */
|
|
55
58
|
export function secretsKeychainItem(bundle, key) {
|
|
56
|
-
return `${
|
|
59
|
+
return `${SECRETS_ITEM_PREFIX}${bundle}.${key}`;
|
|
60
|
+
}
|
|
61
|
+
function keychainItemRequiresUserPresence(item) {
|
|
62
|
+
return item.startsWith(SECRETS_ITEM_PREFIX) || item.startsWith(BUNDLES_ITEM_PREFIX);
|
|
57
63
|
}
|
|
58
64
|
// Resolve the bundled, signed-and-notarized Agents CLI.app shipped
|
|
59
65
|
// alongside the compiled JS. The .app embeds a provisioning profile that
|
|
@@ -91,8 +97,12 @@ export function hasKeychainToken(item, sync = false) {
|
|
|
91
97
|
assertSupportedPlatform();
|
|
92
98
|
if (isLinux())
|
|
93
99
|
return linuxBackend.has(item, sync);
|
|
94
|
-
// macOS:
|
|
95
|
-
|
|
100
|
+
// macOS: `security find-generic-password` *without* `-w` only reads item
|
|
101
|
+
// metadata, never the value, so it doesn't consult the item's ACL — no
|
|
102
|
+
// prompt. The previous code passed `-w`, which forced decryption and
|
|
103
|
+
// triggered the "security wants to access keychain" sheet on every item
|
|
104
|
+
// the helper had written. Existence checks never need the value.
|
|
105
|
+
if (spawnSync('security', ['find-generic-password', '-a', os.userInfo().username, '-s', item], {
|
|
96
106
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
97
107
|
}).status === 0)
|
|
98
108
|
return true;
|
|
@@ -109,20 +119,38 @@ export function getKeychainToken(item, sync = false) {
|
|
|
109
119
|
assertSupportedPlatform();
|
|
110
120
|
if (isLinux())
|
|
111
121
|
return linuxBackend.get(item, sync);
|
|
112
|
-
// macOS:
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
122
|
+
// macOS: read through the signed helper FIRST. The helper holds the
|
|
123
|
+
// keychain-access-group entitlement (so it reads iCloud-synced items) and
|
|
124
|
+
// supplies an LAContext for items protected by kSecAttrAccessControl.
|
|
125
|
+
//
|
|
126
|
+
// Bare `security` is only a fallback for when the helper bundle is absent
|
|
127
|
+
// (e.g. a dev build without the .app). It must NOT be tried first: macOS
|
|
128
|
+
// shows the "security wants to access … enter keychain password" sheet on any
|
|
129
|
+
// item whose ACL doesn't list `security`, which is every item we write. That
|
|
130
|
+
// security-first ordering is exactly what made bundle reads prompt on every
|
|
131
|
+
// `secrets exec`.
|
|
132
|
+
let bin;
|
|
133
|
+
try {
|
|
134
|
+
bin = ensureKeychainHelper();
|
|
120
135
|
}
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
136
|
+
catch {
|
|
137
|
+
// Helper bundle missing — degrade to security. Reads items security created
|
|
138
|
+
// without a prompt; restrictive items may still prompt (dev-build only).
|
|
139
|
+
const secResult = spawnSync('security', ['find-generic-password', '-a', os.userInfo().username, '-s', item, '-w'], {
|
|
140
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
141
|
+
});
|
|
142
|
+
if (secResult.status === 0) {
|
|
143
|
+
const token = secResult.stdout?.toString().trim();
|
|
144
|
+
if (token)
|
|
145
|
+
return token;
|
|
146
|
+
}
|
|
147
|
+
throw new Error(`Keychain item '${item}' not found.`);
|
|
148
|
+
}
|
|
149
|
+
// Helper searches both synced and non-synced via kSecAttrSynchronizableAny.
|
|
150
|
+
const args = keychainItemRequiresUserPresence(item)
|
|
151
|
+
? ['get-auth', item, os.userInfo().username, '--reason', 'read agents-cli secrets']
|
|
152
|
+
: ['get', item, os.userInfo().username];
|
|
153
|
+
const result = spawnSync(bin, args, {
|
|
126
154
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
127
155
|
});
|
|
128
156
|
if (result.status === 1)
|
|
@@ -137,28 +165,23 @@ export function getKeychainToken(item, sync = false) {
|
|
|
137
165
|
return token;
|
|
138
166
|
}
|
|
139
167
|
/**
|
|
140
|
-
*
|
|
141
|
-
*
|
|
142
|
-
*
|
|
143
|
-
* items are absent from the map (caller decides whether that's an error).
|
|
144
|
-
*
|
|
145
|
-
* `reason` is shown to the user under the Touch ID prompt — e.g.
|
|
146
|
-
* "read 'hetzner.com' secrets for agent 'claude'". Apple's HIG recommends
|
|
147
|
-
* a lowercase verb phrase that completes the sentence "X is required to ___".
|
|
168
|
+
* Read multiple keychain items, returning a Map keyed by item name. Missing or
|
|
169
|
+
* unreadable items are simply absent from the map (the caller decides whether a
|
|
170
|
+
* given key was required).
|
|
148
171
|
*
|
|
149
|
-
* On
|
|
150
|
-
*
|
|
172
|
+
* On macOS this uses the signed helper's `get-batch` command so one LAContext
|
|
173
|
+
* can satisfy all protected item reads for the bundle.
|
|
151
174
|
*/
|
|
152
|
-
export function getKeychainTokensBatch(items,
|
|
175
|
+
export function getKeychainTokensBatch(items, sync = false, reason = 'read agents-cli secrets') {
|
|
153
176
|
const result = new Map();
|
|
154
|
-
if (items.length === 0)
|
|
155
|
-
return result;
|
|
156
177
|
if (backend) {
|
|
157
178
|
for (const item of items) {
|
|
158
179
|
try {
|
|
159
|
-
result.set(item, backend.get(item,
|
|
180
|
+
result.set(item, backend.get(item, sync));
|
|
181
|
+
}
|
|
182
|
+
catch {
|
|
183
|
+
// Missing or unreadable — skip; the caller reports which key is missing.
|
|
160
184
|
}
|
|
161
|
-
catch { /* missing — skip */ }
|
|
162
185
|
}
|
|
163
186
|
return result;
|
|
164
187
|
}
|
|
@@ -166,54 +189,44 @@ export function getKeychainTokensBatch(items, _sync = false, reason) {
|
|
|
166
189
|
if (isLinux()) {
|
|
167
190
|
for (const item of items) {
|
|
168
191
|
try {
|
|
169
|
-
result.set(item, linuxBackend.get(item,
|
|
192
|
+
result.set(item, linuxBackend.get(item, sync));
|
|
193
|
+
}
|
|
194
|
+
catch {
|
|
195
|
+
// Missing or unreadable — skip; the caller reports which key is missing.
|
|
170
196
|
}
|
|
171
|
-
catch { /* missing — skip */ }
|
|
172
197
|
}
|
|
173
198
|
return result;
|
|
174
199
|
}
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
if (reason)
|
|
179
|
-
helperArgs.push('--reason', reason);
|
|
180
|
-
helperArgs.push(...items);
|
|
181
|
-
const child = spawnSync(bin, helperArgs, {
|
|
182
|
-
stdio: ['ignore', 'pipe', 'pipe'],
|
|
183
|
-
});
|
|
184
|
-
if (child.status !== 0) {
|
|
185
|
-
const msg = child.stderr?.toString().trim();
|
|
186
|
-
throw new Error(msg || `Failed to batch-read ${items.length} keychain items.`);
|
|
200
|
+
let bin;
|
|
201
|
+
try {
|
|
202
|
+
bin = ensureKeychainHelper();
|
|
187
203
|
}
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
// Last entry from split is the empty string after a trailing newline.
|
|
197
|
-
let i = 0;
|
|
198
|
-
while (i < lines.length) {
|
|
199
|
-
const line = lines[i];
|
|
200
|
-
if (line === '' && i === lines.length - 1)
|
|
201
|
-
break;
|
|
202
|
-
if (line.startsWith('V ')) {
|
|
203
|
-
const service = line.slice(2);
|
|
204
|
-
const value = lines[i + 1] ?? '';
|
|
205
|
-
result.set(service, value);
|
|
206
|
-
i += 2;
|
|
207
|
-
}
|
|
208
|
-
else if (line.startsWith('M ')) {
|
|
209
|
-
i += 1;
|
|
210
|
-
}
|
|
211
|
-
else if (line === '') {
|
|
212
|
-
i += 1;
|
|
213
|
-
}
|
|
214
|
-
else {
|
|
215
|
-
throw new Error(`Malformed get-batch output line: ${JSON.stringify(line)}`);
|
|
204
|
+
catch {
|
|
205
|
+
for (const item of items) {
|
|
206
|
+
try {
|
|
207
|
+
result.set(item, getKeychainToken(item, sync));
|
|
208
|
+
}
|
|
209
|
+
catch {
|
|
210
|
+
// Missing or unreadable — skip; the caller reports which key is missing.
|
|
211
|
+
}
|
|
216
212
|
}
|
|
213
|
+
return result;
|
|
214
|
+
}
|
|
215
|
+
const proc = spawnSync(bin, ['get-batch', os.userInfo().username, '--reason', reason, ...items], {
|
|
216
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
217
|
+
});
|
|
218
|
+
if (proc.status !== 0)
|
|
219
|
+
return result;
|
|
220
|
+
const out = proc.stdout?.toString() || '';
|
|
221
|
+
for (const line of out.split('\n')) {
|
|
222
|
+
if (!line)
|
|
223
|
+
continue;
|
|
224
|
+
const tab = line.indexOf('\t');
|
|
225
|
+
if (tab <= 0)
|
|
226
|
+
continue;
|
|
227
|
+
const item = line.slice(0, tab);
|
|
228
|
+
const encoded = line.slice(tab + 1);
|
|
229
|
+
result.set(item, Buffer.from(encoded, 'base64').toString('utf8'));
|
|
217
230
|
}
|
|
218
231
|
return result;
|
|
219
232
|
}
|
|
@@ -235,11 +248,9 @@ export function setKeychainToken(item, value, sync = false) {
|
|
|
235
248
|
return;
|
|
236
249
|
}
|
|
237
250
|
// macOS path. Both sync and non-sync writes go through the .app helper so
|
|
238
|
-
// the item picks up
|
|
239
|
-
//
|
|
240
|
-
//
|
|
241
|
-
// device-local writes; sync writes get kSecAttrSynchronizable=true by
|
|
242
|
-
// default.
|
|
251
|
+
// the item picks up kSecAttrAccessControl user-presence protection. The
|
|
252
|
+
// helper takes an optional `nosync` arg for device-local writes; sync writes
|
|
253
|
+
// get kSecAttrSynchronizable=true by default.
|
|
243
254
|
const bin = ensureKeychainHelper();
|
|
244
255
|
const args = ['set', item, os.userInfo().username];
|
|
245
256
|
if (!sync)
|
|
@@ -260,13 +271,22 @@ export function deleteKeychainToken(item, sync = false) {
|
|
|
260
271
|
assertSupportedPlatform();
|
|
261
272
|
if (isLinux())
|
|
262
273
|
return linuxBackend.delete(item, sync);
|
|
263
|
-
// macOS:
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
274
|
+
// macOS: delete through the signed helper FIRST. `security delete-generic-password`
|
|
275
|
+
// prompts for keychain-password authorization on any item whose ACL doesn't list
|
|
276
|
+
// `security` — which is every item the helper writes. Same reasoning as
|
|
277
|
+
// getKeychainToken's helper-first ordering above. The helper also handles the
|
|
278
|
+
// synced keychain via kSecAttrSynchronizableAny in one call.
|
|
279
|
+
let bin;
|
|
280
|
+
try {
|
|
281
|
+
bin = ensureKeychainHelper();
|
|
282
|
+
}
|
|
283
|
+
catch {
|
|
284
|
+
// Helper bundle missing (dev build). Fall back to security; it can only
|
|
285
|
+
// touch non-synced items and may prompt for items it didn't write.
|
|
286
|
+
return spawnSync('security', ['delete-generic-password', '-a', os.userInfo().username, '-s', item], {
|
|
287
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
288
|
+
}).status === 0;
|
|
289
|
+
}
|
|
270
290
|
return spawnSync(bin, ['delete', item, os.userInfo().username], {
|
|
271
291
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
272
292
|
}).status === 0;
|
|
@@ -20,6 +20,14 @@ export interface ActiveSession {
|
|
|
20
20
|
cloudProvider?: string;
|
|
21
21
|
cloudTaskId?: string;
|
|
22
22
|
cloudStatus?: string;
|
|
23
|
+
/**
|
|
24
|
+
* IDE window that owns this terminal. Source of truth is the per-window
|
|
25
|
+
* slice key in `live-terminals.json` (computeWindowId in the swarmify
|
|
26
|
+
* extension): `${vscode.env.sessionId}-${extension-host pid}`. Lets the
|
|
27
|
+
* renderer cluster terminals that belong to the same IDE window even when
|
|
28
|
+
* two windows have the same cwd open. Only populated for `terminal` context.
|
|
29
|
+
*/
|
|
30
|
+
windowId?: string;
|
|
23
31
|
}
|
|
24
32
|
export interface ActiveQueryOptions {
|
|
25
33
|
/** Skip the `ps` scan for ad-hoc headless agents. */
|