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]>
245 lines
11 KiB
JavaScript
245 lines
11 KiB
JavaScript
// The session side of the host's git socket (#1587,
|
|
// packages/cli/src/git-socket.mjs). The runner calls these for the
|
|
// push_working and fetch_review tools, inside the sandbox, in the session's
|
|
// own clones under its workspace. Commits leave and arrive only as bundles:
|
|
//
|
|
// push `git bundle create` of the named branch, leaving out what the
|
|
// clone's origin and working remotes already have, written to the
|
|
// run's /tmp; the host takes it once the broker authorized the push.
|
|
// fetch the host sends a bundle of the commit the review request names;
|
|
// the runner fetches it with fsck on into
|
|
// refs/remotes/working/<work ref> of that project's clone.
|
|
//
|
|
// The bundle files are deleted on every path. git runs with no system or
|
|
// global config and no hooks, as the host's does.
|
|
|
|
import { spawn } from "node:child_process";
|
|
import { randomBytes } from "node:crypto";
|
|
import { closeSync, createReadStream, existsSync, openSync, readdirSync, rmSync, statSync, writeSync } from "node:fs";
|
|
import { connect } from "node:net";
|
|
import { join } from "node:path";
|
|
import { once } from "node:events";
|
|
|
|
// The host's cap (BUNDLE_MAX and HAVE_MAX in packages/seat/src/working.mjs;
|
|
// a test holds them equal). Kept here so the runner doesn't load the host's module.
|
|
export const BUNDLE_MAX = 32 * 1024 * 1024;
|
|
export const HAVE_MAX = 32;
|
|
const PROJECT = /^[a-z0-9][a-z0-9._-]{0,63}$/;
|
|
const WORK_REF = /^work\/vikunja-[1-9][0-9]*-[1-9][0-9]*$/;
|
|
const OID = /^[0-9a-f]{40}([0-9a-f]{24})?$/;
|
|
const TIMEOUT_MS = 300_000;
|
|
// How long a git has after SIGTERM before SIGKILL, as on the host.
|
|
const GRACE_MS = 5000;
|
|
const LIMITS = { timeoutMs: TIMEOUT_MS, graceMs: GRACE_MS };
|
|
const SAFE = [
|
|
"-c", "core.hooksPath=/dev/null",
|
|
"-c", "core.fsmonitor=false",
|
|
"-c", "protocol.allow=never",
|
|
"-c", "protocol.file.allow=always",
|
|
"-c", "submodule.recurse=false",
|
|
];
|
|
|
|
const failure = (code, message = code) => Object.assign(new Error(message), { code });
|
|
|
|
// The same rule as validBranch in packages/seat/src/working.mjs.
|
|
export const validBranch = (branch) =>
|
|
typeof branch === "string" && /^[A-Za-z0-9][A-Za-z0-9._\/-]{0,199}$/.test(branch) && !/\.\.|\/\/|\/\.|@\{|\.lock(\/|$)|[./]$/.test(branch);
|
|
|
|
// git with SAFE first, in its own process group (detached). Past
|
|
// limits.timeoutMs the group gets SIGTERM, on which git removes its
|
|
// lockfiles, then SIGKILL limits.graceMs later; once a signalled git has
|
|
// closed, the group gets SIGKILL again. The same sequence as the host's
|
|
// runGit in packages/seat/src/checkout.mjs, kept here so the runner doesn't
|
|
// load the host's module. Exported for the tests, which shorten `limits`.
|
|
export function git(args, env = process.env, limits = LIMITS) {
|
|
return new Promise((resolve) => {
|
|
const child = spawn("git", [...SAFE, ...args], {
|
|
env: { PATH: env.PATH ?? "/usr/bin:/bin", HOME: "/nonexistent", GIT_CONFIG_NOSYSTEM: "1", GIT_CONFIG_GLOBAL: "/dev/null", GIT_TERMINAL_PROMPT: "0", LC_ALL: "C" },
|
|
detached: true,
|
|
stdio: ["ignore", "pipe", "pipe"],
|
|
});
|
|
let out = "";
|
|
let err = "";
|
|
let signalled = false;
|
|
let kill = null;
|
|
// A git that never started has no pid; an empty group is ESRCH.
|
|
const group = (signal) => {
|
|
try {
|
|
process.kill(-child.pid, signal);
|
|
} catch {}
|
|
};
|
|
const term = setTimeout(() => {
|
|
signalled = true;
|
|
group("SIGTERM");
|
|
kill = setTimeout(() => group("SIGKILL"), limits.graceMs);
|
|
}, limits.timeoutMs);
|
|
const settle = (r) => {
|
|
clearTimeout(term);
|
|
clearTimeout(kill);
|
|
if (signalled) group("SIGKILL");
|
|
resolve(r);
|
|
};
|
|
child.stdout.setEncoding("utf8").on("data", (d) => (out += d));
|
|
child.stderr.setEncoding("utf8").on("data", (d) => (err += d));
|
|
child.on("error", (e) => settle({ ok: false, out, err, why: e.code ?? e.message }));
|
|
child.on("close", (code, signal) => settle({ ok: code === 0, out, err, why: signal ? `killed by ${signal}` : err.trim().split("\n").pop() || `exit ${code}` }));
|
|
});
|
|
}
|
|
|
|
function clone(workspace, project) {
|
|
if (!PROJECT.test(project ?? "")) throw failure("invalid-request", "not a project id");
|
|
const dir = join(workspace, project);
|
|
if (!existsSync(join(dir, ".git"))) throw failure("no-clone", `no clone of ${project} in the workspace`);
|
|
return dir;
|
|
}
|
|
|
|
// One connection to the git socket: JSON lines and raw bytes, read in order.
|
|
function open(path) {
|
|
if (typeof path !== "string" || !path) throw failure("not-authorized", "this session has no git socket");
|
|
const socket = connect(path);
|
|
let buf = Buffer.alloc(0);
|
|
let gone = null;
|
|
let wake = null;
|
|
const poke = () => wake?.();
|
|
socket.on("data", (c) => {
|
|
buf = buf.length ? Buffer.concat([buf, c]) : c;
|
|
poke();
|
|
});
|
|
socket.on("end", () => {
|
|
gone ??= "the host closed the connection";
|
|
poke();
|
|
});
|
|
socket.on("error", (e) => {
|
|
gone ??= `git socket: ${e.code ?? e.message}`;
|
|
poke();
|
|
});
|
|
socket.setTimeout(TIMEOUT_MS, () => {
|
|
gone ??= "git socket timed out";
|
|
socket.destroy();
|
|
poke();
|
|
});
|
|
const wait = () => new Promise((r) => (wake = () => ((wake = null), r())));
|
|
return {
|
|
socket,
|
|
async line() {
|
|
for (;;) {
|
|
const nl = buf.indexOf(0x0a);
|
|
if (nl >= 0) {
|
|
const text = buf.subarray(0, nl).toString("utf8");
|
|
buf = buf.subarray(nl + 1);
|
|
try {
|
|
return JSON.parse(text);
|
|
} catch {
|
|
throw failure("invalid-response", "the host's reply isn't JSON");
|
|
}
|
|
}
|
|
if (buf.length > 65536) throw failure("invalid-response", "the host's reply line is too long");
|
|
if (gone) throw failure("outcome-unknown", gone);
|
|
await wait();
|
|
}
|
|
},
|
|
async bytes(size, fd) {
|
|
let got = 0;
|
|
for (;;) {
|
|
if (buf.length) {
|
|
const take = buf.subarray(0, size - got);
|
|
writeSync(fd, take);
|
|
got += take.length;
|
|
buf = buf.subarray(take.length);
|
|
}
|
|
if (got === size) return;
|
|
if (gone) throw failure("outcome-unknown", gone);
|
|
await wait();
|
|
}
|
|
},
|
|
close: () => socket.destroy(),
|
|
};
|
|
}
|
|
|
|
const refused = (r) => {
|
|
if (r?.ok === false) throw failure(/^[a-z-]{1,64}$/.test(r.error ?? "") ? r.error : "refused");
|
|
if (r?.ok !== true) throw failure("invalid-response");
|
|
return r;
|
|
};
|
|
|
|
// await pushWorking({ path, cap, workspace, task_ref, project, branch, tmp })
|
|
// Resolves the host's { ref, commit, previous }.
|
|
export async function pushWorking({ path, cap, workspace, task_ref, project, branch, tmp = "/tmp", env = process.env }) {
|
|
const dir = clone(workspace, project);
|
|
if (!validBranch(branch)) throw failure("invalid-request", "not a branch name");
|
|
if (!(await git(["-C", dir, "rev-parse", "-q", "--verify", `refs/heads/${branch}^{commit}`], env)).ok) throw failure("no-such-branch", `no branch ${branch} in the ${project} clone`);
|
|
const file = join(tmp, `push-${randomBytes(8).toString("hex")}.bundle`);
|
|
try {
|
|
const made = await git(["-C", dir, "bundle", "create", "-q", file, `refs/heads/${branch}`, "--not", "--remotes=origin", "--remotes=working"], env);
|
|
if (!made.ok) throw failure(/empty bundle/.test(made.err) ? "nothing-to-push" : "bundle-failed", made.why);
|
|
const size = statSync(file).size;
|
|
if (size > BUNDLE_MAX) throw failure("bundle-too-large", `${size} bytes, over ${BUNDLE_MAX}`);
|
|
const conn = open(path);
|
|
try {
|
|
conn.socket.write(`${JSON.stringify({ cap, op: "push", task_ref, project, branch, size })}\n`);
|
|
refused(await conn.line());
|
|
for await (const chunk of createReadStream(file)) if (!conn.socket.write(chunk)) await once(conn.socket, "drain");
|
|
const result = refused(await conn.line()).result;
|
|
// The clone remembers what the working repository has, so the next
|
|
// push sends only what is new. Best effort: the push already landed.
|
|
if (WORK_REF.test(result?.ref ?? "") && OID.test(result.commit ?? "")) await git(["-C", dir, "update-ref", `refs/remotes/working/${result.ref}`, result.commit], env);
|
|
return result;
|
|
} finally {
|
|
conn.close();
|
|
}
|
|
} finally {
|
|
rmSync(file, { force: true });
|
|
}
|
|
}
|
|
|
|
// await fetchReview({ path, cap, workspace, task_ref, request, tmp })
|
|
// Resolves { project, ref, commit, clone }; ref is the clone's remote ref.
|
|
export async function fetchReview({ path, cap, workspace, task_ref, request, tmp = "/tmp", env = process.env }) {
|
|
// Commits the host may leave out: what the clones' remotes already hold.
|
|
// The working refs come first, since a review is most often of a commit
|
|
// built on one; origin's fill what is left of HAVE_MAX. The order is per
|
|
// clone, not across clones: each clone's refs newest first, one clone
|
|
// after another in the workspace's directory order. So past HAVE_MAX, a
|
|
// later clone's newer refs can lose to an earlier clone's older ones.
|
|
const have = new Set();
|
|
const projects = projectsIn(workspace);
|
|
for (const remote of ["refs/remotes/working", "refs/remotes/origin"])
|
|
for (const project of projects) {
|
|
if (have.size >= HAVE_MAX) break;
|
|
const r = await git(["-C", join(workspace, project), "for-each-ref", "--sort=-committerdate", "--format=%(objectname)", remote], env);
|
|
if (r.ok) for (const oid of r.out.split("\n")) if (OID.test(oid) && have.size < HAVE_MAX) have.add(oid);
|
|
}
|
|
const file = join(tmp, `fetch-${randomBytes(8).toString("hex")}.bundle`);
|
|
try {
|
|
const conn = open(path);
|
|
let result;
|
|
try {
|
|
conn.socket.write(`${JSON.stringify({ cap, op: "fetch", task_ref, request, have: [...have] })}\n`);
|
|
result = refused(await conn.line()).result;
|
|
if (!PROJECT.test(result?.project ?? "") || !WORK_REF.test(result.ref ?? "") || !OID.test(result.commit ?? "") || !Number.isSafeInteger(result.size) || result.size < 1 || result.size > BUNDLE_MAX)
|
|
throw failure("invalid-response");
|
|
const fd = openSync(file, "wx", 0o600);
|
|
try {
|
|
await conn.bytes(result.size, fd);
|
|
} finally {
|
|
closeSync(fd);
|
|
}
|
|
} finally {
|
|
conn.close();
|
|
}
|
|
const dir = clone(workspace, result.project);
|
|
const into = `refs/remotes/working/${result.ref}`;
|
|
const fetched = await git(["-c", "transfer.fsckObjects=true", "-C", dir, "fetch", "-q", "--no-tags", "--no-write-fetch-head", "--no-recurse-submodules", "--no-auto-maintenance", file, `+refs/mosaic/review:${into}`], env);
|
|
if (!fetched.ok) throw failure("fetch-failed", fetched.why);
|
|
const tip = await git(["-C", dir, "rev-parse", "-q", "--verify", `${into}^{commit}`], env);
|
|
if (tip.out.trim() !== result.commit) throw failure("fetch-failed", "the fetched commit isn't the one the host named");
|
|
return { project: result.project, ref: `working/${result.ref}`, commit: result.commit, clone: dir };
|
|
} finally {
|
|
rmSync(file, { force: true });
|
|
}
|
|
}
|
|
|
|
// The project clones in a workspace: each directory with a project id's
|
|
// name and a .git.
|
|
const projectsIn = (workspace) => readdirSync(workspace).filter((name) => PROJECT.test(name) && existsSync(join(workspace, name, ".git")));
|