import { existsSync, lstatSync, mkdirSync, mkdtempSync, realpathSync, renameSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { errMessage } from "agent-relay-sdk"; import type { LandGate } from "agent-relay-sdk"; import type { WorkspaceLandGateLevel } from "agent-relay-sdk"; import { cleanLandGatesConfig, landGatesTimeoutBudgetMs, loadRepoLandGates } from "agent-relay-sdk/land-gates"; import { git } from "../git"; import { execProcess } from "../process"; import { nodeModulesDirs } from "./deps"; import { runLandGates, type LandGatesResult } from "./land-gates-runner"; import { mergePhaseTimeoutMs, withMergePhaseTimeout } from "./merge-timeouts"; const LAND_COMMITTER = { name: "Agent Relay", email: "agent-relay@noreply" } as const; const LAND_GATES_FILE = ".agent-relay/land-gates.json"; const GATE_REJECT_PREFIX = "land rejected (#1145):"; // Synthesize, but do not advance any ref to, the no-ff merge commit of branchSha // into baseSha. The resulting commit/tree is the exact integrated tree that will // land, so callers can gate it before mutating base. export async function synthesizeNoFfMerge( repoRoot: string, baseSha: string, branchSha: string, message: string, timeoutMs?: number, signal?: AbortSignal, ): Promise<{ ok: true; mergeSha: string } | { ok: false; conflict?: boolean; error: string }> { const tree = await git(["merge-tree", "--write-tree", baseSha, branchSha], repoRoot, { timeoutMs, timeoutLabel: "workspace merge synthesize merge-tree", signal }); if (!tree.ok) return { ok: false, conflict: true, error: tree.stdout || tree.stderr || "merge conflict computing tree" }; const treeOid = tree.stdout.split("\n")[0]?.trim(); if (!treeOid) return { ok: false, error: "merge-tree produced no tree oid" }; const commit = await git( ["-c", "user.name=" + LAND_COMMITTER.name, "-c", "user.email=" + LAND_COMMITTER.email, "commit-tree", treeOid, "-p", baseSha, "-p", branchSha, "-m", message], repoRoot, { timeoutMs, timeoutLabel: "workspace merge synthesize commit-tree", signal }, ); if (!commit.ok || !commit.stdout) return { ok: false, error: commit.stderr || "commit-tree failed" }; return { ok: true, mergeSha: commit.stdout }; } /** * The stderr `git show :` emits when the path is genuinely not in that commit's tree. * Deliberately a CLOSED set matched against a positive exit code: git reports a fatal as 128, * while `execProcess` reports a timeout, an abort, or an output-limit kill as `exitCode: null` * (see its `reportedExitCode`) — so "we never got an answer" can never satisfy this predicate, * whatever ends up in `stderr`. Any other 128 (`fatal: bad object …` from a corrupt/missing blob, * `fatal: invalid object name …`) is likewise not absence: it is a repo we cannot read. * * Exported for the unit test that pins exactly this — the three-valued read is the whole point. */ export function landGatesConfigAbsent(result: { exitCode: number | null; stderr: string }): boolean { return result.exitCode === 128 && /\bpath '[^']*' (?:does not exist in|exists on disk, but not in)\b/.test(result.stderr); } /** * The gate list the base commit declares — or a throw. There is deliberately no third outcome. * * #1636 finding 1 — a failure to DETERMINE the gate list is not an empty gate list. `git()` never * throws, so the old `if (!config.ok || !config.stdout) return []` collapsed five different * situations into "this base declares no gates": a genuinely absent config, a `git show` timeout, * an aborted merge, an unreadable object database, and a `baseSha` that is not in this repo at * all. An empty list then flows into `runLandGates`, which returns `{ ran: 0 }` with no failure, * and the land proceeds — ungated, and reported exactly like a full-gate land. That is the * pre-`3694d6b3` behaviour this file exists to prevent, reachable from any transient git hiccup. * * Absence is now PROVEN rather than inferred from a failure, in two steps that matter separately: * * 1. The base commit must resolve. This is not ceremony — a full-length hex sha that is NOT in * the object database makes `git show :` say `path '…' does not exist in ''`, * the SAME sentence it uses for a real absence. Without this step the message check below * would read "we do not have that commit" as "that commit declares no gates", which is the * original defect with extra steps. * 2. Only then may git's own "not in this tree" verdict be believed (`landGatesConfigAbsent`). * * Everything else throws, and both callers already turn a throw into an `{ abort }` that blocks * the land. A present-but-empty file throws too: `runLandGates` already treats a PRESENT-but- * malformed config as a blocking failure, and zero bytes is malformed, not "no gates". */ async function resolveRequiredLandGates(repoRoot: string, baseSha: string, signal?: AbortSignal): Promise { const timeoutMs = mergePhaseTimeoutMs("gates"); const baseCommit = await git(["rev-parse", "--verify", "--quiet", `${baseSha}^{commit}`], repoRoot, { timeoutMs, timeoutLabel: "workspace merge resolve land-gate base commit", signal }); if (!baseCommit.ok || !baseCommit.stdout) { // `--quiet` suppresses git's own message on a simple miss, so say what we asked and failed at. throw new Error(`could not resolve land-gate base commit ${baseSha}: ${baseCommit.stderr || (baseCommit.timedOut ? "git rev-parse timed out" : `git rev-parse exited ${baseCommit.exitCode}`)}`); } const config = await git(["show", `${baseSha}:${LAND_GATES_FILE}`], repoRoot, { timeoutMs, timeoutLabel: "workspace merge inspect base land-gates config", signal }); if (!config.ok) { if (landGatesConfigAbsent(config)) return []; throw new Error(`could not read ${LAND_GATES_FILE} at ${baseSha}: ${config.stderr || (config.timedOut ? "git show timed out" : `git show exited ${config.exitCode}`)}`); } if (!config.stdout.trim()) throw new Error(`invalid ${LAND_GATES_FILE} at ${baseSha}: the file is empty`); try { return cleanLandGatesConfig(JSON.parse(config.stdout), repoRoot); } catch (err) { throw new Error(`invalid ${LAND_GATES_FILE} at ${baseSha}: ${errMessage(err)}`); } } function requiredGatesForLevel(requiredGates: LandGate[], gateLevel: WorkspaceLandGateLevel): LandGate[] { if (gateLevel === "full") return requiredGates; // subset = candidate-tree repo gates only. The base-required list is omitted; // runLandGates still reads the candidate checkout config below. return []; } // Split raw bytes on NUL (0x00) into records. `-z` always NUL-*terminates* every record (not // just separates), so a dangling tail after the last NUL is truncated/malformed output, not a // real record — dropped by construction (the loop only ever pushes up to the last NUL seen). function splitNul(bytes: Uint8Array): Uint8Array[] { const records: Uint8Array[] = []; let start = 0; for (let i = 0; i < bytes.length; i++) { if (bytes[i] === 0) { records.push(bytes.subarray(start, i)); start = i + 1; } } return records; } function concatBytes(chunks: Uint8Array[]): Uint8Array { const total = chunks.reduce((n, c) => n + c.length, 0); const out = new Uint8Array(total); let offset = 0; for (const c of chunks) { out.set(c, offset); offset += c.length; } return out; } // Hex is only ever used as an internal Set/Map KEY for byte-exact identity checks — never // decoded back or shown to a human (the `paths` a caller sees are a separate, deliberately // lossy UTF-8 rendering; see findGitignoredLandedSymlinks's return). function bytesKey(b: Uint8Array): string { return Buffer.from(b.buffer, b.byteOffset, b.byteLength).toString("hex"); } function absPathBytes(cwd: string, pathBytes: Uint8Array): Buffer { return Buffer.concat([Buffer.from(cwd, "utf8"), Buffer.from("/"), Buffer.from(pathBytes.buffer, pathBytes.byteOffset, pathBytes.byteLength)]); } /** * Parse `git diff --raw --no-renames -z ` and return the byte-exact paths of * candidates the landed tree tracks as a SYMLINK, newly added or type-changed into one. * * `--raw` (not `--name-status`) puts the new git MODE directly in each record's header * (`: \0\0`) — mode 120000 IS a symlink, * straight from the commit object git is about to land. This is the definitive source: unlike * inspecting the on-disk checkout (`lstatSync`), it can't be fooled by a `skip-worktree` path * whose materialized file no longer matches its tracked mode, and it needs no filesystem access * at all for this step. * * Read as raw bytes (`stdoutBytes`, bypassing `execProcess`'s default UTF-8 `TextDecoder`): a * filename containing a byte that isn't valid UTF-8 would otherwise decode to U+FFFD, and code * beyond the U+FFFD boundary — the mode/status header (always plain ASCII, decoded separately * and safely) is unaffected, but the PATH itself must stay exact bytes end-to-end or it can no * longer be correlated with the real on-disk/tree entry it names. */ async function diffRawSymlinkCandidates(cwd: string, baseSha: string, headSha: string, signal?: AbortSignal): Promise<{ ok: true; candidates: Uint8Array[] } | { ok: false; error: string }> { const result = await execProcess( ["git", "-C", cwd, "diff", "--raw", "--no-renames", "-z", baseSha, headSha], { timeoutMs: mergePhaseTimeoutMs("gates"), timeoutLabel: "workspace merge diff for gitignore reject-guard", signal, stdoutBytes: true, trimStdout: false, env: hermeticGitEnv() }, ); // Fail CLOSED: a hard data-loss invariant must not silently pass just because the check // itself errored or timed out. if (!result.ok) return { ok: false, error: result.stderr || result.stdout || `git diff --raw ${baseSha}..${headSha} failed` }; const records = splitNul(result.stdoutBytes ?? new Uint8Array(0)); const candidates: Uint8Array[] = []; for (let i = 0; i + 1 < records.length; i += 2) { const headerBytes = records[i]; const pathBytes = records[i + 1]; if (!headerBytes || !pathBytes) continue; const header = new TextDecoder().decode(headerBytes); // header fields are always plain ASCII const fields = header.split(" "); const newMode = fields[1]; const status = fields[4]?.[0]; if (!newMode || !status) continue; // A(dded) or T(ype-changed, e.g. an existing tracked file replaced by a symlink at the same // path). Excludes D(eleted) and — critically — M(odified): an ordinary edit to a path // that's ALREADY tracked in base (even if a later .gitignore change now covers it) is a // legitimate, common repo state and must stay landable. // // #1145 round-8 — this is a CONSCIOUS scope boundary, not an oversight: a base-existing // TRACKED symlink whose target changes (still mode 120000, so still "M" not "A"/"T") is // deliberately out of scope too. The guard's invariant is "a gitignored symlink must never // newly ENTER history" — a symlink already present in base history already passed whatever // scrutiny landed it there; re-validating every already-tracked symlink on every subsequent // edit is a different (and much broader) invariant this guard does not claim to enforce. if ((status === "A" || status === "T") && newMode === "120000") candidates.push(pathBytes); } return { ok: true, candidates }; } /** * Run one batched `check-ignore --no-index --stdin -z` over `queries` (already NUL-joined * bytes, one path per query — a query may carry a synthetic trailing `/` to probe directory * semantics; see {@link findGitignoredLandedSymlinks}). Everything flows as raw bytes: the * query payload goes in via stdin (sidestepping the "argv must round-trip through a JS string, * which forces UTF-8 en/decoding" problem an invalid-byte filename would hit as a positional * arg), and the matched-paths output is read back as bytes and compared by exact byte identity * (never decoded), so a non-UTF-8 filename can't silently fail to correlate with itself. * * `isolatedGitDir`/`core.excludesFile=/dev/null` isolate the evaluation to ONLY the landed * tree's own `.gitignore` files (see the isolated-git-dir setup in * {@link findGitignoredLandedSymlinks}) — `check-ignore --no-index` also consults * `$GIT_DIR/info/exclude` and `core.excludesFile`, which for a linked worktree's real git-dir * are shared, host-local state (a host's personal excludes could reject, or a differently * configured host could pass, the identical commit). * * #1145 round-8 — `-c core.ignoreCase=false` pins pattern matching to case-SENSITIVE regardless * of the evaluating host: `core.ignoreCase` is not just a config a hostile GLOBAL/SYSTEM config * could set (already neutralized by {@link hermeticGitEnv}'s env scrub) — `git init` on a * case-INSENSITIVE filesystem (e.g. default macOS) auto-detects this and writes * `core.ignoreCase=true` into the freshly-created isolated repo's OWN local config, with no * hostile actor involved at all. Left unpinned, the SAME commit's guard verdict would silently * differ depending on which host/filesystem evaluates it — the verdict must be a property of the * commit, not of the host. `hermeticGitEnv()` (env, not `-c`) covers the GLOBAL/SYSTEM/ * command-scope sources; this `-c` covers the auto-detected LOCAL default the env scrub can't * touch since it's written to the isolated repo's own (legitimately empty) config at `git init` * time, not inherited from environment. (`core.precomposeUnicode` — macOS NFD-normalizes * filenames at the filesystem layer, independent of any git config — was audited but is a * Node.js/OS-level concern this git-config-scrub cannot reach; flagged as a known residual risk * rather than silently claimed closed.) */ async function checkIgnoreBatch(cwd: string, isolatedGitDir: string, queries: Uint8Array[], signal?: AbortSignal): Promise<{ ok: true; matched: Set } | { ok: false; error: string }> { if (queries.length === 0) return { ok: true, matched: new Set() }; const NUL = new Uint8Array([0]); const stdin = concatBytes(queries.flatMap((q) => [q, NUL])); const result = await execProcess( ["git", "-C", cwd, "-c", "core.excludesFile=/dev/null", "-c", "core.ignoreCase=false", "--git-dir=" + isolatedGitDir, "--work-tree=" + cwd, "check-ignore", "--no-index", "--stdin", "-z"], { timeoutMs: mergePhaseTimeoutMs("gates"), timeoutLabel: "workspace merge check-ignore reject-guard", signal, stdin, stdoutBytes: true, trimStdout: false, env: hermeticGitEnv() }, ); // exit 0 = at least one query matched; exit 1 = none matched (both are a successful // evaluation). Anything else — fatal error, timeout — means we couldn't actually evaluate the // invariant; fail closed rather than silently treat it as "not ignored". if (!result.ok && result.exitCode !== 1) { return { ok: false, error: result.stderr || `check-ignore --stdin failed (exit ${result.exitCode ?? "timeout"})` }; } const matched = new Set(splitNul(result.stdoutBytes ?? new Uint8Array(0)).map(bytesKey)); return { ok: true, matched }; } // #1145 — an isolated worktree symlinks host-local gitignored files (CLAUDE.md, AGENTS.md, // .claude-rig, …) in for worker convenience. If one gets force-added and committed, landing it // both tracks a broken/self-referential symlink on base (bad for every fresh clone/worktree) AND // clobbers the host's real file on the next checkout-sync — silent data loss. This must never // enter history, regardless of how it got staged (explicit add, `-f`, or a broad add that names // it), so this is enforced at land time against the exact tree that would land, not at stage // time — and it must be checked from EVERY path that can advance base (rebase-ff, plain-git // direct push, and the PR path before it's even opened), not just the primary integrated-tree // gate. Exported for that reuse. // // Deliberately narrowed to paths the landed tree tracks as a SYMLINK — that's the concrete // data-loss mechanism (checkout silently replaces an untracked/self-referential symlink with the // tracked one, unlike a regular-file conflict which git refuses). A repo can legitimately // force-track an ordinary gitignored file (e.g. a generated artifact) or later add a pattern that // retroactively covers an already-tracked file (edited only, never a symlink) — neither is this // bug, so neither should block a land. // // `cwd` and `filesCwd` are deliberately separate parameters: the diff step needs OBJECT DATABASE // access (any repo containing `baseSha`/`headSha`, no working tree required — `git diff // ` never touches disk), while the check-ignore step needs the tree's `.gitignore` files // actually PRESENT ON DISK at `filesCwd` (`check-ignore --no-index` walks the directory // hierarchy). Every existing caller passes one directory that serves both roles (a real checkout // that's also a git repo) and gets that for free via the `filesCwd = cwd` default; round-5's // archive-based guard materialization (see {@link materializeTreeForGuard}) is the first caller // that legitimately needs them to differ — the extracted directory has the exact files but no // `.git` of its own, so the diff must run against the real repo instead. export async function findGitignoredLandedSymlinks( cwd: string, baseSha: string, headSha: string, signal?: AbortSignal, filesCwd: string = cwd, ): Promise<{ ok: true; paths: string[] } | { ok: false; error: string }> { const diff = await diffRawSymlinkCandidates(cwd, baseSha, headSha, signal); if (!diff.ok) return diff; const candidates = diff.candidates; if (candidates.length === 0) return { ok: true, paths: [] }; // Isolate the ignore-pattern evaluation from host-local state. `--template=` (empty) plus an // explicitly-written empty info/exclude closes the host GIT_TEMPLATE_DIR/init.templateDir // import path: an ordinary `git init` copies template files — including info/exclude — from // whatever template the HOST has configured, which would otherwise leak host-local excludes // into what's supposed to be a from-scratch, landed-tree-only evaluation. const isolatedRoot = mkdtempSync(join(tmpdir(), "agent-relay-landgate-ignore-")); try { const init = await git(["init", "-q", "--template="], isolatedRoot, { timeoutMs: mergePhaseTimeoutMs("gates"), timeoutLabel: "workspace merge reject-guard isolated git-dir init", signal, env: hermeticGitEnv() }); if (!init.ok) return { ok: false, error: init.stderr || "failed to initialize isolated git-dir for gitignore reject-guard" }; const isolatedGitDir = join(isolatedRoot, ".git"); mkdirSync(join(isolatedGitDir, "info"), { recursive: true }); writeFileSync(join(isolatedGitDir, "info", "exclude"), ""); // File semantics: query each candidate's path as-is. Correct for every pattern EXCEPT a // directory-only rule (`.claude-rig/`) — gitignore's directory-only matching requires git to // see the queried path as an actual directory, and a landed SYMLINK never is one, so a rule // written for the directory it replaces would otherwise never fire (BLOCKER: a `.claude-rig/` // host directory gets silently clobbered — including its contents — because the check only // ever evaluated file semantics). const fileCheck = await checkIgnoreBatch(filesCwd, isolatedGitDir, candidates, signal); if (!fileCheck.ok) return fileCheck; // Directory semantics: query each candidate with a synthetic trailing `/`. `check-ignore` // refuses a slash-terminated pathspec when something already exists at that exact path // ("beyond a symbolic link") — appending `/` past our own live candidate symlink always // fails that way — so the candidates are temporarily renamed aside (byte-exact path, // restored in `finally` even on error) for this probe only. With nothing there, git // correctly evaluates "would this path be ignored if it were a directory", independent of // what (if anything) is actually on disk. const aside = crypto.randomUUID(); const moved: Array<{ from: Buffer; to: Buffer }> = []; try { for (const pathBytes of candidates) { const from = absPathBytes(filesCwd, pathBytes); const to = Buffer.concat([from, Buffer.from(`.agent-relay-landgate-aside-${aside}`)]); try { renameSync(from, to); moved.push({ from, to }); } catch (err) { if ((err as NodeJS.ErrnoException).code !== "ENOENT") throw err; // Nothing at this path on disk (sparse checkout, or the diff/disk mode genuinely // disagree per the skip-worktree case above) — already "absent"; nothing to move or // restore, and the probe below still evaluates it correctly. } } const SLASH = new Uint8Array([0x2f]); const dirQueries = candidates.map((p) => concatBytes([p, SLASH])); const dirCheck = await checkIgnoreBatch(filesCwd, isolatedGitDir, dirQueries, signal); if (!dirCheck.ok) return dirCheck; const ignored: string[] = []; for (const pathBytes of candidates) { const isIgnored = fileCheck.matched.has(bytesKey(pathBytes)) || dirCheck.matched.has(bytesKey(concatBytes([pathBytes, SLASH]))); // The returned `paths` are for a human-readable rejection message only — a best-effort // UTF-8 rendering (lossy for a non-UTF-8 filename) is fine here; the MATCH decision above // never depended on it. if (isIgnored) ignored.push(new TextDecoder().decode(pathBytes)); } return { ok: true, paths: ignored }; } finally { for (const { from, to } of moved) { try { renameSync(to, from); } catch (err) { console.error(`[orchestrator] land-gate reject-guard failed to restore ${from.toString("utf8")} after directory-semantics probe: ${errMessage(err)}`); } } } } finally { rmSync(isolatedRoot, { recursive: true, force: true }); } } /** * #1145 BLOCKER-4 (client portion) — `armWorkspacePrAutoMerge`/`mergeWorkspacePr`/ * `refreshWorkspacePrBranch` (workspace-pr.ts) invoke `gh pr merge`/`gh pr update-branch` * directly, which act on GitHub's CURRENT view of the PR — its remote head/base — not * necessarily anything this process has already checked (the branch may have been pushed to, * or the PR refreshed against a moved base, since any earlier local check). So this re-resolves * and re-checks the EXACT remote head/base immediately before every arm/merge/refresh call. * * round-4 BLOCKER-2 — GitHub merges base+head, it doesn't land head verbatim, so this evaluates * {@link guardMergeTreeGitignoredSymlinks} (the synthesized base+head merge tree) rather than * {@link guardShaRangeGitignoredSymlinks} (head alone) — a `.gitignore` rule that exists only on * a diverged base would otherwise never be seen. * * This does NOT close the gap between arming auto-merge and GitHub's own later, server-side * merge (the PR could still change between arm and merge) — that server-side enforcement is * #1463 (a required status check), not a client-side fix. */ export async function guardRemotePrGitignoredSymlinks(cwd: string, target: string, signal?: AbortSignal): Promise<{ ok: true } | { ok: false; error: string }> { const view = await execProcess( ["gh", "pr", "view", target, "--json", "headRefName,baseRefName,headRefOid,baseRefOid"], { cwd, env: process.env, timeoutMs: mergePhaseTimeoutMs("gates"), timeoutLabel: "workspace merge gh pr view for gitignore reject-guard", signal }, ); if (!view.ok) return { ok: false, error: view.stderr || view.stdout || `gh pr view ${target} failed` }; let parsed: { headRefName?: string; baseRefName?: string; headRefOid?: string; baseRefOid?: string }; try { parsed = JSON.parse(view.stdout); } catch (err) { return { ok: false, error: `gh pr view ${target} returned unparsable JSON: ${errMessage(err)}` }; } const { headRefName, baseRefName, headRefOid, baseRefOid } = parsed; if (!headRefName || !baseRefName || !headRefOid || !baseRefOid) { return { ok: false, error: `gh pr view ${target} did not report a complete head/base ref+sha` }; } const fetch = await git(["fetch", "origin", headRefName, baseRefName], cwd, { timeoutMs: mergePhaseTimeoutMs("gates"), timeoutLabel: "workspace merge fetch PR head/base for gitignore reject-guard", signal }); if (!fetch.ok) return { ok: false, error: fetch.stderr || `failed to fetch ${headRefName}/${baseRefName} for gitignore reject-guard` }; const guard = await guardMergeTreeGitignoredSymlinks(cwd, baseRefOid, headRefOid, signal); if (guard.ok || !guard.error.startsWith(GATE_REJECT_PREFIX)) return guard; console.error("[orchestrator] workspace.merge pr-remote gate-reject target=" + target + " " + guard.error); return { ok: false, error: `PR ${target}'s merge result: ${guard.error}` }; } /** * #1145 round-4 LOW — best-effort teardown for a throwaway detached guard worktree. Deliberately * does NOT accept the evaluation's own (possibly by-now-aborted) `AbortSignal`: passing that * through to the `git worktree remove` call meant a cancellation that landed between "worktree * materialized" and "cleanup runs" made {@link execProcess} refuse to even spawn the remove (it * fast-returns `{ok:false}` for an already-aborted signal without spawning), so the directory got * `rmSync`'d off disk directly while its `.git/worktrees/*` registration silently survived — * stranded metadata pointing at a now-missing path. Cleanup must run for real regardless of why * the evaluation stopped; `worktree prune` is a final backstop in case `remove` itself still * couldn't run (e.g. the directory was already gone). Still used by {@link runLandGatesOnIntegratedTree}, * whose worktree is a real checkout (needed to run build/test tooling against realistic, * filter-smudged content) — unlike the two guard-only materializations below, which no longer * create a worktree at all (see {@link materializeTreeForGuard}). */ async function cleanupGuardWorktree(repoRoot: string, tmpParent: string, tmpWorktree: string): Promise { try { await git(["worktree", "remove", "--force", tmpWorktree], repoRoot, { timeoutMs: mergePhaseTimeoutMs("cleanup"), timeoutLabel: "workspace merge reject-guard worktree cleanup" }); } catch { /* best-effort */ } rmSync(tmpParent, { recursive: true, force: true }); try { await git(["worktree", "prune"], repoRoot, { timeoutMs: mergePhaseTimeoutMs("cleanup"), timeoutLabel: "workspace merge reject-guard worktree prune" }); } catch { /* best-effort */ } } /** * #1145 round-6 — every git subprocess the gitignore reject-guard's evaluation spawns runs with a * SCRUBBED environment that neutralizes every config source `git` can read from: GLOBAL * (`~/.gitconfig`), SYSTEM (`/etc/gitconfig`), and COMMAND-SCOPE * (`GIT_CONFIG_COUNT`/`GIT_CONFIG_KEY_n`/`GIT_CONFIG_VALUE_n`) — all three are read from the * AMBIENT PROCESS ENVIRONMENT this orchestrator inherited at spawn, which a compromised isolated * worker can influence even without write access to `repoRoot`'s own `.git/config` (`~/.gitconfig` * lives in the same HOME a worker's spawned shell runs under; `GIT_CONFIG_COUNT`/`KEY_n`/`VALUE_n` * env vars a hostile spawn command sets would be inherited straight through). * * #1145 round-8 — an earlier version of this comment claimed materialization "no longer runs any * filter-susceptible command at all" after round-7 replaced `git archive` with raw object reads * in {@link materializeTreeForGuard}. That was true of THAT function, but not of every guard * evaluation call site — {@link runLandGatesOnIntegratedTree} still ran the guard against a real * `git worktree add` checkout, an oversight two independent reviewers caught live. Do not restate * a completeness claim here without re-enumerating every call site; see each guard function's own * caller list (grep the four exported guard functions) rather than trusting a prior round's * summary, which is exactly how that oversight went unnoticed for two rounds. */ function hermeticGitEnv(): Record { const env: Record = { ...process.env }; // GIT_CONFIG_GLOBAL/_SYSTEM (git >= 2.32) redirect those config sources to /dev/null instead of // ~/.gitconfig / /etc/gitconfig; GIT_CONFIG_NOSYSTEM is the older, broader belt-and-suspenders // system-config disable for git versions/builds where the env-redirect form isn't honored. env.GIT_CONFIG_GLOBAL = "/dev/null"; env.GIT_CONFIG_SYSTEM = "/dev/null"; env.GIT_CONFIG_NOSYSTEM = "1"; // Command-scope config injection (`GIT_CONFIG_COUNT=1 GIT_CONFIG_KEY_0=... GIT_CONFIG_VALUE_0=...`) // is NOT covered by the two globals above — it's a THIRD, independent config source read // straight from these specific env vars, so it must be stripped explicitly, not just redirected. for (const key of Object.keys(env)) { if (/^GIT_CONFIG_(COUNT|KEY_\d+|VALUE_\d+)$/.test(key)) delete env[key]; } return env; } const GITIGNORE_BASENAME = new TextEncoder().encode(".gitignore"); function bytesEqual(a: Uint8Array, b: Uint8Array): boolean { if (a.length !== b.length) return false; for (let i = 0; i < a.length; i++) if (a[i] !== b[i]) return false; return true; } /** Byte-exact "is this path named exactly `.gitignore`, at any depth" — root (`.gitignore`) or * nested (`.../.gitignore`), never a lossy string compare (see the file-level rationale on * {@link findGitignoredLandedSymlinks} for why paths stay raw bytes end-to-end). */ function isGitignorePath(pathBytes: Uint8Array): boolean { if (pathBytes.length === GITIGNORE_BASENAME.length) return bytesEqual(pathBytes, GITIGNORE_BASENAME); const slashIdx = pathBytes.length - GITIGNORE_BASENAME.length - 1; return slashIdx >= 0 && pathBytes[slashIdx] === 0x2f && bytesEqual(pathBytes.subarray(slashIdx + 1), GITIGNORE_BASENAME); } /** Split a byte-exact tree path at its last `/` — the directory portion (empty for a root-level * path) and the basename. Used to recreate just enough of the tree's directory structure to hold * a reconstructed `.gitignore` file at its real location. */ function splitLastSlash(pathBytes: Uint8Array): { dir: Uint8Array; base: Uint8Array } { for (let i = pathBytes.length - 1; i >= 0; i--) { if (pathBytes[i] === 0x2f) return { dir: pathBytes.subarray(0, i), base: pathBytes.subarray(i + 1) }; } return { dir: new Uint8Array(0), base: pathBytes }; } /** * #1145 round-7 BLOCKER — round-6's `git archive` (run inside a from-scratch, env-scrubbed * isolated repo borrowing `repoRoot`'s objects via an alternate) closed the config-driven * smudge-filter vector, but `git archive` ALSO honors the `export-ignore` gitattribute — an * IN-TREE, committed `.gitattributes` line (`.gitignore export-ignore`), not a config/env vector * at all — which makes `git archive` OMIT `.gitignore` from its output ENTIRELY. No config * injection, no HOME write, no filter driver needed: a single ordinary commit defeats every * archive-based materialization regardless of how hermetically its environment is scrubbed, * because the attribute travels WITH the tree being evaluated (reproduced live end-to-end: * `.gitignore` vanishes from `git archive`'s output, `check-ignore` then finds no rule, and the * force-added symlink reads as not-ignored). Any archive/checkout/worktree materialization is * fundamentally contaminable this way — env scrubbing cannot fix an attribute that lives in the * object database itself. * * The fix stops materializing a checked-out TREE at all. It reads the exact `.gitignore` blobs * the guard needs via RAW OBJECT PLUMBING instead: * - `git ls-tree -r` enumerates every path in `sha`'s tree directly from tree objects — no * gitattributes of any kind apply to it (`export-ignore` is specifically an ARCHIVE attribute; * ls-tree has never consulted it), so it can't hide a `.gitignore` (or, symmetrically, a * candidate symlink) from this scan the way `git archive`'s output can be made to. * - `git cat-file -p ` reads a blob's raw, stored bytes directly — no smudge filter and no * attribute of any kind is ever applied to a `cat-file` read (unlike `checkout`/`archive`, * which are the ONLY operations that run content filters or honor `export-ignore`). * - Every `.gitignore` blob found (at EVERY directory level in the tree, not just root — a * nested `sub/deep/.gitignore` is exactly as reachable via `ls-tree -r` as the root one) is * written to a scratch directory ITSELF, at its real relative path, from those raw bytes — * nothing else in the tree is materialized at all. `findGitignoredLandedSymlinks`'s * `check-ignore --no-index` evaluation only ever needs `.gitignore` files to be discoverable by * walking up a queried path's ancestor directories (verified empirically: check-ignore matches * correctly against a scratch tree containing ONLY `.gitignore` files, with no other tracked * content, and no directory created for the queried candidate path itself, in every one of: * root-level match, nested match with the candidate's own directory absent, and a * non-anchored pattern matching a path in a directory that was never created at all) — so this * is not a partial reconstruction that happens to work for the guard's own test cases, it * matches how gitignore directory-hierarchy resolution is actually defined. * * Candidate symlink discovery ({@link diffRawSymlinkCandidates}, `git diff --raw`) is UNCHANGED — * it was never vulnerable to `export-ignore` in the first place (diff is pure object-to-object * comparison; `export-ignore` has only ever been consulted by `archive`), and narrowing to * Added/Type-changed relative to `baseSha` (not every symlink in the final tree) is what keeps a * repo's legitimate, pre-existing tracked symlinks from over-blocking every land that doesn't * touch them — switching that scan to a bare `ls-tree ` (every symlink in the FINAL tree, * with no base comparison) would reintroduce exactly that regression, so it stays diff-based. */ async function materializeTreeForGuard( repoRoot: string, sha: string, signal?: AbortSignal, ): Promise<{ ok: true; path: string; cleanup: () => void } | { ok: false; error: string }> { const tmpParent = mkdtempSync(join(tmpdir(), "agent-relay-landgate-gitignore-")); const scratchDir = join(tmpParent, "checkout"); const env = hermeticGitEnv(); try { mkdirSync(scratchDir, { recursive: true }); const list = await execProcess( ["git", "-C", repoRoot, "ls-tree", "-r", "-z", sha], { timeoutMs: mergePhaseTimeoutMs("worktree-add"), timeoutLabel: "workspace merge ls-tree for gitignore reject-guard", signal, env, stdoutBytes: true, trimStdout: false }, ); if (!list.ok) { rmSync(tmpParent, { recursive: true, force: true }); return { ok: false, error: list.stderr || list.stdout || `git ls-tree -r ${sha} failed for gitignore reject-guard` }; } for (const record of splitNul(list.stdoutBytes ?? new Uint8Array(0))) { const tab = record.indexOf(0x09); if (tab < 0) continue; const pathBytes = record.subarray(tab + 1); if (!isGitignorePath(pathBytes)) continue; const header = new TextDecoder().decode(record.subarray(0, tab)); // " " — always plain ASCII const fields = header.split(" "); const mode = fields[0]; const blobSha = fields[2]; // #1145 round-8 BLOCKER (sol) — only ever honor a `.gitignore` tree entry that's a REGULAR // file (100644, or 100755 for a nonsensically-executable one — mode alone doesn't affect // ignore-pattern parsing). Real git NEVER treats a tracked SYMLINK (120000) as a source of // ignore patterns — a checked-out symlinked `.gitignore` is just a dangling/self-referential // link on disk, not something git's ignore-pattern loader reads through. Honoring it here // would let an attacker force-commit `.gitignore` (or a NESTED `sub/.gitignore`) as a // symlink whose "target" string is actually attacker-chosen pattern text (e.g. `!HOST`, // negating a real ancestor rule) — this materialization would then write THAT blob's bytes // out as if they were legitimate committed ignore rules, a rule real `git status`/checkout // would never apply. Skip anything that isn't a plain regular-file mode. if ((mode !== "100644" && mode !== "100755") || !blobSha) continue; const blob = await execProcess( ["git", "-C", repoRoot, "cat-file", "-p", blobSha], { timeoutMs: mergePhaseTimeoutMs("worktree-add"), timeoutLabel: "workspace merge cat-file for gitignore reject-guard", signal, env, stdoutBytes: true, trimStdout: false }, ); if (!blob.ok) { rmSync(tmpParent, { recursive: true, force: true }); return { ok: false, error: blob.stderr || `git cat-file -p ${blobSha} failed for gitignore reject-guard` }; } const { dir } = splitLastSlash(pathBytes); if (dir.length > 0) mkdirSync(absPathBytes(scratchDir, dir), { recursive: true }); writeFileSync(absPathBytes(scratchDir, pathBytes), blob.stdoutBytes ?? new Uint8Array(0)); } return { ok: true, path: scratchDir, cleanup: () => rmSync(tmpParent, { recursive: true, force: true }) }; } catch (err) { rmSync(tmpParent, { recursive: true, force: true }); return { ok: false, error: errMessage(err) }; } } /** * #1145 — materialize `headSha` (hook/filter-immune, see {@link materializeTreeForGuard}) and run * the gitignore reject-guard against it. Shared by land paths that already have exact SHAs to * check but no existing checkout of `headSha` to run the (filesystem-dependent) check-ignore step * against: {@link rejectGitignoredLandedSymlinks} (plain-git/PR-open direct pushes — see its own * docstring for round-4 BLOCKER-1) and the #950 stranded-base publish recovery in merge.ts * (publishing local-only base history that was never itself checked against the tree it's about * to push). `headSha` IS the exact tree being pushed in both cases — no PR merge is involved, so * (unlike {@link guardMergeTreeGitignoredSymlinks}) evaluating head alone is correct here. */ export async function guardShaRangeGitignoredSymlinks(repoRoot: string, baseSha: string, headSha: string, signal?: AbortSignal): Promise<{ ok: true } | { ok: false; error: string }> { const materialized = await materializeTreeForGuard(repoRoot, headSha, signal); if (!materialized.ok) return materialized; try { // Diff against `repoRoot` (has `baseSha`/`headSha` in its object database — no working tree // needed for that step) but check-ignore against the archived `materialized.path` (has the // actual, untampered files on disk) — see {@link findGitignoredLandedSymlinks}'s docstring. return await evaluateGitignoreGuard(repoRoot, baseSha, headSha, signal, materialized.path); } finally { materialized.cleanup(); } } /** * #1145 round-4 BLOCKER-2 — GitHub doesn't land a PR's head verbatim, it lands the MERGE of base * and head. A `.gitignore` rule that exists only on a diverged BASE (never on head, e.g. head * branched before base got it) is invisible to {@link guardShaRangeGitignoredSymlinks}, which * only ever materializes head — so a symlink that's genuinely ignored in the tree GitHub will * actually produce sailed through. Synthesize the real base+head merge tree (same primitive * {@link runLandGatesOnIntegratedTree} already uses for the direct/managed path) and evaluate the * guard against THAT checkout instead. A merge conflict here means GitHub's own merge can't * produce a clean tree either — there is no "tree GitHub will merge" to evaluate ignore-rules * against, so this fails closed rather than silently falling back to head-only evaluation. * * #1145 round-5 MED — the candidate scan diffs `baseSha` against the SYNTHESIZED MERGE RESULT * (`synth.mergeSha`), not against `headSha`. A path head still carries unmodified from a since- * diverged merge-base (base independently deleted it, head never touched it) reads as an "add" in * a raw `baseSha..headSha` diff, but a real 3-way merge resolves that case to base's deletion — * the path never actually lands. Diffing against the synthesized result instead reflects the * exact landed delta, so a legitimate merge that happens to drop an old ignored path isn't * false-rejected for a symlink that was never going to be there. */ export async function guardMergeTreeGitignoredSymlinks(repoRoot: string, baseSha: string, headSha: string, signal?: AbortSignal): Promise<{ ok: true } | { ok: false; error: string }> { const synth = await synthesizeNoFfMerge(repoRoot, baseSha, headSha, "#1145 land-gate merge-tree probe (never landed)", mergePhaseTimeoutMs("synthesize"), signal); if (!synth.ok) { return { ok: false, error: `${GATE_REJECT_PREFIX} gitignore reject-guard could not synthesize the PR's merge tree: ${synth.error}` }; } const materialized = await materializeTreeForGuard(repoRoot, synth.mergeSha, signal); if (!materialized.ok) return materialized; try { return await evaluateGitignoreGuard(repoRoot, baseSha, synth.mergeSha, signal, materialized.path); } finally { materialized.cleanup(); } } /** Shared `findGitignoredLandedSymlinks` call + result formatting for callers (above, and * merge.ts's #950 replay-publish, which already has a live checkout of `headSha`) that don't * need {@link guardShaRangeGitignoredSymlinks}'s own worktree materialization. `filesCwd` defaults * to `cwd` (the common case: one directory that's both the object-bearing repo and the checked- * out files); round-5's archive-based guards pass them separately — see * {@link findGitignoredLandedSymlinks}'s docstring for why. */ export async function evaluateGitignoreGuard(cwd: string, baseSha: string, headSha: string, signal?: AbortSignal, filesCwd: string = cwd): Promise<{ ok: true } | { ok: false; error: string }> { const guard = await findGitignoredLandedSymlinks(cwd, baseSha, headSha, signal, filesCwd); if (!guard.ok) return { ok: false, error: `gitignore reject-guard could not be evaluated: ${guard.error}` }; if (guard.paths.length === 0) return { ok: true }; console.error("[orchestrator] workspace.merge gate-reject gitignored-symlinks=" + guard.paths.join(",") + " worktree=" + filesCwd); return { ok: false, error: `${GATE_REJECT_PREFIX} adds/modifies gitignored symlink path(s) — these must never enter history: ${guard.paths.join(", ")}` }; } /** * Convenience wrapper for the direct land paths (plain-git, the PR path) that don't already * have a base/head sha resolved: resolve `base`/`head` (refs or shas) in `cwd`, run the guard, * and format a single ready-to-surface `{ ok: false, error }` for any failure — the caller's * abort case, the guard's own fail-closed error, and an actual rejection all collapse to the * same shape. `runLandGatesOnIntegratedTree` already has its own resolved shas and a richer * (truncating) rejection message, so it calls {@link findGitignoredLandedSymlinks} directly * instead of this wrapper. * * round-4 BLOCKER-1 — `cwd` is the CALLER's own live working tree (plain-git.ts's just-rebased * worktree; merge.ts's pre-push PR-open checkout), which is mutable, host-local state: a * `.gitignore` marked `skip-worktree` and emptied on disk defeats a `check-ignore` evaluated * directly against it, even though the COMMITTED tree at `head` still ignores the path (`git * status` stays clean throughout). So this materializes `headSha` fresh from objects via * {@link guardShaRangeGitignoredSymlinks} instead of evaluating `cwd` in place — the same * discipline already applied to symlink mode (`git diff --raw`, not `lstatSync`) and to the * remote-PR guard's throwaway worktree. */ export async function rejectGitignoredLandedSymlinks(cwd: string, base: string, head: string, signal?: AbortSignal): Promise<{ ok: true } | { ok: false; error: string }> { const resolve = async (ref: string): Promise<{ ok: true; sha: string } | { ok: false; error: string }> => { const result = await git(["rev-parse", ref], cwd, { timeoutMs: mergePhaseTimeoutMs("gates"), timeoutLabel: "workspace merge resolve ref for gitignore reject-guard", signal }); return result.ok && result.stdout ? { ok: true, sha: result.stdout } : { ok: false, error: result.stderr || `failed to resolve ${ref} for gitignore reject-guard` }; }; const baseSha = await resolve(base); if (!baseSha.ok) return baseSha; const headSha = await resolve(head); if (!headSha.ok) return headSha; return await guardShaRangeGitignoredSymlinks(cwd, baseSha.sha, headSha.sha, signal); } // Link the worker worktree's node_modules into the throwaway land-gate checkout so the // integrated tree typechecks against the exact deps the worker ran with. The link set is // DERIVED from the worker worktree's actual layout — the same `nodeModulesDirs` the worker // linker uses — so it can never drift from the workspaces the repo declares (#1390: the old // hardcoded list omitted `kernel/node_modules`, so kernel's zod failed to resolve here). export function linkDependencyDirs(sourceWorktree: string, gateWorktree: string): void { for (const dir of nodeModulesDirs(sourceWorktree)) { const rel = join(dir, "node_modules"); const from = join(sourceWorktree, rel); const to = join(gateWorktree, rel); if (!existsSync(from) || existsSync(to)) continue; try { const stat = lstatSync(from); if (!stat.isDirectory() && !stat.isSymbolicLink()) continue; mkdirSync(join(to, ".."), { recursive: true }); symlinkSync(realpathSync(from), to, "dir"); } catch (err) { console.error(`[orchestrator] land-gate dependency link skipped ${rel}: ${errMessage(err)}`); } } } /** * Run land gates against the exact tree that would land. * * The build/test gates run against a real detached checkout — a fast-forwardable branch is * gated outside the worker worktree so worker-only topology, untracked files, and symlink layout * cannot make the boundary check pass differently than the tree that will advance the base ref. * * #1145 round-8 PRIMARY BLOCKER — that real checkout is exactly what the gitignore reject-guard * must NEVER evaluate against: `git worktree add` is a checkout, so it runs whatever `.gitignore`- * blanking smudge filter or hook the landed tree (or the host) can reach, the same class of bypass * closed everywhere else this guard runs (see {@link materializeTreeForGuard}'s docstring) — this * call site was the one place that never got the memo, reachable from the DEFAULT `direct`/ * `mergeRebaseFf` land path (the mainline `relay_task_deliver` route), making it the highest- * impact of the bypasses found across this whole series. The guard now evaluates against * {@link materializeTreeForGuard}'s hermetic scratch dir — built from raw object reads, never a * checkout — and ONLY once that passes does this function materialize the real `tmpWorktree` for * the build/test gates. (Also fixes the round-7 docstring elsewhere that overclaimed "no longer * runs any filter-susceptible command at all" — that was true of `materializeTreeForGuard` * itself, not of every guard evaluation call site; this was the exception.) */ export async function runLandGatesOnIntegratedTree( repoRoot: string, worktreePath: string, behind: number, integrationBaseSha: string, headSha: string, mergeMessage: string, gateLevel: WorkspaceLandGateLevel = "full", signal?: AbortSignal, ): Promise<{ gates: LandGatesResult } | { abort: { conflict?: boolean; error: string } }> { console.error("[orchestrator] workspace.merge gate-start worktree=" + worktreePath + " behind=" + behind + " level=" + gateLevel); // #1145 — the gitignore reject-guard below is a hard data-loss invariant, not a configured // land gate: it must run even when gateLevel=none (an operator escape hatch for skipping // *configured* typecheck/test/build gates on an urgent land). Waiving those gates must never // imply waiving this. So baseRequiredGates/requiredGates are resolved only for level != none, // but the integrated tree is always gitignore-checked below. let baseRequiredGates: LandGate[] = []; if (gateLevel !== "none") { try { baseRequiredGates = await resolveRequiredLandGates(repoRoot, integrationBaseSha, signal); } catch (err) { return { abort: { error: errMessage(err) } }; } } const requiredGates = requiredGatesForLevel(baseRequiredGates, gateLevel); const loadCandidateRepoGates = gateLevel === "subset"; let gateRef = headSha; if (behind > 0) { let synth: Awaited>; try { synth = await withMergePhaseTimeout( "synthesize", (phaseSignal) => synthesizeNoFfMerge(repoRoot, integrationBaseSha, headSha, mergeMessage, mergePhaseTimeoutMs("synthesize"), phaseSignal), { signal }, ); } catch (err) { return { abort: { error: errMessage(err) } }; } if (!synth.ok) return { abort: { conflict: synth.conflict, error: synth.error } }; gateRef = synth.mergeSha; } // Hermetic guard evaluation FIRST — raw object reads only, never a checkout. Only a passing // guard proceeds to materialize the real worktree below (also means a rejected land never pays // for a `worktree add` + dependency link it was never going to use). const materialized = await materializeTreeForGuard(repoRoot, gateRef, signal); if (!materialized.ok) { return { abort: { error: `${GATE_REJECT_PREFIX} gitignore reject-guard could not materialize ${gateRef}: ${materialized.error}` } }; } let guard: Awaited>; try { guard = await findGitignoredLandedSymlinks(repoRoot, integrationBaseSha, gateRef, signal, materialized.path); } finally { materialized.cleanup(); } if (!guard.ok) { console.error("[orchestrator] workspace.merge gate-reject-guard-error " + guard.error + " worktree=" + worktreePath); return { abort: { error: `${GATE_REJECT_PREFIX} gitignore reject-guard could not be evaluated: ${guard.error}` } }; } if (guard.paths.length > 0) { const shown = guard.paths.slice(0, 10); const more = guard.paths.length > shown.length ? ` (+${guard.paths.length - shown.length} more)` : ""; console.error("[orchestrator] workspace.merge gate-reject gitignored-symlinks=" + guard.paths.join(",") + " worktree=" + worktreePath); return { abort: { error: `${GATE_REJECT_PREFIX} branch adds/modifies gitignored symlink path(s) — these must never enter history: ${shown.join(", ")}${more}` } }; } if (gateLevel === "none") { console.error("[orchestrator] workspace.merge gate-skip level=none worktree=" + worktreePath); return { gates: { ran: 0, warnings: [] } }; } return await runGatesInDetachedCheckout(repoRoot, worktreePath, gateRef, requiredGates, loadCandidateRepoGates, signal); } /** * #1628 — the land gate for the plain-git executor, run on the REBASED RESULT. * * `mergeWorkspacePlainGit` rebases the worker tip onto the freshly-fetched `origin/` and * pushes that exact tree. A rebase composes two individually-green branches with NO textual * conflict and can still produce a RED tree — one branch changes a test's setup, a sibling adds an * assertion that depended on the old setup (the concrete #1624 instance: `ff7245bb` + `f5494ac5`). * Neither worker's own `test:delivery`, nor a steward's manual `ci:land` run BEFORE the executor * re-fetched and re-rebased, ever evaluated that composition — so `main` went red while every * green anyone saw was truthful about a tree that never landed. * * `headSha` here is the post-rebase tip, so this gates the exact bytes that will be pushed. The * caller runs it INSIDE its retry loop (a lost `--force-with-lease` re-fetches, re-rebases, and * therefore re-gates), and the lease pins `baseSha` as the expected-old value — so the tree that * lands is always the tree that passed, with no gate→push window to race. * * `baseSha` is the ref we rebased ONTO, and (like the managed path) the gate LIST is read from * THAT commit's `.agent-relay/land-gates.json`, never the candidate's: a branch must not be able * to weaken the gate that judges it by editing the config it lands. */ export async function runLandGatesOnRebasedTree( repoRoot: string, worktreePath: string, baseSha: string, headSha: string, gateLevel: WorkspaceLandGateLevel = "full", signal?: AbortSignal, ): Promise<{ gates: LandGatesResult } | { abort: { error: string } }> { console.error(`[orchestrator] plain-git land gate-start worktree=${worktreePath} base=${baseSha.slice(0, 12)} head=${headSha.slice(0, 12)} level=${gateLevel}`); if (gateLevel === "none") { console.error("[orchestrator] plain-git land gate-skip level=none worktree=" + worktreePath); return { gates: { ran: 0, warnings: [] } }; } let requiredGates: LandGate[]; try { requiredGates = requiredGatesForLevel(await resolveRequiredLandGates(repoRoot, baseSha, signal), gateLevel); } catch (err) { return { abort: { error: errMessage(err) } }; } return await runGatesInDetachedCheckout(repoRoot, worktreePath, headSha, requiredGates, gateLevel === "subset", signal); } /** * Materialize `gateRef` as a throwaway DETACHED checkout, link the worker's dependency dirs in, * and run the gates against it. Shared by both land executors' gate steps. * * The checkout is deliberately outside the worker worktree: worker-only topology (untracked files * a test can import, stale build output, host-local symlinks) must never be able to make the gate * pass differently than the tree that actually advances the base ref. `linkDependencyDirs` brings * the worker's resolved `node_modules` across so the gate typechecks/tests against the exact deps * the worker ran with (#1390). */ async function runGatesInDetachedCheckout( repoRoot: string, sourceWorktree: string, gateRef: string, requiredGates: LandGate[], loadCandidateRepoGates: boolean, signal?: AbortSignal, ): Promise<{ gates: LandGatesResult } | { abort: { error: string } }> { const tmpParent = mkdtempSync(join(tmpdir(), "agent-relay-landgate-")); const tmpWorktree = join(tmpParent, "checkout"); let add: Awaited>; try { add = await withMergePhaseTimeout( "worktree-add", (phaseSignal) => git( ["worktree", "add", "--detach", tmpWorktree, gateRef], repoRoot, { timeoutMs: mergePhaseTimeoutMs("worktree-add"), timeoutLabel: "workspace merge land-gate worktree add", signal: phaseSignal }, ), { signal }, ); } catch (err) { rmSync(tmpParent, { recursive: true, force: true }); return { abort: { error: errMessage(err) } }; } if (!add.ok) { rmSync(tmpParent, { recursive: true, force: true }); return { abort: { error: add.stderr || "failed to materialize integrated tree for land gates" } }; } try { linkDependencyDirs(sourceWorktree, tmpWorktree); let candidateRepoGates: LandGate[] | undefined; if (loadCandidateRepoGates) { try { candidateRepoGates = loadRepoLandGates(tmpWorktree); } catch { candidateRepoGates = undefined; } } const gateTimeoutBudgetMs = landGatesTimeoutBudgetMs([...requiredGates, ...(candidateRepoGates ?? [])]); return { gates: await withMergePhaseTimeout( "gates", () => runLandGates(tmpWorktree, requiredGates, { loadRepoGates: loadCandidateRepoGates, repoGates: candidateRepoGates }), { signal, minimumMs: gateTimeoutBudgetMs }, ), }; } finally { // #1145 round-5 LOW — reuse the signal-free cleanup with a `worktree prune` backstop // (round-4 fixed this for the two guard-only worktrees but left this older integrated-tree // worktree on the original signal-bound cleanup, which can strand `.git/worktrees/*` // metadata when a cancellation lands between "worktree materialized" and "cleanup runs" — // see {@link cleanupGuardWorktree}'s own docstring for why the signal must not be passed // through here). await cleanupGuardWorktree(repoRoot, tmpParent, tmpWorktree); } }