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
+1 -1
View File
@@ -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))));
+8 -2
View File
@@ -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);
+5 -2
View File
@@ -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 }) {
+5 -2
View File
@@ -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([]) })), []);
+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");