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, base 2b2a9e14). 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 on fc6d5822 plus 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:
2026-10-13 09:23:10 -05:00
co-authored by Claude Opus 5.5
parent fc6d58220e
commit d240645df0
8 changed files with 782 additions and 10 deletions
+56
View File
@@ -233,6 +233,62 @@ The sandbox doesn't confine the network, abstract Unix sockets, or the
user: a session runs as the host's UID. `packages/harness/README.md`
("Limits") says what that leaves open.
### The project clones (#1585)
An instance's workspace holds one git clone per project in the business
file, at `<workspace>/<project id>/`. The launcher calls `ensureCheckouts`
(`src/checkout.mjs`) at each launch, before it builds the bundle, so a
session starts in the project and not beside it. The host runs git here,
outside the sandbox:
| The clone | What the host does | Outcome |
|---|---|---|
| doesn't exist | `git clone --no-hardlinks --template=` of the root, at the branch the root has checked out, into `.<project>.clone-<hex>` beside it, then renamed into place | `cloned` |
| is clean, on the root's branch | fetches its origin and fast-forwards | `updated`, or `current` when nothing moved |
| anything else | nothing | `kept`, with a 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, and the root's own remotes and config don't
reach it.
A kept clone's reason is one of:
| Reason | The clone |
|---|---|
| `not-a-clone` | isn't a directory with a `.git` directory; or under `.git` there is a symlink, a special file, a file with a second hard link, a `commondir` or an `objects/info/alternates` |
| `hooks-present` | has a file in `.git/hooks` |
| `submodule` | has a `.git/modules`, or a gitlink (a submodule entry) in its index |
| `config-changed` | has a `.git/config` that isn't a regular file of at most 64 KiB, or that holds a key a clone doesn't write: anything beyond `core.repositoryformatversion`, `core.filemode`, `core.bare`, `core.logallrefupdates`, the origin's one URL (the root) and fetch line, and `branch.<name>.remote` (`origin`) and `.merge` |
| `root-unreadable`, `root-detached` | is fine, but the root can't be read or has no branch checked out |
| `other-branch` | is on another branch, or on none |
| `dirty` | has a change, staged or not, or an untracked file |
| `status-failed`, `fetch-failed` | made `git ls-files`, `git status` or the fetch fail |
| `not-fast-forward` | has commits the root's branch doesn't |
The session can write its clone, so the host treats every clone as
hostile. The checks above run before any git command in an existing clone,
and git doesn't run in a clone that fails one. Every git call also runs
with only the host's `PATH`, `HOME` at a directory that doesn't exist, no
system or global config, hooks at `/dev/null`, `core.fsmonitor` off and
only the `file` transport. A submodule has its own config, which none of
those checks reads and which can name a filter that `git status` would run,
so the host never recurses into one: a clone with a submodule is kept, and
`status` runs with `--ignore-submodules=all` and every call with
`submodule.recurse=false`. The root is read with `GIT_CEILING_DIRECTORIES`
at its parent, so a root that isn't a repository is never taken for the
one above it. Each git call is killed after 100 s.
The checks hold only while nothing else writes the clone. Each workspace
is one instance's, and the launcher refuses `instance-running` before it
gets here, so no session of that instance runs while the host checks and
updates its clone.
A missing clone that can't be made (the root can't be read, has no branch,
or the clone fails) rejects with a `CheckoutError` naming the project, and
the temp directory is removed. The launcher refuses the launch with
`checkout-failed` (`packages/cli/README.md`, "Sessions").
## Exit codes
`launch` exits with the launch script's own code once the script runs.
+244
View File
@@ -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;
}
+333
View File
@@ -0,0 +1,333 @@
// The project checkouts in a role workspace (src/checkout.mjs), against
// real git repositories in a temp directory. Every case also checks that
// the root's files and refs are byte for byte what they were.
import { test } from "node:test";
import assert from "node:assert/strict";
import { spawnSync } from "node:child_process";
import { createHash } from "node:crypto";
import { chmodSync, existsSync, linkSync, lstatSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, readlinkSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { CONFIG_MAX_BYTES, CheckoutError, ensureCheckouts, gitEnv } from "../src/checkout.mjs";
function tmp(t) {
const dir = mkdtempSync(join(tmpdir(), "mosaic-checkout-"));
t.after(() => {
spawnSync("chmod", ["-R", "u+rwx", dir]);
rmSync(dir, { recursive: true, force: true });
});
return dir;
}
// git for the test's own setup, with no user config.
function git(cwd, ...args) {
const r = spawnSync("git", ["-c", "user.name=t", "-c", "[email protected]", ...args], { cwd, encoding: "utf8", env: gitEnv() });
assert.equal(r.status, 0, `git ${args.join(" ")}: ${r.stderr}`);
return r.stdout.trim();
}
// A repository on `main` with one commit.
function repo(dir, name = "root") {
const root = join(dir, name);
mkdirSync(root);
git(root, "init", "-q", "-b", "main");
writeFileSync(join(root, "README"), "one\n");
git(root, "add", "README");
git(root, "commit", "-q", "-m", "one");
return root;
}
function commit(root, text) {
writeFileSync(join(root, "README"), text);
git(root, "commit", "-q", "-am", text.trim());
return git(root, "rev-parse", "HEAD");
}
// Every path under `dir` with its type, mode, link count and contents or
// link target.
function fingerprint(dir) {
const h = createHash("sha256");
const walk = (d) => {
for (const name of readdirSync(d).sort()) {
const p = join(d, name);
const st = lstatSync(p);
h.update(`${p}\0${st.mode}\0${st.nlink}\0`);
if (st.isDirectory()) walk(p);
else if (st.isSymbolicLink()) h.update(readlinkSync(p));
else if (st.isFile()) h.update(readFileSync(p));
}
};
walk(dir);
return h.digest("hex");
}
// A root, an empty workspace, and `ensure()` for the one project `stack`.
function world(t) {
const dir = tmp(t);
const root = repo(dir);
const workspace = join(dir, "ws");
mkdirSync(workspace, { mode: 0o700 });
const projects = { stack: { root } };
return { dir, root, workspace, clone: join(workspace, "stack"), ensure: () => ensureCheckouts({ workspace, projects }) };
}
// ensure() returning its one checkout, with the root unchanged by it.
async function once(w) {
const before = fingerprint(w.root);
const list = await w.ensure();
assert.equal(list.length, 1);
assert.equal(fingerprint(w.root), before, "the root is unchanged");
return list[0];
}
test("a first launch clones the root at its branch, with the root's path as origin", async (t) => {
const w = world(t);
assert.deepEqual(await once(w), { project: "stack", path: w.clone, outcome: "cloned", branch: "main" });
assert.equal(git(w.clone, "symbolic-ref", "--short", "HEAD"), "main");
assert.equal(git(w.clone, "rev-parse", "HEAD"), git(w.root, "rev-parse", "HEAD"));
assert.equal(git(w.clone, "remote", "get-url", "origin"), w.root);
assert.equal(readFileSync(join(w.clone, "README"), "utf8"), "one\n");
assert.deepEqual(readdirSync(w.workspace), ["stack"], "no temp directory is left");
// --no-hardlinks: no file under .git is shared with the root, and no hook came with it.
const files = readdirSync(join(w.clone, ".git"), { recursive: true }).map((p) => join(w.clone, ".git", p)).filter((p) => lstatSync(p).isFile());
assert.ok(files.some((p) => p.includes("/objects/")));
for (const p of files) assert.equal(lstatSync(p).nlink, 1, p);
assert.equal(existsSync(join(w.clone, ".git", "hooks")), false);
});
// A host stopped mid-clone must not leave a half clone where the next
// launch takes it for the session's. A git on PATH runs the real one, then
// notes whether <workspace>/stack exists yet.
test("the clone is not at its place until git clone has finished", async (t) => {
const w = world(t);
const bin = join(w.dir, "bin");
mkdirSync(bin);
const real = spawnSync("sh", ["-c", "command -v git"], { encoding: "utf8" }).stdout.trim();
const seen = join(w.dir, "seen");
writeFileSync(join(bin, "git"), `#!/bin/sh\n'${real}' "$@"; s=$?\ncase " $* " in *" clone "*) if [ -e '${w.clone}' ]; then echo there; else echo absent; fi >> '${seen}';; esac\nexit $s\n`, { mode: 0o755 });
const list = await ensureCheckouts({ workspace: w.workspace, projects: { stack: { root: w.root } }, env: { PATH: `${bin}:${process.env.PATH}` } });
assert.equal(list[0].outcome, "cloned");
assert.equal(readFileSync(seen, "utf8"), "absent\n");
assert.deepEqual(readdirSync(w.workspace), ["stack"]);
});
test("a later launch fast-forwards a clean clone after a commit to the root, then finds it current", async (t) => {
const w = world(t);
await once(w);
const head = commit(w.root, "two\n");
assert.deepEqual(await once(w), { project: "stack", path: w.clone, outcome: "updated", branch: "main" });
assert.equal(git(w.clone, "rev-parse", "HEAD"), head);
assert.equal(readFileSync(join(w.clone, "README"), "utf8"), "two\n");
assert.deepEqual(await once(w), { project: "stack", path: w.clone, outcome: "current", branch: "main" });
assert.equal(git(w.clone, "status", "--porcelain"), "");
});
test("a clone ahead of the root keeps its commits and counts as current", async (t) => {
const w = world(t);
await once(w);
const mine = commit(w.clone, "mine\n");
assert.equal((await once(w)).outcome, "current");
assert.equal(git(w.clone, "rev-parse", "HEAD"), mine);
});
test("a dirty clone, one on another branch and one diverged are kept as they are", async (t) => {
for (const [name, edit, reason] of [
["a modified file", (c) => writeFileSync(join(c, "README"), "mine\n"), "dirty"],
["an untracked file", (c) => writeFileSync(join(c, "NEW"), "mine\n"), "dirty"],
["a staged file", (c) => (writeFileSync(join(c, "NEW"), "x"), git(c, "add", "NEW")), "dirty"],
["another branch", (c) => git(c, "checkout", "-q", "-b", "work"), "other-branch"],
["a detached HEAD", (c) => git(c, "checkout", "-q", "--detach"), "other-branch"],
["a diverged branch", (c) => commit(c, "mine\n"), "not-fast-forward"],
]) {
await t.test(name, async (t) => {
const w = world(t);
await once(w);
edit(w.clone);
commit(w.root, "two\n");
const head = git(w.clone, "rev-parse", "HEAD");
const before = reason === "not-fast-forward" ? null : fingerprint(w.clone);
assert.deepEqual(await once(w), { project: "stack", path: w.clone, outcome: "kept", reason });
assert.equal(git(w.clone, "rev-parse", "HEAD"), head, "HEAD didn't move");
// A diverged clone gets the fetch into refs/remotes, and nothing else.
if (before) assert.equal(fingerprint(w.clone), before, "the clone is exactly as it was");
else assert.equal(readFileSync(join(w.clone, "README"), "utf8"), "mine\n");
});
}
});
// A repository outside the workspace whose config and hooks run a command
// that touches `marker`, for the cases that point git there.
function bait(dir, marker) {
const evil = repo(dir, "evil");
const script = join(dir, "run.sh");
writeFileSync(script, `#!/bin/sh\ntouch '${marker}'\n`, { mode: 0o755 });
git(evil, "config", "core.fsmonitor", script);
git(evil, "config", "core.hooksPath", join(evil, ".git", "hooks"));
mkdirSync(join(evil, ".git", "hooks"), { recursive: true });
for (const h of ["post-merge", "post-checkout", "reference-transaction", "pre-auto-gc", "post-index-change"]) writeFileSync(join(evil, ".git", "hooks", h), `#!/bin/sh\ntouch '${marker}'\n`, { mode: 0o755 });
return { evil, script };
}
// A repository at <dir>/sub whose own config names a clean filter that
// runs the bait script, applied to a tracked file left modified at the same
// size, so any git status inside it must hash the file and run the filter.
// Returns its commit.
function nested(dir, b) {
const sub = join(dir, "sub");
mkdirSync(sub);
git(sub, "init", "-q", "-b", "main");
writeFileSync(join(sub, "data"), "one\n");
writeFileSync(join(sub, ".gitattributes"), "data filter=x\n");
git(sub, "add", "data", ".gitattributes");
git(sub, "commit", "-q", "-m", "sub");
git(sub, "config", "filter.x.clean", b.script);
writeFileSync(join(sub, "data"), "ONE\n");
return git(sub, "rev-parse", "HEAD");
}
const gitlink = (c, b) => git(c, "update-index", "--add", "--cacheinfo", `160000,${nested(c, b)},sub`);
// <clone>/sub carries its own gitlink to <clone>/sub/sub, which alone names the filter.
const gitlinkNested = (c, b) => {
const outer = join(c, "sub");
mkdirSync(outer);
git(outer, "init", "-q", "-b", "main");
writeFileSync(join(outer, "f"), "x\n");
git(outer, "add", "f");
gitlink(outer, b);
git(outer, "commit", "-q", "-m", "outer");
git(c, "update-index", "--add", "--cacheinfo", `160000,${git(outer, "rev-parse", "HEAD")},sub`);
};
const gitmodules = (c) => (writeFileSync(join(c, ".gitmodules"), '[submodule "sub"]\n\tpath = sub\n\turl = ./sub\n'), git(c, "add", ".gitmodules"));
// Each way a session could make the host's git run a command or write
// outside the clone. Every one keeps the clone, nothing runs, and neither
// the root nor the bait repository changes.
test("a clone the session rigged is kept, and git runs nothing and writes nothing outside it", async (t) => {
const egit = (b) => join(b.evil, ".git");
for (const [name, edit, reason] of [
["core.fsmonitor", (c, b) => git(c, "config", "core.fsmonitor", b.script), "config-changed"],
["core.hooksPath", (c, b) => git(c, "config", "core.hooksPath", join(egit(b), "hooks")), "config-changed"],
["a hook file", (c, b) => {
mkdirSync(join(c, ".git", "hooks"));
writeFileSync(join(c, ".git", "hooks", "post-merge"), readFileSync(join(egit(b), "hooks", "post-merge")), { mode: 0o755 });
}, "hooks-present"],
["include.path", (c, b) => git(c, "config", "include.path", join(egit(b), "config")), "config-changed"],
["includeIf", (c, b) => git(c, "config", "includeIf.gitdir:/.path", join(egit(b), "config")), "config-changed"],
["a filter driver", (c, b) => {
git(c, "config", "filter.x.smudge", `${b.script}; cat`);
mkdirSync(join(c, ".git", "info"), { recursive: true });
writeFileSync(join(c, ".git", "info", "attributes"), "* filter=x\n");
}, "config-changed"],
["origin moved", (c, b) => git(c, "remote", "set-url", "origin", b.evil), "config-changed"],
["a second origin url", (c, b) => git(c, "config", "--add", "remote.origin.url", b.evil), "config-changed"],
["the origin url twice, both the root", (c) => git(c, "config", "--add", "remote.origin.url", git(c, "config", "--get", "remote.origin.url")), "config-changed"],
["an ext:: origin", (c, b) => git(c, "remote", "set-url", "origin", `ext::${b.script}`), "config-changed"],
["a branch tracking another remote", (c) => git(c, "config", "branch.main.remote", "elsewhere"), "config-changed"],
["a branch merging a tag", (c) => git(c, "config", "branch.main.merge", "refs/tags/x"), "config-changed"],
["a fetch line onto local branches", (c) => git(c, "config", "remote.origin.fetch", "+refs/heads/*:refs/heads/*"), "config-changed"],
["no origin", (c) => git(c, "remote", "remove", "origin"), "config-changed"],
["a config file over the size cap", (c) => writeFileSync(join(c, ".git", "config"), `${readFileSync(join(c, ".git", "config"), "utf8")}#${"x".repeat(CONFIG_MAX_BYTES)}\n`), "config-changed"],
["config a symlink to /dev/zero", (c) => (rmSync(join(c, ".git", "config")), symlinkSync("/dev/zero", join(c, ".git", "config"))), "not-a-clone"],
["the clone a symlink to the bait", (c, b) => (rmSync(c, { recursive: true }), symlinkSync(b.evil, c)), "not-a-clone"],
["the clone a file", (c) => (rmSync(c, { recursive: true }), writeFileSync(c, "x")), "not-a-clone"],
["the clone a plain directory", (c) => rmSync(join(c, ".git"), { recursive: true }), "not-a-clone"],
[".git a gitdir: file", (c, b) => (rmSync(join(c, ".git"), { recursive: true }), writeFileSync(join(c, ".git"), `gitdir: ${egit(b)}\n`)), "not-a-clone"],
[".git a symlink", (c, b) => (rmSync(join(c, ".git"), { recursive: true }), symlinkSync(egit(b), join(c, ".git"))), "not-a-clone"],
[".git/logs a symlink", (c, b) => (rmSync(join(c, ".git", "logs"), { recursive: true }), symlinkSync(join(egit(b), "logs"), join(c, ".git", "logs"))), "not-a-clone"],
[".git/refs/heads a symlink", (c, b) => (rmSync(join(c, ".git", "refs", "heads"), { recursive: true }), symlinkSync(join(egit(b), "refs", "heads"), join(c, ".git", "refs", "heads"))), "not-a-clone"],
[".git/objects a symlink", (c, b) => (rmSync(join(c, ".git", "objects"), { recursive: true }), symlinkSync(join(egit(b), "objects"), join(c, ".git", "objects"))), "not-a-clone"],
["a reflog hard-linked to a file outside", (c, b) => (rmSync(join(c, ".git", "logs", "HEAD")), linkSync(join(egit(b), "logs", "HEAD"), join(c, ".git", "logs", "HEAD"))), "not-a-clone"],
["HEAD a fifo", (c) => (rmSync(join(c, ".git", "HEAD")), spawnSync("mkfifo", [join(c, ".git", "HEAD")])), "not-a-clone"],
["a commondir file", (c, b) => writeFileSync(join(c, ".git", "commondir"), `${egit(b)}\n`), "not-a-clone"],
["an alternates file", (c, b) => (mkdirSync(join(c, ".git", "objects", "info"), { recursive: true }), writeFileSync(join(c, ".git", "objects", "info", "alternates"), `${join(egit(b), "objects")}\n`)), "not-a-clone"],
// A submodule's own config is outside every check above, and git status
// runs a second git inside a populated submodule.
["a gitlink staged, no .gitmodules", gitlink, "submodule"],
["a gitlink and .gitmodules staged", (c, b) => (gitlink(c, b), gitmodules(c)), "submodule"],
["a gitlink to a repository with its own gitlink", gitlinkNested, "submodule"],
["a gitlink and .gitmodules committed", (c, b) => (gitlink(c, b), gitmodules(c), git(c, "commit", "-q", "-m", "sub")), "submodule"],
["a gitlink and submodule.active in config", (c, b) => (gitlink(c, b), gitmodules(c), git(c, "config", "submodule.active", ".")), "config-changed"],
["a .git/modules with no gitlink", (c, b) => {
const m = join(c, ".git", "modules", "sub");
git(c, "init", "-q", "--bare", "--template=", m);
git(m, "config", "filter.x.clean", b.script);
}, "submodule"],
]) {
await t.test(name, async (t) => {
const w = world(t);
const marker = join(w.dir, "RAN");
const b = bait(w.dir, marker);
await once(w);
edit(w.clone, b);
commit(w.root, "two\n");
const evilBefore = fingerprint(b.evil);
assert.deepEqual(await once(w), { project: "stack", path: w.clone, outcome: "kept", reason });
assert.equal(existsSync(marker), false, "nothing the session wrote ran");
assert.equal(fingerprint(b.evil), evilBefore, "nothing outside the clone was written");
});
}
});
test("a root that can't be cloned refuses with the project id and leaves nothing behind", async (t) => {
for (const [name, make, why] of [
["missing", (w) => rmSync(w.root, { recursive: true }), /can't be read/],
["not a repository", (w) => rmSync(join(w.root, ".git"), { recursive: true }), /can't be read/],
["a plain directory inside another repository", (w) => {
rmSync(join(w.root, ".git"), { recursive: true });
git(w.dir, "init", "-q", "-b", "main");
git(w.dir, "commit", "-q", "--allow-empty", "-m", "outer");
}, /can't be read/],
["unreadable", (w) => chmodSync(w.root, 0o000), /can't be read/],
["on no branch", (w) => git(w.root, "checkout", "-q", "--detach"), /no branch checked out/],
["missing an object", (w) => {
// git starts writing the clone, then can't check the file out.
const blob = git(w.root, "rev-parse", "HEAD:README");
rmSync(join(w.root, ".git", "objects", blob.slice(0, 2), blob.slice(2)));
}, /git clone of .* failed/],
]) {
await t.test(name, async (t) => {
const w = world(t);
make(w);
await assert.rejects(w.ensure, (e) => e instanceof CheckoutError && e.project === "stack" && why.test(e.message));
assert.deepEqual(readdirSync(w.workspace), [], "no clone and no temp directory");
});
}
});
test("a clone whose root is on no branch, or gone, is kept and named", async (t) => {
const w = world(t);
await once(w);
git(w.root, "checkout", "-q", "--detach");
assert.equal((await once(w)).reason, "root-detached");
rmSync(join(w.root, ".git"), { recursive: true });
// The clone's config still names this path, so its check passes and the root's read fails.
assert.deepEqual(await w.ensure(), [{ project: "stack", path: w.clone, outcome: "kept", reason: "root-unreadable" }]);
});
test("no credential or forge remote of the root reaches a clone", async (t) => {
const w = world(t);
git(w.root, "remote", "add", "origin", "https://bot:[email protected]/acme/stack.git");
git(w.root, "remote", "add", "gitea", "ssh://[email protected]/acme/stack.git");
git(w.root, "config", "http.extraHeader", "Authorization: token s3cr3t-token");
const secret = () => spawnSync("grep", ["-rlE", "s3cr3t|git\\.example", join(w.clone, ".git")], { encoding: "utf8" }).stdout;
await once(w);
assert.equal(git(w.clone, "remote"), "origin");
assert.equal(git(w.clone, "remote", "get-url", "origin"), w.root);
assert.equal(secret(), "");
// And the fast-forward keeps it that way.
commit(w.root, "two\n");
assert.equal((await once(w)).outcome, "updated");
assert.equal(secret(), "");
});
test("every project in the business file gets its own clone at its own branch", async (t) => {
const dir = tmp(t);
const a = repo(dir, "a");
const b = repo(dir, "b");
git(b, "checkout", "-q", "-b", "dev");
const workspace = join(dir, "ws");
mkdirSync(workspace);
const list = await ensureCheckouts({ workspace, projects: { alpha: { root: a }, beta: { root: b } } });
assert.deepEqual(list.map((c) => [c.project, c.path, c.outcome, c.branch]), [["alpha", join(workspace, "alpha"), "cloned", "main"], ["beta", join(workspace, "beta"), "cloned", "dev"]]);
assert.equal(git(join(workspace, "beta"), "symbolic-ref", "--short", "HEAD"), "dev");
});