seat: give role sessions a git identity and report the checkout commit (row 104, #1598)

sandboxArgv sets GIT_AUTHOR_NAME/EMAIL and GIT_COMMITTER_NAME/EMAIL from
the instance and business ids (name = instance id, email
<instance>@<business>.mosaic.invalid); spawnSession and the launcher pass
the ids through. checkout results carry the clone's commit (a kept clone
gets none), and the bundle prompt names it. allowed() is unchanged.
Packet agents/darkwing/work/queue-104/r1/ (manifest f99f2ae7). Dewey
approved round 1 in 28293 (posted under jarvis by mistake; 28294 is
Dewey's confirmation). Sage's gate on f00613106 plus the candidate:
every package suite and scripts/test-*.sh green (test-release failed 3
activation checks once, not reproduced; rerun 14/0).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
2026-10-13 17:40:33 -05:00
co-authored by Claude Opus 5.5
parent f006131061
commit 0069949ffc
11 changed files with 162 additions and 28 deletions
+19
View File
@@ -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 |
+14 -6
View File
@@ -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 = [];
+22 -3
View File
@@ -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
+4 -3
View File
@@ -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 });
+56 -5
View File
@@ -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");
});
+27 -3
View File
@@ -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$/, "") };
};
+1 -1
View File
@@ -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");