@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.
Files changed (109) hide show
  1. package/CHANGELOG.md +67 -0
  2. package/README.md +70 -10
  3. package/dist/commands/browser.js +88 -16
  4. package/dist/commands/cli.d.ts +14 -0
  5. package/dist/commands/cli.js +244 -0
  6. package/dist/commands/commands.js +3 -3
  7. package/dist/commands/computer.js +18 -1
  8. package/dist/commands/doctor.d.ts +1 -1
  9. package/dist/commands/doctor.js +2 -2
  10. package/dist/commands/exec.js +3 -3
  11. package/dist/commands/factory.d.ts +3 -14
  12. package/dist/commands/factory.js +3 -3
  13. package/dist/commands/hooks.js +3 -3
  14. package/dist/commands/mcp.js +29 -0
  15. package/dist/commands/plugins.js +11 -4
  16. package/dist/commands/profiles.js +1 -1
  17. package/dist/commands/prune.js +39 -160
  18. package/dist/commands/pull.js +56 -3
  19. package/dist/commands/routines.js +106 -13
  20. package/dist/commands/secrets.js +6 -8
  21. package/dist/commands/sessions.d.ts +36 -7
  22. package/dist/commands/sessions.js +130 -53
  23. package/dist/commands/setup.d.ts +1 -0
  24. package/dist/commands/setup.js +37 -28
  25. package/dist/commands/skills.js +3 -3
  26. package/dist/commands/teams.js +13 -0
  27. package/dist/commands/versions.d.ts +4 -3
  28. package/dist/commands/versions.js +147 -124
  29. package/dist/commands/view.js +12 -12
  30. package/dist/index.js +34 -6
  31. package/dist/lib/acp/harnesses.js +8 -0
  32. package/dist/lib/agents.js +162 -9
  33. package/dist/lib/browser/cdp.d.ts +8 -1
  34. package/dist/lib/browser/cdp.js +40 -3
  35. package/dist/lib/browser/chrome.d.ts +13 -0
  36. package/dist/lib/browser/chrome.js +42 -3
  37. package/dist/lib/browser/domain-skills.d.ts +51 -0
  38. package/dist/lib/browser/domain-skills.js +157 -0
  39. package/dist/lib/browser/drivers/local.js +45 -4
  40. package/dist/lib/browser/drivers/ssh.js +1 -1
  41. package/dist/lib/browser/ipc.d.ts +8 -1
  42. package/dist/lib/browser/ipc.js +37 -28
  43. package/dist/lib/browser/profiles.d.ts +13 -0
  44. package/dist/lib/browser/profiles.js +41 -1
  45. package/dist/lib/browser/service.d.ts +3 -0
  46. package/dist/lib/browser/service.js +21 -5
  47. package/dist/lib/browser/types.d.ts +7 -0
  48. package/dist/lib/cli-resources.d.ts +109 -0
  49. package/dist/lib/cli-resources.js +255 -0
  50. package/dist/lib/cloud/rush.js +5 -5
  51. package/dist/lib/command-skills.js +0 -2
  52. package/dist/lib/computer-rpc.d.ts +3 -0
  53. package/dist/lib/computer-rpc.js +53 -0
  54. package/dist/lib/daemon.js +20 -0
  55. package/dist/lib/exec.d.ts +3 -2
  56. package/dist/lib/exec.js +62 -6
  57. package/dist/lib/hooks.js +182 -0
  58. package/dist/lib/mcp.js +6 -0
  59. package/dist/lib/migrate.js +1 -1
  60. package/dist/lib/overdue.d.ts +26 -0
  61. package/dist/lib/overdue.js +101 -0
  62. package/dist/lib/permissions.js +5 -1
  63. package/dist/lib/plugin-marketplace.js +1 -1
  64. package/dist/lib/profiles-presets.js +37 -0
  65. package/dist/lib/registry.d.ts +18 -0
  66. package/dist/lib/registry.js +44 -0
  67. package/dist/lib/resources/mcp.js +43 -1
  68. package/dist/lib/resources/types.d.ts +1 -1
  69. package/dist/lib/resources.d.ts +1 -1
  70. package/dist/lib/rotate.js +10 -4
  71. package/dist/lib/routines-format.d.ts +35 -0
  72. package/dist/lib/routines-format.js +173 -0
  73. package/dist/lib/routines.d.ts +7 -1
  74. package/dist/lib/routines.js +32 -12
  75. package/dist/lib/runner.js +19 -5
  76. package/dist/lib/scheduler.js +8 -1
  77. package/dist/lib/secrets/{AgentsKeychain.app → Agents CLI.app}/Contents/CodeResources +0 -0
  78. package/dist/lib/secrets/{AgentsKeychain.app/Contents/Info.plist → Agents CLI.app/Contents/Info.plist } +4 -2
  79. package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
  80. package/dist/lib/secrets/bundles.d.ts +33 -2
  81. package/dist/lib/secrets/bundles.js +249 -26
  82. package/dist/lib/secrets/index.d.ts +10 -1
  83. package/dist/lib/secrets/index.js +143 -48
  84. package/dist/lib/session/active.d.ts +8 -0
  85. package/dist/lib/session/active.js +3 -2
  86. package/dist/lib/session/db.d.ts +10 -4
  87. package/dist/lib/session/db.js +16 -16
  88. package/dist/lib/session/parse.d.ts +1 -0
  89. package/dist/lib/session/parse.js +44 -0
  90. package/dist/lib/session/types.d.ts +1 -1
  91. package/dist/lib/session/types.js +1 -1
  92. package/dist/lib/shims.d.ts +6 -2
  93. package/dist/lib/shims.js +88 -10
  94. package/dist/lib/state.d.ts +0 -1
  95. package/dist/lib/state.js +2 -15
  96. package/dist/lib/teams/agents.js +1 -1
  97. package/dist/lib/teams/parsers.d.ts +1 -1
  98. package/dist/lib/teams/parsers.js +153 -3
  99. package/dist/lib/teams/summarizer.js +18 -2
  100. package/dist/lib/teams/worktree.js +14 -3
  101. package/dist/lib/types.d.ts +7 -4
  102. package/dist/lib/types.js +6 -3
  103. package/dist/lib/versions.d.ts +10 -2
  104. package/dist/lib/versions.js +227 -35
  105. package/package.json +9 -9
  106. package/dist/lib/secrets/AgentsKeychain.app/Contents/MacOS/AgentsKeychain +0 -0
  107. package/npm-shrinkwrap.json +0 -3162
  108. /package/dist/lib/secrets/{AgentsKeychain.app → Agents CLI.app}/Contents/_CodeSignature/CodeResources +0 -0
  109. /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
- emit('secrets.set', { bundle: bundle.name });
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
- out.push(readBundle(name));
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
- // Walk the bundle and produce a flat env map. Keychain refs are translated via
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 env = {};
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
- if ('literal' in parsed) {
266
- env[key] = parsed.literal;
267
- continue;
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
- try {
270
- env[key] = resolveRef(parsed.ref, {
271
- allowExec: bundle.allow_exec,
272
- iCloudSync: bundle.icloud_sync,
273
- keychainItemFor: (shortId) => secretsKeychainItem(bundle.name, shortId),
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
- catch (err) {
277
- const msg = err.message;
278
- if (parsed.ref.provider === 'keychain' && /not found/.test(msg)) {
279
- throw new Error(`${msg} Run: agents secrets add ${bundle.name} ${key}`);
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 (AgentsKeychain.app) or `security` CLI.
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 (AgentsKeychain.app) or `security` CLI.
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('Secure credential storage requires macOS or Linux. ' +
41
- 'On Windows, use environment variables or .env files instead.');
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 `${SERVICE_PREFIX}.secrets.${bundle}.${key}`;
59
+ return `${SECRETS_ITEM_PREFIX}${bundle}.${key}`;
57
60
  }
58
- // Resolve the bundled, signed-and-notarized AgentsKeychain.app shipped
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, 'AgentsKeychain.app', 'Contents', 'MacOS', 'AgentsKeychain');
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: Try security first (no prompts for local items), fall back to binary for synced items.
95
- if (spawnSync('security', ['find-generic-password', '-a', os.userInfo().username, '-s', item, '-w'], {
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: Try security first (no prompts for local items)
113
- const secResult = spawnSync('security', ['find-generic-password', '-a', os.userInfo().username, '-s', item, '-w'], {
114
- stdio: ['ignore', 'pipe', 'pipe'],
115
- });
116
- if (secResult.status === 0) {
117
- const token = secResult.stdout?.toString().trim();
118
- if (token)
119
- return token;
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
- // Fallback: binary searches both synced and non-synced via kSecAttrSynchronizableAny
122
- const bin = ensureKeychainHelper();
123
- const result = spawnSync(bin, ['get', item, os.userInfo().username], {
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
- if (sync) {
156
- const bin = ensureKeychainHelper();
157
- const result = spawnSync(bin, ['set', item, os.userInfo().username], {
158
- input: value,
159
- stdio: ['pipe', 'pipe', 'pipe'],
160
- });
161
- if (result.status !== 0) {
162
- const msg = result.stderr?.toString().trim();
163
- throw new Error(msg || `Failed to write keychain item '${item}'.`);
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
- throw new Error(`Failed to write keychain item '${item}' (exit ${result.status}).`);
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: Try security first (no prompts for local items), fall back to binary for synced items.
186
- if (!sync && spawnSync('security', ['delete-generic-password', '-a', os.userInfo().username, '-s', item], {
187
- stdio: ['ignore', 'pipe', 'pipe'],
188
- }).status === 0)
189
- return true;
190
- // Fallback: binary deletes synced items via kSecAttrSynchronizableAny
191
- const bin = ensureKeychainHelper();
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.values(parsed)) {
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
  }