agents: darkwing row 91 round 3 packet, submodule escape finding and fix on 2b2a9e14 (#1585)

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
2026-10-13 08:13:51 -05:00
co-authored by Claude Opus 5.5
parent 3c641c0822
commit 372651716f
9 changed files with 1386 additions and 0 deletions
+175
View File
@@ -0,0 +1,175 @@
# Row 91, a project clone in each role workspace, build round 3 (Darkwing)
Issue #1585. Brief: `docs/plans/2026-10-12_pm-task-flow.md`, "A project
checkout in each role workspace", with Sage's decision 126 (brief blob
`844832c3`). Reviewer: Dewey (decision 130). Gate as revs 881 to 883 set
it.
Rounds 1 and 2 are in `agents/darkwing/work/queue-91/` and `r2/`, with
request comments 28067 and 28087. Round 2's `build.md` describes the row
as a whole: the outcomes, decision 126's three additions, the launcher
and prompt changes. That all still holds. This file covers what round 3
changes.
## Round 3
Round 3 closes the escape Filbert found in round 2. `finding.md` here
says what ran, where, and which guard it got past. In short, `git status
--ignore-submodules=none` started a second git inside a submodule the
session had made in its clone, and that git ran a clean filter from the
submodule's own config, on the host. None of round 2's checks read a
submodule's config.
Sage approved the approach (decision 130 thread): a clone with a
submodule is kept, and no host git recurses into one.
## Candidate and base
Base `2b2a9e14`. I built round 3 on `fbbcf0b7`. Before opening the round
I checked `fbbcf0b7..HEAD` on the manifest paths: row 88's landing
(`2b2a9e14`) changed `packages/cli/README.md`, one of them, in the
command list near the top, not in this row's lines. So I moved round 3 to
`2b2a9e14`. The `fbbcf0b7` patch applies there with `git apply --index`
and no fuzz, and the tests, mutants and gate below all ran on
`2b2a9e14`.
`build.patch` is `git diff 2b2a9e14` of the same eight paths as round 2,
the two new files included. Apply it with `git apply --index` to a
detached worktree of `2b2a9e14`; `sha256sum -c candidate-manifest.sha256`
then gives 8/8 OK.
Against round 2's manifest (`6b58085b`), three lines change:
`packages/seat/src/checkout.mjs`, `packages/seat/tests/checkout.test.mjs`
and `packages/seat/README.md` for this round's edits, and
`packages/cli/README.md` for row 88's lines only. The launcher, its test
and the two bundle files are byte for byte round 2's.
## What changed
`packages/seat/src/checkout.mjs`:
- `unsafe()` returns `submodule` when `.git/modules` exists (lstat, any
kind of entry), after the walk and before the commondir check. A clone
never has one; `git submodule` makes it.
- `update()` runs `git ls-files --stage -z` right after `unsafe()`. A
failure keeps the clone as `status-failed`. Any entry with mode
`160000`, a gitlink, keeps it as `submodule`. This runs before
`rootBranch`, `symbolic-ref` and `status`. `ls-files` reads only the
index, so no filter, hook or submodule runs there.
- `status` passes `--ignore-submodules=all` instead of `=none`.
- `SAFE` gains `-c submodule.recurse=false`, so no git call recurses.
- The header comment says what the checks cover and why a submodule is
kept.
`packages/seat/README.md`: the reasons table gains `submodule`;
`status-failed` names `git ls-files`; one paragraph after the guards says
why the host never recurses into a submodule.
`packages/seat/tests/checkout.test.mjs`: six new cases in "a clone the
session rigged is kept, and git runs nothing and writes nothing outside
it". Each builds a repository at `<clone>/sub` whose own config names a
clean filter that runs the bait script, with a same-size edit to the
filtered file so any status inside it must run the filter.
| case | reason |
|---|---|
| a gitlink staged, no .gitmodules | `submodule` |
| a gitlink and .gitmodules staged | `submodule` |
| a gitlink to a repository with its own gitlink (nested, filter in the inner one only) | `submodule` |
| a gitlink and .gitmodules committed | `submodule` |
| a gitlink and submodule.active in config | `config-changed` |
| a .git/modules with no gitlink | `submodule` |
As for every case in that test, each one checks the outcome, that the
bait's marker file is absent, and that the bait repository's fingerprint
is unchanged. Sage asked for "a bare gitlink, a gitlink with config, and a
populated .git/modules with no gitlink": those are the first, the fifth
and the sixth.
## Mutants
`mutants.sh` is round 1's 31 mutants plus seven for round 3, with the
same `mut.mjs`. All 38 ran on the round 3 candidate on `2b2a9e14`,
from about 13:09Z to 13:10:48Z, `mutants.txt`: 32 killed, 6 survived. Round 1's
results are unchanged (the same three survivors, explained in round 2's
`build.md`).
| mutant | what it does | result |
|---|---|---|
| gitlink-check-off | drops the `160000` check | killed, the four gitlink cases |
| modules-check-off | drops the `.git/modules` return | killed, "a .git/modules with no gitlink" |
| status-recurses | `--ignore-submodules=none` | survived |
| safe-no-submodule-recurse | drops `-c submodule.recurse=false` | survived |
| lsfiles-failure-passes | drops the `ls-files` failure return | survived |
| recursion-unguarded | gitlink check off and `=none` | killed, the four gitlink cases, reason `dirty` |
| gitlink-check-after-status | gitlink check moved after a `=none` status | killed, the four gitlink cases, on "nothing the session wrote ran" |
The last one is the order test Sage asked for: with the check after the
status, the outcome is still `submodule` but the marker is there, so only
the order of the two calls stops the escape, and the test sees it.
The three survivors are second-line guards, expected to survive.
`status-recurses` and `safe-no-submodule-recurse` can't matter while the
gitlink check runs first, since `status` then never sees a gitlink.
`lsfiles-failure-passes` needs an index `ls-files` can't read; a later
`status` on that index fails too, so the clone is still kept as
`status-failed`.
One thing the logs don't show: under `gitlink-check-off` and
`recursion-unguarded` the outcome assertion fails first, so the marker
assertion never runs. Whether `--ignore-submodules=all` alone stops the
filter is therefore not tested here. I didn't swap the two assertions,
because that needs another test run and the slot is released. It would
make each escape fail on the marker first, if Dewey wants it.
## Gate
`gate.sh`, `gate-summary.txt`. 13:04:52Z to 13:07:13Z on 2026-10-13, in
the slot Sage cleared for me at 12:56Z, after the busy check. Base
`2b2a9e14` with this `build.patch` staged; the script checks the
manifest 8/8 OK first. Sequential in a clean worktree with
`node_modules` linked and `DOCKER_HOST=unix:///nonexistent`. All 14
rc=0:
| suite | result |
|---|---|
| seat `node --test tests/*.test.mjs` | 89 pass, 0 fail, 0 skipped |
| cli | 131 pass, 0 fail, 0 skipped |
| bus | 115 pass, 0 fail, 0 skipped |
| harness | 67 pass, 0 fail, 0 skipped |
| tasks | 89 pass, 0 fail, 0 skipped |
| `test-auth.sh` | 15/0 |
| `test-conductor.sh` | 17/0 |
| `test-config.sh` | 24/0 |
| `test-discord.sh` | 66/0 |
| `test-extension-package.sh` | 18/0 |
| `test-foundation.sh` | 44/0 |
| `test-queue.sh` | 27/0 |
| `test-release.sh` | 4/0 |
| `test-task.sh` | 26/0 |
Seat is round 2's 83 plus the six new cases. Cli (+6) and bus (+4) are
row 88's tests on the new base. The Docker-off skips are the same as in
round 2's `build.md`. No Docker network was made, and no process was left
with a cwd in my worktrees.
## Limit
A project whose root has submodules gets a clone with gitlinks in its
index from the first `git clone` (no `--recurse-submodules`). From then
on every launch keeps it as `submodule` and never fast-forwards it. That
fails closed. The one project in the `mosaic-stack` business file today, `stack` (this repository), has no `.gitmodules`. If
one does later, it needs its own rule, not a looser check.
## Found, not fixed
Round 2's note on `recover()` in the launcher still stands.
## Secrets scan
I scanned the packet for token, secret, password, api key and private key
markers. The matches are round 2's test stand-ins in `build.patch`
(`CLAUDE_CODE_OAUTH_TOKEN = "stand-in"`, the fake
`bot:[email protected]` origin and `http.extraHeader` in the "no
credential or forge remote" test). `finding.md` holds no command strings
from the probe and no probe output.
@@ -0,0 +1,944 @@
diff --git a/packages/cli/README.md b/packages/cli/README.md
index eb1c6d9d1..29406721b 100644
--- a/packages/cli/README.md
+++ b/packages/cli/README.md
@@ -237,14 +237,20 @@ is `{"ok": true, "result": {run, instance, harness, model}}` or
| its model names exactly one `launch.max` family | `unknown-model-family` |
| that family has room; every running session counts, the PM's too | `capacity-full` |
| the broker authorizes `role.launch` for the caller | the broker's code (`launch-revoked`, `decision-required`, …) |
+| the workspace has a clone of each project, or the host can make one | `checkout-failed`, with the project id |
| the bundle builds and the runner starts | `launch-failed` |
Every refusal is an `action.refused` event (the broker's `refuse`, or its
own refusal for the last broker check) and a line in the launch log. Then
the launcher reads the instance's credential status from the broker
-(metadata only), builds the bundle in the run directory, spawns the runner
-in its bwrap sandbox (`packages/seat/README.md`, "The sandbox"), binds the
-outer bwrap process's pid and start time, adds the
+(metadata only) and makes sure the instance's workspace has a clone of each
+project in the business file (`packages/seat/README.md`, "The project
+clones"). It makes a missing clone and fast-forwards a clean one. A clone
+it leaves alone is a `checkout-kept` line in the launch log with the
+project and the reason, and the launch goes on. The bundle's prompt names
+each clone's path. Then the launcher builds the bundle in the run
+directory, spawns the runner in its bwrap sandbox (`packages/seat/README.md`,
+"The sandbox"), binds the outer bwrap process's pid and start time, adds the
session to `sessions.json`, and writes the capability as one line on the
runner's stdin. The capability is never on disk, in argv or in the
environment. When the runner exits, the launcher sends `endLaunch` with the
@@ -277,7 +283,8 @@ and, for Claude Code, `--version` from the binary sessions run:
On disk, under the data root: `launches/<business>/<run>/` (0700) is the run
directory; `launches/<business>.jsonl` (0600, append-only) logs every launch,
refusal and end; `workspaces/<business>/<instance>/` (0700) is kept across
-launches; `bus-host/sessions.json` (0600) lists the running sessions.
+launches and holds the instance's project clones; `bus-host/sessions.json`
+(0600) lists the running sessions.
When the host closes, the launcher stops taking requests and drops every
open `launch.sock` connection, so a client that never ends its side can't
diff --git a/packages/cli/src/launcher.mjs b/packages/cli/src/launcher.mjs
index 084264f50..ab8503957 100644
--- a/packages/cli/src/launcher.mjs
+++ b/packages/cli/src/launcher.mjs
@@ -24,7 +24,10 @@
//
// Steps 2 to 8 refuse through the broker's refuse(), so every refusal is an
// action.refused event; step 9's refusals the broker records itself. Then
-// the host builds the bundle, spawns the runner in its sandbox
+// the host makes sure the workspace has a clone of each project
+// (packages/seat/src/checkout.mjs): one it can't make refuses
+// checkout-failed, and one it leaves alone is a checkout-kept record in the
+// launch log. Then it builds the bundle, spawns the runner in its sandbox
// (packages/seat/src/sandbox.mjs) with the paths it may see, binds the run's
// process identity (the outer bwrap pid) and writes the new capability on
// the runner's stdin. When the runner exits, the host ends the launch.
@@ -57,6 +60,7 @@ import {
writeSessionFile,
} from "../../seat/src/session.mjs";
import { BWRAP, sandboxProblem } from "../../seat/src/sandbox.mjs";
+import { CheckoutError, ensureCheckouts } from "../../seat/src/checkout.mjs";
import { cmdlineOf, startTimeOf } from "../../seat/src/proc.mjs";
import { REPO, rolesDir } from "./config.mjs";
import { CliError } from "./errors.mjs";
@@ -169,9 +173,22 @@ export function createLauncher({
// Bundle, spawn, bind, register, hand over the capability.
async function start({ instance, launchedBy, resolved, model, fam }) {
const credentials = await host.op({ op: "credentialStatus", business: b.id, role: instance });
- const { run, runDir } = newRun(dataRoot, b.id);
+ // The clones come before the run, so one that can't be made leaves no
+ // run behind. The host's git treats each clone as hostile, and its
+ // checks hold only while no session writes the workspace: a workspace
+ // is one instance's, check() refused instance-running, and launches are
+ // serialized, so none does.
const workspace = workspaceDir(dataRoot, b.id, instance);
mkdirSync(workspace, { recursive: true, mode: 0o700 });
+ let checkouts;
+ try {
+ checkouts = await ensureCheckouts({ workspace, projects: b.projects, env });
+ } catch (e) {
+ if (e instanceof CheckoutError) throw new Refusal("checkout-failed", `${e.project}: ${e.message}`);
+ throw e;
+ }
+ for (const c of checkouts) if (c.outcome === "kept") record({ event: "checkout-kept", instance, project: c.project, reason: c.reason });
+ const { run, runDir } = newRun(dataRoot, b.id);
const sessionDir = join(runDir, "session");
mkdirSync(sessionDir, { mode: 0o700 });
const toolSocket = join(runDir, "tools.sock");
@@ -184,7 +201,7 @@ export function createLauncher({
arbiter: Object.values(b.arbiters).includes(instance),
tracker,
credentials,
- session: { run, launchedBy, workspace, toolSocket, turnMarker },
+ session: { run, launchedBy, workspace, toolSocket, turnMarker, checkouts },
piVersion: v.pi,
claudeVersion: v.claude,
});
diff --git a/packages/cli/tests/launcher.test.mjs b/packages/cli/tests/launcher.test.mjs
index f80137265..d86dbb64e 100644
--- a/packages/cli/tests/launcher.test.mjs
+++ b/packages/cli/tests/launcher.test.mjs
@@ -9,12 +9,13 @@ import { spawn, spawnSync } from "node:child_process";
import { once } from "node:events";
import { createInterface } from "node:readline";
import { connect } from "node:net";
-import { chmodSync, existsSync, mkdirSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync } from "node:fs";
+import { chmodSync, existsSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { fileURLToPath } from "node:url";
import { Client } from "../../bus/src/client.mjs";
-import { launchLogFile, launchSocketPath, readSessions, Registry, SESSION_CREDENTIALS, sessionsFile } from "../../seat/src/session.mjs";
+import { launchLogFile, launchSocketPath, readSessions, Registry, SESSION_CREDENTIALS, sessionsFile, workspaceDir } from "../../seat/src/session.mjs";
import { sandboxProblem } from "../../seat/src/sandbox.mjs";
+import { gitEnv } from "../../seat/src/checkout.mjs";
import { bootConfig, loadSystem } from "../src/config.mjs";
import { startHost, startTimeOf } from "../src/host.mjs";
import { claudeVersion, createLauncher } from "../src/launcher.mjs";
@@ -44,6 +45,19 @@ async function until(fn, ms = 15000) {
throw new Error("timed out waiting");
}
+// git in a test repository, with nothing of the shell's git config.
+function gitIn(cwd, ...args) {
+ const r = spawnSync("git", ["-c", "user.name=test", "-c", "[email protected]", "-C", cwd, ...args], { env: gitEnv(), encoding: "utf8" });
+ assert.equal(r.status, 0, r.stderr);
+ return r.stdout.trim();
+}
+function commit(dir, text) {
+ writeFileSync(join(dir, "file.txt"), `${text}\n`);
+ gitIn(dir, "add", "file.txt");
+ gitIn(dir, "commit", "-q", "-m", text);
+ return gitIn(dir, "rev-parse", "HEAD");
+}
+
// A config and data root with the fake adapter; setup() starts a host on it.
// The session credential is a stand-in, and the fake adapter never uses it.
// Every credential variable of the shell running the tests is dropped.
@@ -53,6 +67,10 @@ function prepare(t, more = (doc) => doc) {
for (const k of SESSION_CREDENTIALS.flat()) delete f.env[k];
f.env.CLAUDE_CODE_OAUTH_TOKEN = "stand-in";
mkdirSync(f.dataRoot, { mode: 0o700 });
+ // The project root is a repository on main, so each launch can clone it.
+ const project = f.doc.projects.stack.root;
+ gitIn(project, "init", "-q", "-b", "main");
+ commit(project, "one");
const adapter = join(root, "adapter.sh");
writeFileSync(adapter, `exec '${process.execPath}' '${FAKE}'\n`, { mode: 0o644 });
return { root, f, adapter };
@@ -244,6 +262,76 @@ test("the PM launches a coder through its launch tool; the coder answers; refusa
assert.ok(readSessions(f.dataRoot).some((s) => s.run === pm.run));
});
+// Row 91 (#1585): the project clone in each workspace.
+test("each launch makes sure the workspace has a clone of the project: cloned, fast-forwarded, or kept and logged", { skip, timeout: 60000 }, async (t) => {
+ const given = prepare(t);
+ const project = given.f.doc.projects.stack.root;
+ const clone = join(workspaceDir(given.f.dataRoot, "acme", "pm"), "stack");
+ const prompt = (run) => readFileSync(join(given.f.dataRoot, "launches", "acme", run, "bundle", "prompt.md"), "utf8");
+ const kept = () => log(given.f).filter((e) => e.event === "checkout-kept");
+
+ // The first launch clones the root's branch, with the root as origin.
+ const first = await setup(t, undefined, given);
+ const one = gitIn(project, "rev-parse", "HEAD");
+ const pm1 = await first.launcher.launchPm();
+ assert.equal(gitIn(clone, "rev-parse", "HEAD"), one);
+ assert.equal(gitIn(clone, "symbolic-ref", "--short", "HEAD"), "main");
+ assert.equal(gitIn(clone, "config", "--get-all", "remote.origin.url"), project);
+ assert.ok(prompt(pm1.run).includes(`- Project clones: stack at ${clone}, on branch main. `));
+
+ // While the PM runs, a second launch is refused before the host runs git
+ // in its workspace: the root moved on, and the clone didn't.
+ const two = commit(project, "two");
+ await assert.rejects(first.launcher.launchPm(), /PM launch refused: instance-running/);
+ assert.equal(gitIn(clone, "rev-parse", "HEAD"), one);
+ await first.client.call("role.release"); // the test's own cto claim, so the next host's can claim again
+ assert.equal(await first.close(), 0);
+
+ // The next launch fast-forwards the clean clone.
+ const second = await setup(t, undefined, given);
+ const pm2 = await second.launcher.launchPm();
+ assert.equal(gitIn(clone, "rev-parse", "HEAD"), two);
+ assert.ok(prompt(pm2.run).includes(`- Project clones: stack at ${clone}, on branch main. `));
+ assert.deepEqual(kept(), []);
+ await second.client.call("role.release");
+ assert.equal(await second.close(), 0);
+
+ // A clone with the session's own work in it is kept, and the log says why.
+ writeFileSync(join(clone, "notes.txt"), "mine\n");
+ const three = commit(project, "three");
+ const third = await setup(t, undefined, given);
+ const pm3 = await third.launcher.launchPm();
+ assert.equal(gitIn(clone, "rev-parse", "HEAD"), two);
+ assert.equal(readFileSync(join(clone, "notes.txt"), "utf8"), "mine\n");
+ assert.deepEqual(kept().map(({ instance, project: p, reason }) => ({ instance, project: p, reason })), [{ instance: "pm", project: "stack", reason: "dirty" }]);
+ assert.ok(prompt(pm3.run).includes(`- Project clones: stack at ${clone}, not updated at this launch (dirty). `));
+ assert.equal(await third.close(), 0);
+
+ // The root was never written: its branch, head and tree are the test's.
+ assert.equal(gitIn(project, "rev-parse", "HEAD"), three);
+ assert.equal(gitIn(project, "for-each-ref", "--format=%(refname)"), "refs/heads/main");
+ assert.equal(gitIn(project, "status", "--porcelain"), "");
+});
+
+test("a project clone that can't be made refuses checkout-failed, from --pm and from a session, with no run and no clone left", { skip, timeout: 30000 }, async (t) => {
+ const given = prepare(t);
+ renameSync(join(given.f.doc.projects.stack.root, ".git"), join(given.root, "away.git"));
+ const { f, launcher, host } = await setup(t, undefined, given);
+ await assert.rejects(launcher.launchPm(), (e) => e.exitCode === 3 && /^PM launch refused: checkout-failed \(stack: the root .* can't be read: /.test(e.message));
+
+ // A pm capability bound to this process asks for the coder.
+ const pm = await host.bindLaunch({ business: "acme", role: "pm", run: "pm-test", harness: "pi", pid: process.pid, startTime: startTimeOf(process.pid) });
+ await new Client({ path: host.path, cap: pm.cap }).call("role.claim");
+ assert.deepEqual(await launcher.fromSession(pm.cap, "coder"), { ok: false, error: "checkout-failed" });
+
+ const entries = log(f);
+ assert.deepEqual(entries.map((e) => [e.event, e.instance, e.code]), [["refused", "pm", "checkout-failed"], ["refused", "coder", "checkout-failed"]]);
+ for (const e of entries) assert.match(e.why, /^stack: the root /);
+ assert.deepEqual(readSessions(f.dataRoot), []);
+ assert.deepEqual(readdirSync(join(f.dataRoot, "launches")), ["acme.jsonl"], "no run was made");
+ for (const instance of ["pm", "coder"]) assert.deepEqual(readdirSync(workspaceDir(f.dataRoot, "acme", instance)), [], `${instance}: no clone, no temp`);
+});
+
test("a runner that stops at once ends its launch with the runner's reason", { skip, timeout: 30000 }, async (t) => {
// pm's tracker token has expired: the runner's founder-credential stop
// exits 20 before role.claim, and the host records founder-credentials.
diff --git a/packages/harness/src/bundle.mjs b/packages/harness/src/bundle.mjs
index 63aa4d049..678c8749f 100644
--- a/packages/harness/src/bundle.mjs
+++ b/packages/harness/src/bundle.mjs
@@ -55,6 +55,13 @@ export function sessionModel(resolved) {
return { harness, provider: v["execution.provider"] ?? null, model, thinking: v.thinking ?? null };
}
+// Row 91 (#1585): the project clones the host made or kept in the workspace.
+function checkoutLines(checkouts) {
+ if (!checkouts.length) return [];
+ const each = checkouts.map((c) => (c.outcome === "kept" ? `${c.project} at ${c.path}, not updated at this launch (${c.reason})` : `${c.project} at ${c.path}, on branch ${c.branch}`));
+ return [`- Project clones: ${each.join("; ")}. Each is a git clone of the project. The host fetches from its origin at each launch; the origin is outside this sandbox.`];
+}
+
function prompt({ contract, resolved, session, tools, untyped }) {
const lines = [
contract.trimEnd(),
@@ -64,6 +71,7 @@ function prompt({ contract, resolved, session, tools, untyped }) {
`- Business: ${resolved.business}. Role instance: ${resolved.instance} (definition ${resolved.definition}). Run: ${session.run}.`,
`- Launched by: ${session.launchedBy}. Harness: ${session.harness}, model ${session.model}.`,
`- Workspace: ${session.workspace}. File tools work inside it only.`,
+ ...checkoutLines(session.checkouts ?? []),
// Row 89 (#1583): the names the harness gives its built-ins, as the manifest lists them.
`- Built-in tools: ${(session.harness === "pi" ? resolved.limits.tools : claudeBuiltins({ tools: resolved.limits.tools })).join(", ") || "none"}.`,
`- Bus tools: ${tools.map((t) => t.name).join(", ")}. The broker decides every call; a refusal names its reason.`,
@@ -83,7 +91,9 @@ function prompt({ contract, resolved, session, tools, untyped }) {
// arbiter true when the business names this instance as an arbiter
// tracker true when the broker has task verbs for the business
// credentials the broker's credentialStatus(...) for the instance (metadata only)
-// session { run, launchedBy, workspace, toolSocket, turnMarker }
+// session { run, launchedBy, workspace, toolSocket, turnMarker, checkouts }
+// checkouts is ensureCheckouts(...) output (packages/seat/src/checkout.mjs),
+// named in the prompt; none when absent
// piVersion the pinned pi version, for the manifest
// claudeVersion the host's `claude --version`, for the manifest
// Returns { manifest, files } with absolute paths.
diff --git a/packages/harness/tests/bundle.test.mjs b/packages/harness/tests/bundle.test.mjs
index aae81c524..a343200ff 100644
--- a/packages/harness/tests/bundle.test.mjs
+++ b/packages/harness/tests/bundle.test.mjs
@@ -103,6 +103,23 @@ test("the prompt's built-in tools line is the harness's own names, as the manife
}
});
+// Row 91 (#1585): the prompt names each project clone, and why a kept one wasn't updated.
+test("the prompt names the project clones in the workspace, and none when there are none", (t) => {
+ const line = (b) => readFileSync(b.files.prompt, "utf8").split("\n").filter((l) => l.startsWith("- Project clones: "));
+ const plain = build(t, resolvedFor(t, "pm"));
+ assert.deepEqual(line(plain), []);
+ const dir = scratch(t);
+ const session = (checkouts) => ({ run: "run-1", launchedBy: "pm", workspace: join(dir, "ws"), toolSocket: join(dir, "tools.sock"), turnMarker: join(dir, "turn.done"), checkouts });
+ const two = build(t, resolvedFor(t, "pm"), { session: session([
+ { project: "stack", path: join(dir, "ws", "stack"), outcome: "updated", branch: "main" },
+ { project: "site", path: join(dir, "ws", "site"), outcome: "kept", reason: "dirty" },
+ ]) });
+ assert.deepEqual(line(two), [`- Project clones: stack at ${join(dir, "ws", "stack")}, on branch main; site at ${join(dir, "ws", "site")}, not updated at this launch (dirty). Each is a git clone of the project. The host fetches from its origin at each launch; the origin is outside this sandbox.`]);
+ const prompt = readFileSync(two.files.prompt, "utf8");
+ assert.ok(prompt.indexOf("- Workspace: ") < prompt.indexOf("- Project clones: "));
+ assert.deepEqual(line(build(t, resolvedFor(t, "pm"), { session: session([]) })), []);
+});
+
test("a bundle is written once: an existing file refuses", (t) => {
const dir = scratch(t);
mkdirSync(join(dir, "bundle"));
diff --git a/packages/seat/README.md b/packages/seat/README.md
index 836a356ff..3797f503f 100644
--- a/packages/seat/README.md
+++ b/packages/seat/README.md
@@ -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.
diff --git a/packages/seat/src/checkout.mjs b/packages/seat/src/checkout.mjs
new file mode 100644
index 000000000..35172b7f6
--- /dev/null
+++ b/packages/seat/src/checkout.mjs
@@ -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;
+}
diff --git a/packages/seat/tests/checkout.test.mjs b/packages/seat/tests/checkout.test.mjs
new file mode 100644
index 000000000..8dea05826
--- /dev/null
+++ b/packages/seat/tests/checkout.test.mjs
@@ -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");
+});
@@ -0,0 +1,8 @@
2a46ff64c0ab3ffa45b53e0ea212872e91a84ab318ccd6389762105f8b8e9e45 packages/cli/README.md
151dd098535e666b2b78a3b46a194aeec48c8ca85449d1217497a28bdf97624c packages/cli/src/launcher.mjs
9d0599c2b458b9f7e758d0e0b610cddfcc41d4814ddd7253480daaa79e5c9809 packages/cli/tests/launcher.test.mjs
2ea41bfe8a19bdf2abecc4a20439eefdfe160a739b61c32d4412182d3cc72718 packages/harness/src/bundle.mjs
dbfc69ebad4c4a43f6f4334cf0dc8a911d534550c3d7bae7b0a9edef747941f4 packages/harness/tests/bundle.test.mjs
0cbe2d233efa7a52239c3252a1478d01078ea99647c8328b16ca4de4ad142790 packages/seat/README.md
c0c20575da27bb9cbbe791025399cad0e5e2b9ce11fc2f44b92e814d08d2fb36 packages/seat/src/checkout.mjs
47241f384eff8ed6192f2b719451cbc6c7a08b231a885ca48178a78e6339fbb8 packages/seat/tests/checkout.test.mjs
@@ -0,0 +1,72 @@
# Row 91, the submodule escape in round 2 (Darkwing)
Issue #1585. Filbert reported it in their round 2 review: a nested
submodule's clean step ran a command on the host. Their session was cut
off before a write-up (decision 130). I reproduced it on round 2's
`packages/seat/src/checkout.mjs` (manifest `6b58085b`, request comment
28087) in Sage's slot after 12:56Z on 2026-10-13. It is real. Round 3
closes it; `build.md` here has the fix, the tests and the mutants.
## What ran
A clean filter driver. Git runs a file's clean filter when it has to hash
a working-tree file, and `git status` does that for a tracked file whose
size is unchanged but whose stat data no longer matches the index.
## Where
On the bus host, outside the session's sandbox, at the next launch of the
instance. `update()` ran
git status --porcelain=v1 -z --untracked-files=all --ignore-submodules=none
in the clone. With `--ignore-submodules=none`, git checks every populated
gitlink by starting a second `git status` inside the submodule's
directory. That child git reads the submodule's own `.git/config` and
`.gitattributes`. When those name a clean filter for a modified file, the
child git runs it on the host.
The session builds all of it inside its own clone, which it can write: a
repository in a subdirectory, a filter in that repository's config, an
attribute that applies it to a tracked file, a same-size edit to that
file, and a gitlink to the repository staged in the clone's index. None of
that touches the clone's top-level `.git/config`. A `.gitmodules` entry
is not needed. The same works one level down: the gitlink's repository
carries its own gitlink to an inner repository, and only the inner one
names the filter, since the child git status recurses too.
## Which guard it got past
Every round 2 check looks at the clone's own `.git` only:
- the lstat walk covers `<clone>/.git`, not a `.git` in a working-tree
subdirectory;
- the config allowlist reads `<clone>/.git/config`, not a submodule's
config;
- the hooks check reads `<clone>/.git/hooks`.
The `-c` flags in `SAFE` do reach the child git through
`GIT_CONFIG_PARAMETERS`, and `gitEnv()` reaches it through the
environment, so hooks, fsmonitor and the transports stay off in the
submodule. Neither sets anything about filters, and the allowlist can't
cover a config it never reads. Round 2 kept the clone with reason `dirty`,
after the filter had run.
## What did not escape on round 2
- `git submodule add` and `git submodule update --init` in the clone write
`submodule.*` keys to the top-level config and make `.git/modules`. The
allowlist stopped both with `config-changed` before any git ran.
- A gitlink plus `submodule.active` in the top-level config: same,
`config-changed`.
## The fix in round 3
A clone with a gitlink in its index, or with anything at `.git/modules`,
is kept with reason `submodule`, before `status` runs. `status` uses
`--ignore-submodules=all`, and every host git call gets `-c
submodule.recurse=false`. Details in `build.md`, section "Round 3".
The probe I used stays in my scratch, as Sage asked. The new tests in
`packages/seat/tests/checkout.test.mjs` build each case above, and
reviewing round 3 doesn't need the probe.
@@ -0,0 +1,17 @@
start 2026-10-13T13:04:52Z 2b2a9e14e staged=8
manifest 8/8 OK
seat rc=0 13:04:52-13:04:57 ℹ pass 89 ℹ fail 0 ℹ skipped 0
cli rc=0 13:04:57-13:05:15 ℹ pass 131 ℹ fail 0 ℹ skipped 0
bus rc=0 13:05:15-13:05:18 ℹ pass 115 ℹ fail 0 ℹ skipped 0
harness rc=0 13:05:18-13:05:29 ℹ pass 67 ℹ fail 0 ℹ skipped 0
tasks rc=0 13:05:29-13:05:35 ℹ pass 89 ℹ fail 0 ℹ skipped 0
test-auth rc=0 13:05:35-13:05:35 selftest: 15 passed, 0 failed
test-conductor rc=0 13:05:35-13:05:41 selftest: 17 passed, 0 failed
test-config rc=0 13:05:41-13:05:43 selftest: 24 passed, 0 failed
test-discord rc=0 13:05:43-13:05:51 discord suite: 66 passed, 0 failed
test-extension-package rc=0 13:05:51-13:05:52 extension package selftest: 18 passed, 0 failed
test-foundation rc=0 13:05:52-13:06:36 selftest: 44 passed, 0 failed
test-queue rc=0 13:06:36-13:07:11 queue suite: 27 passed, 0 failed
test-release rc=0 13:07:11-13:07:11 selftest: 4 passed, 0 failed
test-task rc=0 13:07:11-13:07:13 selftest: 26 passed, 0 failed
end 2026-10-13T13:07:13Z
+24
View File
@@ -0,0 +1,24 @@
#!/usr/bin/env bash
# Row 91 round 3 gate on 2b2a9e14 plus build.patch: the seat, cli, bus,
# harness and tasks node suites (each package's own test files, from its
# directory), then every scripts/test-*.sh, sequential, full output kept.
# Docker is off for the whole run.
cd ~/darkwing-scratch/r91r3/wt2 || exit 2
W=$PWD
export TMPDIR=~/darkwing-scratch/r91r3/tmp
export DOCKER_HOST=unix:///nonexistent
O=~/darkwing-scratch/r91r3/gate
mkdir -p "$O" "$TMPDIR"
echo "start $(date -u +%Y-%m-%dT%H:%M:%SZ) $(git rev-parse --short HEAD) staged=$(git diff --cached --name-only | wc -l)"
sha256sum -c --quiet ~/darkwing-scratch/r91r3/pkt/candidate-manifest.sha256 && echo "manifest 8/8 OK" || { echo "manifest mismatch"; exit 3; }
for p in seat cli bus harness tasks; do
s=$(date -u +%T)
(cd "$W/packages/$p" && node --test tests/*.test.mjs) > $O/node-$p.txt 2>&1; rc=$?
echo "$p rc=$rc $s-$(date -u +%T) $(grep -E '^ℹ (pass|fail|skipped)' $O/node-$p.txt | tr '\n' ' ')"
done
for f in scripts/test-*.sh; do
n=$(basename $f .sh); s=$(date -u +%T)
bash $f > $O/$n.txt 2>&1; rc=$?
echo "$n rc=$rc $s-$(date -u +%T) $(tail -1 $O/$n.txt | cut -c1-120)"
done
echo "end $(date -u +%Y-%m-%dT%H:%M:%SZ)"
+11
View File
@@ -0,0 +1,11 @@
// node mut.mjs <file> <from> <to> [<from> <to> ...]: apply each replacement in
// order, each matching exactly once, else exit 3 and leave the file alone.
import { readFileSync, writeFileSync } from 'node:fs';
const [f, ...pairs] = process.argv.slice(2);
let s = readFileSync(f, 'utf8');
for (let i = 0; i < pairs.length; i += 2) {
const n = s.split(pairs[i]).length - 1;
if (n !== 1) { console.error(`edit ${i / 2 + 1}: matches=${n}`); process.exit(3); }
s = s.replace(pairs[i], () => pairs[i + 1]);
}
writeFileSync(f, s);
@@ -0,0 +1,97 @@
#!/usr/bin/env bash
# Row 91 (#1585) round 3 mutants: round 1's list, rerun on the round 3
# candidate, then the submodule guards. One or more per guard in
# checkout.mjs, then the launcher's refusal and record, then the prompt line. Run from the
# candidate worktree's root:
# LOG=/some/dir bash mutants.sh /path/to/mut.mjs > mutants.txt
# Each mutant edits one or two sites with mut.mjs (each exactly one match,
# else exit 3),
# runs the named tests and restores the file. "killed" means the run
# failed; "survived" means it passed.
set -u
MUT=$1
run() {
local name=$1 file=$2 from=$3 to=$4; shift 4
edits "$name" "$file" "$@" -- "$from" "$to"
}
# run2 name file from1 to1 from2 to2 tests...: two edits in one mutant.
run2() {
local name=$1 file=$2 a=$3 b=$4 c=$5 d=$6; shift 6
edits "$name" "$file" "$@" -- "$a" "$b" "$c" "$d"
}
# edits name file tests... -- from to [from to]
edits() {
local name=$1 file=$2; shift 2
local tests=()
while [ "$1" != "--" ]; do tests+=("$1"); shift; done; shift
local pairs=("$@")
cp -- "$file" "$file.orig"
if ! node "$MUT" "$file" "${pairs[@]}"; then
echo "$name: NO MATCH"; mv -- "$file.orig" "$file"; return
fi
if node --test "${tests[@]}" > "${LOG:-.}/mut-$name.log" 2>&1; then
echo "$name: survived"
else
echo "$name: killed ($(grep -m1 -o 'ℹ fail [0-9]*' "${LOG:-.}/mut-$name.log"); $(grep '^✖ ' "${LOG:-.}/mut-$name.log" | grep -v 'failing tests' | sort -u | sed 's/^✖ //; s/ ([0-9.]*ms)$//' | paste -sd '|'))"
fi
mv -- "$file.orig" "$file"
}
C=packages/seat/src/checkout.mjs
L=packages/cli/src/launcher.mjs
B=packages/harness/src/bundle.mjs
CT=(packages/seat/tests/checkout.test.mjs)
LT=(packages/cli/tests/launcher.test.mjs)
BT=(packages/harness/tests/bundle.test.mjs)
# The walk of .git.
run walk-off $C 'if (walk(join(dest, ".git"))) return "not-a-clone";' 'if (false) return "not-a-clone";' "${CT[@]}"
run walk-no-nlink $C '} else if (!st.isFile() || st.nlink !== 1) return true;' '} else if (!st.isFile()) return true;' "${CT[@]}"
run walk-symlinks-pass $C '} else if (!st.isFile() || st.nlink' '} else if ((!st.isFile() && !st.isSymbolicLink()) || st.nlink' "${CT[@]}"
run clone-dir-symlink-ok $C 'return lstatSync(p).isDirectory();' 'return lstatSync(p).isDirectory() || lstatSync(p).isSymbolicLink();' "${CT[@]}"
run no-commondir $C 'for (const f of ["commondir", join("objects", "info", "alternates")])' 'for (const f of [join("objects", "info", "alternates")])' "${CT[@]}"
run no-alternates $C 'for (const f of ["commondir", join("objects", "info", "alternates")])' 'for (const f of ["commondir"])' "${CT[@]}"
# Hooks and the config file.
run hooks-ignored $C 'if (readdirSync(join(dest, ".git", "hooks")).length > 0) return "hooks-present";' 'if (false) return "hooks-present";' "${CT[@]}"
run config-cap-off $C 'st.size > CONFIG_MAX_BYTES' 'st.size > Infinity' "${CT[@]}"
# The allowlist.
run allow-any-key $C 'default: return false;' 'default: return true;' "${CT[@]}"
run origin-any-url $C 'case "remote.origin.url": return value === root;' 'case "remote.origin.url": return true;' "${CT[@]}"
run fetch-any $C 'case "remote.origin.fetch": return value === FETCH;' 'case "remote.origin.fetch": return true;' "${CT[@]}"
run branch-remote-any $C 'return branch[1] === "remote" ? value === "origin" :' 'return branch[1] === "remote" ? true :' "${CT[@]}"
run branch-merge-any $C ': /^refs\/heads\/./.test(value ?? "");' ': true;' "${CT[@]}"
run duplicate-key-ok $C 'if (seen.has(key) || !allowed(key, value, root))' 'if (!allowed(key, value, root))' "${CT[@]}"
run no-origin-ok $C 'if (!seen.has("remote.origin.url")) return "config-changed";' '' "${CT[@]}"
# The root, the update and the clone.
run ceiling-off $C 'const ceiling = { GIT_CEILING_DIRECTORIES: dirname(root) };' 'const ceiling = {};' "${CT[@]}"
run detached-root-clones $C 'if (!at.branch) throw new CheckoutError(project, `the root ${root} has no branch checked out`);' '' "${CT[@]}"
run other-branch-updates $C 'if (!head.ok || head.out.trim() !== at.branch) return' 'if (!head.ok) return' "${CT[@]}"
run dirty-updates $C 'if (status.out !== "") return { outcome: "kept", reason: "dirty" };' '' "${CT[@]}"
run untracked-ignored $C '"--untracked-files=all", ' '"--untracked-files=no", ' "${CT[@]}"
run merge-not-ff-only $C '"merge", "-q", "--ff-only", "--no-edit"' '"merge", "-q", "--no-edit"' "${CT[@]}"
run temp-left $C 'rmSync(tmp, { recursive: true, force: true });' 'void tmp;' "${CT[@]}"
run clone-in-place $C 'const tmp = join(workspace, `.${project}.clone-${randomBytes(4).toString("hex")}`);' 'const tmp = dest;' "${CT[@]}"
run clone-hardlinks $C '"clone", "-q", "--no-hardlinks", ' '"clone", "-q", ' "${CT[@]}"
# Second-line guards: the checks above stop each case first, so these are
# expected to survive.
run safe-no-hookspath $C '"-c", "core.hooksPath=/dev/null",' '' "${CT[@]}"
run safe-no-fsmonitor $C '"-c", "core.fsmonitor=false",' '' "${CT[@]}"
# The launcher.
run refusal-unmapped $L 'if (e instanceof CheckoutError) throw new Refusal(' 'if (false) throw new Refusal(' "${LT[@]}"
run kept-unrecorded $L 'for (const c of checkouts) if (c.outcome === "kept") record(' 'for (const c of checkouts) if (false) record(' "${LT[@]}"
run checkouts-not-in-session $L 'session: { run, launchedBy, workspace, toolSocket, turnMarker, checkouts },' 'session: { run, launchedBy, workspace, toolSocket, turnMarker },' "${LT[@]}"
# The prompt line.
run prompt-no-clones $B '...checkoutLines(session.checkouts ?? []),' '' "${BT[@]}"
run prompt-kept-as-current $B '(c.outcome === "kept" ?' '(c.outcome === "never" ?' "${BT[@]}"
# Round 3: the submodule guards. The first two must die; the next three are
# second-line guards the first two stop first, so they are expected to
# survive; the last two take the first away and must die on the marker.
run gitlink-check-off $C 'if (staged.out.split("\0").some((e) => e.startsWith("160000 "))) return { outcome: "kept", reason: "submodule" };' '' "${CT[@]}"
run modules-check-off $C 'return "submodule";' '' "${CT[@]}"
run status-recurses $C '"--ignore-submodules=all"' '"--ignore-submodules=none"' "${CT[@]}"
run safe-no-submodule-recurse $C '"-c", "submodule.recurse=false",' '' "${CT[@]}"
run lsfiles-failure-passes $C 'if (!staged.ok) return { outcome: "kept", reason: "status-failed" };' '' "${CT[@]}"
run2 recursion-unguarded $C 'if (staged.out.split("\0").some((e) => e.startsWith("160000 "))) return { outcome: "kept", reason: "submodule" };' '' '"--ignore-submodules=all"' '"--ignore-submodules=none"' "${CT[@]}"
run2 gitlink-check-after-status $C 'if (staged.out.split("\0").some((e) => e.startsWith("160000 "))) return { outcome: "kept", reason: "submodule" };' '' '"--ignore-submodules=all"], env);
if (!status.ok) return { outcome: "kept", reason: "status-failed" };' '"--ignore-submodules=none"], env);
if (!status.ok) return { outcome: "kept", reason: "status-failed" };
if (staged.out.split("\0").some((e) => e.startsWith("160000 "))) return { outcome: "kept", reason: "submodule" };' "${CT[@]}"
@@ -0,0 +1,38 @@
walk-off: killed (ℹ fail 7; a clone the session rigged is kept, and git runs nothing and writes nothing outside it|a reflog hard-linked to a file outside|config a symlink to /dev/zero|.git/logs a symlink|.git/objects a symlink|.git/refs/heads a symlink|HEAD a fifo)
walk-no-nlink: killed (ℹ fail 2; a clone the session rigged is kept, and git runs nothing and writes nothing outside it|a reflog hard-linked to a file outside)
walk-symlinks-pass: killed (ℹ fail 5; a clone the session rigged is kept, and git runs nothing and writes nothing outside it|config a symlink to /dev/zero|.git/logs a symlink|.git/objects a symlink|.git/refs/heads a symlink)
clone-dir-symlink-ok: killed (ℹ fail 3; a clone the session rigged is kept, and git runs nothing and writes nothing outside it|.git a symlink|the clone a symlink to the bait)
no-commondir: killed (ℹ fail 2; a clone the session rigged is kept, and git runs nothing and writes nothing outside it|a commondir file)
no-alternates: killed (ℹ fail 2; a clone the session rigged is kept, and git runs nothing and writes nothing outside it|an alternates file)
hooks-ignored: killed (ℹ fail 2; a clone the session rigged is kept, and git runs nothing and writes nothing outside it|a hook file)
config-cap-off: killed (ℹ fail 4; a clone the session rigged is kept, and git runs nothing and writes nothing outside it|a config file over the size cap|a dirty clone, one on another branch and one diverged are kept as they are|another branch)
allow-any-key: killed (ℹ fail 7; a clone the session rigged is kept, and git runs nothing and writes nothing outside it|a filter driver|a gitlink and submodule.active in config|core.fsmonitor|core.hooksPath|includeIf|include.path)
origin-any-url: killed (ℹ fail 3; a clone the session rigged is kept, and git runs nothing and writes nothing outside it|an ext:: origin|origin moved)
fetch-any: killed (ℹ fail 2; a clone the session rigged is kept, and git runs nothing and writes nothing outside it|a fetch line onto local branches)
branch-remote-any: killed (ℹ fail 2; a branch tracking another remote|a clone the session rigged is kept, and git runs nothing and writes nothing outside it)
branch-merge-any: killed (ℹ fail 2; a branch merging a tag|a clone the session rigged is kept, and git runs nothing and writes nothing outside it)
duplicate-key-ok: killed (ℹ fail 2; a clone the session rigged is kept, and git runs nothing and writes nothing outside it|the origin url twice, both the root)
no-origin-ok: killed (ℹ fail 2; a clone the session rigged is kept, and git runs nothing and writes nothing outside it|no origin)
ceiling-off: killed (ℹ fail 2; a plain directory inside another repository|a root that can't be cloned refuses with the project id and leaves nothing behind)
detached-root-clones: killed (ℹ fail 2; a root that can't be cloned refuses with the project id and leaves nothing behind|on no branch)
other-branch-updates: killed (ℹ fail 2; a dirty clone, one on another branch and one diverged are kept as they are|another branch)
dirty-updates: killed (ℹ fail 4; a dirty clone, one on another branch and one diverged are kept as they are|a modified file|an untracked file|a staged file)
untracked-ignored: killed (ℹ fail 2; a dirty clone, one on another branch and one diverged are kept as they are|an untracked file)
merge-not-ff-only: survived
temp-left: killed (ℹ fail 2; a root that can't be cloned refuses with the project id and leaves nothing behind|missing an object)
clone-in-place: killed (ℹ fail 1; the clone is not at its place until git clone has finished)
clone-hardlinks: killed (ℹ fail 47; a branch merging a tag|a branch tracking another remote|a clone ahead of the root keeps its commits and counts as current|a clone the session rigged is kept, and git runs nothing and writes nothing outside it|a clone whose root is on no branch, or gone, is kept and named|a commondir file|a config file over the size cap|a detached HEAD|a dirty clone, one on another branch and one diverged are kept as they are|a diverged branch|a fetch line onto local branches|a filter driver|a first launch clones the root at its branch, with the root's path as origin|a gitlink and .gitmodules committed|a gitlink and .gitmodules staged|a gitlink and submodule.active in config|a gitlink staged, no .gitmodules|a gitlink to a repository with its own gitlink|a .git/modules with no gitlink|a hook file|a later launch fast-forwards a clean clone after a commit to the root, then finds it current|a modified file|an alternates file|an ext:: origin|another branch|an untracked file|a reflog hard-linked to a file outside|a second origin url|a staged file|config a symlink to /dev/zero|core.fsmonitor|core.hooksPath|.git a gitdir: file|.git a symlink|.git/logs a symlink|.git/objects a symlink|.git/refs/heads a symlink|HEAD a fifo|includeIf|include.path|no credential or forge remote of the root reaches a clone|no origin|origin moved|the clone a file|the clone a plain directory|the clone a symlink to the bait|the origin url twice, both the root)
safe-no-hookspath: survived
safe-no-fsmonitor: survived
refusal-unmapped: killed (ℹ fail 1; a project clone that can't be made refuses checkout-failed, from --pm and from a session, with no run and no clone left)
kept-unrecorded: killed (ℹ fail 1; each launch makes sure the workspace has a clone of the project: cloned, fast-forwarded, or kept and logged)
checkouts-not-in-session: killed (ℹ fail 1; each launch makes sure the workspace has a clone of the project: cloned, fast-forwarded, or kept and logged)
prompt-no-clones: killed (ℹ fail 1; the prompt names the project clones in the workspace, and none when there are none)
prompt-kept-as-current: killed (ℹ fail 1; the prompt names the project clones in the workspace, and none when there are none)
gitlink-check-off: killed (ℹ fail 5; a clone the session rigged is kept, and git runs nothing and writes nothing outside it|a gitlink and .gitmodules committed|a gitlink and .gitmodules staged|a gitlink staged, no .gitmodules|a gitlink to a repository with its own gitlink)
modules-check-off: killed (ℹ fail 2; a clone the session rigged is kept, and git runs nothing and writes nothing outside it|a .git/modules with no gitlink)
status-recurses: survived
safe-no-submodule-recurse: survived
lsfiles-failure-passes: survived
recursion-unguarded: killed (ℹ fail 5; a clone the session rigged is kept, and git runs nothing and writes nothing outside it|a gitlink and .gitmodules committed|a gitlink and .gitmodules staged|a gitlink staged, no .gitmodules|a gitlink to a repository with its own gitlink)
gitlink-check-after-status: killed (ℹ fail 5; a clone the session rigged is kept, and git runs nothing and writes nothing outside it|a gitlink and .gitmodules committed|a gitlink and .gitmodules staged|a gitlink staged, no .gitmodules|a gitlink to a repository with its own gitlink)