@phnx-labs/agents-cli 1.20.70 → 1.20.72

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 (80) hide show
  1. package/CHANGELOG.md +85 -0
  2. package/dist/bin/agents +0 -0
  3. package/dist/commands/apply.js +36 -3
  4. package/dist/commands/check.js +3 -1
  5. package/dist/commands/commands.js +2 -0
  6. package/dist/commands/exec.js +12 -0
  7. package/dist/commands/fleet-capture.d.ts +14 -0
  8. package/dist/commands/fleet-capture.js +107 -0
  9. package/dist/commands/fork.js +13 -7
  10. package/dist/commands/hosts.js +11 -0
  11. package/dist/commands/logs.js +38 -9
  12. package/dist/commands/mcp.js +2 -0
  13. package/dist/commands/resource-view.d.ts +2 -0
  14. package/dist/commands/resource-view.js +8 -0
  15. package/dist/commands/secrets.d.ts +1 -0
  16. package/dist/commands/secrets.js +24 -4
  17. package/dist/commands/sessions-tail.js +1 -2
  18. package/dist/commands/sessions.d.ts +6 -0
  19. package/dist/commands/sessions.js +14 -6
  20. package/dist/commands/skills.js +2 -0
  21. package/dist/commands/ssh.js +24 -6
  22. package/dist/commands/status.js +8 -2
  23. package/dist/commands/subagents.js +3 -1
  24. package/dist/commands/uninstall.d.ts +11 -0
  25. package/dist/commands/uninstall.js +158 -0
  26. package/dist/commands/usage.d.ts +13 -0
  27. package/dist/commands/usage.js +50 -28
  28. package/dist/commands/utils.d.ts +29 -0
  29. package/dist/commands/utils.js +24 -0
  30. package/dist/commands/versions.js +120 -11
  31. package/dist/index.js +5 -3
  32. package/dist/lib/commands.d.ts +10 -0
  33. package/dist/lib/commands.js +31 -7
  34. package/dist/lib/daemon.d.ts +9 -0
  35. package/dist/lib/daemon.js +43 -0
  36. package/dist/lib/devices/connect.d.ts +13 -0
  37. package/dist/lib/devices/connect.js +20 -0
  38. package/dist/lib/devices/fleet.d.ts +36 -3
  39. package/dist/lib/devices/fleet.js +42 -4
  40. package/dist/lib/devices/sync.d.ts +30 -0
  41. package/dist/lib/devices/sync.js +50 -0
  42. package/dist/lib/doctor-diff.js +38 -16
  43. package/dist/lib/fleet/apply.d.ts +4 -0
  44. package/dist/lib/fleet/apply.js +28 -5
  45. package/dist/lib/fleet/capture.d.ts +35 -0
  46. package/dist/lib/fleet/capture.js +51 -0
  47. package/dist/lib/fleet/manifest.d.ts +6 -0
  48. package/dist/lib/fleet/manifest.js +24 -1
  49. package/dist/lib/fleet/types.d.ts +24 -1
  50. package/dist/lib/git.d.ts +14 -0
  51. package/dist/lib/git.js +39 -1
  52. package/dist/lib/hosts/logs.d.ts +12 -0
  53. package/dist/lib/hosts/logs.js +18 -0
  54. package/dist/lib/menubar/install-menubar.d.ts +12 -0
  55. package/dist/lib/menubar/install-menubar.js +26 -13
  56. package/dist/lib/secrets/agent.d.ts +5 -0
  57. package/dist/lib/secrets/agent.js +35 -3
  58. package/dist/lib/secrets/bundles.js +28 -0
  59. package/dist/lib/secrets/index.d.ts +14 -0
  60. package/dist/lib/secrets/index.js +36 -5
  61. package/dist/lib/secrets/session-store.d.ts +90 -0
  62. package/dist/lib/secrets/session-store.js +221 -0
  63. package/dist/lib/session/render.d.ts +9 -1
  64. package/dist/lib/session/render.js +35 -4
  65. package/dist/lib/share/capture.d.ts +2 -0
  66. package/dist/lib/share/capture.js +12 -5
  67. package/dist/lib/shims.d.ts +27 -0
  68. package/dist/lib/shims.js +29 -3
  69. package/dist/lib/skills.d.ts +8 -1
  70. package/dist/lib/skills.js +11 -1
  71. package/dist/lib/startup/command-registry.d.ts +1 -0
  72. package/dist/lib/startup/command-registry.js +2 -0
  73. package/dist/lib/types.d.ts +5 -1
  74. package/dist/lib/uninstall.d.ts +84 -0
  75. package/dist/lib/uninstall.js +347 -0
  76. package/dist/lib/usage.d.ts +13 -1
  77. package/dist/lib/usage.js +32 -14
  78. package/dist/lib/versions.d.ts +16 -0
  79. package/dist/lib/versions.js +40 -3
  80. package/package.json +1 -1
@@ -54,6 +54,24 @@ export async function showHostTaskLog(id, follow, full = false) {
54
54
  process.stdout.write(full ? raw : tailLines(raw, HOST_LOG_TAIL_LINES));
55
55
  return { found: true, exitCode: 0 };
56
56
  }
57
+ /**
58
+ * Machine-readable form of a host-dispatch task's log — the task record plus its
59
+ * combined stdout. Powers `agents logs <id> --json` for the host-task branch.
60
+ * Reconciles a still-'running' record from the remote `.exit` first, like the
61
+ * text path does.
62
+ */
63
+ export function hostTaskLogJson(id) {
64
+ const task = loadTask(id);
65
+ if (!task)
66
+ return { found: false };
67
+ // reconcileTask returns the healed record; it does NOT mutate its argument in
68
+ // place. Emit the reconciled task so a run that finished between dispatch and
69
+ // this one-shot read surfaces its terminal status/exitCode, not a stale
70
+ // 'running'. (The text path can discard the return — it only reads the log by
71
+ // id — but this JSON payload carries task.status straight to the consumer.)
72
+ const reconciled = reconcileTask(task);
73
+ return { found: true, task: reconciled, log: readTaskLog(reconciled) };
74
+ }
57
75
  /** Read the task's combined-stdout — local mirror first, else fetch+cache remote. */
58
76
  function readTaskLog(task) {
59
77
  try {
@@ -26,6 +26,18 @@ export declare function menubarServiceInstalled(): boolean;
26
26
  export declare function ensureMenubarAppInstalled(opts?: {
27
27
  forceReinstall?: boolean;
28
28
  }): string | null;
29
+ /**
30
+ * Restart the menu-bar launchd agent from a clean state.
31
+ *
32
+ * Always `bootout` first: on modern macOS `bootstrap` fails when the job is
33
+ * already bootstrapped, and the deprecated `load -w` fallback is unreliable.
34
+ * A prior WindowServer disconnect can leave the job throttled so that a plain
35
+ * `kickstart -k` does not bring it back; booting it out and bootstrapping fresh
36
+ * is the only sequence that reliably re-attaches the status item.
37
+ */
38
+ export declare function restartMenubarLaunchAgent(uid: number, plist: string, exec?: (cmd: string, args: readonly string[], opts: {
39
+ stdio: ['ignore', 'ignore', 'ignore'];
40
+ }) => Buffer): void;
29
41
  /**
30
42
  * Install + start the menu-bar helper as a launchd user service (idempotent).
31
43
  * Clears the sticky opt-out, installs the .app, writes the plist, and
@@ -210,6 +210,31 @@ ${envXml}
210
210
  </dict>
211
211
  </plist>`;
212
212
  }
213
+ /**
214
+ * Restart the menu-bar launchd agent from a clean state.
215
+ *
216
+ * Always `bootout` first: on modern macOS `bootstrap` fails when the job is
217
+ * already bootstrapped, and the deprecated `load -w` fallback is unreliable.
218
+ * A prior WindowServer disconnect can leave the job throttled so that a plain
219
+ * `kickstart -k` does not bring it back; booting it out and bootstrapping fresh
220
+ * is the only sequence that reliably re-attaches the status item.
221
+ */
222
+ export function restartMenubarLaunchAgent(uid, plist, exec = execFileSync) {
223
+ const serviceTarget = `gui/${uid}/${SERVICE_LABEL}`;
224
+ const opts = { stdio: ['ignore', 'ignore', 'ignore'] };
225
+ try {
226
+ exec('launchctl', ['bootout', serviceTarget], opts);
227
+ }
228
+ catch { /* may not be loaded */ }
229
+ try {
230
+ exec('launchctl', ['bootstrap', `gui/${uid}`, plist], opts);
231
+ }
232
+ catch { /* best effort */ }
233
+ try {
234
+ exec('launchctl', ['kickstart', serviceTarget], opts);
235
+ }
236
+ catch { /* best effort */ }
237
+ }
213
238
  /**
214
239
  * Install + start the menu-bar helper as a launchd user service (idempotent).
215
240
  * Clears the sticky opt-out, installs the .app, writes the plist, and
@@ -232,19 +257,7 @@ export function enableMenubarService(opts = { clearOptOut: true }) {
232
257
  fs.mkdirSync(path.dirname(plist), { recursive: true });
233
258
  fs.writeFileSync(plist, generateServicePlist(exec));
234
259
  const uid = process.getuid?.() ?? 0;
235
- try {
236
- execFileSync('launchctl', ['bootstrap', `gui/${uid}`, plist], { stdio: ['ignore', 'ignore', 'ignore'] });
237
- }
238
- catch {
239
- try {
240
- execFileSync('launchctl', ['load', '-w', plist], { stdio: ['ignore', 'ignore', 'ignore'] });
241
- }
242
- catch { /* may already be loaded */ }
243
- }
244
- try {
245
- execFileSync('launchctl', ['kickstart', '-k', `gui/${uid}/${SERVICE_LABEL}`], { stdio: ['ignore', 'ignore', 'ignore'] });
246
- }
247
- catch { /* best effort */ }
260
+ restartMenubarLaunchAgent(uid, plist);
248
261
  // Stamp the version we just installed so the upgrade self-heal can tell when
249
262
  // a later release ships a newer helper that needs reinstalling.
250
263
  try {
@@ -280,6 +280,11 @@ export declare function agentAutoLoadMetaSync(nameSetHash: string, bundles: Secr
280
280
  * per ~7d), so auto-caching is ON by default; opt out with
281
281
  * `secrets.agent.auto: false`. Best-effort; an unreadable meta reads as on. */
282
282
  export declare function secretsAgentAutoEnabled(): boolean;
283
+ /** Default `sleepPersist` for a new unlock when `--durable` is not passed. OFF by
284
+ * default (the secure split default: survive restart, re-lock on sleep); set
285
+ * `secrets.agent.durable: true` in agents.yaml to make every unlock sleep-durable.
286
+ * Best-effort; an unreadable meta reads as off. */
287
+ export declare function secretsAgentDurable(): boolean;
283
288
  /** Minimum / maximum bounds for the configurable hold window. A too-small value
284
289
  * would defeat the broker (constant re-prompts); a too-large one pins secrets in
285
290
  * memory far longer than intended. */
@@ -34,6 +34,7 @@ import { isAlive } from '../platform/process.js';
34
34
  import { getKeychainHelperPath } from './install-helper.js';
35
35
  import { getCliVersion, getCliVersionFresh } from '../version.js';
36
36
  import { getCliLaunch } from '../cli-entry.js';
37
+ import { rehydrateSessions, pruneSessionsOnSleep } from './session-store.js';
37
38
  /** Bumped when the wire protocol changes; a client that pings a mismatched
38
39
  * server kills and respawns it rather than talking a stale dialect. */
39
40
  const PROTOCOL_VERSION = 1;
@@ -93,6 +94,20 @@ export function shouldTeardownVersionSkewedBroker(realHeldBundles) {
93
94
  function onDarwin() {
94
95
  return process.platform === 'darwin';
95
96
  }
97
+ /**
98
+ * Build the broker's in-memory store, REHYDRATED from the durable session store
99
+ * (session-store.ts) so an unlock survives a daemon restart / agents-cli upgrade.
100
+ * Expired sessions are dropped; `--durable` and default entries alike come back
101
+ * (SLEEP already pruned the non-durable ones from the keychain while the broker
102
+ * was alive). Empty off darwin or when nothing was held.
103
+ */
104
+ function rehydrateStore(now = Date.now()) {
105
+ const store = new Map();
106
+ for (const { name, entry } of rehydrateSessions(now)) {
107
+ store.set(name, { bundle: entry.bundle, env: entry.env, expiresAt: entry.expiresAt });
108
+ }
109
+ return store;
110
+ }
96
111
  /** Broker runtime dir under the regenerable cache, locked to the user (0700).
97
112
  * AGENTS_SECRETS_AGENT_DIR overrides the location — a test seam so the suite can
98
113
  * run a real broker on a temp socket without touching the user's real dir. */
@@ -442,7 +457,7 @@ export async function runSecretsAgent(opts = {}) {
442
457
  throw err;
443
458
  }
444
459
  }
445
- const store = new Map();
460
+ const store = rehydrateStore();
446
461
  // emptySince tracks the last moment the store held something; the sweep exits
447
462
  // the process once it's been empty for IDLE_EXIT_MS so no idle broker lingers.
448
463
  let emptySince = Date.now();
@@ -589,6 +604,9 @@ export async function runSecretsAgent(opts = {}) {
589
604
  if (shouldWipeOnWatchEvent(chunk)) {
590
605
  store.clear();
591
606
  emptySince = Date.now();
607
+ // Split default: delete non-`--durable` durable sessions too, so a default
608
+ // unlock re-locks on sleep; `--durable` ones survive (session-store.ts).
609
+ pruneSessionsOnSleep();
592
610
  }
593
611
  });
594
612
  watcher.on('error', () => { watcher = null; });
@@ -627,7 +645,7 @@ export async function runSecretsAgent(opts = {}) {
627
645
  export async function startHostedBroker() {
628
646
  if (!onDarwin())
629
647
  return null;
630
- const store = new Map();
648
+ const store = rehydrateStore();
631
649
  const sock = socketPath(); // agentDir() creates the 0700 dir as a side effect
632
650
  const handle = (req) => handleAgentRequest(store, req);
633
651
  const onConn = makeConnectionHandler(handle, readAgentToken);
@@ -651,8 +669,10 @@ export async function startHostedBroker() {
651
669
  watcher = spawn(getKeychainHelperPath(), ['watch-lock'], { stdio: ['ignore', 'pipe', 'ignore'] });
652
670
  watcher.stdout?.setEncoding('utf-8');
653
671
  watcher.stdout?.on('data', (chunk) => {
654
- if (shouldWipeOnWatchEvent(chunk))
672
+ if (shouldWipeOnWatchEvent(chunk)) {
655
673
  store.clear();
674
+ pruneSessionsOnSleep(); // split default — see runSecretsAgent's handler
675
+ }
656
676
  });
657
677
  watcher.on('error', () => { watcher = null; });
658
678
  }
@@ -912,6 +932,18 @@ export function secretsAgentAutoEnabled() {
912
932
  return true;
913
933
  }
914
934
  }
935
+ /** Default `sleepPersist` for a new unlock when `--durable` is not passed. OFF by
936
+ * default (the secure split default: survive restart, re-lock on sleep); set
937
+ * `secrets.agent.durable: true` in agents.yaml to make every unlock sleep-durable.
938
+ * Best-effort; an unreadable meta reads as off. */
939
+ export function secretsAgentDurable() {
940
+ try {
941
+ return readMeta().secrets?.agent?.durable === true;
942
+ }
943
+ catch {
944
+ return false;
945
+ }
946
+ }
915
947
  /** Minimum / maximum bounds for the configurable hold window. A too-small value
916
948
  * would defeat the broker (constant re-prompts); a too-large one pins secrets in
917
949
  * memory far longer than intended. */
@@ -26,6 +26,7 @@ import { fileStore } from './filestore.js';
26
26
  import { emit } from '../events.js';
27
27
  import { readMeta } from '../state.js';
28
28
  import { agentGetSync, agentAutoLoadSync, agentGetMetaSync, agentAutoLoadMetaSync, agentEvictSync, secretsAgentAutoEnabled, secretsHoldMs } from './agent.js';
29
+ import { loadSession, deleteSession } from './session-store.js';
29
30
  import { createHash } from 'node:crypto';
30
31
  const keychainStore = {
31
32
  has: hasKeychainToken,
@@ -362,6 +363,9 @@ export function writeBundle(bundle, opts = {}) {
362
363
  // re-resolves from the keychain instead of serving stale values.
363
364
  if (shouldEvictAfterBundleWrite(Boolean(opts.skipBrokerEviction), process.env.AGENTS_SECRETS_NO_AGENT, isKeychainBackendOverridden())) {
364
365
  agentEvictSync(bundle.name);
366
+ // Also drop any durable session snapshot, or a broker restart would rehydrate
367
+ // the stale env after a rotate/rename (session-store.ts).
368
+ deleteSession(bundle.name);
365
369
  }
366
370
  }
367
371
  export function deleteBundle(name) {
@@ -371,6 +375,7 @@ export function deleteBundle(name) {
371
375
  emit('secrets.delete', { module: 'secrets', bundle: name });
372
376
  if (shouldEvictAfterBundleWrite(false, process.env.AGENTS_SECRETS_NO_AGENT, isKeychainBackendOverridden())) {
373
377
  agentEvictSync(name);
378
+ deleteSession(name); // a deleted bundle must not be rehydratable
374
379
  }
375
380
  }
376
381
  return deleted;
@@ -787,6 +792,29 @@ export function readAndResolveBundleEnv(name, opts = {}) {
787
792
  });
788
793
  return filtered;
789
794
  }
795
+ // Durable-session fallback (Correction B). After a daemon restart / agents-cli
796
+ // upgrade the broker RAM is empty, so the fast-path above misses — but the
797
+ // unlock persisted a no-ACL session item (session-store.ts) that reads with NO
798
+ // Touch ID. Serve from it and re-warm the broker, so a warm bundle stays warm
799
+ // across restart — this fixes BOTH the interactive re-prompt and the headless
800
+ // throw below (which now fires only when there is genuinely no session).
801
+ const session = loadSession(name);
802
+ if (session) {
803
+ const filtered = filterAgentHitBySubsetAndExpiry({ bundle: session.bundle, env: session.env }, opts);
804
+ stampLastUsed(filtered.bundle);
805
+ // Re-warm the broker with the remaining TTL so later reads hit RAM and
806
+ // `agents secrets status` is honest. Best-effort; no-ops off darwin.
807
+ agentAutoLoadSync(name, session.bundle, session.env, Math.max(1, session.expiresAt - Date.now()));
808
+ emit('secrets.get', {
809
+ module: 'secrets',
810
+ bundle: name,
811
+ operation: opts.caller,
812
+ status: 'success',
813
+ source: 'session',
814
+ keyCount: Object.keys(filtered.env).length,
815
+ });
816
+ return filtered;
817
+ }
790
818
  }
791
819
  // Only keychain-backed bundles can pop a Touch ID prompt and are the only ones
792
820
  // the broker ever holds. A file-backed bundle resolves via passphrase with no
@@ -22,6 +22,7 @@
22
22
  * through the explicit export/import flow in src/lib/secrets/sync.ts
23
23
  * rather than the system's cloud-keychain path.
24
24
  */
25
+ import { type SpawnSyncOptions } from 'child_process';
25
26
  import type { NativeImportReport } from './fallback.js';
26
27
  export type { NativeImportReport, NativeImportResult, NativeImportStatus } from './fallback.js';
27
28
  export declare const SECRETS_ITEM_PREFIX = "agents-cli.secrets.";
@@ -221,6 +222,19 @@ export declare function getKeychainTokens(items: string[]): Map<string, string>;
221
222
  * a `ps` snapshot. Exported so a test can assert the value is absent from argv.
222
223
  */
223
224
  export declare function buildAddGenericPasswordArgs(account: string, item: string): string[];
225
+ /**
226
+ * spawnSync options for the bare `-w` keychain write. Pure so the two
227
+ * load-bearing properties are unit-testable without touching the real keychain:
228
+ * - `input` pipes the value TWICE (bare `-w` prompts enter+confirm; one line
229
+ * fails the confirm and stores an empty secret).
230
+ * - `detached: true` runs `security` in a new session with no controlling
231
+ * terminal, so readpassphrase(3) falls back to our piped stdin instead of
232
+ * prompting the user's `/dev/tty` in an interactive shell (see setKeychainToken).
233
+ */
234
+ export declare function buildAddGenericPasswordSpawnOptions(value: string): SpawnSyncOptions & {
235
+ input: string;
236
+ detached: boolean;
237
+ };
224
238
  export declare function setKeychainToken(item: string, value: string, opts?: {
225
239
  noAcl?: boolean;
226
240
  }): void;
@@ -506,6 +506,11 @@ export function computeRekeyPlan(services, values, key) {
506
506
  if (dot > 0)
507
507
  noAcl = bundleNoAcl(rest.slice(0, dot));
508
508
  }
509
+ // Durable "unlocked session" items (agents-cli.session.*, see session-store.ts)
510
+ // are always no-ACL — re-wrapping them in a biometry ACL on rekey would break
511
+ // their silent (no-Touch-ID) reads that the rehydrate/fallback paths depend on.
512
+ if (service.startsWith('agents-cli.session.'))
513
+ noAcl = true;
509
514
  items.push({ oldService: service, newService: hashedServiceName(service, key), noAcl });
510
515
  }
511
516
  return { items, unreadable };
@@ -853,6 +858,25 @@ function parseBatchRecords(out, record) {
853
858
  export function buildAddGenericPasswordArgs(account, item) {
854
859
  return ['add-generic-password', '-U', '-a', account, '-s', item, '-w'];
855
860
  }
861
+ /**
862
+ * spawnSync options for the bare `-w` keychain write. Pure so the two
863
+ * load-bearing properties are unit-testable without touching the real keychain:
864
+ * - `input` pipes the value TWICE (bare `-w` prompts enter+confirm; one line
865
+ * fails the confirm and stores an empty secret).
866
+ * - `detached: true` runs `security` in a new session with no controlling
867
+ * terminal, so readpassphrase(3) falls back to our piped stdin instead of
868
+ * prompting the user's `/dev/tty` in an interactive shell (see setKeychainToken).
869
+ */
870
+ export function buildAddGenericPasswordSpawnOptions(value) {
871
+ // `detached` is honored by spawnSync at runtime (libuv setsid) but is not
872
+ // declared on Node's SpawnSyncOptions type, so widen the return explicitly.
873
+ return {
874
+ input: `${value}\n${value}\n`,
875
+ stdio: ['pipe', 'pipe', 'pipe'],
876
+ timeout: 10_000,
877
+ detached: true,
878
+ };
879
+ }
856
880
  export function setKeychainToken(item, value, opts) {
857
881
  // Validate the CLEARTEXT name (a hashed storage name is always clean), then
858
882
  // resolve the storage name.
@@ -893,11 +917,18 @@ export function setKeychainToken(item, value, opts) {
893
917
  // (assertValueStorable above), so each line carries the whole secret verbatim
894
918
  // (no shell/quoting layer). `timeout` bounds the call so a context that cannot
895
919
  // read the prompt fails loudly instead of hanging.
896
- const sec = spawnSync('/usr/bin/security', buildAddGenericPasswordArgs(os.userInfo().username, item), {
897
- input: `${value}\n${value}\n`,
898
- stdio: ['pipe', 'pipe', 'pipe'],
899
- timeout: 10_000,
900
- });
920
+ //
921
+ // `detached: true` is load-bearing, not an afterthought: readpassphrase(3)
922
+ // reads from the *controlling terminal* (`/dev/tty`) when one exists, and only
923
+ // falls back to fd 0 when it cannot open one. So piping over stdin works in a
924
+ // headless/CI context (no controlling tty) but is IGNORED in an interactive
925
+ // shell — there `security` prompts the real user ("password data for new
926
+ // item:") and hangs to the timeout, and any keystroke would be stored AS the
927
+ // secret. `detached` runs the child in a new session (setsid) with no
928
+ // controlling terminal, so readpassphrase always falls back to our piped
929
+ // stdin. Without it, an interactive `agents view` (refreshing+saving a Claude
930
+ // OAuth token) or `agents secrets add` pops a keychain password sheet.
931
+ const sec = spawnSync('/usr/bin/security', buildAddGenericPasswordArgs(os.userInfo().username, item), buildAddGenericPasswordSpawnOptions(value));
901
932
  if (sec.status !== 0) {
902
933
  const msg = sec.stderr?.toString().trim();
903
934
  throw new Error(msg || `Failed to write keychain item '${item}'.`);
@@ -0,0 +1,90 @@
1
+ /**
2
+ * Durable "unlocked session" store (macOS).
3
+ *
4
+ * The secrets broker (agent.ts) holds unlocked bundles only in RAM, so an
5
+ * `agents secrets unlock` grant is lost whenever that process dies: on an
6
+ * agents-cli upgrade (the postinstall bounces the daemon) and on system SLEEP.
7
+ * This module persists the resolved bundle+env of an unlocked bundle as a
8
+ * NO-ACL keychain item — the `set-no-acl` write path, `kSecAttrAccessibleAfter-
9
+ * FirstUnlockThisDeviceOnly`, whose reads never pop Touch ID — so the broker can
10
+ * REHYDRATE it on start and reads can FALL BACK to it when the broker RAM misses.
11
+ *
12
+ * Split-default posture: `sleepPersist` is false by default — the item is deleted
13
+ * on SLEEP so the bundle re-locks on sleep but survives restart/upgrade; the
14
+ * `--durable` flag sets it true so it survives sleep too. Both expire at the TTL.
15
+ *
16
+ * Cross-platform: a NO-OP off macOS. Linux (secret-service) and Windows
17
+ * (Credential Manager) have no broker and resolve secrets durably from the OS
18
+ * store on every read with no prompt (see agent.ts:onDarwin gates) — there is no
19
+ * ephemeral unlock to persist.
20
+ *
21
+ * NEVER enumerate session items: once keychain-name hashing (#316) is active a
22
+ * `list('agents-cli.session.')` matches nothing. All I/O is by KNOWN name — one
23
+ * fixed-name index item lists the held bundles + their metadata, and one blob per
24
+ * bundle holds the env. Every adapter call is best-effort and never throws;
25
+ * persistence is an optimization, not a correctness dependency.
26
+ */
27
+ import type { SecretsBundle } from './bundles.js';
28
+ /** Prefix for all durable session items (device-local, no-ACL). */
29
+ export declare const SESSION_ITEM_PREFIX = "agents-cli.session.";
30
+ /** Fixed-name index item — the ONLY thing we ever need to find without a known
31
+ * bundle name, so it is read/written by this exact name (never enumerated). */
32
+ export declare const SESSION_INDEX_ITEM = "agents-cli.session.index";
33
+ /** One persisted unlocked bundle (mirrors the broker's StoredBundle + posture). */
34
+ export interface SessionEntry {
35
+ bundle: SecretsBundle;
36
+ env: Record<string, string>;
37
+ /** epoch ms; the entry is dead once Date.now() passes this. */
38
+ expiresAt: number;
39
+ /** true only for `--durable` unlocks — survives SLEEP. */
40
+ sleepPersist: boolean;
41
+ }
42
+ /** Metadata for one held bundle, kept in the index so we can rehydrate / prune
43
+ * without reading every blob. */
44
+ export interface SessionIndexMeta {
45
+ expiresAt: number;
46
+ sleepPersist: boolean;
47
+ }
48
+ export interface SessionIndex {
49
+ bundles: Record<string, SessionIndexMeta>;
50
+ }
51
+ /** The bundle names in the index that are still within their TTL at `now`. */
52
+ export declare function selectRehydratable(index: SessionIndex, now: number): string[];
53
+ /** Split an index on a SLEEP event: keep `sleepPersist` entries, report the rest
54
+ * (which the caller deletes from the keychain). Default entries re-lock on sleep. */
55
+ export declare function pruneOnSleep(index: SessionIndex): {
56
+ survivors: SessionIndex;
57
+ deletedNames: string[];
58
+ };
59
+ /** Drop entries past their TTL; report which were dropped so the caller can
60
+ * delete their blobs. */
61
+ export declare function pruneExpired(index: SessionIndex, now: number): {
62
+ survivors: SessionIndex;
63
+ expiredNames: string[];
64
+ };
65
+ /** Insert/replace one bundle in the index (pure). */
66
+ export declare function upsertEntry(index: SessionIndex, name: string, meta: SessionIndexMeta): SessionIndex;
67
+ /** Remove one bundle from the index (pure). */
68
+ export declare function removeEntry(index: SessionIndex, name: string): SessionIndex;
69
+ /** Read the session index by its fixed name. `{bundles:{}}` when absent/unreadable. */
70
+ export declare function readIndex(): SessionIndex;
71
+ /** Persist the index as a no-ACL item. Deletes the item when empty (tidy). */
72
+ export declare function writeIndex(index: SessionIndex): void;
73
+ /** Persist one unlocked bundle: write its blob no-ACL and record it in the index. */
74
+ export declare function saveSession(name: string, entry: SessionEntry): void;
75
+ /** Read one session blob by known name. Null when absent/expired/malformed. */
76
+ export declare function loadSession(name: string, now?: number): SessionEntry | null;
77
+ /** Delete one bundle's session blob and prune it from the index. */
78
+ export declare function deleteSession(name: string): void;
79
+ /** Delete every session blob + the index (for `secrets lock --all`). */
80
+ export declare function deleteAllSessions(): void;
81
+ /** Rehydrate every unexpired session into `[name, entry]` pairs for the broker to
82
+ * load on start; prunes expired index entries + blobs as a side effect. Survives
83
+ * upgrade/restart. Empty off darwin or when nothing is held. */
84
+ export declare function rehydrateSessions(now?: number): Array<{
85
+ name: string;
86
+ entry: SessionEntry;
87
+ }>;
88
+ /** On a SLEEP event: delete every non-`--durable` session (blob + index entry),
89
+ * keep the durable ones. Default bundles thus re-lock on sleep. */
90
+ export declare function pruneSessionsOnSleep(): void;
@@ -0,0 +1,221 @@
1
+ /**
2
+ * Durable "unlocked session" store (macOS).
3
+ *
4
+ * The secrets broker (agent.ts) holds unlocked bundles only in RAM, so an
5
+ * `agents secrets unlock` grant is lost whenever that process dies: on an
6
+ * agents-cli upgrade (the postinstall bounces the daemon) and on system SLEEP.
7
+ * This module persists the resolved bundle+env of an unlocked bundle as a
8
+ * NO-ACL keychain item — the `set-no-acl` write path, `kSecAttrAccessibleAfter-
9
+ * FirstUnlockThisDeviceOnly`, whose reads never pop Touch ID — so the broker can
10
+ * REHYDRATE it on start and reads can FALL BACK to it when the broker RAM misses.
11
+ *
12
+ * Split-default posture: `sleepPersist` is false by default — the item is deleted
13
+ * on SLEEP so the bundle re-locks on sleep but survives restart/upgrade; the
14
+ * `--durable` flag sets it true so it survives sleep too. Both expire at the TTL.
15
+ *
16
+ * Cross-platform: a NO-OP off macOS. Linux (secret-service) and Windows
17
+ * (Credential Manager) have no broker and resolve secrets durably from the OS
18
+ * store on every read with no prompt (see agent.ts:onDarwin gates) — there is no
19
+ * ephemeral unlock to persist.
20
+ *
21
+ * NEVER enumerate session items: once keychain-name hashing (#316) is active a
22
+ * `list('agents-cli.session.')` matches nothing. All I/O is by KNOWN name — one
23
+ * fixed-name index item lists the held bundles + their metadata, and one blob per
24
+ * bundle holds the env. Every adapter call is best-effort and never throws;
25
+ * persistence is an optimization, not a correctness dependency.
26
+ */
27
+ import { getKeychainToken, setKeychainToken, deleteKeychainToken, isKeychainBackendOverridden } from './index.js';
28
+ /** Prefix for all durable session items (device-local, no-ACL). */
29
+ export const SESSION_ITEM_PREFIX = 'agents-cli.session.';
30
+ /** Fixed-name index item — the ONLY thing we ever need to find without a known
31
+ * bundle name, so it is read/written by this exact name (never enumerated). */
32
+ export const SESSION_INDEX_ITEM = 'agents-cli.session.index';
33
+ // ─── Pure core (no I/O — unit-testable on any platform) ─────────────────────
34
+ /** The bundle names in the index that are still within their TTL at `now`. */
35
+ export function selectRehydratable(index, now) {
36
+ return Object.entries(index.bundles)
37
+ .filter(([, m]) => now < m.expiresAt)
38
+ .map(([name]) => name);
39
+ }
40
+ /** Split an index on a SLEEP event: keep `sleepPersist` entries, report the rest
41
+ * (which the caller deletes from the keychain). Default entries re-lock on sleep. */
42
+ export function pruneOnSleep(index) {
43
+ const survivors = { bundles: {} };
44
+ const deletedNames = [];
45
+ for (const [name, m] of Object.entries(index.bundles)) {
46
+ if (m.sleepPersist)
47
+ survivors.bundles[name] = m;
48
+ else
49
+ deletedNames.push(name);
50
+ }
51
+ return { survivors, deletedNames };
52
+ }
53
+ /** Drop entries past their TTL; report which were dropped so the caller can
54
+ * delete their blobs. */
55
+ export function pruneExpired(index, now) {
56
+ const survivors = { bundles: {} };
57
+ const expiredNames = [];
58
+ for (const [name, m] of Object.entries(index.bundles)) {
59
+ if (now < m.expiresAt)
60
+ survivors.bundles[name] = m;
61
+ else
62
+ expiredNames.push(name);
63
+ }
64
+ return { survivors, expiredNames };
65
+ }
66
+ /** Insert/replace one bundle in the index (pure). */
67
+ export function upsertEntry(index, name, meta) {
68
+ return { bundles: { ...index.bundles, [name]: meta } };
69
+ }
70
+ /** Remove one bundle from the index (pure). */
71
+ export function removeEntry(index, name) {
72
+ const { [name]: _drop, ...rest } = index.bundles;
73
+ void _drop;
74
+ return { bundles: rest };
75
+ }
76
+ // ─── Keychain adapter (macOS only; best-effort, never throws) ────────────────
77
+ /** Whether to actually persist. macOS in production; also whenever a test
78
+ * keychain backend is installed, so the adapter is exercisable on Linux CI
79
+ * against an in-memory store (mirrors the broker's hermetic-test gating). In real
80
+ * Linux/Windows production this is false — secrets already persist in the OS store
81
+ * with no broker, so there is nothing to mirror. */
82
+ function shouldPersist() {
83
+ return process.platform === 'darwin' || isKeychainBackendOverridden();
84
+ }
85
+ function sessionBlobItem(name) {
86
+ return `${SESSION_ITEM_PREFIX}${name}`;
87
+ }
88
+ /** Read the session index by its fixed name. `{bundles:{}}` when absent/unreadable. */
89
+ export function readIndex() {
90
+ try {
91
+ const raw = getKeychainToken(SESSION_INDEX_ITEM);
92
+ const parsed = JSON.parse(raw);
93
+ if (parsed && typeof parsed === 'object' && parsed.bundles)
94
+ return parsed;
95
+ }
96
+ catch {
97
+ /* absent or malformed → empty */
98
+ }
99
+ return { bundles: {} };
100
+ }
101
+ /** Persist the index as a no-ACL item. Deletes the item when empty (tidy). */
102
+ export function writeIndex(index) {
103
+ try {
104
+ if (Object.keys(index.bundles).length === 0) {
105
+ deleteKeychainToken(SESSION_INDEX_ITEM);
106
+ return;
107
+ }
108
+ setKeychainToken(SESSION_INDEX_ITEM, JSON.stringify(index), { noAcl: true });
109
+ }
110
+ catch {
111
+ /* best-effort */
112
+ }
113
+ }
114
+ /** Persist one unlocked bundle: write its blob no-ACL and record it in the index. */
115
+ export function saveSession(name, entry) {
116
+ if (!shouldPersist())
117
+ return;
118
+ try {
119
+ setKeychainToken(sessionBlobItem(name), JSON.stringify(entry), { noAcl: true });
120
+ writeIndex(upsertEntry(readIndex(), name, { expiresAt: entry.expiresAt, sleepPersist: entry.sleepPersist }));
121
+ }
122
+ catch {
123
+ /* best-effort — persistence is an optimization */
124
+ }
125
+ }
126
+ /** Read one session blob by known name. Null when absent/expired/malformed. */
127
+ export function loadSession(name, now = Date.now()) {
128
+ if (!shouldPersist())
129
+ return null;
130
+ try {
131
+ const raw = getKeychainToken(sessionBlobItem(name));
132
+ const entry = JSON.parse(raw);
133
+ if (!entry || typeof entry !== 'object' || !entry.bundle || !entry.env)
134
+ return null;
135
+ if (now >= entry.expiresAt) {
136
+ deleteSession(name); // drop expired on read, mirroring the broker's get handler
137
+ return null;
138
+ }
139
+ return entry;
140
+ }
141
+ catch {
142
+ return null;
143
+ }
144
+ }
145
+ /** Delete one bundle's session blob and prune it from the index. */
146
+ export function deleteSession(name) {
147
+ if (!shouldPersist())
148
+ return;
149
+ try {
150
+ deleteKeychainToken(sessionBlobItem(name));
151
+ writeIndex(removeEntry(readIndex(), name));
152
+ }
153
+ catch {
154
+ /* best-effort */
155
+ }
156
+ }
157
+ /** Delete every session blob + the index (for `secrets lock --all`). */
158
+ export function deleteAllSessions() {
159
+ if (!shouldPersist())
160
+ return;
161
+ try {
162
+ for (const name of Object.keys(readIndex().bundles)) {
163
+ try {
164
+ deleteKeychainToken(sessionBlobItem(name));
165
+ }
166
+ catch { /* keep going */ }
167
+ }
168
+ deleteKeychainToken(SESSION_INDEX_ITEM);
169
+ }
170
+ catch {
171
+ /* best-effort */
172
+ }
173
+ }
174
+ /** Rehydrate every unexpired session into `[name, entry]` pairs for the broker to
175
+ * load on start; prunes expired index entries + blobs as a side effect. Survives
176
+ * upgrade/restart. Empty off darwin or when nothing is held. */
177
+ export function rehydrateSessions(now = Date.now()) {
178
+ if (!shouldPersist())
179
+ return [];
180
+ const out = [];
181
+ try {
182
+ const index = readIndex();
183
+ const { survivors, expiredNames } = pruneExpired(index, now);
184
+ for (const name of expiredNames) {
185
+ try {
186
+ deleteKeychainToken(sessionBlobItem(name));
187
+ }
188
+ catch { /* keep going */ }
189
+ }
190
+ if (expiredNames.length)
191
+ writeIndex(survivors);
192
+ for (const name of selectRehydratable(survivors, now)) {
193
+ const entry = loadSession(name, now);
194
+ if (entry)
195
+ out.push({ name, entry });
196
+ }
197
+ }
198
+ catch {
199
+ /* best-effort */
200
+ }
201
+ return out;
202
+ }
203
+ /** On a SLEEP event: delete every non-`--durable` session (blob + index entry),
204
+ * keep the durable ones. Default bundles thus re-lock on sleep. */
205
+ export function pruneSessionsOnSleep() {
206
+ if (!shouldPersist())
207
+ return;
208
+ try {
209
+ const { survivors, deletedNames } = pruneOnSleep(readIndex());
210
+ for (const name of deletedNames) {
211
+ try {
212
+ deleteKeychainToken(sessionBlobItem(name));
213
+ }
214
+ catch { /* keep going */ }
215
+ }
216
+ writeIndex(survivors);
217
+ }
218
+ catch {
219
+ /* best-effort */
220
+ }
221
+ }