@phnx-labs/agents-cli 1.20.87 → 1.20.89

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 (94) hide show
  1. package/CHANGELOG.md +323 -0
  2. package/README.md +12 -4
  3. package/dist/bin/agents +0 -0
  4. package/dist/commands/commands.js +7 -7
  5. package/dist/commands/doctor.d.ts +0 -19
  6. package/dist/commands/doctor.js +219 -305
  7. package/dist/commands/exec.js +7 -19
  8. package/dist/commands/factory.js +26 -2
  9. package/dist/commands/funnel.js +16 -1
  10. package/dist/commands/inspect.js +3 -5
  11. package/dist/commands/menubar.js +117 -34
  12. package/dist/commands/routines.js +25 -3
  13. package/dist/commands/secrets-rotate-passphrase.d.ts +17 -0
  14. package/dist/commands/secrets-rotate-passphrase.js +96 -0
  15. package/dist/commands/secrets.js +2 -0
  16. package/dist/commands/sessions.d.ts +7 -1
  17. package/dist/commands/sessions.js +40 -12
  18. package/dist/commands/ssh.js +3 -3
  19. package/dist/commands/usage.d.ts +3 -2
  20. package/dist/commands/usage.js +2 -9
  21. package/dist/commands/webhook.js +7 -2
  22. package/dist/lib/agents.d.ts +31 -1
  23. package/dist/lib/agents.js +55 -0
  24. package/dist/lib/command-skills.d.ts +10 -0
  25. package/dist/lib/command-skills.js +14 -0
  26. package/dist/lib/commands.js +28 -2
  27. package/dist/lib/daemon.d.ts +29 -0
  28. package/dist/lib/daemon.js +75 -6
  29. package/dist/lib/devices/doctor-findings.d.ts +167 -0
  30. package/dist/lib/devices/doctor-findings.js +893 -0
  31. package/dist/lib/devices/fleet-divergence.d.ts +22 -0
  32. package/dist/lib/devices/fleet-divergence.js +34 -10
  33. package/dist/lib/devices/fleet-inventory.d.ts +17 -6
  34. package/dist/lib/devices/fleet-inventory.js +56 -8
  35. package/dist/lib/events.d.ts +1 -1
  36. package/dist/lib/exec.d.ts +14 -3
  37. package/dist/lib/exec.js +41 -8
  38. package/dist/lib/factory/snapshot.d.ts +78 -0
  39. package/dist/lib/factory/snapshot.js +209 -0
  40. package/dist/lib/fs-atomic.d.ts +14 -1
  41. package/dist/lib/fs-atomic.js +35 -3
  42. package/dist/lib/funnel.d.ts +1 -0
  43. package/dist/lib/funnel.js +8 -0
  44. package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
  45. package/dist/lib/menubar/MenubarHelper.app/Contents/Resources/AppIcon.icns +0 -0
  46. package/dist/lib/menubar/MenubarHelper.app/Contents/_CodeSignature/CodeResources +2 -2
  47. package/dist/lib/menubar/install-menubar.d.ts +53 -2
  48. package/dist/lib/menubar/install-menubar.js +183 -28
  49. package/dist/lib/platform/process.d.ts +2 -0
  50. package/dist/lib/platform/process.js +5 -3
  51. package/dist/lib/project-resources.js +34 -20
  52. package/dist/lib/resources.d.ts +8 -0
  53. package/dist/lib/resources.js +34 -1
  54. package/dist/lib/routines-placement.d.ts +2 -1
  55. package/dist/lib/routines-placement.js +8 -4
  56. package/dist/lib/routines.d.ts +57 -1
  57. package/dist/lib/routines.js +74 -1
  58. package/dist/lib/runner.d.ts +16 -1
  59. package/dist/lib/runner.js +58 -16
  60. package/dist/lib/sandbox.d.ts +2 -0
  61. package/dist/lib/sandbox.js +38 -0
  62. package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
  63. package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
  64. package/dist/lib/secrets/bundles.js +9 -34
  65. package/dist/lib/secrets/filestore.d.ts +152 -34
  66. package/dist/lib/secrets/filestore.js +676 -123
  67. package/dist/lib/secrets/rc-hygiene.d.ts +0 -6
  68. package/dist/lib/secrets/rc-hygiene.js +0 -24
  69. package/dist/lib/session/active.d.ts +6 -6
  70. package/dist/lib/session/active.js +6 -6
  71. package/dist/lib/session/discover.d.ts +5 -0
  72. package/dist/lib/session/discover.js +137 -1
  73. package/dist/lib/session/parse.d.ts +2 -0
  74. package/dist/lib/session/parse.js +76 -37
  75. package/dist/lib/session/remote-active.d.ts +4 -1
  76. package/dist/lib/session/remote-active.js +8 -2
  77. package/dist/lib/session/sync/agents.js +0 -0
  78. package/dist/lib/session/types.d.ts +1 -1
  79. package/dist/lib/session/types.js +1 -1
  80. package/dist/lib/session/viewing-in.d.ts +31 -0
  81. package/dist/lib/session/viewing-in.js +47 -0
  82. package/dist/lib/staleness/detectors/commands.js +14 -5
  83. package/dist/lib/staleness/types.d.ts +2 -0
  84. package/dist/lib/staleness/writers/commands.js +13 -7
  85. package/dist/lib/state.d.ts +17 -0
  86. package/dist/lib/state.js +30 -2
  87. package/dist/lib/triggers/handlers.d.ts +95 -0
  88. package/dist/lib/triggers/handlers.js +384 -0
  89. package/dist/lib/triggers/webhook.d.ts +10 -2
  90. package/dist/lib/triggers/webhook.js +65 -11
  91. package/dist/lib/usage.d.ts +72 -1
  92. package/dist/lib/usage.js +21 -27
  93. package/dist/lib/versions.js +30 -13
  94. package/package.json +1 -1
@@ -3,29 +3,29 @@
3
3
  *
4
4
  * An AES-256-GCM encrypted-file store under `~/.agents/.cache/secrets/`. The
5
5
  * encryption key is scrypt-derived from a passphrase read from
6
- * `AGENTS_SECRETS_PASSPHRASE` (preferred), a machine-local provisioned key, or
7
- * a TTY prompt. One `<item>.enc` JSON file per item, mode 0600.
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
8
  *
9
- * Two callers:
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.
10
13
  * - Linux (src/lib/secrets/linux.ts): the headless fallback when the default
11
- * Secret Service collection is locked. Auto-provisions a machine-local
12
- * passphrase so `agents secrets` works out of the box on a server.
13
- * - macOS file-backed bundles (src/lib/secrets/bundles.ts): an explicit,
14
- * opt-in non-biometry backend for headless/remote release runs. The bundle
15
- * layer guards this path so it only activates with an explicit
16
- * AGENTS_SECRETS_PASSPHRASE (or TTY) — never the silent machine-local
17
- * auto-provision — so a remote box holds ciphertext only.
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).
18
19
  *
19
20
  * The item-name scheme is shared with the keychain backend so a file-backed
20
21
  * item and its keychain twin carry identical names:
21
22
  * `agents-cli.bundles.<name>` and `agents-cli.secrets.<bundle>.<key>`.
22
23
  */
23
- import { execSync, spawnSync } from 'child_process';
24
24
  import { createCipheriv, createDecipheriv, randomBytes, scryptSync } from 'crypto';
25
25
  import * as fs from 'fs';
26
26
  import * as os from 'os';
27
27
  import * as path from 'path';
28
- import { encodePwshBase64 } from '../pwsh.js';
28
+ import { withFileLock, ensureLockTarget } from '../fs-atomic.js';
29
29
  // ---------- file store location ----------
30
30
  let fileDirOverride = null;
31
31
  let passphraseDirOverride = null;
@@ -37,89 +37,38 @@ export function fileDir() {
37
37
  function ensureFileDir() {
38
38
  fs.mkdirSync(fileDir(), { recursive: true, mode: 0o700 });
39
39
  }
40
- // ---------- passphrase ----------
41
- /**
42
- * Windows has no `/dev/tty` and no POSIX `stty`, so the interactive prompt runs
43
- * through PowerShell's `Read-Host -AsSecureString` (which never echoes). The
44
- * secure string is marshaled back out and written to stdout, which we capture.
45
- * If PowerShell cannot run at all, fail with an actionable error rather than
46
- * letting `fs.openSync('/dev/tty')` throw a raw ENOENT. Reached only on the rare
47
- * interactive-Windows file-fallback path the headless service-account case
48
- * (no TTY) auto-provisions a machine-local key and never gets here.
49
- */
50
- function readPassphraseFromTtyWindows() {
51
- const script = `
52
- $ErrorActionPreference = 'Stop'
53
- $sec = Read-Host -AsSecureString -Prompt 'Enter AGENTS_SECRETS_PASSPHRASE'
54
- $ptr = [Runtime.InteropServices.Marshal]::SecureStringToBSTR($sec)
55
- try { [Console]::Out.Write([Runtime.InteropServices.Marshal]::PtrToStringBSTR($ptr)) }
56
- finally { [Runtime.InteropServices.Marshal]::ZeroFreeBSTR($ptr) }
57
- `;
58
- // Not -NonInteractive: that flag would suppress the Read-Host prompt itself.
59
- const res = spawnSync('powershell.exe', ['-NoProfile', '-EncodedCommand', encodePwshBase64(script)], {
60
- stdio: ['inherit', 'pipe', 'inherit'],
61
- });
62
- if (res.error || res.status !== 0) {
63
- throw new Error('Could not prompt for a passphrase on Windows. Set AGENTS_SECRETS_PASSPHRASE ' +
64
- 'to decrypt the file-backed secret store.');
65
- }
66
- return (res.stdout?.toString() ?? '').replace(/\r?\n$/, '');
67
- }
68
- /**
69
- * Turn off terminal echo on the controlling TTY, or throw — fail CLOSED. If echo
70
- * cannot be disabled (`stty` missing, no controlling terminal) we must NOT fall
71
- * through and read the passphrase anyway: that echoes the secret to the screen
72
- * and into scrollback (RUSH-1764). Refuse and point the user at the environment
73
- * variable instead. `run` performs the echo-disable and throws iff it fails.
74
- * Exported so the fail-closed contract has direct test coverage.
75
- */
76
- export function disableTtyEchoOrThrow(run) {
77
- try {
78
- run();
79
- }
80
- catch {
81
- throw new Error('Refusing to prompt for AGENTS_SECRETS_PASSPHRASE: terminal echo could not be ' +
82
- 'disabled (stty unavailable or no controlling TTY), so the passphrase would be ' +
83
- 'shown in cleartext. Set AGENTS_SECRETS_PASSPHRASE in the environment instead.');
84
- }
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`;
85
60
  }
86
- function readPassphraseFromTty() {
87
- if (process.platform === 'win32')
88
- return readPassphraseFromTtyWindows();
89
- const fd = fs.openSync('/dev/tty', 'r+');
90
- let echoDisabled = false;
91
- try {
92
- fs.writeSync(fd, 'Enter AGENTS_SECRETS_PASSPHRASE: ');
93
- // Fail closed: if echo can't be turned off, abort rather than echo the secret.
94
- disableTtyEchoOrThrow(() => execSync('stty -echo < /dev/tty', { stdio: 'ignore' }));
95
- echoDisabled = true;
96
- let pass = '';
97
- const buf = Buffer.alloc(1);
98
- while (true) {
99
- const n = fs.readSync(fd, buf, 0, 1, null);
100
- if (n === 0)
101
- break;
102
- const ch = buf.toString('utf8', 0, n);
103
- if (ch === '\n' || ch === '\r')
104
- break;
105
- pass += ch;
106
- }
107
- return pass;
108
- }
109
- finally {
110
- if (echoDisabled) {
111
- try {
112
- execSync('stty echo < /dev/tty', { stdio: 'ignore' });
113
- }
114
- catch { /* best effort */ }
115
- }
116
- try {
117
- fs.writeSync(fd, '\n');
118
- }
119
- catch { /* best effort */ }
120
- fs.closeSync(fd);
121
- }
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);
122
70
  }
71
+ // ---------- passphrase ----------
123
72
  /**
124
73
  * Directory for the auto-provisioned machine-local passphrase. Kept outside
125
74
  * `fileDir()` so a scan of the encrypted store never co-locates key + ciphertext.
@@ -196,15 +145,13 @@ function provisionMachinePassphrase() {
196
145
  * Resolve the passphrase for the encrypted file store.
197
146
  *
198
147
  * Order: AGENTS_SECRETS_PASSPHRASE > previously-provisioned machine-local key >
199
- * (interactive) TTY prompt > (headless) auto-provisioned machine-local key.
200
- *
201
- * `allowAutoProvision` (default true, used by the Linux fallback) controls the
202
- * last two steps. macOS file-backed bundles pass `false` so a missing
203
- * passphrase is a hard, explicit error instead of a silently provisioned
204
- * on-disk key — the caller (bundles.ts) guards this before we get here.
148
+ * a freshly auto-provisioned machine-local key. It NEVER prompts and NEVER
149
+ * hard-fails — the file store must work on every platform (macOS included)
150
+ * without the user setting, typing, or remembering a passphrase. Provisioning
151
+ * writes a 0600 key file (encryption-at-rest, same posture as an SSH key); set
152
+ * AGENTS_SECRETS_PASSPHRASE to opt into an off-disk key.
205
153
  */
206
- export function getPassphrase(opts = {}) {
207
- const allowAutoProvision = opts.allowAutoProvision ?? true;
154
+ export function getPassphrase() {
208
155
  if (cachedPassphrase !== null)
209
156
  return cachedPassphrase;
210
157
  const env = process.env.AGENTS_SECRETS_PASSPHRASE;
@@ -213,26 +160,18 @@ export function getPassphrase(opts = {}) {
213
160
  return env;
214
161
  }
215
162
  // A previously-provisioned machine-local passphrase is this machine's stable
216
- // file-store key — prefer it for both interactive and headless runs so they
217
- // always agree (a TTY run won't re-prompt once the file exists).
163
+ // file-store key — prefer it so interactive and headless runs always agree.
218
164
  const onDisk = readMachinePassphrase();
219
165
  if (onDisk) {
220
166
  cachedPassphrase = onDisk;
221
167
  return onDisk;
222
168
  }
223
- if (!allowAutoProvision) {
224
- throw new Error('AGENTS_SECRETS_PASSPHRASE is not set. A passphrase is required to decrypt ' +
225
- 'this file-backed secret store.');
226
- }
227
- // First run, no env, no provisioned key: prompt when interactive, otherwise
228
- // (headless the reported bug) auto-provision instead of hard-failing.
229
- if (process.stdin.isTTY) {
230
- const p = readPassphraseFromTty();
231
- if (!p)
232
- throw new Error('No passphrase entered.');
233
- cachedPassphrase = p;
234
- return p;
235
- }
169
+ // No env passphrase and no machine-local key yet: silently provision a stable
170
+ // machine-local key (a 0600 file) on EVERY platform, macOS included. This is
171
+ // encryption-at-rest with an on-disk key — the same posture as an SSH private
172
+ // key — so the file store "just works" without the user ever setting, typing,
173
+ // or remembering a passphrase, and never pops a prompt or a Touch ID sheet.
174
+ // Set AGENTS_SECRETS_PASSPHRASE to opt into an off-disk key instead.
236
175
  cachedPassphrase = provisionMachinePassphrase();
237
176
  return cachedPassphrase;
238
177
  }
@@ -274,7 +213,7 @@ function fileFor(item) {
274
213
  function fileHas(item) {
275
214
  return fs.existsSync(fileFor(item));
276
215
  }
277
- function fileGet(item, opts = {}) {
216
+ function fileGet(item) {
278
217
  const fp = fileFor(item);
279
218
  if (!fs.existsSync(fp)) {
280
219
  throw new Error(`Secret '${item}' not found in encrypted store.`);
@@ -288,23 +227,34 @@ function fileGet(item, opts = {}) {
288
227
  throw new Error(`Encrypted secret file ${fp} is corrupt (not valid JSON).`);
289
228
  }
290
229
  try {
291
- return decryptForFallback(parsed, getPassphrase(opts));
230
+ return decryptForFallback(parsed, getPassphrase());
292
231
  }
293
232
  catch {
294
233
  throw new Error(`Failed to decrypt '${item}'. Wrong AGENTS_SECRETS_PASSPHRASE or tampered file.`);
295
234
  }
296
235
  }
297
- function fileSet(item, value, opts = {}) {
236
+ function fileSet(item, value) {
298
237
  ensureFileDir();
299
- const enc = encryptForFallback(value, getPassphrase(opts));
300
- fs.writeFileSync(fileFor(item), JSON.stringify(enc), { mode: 0o600 });
238
+ // Under the store lock: a write must not interleave with a rotation's swap.
239
+ // `getPassphrase()` takes no options since the auto-provision requirement was
240
+ // dropped (#1658) — provisioning is now unconditional.
241
+ withStoreLock(() => {
242
+ const enc = encryptForFallback(value, getPassphrase());
243
+ fs.writeFileSync(fileFor(item), JSON.stringify(enc), { mode: 0o600 });
244
+ });
301
245
  }
302
246
  function fileDelete(item) {
303
247
  const fp = fileFor(item);
304
248
  if (!fs.existsSync(fp))
305
249
  return true; // idempotent, matches secret-tool clear
306
- fs.unlinkSync(fp);
307
- return true;
250
+ return withStoreLock(() => {
251
+ // Re-check under the lock — a rotation may have swapped the dir since the
252
+ // pre-lock existence probe above.
253
+ if (!fs.existsSync(fp))
254
+ return true;
255
+ fs.unlinkSync(fp);
256
+ return true;
257
+ });
308
258
  }
309
259
  function fileList(prefix) {
310
260
  const dir = fileDir();
@@ -346,6 +296,593 @@ export const fileBackend = {
346
296
  export function resolvePassphraseDir() {
347
297
  return passphraseDir();
348
298
  }
299
+ // ---------- passphrase rotation (RUSH-1975) ----------
300
+ /**
301
+ * Path of the machine-local passphrase file that currently holds the file-store
302
+ * key, or null if none is provisioned. Prefers the canonical #479 location and
303
+ * falls back to the legacy co-located path, mirroring `readMachinePassphrase`.
304
+ */
305
+ export function machinePassphraseSourcePath() {
306
+ for (const fp of [passphraseFilePath(), legacyPassphraseFilePath()]) {
307
+ try {
308
+ if (fs.readFileSync(fp, 'utf8').trim().length > 0)
309
+ return fp;
310
+ }
311
+ catch {
312
+ // try next location
313
+ }
314
+ }
315
+ return null;
316
+ }
317
+ /**
318
+ * Resolve the key path for a rotation that crashed mid-key-swap and left the live
319
+ * key file absent (Window B: `keyPath` moved to `<key>.rotate-oldkey`, the new key
320
+ * not yet landed). `machinePassphraseSourcePath` returns null in that state because
321
+ * neither canonical nor legacy file has content, so recovery would never run. If a
322
+ * rotation artifact (`.rotate-new` / `.rotate-oldkey`) exists for a canonical key
323
+ * path, that path is the interrupted rotation's target — return it so recovery can
324
+ * finish forward. Null when no such artifact is present.
325
+ */
326
+ function resolveInterruptedKeyPath() {
327
+ for (const fp of [passphraseFilePath(), legacyPassphraseFilePath()]) {
328
+ if (fs.existsSync(`${fp}.rotate-new`) || fs.existsSync(`${fp}.rotate-oldkey`))
329
+ return fp;
330
+ }
331
+ // Co-located layout: the key lives inside the store dir and travels with it in a
332
+ // single rename, so no `.rotate-new`/`.rotate-oldkey` key artifacts are ever
333
+ // written. A crash in that rename leaves the store dir absent with its old store +
334
+ // co-located key sitting in the `<dir>.rotate-old-*` backup, and the canonical/
335
+ // legacy key files both gone — so the checks above return null and recovery would
336
+ // never run. If a backup holding a co-located `.passphrase` is present, the
337
+ // interrupted rotation's key target is that legacy co-located path; return it so
338
+ // recovery restores the backup (old store + old key) and heals the store.
339
+ const dir = fileDir();
340
+ if (!fs.existsSync(dir)) {
341
+ const parent = path.dirname(dir);
342
+ const base = path.basename(dir);
343
+ let entries;
344
+ try {
345
+ entries = fs.readdirSync(parent);
346
+ }
347
+ catch {
348
+ return null;
349
+ }
350
+ const bak = entries.find((e) => e.startsWith(`${base}.rotate-old-`));
351
+ if (bak && fs.existsSync(path.join(parent, bak, '.passphrase'))) {
352
+ return legacyPassphraseFilePath();
353
+ }
354
+ }
355
+ return null;
356
+ }
357
+ /** Flush a file's data to disk (durability before the atomic swap). */
358
+ function writeFileFsync(fp, data, mode) {
359
+ const fd = fs.openSync(fp, 'w', mode);
360
+ try {
361
+ // Narrow to one of fs.writeSync's overloads: the string form encodes as UTF-8,
362
+ // the Buffer form writes raw bytes verbatim (binary-safe copy-through).
363
+ if (typeof data === 'string')
364
+ fs.writeSync(fd, data);
365
+ else
366
+ fs.writeSync(fd, data);
367
+ fs.fsyncSync(fd);
368
+ }
369
+ finally {
370
+ fs.closeSync(fd);
371
+ }
372
+ // A freshly-created file needs its mode set explicitly — the open() mode is
373
+ // masked by the process umask, so 0600 is not guaranteed by the flag alone.
374
+ try {
375
+ fs.chmodSync(fp, mode);
376
+ }
377
+ catch { /* best effort on platforms without chmod */ }
378
+ }
379
+ /** fsync a directory so a rename/create in it is durable. Best-effort: some
380
+ * filesystems reject O_RDONLY fsync on a directory. */
381
+ function fsyncDir(dir) {
382
+ let fd = null;
383
+ try {
384
+ fd = fs.openSync(dir, 'r');
385
+ fs.fsyncSync(fd);
386
+ }
387
+ catch {
388
+ // filesystem doesn't support directory fsync — the rename is still ordered
389
+ }
390
+ finally {
391
+ if (fd !== null)
392
+ fs.closeSync(fd);
393
+ }
394
+ }
395
+ /** True if `enc` decrypts (auth-tag verifies) under `keyVal`. */
396
+ function opensUnder(enc, keyVal) {
397
+ try {
398
+ decryptForFallback(enc, keyVal);
399
+ return true;
400
+ }
401
+ catch {
402
+ return false;
403
+ }
404
+ }
405
+ /**
406
+ * Classify how the `.enc` items in `dir` relate to `keyVal`, so recovery can tell
407
+ * a single-key store (safe to sweep) apart from a MIXED store (live data sealed
408
+ * under two keys at once — unsafe). `candidateKeys` is every key a mid-rotation
409
+ * crash could have left on disk: the live key file, `<key>.rotate-new` (the
410
+ * incoming key), and `<key>.rotate-oldkey` (the retired key). An item that opens
411
+ * under NONE of them is a genuine orphan — sealed under a third key and carried
412
+ * through a rotation verbatim — and is ignored: it neither proves nor disproves
413
+ * consistency. Among the remaining, non-orphan items:
414
+ * 'all' — every one opens under `keyVal` → `keyVal` is the store's one key
415
+ * 'some' — at least one opens under `keyVal` AND at least one does not → MIXED
416
+ * 'none' — none open under `keyVal`
417
+ *
418
+ * Only 'all' is safe to sweep against. The original "any one item opens" heuristic
419
+ * returned true for a mixed store — so a single stray item sealed under the live
420
+ * key (an interstitial `secrets set` after a mid-swap crash) made recovery sweep
421
+ * `<key>.rotate-new`, the only surviving copy of the key the *other* items need,
422
+ * destroying every one of them silently (RUSH-1975).
423
+ */
424
+ function classifyStore(dir, keyVal, candidateKeys) {
425
+ if (keyVal == null)
426
+ return 'none';
427
+ let names;
428
+ try {
429
+ names = fs.readdirSync(dir).filter((f) => f.endsWith('.enc'));
430
+ }
431
+ catch {
432
+ return 'none';
433
+ }
434
+ const otherKeys = candidateKeys.filter((k) => k != null && k !== keyVal);
435
+ let nonOrphan = 0;
436
+ let openUnderKey = 0;
437
+ for (const name of names) {
438
+ let enc;
439
+ try {
440
+ enc = JSON.parse(fs.readFileSync(path.join(dir, name), 'utf8'));
441
+ }
442
+ catch {
443
+ continue;
444
+ }
445
+ const opensKey = opensUnder(enc, keyVal);
446
+ // Ignore genuine orphans (open under no candidate key) — a third-party cache
447
+ // carried through verbatim is not evidence of a mixed store.
448
+ if (!opensKey && !otherKeys.some((k) => opensUnder(enc, k)))
449
+ continue;
450
+ nonOrphan++;
451
+ if (opensKey)
452
+ openUnderKey++;
453
+ }
454
+ if (openUnderKey === 0)
455
+ return 'none';
456
+ return openUnderKey === nonOrphan ? 'all' : 'some';
457
+ }
458
+ /** True if `dir` holds at least one `.enc` item. */
459
+ function storeHasEnc(dir) {
460
+ try {
461
+ return fs.readdirSync(dir).some((f) => f.endsWith('.enc'));
462
+ }
463
+ catch {
464
+ return false;
465
+ }
466
+ }
467
+ /**
468
+ * `.enc` basenames present in the `<dir>.rotate-old-*` backup but ABSENT from the
469
+ * live `dir` — ciphertext that lives ONLY in the backup. A genuine post-swap store
470
+ * is always a superset of the pre-swap store (rotation re-keys every item and copies
471
+ * orphans/non-.enc files through verbatim — nothing is dropped), so this set is empty
472
+ * for any real completed/interrupted rotation. It is non-empty only when the live
473
+ * `dir` is NOT the post-swap store: an interstitial `secrets set` recreated the store
474
+ * dir after a crash in the move-aside window left it absent (RUSH-1975). Sweeping the
475
+ * backup then destroys those items — so recovery refuses instead.
476
+ */
477
+ function backupOnlyEnc(bak, dir) {
478
+ let bakNames;
479
+ try {
480
+ bakNames = fs.readdirSync(bak).filter((f) => f.endsWith('.enc'));
481
+ }
482
+ catch {
483
+ return [];
484
+ }
485
+ let dirNames;
486
+ try {
487
+ dirNames = new Set(fs.readdirSync(dir).filter((f) => f.endsWith('.enc')));
488
+ }
489
+ catch {
490
+ dirNames = new Set();
491
+ }
492
+ return bakNames.filter((n) => !dirNames.has(n));
493
+ }
494
+ /** Read a key file's trimmed contents, or null if absent/empty. */
495
+ function readKeyFile(fp) {
496
+ try {
497
+ const v = fs.readFileSync(fp, 'utf8').trim();
498
+ return v.length > 0 ? v : null;
499
+ }
500
+ catch {
501
+ return null;
502
+ }
503
+ }
504
+ /**
505
+ * Recover from a rotation that was interrupted mid-swap on a prior run, so the
506
+ * store is always left in a single, self-consistent, readable state.
507
+ *
508
+ * Recovery is CONTENT-aware, not presence-aware, and it classifies the WHOLE
509
+ * store, not just one item. The mere existence of the store dir and the key file
510
+ * does not prove they match (RUSH-1975 data-loss window): on the non-co-located
511
+ * key path the swap is four renames, and a crash after the store swap
512
+ * (`stageDir`->`dir`) but before the key swap (`keyTmp`->`keyPath`) finishes
513
+ * leaves a NEW-key store next to the OLD key file, both present. A presence check
514
+ * would see "both here" and wrongly sweep the only copies of the old ciphertext
515
+ * (`<dir>.rotate-old-*`) and the new key (`<key>.rotate-new`), permanently
516
+ * orphaning every secret. So we probe the actual ciphertext with `classifyStore`,
517
+ * which distinguishes a store that opens fully under one key ('all') from one that
518
+ * is MIXED — some items under the live key, others under the incoming key ('some',
519
+ * e.g. after a mid-swap crash contaminated by a later `secrets set`):
520
+ *
521
+ * 1. The live key opens EVERY non-orphan item ('all') → rotation complete and
522
+ * consistent (or never interrupted); sweeping the `.rotate-*` artifacts is safe —
523
+ * unless a `<dir>.rotate-old-*` backup still holds `.enc` items absent from the
524
+ * live dir. That means the live dir is not the post-swap store but a fresh dir an
525
+ * interstitial `secrets set` created after a crash in the move-aside window left
526
+ * the store dir absent, so the backup is the only copy of those items → REFUSE.
527
+ * 2. Else, if `<key>.rotate-new` opens every non-orphan item ('all'), the crash
528
+ * landed after the store swap but before the key swap finished → finish the
529
+ * rotation forward by installing `.rotate-new` as the live key, then sweep.
530
+ * 3. Else, if neither key opens any item, roll back: restore the
531
+ * `<dir>.rotate-old-*` backup over `dir` and `<key>.rotate-oldkey` over the key
532
+ * file — but only once the backup is proven to open fully under the old key.
533
+ * 4. If a key opens SOME but not all items ('some'), the store is MIXED — an
534
+ * interrupted rotation contaminated by a later write, with live data under two
535
+ * keys at once. Sweeping would delete the only copy of one of those keys, so we
536
+ * REFUSE: throw an actionable error and preserve every recovery artifact for
537
+ * out-of-band repair. Likewise, if neither forward nor rollback can be proven,
538
+ * leave every artifact in place — a leftover temp dir is recoverable, deleting
539
+ * the only copy of a key or ciphertext is not.
540
+ *
541
+ * A phase-marker / journal file was considered and deliberately skipped: the
542
+ * AES-256-GCM auth tag already makes the decrypt probe an authoritative,
543
+ * self-validating record of which key matches the store. A separate marker would
544
+ * be a second source of truth that can disagree with reality — its own write has
545
+ * crash windows, and a stale marker misleads — so it would weaken, not strengthen,
546
+ * this guarantee. Idempotent; a no-op when no rotation artifacts are present.
547
+ * Callers run this under the store lock (see `withStoreLock`).
548
+ */
549
+ /**
550
+ * Whether a previous rotation left artifacts on disk — i.e. whether
551
+ * {@link recoverInterruptedRotation} would do any work. Read-only, so `--dry-run`
552
+ * can report a pending recovery without performing (and thus writing) one.
553
+ */
554
+ export function hasInterruptedRotationArtifacts(keyPath) {
555
+ const dir = fileDir();
556
+ const parent = path.dirname(dir);
557
+ const base = path.basename(dir);
558
+ let entries;
559
+ try {
560
+ entries = fs.readdirSync(parent);
561
+ }
562
+ catch {
563
+ return false;
564
+ }
565
+ return entries.some((e) => e.startsWith(`${base}.rotate-`))
566
+ || fs.existsSync(`${keyPath}.rotate-new`)
567
+ || fs.existsSync(`${keyPath}.rotate-oldkey`);
568
+ }
569
+ function recoverInterruptedRotation(keyPath) {
570
+ const dir = fileDir();
571
+ const parent = path.dirname(dir);
572
+ const base = path.basename(dir);
573
+ let entries;
574
+ try {
575
+ entries = fs.readdirSync(parent);
576
+ }
577
+ catch {
578
+ return;
579
+ }
580
+ const keyNew = `${keyPath}.rotate-new`;
581
+ const keyOld = `${keyPath}.rotate-oldkey`;
582
+ const bakName = entries.find((e) => e.startsWith(`${base}.rotate-old-`));
583
+ let bakDir = bakName ? path.join(parent, bakName) : null;
584
+ // Nothing rotation-related on disk -> no interrupted rotation to recover.
585
+ if (!hasInterruptedRotationArtifacts(keyPath))
586
+ return;
587
+ // If the live store dir vanished mid-swap (crash between the two store renames,
588
+ // before the new store landed), restore the old store from its backup — the key
589
+ // was not touched yet, so old store + old key is a consistent state.
590
+ if (!fs.existsSync(dir) && bakDir && fs.existsSync(bakDir)) {
591
+ fs.renameSync(bakDir, dir);
592
+ bakDir = null; // consumed
593
+ }
594
+ const sweep = () => {
595
+ for (const e of fs.readdirSync(parent)) {
596
+ if (e.startsWith(`${base}.rotate-`)) {
597
+ try {
598
+ fs.rmSync(path.join(parent, e), { recursive: true, force: true });
599
+ }
600
+ catch { /* best effort */ }
601
+ }
602
+ }
603
+ for (const suffix of ['.rotate-new', '.rotate-oldkey']) {
604
+ try {
605
+ fs.rmSync(`${keyPath}${suffix}`, { force: true });
606
+ }
607
+ catch { /* best effort */ }
608
+ }
609
+ };
610
+ // An empty or unreadable store holds no ciphertext at risk. Only sweep once the
611
+ // live key file is present again; never delete recovery artifacts for a store we
612
+ // cannot probe.
613
+ if (!storeHasEnc(dir)) {
614
+ if (fs.existsSync(keyPath))
615
+ sweep();
616
+ return;
617
+ }
618
+ const liveKey = readKeyFile(keyPath);
619
+ const newKey = readKeyFile(keyNew);
620
+ const oldKey = readKeyFile(keyOld);
621
+ const candidates = [liveKey, newKey, oldKey];
622
+ // A MIXED store cannot be swept safely: live data is sealed under two keys at
623
+ // once, so deleting either `.rotate-new` or the old-ciphertext backup destroys
624
+ // one class permanently. Refuse loudly and keep every artifact for out-of-band
625
+ // repair — never report success over a store we would be corrupting.
626
+ const refuseMixed = (label) => {
627
+ throw new Error(`Interrupted secrets rotation left a MIXED store at ${dir}: ${label}. This ` +
628
+ `happens when a \`secrets set\` landed between a crashed rotation and this ` +
629
+ `recovery. Refusing to sweep — every recovery artifact is preserved. Recover ` +
630
+ `out of band: for each ${dir}/*.enc, decrypt it under whichever of ${keyPath}` +
631
+ (fs.existsSync(keyNew) ? `, ${keyNew}` : '') +
632
+ (fs.existsSync(keyOld) ? `, ${keyOld}` : '') +
633
+ ` opens it, re-seal all items under one key, then re-run \`rotate-passphrase\`.`);
634
+ };
635
+ // 1. Live key opens the WHOLE store -> rotation complete/consistent. Sweep safe,
636
+ // UNLESS a `<dir>.rotate-old-*` backup still holds items absent from the live
637
+ // dir: then the live dir is not the post-swap store but a fresh dir an
638
+ // interstitial `secrets set` created after a crash in the move-aside window
639
+ // (store dir absent), and the backup is the ONLY copy of those items. Sweeping
640
+ // would destroy them, so refuse. Opens only some items -> MIXED, refuse.
641
+ const liveMatch = classifyStore(dir, liveKey, candidates);
642
+ if (liveMatch === 'all') {
643
+ const orphaned = bakDir && fs.existsSync(bakDir) ? backupOnlyEnc(bakDir, dir) : [];
644
+ if (orphaned.length > 0) {
645
+ const shown = orphaned.slice(0, 3).join(', ') + (orphaned.length > 3 ? ', …' : '');
646
+ throw new Error(`Interrupted secrets rotation left a MIXED (split) store: the live key opens ` +
647
+ `${dir}, but its backup ${bakDir} holds ${orphaned.length} item(s) absent from ` +
648
+ `the live store (${shown}) — so the live dir is not the whole store. This ` +
649
+ `happens when a \`secrets set\` recreated the store dir after a crash left it ` +
650
+ `absent. Refusing to sweep — every recovery artifact is preserved. Recover out ` +
651
+ `of band: merge ${bakDir}/*.enc into ${dir} (both open under ${keyPath}), then ` +
652
+ `re-run \`rotate-passphrase\`.`);
653
+ }
654
+ sweep();
655
+ return;
656
+ }
657
+ if (liveMatch === 'some')
658
+ refuseMixed('some items open under the live key and others do not');
659
+ // 2. `.rotate-new` opens the whole store, the live key none of it -> the crash
660
+ // landed after the store swap, before the key swap finished. Finish the
661
+ // rotation forward by installing the new key, then sweep. Opens only some ->
662
+ // MIXED, refuse.
663
+ const newMatch = classifyStore(dir, newKey, candidates);
664
+ if (newMatch === 'all') {
665
+ try {
666
+ fs.rmSync(keyPath, { force: true });
667
+ }
668
+ catch { /* may be absent mid-key-swap */ }
669
+ fs.renameSync(keyNew, keyPath);
670
+ fsyncDir(path.dirname(keyPath));
671
+ sweep();
672
+ return;
673
+ }
674
+ if (newMatch === 'some')
675
+ refuseMixed('some items open under the incoming (.rotate-new) key and others do not');
676
+ // 3. Neither key opens the live store -> roll back to the pre-rotation state, but
677
+ // only once the backup store is proven to open FULLY under the old key (or the
678
+ // live key, when `.rotate-oldkey` was not written yet — Window A).
679
+ const rollbackKey = bakDir && classifyStore(bakDir, oldKey, candidates) === 'all'
680
+ ? oldKey
681
+ : (bakDir && classifyStore(bakDir, liveKey, candidates) === 'all' ? liveKey : null);
682
+ if (bakDir && fs.existsSync(bakDir) && rollbackKey != null) {
683
+ fs.rmSync(dir, { recursive: true, force: true });
684
+ fs.renameSync(bakDir, dir);
685
+ fsyncDir(path.dirname(dir));
686
+ if (rollbackKey === oldKey && fs.existsSync(keyOld)) {
687
+ try {
688
+ fs.rmSync(keyPath, { force: true });
689
+ }
690
+ catch { /* may be absent */ }
691
+ fs.renameSync(keyOld, keyPath);
692
+ fsyncDir(path.dirname(keyPath));
693
+ }
694
+ sweep();
695
+ return;
696
+ }
697
+ // 4. Neither forward nor rollback is provable -> leave every artifact untouched
698
+ // for out-of-band recovery. Do NOT sweep: that is the data-loss bug.
699
+ }
700
+ /**
701
+ * Rotate under the exclusive store lock, so no `secrets set`/`delete` and no
702
+ * second rotation can interleave with the swap (see `withStoreLock`). The whole
703
+ * run — recovery, verify, swap — holds the lock; it is released on return or throw.
704
+ */
705
+ export function rotatePassphrase(opts = {}) {
706
+ return withStoreLock((heartbeat) => rotatePassphraseLocked(opts, heartbeat));
707
+ }
708
+ function rotatePassphraseLocked(opts, heartbeat = () => { }) {
709
+ const dryRun = opts.dryRun ?? false;
710
+ // Resolve the key path. If the live key file is absent because a prior rotation
711
+ // crashed mid-key-swap, fall back to the interrupted rotation's target so
712
+ // recovery below can still run and heal the store (RUSH-1975 Window B).
713
+ const keyPath = machinePassphraseSourcePath() ?? resolveInterruptedKeyPath();
714
+ if (!keyPath) {
715
+ throw new Error('No machine-local passphrase to rotate. `rotate-passphrase` re-keys the ' +
716
+ 'file store\'s auto-provisioned key; none is provisioned on this machine.');
717
+ }
718
+ // Recovery runs even under --dry-run, deliberately: healing an interrupted
719
+ // rotation is how a crashed store becomes readable again WITHOUT re-keying it,
720
+ // and gating it would leave such a store recoverable only via a full rotation.
721
+ // It is the one thing a dry run writes, so the report says so and the CLI
722
+ // prints it instead of claiming "nothing written".
723
+ const recoveredInterruptedRotation = hasInterruptedRotationArtifacts(keyPath);
724
+ recoverInterruptedRotation(keyPath);
725
+ const oldPass = fs.readFileSync(keyPath, 'utf8').trim();
726
+ if (!oldPass)
727
+ throw new Error(`Machine-local passphrase file ${keyPath} is empty.`);
728
+ const newPass = opts.newPassphrase ?? randomBytes(32).toString('base64');
729
+ if (newPass === oldPass)
730
+ throw new Error('New passphrase equals the current one — refusing a no-op rotation.');
731
+ const dir = fileDir();
732
+ let names;
733
+ try {
734
+ names = fs.readdirSync(dir).filter((f) => f.endsWith('.enc'));
735
+ }
736
+ catch {
737
+ names = [];
738
+ }
739
+ if (names.length === 0) {
740
+ throw new Error(`No encrypted items in ${dir} — nothing to rotate.`);
741
+ }
742
+ // Phase 1 — decrypt-all, re-encrypt, re-verify in memory. Nothing on disk is
743
+ // touched here, so any throw leaves the live store and key file untouched.
744
+ const staged = [];
745
+ const skipped = [];
746
+ for (const name of names) {
747
+ // Keep the store lock fresh across the scrypt-bound loop: each item runs the
748
+ // KDF three times (decrypt, re-encrypt, verify), so on a real store this loop
749
+ // outlives the lock's stale window — without this a peer could break the lock
750
+ // as "stale" mid-run and interleave a write (see `withFileLock`'s heartbeat).
751
+ heartbeat();
752
+ const raw = fs.readFileSync(path.join(dir, name), 'utf8');
753
+ let parsed;
754
+ try {
755
+ parsed = JSON.parse(raw);
756
+ }
757
+ catch {
758
+ skipped.push(`${name} (not valid EncFile JSON)`);
759
+ continue;
760
+ }
761
+ let plain;
762
+ try {
763
+ plain = decryptForFallback(parsed, oldPass);
764
+ }
765
+ catch {
766
+ skipped.push(`${name} (does not decrypt under the current key — orphan)`);
767
+ continue;
768
+ }
769
+ let reEnc = encryptForFallback(plain, newPass);
770
+ if (opts.tamperStaged)
771
+ reEnc = { ...reEnc, ciphertext: `00${reEnc.ciphertext.slice(2)}` };
772
+ let check;
773
+ try {
774
+ check = decryptForFallback(reEnc, newPass);
775
+ }
776
+ catch {
777
+ throw new Error(`Re-encryption of ${name} failed to verify under the new key — aborted, nothing written.`);
778
+ }
779
+ if (check !== plain)
780
+ throw new Error(`Round-trip mismatch on ${name} — aborted, nothing written.`);
781
+ staged.push({ name, enc: JSON.stringify(reEnc) });
782
+ }
783
+ if (staged.length === 0) {
784
+ throw new Error('No item decrypted under the current machine-local key — aborted, nothing written.');
785
+ }
786
+ const report = {
787
+ dryRun,
788
+ committed: false,
789
+ bundleCount: staged.length,
790
+ skipped,
791
+ roundTripOk: true,
792
+ keyFilePath: keyPath,
793
+ recoveredInterruptedRotation,
794
+ };
795
+ if (dryRun)
796
+ return report;
797
+ // Phase 2 — stage the complete replacement store in a sibling temp dir, fsync,
798
+ // then swap. Orphans and any non-.enc files are copied through verbatim so the
799
+ // swapped dir is a complete superset of the old one (nothing is dropped).
800
+ const keyColocated = path.dirname(keyPath) === dir;
801
+ const rand = randomBytes(6).toString('hex');
802
+ const stageDir = `${dir}.rotate-${rand}`;
803
+ fs.rmSync(stageDir, { recursive: true, force: true });
804
+ fs.mkdirSync(stageDir, { recursive: true, mode: 0o700 });
805
+ const stagedNames = new Set(staged.map((s) => s.name));
806
+ for (const { name, enc } of staged) {
807
+ heartbeat(); // each write is an fsync; keep the lock fresh across the batch
808
+ writeFileFsync(path.join(stageDir, name), enc, 0o600);
809
+ }
810
+ for (const entry of fs.readdirSync(dir)) {
811
+ if (stagedNames.has(entry))
812
+ continue;
813
+ if (keyColocated && entry === path.basename(keyPath))
814
+ continue; // rewritten below, not copied
815
+ const src = path.join(dir, entry);
816
+ if (!fs.statSync(src).isFile())
817
+ continue;
818
+ heartbeat();
819
+ // Copy through as raw bytes — reading as 'utf8' would decode any non-UTF-8
820
+ // byte to U+FFFD and silently corrupt the file on the way through the swap.
821
+ writeFileFsync(path.join(stageDir, entry), fs.readFileSync(src), 0o600);
822
+ }
823
+ // A co-located legacy key travels with the store: write the new value into the
824
+ // staged dir so a single directory swap commits both ciphertext and key.
825
+ if (keyColocated)
826
+ writeFileFsync(path.join(stageDir, path.basename(keyPath)), newPass, 0o600);
827
+ fsyncDir(stageDir);
828
+ // Test seam: simulate a crash after staging but before the swap. The live store
829
+ // and key file are still untouched at this point.
830
+ opts.onStagedBeforeCommit?.();
831
+ // For a non-co-located key, stage the new key beside the old one first so the
832
+ // swap is two quick renames with no I/O between them.
833
+ const keyTmp = `${keyPath}.rotate-new`;
834
+ if (!keyColocated) {
835
+ writeFileFsync(keyTmp, newPass, 0o600);
836
+ fsyncDir(path.dirname(keyPath));
837
+ }
838
+ // Swap. Move the live store aside, then the staged store into place. The gap
839
+ // between these two renames is the only crash window that leaves the store dir
840
+ // absent; recoverInterruptedRotation restores it from the backup on next run.
841
+ const bakDir = `${dir}.rotate-old-${rand}`;
842
+ fs.renameSync(dir, bakDir);
843
+ // Test seam: crash after the live store is moved aside, before the staged store
844
+ // lands (the store dir is absent). For a co-located key this is the ONLY swap
845
+ // window — the single rename carries both ciphertext and key.
846
+ opts.onStoreMovedAsideBeforeSwap?.();
847
+ fs.renameSync(stageDir, dir);
848
+ fsyncDir(path.dirname(dir));
849
+ const keyBak = `${keyPath}.rotate-oldkey`;
850
+ if (!keyColocated) {
851
+ // Test seam: crash after the store swap, before the key swap begins (Window A).
852
+ // Also receives the lock heartbeat so a test can prove a long hold stays fresh.
853
+ opts.onStoreSwappedBeforeKeySwap?.(heartbeat);
854
+ fs.renameSync(keyPath, keyBak);
855
+ // Test seam: crash after the old key is moved aside, before the new key lands (Window B).
856
+ opts.onKeyBackedUpBeforeNewKey?.();
857
+ fs.renameSync(keyTmp, keyPath);
858
+ fsyncDir(path.dirname(keyPath));
859
+ }
860
+ // Verify a real read out of the now-live store under the new key. On failure,
861
+ // roll the store (and key) back to the backup — the old passphrase still works.
862
+ try {
863
+ const probe = JSON.parse(fs.readFileSync(path.join(dir, staged[0].name), 'utf8'));
864
+ decryptForFallback(probe, newPass);
865
+ }
866
+ catch (err) {
867
+ fs.rmSync(dir, { recursive: true, force: true });
868
+ fs.renameSync(bakDir, dir);
869
+ if (!keyColocated && fs.existsSync(keyBak)) {
870
+ try {
871
+ fs.rmSync(keyPath, { force: true });
872
+ }
873
+ catch { /* may not exist */ }
874
+ fs.renameSync(keyBak, keyPath);
875
+ }
876
+ throw new Error(`Post-swap verification failed; rolled back to the old key. (${err.message})`);
877
+ }
878
+ // Committed. Drop the old ciphertext and old key — both hold the retired key.
879
+ fs.rmSync(bakDir, { recursive: true, force: true });
880
+ if (!keyColocated)
881
+ fs.rmSync(keyBak, { force: true });
882
+ cachedPassphrase = newPass;
883
+ report.committed = true;
884
+ return report;
885
+ }
349
886
  /** Test-only: reset module state (file dir + cached passphrase). */
350
887
  export function _resetFileStoreForTest(opts = {}) {
351
888
  fileDirOverride = opts.fileDir ?? null;
@@ -361,4 +898,20 @@ export function _resetFileStoreForTest(opts = {}) {
361
898
  }
362
899
  cachedPassphrase = opts.passphrase ?? null;
363
900
  warnedAutoPassphrase = false;
901
+ lockAcquireTimeoutMsOverride = null;
902
+ lockStaleMsOverride = null;
903
+ }
904
+ /** Test-only: shorten the store-lock acquire timeout so a contended-lock assertion
905
+ * fails fast instead of waiting out the 30s production budget. */
906
+ export function _setFileStoreLockTimeoutForTest(ms) {
907
+ lockAcquireTimeoutMsOverride = ms;
908
+ }
909
+ /** Test-only: shrink the store-lock stale window so a heartbeat/steal assertion runs
910
+ * in milliseconds instead of the 5s production window. */
911
+ export function _setFileStoreLockStaleMsForTest(ms) {
912
+ lockStaleMsOverride = ms;
913
+ }
914
+ /** Test-only: the cross-process store-lock target (sibling of the store dir). */
915
+ export function _fileStoreLockPathForTest() {
916
+ return fileStoreLockPath();
364
917
  }