@phnx-labs/agents-cli 1.22.26 → 1.22.28

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 (183) hide show
  1. package/CHANGELOG.md +293 -0
  2. package/README.md +80 -27
  3. package/dist/bin/agents +0 -0
  4. package/dist/commands/bench.d.ts +2 -0
  5. package/dist/commands/bench.js +101 -0
  6. package/dist/commands/doctor.js +24 -10
  7. package/dist/commands/exec.js +71 -26
  8. package/dist/commands/hosts.js +4 -3
  9. package/dist/commands/insights.js +39 -5
  10. package/dist/commands/output.js +100 -25
  11. package/dist/commands/perf.d.ts +10 -0
  12. package/dist/commands/perf.js +14 -6
  13. package/dist/commands/projects.js +24 -10
  14. package/dist/commands/resume.d.ts +11 -0
  15. package/dist/commands/resume.js +51 -0
  16. package/dist/commands/routines.js +31 -4
  17. package/dist/commands/run-account-picker.d.ts +37 -0
  18. package/dist/commands/run-account-picker.js +101 -17
  19. package/dist/commands/secrets.js +86 -2
  20. package/dist/commands/sessions-picker.js +12 -7
  21. package/dist/commands/sessions.d.ts +73 -7
  22. package/dist/commands/sessions.js +391 -49
  23. package/dist/commands/ssh.js +184 -11
  24. package/dist/commands/teams.js +22 -11
  25. package/dist/index.js +16 -20
  26. package/dist/lib/activity.js +45 -99
  27. package/dist/lib/auth-health.d.ts +47 -3
  28. package/dist/lib/auth-health.js +74 -15
  29. package/dist/lib/bench/index.d.ts +4 -0
  30. package/dist/lib/bench/index.js +4 -0
  31. package/dist/lib/bench/runner.d.ts +16 -0
  32. package/dist/lib/bench/runner.js +111 -0
  33. package/dist/lib/bench/schema.d.ts +5 -0
  34. package/dist/lib/bench/schema.js +91 -0
  35. package/dist/lib/bench/storage.d.ts +5 -0
  36. package/dist/lib/bench/storage.js +32 -0
  37. package/dist/lib/bench/types.d.ts +40 -0
  38. package/dist/lib/bench/types.js +1 -0
  39. package/dist/lib/claude-account-token.d.ts +2 -0
  40. package/dist/lib/claude-account-token.js +56 -3
  41. package/dist/lib/cloud/host.js +1 -0
  42. package/dist/lib/crabbox/cli.d.ts +2 -0
  43. package/dist/lib/crabbox/cli.js +2 -0
  44. package/dist/lib/crabbox/lease.js +7 -1
  45. package/dist/lib/daemon.js +20 -0
  46. package/dist/lib/devices/connect.d.ts +2 -0
  47. package/dist/lib/devices/connect.js +7 -0
  48. package/dist/lib/devices/doctor-findings.d.ts +4 -1
  49. package/dist/lib/devices/doctor-findings.js +15 -1
  50. package/dist/lib/devices/harness-inventory.d.ts +97 -0
  51. package/dist/lib/devices/harness-inventory.js +0 -0
  52. package/dist/lib/devices/registry.d.ts +2 -0
  53. package/dist/lib/devices/resolve-target.d.ts +1 -0
  54. package/dist/lib/devices/resolve-target.js +9 -2
  55. package/dist/lib/devices/ssh-config.js +3 -0
  56. package/dist/lib/devices/windows-ssh-enrollment.d.ts +20 -0
  57. package/dist/lib/devices/windows-ssh-enrollment.js +98 -0
  58. package/dist/lib/events.d.ts +1 -1
  59. package/dist/lib/events.js +2 -2
  60. package/dist/lib/exec.js +18 -8
  61. package/dist/lib/fleet/apply.js +9 -7
  62. package/dist/lib/fleet/remote-login.d.ts +4 -3
  63. package/dist/lib/fleet/remote-login.js +11 -9
  64. package/dist/lib/gemini-settings.d.ts +0 -1
  65. package/dist/lib/gemini-settings.js +12 -7
  66. package/dist/lib/git.d.ts +9 -3
  67. package/dist/lib/git.js +39 -22
  68. package/dist/lib/hooks/profile.d.ts +12 -1
  69. package/dist/lib/hooks/profile.js +6 -1
  70. package/dist/lib/hooks.d.ts +8 -5
  71. package/dist/lib/hooks.js +14 -7
  72. package/dist/lib/hosts/dispatch.d.ts +10 -0
  73. package/dist/lib/hosts/dispatch.js +85 -25
  74. package/dist/lib/hosts/logs.js +11 -1
  75. package/dist/lib/hosts/passthrough.d.ts +27 -0
  76. package/dist/lib/hosts/passthrough.js +19 -10
  77. package/dist/lib/hosts/progress.d.ts +12 -1
  78. package/dist/lib/hosts/progress.js +41 -8
  79. package/dist/lib/hosts/providers/devices.js +1 -0
  80. package/dist/lib/hosts/ready.d.ts +45 -4
  81. package/dist/lib/hosts/ready.js +124 -20
  82. package/dist/lib/hosts/reconcile.d.ts +2 -1
  83. package/dist/lib/hosts/reconcile.js +24 -7
  84. package/dist/lib/hosts/reconnect.d.ts +45 -12
  85. package/dist/lib/hosts/reconnect.js +94 -36
  86. package/dist/lib/hosts/registry.d.ts +2 -2
  87. package/dist/lib/hosts/registry.js +4 -5
  88. package/dist/lib/hosts/remote-cmd.d.ts +17 -0
  89. package/dist/lib/hosts/remote-cmd.js +29 -0
  90. package/dist/lib/hosts/tasks.d.ts +3 -0
  91. package/dist/lib/hosts/types.d.ts +1 -0
  92. package/dist/lib/hosts/types.js +3 -0
  93. package/dist/lib/menubar/MenubarHelper.app/Contents/CodeResources +0 -0
  94. package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
  95. package/dist/lib/menubar/install-menubar.d.ts +9 -6
  96. package/dist/lib/menubar/install-menubar.js +20 -9
  97. package/dist/lib/menubar/snapshot.js +15 -2
  98. package/dist/lib/models.d.ts +42 -6
  99. package/dist/lib/models.js +55 -153
  100. package/dist/lib/perf/db.js +16 -3
  101. package/dist/lib/perf/types.d.ts +12 -1
  102. package/dist/lib/pricing/cost.d.ts +9 -0
  103. package/dist/lib/pricing/cost.js +24 -0
  104. package/dist/lib/pricing/index.d.ts +1 -1
  105. package/dist/lib/pricing/index.js +1 -1
  106. package/dist/lib/project-probe.d.ts +22 -3
  107. package/dist/lib/project-probe.js +105 -17
  108. package/dist/lib/project-status.d.ts +9 -0
  109. package/dist/lib/project-status.js +15 -0
  110. package/dist/lib/redact.js +8 -3
  111. package/dist/lib/refresh.js +22 -11
  112. package/dist/lib/remote-agents-json.d.ts +32 -0
  113. package/dist/lib/remote-agents-json.js +47 -16
  114. package/dist/lib/resource-profiles.js +1 -2
  115. package/dist/lib/rotate.d.ts +22 -0
  116. package/dist/lib/rotate.js +26 -0
  117. package/dist/lib/routine-notify-owner.d.ts +102 -0
  118. package/dist/lib/routine-notify-owner.js +232 -0
  119. package/dist/lib/routines.d.ts +10 -0
  120. package/dist/lib/runner.d.ts +9 -1
  121. package/dist/lib/runner.js +182 -17
  122. package/dist/lib/sandbox.d.ts +0 -2
  123. package/dist/lib/sandbox.js +2 -19
  124. package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
  125. package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
  126. package/dist/lib/secrets/agent.d.ts +19 -3
  127. package/dist/lib/secrets/agent.js +61 -22
  128. package/dist/lib/secrets/audit.d.ts +1 -1
  129. package/dist/lib/secrets/audit.js +2 -0
  130. package/dist/lib/secrets/bundles.d.ts +3 -3
  131. package/dist/lib/secrets/bundles.js +15 -5
  132. package/dist/lib/secrets/filestore.d.ts +2 -0
  133. package/dist/lib/secrets/filestore.js +4 -0
  134. package/dist/lib/secrets/lease.d.ts +25 -0
  135. package/dist/lib/secrets/lease.js +44 -0
  136. package/dist/lib/secrets/session-store.d.ts +7 -0
  137. package/dist/lib/secrets/session-store.js +21 -0
  138. package/dist/lib/self-update.d.ts +20 -5
  139. package/dist/lib/self-update.js +93 -16
  140. package/dist/lib/session/active.d.ts +49 -0
  141. package/dist/lib/session/active.js +175 -11
  142. package/dist/lib/session/bash-command.d.ts +16 -0
  143. package/dist/lib/session/bash-command.js +65 -0
  144. package/dist/lib/session/db.d.ts +23 -2
  145. package/dist/lib/session/db.js +86 -20
  146. package/dist/lib/session/discover.d.ts +21 -1
  147. package/dist/lib/session/discover.js +124 -32
  148. package/dist/lib/session/insights.d.ts +19 -0
  149. package/dist/lib/session/insights.js +78 -10
  150. package/dist/lib/session/recovery.d.ts +17 -1
  151. package/dist/lib/session/recovery.js +111 -4
  152. package/dist/lib/session/remote-bundle.js +1 -1
  153. package/dist/lib/session/remote-list.d.ts +10 -9
  154. package/dist/lib/session/remote-list.js +25 -23
  155. package/dist/lib/session/resume-owner.d.ts +55 -0
  156. package/dist/lib/session/resume-owner.js +69 -0
  157. package/dist/lib/session/team-filter.d.ts +65 -0
  158. package/dist/lib/session/team-filter.js +98 -3
  159. package/dist/lib/session/types.d.ts +33 -0
  160. package/dist/lib/smart-launch.d.ts +31 -5
  161. package/dist/lib/smart-launch.js +43 -7
  162. package/dist/lib/ssh-exec.d.ts +27 -0
  163. package/dist/lib/ssh-exec.js +34 -1
  164. package/dist/lib/ssh-tunnel.d.ts +3 -2
  165. package/dist/lib/ssh-tunnel.js +25 -16
  166. package/dist/lib/staleness/detectors/skills.d.ts +2 -0
  167. package/dist/lib/staleness/detectors/skills.js +17 -2
  168. package/dist/lib/staleness/index.d.ts +10 -1
  169. package/dist/lib/staleness/index.js +28 -3
  170. package/dist/lib/startup/command-registry.d.ts +1 -0
  171. package/dist/lib/startup/command-registry.js +2 -0
  172. package/dist/lib/teams/agents.d.ts +1 -0
  173. package/dist/lib/teams/agents.js +38 -11
  174. package/dist/lib/teams/placement-probe.d.ts +1 -1
  175. package/dist/lib/teams/placement-probe.js +24 -24
  176. package/dist/lib/teams/remoteWorktree.d.ts +11 -7
  177. package/dist/lib/teams/remoteWorktree.js +29 -27
  178. package/dist/lib/tmux/session.d.ts +8 -0
  179. package/dist/lib/tmux/session.js +22 -0
  180. package/dist/lib/types.d.ts +2 -0
  181. package/dist/lib/versions.d.ts +1 -0
  182. package/dist/lib/versions.js +40 -22
  183. package/package.json +3 -1
@@ -26,6 +26,7 @@
26
26
  import * as net from 'net';
27
27
  import type { SecretsBundle } from './bundles.js';
28
28
  import { GLOBAL_HARNESS, bundleScopeChain } from './scope.js';
29
+ import { type SecretLease } from './lease.js';
29
30
  export { GLOBAL_HARNESS, bundleScopeChain };
30
31
  /** Default lifetime of an unlocked bundle when `--ttl` is not given. */
31
32
  export declare const DEFAULT_TTL_MS: number;
@@ -72,6 +73,7 @@ export interface StoredBundle {
72
73
  /** epoch ms; the entry is gone once Date.now() passes this. */
73
74
  expiresAt: number;
74
75
  harness: string;
76
+ lease?: SecretLease;
75
77
  }
76
78
  /** One unlocked bundle as reported by `status`. */
77
79
  export interface AgentStatusEntry {
@@ -79,6 +81,8 @@ export interface AgentStatusEntry {
79
81
  expiresAt: number;
80
82
  keyCount: number;
81
83
  harness: string;
84
+ leaseId?: string;
85
+ keys?: string[];
82
86
  }
83
87
  /**
84
88
  * Read the current broker capability token, or null if none is present. Clients
@@ -119,11 +123,16 @@ export type Request = {
119
123
  bundle: SecretsBundle;
120
124
  env: Record<string, string>;
121
125
  ttlMs: number;
126
+ lease?: SecretLease;
122
127
  token?: string;
123
128
  } | {
124
129
  cmd: 'lock';
125
130
  name?: string;
126
131
  token?: string;
132
+ } | {
133
+ cmd: 'revoke';
134
+ leaseId: string;
135
+ token?: string;
127
136
  } | {
128
137
  cmd: 'status';
129
138
  token?: string;
@@ -143,6 +152,7 @@ export type Response = {
143
152
  hit: true;
144
153
  bundle: SecretsBundle;
145
154
  env: Record<string, string>;
155
+ lease?: SecretLease;
146
156
  } | {
147
157
  ok: true;
148
158
  cmd: 'load';
@@ -150,6 +160,10 @@ export type Response = {
150
160
  ok: true;
151
161
  cmd: 'lock';
152
162
  wiped: number;
163
+ } | {
164
+ ok: true;
165
+ cmd: 'revoke';
166
+ wiped: number;
153
167
  } | {
154
168
  ok: true;
155
169
  cmd: 'status';
@@ -237,7 +251,7 @@ export declare function runSecretsAgent(opts?: {
237
251
  *
238
252
  * Serves the SAME socket and wire protocol as the standalone `runSecretsAgent`
239
253
  * — so every existing client (`agentGetSync`, `agentPing`, `agentAutoLoadSync`)
240
- * keeps working unchanged, no PROTOCOL_VERSION bump — but it is daemon-safe:
254
+ * keeps working through the versioned protocol — but it is daemon-safe:
241
255
  *
242
256
  * - no pid-file single-instance guard (the daemon owns the instance);
243
257
  * - no `process.exit`, no SIGTERM/SIGINT handlers, no self-heal/idle-exit
@@ -296,6 +310,7 @@ export declare function syncClientLaunch(sub: string[], agentsBin?: string): {
296
310
  export declare function agentGetSync(name: string, harness?: string): {
297
311
  bundle: SecretsBundle;
298
312
  env: Record<string, string>;
313
+ lease?: SecretLease;
299
314
  } | null;
300
315
  /**
301
316
  * Last non-empty line of a child's stdout — the payload line.
@@ -410,7 +425,7 @@ export declare function clampHoldMs(v: unknown): number;
410
425
  * The worker reuses the robust `ensureAgentRunning` path (spawn-then-ping) rather
411
426
  * than a tight inline retry loop. Best-effort; never throws. macOS only.
412
427
  */
413
- export declare function agentAutoLoadSync(name: string, bundle: SecretsBundle, env: Record<string, string>, ttlMs: number, harness?: string): void;
428
+ export declare function agentAutoLoadSync(name: string, bundle: SecretsBundle, env: Record<string, string>, ttlMs: number, harness?: string, lease?: SecretLease): void;
414
429
  /**
415
430
  * Body of the hidden `secrets _agent-load` worker. Reads one `{name, bundle,
416
431
  * env, ttlMs}` payload from stdin, ensures the broker is up (robust, generous
@@ -425,10 +440,11 @@ export declare function agentAutoLoadSync(name: string, bundle: SecretsBundle, e
425
440
  */
426
441
  export declare function runAgentLoadFromStdin(): Promise<void>;
427
442
  /** Store a resolved bundle in the broker. Returns false on transport failure. */
428
- export declare function agentLoad(name: string, bundle: SecretsBundle, env: Record<string, string>, ttlMs: number, harness?: string): Promise<boolean>;
443
+ export declare function agentLoad(name: string, bundle: SecretsBundle, env: Record<string, string>, ttlMs: number, harness?: string, lease?: SecretLease): Promise<boolean>;
429
444
  /** Wipe one bundle (or all if name omitted) from the broker. Returns the count
430
445
  * wiped, or 0 when no broker is running. */
431
446
  export declare function agentLock(name?: string): Promise<number>;
447
+ export declare function agentRevoke(leaseId: string): Promise<number>;
432
448
  /** List currently-unlocked bundles, or [] when no broker is running. The
433
449
  * internal `secrets list` metadata-cache entry is filtered out here as well as
434
450
  * server-side: during a rollout a NEW client can talk to an OLD broker that
@@ -35,8 +35,11 @@ import { getKeychainHelperPath } from './install-helper.js';
35
35
  import { getCliVersion, getCliVersionFresh } from '../version.js';
36
36
  import { getCliLaunch } from '../cli-entry.js';
37
37
  import { GLOBAL_HARNESS, bundleScopeChain } from './scope.js';
38
- import { rehydrateSessions, pruneSessionsOnSleep } from './session-store.js';
38
+ import { deleteLeaseSession, rehydrateSessions, pruneSessionsOnSleep } from './session-store.js';
39
39
  import { SYNC_GET_CMD, SYNC_PING_CMD, SYNC_LOCK_CMD } from './sync-commands.js';
40
+ import { MAX_LEASE_MS, MIN_LEASE_MS } from './lease.js';
41
+ import { selectLeasedEnv } from './lease.js';
42
+ import { emitSecretAudit } from './audit.js';
40
43
  // Re-exported so callers already reaching for agent.js keep one obvious home for
41
44
  // the scope vocabulary; the definitions live in the leaf module scope.ts because
42
45
  // agent.ts and session-store.ts import each other and a cyclic `const` read can
@@ -44,7 +47,7 @@ import { SYNC_GET_CMD, SYNC_PING_CMD, SYNC_LOCK_CMD } from './sync-commands.js';
44
47
  export { GLOBAL_HARNESS, bundleScopeChain };
45
48
  /** Bumped when the wire protocol changes; a client that pings a mismatched
46
49
  * server kills and respawns it rather than talking a stale dialect. */
47
- const PROTOCOL_VERSION = 2;
50
+ const PROTOCOL_VERSION = 3;
48
51
  /** Default lifetime of an unlocked bundle when `--ttl` is not given. */
49
52
  export const DEFAULT_TTL_MS = 7 * 24 * 60 * 60 * 1000; // 7d
50
53
  /**
@@ -153,7 +156,7 @@ function rehydrateStore(now = Date.now()) {
153
156
  const store = new Map();
154
157
  for (const { name, entry } of rehydrateSessions(now)) {
155
158
  const harness = entry.harness || GLOBAL_HARNESS;
156
- store.set(scopedBundleKey(name, harness), { bundle: entry.bundle, env: entry.env, expiresAt: entry.expiresAt, harness });
159
+ store.set(scopedBundleKey(name, harness), { bundle: entry.bundle, env: entry.env, expiresAt: entry.expiresAt, harness, lease: entry.lease });
157
160
  }
158
161
  return store;
159
162
  }
@@ -351,16 +354,30 @@ export function handleAgentRequest(store, req, now = Date.now()) {
351
354
  continue;
352
355
  if (now >= e.expiresAt) {
353
356
  store.delete(key);
357
+ if (e.lease) {
358
+ deleteLeaseSession(e.lease.id);
359
+ emitSecretAudit({ event: 'secrets.lease-expire', bundle: e.bundle.name, operation: 'lease-expire', source: 'broker', status: 'success', keys: e.lease.keys, keyCount: e.lease.keys.length, agent: e.lease.harness });
360
+ }
354
361
  continue;
355
- } // drop expired on read
356
- return { ok: true, cmd: 'get', hit: true, bundle: e.bundle, env: e.env };
362
+ }
363
+ return { ok: true, cmd: 'get', hit: true, bundle: e.bundle, env: e.env, lease: e.lease };
357
364
  }
358
365
  return { ok: true, cmd: 'get', hit: false };
359
366
  }
360
- case 'load':
367
+ case 'load': {
361
368
  const harness = req.harness || GLOBAL_HARNESS;
362
- store.set(scopedBundleKey(req.name, harness), { bundle: req.bundle, env: req.env, expiresAt: now + req.ttlMs, harness });
369
+ let env = req.env;
370
+ if (req.lease) {
371
+ try {
372
+ env = selectLeasedEnv(req.lease, req.env, now);
373
+ }
374
+ catch (err) {
375
+ return { ok: false, error: err.message };
376
+ }
377
+ }
378
+ store.set(scopedBundleKey(req.name, harness), { bundle: req.bundle, env, expiresAt: req.lease?.expiresAt ?? now + req.ttlMs, harness, lease: req.lease });
363
379
  return { ok: true, cmd: 'load' };
380
+ }
364
381
  case 'lock': {
365
382
  if (req.name) {
366
383
  let wiped = 0;
@@ -373,6 +390,13 @@ export function handleAgentRequest(store, req, now = Date.now()) {
373
390
  store.clear();
374
391
  return { ok: true, cmd: 'lock', wiped };
375
392
  }
393
+ case 'revoke': {
394
+ let wiped = 0;
395
+ for (const [key, entry] of store)
396
+ if (entry.lease?.id === req.leaseId && store.delete(key))
397
+ wiped++;
398
+ return { ok: true, cmd: 'revoke', wiped };
399
+ }
376
400
  case 'status': {
377
401
  const entries = [];
378
402
  for (const [name, e] of store) {
@@ -380,7 +404,7 @@ export function handleAgentRequest(store, req, now = Date.now()) {
380
404
  continue;
381
405
  if (e.bundle.name.startsWith(META_CACHE_PREFIX))
382
406
  continue; // internal list cache
383
- entries.push({ name: e.bundle.name, expiresAt: e.expiresAt, keyCount: Object.keys(e.env).length, harness: e.harness });
407
+ entries.push({ name: e.bundle.name, expiresAt: e.expiresAt, keyCount: Object.keys(e.env).length, harness: e.harness, leaseId: e.lease?.id, keys: e.lease?.keys });
384
408
  }
385
409
  return { ok: true, cmd: 'status', entries };
386
410
  }
@@ -648,8 +672,13 @@ export async function runSecretsAgent(opts = {}) {
648
672
  const sweep = () => {
649
673
  const now = Date.now();
650
674
  for (const [name, e] of store)
651
- if (now >= e.expiresAt)
675
+ if (now >= e.expiresAt) {
652
676
  store.delete(name);
677
+ if (e.lease) {
678
+ deleteLeaseSession(e.lease.id);
679
+ emitSecretAudit({ event: 'secrets.lease-expire', bundle: e.bundle.name, operation: 'lease-expire', source: 'broker', status: 'success', keys: e.lease.keys, keyCount: e.lease.keys.length, agent: e.lease.harness });
680
+ }
681
+ }
653
682
  const live = realBundleCount(store);
654
683
  // Self-heal onto a newer in-place install — but ONLY while no real unlocks
655
684
  // are held, so we never wipe live unlocks and force a re-prompt (#435). A
@@ -766,7 +795,7 @@ export async function runSecretsAgent(opts = {}) {
766
795
  *
767
796
  * Serves the SAME socket and wire protocol as the standalone `runSecretsAgent`
768
797
  * — so every existing client (`agentGetSync`, `agentPing`, `agentAutoLoadSync`)
769
- * keeps working unchanged, no PROTOCOL_VERSION bump — but it is daemon-safe:
798
+ * keeps working through the versioned protocol — but it is daemon-safe:
770
799
  *
771
800
  * - no pid-file single-instance guard (the daemon owns the instance);
772
801
  * - no `process.exit`, no SIGTERM/SIGINT handlers, no self-heal/idle-exit
@@ -795,9 +824,15 @@ export async function startHostedBroker() {
795
824
  // whole daemon down with it.
796
825
  const sweepTimer = setInterval(() => {
797
826
  const now = Date.now();
798
- for (const [name, e] of store)
799
- if (now >= e.expiresAt)
800
- store.delete(name);
827
+ for (const [name, e] of store) {
828
+ if (now < e.expiresAt)
829
+ continue;
830
+ store.delete(name);
831
+ if (e.lease) {
832
+ deleteLeaseSession(e.lease.id);
833
+ emitSecretAudit({ event: 'secrets.lease-expire', bundle: e.bundle.name, operation: 'lease-expire', source: 'broker', status: 'success', keys: e.lease.keys, keyCount: e.lease.keys.length, agent: e.lease.harness });
834
+ }
835
+ }
801
836
  }, SWEEP_INTERVAL_MS);
802
837
  // Auto-lock on sleep, same as the standalone broker: the signed helper emits
803
838
  // LOCK/SLEEP lines; wipe the in-memory store on a wipe-worthy event.
@@ -943,7 +978,7 @@ export function agentGetSync(name, harness = GLOBAL_HARNESS) {
943
978
  const o = JSON.parse(lastLine(r.stdout));
944
979
  if (!o || typeof o !== 'object' || !o.env)
945
980
  return null;
946
- return { bundle: o.bundle, env: o.env };
981
+ return { bundle: o.bundle, env: o.env, lease: o.lease };
947
982
  }
948
983
  catch {
949
984
  return null;
@@ -1025,7 +1060,7 @@ export async function runAgentGetSync(name, harness = GLOBAL_HARNESS) {
1025
1060
  // truncated on exit — the parent would see a partial or empty payload, treat
1026
1061
  // it as a miss, and fall through to a keychain read, silently costing the
1027
1062
  // Touch ID prompt this whole path exists to avoid.
1028
- const payload = JSON.stringify({ bundle: r.bundle, env: r.env }) + '\n';
1063
+ const payload = JSON.stringify({ bundle: r.bundle, env: r.env, lease: r.lease }) + '\n';
1029
1064
  await new Promise((resolve) => { process.stdout.write(payload, () => resolve()); });
1030
1065
  return 0;
1031
1066
  }
@@ -1120,8 +1155,8 @@ export function secretsAgentDurable() {
1120
1155
  /** Minimum / maximum bounds for the configurable hold window. A too-small value
1121
1156
  * would defeat the broker (constant re-prompts); a too-large one pins secrets in
1122
1157
  * memory far longer than intended. */
1123
- export const MIN_HOLD_MS = 60 * 1000; // 1m
1124
- export const MAX_HOLD_MS = 30 * 24 * 60 * 60 * 1000; // 30d
1158
+ export const MIN_HOLD_MS = MIN_LEASE_MS;
1159
+ export const MAX_HOLD_MS = MAX_LEASE_MS;
1125
1160
  /**
1126
1161
  * How long an unlocked / auto-cached bundle is held before the next read
1127
1162
  * re-prompts. Defaults to DEFAULT_TTL_MS (7d); override with
@@ -1165,10 +1200,10 @@ export function clampHoldMs(v) {
1165
1200
  * The worker reuses the robust `ensureAgentRunning` path (spawn-then-ping) rather
1166
1201
  * than a tight inline retry loop. Best-effort; never throws. macOS only.
1167
1202
  */
1168
- export function agentAutoLoadSync(name, bundle, env, ttlMs, harness = GLOBAL_HARNESS) {
1203
+ export function agentAutoLoadSync(name, bundle, env, ttlMs, harness = GLOBAL_HARNESS, lease) {
1169
1204
  if (!onDarwin())
1170
1205
  return;
1171
- const payload = JSON.stringify({ name, bundle, env, ttlMs, harness });
1206
+ const payload = JSON.stringify({ name, bundle, env, ttlMs, harness, lease });
1172
1207
  // Broker actually LISTENING → deterministic synchronous warm (bounded; the read
1173
1208
  // already paid a Touch ID, so <1s here is invisible). We gate on a real liveness
1174
1209
  // ping, NOT mere socket-file existence: a broker that died leaving its socket
@@ -1235,13 +1270,13 @@ export async function runAgentLoadFromStdin() {
1235
1270
  process.exitCode = 1; // broker couldn't be brought up — did NOT load
1236
1271
  return;
1237
1272
  }
1238
- const loaded = await agentLoad(payload.name, payload.bundle, payload.env, payload.ttlMs ?? DEFAULT_TTL_MS, payload.harness ?? GLOBAL_HARNESS);
1273
+ const loaded = await agentLoad(payload.name, payload.bundle, payload.env, payload.ttlMs ?? DEFAULT_TTL_MS, payload.harness ?? GLOBAL_HARNESS, payload.lease);
1239
1274
  if (!loaded)
1240
1275
  process.exitCode = 1; // transport failed — did NOT load
1241
1276
  }
1242
1277
  /** Store a resolved bundle in the broker. Returns false on transport failure. */
1243
- export async function agentLoad(name, bundle, env, ttlMs, harness = GLOBAL_HARNESS) {
1244
- const r = await request({ cmd: 'load', name, bundle, env, ttlMs, harness });
1278
+ export async function agentLoad(name, bundle, env, ttlMs, harness = GLOBAL_HARNESS, lease) {
1279
+ const r = await request({ cmd: 'load', name, bundle, env, ttlMs, harness, lease });
1245
1280
  return r?.ok === true && r.cmd === 'load';
1246
1281
  }
1247
1282
  /** Wipe one bundle (or all if name omitted) from the broker. Returns the count
@@ -1250,6 +1285,10 @@ export async function agentLock(name) {
1250
1285
  const r = await request({ cmd: 'lock', name });
1251
1286
  return r?.ok === true && r.cmd === 'lock' ? r.wiped : 0;
1252
1287
  }
1288
+ export async function agentRevoke(leaseId) {
1289
+ const r = await request({ cmd: 'revoke', leaseId });
1290
+ return r?.ok === true && r.cmd === 'revoke' ? r.wiped : 0;
1291
+ }
1253
1292
  /** List currently-unlocked bundles, or [] when no broker is running. The
1254
1293
  * internal `secrets list` metadata-cache entry is filtered out here as well as
1255
1294
  * server-side: during a rollout a NEW client can talk to an OLD broker that
@@ -1,4 +1,4 @@
1
- export type SecretAuditEvent = 'secrets.get' | 'secrets.unlocked' | 'secrets.create' | 'secrets.import' | 'secrets.export' | 'secrets.view';
1
+ export type SecretAuditEvent = 'secrets.get' | 'secrets.unlocked' | 'secrets.create' | 'secrets.import' | 'secrets.export' | 'secrets.view' | 'secrets.lease-denied' | 'secrets.lease-expire';
2
2
  export interface SecretAuditParams {
3
3
  /** Which audit event this is — a read (`secrets.get`) or an unlock grant. */
4
4
  event: SecretAuditEvent;
@@ -46,6 +46,8 @@ const USAGE_KIND = {
46
46
  'secrets.import': 'import',
47
47
  'secrets.export': 'export',
48
48
  'secrets.view': 'view',
49
+ 'secrets.lease-denied': 'access',
50
+ 'secrets.lease-expire': 'access',
49
51
  };
50
52
  /**
51
53
  * The agent/harness identity to attribute a secret access to. Explicit callers
@@ -264,9 +264,9 @@ export interface ResolveBundleOptions {
264
264
  export declare function canCacheResolvedEnv(bundle: SecretsBundle, selectedKeys: Set<string>, keyMode: ResolveBundleOptions['keyMode']): boolean;
265
265
  /**
266
266
  * Apply the --keys subset + expiry gate to an already-resolved snapshot from
267
- * the secrets-agent fast-path. The agent stores the FULL bundle env, so a
268
- * naive fast-path return would silently defeat --keys and inject expired
269
- * values. Mirrors the slow-path pre-checks in `resolveBundleEnv` /
267
+ * the secrets-agent fast-path. The agent stores either a full unlock or a
268
+ * scoped lease env, so a naive fast-path return could silently defeat --keys
269
+ * and inject expired values. Mirrors the slow-path pre-checks in `resolveBundleEnv` /
270
270
  * `readAndResolveBundleEnv` and returns a new env whose keys match the subset.
271
271
  *
272
272
  * Exported for tests; production callers reach it via the fast-path branch in
@@ -1033,9 +1033,9 @@ export function canCacheResolvedEnv(bundle, selectedKeys, keyMode) {
1033
1033
  }
1034
1034
  /**
1035
1035
  * Apply the --keys subset + expiry gate to an already-resolved snapshot from
1036
- * the secrets-agent fast-path. The agent stores the FULL bundle env, so a
1037
- * naive fast-path return would silently defeat --keys and inject expired
1038
- * values. Mirrors the slow-path pre-checks in `resolveBundleEnv` /
1036
+ * the secrets-agent fast-path. The agent stores either a full unlock or a
1037
+ * scoped lease env, so a naive fast-path return could silently defeat --keys
1038
+ * and inject expired values. Mirrors the slow-path pre-checks in `resolveBundleEnv` /
1039
1039
  * `readAndResolveBundleEnv` and returns a new env whose keys match the subset.
1040
1040
  *
1041
1041
  * Exported for tests; production callers reach it via the fast-path branch in
@@ -1230,7 +1230,12 @@ export function readAndResolveBundleEnv(name, opts = {}) {
1230
1230
  const harness = opts.agent || process.env.AGENTS_AGENT_NAME || GLOBAL_HARNESS;
1231
1231
  const hit = agentGetSync(name, harness);
1232
1232
  if (hit) {
1233
- // The agent stores the FULL bundle env. Apply the same subset filter and
1233
+ const denied = (opts.keys ?? []).filter((key) => hit.lease && !hit.lease.keys.includes(key));
1234
+ if (denied.length > 0) {
1235
+ emitSecretAudit({ event: 'secrets.lease-denied', bundle: name, operation: opts.caller, source: 'agent', status: 'error', keys: denied, keyCount: denied.length, agent: harness, error: 'key outside lease scope' });
1236
+ throw new Error(`Secret lease '${hit.lease?.id}' does not grant key(s): ${denied.join(', ')}`);
1237
+ }
1238
+ // The agent stores a full unlock or a scoped lease env. Apply the same subset filter and
1234
1239
  // expiry gate as the slow path — without this, `--secrets-keys X` would
1235
1240
  // silently inject every key and an expired key would flow through after
1236
1241
  // the first cache-populating run.
@@ -1256,13 +1261,18 @@ export function readAndResolveBundleEnv(name, opts = {}) {
1256
1261
  const resolved = resolveSession(name, Date.now(), harness);
1257
1262
  if (resolved) {
1258
1263
  const session = resolved.entry;
1264
+ const denied = (opts.keys ?? []).filter((key) => session.lease && !session.lease.keys.includes(key));
1265
+ if (denied.length > 0) {
1266
+ emitSecretAudit({ event: 'secrets.lease-denied', bundle: name, operation: opts.caller, source: 'session', status: 'error', keys: denied, keyCount: denied.length, agent: harness, error: 'key outside lease scope' });
1267
+ throw new Error(`Secret lease '${session.lease?.id}' does not grant key(s): ${denied.join(', ')}`);
1268
+ }
1259
1269
  const filtered = filterAgentHitBySubsetAndExpiry({ bundle: session.bundle, env: session.env }, opts);
1260
1270
  stampLastUsed(filtered.bundle);
1261
1271
  // Re-warm the broker with the remaining TTL so later reads hit RAM and
1262
1272
  // `agents secrets status` is honest. Re-warm under the scope the grant was
1263
1273
  // MADE in (resolved.harness), never the asking scope — re-warming a global
1264
1274
  // grant as `claude` would silently narrow it for every other harness.
1265
- agentAutoLoadSync(name, session.bundle, session.env, Math.max(1, session.expiresAt - Date.now()), resolved.harness);
1275
+ agentAutoLoadSync(name, session.bundle, session.env, Math.max(1, session.expiresAt - Date.now()), resolved.harness, session.lease);
1266
1276
  emitSecretAudit({
1267
1277
  event: 'secrets.get',
1268
1278
  bundle: name,
@@ -51,6 +51,8 @@ export declare function encryptForFallback(plaintext: string, passphrase: string
51
51
  /** Decrypt an EncFile under a passphrase. Throws on wrong key or tampered
52
52
  * ciphertext (auth-tag mismatch). Exported for tests. */
53
53
  export declare function decryptForFallback(enc: EncFile, passphrase: string): string;
54
+ /** Absolute path of one encrypted file-store item. No read or decrypt occurs. */
55
+ export declare function fileStoreItemPath(item: string): string;
54
56
  declare function fileHas(item: string): boolean;
55
57
  declare function fileGet(item: string): string;
56
58
  declare function fileGetBatch(items: string[]): Map<string, string>;
@@ -214,6 +214,10 @@ export function decryptForFallback(enc, passphrase) {
214
214
  function fileFor(item) {
215
215
  return path.join(fileDir(), `${item}.enc`);
216
216
  }
217
+ /** Absolute path of one encrypted file-store item. No read or decrypt occurs. */
218
+ export function fileStoreItemPath(item) {
219
+ return fileFor(item);
220
+ }
217
221
  function fileHas(item) {
218
222
  return fs.existsSync(fileFor(item));
219
223
  }
@@ -0,0 +1,25 @@
1
+ export declare const MIN_LEASE_MS: number;
2
+ export declare const MAX_LEASE_MS: number;
3
+ export interface SecretLease {
4
+ id: string;
5
+ bundle: string;
6
+ keys: string[];
7
+ createdAt: number;
8
+ expiresAt: number;
9
+ harness: string;
10
+ sleepPersist: boolean;
11
+ }
12
+ export declare function clampLeaseTtlMs(ttlMs: number): number;
13
+ export declare function normalizeLeaseKeys(keys: readonly string[], availableKeys: readonly string[]): string[];
14
+ export declare function createSecretLease(input: {
15
+ bundle: string;
16
+ keys: readonly string[];
17
+ availableKeys: readonly string[];
18
+ ttlMs: number;
19
+ now?: number;
20
+ id?: string;
21
+ harness?: string;
22
+ sleepPersist?: boolean;
23
+ }): SecretLease;
24
+ export declare function leaseIsActive(lease: SecretLease, now?: number): boolean;
25
+ export declare function selectLeasedEnv(lease: SecretLease, env: Record<string, string>, now?: number): Record<string, string>;
@@ -0,0 +1,44 @@
1
+ import { randomBytes } from 'crypto';
2
+ import { GLOBAL_HARNESS } from './scope.js';
3
+ export const MIN_LEASE_MS = 60 * 1000;
4
+ export const MAX_LEASE_MS = 30 * 24 * 60 * 60 * 1000;
5
+ export function clampLeaseTtlMs(ttlMs) {
6
+ if (!Number.isFinite(ttlMs) || ttlMs <= 0) {
7
+ throw new Error('Lease duration must be a positive finite number.');
8
+ }
9
+ return Math.min(Math.max(Math.floor(ttlMs), MIN_LEASE_MS), MAX_LEASE_MS);
10
+ }
11
+ export function normalizeLeaseKeys(keys, availableKeys) {
12
+ const requested = [...new Set(keys.map((key) => key.trim()).filter(Boolean))].sort();
13
+ if (requested.length === 0)
14
+ throw new Error('A secret lease requires at least one key.');
15
+ const available = new Set(availableKeys);
16
+ const missing = requested.filter((key) => !available.has(key));
17
+ if (missing.length > 0)
18
+ throw new Error(`Unknown secret lease key(s): ${missing.join(', ')}`);
19
+ return requested;
20
+ }
21
+ export function createSecretLease(input) {
22
+ const createdAt = input.now ?? Date.now();
23
+ const ttlMs = clampLeaseTtlMs(input.ttlMs);
24
+ return {
25
+ id: input.id ?? randomBytes(12).toString('hex'),
26
+ bundle: input.bundle,
27
+ keys: normalizeLeaseKeys(input.keys, input.availableKeys),
28
+ createdAt,
29
+ expiresAt: createdAt + ttlMs,
30
+ harness: input.harness ?? GLOBAL_HARNESS,
31
+ sleepPersist: input.sleepPersist ?? false,
32
+ };
33
+ }
34
+ export function leaseIsActive(lease, now = Date.now()) {
35
+ return now < lease.expiresAt;
36
+ }
37
+ export function selectLeasedEnv(lease, env, now = Date.now()) {
38
+ if (!leaseIsActive(lease, now))
39
+ throw new Error(`Secret lease '${lease.id}' has expired.`);
40
+ const missing = lease.keys.filter((key) => typeof env[key] !== 'string');
41
+ if (missing.length > 0)
42
+ throw new Error(`Secret lease '${lease.id}' is missing value(s): ${missing.join(', ')}`);
43
+ return Object.fromEntries(lease.keys.map((key) => [key, env[key]]));
44
+ }
@@ -25,6 +25,7 @@
25
25
  * persistence is an optimization, not a correctness dependency.
26
26
  */
27
27
  import type { SecretsBundle } from './bundles.js';
28
+ import type { SecretLease } from './lease.js';
28
29
  /** Prefix for all durable session items (device-local, no-ACL). */
29
30
  export declare const SESSION_ITEM_PREFIX = "agents-cli.session.";
30
31
  /** Fixed-name index item — the ONLY thing we ever need to find without a known
@@ -39,6 +40,7 @@ export interface SessionEntry {
39
40
  /** true only for `--durable` unlocks — survives SLEEP. */
40
41
  sleepPersist: boolean;
41
42
  harness?: string;
43
+ lease?: SecretLease;
42
44
  }
43
45
  /** Metadata for one held bundle, kept in the index so we can rehydrate / prune
44
46
  * without reading every blob. */
@@ -90,6 +92,11 @@ export declare function loadSession(name: string, now?: number, harness?: string
90
92
  export declare function deleteBundleSessions(name: string): void;
91
93
  /** Delete one bundle's session blob and prune it from the index. */
92
94
  export declare function deleteSession(name: string, harness?: string): void;
95
+ export declare function deleteLeaseSession(leaseId: string): number;
96
+ export declare function activeLeaseSessions(now?: number): Array<{
97
+ name: string;
98
+ lease: SecretLease;
99
+ }>;
93
100
  /** Delete every session blob + the index (for `secrets lock --all`). */
94
101
  export declare function deleteAllSessions(): void;
95
102
  /** Rehydrate every unexpired session into `[name, entry]` pairs for the broker to
@@ -192,6 +192,27 @@ export function deleteSession(name, harness = GLOBAL_HARNESS) {
192
192
  /* best-effort */
193
193
  }
194
194
  }
195
+ export function deleteLeaseSession(leaseId) {
196
+ if (!shouldPersist())
197
+ return 0;
198
+ const index = readIndex();
199
+ let deleted = 0;
200
+ for (const [key, meta] of Object.entries(index.bundles)) {
201
+ const bundleName = key.split(':').slice(1).join(':');
202
+ const harness = meta.harness || GLOBAL_HARNESS;
203
+ const entry = loadSession(bundleName, Date.now(), harness);
204
+ if (entry?.lease?.id !== leaseId)
205
+ continue;
206
+ deleteSession(bundleName, harness);
207
+ deleted++;
208
+ }
209
+ return deleted;
210
+ }
211
+ export function activeLeaseSessions(now = Date.now()) {
212
+ return rehydrateSessions(now)
213
+ .filter((item) => Boolean(item.entry.lease))
214
+ .map(({ name, entry }) => ({ name, lease: entry.lease }));
215
+ }
195
216
  /** Delete every session blob + the index (for `secrets lock --all`). */
196
217
  export function deleteAllSessions() {
197
218
  if (!shouldPersist())
@@ -136,15 +136,30 @@ export declare function verifyInstalledVersion(packageRoot: string, expectedVers
136
136
  */
137
137
  export declare function refreshAliasShims(packageRoot: string): void;
138
138
  export interface AgentsCliInstall {
139
- /** The PATH entry (`<dir>/agents`) that resolves to this install. */
140
- binPath: string;
139
+ /** The PATH entry (`<dir>/agents`) that resolves to this install, when found through PATH. */
140
+ binPath?: string;
141
141
  /** Package root containing package.json and dist/. */
142
142
  packageRoot: string;
143
143
  version: string;
144
+ /** Whether this copy uses the serialized, atomic helper-bundle installer. */
145
+ atomicHelperInstall: boolean;
144
146
  }
147
+ export interface FindAgentsCliInstallsOptions {
148
+ homeDir?: string;
149
+ fnmDir?: string;
150
+ npmCacheDir?: string;
151
+ globalNodeModulesDirs?: string[];
152
+ }
153
+ export interface MultiInstallInventoryEntry {
154
+ packageRoot: string;
155
+ version: string;
156
+ note: string;
157
+ }
158
+ export declare function buildMultiInstallInventory(runningRoot: string, runningVersion: string, installs: AgentsCliInstall[]): MultiInstallInventoryEntry[];
145
159
  /**
146
- * Scan PATH for `agents` entrypoints and resolve each to the agents-cli
147
- * package root it executes. More than one distinct root means upgrades,
160
+ * Resolve every `agents` entrypoint on PATH, then inspect the bounded global
161
+ * install layouts used by NVM, fnm, Volta, Bun, npm, and npx. More than one
162
+ * distinct package root means upgrades,
148
163
  * shims, and the command the user types can act on different copies — the
149
164
  * divergence behind silently-failing self-updates.
150
165
  *
@@ -158,4 +173,4 @@ export interface AgentsCliInstall {
158
173
  * @phnx-labs/agents-cli is some other tool and is skipped.
159
174
  * POSIX-only: Windows npm bins are .cmd wrappers, not symlinks.
160
175
  */
161
- export declare function findAgentsCliInstalls(pathEnv: string): AgentsCliInstall[];
176
+ export declare function findAgentsCliInstalls(pathEnv: string, opts?: FindAgentsCliInstallsOptions): AgentsCliInstall[];