@phnx-labs/agents-cli 1.20.32 → 1.20.34

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 (110) hide show
  1. package/CHANGELOG.md +7 -0
  2. package/README.md +28 -2
  3. package/dist/commands/commands.js +3 -3
  4. package/dist/commands/computer-actions.js +1 -0
  5. package/dist/commands/computer.d.ts +23 -0
  6. package/dist/commands/computer.js +45 -3
  7. package/dist/commands/cost.js +2 -2
  8. package/dist/commands/doctor.d.ts +10 -0
  9. package/dist/commands/doctor.js +51 -2
  10. package/dist/commands/exec.js +56 -1
  11. package/dist/commands/hooks.js +3 -3
  12. package/dist/commands/import.js +1 -1
  13. package/dist/commands/inspect.js +13 -17
  14. package/dist/commands/mcp.js +3 -3
  15. package/dist/commands/permissions.js +3 -3
  16. package/dist/commands/rules.js +3 -3
  17. package/dist/commands/secrets-migrate.js +23 -11
  18. package/dist/commands/secrets.d.ts +20 -0
  19. package/dist/commands/secrets.js +53 -1
  20. package/dist/commands/sessions.js +18 -1
  21. package/dist/commands/skills.js +3 -3
  22. package/dist/commands/status.d.ts +12 -0
  23. package/dist/commands/status.js +81 -0
  24. package/dist/commands/sync.js +2 -2
  25. package/dist/commands/teams.js +77 -18
  26. package/dist/commands/usage.js +2 -2
  27. package/dist/commands/utils.d.ts +8 -0
  28. package/dist/commands/utils.js +20 -0
  29. package/dist/commands/versions.js +4 -3
  30. package/dist/commands/view.d.ts +39 -0
  31. package/dist/commands/view.js +217 -84
  32. package/dist/commands/workflows.js +3 -3
  33. package/dist/index.js +16 -2
  34. package/dist/lib/acp/harnesses.d.ts +1 -1
  35. package/dist/lib/acp/harnesses.js +2 -2
  36. package/dist/lib/agent-spec/index.d.ts +18 -0
  37. package/dist/lib/agent-spec/index.js +35 -0
  38. package/dist/lib/agent-spec/primitives.d.ts +28 -0
  39. package/dist/lib/agent-spec/primitives.js +57 -0
  40. package/dist/lib/agent-spec/provider.d.ts +2 -0
  41. package/dist/lib/agent-spec/provider.js +9 -0
  42. package/dist/lib/agent-spec/resolve.d.ts +33 -0
  43. package/dist/lib/agent-spec/resolve.js +174 -0
  44. package/dist/lib/agent-spec/types.d.ts +57 -0
  45. package/dist/lib/agent-spec/types.js +18 -0
  46. package/dist/lib/agents.d.ts +12 -0
  47. package/dist/lib/agents.js +115 -32
  48. package/dist/lib/browser/chrome.js +20 -0
  49. package/dist/lib/browser/drivers/ssh.d.ts +19 -0
  50. package/dist/lib/browser/drivers/ssh.js +18 -3
  51. package/dist/lib/crabbox/cli.d.ts +98 -0
  52. package/dist/lib/crabbox/cli.js +218 -0
  53. package/dist/lib/crabbox/lease.d.ts +41 -0
  54. package/dist/lib/crabbox/lease.js +73 -0
  55. package/dist/lib/crabbox/runtimes.d.ts +57 -0
  56. package/dist/lib/crabbox/runtimes.js +109 -0
  57. package/dist/lib/doctor-diff.js +29 -2
  58. package/dist/lib/drift-sync.d.ts +43 -0
  59. package/dist/lib/drift-sync.js +179 -0
  60. package/dist/lib/exec.d.ts +15 -0
  61. package/dist/lib/exec.js +21 -11
  62. package/dist/lib/hosts/dispatch.d.ts +27 -10
  63. package/dist/lib/hosts/dispatch.js +55 -19
  64. package/dist/lib/hosts/option.d.ts +14 -0
  65. package/dist/lib/hosts/option.js +19 -0
  66. package/dist/lib/hosts/passthrough.d.ts +30 -0
  67. package/dist/lib/hosts/passthrough.js +141 -0
  68. package/dist/lib/hosts/remote-cmd.d.ts +36 -0
  69. package/dist/lib/hosts/remote-cmd.js +56 -0
  70. package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
  71. package/dist/lib/platform/winpath.d.ts +31 -2
  72. package/dist/lib/platform/winpath.js +133 -24
  73. package/dist/lib/pwsh.d.ts +11 -0
  74. package/dist/lib/pwsh.js +13 -0
  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/agent.d.ts +42 -1
  78. package/dist/lib/secrets/agent.js +89 -11
  79. package/dist/lib/secrets/bundles.js +61 -21
  80. package/dist/lib/secrets/filestore.js +31 -1
  81. package/dist/lib/secrets/index.d.ts +44 -1
  82. package/dist/lib/secrets/index.js +108 -10
  83. package/dist/lib/secrets/linux.d.ts +14 -0
  84. package/dist/lib/secrets/linux.js +21 -0
  85. package/dist/lib/secrets/windows.d.ts +74 -0
  86. package/dist/lib/secrets/windows.js +440 -0
  87. package/dist/lib/session/active.d.ts +8 -0
  88. package/dist/lib/session/active.js +18 -1
  89. package/dist/lib/session/provenance.d.ts +56 -0
  90. package/dist/lib/session/provenance.js +157 -0
  91. package/dist/lib/shims.d.ts +20 -0
  92. package/dist/lib/shims.js +53 -20
  93. package/dist/lib/ssh-exec.d.ts +22 -0
  94. package/dist/lib/ssh-exec.js +59 -2
  95. package/dist/lib/ssh-tunnel.d.ts +0 -5
  96. package/dist/lib/ssh-tunnel.js +65 -8
  97. package/dist/lib/startup/command-registry.d.ts +1 -0
  98. package/dist/lib/startup/command-registry.js +2 -0
  99. package/dist/lib/sync-status.d.ts +102 -0
  100. package/dist/lib/sync-status.js +135 -0
  101. package/dist/lib/teams/agents.d.ts +24 -0
  102. package/dist/lib/teams/agents.js +30 -1
  103. package/dist/lib/types.d.ts +20 -1
  104. package/dist/lib/usage.d.ts +30 -0
  105. package/dist/lib/usage.js +159 -2
  106. package/dist/lib/versions.d.ts +2 -4
  107. package/dist/lib/versions.js +7 -20
  108. package/package.json +2 -1
  109. package/dist/lib/agent-spec.d.ts +0 -36
  110. package/dist/lib/agent-spec.js +0 -157
@@ -36,6 +36,19 @@ import { getCliVersion, getCliVersionFresh } from '../version.js';
36
36
  const PROTOCOL_VERSION = 1;
37
37
  /** Default lifetime of an unlocked bundle when `--ttl` is not given. */
38
38
  export const DEFAULT_TTL_MS = 24 * 60 * 60 * 1000; // 24h
39
+ /**
40
+ * Reserved store-key prefix for the `secrets list` metadata snapshot cache.
41
+ * The broker holds the resolved bundle-metadata array (names/policy/timestamps,
42
+ * NO resolved secret values beyond the literals already in metadata) keyed by a
43
+ * hash of the current keychain bundle name-set, so the second and later
44
+ * `secrets list` within the daily window read metadata without a Touch ID
45
+ * prompt. Keyed by the name-set hash so adding/removing/renaming a bundle
46
+ * changes the key and misses the cache automatically — no active invalidation.
47
+ * The '!' sentinel can never collide with a real bundle name
48
+ * (BUNDLE_NAME_PATTERN requires an alphanumeric first char) and is safe as
49
+ * spawnSync argv (unlike a NUL byte); `status` hides these entries.
50
+ */
51
+ export const META_CACHE_PREFIX = '!meta:';
39
52
  /** After the store goes empty (all bundles locked or expired) for this long,
40
53
  * the broker exits so no idle process lingers holding a socket. */
41
54
  const IDLE_EXIT_MS = 5 * 60 * 1000; // 5m
@@ -233,6 +246,20 @@ export async function uninstallSecretsAgentService() {
233
246
  * unit-testable with a controlled `now`, without a socket or a spawned process.
234
247
  * Mutates `store` in place; returns the wire response.
235
248
  */
249
+ /**
250
+ * Count of real unlocked bundles in the store, excluding the internal
251
+ * `secrets list` metadata cache. Used to decide broker "warmth" for self-heal
252
+ * and idle-exit: a metadata-only store must read as empty so a disposable list
253
+ * cache never blocks an upgrade restart (#435) or an idle one-off broker from
254
+ * exiting. Pure + exported for unit testing.
255
+ */
256
+ export function realBundleCount(store) {
257
+ let n = 0;
258
+ for (const name of store.keys())
259
+ if (!name.startsWith(META_CACHE_PREFIX))
260
+ n++;
261
+ return n;
262
+ }
236
263
  export function handleAgentRequest(store, req, now = Date.now()) {
237
264
  switch (req.cmd) {
238
265
  case 'ping':
@@ -266,6 +293,8 @@ export function handleAgentRequest(store, req, now = Date.now()) {
266
293
  for (const [name, e] of store) {
267
294
  if (now >= e.expiresAt)
268
295
  continue;
296
+ if (name.startsWith(META_CACHE_PREFIX))
297
+ continue; // internal list cache, not a user bundle
269
298
  entries.push({ name, expiresAt: e.expiresAt, keyCount: Object.keys(e.env).length });
270
299
  }
271
300
  return { ok: true, cmd: 'status', entries };
@@ -321,22 +350,28 @@ export async function runSecretsAgent(opts = {}) {
321
350
  // an in-place upgrade has landed and self-heal onto it. getCliVersion caches
322
351
  // this value for the process lifetime; getCliVersionFresh re-reads on disk.
323
352
  const runningVersion = getCliVersion();
353
+ // "Warmth" for self-heal / idle-exit counts only real unlocked bundles, NOT
354
+ // the internal `secrets list` metadata cache (#524). Otherwise a 24h-TTL list
355
+ // cache would keep the store non-empty and (a) block the persistent broker
356
+ // from self-healing onto a freshly-installed version for up to a day (#435's
357
+ // gate is size===0), and (b) stop a one-off broker from ever idle-exiting. The
358
+ // metadata cache is a disposable list snapshot — wiping it on upgrade/idle
359
+ // costs at most one extra prompt on the next `secrets list`.
324
360
  const sweep = () => {
325
361
  const now = Date.now();
326
362
  for (const [name, e] of store)
327
363
  if (now >= e.expiresAt)
328
364
  store.delete(name);
329
- // Self-heal onto a newer in-place install — but ONLY while the store is
330
- // empty, so we never wipe live unlocks and force a re-prompt (#435). The
331
- // `store.size === 0` short-circuit also keeps getCliVersionFresh (a disk
332
- // read) off the hot path. A pending upgrade is adopted at the next idle
333
- // sweep instead of immediately.
334
- if (store.size === 0 &&
335
- shouldSelfHealForUpgrade(persistent, store.size, runningVersion, getCliVersionFresh())) {
365
+ const live = realBundleCount(store);
366
+ // Self-heal onto a newer in-place install but ONLY while no real unlocks
367
+ // are held, so we never wipe live unlocks and force a re-prompt (#435). A
368
+ // metadata-only store still self-heals (the list cache is disposable).
369
+ if (live === 0 &&
370
+ shouldSelfHealForUpgrade(persistent, live, runningVersion, getCliVersionFresh())) {
336
371
  shutdown(0); // KeepAlive relaunches on the new code
337
372
  return;
338
373
  }
339
- if (store.size === 0) {
374
+ if (live === 0) {
340
375
  if (!persistent && now - emptySince >= IDLE_EXIT_MS)
341
376
  shutdown(0);
342
377
  }
@@ -346,7 +381,7 @@ export async function runSecretsAgent(opts = {}) {
346
381
  };
347
382
  const handle = (req) => {
348
383
  const resp = handleAgentRequest(store, req);
349
- if (store.size > 0)
384
+ if (realBundleCount(store) > 0)
350
385
  emptySince = Date.now();
351
386
  return resp;
352
387
  };
@@ -529,6 +564,44 @@ export function agentGetSync(name) {
529
564
  return null;
530
565
  }
531
566
  }
567
+ // Key inside the cached entry's env that holds the JSON metadata snapshot.
568
+ const META_SNAPSHOT_KEY = '__snapshot__';
569
+ /**
570
+ * Read the cached `secrets list` metadata snapshot for the given keychain
571
+ * name-set hash, or null on miss / no broker / off-darwin. Reuses the value
572
+ * fast-path socket read (agentGetSync) — no prompt, no wire change. The hash is
573
+ * the cache key: a changed name-set (bundle added/removed/renamed) yields a
574
+ * different key and therefore a clean miss, so the stale set is never served.
575
+ */
576
+ export function agentGetMetaSync(nameSetHash) {
577
+ if (!onDarwin())
578
+ return null;
579
+ const hit = agentGetSync(META_CACHE_PREFIX + nameSetHash);
580
+ const raw = hit?.env?.[META_SNAPSHOT_KEY];
581
+ if (!raw)
582
+ return null;
583
+ try {
584
+ const parsed = JSON.parse(raw);
585
+ return Array.isArray(parsed) ? parsed : null;
586
+ }
587
+ catch {
588
+ return null;
589
+ }
590
+ }
591
+ /**
592
+ * Fire-and-forget: populate the broker with a freshly-read metadata snapshot so
593
+ * the next `secrets list` within the daily window renders without a prompt.
594
+ * Stored as an ordinary entry (placeholder bundle, snapshot in env) under the
595
+ * reserved META_CACHE_PREFIX key; the snapshot travels over stdin to the
596
+ * detached worker (never argv/disk), same as value caching. macOS only.
597
+ */
598
+ export function agentAutoLoadMetaSync(nameSetHash, bundles, ttlMs) {
599
+ if (!onDarwin())
600
+ return;
601
+ const key = META_CACHE_PREFIX + nameSetHash;
602
+ const placeholder = { name: key, vars: {} };
603
+ agentAutoLoadSync(key, placeholder, { [META_SNAPSHOT_KEY]: JSON.stringify(bundles) }, ttlMs);
604
+ }
532
605
  /** True unless `secrets.agent.auto` is explicitly disabled in agents.yaml. The
533
606
  * broker is the mechanism that delivers the `daily` default policy (one Touch ID
534
607
  * per ~24h), so auto-caching is ON by default; opt out with
@@ -606,10 +679,15 @@ export async function agentLock(name) {
606
679
  const r = await request({ cmd: 'lock', name });
607
680
  return r?.ok === true && r.cmd === 'lock' ? r.wiped : 0;
608
681
  }
609
- /** List currently-unlocked bundles, or [] when no broker is running. */
682
+ /** List currently-unlocked bundles, or [] when no broker is running. The
683
+ * internal `secrets list` metadata-cache entry is filtered out here as well as
684
+ * server-side: during a rollout a NEW client can talk to an OLD broker that
685
+ * predates the server-side exclusion, so this keeps the internal entry from
686
+ * surfacing in `agents secrets status` in that skew window. */
610
687
  export async function agentStatus() {
611
688
  const r = await request({ cmd: 'status' });
612
- return r?.ok === true && r.cmd === 'status' ? r.entries : [];
689
+ const entries = r?.ok === true && r.cmd === 'status' ? r.entries : [];
690
+ return entries.filter((e) => !e.name.startsWith(META_CACHE_PREFIX));
613
691
  }
614
692
  /** Ping result: whether a broker is reachable + speaking our protocol, and the
615
693
  * version of the code it's running (for staleness detection). */
@@ -21,11 +21,12 @@ import * as fs from 'fs';
21
21
  import * as os from 'os';
22
22
  import * as path from 'path';
23
23
  import * as yaml from 'yaml';
24
- import { deleteKeychainToken, getKeychainToken, getKeychainTokens, hasKeychainToken, listKeychainItems, parseBundleValue, resolveRef, secretsKeychainItem, setKeychainToken, } from './index.js';
24
+ import { deleteKeychainToken, getKeychainToken, getKeychainTokens, hasKeychainToken, isKeychainBackendOverridden, keychainUsesFileFallback, listKeychainItems, parseBundleValue, resolveRef, secretsKeychainItem, setKeychainToken, } from './index.js';
25
25
  import { fileStore } from './filestore.js';
26
26
  import { emit } from '../events.js';
27
27
  import { readMeta } from '../state.js';
28
- import { agentGetSync, agentAutoLoadSync, secretsAgentAutoEnabled, DEFAULT_TTL_MS } from './agent.js';
28
+ import { agentGetSync, agentAutoLoadSync, agentGetMetaSync, agentAutoLoadMetaSync, secretsAgentAutoEnabled, DEFAULT_TTL_MS } from './agent.js';
29
+ import { createHash } from 'node:crypto';
29
30
  const keychainStore = {
30
31
  has: hasKeychainToken,
31
32
  get: getKeychainToken,
@@ -379,25 +380,64 @@ export function listBundles() {
379
380
  // prompt instead of N. Bundle metadata items carry user-presence ACLs (same
380
381
  // as secret values), so a naive loop over readBundle() spawns a fresh
381
382
  // LAContext per item — meaning N biometric prompts for `secrets list`.
382
- let keychainServices = [];
383
- try {
384
- keychainServices = listKeychainItems(BUNDLE_META_PREFIX);
385
- }
386
- catch {
387
- keychainServices = [];
388
- }
389
- const keychainNames = keychainServices
390
- .map((s) => s.slice(BUNDLE_META_PREFIX.length))
391
- .filter((n) => BUNDLE_NAME_PATTERN.test(n));
392
- if (keychainNames.length > 0) {
393
- const fetched = getKeychainTokens(keychainNames.map(bundleMetaItem));
394
- for (const name of keychainNames) {
395
- const json = fetched.get(bundleMetaItem(name));
396
- if (json === undefined)
397
- continue;
398
- const bundle = parseBundleMeta(name, json, 'keychain');
399
- if (bundle)
400
- out.push(bundle);
383
+ //
384
+ // SKIP this entirely when the keychain backend is routing to the encrypted
385
+ // file store (Linux headless / locked-collection fallback): there,
386
+ // listKeychainItems() returns the SAME items the file enumeration below
387
+ // reads, so running both would list every file-backed bundle twice — once
388
+ // mislabeled `keychain`, once correctly `[file]`. Under the fallback the
389
+ // file store is the single source of truth, so the block below covers all.
390
+ if (!keychainUsesFileFallback()) {
391
+ let keychainServices = [];
392
+ try {
393
+ keychainServices = listKeychainItems(BUNDLE_META_PREFIX);
394
+ }
395
+ catch {
396
+ keychainServices = [];
397
+ }
398
+ const keychainNames = keychainServices
399
+ .map((s) => s.slice(BUNDLE_META_PREFIX.length))
400
+ .filter((n) => BUNDLE_NAME_PATTERN.test(n));
401
+ if (keychainNames.length > 0) {
402
+ // Daily-policy fast-path (macOS). Bundle metadata items are biometry-gated,
403
+ // so the getKeychainTokens batch below pops Touch ID on every `secrets
404
+ // list` — the broker/`daily` mechanism only ever covered value reads, not
405
+ // this listing. Serve a broker-cached metadata snapshot when one is held,
406
+ // so only the first list per ~24h prompts. The cache key is a hash of the
407
+ // current keychain name-set (enumerated silently above): add / remove /
408
+ // rename a bundle and the key changes, so the stale snapshot is never
409
+ // served — no active invalidation needed. Values are never cached here;
410
+ // this is metadata only.
411
+ const useAgent = process.env.AGENTS_SECRETS_NO_AGENT !== '1' &&
412
+ !isKeychainBackendOverridden() &&
413
+ secretsAgentAutoEnabled();
414
+ const nameSetHash = createHash('sha256')
415
+ .update([...keychainNames].sort().join('\n'))
416
+ .digest('hex')
417
+ .slice(0, 32);
418
+ const cached = useAgent ? agentGetMetaSync(nameSetHash) : null;
419
+ if (cached) {
420
+ for (const bundle of cached)
421
+ out.push(bundle);
422
+ }
423
+ else {
424
+ const fetched = getKeychainTokens(keychainNames.map(bundleMetaItem));
425
+ const keychainBundles = [];
426
+ for (const name of keychainNames) {
427
+ const json = fetched.get(bundleMetaItem(name));
428
+ if (json === undefined)
429
+ continue;
430
+ const bundle = parseBundleMeta(name, json, 'keychain');
431
+ if (bundle)
432
+ keychainBundles.push(bundle);
433
+ }
434
+ for (const bundle of keychainBundles)
435
+ out.push(bundle);
436
+ // Populate the broker for the rest of the daily window (fire-and-forget).
437
+ if (useAgent && keychainBundles.length > 0) {
438
+ agentAutoLoadMetaSync(nameSetHash, keychainBundles, DEFAULT_TTL_MS);
439
+ }
440
+ }
401
441
  }
402
442
  }
403
443
  // File-backed bundles live in the encrypted-file store. Enumeration is a
@@ -20,11 +20,12 @@
20
20
  * item and its keychain twin carry identical names:
21
21
  * `agents-cli.bundles.<name>` and `agents-cli.secrets.<bundle>.<key>`.
22
22
  */
23
- import { execSync } from 'child_process';
23
+ import { execSync, spawnSync } from 'child_process';
24
24
  import { createCipheriv, createDecipheriv, randomBytes, scryptSync } from 'crypto';
25
25
  import * as fs from 'fs';
26
26
  import * as os from 'os';
27
27
  import * as path from 'path';
28
+ import { encodePwshBase64 } from '../pwsh.js';
28
29
  // ---------- file store location ----------
29
30
  let fileDirOverride = null;
30
31
  let cachedPassphrase = null;
@@ -36,7 +37,36 @@ function ensureFileDir() {
36
37
  fs.mkdirSync(fileDir(), { recursive: true, mode: 0o700 });
37
38
  }
38
39
  // ---------- passphrase ----------
40
+ /**
41
+ * Windows has no `/dev/tty` and no POSIX `stty`, so the interactive prompt runs
42
+ * through PowerShell's `Read-Host -AsSecureString` (which never echoes). The
43
+ * secure string is marshaled back out and written to stdout, which we capture.
44
+ * If PowerShell cannot run at all, fail with an actionable error rather than
45
+ * letting `fs.openSync('/dev/tty')` throw a raw ENOENT. Reached only on the rare
46
+ * interactive-Windows file-fallback path — the headless service-account case
47
+ * (no TTY) auto-provisions a machine-local key and never gets here.
48
+ */
49
+ function readPassphraseFromTtyWindows() {
50
+ const script = `
51
+ $ErrorActionPreference = 'Stop'
52
+ $sec = Read-Host -AsSecureString -Prompt 'Enter AGENTS_SECRETS_PASSPHRASE'
53
+ $ptr = [Runtime.InteropServices.Marshal]::SecureStringToBSTR($sec)
54
+ try { [Console]::Out.Write([Runtime.InteropServices.Marshal]::PtrToStringBSTR($ptr)) }
55
+ finally { [Runtime.InteropServices.Marshal]::ZeroFreeBSTR($ptr) }
56
+ `;
57
+ // Not -NonInteractive: that flag would suppress the Read-Host prompt itself.
58
+ const res = spawnSync('powershell.exe', ['-NoProfile', '-EncodedCommand', encodePwshBase64(script)], {
59
+ stdio: ['inherit', 'pipe', 'inherit'],
60
+ });
61
+ if (res.error || res.status !== 0) {
62
+ throw new Error('Could not prompt for a passphrase on Windows. Set AGENTS_SECRETS_PASSPHRASE ' +
63
+ 'to decrypt the file-backed secret store.');
64
+ }
65
+ return (res.stdout?.toString() ?? '').replace(/\r?\n$/, '');
66
+ }
39
67
  function readPassphraseFromTty() {
68
+ if (process.platform === 'win32')
69
+ return readPassphraseFromTtyWindows();
40
70
  const fd = fs.openSync('/dev/tty', 'r+');
41
71
  let echoDisabled = false;
42
72
  try {
@@ -12,7 +12,10 @@
12
12
  * Linux: libsecret (GNOME Keyring) via the `secret-tool` CLI. No biometry —
13
13
  * items are unlocked when the keyring is open.
14
14
  *
15
- * Windows: not supported.
15
+ * Windows: Windows Credential Manager (CRED_TYPE_GENERIC,
16
+ * CRED_PERSIST_LOCAL_MACHINE) via a PowerShell P/Invoke shim, with the same
17
+ * AES-256-GCM encrypted-file fallback used on Linux when the credential store
18
+ * is unreachable (no logon session / no powershell.exe). No biometry.
16
19
  *
17
20
  * Items are device-local: the biometry access control requires the OS to
18
21
  * treat them as bound to this device, so cross-machine propagation goes
@@ -42,6 +45,19 @@ export declare function parseBundleValue(raw: BundleValue): {
42
45
  };
43
46
  /** Serialize a secret ref back to its `provider:value` string form. */
44
47
  export declare function serializeRef(ref: SecretRef): string;
48
+ /**
49
+ * Guard a secret value before it is written to the current platform's primary
50
+ * backend.
51
+ *
52
+ * A value is empty on every platform → always rejected. Embedded newlines are
53
+ * rejected ONLY on darwin: the macOS batch read path (`get-batch`, see
54
+ * getKeychainTokens) is newline-delimited, so a value with a newline would
55
+ * corrupt record framing on read. Linux (secret-tool), Windows (Credential
56
+ * Manager stores the raw UTF-8 blob and emits base64), and the encrypted-file
57
+ * fallback all store raw bytes and round-trip multiline values (PEM / SSH keys)
58
+ * faithfully, so they accept newlines. `platform` is injectable for tests.
59
+ */
60
+ export declare function assertValueStorable(value: string, platform?: NodeJS.Platform): void;
45
61
  /** Build the keychain item name for a profile provider token. */
46
62
  export declare function profileKeychainItem(provider: string): string;
47
63
  /** Build the keychain item name for a secrets-bundle key. */
@@ -61,6 +77,11 @@ export interface KeychainBackend {
61
77
  }
62
78
  /** Install a custom keychain backend (test only). Returns the previous backend so callers can restore. */
63
79
  export declare function setKeychainBackendForTest(b: KeychainBackend | null): KeychainBackend | null;
80
+ /** True when a test backend is installed (real keychain / biometry bypassed).
81
+ * Callers that gate on the live secrets-agent broker use this to stay hermetic —
82
+ * with an in-memory backend there is no real keychain to dedup, so the broker
83
+ * fast-path must not engage. Always false in production (`backend` is null). */
84
+ export declare function isKeychainBackendOverridden(): boolean;
64
85
  /** Check if a keychain/keyring item exists. Never prompts for biometry. */
65
86
  export declare function hasKeychainToken(item: string): boolean;
66
87
  /**
@@ -86,8 +107,30 @@ export declare function getKeychainTokens(items: string[]): Map<string, string>;
86
107
  export declare function setKeychainToken(item: string, value: string): void;
87
108
  /** Delete a keychain/keyring item. Returns true if it existed. Never prompts for biometry. */
88
109
  export declare function deleteKeychainToken(item: string): boolean;
110
+ /**
111
+ * True when the active keychain backend transparently routes reads/writes to
112
+ * the encrypted-file store instead of the OS credential store. This only
113
+ * happens on Linux under the headless / locked-collection fallback
114
+ * (src/lib/secrets/linux.ts); macOS and the test backend always return false.
115
+ *
116
+ * Callers that ALSO enumerate the file store directly (e.g. `listBundles`)
117
+ * use this to avoid double-counting: under the fallback `listKeychainItems`
118
+ * and the direct file enumeration return the same items.
119
+ */
120
+ export declare function keychainUsesFileFallback(): boolean;
89
121
  /** Enumerate keychain/keyring item names starting with the given prefix. */
90
122
  export declare function listKeychainItems(prefix: string): string[];
123
+ /**
124
+ * Enumerate ONLY legacy file-based-keychain item names with the given prefix —
125
+ * the items that still carry a pre-migration (trusted-app) ACL and pop a
126
+ * separate auth sheet on read. Items already in the data-protection keychain are
127
+ * excluded (they need no migration). Silent (attributes only, never decrypts).
128
+ *
129
+ * macOS only: on Linux / the test backend there is no separate legacy keychain,
130
+ * so this returns []. Used by `agents secrets migrate-acl` to rewrite only the
131
+ * stragglers instead of every item (which would be a Touch ID storm).
132
+ */
133
+ export declare function listLegacyKeychainItems(prefix: string): string[];
91
134
  /**
92
135
  * One-time upgrade for a keychain item that was written by a previous helper
93
136
  * generation with a trusted-app ACL. The helper reads the legacy item
@@ -12,7 +12,10 @@
12
12
  * Linux: libsecret (GNOME Keyring) via the `secret-tool` CLI. No biometry —
13
13
  * items are unlocked when the keyring is open.
14
14
  *
15
- * Windows: not supported.
15
+ * Windows: Windows Credential Manager (CRED_TYPE_GENERIC,
16
+ * CRED_PERSIST_LOCAL_MACHINE) via a PowerShell P/Invoke shim, with the same
17
+ * AES-256-GCM encrypted-file fallback used on Linux when the credential store
18
+ * is unreachable (no logon session / no powershell.exe). No biometry.
16
19
  *
17
20
  * Items are device-local: the biometry access control requires the OS to
18
21
  * treat them as bound to this device, so cross-machine propagation goes
@@ -23,7 +26,8 @@ import { execFileSync, spawnSync } from 'child_process';
23
26
  import * as fs from 'fs';
24
27
  import * as os from 'os';
25
28
  import * as path from 'path';
26
- import { linuxBackend } from './linux.js';
29
+ import { linuxBackend, usesFileFallback as linuxUsesFileFallback } from './linux.js';
30
+ import { windowsBackend, usesFileFallback as windowsUsesFileFallback } from './windows.js';
27
31
  import { getKeychainHelperPath } from './install-helper.js';
28
32
  const SERVICE_PREFIX = 'agents-cli';
29
33
  const SECRETS_ITEM_PREFIX = `${SERVICE_PREFIX}.secrets.`;
@@ -47,15 +51,36 @@ export function serializeRef(ref) {
47
51
  return `${ref.provider}:${ref.value}`;
48
52
  }
49
53
  function assertSupportedPlatform() {
50
- if (process.platform !== 'darwin' && process.platform !== 'linux') {
51
- throw new Error('agents secrets requires macOS Keychain or Linux libsecret.\n' +
52
- 'Windows is not supported — use environment variables or a .env file instead.\n' +
53
- 'WSL2 is supported (libsecret via gnome-keyring).');
54
+ if (process.platform !== 'darwin' && process.platform !== 'linux' && process.platform !== 'win32') {
55
+ throw new Error('agents secrets requires macOS Keychain, Linux libsecret, or Windows Credential Manager.\n' +
56
+ 'Use environment variables or a .env file on unsupported platforms.');
54
57
  }
55
58
  }
56
59
  function isLinux() {
57
60
  return process.platform === 'linux';
58
61
  }
62
+ function isWindows() {
63
+ return process.platform === 'win32';
64
+ }
65
+ /**
66
+ * Guard a secret value before it is written to the current platform's primary
67
+ * backend.
68
+ *
69
+ * A value is empty on every platform → always rejected. Embedded newlines are
70
+ * rejected ONLY on darwin: the macOS batch read path (`get-batch`, see
71
+ * getKeychainTokens) is newline-delimited, so a value with a newline would
72
+ * corrupt record framing on read. Linux (secret-tool), Windows (Credential
73
+ * Manager stores the raw UTF-8 blob and emits base64), and the encrypted-file
74
+ * fallback all store raw bytes and round-trip multiline values (PEM / SSH keys)
75
+ * faithfully, so they accept newlines. `platform` is injectable for tests.
76
+ */
77
+ export function assertValueStorable(value, platform = process.platform) {
78
+ if (!value || !value.trim())
79
+ throw new Error('Secret value is empty.');
80
+ if (platform === 'darwin' && /[\r\n]/.test(value)) {
81
+ throw new Error('Secret value contains newlines, which are not supported.');
82
+ }
83
+ }
59
84
  /** Build the keychain item name for a profile provider token. */
60
85
  export function profileKeychainItem(provider) {
61
86
  return `${SERVICE_PREFIX}.${provider}.token`;
@@ -74,6 +99,13 @@ export function setKeychainBackendForTest(b) {
74
99
  backend = b;
75
100
  return prev;
76
101
  }
102
+ /** True when a test backend is installed (real keychain / biometry bypassed).
103
+ * Callers that gate on the live secrets-agent broker use this to stay hermetic —
104
+ * with an in-memory backend there is no real keychain to dedup, so the broker
105
+ * fast-path must not engage. Always false in production (`backend` is null). */
106
+ export function isKeychainBackendOverridden() {
107
+ return backend !== null;
108
+ }
77
109
  /**
78
110
  * Items whose name does NOT start with `agents-cli.` belong to another
79
111
  * application (e.g. Anthropic's `Claude Code-credentials-*`). Their ACL
@@ -93,6 +125,8 @@ export function hasKeychainToken(item) {
93
125
  assertSupportedPlatform();
94
126
  if (isLinux())
95
127
  return linuxBackend.has(item);
128
+ if (isWindows())
129
+ return windowsBackend.has(item);
96
130
  if (!isOurItem(item)) {
97
131
  return spawnSync('/usr/bin/security', ['find-generic-password', '-a', os.userInfo().username, '-s', item], {
98
132
  stdio: ['ignore', 'ignore', 'ignore'],
@@ -116,6 +150,8 @@ export function getKeychainToken(item) {
116
150
  assertSupportedPlatform();
117
151
  if (isLinux())
118
152
  return linuxBackend.get(item);
153
+ if (isWindows())
154
+ return windowsBackend.get(item);
119
155
  if (!isOurItem(item)) {
120
156
  const sec = spawnSync('/usr/bin/security', ['find-generic-password', '-a', os.userInfo().username, '-s', item, '-w'], {
121
157
  stdio: ['ignore', 'pipe', 'pipe'],
@@ -177,6 +213,15 @@ export function getKeychainTokens(items) {
177
213
  }
178
214
  return result;
179
215
  }
216
+ if (isWindows()) {
217
+ for (const item of items) {
218
+ try {
219
+ result.set(item, windowsBackend.get(item));
220
+ }
221
+ catch { /* missing — skip */ }
222
+ }
223
+ return result;
224
+ }
180
225
  const bin = getKeychainHelperPath();
181
226
  const child = spawnSync(bin, ['get-batch', os.userInfo().username, ...items], {
182
227
  stdio: ['ignore', 'pipe', 'pipe'],
@@ -226,16 +271,17 @@ export function setKeychainToken(item, value) {
226
271
  return;
227
272
  }
228
273
  assertSupportedPlatform();
229
- if (!value || !value.trim())
230
- throw new Error('Secret value is empty.');
231
- if (/[\r\n]/.test(value))
232
- throw new Error('Secret value contains newlines, which are not supported.');
274
+ assertValueStorable(value);
233
275
  if (/[\x00=\r\n]/.test(item))
234
276
  throw new Error('Secret item name contains invalid characters.');
235
277
  if (isLinux()) {
236
278
  linuxBackend.set(item, value);
237
279
  return;
238
280
  }
281
+ if (isWindows()) {
282
+ windowsBackend.set(item, value);
283
+ return;
284
+ }
239
285
  // Bare (non-`agents-cli.`) items are written WITHOUT the biometry ACL so
240
286
  // they round-trip with the no-prompt read path in getKeychainToken (which
241
287
  // also uses /usr/bin/security for non-our items). This is what lets a
@@ -273,11 +319,32 @@ export function deleteKeychainToken(item) {
273
319
  assertSupportedPlatform();
274
320
  if (isLinux())
275
321
  return linuxBackend.delete(item);
322
+ if (isWindows())
323
+ return windowsBackend.delete(item);
276
324
  const bin = getKeychainHelperPath();
277
325
  return spawnSync(bin, ['delete', item, os.userInfo().username], {
278
326
  stdio: ['ignore', 'pipe', 'pipe'],
279
327
  }).status === 0;
280
328
  }
329
+ /**
330
+ * True when the active keychain backend transparently routes reads/writes to
331
+ * the encrypted-file store instead of the OS credential store. This only
332
+ * happens on Linux under the headless / locked-collection fallback
333
+ * (src/lib/secrets/linux.ts); macOS and the test backend always return false.
334
+ *
335
+ * Callers that ALSO enumerate the file store directly (e.g. `listBundles`)
336
+ * use this to avoid double-counting: under the fallback `listKeychainItems`
337
+ * and the direct file enumeration return the same items.
338
+ */
339
+ export function keychainUsesFileFallback() {
340
+ if (backend)
341
+ return false;
342
+ if (isLinux())
343
+ return linuxUsesFileFallback();
344
+ if (isWindows())
345
+ return windowsUsesFileFallback();
346
+ return false;
347
+ }
281
348
  /** Enumerate keychain/keyring item names starting with the given prefix. */
282
349
  export function listKeychainItems(prefix) {
283
350
  if (backend)
@@ -285,6 +352,8 @@ export function listKeychainItems(prefix) {
285
352
  assertSupportedPlatform();
286
353
  if (isLinux())
287
354
  return linuxBackend.list(prefix);
355
+ if (isWindows())
356
+ return windowsBackend.list(prefix);
288
357
  const bin = getKeychainHelperPath();
289
358
  const result = spawnSync(bin, ['list', prefix], {
290
359
  stdio: ['ignore', 'pipe', 'pipe'],
@@ -296,6 +365,33 @@ export function listKeychainItems(prefix) {
296
365
  const out = result.stdout?.toString() || '';
297
366
  return out.split('\n').map((s) => s.trim()).filter(Boolean);
298
367
  }
368
+ /**
369
+ * Enumerate ONLY legacy file-based-keychain item names with the given prefix —
370
+ * the items that still carry a pre-migration (trusted-app) ACL and pop a
371
+ * separate auth sheet on read. Items already in the data-protection keychain are
372
+ * excluded (they need no migration). Silent (attributes only, never decrypts).
373
+ *
374
+ * macOS only: on Linux / the test backend there is no separate legacy keychain,
375
+ * so this returns []. Used by `agents secrets migrate-acl` to rewrite only the
376
+ * stragglers instead of every item (which would be a Touch ID storm).
377
+ */
378
+ export function listLegacyKeychainItems(prefix) {
379
+ if (backend)
380
+ return [];
381
+ assertSupportedPlatform();
382
+ if (isLinux())
383
+ return [];
384
+ const bin = getKeychainHelperPath();
385
+ const result = spawnSync(bin, ['list-legacy', prefix], {
386
+ stdio: ['ignore', 'pipe', 'pipe'],
387
+ });
388
+ if (result.status !== 0) {
389
+ const msg = result.stderr?.toString().trim();
390
+ throw new Error(msg || `Failed to enumerate legacy keychain items with prefix '${prefix}'.`);
391
+ }
392
+ const out = result.stdout?.toString() || '';
393
+ return out.split('\n').map((s) => s.trim()).filter(Boolean);
394
+ }
299
395
  /**
300
396
  * One-time upgrade for a keychain item that was written by a previous helper
301
397
  * generation with a trusted-app ACL. The helper reads the legacy item
@@ -310,6 +406,8 @@ export function migrateKeychainItem(item) {
310
406
  assertSupportedPlatform();
311
407
  if (isLinux())
312
408
  return linuxBackend.has(item);
409
+ if (isWindows())
410
+ return windowsBackend.has(item);
313
411
  const bin = getKeychainHelperPath();
314
412
  const result = spawnSync(bin, ['migrate-acl', item, os.userInfo().username], {
315
413
  stdio: ['ignore', 'pipe', 'pipe'],
@@ -18,6 +18,20 @@
18
18
  */
19
19
  import type { KeychainBackend } from './index.js';
20
20
  export { encryptForFallback, decryptForFallback, fileBackend, type EncFile, } from './filestore.js';
21
+ /**
22
+ * True when secret operations currently route to the encrypted-file store
23
+ * instead of the Secret Service (the headless / locked-collection fallback).
24
+ *
25
+ * Runs the same `preflight()` decision every read and write uses, so it can't
26
+ * drift from where bytes actually land. `preflight()` throws only in the
27
+ * interactive / no-secret-tool / no-passphrase case — where nothing is stored
28
+ * — so treat that as "not on the file path".
29
+ *
30
+ * `listBundles()` needs this: under the fallback the keychain enumeration
31
+ * (`linuxBackend.list`) and the file enumeration read the SAME store, so
32
+ * without this signal every file-backed bundle would be listed twice.
33
+ */
34
+ export declare function usesFileFallback(): boolean;
21
35
  /** secret-tool lookup attributes:
22
36
  * service=agents-cli account=<user> item=<itemName> */
23
37
  export declare function hasSecretToolToken(item: string): boolean;
@@ -86,6 +86,27 @@ function preflight() {
86
86
  }
87
87
  return 'secret-tool';
88
88
  }
89
+ /**
90
+ * True when secret operations currently route to the encrypted-file store
91
+ * instead of the Secret Service (the headless / locked-collection fallback).
92
+ *
93
+ * Runs the same `preflight()` decision every read and write uses, so it can't
94
+ * drift from where bytes actually land. `preflight()` throws only in the
95
+ * interactive / no-secret-tool / no-passphrase case — where nothing is stored
96
+ * — so treat that as "not on the file path".
97
+ *
98
+ * `listBundles()` needs this: under the fallback the keychain enumeration
99
+ * (`linuxBackend.list`) and the file enumeration read the SAME store, so
100
+ * without this signal every file-backed bundle would be listed twice.
101
+ */
102
+ export function usesFileFallback() {
103
+ try {
104
+ return preflight() === 'file';
105
+ }
106
+ catch {
107
+ return false;
108
+ }
109
+ }
89
110
  // ---------- secret-tool ops with fallback ----------
90
111
  /** secret-tool lookup attributes:
91
112
  * service=agents-cli account=<user> item=<itemName> */