cli, seat, harness: a project clone in each role workspace (#1585)
Row 91, round 3. Darkwing built it, Dewey approved it (comment 28150) on manifest ca570607 (8 paths, base2b2a9e14). At each launch the host makes or updates a git clone of the project in the role's workspace, and the session prompt names each clone. Round 3 closes Filbert's round 2 escape: a clone with a submodule is kept as it is, and no host git recurses into one (decision 130 thread). Live at the next bus restart; the prompt lines appear only when the launcher passes checkouts. Sage gate onfc6d5822plus the patch, 14:18:25Z to 14:22:27Z: bus 115, business 60, cli 131, control-board 124, conversation 182, discord 178, harness 67, ledger 78, mosaic 69, queue 150, runs 43, seat 89, tasks 89, webui 104, all fail 0. Every scripts/test-*.sh exited 0; test-release ran 14 cases. Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
@@ -0,0 +1,244 @@
|
||||
// The project checkouts in a role workspace (#1585). At every launch the
|
||||
// bus host makes sure each project of the business file has a git clone at
|
||||
// `<workspace>/<project id>/`, before it builds the bundle. The host runs
|
||||
// git here, outside the session's sandbox:
|
||||
//
|
||||
// no clone yet `git clone --no-hardlinks` of the project's root, at
|
||||
// the branch the root has checked out, into a temp
|
||||
// directory beside it and then renamed into place; a
|
||||
// clone that fails is removed and refuses the launch
|
||||
// a clean clone on fetched from its origin and fast-forwarded
|
||||
// the root's branch
|
||||
// anything else left exactly as it is, with the reason
|
||||
//
|
||||
// The host never resets, cleans, deletes or force-updates a clone, and
|
||||
// never writes the root. A clone's origin is the root's path; no credential
|
||||
// or forge remote goes into it.
|
||||
//
|
||||
// The session can write its clone, so the host treats every clone as
|
||||
// hostile. A git config key, a hook or a redirected .git would otherwise
|
||||
// run a command or write a file outside the sandbox when the host runs git
|
||||
// there. So every git call gets an environment without the system and
|
||||
// global config, hooks pointed at /dev/null, fsmonitor off and only local
|
||||
// transports. And before git runs in an existing clone, the host checks
|
||||
// that the clone and its .git are real directories, that nothing under
|
||||
// .git is a symlink, a special file or a file with a second hard link,
|
||||
// that no commondir or alternates file sends git elsewhere, that
|
||||
// .git/hooks is empty, and that .git/config is a small regular file
|
||||
// holding only what a clone writes (allowed() below). A clone that fails a
|
||||
// check is kept, and git doesn't run in it.
|
||||
//
|
||||
// Those checks cover the clone's own .git and nothing else. A submodule
|
||||
// has its own config, which can name a filter driver, and git status
|
||||
// recurses into a populated submodule by running a second git in it. So
|
||||
// a clone with a gitlink in its index or anything under .git/modules is
|
||||
// kept too, and no host git recurses into a submodule: status ignores
|
||||
// them and submodule.recurse is off for every call.
|
||||
//
|
||||
// The checks hold only while nothing else writes the clone. The launcher
|
||||
// calls this for an instance only when no session of that instance runs
|
||||
// (its instance-running refusal), and each workspace is one instance's.
|
||||
|
||||
import { spawn } from "node:child_process";
|
||||
import { randomBytes } from "node:crypto";
|
||||
import { lstatSync, readdirSync, renameSync, rmSync } from "node:fs";
|
||||
import { dirname, join } from "node:path";
|
||||
|
||||
export const GIT_TIMEOUT_MS = 100_000;
|
||||
// A clone's config is a few hundred bytes, a little more per branch.
|
||||
export const CONFIG_MAX_BYTES = 64 * 1024;
|
||||
|
||||
export class CheckoutError extends Error {
|
||||
constructor(project, message) {
|
||||
super(message);
|
||||
this.project = project;
|
||||
}
|
||||
}
|
||||
|
||||
// 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",
|
||||
];
|
||||
|
||||
// The environment git runs with: the host's PATH and nothing else of it.
|
||||
// HOME doesn't exist, so no user file (attributes, ignore) is read either.
|
||||
export function gitEnv(env = process.env) {
|
||||
return {
|
||||
PATH: env.PATH ?? "/usr/bin:/bin",
|
||||
HOME: "/nonexistent",
|
||||
GIT_CONFIG_NOSYSTEM: "1",
|
||||
GIT_CONFIG_GLOBAL: "/dev/null",
|
||||
GIT_TERMINAL_PROMPT: "0",
|
||||
GIT_MERGE_AUTOEDIT: "no",
|
||||
LC_ALL: "C",
|
||||
};
|
||||
}
|
||||
|
||||
// Async, so a long clone doesn't hold up the rest of the bus host.
|
||||
function git(args, env, extra = {}) {
|
||||
return new Promise((resolve) => {
|
||||
const child = spawn("git", [...SAFE, ...args], { env: { ...gitEnv(env), ...extra }, 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));
|
||||
// A git that can't start may not emit close, so error settles it too.
|
||||
child.on("error", (e) => resolve({ ok: false, out, why: e.code ?? e.message }));
|
||||
child.on("close", (code, signal) => resolve({ ok: code === 0, out, why: signal ? `killed by ${signal}` : err.trim().split("\n").pop() || `exit ${code}` }));
|
||||
});
|
||||
}
|
||||
|
||||
const inClone = (dest) => [`--git-dir=${join(dest, ".git")}`, `--work-tree=${dest}`];
|
||||
|
||||
// The keys `git clone` writes, and what each may hold. branch.<name>.remote
|
||||
// and .merge are the ones `git checkout -b`, `git branch -u` and the like
|
||||
// add. Every other key keeps the clone.
|
||||
const FETCH = "+refs/heads/*:refs/remotes/origin/*";
|
||||
function allowed(key, value, root) {
|
||||
const branch = /^branch\..+\.(remote|merge)$/.exec(key);
|
||||
if (branch) return branch[1] === "remote" ? value === "origin" : /^refs\/heads\/./.test(value ?? "");
|
||||
switch (key) {
|
||||
case "core.repositoryformatversion": return value === "0";
|
||||
case "core.filemode": return value === "true" || value === "false";
|
||||
case "core.bare": return value === "false";
|
||||
case "core.logallrefupdates": return value === "true";
|
||||
case "remote.origin.url": return value === root;
|
||||
case "remote.origin.fetch": return value === FETCH;
|
||||
default: return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Why the clone at `dest` isn't safe for the host to run git in, or null.
|
||||
async function unsafe(dest, root, env) {
|
||||
const isDir = (p) => {
|
||||
try {
|
||||
return lstatSync(p).isDirectory();
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
if (!isDir(dest) || !isDir(join(dest, ".git"))) return "not-a-clone";
|
||||
// A second hard link would let a host write through to a file outside.
|
||||
const walk = (dir) => {
|
||||
for (const name of readdirSync(dir)) {
|
||||
const st = lstatSync(join(dir, name));
|
||||
if (st.isDirectory()) {
|
||||
if (walk(join(dir, name))) return true;
|
||||
} else if (!st.isFile() || st.nlink !== 1) return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
try {
|
||||
if (walk(join(dest, ".git"))) return "not-a-clone";
|
||||
} catch {
|
||||
return "not-a-clone";
|
||||
}
|
||||
// A clone never has .git/modules; `git submodule` in the session makes one.
|
||||
try {
|
||||
lstatSync(join(dest, ".git", "modules"));
|
||||
return "submodule";
|
||||
} catch {}
|
||||
for (const f of ["commondir", join("objects", "info", "alternates")]) {
|
||||
try {
|
||||
lstatSync(join(dest, ".git", f));
|
||||
return "not-a-clone";
|
||||
} catch {}
|
||||
}
|
||||
try {
|
||||
if (readdirSync(join(dest, ".git", "hooks")).length > 0) return "hooks-present";
|
||||
} catch (e) {
|
||||
if (e.code !== "ENOENT") return "not-a-clone";
|
||||
}
|
||||
try {
|
||||
const st = lstatSync(join(dest, ".git", "config"));
|
||||
if (!st.isFile() || st.size > CONFIG_MAX_BYTES) return "config-changed";
|
||||
} catch {
|
||||
return "config-changed";
|
||||
}
|
||||
const list = await git(["config", "--file", join(dest, ".git", "config"), "--list", "--null"], env);
|
||||
if (!list.ok) return "config-changed";
|
||||
const seen = new Set();
|
||||
for (const entry of list.out.split("\0").filter(Boolean)) {
|
||||
const nl = entry.indexOf("\n");
|
||||
const [key, value] = nl < 0 ? [entry, null] : [entry.slice(0, nl), entry.slice(nl + 1)];
|
||||
if (seen.has(key) || !allowed(key, value, root)) return "config-changed";
|
||||
seen.add(key);
|
||||
}
|
||||
if (!seen.has("remote.origin.url")) return "config-changed";
|
||||
return null;
|
||||
}
|
||||
|
||||
// The branch the root has checked out, null when it has none, or an error.
|
||||
// The ceiling stops git finding a repository above a root that isn't one.
|
||||
async function rootBranch(root, env) {
|
||||
const ceiling = { GIT_CEILING_DIRECTORIES: dirname(root) };
|
||||
const r = await git(["-C", root, "symbolic-ref", "-q", "--short", "HEAD"], env, ceiling);
|
||||
if (r.ok) return { branch: r.out.trim() };
|
||||
// symbolic-ref -q exits 1 with no output on a detached HEAD.
|
||||
const head = await git(["-C", root, "rev-parse", "--verify", "-q", "HEAD"], env, ceiling);
|
||||
return head.ok ? { branch: null } : { error: r.why };
|
||||
}
|
||||
|
||||
async function clone(project, root, workspace, dest, env) {
|
||||
const at = await rootBranch(root, env);
|
||||
if (at.error) throw new CheckoutError(project, `the root ${root} can't be read: ${at.error}`);
|
||||
if (!at.branch) throw new CheckoutError(project, `the root ${root} has no branch checked out`);
|
||||
// A project id never starts with a dot, so the temp name is no project's.
|
||||
const tmp = join(workspace, `.${project}.clone-${randomBytes(4).toString("hex")}`);
|
||||
try {
|
||||
const r = await git(["clone", "-q", "--no-hardlinks", "--template=", "--branch", at.branch, "--", root, tmp], env);
|
||||
if (!r.ok) throw new CheckoutError(project, `git clone of ${root} failed: ${r.why}`);
|
||||
renameSync(tmp, dest);
|
||||
} catch (e) {
|
||||
rmSync(tmp, { recursive: true, force: true });
|
||||
if (e instanceof CheckoutError) throw e;
|
||||
throw new CheckoutError(project, `the clone of ${root} couldn't be put in place: ${e.message}`);
|
||||
}
|
||||
return { outcome: "cloned", branch: at.branch };
|
||||
}
|
||||
|
||||
async function update(root, dest, env) {
|
||||
const problem = await unsafe(dest, root, env);
|
||||
if (problem) return { outcome: "kept", reason: problem };
|
||||
// ls-files reads only the index: no filter, hook or submodule runs.
|
||||
const staged = await git([...inClone(dest), "ls-files", "--stage", "-z"], env);
|
||||
if (!staged.ok) return { outcome: "kept", reason: "status-failed" };
|
||||
if (staged.out.split("\0").some((e) => e.startsWith("160000 "))) return { outcome: "kept", reason: "submodule" };
|
||||
const at = await rootBranch(root, env);
|
||||
if (at.error) return { outcome: "kept", reason: "root-unreadable" };
|
||||
if (!at.branch) return { outcome: "kept", reason: "root-detached" };
|
||||
const head = await git([...inClone(dest), "symbolic-ref", "-q", "--short", "HEAD"], env);
|
||||
if (!head.ok || head.out.trim() !== at.branch) return { outcome: "kept", reason: "other-branch" };
|
||||
const status = await git(["--no-optional-locks", ...inClone(dest), "status", "--porcelain=v1", "-z", "--untracked-files=all", "--ignore-submodules=all"], env);
|
||||
if (!status.ok) return { outcome: "kept", reason: "status-failed" };
|
||||
if (status.out !== "") return { outcome: "kept", reason: "dirty" };
|
||||
const before = (await git([...inClone(dest), "rev-parse", "HEAD"], env)).out.trim();
|
||||
if (!(await git([...inClone(dest), "fetch", "-q", "--no-tags", "--no-recurse-submodules", "origin"], env)).ok) return { outcome: "kept", reason: "fetch-failed" };
|
||||
if (!(await git([...inClone(dest), "merge", "-q", "--ff-only", "--no-edit", `refs/remotes/origin/${at.branch}`], env)).ok) return { outcome: "kept", reason: "not-fast-forward" };
|
||||
const after = (await git([...inClone(dest), "rev-parse", "HEAD"], env)).out.trim();
|
||||
return { outcome: before === after ? "current" : "updated", branch: at.branch };
|
||||
}
|
||||
|
||||
// await ensureCheckouts({ workspace, projects, env }) for the business file's
|
||||
// projects ({ <id>: { root } }). Returns one { project, path, outcome,
|
||||
// reason?, branch? } per project, outcome cloned, updated, current or kept.
|
||||
// Rejects with CheckoutError when a missing clone can't be made.
|
||||
export async function ensureCheckouts({ workspace, projects, env = process.env }) {
|
||||
const out = [];
|
||||
for (const [project, { root }] of Object.entries(projects)) {
|
||||
const dest = join(workspace, project);
|
||||
let exists = true;
|
||||
try {
|
||||
lstatSync(dest);
|
||||
} catch (e) {
|
||||
if (e.code !== "ENOENT") throw new CheckoutError(project, `${dest} can't be read: ${e.code}`);
|
||||
exists = false;
|
||||
}
|
||||
out.push({ project, path: dest, ...(exists ? await update(root, dest, env) : await clone(project, root, workspace, dest, env)) });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
Reference in New Issue
Block a user