docs: Darkwing row 104 round 1 packet, seats commit under their own name (#1598)

Candidate against 817ddfea6: git identity in the sandbox environment,
commit field in checkout results, one prompt line. Gate green, 19 of 19
mutants killed. The candidate itself is not committed.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
2026-10-13 17:17:53 -05:00
co-authored by Claude Opus 5.5
parent fe27adea1c
commit b2ae135524
5 changed files with 693 additions and 0 deletions
+122
View File
@@ -0,0 +1,122 @@
# Row 104 (#1598) round 1: seats commit under their own name
Brief: docs/plans/2026-10-13_retest-faults.md, section "Seats commit under their own name" (last changed in 18c29c28d; blob 523e9e07 at BASE).
BASE: 817ddfea65e6a2f1fc867c6a640d11e8faea6829. The gate and the mutants ran on it. Canonical HEAD was ae4ae7102 when I wrote this packet; `git diff 817ddfea6 ae4ae7102` on the manifest paths is empty. Candidate: build.patch against BASE. candidate-manifest.sha256 lists the 11 files, and applying the patch to a fresh worktree at BASE reproduces every digest in it.
## Files beyond the brief's list
Sage approved these in their row 104 decision. Dewey, please review them with the rest:
- packages/seat/src/checkout.mjs and packages/seat/tests/checkout.test.mjs. They add the `commit` field the prompt line needs. allowed() is byte-identical. A kept clone gets no `commit` field and no extra git run.
- packages/cli/src/launcher.mjs and packages/seat/src/session.mjs. These are "whichever launch file passes it the role and business". The launcher passes `business: b.id, role: instance` to spawnSession, which passes both to sandboxArgv.
- packages/seat/tests/session.test.mjs and packages/cli/tests/launcher.test.mjs, their tests.
## Deviation from the brief
The brief says the name is the role. The name is the role instance's id, and the email is `<instance>@<business>.mosaic.invalid`. That way, two instances of one role commit as different authors. Sage accepted this in their row 104 decision. The README line says so too.
## What changed
- packages/seat/src/sandbox.mjs.
- New `gitIdentity({ business, role })`. Both values must match ID_PATTERN (packages/business/src/vocabulary.mjs); anything else throws `git identity: <what> <value> isn't an id`. It returns GIT_AUTHOR_NAME and GIT_COMMITTER_NAME set to the instance, and GIT_AUTHOR_EMAIL and GIT_COMMITTER_EMAIL set to `<instance>@<business>.mosaic.invalid`. `.invalid` is a reserved domain that can't receive mail.
- sandboxArgv now takes a required `{ business, role, bwrap }` and adds the four values as `--setenv` pairs after HOME and TMPDIR. With no ids it throws. There is no session without an identity.
- The header comment names the identity.
- packages/seat/src/session.mjs. spawnSession takes `business` and `role` and passes them on. The comment says why.
- packages/cli/src/launcher.mjs:251. Passes `business: b.id, role: instance`.
- packages/seat/src/checkout.mjs.
- clone() reads the new clone's commit with `rev-parse --verify HEAD` before the rename. It returns `{ outcome: "cloned", branch, commit }`. If that read fails, it refuses with CheckoutError `the new clone of <root> has no readable HEAD: ...` and removes the temp directory, like a failed clone.
- update() reads HEAD after the fast-forward. It returns `commit` for updated and current, or `null` when the read fails.
- Kept results are unchanged. allowed() is unchanged.
- The ensureCheckouts comment lists the new fields.
- packages/harness/src/bundle.mjs, checkoutLines, still one prompt line. For each clone that isn't kept, it gives the branch and either `commit <sha>` or "a commit the host couldn't read", at session start. Then it says:
- the host fast-forwards a clean clone only at a launch, never during a session;
- don't set git config in a clone, because any key that `git clone` didn't write, user.name and user.email included, makes every later launch keep it;
- commits already carry the instance's name and address from the environment.
- packages/seat/README.md.
- In the sandbox section: where the identity comes from, the deviation, and why it isn't in the clone's config.
- In the clones section: the branch and commit in each result, null when unreadable, none for kept, and what the prompt says.
## Tests
| Brief item | Test |
|---|---|
| the sandbox sets the four variables | sandbox.test: "the git identity is the instance's, at <instance>@<business>.mosaic.invalid, and sandboxArgv refuses without it". It checks gitIdentity's output, refuses undefined, "", "Acme", "coder\nx", "coder>" and 7, and checks that the `--setenv` pairs before `--` are exactly HOME, TMPDIR and the four. sandboxArgv with `{}` throws. |
| a commit in a sandboxed clone with no config reads the author back | sandbox.test checks() row "commit in a fresh clone as the instance", run by a shell inside the sandbox. It runs git init, clone, add and commit with no user.* key in the clone's config (asserted), then `git log -1` gives `coder <[email protected]>` for both author and committer. |
| the launch side passes the instance and business | launcher.test, "the PM launches a coder...". The GIT_* entries in `/proc/<runnerPid>/environ` are exactly the four, for pm and for coder. No host GIT_* gets in. |
| spawnSession passes them | session.test's spawnSession call, which refuses without them (mutant m16). |
| commit in results | checkout.test: cloned, updated, current, ahead-of-root and the multi-project test each check `commit` against the right HEAD. New: "a commit git can't read after the fast-forward is null" and "a new clone whose commit can't be read refuses the launch and leaves nothing behind". |
| a kept clone gets nothing more | checkout.test, new: "a clone with user.name or user.email in its config is kept, and git runs nothing in it" (subtests user.name and user.email). A git on PATH logs every call. The only call naming the clone is unsafe()'s `config --file <clone>/.git/config --list --null`, which reads the file and runs nothing in the repo. The existing kept deepEquals still have no `commit`. |
| the prompt line | bundle.test, "the prompt names the project clones...". It checks the whole line for an updated clone with a commit, a kept one and a current one with commit null. launcher.test checks the live prompt for `on branch main at commit <sha> when this session started`. |
## Mutants
`mutants.py` in the packet: 19 mutants, each one text substitution that must match exactly once. It runs the named test files, each in its own process group (killed after 600 s; none reached it). A mutant counts as killed only if every named file fails. Each file is restored from its .orig, and the candidate's diff hash was the same before and after (b36d6bbff68e42f9). The run went from 22:10:20 to 22:15:08 UTC on BASE. No kill came from a SyntaxError or another load error; each ✖ below is the test that failed.
```
m01 sandboxArgv drops the identity setenv: killed sandbox fail 3, launcher fail 1 (git identity unit test; confined shell checks)
m02 gitIdentity checks nothing: killed sandbox fail 1 (git identity unit test)
m03 gitIdentity checks the type only: killed sandbox fail 1 (git identity unit test)
m04 the email drops the business: killed sandbox fail 3, launcher fail 1
m05 clone() returns no commit: killed checkout fail 2 (first launch clones...; every project...)
m06 clone() skips the HEAD check: killed checkout fail 1 (a new clone whose commit can't be read...)
m07 update() returns no commit: killed checkout fail 3 (fast-forwards a clean clone...; a clone ahead of the root...)
m08 update() has no null fallback: killed checkout fail 1 (a commit git can't read after the fast-forward is null)
m09 a kept clone gets a commit field: killed checkout fail 34
m10 a kept clone gets a git run: killed checkout fail 3 (kept, and git runs nothing in it: user.name, user.email)
m11 the prompt drops the commit: killed bundle fail 1, launcher fail 1
m12 the prompt has no unreadable-commit text: killed bundle fail 1
m13 the prompt drops the branch: killed bundle fail 1, launcher fail 1
m14 the prompt drops the config warning: killed bundle fail 1
m15 the prompt drops the launch-only refresh: killed bundle fail 1
m16 spawnSession passes no identity: killed session fail 1, launcher fail 8
m17 spawnSession fixes the role: killed launcher fail 1 (the PM launches a coder...)
m18 the launcher passes the definition role: killed launcher fail 1
m19 the launcher passes another business: killed launcher fail 1
```
m17 lists only launcher.test. session.test launches a coder, so a role fixed at "coder" passes it, and the launcher's pm check is the one that catches it.
## Gate
`gate.sh` in the packet runs `node --test tests/*.test.mjs` in every package with a tests/ directory, ledger included, then every scripts/test-*.sh. They run one after another, each teed to its own log, and the script stops at the first failure. TMPDIR was in my scratch. DOCKER_HOST was unset for every suite, test-task included, with Docker 29.7.2 up. The run started at 22:04:39 UTC on BASE:
```
pkg-bus rc=0 22:04:39-22:04:43 ℹ pass 128 ℹ fail 0 ℹ skipped 0
pkg-business rc=0 22:04:43-22:04:44 ℹ pass 67 ℹ fail 0 ℹ skipped 0
pkg-cli rc=0 22:04:44-22:05:03 ℹ pass 165 ℹ fail 0 ℹ skipped 0
pkg-control-board rc=0 22:05:03-22:05:04 ℹ pass 124 ℹ fail 0 ℹ skipped 0
pkg-conversation rc=0 22:05:04-22:05:52 ℹ pass 182 ℹ fail 0 ℹ skipped 0
pkg-discord rc=0 22:05:52-22:05:54 ℹ pass 178 ℹ fail 0 ℹ skipped 0
pkg-harness rc=0 22:05:54-22:06:05 ℹ pass 73 ℹ fail 0 ℹ skipped 0
pkg-ledger rc=0 22:06:05-22:06:24 ℹ pass 78 ℹ fail 0 ℹ skipped 0
pkg-mosaic rc=0 22:06:24-22:06:26 ℹ pass 69 ℹ fail 0 ℹ skipped 0
pkg-queue rc=0 22:06:26-22:06:54 ℹ pass 155 ℹ fail 0 ℹ skipped 0
pkg-runs rc=0 22:06:54-22:06:55 ℹ pass 43 ℹ fail 0 ℹ skipped 0
pkg-seat rc=0 22:06:55-22:07:00 ℹ pass 119 ℹ fail 0 ℹ skipped 0
pkg-tasks rc=0 22:07:00-22:07:05 ℹ pass 92 ℹ fail 0 ℹ skipped 0
pkg-webui rc=0 22:07:05-22:08:00 ℹ pass 108 ℹ fail 0 ℹ skipped 0
script-auth rc=0 22:08:00-22:08:01 selftest: 15 passed, 0 failed
script-conductor rc=0 22:08:01-22:08:07 selftest: 17 passed, 0 failed
script-config rc=0 22:08:07-22:08:08 selftest: 24 passed, 0 failed
script-discord rc=0 22:08:08-22:08:15 discord suite: 66 passed, 0 failed
script-extension-package rc=0 22:08:15-22:08:16 extension package selftest: 18 passed, 0 failed
script-foundation rc=0 22:08:16-22:08:52 selftest: 44 passed, 0 failed
script-queue rc=0 22:08:52-22:09:22 queue suite: 28 passed, 0 failed
script-release rc=0 22:09:22-22:09:31 selftest: 14 passed, 0 failed
script-task rc=0 22:09:31-22:10:02 selftest: 98 passed, 0 failed
GATE_PASSED 22:10:02
```
cli 165 and task 98 match Sage's last gate. The seat suite has 113 tests at BASE (run at 22:17 UTC in a fresh worktree) and 119 here. The 6 new ones are the identity unit test, the kept-clone test with its user.name and user.email subtests, the null commit test and the unreadable-clone test. harness stays at 73, because the bundle change edits an existing test. Afterwards I removed the empty wt_default network (created at 22:09:22 UTC, during test-release), and no process of mine was left.
## Choices and limits
- A fresh clone with no readable HEAD refuses the launch, but an existing clone whose HEAD can't be read after the fast-forward reports `commit: null`. A new clone that git can't read is broken, and its temp directory can still go. An existing clone is already in place, and its fetch and merge succeeded, so a failed read shouldn't stop the launch. The prompt then says "a commit the host couldn't read".
- The environment wins over config for authorship. A session that sets user.name in its clone still commits as its instance. The config key would still make later launches keep the clone, so the prompt says not to.
- What the tests prove. The runner's own environ carries the identity (launcher.test), and a shell inside the sandbox commits under it (sandbox.test). runner.mjs adapterEnv spreads the session env into each turn's adapter. No test runs a live harness's Bash tool, so a harness that scrubbed GIT_* from its shells would get past these tests. Neither pi nor Claude Code does that today, as far as I know, but I haven't run one to check.
- sessionEnv's allowlist already keeps host GIT_* out. launcher.test asserts that the four are the only GIT_* in the runner's environ.
- Out of scope, as the brief says: allowed(), signing, and the coder's existing clone. That clone still holds user.name and user.email, so it stays `config-changed` until Sage unsets them with Mos's go.
## Going live (decision 109)
The seat, harness and CLI changes reach the live bus only through a new pinned copy and re-pin under Mos's go. The launch side (launcher.mjs, session.mjs, sandbox.mjs) goes live only at a bus restart that Mos approves. Mos gets a notice before and after the landing. The live bus, Console and CLI run from the pinned copy at 465bbdfbb.
@@ -0,0 +1,437 @@
diff --git a/packages/cli/src/launcher.mjs b/packages/cli/src/launcher.mjs
index c671725c8..8eb6d902a 100644
--- a/packages/cli/src/launcher.mjs
+++ b/packages/cli/src/launcher.mjs
@@ -248,7 +248,7 @@ export function createLauncher({
claudeBin: env.MOSAIC_CLAUDE_BIN || null,
readOnly,
};
- const child = spawnSession({ runDir, env: sessionEnv(env, run), paths, bwrap });
+ const child = spawnSession({ runDir, env: sessionEnv(env, run), paths, business: b.id, role: instance, bwrap });
let bound = false;
let entry = null;
const exited = new Promise((resolve) => child.once("close", (code, signal) => resolve(sessionExit(code, signal))));
diff --git a/packages/cli/tests/launcher.test.mjs b/packages/cli/tests/launcher.test.mjs
index 14e996dfe..7e2623728 100644
--- a/packages/cli/tests/launcher.test.mjs
+++ b/packages/cli/tests/launcher.test.mjs
@@ -226,6 +226,12 @@ test("the PM launches a coder through its launch tool; the coder answers; refusa
assert.equal(gitBound(coder.pid), true);
assert.equal(gitBound(readSessions(f.dataRoot).find((s) => s.instance === "pm").pid), false);
assert.equal(JSON.parse(readFileSync(join(coder.runDir, "session.json"), "utf8")).gitSocket, gitSocketPath(f.dataRoot));
+ // Row 104 (#1598): each runner commits as its own instance, in this business.
+ const identity = (pid) => readFileSync(`/proc/${pid}/environ`, "utf8").split("\0").filter((e) => e.startsWith("GIT_")).sort();
+ for (const who of ["pm", "coder"]) {
+ const runnerPid = readSessions(f.dataRoot).find((s) => s.instance === who).runnerPid;
+ assert.deepEqual(identity(runnerPid), [`GIT_AUTHOR_EMAIL=${who}@acme.mosaic.invalid`, `GIT_AUTHOR_NAME=${who}`, `GIT_COMMITTER_EMAIL=${who}@acme.mosaic.invalid`, `GIT_COMMITTER_NAME=${who}`], who);
+ }
// The coder claimed its role and answers its own messages.
const hello = await client.call("message.send", { to: "coder", class: "REQUEST", body: "hello" });
@@ -285,7 +291,7 @@ test("each launch makes sure the workspace has a clone of the project: cloned, f
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. `));
+ assert.ok(prompt(pm1.run).includes(`- Project clones: stack at ${clone}, on branch main at commit ${one} when this session started. `));
// 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.
@@ -299,7 +305,7 @@ test("each launch makes sure the workspace has a clone of the project: cloned, f
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.ok(prompt(pm2.run).includes(`- Project clones: stack at ${clone}, on branch main at commit ${two} when this session started. `));
assert.deepEqual(kept(), []);
await second.client.call("role.release");
assert.equal(await second.close(), 0);
diff --git a/packages/harness/src/bundle.mjs b/packages/harness/src/bundle.mjs
index 728eef797..478ef0a03 100644
--- a/packages/harness/src/bundle.mjs
+++ b/packages/harness/src/bundle.mjs
@@ -56,10 +56,13 @@ export function sessionModel(resolved) {
}
// Row 91 (#1585): the project clones the host made or kept in the workspace.
+// Row 104 (#1598): the commit each was at when the session started, when it
+// refreshes, and why the session must not set git config in it.
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.`];
+ const at = (c) => (c.commit ? `commit ${c.commit}` : "a commit the host couldn't read");
+ 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} at ${at(c)} when this session started`));
+ return [`- Project clones: ${each.join("; ")}. Each is a git clone of the project. The host fetches from its origin and fast-forwards a clean clone only at a launch, never during a session; the origin is outside this sandbox. Don't set git config in a clone. Any key that \`git clone\` didn't write, user.name and user.email included, makes every later launch keep that clone as it is. Your commits already carry your role instance's name and address from the environment.`];
}
function prompt({ contract, resolved, session, tools, untyped }) {
diff --git a/packages/harness/tests/bundle.test.mjs b/packages/harness/tests/bundle.test.mjs
index 6a17a0dcd..51555415f 100644
--- a/packages/harness/tests/bundle.test.mjs
+++ b/packages/harness/tests/bundle.test.mjs
@@ -104,6 +104,7 @@ 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.
+// Row 104 (#1598): and the commit each was at, when it refreshes, and the git config warning.
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"));
@@ -111,10 +112,12 @@ test("the prompt names the project clones in the workspace, and none when there
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: "stack", path: join(dir, "ws", "stack"), outcome: "updated", branch: "main", commit: "a".repeat(40) },
{ project: "site", path: join(dir, "ws", "site"), outcome: "kept", reason: "dirty" },
+ { project: "docs", path: join(dir, "ws", "docs"), outcome: "current", branch: "dev", commit: null },
]) });
- 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 after = "Each is a git clone of the project. The host fetches from its origin and fast-forwards a clean clone only at a launch, never during a session; the origin is outside this sandbox. Don't set git config in a clone. Any key that `git clone` didn't write, user.name and user.email included, makes every later launch keep that clone as it is. Your commits already carry your role instance's name and address from the environment.";
+ assert.deepEqual(line(two), [`- Project clones: stack at ${join(dir, "ws", "stack")}, on branch main at commit ${"a".repeat(40)} when this session started; site at ${join(dir, "ws", "site")}, not updated at this launch (dirty); docs at ${join(dir, "ws", "docs")}, on branch dev at a commit the host couldn't read when this session started. ${after}`]);
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([]) })), []);
diff --git a/packages/seat/README.md b/packages/seat/README.md
index 2d01c9d0f..7ef200400 100644
--- a/packages/seat/README.md
+++ b/packages/seat/README.md
@@ -196,6 +196,18 @@ and the file's header comment has the table.
- Private: `/tmp` is the run's `tmp/`, `HOME` the run's empty `home/`, and
`/dev` and `/proc` are the namespace's own.
+The environment is `sessionEnv`'s allowlist plus `HOME`, `TMPDIR` and the
+session's git identity (#1598). `gitIdentity()` sets `GIT_AUTHOR_NAME` and
+`GIT_COMMITTER_NAME` to the role instance's id, and `GIT_AUTHOR_EMAIL` and
+`GIT_COMMITTER_EMAIL` to `<instance>@<business>.mosaic.invalid`, a domain
+that can't receive mail. The launcher passes the instance and the business
+id, and `sandboxArgv` throws when either isn't an id. The brief names the
+role; the instance id is used instead, so two instances of one role commit
+as different authors (Sage's decision on row 104). The identity lives in the
+environment and not in the clone's config, because a `user.name` or
+`user.email` key there makes every later launch keep the clone
+(`config-changed`, below).
+
The root is remounted read-only, so a write anywhere else fails. Nothing
else of the host's filesystem exists inside: no data root, no other
workspace or run, no `~/.config`, `~/.mosaic`, `~/.ssh` or `~/.claude`, no
@@ -254,6 +266,13 @@ 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.
+Each result also has the clone's branch and, for `cloned`, `updated` and
+`current`, the commit it is at after the launch (`null` when git can't read
+it). A kept clone has neither, and the host runs no more git in it to find
+one. The bundle's prompt names the branch and commit, says the host
+refreshes a clone only at a launch, and tells the session not to set git
+config in its clone (#1598).
+
A kept clone's reason is one of:
| Reason | The clone |
diff --git a/packages/seat/src/checkout.mjs b/packages/seat/src/checkout.mjs
index f91fe1590..20c767082 100644
--- a/packages/seat/src/checkout.mjs
+++ b/packages/seat/src/checkout.mjs
@@ -226,16 +226,20 @@ async function clone(project, root, workspace, dest, g) {
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")}`);
+ let commit;
try {
const r = await g(["clone", "-q", "--no-hardlinks", "--template=", "--branch", at.branch, "--", root, tmp]);
if (!r.ok) throw new CheckoutError(project, `git clone of ${root} failed: ${r.why}`);
+ const head = await g([...inClone(tmp), "rev-parse", "--verify", "HEAD"]);
+ if (!head.ok) throw new CheckoutError(project, `the new clone of ${root} has no readable HEAD: ${head.why}`);
+ commit = head.out.trim();
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 };
+ return { outcome: "cloned", branch: at.branch, commit };
}
async function update(root, dest, g) {
@@ -256,15 +260,19 @@ async function update(root, dest, g) {
const before = (await g([...inClone(dest), "rev-parse", "HEAD"])).out.trim();
if (!(await g([...inClone(dest), "fetch", "-q", "--no-tags", "--no-recurse-submodules", "origin"])).ok) return { outcome: "kept", reason: "fetch-failed" };
if (!(await g([...inClone(dest), "merge", "-q", "--ff-only", "--no-edit", `refs/remotes/origin/${at.branch}`])).ok) return { outcome: "kept", reason: "not-fast-forward" };
- const after = (await g([...inClone(dest), "rev-parse", "HEAD"])).out.trim();
- return { outcome: before === after ? "current" : "updated", branch: at.branch };
+ const read = await g([...inClone(dest), "rev-parse", "HEAD"]);
+ const after = read.out.trim();
+ return { outcome: before === after ? "current" : "updated", branch: at.branch, commit: read.ok ? after : null };
}
// await ensureCheckouts({ workspace, projects, env, limits }) 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. `limits` ({ timeoutMs, graceMs }) is for the tests.
+// path, outcome, reason?, branch?, commit? } per project, outcome cloned,
+// updated, current or kept. branch and commit are the clone's after this
+// launch (commit null when git couldn't read it); a kept clone has neither,
+// since git may not be safe to run there. Rejects with CheckoutError when a
+// missing clone can't be made. `limits` ({ timeoutMs, graceMs }) is for the
+// tests.
export async function ensureCheckouts({ workspace, projects, env = process.env, limits = GIT_LIMITS }) {
const g = (args, extra) => git(args, env, extra, limits);
const out = [];
diff --git a/packages/seat/src/sandbox.mjs b/packages/seat/src/sandbox.mjs
index 274a570f1..fdf41bbef 100644
--- a/packages/seat/src/sandbox.mjs
+++ b/packages/seat/src/sandbox.mjs
@@ -15,6 +15,9 @@
// run's bundle/ and session.json, which stay read-only
// private /tmp (the run's tmp/), HOME (the run's home/), /dev, /proc
//
+// The environment adds HOME, TMPDIR and the role's git identity
+// (gitIdentity() below).
+//
// Nothing else exists inside: no data root, no other workspace or run
// directory, no ~/.config, ~/.mosaic or ~/.ssh, no checkout beyond the
// runner's files. The root is remounted read-only, so a write anywhere but
@@ -34,6 +37,7 @@ import { spawnSync } from "node:child_process";
import { existsSync, lstatSync, mkdirSync, readlinkSync, realpathSync } from "node:fs";
import { dirname, join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
+import { ID_PATTERN } from "../../business/src/vocabulary.mjs";
export const REPO = resolve(dirname(fileURLToPath(import.meta.url)), "..", "..", "..");
export const BWRAP = "/usr/bin/bwrap";
@@ -132,10 +136,25 @@ export function prepareRun(runDir) {
for (const d of ["home", "tmp"]) mkdirSync(join(runDir, d), { recursive: true, mode: 0o700 });
}
+// The git identity a session commits under (#1598): the role instance's
+// name, and <instance>@<business>.mosaic.invalid, a domain that can't
+// receive mail. It goes in the environment, so a clone's config needs no
+// user.name or user.email key, which would keep the clone at the next launch
+// (checkout.mjs, allowed()). Both ids must be business ids.
+export function gitIdentity({ business, role }) {
+ for (const [what, id] of [["business", business], ["role", role]]) {
+ if (typeof id !== "string" || !ID_PATTERN.test(id)) throw new Error(`git identity: ${what} ${JSON.stringify(id)} isn't an id`);
+ }
+ const email = `${role}@${business}.mosaic.invalid`;
+ return { GIT_AUTHOR_NAME: role, GIT_AUTHOR_EMAIL: email, GIT_COMMITTER_NAME: role, GIT_COMMITTER_EMAIL: email };
+}
+
// The full command line for `command` inside the sandbox. HOME is the run's
-// home/, empty at the start; the harness makes what it needs there.
-export function sandboxArgv(list, runDir, command, { bwrap = BWRAP } = {}) {
- return [bwrap, ...NAMESPACES, ...argsOf(list), "--remount-ro", "/", "--setenv", "HOME", join(runDir, "home"), "--setenv", "TMPDIR", "/tmp", "--chdir", runDir, "--", ...KILL_WITH_PARENT, ...command];
+// home/, empty at the start; the harness makes what it needs there. The git
+// identity is the role's, for the business it runs in.
+export function sandboxArgv(list, runDir, command, { business, role, bwrap = BWRAP }) {
+ const identity = Object.entries(gitIdentity({ business, role })).flatMap(([k, v]) => ["--setenv", k, v]);
+ return [bwrap, ...NAMESPACES, ...argsOf(list), "--remount-ro", "/", "--setenv", "HOME", join(runDir, "home"), "--setenv", "TMPDIR", "/tmp", ...identity, "--chdir", runDir, "--", ...KILL_WITH_PARENT, ...command];
}
// Whether bwrap runs here at all: the namespaces and the system binds, then
diff --git a/packages/seat/src/session.mjs b/packages/seat/src/session.mjs
index ff79ee827..f04386ac3 100644
--- a/packages/seat/src/session.mjs
+++ b/packages/seat/src/session.mjs
@@ -118,10 +118,11 @@ export function appendLaunchLog(dataRoot, business, entry) {
// the host writes once the broker has bound the launch; stdout and stderr go
// to runner.log. paths: what sandbox.mjs's binds() takes besides the run
// directory (workspace, adapter, harness, brokerSocket, launchSocket,
-// claudeBin; readOnly for tests). `bwrap` is for the tests.
-export function spawnSession({ runDir, env, paths, node = process.execPath, bwrap }) {
+// claudeBin; readOnly for tests). business and role (the instance) give the
+// session its git identity. `bwrap` is for the tests.
+export function spawnSession({ runDir, env, paths, business, role, node = process.execPath, bwrap }) {
prepareRun(runDir);
- const argv = sandboxArgv(binds({ ...paths, runDir, node }), runDir, [node, RUNNER, runDir], { bwrap });
+ const argv = sandboxArgv(binds({ ...paths, runDir, node }), runDir, [node, RUNNER, runDir], { business, role, bwrap });
const out = openSync(join(runDir, "runner.log"), "a", 0o600);
try {
return spawn(argv[0], argv.slice(1), { cwd: runDir, env, stdio: ["pipe", out, out], detached: true });
diff --git a/packages/seat/tests/checkout.test.mjs b/packages/seat/tests/checkout.test.mjs
index 8e2344165..6e06f23a8 100644
--- a/packages/seat/tests/checkout.test.mjs
+++ b/packages/seat/tests/checkout.test.mjs
@@ -83,7 +83,7 @@ async function once(w) {
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.deepEqual(await once(w), { project: "stack", path: w.clone, outcome: "cloned", branch: "main", commit: git(w.root, "rev-parse", "HEAD") });
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);
@@ -116,10 +116,10 @@ test("a later launch fast-forwards a clean clone after a commit to the root, the
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.deepEqual(await once(w), { project: "stack", path: w.clone, outcome: "updated", branch: "main", commit: head });
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.deepEqual(await once(w), { project: "stack", path: w.clone, outcome: "current", branch: "main", commit: head });
assert.equal(git(w.clone, "status", "--porcelain"), "");
});
@@ -127,7 +127,7 @@ test("a clone ahead of the root keeps its commits and counts as current", async
const w = world(t);
await once(w);
const mine = commit(w.clone, "mine\n");
- assert.equal((await once(w)).outcome, "current");
+ assert.deepEqual(await once(w), { project: "stack", path: w.clone, outcome: "current", branch: "main", commit: mine });
assert.equal(git(w.clone, "rev-parse", "HEAD"), mine);
});
@@ -156,6 +156,57 @@ test("a dirty clone, one on another branch and one diverged are kept as they are
}
});
+// Row 104 (#1598): a session that sets its own git identity in the clone's
+// config gets the clone kept at every later launch. The host only reads the
+// clone's config file and runs no git in it, so the kept result has no commit.
+test("a clone with user.name or user.email in its config is kept, and git runs nothing in it", async (t) => {
+ for (const key of ["user.name", "user.email"]) {
+ await t.test(key, async (t) => {
+ const w = world(t);
+ await once(w);
+ git(w.clone, "config", key, "someone");
+ commit(w.root, "two\n");
+ const bin = join(w.dir, "bin");
+ mkdirSync(bin);
+ const real = spawnSync("sh", ["-c", "command -v git"], { encoding: "utf8" }).stdout.trim();
+ const calls = join(w.dir, "calls");
+ writeFileSync(join(bin, "git"), `#!/bin/sh\nprintf '%s\\n' "$*" >> '${calls}'\nexec '${real}' "$@"\n`, { mode: 0o755 });
+ const list = await ensureCheckouts({ workspace: w.workspace, projects: { stack: { root: w.root } }, env: { PATH: `${bin}:${process.env.PATH}` } });
+ assert.deepEqual(list, [{ project: "stack", path: w.clone, outcome: "kept", reason: "config-changed" }]);
+ // The one call naming the clone reads its config file, as unsafe() does for every clone.
+ const named = readFileSync(calls, "utf8").split("\n").filter((l) => l.includes(w.clone));
+ assert.deepEqual(named.map((l) => l.slice(l.indexOf(" config "))), [` config --file ${join(w.clone, ".git", "config")} --list --null`], "no git ran in the kept clone");
+ });
+ }
+});
+
+// When git can't read the clone's HEAD after the fast-forward, the commit is
+// null, not an empty string. A git on PATH fails the second rev-parse HEAD.
+test("a commit git can't read after the fast-forward is null", async (t) => {
+ const w = world(t);
+ await once(w);
+ commit(w.root, "two\n");
+ const bin = join(w.dir, "bin");
+ mkdirSync(bin);
+ const real = spawnSync("sh", ["-c", "command -v git"], { encoding: "utf8" }).stdout.trim();
+ const count = join(w.dir, "count");
+ writeFileSync(join(bin, "git"), `#!/bin/sh\ncase " $* " in *" rev-parse HEAD ") echo x >> '${count}'; [ "$(wc -l < '${count}')" -ge 2 ] && exit 1;; esac\nexec '${real}' "$@"\n`, { mode: 0o755 });
+ const list = await ensureCheckouts({ workspace: w.workspace, projects: { stack: { root: w.root } }, env: { PATH: `${bin}:${process.env.PATH}` } });
+ assert.deepEqual(list, [{ project: "stack", path: w.clone, outcome: "updated", branch: "main", commit: null }]);
+});
+
+// A new clone whose HEAD git can't read refuses like a failed clone, and its
+// temp directory goes. A git on PATH fails rev-parse --verify HEAD.
+test("a new clone whose commit can't be read refuses the launch and leaves nothing behind", 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();
+ writeFileSync(join(bin, "git"), `#!/bin/sh\ncase " $* " in *" rev-parse --verify HEAD ") echo 'fatal: no HEAD' >&2; exit 128;; esac\nexec '${real}' "$@"\n`, { mode: 0o755 });
+ await assert.rejects(ensureCheckouts({ workspace: w.workspace, projects: { stack: { root: w.root } }, env: { PATH: `${bin}:${process.env.PATH}` } }), (e) => e instanceof CheckoutError && e.project === "stack" && /^the new clone of .* has no readable HEAD: /.test(e.message));
+ assert.deepEqual(readdirSync(w.workspace), [], "no clone and no temp directory");
+});
+
// 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) {
@@ -328,7 +379,7 @@ test("every project in the business file gets its own clone at its own branch",
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.deepEqual(list.map((c) => [c.project, c.path, c.outcome, c.branch, c.commit]), [["alpha", join(workspace, "alpha"), "cloned", "main", git(a, "rev-parse", "HEAD")], ["beta", join(workspace, "beta"), "cloned", "dev", git(b, "rev-parse", "HEAD")]]);
assert.equal(git(join(workspace, "beta"), "symbolic-ref", "--short", "HEAD"), "dev");
});
diff --git a/packages/seat/tests/sandbox.test.mjs b/packages/seat/tests/sandbox.test.mjs
index 9d029c6db..c2bb68347 100644
--- a/packages/seat/tests/sandbox.test.mjs
+++ b/packages/seat/tests/sandbox.test.mjs
@@ -18,7 +18,7 @@ import { Store } from "../../bus/src/store.mjs";
import { SOURCES } from "../../harness/src/bundle.mjs";
import { startTimeOf } from "../src/proc.mjs";
import { newRun, RUNNER, runnerOf, sessionEnv, spawnSession, writeSessionFile } from "../src/session.mjs";
-import { binds, prepareRun, REPO, RUNNER_FILES, sandboxArgv, sandboxProblem } from "../src/sandbox.mjs";
+import { binds, gitIdentity, prepareRun, REPO, RUNNER_FILES, sandboxArgv, sandboxProblem } from "../src/sandbox.mjs";
const FAKE = new URL("../../harness/tests/fixtures/fake-adapter.mjs", import.meta.url).pathname;
const skip = sandboxProblem() ?? false;
@@ -80,6 +80,26 @@ test("the host's git socket is bound only when given, read-only and before the w
assert.ok(RUNNER_FILES.includes(join(REPO, "packages/harness/src/git-tools.mjs")));
});
+// Row 104 (#1598): the git identity comes from the business and the role
+// instance, both checked as ids, and sandboxArgv sets it for every session.
+test("the git identity is the instance's, at <instance>@<business>.mosaic.invalid, and sandboxArgv refuses without it", (t) => {
+ const email = "[email protected]";
+ assert.deepEqual(gitIdentity({ business: "acme", role: "coder" }), { GIT_AUTHOR_NAME: "coder", GIT_AUTHOR_EMAIL: email, GIT_COMMITTER_NAME: "coder", GIT_COMMITTER_EMAIL: email });
+ for (const [business, role] of [[undefined, "coder"], ["acme", undefined], ["acme", ""], ["Acme", "coder"], ["acme", "coder\nx"], ["acme", "coder>"], ["acme", 7]]) {
+ assert.throws(() => gitIdentity({ business, role }), /^Error: git identity: (business|role) .* isn't an id$/, `${business}/${role}`);
+ }
+ const root = scratch(t);
+ const runDir = join(root, "run");
+ const list = binds({ runDir, workspace: join(root, "ws"), adapter: join(root, "a.sh"), harness: "pi", brokerSocket: join(root, "bus.sock") });
+ const argv = sandboxArgv(list, runDir, ["/bin/true"], { business: "acme", role: "coder" });
+ const set = {};
+ argv.slice(0, argv.indexOf("--")).forEach((a, i, all) => {
+ if (a === "--setenv") set[all[i + 1]] = all[i + 2];
+ });
+ assert.deepEqual(set, { HOME: join(runDir, "home"), TMPDIR: "/tmp", ...gitIdentity({ business: "acme", role: "coder" }) });
+ assert.throws(() => sandboxArgv(list, runDir, ["/bin/true"], {}), /git identity: business undefined isn't an id/);
+});
+
// The founder's files and the other sessions' state, as stand-ins. The
// /dev/shm file and the SysV segment are the host's, made for this test and
// removed after it.
@@ -136,6 +156,10 @@ function checks({ s, ws, runDir, run, dataRoot, store }) {
["write and read /tmp", "echo tmp > /tmp/probe && cat /tmp/probe", true, "tmp"],
["run node", `'${process.execPath}' -e 'console.log(6 * 7)'`, true, "42"],
["run pi, the session's harness", `'${join(REPO, "node_modules", ".bin", "pi")}' --version >/dev/null`, true],
+ // Row 104 (#1598): a commit in a clone with no user config carries the
+ // instance's identity from the environment, and the clone's config
+ // gains no user key.
+ ["commit in a fresh clone as the instance", `d=$(mktemp -d) && cd "$d" && git init -q o && git -C o commit --allow-empty -q -m base && git clone -q o c && cd c && echo x > f && git add f && git commit -q -m m && ! git config --file .git/config --get-regexp '^user\\.' && git log -1 --format='%an <%ae>|%cn <%ce>'`, true, "coder <[email protected]>|coder <[email protected]>"],
];
}
@@ -205,7 +229,7 @@ async function session(t, { expose = () => [] } = {}) {
});
const paths = { workspace: ws, adapter, harness: "pi", brokerSocket, readOnly: [FAKE, ...expose(s)] };
- const child = spawnSession({ runDir, env: sessionEnv({ HOME: process.env.HOME, PATH: process.env.PATH }, run), paths });
+ const child = spawnSession({ runDir, env: sessionEnv({ HOME: process.env.HOME, PATH: process.env.PATH }, run), paths, business: "acme", role: "coder" });
const exited = new Promise((r) => child.once("close", (code, signal) => r({ code, signal })));
t.after(async () => {
try {
@@ -263,7 +287,7 @@ test("a bind that exposes a stand-in, or a missing workspace bind, fails the mat
// without the workspace's.
const all = binds({ runDir: x.runDir, workspace: x.ws, adapter: x.adapter, harness: "pi", brokerSocket: x.brokerSocket });
const direct = (list) => async (cmd) => {
- const argv = sandboxArgv(list, x.runDir, ["/bin/sh", "-c", cmd]);
+ const argv = sandboxArgv(list, x.runDir, ["/bin/sh", "-c", cmd], { business: "acme", role: "coder" });
const r = spawnSync(argv[0], argv.slice(1), { encoding: "utf8", timeout: 10000 });
return { rc: r.status, out: (r.stdout + r.stderr).replace(/\n$/, "") };
};
diff --git a/packages/seat/tests/session.test.mjs b/packages/seat/tests/session.test.mjs
index 0e3b893bb..72e8c9453 100644
--- a/packages/seat/tests/session.test.mjs
+++ b/packages/seat/tests/session.test.mjs
@@ -192,7 +192,7 @@ test("a session runs in its sandbox as pid 1 of its namespace, claims, answers,
});
const paths = { workspace: ws, adapter, harness: "pi", brokerSocket, readOnly: [FAKE] };
- const child = spawnSession({ runDir, env: sessionEnv({ HOME: process.env.HOME, PATH: process.env.PATH, GITEA_TOKEN: "x" }, run), paths });
+ const child = spawnSession({ runDir, env: sessionEnv({ HOME: process.env.HOME, PATH: process.env.PATH, GITEA_TOKEN: "x" }, run), paths, business: "acme", role: "coder" });
t.after(() => {
try {
process.kill(child.pid, "SIGKILL");
@@ -0,0 +1,11 @@
f5b0e13edfdb7556727504f2dbf289a323f65e01a1dd83fbc7982dadf6fe0c22 packages/cli/src/launcher.mjs
c0f0cdee82ec68ec0171bce993aec6b61ea183c17123045241ecf4402a7c0f15 packages/cli/tests/launcher.test.mjs
c3fd0952523fb52d59dfafd6ea8815387c6360ae2064fa51d9371811594257da packages/harness/src/bundle.mjs
2f0be137b70f7150ca3a73b7d5d8ffe59b0ba5c8848f17fd6492319f6d3d04d9 packages/harness/tests/bundle.test.mjs
7cb20222d6689782296c1a68cda83dc3c3e7b15c986a723ae05eff1a997e5414 packages/seat/README.md
af4da9693e1f00446e710ea17db043ae3a33a2b70b3cceae7e66be4de6b1442e packages/seat/src/checkout.mjs
24729d6bdb70ab426d7d020783aab55d78b7bfd1508c0ab53e2cb078bb9083e6 packages/seat/src/sandbox.mjs
c940c5e8e9a588eb941f92c0c5ed371cd8f0d74fee233c2dee5ffa6aecc2ec7b packages/seat/src/session.mjs
9c00224dff9f3136ce3090d7d7053724e0754de04ccf6a927a12e6395d7dbb0b packages/seat/tests/checkout.test.mjs
72811e3e712214da8ade183bad03c6ea4a16ac77c4d04fb78fd6c8d75493499d packages/seat/tests/sandbox.test.mjs
0d6d6b8b04570b7f9247060e4592c967c0cdd09d755d71c09d3b8bcd61c71aa6 packages/seat/tests/session.test.mjs
+20
View File
@@ -0,0 +1,20 @@
#!/bin/bash
set -o pipefail
# Darkwing gate, row 104 (#1598), candidate in the r104 worktree on base 817ddfea6.
# Every package's node suite, then every scripts/test-*.sh, sequential, each
# teed to its own log; stops at the first failure.
G=$HOME/darkwing-scratch/r104/wt
OUT=$HOME/darkwing-scratch/r104/gate
export TMPDIR=$HOME/darkwing-scratch/r104/tmp
mkdir -p "$OUT" "$TMPDIR"
cd "$G" || exit 9
[ -e "$G/node_modules" ] || { echo "STOP no node_modules in $G"; exit 8; }
echo "base $(git rev-parse --short HEAD) DOCKER_HOST=${DOCKER_HOST:-unset} docker=$(docker info --format '{{.ServerVersion}}' 2>/dev/null || echo none) start $(date -u +%H:%M:%S)"
run() { local name="$1" dir="$2" cmd="$3" t0; t0=$(date -u +%H:%M:%S)
(cd "$G/$dir" && bash -c "$cmd") 2>&1 | tee "$OUT/$name.log" > /dev/null; local rc=$?
echo "$name rc=$rc $t0-$(date -u +%H:%M:%S) $(grep -E '^ℹ (pass|fail|skipped) [0-9]+' "$OUT/$name.log" | tr '\n' ' ') | $(tail -n1 "$OUT/$name.log")"
[ $rc -eq 0 ] || { echo "STOP $name"; exit $rc; }; }
for d in packages/*/; do p=$(basename "$d"); [ -d "$d/tests" ] || continue
run "pkg-$p" "packages/$p" 'node --test tests/*.test.mjs'; done
for s in scripts/test-*.sh; do n=$(basename "$s" .sh); run "script-${n#test-}" . "bash $s"; done
echo "GATE_PASSED $(date -u +%H:%M:%S)"
@@ -0,0 +1,103 @@
#!/usr/bin/env python3
# Row 104 (#1598) mutants. Each takes out or bends one new check; the named
# test files must fail on it. Each file is restored from its .orig after the
# run, and the candidate's diff must hash the same at the end as at the start.
import hashlib, os, signal, subprocess, sys, time
WT = os.path.expanduser("~/darkwing-scratch/r104/wt")
OUT = os.path.expanduser("~/darkwing-scratch/r104/mutants")
ENV = dict(os.environ, TMPDIR=os.path.expanduser("~/darkwing-scratch/r104/tmp"))
os.makedirs(OUT, exist_ok=True)
os.makedirs(ENV["TMPDIR"], exist_ok=True)
SB, CO, BU, SE, LA = (
"packages/seat/src/sandbox.mjs", "packages/seat/src/checkout.mjs", "packages/harness/src/bundle.mjs",
"packages/seat/src/session.mjs", "packages/cli/src/launcher.mjs")
T_SB, T_CO, T_BU, T_SE, T_LA = (
"packages/seat/tests/sandbox.test.mjs", "packages/seat/tests/checkout.test.mjs", "packages/harness/tests/bundle.test.mjs",
"packages/seat/tests/session.test.mjs", "packages/cli/tests/launcher.test.mjs")
M = [
("m01 sandboxArgv drops the identity setenv", SB,
'"/tmp", ...identity, "--chdir"', '"/tmp", "--chdir"', [T_SB, T_LA]),
("m02 gitIdentity checks nothing", SB,
'if (typeof id !== "string" || !ID_PATTERN.test(id)) throw', 'if (false) throw', [T_SB]),
("m03 gitIdentity checks the type only", SB,
'typeof id !== "string" || !ID_PATTERN.test(id)', 'typeof id !== "string"', [T_SB]),
("m04 the email drops the business", SB,
'`${role}@${business}.mosaic.invalid`', '`${role}@mosaic.invalid`', [T_SB, T_LA]),
("m05 clone() returns no commit", CO,
'return { outcome: "cloned", branch: at.branch, commit };', 'return { outcome: "cloned", branch: at.branch };', [T_CO]),
("m06 clone() skips the HEAD check", CO,
'if (!head.ok) throw new CheckoutError(project, `the new clone', 'if (false) throw new CheckoutError(project, `the new clone', [T_CO]),
("m07 update() returns no commit", CO,
'branch: at.branch, commit: read.ok ? after : null }', 'branch: at.branch }', [T_CO]),
("m08 update() has no null fallback", CO,
'commit: read.ok ? after : null }', 'commit: after }', [T_CO]),
("m09 a kept clone gets a commit field", CO,
'if (problem) return { outcome: "kept", reason: problem };', 'if (problem) return { outcome: "kept", reason: problem, commit: null };', [T_CO]),
("m10 a kept clone gets a git run", CO,
'if (problem) return { outcome: "kept", reason: problem };',
'if (problem) { await g([...inClone(dest), "rev-parse", "HEAD"]); return { outcome: "kept", reason: problem }; }', [T_CO]),
("m11 the prompt drops the commit", BU,
'(c.commit ? `commit ${c.commit}` : ', '(c.commit ? "a commit" : ', [T_BU, T_LA]),
("m12 the prompt has no unreadable-commit text", BU,
'(c.commit ? `commit ${c.commit}` : "a commit the host couldn\'t read")', '`commit ${c.commit}`', [T_BU]),
("m13 the prompt drops the branch", BU,
'on branch ${c.branch} at ${at(c)}', 'at ${at(c)}', [T_BU, T_LA]),
("m14 the prompt drops the config warning", BU,
"Don't set git config in a clone. Any key that \\`git clone\\` didn't write, user.name and user.email included, makes every later launch keep that clone as it is. ", "", [T_BU]),
("m15 the prompt drops the launch-only refresh", BU,
"The host fetches from its origin and fast-forwards a clean clone only at a launch, never during a session; the origin is outside this sandbox. ", "", [T_BU]),
("m16 spawnSession passes no identity", SE,
'{ business, role, bwrap }', '{ bwrap }', [T_SE, T_LA]),
("m17 spawnSession fixes the role", SE,
'{ business, role, bwrap }', '{ business, role: "coder", bwrap }', [T_LA]),
("m18 the launcher passes the definition role", LA,
'business: b.id, role: instance, bwrap', 'business: b.id, role: "pm", bwrap', [T_LA]),
("m19 the launcher passes another business", LA,
'business: b.id, role: instance, bwrap', 'business: "x", role: instance, bwrap', [T_LA]),
]
def diffhash():
d = subprocess.run(["git", "-C", WT, "diff", "--", ".", ":!node_modules"], capture_output=True).stdout
return hashlib.sha256(d).hexdigest()
def run(test, log):
pkg = os.path.join(WT, *test.split("/")[:2])
with open(log, "ab") as f:
f.write(f"== {test}\n".encode()); f.flush()
p = subprocess.Popen(["node", "--test", os.path.relpath(os.path.join(WT, test), pkg)], cwd=pkg, env=ENV,
stdout=f, stderr=subprocess.STDOUT, start_new_session=True)
try:
return p.wait(timeout=600)
except subprocess.TimeoutExpired:
os.killpg(p.pid, signal.SIGKILL); p.wait(); return "timeout"
for _, path, *_ in M:
if os.path.exists(os.path.join(WT, path + ".orig")):
sys.exit(f"STOP {path}.orig exists")
start = diffhash()
print(f"start diff {start[:16]} {time.strftime('%H:%M:%S', time.gmtime())}", flush=True)
bad = []
only = set(sys.argv[1:])
for name, path, old, new, tests in M:
if only and name.split()[0] not in only: continue
full = os.path.join(WT, path)
src = open(full).read()
if src.count(old) != 1:
print(f"{name}: ANCHOR {src.count(old)}", flush=True); bad.append(name); continue
os.rename(full, full + ".orig")
try:
open(full, "w").write(src.replace(old, new))
log = os.path.join(OUT, name.split()[0] + ".log")
open(log, "w").close()
rcs = [run(t, log) for t in tests]
finally:
os.replace(full + ".orig", full)
killed = all(rc not in (0, "timeout") for rc in rcs)
print(f"{name}: {'killed' if killed else 'SURVIVED'} {list(zip([t.split('/')[-1] for t in tests], rcs))}", flush=True)
if not killed: bad.append(name)
end = diffhash()
print(f"end diff {end[:16]} {'same' if end == start else 'CHANGED'} {time.strftime('%H:%M:%S', time.gmtime())}", flush=True)
print("MUTANTS_OK" if not bad and end == start else f"MUTANTS_BAD {bad}")