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
+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 });