Files
stack/packages/seat/src/working.mjs
T
jason.woltjeandClaude Opus 5.5 465bbdfbbc seat, cli, harness: git kill reach, close bound and host.lock (#1597)
Row 103, the row 100 follow-up. Built by Darkwing, packet
agents/darkwing/work/queue-103/r1/, candidate manifest
6e5ac8c0782bc595d7c0d711d457738ffb42f1ba5f38253912943dd55e55888a.
Sage approved on #1597, comment 28252; Dewey approved, comment 28260.

Host git runs through runGit: a detached group, SIGTERM at the limit,
SIGKILL after the grace, and one more SIGKILL to the group after a
signalled git closes. git-socket close() waits at most 2 s. startHost
claims bus-host/host.lock by hard link before the broker fork.
packages/cli/README.md and tests/host.test.mjs carry row 85's hunks
too; row 85 lands first.

Sage's gate rerun on 3a1e11b83 with rows 85, 102 and 103 applied,
Docker up: node suites bus 128, business 67, cli 165, control-board
124, conversation 182, discord 178, harness 73, ledger 78, mosaic 69,
queue 155, runs 43, seat 113, tasks 92, webui 108; scripts auth 15,
conductor 17, config 24, discord 66, extension-package 18,
foundation 44, queue 28, release 14, task 98. All pass, 0 fail.

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

251 lines
12 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. A work ref
// that can't go back refuses rollback-failed in place of record's error.
// The temp ref is deleted on every path. The caller owns the bundle file and
// deletes it.
//
// A git past GIT_TIMEOUT_MS gets SIGTERM, on which git removes its lockfiles,
// and SIGKILL only if it still runs GIT_GRACE_MS later (checkout.mjs). Both
// go to git's process group, so nothing git started outlives it. A SIGKILL
// leaves a ref's .lock behind, and that lock refuses every later push to the
// ref.
//
// The lock is a promise chain in this process, so it holds only while one
// launcher serves a dataRoot. startHost refuses a second bus host for the
// dataRoot: it takes bus-host/host.lock with a hard link, which fails if
// the file exists.
//
// 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 { randomBytes } from "node:crypto";
import { closeSync, lstatSync, mkdirSync, openSync, readSync, renameSync, rmSync } from "node:fs";
import { dirname, join } from "node:path";
import { gitEnv, GIT_LIMITS, runGit } 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"];
// `limits` is { timeoutMs, graceMs }; the tests shorten them. runGit
// (checkout.mjs) runs git in its own process group and signals the group.
const git = (args, env, { pre = [], limits = GIT_LIMITS } = {}) => runGit([...SAFE, ...pre, ...args], gitEnv(env), limits);
// git with this call's env and limits, for the helpers below.
const bound = (env, limits) => (args, opts = {}) => git(args, env, { ...opts, limits });
// 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, g) {
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 g(["init", "-q", "--bare", "--template=", tmp]);
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, g) {
const r = await g([`--git-dir=${repo}`, "fetch", "-q", "--no-tags", "--no-write-fetch-head", "--no-recurse-submodules", "--no-auto-maintenance", root, "+HEAD:refs/heads/main"]);
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, g) => {
const r = await g([`--git-dir=${repo}`, "rev-parse", "-q", "--verify", `${ref}^{commit}`]);
return r.ok ? r.out.trim() : null;
};
// await push({ dataRoot, business, project, root, taskRef, branch, bundle, record, env, fsck, limits })
// Resolves { ref, commit, previous }; rejects with WorkingError. `record`
// runs under the lock once the ref moved, and a throw moves it back. `fsck`
// and `limits` are for the tests.
export function push({ dataRoot, business, project, root, taskRef, branch, bundle, record = async () => {}, env = process.env, fsck = FSCK, limits = GIT_LIMITS }) {
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);
const g = bound(env, limits);
return locked(repo, async () => {
if (!header(bundle)) refuse("invalid-bundle", "not a v2 git bundle");
await ensure(repo, g);
await seed(repo, root, g);
const at = [`--git-dir=${repo}`];
// Not -q: with it, git 2.55 drops the list of missing prerequisites.
const verify = await g([...at, "bundle", "verify", bundle]);
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 g([...at, "bundle", "list-heads", bundle]);
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 g([...at, "fetch", "-q", "--no-tags", "--no-write-fetch-head", "--no-recurse-submodules", "--no-auto-maintenance", bundle, `refs/heads/${branch}:${incoming}`], { pre: fsck });
if (!fetched.ok) refuse("invalid-bundle", fetched.why);
const commit = await tip(repo, incoming, g);
if (!commit) refuse("invalid-bundle", "the branch isn't a commit");
if (!(await g([...at, "merge-base", commit, "refs/heads/main"])).ok) refuse("unrelated-history", "the commit shares no history with main");
const previous = await tip(repo, ref, g);
// 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 g([...at, "fetch", "--no-tags", "--no-write-fetch-head", "--no-auto-maintenance", repo, `${incoming}:${ref}`]);
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];
const undone = await g([...at, ...back]);
// The ref still names a commit the broker didn't record. That is the
// operator's to fix, so it outranks record's own error.
if (!undone.ok) refuse("rollback-failed", `${ref} still names ${commit}, which wasn't recorded (${e.code ?? e.message}); moving it back failed: ${undone.why}`);
throw e;
}
return { ref: workRef(taskRef), commit, previous };
} finally {
await g([...at, "update-ref", "-d", incoming]);
}
});
}
// await review({ dataRoot, business, project, root, taskRef, commit, have, out, env, limits })
// 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, limits = GIT_LIMITS }) {
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);
const g = bound(env, limits);
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, g);
const at = [`--git-dir=${repo}`];
if (!(await tip(repo, commit, g))) refuse("unknown-commit");
const known = [];
for (const h of have) if (await tip(repo, h, g)) known.push(h);
const temp = "refs/mosaic/review";
try {
if (!(await g([...at, "update-ref", temp, commit])).ok) refuse("fetch-failed");
const made = await g([...at, "bundle", "create", "-q", out, temp, ...(known.length ? ["--not", ...known] : [])]);
if (!made.ok) refuse(/empty bundle/.test(made.err) ? "nothing-to-fetch" : "fetch-failed", made.why);
} finally {
await g([...at, "update-ref", "-d", temp]);
}
return { ref: workRef(taskRef), commit };
});
}