@phnx-labs/agents-cli 1.19.1 → 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 +70 -10
- 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/mcp.js +29 -0
- 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 +6 -8
- package/dist/commands/sessions.d.ts +36 -7
- package/dist/commands/sessions.js +130 -53
- 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 +147 -124
- package/dist/commands/view.js +12 -12
- package/dist/index.js +34 -6
- package/dist/lib/acp/harnesses.js +8 -0
- package/dist/lib/agents.js +162 -9
- 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 +62 -6
- 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/registry.d.ts +18 -0
- package/dist/lib/registry.js +44 -0
- package/dist/lib/resources/mcp.js +43 -1
- package/dist/lib/resources/types.d.ts +1 -1
- 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/{AgentsKeychain.app → Agents CLI.app}/Contents/CodeResources +0 -0
- package/dist/lib/secrets/{AgentsKeychain.app/Contents/Info.plist → Agents CLI.app/Contents/Info.plist } +4 -2
- package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
- package/dist/lib/secrets/bundles.d.ts +33 -2
- package/dist/lib/secrets/bundles.js +249 -26
- package/dist/lib/secrets/index.d.ts +10 -1
- package/dist/lib/secrets/index.js +143 -48
- package/dist/lib/session/active.d.ts +8 -0
- package/dist/lib/session/active.js +3 -2
- package/dist/lib/session/db.d.ts +10 -4
- package/dist/lib/session/db.js +16 -16
- 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 +6 -2
- package/dist/lib/shims.js +88 -10
- 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 +7 -4
- 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 +9 -9
- package/dist/lib/secrets/AgentsKeychain.app/Contents/MacOS/AgentsKeychain +0 -0
- package/npm-shrinkwrap.json +0 -3162
- /package/dist/lib/secrets/{AgentsKeychain.app → Agents CLI.app}/Contents/_CodeSignature/CodeResources +0 -0
- /package/dist/lib/secrets/{AgentsKeychain.app → Agents CLI.app}/Contents/embedded.provisionprofile +0 -0
|
@@ -15,7 +15,7 @@ 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, hasKeychainToken, listKeychainItems, parseBundleValue, resolveRef, secretsKeychainItem, setKeychainToken, } from './index.js';
|
|
18
|
+
import { deleteKeychainToken, getKeychainToken, getKeychainTokensBatch, 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',
|
|
@@ -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,41 +294,217 @@ 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.
|
|
254
301
|
}
|
|
255
302
|
}
|
|
256
|
-
|
|
257
|
-
// the bundle-scoped naming scheme so two bundles with the same short ID never
|
|
258
|
-
// collide. Throws on the first missing secret so `agents run` fails loudly
|
|
259
|
-
// rather than silently injecting empty strings.
|
|
260
|
-
export function resolveBundleEnv(bundle) {
|
|
303
|
+
export function resolveBundleEnv(bundle, opts = {}) {
|
|
261
304
|
stampLastUsed(bundle);
|
|
262
|
-
const
|
|
305
|
+
const parsedByKey = new Map();
|
|
306
|
+
const keychainItemsToFetch = [];
|
|
307
|
+
const keychainKeys = [];
|
|
308
|
+
const kindCounts = {};
|
|
263
309
|
for (const [key, raw] of Object.entries(bundle.vars)) {
|
|
264
310
|
const parsed = parseBundleValue(raw);
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
311
|
+
parsedByKey.set(key, parsed);
|
|
312
|
+
const kind = 'literal' in parsed ? 'literal' : parsed.ref.provider;
|
|
313
|
+
kindCounts[kind] = (kindCounts[kind] ?? 0) + 1;
|
|
314
|
+
if ('ref' in parsed && parsed.ref.provider === 'keychain') {
|
|
315
|
+
const item = secretsKeychainItem(bundle.name, parsed.ref.value);
|
|
316
|
+
keychainItemsToFetch.push(item);
|
|
317
|
+
keychainKeys.push(key);
|
|
268
318
|
}
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
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
|
+
};
|
|
334
|
+
// Build the localizedReason shown under the Touch ID prompt. Lowercase verb
|
|
335
|
+
// phrase per Apple HIG — the system prepends "<App> is required to ".
|
|
336
|
+
const reason = opts.caller
|
|
337
|
+
? `read ${bundle.name} secrets (for ${opts.caller})`
|
|
338
|
+
: `read ${bundle.name} secrets`;
|
|
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}`);
|
|
372
|
+
}
|
|
373
|
+
}
|
|
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);
|
|
275
456
|
}
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
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}`);
|
|
280
499
|
}
|
|
281
|
-
throw new Error(`Bundle '${bundle.name}' key '${key}': ${msg}`);
|
|
282
500
|
}
|
|
501
|
+
emitReadAudit('success');
|
|
502
|
+
return { bundle, env };
|
|
503
|
+
}
|
|
504
|
+
catch (err) {
|
|
505
|
+
emitReadAudit('error', err);
|
|
506
|
+
throw err;
|
|
283
507
|
}
|
|
284
|
-
return env;
|
|
285
508
|
}
|
|
286
509
|
// Build a keychain ref expression from a bundle+key pair, for storage in the bundle metadata.
|
|
287
510
|
export function keychainRef(key) {
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Cross-platform secure credential storage.
|
|
3
3
|
*
|
|
4
|
-
* macOS: Uses Keychain via signed Swift helper (
|
|
4
|
+
* macOS: Uses Keychain via signed Swift helper (Agents CLI.app) or `security` CLI.
|
|
5
5
|
* Linux: Uses libsecret (GNOME Keyring) via `secret-tool` CLI.
|
|
6
6
|
* Windows: Not yet supported.
|
|
7
7
|
*
|
|
@@ -56,6 +56,15 @@ export declare function setKeychainBackendForTest(b: KeychainBackend | null): Ke
|
|
|
56
56
|
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
|
+
/**
|
|
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).
|
|
63
|
+
*
|
|
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.
|
|
66
|
+
*/
|
|
67
|
+
export declare function getKeychainTokensBatch(items: string[], sync?: boolean, reason?: string): Map<string, string>;
|
|
59
68
|
/** Store or update a secret value in the keychain/keyring. iCloud-synced when sync=true (macOS only). */
|
|
60
69
|
export declare function setKeychainToken(item: string, value: string, sync?: boolean): void;
|
|
61
70
|
/** Delete a keychain/keyring item. Returns true if it existed. */
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Cross-platform secure credential storage.
|
|
3
3
|
*
|
|
4
|
-
* macOS: Uses Keychain via signed Swift helper (
|
|
4
|
+
* macOS: Uses Keychain via signed Swift helper (Agents CLI.app) or `security` CLI.
|
|
5
5
|
* Linux: Uses libsecret (GNOME Keyring) via `secret-tool` CLI.
|
|
6
6
|
* Windows: Not yet supported.
|
|
7
7
|
*
|
|
@@ -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,9 +56,12 @@ 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}`;
|
|
57
60
|
}
|
|
58
|
-
|
|
61
|
+
function keychainItemRequiresUserPresence(item) {
|
|
62
|
+
return item.startsWith(SECRETS_ITEM_PREFIX) || item.startsWith(BUNDLES_ITEM_PREFIX);
|
|
63
|
+
}
|
|
64
|
+
// Resolve the bundled, signed-and-notarized Agents CLI.app shipped
|
|
59
65
|
// alongside the compiled JS. The .app embeds a provisioning profile that
|
|
60
66
|
// grants the application-identifier + keychain-access-groups entitlements
|
|
61
67
|
// macOS requires for kSecAttrSynchronizable writes. Bare CLI binaries
|
|
@@ -64,7 +70,7 @@ export function secretsKeychainItem(bundle, key) {
|
|
|
64
70
|
// be prebuilt by `scripts/build-keychain-helper.sh` and shipped.
|
|
65
71
|
function ensureKeychainHelper() {
|
|
66
72
|
const here = path.dirname(fileURLToPath(import.meta.url));
|
|
67
|
-
const binPath = path.join(here, '
|
|
73
|
+
const binPath = path.join(here, 'Agents CLI.app', 'Contents', 'MacOS', 'Agents CLI');
|
|
68
74
|
if (!fs.existsSync(binPath)) {
|
|
69
75
|
throw new Error(`Keychain helper missing at ${binPath}. ` +
|
|
70
76
|
'This npm package was built without the signed helper bundle. Reinstall agents-cli.');
|
|
@@ -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,18 +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
|
-
|
|
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, {
|
|
124
154
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
125
155
|
});
|
|
126
156
|
if (result.status === 1)
|
|
@@ -134,6 +164,72 @@ export function getKeychainToken(item, sync = false) {
|
|
|
134
164
|
throw new Error(`Keychain item '${item}' exists but is empty.`);
|
|
135
165
|
return token;
|
|
136
166
|
}
|
|
167
|
+
/**
|
|
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).
|
|
171
|
+
*
|
|
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.
|
|
174
|
+
*/
|
|
175
|
+
export function getKeychainTokensBatch(items, sync = false, reason = 'read agents-cli secrets') {
|
|
176
|
+
const result = new Map();
|
|
177
|
+
if (backend) {
|
|
178
|
+
for (const item of items) {
|
|
179
|
+
try {
|
|
180
|
+
result.set(item, backend.get(item, sync));
|
|
181
|
+
}
|
|
182
|
+
catch {
|
|
183
|
+
// Missing or unreadable — skip; the caller reports which key is missing.
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
return result;
|
|
187
|
+
}
|
|
188
|
+
assertSupportedPlatform();
|
|
189
|
+
if (isLinux()) {
|
|
190
|
+
for (const item of items) {
|
|
191
|
+
try {
|
|
192
|
+
result.set(item, linuxBackend.get(item, sync));
|
|
193
|
+
}
|
|
194
|
+
catch {
|
|
195
|
+
// Missing or unreadable — skip; the caller reports which key is missing.
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
return result;
|
|
199
|
+
}
|
|
200
|
+
let bin;
|
|
201
|
+
try {
|
|
202
|
+
bin = ensureKeychainHelper();
|
|
203
|
+
}
|
|
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
|
+
}
|
|
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'));
|
|
230
|
+
}
|
|
231
|
+
return result;
|
|
232
|
+
}
|
|
137
233
|
/** Store or update a secret value in the keychain/keyring. iCloud-synced when sync=true (macOS only). */
|
|
138
234
|
export function setKeychainToken(item, value, sync = false) {
|
|
139
235
|
if (backend) {
|
|
@@ -151,28 +247,21 @@ export function setKeychainToken(item, value, sync = false) {
|
|
|
151
247
|
linuxBackend.set(item, value, sync);
|
|
152
248
|
return;
|
|
153
249
|
}
|
|
154
|
-
// macOS path
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
}
|
|
165
|
-
return;
|
|
166
|
-
}
|
|
167
|
-
// `security -i` keeps the value out of argv (and `ps`).
|
|
168
|
-
const user = os.userInfo().username;
|
|
169
|
-
const cmd = `add-generic-password -a ${quoteForSecurityCli(user)} -s ${quoteForSecurityCli(item)} -w ${quoteForSecurityCli(value)} -T ${quoteForSecurityCli('')} -U\n`;
|
|
170
|
-
const result = spawnSync('security', ['-i'], {
|
|
171
|
-
input: cmd,
|
|
250
|
+
// macOS path. Both sync and non-sync writes go through the .app helper so
|
|
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.
|
|
254
|
+
const bin = ensureKeychainHelper();
|
|
255
|
+
const args = ['set', item, os.userInfo().username];
|
|
256
|
+
if (!sync)
|
|
257
|
+
args.push('nosync');
|
|
258
|
+
const result = spawnSync(bin, args, {
|
|
259
|
+
input: value,
|
|
172
260
|
stdio: ['pipe', 'pipe', 'pipe'],
|
|
173
261
|
});
|
|
174
262
|
if (result.status !== 0) {
|
|
175
|
-
|
|
263
|
+
const msg = result.stderr?.toString().trim();
|
|
264
|
+
throw new Error(msg || `Failed to write keychain item '${item}'.`);
|
|
176
265
|
}
|
|
177
266
|
}
|
|
178
267
|
/** Delete a keychain/keyring item. Returns true if it existed. */
|
|
@@ -182,20 +271,26 @@ export function deleteKeychainToken(item, sync = false) {
|
|
|
182
271
|
assertSupportedPlatform();
|
|
183
272
|
if (isLinux())
|
|
184
273
|
return linuxBackend.delete(item, sync);
|
|
185
|
-
// macOS:
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
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
|
+
}
|
|
192
290
|
return spawnSync(bin, ['delete', item, os.userInfo().username], {
|
|
193
291
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
194
292
|
}).status === 0;
|
|
195
293
|
}
|
|
196
|
-
function quoteForSecurityCli(s) {
|
|
197
|
-
return '"' + s.replace(/\\/g, '\\\\').replace(/"/g, '\\"') + '"';
|
|
198
|
-
}
|
|
199
294
|
/** Enumerate keychain/keyring item names starting with the given prefix. */
|
|
200
295
|
export function listKeychainItems(prefix) {
|
|
201
296
|
if (backend)
|
|
@@ -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. */
|
|
@@ -73,11 +73,11 @@ function readLiveTerminals() {
|
|
|
73
73
|
if (!parsed || typeof parsed !== 'object')
|
|
74
74
|
return [];
|
|
75
75
|
const merged = new Map();
|
|
76
|
-
for (const slice of Object.
|
|
76
|
+
for (const [windowId, slice] of Object.entries(parsed)) {
|
|
77
77
|
for (const e of (slice?.entries ?? [])) {
|
|
78
78
|
if (!e?.sessionId || !isPidAlive(e.pid))
|
|
79
79
|
continue;
|
|
80
|
-
merged.set(e.sessionId, e);
|
|
80
|
+
merged.set(e.sessionId, { ...e, windowId });
|
|
81
81
|
}
|
|
82
82
|
}
|
|
83
83
|
return Array.from(merged.values());
|
|
@@ -259,6 +259,7 @@ export async function listTerminalsActive() {
|
|
|
259
259
|
sessionFile,
|
|
260
260
|
startedAtMs: t.startedAtMs,
|
|
261
261
|
status: classifyActivity(sessionFile),
|
|
262
|
+
windowId: t.windowId,
|
|
262
263
|
};
|
|
263
264
|
});
|
|
264
265
|
}
|