From 0069949ffc78fa8d4998243a81fd7efe85279442 Mon Sep 17 00:00:00 2001 From: Jason Woltje Date: Tue, 13 Oct 2026 17:40:33 -0500 Subject: [PATCH] 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 @.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 --- packages/cli/src/launcher.mjs | 2 +- packages/cli/tests/launcher.test.mjs | 10 ++++- packages/harness/src/bundle.mjs | 7 ++- packages/harness/tests/bundle.test.mjs | 7 ++- packages/seat/README.md | 19 ++++++++ packages/seat/src/checkout.mjs | 20 ++++++--- packages/seat/src/sandbox.mjs | 25 +++++++++-- packages/seat/src/session.mjs | 7 +-- packages/seat/tests/checkout.test.mjs | 61 +++++++++++++++++++++++--- packages/seat/tests/sandbox.test.mjs | 30 +++++++++++-- packages/seat/tests/session.test.mjs | 2 +- 11 files changed, 162 insertions(+), 28 deletions(-) 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 `@.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 ({ : { 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 @.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 @.mosaic.invalid, and sandboxArgv refuses without it", (t) => { + const email = "coder@acme.mosaic.invalid"; + 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 |coder "], ]; } @@ -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");