@phnx-labs/agents-cli 1.22.84 → 1.22.85

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 (257) hide show
  1. package/CHANGELOG.md +57 -0
  2. package/README.md +80 -53
  3. package/dist/bootstrap.d.ts +7 -6
  4. package/dist/bootstrap.js +20 -27
  5. package/dist/cli/command-registry.js +5 -1
  6. package/dist/commands/accounts.d.ts +6 -13
  7. package/dist/commands/accounts.js +330 -143
  8. package/dist/commands/apply.js +1 -1
  9. package/dist/commands/auth-mint.d.ts +7 -3
  10. package/dist/commands/auth-mint.js +21 -9
  11. package/dist/commands/auth.js +2 -3
  12. package/dist/commands/browser.js +10 -10
  13. package/dist/commands/daemon.d.ts +7 -3
  14. package/dist/commands/daemon.js +40 -25
  15. package/dist/commands/doctor.js +28 -2
  16. package/dist/commands/exec.js +87 -39
  17. package/dist/commands/fleet-capture.js +21 -2
  18. package/dist/commands/harness-wizard.js +2 -2
  19. package/dist/commands/lease.js +7 -12
  20. package/dist/commands/profiles.d.ts +2 -2
  21. package/dist/commands/profiles.js +20 -13
  22. package/dist/commands/repo.js +3 -3
  23. package/dist/commands/run-account-picker.d.ts +3 -3
  24. package/dist/commands/run-account-picker.js +29 -30
  25. package/dist/commands/secrets-passthrough.d.ts +20 -0
  26. package/dist/commands/secrets-passthrough.js +43 -0
  27. package/dist/commands/setup-accounts.d.ts +3 -2
  28. package/dist/commands/setup-accounts.js +4 -4
  29. package/dist/commands/setup-secrets.d.ts +16 -21
  30. package/dist/commands/setup-secrets.js +43 -224
  31. package/dist/commands/ssh.js +2 -2
  32. package/dist/commands/sync.js +1 -1
  33. package/dist/commands/update.d.ts +10 -0
  34. package/dist/commands/update.js +43 -40
  35. package/dist/commands/versions.d.ts +11 -11
  36. package/dist/commands/versions.js +131 -83
  37. package/dist/commands/view.d.ts +1 -9
  38. package/dist/commands/view.js +41 -55
  39. package/dist/commands/webhook.js +5 -5
  40. package/dist/commands/workflows.js +2 -1
  41. package/dist/index.d.ts +12 -12
  42. package/dist/index.js +13 -42
  43. package/dist/lib/account-capabilities.d.ts +1 -1
  44. package/dist/lib/account-capabilities.js +1 -1
  45. package/dist/lib/account-catalog.d.ts +121 -6
  46. package/dist/lib/account-catalog.js +421 -7
  47. package/dist/lib/account-registry.d.ts +8 -30
  48. package/dist/lib/account-registry.js +36 -79
  49. package/dist/lib/account-schema.d.ts +1 -1
  50. package/dist/lib/account-schema.js +1 -1
  51. package/dist/lib/accounting/account-pool-collect.js +16 -4
  52. package/dist/lib/accounting/rotate.d.ts +20 -7
  53. package/dist/lib/accounting/rotate.js +89 -13
  54. package/dist/lib/accounting/usage.js +11 -16
  55. package/dist/lib/accounts/add.d.ts +138 -0
  56. package/dist/lib/accounts/add.js +651 -0
  57. package/dist/lib/accounts/migrate.d.ts +117 -0
  58. package/dist/lib/accounts/migrate.js +536 -0
  59. package/dist/lib/accounts/slots.d.ts +13 -0
  60. package/dist/lib/accounts/slots.js +100 -0
  61. package/dist/lib/agent-spec/agents.d.ts +16 -0
  62. package/dist/lib/agent-spec/agents.js +32 -4
  63. package/dist/lib/app-bundle-install.js +5 -4
  64. package/dist/lib/auth-health.d.ts +3 -0
  65. package/dist/lib/auth-health.js +11 -0
  66. package/dist/lib/auth-mint.d.ts +52 -16
  67. package/dist/lib/auth-mint.js +117 -39
  68. package/dist/lib/browser/chrome.d.ts +1 -1
  69. package/dist/lib/browser/chrome.js +5 -5
  70. package/dist/lib/byok-usage.js +3 -3
  71. package/dist/lib/claude-account-token.d.ts +47 -3
  72. package/dist/lib/claude-account-token.js +172 -50
  73. package/dist/lib/cloud/antigravity.js +4 -4
  74. package/dist/lib/cloud/cursor.js +4 -4
  75. package/dist/lib/crabbox/cli.d.ts +1 -1
  76. package/dist/lib/crabbox/cli.js +6 -6
  77. package/dist/lib/crabbox/runtimes.d.ts +3 -3
  78. package/dist/lib/crabbox/runtimes.js +5 -5
  79. package/dist/lib/daemon/account-state-daemon-service.d.ts +37 -1
  80. package/dist/lib/daemon/account-state-daemon-service.js +199 -3
  81. package/dist/lib/daemon/auth-sync-service.js +28 -1
  82. package/dist/lib/daemon/daemon.d.ts +6 -16
  83. package/dist/lib/daemon/daemon.js +52 -97
  84. package/dist/lib/daemon/daemon.test-fixture.d.ts +7 -9
  85. package/dist/lib/daemon/daemon.test-fixture.js +22 -40
  86. package/dist/lib/daemon/harness-update-service.d.ts +1 -1
  87. package/dist/lib/daemon/harness-update-service.js +1 -1
  88. package/dist/lib/daemon/runner.d.ts +15 -3
  89. package/dist/lib/daemon/runner.js +49 -24
  90. package/dist/lib/daemon-health.d.ts +1 -2
  91. package/dist/lib/daemon-health.js +7 -6
  92. package/dist/lib/daemon-services.d.ts +1 -1
  93. package/dist/lib/daemon-services.js +1 -11
  94. package/dist/lib/daemon-webhooks.d.ts +8 -7
  95. package/dist/lib/daemon-webhooks.js +10 -9
  96. package/dist/lib/device-config.js +1 -1
  97. package/dist/lib/devices/doctor-findings.d.ts +1 -1
  98. package/dist/lib/devices/harness-inventory.d.ts +39 -0
  99. package/dist/lib/devices/harness-inventory.js +126 -4
  100. package/dist/lib/doctor-diff.js +2 -1
  101. package/dist/lib/exec-account-home.d.ts +38 -0
  102. package/dist/lib/exec-account-home.js +164 -0
  103. package/dist/lib/exec.d.ts +30 -0
  104. package/dist/lib/exec.js +101 -31
  105. package/dist/lib/fleet/apply.d.ts +2 -2
  106. package/dist/lib/fleet/apply.js +4 -4
  107. package/dist/lib/fleet/auth-sync.js +4 -3
  108. package/dist/lib/fleet-shared-repo-sync.js +6 -9
  109. package/dist/lib/harness/adapter.d.ts +7 -7
  110. package/dist/lib/harness/adapter.js +10 -6
  111. package/dist/lib/harness/adapters/grok.js +8 -3
  112. package/dist/lib/harness/adapters/muse.js +1 -1
  113. package/dist/lib/harness/adapters/opencode.js +12 -3
  114. package/dist/lib/harness-auth-capabilities.d.ts +61 -0
  115. package/dist/lib/harness-auth-capabilities.js +52 -0
  116. package/dist/lib/helper-versions.d.ts +6 -4
  117. package/dist/lib/helper-versions.js +5 -4
  118. package/dist/lib/hosts/credential-transport.d.ts +10 -0
  119. package/dist/lib/hosts/credential-transport.js +37 -0
  120. package/dist/lib/hosts/dispatch.d.ts +7 -0
  121. package/dist/lib/hosts/dispatch.js +13 -6
  122. package/dist/lib/identity/client.d.ts +3 -3
  123. package/dist/lib/identity/client.js +3 -3
  124. package/dist/lib/installations/index.d.ts +1 -1
  125. package/dist/lib/installations/index.js +1 -1
  126. package/dist/lib/installations/migrate.js +9 -0
  127. package/dist/lib/installations/resolve.js +1 -1
  128. package/dist/lib/installations/shims.d.ts +15 -0
  129. package/dist/lib/installations/shims.js +69 -0
  130. package/dist/lib/installations/store.d.ts +43 -0
  131. package/dist/lib/installations/store.js +75 -7
  132. package/dist/lib/installations/versions.js +1 -1
  133. package/dist/lib/menubar/install-menubar.d.ts +3 -3
  134. package/dist/lib/menubar/install-menubar.js +3 -3
  135. package/dist/lib/native-accounts.d.ts +35 -0
  136. package/dist/lib/native-accounts.js +41 -0
  137. package/dist/lib/net-close.d.ts +11 -0
  138. package/dist/lib/net-close.js +25 -0
  139. package/dist/lib/openclaw-keychain.js +27 -1
  140. package/dist/lib/profiles.js +22 -9
  141. package/dist/lib/project-resources.js +1 -1
  142. package/dist/lib/reserved-stores.d.ts +53 -0
  143. package/dist/lib/reserved-stores.js +120 -0
  144. package/dist/lib/secrets-client.d.ts +191 -0
  145. package/dist/lib/secrets-client.js +710 -0
  146. package/dist/lib/secrets-policy.d.ts +222 -0
  147. package/dist/lib/secrets-policy.js +483 -0
  148. package/dist/lib/secrets-types.d.ts +188 -0
  149. package/dist/lib/secrets-types.js +24 -0
  150. package/dist/lib/service-manifest.js +3 -4
  151. package/dist/lib/session/db.d.ts +18 -0
  152. package/dist/lib/session/db.js +47 -14
  153. package/dist/lib/session/sync/config.js +2 -2
  154. package/dist/lib/sha256-asset.d.ts +11 -16
  155. package/dist/lib/sha256-asset.js +11 -16
  156. package/dist/lib/share/config.js +14 -5
  157. package/dist/lib/signin-badge.d.ts +26 -0
  158. package/dist/lib/signin-badge.js +42 -0
  159. package/dist/lib/staleness/detectors/workflows.js +3 -105
  160. package/dist/lib/staleness/writers/workflows.js +2 -1
  161. package/dist/lib/state.d.ts +7 -4
  162. package/dist/lib/state.js +18 -7
  163. package/dist/lib/sync-umbrella.js +2 -2
  164. package/dist/lib/teams/agents.js +1 -1
  165. package/dist/lib/types.d.ts +74 -33
  166. package/dist/lib/view-types.d.ts +6 -3
  167. package/dist/lib/workflows-registry.d.ts +71 -0
  168. package/dist/lib/workflows-registry.js +280 -0
  169. package/dist/lib/workflows.d.ts +21 -21
  170. package/dist/lib/workflows.js +12 -327
  171. package/package.json +2 -3
  172. package/scripts/postinstall.js +4 -29
  173. package/dist/commands/secrets-import.d.ts +0 -18
  174. package/dist/commands/secrets-import.js +0 -74
  175. package/dist/commands/secrets-migrate.d.ts +0 -25
  176. package/dist/commands/secrets-migrate.js +0 -334
  177. package/dist/commands/secrets-rotate-passphrase.d.ts +0 -17
  178. package/dist/commands/secrets-rotate-passphrase.js +0 -96
  179. package/dist/commands/secrets-sync.d.ts +0 -11
  180. package/dist/commands/secrets-sync.js +0 -153
  181. package/dist/commands/secrets-vault.d.ts +0 -10
  182. package/dist/commands/secrets-vault.js +0 -130
  183. package/dist/commands/secrets.d.ts +0 -212
  184. package/dist/commands/secrets.js +0 -3030
  185. package/dist/lib/accounts/connect.d.ts +0 -176
  186. package/dist/lib/accounts/connect.js +0 -453
  187. package/dist/lib/daemon/keychain-reap-service.d.ts +0 -17
  188. package/dist/lib/daemon/keychain-reap-service.js +0 -32
  189. package/dist/lib/daemon/secrets-broker-service.d.ts +0 -21
  190. package/dist/lib/daemon/secrets-broker-service.js +0 -51
  191. package/dist/lib/secrets/agent.d.ts +0 -358
  192. package/dist/lib/secrets/agent.js +0 -1291
  193. package/dist/lib/secrets/audit.d.ts +0 -46
  194. package/dist/lib/secrets/audit.js +0 -101
  195. package/dist/lib/secrets/bundles.d.ts +0 -290
  196. package/dist/lib/secrets/bundles.js +0 -1547
  197. package/dist/lib/secrets/download-keychain.d.ts +0 -47
  198. package/dist/lib/secrets/download-keychain.js +0 -70
  199. package/dist/lib/secrets/drivers/rush.d.ts +0 -14
  200. package/dist/lib/secrets/drivers/rush.js +0 -90
  201. package/dist/lib/secrets/fallback.d.ts +0 -48
  202. package/dist/lib/secrets/fallback.js +0 -48
  203. package/dist/lib/secrets/filestore.d.ts +0 -222
  204. package/dist/lib/secrets/filestore.js +0 -1099
  205. package/dist/lib/secrets/headless.d.ts +0 -43
  206. package/dist/lib/secrets/headless.js +0 -56
  207. package/dist/lib/secrets/icloud-import.d.ts +0 -79
  208. package/dist/lib/secrets/icloud-import.js +0 -206
  209. package/dist/lib/secrets/index.d.ts +0 -451
  210. package/dist/lib/secrets/index.js +0 -1568
  211. package/dist/lib/secrets/install-helper.d.ts +0 -72
  212. package/dist/lib/secrets/install-helper.js +0 -245
  213. package/dist/lib/secrets/lease.d.ts +0 -25
  214. package/dist/lib/secrets/lease.js +0 -44
  215. package/dist/lib/secrets/linux.d.ts +0 -77
  216. package/dist/lib/secrets/linux.js +0 -393
  217. package/dist/lib/secrets/list-filter.d.ts +0 -109
  218. package/dist/lib/secrets/list-filter.js +0 -261
  219. package/dist/lib/secrets/mcp.d.ts +0 -93
  220. package/dist/lib/secrets/mcp.js +0 -211
  221. package/dist/lib/secrets/profiles.d.ts +0 -10
  222. package/dist/lib/secrets/profiles.js +0 -13
  223. package/dist/lib/secrets/push.d.ts +0 -133
  224. package/dist/lib/secrets/push.js +0 -273
  225. package/dist/lib/secrets/rc-hygiene.d.ts +0 -63
  226. package/dist/lib/secrets/rc-hygiene.js +0 -143
  227. package/dist/lib/secrets/read-backoff.d.ts +0 -27
  228. package/dist/lib/secrets/read-backoff.js +0 -64
  229. package/dist/lib/secrets/reaper.d.ts +0 -90
  230. package/dist/lib/secrets/reaper.js +0 -243
  231. package/dist/lib/secrets/remote.d.ts +0 -152
  232. package/dist/lib/secrets/remote.js +0 -344
  233. package/dist/lib/secrets/reserved-sync.d.ts +0 -66
  234. package/dist/lib/secrets/reserved-sync.js +0 -147
  235. package/dist/lib/secrets/scope.d.ts +0 -26
  236. package/dist/lib/secrets/scope.js +0 -29
  237. package/dist/lib/secrets/session-store.d.ts +0 -107
  238. package/dist/lib/secrets/session-store.js +0 -342
  239. package/dist/lib/secrets/sync-backend.d.ts +0 -48
  240. package/dist/lib/secrets/sync-backend.js +0 -13
  241. package/dist/lib/secrets/sync-commands.d.ts +0 -21
  242. package/dist/lib/secrets/sync-commands.js +0 -21
  243. package/dist/lib/secrets/sync.d.ts +0 -48
  244. package/dist/lib/secrets/sync.js +0 -237
  245. package/dist/lib/secrets/unlock-hints.d.ts +0 -27
  246. package/dist/lib/secrets/unlock-hints.js +0 -36
  247. package/dist/lib/secrets/usage-db.d.ts +0 -46
  248. package/dist/lib/secrets/usage-db.js +0 -96
  249. package/dist/lib/secrets/vault-age-helper.d.ts +0 -1
  250. package/dist/lib/secrets/vault-age-helper.js +0 -34
  251. package/dist/lib/secrets/vault.d.ts +0 -49
  252. package/dist/lib/secrets/vault.js +0 -399
  253. package/dist/lib/secrets/windows.d.ts +0 -81
  254. package/dist/lib/secrets/windows.js +0 -556
  255. package/scripts/install-helper.js +0 -97
  256. /package/dist/lib/{secrets/sync-passphrase.d.ts → sync-passphrase.d.ts} +0 -0
  257. /package/dist/lib/{secrets/sync-passphrase.js → sync-passphrase.js} +0 -0
@@ -1,1291 +0,0 @@
1
- /**
2
- * The secrets-agent: an in-memory broker that holds resolved bundle env after one
3
- * Touch ID unlock, so concurrent agents don't each prompt.
4
- *
5
- * Security model: while unlocked, any same-user process reaching the socket can
6
- * read it silently — the same trust boundary the keychain already concedes. We
7
- * bound it with per-bundle opt-in, a TTL (~7d), auto-wipe on sleep/logout, and
8
- * explicit lock. Nothing touches disk. Off-darwin the broker is unused.
9
- */
10
- import * as net from 'net';
11
- import * as fs from 'fs';
12
- import * as os from 'os';
13
- import * as path from 'path';
14
- import { randomBytes } from 'crypto';
15
- import { spawn, spawnSync, execFileSync } from 'child_process';
16
- import { getHelpersDir, readMeta } from '../state.js';
17
- import { isAlive, waitForExit } from '../platform/process.js';
18
- import { getKeychainHelperPath } from './install-helper.js';
19
- import { getCliVersion, getCliVersionFresh } from '../version.js';
20
- import { getCliLaunch } from '../cli-entry.js';
21
- import { GLOBAL_HARNESS, bundleScopeChain } from './scope.js';
22
- import { deleteLeaseSession, rehydrateSessions, pruneSessionsOnSleep } from './session-store.js';
23
- import { serviceManagerRegistrationAllowed } from '../service-manifest.js';
24
- import { SYNC_GET_CMD, SYNC_PING_CMD, SYNC_LOCK_CMD } from './sync-commands.js';
25
- import { MAX_LEASE_MS, MIN_LEASE_MS } from './lease.js';
26
- import { selectLeasedEnv } from './lease.js';
27
- import { emitSecretAudit } from './audit.js';
28
- import { isDaemonServiceEnabled } from '../daemon-services.js';
29
- // Re-exported from scope.ts to break the agent.ts ↔ session-store.ts import cycle.
30
- export { GLOBAL_HARNESS, bundleScopeChain };
31
- /** Bumped when the wire protocol changes; a client that pings a mismatched
32
- * server kills and respawns it rather than talking a stale dialect. */
33
- const PROTOCOL_VERSION = 3;
34
- /** Default lifetime of an unlocked bundle when `--ttl` is not given. */
35
- export const DEFAULT_TTL_MS = 7 * 24 * 60 * 60 * 1000; // 7d
36
- /**
37
- * Whether the secrets broker service is enabled in daemon service config.
38
- */
39
- export function isSecretsBrokerEnabled() {
40
- return isDaemonServiceEnabled('secrets-broker');
41
- }
42
- /**
43
- * Reserved store-key prefix for the `secrets list` metadata snapshot cache.
44
- * Keyed by a hash of the keychain name-set; adding/removing/renaming a bundle
45
- * changes the key and invalidates passively. The '!' sentinel cannot collide
46
- * with a real bundle name and is safe as argv.
47
- */
48
- export const META_CACHE_PREFIX = '!meta:';
49
- /** After the store goes empty (all bundles locked or expired) for this long,
50
- * the broker exits so no idle process lingers holding a socket. */
51
- const IDLE_EXIT_MS = 5 * 60 * 1000; // 5m
52
- /** How often the broker sweeps expired entries. */
53
- const SWEEP_INTERVAL_MS = 30 * 1000;
54
- /** How many times a starting broker probes an in-use socket before treating its
55
- * owner as gone. One probe is not enough: the broker is single-threaded, so a
56
- * large read or the startup rehydrate can outlast a single ping budget while the
57
- * process is healthy. */
58
- const BIND_PROBE_ATTEMPTS = 3;
59
- /** How long a broker teardown waits for the old process to actually exit before
60
- * clearing its socket + ownership record. */
61
- const BROKER_STOP_GRACE_MS = 3000;
62
- /** Pause between those probes. */
63
- const BIND_PROBE_INTERVAL_MS = 250;
64
- function delay(ms) {
65
- return new Promise((resolve) => setTimeout(resolve, ms));
66
- }
67
- /**
68
- * Timeouts for the synchronous broker clients. Parent `spawnSync` budgets are
69
- * looser than socket budgets so a slow child boot isn't misreported as "broker
70
- * down" (which costs a Touch ID prompt).
71
- */
72
- const SOCKET_GET_TIMEOUT_MS = 2000;
73
- const SOCKET_PING_TIMEOUT_MS = 700;
74
- const SOCKET_LOCK_TIMEOUT_MS = 2000;
75
- const SYNC_GET_TIMEOUT_MS = 4000;
76
- const SYNC_PING_TIMEOUT_MS = 2500;
77
- const SYNC_LOCK_TIMEOUT_MS = 4000;
78
- // Re-exported from sync-commands.ts so index.ts can share the same argv tokens
79
- // without importing this module (would create a cycle/startup cost).
80
- export { SYNC_GET_CMD, SYNC_PING_CMD, SYNC_LOCK_CMD } from './sync-commands.js';
81
- /**
82
- * Decide whether a persistent broker should exit so launchd relaunches it on
83
- * freshly-installed code. Only when the store is empty: exiting with held
84
- * bundles would force a re-prompt. Deferring protects against rapid upgrades
85
- * wiping the hot cache (#435).
86
- */
87
- export function shouldSelfHealForUpgrade(persistent, storeSize, runningVersion, onDiskVersion) {
88
- if (!persistent)
89
- return false;
90
- if (storeSize > 0)
91
- return false; // hot cache — defer rather than wipe unlocks
92
- if (runningVersion === 'unknown' || onDiskVersion === 'unknown')
93
- return false;
94
- return onDiskVersion !== runningVersion;
95
- }
96
- /**
97
- * Client-side twin: may only tear down a version-skewed broker when it holds
98
- * no real unlocks, otherwise every held bundle re-prompts (#435).
99
- */
100
- export function shouldTeardownVersionSkewedBroker(realHeldBundles) {
101
- return realHeldBundles === 0;
102
- }
103
- /**
104
- * Whether a client may evict a version-skewed broker. A daemon-hosted broker
105
- * must never be client-evicted: the daemon owns the socket via ownerPath(), so
106
- * unlinking it would orphan the broker until daemon restart (#435).
107
- */
108
- export function shouldClientEvictSkewedBroker(daemonRunning, realHeldBundles) {
109
- if (daemonRunning)
110
- return false;
111
- return shouldTeardownVersionSkewedBroker(realHeldBundles);
112
- }
113
- function onDarwin() {
114
- return process.platform === 'darwin';
115
- }
116
- /**
117
- * Build the broker's in-memory store from durable sessions so unlocks survive
118
- * restart/upgrade. Empty off darwin or when nothing was held.
119
- */
120
- function rehydrateStore(now = Date.now()) {
121
- const store = new Map();
122
- for (const { name, entry } of rehydrateSessions(now)) {
123
- const harness = entry.harness || GLOBAL_HARNESS;
124
- store.set(scopedBundleKey(name, harness), { bundle: entry.bundle, env: entry.env, expiresAt: entry.expiresAt, harness, lease: entry.lease });
125
- }
126
- return store;
127
- }
128
- /** Broker runtime dir (0700). Override with AGENTS_SECRETS_AGENT_DIR for tests. */
129
- function agentDir() {
130
- const dir = process.env.AGENTS_SECRETS_AGENT_DIR || path.join(getHelpersDir(), 'secrets-agent');
131
- fs.mkdirSync(dir, { recursive: true });
132
- try {
133
- fs.chmodSync(dir, 0o700);
134
- }
135
- catch { /* best effort */ }
136
- return dir;
137
- }
138
- function socketPath() {
139
- return path.join(agentDir(), 'agent.sock');
140
- }
141
- /** Public accessor for the broker's socket path — `agents daemon status`/`services` reads it for display. */
142
- export function secretsBrokerSocketPath() {
143
- return socketPath();
144
- }
145
- function pidPath() {
146
- return path.join(agentDir(), 'agent.pid');
147
- }
148
- /**
149
- * Per-broker capability token path (0600 inside the 0700 agent dir). The file
150
- * permission is the authorization boundary.
151
- */
152
- function tokenPath() {
153
- return path.join(agentDir(), 'agent.token');
154
- }
155
- /**
156
- * Path of the current socket owner's pid. NOT `agent.pid`, which is the
157
- * standalone service's O_EXCL single-instance claim. Separating them prevents a
158
- * standby standalone from seeing a live holder and exiting in a restart loop.
159
- */
160
- function ownerPath() {
161
- return path.join(agentDir(), 'agent.owner');
162
- }
163
- /**
164
- * Read the current broker capability token. A restart mints a new token, so a
165
- * stale read fails soft and the caller falls back to a direct keychain read.
166
- */
167
- export function readAgentToken() {
168
- try {
169
- const t = fs.readFileSync(tokenPath(), 'utf-8').trim();
170
- return t.length > 0 ? t : null;
171
- }
172
- catch {
173
- return null;
174
- }
175
- }
176
- /** Mint and persist a fresh capability token (0600) at socket-bind time. */
177
- function writeAgentToken() {
178
- const token = randomBytes(32).toString('hex');
179
- const fp = tokenPath();
180
- fs.writeFileSync(fp, token, { mode: 0o600 });
181
- try {
182
- fs.chmodSync(fp, 0o600);
183
- }
184
- catch { /* dir 0700 already gates it */ }
185
- return token;
186
- }
187
- /**
188
- * Argv for re-invoking this CLI with a hidden subcommand. Uses getCliLaunch so
189
- * both JS-entry and Bun-standalone installs work; the old `process.argv[1]`
190
- * approach broke on standalone builds with a virtual `/$bunfs/root/agents` path.
191
- */
192
- function cliSpawn(sub) {
193
- const { command, args } = getCliLaunch(sub);
194
- return { cmd: command, args };
195
- }
196
- function brokerSpawn() {
197
- return cliSpawn(['secrets', '_agent-run']);
198
- }
199
- // ─── Legacy standalone launchd service (retired, #416) ───────────────────────
200
- // The broker is now hosted by the always-on daemon. The functions below only
201
- // detect and retire a plist left by older versions so the daemon owns the socket.
202
- const SERVICE_LABEL = 'com.phnx-labs.agents-secrets-agent';
203
- // LaunchAgents dir. A relocated test dir is not launchd-managed, so retirement
204
- // there is pure file removal.
205
- function launchAgentsDir() {
206
- return process.env.AGENTS_SECRETS_LAUNCHAGENTS_DIR || path.join(os.homedir(), 'Library', 'LaunchAgents');
207
- }
208
- function servicePlistPath() {
209
- return path.join(launchAgentsDir(), `${SERVICE_LABEL}.plist`);
210
- }
211
- /** True if a legacy standalone-broker launchd plist is still installed. */
212
- export function secretsAgentServiceInstalled() {
213
- return onDarwin() && fs.existsSync(servicePlistPath());
214
- }
215
- /**
216
- * Retire the legacy standalone launchd service so the daemon owns the socket.
217
- * Idempotent; does not wipe held bundles.
218
- */
219
- export function retireLegacySecretsAgentService() {
220
- if (!onDarwin() || !secretsAgentServiceInstalled())
221
- return;
222
- const plist = servicePlistPath();
223
- // Only the real LaunchAgents dir is launchd-managed; a relocated (test) dir
224
- // has no bootstrapped job, so skip launchctl and just remove the plist.
225
- // Also skip launchctl under a redirected HOME: launchctl is per-user-session
226
- // and HOME-independent, so a sandboxed process would still talk to the real
227
- // launchd (RUSH-2968).
228
- const reg = serviceManagerRegistrationAllowed();
229
- if (!process.env.AGENTS_SECRETS_LAUNCHAGENTS_DIR && reg.allowed) {
230
- const uid = process.getuid?.() ?? 0;
231
- try {
232
- execFileSync('launchctl', ['bootout', `gui/${uid}/${SERVICE_LABEL}`], { stdio: ['ignore', 'ignore', 'ignore'] });
233
- }
234
- catch {
235
- try {
236
- execFileSync('launchctl', ['unload', '-w', plist], { stdio: ['ignore', 'ignore', 'ignore'] });
237
- }
238
- catch { /* not loaded */ }
239
- }
240
- }
241
- else if (!reg.allowed) {
242
- process.stderr.write(`[agents] ${reg.reason}\n`);
243
- }
244
- try {
245
- fs.unlinkSync(plist);
246
- }
247
- catch { /* already gone */ }
248
- }
249
- /**
250
- * Stop the persistent broker for `agents secrets stop`: wipe held bundles, then
251
- * retire any legacy standalone service. The daemon-hosted broker itself is left
252
- * running because it backs unrelated background work.
253
- */
254
- export async function uninstallSecretsAgentService() {
255
- if (!onDarwin())
256
- return;
257
- await agentLock(); // wipe the in-memory store before retiring the legacy service
258
- retireLegacySecretsAgentService();
259
- }
260
- // ─── Broker server (runs in the detached `secrets _agent-run` process) ───────
261
- /**
262
- * Count of real unlocked bundles, excluding the internal `secrets list` metadata
263
- * cache. A metadata-only store must read as empty so it doesn't block upgrade
264
- * self-heal or idle-exit (#435). Exported for tests.
265
- */
266
- export function realBundleCount(store) {
267
- let n = 0;
268
- for (const e of store.values())
269
- if (!e.bundle.name.startsWith(META_CACHE_PREFIX))
270
- n++;
271
- return n;
272
- }
273
- export function scopedBundleKey(name, harness) {
274
- return `${harness}:${name}`;
275
- }
276
- /** Pure request handler over the in-memory store. Exported for tests. */
277
- export function handleAgentRequest(store, req, now = Date.now(),
278
- // Eviction tombstones: reject loads whose snapshot predates the eviction so a
279
- // detached auto-load can't re-populate stale state.
280
- evictedAt = new Map()) {
281
- switch (req.cmd) {
282
- case 'ping':
283
- // Report the running version, not on-disk, so clients detect pre-upgrade brokers.
284
- return { ok: true, cmd: 'ping', version: PROTOCOL_VERSION, cliVersion: getCliVersion() };
285
- case 'get': {
286
- // own-harness → global: scoped grants win, unscoped unlocks serve all.
287
- for (const scope of bundleScopeChain(req.harness)) {
288
- const key = scopedBundleKey(req.name, scope);
289
- const e = store.get(key);
290
- if (!e)
291
- continue;
292
- if (now >= e.expiresAt) {
293
- store.delete(key);
294
- if (e.lease) {
295
- deleteLeaseSession(e.lease.id);
296
- 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 });
297
- }
298
- continue;
299
- }
300
- return { ok: true, cmd: 'get', hit: true, bundle: e.bundle, env: e.env, lease: e.lease };
301
- }
302
- return { ok: true, cmd: 'get', hit: false };
303
- }
304
- case 'load': {
305
- const evictedTs = evictedAt.get(req.name);
306
- if (evictedTs !== undefined && req.snapshotAt !== undefined && req.snapshotAt <= evictedTs) {
307
- // Reject loads captured before a mutating write evicted this name.
308
- return { ok: false, error: `stale load for '${req.name}': snapshot predates an eviction` };
309
- }
310
- const harness = req.harness || GLOBAL_HARNESS;
311
- let env = req.env;
312
- if (req.lease) {
313
- try {
314
- env = selectLeasedEnv(req.lease, req.env, now);
315
- }
316
- catch (err) {
317
- return { ok: false, error: err.message };
318
- }
319
- }
320
- store.set(scopedBundleKey(req.name, harness), { bundle: req.bundle, env, expiresAt: req.lease?.expiresAt ?? now + req.ttlMs, harness, lease: req.lease });
321
- return { ok: true, cmd: 'load' };
322
- }
323
- case 'lock': {
324
- if (req.name) {
325
- let wiped = 0;
326
- for (const [key, entry] of store)
327
- if (entry.bundle.name === req.name && store.delete(key))
328
- wiped++;
329
- evictedAt.set(req.name, now);
330
- return { ok: true, cmd: 'lock', wiped };
331
- }
332
- const wiped = store.size;
333
- for (const entry of store.values())
334
- evictedAt.set(entry.bundle.name, now);
335
- store.clear();
336
- return { ok: true, cmd: 'lock', wiped };
337
- }
338
- case 'revoke': {
339
- let wiped = 0;
340
- for (const [key, entry] of store)
341
- if (entry.lease?.id === req.leaseId && store.delete(key))
342
- wiped++;
343
- return { ok: true, cmd: 'revoke', wiped };
344
- }
345
- case 'status': {
346
- const entries = [];
347
- for (const [name, e] of store) {
348
- if (now >= e.expiresAt)
349
- continue;
350
- if (e.bundle.name.startsWith(META_CACHE_PREFIX))
351
- continue; // internal list cache
352
- 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 });
353
- }
354
- return { ok: true, cmd: 'status', entries };
355
- }
356
- }
357
- }
358
- /**
359
- * Wipe the in-memory store only on SLEEP, not screen-lock (already gated by the
360
- * login password). Exported for regression coverage of the LOCK-survives /
361
- * SLEEP-wipes contract.
362
- */
363
- export function shouldWipeOnWatchEvent(chunk) {
364
- return /\bSLEEP\b/.test(chunk);
365
- }
366
- /**
367
- * Authorization gate: every request except `ping` must carry the per-broker
368
- * capability token from the 0600 token file. `ping` stays unauthenticated so
369
- * clients can probe reachability before reading the token. Fail closed. Exported
370
- * for tests.
371
- */
372
- export function isRequestAuthorized(req, expectedToken) {
373
- if (req.cmd === 'ping')
374
- return true;
375
- if (!expectedToken)
376
- return false;
377
- return req.token === expectedToken;
378
- }
379
- /**
380
- * Build the socket `connection` handler shared by standalone and daemon-hosted
381
- * brokers. Newline-framed JSON in, one response line out, with per-request token
382
- * lookup so rotation on restart is picked up.
383
- */
384
- export function makeConnectionHandler(handle, token) {
385
- return (conn) => {
386
- conn.setEncoding('utf-8');
387
- let buf = '';
388
- conn.on('data', (chunk) => {
389
- buf += chunk;
390
- let nl;
391
- while ((nl = buf.indexOf('\n')) >= 0) {
392
- const line = buf.slice(0, nl);
393
- buf = buf.slice(nl + 1);
394
- if (!line.trim())
395
- continue;
396
- let resp;
397
- try {
398
- const req = JSON.parse(line);
399
- resp = isRequestAuthorized(req, token())
400
- ? handle(req)
401
- : { ok: false, error: 'unauthorized' };
402
- }
403
- catch (err) {
404
- resp = { ok: false, error: err.message };
405
- }
406
- conn.write(JSON.stringify(resp) + '\n');
407
- }
408
- });
409
- conn.on('error', () => { });
410
- };
411
- }
412
- /**
413
- * Whether a live process still owns the broker pid file. A single missed ping
414
- * isn't proof of death: the single-threaded broker can blow the ping budget
415
- * while healthy. The pid file is the second liveness signal that makes reclaim
416
- * safe.
417
- */
418
- /** Drop our ownership record only if it is still ours. */
419
- export function releaseBrokerPid() {
420
- try {
421
- if (parseInt(fs.readFileSync(ownerPath(), 'utf-8').trim(), 10) === process.pid) {
422
- fs.unlinkSync(ownerPath());
423
- }
424
- }
425
- catch { /* absent or unreadable — nothing to release */ }
426
- }
427
- export function brokerPidAlive() {
428
- try {
429
- const holder = parseInt(fs.readFileSync(ownerPath(), 'utf-8').trim(), 10);
430
- return !isNaN(holder) && holder !== process.pid && isAlive(holder);
431
- }
432
- catch {
433
- return false; // no pid file → nothing claims ownership
434
- }
435
- }
436
- /**
437
- * Bind the shared broker socket without stealing it from a live owner. Uses
438
- * retries plus pid-file liveness, not a single ping, to decide "unreachable".
439
- */
440
- async function bindBrokerSocket(sock, onConnection) {
441
- const listenOnce = () => new Promise((resolve, reject) => {
442
- const server = net.createServer(onConnection);
443
- const onError = (err) => {
444
- if (err.code === 'EADDRINUSE')
445
- resolve('inuse');
446
- else
447
- reject(err);
448
- };
449
- server.once('error', onError);
450
- server.listen(sock, () => {
451
- try {
452
- fs.chmodSync(sock, 0o600);
453
- }
454
- catch { /* dir 0700 already gates it */ }
455
- // Mint the token and record ownership only once we are the confirmed owner.
456
- try {
457
- writeAgentToken();
458
- }
459
- catch { /* dir 0700 gates the socket regardless */ }
460
- try {
461
- fs.writeFileSync(ownerPath(), String(process.pid));
462
- }
463
- catch { /* dir 0700 gates it */ }
464
- resolve(server);
465
- });
466
- });
467
- let bound = await listenOnce();
468
- if (bound !== 'inuse')
469
- return bound;
470
- // Give a busy owner several chances before concluding it is gone.
471
- for (let attempt = 0; attempt < BIND_PROBE_ATTEMPTS; attempt++) {
472
- if ((await agentPing()).reachable)
473
- return null;
474
- if (attempt < BIND_PROBE_ATTEMPTS - 1)
475
- await delay(BIND_PROBE_INTERVAL_MS);
476
- }
477
- // Refuse to steal from a process that still owns the pid file.
478
- if (brokerPidAlive()) {
479
- throw new Error(`Secrets broker socket is held by a live process that is not answering: ${sock}. ` +
480
- `Run 'agents secrets lock --all' then retry, or stop the stuck broker.`);
481
- }
482
- try {
483
- fs.unlinkSync(sock);
484
- }
485
- catch { /* disappeared between probe and reclaim */ }
486
- bound = await listenOnce();
487
- if (bound !== 'inuse')
488
- return bound;
489
- if ((await agentPing()).reachable)
490
- return null;
491
- throw new Error(`Secrets broker socket is in use but unreachable: ${sock}`);
492
- }
493
- /**
494
- * Run the standalone broker in the foreground. Spawned by ensureAgentRunning via
495
- * `agents secrets _agent-run`. Serves the socket, sweeps expired entries, wipes
496
- * on sleep, and self-exits when idle.
497
- */
498
- export async function runSecretsAgent(opts = {}) {
499
- if (!onDarwin())
500
- return null; // nothing to broker without biometry prompts
501
- // Persistent launchd service must never idle-exit; launchd would cold-start again.
502
- const persistent = opts.service === true;
503
- // O_EXCL single-instance guard.
504
- const pidFile = pidPath();
505
- try {
506
- const fd = fs.openSync(pidFile, fs.constants.O_CREAT | fs.constants.O_EXCL | fs.constants.O_WRONLY);
507
- fs.writeSync(fd, String(process.pid));
508
- fs.closeSync(fd);
509
- }
510
- catch (err) {
511
- if (err?.code === 'EEXIST') {
512
- const holder = parseInt(fs.readFileSync(pidFile, 'utf-8').trim(), 10);
513
- if (!isNaN(holder) && isAlive(holder))
514
- return null; // another broker is live
515
- // Stale pid — reclaim it.
516
- try {
517
- fs.unlinkSync(pidFile);
518
- }
519
- catch { /* race; fall through */ }
520
- fs.writeFileSync(pidFile, String(process.pid));
521
- }
522
- else {
523
- throw err;
524
- }
525
- }
526
- const store = rehydrateStore();
527
- // Track when the store last held something so idle brokers exit.
528
- let emptySince = Date.now();
529
- const sock = socketPath();
530
- const releasePid = () => {
531
- try {
532
- if (parseInt(fs.readFileSync(pidFile, 'utf-8').trim(), 10) === process.pid) {
533
- fs.unlinkSync(pidFile);
534
- }
535
- }
536
- catch { /* gone or no longer ours */ }
537
- };
538
- // Register lifecycle handlers before socket arbitration so a standby service
539
- // still releases its pid-file lease on kickstart/bootout.
540
- let standbyTimer = null;
541
- let cleanupActive = null;
542
- let shuttingDown = false;
543
- const onSigterm = () => shutdown(0);
544
- const onSigint = () => shutdown(0);
545
- const detachSignals = () => {
546
- process.off('SIGTERM', onSigterm);
547
- process.off('SIGINT', onSigint);
548
- };
549
- const shutdown = (code) => {
550
- if (shuttingDown)
551
- return;
552
- shuttingDown = true;
553
- if (standbyTimer) {
554
- clearTimeout(standbyTimer);
555
- standbyTimer = null;
556
- }
557
- if (cleanupActive)
558
- cleanupActive();
559
- else
560
- releasePid();
561
- process.exit(code);
562
- };
563
- process.on('SIGTERM', onSigterm);
564
- process.on('SIGINT', onSigint);
565
- // Capture the running version so the sweep can self-heal onto upgraded code.
566
- const runningVersion = getCliVersion();
567
- // "Warmth" for self-heal / idle-exit counts only real unlocked bundles, NOT
568
- // the internal `secrets list` metadata cache (#524). Otherwise a 7d-TTL list
569
- // cache would keep the store non-empty and (a) block the persistent broker
570
- // from self-healing onto a freshly-installed version for up to a week (#435's
571
- // gate is size===0), and (b) stop a one-off broker from ever idle-exiting. The
572
- // metadata cache is a disposable list snapshot — wiping it on upgrade/idle
573
- // costs at most one extra prompt on the next `secrets list`.
574
- const sweep = () => {
575
- const now = Date.now();
576
- for (const [name, e] of store)
577
- if (now >= e.expiresAt) {
578
- store.delete(name);
579
- if (e.lease) {
580
- deleteLeaseSession(e.lease.id);
581
- 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 });
582
- }
583
- }
584
- const live = realBundleCount(store);
585
- // Self-heal only while no real unlocks are held (#435).
586
- if (live === 0 &&
587
- shouldSelfHealForUpgrade(persistent, live, runningVersion, getCliVersionFresh())) {
588
- shutdown(0);
589
- return;
590
- }
591
- if (live === 0) {
592
- if (!persistent && now - emptySince >= IDLE_EXIT_MS)
593
- shutdown(0);
594
- }
595
- else {
596
- emptySince = now;
597
- }
598
- };
599
- const evictedAt = new Map();
600
- const handle = (req) => {
601
- const resp = handleAgentRequest(store, req, Date.now(), evictedAt);
602
- if (realBundleCount(store) > 0)
603
- emptySince = Date.now();
604
- return resp;
605
- };
606
- const onConnection = makeConnectionHandler(handle, readAgentToken);
607
- let server = null;
608
- do {
609
- try {
610
- server = await bindBrokerSocket(sock, onConnection);
611
- }
612
- catch (err) {
613
- detachSignals();
614
- releasePid();
615
- throw err;
616
- }
617
- if (!server && persistent) {
618
- // Stay quiescent rather than returning, or launchd KeepAlive would restart-loop.
619
- do {
620
- await new Promise((resolve) => {
621
- standbyTimer = setTimeout(() => {
622
- standbyTimer = null;
623
- resolve();
624
- }, 1000);
625
- });
626
- } while ((await agentPing()).reachable);
627
- }
628
- } while (!server && persistent);
629
- if (!server) {
630
- detachSignals();
631
- releasePid();
632
- return null;
633
- }
634
- let watcher = null;
635
- let sweepTimer = null;
636
- // Sync cleanup for signal-driven exit: can't await 'close', so fire-and-forget.
637
- cleanupActive = () => {
638
- store.clear();
639
- if (sweepTimer)
640
- clearInterval(sweepTimer);
641
- try {
642
- watcher?.kill();
643
- }
644
- catch { /* already gone */ }
645
- try {
646
- server.close();
647
- }
648
- catch { /* not listening */ }
649
- try {
650
- fs.unlinkSync(sock);
651
- }
652
- catch { /* gone */ }
653
- releaseBrokerPid();
654
- releasePid();
655
- };
656
- sweepTimer = setInterval(sweep, SWEEP_INTERVAL_MS);
657
- // Auto-lock on sleep. Wipe on SLEEP; screen-lock alone survives. If the helper
658
- // predates watch-lock, fall back to TTL-only.
659
- try {
660
- watcher = spawn(getKeychainHelperPath(), ['watch-lock'], { stdio: ['ignore', 'pipe', 'ignore'] });
661
- watcher.stdout?.setEncoding('utf-8');
662
- watcher.stdout?.on('data', (chunk) => {
663
- if (shouldWipeOnWatchEvent(chunk)) {
664
- store.clear();
665
- emptySince = Date.now();
666
- // Default (non-durable) sessions re-lock on sleep; durable ones survive.
667
- pruneSessionsOnSleep();
668
- }
669
- });
670
- watcher.on('error', () => { watcher = null; });
671
- }
672
- catch {
673
- watcher = null;
674
- }
675
- return {
676
- async close() {
677
- if (shuttingDown)
678
- return;
679
- shuttingDown = true;
680
- detachSignals();
681
- store.clear();
682
- if (sweepTimer)
683
- clearInterval(sweepTimer);
684
- try {
685
- watcher?.kill();
686
- }
687
- catch { /* already gone */ }
688
- await closeServerBounded(server);
689
- try {
690
- fs.unlinkSync(sock);
691
- }
692
- catch { /* gone */ }
693
- releaseBrokerPid();
694
- releasePid();
695
- },
696
- };
697
- }
698
- /**
699
- * Host the secrets broker inside the always-on daemon (#416). Serves the same
700
- * socket/protocol as the standalone broker, but daemon-safe: no pid-file guard,
701
- * no process.exit/SIG handlers, no self-heal/idle-exit (the daemon owns the
702
- * lifecycle). Returns null off-darwin.
703
- */
704
- export async function startHostedBroker() {
705
- if (!onDarwin())
706
- return null; // nothing to broker without biometry prompts
707
- const store = rehydrateStore();
708
- const sock = socketPath();
709
- const evictedAt = new Map();
710
- const handle = (req) => handleAgentRequest(store, req, Date.now(), evictedAt);
711
- const onConn = makeConnectionHandler(handle, readAgentToken);
712
- const server = await bindBrokerSocket(sock, onConn);
713
- if (!server)
714
- return null;
715
- // TTL eviction only. The daemon is always-on and owns its own lifecycle.
716
- const sweepTimer = setInterval(() => {
717
- const now = Date.now();
718
- for (const [name, e] of store) {
719
- if (now < e.expiresAt)
720
- continue;
721
- store.delete(name);
722
- if (e.lease) {
723
- deleteLeaseSession(e.lease.id);
724
- 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 });
725
- }
726
- }
727
- }, SWEEP_INTERVAL_MS);
728
- // Auto-lock on sleep, same as the standalone broker.
729
- let watcher = null;
730
- try {
731
- watcher = spawn(getKeychainHelperPath(), ['watch-lock'], { stdio: ['ignore', 'pipe', 'ignore'] });
732
- watcher.stdout?.setEncoding('utf-8');
733
- watcher.stdout?.on('data', (chunk) => {
734
- if (shouldWipeOnWatchEvent(chunk)) {
735
- store.clear();
736
- pruneSessionsOnSleep();
737
- }
738
- });
739
- watcher.on('error', () => { watcher = null; });
740
- }
741
- catch {
742
- watcher = null;
743
- }
744
- return {
745
- async close() {
746
- store.clear();
747
- clearInterval(sweepTimer);
748
- try {
749
- watcher?.kill();
750
- }
751
- catch { /* already gone */ }
752
- await closeServerBounded(server);
753
- try {
754
- fs.unlinkSync(sock);
755
- }
756
- catch { /* gone */ }
757
- releaseBrokerPid();
758
- },
759
- };
760
- }
761
- /** How long to wait for net.Server.close()'s 'close' event before giving up. */
762
- const SERVER_CLOSE_TIMEOUT_MS = 2_000;
763
- /**
764
- * Wait for net.Server.close()'s 'close' event (or a bounded timeout) so a
765
- * successor bind doesn't race a half-closed socket (RUSH-2421).
766
- */
767
- export function closeServerBounded(server, timeoutMs = SERVER_CLOSE_TIMEOUT_MS) {
768
- return new Promise((resolve) => {
769
- let settled = false;
770
- const finish = () => {
771
- if (settled)
772
- return;
773
- settled = true;
774
- clearTimeout(timer);
775
- resolve();
776
- };
777
- const timer = setTimeout(finish, timeoutMs);
778
- try {
779
- server.close(() => finish());
780
- }
781
- catch {
782
- // Already closed / not listening — treat as released.
783
- finish();
784
- }
785
- });
786
- }
787
- // ─── Client ──────────────────────────────────────────────────────────────────
788
- /** Open the socket, send one request, resolve the one response. Async path. */
789
- function request(req, timeoutMs = 2000) {
790
- // Attach the capability token to every command except ping.
791
- const authedReq = req.cmd === 'ping' ? req : { ...req, token: readAgentToken() ?? undefined };
792
- return new Promise((resolve) => {
793
- const conn = net.createConnection(socketPath());
794
- let buf = '';
795
- let done = false;
796
- const finish = (r) => {
797
- if (done)
798
- return;
799
- done = true;
800
- clearTimeout(timer);
801
- try {
802
- conn.destroy();
803
- }
804
- catch { /* already closed */ }
805
- resolve(r);
806
- };
807
- const timer = setTimeout(() => finish(null), timeoutMs);
808
- conn.on('error', () => finish(null));
809
- conn.on('connect', () => conn.write(JSON.stringify(authedReq) + '\n'));
810
- conn.setEncoding('utf-8');
811
- conn.on('data', (chunk) => {
812
- buf += chunk;
813
- const nl = buf.indexOf('\n');
814
- if (nl < 0)
815
- return;
816
- try {
817
- finish(JSON.parse(buf.slice(0, nl)));
818
- }
819
- catch {
820
- finish(null);
821
- }
822
- });
823
- });
824
- }
825
- /** Cheap socket-existence check so the never-unlocked path stays a single stat. */
826
- export function agentSocketExists() {
827
- return onDarwin() && fs.existsSync(socketPath());
828
- }
829
- /**
830
- * Spawn one of the `__secrets-*` sync clients via `getCliLaunch`. The old
831
- * `process.execPath -e` pattern broke on the bun-compiled Mach-O binary
832
- * (1.20.53); routing through getCliLaunch fixes both install shapes. The
833
- * subcommands are intercepted in index.ts before commander/startup so a cache
834
- * hit doesn't fork detached syncs or run update checks.
835
- */
836
- export function syncClientLaunch(sub, agentsBin) {
837
- return agentsBin ? getCliLaunch(sub, agentsBin) : getCliLaunch(sub);
838
- }
839
- function syncClient(sub, timeout) {
840
- // Soft on every failure: the fast path must fall through to the real keychain
841
- // read. getCliLaunch can throw on broken installs; crashing there turns a
842
- // graceful fallback into a failure at the worst time.
843
- try {
844
- const { command, args } = syncClientLaunch(sub);
845
- return spawnSync(command, args, { encoding: 'utf-8', timeout });
846
- }
847
- catch {
848
- return null;
849
- }
850
- }
851
- /**
852
- * Synchronous read for the hot path. Returns null on any failure so the caller
853
- * falls through to the real keychain. macOS only.
854
- */
855
- export function agentGetSync(name, harness = GLOBAL_HARNESS) {
856
- if (!isSecretsBrokerEnabled())
857
- return null;
858
- if (!agentSocketExists())
859
- return null;
860
- const r = syncClient([SYNC_GET_CMD, name, harness], SYNC_GET_TIMEOUT_MS);
861
- if (!r || r.status !== 0 || !r.stdout)
862
- return null;
863
- try {
864
- const o = JSON.parse(lastLine(r.stdout));
865
- if (!o || typeof o !== 'object' || !o.env)
866
- return null;
867
- return { bundle: o.bundle, env: o.env, lease: o.lease };
868
- }
869
- catch {
870
- return null;
871
- }
872
- }
873
- /**
874
- * Last non-empty line of a child's stdout — the payload line. Anchors on the
875
- * terminator so any future CLI startup chatter on stdout doesn't break JSON.parse
876
- * and silently turn cache hits into misses.
877
- */
878
- export function lastLine(stdout) {
879
- const lines = stdout.split('\n');
880
- for (let i = lines.length - 1; i >= 0; i--) {
881
- const t = lines[i].trim();
882
- if (t)
883
- return t;
884
- }
885
- return '';
886
- }
887
- /**
888
- * Synchronous liveness check: is a broker actually listening? Gates the
889
- * synchronous warm path so a stale socket doesn't drag a foreground read
890
- * through a cold-start. macOS only.
891
- */
892
- export function agentReachableSync() {
893
- if (!onDarwin())
894
- return false;
895
- if (!agentSocketExists())
896
- return false;
897
- const r = syncClient([SYNC_PING_CMD], SYNC_PING_TIMEOUT_MS);
898
- return r !== null && r.status === 0 && !r.error;
899
- }
900
- /**
901
- * Synchronously evict one bundle after a mutating write so the broker doesn't
902
- * serve a stale snapshot for the hold window. Best-effort silent no-op. macOS only.
903
- */
904
- export function agentEvictSync(name) {
905
- if (!onDarwin())
906
- return;
907
- if (!agentSocketExists())
908
- return;
909
- // No try/catch: syncClient swallows its own failures and returns null.
910
- syncClient([SYNC_LOCK_CMD, name], SYNC_LOCK_TIMEOUT_MS);
911
- }
912
- // ─── Top-level `__secrets-*` sync-client entrypoints ────────────────────────
913
- // Dispatched from index.ts before commander/startup so cache hits stay cheap.
914
- /** Body of `__secrets-get <name>`. Exit 0 = hit, 3 = miss/down. */
915
- export async function runAgentGetSync(name, harness = GLOBAL_HARNESS) {
916
- const r = await request({ cmd: 'get', name, harness }, SOCKET_GET_TIMEOUT_MS);
917
- if (r?.ok === true && r.cmd === 'get' && r.hit) {
918
- // Terminate with a newline and await the write callback; an unflushed pipe
919
- // write would be truncated on exit and cost a Touch ID prompt.
920
- const payload = JSON.stringify({ bundle: r.bundle, env: r.env, lease: r.lease }) + '\n';
921
- await new Promise((resolve) => { process.stdout.write(payload, () => resolve()); });
922
- return 0;
923
- }
924
- return 3;
925
- }
926
- /** Body of `__secrets-ping`. Exit 0 = listening broker, 3 = nothing there.
927
- * Does not gate on PROTOCOL_VERSION so version-skewed brokers still answer reads. */
928
- export async function runAgentPingSync() {
929
- const r = await request({ cmd: 'ping' }, SOCKET_PING_TIMEOUT_MS);
930
- return r?.ok === true && r.cmd === 'ping' ? 0 : 3;
931
- }
932
- /** Body of `__secrets-lock <name>`. Best-effort evict; exit 0 even when no
933
- * broker answers. An empty name is refused to avoid accidentally locking ALL
934
- * bundles. */
935
- export async function runAgentLockSync(name) {
936
- if (!name)
937
- return 3;
938
- await request({ cmd: 'lock', name }, SOCKET_LOCK_TIMEOUT_MS);
939
- return 0;
940
- }
941
- // Key inside the cached entry's env that holds the JSON metadata snapshot.
942
- const META_SNAPSHOT_KEY = '__snapshot__';
943
- /**
944
- * Read the cached `secrets list` metadata snapshot for a keychain name-set hash.
945
- * Reuses agentGetSync. The name-set hash is the cache key, so add/remove/rename
946
- * yields a clean miss.
947
- */
948
- export function agentGetMetaSync(nameSetHash) {
949
- if (!onDarwin())
950
- return null;
951
- const hit = agentGetSync(META_CACHE_PREFIX + nameSetHash);
952
- const raw = hit?.env?.[META_SNAPSHOT_KEY];
953
- if (!raw)
954
- return null;
955
- try {
956
- const parsed = JSON.parse(raw);
957
- return Array.isArray(parsed) ? parsed : null;
958
- }
959
- catch {
960
- return null;
961
- }
962
- }
963
- /**
964
- * Fire-and-forget: populate the broker with a metadata snapshot so the next
965
- * `secrets list` within the hold window is prompt-free. Stored under a reserved
966
- * META_CACHE_PREFIX key; snapshot travels over stdin. macOS only.
967
- */
968
- export function agentAutoLoadMetaSync(nameSetHash, bundles, ttlMs) {
969
- if (!onDarwin())
970
- return;
971
- const key = META_CACHE_PREFIX + nameSetHash;
972
- const placeholder = { name: key, vars: {} };
973
- agentAutoLoadSync(key, placeholder, { [META_SNAPSHOT_KEY]: JSON.stringify(bundles) }, ttlMs);
974
- }
975
- /** True unless `secrets.agent.auto` is explicitly disabled in agents.yaml. The
976
- * broker is the mechanism that delivers the `daily` default policy (one Touch ID
977
- * per ~7d), so auto-caching is ON by default; opt out with
978
- * `secrets.agent.auto: false`. Best-effort; an unreadable meta reads as on. */
979
- export function secretsAgentAutoEnabled() {
980
- try {
981
- return readMeta().secrets?.agent?.auto !== false;
982
- }
983
- catch {
984
- return true;
985
- }
986
- }
987
- /** Default `sleepPersist` for a new unlock when `--durable` is not passed. OFF by
988
- * default (the secure split default: survive restart, re-lock on sleep); set
989
- * `secrets.agent.durable: true` in agents.yaml to make every unlock sleep-durable.
990
- * Best-effort; an unreadable meta reads as off. */
991
- export function secretsAgentDurable() {
992
- try {
993
- return readMeta().secrets?.agent?.durable === true;
994
- }
995
- catch {
996
- return false;
997
- }
998
- }
999
- /** Minimum / maximum bounds for the configurable hold window. A too-small value
1000
- * would defeat the broker (constant re-prompts); a too-large one pins secrets in
1001
- * memory far longer than intended. */
1002
- export const MIN_HOLD_MS = MIN_LEASE_MS;
1003
- export const MAX_HOLD_MS = MAX_LEASE_MS;
1004
- /**
1005
- * How long an unlocked / auto-cached bundle is held before the next read
1006
- * re-prompts. Defaults to DEFAULT_TTL_MS (7d); override with
1007
- * `secrets.agent.holdMs` (milliseconds) in agents.yaml — e.g. 86400000 for a 24h
1008
- * cap. Clamped to [MIN_HOLD_MS, MAX_HOLD_MS] so a typo can neither disable the
1009
- * hold nor pin a secret in memory indefinitely. Best-effort: an unreadable or
1010
- * non-numeric value falls back to the 7d default. Pure except for the meta read.
1011
- */
1012
- export function secretsHoldMs() {
1013
- try {
1014
- return clampHoldMs(readMeta().secrets?.agent?.holdMs);
1015
- }
1016
- catch {
1017
- return DEFAULT_TTL_MS;
1018
- }
1019
- }
1020
- /** Pure clamp for a configured `holdMs`: a positive finite number is bounded to
1021
- * [MIN_HOLD_MS, MAX_HOLD_MS]; anything else (absent, 0, negative, NaN, non-number)
1022
- * falls back to the 7d default. Exported for direct unit testing. */
1023
- export function clampHoldMs(v) {
1024
- if (typeof v === 'number' && Number.isFinite(v) && v > 0) {
1025
- return Math.min(Math.max(Math.floor(v), MIN_HOLD_MS), MAX_HOLD_MS);
1026
- }
1027
- return DEFAULT_TTL_MS;
1028
- }
1029
- /**
1030
- * Fire-and-forget: populate the broker with a freshly-resolved bundle so the
1031
- * NEXT process reads it without a prompt. Used by the auto-cache path after a
1032
- * real keychain read of a `daily`-policy bundle, so the NEXT concurrent read is
1033
- * silent. Env travels over stdin, never argv.
1034
- *
1035
- * Reliability (this is what makes `daily` actually "stick"): when a broker is
1036
- * ALREADY listening, warm it SYNCHRONOUSLY with a bounded wait so the bundle is
1037
- * held by the time this process exits. The old detached-only path lost the race
1038
- * under load — a short-lived reader (`agents secrets export`, a release-script
1039
- * loop) exited before the unref'd worker connected, so the cache silently never
1040
- * populated and every read re-prompted despite the `daily` policy. Only when the
1041
- * broker must COLD-START (no socket yet) do we fall back to the detached worker,
1042
- * so a first-ever read never blocks on a multi-second broker boot.
1043
- *
1044
- * The worker reuses the robust `ensureAgentRunning` path (spawn-then-ping) rather
1045
- * than a tight inline retry loop. Best-effort; never throws. macOS only.
1046
- */
1047
- export function agentAutoLoadSync(name, bundle, env, ttlMs, harness = GLOBAL_HARNESS, lease,
1048
- // When the caller read the bundle from the keychain — lets the broker
1049
- // reject this load if an eviction lands in between (tombstones, above).
1050
- snapshotAt) {
1051
- if (!onDarwin())
1052
- return;
1053
- if (!isSecretsBrokerEnabled())
1054
- return;
1055
- const payload = JSON.stringify({ name, bundle, env, ttlMs, harness, lease, snapshotAt });
1056
- // Broker actually LISTENING → deterministic synchronous warm (bounded; the read
1057
- // already paid a Touch ID, so <1s here is invisible). We gate on a real liveness
1058
- // ping, NOT mere socket-file existence: a broker that died leaving its socket
1059
- // behind (crash, OOM, or the version-skew teardown in this file) would otherwise
1060
- // drag this FOREGROUND read through the worker's 20s cold-start budget on every
1061
- // read. A dead/stale socket fails the ping fast, so we drop straight to the
1062
- // detached path (which does the cold-start + stale-socket cleanup off the hot
1063
- // path) — restoring "a dead broker costs the foreground read nothing".
1064
- if (agentReachableSync()) {
1065
- try {
1066
- const { cmd, args } = cliSpawn(['secrets', '_agent-load']);
1067
- const r = spawnSync(cmd, args, { input: payload, timeout: 3000, stdio: ['pipe', 'ignore', 'ignore'] });
1068
- if (!r.error && r.status === 0)
1069
- return;
1070
- }
1071
- catch {
1072
- // fall through to the detached best-effort path
1073
- }
1074
- }
1075
- try {
1076
- const { cmd, args } = cliSpawn(['secrets', '_agent-load']);
1077
- const worker = spawn(cmd, args, { stdio: ['pipe', 'ignore', 'ignore'], detached: true });
1078
- worker.stdin?.write(payload);
1079
- worker.stdin?.end();
1080
- worker.unref();
1081
- }
1082
- catch {
1083
- // best-effort: the next read just pops Touch ID as it would today
1084
- }
1085
- }
1086
- /**
1087
- * Body of the hidden `secrets _agent-load` worker. Reads one `{name, bundle,
1088
- * env, ttlMs}` payload from stdin, ensures the broker is up (robust, generous
1089
- * budget), and loads the bundle into it.
1090
- *
1091
- * Exit code is load-truthful: 0 ONLY when the bundle was actually loaded into a
1092
- * reachable broker; non-zero on any failure (malformed payload, broker couldn't
1093
- * be brought up, or the load transport failed). The synchronous caller
1094
- * (agentAutoLoadSync) relies on this to decide whether to skip the detached
1095
- * fallback — a bare "process exited 0" would otherwise be a false-positive
1096
- * success that silently reintroduces the very re-prompt storm this path fixes.
1097
- */
1098
- export async function runAgentLoadFromStdin() {
1099
- if (!onDarwin())
1100
- return;
1101
- const chunks = [];
1102
- for await (const chunk of process.stdin)
1103
- chunks.push(chunk);
1104
- let payload;
1105
- try {
1106
- payload = JSON.parse(Buffer.concat(chunks).toString('utf-8'));
1107
- }
1108
- catch {
1109
- process.exitCode = 1; // malformed payload — nothing loaded
1110
- return;
1111
- }
1112
- if (!payload || !payload.name || !payload.bundle || !payload.env) {
1113
- process.exitCode = 1;
1114
- return;
1115
- }
1116
- // Generous budget: the broker is a cold-starting full CLI; under load it can
1117
- // take several seconds to bind. We're detached, so waiting costs nothing.
1118
- if (!(await ensureAgentRunning(20000))) {
1119
- process.exitCode = 1; // broker couldn't be brought up — did NOT load
1120
- return;
1121
- }
1122
- const loaded = await agentLoad(payload.name, payload.bundle, payload.env, payload.ttlMs ?? DEFAULT_TTL_MS, payload.harness ?? GLOBAL_HARNESS, payload.lease, payload.snapshotAt);
1123
- if (!loaded)
1124
- process.exitCode = 1; // transport failed — did NOT load
1125
- }
1126
- /** Store a resolved bundle in the broker. Returns false on transport failure. */
1127
- export async function agentLoad(name, bundle, env, ttlMs, harness = GLOBAL_HARNESS, lease, snapshotAt) {
1128
- const r = await request({ cmd: 'load', name, bundle, env, ttlMs, harness, lease, snapshotAt });
1129
- return r?.ok === true && r.cmd === 'load';
1130
- }
1131
- /** Wipe one bundle (or all if name omitted) from the broker. Returns the count
1132
- * wiped, or 0 when no broker is running. */
1133
- export async function agentLock(name) {
1134
- const r = await request({ cmd: 'lock', name });
1135
- return r?.ok === true && r.cmd === 'lock' ? r.wiped : 0;
1136
- }
1137
- /** List currently-unlocked bundles, or [] when no broker is running. The
1138
- * internal `secrets list` metadata-cache entry is filtered out here as well as
1139
- * server-side: during a rollout a NEW client can talk to an OLD broker that
1140
- * predates the server-side exclusion, so this keeps the internal entry from
1141
- * surfacing in `agents secrets status` in that skew window. */
1142
- export async function agentStatus() {
1143
- const r = await request({ cmd: 'status' });
1144
- const entries = r?.ok === true && r.cmd === 'status' ? r.entries : [];
1145
- return entries.filter((e) => !e.name.startsWith(META_CACHE_PREFIX));
1146
- }
1147
- /** Ping result: whether a broker is reachable + speaking our protocol, and the
1148
- * version of the code it's running (for staleness detection). */
1149
- export async function agentPing() {
1150
- if (!agentSocketExists())
1151
- return { reachable: false };
1152
- const r = await request({ cmd: 'ping' });
1153
- if (r?.ok === true && r.cmd === 'ping' && r.version === PROTOCOL_VERSION) {
1154
- return { reachable: true, cliVersion: r.cliVersion };
1155
- }
1156
- return { reachable: false };
1157
- }
1158
- /**
1159
- * Ensure a broker is running and reachable. Returns true once the socket answers
1160
- * a ping. macOS only.
1161
- *
1162
- * Prefers the always-on daemon, which hosts the broker socket (#416): retire any
1163
- * legacy standalone launchd service so the daemon owns the socket, then bring the
1164
- * daemon up (Path 0) — one supervised backbone that survives the whole login
1165
- * session, so subsequent reads never cold-start. Only when the daemon can't be
1166
- * used do we fall back to a one-off detached broker (Path 1) — the model that
1167
- * gets starved under heavy load, so it's last.
1168
- */
1169
- export async function ensureAgentRunning(timeoutMs = 5000) {
1170
- if (!onDarwin())
1171
- return false;
1172
- if (!isSecretsBrokerEnabled())
1173
- return false;
1174
- // Self-heal: if a broker is reachable but running pre-upgrade code (its
1175
- // reported version != the version on disk now), tear it down so the paths
1176
- // below bring up a fresh one on current code. A current, reachable broker is
1177
- // accepted immediately — and so is a version-skewed one that still holds
1178
- // real unlocks (see shouldTeardownVersionSkewedBroker: wiping a hot cache
1179
- // re-prompts Touch ID for every held bundle).
1180
- const ping = await agentPing();
1181
- if (ping.reachable) {
1182
- if (ping.cliVersion === undefined || ping.cliVersion === getCliVersionFresh())
1183
- return true;
1184
- // A reachable but version-skewed broker: tear it down ONLY when no daemon
1185
- // hosts it and it holds no unlocks. Evicting a daemon-hosted broker orphans
1186
- // the daemon's socket and starts the Touch ID storm — see
1187
- // shouldClientEvictSkewedBroker.
1188
- const { isDaemonRunning } = await import('../daemon/daemon.js');
1189
- if (!shouldClientEvictSkewedBroker(isDaemonRunning(), (await agentStatus()).length))
1190
- return true;
1191
- await teardownStaleBroker();
1192
- }
1193
- // A legacy standalone secrets-agent service may still be installed from an
1194
- // older version. Retire it (#416 step 2) so the always-on daemon owns the
1195
- // broker socket rather than racing a launchd job for it. No-op when no legacy
1196
- // plist is present. We only reach here when nothing is already reachable, so
1197
- // retiring never disrupts a warm broker.
1198
- retireLegacySecretsAgentService();
1199
- // Path 0 (#416): the always-on daemon hosts the broker socket — one supervised
1200
- // backbone rather than a separate launchd service. If bringing the daemon up
1201
- // makes the broker answer, we're done.
1202
- try {
1203
- const { ensureDaemonStarted } = await import('../daemon/daemon.js');
1204
- if (ensureDaemonStarted()) {
1205
- const d0 = Date.now() + timeoutMs;
1206
- while (Date.now() < d0) {
1207
- if ((await agentPing()).reachable)
1208
- return true;
1209
- await new Promise((r) => setTimeout(r, 120));
1210
- }
1211
- }
1212
- }
1213
- catch { /* daemon path unavailable — fall through to the one-off spawn */ }
1214
- // Path 1 (fallback): one-off detached broker when the daemon can't host it.
1215
- // Clear a stale socket/pid first.
1216
- const stalePid = (() => {
1217
- try {
1218
- return parseInt(fs.readFileSync(pidPath(), 'utf-8').trim(), 10);
1219
- }
1220
- catch {
1221
- return NaN;
1222
- }
1223
- })();
1224
- if (!isNaN(stalePid) && isAlive(stalePid)) {
1225
- try {
1226
- process.kill(stalePid, 'SIGTERM');
1227
- }
1228
- catch { /* already dead */ }
1229
- // Wait for it to actually go before clearing its socket and ownership
1230
- // record. Unlinking them under a live broker is what leaves an orphan
1231
- // holding every unlocked bundle in RAM with no way to reach it — and it also
1232
- // destroys the very record brokerPidAlive() reads, so the successor sees an
1233
- // ownerless socket and reclaims it regardless.
1234
- waitForExit(stalePid, BROKER_STOP_GRACE_MS);
1235
- }
1236
- try {
1237
- fs.unlinkSync(socketPath());
1238
- }
1239
- catch { /* gone */ }
1240
- try {
1241
- fs.unlinkSync(pidPath());
1242
- }
1243
- catch { /* gone */ }
1244
- try {
1245
- fs.unlinkSync(ownerPath());
1246
- }
1247
- catch { /* gone */ }
1248
- const { cmd, args } = brokerSpawn();
1249
- spawn(cmd, args, { stdio: 'ignore', detached: true }).unref();
1250
- const deadline = Date.now() + timeoutMs;
1251
- while (Date.now() < deadline) {
1252
- if ((await agentPing()).reachable)
1253
- return true;
1254
- await new Promise((r) => setTimeout(r, 100));
1255
- }
1256
- return false;
1257
- }
1258
- /**
1259
- * Tear down a stale broker (running pre-upgrade code) so a fresh one can take
1260
- * over. Retire any legacy standalone service first (#416 step 2) so the daemon —
1261
- * not the old launchd job — hosts the fresh broker, then kill the process and
1262
- * clear its socket/pid. The caller then brings the daemon-hosted broker up.
1263
- */
1264
- async function teardownStaleBroker() {
1265
- retireLegacySecretsAgentService();
1266
- const pid = (() => { try {
1267
- return parseInt(fs.readFileSync(pidPath(), 'utf-8').trim(), 10);
1268
- }
1269
- catch {
1270
- return NaN;
1271
- } })();
1272
- if (!isNaN(pid) && isAlive(pid)) {
1273
- try {
1274
- process.kill(pid, 'SIGTERM');
1275
- }
1276
- catch { /* gone */ }
1277
- waitForExit(pid, BROKER_STOP_GRACE_MS); // same reason as ensureAgentRunning
1278
- }
1279
- try {
1280
- fs.unlinkSync(socketPath());
1281
- }
1282
- catch { /* gone */ }
1283
- try {
1284
- fs.unlinkSync(pidPath());
1285
- }
1286
- catch { /* gone */ }
1287
- try {
1288
- fs.unlinkSync(ownerPath());
1289
- }
1290
- catch { /* gone */ }
1291
- }