From e78c09c6f4653c60aac1d59f692b7b98244cb2e2 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Fri, 7 Aug 2026 21:18:24 -0700 Subject: [PATCH] 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) --- .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 ++- .../engine/src/worktree/secrets-env-writer.ts | 434 ++++++++++++++---- .../src/worktree/worktree-acquisition.ts | 22 +- 7 files changed, 799 insertions(+), 100 deletions(-) create mode 100644 .changeset/fn-8825-secrets-env-fingerprint.md diff --git a/.changeset/fn-8825-secrets-env-fingerprint.md b/.changeset/fn-8825-secrets-env-fingerprint.md new file mode 100644 index 0000000000..9800b3de33 --- /dev/null +++ b/.changeset/fn-8825-secrets-env-fingerprint.md @@ -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. diff --git a/docs/secrets.md b/docs/secrets.md index 182121ecc6..efefc4a5db 100644 --- a/docs/secrets.md +++ b/docs/secrets.md @@ -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`). - Safety guard: when `requireGitignored` is enabled, Fusion runs `git check-ignore -- ` 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. -- Fingerprint sidecar: successful writes persist `.fusion-secrets-env.fingerprint` containing `\n\n` (mode `0o600`) so 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. +- Fingerprint record: successful writes atomically persist `.fusion-secrets-env.fingerprint` containing `\n\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. +- 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\n` (normally `\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. diff --git a/packages/engine/src/__tests__/reliability-interactions/secrets-env-materialization.test.ts b/packages/engine/src/__tests__/reliability-interactions/secrets-env-materialization.test.ts index ca5548321a..6151bb6a9d 100644 --- a/packages/engine/src/__tests__/reliability-interactions/secrets-env-materialization.test.ts +++ b/packages/engine/src/__tests__/reliability-interactions/secrets-env-materialization.test.ts @@ -1,11 +1,14 @@ +import { createHash } from "node:crypto"; 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 { join } from "node:path"; +import { join, resolve } from "node:path"; import { tmpdir } from "node:os"; import { afterEach, describe, expect, it, vi } from "vitest"; import { writeSecretsEnvFile } from "../../worktree/secrets-env-writer.js"; +import { refreshReusedWorktreeBase } from "../../worktree-base-refresh.js"; import { reapOrphanWorktrees } from "../../worktree/worktree-pool.js"; +import { acquireTaskWorktree } from "../../worktree/worktree-acquisition.js"; const dirs: 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" })); }); + 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 () => { const root = tmpRepo(); const worktreesDir = join(root, ".worktrees"); diff --git a/packages/engine/src/__tests__/secrets-env-writer.test.ts b/packages/engine/src/__tests__/secrets-env-writer.test.ts index eb86461ba8..bdd3105620 100644 --- a/packages/engine/src/__tests__/secrets-env-writer.test.ts +++ b/packages/engine/src/__tests__/secrets-env-writer.test.ts @@ -1,15 +1,17 @@ +import { createHash } from "node:crypto"; 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 { join } from "node:path"; import { tmpdir } from "node:os"; 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[] = []; function tmpWorktree(): string { const dir = mkdtempSync(join(tmpdir(), "secrets-env-")); + execFileSync("git", ["init", "-q"], { cwd: dir }); dirs.push(dir); return dir; } @@ -75,11 +77,12 @@ describe("secrets-env-writer", () => { const env = readFileSync(join(dir, ".env"), "utf8"); expect(env).toContain("ALPHA="); 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(existsSync(join(dir, ".fusion-secrets-env.fingerprint"))).toBe(false); if (process.platform !== "win32") { 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 }); @@ -177,6 +180,304 @@ describe("secrets-env-writer", () => { 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 () => { const dir = tmpWorktree(); const filesystem = vi.fn(); @@ -202,7 +503,7 @@ describe("secrets-env-writer", () => { }); expect(cleaned.outcome).toBe("cleaned"); 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({ rootDir: process.cwd(), @@ -222,6 +523,18 @@ describe("secrets-env-writer", () => { }); expect(skipped.reason).toBe("fingerprint-mismatch"); 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); }); }); diff --git a/packages/engine/src/__tests__/worktree-acquisition-secrets-env.test.ts b/packages/engine/src/__tests__/worktree-acquisition-secrets-env.test.ts index 05f59379b5..12675fd785 100644 --- a/packages/engine/src/__tests__/worktree-acquisition-secrets-env.test.ts +++ b/packages/engine/src/__tests__/worktree-acquisition-secrets-env.test.ts @@ -2,10 +2,19 @@ import { dirname } from "node:path"; 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", () => ({ writeSecretsEnvFile, + reconcileSecretsEnvFingerprint, +})); + +vi.mock("../worktree-base-refresh.js", () => ({ + refreshReusedWorktreeBase, })); vi.mock("../worktree/worktree-pool.js", async () => { @@ -42,6 +51,8 @@ describe("worktree-acquisition secrets env hook", () => { beforeEach(() => { 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); 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(); }); + 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 () => { writeSecretsEnvFile.mockRejectedValueOnce(new Error("boom")); await expect(acquireTaskWorktree({ diff --git a/packages/engine/src/worktree/secrets-env-writer.ts b/packages/engine/src/worktree/secrets-env-writer.ts index 8eadde7a73..cbabed0642 100644 --- a/packages/engine/src/worktree/secrets-env-writer.ts +++ b/packages/engine/src/worktree/secrets-env-writer.ts @@ -1,16 +1,20 @@ import { createHash } from "node:crypto"; +import { execFile } from "node:child_process"; import { promises as fs } from "node:fs"; 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 { 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 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 CleanupSkipReason = "fingerprint-mismatch" | "file-missing" | "no-record" | "disabled" | "stat-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" | "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 { rootDir: string; @@ -39,6 +43,8 @@ export interface CleanupSecretsEnvFileOptions { filename: string; audit?: Pick; 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; } export interface CleanupSecretsEnvFileResult { @@ -46,6 +52,27 @@ export interface CleanupSecretsEnvFileResult { 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; + /** 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; +} + +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 { 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 { const header = `${HEADER_PREFIX} (task: ${taskId})\n`; - const body = entries - .sort((a, b) => a.exportKey.localeCompare(b.exportKey)) - .map((item) => `${item.exportKey}=${quote(item.plaintextValue)}`) - .join("\n"); + const body = entries.sort((a, b) => a.exportKey.localeCompare(b.exportKey)).map((item) => `${item.exportKey}=${quote(item.plaintextValue)}`).join("\n"); return `${header}${body}\n`; } @@ -73,19 +97,232 @@ function removeManagedBlock(input: string): string { 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 { + 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 { + 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 { + let handle: Awaited> | 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 { + const tmpPath = `${recordPath}.${process.pid}.${Date.now()}.tmp`; + let handle: Awaited> | 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 { + 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 { + 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 { + 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 { + 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 }> { return await new Promise((resolve) => { execImpl("git", ["check-ignore", "--", filename], { cwd: worktreePath, timeout: 10_000 }, (error) => { - if (!error) { - resolve({ ignored: true }); - return; - } + if (!error) return resolve({ ignored: true }); const anyErr = error as NodeJS.ErrnoException & { code?: number }; - if (anyErr.code === 1) { - resolve({ ignored: false }); - return; - } - resolve({ ignored: false, error: anyErr.message }); + if (anyErr.code === 1) return resolve({ ignored: false }); + 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 } }); return { outcome: "skipped", filename, reason: "invalid-filename" }; } - const envPath = path.join(opts.worktreePath, filename); - try { - 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 */ } - + try { if ((await fs.lstat(envPath)).isSymbolicLink()) return { outcome: "skipped", filename, reason: "invalid-filename" }; } catch { /* absent is safe */ } if (cfg?.requireGitignored !== false) { const check = await checkIgnored(opts.execFileImpl ?? execFile, opts.worktreePath, filename); if (!check.ignored) { @@ -120,53 +349,56 @@ export async function writeSecretsEnvFile(opts: WriteSecretsEnvFileOptions): Pro return { outcome: "skipped", filename, reason: "not-gitignored" }; } } - let listed: Awaited["listEnvExportable"]>>; - try { - listed = await opts.secretsStore.listEnvExportable({ keyPrefix: cfg?.keyPrefix }); - } catch { + try { 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 } }); return { outcome: "skipped", filename, reason: "list-failed" }; } - - 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; - }); - + const valid = listed.filter((entry) => VALID_ENV_KEY.test(entry.exportKey)); if (valid.length === 0) { 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" }; } - + /* + * 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); if (overwritePolicy === "skip") { - try { - 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 */ } + try { await fs.access(envPath); return { outcome: "skipped", filename, reason: "skip-existing" }; } catch { /* absent */ } } else if (overwritePolicy === "merge") { - try { - 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 */ } + try { const preserved = removeManagedBlock(await fs.readFile(envPath, "utf8")); nextBody = `${preserved.replace(/\n*$/u, "")}${preserved.length ? "\n" : ""}${nextBody}`; } catch { /* absent */ } } - const tmpPath = `${envPath}.fusion-tmp`; await fs.writeFile(tmpPath, nextBody, { mode: 0o600, encoding: "utf8" }); await fs.rename(tmpPath, envPath); await fs.chmod(envPath, 0o600).catch(() => undefined); - const fingerprint = sha256(nextBody); - const sidecarPath = path.join(opts.worktreePath, FINGERPRINT_FILE); - await fs.writeFile(sidecarPath, `${fingerprint}\n${filename}\n`, { mode: 0o600, encoding: "utf8" }); - await fs.chmod(sidecarPath, 0o600).catch(() => undefined); - + const privatePath = await resolvePrivateRecordPath(opts.worktreePath); + await atomicWriteRecord(privatePath, fingerprint, filename); + /* + * 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)); 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)`); @@ -174,46 +406,72 @@ export async function writeSecretsEnvFile(opts: WriteSecretsEnvFileOptions): Pro } export async function cleanupSecretsEnvFile(opts: CleanupSecretsEnvFileOptions): Promise { - 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 { - await fs.access(opts.worktreePath); + privatePath = await resolvePrivateRecordPath(opts.worktreePath); } 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" }; + } } - - let sidecar: string; - try { - sidecar = await fs.readFile(sidecarPath, "utf8"); - } catch { - await opts.audit?.filesystem({ type: "secret:env-cleanup-skipped", target: opts.taskId, metadata: { filename: opts.filename, reason: "no-record" } }); - return { outcome: "skipped", reason: "no-record" }; + if (privatePath) { + try { + const reconciliation = await reconcileSecretsEnvFingerprint(opts.worktreePath); + if (!reconciliation.executionSafe) return { outcome: "skipped", reason: reconciliation.outcome === "conflict" ? "ambiguous-record" : "invalid-record" }; + } catch { + // A Git-backed cleanup must never downgrade a failed reconciliation into legacy-only cleanup. + return { outcome: "skipped", reason: "invalid-record" }; + } } - - const [fingerprint = "", filename = ""] = sidecar.split(/\n/u); - if (!isValidFilename(filename)) { - await opts.audit?.filesystem({ type: "secret:env-cleanup-skipped", target: opts.taskId, metadata: { filename, reason: "stat-failed" } }); - return { outcome: "skipped", reason: "stat-failed" }; - } - - const envPath = path.join(opts.worktreePath, filename); + const [privateState, legacyState] = await Promise.all([privatePath ? readRecord(privatePath, "private") : Promise.resolve({ kind: "absent" } as RecordState), readRecord(legacyPath, "legacy")]); + if (privateState.kind === "invalid" || legacyState.kind === "invalid") return { outcome: "skipped", reason: "invalid-record" }; + if (privateState.kind === "valid" && legacyState.kind === "valid" && !recordsMatch(privateState.record, legacyState.record)) return { outcome: "skipped", reason: "ambiguous-record" }; + const record = privateState.kind === "valid" ? privateState.record : legacyState.kind === "valid" ? legacyState.record : undefined; + if (!record) return { outcome: "skipped", reason: "no-record" }; + const recordPaths = [privateState, legacyState].flatMap((state) => state.kind === "valid" && recordsMatch(state.record, record) ? [state.record.path] : []); let body: string; - try { - body = await fs.readFile(envPath, "utf8"); - } catch { - await fs.unlink(sidecarPath).catch(() => undefined); - await opts.audit?.filesystem({ type: "secret:env-cleanup-skipped", target: opts.taskId, metadata: { filename, reason: "file-missing" } }); + try { body = await fs.readFile(path.join(opts.worktreePath, record.filename), "utf8"); } catch { + try { + await removeRecords(recordPaths); + } catch { + return { outcome: "skipped", reason: "record-remove-failed" }; + } return { outcome: "skipped", reason: "file-missing" }; } - - if (sha256(body) !== fingerprint) { - await fs.unlink(sidecarPath).catch(() => undefined); - await opts.audit?.filesystem({ type: "secret:env-cleanup-skipped", target: opts.taskId, metadata: { filename, reason: "fingerprint-mismatch" } }); + if (sha256(body) !== record.fingerprint) { + try { + await removeRecords(recordPaths); + } catch { + return { outcome: "skipped", reason: "record-remove-failed" }; + } return { outcome: "skipped", reason: "fingerprint-mismatch" }; } - - await fs.unlink(envPath); - await fs.unlink(sidecarPath).catch(() => undefined); - await opts.audit?.filesystem({ type: "secret:env-cleanup", target: opts.taskId, metadata: { filename, fingerprint, reason: "fingerprint-match" } }); + try { + if (privatePath && await isTrackedWorktreeFile(opts.worktreePath, record.filename)) { + return { outcome: "skipped", reason: "tracked-file" }; + } + } 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" }; } diff --git a/packages/engine/src/worktree/worktree-acquisition.ts b/packages/engine/src/worktree/worktree-acquisition.ts index 9d64bc9a29..9bf67d1821 100644 --- a/packages/engine/src/worktree/worktree-acquisition.ts +++ b/packages/engine/src/worktree/worktree-acquisition.ts @@ -36,7 +36,7 @@ import { type WorktrunkOpName, } from "./worktrunk-failure-handler.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 { installTaskWorktreeIdentityGuard } from "./worktree-hooks.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 refreshExistingWorktree = async (path: string): Promise => { 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>; + 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 }); 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 } });