import { execFile, spawn, type ChildProcess } from "node:child_process"; import { createHash } from "node:crypto"; import { constants } from "node:fs"; import { lstat, mkdir, open, readFile, readlink, realpath, rm, } from "node:fs/promises"; import * as path from "node:path"; import { promisify } from "node:util"; import { inflateSync } from "node:zlib"; import type { SourceChangeEvidence, WorkflowWorkspace, } from "./domain.ts"; const execFileAsync = promisify(execFile); export interface CommandResult { code: number; stdout: string; stderr: string; } export interface CommandRunOptions { env?: NodeJS.ProcessEnv; timeoutMs?: number; } export interface CommandRunner { run( cwd: string, executable: string, args: string[], options?: CommandRunOptions, ): Promise; } export class WorkspaceSafetyError extends Error { constructor(message: string) { super(message); this.name = "WorkspaceSafetyError"; } } export interface GitStateSnapshot { root: string; branch: string; head: string; commonDirectory: string; gitDirectory: string; worktreeListDigest: string; worktreeMetadataDigest: string; status: string; changedPaths: string[]; pathStatuses: Record; pathDigests: Record; } function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value); } function pngCrc32(contents: Uint8Array) { let crc = 0xffffffff; for (const byte of contents) { crc ^= byte; for (let bit = 0; bit < 8; bit++) { crc = (crc >>> 1) ^ (crc & 1 ? 0xedb88320 : 0); } } return (crc ^ 0xffffffff) >>> 0; } function paethPredictor(left: number, above: number, upperLeft: number) { const estimate = left + above - upperLeft; const leftDistance = Math.abs(estimate - left); const aboveDistance = Math.abs(estimate - above); const upperLeftDistance = Math.abs(estimate - upperLeft); if (leftDistance <= aboveDistance && leftDistance <= upperLeftDistance) return left; return aboveDistance <= upperLeftDistance ? above : upperLeft; } function parsePngVisual(contents: Buffer) { const signature = Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]); if ( contents.byteLength < signature.byteLength || !contents.subarray(0, signature.byteLength).equals(signature) ) { throw new Error("invalid PNG signature"); } let offset = signature.byteLength; let width = 0; let height = 0; let bitDepth = 0; let colorType = -1; let palette = Buffer.alloc(0); const compressed: Buffer[] = []; let sawHeader = false; let sawEnd = false; while (offset < contents.byteLength) { if (offset + 12 > contents.byteLength) throw new Error("truncated PNG chunk"); const length = contents.readUInt32BE(offset); const end = offset + 12 + length; if (end > contents.byteLength) throw new Error("truncated PNG data"); const type = contents.subarray(offset + 4, offset + 8); const data = contents.subarray(offset + 8, offset + 8 + length); const expectedCrc = contents.readUInt32BE(offset + 8 + length); if (pngCrc32(Buffer.concat([type, data])) !== expectedCrc) { throw new Error("invalid PNG checksum"); } const name = type.toString("ascii"); if (name === "IHDR") { if (sawHeader || length !== 13 || offset !== signature.byteLength) { throw new Error("invalid PNG header"); } sawHeader = true; width = data.readUInt32BE(0); height = data.readUInt32BE(4); bitDepth = data[8] ?? 0; colorType = data[9] ?? -1; if ( width < 1 || height < 1 || width > 16_384 || height > 16_384 || data[10] !== 0 || data[11] !== 0 || data[12] !== 0 ) { throw new Error("unsupported PNG header"); } } else if (name === "IDAT") { compressed.push(Buffer.from(data)); } else if (name === "PLTE" || name === "tRNS") { palette = Buffer.concat([palette, Buffer.from(type), Buffer.from(data)]); } else if (name === "IEND") { if (length !== 0) throw new Error("invalid PNG end chunk"); sawEnd = true; offset = end; break; } offset = end; } if (!sawHeader || !sawEnd || compressed.length === 0 || offset !== contents.byteLength) { throw new Error("incomplete PNG image"); } const channels = new Map([ [0, 1], [2, 3], [3, 1], [4, 2], [6, 4], ]).get(colorType); if (!channels || ![1, 2, 4, 8, 16].includes(bitDepth)) { throw new Error("unsupported PNG color format"); } const rowBytes = Math.ceil((width * channels * bitDepth) / 8); const bytesPerPixel = Math.max(1, Math.ceil((channels * bitDepth) / 8)); const filtered = inflateSync(Buffer.concat(compressed), { maxOutputLength: (rowBytes + 1) * height, }); if (filtered.byteLength !== (rowBytes + 1) * height) { throw new Error("invalid PNG scanline length"); } const pixels = Buffer.alloc(rowBytes * height); for (let row = 0; row < height; row++) { const sourceOffset = row * (rowBytes + 1); const targetOffset = row * rowBytes; const filter = filtered[sourceOffset]; if (filter === undefined || filter > 4) throw new Error("invalid PNG filter"); for (let column = 0; column < rowBytes; column++) { const encoded = filtered[sourceOffset + 1 + column] ?? 0; const left = column >= bytesPerPixel ? (pixels[targetOffset + column - bytesPerPixel] ?? 0) : 0; const above = row > 0 ? (pixels[targetOffset - rowBytes + column] ?? 0) : 0; const upperLeft = row > 0 && column >= bytesPerPixel ? (pixels[targetOffset - rowBytes + column - bytesPerPixel] ?? 0) : 0; const predictor = filter === 0 ? 0 : filter === 1 ? left : filter === 2 ? above : filter === 3 ? Math.floor((left + above) / 2) : paethPredictor(left, above, upperLeft); pixels[targetOffset + column] = (encoded + predictor) & 0xff; } } return { width, height, digest: createHash("sha256") .update(`${width}:${height}:${bitDepth}:${colorType}\n`) .update(palette) .update(pixels) .digest("hex"), }; } function isWithin(root: string, candidate: string) { return candidate === root || candidate.startsWith(`${root}${path.sep}`); } async function nearestExisting(candidate: string, floor: string) { let current = candidate; while (isWithin(floor, current)) { try { await lstat(current); return current; } catch (error) { if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; } if (current === floor) break; current = path.dirname(current); } throw new Error(`No existing ancestor for ${candidate} is inside ${floor}`); } export class MutationScope { readonly root: string; readonly ownedRoots: string[]; private constructor(root: string, ownedRoots: string[]) { this.root = root; this.ownedRoots = ownedRoots; } static async create(worktreeRoot: string, ownedPaths: string[]) { if (ownedPaths.length === 0) throw new Error("Mutation ownership cannot be empty"); const root = await realpath(path.resolve(worktreeRoot)); const ownedRoots = ownedPaths.map((ownedPath) => { if (!ownedPath || path.isAbsolute(ownedPath)) { throw new Error(`Owned path must be relative: ${ownedPath}`); } const resolved = path.resolve(root, ownedPath); if (!isWithin(root, resolved)) { throw new Error(`Owned path escapes the worktree: ${ownedPath}`); } return resolved; }); return new MutationScope(root, [...new Set(ownedRoots)]); } async remove(targetPath: string) { const target = await this.assertWritePath(targetPath); await rm(target, { recursive: true, force: true }); } owns(candidate: string) { const absolute = path.isAbsolute(candidate) ? path.resolve(candidate) : path.resolve(this.root, candidate); return ( isWithin(this.root, absolute) && this.ownedRoots.some((ownedRoot) => isWithin(ownedRoot, absolute)) ); } async assertWritePath(candidate: string) { const absolute = path.isAbsolute(candidate) ? path.resolve(candidate) : path.resolve(this.root, candidate); if (!this.owns(absolute)) { throw new Error(`Write path is outside Assignment ownership: ${candidate}`); } const ancestor = await nearestExisting(absolute, this.root); const canonicalAncestor = await realpath(ancestor); if (!isWithin(this.root, canonicalAncestor)) { throw new Error(`Write path resolves outside the worktree: ${candidate}`); } let current = this.root; const relative = path.relative(this.root, absolute); for (const segment of relative.split(path.sep).filter(Boolean)) { current = path.join(current, segment); try { const details = await lstat(current); if (details.isSymbolicLink()) { throw new Error(`Write path traverses a symbolic link: ${candidate}`); } } catch (error) { if ((error as NodeJS.ErrnoException).code === "ENOENT") break; throw error; } } return absolute; } async writeFile(targetPath: string, content: string) { const target = await this.assertWritePath(targetPath); const handle = await open( target, constants.O_CREAT | constants.O_WRONLY | constants.O_NOFOLLOW, 0o666, ); try { const descriptorPath = await realpath(`/proc/self/fd/${handle.fd}`).catch( () => realpath(target), ); if (!this.owns(descriptorPath)) { throw new WorkspaceSafetyError( `Opened write target escapes worktree ownership: ${targetPath}`, ); } await handle.truncate(0); await handle.writeFile(content, "utf8"); } finally { await handle.close(); } } async mkdir(targetPath: string) { const target = await this.assertWritePath(targetPath); const relative = path.relative(this.root, target); let current = this.root; for (const segment of relative.split(path.sep).filter(Boolean)) { current = path.join(current, segment); await mkdir(current).catch((error: NodeJS.ErrnoException) => { if (error.code !== "EEXIST") throw error; }); const canonical = await realpath(current); if (this.owns(current) && !this.owns(canonical)) { throw new WorkspaceSafetyError( `Created directory escapes worktree ownership: ${targetPath}`, ); } } } } async function pathDigest(root: string, relativePath: string) { const target = path.resolve(root, relativePath); try { const details = await lstat(target); if (details.isSymbolicLink()) { return `symlink:${await readlink(target)}`; } if (details.isDirectory()) return "directory"; const content = await readFile(target); return `file:${createHash("sha256").update(content).digest("hex")}`; } catch (error) { if ((error as NodeJS.ErrnoException).code === "ENOENT") return "missing"; throw error; } } function changedPathStatuses(status: string) { const entries = status.split("\0"); const statuses: Record = {}; for (let index = 0; index < entries.length; index++) { const entry = entries[index]; if (!entry) continue; const code = entry.slice(0, 2); const file = entry.slice(3); if (file) statuses[file] = code; if (code.includes("R") || code.includes("C")) { const renamed = entries[++index]; if (renamed) statuses[renamed] = code; } } return statuses; } export class GitStateProbe { private readonly runner: CommandRunner; constructor(runner: CommandRunner = new ExecFileCommandRunner()) { this.runner = runner; } async captureRepository(worktreeRoot: string) { const root = await realpath(path.resolve(worktreeRoot)); const listing = await this.git(root, ["worktree", "list", "--porcelain"]); const roots = listing .split("\n") .filter((line) => line.startsWith("worktree ")) .map((line) => line.slice("worktree ".length).trim()); const snapshots = new Map(); for (const candidate of roots) { try { const snapshot = await this.capture(candidate); snapshots.set(snapshot.root, snapshot); } catch (error) { if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; } } return snapshots; } async capture(worktreeRoot: string): Promise { const root = await realpath(path.resolve(worktreeRoot)); const [branch, head, common, gitDirectory, worktreeList, status] = await Promise.all([ this.git(root, ["branch", "--show-current"]), this.git(root, ["rev-parse", "HEAD"]), this.git(root, ["rev-parse", "--git-common-dir"]), this.git(root, ["rev-parse", "--git-dir"]), this.git(root, ["worktree", "list", "--porcelain"]), this.git(root, ["status", "--porcelain=v1", "-z", "--untracked-files=all"]), ]); const commonDirectory = path.resolve(root, common.trim()); const resolvedGitDirectory = path.resolve(root, gitDirectory.trim()); const metadataHash = createHash("sha256"); for (const metadataPath of [ path.join(commonDirectory, "config"), path.join(resolvedGitDirectory, "gitdir"), path.join(resolvedGitDirectory, "commondir"), path.join(resolvedGitDirectory, "HEAD"), path.join(resolvedGitDirectory, "locked"), path.join(resolvedGitDirectory, "config.worktree"), ]) { metadataHash.update(metadataPath); try { metadataHash.update(await readFile(metadataPath)); } catch (error) { if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; metadataHash.update("missing"); } } const pathStatuses = changedPathStatuses(status); const paths = Object.keys(pathStatuses).sort(); const pathDigests = Object.fromEntries( await Promise.all( paths.map(async (changed) => [changed, await pathDigest(root, changed)] as const), ), ); return { root, branch: branch.trim(), head: head.trim(), commonDirectory, gitDirectory: resolvedGitDirectory, worktreeListDigest: createHash("sha256") .update(worktreeList) .digest("hex"), worktreeMetadataDigest: metadataHash.digest("hex"), status, changedPaths: paths, pathStatuses, pathDigests, }; } changedSince(before: GitStateSnapshot, after: GitStateSnapshot) { const paths = [...new Set([...before.changedPaths, ...after.changedPaths])]; return paths.filter( (changed) => before.pathStatuses[changed] !== after.pathStatuses[changed] || before.pathDigests[changed] !== after.pathDigests[changed], ); } assertIdentity(before: GitStateSnapshot, after: GitStateSnapshot) { if ( before.root !== after.root || before.branch !== after.branch || before.head !== after.head || before.commonDirectory !== after.commonDirectory || before.gitDirectory !== after.gitDirectory || before.worktreeListDigest !== after.worktreeListDigest || before.worktreeMetadataDigest !== after.worktreeMetadataDigest ) { throw new WorkspaceSafetyError( "Git branch, HEAD, or worktree identity changed during mutation", ); } } private async git(cwd: string, args: string[]) { const result = await this.runner.run(cwd, "git", args, { timeoutMs: INTERNAL_GIT_TIMEOUT_MS, }); if (result.code !== 0) { throw new Error(`Git state probe failed: ${result.stderr.trim()}`); } return result.stdout; } } const SHELL_EXECUTABLES = new Set([ "bash", "cmd", "cmd.exe", "fish", "powershell", "pwsh", "sh", "zsh", ]); const COMMAND_WRAPPERS = new Set(["env", "find", "npx", "xargs"]); const ENVIRONMENT_ASSIGNMENT = /^[A-Za-z_][A-Za-z0-9_]*=/; const INLINE_EVALUATION_FLAGS = new Map>([ ["node", new Set(["-e", "--eval", "-p", "--print"])], ["python", new Set(["-c"])], ["python3", new Set(["-c"])], ["ruby", new Set(["-e"])], ["perl", new Set(["-e", "-E"])], ]); const PACKAGE_EXEC_SUBCOMMANDS = new Map>([ ["npm", new Set(["exec", "x"])], ["pnpm", new Set(["dlx", "exec"])], ["yarn", new Set(["dlx", "exec"])], ["bun", new Set(["x"])], ]); export const INTERNAL_GIT_TIMEOUT_MS = 30_000; const DEFAULT_HOST_COMMAND_TIMEOUT_MS = 3 * 60 * 1_000; export class ApprovedHostCommandExecutor { private readonly runner: CommandRunner; private readonly probe: GitStateProbe; private readonly timeoutMs: number; constructor( runner: CommandRunner = new ExecFileCommandRunner(), probe: GitStateProbe = new GitStateProbe(runner), timeoutMs = DEFAULT_HOST_COMMAND_TIMEOUT_MS, ) { this.runner = runner; this.probe = probe; this.timeoutMs = timeoutMs; } async run(options: { worktreeRoot: string; ownedPaths: string[]; executable: string; args: string[]; }) { if (!options.executable || options.args.some((argument) => argument.includes("\0"))) { throw new Error("Host commands require a fixed executable and argument array"); } let executable = options.executable; let args = [...options.args]; const commandEnv: NodeJS.ProcessEnv = { ...process.env, PYTHONDONTWRITEBYTECODE: "1", }; if ( path.basename(executable).toLowerCase() === "env" && args[0] === "PI_OFFLINE=1" && args[1] && !ENVIRONMENT_ASSIGNMENT.test(args[1]) ) { commandEnv.PI_OFFLINE = "1"; executable = args[1]; args = args.slice(2); } const executableName = path.basename(executable).toLowerCase(); if ( SHELL_EXECUTABLES.has(executableName) || COMMAND_WRAPPERS.has(executableName) ) { throw new Error("Host commands cannot invoke a command wrapper or shell"); } const evaluationFlags = INLINE_EVALUATION_FLAGS.get(executableName); if (evaluationFlags && args.some((argument) => evaluationFlags.has(argument))) { throw new Error("Host commands cannot execute inline interpreter code"); } const packageExecCommands = PACKAGE_EXEC_SUBCOMMANDS.get(executableName); if (packageExecCommands?.has(args[0] ?? "")) { throw new Error("Host commands cannot invoke package-manager executable wrappers"); } if (executableName === "git") { throw new Error( "Git host commands are not allowed; workspace Git operations are managed internally", ); } const root = await realpath(path.resolve(options.worktreeRoot)); const scope = options.ownedPaths.length > 0 ? await MutationScope.create(root, options.ownedPaths) : undefined; const beforeRepository = await this.probe.captureRepository(root); const before = beforeRepository.get(root) ?? (await this.probe.capture(root)); const result = await this.runner.run(root, executable, args, { env: commandEnv, timeoutMs: this.timeoutMs, }); try { const afterRepository = await this.probe.captureRepository(root); const after = afterRepository.get(root) ?? (await this.probe.capture(root)); this.probe.assertIdentity(before, after); for (const [repositoryRoot, repositoryBefore] of beforeRepository) { if (repositoryRoot === root) continue; const repositoryAfter = afterRepository.get(repositoryRoot); if (!repositoryAfter) { throw new WorkspaceSafetyError( `Host command removed repository worktree ${repositoryRoot}`, ); } this.probe.assertIdentity(repositoryBefore, repositoryAfter); const touched = this.probe.changedSince(repositoryBefore, repositoryAfter); if (touched.length > 0) { throw new WorkspaceSafetyError( `Host command changed another repository worktree: ${repositoryRoot}`, ); } } const touchedPaths = this.probe.changedSince(before, after); const outOfScope = scope ? touchedPaths.filter((changed) => !scope.owns(changed)) : touchedPaths; if (outOfScope.length > 0) { throw new WorkspaceSafetyError( `Host command changed paths outside ownership: ${outOfScope.join(", ")}`, ); } return { result, before, after }; } catch (error) { if (error instanceof WorkspaceSafetyError) throw error; throw new WorkspaceSafetyError( `Unable to verify Git safety after host command: ${error instanceof Error ? error.message : String(error)}`, ); } } } export class FilesystemPrototypeValidator { async validateCandidate(options: { worktreeRoot: string; assignment: { ownedPaths: string[] }; output: { path: string; route: string; browserEvidence: string[] }; }) { const scope = await MutationScope.create( options.worktreeRoot, options.assignment.ownedPaths, ); const candidatePath = await scope.assertWritePath(options.output.path); const candidate = await lstat(candidatePath); if (candidate.isSymbolicLink() || (!candidate.isDirectory() && !candidate.isFile())) { throw new WorkspaceSafetyError("Prototype candidate path is not a real file or directory"); } const widths: number[] = []; const artifactFiles: string[] = []; const screenshotDigests: string[] = []; for (const evidencePath of options.output.browserEvidence) { const absoluteEvidence = await scope.assertWritePath(evidencePath); const evidence = await lstat(absoluteEvidence); if ( evidence.isSymbolicLink() || !evidence.isFile() || evidence.size === 0 || evidence.size > 64 * 1024 ) { throw new WorkspaceSafetyError( `Prototype browser evidence is missing or invalid: ${evidencePath}`, ); } let report: unknown; try { report = JSON.parse(await readFile(absoluteEvidence, "utf8")); } catch { throw new WorkspaceSafetyError( `Prototype browser evidence must be a JSON validation report: ${evidencePath}`, ); } if ( !isRecord(report) || report.route !== options.output.route || report.result !== "passed" || !isRecord(report.viewport) || !Number.isSafeInteger(report.viewport.width) || !Number.isSafeInteger(report.viewport.height) || typeof report.screenshotPath !== "string" ) { throw new WorkspaceSafetyError( `Prototype browser evidence report is invalid: ${evidencePath}`, ); } const screenshotPath = await scope.assertWritePath(report.screenshotPath); const screenshot = await lstat(screenshotPath); if ( screenshot.isSymbolicLink() || !screenshot.isFile() || screenshot.size === 0 ) { throw new WorkspaceSafetyError( `Prototype browser screenshot is invalid: ${report.screenshotPath}`, ); } const screenshotContents = await readFile(screenshotPath); let visual: ReturnType; try { visual = parsePngVisual(screenshotContents); } catch (error) { throw new WorkspaceSafetyError( `Prototype browser screenshot is not a decodable PNG: ${report.screenshotPath} (${error instanceof Error ? error.message : String(error)})`, ); } if ( visual.width !== report.viewport.width || visual.height !== report.viewport.height ) { throw new WorkspaceSafetyError( `Prototype screenshot dimensions do not match its viewport: ${report.screenshotPath}`, ); } artifactFiles.push(evidencePath, report.screenshotPath); screenshotDigests.push(visual.digest); widths.push(report.viewport.width); } if (!widths.some((width) => width <= 480) || !widths.some((width) => width >= 1024)) { throw new WorkspaceSafetyError( "Prototype browser evidence must include passed mobile and desktop viewports", ); } return { artifactFiles: [...new Set(artifactFiles)], visualDigest: createHash("sha256") .update(screenshotDigests.sort().join("\n")) .digest("hex"), }; } async validateCleanup(options: { worktreeRoot: string; candidates: Array<{ path: string }>; }) { const root = await realpath(path.resolve(options.worktreeRoot)); for (const candidate of options.candidates) { const target = path.resolve(root, candidate.path); if (!isWithin(root, target)) { throw new WorkspaceSafetyError("Prototype cleanup candidate escapes worktree"); } try { await lstat(target); throw new WorkspaceSafetyError( `Prototype-only path remains after cleanup: ${candidate.path}`, ); } catch (error) { if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; } } } } export class GitDiffProvider { private readonly runner: CommandRunner; constructor(runner: CommandRunner = new ExecFileCommandRunner()) { this.runner = runner; } async read(worktreeRoot: string, baseHead = "HEAD") { const root = await realpath(path.resolve(worktreeRoot)); const diff = await this.runner.run( root, "git", ["diff", "--binary", "--no-ext-diff", baseHead, "--"], { timeoutMs: INTERNAL_GIT_TIMEOUT_MS }, ); if (diff.code !== 0) { throw new Error(`Unable to read review diff: ${diff.stderr.trim()}`); } const untracked = await this.runner.run( root, "git", ["ls-files", "--others", "--exclude-standard", "-z"], { timeoutMs: INTERNAL_GIT_TIMEOUT_MS }, ); if (untracked.code !== 0) { throw new Error(`Unable to list untracked review files: ${untracked.stderr.trim()}`); } const additions: string[] = []; for (const relative of untracked.stdout.split("\0").filter(Boolean)) { const target = path.resolve(root, relative); if (!isWithin(root, target)) { throw new WorkspaceSafetyError(`Untracked path escapes worktree: ${relative}`); } const details = await lstat(target); if (details.isSymbolicLink()) { additions.push(`## Untracked symlink ${relative}\n${await readlink(target)}`); } else if (details.isFile()) { additions.push( `## Untracked file ${relative}\n${(await readFile(target)).toString("utf8")}`, ); } } const completeDiff = `${diff.stdout}\n${additions.join("\n\n")}`; if (Buffer.byteLength(completeDiff, "utf8") > 4 * 1024 * 1024) { throw new WorkspaceSafetyError( "Review diff exceeds the 4 MiB audited limit and cannot be reviewed completely", ); } return completeDiff; } } export interface WorkspacePlan { sourceRoot: string; worktreeRoot: string; worktreePath: string; branch: string; baseHead: string; branchExists: boolean; sourceChanges: SourceChangeEvidence[]; sourceChangesOmitted: number; dirtySourceAllowed: boolean; } export interface PrepareWorkspaceOptions { sourceRoot: string; configuredRoot: string; featureName: string; runId: string; allowDirtySource?: boolean; } export class WorkspaceValidationError extends Error { readonly code: | "not-repository" | "dirty-source" | "unmerged-source" | "unignored-root" | "existing-worktree-path" | "git-failed"; readonly sourceChanges: SourceChangeEvidence[]; readonly sourceChangesOmitted: number; constructor( code: WorkspaceValidationError["code"], message: string, evidence: { sourceChanges?: SourceChangeEvidence[]; sourceChangesOmitted?: number; } = {}, ) { super(message); this.name = "WorkspaceValidationError"; this.code = code; this.sourceChanges = evidence.sourceChanges ?? []; this.sourceChangesOmitted = evidence.sourceChangesOmitted ?? 0; } } const MAX_SOURCE_CHANGE_EVIDENCE = 20; function sourceChangeEvidence(status: string) { const entries = Object.entries(changedPathStatuses(status)).sort(([left], [right]) => left.localeCompare(right), ); const sourceChanges = entries .slice(0, MAX_SOURCE_CHANGE_EVIDENCE) .map(([changedPath, changedStatus]) => ({ path: changedPath.replace(/[\u0000-\u001f\u007f-\u009f]/g, "?"), status: changedStatus, })); return { sourceChanges, sourceChangesOmitted: Math.max(0, entries.length - sourceChanges.length), hasUnmerged: entries.some(([, changedStatus]) => ["DD", "AU", "UD", "UA", "DU", "AA", "UU"].includes(changedStatus), ), }; } const PROCESS_TERMINATION_GRACE_MS = 100; const PROCESS_TERMINATION_POLL_MS = 10; const PROCESS_TERMINATION_TIMEOUT_MS = 1_000; function wait(milliseconds: number) { return new Promise((resolve) => setTimeout(resolve, milliseconds)); } function processGroupExists(processId: number) { try { process.kill(-processId, 0); return true; } catch (error) { return (error as NodeJS.ErrnoException).code !== "ESRCH"; } } function signalProcessGroup(processId: number, signal: NodeJS.Signals) { try { process.kill(-processId, signal); } catch (error) { if ((error as NodeJS.ErrnoException).code !== "ESRCH") throw error; } } async function forceKillAndReap(child: ChildProcess) { if (child.exitCode !== null || child.signalCode !== null) return; if (process.platform !== "win32" && child.pid !== undefined) { signalProcessGroup(child.pid, "SIGKILL"); } child.kill("SIGKILL"); if (child.exitCode !== null || child.signalCode !== null) return; await new Promise((resolve) => { const timer = setTimeout(resolve, PROCESS_TERMINATION_GRACE_MS); child.once("close", () => { clearTimeout(timer); resolve(); }); }); } async function terminateProcessTree(processId: number) { if (process.platform === "win32") { await execFileAsync( "taskkill", ["/pid", String(processId), "/T", "/F"], { timeout: PROCESS_TERMINATION_TIMEOUT_MS }, ); return; } signalProcessGroup(processId, "SIGTERM"); await wait(PROCESS_TERMINATION_GRACE_MS); signalProcessGroup(processId, "SIGKILL"); const deadline = Date.now() + PROCESS_TERMINATION_TIMEOUT_MS; while (processGroupExists(processId)) { if (Date.now() >= deadline) { throw new Error(`process group ${processId} remained alive after SIGKILL`); } await wait(PROCESS_TERMINATION_POLL_MS); } } const COMMAND_OUTPUT_LIMIT_BYTES = 4 * 1024 * 1024; export class ExecFileCommandRunner implements CommandRunner { private readonly terminate: (processId: number) => Promise; constructor(options: { terminateProcessTree?: (processId: number) => Promise; } = {}) { this.terminate = options.terminateProcessTree ?? terminateProcessTree; } async run( cwd: string, executable: string, args: string[], options: CommandRunOptions = {}, ) { return new Promise((resolve) => { const stdoutChunks: Buffer[] = []; const stderrChunks: Buffer[] = []; let stdoutBytes = 0; let stderrBytes = 0; let spawnError: Error | undefined; let timedOut = false; let outputExceeded = false; let termination: Promise | undefined; let terminationFinished = false; let terminationFailure: string | undefined; let timeout: NodeJS.Timeout | undefined; let terminationDeadline: NodeJS.Timeout | undefined; let terminationDeadlineReached = false; let settled = false; const child = spawn(executable, args, { cwd, detached: process.platform !== "win32", env: options.env, stdio: ["ignore", "pipe", "pipe"], }); const terminate = () => { if (!termination && child.pid !== undefined) { termination = this.terminate(child.pid); void termination.then( () => { terminationFinished = true; }, (failure: unknown) => { terminationFinished = true; terminationFailure = failure instanceof Error ? failure.message : String(failure); }, ); terminationDeadline = setTimeout(() => { if (settled) return; terminationDeadlineReached = true; const terminationError = terminationFailure ?? (terminationFinished ? "process did not exit after process tree termination" : "process tree termination did not finish within its deadline"); void forceKillAndReap(child).then( () => { if (settled) return; settled = true; resolve({ code: 125, stdout: Buffer.concat(stdoutChunks).toString("utf8"), stderr: `Host command termination failed: ${terminationError}`, }); }, (failure: unknown) => { if (settled) return; settled = true; const detail = failure instanceof Error ? failure.message : String(failure); resolve({ code: 125, stdout: Buffer.concat(stdoutChunks).toString("utf8"), stderr: `Host command termination failed: ${terminationError}; final force-kill failed: ${detail}`, }); }, ); }, PROCESS_TERMINATION_TIMEOUT_MS + PROCESS_TERMINATION_GRACE_MS + 100); } }; const capture = (chunks: Buffer[], chunk: Buffer, stream: "stdout" | "stderr") => { const capturedBytes = stream === "stdout" ? stdoutBytes : stderrBytes; const remaining = COMMAND_OUTPUT_LIMIT_BYTES - capturedBytes; if (remaining > 0) chunks.push(chunk.subarray(0, remaining)); if (stream === "stdout") stdoutBytes += chunk.length; else stderrBytes += chunk.length; if (chunk.length > remaining && !outputExceeded) { outputExceeded = true; terminate(); } }; child.stdout.on("data", (chunk: Buffer) => capture(stdoutChunks, chunk, "stdout")); child.stderr.on("data", (chunk: Buffer) => capture(stderrChunks, chunk, "stderr")); child.on("error", (error) => { spawnError = error; }); child.on("close", async (code) => { if (settled) return; if (timeout !== undefined) clearTimeout(timeout); let terminationError: string | undefined; try { await termination; } catch (failure) { terminationError = failure instanceof Error ? failure.message : String(failure); } if (settled || terminationDeadlineReached) return; settled = true; if (terminationDeadline !== undefined) clearTimeout(terminationDeadline); const stdout = Buffer.concat(stdoutChunks).toString("utf8"); const stderr = Buffer.concat(stderrChunks).toString("utf8"); if (timedOut) { const detail = [stderr, terminationError].filter(Boolean).join("\n"); resolve({ code: terminationError ? 125 : 124, stdout, stderr: `Host command timed out after ${options.timeoutMs}ms${detail ? `\n${detail}` : ""}`, }); return; } if (outputExceeded) { resolve({ code: terminationError ? 125 : 1, stdout, stderr: `Host command output exceeded ${COMMAND_OUTPUT_LIMIT_BYTES} bytes${terminationError ? `\n${terminationError}` : ""}`, }); return; } resolve({ code: code ?? 1, stdout, stderr: stderr || spawnError?.message || "", }); }); if (options.timeoutMs !== undefined) { timeout = setTimeout(() => { timedOut = true; terminate(); }, options.timeoutMs); } }); } } export class GitWorkspaceManager { private readonly runner: CommandRunner; constructor(runner: CommandRunner = new ExecFileCommandRunner()) { this.runner = runner; } async prepare(options: PrepareWorkspaceOptions): Promise { const requestedRoot = path.resolve(options.sourceRoot); const repository = await this.git(requestedRoot, [ "rev-parse", "--show-toplevel", ]); if (repository.code !== 0) { throw new WorkspaceValidationError( "not-repository", `Unable to resolve Git repository: ${repository.stderr.trim()}`, ); } const sourceRoot = await realpath(path.resolve(repository.stdout.trim())); if (sourceRoot !== (await realpath(requestedRoot))) { throw new WorkspaceValidationError( "not-repository", `Workflow source must be the repository root ${sourceRoot}`, ); } const status = await this.git(sourceRoot, [ "status", "--porcelain=v1", "-z", "--untracked-files=all", ]); if (status.code !== 0) { throw new WorkspaceValidationError( "git-failed", `Unable to inspect source state: ${status.stderr.trim()}`, ); } const evidence = sourceChangeEvidence(status.stdout); if (evidence.hasUnmerged) { throw new WorkspaceValidationError( "unmerged-source", "Source checkout has unmerged paths; resolve conflicts before creating a workflow worktree", evidence, ); } if (evidence.sourceChanges.length > 0 && !options.allowDirtySource) { throw new WorkspaceValidationError( "dirty-source", "Source checkout has changes that are not included in the workflow worktree created from HEAD", evidence, ); } const worktreeRoot = await this.containedWorktreeRoot( sourceRoot, options.configuredRoot, ); const relativeRoot = path .relative(sourceRoot, worktreeRoot) .replaceAll("\\", "/"); const ignored = await this.git(sourceRoot, [ "check-ignore", "--quiet", "--no-index", "--", `${relativeRoot}/.workflow-ignore-check`, ]); if (ignored.code !== 0) { throw new WorkspaceValidationError( "unignored-root", `Configured worktree root ${relativeRoot} must already be ignored by Git`, ); } const worktreePath = path.join( worktreeRoot, `workflow-${options.featureName}-${options.runId}`, ); try { await lstat(worktreePath); throw new WorkspaceValidationError( "existing-worktree-path", `Worktree path already exists: ${worktreePath}`, ); } catch (error) { if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; } const branch = `feature/${options.featureName}`; const branchCheck = await this.git(sourceRoot, [ "show-ref", "--verify", "--quiet", `refs/heads/${branch}`, ]); if (branchCheck.code !== 0 && branchCheck.code !== 1) { throw new WorkspaceValidationError( "git-failed", `Unable to inspect branch ${branch}: ${branchCheck.stderr.trim()}`, ); } const head = await this.git(sourceRoot, ["rev-parse", "HEAD"]); if (head.code !== 0) { throw new WorkspaceValidationError( "git-failed", `Unable to resolve HEAD: ${head.stderr.trim()}`, ); } return { sourceRoot, worktreeRoot, worktreePath, branch, baseHead: head.stdout.trim(), branchExists: branchCheck.code === 0, sourceChanges: evidence.sourceChanges, sourceChangesOmitted: evidence.sourceChangesOmitted, dirtySourceAllowed: evidence.sourceChanges.length > 0 && options.allowDirtySource === true, }; } async create(plan: WorkspacePlan): Promise { const branch = await this.git(plan.sourceRoot, [ "show-ref", "--verify", "--quiet", `refs/heads/${plan.branch}`, ]); if (branch.code !== 0 && branch.code !== 1) { return { state: "creating", sourceRoot: plan.sourceRoot, root: plan.worktreePath, branch: plan.branch, baseHead: plan.baseHead, sourceChanges: structuredClone(plan.sourceChanges), sourceChangesOmitted: plan.sourceChangesOmitted, dirtySourceAllowed: plan.dirtySourceAllowed, error: `Unable to inspect partial branch creation: ${branch.stderr.trim()}`, }; } let addArguments = [ "worktree", "add", "-b", plan.branch, plan.worktreePath, plan.baseHead, ]; if (branch.code === 0) { const branchHead = await this.git(plan.sourceRoot, [ "rev-parse", `refs/heads/${plan.branch}`, ]); if (branchHead.code !== 0 || branchHead.stdout.trim() !== plan.baseHead) { return { state: "failed", sourceRoot: plan.sourceRoot, root: plan.worktreePath, branch: plan.branch, baseHead: plan.baseHead, sourceChanges: structuredClone(plan.sourceChanges), sourceChangesOmitted: plan.sourceChangesOmitted, dirtySourceAllowed: plan.dirtySourceAllowed, error: `Existing partial branch ${plan.branch} does not match persisted base HEAD ${plan.baseHead}`, }; } addArguments = ["worktree", "add", plan.worktreePath, plan.branch]; } const result = await this.git(plan.sourceRoot, addArguments); if (result.code !== 0) { return this.reconcile({ state: "creating", sourceRoot: plan.sourceRoot, root: plan.worktreePath, branch: plan.branch, baseHead: plan.baseHead, sourceChanges: structuredClone(plan.sourceChanges), sourceChangesOmitted: plan.sourceChangesOmitted, dirtySourceAllowed: plan.dirtySourceAllowed, error: `Git worktree creation returned ${result.code}: ${result.stderr.trim()}`, }); } return { state: "ready", sourceRoot: plan.sourceRoot, root: plan.worktreePath, branch: plan.branch, baseHead: plan.baseHead, sourceChanges: structuredClone(plan.sourceChanges), sourceChangesOmitted: plan.sourceChangesOmitted, dirtySourceAllowed: plan.dirtySourceAllowed, }; } async reconcile( workspace: WorkflowWorkspace, expectedWorktreeIdentity = workspace.root, ): Promise { if ( workspace.state !== "creating" || !workspace.root || !workspace.branch ) { return workspace; } const listed = await this.git(workspace.sourceRoot, [ "worktree", "list", "--porcelain", ]); if (listed.code !== 0) { return { ...workspace, state: "failed", error: `Unable to reconcile worktree: ${listed.stderr.trim()}`, }; } const expectedRoot = path.resolve(workspace.root); if ( !expectedWorktreeIdentity || path.resolve(expectedWorktreeIdentity) !== expectedRoot ) { return { ...workspace, state: "failed", error: "Persisted worktree lease identity does not match its workspace", }; } if (!workspace.baseHead) { return { ...workspace, state: "failed", error: "Persisted worktree base HEAD is missing", }; } const blocks = listed.stdout .split(/\n\n+/) .map((block) => block.split("\n")); const expectedBranch = `branch refs/heads/${workspace.branch}`; const matchingPath = blocks.find((lines) => lines.some( (line) => line.startsWith("worktree ") && path.resolve(line.slice("worktree ".length)) === expectedRoot, ), ); const matchingBranch = blocks.find((lines) => lines.includes(expectedBranch), ); if (!matchingPath && !matchingBranch) { return { ...workspace, state: "creating" }; } const expectedHead = `HEAD ${workspace.baseHead}`; if ( matchingPath === matchingBranch && matchingPath?.includes(expectedHead) ) { return { ...workspace, state: "ready", error: undefined }; } return { ...workspace, state: "failed", error: "Interrupted worktree creation conflicts with the persisted path, branch, or base HEAD", }; } private async containedWorktreeRoot( sourceRoot: string, configuredRoot: string, ) { const requested = path.resolve(sourceRoot, configuredRoot); const relative = path.relative(sourceRoot, requested); if (!relative || relative.startsWith("..") || path.isAbsolute(relative)) { throw new WorkspaceValidationError( "unignored-root", "Configured worktree root must be a directory inside the repository", ); } let current = sourceRoot; for (const segment of relative.split(path.sep)) { current = path.join(current, segment); let info; try { info = await lstat(current); } catch (error) { if ((error as NodeJS.ErrnoException).code === "ENOENT") break; throw error; } if (info.isSymbolicLink()) { throw new WorkspaceValidationError( "unignored-root", `Configured worktree root cannot traverse symlink ${current}`, ); } } const canonicalParent = await realpath(path.dirname(current)); const canonicalRelative = path.relative(sourceRoot, canonicalParent); if ( canonicalRelative.startsWith("..") || path.isAbsolute(canonicalRelative) ) { throw new WorkspaceValidationError( "unignored-root", "Configured worktree root resolves outside the repository", ); } return requested; } private git(cwd: string, args: string[]) { return this.runner.run(cwd, "git", args, { timeoutMs: INTERNAL_GIT_TIMEOUT_MS, }); } }