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]>
438 lines
29 KiB
Diff
438 lines
29 KiB
Diff
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");
|