@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.
- package/CHANGELOG.md +263 -0
- package/README.md +9 -1
- package/dist/bin/agents +0 -0
- package/dist/commands/commands.js +7 -7
- package/dist/commands/factory.js +26 -2
- package/dist/commands/funnel.js +16 -1
- package/dist/commands/menubar.js +117 -34
- package/dist/commands/routines.js +23 -1
- package/dist/commands/secrets-rotate-passphrase.d.ts +17 -0
- package/dist/commands/secrets-rotate-passphrase.js +96 -0
- package/dist/commands/secrets.js +2 -0
- package/dist/commands/sessions.d.ts +7 -1
- package/dist/commands/sessions.js +39 -12
- package/dist/commands/webhook.js +7 -2
- package/dist/lib/commands.js +9 -1
- package/dist/lib/daemon.d.ts +29 -0
- package/dist/lib/daemon.js +58 -4
- package/dist/lib/events.d.ts +1 -1
- package/dist/lib/factory/snapshot.d.ts +78 -0
- package/dist/lib/factory/snapshot.js +209 -0
- package/dist/lib/fs-atomic.d.ts +14 -1
- package/dist/lib/fs-atomic.js +35 -3
- package/dist/lib/funnel.d.ts +1 -0
- package/dist/lib/funnel.js +8 -0
- package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
- package/dist/lib/menubar/MenubarHelper.app/Contents/Resources/AppIcon.icns +0 -0
- package/dist/lib/menubar/MenubarHelper.app/Contents/_CodeSignature/CodeResources +2 -2
- package/dist/lib/menubar/install-menubar.d.ts +53 -2
- package/dist/lib/menubar/install-menubar.js +183 -28
- package/dist/lib/platform/process.d.ts +2 -0
- package/dist/lib/platform/process.js +5 -3
- package/dist/lib/resources.d.ts +8 -0
- package/dist/lib/resources.js +34 -1
- package/dist/lib/routines-placement.d.ts +2 -1
- package/dist/lib/routines-placement.js +8 -4
- package/dist/lib/routines.d.ts +57 -1
- package/dist/lib/routines.js +74 -1
- package/dist/lib/runner.d.ts +2 -0
- package/dist/lib/runner.js +21 -8
- package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
- package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
- package/dist/lib/secrets/bundles.js +9 -34
- package/dist/lib/secrets/filestore.d.ts +152 -34
- package/dist/lib/secrets/filestore.js +676 -123
- package/dist/lib/session/remote-active.d.ts +4 -1
- package/dist/lib/session/remote-active.js +8 -2
- package/dist/lib/session/viewing-in.d.ts +31 -0
- package/dist/lib/session/viewing-in.js +47 -0
- package/dist/lib/state.d.ts +17 -0
- package/dist/lib/state.js +30 -2
- package/dist/lib/triggers/handlers.d.ts +95 -0
- package/dist/lib/triggers/handlers.js +384 -0
- package/dist/lib/triggers/webhook.d.ts +10 -2
- package/dist/lib/triggers/webhook.js +65 -11
- package/package.json +1 -1
package/dist/lib/runner.js
CHANGED
|
@@ -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
|
|
Binary file
|
|
Binary file
|
|
@@ -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
|
|
51
|
-
//
|
|
52
|
-
//
|
|
53
|
-
//
|
|
54
|
-
//
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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)
|
|
7
|
-
*
|
|
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.
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
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
|
-
*
|
|
41
|
-
*
|
|
42
|
-
*
|
|
43
|
-
*
|
|
44
|
-
*
|
|
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(
|
|
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
|
|
65
|
-
|
|
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 {};
|