@phnx-labs/agents-cli 1.20.88 → 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 (55) hide show
  1. package/CHANGELOG.md +263 -0
  2. package/README.md +9 -1
  3. package/dist/bin/agents +0 -0
  4. package/dist/commands/commands.js +7 -7
  5. package/dist/commands/factory.js +26 -2
  6. package/dist/commands/funnel.js +16 -1
  7. package/dist/commands/menubar.js +117 -34
  8. package/dist/commands/routines.js +23 -1
  9. package/dist/commands/secrets-rotate-passphrase.d.ts +17 -0
  10. package/dist/commands/secrets-rotate-passphrase.js +96 -0
  11. package/dist/commands/secrets.js +2 -0
  12. package/dist/commands/sessions.d.ts +7 -1
  13. package/dist/commands/sessions.js +39 -12
  14. package/dist/commands/webhook.js +7 -2
  15. package/dist/lib/commands.js +9 -1
  16. package/dist/lib/daemon.d.ts +29 -0
  17. package/dist/lib/daemon.js +58 -4
  18. package/dist/lib/events.d.ts +1 -1
  19. package/dist/lib/factory/snapshot.d.ts +78 -0
  20. package/dist/lib/factory/snapshot.js +209 -0
  21. package/dist/lib/fs-atomic.d.ts +14 -1
  22. package/dist/lib/fs-atomic.js +35 -3
  23. package/dist/lib/funnel.d.ts +1 -0
  24. package/dist/lib/funnel.js +8 -0
  25. package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
  26. package/dist/lib/menubar/MenubarHelper.app/Contents/Resources/AppIcon.icns +0 -0
  27. package/dist/lib/menubar/MenubarHelper.app/Contents/_CodeSignature/CodeResources +2 -2
  28. package/dist/lib/menubar/install-menubar.d.ts +53 -2
  29. package/dist/lib/menubar/install-menubar.js +183 -28
  30. package/dist/lib/platform/process.d.ts +2 -0
  31. package/dist/lib/platform/process.js +5 -3
  32. package/dist/lib/resources.d.ts +8 -0
  33. package/dist/lib/resources.js +34 -1
  34. package/dist/lib/routines-placement.d.ts +2 -1
  35. package/dist/lib/routines-placement.js +8 -4
  36. package/dist/lib/routines.d.ts +57 -1
  37. package/dist/lib/routines.js +74 -1
  38. package/dist/lib/runner.d.ts +2 -0
  39. package/dist/lib/runner.js +21 -8
  40. package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
  41. package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
  42. package/dist/lib/secrets/bundles.js +9 -34
  43. package/dist/lib/secrets/filestore.d.ts +152 -34
  44. package/dist/lib/secrets/filestore.js +676 -123
  45. package/dist/lib/session/remote-active.d.ts +4 -1
  46. package/dist/lib/session/remote-active.js +8 -2
  47. package/dist/lib/session/viewing-in.d.ts +31 -0
  48. package/dist/lib/session/viewing-in.js +47 -0
  49. package/dist/lib/state.d.ts +17 -0
  50. package/dist/lib/state.js +30 -2
  51. package/dist/lib/triggers/handlers.d.ts +95 -0
  52. package/dist/lib/triggers/handlers.js +384 -0
  53. package/dist/lib/triggers/webhook.d.ts +10 -2
  54. package/dist/lib/triggers/webhook.js +65 -11
  55. package/package.json +1 -1
@@ -33,6 +33,7 @@ import { getConfiguredRunStrategy, resolveRunVersion, resolveAccountVersion, rot
33
33
  import { readAuthHealth, isDeadVerdict } from './auth-health.js';
34
34
  import { machineId } from './machine-id.js';
35
35
  import { isSelfUpdatingAgent } from './agents.js';
36
+ import { expandLocalHome, getProjectRoot } from './project-root.js';
36
37
  /** CLI command templates per agent, with {prompt} as a placeholder. */
37
38
  const AGENT_COMMANDS = {
38
39
  claude: ['claude', '-p', '--verbose', '{prompt}', '--output-format', 'stream-json', '--permission-mode', 'plan'],
@@ -50,6 +51,18 @@ const ROUTINE_TRANSCRIPT_SPECS = {
50
51
  codex: [{ root: ['.codex', 'sessions'], ext: '.jsonl' }],
51
52
  cursor: [{ root: ['.cursor', 'projects'], ext: '.jsonl' }],
52
53
  };
54
+ /** Stable working directory for routine children, independent of the daemon's launch cwd. */
55
+ export function routineSpawnCwd(config, configuredRoot = getProjectRoot()) {
56
+ if (!config.repo)
57
+ return os.homedir();
58
+ const [owner, repo] = config.repo.split('/');
59
+ if (configuredRoot) {
60
+ const root = path.resolve(expandLocalHome(configuredRoot));
61
+ if (path.basename(root) === owner)
62
+ return path.join(root, repo);
63
+ }
64
+ return path.join(os.homedir(), 'src', 'github.com', owner, repo);
65
+ }
53
66
  /** Build the full CLI argv for executing a job, applying mode, model, and permission flags. */
54
67
  export function buildJobCommand(config, resolvedPrompt) {
55
68
  // Workflow branch: delegate to `agents run <workflow>` which handles subagent
@@ -449,14 +462,14 @@ export function buildRoutineSpawnEnv(baseEnv, agent, version, timezone, overlayH
449
462
  * Rate-limit scanning uses only this attempt's log (not prior failover output).
450
463
  * The attempt log is also appended into `combinedLogPath` for a continuous trail.
451
464
  */
452
- function spawnJobAttempt(cmd, env, attemptLogPath, timeoutMs, combinedLogPath) {
465
+ function spawnJobAttempt(cmd, env, attemptLogPath, timeoutMs, combinedLogPath, cwd = os.homedir()) {
453
466
  // Isolate this attempt's output so detectRateLimit never sees prior attempts.
454
467
  fs.writeFileSync(attemptLogPath, '', { mode: 0o600 });
455
468
  const stdoutFd = fs.openSync(attemptLogPath, 'a', 0o600);
456
469
  return new Promise((resolve) => {
457
470
  const child = spawn(cmd[0], cmd.slice(1), {
458
471
  stdio: ['ignore', stdoutFd, stdoutFd],
459
- ...backgroundSpawnOptions({ fdStdio: true }),
472
+ ...backgroundSpawnOptions({ cwd, fdStdio: true }),
460
473
  env,
461
474
  });
462
475
  let settled = false;
@@ -604,7 +617,7 @@ export async function executeJob(config, deps) {
604
617
  const runDir = getRunDir(config.name, runId);
605
618
  fs.mkdirSync(runDir, { recursive: true });
606
619
  const baseEnv = injectRoutineActor(useSandbox
607
- ? buildSpawnEnv(overlayHome)
620
+ ? buildSpawnEnv(overlayHome, config.env)
608
621
  : { ...process.env }, config);
609
622
  // Workflows run via `agents run <workflow>` which delegates to claude under the hood.
610
623
  // Use 'claude' as the effective agent for report extraction and metadata when workflow is set.
@@ -715,7 +728,7 @@ export async function executeJob(config, deps) {
715
728
  const elapsed = Date.now() - Date.parse(meta.startedAt);
716
729
  const remaining = Math.max(1_000, timeoutMs - (Number.isFinite(elapsed) ? elapsed : 0));
717
730
  const attemptLogPath = path.join(runDir, `stdout.attempt-${i}.log`);
718
- const attempt = await spawnJobAttempt(cmd, spawnEnv, attemptLogPath, remaining, stdoutPath);
731
+ const attempt = await spawnJobAttempt(cmd, spawnEnv, attemptLogPath, remaining, stdoutPath, routineSpawnCwd(config));
719
732
  meta.pid = attempt.pid;
720
733
  writeRunMeta(meta);
721
734
  if (attempt.status === 'timeout') {
@@ -970,7 +983,7 @@ async function executeCommandJobForeground(config) {
970
983
  const result = await new Promise((resolve) => {
971
984
  const child = spawn(cmd[0], cmd.slice(1), {
972
985
  stdio: ['ignore', stdoutFd, stdoutFd],
973
- ...backgroundSpawnOptions({ fdStdio: true }),
986
+ ...backgroundSpawnOptions({ cwd: routineSpawnCwd(config), fdStdio: true }),
974
987
  env,
975
988
  });
976
989
  meta.pid = child.pid || null;
@@ -1086,7 +1099,7 @@ export async function executeJobDetached(config, hooks) {
1086
1099
  const stdoutPath = path.join(runDir, 'stdout.log');
1087
1100
  const stdoutFd = fs.openSync(stdoutPath, 'w', 0o600);
1088
1101
  const baseEnv = injectRoutineActor(useSandbox
1089
- ? buildSpawnEnv(overlayHome)
1102
+ ? buildSpawnEnv(overlayHome, config.env)
1090
1103
  : { ...process.env }, config);
1091
1104
  const spawnEnv = dispatchesViaAgentsRun(config)
1092
1105
  ? (() => {
@@ -1135,7 +1148,7 @@ export async function executeJobDetached(config, hooks) {
1135
1148
  }
1136
1149
  const child = spawn(cmd[0], cmd.slice(1), {
1137
1150
  stdio: ['ignore', stdoutFd, stdoutFd],
1138
- ...backgroundSpawnOptions({ fdStdio: true }),
1151
+ ...backgroundSpawnOptions({ cwd: routineSpawnCwd(config), fdStdio: true }),
1139
1152
  env: spawnEnv,
1140
1153
  });
1141
1154
  let settled = false;
@@ -1233,7 +1246,7 @@ function executeCommandJobDetached(config, hooks) {
1233
1246
  };
1234
1247
  const child = spawn(cmd[0], cmd.slice(1), {
1235
1248
  stdio: ['ignore', stdoutFd, stdoutFd],
1236
- ...backgroundSpawnOptions({ fdStdio: true }),
1249
+ ...backgroundSpawnOptions({ cwd: routineSpawnCwd(config), fdStdio: true }),
1237
1250
  env,
1238
1251
  });
1239
1252
  // Record the real terminal status ourselves — the daemon stays alive after this
@@ -47,20 +47,19 @@ const keychainStore = {
47
47
  delete: deleteKeychainToken,
48
48
  list: listKeychainItems,
49
49
  };
50
- // The file store auto-provisions a machine-local passphrase on Linux (the
51
- // existing headless-libsecret fallback) but NEVER on macOS: a file-backed
52
- // bundle on a Mac must be unlocked with an explicit AGENTS_SECRETS_PASSPHRASE
53
- // supplied per run, so the box holds ciphertext only. assertFileBackendUsable()
54
- // enforces that the passphrase is present before we touch the store.
55
- const FILE_ALLOW_AUTO_PROVISION = process.platform !== 'darwin';
50
+ // The file store auto-provisions a stable machine-local passphrase on EVERY
51
+ // platform (macOS included) a 0600 key file, encryption-at-rest with the same
52
+ // posture as an SSH key so `agents secrets` "just works" with no passphrase to
53
+ // set, type, or remember, and no Touch ID. Set AGENTS_SECRETS_PASSPHRASE to opt
54
+ // into an off-disk key.
56
55
  const fileItemStore = {
57
56
  has: (item) => fileStore.has(item),
58
- get: (item) => fileStore.get(item, { allowAutoProvision: FILE_ALLOW_AUTO_PROVISION }),
57
+ get: (item) => fileStore.get(item),
59
58
  getBatch: (items) => {
60
59
  const out = new Map();
61
60
  for (const item of items) {
62
61
  try {
63
- out.set(item, fileStore.get(item, { allowAutoProvision: FILE_ALLOW_AUTO_PROVISION }));
62
+ out.set(item, fileStore.get(item));
64
63
  }
65
64
  catch {
66
65
  // Missing/undecryptable item — absent from the map, mirroring
@@ -69,10 +68,10 @@ const fileItemStore = {
69
68
  }
70
69
  return out;
71
70
  },
72
- set: (item, value) => fileStore.set(item, value, { allowAutoProvision: FILE_ALLOW_AUTO_PROVISION }),
71
+ set: (item, value) => fileStore.set(item, value),
73
72
  setBatch: (items) => {
74
73
  for (const [item, value] of items) {
75
- fileStore.set(item, value, { allowAutoProvision: FILE_ALLOW_AUTO_PROVISION });
74
+ fileStore.set(item, value);
76
75
  }
77
76
  },
78
77
  delete: (item) => fileStore.delete(item),
@@ -116,24 +115,6 @@ export function bundleBackend(name) {
116
115
  }
117
116
  return 'keychain';
118
117
  }
119
- /**
120
- * Guard a file-backed bundle operation. On macOS the file store must be
121
- * unlocked with an explicit passphrase (env or interactive prompt) — we refuse
122
- * to silently auto-provision a machine-local key there, so a remote/headless
123
- * Mac cannot decrypt on its own. Linux keeps the existing auto-provision
124
- * behavior, so this is a no-op there.
125
- */
126
- function assertFileBackendUsable(name) {
127
- if (process.platform !== 'darwin')
128
- return;
129
- if (process.env.AGENTS_SECRETS_PASSPHRASE && process.env.AGENTS_SECRETS_PASSPHRASE.length > 0)
130
- return;
131
- if (process.stdin.isTTY)
132
- return;
133
- throw new Error(`File-backed bundle '${name}' needs AGENTS_SECRETS_PASSPHRASE to be set on macOS ` +
134
- `(no biometry prompt is available headlessly). Set it for this run, e.g.\n` +
135
- ` AGENTS_SECRETS_PASSPHRASE=… agents secrets exec ${name} -- <command>`);
136
- }
137
118
  function assertVaultBackendUsable(name) {
138
119
  if (getVaultSession().loggedIn)
139
120
  return;
@@ -286,8 +267,6 @@ export function readBundleIfDecryptable(name) {
286
267
  export function readBundle(name) {
287
268
  validateBundleName(name);
288
269
  const backend = bundleBackend(name);
289
- if (backend === 'file')
290
- assertFileBackendUsable(name);
291
270
  if (backend === 'vault')
292
271
  assertVaultBackendUsable(name);
293
272
  let json;
@@ -395,8 +374,6 @@ export function shouldEvictAfterBundleWrite(skipRequested, noAgentEnv, backendOv
395
374
  function prepareBundleWrite(bundle) {
396
375
  validateBundleName(bundle.name);
397
376
  const backend = bundle.backend ?? 'keychain';
398
- if (backend === 'file')
399
- assertFileBackendUsable(bundle.name);
400
377
  if (backend === 'vault')
401
378
  assertVaultBackendUsable(bundle.name);
402
379
  for (const key of Object.keys(bundle.vars)) {
@@ -1177,8 +1154,6 @@ export function readAndResolveBundleEnv(name, opts = {}) {
1177
1154
  `never raises a Touch ID sheet on its own.`);
1178
1155
  }
1179
1156
  }
1180
- if (backend === 'file')
1181
- assertFileBackendUsable(name);
1182
1157
  if (backend === 'vault')
1183
1158
  assertVaultBackendUsable(name);
1184
1159
  const store = itemStore(backend);
@@ -3,18 +3,19 @@
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:
@@ -22,31 +23,19 @@
22
23
  */
23
24
  import type { KeychainBackend } from './index.js';
24
25
  export declare function fileDir(): string;
25
- /**
26
- * Turn off terminal echo on the controlling TTY, or throw — fail CLOSED. If echo
27
- * cannot be disabled (`stty` missing, no controlling terminal) we must NOT fall
28
- * through and read the passphrase anyway: that echoes the secret to the screen
29
- * and into scrollback (RUSH-1764). Refuse and point the user at the environment
30
- * variable instead. `run` performs the echo-disable and throws iff it fails.
31
- * Exported so the fail-closed contract has direct test coverage.
32
- */
33
- export declare function disableTtyEchoOrThrow(run: () => void): void;
34
26
  /** True if a machine-local passphrase has already been provisioned. */
35
27
  export declare function machinePassphraseExists(): boolean;
36
28
  /**
37
29
  * Resolve the passphrase for the encrypted file store.
38
30
  *
39
31
  * Order: AGENTS_SECRETS_PASSPHRASE > previously-provisioned machine-local key >
40
- * (interactive) TTY prompt > (headless) auto-provisioned machine-local key.
41
- *
42
- * `allowAutoProvision` (default true, used by the Linux fallback) controls the
43
- * last two steps. macOS file-backed bundles pass `false` so a missing
44
- * passphrase is a hard, explicit error instead of a silently provisioned
45
- * on-disk key — the caller (bundles.ts) guards this before we get here.
32
+ * a freshly auto-provisioned machine-local key. It NEVER prompts and NEVER
33
+ * hard-fails — the file store must work on every platform (macOS included)
34
+ * without the user setting, typing, or remembering a passphrase. Provisioning
35
+ * writes a 0600 key file (encryption-at-rest, same posture as an SSH key); set
36
+ * AGENTS_SECRETS_PASSPHRASE to opt into an off-disk key.
46
37
  */
47
- export declare function getPassphrase(opts?: {
48
- allowAutoProvision?: boolean;
49
- }): string;
38
+ export declare function getPassphrase(): string;
50
39
  /** Encrypted-file on-disk shape. Exported for tests. */
51
40
  export interface EncFile {
52
41
  salt: string;
@@ -61,12 +50,8 @@ export declare function encryptForFallback(plaintext: string, passphrase: string
61
50
  * ciphertext (auth-tag mismatch). Exported for tests. */
62
51
  export declare function decryptForFallback(enc: EncFile, passphrase: string): string;
63
52
  declare function fileHas(item: string): boolean;
64
- declare function fileGet(item: string, opts?: {
65
- allowAutoProvision?: boolean;
66
- }): string;
67
- declare function fileSet(item: string, value: string, opts?: {
68
- allowAutoProvision?: boolean;
69
- }): void;
53
+ declare function fileGet(item: string): string;
54
+ declare function fileSet(item: string, value: string): void;
70
55
  declare function fileDelete(item: string): boolean;
71
56
  declare function fileList(prefix: string): string[];
72
57
  /** True if the fallback dir has any committed encrypted items. */
@@ -85,10 +70,143 @@ export declare const fileStore: {
85
70
  export declare const fileBackend: KeychainBackend;
86
71
  /** Resolved passphrase directory (exported for integration tests). */
87
72
  export declare function resolvePassphraseDir(): string;
73
+ /**
74
+ * Path of the machine-local passphrase file that currently holds the file-store
75
+ * key, or null if none is provisioned. Prefers the canonical #479 location and
76
+ * falls back to the legacy co-located path, mirroring `readMachinePassphrase`.
77
+ */
78
+ export declare function machinePassphraseSourcePath(): string | null;
79
+ /** Outcome of a `rotatePassphrase` run. Carries no secret material. */
80
+ export interface RotatePassphraseReport {
81
+ /** True when nothing was written (report-only). */
82
+ dryRun: boolean;
83
+ /** True when the store was re-encrypted and the key file swapped. */
84
+ committed: boolean;
85
+ /** Encrypted items that decrypt under the current key and were (or would be) re-keyed. */
86
+ bundleCount: number;
87
+ /** `.enc` files that do NOT decrypt under the current key — left untouched, never re-keyed. */
88
+ skipped: string[];
89
+ /** Every re-keyed item round-tripped (decrypt) under the new key before any swap. */
90
+ roundTripOk: boolean;
91
+ /** The machine-local passphrase file that was (or would be) rewritten in place. */
92
+ keyFilePath: string;
93
+ /**
94
+ * A previous rotation had left artifacts on disk and they were healed before
95
+ * this rotation proceeded. This happens under `--dry-run` too — recovery is how
96
+ * a crashed store becomes readable again without re-keying it — and is the only
97
+ * thing a dry run writes.
98
+ */
99
+ recoveredInterruptedRotation: boolean;
100
+ }
101
+ /**
102
+ * Recover from a rotation that was interrupted mid-swap on a prior run, so the
103
+ * store is always left in a single, self-consistent, readable state.
104
+ *
105
+ * Recovery is CONTENT-aware, not presence-aware, and it classifies the WHOLE
106
+ * store, not just one item. The mere existence of the store dir and the key file
107
+ * does not prove they match (RUSH-1975 data-loss window): on the non-co-located
108
+ * key path the swap is four renames, and a crash after the store swap
109
+ * (`stageDir`->`dir`) but before the key swap (`keyTmp`->`keyPath`) finishes
110
+ * leaves a NEW-key store next to the OLD key file, both present. A presence check
111
+ * would see "both here" and wrongly sweep the only copies of the old ciphertext
112
+ * (`<dir>.rotate-old-*`) and the new key (`<key>.rotate-new`), permanently
113
+ * orphaning every secret. So we probe the actual ciphertext with `classifyStore`,
114
+ * which distinguishes a store that opens fully under one key ('all') from one that
115
+ * is MIXED — some items under the live key, others under the incoming key ('some',
116
+ * e.g. after a mid-swap crash contaminated by a later `secrets set`):
117
+ *
118
+ * 1. The live key opens EVERY non-orphan item ('all') → rotation complete and
119
+ * consistent (or never interrupted); sweeping the `.rotate-*` artifacts is safe —
120
+ * unless a `<dir>.rotate-old-*` backup still holds `.enc` items absent from the
121
+ * live dir. That means the live dir is not the post-swap store but a fresh dir an
122
+ * interstitial `secrets set` created after a crash in the move-aside window left
123
+ * the store dir absent, so the backup is the only copy of those items → REFUSE.
124
+ * 2. Else, if `<key>.rotate-new` opens every non-orphan item ('all'), the crash
125
+ * landed after the store swap but before the key swap finished → finish the
126
+ * rotation forward by installing `.rotate-new` as the live key, then sweep.
127
+ * 3. Else, if neither key opens any item, roll back: restore the
128
+ * `<dir>.rotate-old-*` backup over `dir` and `<key>.rotate-oldkey` over the key
129
+ * file — but only once the backup is proven to open fully under the old key.
130
+ * 4. If a key opens SOME but not all items ('some'), the store is MIXED — an
131
+ * interrupted rotation contaminated by a later write, with live data under two
132
+ * keys at once. Sweeping would delete the only copy of one of those keys, so we
133
+ * REFUSE: throw an actionable error and preserve every recovery artifact for
134
+ * out-of-band repair. Likewise, if neither forward nor rollback can be proven,
135
+ * leave every artifact in place — a leftover temp dir is recoverable, deleting
136
+ * the only copy of a key or ciphertext is not.
137
+ *
138
+ * A phase-marker / journal file was considered and deliberately skipped: the
139
+ * AES-256-GCM auth tag already makes the decrypt probe an authoritative,
140
+ * self-validating record of which key matches the store. A separate marker would
141
+ * be a second source of truth that can disagree with reality — its own write has
142
+ * crash windows, and a stale marker misleads — so it would weaken, not strengthen,
143
+ * this guarantee. Idempotent; a no-op when no rotation artifacts are present.
144
+ * Callers run this under the store lock (see `withStoreLock`).
145
+ */
146
+ /**
147
+ * Whether a previous rotation left artifacts on disk — i.e. whether
148
+ * {@link recoverInterruptedRotation} would do any work. Read-only, so `--dry-run`
149
+ * can report a pending recovery without performing (and thus writing) one.
150
+ */
151
+ export declare function hasInterruptedRotationArtifacts(keyPath: string): boolean;
152
+ /**
153
+ * Rotate the machine-local file-store passphrase: decrypt every `.enc` item
154
+ * under the current key and re-encrypt it under a freshly generated one, then
155
+ * swap both the ciphertext and the key file atomically.
156
+ *
157
+ * Safety contract (RUSH-1975):
158
+ * - Verify before writing: every re-keyed item must round-trip decrypt under
159
+ * the new key, and the re-keyed count must reconcile with the source, or the
160
+ * run aborts having written nothing.
161
+ * - Atomic: the new store is staged in a sibling temp dir, fsync'd, then swapped
162
+ * into place by directory rename; the new key file is fsync'd and swapped the
163
+ * same way. A crash before the swap leaves the old store and old key fully
164
+ * intact and readable; a crash inside the swap self-heals on the next run
165
+ * (see `recoverInterruptedRotation`). No half-re-keyed store is ever exposed.
166
+ * - No plaintext (secret value or passphrase) is ever written to disk, argv, or
167
+ * a log — only ciphertext is staged, and the new key lands only in the 0600
168
+ * key file.
169
+ * - Items that do not decrypt under the current key (orphan caches, stale test
170
+ * artifacts written under another key) are copied through verbatim, never
171
+ * re-keyed, and reported in `skipped`.
172
+ *
173
+ * `newPassphrase` and the `on*` callbacks are test seams. `newPassphrase` pins the
174
+ * generated key so a test can assert the swap. `onStagedBeforeCommit` fires after
175
+ * staging but before any swap (a crash here leaves the old store fully intact).
176
+ * `onStoreSwappedBeforeKeySwap` fires after the store swap but before the key swap
177
+ * begins (Window A: NEW-key store beside the OLD key file). `onKeyBackedUpBeforeNewKey`
178
+ * fires after the old key is moved aside but before the new key lands (Window B: NEW
179
+ * store, key file absent). Each throws to simulate a mid-swap crash at that exact
180
+ * point, and next-run recovery must heal it without data loss. `tamperStaged` forces
181
+ * a staged item to fail its round-trip check, exercising the verify-before-swap abort.
182
+ */
183
+ export interface RotatePassphraseOpts {
184
+ dryRun?: boolean;
185
+ newPassphrase?: string;
186
+ onStagedBeforeCommit?: () => void;
187
+ onStoreMovedAsideBeforeSwap?: () => void;
188
+ onStoreSwappedBeforeKeySwap?: (heartbeat: () => void) => void;
189
+ onKeyBackedUpBeforeNewKey?: () => void;
190
+ tamperStaged?: boolean;
191
+ }
192
+ /**
193
+ * Rotate under the exclusive store lock, so no `secrets set`/`delete` and no
194
+ * second rotation can interleave with the swap (see `withStoreLock`). The whole
195
+ * run — recovery, verify, swap — holds the lock; it is released on return or throw.
196
+ */
197
+ export declare function rotatePassphrase(opts?: RotatePassphraseOpts): RotatePassphraseReport;
88
198
  /** Test-only: reset module state (file dir + cached passphrase). */
89
199
  export declare function _resetFileStoreForTest(opts?: {
90
200
  fileDir?: string | null;
91
201
  passphraseDir?: string | null;
92
202
  passphrase?: string | null;
93
203
  }): void;
204
+ /** Test-only: shorten the store-lock acquire timeout so a contended-lock assertion
205
+ * fails fast instead of waiting out the 30s production budget. */
206
+ export declare function _setFileStoreLockTimeoutForTest(ms: number | null): void;
207
+ /** Test-only: shrink the store-lock stale window so a heartbeat/steal assertion runs
208
+ * in milliseconds instead of the 5s production window. */
209
+ export declare function _setFileStoreLockStaleMsForTest(ms: number | null): void;
210
+ /** Test-only: the cross-process store-lock target (sibling of the store dir). */
211
+ export declare function _fileStoreLockPathForTest(): string;
94
212
  export {};