@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.
Files changed (103) hide show
  1. package/CHANGELOG.md +67 -0
  2. package/README.md +69 -9
  3. package/dist/browser.js +0 -0
  4. package/dist/commands/browser.js +88 -16
  5. package/dist/commands/cli.d.ts +14 -0
  6. package/dist/commands/cli.js +244 -0
  7. package/dist/commands/commands.js +3 -3
  8. package/dist/commands/computer.js +18 -1
  9. package/dist/commands/doctor.d.ts +1 -1
  10. package/dist/commands/doctor.js +2 -2
  11. package/dist/commands/exec.js +3 -3
  12. package/dist/commands/factory.d.ts +3 -14
  13. package/dist/commands/factory.js +3 -3
  14. package/dist/commands/hooks.js +3 -3
  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 +5 -7
  21. package/dist/commands/sessions.d.ts +28 -0
  22. package/dist/commands/sessions.js +98 -33
  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 +131 -127
  29. package/dist/commands/view.js +12 -12
  30. package/dist/computer.js +0 -0
  31. package/dist/index.js +34 -6
  32. package/dist/lib/acp/harnesses.js +8 -0
  33. package/dist/lib/agents.js +110 -23
  34. package/dist/lib/browser/cdp.d.ts +8 -1
  35. package/dist/lib/browser/cdp.js +40 -3
  36. package/dist/lib/browser/chrome.d.ts +13 -0
  37. package/dist/lib/browser/chrome.js +42 -3
  38. package/dist/lib/browser/domain-skills.d.ts +51 -0
  39. package/dist/lib/browser/domain-skills.js +157 -0
  40. package/dist/lib/browser/drivers/local.js +45 -4
  41. package/dist/lib/browser/drivers/ssh.js +1 -1
  42. package/dist/lib/browser/ipc.d.ts +8 -1
  43. package/dist/lib/browser/ipc.js +37 -28
  44. package/dist/lib/browser/profiles.d.ts +13 -0
  45. package/dist/lib/browser/profiles.js +41 -1
  46. package/dist/lib/browser/service.d.ts +3 -0
  47. package/dist/lib/browser/service.js +21 -5
  48. package/dist/lib/browser/types.d.ts +7 -0
  49. package/dist/lib/cli-resources.d.ts +109 -0
  50. package/dist/lib/cli-resources.js +255 -0
  51. package/dist/lib/cloud/rush.js +5 -5
  52. package/dist/lib/command-skills.js +0 -2
  53. package/dist/lib/computer-rpc.d.ts +3 -0
  54. package/dist/lib/computer-rpc.js +53 -0
  55. package/dist/lib/daemon.js +20 -0
  56. package/dist/lib/exec.d.ts +3 -2
  57. package/dist/lib/exec.js +44 -9
  58. package/dist/lib/hooks.js +182 -0
  59. package/dist/lib/mcp.js +6 -0
  60. package/dist/lib/migrate.js +1 -1
  61. package/dist/lib/overdue.d.ts +26 -0
  62. package/dist/lib/overdue.js +101 -0
  63. package/dist/lib/permissions.js +5 -1
  64. package/dist/lib/plugin-marketplace.js +1 -1
  65. package/dist/lib/profiles-presets.js +37 -0
  66. package/dist/lib/resources/mcp.js +37 -0
  67. package/dist/lib/resources.d.ts +1 -1
  68. package/dist/lib/rotate.js +10 -4
  69. package/dist/lib/routines-format.d.ts +35 -0
  70. package/dist/lib/routines-format.js +173 -0
  71. package/dist/lib/routines.d.ts +7 -1
  72. package/dist/lib/routines.js +32 -12
  73. package/dist/lib/runner.js +19 -5
  74. package/dist/lib/scheduler.js +8 -1
  75. package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
  76. package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
  77. package/dist/lib/secrets/bundles.d.ts +22 -1
  78. package/dist/lib/secrets/bundles.js +234 -36
  79. package/dist/lib/secrets/index.d.ts +6 -11
  80. package/dist/lib/secrets/index.js +107 -87
  81. package/dist/lib/session/active.d.ts +8 -0
  82. package/dist/lib/session/active.js +3 -2
  83. package/dist/lib/session/db.d.ts +0 -4
  84. package/dist/lib/session/db.js +0 -26
  85. package/dist/lib/session/parse.d.ts +1 -0
  86. package/dist/lib/session/parse.js +44 -0
  87. package/dist/lib/session/types.d.ts +1 -1
  88. package/dist/lib/session/types.js +1 -1
  89. package/dist/lib/shims.d.ts +1 -1
  90. package/dist/lib/shims.js +66 -4
  91. package/dist/lib/state.d.ts +0 -1
  92. package/dist/lib/state.js +2 -15
  93. package/dist/lib/teams/agents.js +1 -1
  94. package/dist/lib/teams/parsers.d.ts +1 -1
  95. package/dist/lib/teams/parsers.js +153 -3
  96. package/dist/lib/teams/summarizer.js +18 -2
  97. package/dist/lib/teams/worktree.js +14 -3
  98. package/dist/lib/types.d.ts +6 -3
  99. package/dist/lib/types.js +6 -3
  100. package/dist/lib/versions.d.ts +10 -2
  101. package/dist/lib/versions.js +227 -35
  102. package/package.json +7 -7
  103. 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
- 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,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 keychainItemToKey = new Map();
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
- keychainItemToKey.set(item, key);
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
- // Single helper invocation, one biometric prompt.
276
- const fetched = keychainItemsToFetch.length > 0
277
- ? getKeychainTokensBatch(keychainItemsToFetch, bundle.icloud_sync, reason)
278
- : new Map();
279
- // Second pass: assemble env. Keychain values come from the batch; everything
280
- // else is resolved inline (literals and env/file/exec refs don't prompt).
281
- const env = {};
282
- for (const [key, raw] of Object.entries(bundle.vars)) {
283
- const parsed = parsedByKey.get(key);
284
- if ('literal' in parsed) {
285
- env[key] = parsed.literal;
286
- continue;
287
- }
288
- if (parsed.ref.provider === 'keychain') {
289
- const item = secretsKeychainItem(bundle.name, parsed.ref.value);
290
- const value = fetched.get(item);
291
- if (value === undefined) {
292
- throw new Error(`Bundle '${bundle.name}' key '${key}': keychain item '${item}' not found. ` +
293
- `Run: agents secrets add ${bundle.name} ${key}`);
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
- try {
299
- env[key] = resolveRef(parsed.ref, {
300
- allowExec: bundle.allow_exec,
301
- iCloudSync: bundle.icloud_sync,
302
- keychainItemFor: (shortId) => secretsKeychainItem(bundle.name, shortId),
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
- catch (err) {
306
- throw new Error(`Bundle '${bundle.name}' key '${key}': ${err.message}`);
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
- * Batch-read multiple keychain items behind a single LocalAuthentication
61
- * prompt. macOS shows ONE Touch ID prompt and every requested item is
62
- * unlocked in the same process. Returns a Map keyed by item name. Missing
63
- * items are absent from the map (caller decides whether that's an error).
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
- * `reason` is shown to the user under the Touch ID prompt — e.g.
66
- * "read 'hetzner.com' secrets for agent 'claude'". Apple's HIG recommends
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[], _sync?: boolean, reason?: string): Map<string, 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('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,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 `${SERVICE_PREFIX}.secrets.${bundle}.${key}`;
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: 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,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: 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
- // `get` is the unauthenticated path no LocalAuthentication prompt. Used by
123
- // profiles.ts (OAuth refresh) where biometric on every API call is too noisy.
124
- const bin = ensureKeychainHelper();
125
- const result = spawnSync(bin, ['get', item, os.userInfo().username], {
136
+ catch {
137
+ // Helper bundle missingdegrade 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
- * Batch-read multiple keychain items behind a single LocalAuthentication
141
- * prompt. macOS shows ONE Touch ID prompt and every requested item is
142
- * unlocked in the same process. Returns a Map keyed by item name. Missing
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 Linux or when a test backend is installed, falls back to individual
150
- * `get` calls no biometric prompt path on those platforms.
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, _sync = false, reason) {
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, _sync));
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, _sync));
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
- // macOS: single helper invocation with Touch ID gate.
176
- const bin = ensureKeychainHelper();
177
- const helperArgs = ['get-batch', os.userInfo().username];
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
- const out = child.stdout?.toString() ?? '';
189
- // Parser. Output is a sequence of records:
190
- // "V <service>\n<value>\n" (present)
191
- // "M <service>\n" (missing)
192
- // Service names cannot contain '\n' (validated at write time); values are
193
- // also newline-free (rejected by setKeychainToken). So splitting on '\n'
194
- // and walking line-by-line is unambiguous.
195
- const lines = out.split('\n');
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 our SecAccess ACL (helper as trusted reader). That ACL
239
- // is what stops macOS from showing the legacy "enter password" sheet on
240
- // subsequent reads. The helper takes an optional `nosync` arg for
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: Try security first (no prompts for local items), fall back to binary for synced items.
264
- if (!sync && spawnSync('security', ['delete-generic-password', '-a', os.userInfo().username, '-s', item], {
265
- stdio: ['ignore', 'pipe', 'pipe'],
266
- }).status === 0)
267
- return true;
268
- // Fallback: binary deletes synced items via kSecAttrSynchronizableAny
269
- 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
+ }
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. */