@phnx-labs/agents-cli 1.22.84 → 1.22.86

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 (259) hide show
  1. package/CHANGELOG.md +96 -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/alias.js +7 -3
  9. package/dist/commands/apply.js +1 -1
  10. package/dist/commands/auth-mint.d.ts +7 -3
  11. package/dist/commands/auth-mint.js +21 -9
  12. package/dist/commands/auth.js +2 -3
  13. package/dist/commands/browser.js +10 -10
  14. package/dist/commands/daemon.d.ts +7 -3
  15. package/dist/commands/daemon.js +40 -25
  16. package/dist/commands/doctor.js +28 -2
  17. package/dist/commands/exec.js +87 -39
  18. package/dist/commands/fleet-capture.js +21 -2
  19. package/dist/commands/harness-wizard.js +2 -2
  20. package/dist/commands/lease.js +7 -12
  21. package/dist/commands/profiles.d.ts +2 -2
  22. package/dist/commands/profiles.js +20 -13
  23. package/dist/commands/repo.js +3 -3
  24. package/dist/commands/run-account-picker.d.ts +3 -3
  25. package/dist/commands/run-account-picker.js +29 -30
  26. package/dist/commands/secrets-passthrough.d.ts +20 -0
  27. package/dist/commands/secrets-passthrough.js +43 -0
  28. package/dist/commands/setup-accounts.d.ts +3 -2
  29. package/dist/commands/setup-accounts.js +4 -4
  30. package/dist/commands/setup-secrets.d.ts +16 -21
  31. package/dist/commands/setup-secrets.js +43 -224
  32. package/dist/commands/ssh.js +2 -2
  33. package/dist/commands/sync.js +1 -1
  34. package/dist/commands/update.d.ts +10 -0
  35. package/dist/commands/update.js +43 -40
  36. package/dist/commands/versions.d.ts +11 -11
  37. package/dist/commands/versions.js +131 -83
  38. package/dist/commands/view.d.ts +1 -9
  39. package/dist/commands/view.js +41 -55
  40. package/dist/commands/webhook.js +5 -5
  41. package/dist/commands/workflows.js +2 -1
  42. package/dist/index.d.ts +12 -12
  43. package/dist/index.js +13 -42
  44. package/dist/lib/account-capabilities.d.ts +1 -1
  45. package/dist/lib/account-capabilities.js +1 -1
  46. package/dist/lib/account-catalog.d.ts +121 -6
  47. package/dist/lib/account-catalog.js +421 -7
  48. package/dist/lib/account-registry.d.ts +8 -30
  49. package/dist/lib/account-registry.js +36 -79
  50. package/dist/lib/account-schema.d.ts +1 -1
  51. package/dist/lib/account-schema.js +1 -1
  52. package/dist/lib/accounting/account-pool-collect.js +16 -4
  53. package/dist/lib/accounting/rotate.d.ts +20 -7
  54. package/dist/lib/accounting/rotate.js +89 -13
  55. package/dist/lib/accounting/usage.js +11 -16
  56. package/dist/lib/accounts/add.d.ts +138 -0
  57. package/dist/lib/accounts/add.js +651 -0
  58. package/dist/lib/accounts/migrate.d.ts +117 -0
  59. package/dist/lib/accounts/migrate.js +536 -0
  60. package/dist/lib/accounts/slots.d.ts +13 -0
  61. package/dist/lib/accounts/slots.js +100 -0
  62. package/dist/lib/agent-spec/agents.d.ts +16 -0
  63. package/dist/lib/agent-spec/agents.js +38 -4
  64. package/dist/lib/app-bundle-install.js +5 -4
  65. package/dist/lib/auth-health.d.ts +3 -0
  66. package/dist/lib/auth-health.js +11 -0
  67. package/dist/lib/auth-mint.d.ts +52 -16
  68. package/dist/lib/auth-mint.js +117 -39
  69. package/dist/lib/browser/chrome.d.ts +1 -1
  70. package/dist/lib/browser/chrome.js +5 -5
  71. package/dist/lib/byok-usage.js +3 -3
  72. package/dist/lib/claude-account-token.d.ts +47 -3
  73. package/dist/lib/claude-account-token.js +172 -50
  74. package/dist/lib/cloud/antigravity.js +4 -4
  75. package/dist/lib/cloud/cursor.js +4 -4
  76. package/dist/lib/crabbox/cli.d.ts +1 -1
  77. package/dist/lib/crabbox/cli.js +6 -6
  78. package/dist/lib/crabbox/runtimes.d.ts +3 -3
  79. package/dist/lib/crabbox/runtimes.js +5 -5
  80. package/dist/lib/daemon/account-state-daemon-service.d.ts +37 -1
  81. package/dist/lib/daemon/account-state-daemon-service.js +199 -3
  82. package/dist/lib/daemon/auth-sync-service.js +32 -1
  83. package/dist/lib/daemon/daemon.d.ts +6 -16
  84. package/dist/lib/daemon/daemon.js +52 -97
  85. package/dist/lib/daemon/daemon.test-fixture.d.ts +7 -9
  86. package/dist/lib/daemon/daemon.test-fixture.js +22 -40
  87. package/dist/lib/daemon/harness-update-service.d.ts +1 -1
  88. package/dist/lib/daemon/harness-update-service.js +1 -1
  89. package/dist/lib/daemon/runner.d.ts +15 -3
  90. package/dist/lib/daemon/runner.js +49 -24
  91. package/dist/lib/daemon-health.d.ts +1 -2
  92. package/dist/lib/daemon-health.js +7 -6
  93. package/dist/lib/daemon-services.d.ts +1 -1
  94. package/dist/lib/daemon-services.js +1 -11
  95. package/dist/lib/daemon-webhooks.d.ts +8 -7
  96. package/dist/lib/daemon-webhooks.js +10 -9
  97. package/dist/lib/device-config.js +1 -1
  98. package/dist/lib/devices/doctor-findings.d.ts +1 -1
  99. package/dist/lib/devices/harness-inventory.d.ts +39 -0
  100. package/dist/lib/devices/harness-inventory.js +126 -4
  101. package/dist/lib/doctor-diff.js +2 -1
  102. package/dist/lib/exec-account-home.d.ts +38 -0
  103. package/dist/lib/exec-account-home.js +164 -0
  104. package/dist/lib/exec.d.ts +30 -0
  105. package/dist/lib/exec.js +101 -31
  106. package/dist/lib/fleet/apply.d.ts +2 -2
  107. package/dist/lib/fleet/apply.js +4 -4
  108. package/dist/lib/fleet/auth-sync.js +4 -3
  109. package/dist/lib/fleet-shared-repo-sync.js +6 -9
  110. package/dist/lib/harness/adapter.d.ts +7 -7
  111. package/dist/lib/harness/adapter.js +10 -6
  112. package/dist/lib/harness/adapters/grok.js +8 -3
  113. package/dist/lib/harness/adapters/muse.js +1 -1
  114. package/dist/lib/harness/adapters/opencode.js +12 -3
  115. package/dist/lib/harness-auth-capabilities.d.ts +61 -0
  116. package/dist/lib/harness-auth-capabilities.js +52 -0
  117. package/dist/lib/helper-versions.d.ts +6 -4
  118. package/dist/lib/helper-versions.js +5 -4
  119. package/dist/lib/hosts/credential-transport.d.ts +10 -0
  120. package/dist/lib/hosts/credential-transport.js +37 -0
  121. package/dist/lib/hosts/dispatch.d.ts +7 -0
  122. package/dist/lib/hosts/dispatch.js +13 -6
  123. package/dist/lib/identity/client.d.ts +3 -3
  124. package/dist/lib/identity/client.js +3 -3
  125. package/dist/lib/installations/index.d.ts +1 -1
  126. package/dist/lib/installations/index.js +1 -1
  127. package/dist/lib/installations/migrate.js +9 -0
  128. package/dist/lib/installations/resolve.js +1 -1
  129. package/dist/lib/installations/shims.d.ts +21 -4
  130. package/dist/lib/installations/shims.js +86 -5
  131. package/dist/lib/installations/store.d.ts +43 -0
  132. package/dist/lib/installations/store.js +75 -7
  133. package/dist/lib/installations/versions.js +1 -1
  134. package/dist/lib/menubar/install-menubar.d.ts +3 -3
  135. package/dist/lib/menubar/install-menubar.js +3 -3
  136. package/dist/lib/native-accounts.d.ts +35 -0
  137. package/dist/lib/native-accounts.js +41 -0
  138. package/dist/lib/net-close.d.ts +11 -0
  139. package/dist/lib/net-close.js +25 -0
  140. package/dist/lib/openclaw-keychain.js +27 -1
  141. package/dist/lib/profiles.js +22 -9
  142. package/dist/lib/project-resources.js +1 -1
  143. package/dist/lib/reserved-stores.d.ts +53 -0
  144. package/dist/lib/reserved-stores.js +120 -0
  145. package/dist/lib/secrets-client.d.ts +199 -0
  146. package/dist/lib/secrets-client.js +718 -0
  147. package/dist/lib/secrets-policy.d.ts +235 -0
  148. package/dist/lib/secrets-policy.js +505 -0
  149. package/dist/lib/secrets-types.d.ts +188 -0
  150. package/dist/lib/secrets-types.js +24 -0
  151. package/dist/lib/self-heal/checks/shims.js +5 -3
  152. package/dist/lib/service-manifest.js +3 -4
  153. package/dist/lib/session/db.d.ts +18 -0
  154. package/dist/lib/session/db.js +47 -14
  155. package/dist/lib/session/sync/config.js +2 -2
  156. package/dist/lib/sha256-asset.d.ts +11 -16
  157. package/dist/lib/sha256-asset.js +11 -16
  158. package/dist/lib/share/config.js +14 -5
  159. package/dist/lib/signin-badge.d.ts +26 -0
  160. package/dist/lib/signin-badge.js +42 -0
  161. package/dist/lib/staleness/detectors/workflows.js +3 -105
  162. package/dist/lib/staleness/writers/workflows.js +2 -1
  163. package/dist/lib/state.d.ts +7 -4
  164. package/dist/lib/state.js +18 -7
  165. package/dist/lib/sync-umbrella.js +2 -2
  166. package/dist/lib/teams/agents.js +1 -1
  167. package/dist/lib/types.d.ts +74 -33
  168. package/dist/lib/view-types.d.ts +6 -3
  169. package/dist/lib/workflows-registry.d.ts +71 -0
  170. package/dist/lib/workflows-registry.js +280 -0
  171. package/dist/lib/workflows.d.ts +21 -21
  172. package/dist/lib/workflows.js +12 -327
  173. package/package.json +2 -3
  174. package/scripts/postinstall.js +35 -30
  175. package/dist/commands/secrets-import.d.ts +0 -18
  176. package/dist/commands/secrets-import.js +0 -74
  177. package/dist/commands/secrets-migrate.d.ts +0 -25
  178. package/dist/commands/secrets-migrate.js +0 -334
  179. package/dist/commands/secrets-rotate-passphrase.d.ts +0 -17
  180. package/dist/commands/secrets-rotate-passphrase.js +0 -96
  181. package/dist/commands/secrets-sync.d.ts +0 -11
  182. package/dist/commands/secrets-sync.js +0 -153
  183. package/dist/commands/secrets-vault.d.ts +0 -10
  184. package/dist/commands/secrets-vault.js +0 -130
  185. package/dist/commands/secrets.d.ts +0 -212
  186. package/dist/commands/secrets.js +0 -3030
  187. package/dist/lib/accounts/connect.d.ts +0 -176
  188. package/dist/lib/accounts/connect.js +0 -453
  189. package/dist/lib/daemon/keychain-reap-service.d.ts +0 -17
  190. package/dist/lib/daemon/keychain-reap-service.js +0 -32
  191. package/dist/lib/daemon/secrets-broker-service.d.ts +0 -21
  192. package/dist/lib/daemon/secrets-broker-service.js +0 -51
  193. package/dist/lib/secrets/agent.d.ts +0 -358
  194. package/dist/lib/secrets/agent.js +0 -1291
  195. package/dist/lib/secrets/audit.d.ts +0 -46
  196. package/dist/lib/secrets/audit.js +0 -101
  197. package/dist/lib/secrets/bundles.d.ts +0 -290
  198. package/dist/lib/secrets/bundles.js +0 -1547
  199. package/dist/lib/secrets/download-keychain.d.ts +0 -47
  200. package/dist/lib/secrets/download-keychain.js +0 -70
  201. package/dist/lib/secrets/drivers/rush.d.ts +0 -14
  202. package/dist/lib/secrets/drivers/rush.js +0 -90
  203. package/dist/lib/secrets/fallback.d.ts +0 -48
  204. package/dist/lib/secrets/fallback.js +0 -48
  205. package/dist/lib/secrets/filestore.d.ts +0 -222
  206. package/dist/lib/secrets/filestore.js +0 -1099
  207. package/dist/lib/secrets/headless.d.ts +0 -43
  208. package/dist/lib/secrets/headless.js +0 -56
  209. package/dist/lib/secrets/icloud-import.d.ts +0 -79
  210. package/dist/lib/secrets/icloud-import.js +0 -206
  211. package/dist/lib/secrets/index.d.ts +0 -451
  212. package/dist/lib/secrets/index.js +0 -1568
  213. package/dist/lib/secrets/install-helper.d.ts +0 -72
  214. package/dist/lib/secrets/install-helper.js +0 -245
  215. package/dist/lib/secrets/lease.d.ts +0 -25
  216. package/dist/lib/secrets/lease.js +0 -44
  217. package/dist/lib/secrets/linux.d.ts +0 -77
  218. package/dist/lib/secrets/linux.js +0 -393
  219. package/dist/lib/secrets/list-filter.d.ts +0 -109
  220. package/dist/lib/secrets/list-filter.js +0 -261
  221. package/dist/lib/secrets/mcp.d.ts +0 -93
  222. package/dist/lib/secrets/mcp.js +0 -211
  223. package/dist/lib/secrets/profiles.d.ts +0 -10
  224. package/dist/lib/secrets/profiles.js +0 -13
  225. package/dist/lib/secrets/push.d.ts +0 -133
  226. package/dist/lib/secrets/push.js +0 -273
  227. package/dist/lib/secrets/rc-hygiene.d.ts +0 -63
  228. package/dist/lib/secrets/rc-hygiene.js +0 -143
  229. package/dist/lib/secrets/read-backoff.d.ts +0 -27
  230. package/dist/lib/secrets/read-backoff.js +0 -64
  231. package/dist/lib/secrets/reaper.d.ts +0 -90
  232. package/dist/lib/secrets/reaper.js +0 -243
  233. package/dist/lib/secrets/remote.d.ts +0 -152
  234. package/dist/lib/secrets/remote.js +0 -344
  235. package/dist/lib/secrets/reserved-sync.d.ts +0 -66
  236. package/dist/lib/secrets/reserved-sync.js +0 -147
  237. package/dist/lib/secrets/scope.d.ts +0 -26
  238. package/dist/lib/secrets/scope.js +0 -29
  239. package/dist/lib/secrets/session-store.d.ts +0 -107
  240. package/dist/lib/secrets/session-store.js +0 -342
  241. package/dist/lib/secrets/sync-backend.d.ts +0 -48
  242. package/dist/lib/secrets/sync-backend.js +0 -13
  243. package/dist/lib/secrets/sync-commands.d.ts +0 -21
  244. package/dist/lib/secrets/sync-commands.js +0 -21
  245. package/dist/lib/secrets/sync.d.ts +0 -48
  246. package/dist/lib/secrets/sync.js +0 -237
  247. package/dist/lib/secrets/unlock-hints.d.ts +0 -27
  248. package/dist/lib/secrets/unlock-hints.js +0 -36
  249. package/dist/lib/secrets/usage-db.d.ts +0 -46
  250. package/dist/lib/secrets/usage-db.js +0 -96
  251. package/dist/lib/secrets/vault-age-helper.d.ts +0 -1
  252. package/dist/lib/secrets/vault-age-helper.js +0 -34
  253. package/dist/lib/secrets/vault.d.ts +0 -49
  254. package/dist/lib/secrets/vault.js +0 -399
  255. package/dist/lib/secrets/windows.d.ts +0 -81
  256. package/dist/lib/secrets/windows.js +0 -556
  257. package/scripts/install-helper.js +0 -97
  258. /package/dist/lib/{secrets/sync-passphrase.d.ts → sync-passphrase.d.ts} +0 -0
  259. /package/dist/lib/{secrets/sync-passphrase.js → sync-passphrase.js} +0 -0
@@ -1,1099 +0,0 @@
1
- /**
2
- * Passphrase-encrypted file store for secrets — platform-neutral.
3
- *
4
- * An AES-256-GCM encrypted-file store under `~/.agents/.cache/secrets/`. The
5
- * encryption key is scrypt-derived from a passphrase read from
6
- * `AGENTS_SECRETS_PASSPHRASE` (preferred) or a machine-local key the store
7
- * auto-provisions on first use. One `<item>.enc` JSON file per item, mode 0600.
8
- *
9
- * Two callers, one policy: the store silently auto-provisions a stable
10
- * machine-local key (a 0600 file under `~/.agents/.secrets-key/`) on EVERY
11
- * platform, so it works out of the box with no passphrase to set or remember and
12
- * never pops a prompt or Touch ID sheet.
13
- * - Linux (src/lib/secrets/linux.ts): the headless fallback when the default
14
- * Secret Service collection is locked.
15
- * - macOS/Windows file-backed bundles (src/lib/secrets/bundles.ts): an explicit,
16
- * opt-in non-biometry backend for headless/remote runs.
17
- * Set AGENTS_SECRETS_PASSPHRASE to opt into a key held off disk instead (e.g. to
18
- * share one bundle's ciphertext across boxes under a common key).
19
- *
20
- * The item-name scheme is shared with the keychain backend so a file-backed
21
- * item and its keychain twin carry identical names:
22
- * `agents-cli.bundles.<name>` and `agents-cli.secrets.<bundle>.<key>`.
23
- */
24
- import { createCipheriv, createDecipheriv, createHash, randomBytes, scryptSync } from 'crypto';
25
- import * as fs from 'fs';
26
- import * as os from 'os';
27
- import * as path from 'path';
28
- import { withFileLock, ensureLockTarget } from '../fs-atomic.js';
29
- // ---------- file store location ----------
30
- let fileDirOverride = null;
31
- let passphraseDirOverride = null;
32
- let cachedPassphrase = null;
33
- let warnedAutoPassphrase = false;
34
- export function fileDir() {
35
- return fileDirOverride ?? path.join(os.homedir(), '.agents', '.cache', 'secrets');
36
- }
37
- function ensureFileDir() {
38
- fs.mkdirSync(fileDir(), { recursive: true, mode: 0o700 });
39
- }
40
- // ---------- cross-process store lock (RUSH-1975) ----------
41
- // Every mutation of the store (a `secrets set`/`delete`) and every rotation runs
42
- // under one exclusive lock, so a write can never land in the store dir between a
43
- // rotation's store-swap and key-swap renames (which would forge a MIXED store —
44
- // items sealed under two keys at once — with no crash involved) and two rotations
45
- // can never run concurrently. The macOS-only broker-unlock guard (`agentStatus`)
46
- // does nothing on the Linux headless targets this command exists for, so this is
47
- // the real serialization. The lock target is a SIBLING of the store dir, never a
48
- // file inside it — so it is never copied through a rotation nor swept as a
49
- // `.rotate-*` artifact, and it stays put across a mid-swap store-dir rename.
50
- //
51
- // A rotation's critical section is fully synchronous and scrypt-bound, so on a real
52
- // store it runs well past the lock's stale window. It calls `withFileLock`'s
53
- // `heartbeat()` through the re-encrypt and staging loops to keep the lockfile mtime
54
- // fresh — otherwise a peer would see the live holder as crashed, break the lock, and
55
- // interleave a write into the swap (a live lock-steal, no crash needed).
56
- let lockAcquireTimeoutMsOverride = null;
57
- let lockStaleMsOverride = null;
58
- function fileStoreLockPath() {
59
- return `${fileDir()}.lock`;
60
- }
61
- function withStoreLock(fn) {
62
- const lock = fileStoreLockPath();
63
- ensureLockTarget(lock);
64
- const opts = {};
65
- if (lockAcquireTimeoutMsOverride != null)
66
- opts.acquireTimeoutMs = lockAcquireTimeoutMsOverride;
67
- if (lockStaleMsOverride != null)
68
- opts.staleMs = lockStaleMsOverride;
69
- return withFileLock(lock, fn, opts);
70
- }
71
- // ---------- passphrase ----------
72
- /**
73
- * Directory for the auto-provisioned machine-local passphrase. Kept outside
74
- * `fileDir()` so a scan of the encrypted store never co-locates key + ciphertext.
75
- */
76
- function passphraseDir() {
77
- return passphraseDirOverride ?? path.join(os.homedir(), '.agents', '.secrets-key');
78
- }
79
- function ensurePassphraseDir() {
80
- fs.mkdirSync(passphraseDir(), { recursive: true, mode: 0o700 });
81
- }
82
- /** Path of the auto-provisioned machine-local passphrase (not an `.enc` item). */
83
- function passphraseFilePath() {
84
- return path.join(passphraseDir(), 'passphrase');
85
- }
86
- /** Legacy co-located path — read-only for machines provisioned before #479. */
87
- function legacyPassphraseFilePath() {
88
- return path.join(fileDir(), '.passphrase');
89
- }
90
- /** True if a machine-local passphrase has already been provisioned. */
91
- export function machinePassphraseExists() {
92
- return readMachinePassphrase() !== null;
93
- }
94
- function readMachinePassphrase() {
95
- for (const fp of [passphraseFilePath(), legacyPassphraseFilePath()]) {
96
- try {
97
- const p = fs.readFileSync(fp, 'utf8').trim();
98
- if (p.length > 0)
99
- return p;
100
- }
101
- catch {
102
- // try next location
103
- }
104
- }
105
- return null;
106
- }
107
- /**
108
- * Provision (or read back) a stable machine-local passphrase for the encrypted
109
- * file store, so `agents secrets` works out of the box on a headless box where
110
- * the keyring is locked and no AGENTS_SECRETS_PASSPHRASE is set.
111
- *
112
- * Security model: this is encryption-at-rest with the key held in a 0600 file —
113
- * the same posture as an SSH private key. It is NOT equivalent to the common
114
- * "export AGENTS_SECRETS_PASSPHRASE=… in ~/.zshenv (chmod 600)" workaround, and
115
- * is strictly safer: this file is read by the one process that needs it, while
116
- * a shell-rc export is inherited by every process the login shell spawns and is
117
- * readable from /proc/<pid>/environ by any same-user process (RUSH-1968; see
118
- * rc-hygiene.ts). The keyring (key in a daemon's locked memory) is stronger
119
- * still but is unavailable without a graphical/unlocked session.
120
- */
121
- function provisionMachinePassphrase() {
122
- const existing = readMachinePassphrase();
123
- if (existing)
124
- return existing;
125
- ensurePassphraseDir();
126
- const generated = randomBytes(32).toString('base64');
127
- const fp = passphraseFilePath();
128
- try {
129
- // wx: fail if a concurrent process created it first (then we read theirs).
130
- fs.writeFileSync(fp, generated, { mode: 0o600, flag: 'wx' });
131
- }
132
- catch {
133
- const raced = readMachinePassphrase();
134
- if (raced)
135
- return raced;
136
- throw new Error(`Failed to provision machine-local passphrase at ${fp}.`);
137
- }
138
- if (!warnedAutoPassphrase) {
139
- warnedAutoPassphrase = true;
140
- process.stderr.write(`[agents] keyring locked and no AGENTS_SECRETS_PASSPHRASE set; provisioned a ` +
141
- `machine-local passphrase at ${fp} (mode 0600). Set AGENTS_SECRETS_PASSPHRASE ` +
142
- `for a key held off disk.\n`);
143
- }
144
- return generated;
145
- }
146
- /**
147
- * Resolve the passphrase for the encrypted file store.
148
- *
149
- * Order: AGENTS_SECRETS_PASSPHRASE > previously-provisioned machine-local key >
150
- * legacy co-located key > a freshly auto-provisioned machine-local key. It NEVER
151
- * prompts, and never fails for WANT of a passphrase — the file store must work on
152
- * every platform (macOS included) without the user setting, typing, or
153
- * remembering one. (It can still throw if provisioning cannot write the key file
154
- * at all; that is a disk/permissions failure, not a missing passphrase.) Provisioning
155
- * writes a 0600 key file (encryption-at-rest, same posture as an SSH key); set
156
- * AGENTS_SECRETS_PASSPHRASE to opt into an off-disk key.
157
- */
158
- export function getPassphrase() {
159
- if (cachedPassphrase !== null)
160
- return cachedPassphrase;
161
- const env = process.env.AGENTS_SECRETS_PASSPHRASE;
162
- if (env && env.length > 0) {
163
- cachedPassphrase = env;
164
- return env;
165
- }
166
- // A previously-provisioned machine-local passphrase is this machine's stable
167
- // file-store key — prefer it so interactive and headless runs always agree.
168
- const onDisk = readMachinePassphrase();
169
- if (onDisk) {
170
- cachedPassphrase = onDisk;
171
- return onDisk;
172
- }
173
- // No env passphrase and no machine-local key yet: silently provision a stable
174
- // machine-local key (a 0600 file) on EVERY platform, macOS included. This is
175
- // encryption-at-rest with an on-disk key — the same posture as an SSH private
176
- // key — so the file store "just works" without the user ever setting, typing,
177
- // or remembering a passphrase, and never pops a prompt or a Touch ID sheet.
178
- // Set AGENTS_SECRETS_PASSPHRASE to opt into an off-disk key instead.
179
- cachedPassphrase = provisionMachinePassphrase();
180
- return cachedPassphrase;
181
- }
182
- function deriveKey(passphrase, salt) {
183
- return scryptSync(passphrase, salt, 32);
184
- }
185
- /** Encrypt plaintext under a passphrase using AES-256-GCM with a random
186
- * scrypt salt and a random 96-bit IV. Exported for tests. */
187
- export function encryptForFallback(plaintext, passphrase) {
188
- const salt = randomBytes(16);
189
- const iv = randomBytes(12);
190
- const key = deriveKey(passphrase, salt);
191
- const cipher = createCipheriv('aes-256-gcm', key, iv);
192
- const ciphertext = Buffer.concat([cipher.update(plaintext, 'utf8'), cipher.final()]);
193
- return {
194
- salt: salt.toString('hex'),
195
- iv: iv.toString('hex'),
196
- authTag: cipher.getAuthTag().toString('hex'),
197
- ciphertext: ciphertext.toString('hex'),
198
- };
199
- }
200
- /** Decrypt an EncFile under a passphrase. Throws on wrong key or tampered
201
- * ciphertext (auth-tag mismatch). Exported for tests. */
202
- export function decryptForFallback(enc, passphrase) {
203
- const salt = Buffer.from(enc.salt, 'hex');
204
- const iv = Buffer.from(enc.iv, 'hex');
205
- const authTag = Buffer.from(enc.authTag, 'hex');
206
- const ciphertext = Buffer.from(enc.ciphertext, 'hex');
207
- const key = deriveKey(passphrase, salt);
208
- const decipher = createDecipheriv('aes-256-gcm', key, iv);
209
- decipher.setAuthTag(authTag);
210
- const plaintext = Buffer.concat([decipher.update(ciphertext), decipher.final()]);
211
- return plaintext.toString('utf8');
212
- }
213
- // ---------- bundle-metadata plaintext cache (PHNX-3585) ----------
214
- //
215
- // Enumerating file-backed bundles — `agents secrets list`, and the
216
- // account-rotation `readAccountRegistry` on the `agents run` hot path — decrypts
217
- // EVERY bundle's metadata item, and each decrypt runs a fresh scrypt KDF (~12ms)
218
- // because every `.enc` file carries its own random salt. On a box with dozens of
219
- // bundles that is hundreds of ms of pure KDF before the harness even starts (the
220
- // ~0.55s New-Claude boot cost in PHNX-3585).
221
- //
222
- // Bundle METADATA is non-secret by contract (secrets/bundles.ts `writeBundle`:
223
- // "Metadata is non-sensitive by contract", stored no-ACL so `secrets list`
224
- // enumerates without Touch ID) — the vars hold `keychain:`/`env:`/literal REFS,
225
- // never the secret bytes, which live in separate `agents-cli.secrets.<bundle>.*`
226
- // value items. So its decrypted JSON is safe to cache in cleartext, at the SAME
227
- // 0600 protection as the ciphertext beside it.
228
- //
229
- // The cache keys each entry on the `.enc` file's (mtimeMs,size) plus a
230
- // passphrase fingerprint, so ANY bundle write (new mtime/size) or passphrase
231
- // change (new fingerprint; rotation also re-writes every file) misses and
232
- // re-derives — no staleness, self-healing on any miss. It is scoped to the
233
- // metadata namespace ONLY (`agents-cli.bundles.`); secret VALUE items are never
234
- // read through it, so no secret is ever written to the cache.
235
- const META_ITEM_PREFIX = 'agents-cli.bundles.';
236
- function isMetaItem(item) {
237
- return item.startsWith(META_ITEM_PREFIX);
238
- }
239
- let metaCache = null;
240
- let metaCacheDirty = false;
241
- let metaCacheFlushRegistered = false;
242
- function metaCachePath() {
243
- // A SIBLING of the store dir, never inside it — the same rule the store lock
244
- // above follows, so a rotation's store-dir rename / `.rotate-*` sweep never
245
- // moves or reaps it. Under `~/.agents/.cache/`, so it is machine-local and
246
- // never enters `agents repo push/pull`.
247
- return `${fileDir()}.meta-cache.json`;
248
- }
249
- function passphraseFingerprint() {
250
- // Non-reversible tag, never the passphrase itself — so a passphrase change
251
- // invalidates the cache without the key ever landing on disk in the clear.
252
- return createHash('sha256').update(getPassphrase()).digest('hex').slice(0, 16);
253
- }
254
- function loadMetaCache() {
255
- if (metaCache)
256
- return metaCache;
257
- const keyFp = passphraseFingerprint();
258
- try {
259
- const doc = JSON.parse(fs.readFileSync(metaCachePath(), 'utf8'));
260
- if (doc && doc.v === 1 && doc.keyFp === keyFp && doc.entries && typeof doc.entries === 'object') {
261
- metaCache = doc;
262
- return metaCache;
263
- }
264
- }
265
- catch {
266
- /* absent or corrupt — rebuild from scratch */
267
- }
268
- metaCache = { v: 1, keyFp, entries: {} };
269
- return metaCache;
270
- }
271
- /** Decrypted metadata for `item` if the cache holds a fresh entry, else null. */
272
- function metaCacheGet(item, fp) {
273
- let st;
274
- try {
275
- st = fs.statSync(fp);
276
- }
277
- catch {
278
- return null;
279
- }
280
- const e = loadMetaCache().entries[item];
281
- return e && e.mtimeMs === st.mtimeMs && e.size === st.size ? e.plain : null;
282
- }
283
- /** Record freshly-decrypted metadata for `item`, flushed once at process exit.
284
- * `snapshot` is the pre-readFileSync stat — the identity of the bytes just
285
- * decrypted. Skip the put if a post-decrypt stat does not match: a concurrent
286
- * writer during scrypt must not pin that plaintext to the new file identity. */
287
- function metaCachePut(item, fp, plain, snapshot) {
288
- try {
289
- const now = fs.statSync(fp);
290
- if (now.mtimeMs !== snapshot.mtimeMs || now.size !== snapshot.size)
291
- return;
292
- }
293
- catch {
294
- return;
295
- }
296
- loadMetaCache().entries[item] = { mtimeMs: snapshot.mtimeMs, size: snapshot.size, plain };
297
- metaCacheDirty = true;
298
- if (!metaCacheFlushRegistered) {
299
- metaCacheFlushRegistered = true;
300
- process.on('exit', flushMetaCache);
301
- }
302
- }
303
- /** Drop a cached entry the moment its file is rewritten or removed, so a
304
- * same-process write→read never returns pre-write metadata (mtime/size alone
305
- * can collide within a millisecond on an unchanged-length rewrite). */
306
- function metaCacheEvict(item) {
307
- if (!isMetaItem(item))
308
- return;
309
- const cache = metaCache;
310
- if (cache && cache.entries[item]) {
311
- delete cache.entries[item];
312
- metaCacheDirty = true;
313
- }
314
- }
315
- function flushMetaCache() {
316
- if (!metaCacheDirty || !metaCache)
317
- return;
318
- metaCacheDirty = false;
319
- // Prune entries whose `.enc` file is gone so churn can't grow the cache without
320
- // bound; a surviving stale entry would re-validate against mtime/size anyway.
321
- for (const item of Object.keys(metaCache.entries)) {
322
- if (!fs.existsSync(fileFor(item)))
323
- delete metaCache.entries[item];
324
- }
325
- try {
326
- ensureFileDir();
327
- const tmp = `${metaCachePath()}.${process.pid}.tmp`;
328
- fs.writeFileSync(tmp, JSON.stringify(metaCache), { mode: 0o600 });
329
- fs.renameSync(tmp, metaCachePath());
330
- }
331
- catch {
332
- /* the cache is advisory — a write failure just re-derives next run */
333
- }
334
- }
335
- // ---------- file backend ----------
336
- function fileFor(item) {
337
- return path.join(fileDir(), `${item}.enc`);
338
- }
339
- /** Absolute path of one encrypted file-store item. No read or decrypt occurs. */
340
- export function fileStoreItemPath(item) {
341
- return fileFor(item);
342
- }
343
- function fileHas(item) {
344
- return fs.existsSync(fileFor(item));
345
- }
346
- function fileGet(item) {
347
- const fp = fileFor(item);
348
- if (!fs.existsSync(fp)) {
349
- throw new Error(`Secret '${item}' not found in encrypted store.`);
350
- }
351
- // Non-secret metadata short-circuits the scrypt decrypt when the file is
352
- // unchanged (see the cache block above). Secret VALUE items skip the cache
353
- // entirely, so they always decrypt fresh and never touch cleartext storage.
354
- const metaItem = isMetaItem(item);
355
- if (metaItem) {
356
- const hit = metaCacheGet(item, fp);
357
- if (hit !== null)
358
- return hit;
359
- }
360
- // Identity of the bytes we are about to decrypt. A post-decrypt stat would
361
- // attribute this plaintext to a concurrent writer's new file.
362
- let snapshot;
363
- if (metaItem) {
364
- try {
365
- snapshot = fs.statSync(fp);
366
- }
367
- catch {
368
- snapshot = undefined;
369
- }
370
- }
371
- const raw = fs.readFileSync(fp, 'utf8');
372
- let parsed;
373
- try {
374
- parsed = JSON.parse(raw);
375
- }
376
- catch {
377
- throw new Error(`Encrypted secret file ${fp} is corrupt (not valid JSON).`);
378
- }
379
- let plain;
380
- try {
381
- plain = decryptForFallback(parsed, getPassphrase());
382
- }
383
- catch {
384
- throw new Error(`Failed to decrypt '${item}'. Wrong AGENTS_SECRETS_PASSPHRASE or tampered file.`);
385
- }
386
- if (metaItem && snapshot)
387
- metaCachePut(item, fp, plain, snapshot);
388
- return plain;
389
- }
390
- function fileGetBatch(items) {
391
- const out = new Map();
392
- for (const item of items) {
393
- // A missing file is an absent item. Any error reading an existing file,
394
- // especially an AES-GCM authentication failure, is a broken store and must
395
- // stop the caller rather than silently produce an incomplete environment.
396
- if (!fileHas(item))
397
- continue;
398
- out.set(item, fileGet(item));
399
- }
400
- return out;
401
- }
402
- function fileSet(item, value) {
403
- ensureFileDir();
404
- // Under the store lock: a write must not interleave with a rotation's swap.
405
- // `getPassphrase()` takes no options since the auto-provision requirement was
406
- // dropped (#1658) — provisioning is now unconditional.
407
- withStoreLock(() => {
408
- const enc = encryptForFallback(value, getPassphrase());
409
- fs.writeFileSync(fileFor(item), JSON.stringify(enc), { mode: 0o600 });
410
- });
411
- metaCacheEvict(item);
412
- }
413
- function fileDelete(item) {
414
- const fp = fileFor(item);
415
- if (!fs.existsSync(fp))
416
- return true; // idempotent, matches secret-tool clear
417
- const deleted = withStoreLock(() => {
418
- // Re-check under the lock — a rotation may have swapped the dir since the
419
- // pre-lock existence probe above.
420
- if (!fs.existsSync(fp))
421
- return true;
422
- fs.unlinkSync(fp);
423
- return true;
424
- });
425
- metaCacheEvict(item);
426
- return deleted;
427
- }
428
- function fileList(prefix) {
429
- const dir = fileDir();
430
- if (!fs.existsSync(dir))
431
- return [];
432
- return fs.readdirSync(dir)
433
- .filter((f) => f.endsWith('.enc'))
434
- .map((f) => f.slice(0, -'.enc'.length))
435
- .filter((name) => name.startsWith(prefix));
436
- }
437
- /** True if the fallback dir has any committed encrypted items. */
438
- export function fileStoreHasItems() {
439
- try {
440
- return fs.readdirSync(fileDir()).some((e) => e.endsWith('.enc'));
441
- }
442
- catch {
443
- return false;
444
- }
445
- }
446
- /** Low-level file-store ops, exported so callers (linux fallback, macOS
447
- * file-backed bundles) can opt into or out of passphrase auto-provision. */
448
- export const fileStore = {
449
- has: fileHas,
450
- get: fileGet,
451
- getBatch: fileGetBatch,
452
- set: fileSet,
453
- delete: fileDelete,
454
- list: fileList,
455
- };
456
- /** File-only KeychainBackend (exported for tests; the Linux backend uses these
457
- * ops with auto-provision allowed). */
458
- export const fileBackend = {
459
- has: fileHas,
460
- get: (item) => fileGet(item),
461
- set: (item, value) => fileSet(item, value),
462
- delete: fileDelete,
463
- list: fileList,
464
- };
465
- /** Resolved passphrase directory (exported for integration tests). */
466
- export function resolvePassphraseDir() {
467
- return passphraseDir();
468
- }
469
- // ---------- passphrase rotation (RUSH-1975) ----------
470
- /**
471
- * Path of the machine-local passphrase file that currently holds the file-store
472
- * key, or null if none is provisioned. Prefers the canonical #479 location and
473
- * falls back to the legacy co-located path, mirroring `readMachinePassphrase`.
474
- */
475
- export function machinePassphraseSourcePath() {
476
- for (const fp of [passphraseFilePath(), legacyPassphraseFilePath()]) {
477
- try {
478
- if (fs.readFileSync(fp, 'utf8').trim().length > 0)
479
- return fp;
480
- }
481
- catch {
482
- // try next location
483
- }
484
- }
485
- return null;
486
- }
487
- /**
488
- * Resolve the key path for a rotation that crashed mid-key-swap and left the live
489
- * key file absent (Window B: `keyPath` moved to `<key>.rotate-oldkey`, the new key
490
- * not yet landed). `machinePassphraseSourcePath` returns null in that state because
491
- * neither canonical nor legacy file has content, so recovery would never run. If a
492
- * rotation artifact (`.rotate-new` / `.rotate-oldkey`) exists for a canonical key
493
- * path, that path is the interrupted rotation's target — return it so recovery can
494
- * finish forward. Null when no such artifact is present.
495
- */
496
- function resolveInterruptedKeyPath() {
497
- for (const fp of [passphraseFilePath(), legacyPassphraseFilePath()]) {
498
- if (fs.existsSync(`${fp}.rotate-new`) || fs.existsSync(`${fp}.rotate-oldkey`))
499
- return fp;
500
- }
501
- // Co-located layout: the key lives inside the store dir and travels with it in a
502
- // single rename, so no `.rotate-new`/`.rotate-oldkey` key artifacts are ever
503
- // written. A crash in that rename leaves the store dir absent with its old store +
504
- // co-located key sitting in the `<dir>.rotate-old-*` backup, and the canonical/
505
- // legacy key files both gone — so the checks above return null and recovery would
506
- // never run. If a backup holding a co-located `.passphrase` is present, the
507
- // interrupted rotation's key target is that legacy co-located path; return it so
508
- // recovery restores the backup (old store + old key) and heals the store.
509
- const dir = fileDir();
510
- if (!fs.existsSync(dir)) {
511
- const parent = path.dirname(dir);
512
- const base = path.basename(dir);
513
- let entries;
514
- try {
515
- entries = fs.readdirSync(parent);
516
- }
517
- catch {
518
- return null;
519
- }
520
- const bak = entries.find((e) => e.startsWith(`${base}.rotate-old-`));
521
- if (bak && fs.existsSync(path.join(parent, bak, '.passphrase'))) {
522
- return legacyPassphraseFilePath();
523
- }
524
- }
525
- return null;
526
- }
527
- /** Flush a file's data to disk (durability before the atomic swap). */
528
- function writeFileFsync(fp, data, mode) {
529
- const fd = fs.openSync(fp, 'w', mode);
530
- try {
531
- // Narrow to one of fs.writeSync's overloads: the string form encodes as UTF-8,
532
- // the Buffer form writes raw bytes verbatim (binary-safe copy-through).
533
- if (typeof data === 'string')
534
- fs.writeSync(fd, data);
535
- else
536
- fs.writeSync(fd, data);
537
- fs.fsyncSync(fd);
538
- }
539
- finally {
540
- fs.closeSync(fd);
541
- }
542
- // A freshly-created file needs its mode set explicitly — the open() mode is
543
- // masked by the process umask, so 0600 is not guaranteed by the flag alone.
544
- try {
545
- fs.chmodSync(fp, mode);
546
- }
547
- catch { /* best effort on platforms without chmod */ }
548
- }
549
- /** fsync a directory so a rename/create in it is durable. Best-effort: some
550
- * filesystems reject O_RDONLY fsync on a directory. */
551
- function fsyncDir(dir) {
552
- let fd = null;
553
- try {
554
- fd = fs.openSync(dir, 'r');
555
- fs.fsyncSync(fd);
556
- }
557
- catch {
558
- // filesystem doesn't support directory fsync — the rename is still ordered
559
- }
560
- finally {
561
- if (fd !== null)
562
- fs.closeSync(fd);
563
- }
564
- }
565
- /** True if `enc` decrypts (auth-tag verifies) under `keyVal`. */
566
- function opensUnder(enc, keyVal) {
567
- try {
568
- decryptForFallback(enc, keyVal);
569
- return true;
570
- }
571
- catch {
572
- return false;
573
- }
574
- }
575
- /**
576
- * Classify how the `.enc` items in `dir` relate to `keyVal`, so recovery can tell
577
- * a single-key store (safe to sweep) apart from a MIXED store (live data sealed
578
- * under two keys at once — unsafe). `candidateKeys` is every key a mid-rotation
579
- * crash could have left on disk: the live key file, `<key>.rotate-new` (the
580
- * incoming key), and `<key>.rotate-oldkey` (the retired key). An item that opens
581
- * under NONE of them is a genuine orphan — sealed under a third key and carried
582
- * through a rotation verbatim — and is ignored: it neither proves nor disproves
583
- * consistency. Among the remaining, non-orphan items:
584
- * 'all' — every one opens under `keyVal` → `keyVal` is the store's one key
585
- * 'some' — at least one opens under `keyVal` AND at least one does not → MIXED
586
- * 'none' — none open under `keyVal`
587
- *
588
- * Only 'all' is safe to sweep against. The original "any one item opens" heuristic
589
- * returned true for a mixed store — so a single stray item sealed under the live
590
- * key (an interstitial `secrets set` after a mid-swap crash) made recovery sweep
591
- * `<key>.rotate-new`, the only surviving copy of the key the *other* items need,
592
- * destroying every one of them silently (RUSH-1975).
593
- */
594
- function classifyStore(dir, keyVal, candidateKeys) {
595
- if (keyVal == null)
596
- return 'none';
597
- let names;
598
- try {
599
- names = fs.readdirSync(dir).filter((f) => f.endsWith('.enc'));
600
- }
601
- catch {
602
- return 'none';
603
- }
604
- const otherKeys = candidateKeys.filter((k) => k != null && k !== keyVal);
605
- let nonOrphan = 0;
606
- let openUnderKey = 0;
607
- for (const name of names) {
608
- let enc;
609
- try {
610
- enc = JSON.parse(fs.readFileSync(path.join(dir, name), 'utf8'));
611
- }
612
- catch {
613
- continue;
614
- }
615
- const opensKey = opensUnder(enc, keyVal);
616
- // Ignore genuine orphans (open under no candidate key) — a third-party cache
617
- // carried through verbatim is not evidence of a mixed store.
618
- if (!opensKey && !otherKeys.some((k) => opensUnder(enc, k)))
619
- continue;
620
- nonOrphan++;
621
- if (opensKey)
622
- openUnderKey++;
623
- }
624
- if (openUnderKey === 0)
625
- return 'none';
626
- return openUnderKey === nonOrphan ? 'all' : 'some';
627
- }
628
- /** True if `dir` holds at least one `.enc` item. */
629
- function storeHasEnc(dir) {
630
- try {
631
- return fs.readdirSync(dir).some((f) => f.endsWith('.enc'));
632
- }
633
- catch {
634
- return false;
635
- }
636
- }
637
- /**
638
- * `.enc` basenames present in the `<dir>.rotate-old-*` backup but ABSENT from the
639
- * live `dir` — ciphertext that lives ONLY in the backup. A genuine post-swap store
640
- * is always a superset of the pre-swap store (rotation re-keys every item and copies
641
- * orphans/non-.enc files through verbatim — nothing is dropped), so this set is empty
642
- * for any real completed/interrupted rotation. It is non-empty only when the live
643
- * `dir` is NOT the post-swap store: an interstitial `secrets set` recreated the store
644
- * dir after a crash in the move-aside window left it absent (RUSH-1975). Sweeping the
645
- * backup then destroys those items — so recovery refuses instead.
646
- */
647
- function backupOnlyEnc(bak, dir) {
648
- let bakNames;
649
- try {
650
- bakNames = fs.readdirSync(bak).filter((f) => f.endsWith('.enc'));
651
- }
652
- catch {
653
- return [];
654
- }
655
- let dirNames;
656
- try {
657
- dirNames = new Set(fs.readdirSync(dir).filter((f) => f.endsWith('.enc')));
658
- }
659
- catch {
660
- dirNames = new Set();
661
- }
662
- return bakNames.filter((n) => !dirNames.has(n));
663
- }
664
- /** Read a key file's trimmed contents, or null if absent/empty. */
665
- function readKeyFile(fp) {
666
- try {
667
- const v = fs.readFileSync(fp, 'utf8').trim();
668
- return v.length > 0 ? v : null;
669
- }
670
- catch {
671
- return null;
672
- }
673
- }
674
- /**
675
- * Recover from a rotation that was interrupted mid-swap on a prior run, so the
676
- * store is always left in a single, self-consistent, readable state.
677
- *
678
- * Recovery is CONTENT-aware, not presence-aware, and it classifies the WHOLE
679
- * store, not just one item. The mere existence of the store dir and the key file
680
- * does not prove they match (RUSH-1975 data-loss window): on the non-co-located
681
- * key path the swap is four renames, and a crash after the store swap
682
- * (`stageDir`->`dir`) but before the key swap (`keyTmp`->`keyPath`) finishes
683
- * leaves a NEW-key store next to the OLD key file, both present. A presence check
684
- * would see "both here" and wrongly sweep the only copies of the old ciphertext
685
- * (`<dir>.rotate-old-*`) and the new key (`<key>.rotate-new`), permanently
686
- * orphaning every secret. So we probe the actual ciphertext with `classifyStore`,
687
- * which distinguishes a store that opens fully under one key ('all') from one that
688
- * is MIXED — some items under the live key, others under the incoming key ('some',
689
- * e.g. after a mid-swap crash contaminated by a later `secrets set`):
690
- *
691
- * 1. The live key opens EVERY non-orphan item ('all') → rotation complete and
692
- * consistent (or never interrupted); sweeping the `.rotate-*` artifacts is safe —
693
- * unless a `<dir>.rotate-old-*` backup still holds `.enc` items absent from the
694
- * live dir. That means the live dir is not the post-swap store but a fresh dir an
695
- * interstitial `secrets set` created after a crash in the move-aside window left
696
- * the store dir absent, so the backup is the only copy of those items → REFUSE.
697
- * 2. Else, if `<key>.rotate-new` opens every non-orphan item ('all'), the crash
698
- * landed after the store swap but before the key swap finished → finish the
699
- * rotation forward by installing `.rotate-new` as the live key, then sweep.
700
- * 3. Else, if neither key opens any item, roll back: restore the
701
- * `<dir>.rotate-old-*` backup over `dir` and `<key>.rotate-oldkey` over the key
702
- * file — but only once the backup is proven to open fully under the old key.
703
- * 4. If a key opens SOME but not all items ('some'), the store is MIXED — an
704
- * interrupted rotation contaminated by a later write, with live data under two
705
- * keys at once. Sweeping would delete the only copy of one of those keys, so we
706
- * REFUSE: throw an actionable error and preserve every recovery artifact for
707
- * out-of-band repair. Likewise, if neither forward nor rollback can be proven,
708
- * leave every artifact in place — a leftover temp dir is recoverable, deleting
709
- * the only copy of a key or ciphertext is not.
710
- *
711
- * A phase-marker / journal file was considered and deliberately skipped: the
712
- * AES-256-GCM auth tag already makes the decrypt probe an authoritative,
713
- * self-validating record of which key matches the store. A separate marker would
714
- * be a second source of truth that can disagree with reality — its own write has
715
- * crash windows, and a stale marker misleads — so it would weaken, not strengthen,
716
- * this guarantee. Idempotent; a no-op when no rotation artifacts are present.
717
- * Callers run this under the store lock (see `withStoreLock`).
718
- */
719
- /**
720
- * Whether a previous rotation left artifacts on disk — i.e. whether
721
- * {@link recoverInterruptedRotation} would do any work. Read-only, so `--dry-run`
722
- * can report a pending recovery without performing (and thus writing) one.
723
- */
724
- export function hasInterruptedRotationArtifacts(keyPath) {
725
- const dir = fileDir();
726
- const parent = path.dirname(dir);
727
- const base = path.basename(dir);
728
- let entries;
729
- try {
730
- entries = fs.readdirSync(parent);
731
- }
732
- catch {
733
- return false;
734
- }
735
- return entries.some((e) => e.startsWith(`${base}.rotate-`))
736
- || fs.existsSync(`${keyPath}.rotate-new`)
737
- || fs.existsSync(`${keyPath}.rotate-oldkey`);
738
- }
739
- function recoverInterruptedRotation(keyPath) {
740
- const dir = fileDir();
741
- const parent = path.dirname(dir);
742
- const base = path.basename(dir);
743
- let entries;
744
- try {
745
- entries = fs.readdirSync(parent);
746
- }
747
- catch {
748
- return;
749
- }
750
- const keyNew = `${keyPath}.rotate-new`;
751
- const keyOld = `${keyPath}.rotate-oldkey`;
752
- const bakName = entries.find((e) => e.startsWith(`${base}.rotate-old-`));
753
- let bakDir = bakName ? path.join(parent, bakName) : null;
754
- // Nothing rotation-related on disk -> no interrupted rotation to recover.
755
- if (!hasInterruptedRotationArtifacts(keyPath))
756
- return;
757
- // If the live store dir vanished mid-swap (crash between the two store renames,
758
- // before the new store landed), restore the old store from its backup — the key
759
- // was not touched yet, so old store + old key is a consistent state.
760
- if (!fs.existsSync(dir) && bakDir && fs.existsSync(bakDir)) {
761
- fs.renameSync(bakDir, dir);
762
- bakDir = null; // consumed
763
- }
764
- const sweep = () => {
765
- for (const e of fs.readdirSync(parent)) {
766
- if (e.startsWith(`${base}.rotate-`)) {
767
- try {
768
- fs.rmSync(path.join(parent, e), { recursive: true, force: true });
769
- }
770
- catch { /* best effort */ }
771
- }
772
- }
773
- for (const suffix of ['.rotate-new', '.rotate-oldkey']) {
774
- try {
775
- fs.rmSync(`${keyPath}${suffix}`, { force: true });
776
- }
777
- catch { /* best effort */ }
778
- }
779
- };
780
- // An empty or unreadable store holds no ciphertext at risk. Only sweep once the
781
- // live key file is present again; never delete recovery artifacts for a store we
782
- // cannot probe.
783
- if (!storeHasEnc(dir)) {
784
- if (fs.existsSync(keyPath))
785
- sweep();
786
- return;
787
- }
788
- const liveKey = readKeyFile(keyPath);
789
- const newKey = readKeyFile(keyNew);
790
- const oldKey = readKeyFile(keyOld);
791
- const candidates = [liveKey, newKey, oldKey];
792
- // A MIXED store cannot be swept safely: live data is sealed under two keys at
793
- // once, so deleting either `.rotate-new` or the old-ciphertext backup destroys
794
- // one class permanently. Refuse loudly and keep every artifact for out-of-band
795
- // repair — never report success over a store we would be corrupting.
796
- const refuseMixed = (label) => {
797
- throw new Error(`Interrupted secrets rotation left a MIXED store at ${dir}: ${label}. This ` +
798
- `happens when a \`secrets set\` landed between a crashed rotation and this ` +
799
- `recovery. Refusing to sweep — every recovery artifact is preserved. Recover ` +
800
- `out of band: for each ${dir}/*.enc, decrypt it under whichever of ${keyPath}` +
801
- (fs.existsSync(keyNew) ? `, ${keyNew}` : '') +
802
- (fs.existsSync(keyOld) ? `, ${keyOld}` : '') +
803
- ` opens it, re-seal all items under one key, then re-run \`rotate-passphrase\`.`);
804
- };
805
- // 1. Live key opens the WHOLE store -> rotation complete/consistent. Sweep safe,
806
- // UNLESS a `<dir>.rotate-old-*` backup still holds items absent from the live
807
- // dir: then the live dir is not the post-swap store but a fresh dir an
808
- // interstitial `secrets set` created after a crash in the move-aside window
809
- // (store dir absent), and the backup is the ONLY copy of those items. Sweeping
810
- // would destroy them, so refuse. Opens only some items -> MIXED, refuse.
811
- const liveMatch = classifyStore(dir, liveKey, candidates);
812
- if (liveMatch === 'all') {
813
- const orphaned = bakDir && fs.existsSync(bakDir) ? backupOnlyEnc(bakDir, dir) : [];
814
- if (orphaned.length > 0) {
815
- const shown = orphaned.slice(0, 3).join(', ') + (orphaned.length > 3 ? ', …' : '');
816
- throw new Error(`Interrupted secrets rotation left a MIXED (split) store: the live key opens ` +
817
- `${dir}, but its backup ${bakDir} holds ${orphaned.length} item(s) absent from ` +
818
- `the live store (${shown}) — so the live dir is not the whole store. This ` +
819
- `happens when a \`secrets set\` recreated the store dir after a crash left it ` +
820
- `absent. Refusing to sweep — every recovery artifact is preserved. Recover out ` +
821
- `of band: merge ${bakDir}/*.enc into ${dir} (both open under ${keyPath}), then ` +
822
- `re-run \`rotate-passphrase\`.`);
823
- }
824
- sweep();
825
- return;
826
- }
827
- if (liveMatch === 'some')
828
- refuseMixed('some items open under the live key and others do not');
829
- // 2. `.rotate-new` opens the whole store, the live key none of it -> the crash
830
- // landed after the store swap, before the key swap finished. Finish the
831
- // rotation forward by installing the new key, then sweep. Opens only some ->
832
- // MIXED, refuse.
833
- const newMatch = classifyStore(dir, newKey, candidates);
834
- if (newMatch === 'all') {
835
- try {
836
- fs.rmSync(keyPath, { force: true });
837
- }
838
- catch { /* may be absent mid-key-swap */ }
839
- fs.renameSync(keyNew, keyPath);
840
- fsyncDir(path.dirname(keyPath));
841
- sweep();
842
- return;
843
- }
844
- if (newMatch === 'some')
845
- refuseMixed('some items open under the incoming (.rotate-new) key and others do not');
846
- // 3. Neither key opens the live store -> roll back to the pre-rotation state, but
847
- // only once the backup store is proven to open FULLY under the old key (or the
848
- // live key, when `.rotate-oldkey` was not written yet — Window A).
849
- const rollbackKey = bakDir && classifyStore(bakDir, oldKey, candidates) === 'all'
850
- ? oldKey
851
- : (bakDir && classifyStore(bakDir, liveKey, candidates) === 'all' ? liveKey : null);
852
- if (bakDir && fs.existsSync(bakDir) && rollbackKey != null) {
853
- fs.rmSync(dir, { recursive: true, force: true });
854
- fs.renameSync(bakDir, dir);
855
- fsyncDir(path.dirname(dir));
856
- if (rollbackKey === oldKey && fs.existsSync(keyOld)) {
857
- try {
858
- fs.rmSync(keyPath, { force: true });
859
- }
860
- catch { /* may be absent */ }
861
- fs.renameSync(keyOld, keyPath);
862
- fsyncDir(path.dirname(keyPath));
863
- }
864
- sweep();
865
- return;
866
- }
867
- // 4. Neither forward nor rollback is provable -> leave every artifact untouched
868
- // for out-of-band recovery. Do NOT sweep: that is the data-loss bug.
869
- }
870
- /**
871
- * Rotate under the exclusive store lock, so no `secrets set`/`delete` and no
872
- * second rotation can interleave with the swap (see `withStoreLock`). The whole
873
- * run — recovery, verify, swap — holds the lock; it is released on return or throw.
874
- */
875
- export function rotatePassphrase(opts = {}) {
876
- return withStoreLock((heartbeat) => rotatePassphraseLocked(opts, heartbeat));
877
- }
878
- function rotatePassphraseLocked(opts, heartbeat = () => { }) {
879
- const dryRun = opts.dryRun ?? false;
880
- // Resolve the key path. If the live key file is absent because a prior rotation
881
- // crashed mid-key-swap, fall back to the interrupted rotation's target so
882
- // recovery below can still run and heal the store (RUSH-1975 Window B).
883
- const keyPath = machinePassphraseSourcePath() ?? resolveInterruptedKeyPath();
884
- if (!keyPath) {
885
- throw new Error('No machine-local passphrase to rotate. `rotate-passphrase` re-keys the ' +
886
- 'file store\'s auto-provisioned key; none is provisioned on this machine.');
887
- }
888
- // Recovery runs even under --dry-run, deliberately: healing an interrupted
889
- // rotation is how a crashed store becomes readable again WITHOUT re-keying it,
890
- // and gating it would leave such a store recoverable only via a full rotation.
891
- // It is the one thing a dry run writes, so the report says so and the CLI
892
- // prints it instead of claiming "nothing written".
893
- const recoveredInterruptedRotation = hasInterruptedRotationArtifacts(keyPath);
894
- recoverInterruptedRotation(keyPath);
895
- const oldPass = fs.readFileSync(keyPath, 'utf8').trim();
896
- if (!oldPass)
897
- throw new Error(`Machine-local passphrase file ${keyPath} is empty.`);
898
- const newPass = opts.newPassphrase ?? randomBytes(32).toString('base64');
899
- if (newPass === oldPass)
900
- throw new Error('New passphrase equals the current one — refusing a no-op rotation.');
901
- const dir = fileDir();
902
- let names;
903
- try {
904
- names = fs.readdirSync(dir).filter((f) => f.endsWith('.enc'));
905
- }
906
- catch {
907
- names = [];
908
- }
909
- if (names.length === 0) {
910
- throw new Error(`No encrypted items in ${dir} — nothing to rotate.`);
911
- }
912
- // Phase 1 — decrypt-all, re-encrypt, re-verify in memory. Nothing on disk is
913
- // touched here, so any throw leaves the live store and key file untouched.
914
- const staged = [];
915
- const skipped = [];
916
- for (const name of names) {
917
- // Keep the store lock fresh across the scrypt-bound loop: each item runs the
918
- // KDF three times (decrypt, re-encrypt, verify), so on a real store this loop
919
- // outlives the lock's stale window — without this a peer could break the lock
920
- // as "stale" mid-run and interleave a write (see `withFileLock`'s heartbeat).
921
- heartbeat();
922
- const raw = fs.readFileSync(path.join(dir, name), 'utf8');
923
- let parsed;
924
- try {
925
- parsed = JSON.parse(raw);
926
- }
927
- catch {
928
- skipped.push(`${name} (not valid EncFile JSON)`);
929
- continue;
930
- }
931
- let plain;
932
- try {
933
- plain = decryptForFallback(parsed, oldPass);
934
- }
935
- catch {
936
- skipped.push(`${name} (does not decrypt under the current key — orphan)`);
937
- continue;
938
- }
939
- let reEnc = encryptForFallback(plain, newPass);
940
- if (opts.tamperStaged)
941
- reEnc = { ...reEnc, ciphertext: `00${reEnc.ciphertext.slice(2)}` };
942
- let check;
943
- try {
944
- check = decryptForFallback(reEnc, newPass);
945
- }
946
- catch {
947
- throw new Error(`Re-encryption of ${name} failed to verify under the new key — aborted, nothing written.`);
948
- }
949
- if (check !== plain)
950
- throw new Error(`Round-trip mismatch on ${name} — aborted, nothing written.`);
951
- staged.push({ name, enc: JSON.stringify(reEnc) });
952
- }
953
- if (staged.length === 0) {
954
- throw new Error('No item decrypted under the current machine-local key — aborted, nothing written.');
955
- }
956
- const report = {
957
- dryRun,
958
- committed: false,
959
- bundleCount: staged.length,
960
- skipped,
961
- roundTripOk: true,
962
- keyFilePath: keyPath,
963
- recoveredInterruptedRotation,
964
- };
965
- if (dryRun)
966
- return report;
967
- // Phase 2 — stage the complete replacement store in a sibling temp dir, fsync,
968
- // then swap. Orphans and any non-.enc files are copied through verbatim so the
969
- // swapped dir is a complete superset of the old one (nothing is dropped).
970
- const keyColocated = path.dirname(keyPath) === dir;
971
- const rand = randomBytes(6).toString('hex');
972
- const stageDir = `${dir}.rotate-${rand}`;
973
- fs.rmSync(stageDir, { recursive: true, force: true });
974
- fs.mkdirSync(stageDir, { recursive: true, mode: 0o700 });
975
- const stagedNames = new Set(staged.map((s) => s.name));
976
- for (const { name, enc } of staged) {
977
- heartbeat(); // each write is an fsync; keep the lock fresh across the batch
978
- writeFileFsync(path.join(stageDir, name), enc, 0o600);
979
- }
980
- for (const entry of fs.readdirSync(dir)) {
981
- if (stagedNames.has(entry))
982
- continue;
983
- if (keyColocated && entry === path.basename(keyPath))
984
- continue; // rewritten below, not copied
985
- const src = path.join(dir, entry);
986
- if (!fs.statSync(src).isFile())
987
- continue;
988
- heartbeat();
989
- // Copy through as raw bytes — reading as 'utf8' would decode any non-UTF-8
990
- // byte to U+FFFD and silently corrupt the file on the way through the swap.
991
- writeFileFsync(path.join(stageDir, entry), fs.readFileSync(src), 0o600);
992
- }
993
- // A co-located legacy key travels with the store: write the new value into the
994
- // staged dir so a single directory swap commits both ciphertext and key.
995
- if (keyColocated)
996
- writeFileFsync(path.join(stageDir, path.basename(keyPath)), newPass, 0o600);
997
- fsyncDir(stageDir);
998
- // Test seam: simulate a crash after staging but before the swap. The live store
999
- // and key file are still untouched at this point.
1000
- opts.onStagedBeforeCommit?.();
1001
- // For a non-co-located key, stage the new key beside the old one first so the
1002
- // swap is two quick renames with no I/O between them.
1003
- const keyTmp = `${keyPath}.rotate-new`;
1004
- if (!keyColocated) {
1005
- writeFileFsync(keyTmp, newPass, 0o600);
1006
- fsyncDir(path.dirname(keyPath));
1007
- }
1008
- // Swap. Move the live store aside, then the staged store into place. The gap
1009
- // between these two renames is the only crash window that leaves the store dir
1010
- // absent; recoverInterruptedRotation restores it from the backup on next run.
1011
- const bakDir = `${dir}.rotate-old-${rand}`;
1012
- fs.renameSync(dir, bakDir);
1013
- // Test seam: crash after the live store is moved aside, before the staged store
1014
- // lands (the store dir is absent). For a co-located key this is the ONLY swap
1015
- // window — the single rename carries both ciphertext and key.
1016
- opts.onStoreMovedAsideBeforeSwap?.();
1017
- fs.renameSync(stageDir, dir);
1018
- fsyncDir(path.dirname(dir));
1019
- const keyBak = `${keyPath}.rotate-oldkey`;
1020
- if (!keyColocated) {
1021
- // Test seam: crash after the store swap, before the key swap begins (Window A).
1022
- // Also receives the lock heartbeat so a test can prove a long hold stays fresh.
1023
- opts.onStoreSwappedBeforeKeySwap?.(heartbeat);
1024
- fs.renameSync(keyPath, keyBak);
1025
- // Test seam: crash after the old key is moved aside, before the new key lands (Window B).
1026
- opts.onKeyBackedUpBeforeNewKey?.();
1027
- fs.renameSync(keyTmp, keyPath);
1028
- fsyncDir(path.dirname(keyPath));
1029
- }
1030
- // Verify a real read out of the now-live store under the new key. On failure,
1031
- // roll the store (and key) back to the backup — the old passphrase still works.
1032
- try {
1033
- const probe = JSON.parse(fs.readFileSync(path.join(dir, staged[0].name), 'utf8'));
1034
- decryptForFallback(probe, newPass);
1035
- }
1036
- catch (err) {
1037
- fs.rmSync(dir, { recursive: true, force: true });
1038
- fs.renameSync(bakDir, dir);
1039
- if (!keyColocated && fs.existsSync(keyBak)) {
1040
- try {
1041
- fs.rmSync(keyPath, { force: true });
1042
- }
1043
- catch { /* may not exist */ }
1044
- fs.renameSync(keyBak, keyPath);
1045
- }
1046
- throw new Error(`Post-swap verification failed; rolled back to the old key. (${err.message})`);
1047
- }
1048
- // Committed. Drop the old ciphertext and old key — both hold the retired key.
1049
- fs.rmSync(bakDir, { recursive: true, force: true });
1050
- if (!keyColocated)
1051
- fs.rmSync(keyBak, { force: true });
1052
- cachedPassphrase = newPass;
1053
- report.committed = true;
1054
- return report;
1055
- }
1056
- /** Test-only: reset module state (file dir + cached passphrase). */
1057
- export function _resetFileStoreForTest(opts = {}) {
1058
- fileDirOverride = opts.fileDir ?? null;
1059
- if (opts.passphraseDir !== undefined) {
1060
- passphraseDirOverride = opts.passphraseDir;
1061
- }
1062
- else if (opts.fileDir) {
1063
- // Hermetic sibling when only the store dir is overridden (linux.test.ts).
1064
- passphraseDirOverride = path.resolve(opts.fileDir, '..', `${path.basename(opts.fileDir)}-key`);
1065
- }
1066
- else {
1067
- passphraseDirOverride = null;
1068
- }
1069
- cachedPassphrase = opts.passphrase ?? null;
1070
- warnedAutoPassphrase = false;
1071
- lockAcquireTimeoutMsOverride = null;
1072
- lockStaleMsOverride = null;
1073
- // Drop the in-memory metadata cache so a test that repoints the store dir or
1074
- // passphrase never reads another fixture's cached plaintext (the on-disk cache
1075
- // is per-dir and keyed by the passphrase fingerprint, so it self-invalidates
1076
- // too, but the process-level map must be cleared explicitly).
1077
- metaCache = null;
1078
- metaCacheDirty = false;
1079
- }
1080
- /** Test-only: flush the in-memory metadata cache to disk now. Production flushes
1081
- * once at process exit; a test needs the on-disk copy mid-run to simulate the
1082
- * next `agents` process reading it. */
1083
- export function _flushMetaCacheForTest() {
1084
- flushMetaCache();
1085
- }
1086
- /** Test-only: shorten the store-lock acquire timeout so a contended-lock assertion
1087
- * fails fast instead of waiting out the 30s production budget. */
1088
- export function _setFileStoreLockTimeoutForTest(ms) {
1089
- lockAcquireTimeoutMsOverride = ms;
1090
- }
1091
- /** Test-only: shrink the store-lock stale window so a heartbeat/steal assertion runs
1092
- * in milliseconds instead of the 5s production window. */
1093
- export function _setFileStoreLockStaleMsForTest(ms) {
1094
- lockStaleMsOverride = ms;
1095
- }
1096
- /** Test-only: the cross-process store-lock target (sibling of the store dir). */
1097
- export function _fileStoreLockPathForTest() {
1098
- return fileStoreLockPath();
1099
- }