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:
gsxdsm
2026-08-07 21:18:24 -07:00
parent 7b010ec95f
commit e78c09c6f4
7 changed files with 799 additions and 100 deletions

View 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.

View File

@@ -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 -- <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.
- Fingerprint sidecar: successful writes persist `.fusion-secrets-env.fingerprint` containing `<sha256>\n<filename>\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 `<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.
- 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.

View File

@@ -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");

View File

@@ -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);
});
});

View File

@@ -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({

View File

@@ -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<RunAuditor, "filesystem">;
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 {
@@ -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<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 {
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<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 }> {
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<ReturnType<NonNullable<typeof opts.secretsStore>["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<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 {
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" };
}

View File

@@ -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<WorktreeBaseRefreshResult | 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 });
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 } });