Files
stack/packages/seat/src/working.mjs
T
jason.woltjeandClaude Opus 5.5 4da6815293 review loop: working repositories, git socket, push and review verdicts, row 93 (#1587)
Commits move as v2 bundles over bus-host/git.sock (decision 135). The bus
host keeps one bare working repository per business and project, seeds main
from the project root under a per-repository lock, fetches a pushed bundle
with fsck into a temp ref, requires history shared with main, and moves the
task's work ref only as a fast-forward. Host and runner git run with no
system or global config, hooks at /dev/null and local transports only. The
socket binds only for roles holding git.push.working or review.verdict.

The broker records a push as the action.allowed for git.push.working with
its commit, takes review.request for the latest push and one review.verdict
per request, and refuses task.close on a pushed task without an approve
verdict for the latest push. recordEvent reserves both shapes, so agents
can't forge either.

Reviewed by Dewey (28196) and Sage (28197), both approve. Gate on 7a729aef:
14 node suites and 9 scripts green.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-13 10:48:34 -05:00

241 lines
11 KiB
JavaScript

// The working repositories (#1587): one bare git repository per business
// and project at `<dataRoot>/working/<business>/<project>.git`, which only
// the bus host writes. A coder's push lands there as
// `refs/heads/work/vikunja-<project>-<task>`, and a reviewer fetches from
// there. It isn't the project's root and it isn't a forge; no credential and
// no remote goes into it, and no sandbox binds it.
//
// Commits move as git bundles. The session's runner makes the bundle in its
// own clone, inside its sandbox, and sends the bytes over the host's git
// socket (packages/cli/src/launcher.mjs). The host never runs git in a
// session's clone and never opens a path in a session's workspace: a live
// session can change its clone between any check and any git call, so no
// check of the clone would hold. Host git reads only the bundle, as a fetch
// from an untrusted remote, with fsck on.
//
// Every host git call here runs with no system or global config, hooks at
// /dev/null, fsmonitor off, only local transports and no submodule
// recursion (gitEnv and SAFE), in a repository the host made itself.
//
// push, under the repository's lock:
// 1. the bundle file starts with the v2 header (v3 can carry a filter)
// 2. main is fetched again from the project's root, which the host trusts
// 3. `bundle verify`: a prerequisite the repository lacks refuses
// missing-prerequisite
// 4. the bundle has the named branch (branch-not-in-bundle)
// 5. that one ref is fetched with transfer.fsckObjects into a temp ref;
// a broken object refuses invalid-bundle
// 6. the commit shares history with main (unrelated-history)
// 7. the temp ref moves to the work ref with no `+`, so git refuses
// anything but a fast-forward (non-fast-forward)
// 8. record() runs; when it throws, the work ref goes back
// The temp ref is deleted on every path. The caller owns the bundle file and
// deletes it.
//
// review, under the same lock: main again from the root, then a bundle of
// the requested commit that leaves out the commits the reviewer said it has
// (those the repository knows), written to the file the caller names.
import { spawn } from "node:child_process";
import { randomBytes } from "node:crypto";
import { closeSync, lstatSync, mkdirSync, openSync, readSync, renameSync, rmSync } from "node:fs";
import { dirname, join } from "node:path";
import { gitEnv, GIT_TIMEOUT_MS } from "./checkout.mjs";
// The largest bundle the host takes or sends.
export const BUNDLE_MAX = 32 * 1024 * 1024;
// The most commits a reviewer may say it has.
export const HAVE_MAX = 32;
const V2 = "# v2 git bundle\n";
const OID = /^[0-9a-f]{40}([0-9a-f]{24})?$/;
const TASK_REF = /^vikunja:([1-9][0-9]*)\/([1-9][0-9]*)$/;
const PROJECT = /^[a-z0-9][a-z0-9._-]{0,63}$/;
export class WorkingError extends Error {
constructor(code, message = code) {
super(message);
this.code = code;
}
}
const refuse = (code, message) => {
throw new WorkingError(code, message);
};
// Ahead of every git subcommand. Command-line config wins over any file.
const SAFE = [
"-c", "core.hooksPath=/dev/null",
"-c", "core.fsmonitor=false",
"-c", "protocol.allow=never",
"-c", "protocol.file.allow=always",
"-c", "submodule.recurse=false",
];
// Ahead of the fetch from a bundle. Tests replace it to show it matters.
export const FSCK = ["-c", "transfer.fsckObjects=true"];
function git(args, env, { pre = [] } = {}) {
return new Promise((resolve) => {
const child = spawn("git", [...SAFE, ...pre, ...args], { env: gitEnv(env), timeout: GIT_TIMEOUT_MS, killSignal: "SIGKILL", stdio: ["ignore", "pipe", "pipe"] });
let out = "";
let err = "";
child.stdout.setEncoding("utf8").on("data", (d) => (out += d));
child.stderr.setEncoding("utf8").on("data", (d) => (err += d));
child.on("error", (e) => resolve({ ok: false, out, err, why: e.code ?? e.message }));
child.on("close", (code, signal) => resolve({ ok: code === 0, out, err, why: signal ? `killed by ${signal}` : err.trim().split("\n").pop() || `exit ${code}` }));
});
}
// The branch name a task's work goes to. A task ref holds a colon, which a
// ref name can't.
export function workRef(taskRef) {
const m = TASK_REF.exec(taskRef ?? "");
if (!m) refuse("invalid-request", "not a task ref");
return `work/vikunja-${m[1]}-${m[2]}`;
}
// A branch name the session gives: it only picks a ref inside the bundle.
export function validBranch(branch) {
return (
typeof branch === "string" &&
/^[A-Za-z0-9][A-Za-z0-9._\/-]{0,199}$/.test(branch) &&
!/\.\.|\/\/|\/\.|@\{|\.lock(\/|$)|[./]$/.test(branch)
);
}
export const workingRepo = (dataRoot, business, project) => join(dataRoot, "working", business, `${project}.git`);
// One chain per repository: a push and a fetch never overlap in one repo.
const locks = new Map();
function locked(repo, fn) {
const run = (locks.get(repo) ?? Promise.resolve()).then(fn);
const tail = run.catch(() => {});
locks.set(repo, tail);
tail.then(() => {
if (locks.get(repo) === tail) locks.delete(repo);
});
return run;
}
// The bare repository, made on first use in a temp directory beside it and
// renamed into place. One that exists must be a real directory.
async function ensure(repo, env) {
try {
if (!lstatSync(repo).isDirectory()) refuse("working-repo-invalid", `${repo} isn't a directory`);
return;
} catch (e) {
if (e instanceof WorkingError) throw e;
if (e.code !== "ENOENT") refuse("working-repo-invalid", `${repo} can't be read: ${e.code}`);
}
mkdirSync(dirname(repo), { recursive: true, mode: 0o700 });
const tmp = join(dirname(repo), `.init-${randomBytes(4).toString("hex")}`);
try {
const r = await git(["init", "-q", "--bare", "--template=", tmp], env);
if (!r.ok) refuse("working-repo-invalid", `git init failed: ${r.why}`);
renameSync(tmp, repo);
} finally {
rmSync(tmp, { recursive: true, force: true });
}
}
// main from what the root has checked out. The root is the owner's; it isn't
// a session's.
async function seed(repo, root, env) {
const r = await git([`--git-dir=${repo}`, "fetch", "-q", "--no-tags", "--no-write-fetch-head", "--no-recurse-submodules", "--no-auto-maintenance", root, "+HEAD:refs/heads/main"], env);
if (!r.ok) refuse("root-unreadable", `main couldn't be fetched from ${root}: ${r.why}`);
}
function header(file) {
const fd = openSync(file, "r");
try {
const b = Buffer.alloc(V2.length);
return readSync(fd, b, 0, b.length, 0) === b.length && b.toString("latin1") === V2;
} finally {
closeSync(fd);
}
}
const tip = async (repo, ref, env) => {
const r = await git([`--git-dir=${repo}`, "rev-parse", "-q", "--verify", `${ref}^{commit}`], env);
return r.ok ? r.out.trim() : null;
};
// await push({ dataRoot, business, project, root, taskRef, branch, bundle, record, env, fsck })
// Resolves { ref, commit, previous }; rejects with WorkingError. `record`
// runs under the lock once the ref moved, and a throw moves it back. `fsck`
// is for the tests.
export function push({ dataRoot, business, project, root, taskRef, branch, bundle, record = async () => {}, env = process.env, fsck = FSCK }) {
if (!PROJECT.test(project ?? "")) refuse("invalid-request", "not a project id");
if (!validBranch(branch)) refuse("invalid-request", "not a branch name");
const ref = `refs/heads/${workRef(taskRef)}`;
const repo = workingRepo(dataRoot, business, project);
return locked(repo, async () => {
if (!header(bundle)) refuse("invalid-bundle", "not a v2 git bundle");
await ensure(repo, env);
await seed(repo, root, env);
const at = [`--git-dir=${repo}`];
// Not -q: with it, git 2.55 drops the list of missing prerequisites.
const verify = await git([...at, "bundle", "verify", bundle], env);
if (!verify.ok) {
if (/lacks these prerequisite/.test(verify.err)) refuse("missing-prerequisite", "the bundle needs commits the working repository doesn't have");
refuse("invalid-bundle", verify.why);
}
const heads = await git([...at, "bundle", "list-heads", bundle], env);
if (!heads.ok) refuse("invalid-bundle", heads.why);
if (!heads.out.split("\n").some((l) => l.split(" ")[1] === `refs/heads/${branch}`)) refuse("branch-not-in-bundle");
const incoming = `refs/mosaic/incoming/${randomBytes(8).toString("hex")}`;
try {
const fetched = await git([...at, "fetch", "-q", "--no-tags", "--no-write-fetch-head", "--no-recurse-submodules", "--no-auto-maintenance", bundle, `refs/heads/${branch}:${incoming}`], env, { pre: fsck });
if (!fetched.ok) refuse("invalid-bundle", fetched.why);
const commit = await tip(repo, incoming, env);
if (!commit) refuse("invalid-bundle", "the branch isn't a commit");
if (!(await git([...at, "merge-base", commit, "refs/heads/main"], env)).ok) refuse("unrelated-history", "the commit shares no history with main");
const previous = await tip(repo, ref, env);
// From the repository into itself, by its path (`.` would be the cwd), and
// not -q, which drops the "(non-fast-forward)" reason.
const moved = await git([...at, "fetch", "--no-tags", "--no-write-fetch-head", "--no-auto-maintenance", repo, `${incoming}:${ref}`], env);
if (!moved.ok) refuse(/\(non-fast-forward\)/.test(moved.err) ? "non-fast-forward" : "push-failed", moved.why);
try {
await record({ ref: workRef(taskRef), commit, previous });
} catch (e) {
const back = previous ? ["update-ref", ref, previous, commit] : ["update-ref", "-d", ref, commit];
await git([...at, ...back], env);
throw e;
}
return { ref: workRef(taskRef), commit, previous };
} finally {
await git([...at, "update-ref", "-d", incoming], env);
}
});
}
// await review({ dataRoot, business, project, root, taskRef, commit, have, out, env })
// Writes a bundle of `commit` to `out`, with one ref, refs/mosaic/review.
// `have` lists commits the reviewer's clone holds; those the repository
// doesn't know are skipped. Resolves { ref, commit }.
export function review({ dataRoot, business, project, root, taskRef, commit, have = [], out, env = process.env }) {
if (!PROJECT.test(project ?? "") || !OID.test(commit ?? "")) refuse("invalid-request");
if (!Array.isArray(have) || have.length > HAVE_MAX || have.some((h) => !OID.test(h ?? ""))) refuse("invalid-request", "have must list commit ids");
const repo = workingRepo(dataRoot, business, project);
return locked(repo, async () => {
try {
if (!lstatSync(repo).isDirectory()) refuse("working-repo-invalid");
} catch (e) {
if (e instanceof WorkingError) throw e;
refuse("unknown-commit", "the working repository doesn't exist");
}
await seed(repo, root, env);
const at = [`--git-dir=${repo}`];
if (!(await tip(repo, commit, env))) refuse("unknown-commit");
const known = [];
for (const h of have) if (await tip(repo, h, env)) known.push(h);
const temp = "refs/mosaic/review";
try {
if (!(await git([...at, "update-ref", temp, commit], env)).ok) refuse("fetch-failed");
const made = await git([...at, "bundle", "create", "-q", out, temp, ...(known.length ? ["--not", ...known] : [])], env);
if (!made.ok) refuse(/empty bundle/.test(made.err) ? "nothing-to-fetch" : "fetch-failed", made.why);
} finally {
await git([...at, "update-ref", "-d", temp], env);
}
return { ref: workRef(taskRef), commit };
});
}