// 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/ 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")));