// #1452 — publish/unpublish an isolated worker's tip to origin's review-refs namespace // (see agent-relay-sdk `review-ref.ts` for the namespace contract). // // SECURITY MODEL (round 3): ALL safety is enforced HERE, in the host, never trusting the // caller. Specifically: // • The push/delete target ref is ALWAYS derived here via `reviewRefFor` and HARD-REJECTED // unless it is under refs/review/*. A caller can NOT supply a push target — so a force-push // is structurally incapable of ever landing on refs/heads/* / main, no matter what branch, // workspaceId (or hostile value) the caller sends. // • Publishing REQUIRES the ORIGINAL live worktree (never the repoRoot fallback) and verifies // the worktree is actually on the EXPECTED workspace branch. A stale/recycled/delayed command // whose worktree is gone or now on a different branch is REFUSED — it can never publish an old // tip under refs/review/main or a newer lane's ref (round-3 H3). The repoRoot fallback is kept // for DELETION only, where "which checkout" doesn't matter (the target ref is branch-derived). // • The published sha must be REACHABLE from the verified worktree tip (an ancestor of, or equal // to, HEAD). A worker-supplied sha that is not reachable is REFUSED — FAIL CLOSED, no HEAD // fallback — so a sibling/private local commit can never be published under a review ref // (round-3 H1). With no sha supplied we publish the verified HEAD (inherently reachable). // • Publishing (the origin-mutating half) self-gates on THIS host's `reviewPublishEnabled()`, // so a queued/replayed/admin command can't publish while the feature is off on the host. // • Unpublishing does NOT gate on the flag — a ref created during a prior ON era must still be // cleanable after the flag flips off (the relay's durable reconciler drives that; H4). // • Every git invocation is time-bounded (REVIEW_REF_GIT_TIMEOUT_MS) so an unreachable origin // fails fast instead of freezing the caller's teardown/land path (round-3 H5). // Both are best-effort and never throw the caller's flow: a review ref is advisory. import { existsSync } from "node:fs"; import { resolve } from "node:path"; import { REVIEW_REF_PREFIX, errMessage, isCommitOid, isValidReviewRef, reviewRefFor } from "agent-relay-sdk"; import type { ManagedAgentReport, RelayClient, RelayCommand } from "../relay"; import { reviewPublishEnabled, reviewRefGitTimeoutMs } from "../config"; import { git } from "../git"; export interface ReviewRefInput { worktreePath?: string; repoRoot?: string; /** The EXPECTED workspace branch. For publish it is AUTHORITATIVE: the live worktree must be on * it or the publish is refused (H3). Never a push target — the ref is derived + asserted here. */ branch?: string; workspaceId?: string; /** The delivered tip, captured at delivery. Published iff it is reachable from the verified * worktree HEAD; an unreachable sha is REFUSED (fail closed, no HEAD fallback — H1). */ sha?: string; /** For PUBLISH (#1452 round-9): the relay-PINNED expected ref. The host derives its OWN ref and * REFUSES to push unless they are identical (fail-closed on a relay/host sanitizer skew), so the * ledger's pinned ref is provably the exact ref that gets pushed — closing the lost-result orphan. * For DELETION (#1452 round-5 H4): the EXACT host-confirmed ref to delete, passed verbatim from the * relay's ledger. Preferred over re-deriving from branch/workspaceId, which could diverge across a * sanitizer skew and leak the real ref. Either way it is asserted under refs/review/* before use. */ ref?: string; remote?: string; signal?: AbortSignal; /** #1452 round-13 HIGH#1 — a re-fence CAS invoked as the LAST step before the force-push (after ALL git * prep). Returns true iff this host STILL holds the live execution lease (command accepted/running). A * false — the TTL sweep timed the command out while this host stalled during prep — SKIPS the push with * no unbounded suspension between the check and the push, so a stalled-then-swept claimant cannot * resurrect a ref whose ledger row the reconciler already retired. Omitted in unit tests exercising the * pure git-safety logic; production always supplies it via runClaimGatedPublishReview. */ reFence?: () => Promise; } export interface PublishReviewResult { workspaceId?: string; ref?: string; sha?: string; pushed: boolean; skipped?: boolean; /** #1452 round-13 HIGH#1 — the pre-push re-fence CAS lost (command terminalized after claim). The push * was NOT attempted; the caller must NOT settle (the command is already terminal, reconciler-owned). */ fenceLost?: boolean; error?: string; } export interface UnpublishReviewResult { workspaceId?: string; ref?: string; deleted: boolean; error?: string; } // Every review-ref git op runs under the shared timeout AND the caller's abort signal (H5): touching // `origin` must never freeze the teardown/land/command loop that awaits it. function reviewGit(args: string[], cwd: string, signal?: AbortSignal) { return git(args, cwd, { signal, timeoutMs: reviewRefGitTimeoutMs(), timeoutLabel: `git ${args[0]} (review-ref)` }); } // The live worktree ONLY. Publishing must never fall back to the owning repo checkout: after a // recycle/removal that repoRoot is on `main` (or a newer lane), and publishing from it would push // an old tip under refs/review/main or the wrong lane's ref (H3). Undefined ⇒ refuse to publish. function publishCwd(input: ReviewRefInput): string | undefined { if (!input.worktreePath) return undefined; const abs = resolve(input.worktreePath); return existsSync(abs) ? abs : undefined; } // For DELETION only: the target ref is branch-derived (not tied to any checkout's current branch), // so any repo that can reach origin works. Prefer the worktree; fall back to the owning repo, which // is still valid — and often the only checkout left — after the worktree is gone. function deleteCwd(input: ReviewRefInput): string | undefined { for (const candidate of [input.worktreePath, input.repoRoot]) { if (candidate) { const abs = resolve(candidate); if (existsSync(abs)) return abs; } } return undefined; } async function worktreeBranch(cwd: string, signal?: AbortSignal): Promise { const r = await reviewGit(["symbolic-ref", "--quiet", "--short", "HEAD"], cwd, signal); return r.ok && r.stdout.trim() ? r.stdout.trim() : undefined; } /** * Force-push the delivered tip to a HOST-DERIVED refs/review/* ref on origin. Force is safe: the * ref is re-published every round and is never a base anyone builds on. STRUCTURALLY cannot target * refs/heads/* — the ref is derived here and asserted under refs/review/*. Additionally REFUSES * unless (a) the ORIGINAL live worktree exists, (b) it is on the EXPECTED workspace branch (H3), * and (c) the published sha is reachable from that worktree's verified HEAD (H1, fail closed). */ export async function publishReviewRef(input: ReviewRefInput): Promise { // Host self-gate: publishing mutates origin, so it obeys THIS host's flag, not the caller's. if (!reviewPublishEnabled()) return { workspaceId: input.workspaceId, pushed: false, skipped: true }; // H3: require the ORIGINAL live worktree — never the repoRoot fallback — so a stale/recycled/ // delayed command whose worktree is gone can't publish from an unrelated checkout. const cwd = publishCwd(input); if (!cwd) return { workspaceId: input.workspaceId, pushed: false, error: "review publish requires the original live worktree" }; const remote = input.remote ?? "origin"; // H3: the recorded workspace branch is AUTHORITATIVE. Verify the live worktree is actually on it; // a recycled lane now on a different branch (or a detached HEAD) must not publish under this // workspace's ref. When the caller gives no expected branch we fall back to the worktree's actual // branch (still a live, verified worktree) purely to derive the ref. const actualBranch = await worktreeBranch(cwd, input.signal); const expectedBranch = input.branch?.trim(); if (expectedBranch && actualBranch !== expectedBranch) { return { workspaceId: input.workspaceId, pushed: false, error: `refusing to publish: worktree is on ${actualBranch ?? "(detached HEAD)"}, expected ${expectedBranch}`, }; } const branch = expectedBranch ?? actualBranch; const ref = reviewRefFor({ branch, workspaceId: input.workspaceId }); if (!ref || !isValidReviewRef(ref)) { return { workspaceId: input.workspaceId, ref, pushed: false, error: `refusing to publish: derived ref is not a valid ref under ${REVIEW_REF_PREFIX}/` }; } // #1452 round-9/round-10 — the relay MUST pin an expected ref in its ledger BEFORE authorizing this // push, and the host's OWN derivation must equal it. Round-10 MED #3: the pin is MANDATORY, not // optional — a missing/invalid pin (a legacy, malformed, or hand-crafted command) is REFUSED, because // publishing the host-derived ref with no durable pin is exactly the ref the relay can't prove it can // clean under an SDK skew. A present-but-divergent pin is a sanitizer (version) skew and is likewise // REFUSED (fail closed) rather than push a ref the relay would fail to clean on a lost result. Only an // EXACT match proceeds, so the ledger's pinned ref is provably the exact ref that lands on origin. const pinnedRef = input.ref?.trim(); if (!pinnedRef || !isValidReviewRef(pinnedRef)) { return { workspaceId: input.workspaceId, ref, pushed: false, error: `refusing to publish: a valid relay-pinned review ref is required (got ${pinnedRef ?? "none"})` }; } if (pinnedRef !== ref) { return { workspaceId: input.workspaceId, ref, pushed: false, error: `refusing to publish: relay-pinned ref ${pinnedRef} does not match host-derived ${ref} (sanitizer skew)` }; } // The authoritative tip is the verified worktree's HEAD. const headRes = await reviewGit(["rev-parse", "HEAD"], cwd, input.signal); if (!headRes.ok || !headRes.stdout.trim()) { return { workspaceId: input.workspaceId, ref, pushed: false, error: headRes.stderr || "failed to resolve HEAD" }; } const tip = headRes.stdout.trim(); // H1 — FAIL CLOSED. A worker-supplied sha is published ONLY if it is reachable from the verified // tip (an ancestor of, or equal to, HEAD). `merge-base --is-ancestor` exits 0 for reachable, 1 // for not-reachable, and non-zero for an invalid object — anything but 0 is a refusal, with NO // HEAD fallback, so a sibling/detached local commit can never ride under a review ref. With no // sha supplied we publish the verified HEAD, which is trivially reachable. // // H3 (round-7) — the supplied sha must be an IMMUTABLE object id, never a symbolic/relative rev // (`HEAD`, a branch, `x^`). Reachability validation and the push are SEPARATE git invocations, so a // moving rev could resolve to a different (unchecked) commit between them. We REJECT any non-OID up // front, then `rev-parse --verify` it to a canonical commit OID exactly ONCE and use that same // pinned id for BOTH the reachability check and the push — nothing can move between the two. let sha = input.sha?.trim(); if (sha) { if (!isCommitOid(sha)) { return { workspaceId: input.workspaceId, ref, pushed: false, error: `refusing to publish: sha ${sha} is not a concrete commit OID` }; } const resolved = await reviewGit(["rev-parse", "--verify", "--quiet", `${sha}^{commit}`], cwd, input.signal); const oid = resolved.ok ? resolved.stdout.trim() : ""; if (!oid) { return { workspaceId: input.workspaceId, ref, pushed: false, error: `refusing to publish: sha ${sha} is not a resolvable commit object` }; } // H3b (round-8) — the supplied OID must itself BE a commit, not a tag/tree/blob that PEELS to a // DIFFERENT commit. An annotated-tag object id passes the hex-shape gate, but `^{commit}` peels it // to the underlying commit; we would then push that peeled commit and report it, while the ledger // still holds the tag OID as the requested sha — breaking settlement's `requested == pushed` // invariant and orphaning the ref (the reconciler marks the successful push `failed` and discards // the confirmed ref). Require the canonical commit to equal the input (case-insensitively) so the // sha we publish is EXACTLY the sha we were asked to publish; anything that peels is refused. if (oid.toLowerCase() !== sha.toLowerCase()) { return { workspaceId: input.workspaceId, ref, pushed: false, error: `refusing to publish: sha ${sha} is not a commit object (it peels to a different commit ${oid})` }; } const reachable = await reviewGit(["merge-base", "--is-ancestor", oid, tip], cwd, input.signal); if (!reachable.ok) { return { workspaceId: input.workspaceId, ref, pushed: false, error: `refusing to publish: sha ${oid} is not reachable from the verified workspace tip ${tip}`, }; } sha = oid; // the pinned, canonical OID — used verbatim for the push below } else { sha = tip; } // #1452 round-13 HIGH#1 — THE LAST STEP before the irreversible force-push: re-validate the execution // lease. All git prep above (branch check, HEAD/sha resolution, reachability) has run and can take real // wall-clock or suspend the process; if the TTL sweep timed this command out in the meantime, a // replacement orchestrator may already have retired the ledger row, so pushing now would strand an // orphan. The re-fence is a relay-side CAS (accepted/running → running, lease refreshed); a lost fence // SKIPS the push. It sits ADJACENT to the push — only a single await separates them — so there is no // multi-git-op window (the round-12 gap sol reproduced) for a suspend-then-push to slip through. if (input.reFence) { const stillHeld = await input.reFence(); if (!stillHeld) return { workspaceId: input.workspaceId, ref, sha, pushed: false, fenceLost: true }; } const push = await reviewGit(["push", "--force", remote, `${sha}:${ref}`], cwd, input.signal); if (!push.ok) return { workspaceId: input.workspaceId, ref, sha, pushed: false, error: push.stderr || push.stdout || `push to ${remote} ${ref} failed` }; return { workspaceId: input.workspaceId, ref, sha, pushed: true }; } /** * Delete a workspace's review ref from origin. NOT flag-gated (must clean up refs from a prior * ON era). STRUCTURALLY constrained to refs/review/* — the ref is derived here and asserted, so * a hostile branch/workspaceId can never delete refs/heads/*. Idempotent, and it distinguishes * "definitively absent" (clean no-op) from "unknown" (existence check errored → surfaced as an * error so teardown can retry, NOT silently treated as absent). */ export async function unpublishReviewRef(input: ReviewRefInput): Promise { // H4: delete the EXACT host-confirmed ref the ledger recorded, verbatim. Only when the caller has // no stored ref (a hot-path teardown that never went through the ledger) do we re-derive it — and // that derivation runs on THIS host with THIS host's sanitizer, the same one that published it, so // it is self-consistent. Either way the result is HARD-asserted under refs/review/* before any push. const ref = input.ref?.trim() || reviewRefFor({ branch: input.branch, workspaceId: input.workspaceId }); if (!ref) return { workspaceId: input.workspaceId, deleted: false }; if (!isValidReviewRef(ref)) return { workspaceId: input.workspaceId, ref, deleted: false, error: `refusing to delete: not a valid ref under ${REVIEW_REF_PREFIX}/` }; const cwd = deleteCwd(input); if (!cwd) return { workspaceId: input.workspaceId, ref, deleted: false, error: "no worktree or repo to push from" }; const remote = input.remote ?? "origin"; // Existence pre-check. `ls-remote --exit-code` exits 2 for "no matching refs" (definitively // absent), 0 with output for present, and other codes for a real error (network/auth). Only // 2 is treated as a clean absence; a genuine error is surfaced so it isn't misread as absent. const ls = await reviewGit(["ls-remote", "--exit-code", remote, ref], cwd, input.signal); if (ls.exitCode === 2) return { workspaceId: input.workspaceId, ref, deleted: false }; if (!ls.ok) return { workspaceId: input.workspaceId, ref, deleted: false, error: ls.stderr || `review ref existence check failed for ${ref}` }; if (!ls.stdout.trim()) return { workspaceId: input.workspaceId, ref, deleted: false }; const push = await reviewGit(["push", remote, "--delete", ref], cwd, input.signal); if (push.ok) return { workspaceId: input.workspaceId, ref, deleted: true }; // Lost a race with a concurrent teardown that deleted it first — still success, not an error. const msg = `${push.stderr || push.stdout || ""}`.toLowerCase(); if (msg.includes("remote ref does not exist") || msg.includes("does not exist") || msg.includes("no such ref")) { return { workspaceId: input.workspaceId, ref, deleted: false }; } return { workspaceId: input.workspaceId, ref, deleted: false, error: push.stderr || push.stdout || `delete ${ref} failed` }; } /** * Best-effort review-ref cleanup for a workspace teardown/branch-death path. Never throws and * never blocks teardown. Wire this wherever an agent branch or worktree is destroyed so * refs/review/* cannot leak — the ref shares the branch's lifecycle. * * H5: this runs on the HOT teardown/land path, so it does ZERO network work unless the feature is * currently enabled on THIS host (`reviewPublishEnabled()`). When the feature was never enabled no * ref was ever published from here, so probing origin on every teardown would be pure waste that an * unreachable origin could turn into a stall. Cleaning a ref left over from a PRIOR on-era (after * the flag flips off) is NOT done here — it is the job of the relay's durable reconciler, which * runs OFF the critical path and drives the (un-gated) `unpublishReviewRef` explicitly. All git ops * are additionally time-bounded, so even in the enabled case a dead origin fails fast. */ export async function bestEffortUnpublishReview(input: ReviewRefInput): Promise { try { if (!reviewPublishEnabled()) return undefined; if (!input.branch && !input.workspaceId) return undefined; const cwd = deleteCwd(input); if (!cwd) return undefined; const remote = input.remote ?? "origin"; const hasRemote = await reviewGit(["remote", "get-url", remote], cwd, input.signal); if (!hasRemote.ok) return undefined; return await unpublishReviewRef(input); } catch { return undefined; } } // #1452 — dispatch handler for the workspace.publish-review / workspace.unpublish-review host // commands. Lives here (not in control.ts) so the central command switch stays a thin one-liner // under the size ceiling (#291); the relay types are a type-only import (erased at runtime, so no // dependency cycle). All safety is enforced by the helpers above. function strParam(params: Record, key: string): string | undefined { const value = params[key]; return typeof value === "string" ? value : undefined; } // #1452 round-11/13 — run a review PUBLISH command gated on an ATOMIC claim + an adjacent re-fence. The // push force-mutates origin, so: // • CLAIM (CAS pending→accepted, relay-side): push ONLY IF the claim won; a lost claim means the durable // reconciler canceled the command (round closed) and pushing would orphan a ref whose ledger row is gone. // • The claim mints a per-execution FENCE TOKEN — the ONLY credential that authorizes this command's // terminal settlement (HIGH#2), so no external command:write holder can spoof its succeeded/failed. // • RE-FENCE adjacent to the push (inside publishReviewRef, after all git prep): a lost lease SKIPS the // push (HIGH#1), closing the stalled-then-swept split-brain with no multi-op suspension window. // Lives here (not control.ts) so the review-ref logic — and control.ts's size ceiling — stay contained. // Returns false only on an unexpected throw. export async function runClaimGatedPublishReview(command: RelayCommand, relay: RelayClient, managedAgents: ManagedAgentReport[]): Promise { const claim = await relay.claimCommand(command.id, "workspace.publish-review"); if (!claim.claimed) { console.error(`[orchestrator] skipping publish-review ${command.id}: claim lost (canceled/reaped or already claimed) — not pushing`); return true; // not an error: the reconciler owns the pinned ref's lifecycle } const token = claim.token; try { const p = command.params; const input = { workspaceId: strParam(p, "workspaceId"), repoRoot: strParam(p, "repoRoot"), worktreePath: strParam(p, "worktreePath"), branch: strParam(p, "branch") }; const result = await publishReviewRef({ ...input, sha: strParam(p, "sha"), ref: strParam(p, "reviewRef"), // The re-fence CAS runs as the LAST step before the force-push (HIGH#1 — adjacent, no prep window). reFence: () => relay.fencePublishPush(command.id, "workspace.publish-review"), }); if (result.fenceLost) { console.error(`[orchestrator] skipping publish-review ${command.id}: re-fence lost adjacent to push (timed out after claim) — not pushing`); return true; // command already terminal; the reconciler's TTL fail-closed cleanup owns the pinned ref } // Settle through the token-gated route — the ONLY external path allowed to terminalize this command // (generic PATCH + WS bus are refused). A missing token (only if the relay minted none) means we can't // settle; the TTL sweep + reconciler then own it, never orphaning. if (token) { await relay.settleReviewPublish(command.id, token, result.error ? "failed" : "succeeded", result as unknown as Record, result.error); } await relay.updateManagedAgents(managedAgents); return true; } catch (error) { if (token) { // Best-effort failure settlement via the same token-gated route; if it can't land, the sweep owns it. try { await relay.settleReviewPublish(command.id, token, "failed", undefined, errMessage(error)); } catch { /* sweep/reconciler own an unsettled command */ } } return false; } } // #1452 — dispatch handler for workspace.unpublish-review (the review-ref DELETE). Publish is handled by // the claim+fence+token-gated runClaimGatedPublishReview above; control.ts routes publish there, so only // unpublish reaches here. Unpublish is idempotent and NOT fenced/claimed, so it settles via the generic // updateCommand (which the confinement guard leaves open for unpublish's succeeded/failed). export async function handleReviewRefCommand(command: RelayCommand, relay: RelayClient): Promise { const p = command.params; const input = { workspaceId: strParam(p, "workspaceId"), repoRoot: strParam(p, "repoRoot"), worktreePath: strParam(p, "worktreePath"), branch: strParam(p, "branch") }; const result = await unpublishReviewRef({ ...input, ref: strParam(p, "ref") }); await relay.updateCommand(command.id, result.error ? "failed" : "succeeded", result as unknown as Record, result.error); }