cli, seat, harness: git socket hardening and refusal tests, row 100 (#1594)

Row 93 follow-up: SIGTERM then SIGKILL for host git, rollback-failed,
a total body deadline, fetch-aborted for a reviewer gone mid-fetch,
working refs first in have, and tests for five untested refusals.
Dewey approved in 28226 and Sage in 28225; Sage's full gate passed
16:57:32-17:01:32Z. The host side is live at the next bus restart.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
2026-10-13 12:15:52 -05:00
co-authored by Claude Opus 5.5
parent c8302a0675
commit 664199f794
8 changed files with 619 additions and 62 deletions
+237 -3
View File
@@ -12,6 +12,7 @@ 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 { Duplex } from "node:stream";
import { join } from "node:path";
import { Client } from "../../bus/src/client.mjs";
import { configDir, loadBusiness } from "../../business/src/index.mjs";
@@ -20,7 +21,7 @@ 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 { createGitSocket, send } from "../src/git-socket.mjs";
import { startHost, startTimeOf } from "../src/host.mjs";
import { fixture } from "./helpers.mjs";
@@ -56,7 +57,8 @@ const bundles = (dir) => (existsSync(dir) ? readdirSync(dir).filter((n) => n.end
// 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 = {}) {
// The socket sees wrap(host), so a test can change one answer of the host's.
async function setup(t, { wrap = (h) => h, ...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-"));
@@ -78,7 +80,7 @@ async function setup(t, git = {}) {
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 });
const sock = createGitSocket({ host: wrap(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 = {};
@@ -120,6 +122,32 @@ function raw(path, text) {
});
}
// A connection that has sent `text` and waits. hangUp() ends it; reply
// resolves with whatever the host sent once the connection is closed.
function idle(path, text = "") {
return new Promise((resolve, reject) => {
const s = connect(path);
let buf = "";
s.on("data", (b) => (buf += b));
s.on("error", () => {});
const reply = new Promise((r) => s.on("close", () => r(buf)));
s.on("connect", () => {
if (text) s.write(text);
resolve({ reply, hangUp: () => (s.end(), reply) });
});
s.once("error", reject);
});
}
// Polls fn() until it is truthy, for up to 5 s.
async function until(fn, what) {
for (const end = Date.now() + 5000; Date.now() < end; await new Promise((r) => setTimeout(r, 20))) if (await fn()) return;
assert.fail(`timed out waiting for ${what}`);
}
const refusedOn = async (w, subject = TASK) => (await w.trail("action.refused", subject)).map((e) => `${e.actor_role} ${e.body.code}`);
const pushReq = (cap, size) => `${JSON.stringify({ cap, op: "push", task_ref: TASK, project: "stack", branch: "feat", size })}\n`;
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);
@@ -153,6 +181,11 @@ test("the review loop: the coder pushes and asks, the reviewer fetches and appro
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]]);
// The reviewer hangs up once it has the bundle, which can beat the host's
// own end of the send. close() waits for each connection's job, so a fetch
// filed as anything but done would be on the trail by now.
await w.sock.close();
assert.deepEqual(await refusedOn(w), []);
assert.deepEqual(w.logs, []);
});
@@ -314,3 +347,204 @@ test("a rigged clone runs nothing: hooks, fsmonitor, a filter, a gitlink and a s
assert.equal(w.refs(), landed);
assert.deepEqual(readdirSync(marks), []);
});
test("an identity with no role, or of another business, refuses not-a-role and moves no ref", async (t) => {
let who = null;
const w = await setup(t, { wrap: (host) => ({ op: async (m) => (m.op === "identity" && who ? { ...(await host.op(m)), ...who } : host.op(m)) }) });
const { coder } = w.roles;
gitIn(coder.clone, "checkout", "-q", "-b", "feat");
commit(coder.clone, "two");
for (const answer of [{ role: null }, { business: "other" }]) {
who = answer;
await rejects(w.push("coder"), "not-a-role");
}
assert.equal(w.refs(), "");
assert.deepEqual((await refusedOn(w)).filter((r) => r.endsWith("not-a-role")), ["coder not-a-role", "coder not-a-role"]);
who = null;
assert.equal((await w.push("coder")).ref, "work/vikunja-1-2");
});
test("a review of a project the business no longer has refuses unknown-project", async (t) => {
const w = await setup(t, { wrap: (host) => ({ op: async (m) => (m.op === "reviewFetch" ? { ...(await host.op(m)), project: "gone" } : host.op(m)) }) });
const { coder } = w.roles;
gitIn(coder.clone, "checkout", "-q", "-b", "feat");
const c1 = commit(coder.clone, "two");
await w.push("coder");
const req = await coder.client.call("review.request", { task_ref: TASK, commit: c1, to: "reviewer" });
await rejects(w.fetch("reviewer", req.request), "unknown-project");
assert.ok((await refusedOn(w)).includes("reviewer unknown-project"));
assert.deepEqual(w.logs, []);
});
test("past maxActive connections the socket refuses git-busy, and a hung-up connection frees its slot", async (t) => {
const w = await setup(t, { maxActive: 2 });
const held = [await idle(w.sock.path), await idle(w.sock.path)];
assert.deepEqual(await raw(w.sock.path, `${JSON.stringify({ op: "push" })}\n`), { ok: false, error: "git-busy" });
await held[0].hangUp();
assert.deepEqual(await raw(w.sock.path, `${JSON.stringify({ op: "push" })}\n`), { ok: false, error: "unauthenticated" });
await held[1].hangUp();
});
test("close() answers host-closing to a connection still sending its request line", async (t) => {
const w = await setup(t);
const waiting = await idle(w.sock.path, '{"cap":');
// Served in order, so a full exchange after it means the host took it.
assert.deepEqual(await raw(w.sock.path, `${JSON.stringify({ op: "push" })}\n`), { ok: false, error: "unauthenticated" });
await w.sock.close();
assert.deepEqual(JSON.parse(await waiting.reply), { ok: false, error: "host-closing" });
});
test("a bundle that trickles in refuses body-timeout at its deadline and frees its slot", async (t) => {
const w = await setup(t, { maxActive: 2, bodyDeadline: () => 400 });
const { coder } = w.roles;
const other = await idle(w.sock.path);
const started = Date.now();
// One byte every 50 ms: 1000 bytes would take 50 s.
const trickled = new Promise((resolve, reject) => {
const s = connect(w.sock.path);
let buf = "";
let drip = null;
s.on("connect", () => s.write(pushReq(coder.cap, 1000)));
s.on("data", (b) => {
buf += b;
if (!drip && buf === `${JSON.stringify({ ok: true, send: 1000 })}\n`) drip = setInterval(() => s.writable && s.write("x"), 50);
});
s.on("close", () => {
clearInterval(drip);
resolve({ last: buf.trim().split("\n").pop(), ms: Date.now() - started });
});
s.on("error", () => {});
setTimeout(() => (s.destroy(), reject(new Error("no refusal in 5 s"))), 5000).unref();
});
// The trickle holds the second slot while it lasts.
assert.deepEqual(await raw(w.sock.path, `${JSON.stringify({ op: "push" })}\n`), { ok: false, error: "git-busy" });
const { last, ms } = await trickled;
assert.deepEqual(JSON.parse(last), { ok: false, error: "body-timeout" });
assert.ok(ms < 5000, `${ms} ms`);
assert.deepEqual(await raw(w.sock.path, `${JSON.stringify({ op: "push" })}\n`), { ok: false, error: "unauthenticated" });
assert.ok((await refusedOn(w)).includes("coder body-timeout"));
assert.equal(w.refs(), "");
await other.hangUp();
});
test("a reviewer gone before its bundle was sent is filed fetch-aborted, not as the host's fault", async (t) => {
const w = await setup(t);
const { coder, reviewer } = w.roles;
gitIn(coder.clone, "checkout", "-q", "-b", "feat");
// Noise git can't compress, so the bundle can't all fit in the socket's buffers.
writeFileSync(join(coder.clone, "big"), randomBytes(4 * 1024 * 1024));
gitIn(coder.clone, "add", "big");
gitIn(coder.clone, "commit", "-q", "-m", "big");
const c = (await w.push("coder")).commit;
const req = await coder.client.call("review.request", { task_ref: TASK, commit: c, to: "reviewer" });
// Reads the result line and hangs up.
const line = await new Promise((resolve, reject) => {
const s = connect(w.sock.path);
let buf = "";
s.on("connect", () => s.write(`${JSON.stringify({ cap: reviewer.cap, op: "fetch", task_ref: TASK, request: req.request, have: [] })}\n`));
s.on("data", (b) => {
buf += b.toString("latin1");
const nl = buf.indexOf("\n");
if (nl >= 0) {
s.destroy();
resolve(JSON.parse(buf.slice(0, nl)));
}
});
s.on("error", reject);
});
assert.equal(line.ok, true);
assert.equal(line.result.commit, c);
await until(async () => (await refusedOn(w)).includes("reviewer fetch-aborted"), "fetch-aborted on the trail");
assert.ok(!(await refusedOn(w)).some((r) => r.endsWith("git-failed")));
assert.deepEqual(w.logs, []);
});
test("a reviewer that hangs up the moment it has the whole bundle was served, not aborted", async (t) => {
const w = await setup(t);
const { coder, reviewer } = w.roles;
gitIn(coder.clone, "checkout", "-q", "-b", "feat");
writeFileSync(join(coder.clone, "big"), randomBytes(1024 * 1024));
gitIn(coder.clone, "add", "big");
gitIn(coder.clone, "commit", "-q", "-m", "big");
const c = (await w.push("coder")).commit;
const req = await coder.client.call("review.request", { task_ref: TASK, commit: c, to: "reviewer" });
// Reads the result line and `size` bytes, then destroys the connection at
// once, which can beat the host's own end of the send. Ten times, since it
// is a race.
const take = () =>
new Promise((resolve, reject) => {
const s = connect(w.sock.path);
let buf = Buffer.alloc(0);
let size = null;
s.on("connect", () => s.write(`${JSON.stringify({ cap: reviewer.cap, op: "fetch", task_ref: TASK, request: req.request, have: [] })}\n`));
s.on("data", (b) => {
buf = Buffer.concat([buf, b]);
if (size === null) {
const nl = buf.indexOf(0x0a);
if (nl < 0) return;
size = JSON.parse(buf.subarray(0, nl).toString("utf8")).result.size;
buf = buf.subarray(nl + 1);
}
if (buf.length >= size) {
s.destroy();
resolve(buf.length);
}
});
s.on("error", reject);
});
for (let i = 0; i < 10; i++) assert.ok((await take()) > 0);
// close() waits for each connection's job.
await w.sock.close();
assert.deepEqual(await refusedOn(w), []);
assert.deepEqual(w.logs, []);
});
// The two closes the socket tests can't order every time, on a stand-in
// socket: the reader gone once every byte left the host but before 'finish',
// and the reader gone while the last chunk was still queued in the host.
test("send: a close after every byte left is served; one that fails a queued write is fetch-aborted", async (t) => {
const dir = mkdtempSync(join(tmpdir(), "mosaic-send-"));
t.after(() => rmSync(dir, { recursive: true, force: true }));
const file = join(dir, "b.bundle");
writeFileSync(file, "ten bytes!");
// Takes every write at once and never finishes the end.
const quick = new Duplex({ read() {}, write: (c, e, cb) => cb(), final() {} });
quick.on("error", () => {});
const served = send(quick, file);
await until(() => quick.writableEnded, "the end");
quick.destroy();
await served;
// Holds the one write, then fails it as a socket whose reader left does.
let pending = null;
const slow = new Duplex({ read() {}, writableHighWaterMark: 1, write: (c, e, cb) => (pending = cb), final: (cb) => cb() });
slow.on("error", () => {});
const gone = send(slow, file);
await until(() => pending, "the write");
pending(Object.assign(new Error("write EPIPE"), { code: "EPIPE" }));
await rejects(gone, "fetch-aborted");
});
test("a record that fails and can't be undone refuses rollback-failed, which the host logs", async (t) => {
let lock = null;
const w = await setup(t, {
wrap: (host) => ({
op: async (m) => {
if (m.op !== "recordPush" || !lock) return host.op(m);
writeFileSync(lock, "");
throw Object.assign(new Error("not recorded"), { code: "store-failed" });
},
}),
});
const { coder } = w.roles;
gitIn(coder.clone, "checkout", "-q", "-b", "feat");
const c1 = commit(coder.clone, "two");
lock = join(w.repo, "refs", "heads", "work", "vikunja-1-2.lock");
await rejects(w.push("coder"), "rollback-failed");
assert.equal(w.refs(), `refs/heads/main ${w.main}\nrefs/heads/work/vikunja-1-2 ${c1}`);
assert.equal(w.logs.length, 1);
assert.match(w.logs[0], /^git socket: rollback-failed: refs\/heads\/work\/vikunja-1-2 still names /);
assert.ok((await refusedOn(w)).includes("coder rollback-failed"));
rmSync(lock);
});