FN-8825: move secrets fingerprints into private Git metadata
Store secrets environment fingerprint metadata in private Git directories so refresh-enabled worktrees remain clean. - Reconcile and durably migrate legacy root fingerprint sidecars before strict worktree refreshes. - Fail closed for malformed, tracked, conflicting, or non-durable fingerprint records and protect cleanup integrity. - Add coverage and document the private fingerprint record contract. Files changed: .changeset/fn-8825-secrets-env-fingerprint.md | 7 + docs/secrets.md | 5 +- .../secrets-env-materialization.test.ts | 45 ++- .../src/__tests__/secrets-env-writer.test.ts | 325 ++++++++++++++- .../worktree-acquisition-secrets-env.test.ts | 61 ++- packages/engine/src/worktree/secrets-env-writer.ts | 434 ++++++++++++++++----- .../engine/src/worktree/worktree-acquisition.ts | 22 +- 7 files changed, 799 insertions(+), 100 deletions(-) Fusion-Task-Id: FN-8825 Fusion-Task-Lineage: eb7148bd-1da7-4448-8abf-498ddaebd4c0 Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
7
.changeset/fn-8825-secrets-env-fingerprint.md
Normal file
7
.changeset/fn-8825-secrets-env-fingerprint.md
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
---
|
||||||
|
"@runfusion/fusion": patch
|
||||||
|
---
|
||||||
|
|
||||||
|
summary: Keep secrets environment fingerprint records out of task worktrees.
|
||||||
|
category: fix
|
||||||
|
dev: Reconciles legacy root records before strict worktree refresh and preserves ambiguous metadata.
|
||||||
@@ -138,8 +138,9 @@ Fusion can materialize env-exportable secrets into each acquired task worktree w
|
|||||||
- Supported settings: `enabled`, `filename` (default `.env`, validated as local filename only), `overwritePolicy` (`skip`/`merge`/`replace`), `keyPrefix`, `requireGitignored` (default `true`).
|
- Supported settings: `enabled`, `filename` (default `.env`, validated as local filename only), `overwritePolicy` (`skip`/`merge`/`replace`), `keyPrefix`, `requireGitignored` (default `true`).
|
||||||
- Safety guard: when `requireGitignored` is enabled, Fusion runs `git check-ignore -- <filename>` and refuses writes unless the file is ignored.
|
- Safety guard: when `requireGitignored` is enabled, Fusion runs `git check-ignore -- <filename>` and refuses writes unless the file is ignored.
|
||||||
- Write contract: managed content is canonicalized and written atomically with mode `0o600`; audit metadata includes keys and counts, never values.
|
- Write contract: managed content is canonicalized and written atomically with mode `0o600`; audit metadata includes keys and counts, never values.
|
||||||
- Fingerprint sidecar: successful writes persist `.fusion-secrets-env.fingerprint` containing `<sha256>\n<filename>\n` (mode `0o600`) so teardown can verify file integrity before deletion.
|
- Fingerprint record: successful writes atomically persist `.fusion-secrets-env.fingerprint` containing `<sha256>\n<filename>\n` with mode `0o600` in the worktree's private Git directory (`git rev-parse --git-dir`), never in project content. This keeps Fusion bookkeeping out of porcelain status while teardown can verify file integrity before deletion.
|
||||||
- Teardown cleanup: when a worktree is removed, Fusion deletes the managed env file only when the on-disk fingerprint still matches; edited files are preserved and only the sidecar is removed.
|
- Legacy reconciliation: before a reused worktree refreshes, Fusion recognizes the exact v0.75.1 UTF-8 root wire format: `<64 lowercase SHA-256 hex>\n<valid filename>\n` (normally `<sha>\n.secrets.env\n`), with no marker or envelope. It writes and syncs a temporary private record, atomically renames it, and syncs the private Git directory before unlinking the root record; it then syncs the worktree root directory before declaring execution safe. Retry re-writes and syncs even a private-only record, so a readable record left after a failed post-rename write cannot bypass the private-directory durability barrier. Thus a crash before either barrier remains fail-closed and retries converge without losing the only validated cleanup authority. Identical private and legacy records reduce to the private record; a valid private record supersedes an absent or invalid legacy record. A malformed sole record, unavailable Git directory, directory-sync failure, tracked root record, or different valid records fails the refresh closed without deleting or overwriting records or the env file.
|
||||||
|
- Teardown cleanup: Fusion selects only one uniquely validated canonical record. It deletes the managed env file only when its fingerprint still matches and Git proves it is untracked; a missing env removes equivalent validated records, while a tracked or edited env, malformed record, or conflicting records are preserved for safety and diagnosis. Metadata removal or directory-sync failure is reported as a fixed non-success cleanup outcome rather than a false successful cleanup, so retry can reconcile the remaining authority.
|
||||||
|
|
||||||
Settings shape is split by scope: project-level secrets settings include `ProjectSettings.secretsEnv` and MCP secret references in `ProjectSettings.mcpServers`, while cross-node sync passphrase state is stored only as the reserved `__sync_passphrase__` row in `secrets_global` and exposed read-only through `GlobalSettings.secretsSyncPassphraseConfigured` (`packages/core/src/types.ts`). Settings never carry plaintext passphrases or MCP credentials; MCP env/header/token fields use `{ secretRef, scope }` and materialize through `SecretsStore.revealSecret(...)` only at the runtime use seam.
|
Settings shape is split by scope: project-level secrets settings include `ProjectSettings.secretsEnv` and MCP secret references in `ProjectSettings.mcpServers`, while cross-node sync passphrase state is stored only as the reserved `__sync_passphrase__` row in `secrets_global` and exposed read-only through `GlobalSettings.secretsSyncPassphraseConfigured` (`packages/core/src/types.ts`). Settings never carry plaintext passphrases or MCP credentials; MCP env/header/token fields use `{ secretRef, scope }` and materialize through `SecretsStore.revealSecret(...)` only at the runtime use seam.
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +1,14 @@
|
|||||||
|
import { createHash } from "node:crypto";
|
||||||
import { execFileSync } from "node:child_process";
|
import { execFileSync } from "node:child_process";
|
||||||
import { mkdtempSync, mkdirSync, readFileSync, writeFileSync, existsSync } from "node:fs";
|
import { mkdtempSync, mkdirSync, readFileSync, writeFileSync, existsSync, rmSync } from "node:fs";
|
||||||
import { rm } from "node:fs/promises";
|
import { rm } from "node:fs/promises";
|
||||||
import { join } from "node:path";
|
import { join, resolve } from "node:path";
|
||||||
import { tmpdir } from "node:os";
|
import { tmpdir } from "node:os";
|
||||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
import { writeSecretsEnvFile } from "../../worktree/secrets-env-writer.js";
|
import { writeSecretsEnvFile } from "../../worktree/secrets-env-writer.js";
|
||||||
|
import { refreshReusedWorktreeBase } from "../../worktree-base-refresh.js";
|
||||||
import { reapOrphanWorktrees } from "../../worktree/worktree-pool.js";
|
import { reapOrphanWorktrees } from "../../worktree/worktree-pool.js";
|
||||||
|
import { acquireTaskWorktree } from "../../worktree/worktree-acquisition.js";
|
||||||
|
|
||||||
const dirs: string[] = [];
|
const dirs: string[] = [];
|
||||||
function tmpRepo(): string {
|
function tmpRepo(): string {
|
||||||
@@ -41,6 +44,44 @@ describe("reliability interactions: secrets env materialization", () => {
|
|||||||
expect(audit.filesystem).toHaveBeenCalledWith(expect.objectContaining({ type: "secret:env-write-skipped" }));
|
expect(audit.filesystem).toHaveBeenCalledWith(expect.objectContaining({ type: "secret:env-write-skipped" }));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("adopts a planning-era legacy sidecar before linked-worktree refresh while real dirt still blocks", async () => {
|
||||||
|
const root = tmpRepo();
|
||||||
|
execFileSync("git", ["config", "user.email", "test@example.com"], { cwd: root });
|
||||||
|
execFileSync("git", ["config", "user.name", "Test"], { cwd: root });
|
||||||
|
writeFileSync(join(root, ".gitignore"), ".secrets.env\n");
|
||||||
|
writeFileSync(join(root, "README.md"), "base\n");
|
||||||
|
execFileSync("git", ["add", ".gitignore", "README.md"], { cwd: root });
|
||||||
|
execFileSync("git", ["commit", "-qm", "base"], { cwd: root });
|
||||||
|
const base = execFileSync("git", ["rev-parse", "HEAD"], { cwd: root, encoding: "utf8" }).trim();
|
||||||
|
const worktree = join(root, "linked");
|
||||||
|
execFileSync("git", ["worktree", "add", "-b", "fusion/fn-1", worktree, base], { cwd: root });
|
||||||
|
const secretsStore = { listEnvExportable: vi.fn().mockResolvedValue([{ id: "1", key: "A", exportKey: "ALPHA", scope: "project", plaintextValue: "v" }]) } as any;
|
||||||
|
await writeSecretsEnvFile({ rootDir: root, worktreePath: worktree, taskId: "FN-1", settings: { secretsEnv: { enabled: true, filename: ".secrets.env" } }, worktreeSource: "fresh", secretsStore });
|
||||||
|
const gitDirOutput = execFileSync("git", ["rev-parse", "--git-dir"], { cwd: worktree, encoding: "utf8" }).trim();
|
||||||
|
const privateRecord = join(resolve(worktree, gitDirOutput), ".fusion-secrets-env.fingerprint");
|
||||||
|
const legacyBytes = `${createHash("sha256").update(readFileSync(join(worktree, ".secrets.env"), "utf8")).digest("hex")}\n.secrets.env\n`;
|
||||||
|
// FNXC:SecretsEnvMaterialization 2026-08-08-03:02: This is the byte-for-byte v0.75.1 root wire format left by planning before execution reuses its linked worktree.
|
||||||
|
writeFileSync(join(worktree, ".fusion-secrets-env.fingerprint"), legacyBytes);
|
||||||
|
rmSync(privateRecord);
|
||||||
|
|
||||||
|
// FNXC:SecretsEnvMaterialization 2026-08-08-03:51: Exercise the production resume seam, not the reconciler in isolation: planning's v0.75.1 root record must be adopted before executor-style refresh evaluates porcelain.
|
||||||
|
const store = { updateTask: vi.fn().mockResolvedValue(undefined), logEntry: vi.fn().mockResolvedValue(undefined) } as any;
|
||||||
|
await expect(acquireTaskWorktree({
|
||||||
|
task: { id: "FN-1", title: "secrets handoff", description: "", branch: "fusion/fn-1", worktree, baseCommitSha: base } as any,
|
||||||
|
rootDir: root,
|
||||||
|
store,
|
||||||
|
settings: { secretsEnv: { enabled: true, filename: ".secrets.env" } } as any,
|
||||||
|
refreshStaleBase: true,
|
||||||
|
createWorktree: vi.fn(),
|
||||||
|
logger: { log: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() },
|
||||||
|
})).resolves.toMatchObject({ source: "existing", isResume: true, baseRefresh: { executionSafe: true } });
|
||||||
|
expect(existsSync(join(worktree, ".fusion-secrets-env.fingerprint"))).toBe(false);
|
||||||
|
expect(execFileSync("git", ["status", "--porcelain"], { cwd: worktree, encoding: "utf8" })).toBe("");
|
||||||
|
|
||||||
|
writeFileSync(join(worktree, "unrelated.txt"), "dirt\n");
|
||||||
|
await expect(refreshReusedWorktreeBase({ task: { id: "FN-1", baseCommitSha: base } as any, rootDir: root, worktreePath: worktree, store, settings: {} })).resolves.toMatchObject({ kind: "dirty-worktree", executionSafe: false });
|
||||||
|
});
|
||||||
|
|
||||||
it("orphan reap reclaims orphaned env artifacts", async () => {
|
it("orphan reap reclaims orphaned env artifacts", async () => {
|
||||||
const root = tmpRepo();
|
const root = tmpRepo();
|
||||||
const worktreesDir = join(root, ".worktrees");
|
const worktreesDir = join(root, ".worktrees");
|
||||||
|
|||||||
@@ -1,15 +1,17 @@
|
|||||||
|
import { createHash } from "node:crypto";
|
||||||
import { execFileSync } from "node:child_process";
|
import { execFileSync } from "node:child_process";
|
||||||
import { mkdtempSync, readFileSync, statSync, symlinkSync, writeFileSync, existsSync } from "node:fs";
|
import { mkdtempSync, mkdirSync, readFileSync, statSync, symlinkSync, writeFileSync, existsSync, rmSync, renameSync } from "node:fs";
|
||||||
import { rm } from "node:fs/promises";
|
import { rm } from "node:fs/promises";
|
||||||
import { join } from "node:path";
|
import { join } from "node:path";
|
||||||
import { tmpdir } from "node:os";
|
import { tmpdir } from "node:os";
|
||||||
import { describe, it, expect, vi, afterEach } from "vitest";
|
import { describe, it, expect, vi, afterEach } from "vitest";
|
||||||
import { cleanupSecretsEnvFile, writeSecretsEnvFile } from "../worktree/secrets-env-writer.js";
|
import { cleanupSecretsEnvFile, reconcileSecretsEnvFingerprint, writeSecretsEnvFile } from "../worktree/secrets-env-writer.js";
|
||||||
|
|
||||||
const dirs: string[] = [];
|
const dirs: string[] = [];
|
||||||
|
|
||||||
function tmpWorktree(): string {
|
function tmpWorktree(): string {
|
||||||
const dir = mkdtempSync(join(tmpdir(), "secrets-env-"));
|
const dir = mkdtempSync(join(tmpdir(), "secrets-env-"));
|
||||||
|
execFileSync("git", ["init", "-q"], { cwd: dir });
|
||||||
dirs.push(dir);
|
dirs.push(dir);
|
||||||
return dir;
|
return dir;
|
||||||
}
|
}
|
||||||
@@ -75,11 +77,12 @@ describe("secrets-env-writer", () => {
|
|||||||
const env = readFileSync(join(dir, ".env"), "utf8");
|
const env = readFileSync(join(dir, ".env"), "utf8");
|
||||||
expect(env).toContain("ALPHA=");
|
expect(env).toContain("ALPHA=");
|
||||||
expect(env).toContain("BETA=");
|
expect(env).toContain("BETA=");
|
||||||
const sidecar = readFileSync(join(dir, ".fusion-secrets-env.fingerprint"), "utf8");
|
const sidecar = readFileSync(join(dir, ".git", ".fusion-secrets-env.fingerprint"), "utf8");
|
||||||
expect(sidecar).toContain(".env");
|
expect(sidecar).toContain(".env");
|
||||||
|
expect(existsSync(join(dir, ".fusion-secrets-env.fingerprint"))).toBe(false);
|
||||||
if (process.platform !== "win32") {
|
if (process.platform !== "win32") {
|
||||||
expect(statSync(join(dir, ".env")).mode & 0o777).toBe(0o600);
|
expect(statSync(join(dir, ".env")).mode & 0o777).toBe(0o600);
|
||||||
expect(statSync(join(dir, ".fusion-secrets-env.fingerprint")).mode & 0o777).toBe(0o600);
|
expect(statSync(join(dir, ".git", ".fusion-secrets-env.fingerprint")).mode & 0o777).toBe(0o600);
|
||||||
}
|
}
|
||||||
|
|
||||||
const outputBlob = JSON.stringify({ calls: filesystem.mock.calls, logs: log.mock.calls, warns: warn.mock.calls });
|
const outputBlob = JSON.stringify({ calls: filesystem.mock.calls, logs: log.mock.calls, warns: warn.mock.calls });
|
||||||
@@ -177,6 +180,304 @@ describe("secrets-env-writer", () => {
|
|||||||
expect(b.reason).toBe("invalid-filename");
|
expect(b.reason).toBe("invalid-filename");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("adopts a valid legacy record before strict porcelain and preserves ambiguous records", async () => {
|
||||||
|
const dir = tmpWorktree();
|
||||||
|
const fingerprint = "a".repeat(64);
|
||||||
|
const legacy = join(dir, ".fusion-secrets-env.fingerprint");
|
||||||
|
const privateRecord = join(dir, ".git", ".fusion-secrets-env.fingerprint");
|
||||||
|
writeFileSync(legacy, `${fingerprint}\n.env\n`);
|
||||||
|
|
||||||
|
await expect(reconcileSecretsEnvFingerprint(dir)).resolves.toEqual({ executionSafe: true, outcome: "adopted-legacy" });
|
||||||
|
expect(readFileSync(privateRecord, "utf8")).toBe(`${fingerprint}\n.env\n`);
|
||||||
|
expect(existsSync(legacy)).toBe(false);
|
||||||
|
expect(execFileSync("git", ["status", "--porcelain"], { cwd: dir, encoding: "utf8" })).toBe("");
|
||||||
|
|
||||||
|
writeFileSync(legacy, `${"b".repeat(64)}\n.env\n`);
|
||||||
|
await expect(reconcileSecretsEnvFingerprint(dir)).resolves.toEqual({ executionSafe: false, outcome: "conflict" });
|
||||||
|
expect(readFileSync(privateRecord, "utf8")).toBe(`${fingerprint}\n.env\n`);
|
||||||
|
expect(readFileSync(legacy, "utf8")).toBe(`${"b".repeat(64)}\n.env\n`);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses materialization when record reconciliation is malformed or conflicting", async () => {
|
||||||
|
const dir = tmpWorktree();
|
||||||
|
const privateRecord = join(dir, ".git", ".fusion-secrets-env.fingerprint");
|
||||||
|
const legacyRecord = join(dir, ".fusion-secrets-env.fingerprint");
|
||||||
|
const env = join(dir, ".env");
|
||||||
|
const filesystem = vi.fn();
|
||||||
|
const secretValue = "must-not-replace-existing-authority";
|
||||||
|
const originalEnv = "PRESERVE=1\n";
|
||||||
|
writeFileSync(env, originalEnv);
|
||||||
|
writeFileSync(privateRecord, `${"a".repeat(64)}\n.env\n`);
|
||||||
|
writeFileSync(legacyRecord, `${"b".repeat(64)}\n.env\n`);
|
||||||
|
|
||||||
|
const result = await writeSecretsEnvFile({
|
||||||
|
rootDir: dir,
|
||||||
|
worktreePath: dir,
|
||||||
|
taskId: "FN-8825",
|
||||||
|
settings: { secretsEnv: { enabled: true, requireGitignored: false } },
|
||||||
|
worktreeSource: "fresh",
|
||||||
|
audit: { filesystem },
|
||||||
|
secretsStore: {
|
||||||
|
listEnvExportable: vi.fn().mockResolvedValue([
|
||||||
|
{ id: "1", key: "SECRET", exportKey: "SECRET", scope: "project", plaintextValue: secretValue },
|
||||||
|
]),
|
||||||
|
} as any,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result).toEqual({ outcome: "skipped", filename: ".env", reason: "record-reconciliation-failed" });
|
||||||
|
expect(readFileSync(env, "utf8")).toBe(originalEnv);
|
||||||
|
expect(readFileSync(privateRecord, "utf8")).toBe(`${"a".repeat(64)}\n.env\n`);
|
||||||
|
expect(readFileSync(legacyRecord, "utf8")).toBe(`${"b".repeat(64)}\n.env\n`);
|
||||||
|
expect(filesystem).toHaveBeenCalledWith(expect.objectContaining({
|
||||||
|
type: "secret:env-write-skipped",
|
||||||
|
metadata: { reason: "record-reconciliation-failed", reconciliationOutcome: "conflict" },
|
||||||
|
}));
|
||||||
|
expect(JSON.stringify(filesystem.mock.calls)).not.toContain(secretValue);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not replace a sole malformed record during materialization", async () => {
|
||||||
|
const dir = tmpWorktree();
|
||||||
|
const legacyRecord = join(dir, ".fusion-secrets-env.fingerprint");
|
||||||
|
const env = join(dir, ".env");
|
||||||
|
writeFileSync(legacyRecord, "not-a-fingerprint\n.env\n");
|
||||||
|
writeFileSync(env, "PRESERVE=1\n");
|
||||||
|
|
||||||
|
const result = await writeSecretsEnvFile({
|
||||||
|
rootDir: dir,
|
||||||
|
worktreePath: dir,
|
||||||
|
taskId: "FN-8825",
|
||||||
|
settings: { secretsEnv: { enabled: true, requireGitignored: false } },
|
||||||
|
worktreeSource: "fresh",
|
||||||
|
secretsStore: {
|
||||||
|
listEnvExportable: vi.fn().mockResolvedValue([
|
||||||
|
{ id: "1", key: "SECRET", exportKey: "SECRET", scope: "project", plaintextValue: "new-value" },
|
||||||
|
]),
|
||||||
|
} as any,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.reason).toBe("record-reconciliation-failed");
|
||||||
|
expect(readFileSync(env, "utf8")).toBe("PRESERVE=1\n");
|
||||||
|
expect(readFileSync(legacyRecord, "utf8")).toBe("not-a-fingerprint\n.env\n");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("preserves legacy authority and converges after private durable replacement fails", async () => {
|
||||||
|
const dir = tmpWorktree();
|
||||||
|
const legacy = join(dir, ".fusion-secrets-env.fingerprint");
|
||||||
|
const privateRecord = join(dir, ".git", ".fusion-secrets-env.fingerprint");
|
||||||
|
const contents = `${"a".repeat(64)}\n.env\n`;
|
||||||
|
writeFileSync(legacy, contents);
|
||||||
|
// A directory at the destination makes the atomic rename fail after the temporary record sync.
|
||||||
|
mkdirSync(privateRecord);
|
||||||
|
|
||||||
|
await expect(reconcileSecretsEnvFingerprint(dir)).resolves.toEqual({ executionSafe: false, outcome: "private-record-write-failed" });
|
||||||
|
expect(readFileSync(legacy, "utf8")).toBe(contents);
|
||||||
|
expect(existsSync(privateRecord)).toBe(true);
|
||||||
|
|
||||||
|
rmSync(privateRecord, { recursive: true });
|
||||||
|
// FNXC:SecretsEnvMaterialization 2026-08-08-03:30: A failed private durability barrier retains legacy authority so the next acquisition can safely converge.
|
||||||
|
await expect(reconcileSecretsEnvFingerprint(dir)).resolves.toEqual({ executionSafe: true, outcome: "adopted-legacy" });
|
||||||
|
expect(existsSync(legacy)).toBe(false);
|
||||||
|
expect(readFileSync(privateRecord, "utf8")).toBe(contents);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("re-establishes private durability before removing an equal legacy record on retry", async () => {
|
||||||
|
const dir = tmpWorktree();
|
||||||
|
const legacy = join(dir, ".fusion-secrets-env.fingerprint");
|
||||||
|
const privateRecord = join(dir, ".git", ".fusion-secrets-env.fingerprint");
|
||||||
|
const contents = `${"a".repeat(64)}\n.env\n`;
|
||||||
|
// FNXC:SecretsEnvMaterialization 2026-08-08-03:42: Simulate interruption after rename before private-directory durability completes.
|
||||||
|
writeFileSync(privateRecord, contents);
|
||||||
|
writeFileSync(legacy, contents);
|
||||||
|
|
||||||
|
await expect(reconcileSecretsEnvFingerprint(dir, {
|
||||||
|
writePrivateRecord: async () => { throw new Error("private-directory-sync-failed"); },
|
||||||
|
})).resolves.toEqual({ executionSafe: false, outcome: "private-record-write-failed" });
|
||||||
|
expect(readFileSync(privateRecord, "utf8")).toBe(contents);
|
||||||
|
expect(readFileSync(legacy, "utf8")).toBe(contents);
|
||||||
|
|
||||||
|
await expect(reconcileSecretsEnvFingerprint(dir)).resolves.toEqual({ executionSafe: true, outcome: "removed-legacy" });
|
||||||
|
expect(readFileSync(privateRecord, "utf8")).toBe(contents);
|
||||||
|
expect(existsSync(legacy)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("re-establishes private durability before private-only retry can authorize refresh", async () => {
|
||||||
|
const dir = tmpWorktree();
|
||||||
|
const privateRecord = join(dir, ".git", ".fusion-secrets-env.fingerprint");
|
||||||
|
const contents = `${"a".repeat(64)}\n.secrets.env\n`;
|
||||||
|
// FNXC:SecretsEnvMaterialization 2026-08-08-04:06: Model a failed write after rename when only its readable private artifact survived.
|
||||||
|
writeFileSync(privateRecord, contents);
|
||||||
|
|
||||||
|
await expect(reconcileSecretsEnvFingerprint(dir, {
|
||||||
|
writePrivateRecord: async () => { throw new Error("private-directory-sync-failed"); },
|
||||||
|
})).resolves.toEqual({ executionSafe: false, outcome: "private-record-write-failed" });
|
||||||
|
expect(readFileSync(privateRecord, "utf8")).toBe(contents);
|
||||||
|
|
||||||
|
await expect(reconcileSecretsEnvFingerprint(dir)).resolves.toEqual({ executionSafe: true, outcome: "clean" });
|
||||||
|
expect(readFileSync(privateRecord, "utf8")).toBe(contents);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("fails closed and converges after every private and root durability boundary interruption", async () => {
|
||||||
|
const boundaries = ["temporary-file-synced", "private-record-renamed", "private-directory-synced", "legacy-unlinked", "root-directory-synced"] as const;
|
||||||
|
for (const boundary of boundaries) {
|
||||||
|
const dir = tmpWorktree();
|
||||||
|
const legacy = join(dir, ".fusion-secrets-env.fingerprint");
|
||||||
|
const privateRecord = join(dir, ".git", ".fusion-secrets-env.fingerprint");
|
||||||
|
const contents = `${"a".repeat(64)}\n.secrets.env\n`;
|
||||||
|
writeFileSync(legacy, contents);
|
||||||
|
const observed: string[] = [];
|
||||||
|
|
||||||
|
const blocked = await reconcileSecretsEnvFingerprint(dir, {
|
||||||
|
durabilityBoundary: async (stage) => {
|
||||||
|
observed.push(stage);
|
||||||
|
if (stage === boundary) throw new Error(`interrupted-${stage}`);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(observed).toContain(boundary);
|
||||||
|
expect(blocked.executionSafe).toBe(false);
|
||||||
|
if (boundary === "legacy-unlinked" || boundary === "root-directory-synced") {
|
||||||
|
expect(existsSync(privateRecord)).toBe(true);
|
||||||
|
expect(existsSync(legacy)).toBe(false);
|
||||||
|
} else {
|
||||||
|
// FNXC:SecretsEnvMaterialization 2026-08-08-03:51: No private barrier failure may discard the only v0.75.1 root authority.
|
||||||
|
expect(readFileSync(legacy, "utf8")).toBe(contents);
|
||||||
|
}
|
||||||
|
await expect(reconcileSecretsEnvFingerprint(dir)).resolves.toMatchObject({ executionSafe: true });
|
||||||
|
expect(readFileSync(privateRecord, "utf8")).toBe(contents);
|
||||||
|
expect(existsSync(legacy)).toBe(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("records the complete durable adoption order before porcelain may run", async () => {
|
||||||
|
const dir = tmpWorktree();
|
||||||
|
writeFileSync(join(dir, ".fusion-secrets-env.fingerprint"), `${"a".repeat(64)}\n.secrets.env\n`);
|
||||||
|
const observed: string[] = [];
|
||||||
|
|
||||||
|
await expect(reconcileSecretsEnvFingerprint(dir, {
|
||||||
|
durabilityBoundary: async (boundary) => { observed.push(boundary); },
|
||||||
|
})).resolves.toMatchObject({ executionSafe: true, outcome: "adopted-legacy" });
|
||||||
|
|
||||||
|
expect(observed).toEqual([
|
||||||
|
"temporary-file-synced",
|
||||||
|
"private-record-renamed",
|
||||||
|
"private-directory-synced",
|
||||||
|
"legacy-unlinked",
|
||||||
|
"root-directory-synced",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("fails closed for a sole malformed record without deleting it", async () => {
|
||||||
|
const dir = tmpWorktree();
|
||||||
|
const legacy = join(dir, ".fusion-secrets-env.fingerprint");
|
||||||
|
writeFileSync(legacy, "malformed\n.env\n");
|
||||||
|
await expect(reconcileSecretsEnvFingerprint(dir)).resolves.toEqual({ executionSafe: false, outcome: "invalid-record" });
|
||||||
|
expect(readFileSync(legacy, "utf8")).toBe("malformed\n.env\n");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("never adopts or removes a tracked root record", async () => {
|
||||||
|
const dir = tmpWorktree();
|
||||||
|
const legacy = join(dir, ".fusion-secrets-env.fingerprint");
|
||||||
|
const contents = `${"a".repeat(64)}\n.env\n`;
|
||||||
|
writeFileSync(legacy, contents);
|
||||||
|
execFileSync("git", ["add", ".fusion-secrets-env.fingerprint"], { cwd: dir });
|
||||||
|
execFileSync("git", ["-c", "user.name=Fusion Test", "-c", "user.email=test@example.invalid", "commit", "-qm", "tracked root record"], { cwd: dir });
|
||||||
|
|
||||||
|
await expect(reconcileSecretsEnvFingerprint(dir)).resolves.toEqual({ executionSafe: false, outcome: "tracked-record" });
|
||||||
|
expect(readFileSync(legacy, "utf8")).toBe(contents);
|
||||||
|
expect(existsSync(join(dir, ".git", ".fusion-secrets-env.fingerprint"))).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reconciles every unambiguous private and legacy record pairing", async () => {
|
||||||
|
const dir = tmpWorktree();
|
||||||
|
const privateRecord = join(dir, ".git", ".fusion-secrets-env.fingerprint");
|
||||||
|
const legacyRecord = join(dir, ".fusion-secrets-env.fingerprint");
|
||||||
|
const first = `${"a".repeat(64)}\n.env\n`;
|
||||||
|
const second = `${"b".repeat(64)}\n.secrets.env\n`;
|
||||||
|
|
||||||
|
writeFileSync(privateRecord, first);
|
||||||
|
// FNXC:SecretsEnvMaterialization 2026-08-08-03:02: v0.75.1 emitted the terminal LF, but legacy compatibility tolerates its missing final LF when both fields remain exact.
|
||||||
|
writeFileSync(legacyRecord, first.trimEnd());
|
||||||
|
await expect(reconcileSecretsEnvFingerprint(dir)).resolves.toEqual({ executionSafe: true, outcome: "removed-legacy" });
|
||||||
|
expect(existsSync(legacyRecord)).toBe(false);
|
||||||
|
|
||||||
|
writeFileSync(privateRecord, first.trimEnd());
|
||||||
|
await expect(reconcileSecretsEnvFingerprint(dir)).resolves.toEqual({ executionSafe: false, outcome: "invalid-record" });
|
||||||
|
writeFileSync(privateRecord, first);
|
||||||
|
|
||||||
|
writeFileSync(privateRecord, "broken\n.env\n");
|
||||||
|
writeFileSync(legacyRecord, second);
|
||||||
|
await expect(reconcileSecretsEnvFingerprint(dir)).resolves.toEqual({ executionSafe: true, outcome: "recovered-private" });
|
||||||
|
expect(readFileSync(privateRecord, "utf8")).toBe(second);
|
||||||
|
expect(existsSync(legacyRecord)).toBe(false);
|
||||||
|
|
||||||
|
writeFileSync(legacyRecord, "broken\n.env\n");
|
||||||
|
await expect(reconcileSecretsEnvFingerprint(dir)).resolves.toEqual({ executionSafe: true, outcome: "removed-legacy" });
|
||||||
|
expect(readFileSync(privateRecord, "utf8")).toBe(second);
|
||||||
|
expect(existsSync(legacyRecord)).toBe(false);
|
||||||
|
|
||||||
|
writeFileSync(privateRecord, "broken\n.env\n");
|
||||||
|
await expect(reconcileSecretsEnvFingerprint(dir)).resolves.toEqual({ executionSafe: false, outcome: "invalid-record" });
|
||||||
|
expect(readFileSync(privateRecord, "utf8")).toBe("broken\n.env\n");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reconciles invalid legacy metadata to a valid private record before cleanup", async () => {
|
||||||
|
const dir = tmpWorktree();
|
||||||
|
const secretsStore = { listEnvExportable: vi.fn().mockResolvedValue([{ id: "1", key: "A", exportKey: "ALPHA", scope: "project", plaintextValue: "v" }]) } as any;
|
||||||
|
await writeSecretsEnvFile({ rootDir: dir, worktreePath: dir, taskId: "FN-1", settings: { secretsEnv: { enabled: true, requireGitignored: false } }, worktreeSource: "fresh", secretsStore });
|
||||||
|
writeFileSync(join(dir, ".fusion-secrets-env.fingerprint"), "broken\n.env\n");
|
||||||
|
await expect(cleanupSecretsEnvFile({ worktreePath: dir, taskId: "FN-1", expectedFingerprint: null, filename: ".env" })).resolves.toMatchObject({ outcome: "cleaned" });
|
||||||
|
expect(existsSync(join(dir, ".env"))).toBe(false);
|
||||||
|
expect(existsSync(join(dir, ".fusion-secrets-env.fingerprint"))).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("fails closed rather than using legacy orphan cleanup when a Git worktree cannot resolve its private dir", async () => {
|
||||||
|
const dir = tmpWorktree();
|
||||||
|
const body = "A=1\n";
|
||||||
|
writeFileSync(join(dir, ".env"), body);
|
||||||
|
writeFileSync(join(dir, ".fusion-secrets-env.fingerprint"), `${createHash("sha256").update(body).digest("hex")}\n.env\n`);
|
||||||
|
renameSync(join(dir, ".git"), join(dir, ".git-unavailable"));
|
||||||
|
writeFileSync(join(dir, ".git"), "gitdir: /missing-private-git-dir\n");
|
||||||
|
|
||||||
|
await expect(cleanupSecretsEnvFile({ worktreePath: dir, taskId: "FN-1", expectedFingerprint: null, filename: ".env" })).resolves.toEqual({ outcome: "skipped", reason: "invalid-record" });
|
||||||
|
expect(existsSync(join(dir, ".env"))).toBe(true);
|
||||||
|
expect(existsSync(join(dir, ".fusion-secrets-env.fingerprint"))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("cleanup safely handles missing, repeated, and non-Git legacy records", async () => {
|
||||||
|
const dir = tmpWorktree();
|
||||||
|
const secretsStore = { listEnvExportable: vi.fn().mockResolvedValue([{ id: "1", key: "A", exportKey: "ALPHA", scope: "project", plaintextValue: "v" }]) } as any;
|
||||||
|
await writeSecretsEnvFile({ rootDir: dir, worktreePath: dir, taskId: "FN-1", settings: { secretsEnv: { enabled: true, requireGitignored: false } }, worktreeSource: "fresh", secretsStore });
|
||||||
|
rmSync(join(dir, ".env"));
|
||||||
|
await expect(cleanupSecretsEnvFile({ worktreePath: dir, taskId: "FN-1", expectedFingerprint: null, filename: ".env" })).resolves.toMatchObject({ outcome: "skipped", reason: "file-missing" });
|
||||||
|
expect(existsSync(join(dir, ".git", ".fusion-secrets-env.fingerprint"))).toBe(false);
|
||||||
|
await expect(cleanupSecretsEnvFile({ worktreePath: dir, taskId: "FN-1", expectedFingerprint: null, filename: ".env" })).resolves.toMatchObject({ outcome: "skipped", reason: "no-record" });
|
||||||
|
|
||||||
|
const orphan = mkdtempSync(join(tmpdir(), "secrets-env-orphan-"));
|
||||||
|
dirs.push(orphan);
|
||||||
|
const body = "A=1\n";
|
||||||
|
writeFileSync(join(orphan, ".env"), body);
|
||||||
|
writeFileSync(join(orphan, ".fusion-secrets-env.fingerprint"), `${createHash("sha256").update(body).digest("hex")}\n.env\n`);
|
||||||
|
await expect(cleanupSecretsEnvFile({ worktreePath: orphan, taskId: "orphan", expectedFingerprint: null, filename: ".env" })).resolves.toMatchObject({ outcome: "cleaned", reason: "fingerprint-match" });
|
||||||
|
expect(existsSync(join(orphan, ".env"))).toBe(false);
|
||||||
|
expect(existsSync(join(orphan, ".fusion-secrets-env.fingerprint"))).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not report cleanup success when private metadata removal fails", async () => {
|
||||||
|
const dir = tmpWorktree();
|
||||||
|
const secretsStore = { listEnvExportable: vi.fn().mockResolvedValue([{ id: "1", key: "A", exportKey: "ALPHA", scope: "project", plaintextValue: "v" }]) } as any;
|
||||||
|
await writeSecretsEnvFile({ rootDir: dir, worktreePath: dir, taskId: "FN-1", settings: { secretsEnv: { enabled: true, requireGitignored: false } }, worktreeSource: "fresh", secretsStore });
|
||||||
|
const privateRecord = join(dir, ".git", ".fusion-secrets-env.fingerprint");
|
||||||
|
// FNXC:SecretsEnvMaterialization 2026-08-08-03:30: A failed bookkeeping removal is retryable but must never be published as successful cleanup.
|
||||||
|
await expect(cleanupSecretsEnvFile({
|
||||||
|
worktreePath: dir,
|
||||||
|
taskId: "FN-1",
|
||||||
|
expectedFingerprint: null,
|
||||||
|
filename: ".env",
|
||||||
|
removeRecordPaths: async () => { throw new Error("metadata removal failed"); },
|
||||||
|
})).resolves.toEqual({ outcome: "skipped", reason: "record-remove-failed" });
|
||||||
|
expect(existsSync(privateRecord)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
it("cleanup removes only fingerprint-matching env", async () => {
|
it("cleanup removes only fingerprint-matching env", async () => {
|
||||||
const dir = tmpWorktree();
|
const dir = tmpWorktree();
|
||||||
const filesystem = vi.fn();
|
const filesystem = vi.fn();
|
||||||
@@ -202,7 +503,7 @@ describe("secrets-env-writer", () => {
|
|||||||
});
|
});
|
||||||
expect(cleaned.outcome).toBe("cleaned");
|
expect(cleaned.outcome).toBe("cleaned");
|
||||||
expect(existsSync(join(dir, ".env"))).toBe(false);
|
expect(existsSync(join(dir, ".env"))).toBe(false);
|
||||||
expect(existsSync(join(dir, ".fusion-secrets-env.fingerprint"))).toBe(false);
|
expect(existsSync(join(dir, ".git", ".fusion-secrets-env.fingerprint"))).toBe(false);
|
||||||
|
|
||||||
await writeSecretsEnvFile({
|
await writeSecretsEnvFile({
|
||||||
rootDir: process.cwd(),
|
rootDir: process.cwd(),
|
||||||
@@ -222,6 +523,18 @@ describe("secrets-env-writer", () => {
|
|||||||
});
|
});
|
||||||
expect(skipped.reason).toBe("fingerprint-mismatch");
|
expect(skipped.reason).toBe("fingerprint-mismatch");
|
||||||
expect(existsSync(join(dir, ".env"))).toBe(true);
|
expect(existsSync(join(dir, ".env"))).toBe(true);
|
||||||
expect(existsSync(join(dir, ".fusion-secrets-env.fingerprint"))).toBe(false);
|
expect(existsSync(join(dir, ".git", ".fusion-secrets-env.fingerprint"))).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("never deletes a tracked env even when its fingerprint matches", async () => {
|
||||||
|
const dir = tmpWorktree();
|
||||||
|
const secretsStore = { listEnvExportable: vi.fn().mockResolvedValue([{ id: "1", key: "A", exportKey: "ALPHA", scope: "project", plaintextValue: "v" }]) } as any;
|
||||||
|
await writeSecretsEnvFile({ rootDir: dir, worktreePath: dir, taskId: "FN-1", settings: { secretsEnv: { enabled: true, requireGitignored: false } }, worktreeSource: "fresh", secretsStore });
|
||||||
|
execFileSync("git", ["add", ".env"], { cwd: dir });
|
||||||
|
execFileSync("git", ["-c", "user.name=Fusion Test", "-c", "user.email=test@example.invalid", "commit", "-qm", "tracked environment"], { cwd: dir });
|
||||||
|
|
||||||
|
await expect(cleanupSecretsEnvFile({ worktreePath: dir, taskId: "FN-1", expectedFingerprint: null, filename: ".env" })).resolves.toEqual({ outcome: "skipped", reason: "tracked-file" });
|
||||||
|
expect(existsSync(join(dir, ".env"))).toBe(true);
|
||||||
|
expect(existsSync(join(dir, ".git", ".fusion-secrets-env.fingerprint"))).toBe(true);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -2,10 +2,19 @@ import { dirname } from "node:path";
|
|||||||
|
|
||||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
|
|
||||||
const { writeSecretsEnvFile } = vi.hoisted(() => ({ writeSecretsEnvFile: vi.fn() }));
|
const { writeSecretsEnvFile, reconcileSecretsEnvFingerprint, refreshReusedWorktreeBase } = vi.hoisted(() => ({
|
||||||
|
writeSecretsEnvFile: vi.fn(),
|
||||||
|
reconcileSecretsEnvFingerprint: vi.fn(),
|
||||||
|
refreshReusedWorktreeBase: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
vi.mock("../worktree/secrets-env-writer.js", () => ({
|
vi.mock("../worktree/secrets-env-writer.js", () => ({
|
||||||
writeSecretsEnvFile,
|
writeSecretsEnvFile,
|
||||||
|
reconcileSecretsEnvFingerprint,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../worktree-base-refresh.js", () => ({
|
||||||
|
refreshReusedWorktreeBase,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("../worktree/worktree-pool.js", async () => {
|
vi.mock("../worktree/worktree-pool.js", async () => {
|
||||||
@@ -42,6 +51,8 @@ describe("worktree-acquisition secrets env hook", () => {
|
|||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
writeSecretsEnvFile.mockReset().mockResolvedValue({ outcome: "skipped", filename: ".env", reason: "disabled" });
|
writeSecretsEnvFile.mockReset().mockResolvedValue({ outcome: "skipped", filename: ".env", reason: "disabled" });
|
||||||
|
reconcileSecretsEnvFingerprint.mockReset().mockResolvedValue({ executionSafe: true, outcome: "clean" });
|
||||||
|
refreshReusedWorktreeBase.mockReset().mockResolvedValue({ kind: "up-to-date", executionSafe: true });
|
||||||
vi.mocked(classifyTaskWorktree).mockResolvedValue({ ok: true } as any);
|
vi.mocked(classifyTaskWorktree).mockResolvedValue({ ok: true } as any);
|
||||||
store = { updateTask: vi.fn().mockResolvedValue(undefined), logEntry: vi.fn().mockResolvedValue(undefined) };
|
store = { updateTask: vi.fn().mockResolvedValue(undefined), logEntry: vi.fn().mockResolvedValue(undefined) };
|
||||||
});
|
});
|
||||||
@@ -89,6 +100,54 @@ describe("worktree-acquisition secrets env hook", () => {
|
|||||||
expect(writeSecretsEnvFile).not.toHaveBeenCalled();
|
expect(writeSecretsEnvFile).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("reconciles a pinned planning sidecar before its refresh-enabled execution handoff", async () => {
|
||||||
|
const existingWorktree = process.cwd();
|
||||||
|
const projectRoot = dirname(existingWorktree);
|
||||||
|
reconcileSecretsEnvFingerprint.mockResolvedValueOnce({ executionSafe: true, outcome: "adopted-legacy" });
|
||||||
|
|
||||||
|
await expect(acquireTaskWorktree({
|
||||||
|
task: { ...task, branch: "fusion/fn-1", worktree: existingWorktree },
|
||||||
|
rootDir: projectRoot,
|
||||||
|
store,
|
||||||
|
settings: { secretsEnv: { enabled: true } } as any,
|
||||||
|
refreshStaleBase: true,
|
||||||
|
createWorktree: vi.fn(),
|
||||||
|
})).resolves.toMatchObject({ source: "existing", isResume: true });
|
||||||
|
|
||||||
|
// FNXC:SecretsEnvMaterialization 2026-08-08-03:30: Execution handoff must reconcile the planning-era root record before strict refresh sees porcelain.
|
||||||
|
expect(reconcileSecretsEnvFingerprint).toHaveBeenCalledWith(existingWorktree);
|
||||||
|
expect(refreshReusedWorktreeBase).toHaveBeenCalledWith(expect.objectContaining({ worktreePath: existingWorktree }));
|
||||||
|
expect(reconcileSecretsEnvFingerprint.mock.invocationCallOrder[0]).toBeLessThan(refreshReusedWorktreeBase.mock.invocationCallOrder[0]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("fails closed with a redacted fixed audit outcome when reconciliation rejects", async () => {
|
||||||
|
const existingWorktree = process.cwd();
|
||||||
|
const projectRoot = dirname(existingWorktree);
|
||||||
|
const git = vi.fn();
|
||||||
|
reconcileSecretsEnvFingerprint.mockRejectedValueOnce(new Error("secret-derived resolver detail"));
|
||||||
|
|
||||||
|
await expect(acquireTaskWorktree({
|
||||||
|
task: { ...task, branch: "fusion/fn-1", worktree: existingWorktree },
|
||||||
|
rootDir: projectRoot,
|
||||||
|
store,
|
||||||
|
settings: { secretsEnv: { enabled: true } } as any,
|
||||||
|
refreshStaleBase: true,
|
||||||
|
audit: { git },
|
||||||
|
createWorktree: vi.fn(),
|
||||||
|
})).rejects.toMatchObject({
|
||||||
|
name: "WorktreeBaseRefreshError",
|
||||||
|
refresh: { kind: "base-reconciliation-required", detail: "git-dir-unavailable" },
|
||||||
|
});
|
||||||
|
expect(refreshReusedWorktreeBase).not.toHaveBeenCalled();
|
||||||
|
expect(git).toHaveBeenCalledWith(expect.objectContaining({
|
||||||
|
type: "worktree:base-refresh-blocked",
|
||||||
|
target: "FN-1",
|
||||||
|
metadata: { taskId: "FN-1", outcome: "base-reconciliation-required", reconciliationOutcome: "git-dir-unavailable" },
|
||||||
|
}));
|
||||||
|
expect(JSON.stringify(git.mock.calls)).not.toContain("secret-derived resolver detail");
|
||||||
|
expect(JSON.stringify(git.mock.calls)).not.toContain(existingWorktree);
|
||||||
|
});
|
||||||
|
|
||||||
it("isolates writer failures", async () => {
|
it("isolates writer failures", async () => {
|
||||||
writeSecretsEnvFile.mockRejectedValueOnce(new Error("boom"));
|
writeSecretsEnvFile.mockRejectedValueOnce(new Error("boom"));
|
||||||
await expect(acquireTaskWorktree({
|
await expect(acquireTaskWorktree({
|
||||||
|
|||||||
@@ -1,16 +1,20 @@
|
|||||||
import { createHash } from "node:crypto";
|
import { createHash } from "node:crypto";
|
||||||
|
import { execFile } from "node:child_process";
|
||||||
import { promises as fs } from "node:fs";
|
import { promises as fs } from "node:fs";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { execFile } from "node:child_process";
|
import { promisify } from "node:util";
|
||||||
import type { ProjectSettings, SecretsStore } from "@fusion/core";
|
import type { ProjectSettings, SecretsStore } from "@fusion/core";
|
||||||
import type { RunAuditor } from "../util/run-audit.js";
|
import type { RunAuditor } from "../util/run-audit.js";
|
||||||
|
|
||||||
const FINGERPRINT_FILE = ".fusion-secrets-env.fingerprint";
|
export const FINGERPRINT_FILE = ".fusion-secrets-env.fingerprint";
|
||||||
const HEADER_PREFIX = "# Managed by Fusion — do not edit by hand.";
|
const HEADER_PREFIX = "# Managed by Fusion — do not edit by hand.";
|
||||||
const VALID_ENV_KEY = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
const VALID_ENV_KEY = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
||||||
|
const VALID_FINGERPRINT = /^[0-9a-f]{64}$/;
|
||||||
|
const execFileAsync = promisify(execFile);
|
||||||
|
|
||||||
export type WriteSkipReason = "disabled" | "no-secrets" | "not-gitignored" | "skip-existing" | "invalid-filename" | "no-store" | "list-failed";
|
export type WriteSkipReason = "disabled" | "no-secrets" | "not-gitignored" | "skip-existing" | "invalid-filename" | "no-store" | "list-failed" | "record-reconciliation-failed";
|
||||||
export type CleanupSkipReason = "fingerprint-mismatch" | "file-missing" | "no-record" | "disabled" | "stat-failed";
|
export type CleanupSkipReason = "fingerprint-mismatch" | "file-missing" | "no-record" | "disabled" | "stat-failed" | "ambiguous-record" | "invalid-record" | "tracked-file" | "record-remove-failed";
|
||||||
|
export type FingerprintReconciliationOutcome = "clean" | "adopted-legacy" | "removed-legacy" | "recovered-private" | "conflict" | "invalid-record" | "tracked-record" | "git-dir-unavailable" | "private-record-write-failed" | "legacy-remove-failed";
|
||||||
|
|
||||||
export interface WriteSecretsEnvFileOptions {
|
export interface WriteSecretsEnvFileOptions {
|
||||||
rootDir: string;
|
rootDir: string;
|
||||||
@@ -39,6 +43,8 @@ export interface CleanupSecretsEnvFileOptions {
|
|||||||
filename: string;
|
filename: string;
|
||||||
audit?: Pick<RunAuditor, "filesystem">;
|
audit?: Pick<RunAuditor, "filesystem">;
|
||||||
logger?: { log: (m: string) => void; warn: (m: string) => void };
|
logger?: { log: (m: string) => void; warn: (m: string) => void };
|
||||||
|
/** Test seam for proving cleanup never converts metadata-removal failures into success. */
|
||||||
|
removeRecordPaths?: (recordPaths: string[]) => Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CleanupSecretsEnvFileResult {
|
export interface CleanupSecretsEnvFileResult {
|
||||||
@@ -46,6 +52,27 @@ export interface CleanupSecretsEnvFileResult {
|
|||||||
reason?: CleanupSkipReason | "fingerprint-match" | "directory-missing";
|
reason?: CleanupSkipReason | "fingerprint-match" | "directory-missing";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ReconcileSecretsEnvFingerprintResult {
|
||||||
|
executionSafe: boolean;
|
||||||
|
outcome: FingerprintReconciliationOutcome;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ReconcileSecretsEnvFingerprintOptions {
|
||||||
|
/** Test seam for proving a legacy record survives every private durability barrier failure. */
|
||||||
|
writePrivateRecord?: (recordPath: string, fingerprint: string, filename: string) => Promise<void>;
|
||||||
|
/** Test seam that models an interruption at each durable migration boundary. */
|
||||||
|
durabilityBoundary?: (boundary: "temporary-file-synced" | "private-record-renamed" | "private-directory-synced" | "legacy-unlinked" | "root-directory-synced") => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FingerprintRecord {
|
||||||
|
fingerprint: string;
|
||||||
|
filename: string;
|
||||||
|
raw: string;
|
||||||
|
path: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
type RecordState = { kind: "absent" } | { kind: "invalid"; path: string } | { kind: "valid"; record: FingerprintRecord };
|
||||||
|
|
||||||
function isValidFilename(filename: string): boolean {
|
function isValidFilename(filename: string): boolean {
|
||||||
return !!filename && !filename.includes("/") && !filename.includes("\\") && !filename.includes("..") && !filename.includes("\0") && filename !== FINGERPRINT_FILE;
|
return !!filename && !filename.includes("/") && !filename.includes("\\") && !filename.includes("..") && !filename.includes("\0") && filename !== FINGERPRINT_FILE;
|
||||||
}
|
}
|
||||||
@@ -60,10 +87,7 @@ function quote(value: string): string {
|
|||||||
|
|
||||||
function toManagedBody(taskId: string, entries: Array<{ exportKey: string; plaintextValue: string }>): string {
|
function toManagedBody(taskId: string, entries: Array<{ exportKey: string; plaintextValue: string }>): string {
|
||||||
const header = `${HEADER_PREFIX} (task: ${taskId})\n`;
|
const header = `${HEADER_PREFIX} (task: ${taskId})\n`;
|
||||||
const body = entries
|
const body = entries.sort((a, b) => a.exportKey.localeCompare(b.exportKey)).map((item) => `${item.exportKey}=${quote(item.plaintextValue)}`).join("\n");
|
||||||
.sort((a, b) => a.exportKey.localeCompare(b.exportKey))
|
|
||||||
.map((item) => `${item.exportKey}=${quote(item.plaintextValue)}`)
|
|
||||||
.join("\n");
|
|
||||||
return `${header}${body}\n`;
|
return `${header}${body}\n`;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -73,19 +97,232 @@ function removeManagedBlock(input: string): string {
|
|||||||
return input.slice(0, idx).replace(/\n+$/u, "\n");
|
return input.slice(0, idx).replace(/\n+$/u, "\n");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type RecordFormat = "private" | "legacy";
|
||||||
|
|
||||||
|
function parseRecord(raw: string, recordPath: string, format: RecordFormat): FingerprintRecord | undefined {
|
||||||
|
// FNXC:SecretsEnvMaterialization 2026-08-08-03:02: v0.75.1 wrote root metadata without a marker; private records require a terminal LF so partial bookkeeping cannot authorize deletion.
|
||||||
|
const match = (format === "private"
|
||||||
|
? /^([0-9a-f]{64})\n([^\n]+)\n$/u
|
||||||
|
: /^([0-9a-f]{64})\n([^\n]+)\n?$/u).exec(raw);
|
||||||
|
if (!match || !VALID_FINGERPRINT.test(match[1]) || !isValidFilename(match[2])) return undefined;
|
||||||
|
return { fingerprint: match[1], filename: match[2], raw, path: recordPath };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readRecord(recordPath: string, format: RecordFormat): Promise<RecordState> {
|
||||||
|
try {
|
||||||
|
const raw = await fs.readFile(recordPath, "utf8");
|
||||||
|
const record = parseRecord(raw, recordPath, format);
|
||||||
|
return record ? { kind: "valid", record } : { kind: "invalid", path: recordPath };
|
||||||
|
} catch (error) {
|
||||||
|
return (error as NodeJS.ErrnoException).code === "ENOENT" ? { kind: "absent" } : { kind: "invalid", path: recordPath };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* FNXC:SecretsEnvMaterialization 2026-08-07-23:13:
|
||||||
|
* Fingerprint metadata is Fusion bookkeeping, not project content. Resolve the linked worktree's private
|
||||||
|
* Git directory asynchronously so secret materialization cannot create an untracked root sidecar that blocks
|
||||||
|
* the next strict worktree base refresh.
|
||||||
|
*/
|
||||||
|
async function resolvePrivateRecordPath(worktreePath: string): Promise<string> {
|
||||||
|
const { stdout } = await execFileAsync("git", ["rev-parse", "--git-dir"], { cwd: worktreePath, encoding: "utf8", timeout: 10_000 });
|
||||||
|
const gitDir = stdout.trim();
|
||||||
|
if (!gitDir) throw new Error("git-dir-empty");
|
||||||
|
return path.join(path.isAbsolute(gitDir) ? gitDir : path.resolve(worktreePath, gitDir), FINGERPRINT_FILE);
|
||||||
|
}
|
||||||
|
|
||||||
|
function recordsMatch(left: FingerprintRecord, right: FingerprintRecord): boolean {
|
||||||
|
return left.fingerprint === right.fingerprint && left.filename === right.filename;
|
||||||
|
}
|
||||||
|
|
||||||
|
function directorySyncUnsupported(error: unknown): boolean {
|
||||||
|
// FNXC:SecretsEnvMaterialization 2026-08-08-03:02: Windows cannot fsync directory handles. This narrow portability exception never conceals ordinary I/O errors.
|
||||||
|
return process.platform === "win32" && ["EINVAL", "EPERM", "EISDIR", "ENOTSUP"].includes((error as NodeJS.ErrnoException).code ?? "");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function syncParentDirectory(filePath: string): Promise<void> {
|
||||||
|
let handle: Awaited<ReturnType<typeof fs.open>> | undefined;
|
||||||
|
try {
|
||||||
|
handle = await fs.open(path.dirname(filePath), "r");
|
||||||
|
await handle.sync();
|
||||||
|
} catch (error) {
|
||||||
|
if (!directorySyncUnsupported(error)) throw error;
|
||||||
|
} finally {
|
||||||
|
await handle?.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* FNXC:SecretsEnvMaterialization 2026-08-08-03:02:
|
||||||
|
* A renamed record is not crash-durable until the private Git directory is synced. The legacy root
|
||||||
|
* authority must survive every failure before this barrier, so callers unlink it only after this returns.
|
||||||
|
*/
|
||||||
|
async function atomicWriteRecord(
|
||||||
|
recordPath: string,
|
||||||
|
fingerprint: string,
|
||||||
|
filename: string,
|
||||||
|
durabilityBoundary?: ReconcileSecretsEnvFingerprintOptions["durabilityBoundary"],
|
||||||
|
): Promise<void> {
|
||||||
|
const tmpPath = `${recordPath}.${process.pid}.${Date.now()}.tmp`;
|
||||||
|
let handle: Awaited<ReturnType<typeof fs.open>> | undefined;
|
||||||
|
try {
|
||||||
|
handle = await fs.open(tmpPath, "w", 0o600);
|
||||||
|
await handle.writeFile(`${fingerprint}\n${filename}\n`, "utf8");
|
||||||
|
await handle.sync();
|
||||||
|
await durabilityBoundary?.("temporary-file-synced");
|
||||||
|
await handle.close();
|
||||||
|
handle = undefined;
|
||||||
|
await fs.rename(tmpPath, recordPath);
|
||||||
|
await durabilityBoundary?.("private-record-renamed");
|
||||||
|
await syncParentDirectory(recordPath);
|
||||||
|
await durabilityBoundary?.("private-directory-synced");
|
||||||
|
} catch (error) {
|
||||||
|
await handle?.close().catch(() => undefined);
|
||||||
|
await fs.unlink(tmpPath).catch(() => undefined);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* FNXC:SecretsEnvMaterialization 2026-08-08-03:15:
|
||||||
|
* Root records from v0.75.1 are Fusion-owned only while untracked. Never adopt or remove a tracked
|
||||||
|
* lookalike: a project may intentionally version that path, and fingerprint equality is not authority to delete it.
|
||||||
|
*/
|
||||||
|
async function isTrackedWorktreeFile(worktreePath: string, filename: string): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
await execFileAsync("git", ["ls-files", "--error-unmatch", "--", filename], { cwd: worktreePath, encoding: "utf8", timeout: 10_000 });
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
if ((error as NodeJS.ErrnoException & { code?: number }).code === 1) return false;
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function removeLegacyRecord(
|
||||||
|
worktreePath: string,
|
||||||
|
legacyPath: string,
|
||||||
|
durabilityBoundary?: ReconcileSecretsEnvFingerprintOptions["durabilityBoundary"],
|
||||||
|
): Promise<void> {
|
||||||
|
if (await isTrackedWorktreeFile(worktreePath, FINGERPRINT_FILE)) throw new Error("legacy-record-tracked");
|
||||||
|
try {
|
||||||
|
await fs.unlink(legacyPath);
|
||||||
|
} catch (error) {
|
||||||
|
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
|
||||||
|
}
|
||||||
|
await durabilityBoundary?.("legacy-unlinked");
|
||||||
|
await syncParentDirectory(legacyPath);
|
||||||
|
await durabilityBoundary?.("root-directory-synced");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* FNXC:SecretsEnvMaterialization 2026-08-08-03:30:
|
||||||
|
* Cleanup may report a fingerprint match only after its bookkeeping is removed. Suppressing a metadata
|
||||||
|
* removal error makes a later reuse look clean while retaining stale authority, so callers receive a fixed
|
||||||
|
* non-success result and can safely retry instead.
|
||||||
|
*/
|
||||||
|
async function removeRecordPaths(recordPaths: string[]): Promise<void> {
|
||||||
|
for (const recordPath of recordPaths) {
|
||||||
|
try {
|
||||||
|
await fs.unlink(recordPath);
|
||||||
|
} catch (error) {
|
||||||
|
if ((error as NodeJS.ErrnoException).code === "ENOENT") continue;
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
await syncParentDirectory(recordPath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* FNXC:SecretsEnvMaterialization 2026-08-08-01:54:
|
||||||
|
* Root legacy metadata must be removed before strict porcelain proceeds. A failed removal is not benign:
|
||||||
|
* leaving Fusion's root file behind would recreate the dirty-worktree dispatch failure, so reconciliation
|
||||||
|
* remains closed until the record can be safely reconciled.
|
||||||
|
*/
|
||||||
|
/** Reconcile v0.75.1 root metadata before porcelain is consulted. */
|
||||||
|
export async function reconcileSecretsEnvFingerprint(
|
||||||
|
worktreePath: string,
|
||||||
|
options: ReconcileSecretsEnvFingerprintOptions = {},
|
||||||
|
): Promise<ReconcileSecretsEnvFingerprintResult> {
|
||||||
|
const legacyPath = path.join(worktreePath, FINGERPRINT_FILE);
|
||||||
|
let privatePath: string;
|
||||||
|
try {
|
||||||
|
privatePath = await resolvePrivateRecordPath(worktreePath);
|
||||||
|
} catch {
|
||||||
|
return { executionSafe: false, outcome: "git-dir-unavailable" };
|
||||||
|
}
|
||||||
|
const [privateState, legacyState] = await Promise.all([readRecord(privatePath, "private"), readRecord(legacyPath, "legacy")]);
|
||||||
|
if (privateState.kind === "absent" && legacyState.kind === "absent") return { executionSafe: true, outcome: "clean" };
|
||||||
|
try {
|
||||||
|
if (legacyState.kind !== "absent" && await isTrackedWorktreeFile(worktreePath, FINGERPRINT_FILE)) {
|
||||||
|
return { executionSafe: false, outcome: "tracked-record" };
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
return { executionSafe: false, outcome: "git-dir-unavailable" };
|
||||||
|
}
|
||||||
|
if (privateState.kind === "invalid" && legacyState.kind !== "valid") return { executionSafe: false, outcome: "invalid-record" };
|
||||||
|
if (legacyState.kind === "invalid" && privateState.kind !== "valid") return { executionSafe: false, outcome: "invalid-record" };
|
||||||
|
if (privateState.kind === "valid" && legacyState.kind === "valid" && !recordsMatch(privateState.record, legacyState.record)) {
|
||||||
|
return { executionSafe: false, outcome: "conflict" };
|
||||||
|
}
|
||||||
|
if (privateState.kind === "valid") {
|
||||||
|
/*
|
||||||
|
* FNXC:SecretsEnvMaterialization 2026-08-08-04:06:
|
||||||
|
* A readable private record may have survived a failed write after rename but before its parent directory
|
||||||
|
* was synced. Re-establish its file and directory durability on every private-record reconciliation,
|
||||||
|
* including private-only retry, so a prior failed write cannot authorize refresh without that barrier.
|
||||||
|
*/
|
||||||
|
try {
|
||||||
|
await (options.writePrivateRecord
|
||||||
|
? options.writePrivateRecord(privatePath, privateState.record.fingerprint, privateState.record.filename)
|
||||||
|
: atomicWriteRecord(privatePath, privateState.record.fingerprint, privateState.record.filename, options.durabilityBoundary));
|
||||||
|
} catch {
|
||||||
|
return { executionSafe: false, outcome: "private-record-write-failed" };
|
||||||
|
}
|
||||||
|
if (legacyState.kind !== "absent") {
|
||||||
|
try {
|
||||||
|
await removeLegacyRecord(worktreePath, legacyPath, options.durabilityBoundary);
|
||||||
|
} catch {
|
||||||
|
return { executionSafe: false, outcome: "legacy-remove-failed" };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// FNXC:SecretsEnvMaterialization 2026-08-08-03:51: A crash after unlink but before root-directory sync leaves only private metadata. Re-sync the root on retry before this state can authorize refresh.
|
||||||
|
if (legacyState.kind === "absent") {
|
||||||
|
try {
|
||||||
|
await syncParentDirectory(legacyPath);
|
||||||
|
} catch {
|
||||||
|
return { executionSafe: false, outcome: "legacy-remove-failed" };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { executionSafe: true, outcome: legacyState.kind === "absent" ? "clean" : "removed-legacy" };
|
||||||
|
}
|
||||||
|
/*
|
||||||
|
* FNXC:SecretsEnvMaterialization 2026-08-08-03:02:
|
||||||
|
* v0.75.1 root bytes remain cleanup authority until the replacement record and its private Git-directory
|
||||||
|
* entry are durable. Only then may the root unlink occur; its directory sync is the final safe-to-refresh barrier.
|
||||||
|
*/
|
||||||
|
if (legacyState.kind !== "valid") return { executionSafe: false, outcome: "invalid-record" };
|
||||||
|
try {
|
||||||
|
await (options.writePrivateRecord
|
||||||
|
? options.writePrivateRecord(privatePath, legacyState.record.fingerprint, legacyState.record.filename)
|
||||||
|
: atomicWriteRecord(privatePath, legacyState.record.fingerprint, legacyState.record.filename, options.durabilityBoundary));
|
||||||
|
} catch {
|
||||||
|
return { executionSafe: false, outcome: "private-record-write-failed" };
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await removeLegacyRecord(worktreePath, legacyPath, options.durabilityBoundary);
|
||||||
|
} catch {
|
||||||
|
return { executionSafe: false, outcome: "legacy-remove-failed" };
|
||||||
|
}
|
||||||
|
return { executionSafe: true, outcome: privateState.kind === "invalid" ? "recovered-private" : "adopted-legacy" };
|
||||||
|
}
|
||||||
|
|
||||||
async function checkIgnored(execImpl: typeof execFile, worktreePath: string, filename: string): Promise<{ ignored: boolean; error?: string }> {
|
async function checkIgnored(execImpl: typeof execFile, worktreePath: string, filename: string): Promise<{ ignored: boolean; error?: string }> {
|
||||||
return await new Promise((resolve) => {
|
return await new Promise((resolve) => {
|
||||||
execImpl("git", ["check-ignore", "--", filename], { cwd: worktreePath, timeout: 10_000 }, (error) => {
|
execImpl("git", ["check-ignore", "--", filename], { cwd: worktreePath, timeout: 10_000 }, (error) => {
|
||||||
if (!error) {
|
if (!error) return resolve({ ignored: true });
|
||||||
resolve({ ignored: true });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const anyErr = error as NodeJS.ErrnoException & { code?: number };
|
const anyErr = error as NodeJS.ErrnoException & { code?: number };
|
||||||
if (anyErr.code === 1) {
|
if (anyErr.code === 1) return resolve({ ignored: false });
|
||||||
resolve({ ignored: false });
|
return resolve({ ignored: false, error: anyErr.message });
|
||||||
return;
|
|
||||||
}
|
|
||||||
resolve({ ignored: false, error: anyErr.message });
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -103,16 +340,8 @@ export async function writeSecretsEnvFile(opts: WriteSecretsEnvFileOptions): Pro
|
|||||||
await opts.audit?.filesystem({ type: "secret:env-write-skipped", target: opts.taskId, metadata: { filename, reason: "invalid-filename", overwritePolicy } });
|
await opts.audit?.filesystem({ type: "secret:env-write-skipped", target: opts.taskId, metadata: { filename, reason: "invalid-filename", overwritePolicy } });
|
||||||
return { outcome: "skipped", filename, reason: "invalid-filename" };
|
return { outcome: "skipped", filename, reason: "invalid-filename" };
|
||||||
}
|
}
|
||||||
|
|
||||||
const envPath = path.join(opts.worktreePath, filename);
|
const envPath = path.join(opts.worktreePath, filename);
|
||||||
try {
|
try { if ((await fs.lstat(envPath)).isSymbolicLink()) return { outcome: "skipped", filename, reason: "invalid-filename" }; } catch { /* absent is safe */ }
|
||||||
const stat = await fs.lstat(envPath);
|
|
||||||
if (stat.isSymbolicLink()) {
|
|
||||||
await opts.audit?.filesystem({ type: "secret:env-write-skipped", target: opts.taskId, metadata: { filename, reason: "invalid-filename", overwritePolicy, symlink: true } });
|
|
||||||
return { outcome: "skipped", filename, reason: "invalid-filename" };
|
|
||||||
}
|
|
||||||
} catch { /* file may not exist */ }
|
|
||||||
|
|
||||||
if (cfg?.requireGitignored !== false) {
|
if (cfg?.requireGitignored !== false) {
|
||||||
const check = await checkIgnored(opts.execFileImpl ?? execFile, opts.worktreePath, filename);
|
const check = await checkIgnored(opts.execFileImpl ?? execFile, opts.worktreePath, filename);
|
||||||
if (!check.ignored) {
|
if (!check.ignored) {
|
||||||
@@ -120,53 +349,56 @@ export async function writeSecretsEnvFile(opts: WriteSecretsEnvFileOptions): Pro
|
|||||||
return { outcome: "skipped", filename, reason: "not-gitignored" };
|
return { outcome: "skipped", filename, reason: "not-gitignored" };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let listed: Awaited<ReturnType<NonNullable<typeof opts.secretsStore>["listEnvExportable"]>>;
|
let listed: Awaited<ReturnType<NonNullable<typeof opts.secretsStore>["listEnvExportable"]>>;
|
||||||
try {
|
try { listed = await opts.secretsStore.listEnvExportable({ keyPrefix: cfg?.keyPrefix }); } catch {
|
||||||
listed = await opts.secretsStore.listEnvExportable({ keyPrefix: cfg?.keyPrefix });
|
|
||||||
} catch {
|
|
||||||
await opts.audit?.filesystem({ type: "secret:env-write-skipped", target: opts.taskId, metadata: { filename, reason: "list-failed", overwritePolicy } });
|
await opts.audit?.filesystem({ type: "secret:env-write-skipped", target: opts.taskId, metadata: { filename, reason: "list-failed", overwritePolicy } });
|
||||||
return { outcome: "skipped", filename, reason: "list-failed" };
|
return { outcome: "skipped", filename, reason: "list-failed" };
|
||||||
}
|
}
|
||||||
|
const valid = listed.filter((entry) => VALID_ENV_KEY.test(entry.exportKey));
|
||||||
const valid = listed.filter((entry) => {
|
|
||||||
if (!VALID_ENV_KEY.test(entry.exportKey)) {
|
|
||||||
opts.logger?.warn(`secrets-env: skipping invalid export key ${entry.exportKey}`);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
});
|
|
||||||
|
|
||||||
if (valid.length === 0) {
|
if (valid.length === 0) {
|
||||||
await opts.audit?.filesystem({ type: "secret:env-write-skipped", target: opts.taskId, metadata: { filename, reason: "no-secrets", overwritePolicy } });
|
await opts.audit?.filesystem({ type: "secret:env-write-skipped", target: opts.taskId, metadata: { filename, reason: "no-secrets", overwritePolicy } });
|
||||||
return { outcome: "skipped", filename, reason: "no-secrets" };
|
return { outcome: "skipped", filename, reason: "no-secrets" };
|
||||||
}
|
}
|
||||||
|
/*
|
||||||
|
* FNXC:SecretsEnvMaterialization 2026-08-08-03:59:
|
||||||
|
* A materialization write must not bypass the same record matrix that protects refresh and cleanup.
|
||||||
|
* Preserve malformed or conflicting bookkeeping and its existing env authority rather than replacing it
|
||||||
|
* with a new private record; only an unambiguous, durably reconciled state may receive new metadata.
|
||||||
|
*/
|
||||||
|
const reconciliation = await reconcileSecretsEnvFingerprint(opts.worktreePath);
|
||||||
|
if (!reconciliation.executionSafe) {
|
||||||
|
await opts.audit?.filesystem({
|
||||||
|
type: "secret:env-write-skipped",
|
||||||
|
target: opts.taskId,
|
||||||
|
metadata: { reason: "record-reconciliation-failed", reconciliationOutcome: reconciliation.outcome },
|
||||||
|
});
|
||||||
|
return { outcome: "skipped", filename, reason: "record-reconciliation-failed" };
|
||||||
|
}
|
||||||
let nextBody = toManagedBody(opts.taskId, valid);
|
let nextBody = toManagedBody(opts.taskId, valid);
|
||||||
if (overwritePolicy === "skip") {
|
if (overwritePolicy === "skip") {
|
||||||
try {
|
try { await fs.access(envPath); return { outcome: "skipped", filename, reason: "skip-existing" }; } catch { /* absent */ }
|
||||||
await fs.access(envPath);
|
|
||||||
await opts.audit?.filesystem({ type: "secret:env-write-skipped", target: opts.taskId, metadata: { filename, reason: "skip-existing", overwritePolicy } });
|
|
||||||
return { outcome: "skipped", filename, reason: "skip-existing" };
|
|
||||||
} catch { /* file does not exist — proceed to write */ }
|
|
||||||
} else if (overwritePolicy === "merge") {
|
} else if (overwritePolicy === "merge") {
|
||||||
try {
|
try { const preserved = removeManagedBlock(await fs.readFile(envPath, "utf8")); nextBody = `${preserved.replace(/\n*$/u, "")}${preserved.length ? "\n" : ""}${nextBody}`; } catch { /* absent */ }
|
||||||
const existing = await fs.readFile(envPath, "utf8");
|
|
||||||
const preserved = removeManagedBlock(existing);
|
|
||||||
nextBody = `${preserved.replace(/\n*$/u, "")}${preserved.length > 0 ? "\n" : ""}${nextBody}`;
|
|
||||||
} catch { /* file does not exist — write fresh */ }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const tmpPath = `${envPath}.fusion-tmp`;
|
const tmpPath = `${envPath}.fusion-tmp`;
|
||||||
await fs.writeFile(tmpPath, nextBody, { mode: 0o600, encoding: "utf8" });
|
await fs.writeFile(tmpPath, nextBody, { mode: 0o600, encoding: "utf8" });
|
||||||
await fs.rename(tmpPath, envPath);
|
await fs.rename(tmpPath, envPath);
|
||||||
await fs.chmod(envPath, 0o600).catch(() => undefined);
|
await fs.chmod(envPath, 0o600).catch(() => undefined);
|
||||||
|
|
||||||
const fingerprint = sha256(nextBody);
|
const fingerprint = sha256(nextBody);
|
||||||
const sidecarPath = path.join(opts.worktreePath, FINGERPRINT_FILE);
|
const privatePath = await resolvePrivateRecordPath(opts.worktreePath);
|
||||||
await fs.writeFile(sidecarPath, `${fingerprint}\n${filename}\n`, { mode: 0o600, encoding: "utf8" });
|
await atomicWriteRecord(privatePath, fingerprint, filename);
|
||||||
await fs.chmod(sidecarPath, 0o600).catch(() => undefined);
|
/*
|
||||||
|
* FNXC:SecretsEnvMaterialization 2026-08-08-02:00:
|
||||||
|
* A current private record supersedes root metadata only after it is durable. Do not swallow a legacy
|
||||||
|
* removal failure: preserving that untracked root file would deterministically poison the next strict
|
||||||
|
* refresh, so callers must observe the failed materialization rather than report a false clean write.
|
||||||
|
*/
|
||||||
|
try {
|
||||||
|
await removeLegacyRecord(opts.worktreePath, path.join(opts.worktreePath, FINGERPRINT_FILE));
|
||||||
|
} catch {
|
||||||
|
await opts.audit?.filesystem({ type: "secret:env-write-skipped", target: opts.taskId, metadata: { filename, reason: "legacy-remove-failed" } });
|
||||||
|
throw new Error("secrets-env legacy record removal failed");
|
||||||
|
}
|
||||||
const keys = valid.map((entry) => entry.exportKey).sort((a, b) => a.localeCompare(b));
|
const keys = valid.map((entry) => entry.exportKey).sort((a, b) => a.localeCompare(b));
|
||||||
await opts.audit?.filesystem({ type: "secret:env-write", target: opts.taskId, metadata: { filename, keyCount: keys.length, fingerprint, overwritePolicy, keys } });
|
await opts.audit?.filesystem({ type: "secret:env-write", target: opts.taskId, metadata: { filename, keyCount: keys.length, fingerprint, overwritePolicy, keys } });
|
||||||
opts.logger?.log(`secrets-env: wrote ${filename} (${keys.length} keys)`);
|
opts.logger?.log(`secrets-env: wrote ${filename} (${keys.length} keys)`);
|
||||||
@@ -174,46 +406,72 @@ export async function writeSecretsEnvFile(opts: WriteSecretsEnvFileOptions): Pro
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function cleanupSecretsEnvFile(opts: CleanupSecretsEnvFileOptions): Promise<CleanupSecretsEnvFileResult> {
|
export async function cleanupSecretsEnvFile(opts: CleanupSecretsEnvFileOptions): Promise<CleanupSecretsEnvFileResult> {
|
||||||
const sidecarPath = path.join(opts.worktreePath, FINGERPRINT_FILE);
|
const removeRecords = opts.removeRecordPaths ?? removeRecordPaths;
|
||||||
|
try { await fs.access(opts.worktreePath); } catch { return { outcome: "cleaned", reason: "directory-missing" }; }
|
||||||
|
const legacyPath = path.join(opts.worktreePath, FINGERPRINT_FILE);
|
||||||
|
let privatePath: string | undefined;
|
||||||
try {
|
try {
|
||||||
await fs.access(opts.worktreePath);
|
privatePath = await resolvePrivateRecordPath(opts.worktreePath);
|
||||||
} catch {
|
} catch {
|
||||||
await opts.audit?.filesystem({ type: "secret:env-cleanup", target: opts.taskId, metadata: { filename: opts.filename, fingerprint: opts.expectedFingerprint, reason: "directory-missing" } });
|
/*
|
||||||
return { outcome: "cleaned", reason: "directory-missing" };
|
* FNXC:SecretsEnvMaterialization 2026-08-08-03:23:
|
||||||
|
* A Git worktree whose private-dir lookup fails is not an orphan. Fail closed rather than treating its
|
||||||
|
* root record as orphan metadata, because that fallback could delete a tracked project file on a transient
|
||||||
|
* Git failure. Only a path with no .git entry can use legacy orphan cleanup.
|
||||||
|
*/
|
||||||
|
try {
|
||||||
|
await fs.lstat(path.join(opts.worktreePath, ".git"));
|
||||||
|
return { outcome: "skipped", reason: "invalid-record" };
|
||||||
|
} catch (error) {
|
||||||
|
if ((error as NodeJS.ErrnoException).code !== "ENOENT") return { outcome: "skipped", reason: "invalid-record" };
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
if (privatePath) {
|
||||||
let sidecar: string;
|
try {
|
||||||
try {
|
const reconciliation = await reconcileSecretsEnvFingerprint(opts.worktreePath);
|
||||||
sidecar = await fs.readFile(sidecarPath, "utf8");
|
if (!reconciliation.executionSafe) return { outcome: "skipped", reason: reconciliation.outcome === "conflict" ? "ambiguous-record" : "invalid-record" };
|
||||||
} catch {
|
} catch {
|
||||||
await opts.audit?.filesystem({ type: "secret:env-cleanup-skipped", target: opts.taskId, metadata: { filename: opts.filename, reason: "no-record" } });
|
// A Git-backed cleanup must never downgrade a failed reconciliation into legacy-only cleanup.
|
||||||
return { outcome: "skipped", reason: "no-record" };
|
return { outcome: "skipped", reason: "invalid-record" };
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
const [privateState, legacyState] = await Promise.all([privatePath ? readRecord(privatePath, "private") : Promise.resolve({ kind: "absent" } as RecordState), readRecord(legacyPath, "legacy")]);
|
||||||
const [fingerprint = "", filename = ""] = sidecar.split(/\n/u);
|
if (privateState.kind === "invalid" || legacyState.kind === "invalid") return { outcome: "skipped", reason: "invalid-record" };
|
||||||
if (!isValidFilename(filename)) {
|
if (privateState.kind === "valid" && legacyState.kind === "valid" && !recordsMatch(privateState.record, legacyState.record)) return { outcome: "skipped", reason: "ambiguous-record" };
|
||||||
await opts.audit?.filesystem({ type: "secret:env-cleanup-skipped", target: opts.taskId, metadata: { filename, reason: "stat-failed" } });
|
const record = privateState.kind === "valid" ? privateState.record : legacyState.kind === "valid" ? legacyState.record : undefined;
|
||||||
return { outcome: "skipped", reason: "stat-failed" };
|
if (!record) return { outcome: "skipped", reason: "no-record" };
|
||||||
}
|
const recordPaths = [privateState, legacyState].flatMap((state) => state.kind === "valid" && recordsMatch(state.record, record) ? [state.record.path] : []);
|
||||||
|
|
||||||
const envPath = path.join(opts.worktreePath, filename);
|
|
||||||
let body: string;
|
let body: string;
|
||||||
try {
|
try { body = await fs.readFile(path.join(opts.worktreePath, record.filename), "utf8"); } catch {
|
||||||
body = await fs.readFile(envPath, "utf8");
|
try {
|
||||||
} catch {
|
await removeRecords(recordPaths);
|
||||||
await fs.unlink(sidecarPath).catch(() => undefined);
|
} catch {
|
||||||
await opts.audit?.filesystem({ type: "secret:env-cleanup-skipped", target: opts.taskId, metadata: { filename, reason: "file-missing" } });
|
return { outcome: "skipped", reason: "record-remove-failed" };
|
||||||
|
}
|
||||||
return { outcome: "skipped", reason: "file-missing" };
|
return { outcome: "skipped", reason: "file-missing" };
|
||||||
}
|
}
|
||||||
|
if (sha256(body) !== record.fingerprint) {
|
||||||
if (sha256(body) !== fingerprint) {
|
try {
|
||||||
await fs.unlink(sidecarPath).catch(() => undefined);
|
await removeRecords(recordPaths);
|
||||||
await opts.audit?.filesystem({ type: "secret:env-cleanup-skipped", target: opts.taskId, metadata: { filename, reason: "fingerprint-mismatch" } });
|
} catch {
|
||||||
|
return { outcome: "skipped", reason: "record-remove-failed" };
|
||||||
|
}
|
||||||
return { outcome: "skipped", reason: "fingerprint-mismatch" };
|
return { outcome: "skipped", reason: "fingerprint-mismatch" };
|
||||||
}
|
}
|
||||||
|
try {
|
||||||
await fs.unlink(envPath);
|
if (privatePath && await isTrackedWorktreeFile(opts.worktreePath, record.filename)) {
|
||||||
await fs.unlink(sidecarPath).catch(() => undefined);
|
return { outcome: "skipped", reason: "tracked-file" };
|
||||||
await opts.audit?.filesystem({ type: "secret:env-cleanup", target: opts.taskId, metadata: { filename, fingerprint, reason: "fingerprint-match" } });
|
}
|
||||||
|
} catch {
|
||||||
|
// Cleanup cannot prove ownership when Git cannot answer; preserve both content and record for retry.
|
||||||
|
return { outcome: "skipped", reason: "tracked-file" };
|
||||||
|
}
|
||||||
|
await fs.unlink(path.join(opts.worktreePath, record.filename));
|
||||||
|
try {
|
||||||
|
await removeRecords(recordPaths);
|
||||||
|
} catch {
|
||||||
|
return { outcome: "skipped", reason: "record-remove-failed" };
|
||||||
|
}
|
||||||
|
await opts.audit?.filesystem({ type: "secret:env-cleanup", target: opts.taskId, metadata: { filename: record.filename, fingerprint: record.fingerprint, reason: "fingerprint-match" } });
|
||||||
return { outcome: "cleaned", reason: "fingerprint-match" };
|
return { outcome: "cleaned", reason: "fingerprint-match" };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ import {
|
|||||||
type WorktrunkOpName,
|
type WorktrunkOpName,
|
||||||
} from "./worktrunk-failure-handler.js";
|
} from "./worktrunk-failure-handler.js";
|
||||||
import type { RunAuditor } from "../util/run-audit.js";
|
import type { RunAuditor } from "../util/run-audit.js";
|
||||||
import { writeSecretsEnvFile } from "./secrets-env-writer.js";
|
import { reconcileSecretsEnvFingerprint, writeSecretsEnvFile } from "./secrets-env-writer.js";
|
||||||
import { removeDesktopBuildArtifacts } from "./worktree-desktop-artifacts.js";
|
import { removeDesktopBuildArtifacts } from "./worktree-desktop-artifacts.js";
|
||||||
import { installTaskWorktreeIdentityGuard } from "./worktree-hooks.js";
|
import { installTaskWorktreeIdentityGuard } from "./worktree-hooks.js";
|
||||||
import { copyConfiguredWorktreeFiles, type WorktreeCopyFileResult } from "./worktree-copy-files.js";
|
import { copyConfiguredWorktreeFiles, type WorktreeCopyFileResult } from "./worktree-copy-files.js";
|
||||||
@@ -215,6 +215,26 @@ export async function acquireTaskWorktree(opts: AcquireTaskWorktreeOptions): Pro
|
|||||||
const { task, rootDir, store, settings, pool, logger, audit, runContext, createWorktree, runConfiguredCommand, runInitCommand, taskEnv, secretsStore } = opts;
|
const { task, rootDir, store, settings, pool, logger, audit, runContext, createWorktree, runConfiguredCommand, runInitCommand, taskEnv, secretsStore } = opts;
|
||||||
const refreshExistingWorktree = async (path: string): Promise<WorktreeBaseRefreshResult | undefined> => {
|
const refreshExistingWorktree = async (path: string): Promise<WorktreeBaseRefreshResult | undefined> => {
|
||||||
if (!opts.refreshStaleBase) return undefined;
|
if (!opts.refreshStaleBase) return undefined;
|
||||||
|
/*
|
||||||
|
* FNXC:SecretsEnvMaterialization 2026-08-07-23:13:
|
||||||
|
* Reconcile the v0.75.1 root record before strict porcelain checking. A malformed, conflicting, or
|
||||||
|
* unresolvable record fails closed rather than hiding project dirt or authorizing unsafe secret cleanup.
|
||||||
|
*/
|
||||||
|
let reconciliation: Awaited<ReturnType<typeof reconcileSecretsEnvFingerprint>>;
|
||||||
|
try {
|
||||||
|
reconciliation = await reconcileSecretsEnvFingerprint(path);
|
||||||
|
} catch {
|
||||||
|
// FNXC:SecretsEnvMaterialization 2026-08-08-02:00: A resolver I/O failure is an opaque but fixed
|
||||||
|
// reconciliation outcome. Convert it to the same pre-refresh fail-closed path without recording OS
|
||||||
|
// error text, paths, or any secret-derived value in the durable audit trail.
|
||||||
|
reconciliation = { executionSafe: false, outcome: "git-dir-unavailable" };
|
||||||
|
}
|
||||||
|
if (!reconciliation.executionSafe) {
|
||||||
|
const refresh: WorktreeBaseRefreshResult = { kind: "base-reconciliation-required", executionSafe: false, detail: reconciliation.outcome };
|
||||||
|
await audit?.git?.({ type: "worktree:base-refresh-blocked", target: task.id, metadata: { taskId: task.id, outcome: refresh.kind, reconciliationOutcome: reconciliation.outcome } });
|
||||||
|
await store.logEntry(task.id, `Worktree secrets record reconciliation blocked execution (${reconciliation.outcome})`, undefined, runContext);
|
||||||
|
throw new WorktreeBaseRefreshError(refresh);
|
||||||
|
}
|
||||||
const refresh = await refreshReusedWorktreeBase({ task, rootDir, worktreePath: path, store, settings, audit, logger });
|
const refresh = await refreshReusedWorktreeBase({ task, rootDir, worktreePath: path, store, settings, audit, logger });
|
||||||
if (!refresh.executionSafe) {
|
if (!refresh.executionSafe) {
|
||||||
await audit?.git({ type: refresh.kind === "stale-base-conflict" ? "worktree:base-refresh-conflict" : "worktree:base-refresh-blocked", target: path, metadata: { taskId: task.id, outcome: refresh.kind } });
|
await audit?.git({ type: refresh.kind === "stale-base-conflict" ? "worktree:base-refresh-conflict" : "worktree:base-refresh-blocked", target: path, metadata: { taskId: task.id, outcome: refresh.kind } });
|
||||||
|
|||||||
Reference in New Issue
Block a user