feat(FN-4912): complete Step 5 — add secrets env writer helpers

Fusion-Task-Id: FN-4912
Fusion-Task-Lineage: 943d0651-052a-41b5-8069-4c60f4db1ba7
This commit is contained in:
Fusion (runfusion.ai)
2026-05-17 22:44:27 -07:00
committed by gsxdsm
parent 4ab60aefb4
commit a8615ecf6c
2 changed files with 220 additions and 0 deletions

View File

@@ -209,6 +209,7 @@ export {
type FilesystemMutationType,
} from "./run-audit.js";
export { PrCommentHandler } from "./pr-comment-handler.js";
export { writeSecretsEnvFile, cleanupSecretsEnvFile, type WriteSecretsEnvFileOptions, type WriteSecretsEnvFileResult, type CleanupSecretsEnvFileOptions, type CleanupSecretsEnvFileResult } from "./secrets-env-writer.js";
export {
NtfyNotifier,
DEFAULT_NTFY_EVENTS,

View File

@@ -0,0 +1,219 @@
import { createHash } from "node:crypto";
import { promises as fs } from "node:fs";
import path from "node:path";
import { execFile } from "node:child_process";
import type { ProjectSettings, SecretsStore } from "@fusion/core";
import type { RunAuditor } from "./run-audit.js";
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_]*$/;
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 interface WriteSecretsEnvFileOptions {
rootDir: string;
worktreePath: string;
taskId: string;
settings: Pick<ProjectSettings, "secretsEnv">;
worktreeSource: "pool" | "fresh";
secretsStore?: Pick<SecretsStore, "listEnvExportable">;
audit?: Pick<RunAuditor, "filesystem">;
logger?: { log: (m: string) => void; warn: (m: string) => void; error?: (m: string) => void };
execFileImpl?: typeof execFile;
}
export interface WriteSecretsEnvFileResult {
outcome: "written" | "skipped";
filename: string;
reason?: WriteSkipReason;
keyCount?: number;
fingerprint?: string;
}
export interface CleanupSecretsEnvFileOptions {
worktreePath: string;
taskId: string;
expectedFingerprint: string | null;
filename: string;
audit?: Pick<RunAuditor, "filesystem">;
logger?: { log: (m: string) => void; warn: (m: string) => void };
}
export interface CleanupSecretsEnvFileResult {
outcome: "cleaned" | "skipped";
reason?: CleanupSkipReason | "fingerprint-match" | "directory-missing";
}
function isValidFilename(filename: string): boolean {
return !!filename && !filename.includes("/") && !filename.includes("\\") && !filename.includes("..") && !filename.includes("\0") && filename !== FINGERPRINT_FILE;
}
function sha256(content: string): string {
return createHash("sha256").update(Buffer.from(content, "utf8")).digest("hex");
}
function quote(value: string): string {
return `"${value.replaceAll("\\", "\\\\").replaceAll('"', '\\"').replaceAll("\n", "\\n").replaceAll("\r", "\\r").replaceAll("\t", "\\t")}"`;
}
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");
return `${header}${body}\n`;
}
function removeManagedBlock(input: string): string {
const idx = input.indexOf(HEADER_PREFIX);
if (idx === -1) return input;
return input.slice(0, idx).replace(/\n+$/u, "\n");
}
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;
}
const anyErr = error as NodeJS.ErrnoException & { code?: number };
if (anyErr.code === 1) {
resolve({ ignored: false });
return;
}
resolve({ ignored: false, error: anyErr.message });
});
});
}
export async function writeSecretsEnvFile(opts: WriteSecretsEnvFileOptions): Promise<WriteSecretsEnvFileResult> {
const cfg = opts.settings.secretsEnv;
const filename = cfg?.filename ?? ".env";
const overwritePolicy = cfg?.overwritePolicy ?? "merge";
if (cfg?.enabled !== true) return { outcome: "skipped", filename, reason: "disabled" };
if (!opts.secretsStore) {
await opts.audit?.filesystem({ type: "secret:env-write-skipped", target: opts.taskId, metadata: { filename, reason: "no-store" } });
return { outcome: "skipped", filename, reason: "no-store" };
}
if (!isValidFilename(filename)) {
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 {}
if (cfg?.requireGitignored !== false) {
const check = await checkIgnored(opts.execFileImpl ?? execFile, opts.worktreePath, filename);
if (!check.ignored) {
await opts.audit?.filesystem({ type: "secret:env-write-skipped", target: opts.taskId, metadata: { filename, reason: "not-gitignored", overwritePolicy, checkIgnoreError: check.error } });
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 {
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;
});
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" };
}
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 {}
} 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 {}
}
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 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)`);
return { outcome: "written", filename, keyCount: keys.length, fingerprint };
}
export async function cleanupSecretsEnvFile(opts: CleanupSecretsEnvFileOptions): Promise<CleanupSecretsEnvFileResult> {
const sidecarPath = path.join(opts.worktreePath, FINGERPRINT_FILE);
try {
await fs.access(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" };
}
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" };
}
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);
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" } });
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" } });
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" } });
return { outcome: "cleaned", reason: "fingerprint-match" };
}