review loop: working repositories, git socket, push and review verdicts, row 93 (#1587)
Commits move as v2 bundles over bus-host/git.sock (decision 135). The bus
host keeps one bare working repository per business and project, seeds main
from the project root under a per-repository lock, fetches a pushed bundle
with fsck into a temp ref, requires history shared with main, and moves the
task's work ref only as a fast-forward. Host and runner git run with no
system or global config, hooks at /dev/null and local transports only. The
socket binds only for roles holding git.push.working or review.verdict.
The broker records a push as the action.allowed for git.push.working with
its commit, takes review.request for the latest push and one review.verdict
per request, and refuses task.close on a pushed task without an approve
verdict for the latest push. recordEvent reserves both shapes, so agents
can't forge either.
Reviewed by Dewey (28196) and Sage (28197), both approve. Gate on 7a729aef:
14 node suites and 9 scripts green.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
+38
-1
@@ -191,7 +191,8 @@ No capability appears in argv, stdout, the environment or a file.
|
||||
uses: `bindLaunch(record)` binds a launched run's process identity
|
||||
(`pid`, `startTime`) and returns `{business, run, cap}`; `op(message)` sends
|
||||
one of the broker process's launch ops (`identity`, `authorizeLaunch`,
|
||||
`refuse`, `endLaunch`, `credentialStatus`); `beforeClose(fn)` runs `fn`
|
||||
`refuse`, `endLaunch`, `credentialStatus`, and for the git socket
|
||||
`checkPush`, `recordPush`, `reviewFetch`); `beforeClose(fn)` runs `fn`
|
||||
before the broker closes, so the launcher stops its sessions first.
|
||||
Requests to the broker process go one at a time. Each carries an id, and
|
||||
the broker echoes it on the reply. If a reply doesn't arrive within 10 s, or
|
||||
@@ -306,6 +307,42 @@ the recorded start time and that its command line is the runner's, then
|
||||
sends SIGTERM until it exits (30 s). A stale entry prints "not running" and
|
||||
exits 0; an unknown run exits 2.
|
||||
|
||||
### Git socket
|
||||
|
||||
The launcher also serves `<dataRoot>/bus-host/git.sock` (0600,
|
||||
`src/git-socket.mjs`, row 93, #1587). A coder's runner pushes work to the
|
||||
business's working repository through it, and a reviewer's runner fetches a
|
||||
commit it was asked to review (`push_working` and `fetch_review`,
|
||||
`packages/harness/README.md`). The sandbox binds it only for a role that
|
||||
holds git.push.working or review.verdict, and a session's `session.json`
|
||||
names it as `gitSocket` only then.
|
||||
|
||||
Only a git bundle crosses. The host never runs git in a session's clone
|
||||
and never opens a path in its workspace; `packages/seat/README.md` ("The
|
||||
working repositories") has what it does with the bundle. A request is one
|
||||
JSON line of at most 4096 bytes:
|
||||
|
||||
| Op | Request | Reply |
|
||||
|---|---|---|
|
||||
| push | `{cap, op: "push", task_ref, project, branch, size}` | `{ok: true, send: <size>}`; the runner sends exactly that many bundle bytes; then `{ok: true, result: {ref, commit, previous}}` |
|
||||
| fetch | `{cap, op: "fetch", task_ref, request, have}` | `{ok: true, result: {project, ref, commit, size}}`, then that many bytes of a bundle whose one ref is `refs/mosaic/review` |
|
||||
|
||||
The host checks the caller's identity (a role of this business), then the
|
||||
request (task ref, project, branch name, a size of at most 32 MiB), then
|
||||
the broker: `checkPush` for a push, `reviewFetch` for a fetch. Only
|
||||
then does it read bundle bytes. The broker records a push with
|
||||
`recordPush` under the repository's lock once the ref moved; when it
|
||||
refuses, the ref goes back. A refusal is `{ok: false, error: <code>}` and
|
||||
ends the connection. The host's own refusals go through the broker's
|
||||
`refuse` with the task ref as subject, so each is an `action.refused`
|
||||
event on the task's trail. Eight connections are served at once; more
|
||||
refuse `git-busy`.
|
||||
|
||||
A bundle file lives in `bus-host/` (0700) under a random name and is
|
||||
deleted on every path. On close the launcher drops the git socket's
|
||||
connections and waits for any push in flight to finish under its lock
|
||||
before the broker stops.
|
||||
|
||||
### Trackers
|
||||
|
||||
`config.trackers[<business>]` is `{baseUrl, project, pollSeconds,
|
||||
|
||||
@@ -0,0 +1,262 @@
|
||||
// The host's git socket (#1587), `<dataRoot>/bus-host/git.sock` (0600). A
|
||||
// coder's runner pushes its work to the business's working repository
|
||||
// through it, and a reviewer's runner fetches a commit it was asked to
|
||||
// review. The bundle is the only thing that crosses: the host never runs git
|
||||
// in a session's clone and never opens a path in a session's workspace
|
||||
// (packages/seat/src/working.mjs says why). The sandbox binds the socket only
|
||||
// for a role that holds git.push.working or review.verdict.
|
||||
//
|
||||
// Every connection starts with one JSON line of at most 4096 bytes.
|
||||
//
|
||||
// push {"cap", "op": "push", "task_ref", "project", "branch", "size"}
|
||||
// The host answers {"ok": true, "send": <size>} or a refusal. On
|
||||
// "send" the runner writes exactly <size> bytes of a v2 git bundle,
|
||||
// and the host answers {"ok": true, "result": {ref, commit,
|
||||
// previous}} or a refusal.
|
||||
// fetch {"cap", "op": "fetch", "task_ref", "request", "have": [<oid>...]}
|
||||
// The host answers {"ok": true, "result": {project, ref, commit,
|
||||
// size}} followed by <size> bytes of a bundle whose one ref is
|
||||
// refs/mosaic/review, or a refusal.
|
||||
//
|
||||
// A refusal is {"ok": false, "error": "<code>"}, and the connection ends.
|
||||
// The host checks, in order: identity (a role of this business), the request
|
||||
// (task ref, project, branch, size, at most 32 MiB: bundle-too-large), then
|
||||
// the broker: git.push.working for a push, the review.requested for a fetch
|
||||
// (packages/bus/src/broker.mjs). Only then does it take any bundle bytes.
|
||||
// A push is recorded, as the action.allowed for git.push.working with its
|
||||
// commit, under the repository's lock once the ref moved; when the broker
|
||||
// refuses the record, the ref goes back.
|
||||
// The host's own refusals go through the broker's refuse(), so each is an
|
||||
// action.refused event on the task's trail; the broker records its own.
|
||||
//
|
||||
// A bundle file lives in bus-host/ (0700) under a random name, and the host
|
||||
// deletes it on every path.
|
||||
|
||||
import { randomBytes } from "node:crypto";
|
||||
import { closeSync, createReadStream, existsSync, lstatSync, openSync, rmSync, statSync, writeSync, chmodSync } from "node:fs";
|
||||
import { createServer } from "node:net";
|
||||
import { join } from "node:path";
|
||||
import { pipeline } from "node:stream/promises";
|
||||
import { gitSocketPath } from "../../seat/src/session.mjs";
|
||||
import { BUNDLE_MAX, FSCK, HAVE_MAX, push, review, validBranch, workRef, WorkingError } from "../../seat/src/working.mjs";
|
||||
import { CliError } from "./errors.mjs";
|
||||
|
||||
const HEADER_MAX = 4096;
|
||||
const HEADER_TIMEOUT_MS = 10000;
|
||||
// The longest the host waits between two chunks of a bundle.
|
||||
const BODY_IDLE_MS = 30000;
|
||||
// Connections served at once; more refuse git-busy.
|
||||
const MAX_ACTIVE = 8;
|
||||
const CAP = /^[0-9a-f]{64}$/;
|
||||
const OID = /^[0-9a-f]{40}([0-9a-f]{24})?$/;
|
||||
|
||||
// A refusal the host decided; it goes to the broker's refuse().
|
||||
class Refusal extends Error {
|
||||
constructor(code, message = code) {
|
||||
super(message);
|
||||
this.code = code;
|
||||
}
|
||||
}
|
||||
// A refusal the broker decided and recorded itself.
|
||||
class Recorded extends Error {
|
||||
constructor(code) {
|
||||
super(code);
|
||||
this.code = code;
|
||||
}
|
||||
}
|
||||
|
||||
// Resolves { req, rest } once a whole first line came; rest is what followed it.
|
||||
function readHeader(socket) {
|
||||
return new Promise((resolve, reject) => {
|
||||
let buf = Buffer.alloc(0);
|
||||
const done = (err, value) => {
|
||||
socket.off("data", onData);
|
||||
socket.off("end", onGone);
|
||||
socket.off("close", onGone);
|
||||
socket.off("timeout", onGone);
|
||||
socket.setTimeout(0);
|
||||
socket.pause();
|
||||
err ? reject(err) : resolve(value);
|
||||
};
|
||||
const onGone = () => done(new Refusal("invalid-request", "no request line"));
|
||||
const onData = (chunk) => {
|
||||
buf = Buffer.concat([buf, chunk]);
|
||||
const nl = buf.indexOf(0x0a);
|
||||
if (nl < 0 ? buf.length > HEADER_MAX : nl > HEADER_MAX) return done(new Refusal("invalid-request", "request line too long"));
|
||||
if (nl < 0) return;
|
||||
let req;
|
||||
try {
|
||||
req = JSON.parse(buf.subarray(0, nl).toString("utf8"));
|
||||
} catch {
|
||||
return done(new Refusal("invalid-request", "request line isn't JSON"));
|
||||
}
|
||||
done(null, { req, rest: buf.subarray(nl + 1) });
|
||||
};
|
||||
socket.on("data", onData);
|
||||
socket.once("end", onGone);
|
||||
socket.once("close", onGone);
|
||||
socket.setTimeout(HEADER_TIMEOUT_MS);
|
||||
socket.once("timeout", onGone);
|
||||
});
|
||||
}
|
||||
|
||||
// Exactly `size` bytes from the socket into a new file (0600).
|
||||
function readBody(socket, file, size) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const fd = openSync(file, "wx", 0o600);
|
||||
let got = 0;
|
||||
let settled = false;
|
||||
const done = (err) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
socket.off("data", onData);
|
||||
socket.off("end", onShort);
|
||||
socket.off("close", onShort);
|
||||
socket.off("timeout", onShort);
|
||||
socket.setTimeout(0);
|
||||
socket.pause();
|
||||
closeSync(fd);
|
||||
err ? reject(err) : resolve();
|
||||
};
|
||||
const onShort = () => done(new Refusal("invalid-request", `the bundle ended after ${got} of ${size} bytes`));
|
||||
const onData = (chunk) => {
|
||||
if (got + chunk.length > size) return done(new Refusal("invalid-request", "more bytes than the request's size"));
|
||||
try {
|
||||
writeSync(fd, chunk);
|
||||
} catch (e) {
|
||||
return done(e);
|
||||
}
|
||||
got += chunk.length;
|
||||
if (got === size) done();
|
||||
};
|
||||
socket.on("data", onData);
|
||||
socket.once("end", onShort);
|
||||
socket.once("close", onShort);
|
||||
socket.setTimeout(BODY_IDLE_MS);
|
||||
socket.once("timeout", onShort);
|
||||
socket.resume();
|
||||
});
|
||||
}
|
||||
|
||||
// host: startHost()'s handle; business: loadBusiness() output. bundleMax and
|
||||
// fsck are for the tests.
|
||||
export function createGitSocket({ host, business: b, dataRoot, env = process.env, log = () => {}, bundleMax = BUNDLE_MAX, fsck = FSCK }) {
|
||||
const path = gitSocketPath(dataRoot);
|
||||
const dir = join(dataRoot, "bus-host");
|
||||
let server = null;
|
||||
let closed = false;
|
||||
const sockets = new Set();
|
||||
const active = new Set();
|
||||
|
||||
const op = async (message) => {
|
||||
try {
|
||||
return await host.op(message);
|
||||
} catch (e) {
|
||||
throw new Recorded(e.code ?? "not-authorized");
|
||||
}
|
||||
};
|
||||
const project = (id) => (typeof id === "string" && Object.hasOwn(b.projects, id) ? b.projects[id] : null);
|
||||
|
||||
async function doPush(socket, cap, req, rest, file) {
|
||||
const { task_ref, branch, size } = req;
|
||||
const p = project(req.project);
|
||||
if (!p || !validBranch(branch) || !Number.isSafeInteger(size) || size < 1) throw new Refusal("invalid-request");
|
||||
workRef(task_ref);
|
||||
if (size > bundleMax) throw new Refusal("bundle-too-large", `${size} bytes, over ${bundleMax}`);
|
||||
// The runner waits for "send"; bytes before it break the protocol.
|
||||
if (rest.length) throw new Refusal("invalid-request", "bundle bytes before send");
|
||||
await op({ op: "checkPush", cap, task_ref });
|
||||
socket.write(`${JSON.stringify({ ok: true, send: size })}\n`);
|
||||
await readBody(socket, file, size);
|
||||
const result = await push({
|
||||
dataRoot, business: b.id, project: req.project, root: p.root, taskRef: task_ref, branch, bundle: file, env, fsck,
|
||||
record: (r) => op({ op: "recordPush", cap, push: { task_ref, project: req.project, branch, ...r } }),
|
||||
});
|
||||
socket.end(`${JSON.stringify({ ok: true, result })}\n`);
|
||||
}
|
||||
|
||||
async function doFetch(socket, cap, req, file) {
|
||||
const { task_ref, request, have = [] } = req;
|
||||
if (typeof request !== "string" || !Array.isArray(have) || have.length > HAVE_MAX || have.some((h) => typeof h !== "string" || !OID.test(h))) throw new Refusal("invalid-request");
|
||||
workRef(task_ref);
|
||||
const r = await op({ op: "reviewFetch", cap, args: { task_ref, request } });
|
||||
const p = project(r.project);
|
||||
if (!p) throw new Refusal("unknown-project");
|
||||
const made = await review({ dataRoot, business: b.id, project: r.project, root: p.root, taskRef: task_ref, commit: r.commit, have, out: file, env });
|
||||
const size = statSync(file).size;
|
||||
if (size > bundleMax) throw new Refusal("bundle-too-large", `${size} bytes, over ${bundleMax}`);
|
||||
socket.write(`${JSON.stringify({ ok: true, result: { project: r.project, ref: made.ref, commit: made.commit, size } })}\n`);
|
||||
await pipeline(createReadStream(file), socket);
|
||||
}
|
||||
|
||||
async function serve(socket) {
|
||||
let cap = null;
|
||||
let subject = null;
|
||||
let file = null;
|
||||
const fail = (code) => {
|
||||
if (!socket.destroyed) socket.end(`${JSON.stringify({ ok: false, error: code })}\n`);
|
||||
};
|
||||
try {
|
||||
const { req, rest } = await readHeader(socket);
|
||||
if (typeof req?.cap !== "string" || !CAP.test(req.cap)) return fail("unauthenticated");
|
||||
if (closed) return fail("host-closing");
|
||||
let who;
|
||||
try {
|
||||
who = await host.op({ op: "identity", cap: req.cap });
|
||||
} catch (e) {
|
||||
return fail(e.code ?? "unauthenticated");
|
||||
}
|
||||
cap = req.cap;
|
||||
if (typeof req.task_ref === "string") subject = req.task_ref;
|
||||
if (!who.role || who.business !== b.id) throw new Refusal("not-a-role");
|
||||
file = join(dir, `${randomBytes(8).toString("hex")}.bundle`);
|
||||
if (req.op === "push") await doPush(socket, cap, req, rest, file);
|
||||
else if (req.op === "fetch") await doFetch(socket, cap, req, file);
|
||||
else throw new Refusal("invalid-request", "op is push or fetch");
|
||||
} catch (e) {
|
||||
if (e instanceof Recorded) return fail(e.code);
|
||||
const code = e instanceof Refusal || e instanceof WorkingError ? e.code : "git-failed";
|
||||
if (code === "git-failed") log(`git socket: ${e.message}`);
|
||||
if (cap) await host.op({ op: "refuse", cap, code, subject }).catch(() => {});
|
||||
fail(code);
|
||||
} finally {
|
||||
if (file) rmSync(file, { force: true });
|
||||
}
|
||||
}
|
||||
|
||||
async function listen() {
|
||||
if (existsSync(path)) {
|
||||
if (!lstatSync(path).isSocket()) throw new CliError(`${path} exists and isn't a socket`, 3);
|
||||
rmSync(path);
|
||||
}
|
||||
server = createServer((socket) => {
|
||||
sockets.add(socket);
|
||||
socket.once("close", () => sockets.delete(socket));
|
||||
socket.on("error", () => {});
|
||||
if (active.size >= MAX_ACTIVE) return socket.end(`${JSON.stringify({ ok: false, error: "git-busy" })}\n`);
|
||||
const job = serve(socket).catch((e) => log(`git socket: ${e.message}`));
|
||||
active.add(job);
|
||||
job.finally(() => active.delete(job));
|
||||
});
|
||||
// bus-host/ is 0700, so the moment before the chmod exposes nothing.
|
||||
await new Promise((resolve, reject) => {
|
||||
server.once("error", reject);
|
||||
server.listen(path, resolve);
|
||||
});
|
||||
chmodSync(path, 0o600);
|
||||
}
|
||||
|
||||
// A push whose bundle already came finishes, under its lock, while the
|
||||
// broker still runs: the launcher's close() runs before the host stops it.
|
||||
async function close() {
|
||||
closed = true;
|
||||
if (!server) return;
|
||||
const stopped = new Promise((r) => server.close(r));
|
||||
for (const socket of sockets) socket.destroy();
|
||||
await Promise.allSettled([...active]);
|
||||
await stopped;
|
||||
rmSync(path, { force: true });
|
||||
}
|
||||
|
||||
return { path, listen, close };
|
||||
}
|
||||
@@ -34,6 +34,10 @@
|
||||
//
|
||||
// `bus start <business> --pm` launches launch.by the same way with
|
||||
// launchedBy = the business's human and no caller capability.
|
||||
//
|
||||
// listen() also serves the git socket (src/git-socket.mjs, row 93), which
|
||||
// the sandbox binds for a role that holds git.push.working or
|
||||
// review.verdict; session.json names it as gitSocket.
|
||||
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { createServer } from "node:net";
|
||||
@@ -47,6 +51,7 @@ import {
|
||||
endReason,
|
||||
family,
|
||||
hasSessionCredential,
|
||||
gitSocketPath,
|
||||
launchSocketPath,
|
||||
newRun,
|
||||
readSessions,
|
||||
@@ -61,6 +66,7 @@ import {
|
||||
} from "../../seat/src/session.mjs";
|
||||
import { BWRAP, sandboxProblem } from "../../seat/src/sandbox.mjs";
|
||||
import { CheckoutError, ensureCheckouts } from "../../seat/src/checkout.mjs";
|
||||
import { createGitSocket } from "./git-socket.mjs";
|
||||
import { cmdlineOf, startTimeOf } from "../../seat/src/proc.mjs";
|
||||
import { REPO, rolesDir } from "./config.mjs";
|
||||
import { CliError } from "./errors.mjs";
|
||||
@@ -95,8 +101,9 @@ export function claudeVersion(env = process.env) {
|
||||
}
|
||||
|
||||
// host: startHost()'s handle. tracker: true when the broker has task verbs for
|
||||
// the business. adapters, bwrap, readOnly, sessionDefaults, versions and
|
||||
// stopTimeoutMs exist for the tests; the command line never sets them.
|
||||
// the business. adapters, bwrap, readOnly, sessionDefaults, versions,
|
||||
// stopTimeoutMs and git (createGitSocket options) exist for the tests; the
|
||||
// command line never sets them.
|
||||
export function createLauncher({
|
||||
host,
|
||||
system,
|
||||
@@ -108,6 +115,7 @@ export function createLauncher({
|
||||
sessionDefaults = {},
|
||||
versions = null,
|
||||
stopTimeoutMs = STOP_TIMEOUT_MS,
|
||||
git = {},
|
||||
log = (l) => process.stderr.write(`mosaic-bus: ${l}\n`),
|
||||
}) {
|
||||
const dataRoot = system.dataRoot;
|
||||
@@ -129,6 +137,7 @@ export function createLauncher({
|
||||
let closed = false;
|
||||
let server = null;
|
||||
const sockets = new Set();
|
||||
const gitSocket = createGitSocket({ host, business: b, dataRoot, env, log, ...git });
|
||||
|
||||
const record = (entry) => {
|
||||
try {
|
||||
@@ -206,6 +215,9 @@ export function createLauncher({
|
||||
claudeVersion: v.claude,
|
||||
});
|
||||
const { manifest, ...files } = bundle.files;
|
||||
// Row 93: the git socket, for a role that pushes work or reviews it.
|
||||
const held = [...resolved.limits.authority.withinRole, ...resolved.limits.authority.crossRole];
|
||||
const git = held.includes("git.push.working") || held.includes("review.verdict") ? gitSocketPath(dataRoot) : null;
|
||||
writeSessionFile(runDir, {
|
||||
...sessionDefaults,
|
||||
business: b.id,
|
||||
@@ -217,6 +229,7 @@ export function createLauncher({
|
||||
model: model.model,
|
||||
brokerSocket: host.path,
|
||||
launchSocket: launchSocketPath(dataRoot),
|
||||
gitSocket: git,
|
||||
workspace,
|
||||
sessionDir,
|
||||
adapter: adapters[model.harness],
|
||||
@@ -231,6 +244,7 @@ export function createLauncher({
|
||||
harness: model.harness,
|
||||
brokerSocket: host.path,
|
||||
launchSocket: resolved.launch ? launchSocketPath(dataRoot) : null,
|
||||
gitSocket: git,
|
||||
claudeBin: env.MOSAIC_CLAUDE_BIN || null,
|
||||
readOnly,
|
||||
};
|
||||
@@ -415,6 +429,7 @@ export function createLauncher({
|
||||
server.listen(path, resolve);
|
||||
});
|
||||
chmodSync(path, 0o600);
|
||||
await gitSocket.listen();
|
||||
}
|
||||
|
||||
// SIGTERM every runner, wait, then end what's left. Runs before the host
|
||||
@@ -429,6 +444,7 @@ export function createLauncher({
|
||||
await stopped;
|
||||
rmSync(launchSocketPath(dataRoot), { force: true });
|
||||
}
|
||||
await gitSocket.close();
|
||||
await chain;
|
||||
// Again every second: a runner still starting has no handler yet, and as
|
||||
// pid 1 of its namespace it never sees a signal sent before it has one.
|
||||
|
||||
@@ -0,0 +1,316 @@
|
||||
// Row 93 (#1587): the host's git socket inside a real bus host, driven by the
|
||||
// runner's own git tools (packages/harness/src/git-tools.mjs) over real
|
||||
// clones. A coder pushes, asks for a review, the reviewer fetches the commit
|
||||
// and answers, all as the two roles' launches. No sandbox: the socket and the
|
||||
// tools are what is under test, and each test checks that the project root is
|
||||
// unchanged and that no bundle file is left in bus-host/ or either tmp.
|
||||
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { createHash, randomBytes } from "node:crypto";
|
||||
import { connect } from "node:net";
|
||||
import { chmodSync, existsSync, lstatSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, readlinkSync, renameSync, rmSync, statSync, symlinkSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { Client } from "../../bus/src/client.mjs";
|
||||
import { configDir, loadBusiness } from "../../business/src/index.mjs";
|
||||
import { gitEnv } from "../../seat/src/checkout.mjs";
|
||||
import { gitSocketPath } from "../../seat/src/session.mjs";
|
||||
import { workingRepo } from "../../seat/src/working.mjs";
|
||||
import { fetchReview, pushWorking } from "../../harness/src/git-tools.mjs";
|
||||
import { bootConfig, loadSystem, rolesDir } from "../src/config.mjs";
|
||||
import { createGitSocket } from "../src/git-socket.mjs";
|
||||
import { startHost, startTimeOf } from "../src/host.mjs";
|
||||
import { fixture } from "./helpers.mjs";
|
||||
|
||||
const TASK = "vikunja:1/2";
|
||||
|
||||
function gitIn(cwd, ...args) {
|
||||
const r = spawnSync("git", ["-c", "user.name=test", "-c", "[email protected]", "-C", cwd, ...args], { env: gitEnv(), encoding: "utf8" });
|
||||
assert.equal(r.status, 0, r.stderr);
|
||||
return r.stdout.trim();
|
||||
}
|
||||
function commit(dir, text, file = "file.txt") {
|
||||
writeFileSync(join(dir, file), `${text}\n`);
|
||||
gitIn(dir, "add", file);
|
||||
gitIn(dir, "commit", "-q", "-m", text);
|
||||
return gitIn(dir, "rev-parse", "HEAD");
|
||||
}
|
||||
function fingerprint(dir) {
|
||||
const h = createHash("sha256");
|
||||
const walk = (d) => {
|
||||
for (const name of readdirSync(d).sort()) {
|
||||
const p = join(d, name);
|
||||
const st = lstatSync(p);
|
||||
h.update(`${p}\0${st.mode}\0`);
|
||||
if (st.isDirectory()) walk(p);
|
||||
else if (st.isSymbolicLink()) h.update(readlinkSync(p));
|
||||
else if (st.isFile()) h.update(readFileSync(p));
|
||||
}
|
||||
};
|
||||
walk(dir);
|
||||
return h.digest("hex");
|
||||
}
|
||||
const bundles = (dir) => (existsSync(dir) ? readdirSync(dir).filter((n) => n.endsWith(".bundle")) : []);
|
||||
|
||||
// A host for business acme, its git socket, and the coder, reviewer and pm
|
||||
// bound and claimed. Each role has a workspace with a clone of the project.
|
||||
async function setup(t, git = {}) {
|
||||
// One teardown, in order: stop the socket and the host, check, then remove
|
||||
// the tree. (t.after hooks run first-registered first.)
|
||||
const root = mkdtempSync(join(tmpdir(), "mosaic-git-"));
|
||||
const down = [];
|
||||
t.after(async () => {
|
||||
try {
|
||||
for (const fn of down) await fn();
|
||||
} finally {
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
const f = fixture(root, "acme");
|
||||
mkdirSync(f.dataRoot, { mode: 0o700 });
|
||||
const project = f.doc.projects.stack.root;
|
||||
gitIn(project, "init", "-q", "-b", "main");
|
||||
const main = commit(project, "one");
|
||||
const before = fingerprint(project);
|
||||
const system = loadSystem({ env: f.env });
|
||||
const host = await startHost({ boot: bootConfig({ system, businessId: "acme", env: f.env }), business: "acme", log: () => {} });
|
||||
down.push(() => host.close(0));
|
||||
const logs = [];
|
||||
const sock = createGitSocket({ host, business: loadBusiness("acme", { dir: configDir(f.env), rolesDir: rolesDir(f.env) }), dataRoot: f.dataRoot, env: f.env, log: (l) => logs.push(l), ...git });
|
||||
down.unshift(() => sock.close());
|
||||
await sock.listen();
|
||||
const roles = {};
|
||||
for (const role of ["coder", "reviewer", "pm", "cto"]) {
|
||||
const { cap } = await host.bindLaunch({ business: "acme", role, run: `${role}-test`, harness: "pi", pid: process.pid, startTime: startTimeOf(process.pid) });
|
||||
const client = new Client({ path: host.path, cap });
|
||||
await client.call("role.claim");
|
||||
const workspace = join(root, "ws", role);
|
||||
const tmpDir = join(root, "tmp", role);
|
||||
mkdirSync(workspace, { recursive: true });
|
||||
mkdirSync(tmpDir, { recursive: true });
|
||||
gitIn(workspace, "clone", "-q", project, "stack");
|
||||
roles[role] = { cap, client, workspace, tmp: tmpDir, clone: join(workspace, "stack") };
|
||||
}
|
||||
// Checked after every test: the root as it was, and no bundle anywhere.
|
||||
down.push(() => {
|
||||
assert.equal(fingerprint(project), before, "the project root is unchanged");
|
||||
for (const dir of [join(f.dataRoot, "bus-host"), ...Object.values(roles).map((r) => r.tmp)]) assert.deepEqual(bundles(dir), [], `no bundle left in ${dir}`);
|
||||
});
|
||||
const push = (role, args = {}) => pushWorking({ path: sock.path, cap: roles[role].cap, workspace: roles[role].workspace, tmp: roles[role].tmp, task_ref: TASK, project: "stack", branch: "feat", ...args });
|
||||
const fetch = (role, request, args = {}) => fetchReview({ path: sock.path, cap: roles[role].cap, workspace: roles[role].workspace, tmp: roles[role].tmp, task_ref: TASK, request, ...args });
|
||||
const repo = workingRepo(f.dataRoot, "acme", "stack");
|
||||
const refs = () => (existsSync(repo) ? gitIn(root, `--git-dir=${repo}`, "for-each-ref", "--format=%(refname) %(objectname)") : "");
|
||||
const trail = async (kind, subject = TASK) => (await roles.cto.client.call("trail", { subject })).filter((r) => r.table === "events" && r.kind === kind);
|
||||
return { root, f, project, main, host, sock, roles, push, fetch, repo, refs, trail, logs };
|
||||
}
|
||||
|
||||
const rejects = (p, code) => assert.rejects(p, (e) => e.code === code || assert.fail(`${e.code}: ${e.message}, wanted ${code}`));
|
||||
|
||||
// One raw exchange on the git socket.
|
||||
function raw(path, text) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const s = connect(path);
|
||||
let buf = "";
|
||||
s.on("connect", () => s.write(text));
|
||||
s.on("data", (b) => (buf += b));
|
||||
s.on("end", () => resolve(JSON.parse(buf.split("\n")[0])));
|
||||
s.on("error", reject);
|
||||
});
|
||||
}
|
||||
|
||||
test("the review loop: the coder pushes and asks, the reviewer fetches and approves, and the task's trail holds each step", async (t) => {
|
||||
const w = await setup(t);
|
||||
assert.equal(statSync(w.sock.path).mode & 0o777, 0o600);
|
||||
assert.equal(w.sock.path, gitSocketPath(w.f.dataRoot));
|
||||
const { coder, reviewer } = w.roles;
|
||||
gitIn(coder.clone, "checkout", "-q", "-b", "feat");
|
||||
const c1 = commit(coder.clone, "two");
|
||||
assert.deepEqual(await w.push("coder"), { ref: "work/vikunja-1-2", commit: c1, previous: null });
|
||||
assert.equal(gitIn(coder.clone, "rev-parse", "refs/remotes/working/work/vikunja-1-2"), c1);
|
||||
assert.equal(w.refs(), `refs/heads/main ${w.main}\nrefs/heads/work/vikunja-1-2 ${c1}`);
|
||||
// Nothing new: the clone remembers the push.
|
||||
await rejects(w.push("coder"), "nothing-to-push");
|
||||
|
||||
const req = await coder.client.call("review.request", { task_ref: TASK, commit: c1, to: "reviewer" });
|
||||
const got = await w.fetch("reviewer", req.request);
|
||||
assert.deepEqual(got, { project: "stack", ref: "working/work/vikunja-1-2", commit: c1, clone: reviewer.clone });
|
||||
assert.equal(gitIn(reviewer.clone, "rev-parse", "refs/remotes/working/work/vikunja-1-2"), c1);
|
||||
assert.equal(gitIn(reviewer.clone, "show", "-s", "--format=%s", c1), "two");
|
||||
const v = await reviewer.client.call("review.verdict", { task_ref: TASK, request: req.request, verdict: "changes", body: "one more" });
|
||||
assert.equal(v.commit, c1);
|
||||
|
||||
// The coder answers with a fast-forward; the bundle carries only the new commit.
|
||||
const c2 = commit(coder.clone, "three");
|
||||
assert.deepEqual(await w.push("coder"), { ref: "work/vikunja-1-2", commit: c2, previous: c1 });
|
||||
const req2 = await coder.client.call("review.request", { task_ref: TASK, commit: c2, to: "reviewer" });
|
||||
assert.equal((await w.fetch("reviewer", req2.request)).commit, c2);
|
||||
const ok = await reviewer.client.call("review.verdict", { task_ref: TASK, request: req2.request, verdict: "approve", body: "good" });
|
||||
|
||||
// A push is the action.allowed for git.push.working that names its commit.
|
||||
const pushes = (await w.trail("action.allowed")).filter((e) => e.body.action === "git.push.working");
|
||||
assert.deepEqual(pushes.map((e) => [e.actor_role, e.body.commit, e.body.previous, e.body.branch]), [["coder", c1, null, "feat"], ["coder", c2, c1, "feat"]]);
|
||||
assert.deepEqual((await w.trail("review.requested")).map((e) => e.id), [req.request, req2.request]);
|
||||
assert.deepEqual((await w.trail("review.verdict")).map((e) => [e.id, e.body.verdict, e.body.commit]), [[v.verdict, "changes", c1], [ok.verdict, "approve", c2]]);
|
||||
assert.deepEqual(w.logs, []);
|
||||
});
|
||||
|
||||
test("refusals: no action, non-fast-forward, an unknown commit or request, and a push for another task moves only that task's ref", async (t) => {
|
||||
const w = await setup(t);
|
||||
const { coder, reviewer, pm } = w.roles;
|
||||
gitIn(coder.clone, "checkout", "-q", "-b", "feat");
|
||||
const c1 = commit(coder.clone, "two");
|
||||
await w.push("coder");
|
||||
const refs = w.refs();
|
||||
|
||||
// The reviewer and pm hold no git.push.working; the broker refuses before the host takes a byte.
|
||||
for (const role of ["reviewer", "pm"]) {
|
||||
gitIn(w.roles[role].clone, "checkout", "-q", "-b", "feat");
|
||||
commit(w.roles[role].clone, `${role} work`);
|
||||
await rejects(w.push(role), "decision-required");
|
||||
}
|
||||
assert.equal(w.refs(), refs);
|
||||
|
||||
// A rewritten branch.
|
||||
gitIn(coder.clone, "update-ref", "-d", "refs/remotes/working/work/vikunja-1-2");
|
||||
gitIn(coder.clone, "reset", "-q", "--hard", w.main);
|
||||
commit(coder.clone, "other two");
|
||||
await rejects(w.push("coder"), "non-fast-forward");
|
||||
assert.equal(w.refs(), refs);
|
||||
|
||||
// The same branch for another task lands on that task's ref and leaves this one.
|
||||
const other = await w.push("coder", { task_ref: "vikunja:1/3" });
|
||||
assert.equal(other.ref, "work/vikunja-1-3");
|
||||
assert.match(w.refs(), new RegExp(`refs/heads/work/vikunja-1-2 ${c1}`));
|
||||
|
||||
await rejects(coder.client.call("review.request", { task_ref: TASK, commit: "e".repeat(40), to: "reviewer" }), "unknown-commit");
|
||||
const req = await coder.client.call("review.request", { task_ref: TASK, commit: c1, to: "reviewer" });
|
||||
await rejects(w.fetch("reviewer", "no-such-request"), "request-not-found");
|
||||
await rejects(w.fetch("reviewer", req.request, { task_ref: "vikunja:1/3" }), "request-not-found");
|
||||
// The coder and pm hold no review.verdict.
|
||||
await rejects(w.fetch("coder", req.request), "not-authorized");
|
||||
await rejects(w.fetch("pm", req.request), "not-authorized");
|
||||
assert.equal(existsSync(join(reviewer.clone, ".git", "refs", "remotes", "working")), false);
|
||||
|
||||
// Each refusal is an action.refused event on the task, from the role that tried.
|
||||
const refused = (await w.trail("action.refused")).map((e) => `${e.actor_role} ${e.body.code}`);
|
||||
for (const r of ["reviewer decision-required", "pm decision-required", "coder non-fast-forward", "coder unknown-commit", "reviewer request-not-found", "coder not-authorized", "pm not-authorized"])
|
||||
assert.ok(refused.includes(r), `${r} in ${refused.join(", ")}`);
|
||||
});
|
||||
|
||||
test("the size cap refuses a push before any byte", async (t) => {
|
||||
const w = await setup(t, { bundleMax: 64 });
|
||||
gitIn(w.roles.coder.clone, "checkout", "-q", "-b", "feat");
|
||||
commit(w.roles.coder.clone, "two");
|
||||
await rejects(w.push("coder"), "bundle-too-large");
|
||||
assert.equal(w.refs(), "");
|
||||
assert.deepEqual((await w.trail("action.allowed")).filter((e) => e.body.action === "git.push.working"), []);
|
||||
});
|
||||
|
||||
test("a fetch over the cap refuses before any byte reaches the reviewer", async (t) => {
|
||||
const w = await setup(t, { bundleMax: 1024 * 1024 });
|
||||
const { coder } = w.roles;
|
||||
gitIn(coder.clone, "checkout", "-q", "-b", "feat");
|
||||
// Two pushes of 600 KiB of noise each pass a 1 MiB cap; the reviewer has
|
||||
// neither, so its bundle carries both and is over.
|
||||
writeFileSync(join(coder.clone, "big"), randomBytes(600 * 1024));
|
||||
gitIn(coder.clone, "add", "big");
|
||||
gitIn(coder.clone, "commit", "-q", "-m", "big");
|
||||
const c = (await w.push("coder")).commit;
|
||||
writeFileSync(join(coder.clone, "big"), randomBytes(600 * 1024));
|
||||
gitIn(coder.clone, "commit", "-q", "-am", "bigger");
|
||||
const c2 = (await w.push("coder")).commit;
|
||||
assert.notEqual(c, c2);
|
||||
const req = await coder.client.call("review.request", { task_ref: TASK, commit: c2, to: "reviewer" });
|
||||
await rejects(w.fetch("reviewer", req.request), "bundle-too-large");
|
||||
assert.equal(existsSync(join(w.roles.reviewer.clone, ".git", "refs", "remotes", "working")), false);
|
||||
});
|
||||
|
||||
test("the socket refuses a request with no capability, an unknown one, and a bad request line", async (t) => {
|
||||
const w = await setup(t);
|
||||
assert.deepEqual(await raw(w.sock.path, "not json\n"), { ok: false, error: "invalid-request" });
|
||||
assert.deepEqual(await raw(w.sock.path, `${JSON.stringify({ op: "push" })}\n`), { ok: false, error: "unauthenticated" });
|
||||
assert.deepEqual(await raw(w.sock.path, `${JSON.stringify({ cap: "f".repeat(64), op: "push" })}\n`), { ok: false, error: "unauthenticated" });
|
||||
const cap = w.roles.coder.cap;
|
||||
assert.deepEqual(await raw(w.sock.path, `${JSON.stringify({ cap, op: "merge" })}\n`), { ok: false, error: "invalid-request" });
|
||||
assert.deepEqual(await raw(w.sock.path, `${JSON.stringify({ cap, op: "push", task_ref: TASK, project: "nope", branch: "feat", size: 10 })}\n`), { ok: false, error: "invalid-request" });
|
||||
assert.deepEqual(await raw(w.sock.path, `${JSON.stringify({ cap, op: "push", task_ref: "gitea:1", project: "stack", branch: "feat", size: 10 })}\n`), { ok: false, error: "invalid-request" });
|
||||
// Bytes before "send" are refused, not read.
|
||||
assert.deepEqual(await raw(w.sock.path, `${JSON.stringify({ cap, op: "push", task_ref: TASK, project: "stack", branch: "feat", size: 10 })}\n0123456789`), { ok: false, error: "invalid-request" });
|
||||
assert.deepEqual(await raw(w.sock.path, `${JSON.stringify({ cap, op: "fetch", task_ref: TASK, request: "x", have: ["nope"] })}\n`), { ok: false, error: "invalid-request" });
|
||||
// A push whose bytes have a bundle's header and then no ref line git can read.
|
||||
const junk = await new Promise((resolve, reject) => {
|
||||
const s = connect(w.sock.path);
|
||||
let buf = "";
|
||||
s.on("connect", () => s.write(`${JSON.stringify({ cap, op: "push", task_ref: TASK, project: "stack", branch: "feat", size: 20 })}\n`));
|
||||
s.on("data", (b) => {
|
||||
buf += b;
|
||||
if (buf === `${JSON.stringify({ ok: true, send: 20 })}\n`) s.write("# v2 git bundle\nzzz\n");
|
||||
});
|
||||
s.on("end", () => resolve(buf.trim().split("\n").pop()));
|
||||
s.on("error", reject);
|
||||
});
|
||||
assert.deepEqual(JSON.parse(junk), { ok: false, error: "invalid-bundle" });
|
||||
assert.equal(w.refs(), `refs/heads/main ${w.main}`);
|
||||
});
|
||||
|
||||
test("a rigged clone runs nothing: hooks, fsmonitor, a filter, a gitlink and a symlinked .git/config leave no marker", async (t) => {
|
||||
const w = await setup(t);
|
||||
const { coder, reviewer } = w.roles;
|
||||
const marks = join(w.root, "marks");
|
||||
mkdirSync(marks);
|
||||
gitIn(coder.clone, "checkout", "-q", "-b", "feat");
|
||||
commit(coder.clone, "two");
|
||||
// A gitlink whose .gitmodules would run a command if anything cloned it.
|
||||
// (An `update = !cmd` line fsck refuses outright; the end of the test pushes one.)
|
||||
writeFileSync(join(coder.clone, ".gitmodules"), `[submodule "sub"]\n\tpath = sub\n\turl = ext::sh -c touch% ${marks}/submodule\n`);
|
||||
gitIn(coder.clone, "add", ".gitmodules");
|
||||
gitIn(coder.clone, "update-index", "--add", "--cacheinfo", `160000,${w.main},sub`);
|
||||
gitIn(coder.clone, "commit", "-q", "-m", "gitlink");
|
||||
writeFileSync(join(coder.clone, ".gitattributes"), "* filter=evil\n");
|
||||
gitIn(coder.clone, "add", ".gitattributes");
|
||||
gitIn(coder.clone, "commit", "-q", "-m", "attributes");
|
||||
const tipCommit = gitIn(coder.clone, "rev-parse", "HEAD");
|
||||
// On a second branch, a .gitmodules with a command as its update setting.
|
||||
gitIn(coder.clone, "checkout", "-q", "-b", "bad");
|
||||
writeFileSync(join(coder.clone, ".gitmodules"), `[submodule "sub"]\n\tpath = sub\n\turl = ./sub\n\tupdate = !touch ${marks}/update\n`);
|
||||
gitIn(coder.clone, "add", ".gitmodules");
|
||||
gitIn(coder.clone, "commit", "-q", "-m", "update command");
|
||||
gitIn(coder.clone, "checkout", "-q", "feat");
|
||||
// Rigged only now, so the test's own git calls above ran none of it.
|
||||
const rig = (clone, name) => {
|
||||
const hooks = join(w.root, `hooks-${name}`);
|
||||
mkdirSync(hooks);
|
||||
for (const hook of ["reference-transaction", "pre-push", "post-checkout", "post-merge", "post-rewrite", "pre-auto-gc", "fsmonitor-watchman"]) {
|
||||
writeFileSync(join(hooks, hook), `#!/bin/sh\ntouch '${marks}/${name}-${hook}'\nexit 0\n`);
|
||||
chmodSync(join(hooks, hook), 0o755);
|
||||
mkdirSync(join(clone, ".git", "hooks"), { recursive: true });
|
||||
writeFileSync(join(clone, ".git", "hooks", hook), `#!/bin/sh\ntouch '${marks}/${name}-local-${hook}'\nexit 0\n`);
|
||||
chmodSync(join(clone, ".git", "hooks", hook), 0o755);
|
||||
}
|
||||
const config = join(w.root, `config-${name}`);
|
||||
writeFileSync(
|
||||
config,
|
||||
`${readFileSync(join(clone, ".git", "config"), "utf8")}[core]\n\thooksPath = ${hooks}\n\tfsmonitor = ${hooks}/fsmonitor-watchman\n[filter "evil"]\n\tclean = touch ${marks}/${name}-clean\n\tsmudge = touch ${marks}/${name}-smudge\n\tprocess = touch ${marks}/${name}-process\n[uploadpack]\n\tpackObjectsHook = touch ${marks}/${name}-pack\n[gc]\n\tauto = 1\n`,
|
||||
);
|
||||
renameSync(join(clone, ".git", "config"), join(w.root, `config-${name}.orig`));
|
||||
symlinkSync(config, join(clone, ".git", "config"));
|
||||
};
|
||||
rig(coder.clone, "coder");
|
||||
rig(reviewer.clone, "reviewer");
|
||||
const pushed = await w.push("coder");
|
||||
assert.equal(pushed.commit, tipCommit);
|
||||
const req = await coder.client.call("review.request", { task_ref: TASK, commit: pushed.commit, to: "reviewer" });
|
||||
assert.equal((await w.fetch("reviewer", req.request)).commit, pushed.commit);
|
||||
assert.deepEqual(readdirSync(marks), []);
|
||||
// The gitlink came across as a tree entry and nothing more.
|
||||
assert.match(gitIn(reviewer.clone, "-c", "core.hooksPath=/dev/null", "ls-tree", pushed.commit, "sub"), /^160000 commit /);
|
||||
assert.equal(existsSync(join(reviewer.clone, "sub", ".git")), false);
|
||||
assert.deepEqual(readdirSync(marks), []);
|
||||
// The update command: the host's fsck refuses the bundle and the work ref stays.
|
||||
const landed = w.refs();
|
||||
await rejects(w.push("coder", { branch: "bad" }), "invalid-bundle");
|
||||
assert.equal(w.refs(), landed);
|
||||
assert.deepEqual(readdirSync(marks), []);
|
||||
});
|
||||
@@ -13,7 +13,7 @@ import { chmodSync, existsSync, mkdirSync, readFileSync, readdirSync, renameSync
|
||||
import { join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { Client } from "../../bus/src/client.mjs";
|
||||
import { launchLogFile, launchSocketPath, readSessions, Registry, SESSION_CREDENTIALS, sessionsFile, workspaceDir } from "../../seat/src/session.mjs";
|
||||
import { gitSocketPath, launchLogFile, launchSocketPath, readSessions, Registry, SESSION_CREDENTIALS, sessionsFile, workspaceDir } from "../../seat/src/session.mjs";
|
||||
import { sandboxProblem } from "../../seat/src/sandbox.mjs";
|
||||
import { gitEnv } from "../../seat/src/checkout.mjs";
|
||||
import { bootConfig, loadSystem } from "../src/config.mjs";
|
||||
@@ -150,6 +150,9 @@ test("bus start --pm: the PM runs under its own PID namespace, registered, with
|
||||
assert.equal(session.launchedBy, "jason");
|
||||
assert.equal(session.brokerSocket, host.path);
|
||||
assert.equal(session.launchSocket, sock);
|
||||
// Row 93: the PM neither pushes nor reviews, so no git socket; the launcher serves one.
|
||||
assert.equal(session.gitSocket, null);
|
||||
assert.equal(statSync(gitSocketPath(f.dataRoot)).mode & 0o777, 0o600);
|
||||
const manifest = JSON.parse(readFileSync(join(runDir, "bundle", "manifest.json"), "utf8"));
|
||||
assert.equal(manifest.piVersion, "0.85.1");
|
||||
assert.equal(manifest.claudeVersion, null);
|
||||
@@ -218,6 +221,11 @@ test("the PM launches a coder through its launch tool; the coder answers; refusa
|
||||
const bound = (pid) => readFileSync(`/proc/${pid}/cmdline`, "utf8").split("\0").includes(sock);
|
||||
assert.equal(bound(readSessions(f.dataRoot).find((s) => s.instance === "pm").pid), true);
|
||||
assert.equal(bound(coder.pid), false);
|
||||
// Row 93: and only a role that pushes or reviews has the git socket.
|
||||
const gitBound = (pid) => readFileSync(`/proc/${pid}/cmdline`, "utf8").split("\0").includes(gitSocketPath(f.dataRoot));
|
||||
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));
|
||||
|
||||
// The coder claimed its role and answers its own messages.
|
||||
const hello = await client.call("message.send", { to: "coder", class: "REQUEST", body: "hello" });
|
||||
|
||||
Reference in New Issue
Block a user