Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
@@ -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
|
||||||
@@ -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)"
|
||||||
@@ -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)
|
||||||
Reference in New Issue
Block a user