Compare commits

..
Author SHA1 Message Date
jason.woltje 80924f7d5d row 211 (#1709): BUILD.md records the r2 rebase onto 78b69e1e6 (sage-4362); same patch-id, no conflict; doc only 2026-10-24 23:04:56 -05:00
jason.woltje 20e885dc35 row 211 (#1709, #426): a pre-push guard that refuses deletes and non-fast-forward pushes, its installer, and slot-busy.sh that exits 1 on BUSY
scripts/git-hooks/pre-push refuses a delete, a remote tip missing locally and
any update whose remote tip isn't an ancestor of the local tip; a new branch
and a fast-forward pass. scripts/install-git-hooks.sh puts it in the common
.git/hooks, refuses a set core.hooksPath or a different hook without
--force-install, and runs a canary through git hook run. scripts/slot-busy.sh
exits 0 CLEAR, 1 BUSY, 2 error. scripts/test-git-guard.sh (61 cases) and the
mutant runner in agents/darkwing/work/queue-211; TOOLS.md gets the usage.
Not run yet: written during Gander's slot (sage-2801).
2026-10-24 23:04:14 -05:00
8 changed files with 737 additions and 628 deletions

No files matched your search

@@ -1,628 +0,0 @@
# Row 198 r5: assertion diff
Status: uncommitted working-tree review file. Nothing here has been committed or pushed, and no test has been run (CLEAR pending).
- Base: `78b69e1e6` (refactor). Rebase paused on pick 1 (`4288b7b16`). Commit 1 is staged; later edits are in the working tree.
- Groups: A = tests that existed on refactor before row 198 (Mos reads these, mos-1272). B = tests row 198 adds (Sage rules).
- Hunks: 24 group A, 3 group B. Changed assert lines: 230 removed, 61 added (hunk sums; the file-level diff includes non-assert lines).
- Retired test names: 36 (list below).
Labels are from line-range attribution. A removed block can span several old tests, so the enclosing-test label is the first match only; the reason and the diff text are authoritative.
## Group A (existed on refactor before row 198)
### A1: control-board/tests/discord.test.mjs
Enclosing test (first match, old or new side): server rescans connector discovery and refuses HTTP reply without transport
Reason: Port: the HTTP /api/board fetch becomes board.index(); the 409 is the status board.reply returns in-process. Kept: state, connector.alive, registered null, forged task not used.
```diff
- assert.equal(board.sessions[0].state, 'idle'); assert.equal(board.sessions[0].connector.alive, true);
- assert.equal(board.sessions[0].registered, null); assert.notEqual(board.sessions[0].task, 'forged');
- assert.equal(response.status, 409);
+ assert.equal(payload.sessions[0].state, 'idle'); assert.equal(payload.sessions[0].connector.alive, true);
+ assert.equal(payload.sessions[0].registered, null); assert.notEqual(payload.sessions[0].task, 'forged');
+ assert.equal(status, 409);
```
### A2: control-board/tests/discord.test.mjs
Enclosing test (first match, old or new side): server rescans connector discovery and refuses HTTP reply without transport
Reason: Port: the HTTP /api/board fetch becomes board.index(); zero-sessions assert unchanged.
```diff
- assert.equal((await (await fetch(base + '/api/board')).json()).sessions.length, 0);
+ assert.equal((await board.index()).sessions.length, 0);
```
### A4: control-board/tests/serve.test.mjs
Enclosing test (first match, old or new side): isLoopbackHost: rejects non-loopback hosts
Reason: Retired writer HTTP asserts: page content-type and cache-control, healthz body, 404/405 and the 500 envelope. The writer opens no listener now. index.json and per-agent write asserts are not re-asserted here (gap).
```diff
- assert.ok(caught instanceof ConfigError, "expected a ConfigError");
- assert.equal(res.status, 200, path);
- assert.equal(res.headers.get("content-type"), "text/html; charset=utf-8", path);
- assert.equal(res.headers.get("cache-control"), "no-store", path);
- assert.equal(await res.text(), page, path);
- assert.equal(health.status, 200);
- assert.deepEqual(await health.json(), { ok: true });
- assert.equal(first.status, 200);
- assert.equal(first.headers.get("content-type"), "application/json");
- assert.equal(first.headers.get("cache-control"), "no-store");
- assert.equal(firstBody.sessions[0].state, "idle");
- assert.ok(!firstBody.waitingOnYou.includes("proj/agent1"));
- assert.ok(existsSync(join(boardDir, "index.json")), "index.json must be written by the scan");
- assert.ok(existsSync(join(boardDir, "sessions", "proj", "agent1.json")), "per-agent file must be written by the scan");
- assert.equal(second.status, 200);
- assert.equal(secondBody.sessions[0].state, "working");
- assert.equal(missing.status, 404);
- assert.equal(posted.status, 405);
- assert.equal(res.status, 200);
- assert.equal(body.sessions[0].taskSource, "registration");
- assert.equal(body.sessions[0].task, "registered task");
- assert.deepEqual(body.registered, ["proj/agent1"]);
- assert.deepEqual(body.registrationErrors, []);
- assert.equal(res.status, 500);
- assert.equal(res.headers.get("content-type"), "application/json");
- assert.equal(typeof body.error, "string");
- assert.ok(body.error.length > 0);
```
### A5: control-board/tests/serve.test.mjs
Enclosing test (first match, old or new side): CLI: serve refuses a non-loopback host with exit 2 and a refused: message
Reason: Retired with serve: CLI refusal now asserted by cli.mjs:14 via board.test.mjs (group B hunk 0).
```diff
- assert.equal(r.status, 2);
- assert.match(r.stderr, /^refused:/);
```
### A6: control-board/tests/serve.test.mjs
Enclosing test (first match, old or new side): CLI: live serve prints its URL and answers /healthz
Reason: Retired page.html source checks (esc, seen POST, healthz) and HTTP seen round-trip. page.html is deleted; markSeen order is in board.test.mjs. The esc() asserts have no in-process target (open gap).
```diff
- assert.equal(res.status, 200);
- assert.deepEqual(await res.json(), { ok: true });
- assert.ok(m, "esc() must exist in page.html");
- assert.equal(esc('<script>alert("x")</script>&\''), "&lt;script&gt;alert(&quot;x&quot;)&lt;/script&gt;&amp;&#39;");
- assert.equal(esc(null), "");
- assert.equal(esc(undefined), "");
- assert.equal(esc(42), "42");
- assert.deepEqual(rawJoins, [], `API values concatenated into HTML without esc(): ${rawJoins.join(" | ")}`);
- assert.ok(escCalls >= 15, `expected many esc() calls, saw ${escCalls}`);
- assert.equal(postRes.status, 200);
- assert.ok(postBody.seen.includes("proj/agent1"));
- assert.ok(!postBody.waitingOnYou.includes("proj/agent1"));
- assert.equal(boardRes.status, 200);
- assert.ok(boardBody.seen.includes("proj/agent1"));
- assert.ok(!boardBody.waitingOnYou.includes("proj/agent1"));
- assert.equal(clearRes.status, 200);
- assert.ok(!clearBody.seen.includes("proj/agent1"));
- assert.ok(clearBody.waitingOnYou.includes("proj/agent1"));
- assert.equal(res.status, 400);
- assert.ok(!existsSync(join(boardDir, "seen.json")), "seen.json must not be written on a rejected content-type");
- assert.equal(res.status, 400);
- assert.ok(!existsSync(join(boardDir, "seen.json")));
- if (status !== null) assert.equal(status, 400);
- assert.ok(!existsSync(join(boardDir, "seen.json")), "seen.json must not be written for an oversized body");
- assert.equal(res.status, 400);
- assert.ok(!existsSync(join(boardDir, "seen.json")));
- assert.equal(board.status, 405);
- assert.equal(put.status, 405);
```
### A7: control-board/tests/serve.test.mjs
Enclosing test (first match, old or new side): CLI: scan --print marks a seen row with 's' and the summary line ends with 'N seen)'
Reason: Retired page.html source checks only (seenControl, Seen section, Hide seen, sourceTag escapes). No verified Console equivalent in this pass (open gap).
```diff
- assert.ok(m, "seenControl() must exist in page.html");
- assert.match(body, /esc\(rec\.project\)/);
- assert.match(body, /esc\(rec\.agent\)/);
- assert.match(body, /esc\(rec\.lastActivity\)/);
- assert.match(html, /headers:\s*\{\s*"content-type":\s*"application\/json"\s*\}/, "the seen POST must send content-type: application/json");
- assert.match(html, /<details id="seenDetails">(?![^>]*\sopen)/, "the Seen section must start collapsed");
- assert.match(html, /<h2 id="seen-h">Seen<\/h2>/);
- assert.ok(m, "renderSeen() must exist in page.html");
- assert.match(m[0], /filter\(function \(r\) \{ return r\.seen; \}\)/, "the Seen section lists exactly the rows the API marks seen");
- assert.match(m[0], /buildRowPair\(r, true\)/, "seen rows reuse the escaped row builder, project column included");
- assert.match(html, /renderWaiting\(lastData\);\n\s*renderSeen\(lastData\);/, "renderAll must render the Seen section on every refresh");
- assert.ok(m, "renderProjects() must exist in page.html");
- assert.match(body, /if \(!\(project in hideSeenState\)\) hideSeenState\[project\] = true/, "Hide seen defaults to on and the choice survives re-render");
- assert.match(body, /class="hide-seen-toggle" data-project="' \+ esc\(project\)/, "the checkbox carries the escaped project name");
- assert.match(body, /hideSeen && r\.seen/, "seen rows are filtered when the box is ticked");
- assert.match(body, /" seen hidden"/, "the note reports how many seen rows are hidden");
- assert.match(html, /closest\("\.hide-offline-toggle, \.hide-seen-toggle"\)/, "one change handler serves both checkboxes");
- assert.ok(m, "renderProjects() must exist in page.html");
- assert.match(
- assert.match(body, /<span>\(' \+ headCount \+ "\)<\/span>/, "the header uses headCount, not the raw group length");
- assert.doesNotMatch(body, /<span>\(' \+ group\.length/, "the raw group length must no longer be rendered in the header");
- assert.ok(m, "buildRowPair() must exist in page.html");
- assert.match(body, /rec\.task\s*\?[\s\S]*esc\(rec\.task\)[\s\S]*:\s*'<span class="unknown">unknown<\/span>'/, "task is escaped, or reads unknown when null");
- assert.match(body, /rec\.activeProject\s*\?[\s\S]*esc\(rec\.activeProject\)[\s\S]*unknown/, "active project is escaped, or reads unknown when null");
- assert.match(body, /<dt>Task<\/dt>/, "detail lists the task");
- assert.match(body, /<dt>Active project<\/dt>/, "detail lists the active project");
- assert.match(body, /<dt>Workspace<\/dt><dd>" \+ \(rec\.workspace \? esc\(rec\.workspace\) : "unknown"\)/, "detail lists the workspace, escaped, or unknown");
- assert.match(body, /var span = showProject \? 7 : 6/, "the detail row spans the two new columns");
- assert.equal(heads.length, 3, "all three tables carry the two new headers");
- assert.match(html, /<td colspan="6" class="empty">No agents\.<\/td>/, "the empty project row spans every column");
- assert.ok(rowPair, "buildRowPair() must exist in page.html");
- assert.match(rowPair[0], /sourceTag\(rec\.taskSource\)/, "the task cell shows its source");
- assert.match(rowPair[0], /sourceTag\(rec\.activeProjectSource\)/, "the active project cell shows its source");
- assert.match(
- assert.match(rowPair[0], /esc\(rec\.agent\) \+ "<\/button>" \+ modelTag\(rec\)/, "the agent cell shows the model under the name");
- assert.match(rowPair[0], /<dt>Model<\/dt><dd>" \+ modelText\(rec\) \+ "<\/dd>/, "the detail list has a Model row");
- assert.ok(line, `${name}() must exist in page.html`);
- assert.match(line, /esc\(rec\.model\)/, `${name}() escapes the model`);
- if (name === "modelText") assert.match(line, /esc\(rec\.provider\)/, "modelText() escapes the provider");
- assert.ok(sourceLabelMatch, "SOURCE_LABEL must exist in page.html");
- assert.equal(sourceLabel.registration, "registered", 'SOURCE_LABEL must map "registration" to "registered"');
- assert.ok(sourceTagLine, "sourceTag() must exist in page.html");
- assert.match(sourceTagLine, /esc\(source\)/, "sourceTag() escapes the raw source value (used in the title)");
- assert.match(sourceTagLine, /esc\(sourceLabel\(source\)\)/, "sourceTag() escapes the label text it displays");
- assert.ok(fromSourceLine, "fromSource() must exist in page.html");
- assert.match(fromSourceLine, /esc\(sourceLabel\(source\)\)/, "fromSource() escapes the label text it displays");
- assert.ok(registeredTextFn, "registeredText() must exist in page.html");
- assert.match(line, /esc\(/, `every dynamic value read off reg must be escaped: ${line.trim()}`);
```
### A8: control-board/tests/serve.test.mjs
Enclosing test (first match, old or new side): every refusal code the reader can raise has an HTTP status
Reason: Moved in, not weakened: REFUSAL_STATUS table and code-count asserts from the retired conversation test (hunk 15), plus the DEFAULT_AGENT_SEND path asserts (hunk 12).
```diff
+ assert.equal(DEFAULT_AGENT_SEND, resolve(pkgRoot, "..", "..", "tools", "tmux", "agent-send.sh"));
+ assert.ok(existsSync(DEFAULT_AGENT_SEND));
+ assert.ok(statSync(DEFAULT_AGENT_SEND).mode & 0o111);
+ assert.ok(codes.size >= 15, [...codes].join(" "));
+ assert.deepEqual([...codes].filter((c) => !(c in REFUSAL_STATUS)), []);
+ assert.deepEqual(Object.keys(REFUSAL_STATUS).filter((c) => !codes.has(c)), [], "no stale entries");
+ assert.deepEqual(REFUSAL_STATUS, {
```
### A9: control-board/tests/serve.test.mjs
Enclosing test (first match, old or new side): POST /api/reply: a registration with a tmux socket adds -L <socket>
Reason: Re-indent only: the same -L socket asserts move into the board.reply closure (identical text after trim).
```diff
- assert.equal(status, 200);
- assert.equal(body.socket, "mosaic-fleet");
- assert.equal(argv[argv.indexOf("-L") + 1], "mosaic-fleet");
+ assert.equal(status, 200);
+ assert.equal(body.socket, "mosaic-fleet");
+ assert.equal(argv[argv.indexOf("-L") + 1], "mosaic-fleet");
```
### A10: control-board/tests/serve.test.mjs
Enclosing test (first match, old or new side): POST /api/reply: refusals before the tool runs: empty or blank or long text 400, unknown row 404, no registration 409, stale registration 409, no tmux session 409, bad JSON 400; the tool is never called
Reason: Re-indent only: the refusal asserts move into the ported reply test; the tool-not-run check is unchanged.
```diff
- assert.equal(status, c.status, `${JSON.stringify(c.body).slice(0, 60)}: ${JSON.stringify(body)}`);
- assert.match(body.error, c.error);
- assert.equal(existsSync(f.capture), false, "the tool must not run on a refusal");
+ assert.equal(status, c.status, `${JSON.stringify(c.body).slice(0, 60)}: ${JSON.stringify(body)}`);
+ assert.match(body.error, c.error);
+ assert.equal(existsSync(f.capture), false, "the tool must not run on a refusal");
```
### A11: control-board/tests/serve.test.mjs
Enclosing test (first match, old or new side): POST /api/reply: a missing agent-send.sh is a 500 with the path in the error, not a crash
Reason: Re-indent only for the 500 and path asserts. The healthz status line is dropped with the HTTP server.
```diff
- assert.equal(status, 500);
- assert.match(body.error, /missing-agent-send\.sh/);
- assert.equal(health.status, 200);
+ assert.equal(status, 500);
+ assert.match(body.error, /missing-agent-send\.sh/);
```
### A12: control-board/tests/serve.test.mjs
Enclosing test (first match, old or new side): replyToRow: DEFAULT_AGENT_SEND is the repository's tools/tmux/agent-send.sh and it is executable
Reason: Moved, not weakened: DEFAULT_AGENT_SEND asserts move to the refusal-table test (hunk 8).
```diff
- assert.equal(DEFAULT_AGENT_SEND, resolve(pkgRoot, "..", "..", "tools", "tmux", "agent-send.sh"));
- assert.ok(existsSync(DEFAULT_AGENT_SEND));
- assert.ok(statSync(DEFAULT_AGENT_SEND).mode & 0o111);
```
### A13: control-board/tests/serve.test.mjs
Enclosing test (first match, old or new side): page.html: the reply box appears only where canReply() holds (live registration with a tmux session), the detail has a Reply row, the submit posts JSON to /api/reply, receipts and drafts survive a refresh, and every receipt value is escaped
Reason: Retired page.html source checks for the reply box (canReply, replyControl, postReply, receipts). Reply gate behaviour is covered in the Console by browser-edge.test (reply needs a registered seat, line 61; in-flight reply).
```diff
- assert.ok(canReplyFn, "canReply() must exist");
- assert.match(canReplyFn[0], /reg\.alive !== false/, "a stale registration gets no box");
- assert.match(canReplyFn[0], /reg\.tmux && reg\.tmux\.session/, "a registration without a tmux session gets no box");
- assert.ok(replyControlFn, "replyControl() must exist");
- assert.match(replyControlFn[0], /if \(!canReply\(rec\)\) return '<span class="unknown">reply needs a registered seat<\/span>'/);
- assert.match(replyControlFn[0], /class="reply-form" data-id="' \+ esc\(id\)/);
- assert.match(replyControlFn[0], /value="' \+ esc\(replyDrafts\[id\] \|\| ""\)/, "an unsent draft is put back after a refresh");
- assert.match(rowPair[0], /<dt>Reply<\/dt><dd>" \+ replyControl\(rec\) \+ "<\/dd>/);
- assert.ok(receiptFn, "replyReceipt() must exist");
- assert.match(line, /esc\(/, `every receipt value must be escaped: ${line.trim()}`);
- assert.match(receiptFn[0], /esc\(r\.stderr \|\| r\.error/, "a failure shows the tool's stderr");
- assert.ok(postReplyFn, "postReply() must exist");
- assert.match(postReplyFn[0], /fetch\("\/api\/reply", \{\s*method: "POST",\s*headers: \{ "content-type": "application\/json" \}/);
- assert.match(postReplyFn[0], /JSON\.stringify\(\{ agent: id, text: text \}\)/);
- assert.match(postReplyFn[0], /replyReceipts\[id\] = body/);
- assert.ok(lines.some((l) => l.includes('main.addEventListener("submit"')), "the form submits through one delegated handler");
- assert.ok(lines.some((l) => l.includes('main.addEventListener("input"')), "drafts are captured as they are typed");
- assert.match(html, /function withReplyFocus\(render\)/, "a refresh gives the caret back to the reply box");
- assert.doesNotMatch(html, /send-keys/, "the page never talks tmux");
+ // Ported from the writer's HTTP test (refactor 78b69e1e6): the same assertions over board.index().
```
### A14: control-board/tests/serve.test.mjs
Enclosing test (first match, old or new side): startServer: /api/board carries taskSetBy from a live registration and null for the derived rows
Reason: Ported: byAgent taskSource, taskSetBy and registrationErrors asserts kept on board.index() (test renamed 'index: carries taskSetBy...'). Retired: page.html tag and detail checks and the HTTP Host/Origin fences (no listener).
```diff
- assert.equal(byAgent.agent1.taskSource, "registration");
- assert.equal(byAgent.agent1.taskSetBy, "darkwing");
- assert.equal(byAgent.agent2.taskSource, "first-user-message");
- assert.equal(byAgent.agent2.taskSetBy, null);
- assert.deepEqual(body.registrationErrors, []);
- assert.ok(rowPair);
- assert.match(rowPair[0], /sourceTag\(rec\.taskSource\) \+ setByTag\(rec\)/, "the set-by tag follows the task's source tag on the row");
- assert.match(rowPair[0], /<dt>Task set by<\/dt><dd>" \+ setByText\(rec\) \+ "<\/dd>/, "the detail has a Task set by row");
- assert.ok(tagLine);
- assert.match(tagLine, /rec\.taskSetBy \?/, "nothing is shown without a value");
- assert.equal((tagLine.match(/esc\(rec\.taskSetBy\)/g) || []).length, 2, "the title and the text are both escaped");
- assert.match(tagLine, /not verified/);
- assert.ok(textLine);
- assert.match(textLine, /esc\(rec\.taskSetBy\)/);
- assert.match(textLine, /not verified/);
- assert.ok(canReplyFn);
- assert.equal(canReplyFn[0].includes("taskSetBy"), false);
- assert.equal(html.match(/function replyControl\(rec\) \{[\s\S]*?\n \}/)[0].includes("taskSetBy"), false);
- assert.equal(board.status, 403, `GET /api/board, ${label}`);
- assert.deepEqual(JSON.parse(board.text), { error }, `GET /api/board, ${label}`);
- assert.equal(board.headers["access-control-allow-origin"], undefined);
- assert.equal(posted.status, 403, `POST /api/reply, ${label}`);
- assert.deepEqual(JSON.parse(posted.text), { error }, `POST /api/reply, ${label}`);
- assert.equal(posted.headers["access-control-allow-origin"], undefined);
- assert.equal((await rawRequest(port, {})).status, 400, "GET /api/board, missing Host");
- assert.equal((await rawRequest(port, { method: "POST", path: "/api/reply", headers: { "content-type": "application/json" }, body: reply })).status, 400, "POST /api/reply, missing Host");
- assert.equal(existsSync(f.capture), false, "agent-send was never run");
- assert.equal(existsSync(join(f.boardDir, "index.json")), false, "no refused request rescanned the board");
- assert.equal((await rawRequest(port, { path, headers: { host: `rebind.example:${port}` } })).status, 403, path);
- assert.equal((await rawRequest(port, { method: "POST", path: "/api/seen", headers: { host: `rebind.example:${port}`, "content-type": "application/json" }, body: "{}" })).status, 403);
- assert.equal((await rawRequest(port, { headers: { host } })).status, 200, host);
- assert.equal((await rawRequest(port, { headers: { host, origin: `http://${host}` } })).status, 200, `${host} with its own Origin`);
- assert.equal(posted.status, 200);
- assert.equal(JSON.parse(posted.text).delivered, true);
- assert.equal((await fetch(`http://${own}/api/board`)).status, 200, "fetch without Origin");
- assert.equal(r.status, 403, `${path}, ${label}`);
- assert.deepEqual(JSON.parse(r.text), { error }, `${path}, ${label}`);
- for (const h of Object.keys(r.headers)) assert.ok(!h.startsWith("access-control-"), `${path}, ${label}: ${h}`);
- assert.deepEqual(calls, [], "the reader never ran for a refused request");
- assert.equal((await rawRequest(port, { path: "/api/conversations", headers: { host: own, origin: `http://${own}` } })).status, 200, "same-origin passes");
- assert.deepEqual(calls, ["catalogue"]);
+ assert.equal(byAgent.agent1.taskSource, "registration");
+ assert.equal(byAgent.agent1.taskSetBy, "darkwing");
+ assert.equal(byAgent.agent2.taskSource, "first-user-message");
+ assert.equal(byAgent.agent2.taskSetBy, null);
+ assert.deepEqual(body.registrationErrors, []);
```
### A15: control-board/tests/serve.test.mjs
Enclosing test (first match, old or new side): conversation routes: a reader listing that refuses gets its refusal status and body, not a 200
Reason: Ported: status and refusal body kept on board.conversation (renamed 'conversation listing...'). Retired: HTTP content-type, nosniff, no-store and no-CORS asserts, now in the Console test (group B hunk 25). Retired: pi and claude catalogue and paging asserts, HTTP-only (gap).
```diff
- assert.equal(res.status, 503);
- assert.deepEqual(await res.json(), { error: refusal.message, refusal: { code: refusal.code, reconcile: refusal.reconcile } });
- assert.ok(codes.size >= 15, [...codes].join(" "));
- assert.deepEqual([...codes].filter((c) => !(c in REFUSAL_STATUS)), []);
- assert.deepEqual(Object.keys(REFUSAL_STATUS).filter((c) => !codes.has(c)), [], "no stale entries");
- assert.deepEqual(REFUSAL_STATUS, {
- assert.equal(res.headers.get("content-type"), "application/json", path);
- assert.equal(res.headers.get("x-content-type-options"), "nosniff", path);
- assert.equal(res.headers.get("cache-control"), "no-store", path);
- assert.equal(res.headers.get("access-control-allow-origin"), null, path);
- assert.equal(cat.status, 200);
- assert.deepEqual([pi.seat, pi.title, pi.readOnly], ["agent1", "m0", true]);
- assert.deepEqual([link.refusal, claude.seat, claude.unsupportedReason], ["unsafe-path", "rocko", "unsupported-harness"]);
- assert.equal(first.status, 200);
- assert.deepEqual([first.body.page.kind, first.body.page.entries.length, first.body.page.hasMore, first.body.page.branch], ["page", 100, true, "main"]);
- assert.equal(second.status, 200);
- assert.deepEqual([second.body.page.entries.length, second.body.page.hasMore], [20, false]);
- assert.equal(second.body.page.entries.at(-1).content[0].text, "m119");
- assert.deepEqual([follow.status, follow.body.page.entries.length], [200, 0]);
- assert.equal(branch.status, 200);
- assert.equal(r.status, status, path);
- assert.deepEqual(r.body.refusal, { code, reconcile }, path);
- assert.equal(typeof r.body.error, "string");
- assert.equal(r.status, 400, path);
- assert.deepEqual(treePrint(f.sessionsDir), before, "the sessions directory is unchanged");
- assert.equal(existsSync(join(f.proj, ".pi", "state", "rocko")), false, "the Claude seat's directory was never created");
+ assert.equal(out.status, 503);
+ assert.deepEqual(out.body, { error: refusal.message, refusal: { code: refusal.code, reconcile: refusal.reconcile } });
```
### A16: webui/tests/browser.test.mjs
Enclosing test (first match, old or new side): served Console browser: real board fixtures, keyboard, drafts, receipts, themes, 320px and failures
Reason: CHANGED ASSERT: the error text must contain 'in the Console' instead of the board URL, since there is no board URL in-process. Not run; verify on CLEAR.
```diff
- assert.ok(error.includes(f.boardURL)); assert.match(error, /Showing last known data/);
+ assert.ok(error.includes('in the Console')); assert.match(error, /Showing last known data/);
```
### A17: webui/tests/preview.test.mjs
Enclosing test (first match, old or new side): loopback requests are unchanged in preview mode, and the bus views work with the board down
Reason: CHANGED ASSERT: a dead board read now returns error 'invalid-response' at 502 (consoleRead, webui/src/serve.mjs:230-234). Status kept; the board echo field is gone.
```diff
- assert.equal(board.status, 502); assert.equal(board.body.board, dead);
+ assert.equal(board.status, 502); assert.equal(board.body.error, 'invalid-response');
```
### A18: webui/tests/reads.test.mjs
Enclosing test (first match, old or new side): settings: release, board, loopback address, notifier binding name only
Reason: CHANGED ASSERT: settings.board is null, since the board is in-process (webui/src/serve.mjs:874, boardLabel).
```diff
- assert.deepEqual({ ...body, launch: undefined, at: undefined }, { release: '0.0.99', business: 'acme', notifier: { binding: 'jason-dm' }, board: 'http://127.0.0.1:7331', address: `${base}/`, launch: undefined, at: undefined });
+ assert.deepEqual({ ...body, launch: undefined, at: undefined }, { release: '0.0.99', business: 'acme', notifier: { binding: 'jason-dm' }, board: null, address: `${base}/`, launch: undefined, at: undefined });
```
### A19: webui/tests/serve.test.mjs
Enclosing test (first match, old or new side): loopback host and board origin fail closed
Reason: Retired: boardURL() is removed with the HTTP board address, so its asserts go with it.
```diff
- for (const u of ['https://127.0.0.1', 'http://example.com', 'http://127.0.0.1/path', 'http://user:[email protected]', 'http://127.0.0.1?x', 'http://127.0.0.1#x']) assert.throws(() => boardURL(u));
- assert.equal(boardURL('http://localhost:7331'), 'http://127.0.0.1:7331');
- assert.equal(boardURL('http://[::1]:7331'), 'http://[::1]:7331');
```
### A20: webui/tests/serve.test.mjs
Enclosing test (first match, old or new side): proxy preserves exact request bytes, status and receipt, rejects forms and malformed JSON, never follows redirect
Reason: BEHAVIOUR CHANGE, not a re-expression: the refactor forwarded this request and returned the board's 409. The Console now answers 403 when send is off or the seat is unnamed (webui/src/serve.mjs ~843-846). The oversized-body 400 is also dropped here; no Console test covers the 4096 limit (open gap).
```diff
- assert.equal(res.status, 409); assert.equal(await res.text(), '{"error":"fixture refusal <unsafe>"}');
- assert.deepEqual(requests.at(-1), { url: path, method: 'POST', body: raw });
- try { assert.equal((await post(base, '/api/reply', raw)).status, 400); } catch (err) { if (raw.length < 4096) throw err; }
+ assert.equal(res.status, 403, path); assert.equal((await res.json()).sent, false);
```
### A21: webui/tests/serve.test.mjs
Enclosing test (first match, old or new side): proxy preserves exact request bytes, status and receipt, rejects forms and malformed JSON, never follows redirect
Reason: Dropped: text/plain 400 (Console body() at serve.mjs:58 refuses non-JSON, mapped to 400 at :191; no test, open gap), the request-count checks (no HTTP hop) and the board-down 502 (board is in-process). Added: 405 for GET /api/reply and OPTIONS /api/board; 'the board never ran a write' check on f.captures.
```diff
- assert.equal((await post(base, '/api/reply', '{}', { 'content-type': 'text/plain' })).status, 400);
- assert.equal(requests.length, 2);
- assert.equal((await fetch(base + '/api/board')).status, 502);
- assert.equal(requests.length, 3);
+ assert.equal(f.captures.length, 0, 'the board never ran a write');
+ assert.equal((await fetch(base + '/api/reply')).status, 405);
+ assert.equal((await fetch(base + '/api/board', { method: 'OPTIONS' })).status, 405);
```
### A22: webui/tests/serve.test.mjs
Enclosing test (first match, old or new side): proxy preserves exact request bytes, status and receipt, rejects forms and malformed JSON, never follows redirect
Reason: CHANGED ASSERT: the exact 404 body and refusal object become a want.status and want.body table. The table must match the same body (check in the file, not run).
```diff
- assert.equal(res.status, 404, path);
- assert.equal(await res.text(), '{"error":"fixture unknown <x>","refusal":{"code":"unknown-branch","reconcile":true}}');
- assert.deepEqual(requests.at(-1), { url: path, method: 'GET', body: '' });
+ assert.equal(res.status, want.status, path);
+ assert.deepEqual(await res.json(), want.body, path);
```
### A23: webui/tests/serve.test.mjs
Enclosing test (first match, old or new side): proxy preserves exact request bytes, status and receipt, rejects forms and malformed JSON, never follows redirect
Reason: WEAKENED: the exact 'refused before the board' check (request count unchanged, last url /api/board) becomes a status >= 400 check. This drops the proof the board was not reached. Flagged for Mos.
```diff
- assert.equal((await post(base, '/api/conversation?id=x', '{}')).status, 405);
- assert.equal((await post(base, '/api/conversations', '{}')).status, 405);
- assert.equal((await fetch(base + '/api/conversation?id=x', { headers: { origin: 'https://evil.example' } })).status, 403);
- assert.equal(requests.length, before, 'refused before the board');
- assert.equal(requests.at(-1).url, '/api/board');
+ assert.ok((await fetch(base + '/api/board')).status >= 400);
```
### A24: webui/tests/serve.test.mjs
Enclosing test (first match, old or new side): unreachable board reports URL; CLI rejects unsupported options
Reason: CHANGED ASSERT: the 502 with the board URL becomes 503 not-configured for /api/board and /api/conversations. No board URL appears in any response now.
```diff
- assert.equal(res.status, 502); const result = await res.json(); assert.equal(result.board, url); assert.ok(result.error.includes(url));
+ assert.equal((await fetch(base + '/api/board')).status, 503);
+ assert.equal((await fetch(base + '/api/conversations')).status, 503);
```
### A26: webui/tests/service.test.mjs
Enclosing test (first match, old or new side): render carries every serve flag, the preview set included
Reason: CHANGED ASSERT: the expected ExecStart drops the --board flag (in-process board; webui/src/cli.mjs and scripts/webui-service.sh changed in commit 1).
```diff
- assert.equal(execStart(r.stdout), `ExecStart=${process.execPath} ${REPO}/packages/webui/src/cli.mjs serve --port 7340 --business mosaic.dev_1 --board http://127.0.0.1:7331 --public-host console.example.com --auth-header X-authentik-uid --allow-user ${UID}`);
+ assert.equal(execStart(r.stdout), `ExecStart=${process.execPath} ${REPO}/packages/webui/src/cli.mjs serve --port 7340 --business mosaic.dev_1 --public-host console.example.com --auth-header X-authentik-uid --allow-user ${UID}`);
```
## Group B (added by row 198; Sage rules)
### B0: control-board/tests/board.test.mjs
Enclosing test (first match, old or new side): cli serve refuses and points to the Console
Reason: New in row 198: cli serve refusal (cli.mjs:14) and the 'serve' retirement message, plus the markSeen order and answer asserts.
```diff
+ import assert from "node:assert/strict";
+ assert.deepEqual(events, ["write", "bump", "scan"]);
+ assert.deepEqual(before.seen, []);
+ assert.deepEqual(index.seen, ["proj/agent1"]);
+ const board = createBoard({ specs, boardDir, isAlive, onEvent: (name) => events.push(name), exec: async () => assert.fail("no send on refusal") });
+ assert.equal(out.status, 409);
+ assert.equal(events.includes("send"), false);
+ assert.equal(events.includes("bump"), false);
+ assert.equal(r.status, 2);
+ assert.match(r.stderr, /retired on 7331/);
+ assert.match(r.stderr, /webui/);
```
### B3: control-board/tests/relaunch.test.mjs
Enclosing test (first match, old or new side): without the pid seam, a null-pid registration is undecided: kept, alive null, no relaunch
Reason: New in row 198: null-pid relaunch test. Correction: a null pid makes registeredAlive undecided (scan.mjs:372, 377, 381), not dead. The seam only affects relaunch; the default stays () => true.
```diff
+ assert.notEqual(row.registered, null);
+ assert.equal(row.registered.alive, null);
+ assert.equal(row.relaunchedAt, null);
```
### B25: webui/tests/serve.test.mjs
Enclosing test (first match, old or new side): conversation routes: nosniff, no-store, no CORS header, and every board refusal keeps its status
Reason: New in row 198: Console conversation headers test (nosniff, no-store, no CORS header) and the REFUSAL_STATUS table through the Console route.
```diff
+ assert.equal(hostile, 403);
+ assert.equal((await fetch(f.base + path, { headers: { origin: 'https://evil.example' } })).status, 403);
+ assert.equal(res.status, status, `${code} ${path}`);
+ assert.deepEqual(await res.json(), { error: code }, `${code} ${path}`);
+ assert.equal(res.headers.get('x-content-type-options'), 'nosniff', path);
+ assert.equal(res.headers.get('cache-control'), 'no-store', path);
+ assert.equal(res.headers.get('access-control-allow-origin'), null, path);
```
## Retired, not ported
One line per test name that exists on refactor and not in the current tree. Name-based check: `comm` on test names in the three files.
- `control-board/tests/serve.test.mjs`: startServer: refuses a non-loopback host with ConfigError, never opens a socket — Listener refusal. No listener remains in control-board.
- `control-board/tests/serve.test.mjs`: startServer: serves page, healthz, and a rescanning /api/board — HTTP-only page, healthz and /api/board. No listener remains; page content is not re-asserted (open gap).
- `control-board/tests/serve.test.mjs`: startServer: a seatsDir registration overrides the row and index.registered reflects it — HTTP-only seatsDir override check. Not re-asserted in this port (open gap).
- `control-board/tests/serve.test.mjs`: startServer: /api/board returns 500 JSON with an error field when scan throws — HTTP error envelope. A throwing board read is covered by the preview dead-board test (502 invalid-response, hunk 17); the 500 shape is not re-asserted.
- `control-board/tests/serve.test.mjs`: CLI: serve refuses a non-loopback host with exit 2 and a refused: message — serve is retired; the refusal is asserted by board.test.mjs 'cli serve refuses and points to the Console' (group B hunk 0).
- `control-board/tests/serve.test.mjs`: CLI: live serve prints its URL and answers /healthz — serve is retired (cli.mjs:14). The writer's HTTP healthz and page are gone; no listener remains (see Board listeners).
- `control-board/tests/serve.test.mjs`: page.html: esc() escapes every HTML-significant character — page.html is deleted. Source-shape asserts (esc, Hide seen, reply gate, source tags) have no in-process target. Console app.js coverage NOT verified in this pass (open gap).
- `control-board/tests/serve.test.mjs`: POST /api/seen marks a row; GET /api/board still shows it seen; seen:false clears it — Seen POST HTTP test removed, no port (no hunk, no phrase). markSeen write, bump, scan order is covered by board.test.mjs. Body-parsing gap below.
- `control-board/tests/serve.test.mjs`: POST /api/seen without a JSON content-type returns 400 and does not write a mark — Body-parsing gap. Console body() refuses non-JSON content-type (webui/src/serve.mjs:58) then 400 at :191. No Console test (open gap).
- `control-board/tests/serve.test.mjs`: POST /api/seen with invalid JSON returns 400 — Body-parsing gap. Console answers 400 (webui/src/serve.mjs:191). No Console test (open gap).
- `control-board/tests/serve.test.mjs`: POST /api/seen with a body over 4096 bytes returns 400 (or resets the connection) and writes no mark — Body-parsing gap. Console body() default max 4096 (webui/src/serve.mjs:56) answers 400 via :191. No Console test (open gap).
- `control-board/tests/serve.test.mjs`: POST /api/seen with a missing agent returns 400 — Body-parsing gap. Console answers 400 with the error message (webui/src/serve.mjs:197). No Console test (open gap).
- `control-board/tests/serve.test.mjs`: POST /api/board returns 405; PUT /api/seen returns 405 — Writer HTTP method refusal. Console 405s are asserted by group A hunk 21 (GET /api/reply, OPTIONS /api/board).
- `control-board/tests/serve.test.mjs`: page.html: seenControl() escapes rec.project/agent/lastActivity, and the POST uses a JSON content-type — page.html is deleted. Source-shape asserts (esc, Hide seen, reply gate, source tags) have no in-process target. Console app.js coverage NOT verified in this pass (open gap).
- `control-board/tests/serve.test.mjs`: page.html: has a collapsed Seen section that lists seen rows with the shared row builder — page.html is deleted. Source-shape asserts (esc, Hide seen, reply gate, source tags) have no in-process target. Console app.js coverage NOT verified in this pass (open gap).
- `control-board/tests/serve.test.mjs`: page.html: each project has a Hide seen checkbox (default on) beside Hide offline, with a hidden-count note — page.html is deleted. Source-shape asserts (esc, Hide seen, reply gate, source tags) have no in-process target. Console app.js coverage NOT verified in this pass (open gap).
- `control-board/tests/serve.test.mjs`: page.html: a project header reads \ — page.html is deleted. Source-shape asserts (esc, Hide seen, reply gate, source tags) have no in-process target. Console app.js coverage NOT verified in this pass (open gap).
- `control-board/tests/serve.test.mjs`: page.html: every row shows Task and Active project, derived or the word unknown, with the workspace in the detail — page.html is deleted. Source-shape asserts (esc, Hide seen, reply gate, source tags) have no in-process target. Console app.js coverage NOT verified in this pass (open gap).
- `control-board/tests/serve.test.mjs`: page.html: task and active project cells show their source via sourceTag(); the detail has a Registered row via registeredText(); SOURCE_LABEL maps registration to registered; every dynamic value in sourceTag/fromSource/registeredText is escaped — page.html is deleted. Source-shape asserts (esc, Hide seen, reply gate, source tags) have no in-process target. Console app.js coverage NOT verified in this pass (open gap).
- `control-board/tests/serve.test.mjs`: POST /api/reply: runs agent-send.sh with -s from the registration, -S <host>:control-board, -m text plus the fixed trailer, no -L on the default socket, MOSAIC_TMUX_SOCKET stripped; answers delivered with the exit code and both streams — Renamed; the phrase is still present in the current tree (rename only, no assert hunk).
- `control-board/tests/serve.test.mjs`: POST /api/reply: a registration with a tmux socket adds -L <socket> — Name not carried over; the -L socket asserts stay (hunk 9, re-indent only).
- `control-board/tests/serve.test.mjs`: POST /api/reply: a non-zero tool exit is a 200 with delivered false, the exit code and the stderr verbatim — Renamed; the phrase is still present in the current tree (rename only, no assert hunk).
- `control-board/tests/serve.test.mjs`: POST /api/reply: refusals before the tool runs: empty or blank or long text 400, unknown row 404, no registration 409, stale registration 409, no tmux session 409, bad JSON 400; the tool is never called — Name not carried over; the refusal table stays (hunk 10, re-indent only).
- `control-board/tests/serve.test.mjs`: POST /api/reply: a missing agent-send.sh is a 500 with the path in the error, not a crash — Name not carried over; the 500 and path asserts stay in the ported reply test (hunk 11).
- `control-board/tests/serve.test.mjs`: page.html: the reply box appears only where canReply() holds (live registration with a tmux session), the detail has a Reply row, the submit posts JSON to /api/reply, receipts and drafts survive a refresh, and every receipt value is escaped — page.html is deleted. Source-shape asserts (esc, Hide seen, reply gate, source tags) have no in-process target. Console app.js coverage NOT verified in this pass (open gap).
- `control-board/tests/serve.test.mjs`: startServer: /api/board carries taskSetBy from a live registration and null for the derived rows — Renamed 'index: carries taskSetBy...'; asserts ported (hunk 14).
- `control-board/tests/serve.test.mjs`: page.html: the task cell and detail show who set a registered task via setByTag()/setByText(), both escaped, only from rec.taskSetBy; the reply gate does not read it — page.html is deleted. Source-shape asserts (esc, Hide seen, reply gate, source tags) have no in-process target. Console app.js coverage NOT verified in this pass (open gap).
- `control-board/tests/serve.test.mjs`: Host/Origin guard: GET /api/board and POST /api/reply refuse a foreign Host, a wrong port and a cross-origin Origin with 403 JSON, before any scan or send, and never send CORS headers — Writer HTTP fence. control-board opens no listener (see Board listeners). Console fences at webui/src/serve.mjs:713-717 and :726 covered by the F16 test.
- `control-board/tests/serve.test.mjs`: Host/Origin guard: loopback names on this port are accepted, with or without a same-origin Origin — Writer HTTP fence; same reason as the other Host/Origin guard test.
- `control-board/tests/serve.test.mjs`: conversation routes (F16): a foreign Host, a wrong port and a cross-origin Origin get 403 before the reader runs, with no CORS headers — Writer HTTP fence. Replaced by the Console F16 test (webui serve.test.mjs, 'conversation route: a non-local Host or a foreign Origin is refused 403').
- `control-board/tests/serve.test.mjs`: conversation routes: a reader listing that refuses gets its refusal status and body, not a 200 — Renamed 'conversation listing: ...' and ported to board.conversation (hunk 15). HTTP header asserts moved to the Console test (group B hunk 25).
- `control-board/tests/serve.test.mjs`: conversation routes: catalogue, first page, next page and follow over HTTP; refusals map to 4xx with their code; nothing is written — HTTP-only catalogue and paging. No in-process port; page-content asserts are not re-asserted (open gap).
- `control-board/tests/discord.test.mjs`: server rescans connector discovery and refuses HTTP reply without transport — Renamed in place to 'board rescans connector discovery and refuses reply without transport'; HTTP fetch and 409 ported to board.index()/board.reply() (hunks 1-2).
- `webui/tests/serve.test.mjs`: loopback host and board origin fail closed — boardURL() removed with the HTTP board address (hunk 19).
- `webui/tests/serve.test.mjs`: proxy preserves exact request bytes, status and receipt, rejects forms and malformed JSON, never follows redirect — Writer HTTP proxy test; the Console has no proxy hop in-process. Changed and weakened asserts in hunks 20-23 (see Weakened).
- `webui/tests/serve.test.mjs`: unreachable board reports URL; CLI rejects unsupported options — No board URL to report in-process; replaced by 503 not-configured asserts (hunk 24).
## Weakened and changed asserts (for Mos)
- Hunk 20: 409 becomes 403 (behaviour change, see below); the oversized-body 400 is dropped.
- Hunk 21: the text/plain 400 and the request-count and board-502 checks are dropped.
- Hunk 22: exact 404 body text becomes a table comparison.
- Hunk 23: exact 'refused before the board' check becomes status >= 400.
## Behaviour changes to report
1. POST /api/reply in the Console: 403 when send is off or the seat is unnamed (webui/src/serve.mjs ~843-846). The refactor forwarded the request unconditionally (hunk 20).
2. /api/settings reports `board: null` in-process (hunk 18).
3. A dead board read returns 502 `invalid-response` (hunk 17).
4. Null-pid correction: a null pid is undecided (scan.mjs:372, 377, 381), not dead. The default `() => true` only matters for relaunch, and the new relaunch test (group B hunk 3) covers that.
5. creative-hidden.test.mjs: ported from `startServer` to `createBoard` and `board.conversation`. Assertions unchanged. `startServer` is no longer exported by control-board/src/serve.mjs.
## Board listeners after 198
Command: `grep -rn "createServer\|\.listen(\|startServer\|startBoard" packages/control-board/src`
Result: no matches (exit 1). control-board/src opens no listener.
CLI entry: packages/control-board/src/cli.mjs:14 refuses `serve` with `ConfigError` (RETIRED message, line 11). No listener.
Not in the board's code, for completeness: the Console (packages/webui/src/serve.mjs:1 imports `createServer`; :684 creates the server; :909 listens) hosts the board in-process. packages/webui/src/tester-socket.mjs:24 listens on a separate tester socket, not the board.
So the Host/Origin tests in control-board are retired on this basis. The Console's Host and Origin fences are covered by the F16 test in webui/tests/serve.test.mjs.
## Open items (not closed in this pass)
- page.html source checks are retired with no verified Console equivalent in app.js (open gap).
- Seen POST body parsing (400 for invalid JSON, missing agent, 4096 limit, non-JSON content-type) has no board test and no Console test. Gap recorded; no new test added (sage-4373).
- Catalogue and paging over HTTP, seatsDir registration override, and the 500 shape are not re-asserted (see retire list).
- Not run: chat-replies-browser (line 71 `startServer({ port: 0, board: f.board })`) and the shell 'board unavailable' text (ReadError message path, serve.mjs:230-234). Verify on CLEAR.
- The webui F16 test (serve.test.mjs:93) is a new test; the hunk map does not isolate it as its own hunk. Confirm its add lines are in group B hunk 25's range.
- webui/tests/discord.test.mjs needs no isPidAlive seam: its registration uses pid `process.pid`.
- conversation/tests/flows.test.mjs imports replyToRow from control-board/src/serve.mjs; the export still exists. Not run.
- Syntax: `node --check` passes for all touched JS files (no tests run).
+151
View File
@@ -0,0 +1,151 @@
# Row 211 (#1709, Vikunja P19 #426): a pre-push hook that refuses force-pushes, and a busy check that fails on BUSY
Darkwing, 2026-10-22. Brief: `docs/plans/2026-10-20_force-push-guard.md` (blob 3a8c2cf13). Moved from Rocko
to me by sage-2801 (Q220, mos-1301). Base: 78b69e1e6 (r2; r1 was aca16404c). Branch: `darkwing/row211-r2`
(r1 `darkwing/row211` 6db25b1f0 stays as pushed). Reviewer: Dewey. Gate owner: Sage.
## What ships
- `scripts/git-hooks/pre-push` (new, `/bin/sh`). For each ref line on stdin it refuses:
- a delete (local sha all zeros);
- a remote tip that isn't in this repository (`git cat-file -e <remote>^{commit}`), with "fetch first,
then push again";
- a remote tip that isn't an ancestor of the local tip (`git merge-base --is-ancestor`), naming the ref
and both shas;
- an ancestor check that fails for any other reason (exit above 1);
- a short or overlong ref line, or a non-hex object name.
A new ref (remote sha all zeros) passes, and so does an empty push. It fetches nothing. On any refusal
it prints one line per refused ref and a closing line, and exits 1, so git pushes no ref at all.
- `scripts/install-git-hooks.sh` (new). Installs the hook as `pre-push` in the common `.git/hooks`
(`git rev-parse --git-common-dir`), so a `--repo` naming a worktree lands in the shared directory.
It refuses (exit 2):
- a set `core.hooksPath` at any scope;
- a different installed `pre-push`, unless `--force-install` is given;
- a symlinked `pre-push`, even with `--force-install`;
- any of `GIT_DIR`, `GIT_WORK_TREE`, `GIT_COMMON_DIR`, `GIT_INDEX_FILE`, `GIT_OBJECT_DIRECTORY` or
`GIT_ALTERNATE_OBJECT_DIRECTORIES` in the environment;
- a directory that isn't a git checkout.
It never writes the installed file in place. A new hook goes in by `link()` from a temporary copy, so
a hook that appeared meanwhile is kept and the install refuses. A replacement goes in by `rename()`.
Then it compares the bytes, checks the mode, and runs a canary through `git hook run` (git 2.55 here):
an empty push must pass and a delete line must be refused with the guard's message.
- `scripts/slot-busy.sh` (new). Exit 0 CLEAR, 1 BUSY, 2 its own error, one `BUSY <pid> <rule> cwd=<cwd>
<command>` line per match. Rules: `node ... --test`, `*gate*.sh` (after a space, `/`, `_` or `-`), a
`mutant` substring, `test-*.sh`, `docker compose` and `docker-compose`. It skips its own pid and its
ancestors, empty command lines (kernel threads) and shell-snapshot wrappers.
- `scripts/test-git-guard.sh` (new), 61 cases, and three Maintenance rows plus a short section in
`docs/TOOLS.md`.
## Choices
1. **A separate `scripts/install-git-hooks.sh`, not `queue-commit.sh --install-push-hook`.** The brief
left this open. `queue-commit.sh --install-hook` is the queue guard's installer. It is tied to the
canonical checkout and to the lead (jason/sage) and runs the queue guard's canary. The push guard has
neither tie: it must also be installable in a scratch clone for the tests, and every seat worktree
shares it. Putting it in `queue-commit.sh` would widen that script's reach for no gain. The cost is a
second installer with a similar shape. The install pattern (copy beside, link or rename, verify,
canary) follows `queue-commit.sh`.
2. **No privilege check in the installer.** The brief makes the canonical install a separate step with
Sage after landing, and the tests need to install into scratch clones as the suite's user. So the
script installs wherever it is told. The guard is advisory on the client in any case (`--no-verify`).
3. **The hook source is the working file next to the installer** (`$ROOT/scripts/git-hooks/pre-push`),
not a blob from a ref. That lets the mutant runner install a mutated copy from its own tree, and
matches how `queue-commit.sh` reads its guard.
4. **A new `scripts/test-git-guard.sh`, not more cases in `test-land-work.sh`.** The brief allowed either.
Land-work's suite is about `land-work.sh`, and the gate runs every `scripts/test-*.sh`, so a new file
gets gated with no wiring.
5. **`--proc-root DIR` is a test seam in `slot-busy.sh`.** On a live host the real `/proc` may hold
other seats' runs at any time, so the suite can't demand exit 0 there. Exit 0 and every rule are
tested against a fake `/proc` with pids above `pid_max` (4194304), so they can't collide with the
ancestor set, which always comes from the real `/proc`. The real `/proc` is tested with a stand-in:
a `/bin/sh` script named `node`, started as `node --test standin.test.mjs` in its own cwd. The suite
asserts its BUSY line with that cwd, exit 1, that `slot-busy > /dev/null && echo ran` prints nothing,
that the suite itself (an ancestor whose command line matches `test-*.sh`) has no BUSY line, and that
after the stand-in exits its pid is gone. The brief's "0 without one" is the fake-`/proc` case.
6. **Ancestors are skipped**, not only its own pid as the brief says. A gate script that runs the busy
check matches its own rules (`*gate*.sh`, `test-*.sh`, `mutant`), so checking only `$$` would make
every such caller see itself as BUSY.
7. **The gate rule is broader than `gate.sh`**: `[ /_-]gate[^ /]*\.sh `, so `sage-gate.sh` and
`gate-253.sh` match too. A false BUSY costs a wait; a false CLEAR costs a broken run.
8. **Shell-snapshot wrappers are skipped.** An agent harness runs each command as `bash -c 'source
.../shell-snapshots/snapshot-*.sh && eval <command text>'`, so the wrapper's command line holds the
text of whatever the agent typed, including a busy check that names `test-*.sh`. The real child
processes the wrapper starts are still matched.
## Tests (`scripts/test-git-guard.sh`)
Scratch bare remote, a scratch clone with the guard installed, and a worktree of that clone. Git runs
with `GIT_CONFIG_NOSYSTEM=1` and an empty `GIT_CONFIG_GLOBAL`, as in `test-land-work.sh`. Nothing touches
origin, `~/.mosaic`, `~/.mosaic-dev` or `~/.config/mosaic-dev`.
- Install: exit 0, the canary passed, bytes and mode, no temporary file left, and the worktree's git
resolves `hooks/pre-push` to the common file.
- From the worktree: a new branch and a fast-forward pass. A `--force` push of a rewritten branch, a
`+refspec` push and a `--force-with-lease` push are refused by the guard's message, naming the ref and
both shas, and the remote ref is unchanged. `:ref` and `--delete` are refused and the branch stays.
- A second clone moves the remote; a `--force` push from the worktree is then refused with "fetch
first", the remote stays where the other clone put it, and the guard fetched nothing.
- The hook fed by hand: a short line, a non-hex name and an unknown local object are refused; an empty
push passes.
- The installer: `core.hooksPath` set refuses with that message and writes no hook (in `.git/hooks` or
the hooks path). A different hook refuses and stays byte-identical. `--force-install` replaces it, and
a hard link to the old file keeps the old bytes, so the replacement was a rename. A second install of
the same bytes exits 0. A symlinked hook refuses even with `--force-install`. A `--repo` naming a
worktree lands in the common directory. `GIT_DIR` set, a plain directory and an unknown argument
refuse.
- `slot-busy.sh`, fake `/proc`: CLEAR with exit 0 when nothing matches (a server, a `grep gate`, a
snapshot wrapper whose text holds `node --test`, an empty command line). Exit 1 and the BUSY line for
`node --test`, with no CLEAR in the output, and `> /dev/null && echo ran` prints nothing. Each rule
once more (7 cases). Exit 2 for a missing root, an empty root and an unknown argument.
- `slot-busy.sh`, real `/proc`: the stand-in cases in choice 5.
## Mutants (`agents/darkwing/work/queue-211/mutants.sh`)
Each mutant copies the four scripts into its own tree, edits one site there (exactly one match), and runs
that tree's suite under `timeout -k 10 180`. The worktree's files are never edited. The unmutated suite
runs first and a red base stops the run. The brief's five come first:
| Mutant | Site |
|---|---|
| ancestor-dropped | pre-push: `rc=0` in place of the `merge-base --is-ancestor` call |
| unknown-object-passes | pre-push: the missing-remote-tip branch skips the ref without refusing |
| delete-passes | pre-push: the delete branch skips the ref without refusing |
| install-over-hookspath | installer: the `core.hooksPath` refusal is `true \|\| die` |
| busy-exits-0 | slot-busy: `exit 0` when BUSY |
| differing-hook-replaced | installer: a different hook is replaced without `--force-install` |
| replace-in-place | installer: the replacement writes into the installed file (`cat >`) instead of renaming |
| symlink-followed | installer: no symlink refusal |
| ancestor-error-passes | pre-push: an ancestor check exiting above 1 doesn't refuse |
| snapshot-matched | slot-busy: no shell-snapshot skip |
| ancestors-matched | slot-busy: no ancestor skip |
| no-node-rule | slot-busy: the `node --test` rule removed |
| no-compose-rule | slot-busy: the `docker compose` rule removed |
`install-over-hookspath` still exits 2, because its canary then fails (git looks for the hook in the hooks
path). The suite kills it on the message and on "no hook was written", not on the exit code.
## Rebase r2 (sage-4362, 2026-10-24)
r1 6db25b1f0 sat on aca16404c, 555 commits behind refactor, and overlapped it in `docs/TOOLS.md`. r2
cherry-picks it onto 78b69e1e6 as a new commit on a new branch; no force-push. The TOOLS.md hunk merged
without a conflict, `git patch-id --stable` is the same for both commits (db451e11), and the four scripts,
`mutants.sh` and this file's other text are byte-identical to r1, so no assertion or reviewed line
changed. On the new base none of the six paths existed before, `scripts/git-hooks/` holds only the queue
guard's `pre-commit` (untouched here), and no test reads or pins `docs/TOOLS.md`.
## Runs
Pending: nothing has run yet. Gander held the gate slot (row 253 run 5, from 16:32Z) while this was
written, so only `bash -n`, `sh -n` and a `DRY=1` mutant pass (every site matched once, no suite run)
happened. The suite, the mutants and every other `scripts/test-*.sh` run in a CLEAR Sage announces, with
`TMPDIR` under `/mnt/storage/scratch/tmp`. Results and the measured suite time go here before review.
## Known limits
- `git push --no-verify` skips the hook. Gitea branch protection (ops-01, through Mos) is the guard that
holds.
- The hook doesn't check tags differently from branches: a moved tag is refused like a rewritten branch.
- `slot-busy.sh` can call a process BUSY that isn't a run, for example an editor with a `test-*.sh` open.
It never reads files under a seat's directory, so it can't tell.
+74
View File
@@ -0,0 +1,74 @@
#!/usr/bin/env bash
# Row 211 (#426) mutants. Run from the candidate worktree's root:
# TMPDIR=<scratch dir> LOG=<dir> bash agents/darkwing/work/queue-211/mutants.sh > mutants.txt
# Each mutant copies the four row-211 scripts into its own tree, edits one
# site there (exactly one match, else NO MATCH), and runs that tree's
# scripts/test-git-guard.sh under a 180 s timeout. The worktree's files are
# never edited. "killed" means the suite failed; "survived" means it passed.
# The unmutated suite runs first, and a red base stops the run (exit 3).
set -u
LOG=${LOG:-.}
FILES="scripts/git-hooks/pre-push scripts/install-git-hooks.sh scripts/slot-busy.sh scripts/test-git-guard.sh"
WORK=$(mktemp -d "${TMPDIR:-/tmp}/r211-mutants.XXXXXX") || exit 2
trap 'rm -rf "$WORK"' EXIT
tree() { # $1: name. Prints the copy's root.
local t="$WORK/$1" f
for f in $FILES; do mkdir -p "$t/$(dirname "$f")" && cp -p -- "$f" "$t/$f" || return 1; done
echo "$t"
}
suite() { # $1: tree, $2: log
timeout -k 10 180 bash "$1/scripts/test-git-guard.sh" > "$2" 2>&1
}
edit() { # $1: file, $2: from, $3: to. Exit 3 unless $2 occurs exactly once.
python3 -B - "$1" "$2" "$3" <<'PY'
import sys
path, old, new = sys.argv[1:4]
text = open(path).read()
if text.count(old) != 1:
sys.exit(3)
open(path, "w").write(text.replace(old, new))
PY
}
base=$(tree base) || exit 2
if [ -n "${DRY:-}" ]; then
echo "base: not run (DRY)"
elif suite "$base" "$LOG/mut-base.log"; then
echo "base: green ($(tail -1 "$LOG/mut-base.log"))"
else
echo "base: RED, stopping ($(tail -1 "$LOG/mut-base.log"))"
exit 3
fi
run() { # $1: name, $2: file, $3: from, $4: to
local name=$1 file=$2 t log
t=$(tree "$name") || { echo "$name: COPY FAILED"; return; }
if ! edit "$t/$file" "$3" "$4"; then echo "$name: NO MATCH"; return; fi
if [ -n "${DRY:-}" ]; then echo "$name: match"; return; fi
log="$LOG/mut-$name.log"
if suite "$t" "$log"; then
echo "$name: survived"
else
echo "$name: killed (exit $?, $(grep -c '^FAIL' "$log") FAIL; first: $(grep -m1 '^FAIL' "$log" | cut -c6-100))"
fi
}
H=scripts/git-hooks/pre-push
I=scripts/install-git-hooks.sh
S=scripts/slot-busy.sh
# The brief's five.
run ancestor-dropped $H $' git merge-base --is-ancestor "$rsha" "$lsha" 2>/dev/null\n rc=$?' ' rc=0'
run unknown-object-passes $H $' refuse "$rref: the remote tip $rsha isn\'t in this repository; fetch first, then push again"\n continue' ' continue'
run delete-passes $H $' refuse "$rref: a delete (remote $rsha); seat branches are never deleted by push"\n continue' ' continue'
run install-over-hookspath $I '[ -z "$out" ] || die 2 "refused: core.hooksPath' '[ -z "$out" ] || true || die 2 "refused: core.hooksPath'
run busy-exits-0 $S 'if [ "$busy" = 1 ]; then exit 1; fi' 'if [ "$busy" = 1 ]; then exit 0; fi'
# Further sites the suite pins.
run differing-hook-replaced $I '[ "$FORCE" = 1 ] || die 2 "refused: a different' 'true || die 2 "refused: a different'
run replace-in-place $I 'mv -f -- "$tmp" "$HOOK" ||' 'cat -- "$tmp" > "$HOOK" && rm -f -- "$tmp" ||'
run symlink-followed $I 'if [ -L "$HOOK" ]; then die 2' 'if false; then die 2'
run ancestor-error-passes $H ' *) refuse "$rref: the ancestor check failed' ' *) : "$rref: the ancestor check failed'
run snapshot-matched $S $' if [[ "$c" == *"/shell-snapshots/snapshot-"* ]]; then return 1; fi\n' ''
run ancestors-matched $S ' [ -z "${SKIP[$pid]:-}" ] || continue' ' :'
run no-node-rule $S 'if [[ "$c" =~ $RE_NODE ]]; then echo "node --test"; return 0; fi' ':'
run no-compose-rule $S 'if [[ "$c" =~ $RE_COMPOSE ]]; then echo "docker compose"; return 0; fi' ':'
+29
View File
@@ -166,8 +166,37 @@ resolved through the CLI.
| `scripts/test-conductor.sh` | Auto-apply selftests (sandboxed) | 17 cases |
| `scripts/test-auth.sh` | Auth checkpoint selftests (no Docker) | 13 cases |
| `scripts/test-land-work.sh` | Land-work selftests (sandboxed, fake Docker) | 84 cases |
| `scripts/test-git-guard.sh` | Push guard, its installer and `slot-busy.sh` (scratch bare remote, fake `/proc`) | 61 cases |
| `scripts/install-git-hooks.sh [--repo DIR] [--force-install]` | Install the push guard as `pre-push` in the common `.git/hooks` | Refuses a set `core.hooksPath` or a different installed hook; exit 0 ok · 1 failed · 2 refused · 4 usage |
| `scripts/slot-busy.sh` | Busy check: one `BUSY` line per gate or test process, or `CLEAR` | Exit 0 CLEAR · 1 BUSY · 2 error; chain on the exit code |
| `scripts/gitea-api.sh <METHOD> <path> [body]` | Gitea API helper; reads the JSON credential file or a raw per-seat token file | Token never on argv/stdout |
### Push guard and busy check (row 211, #426)
`scripts/git-hooks/pre-push` refuses a push that deletes a branch, or that
moves a branch to a commit that doesn't descend from the remote tip (a
`--force`, `--force-with-lease` or `+refspec` push of a rewritten branch). A
new branch and a fast-forward pass. The hook fetches nothing: when the remote
tip isn't in the local repository it refuses and says to fetch first.
`git push --no-verify` skips it, so Gitea's branch protection (ops-01) is the
guard that holds. `scripts/install-git-hooks.sh` copies it into the common
`.git/hooks`, which every worktree of the checkout shares, then runs a canary
through `git hook run`. Installing it in the canonical checkout is a step for
the lead after the row lands.
Chain the busy check on its exit code, and set `pipefail` in any script that
pipes it:
```bash
bash scripts/slot-busy.sh > "$LOG" && gate
```
Never `bash scripts/slot-busy.sh | tail -1 && gate`: `tail` exits 0, so the
gate runs while BUSY. It matches `node ... --test`, a `*gate*.sh`, `mutant`, a
`test-*.sh` and `docker compose` command lines, skips itself, its ancestors
and agent shell-snapshot wrappers, and reads only `/proc/<pid>/cmdline` and
the cwd link.
## Seat launch with registration (`scripts/mosaic`)
```bash
+50
View File
@@ -0,0 +1,50 @@
#!/bin/sh
# Mosaic push guard (row 211, #426). Installed as pre-push in the common
# .git/hooks by `scripts/install-git-hooks.sh`; never edit the installed copy.
# For each ref git is about to push, it refuses a delete and any update whose
# remote tip isn't an ancestor of the local tip: a force-push or a +refspec.
# A new ref passes. It fetches nothing, so a remote tip that isn't in this
# repository refuses. Any failure of the check refuses too.
# `git push --no-verify` skips this hook; Gitea's branch protection is the
# guard that holds (ops-01). Canon: never force-push.
refused=0
refuse() {
echo "mosaic push guard: refused: $*" >&2
refused=1
}
zero() {
case "$1" in *[!0]*) return 1 ;; esac
return 0
}
while read -r lref lsha rref rsha extra || [ -n "${lref:-}" ]; do
if [ -z "${rsha:-}" ] || [ -n "${extra:-}" ]; then
refuse "unreadable ref line from git: $lref ${lsha:-} ${rref:-} ${rsha:-} ${extra:-}"
continue
fi
case "$lsha$rsha" in
*[!0-9a-f]*) refuse "$rref: a ref line with a non-hex object name (local $lsha, remote $rsha)"; continue ;;
esac
if zero "$lsha"; then
refuse "$rref: a delete (remote $rsha); seat branches are never deleted by push"
continue
fi
if zero "$rsha"; then
continue
fi
if ! git cat-file -e "$rsha^{commit}" 2>/dev/null; then
refuse "$rref: the remote tip $rsha isn't in this repository; fetch first, then push again"
continue
fi
git merge-base --is-ancestor "$rsha" "$lsha" 2>/dev/null
rc=$?
case $rc in
0) ;;
1) refuse "$rref: not a fast-forward; remote $rsha isn't an ancestor of local $lsha (a force-push or a rewritten branch)" ;;
*) refuse "$rref: the ancestor check failed (exit $rc) for remote $rsha and local $lsha" ;;
esac
done
if [ "$refused" != 0 ]; then
echo "mosaic push guard: nothing was pushed; a pushed branch only moves forward (canon: never force-push)" >&2
exit 1
fi
exit 0
+99
View File
@@ -0,0 +1,99 @@
#!/usr/bin/env bash
# install-git-hooks.sh: install the push guard (row 211, #426) as pre-push in
# a repository's common .git/hooks, which every linked worktree shares.
#
# Usage:
# scripts/install-git-hooks.sh [--repo DIR] [--force-install]
#
# DIR defaults to this script's own checkout. The hook's bytes are this
# checkout's scripts/git-hooks/pre-push. It refuses when core.hooksPath is set
# (git would not run the hook), and when a different pre-push is installed,
# unless --force-install is given. It never edits an installed hook in place:
# a new hook is linked in, a replacement is renamed over the old one. A canary
# then runs the installed hook through git and needs a delete refused.
#
# Exit codes: 0 installed (or already the same bytes); 1 failed; 2 refused;
# 4 usage. Installing in the canonical checkout is its own step with the lead.
set -uo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd -P)"
SRC="$ROOT/scripts/git-hooks/pre-push"
TMPD=""
say() { printf 'install-git-hooks: %s\n' "$*" >&2; }
die() { local code=$1; shift; say "$*"; exit "$code"; }
cleanup() { if [ -n "$TMPD" ]; then rm -rf -- "$TMPD"; fi; }
trap cleanup EXIT
usage() {
sed -n '5,6p' "${BASH_SOURCE[0]}" | sed 's/^# \{0,1\}//' >&2
exit 4
}
REPO=$ROOT
FORCE=0
while [ $# -gt 0 ]; do
case "$1" in
--repo) [ $# -ge 2 ] || usage; REPO=$2; shift 2 ;;
--force-install) FORCE=1; shift ;;
-h|--help) usage ;;
*) say "unknown argument: $1"; usage ;;
esac
done
for v in GIT_DIR GIT_WORK_TREE GIT_COMMON_DIR GIT_INDEX_FILE GIT_OBJECT_DIRECTORY GIT_ALTERNATE_OBJECT_DIRECTORIES; do
if [ -n "${!v+x}" ]; then die 2 "refused: $v is set; run with the normal git environment"; fi
done
[ -f "$SRC" ] && [ ! -L "$SRC" ] || die 2 "refused: $SRC is missing or not a regular file"
g() { git -C "$REPO" "$@"; }
top=$(g rev-parse --show-toplevel 2>/dev/null) || die 2 "refused: $REPO is not a git checkout"
common=$(g rev-parse --path-format=absolute --git-common-dir) || die 1 "cannot read $REPO's git common directory"
HOOKS="$common/hooks"
HOOK="$HOOKS/pre-push"
out=$(g config --show-scope --get-all core.hooksPath 2>/dev/null)
[ -z "$out" ] || die 2 "refused: core.hooksPath is set ($(printf '%s' "$out" | tr '\n\t' '; ')), so git would not run the push guard; unset it first"
if [ -L "$HOOK" ]; then die 2 "refused: $HOOK is a symlink; remove it by hand if it is not wanted"; fi
mkdir -p "$HOOKS" || die 1 "cannot create $HOOKS"
tmp="$HOOKS/.pre-push.install-git-hooks.$$"
if [ -e "$HOOK" ]; then
[ -f "$HOOK" ] || die 2 "refused: $HOOK is not a regular file"
if cmp -s "$SRC" "$HOOK"; then
chmod 0755 "$HOOK" || die 1 "chmod failed"
note="the same bytes were already there"
else
[ "$FORCE" = 1 ] || die 2 "refused: a different pre-push hook is installed at $HOOK; compare it with $SRC, and pass --force-install to replace it"
cp -- "$SRC" "$tmp" && chmod 0755 "$tmp" || { rm -f -- "$tmp"; die 1 "cannot write $tmp"; }
# rename() swaps the whole file; the old hook's bytes are never edited.
mv -f -- "$tmp" "$HOOK" || { rm -f -- "$tmp"; die 1 "cannot replace $HOOK"; }
note="replaced a different hook (--force-install)"
fi
else
cp -- "$SRC" "$tmp" && chmod 0755 "$tmp" || { rm -f -- "$tmp"; die 1 "cannot write $tmp"; }
# link() never replaces, so a hook that appeared meanwhile is kept.
if ! ln -- "$tmp" "$HOOK" 2>/dev/null; then rm -f -- "$tmp"; die 2 "refused: a pre-push hook appeared at $HOOK while installing"; fi
rm -f -- "$tmp"
note="new"
fi
cmp -s "$SRC" "$HOOK" || die 1 "$HOOK differs from $SRC after install"
[ -x "$HOOK" ] || die 1 "$HOOK is not executable after install"
# The canary: git runs the hook it would run for a push. A delete line needs
# no objects; the guard must refuse it, and pass an empty push.
TMPD=$(mktemp -d "${TMPDIR:-/tmp}/install-git-hooks.XXXXXX") || die 1 "mktemp failed"
z=0000000000000000000000000000000000000000
printf '(delete) %s refs/heads/install-canary 1%s\n' "$z" "${z#0}" > "$TMPD/delete"
: > "$TMPD/empty"
if ! out=$(cd "$top" && git hook run --to-stdin="$TMPD/empty" pre-push -- canary canary 2>&1); then
die 2 "refused: the canary's empty push failed, so git is not running the push guard as installed: $out"
fi
out=$(cd "$top" && git hook run --to-stdin="$TMPD/delete" pre-push -- canary canary 2>&1); rc=$?
[ "$rc" -ne 0 ] && [[ "$out" == *"mosaic push guard: refused"* ]] \
|| die 2 "refused: the canary's delete was not refused by the push guard (exit $rc)"
printf 'ok installed the push guard at %s (%s, sha256 %s, mode 0755); canary passed\n' \
"$HOOK" "$note" "$(sha256sum < "$HOOK" | cut -d' ' -f1)"
exit 0
+77
View File
@@ -0,0 +1,77 @@
#!/usr/bin/env bash
# slot-busy.sh: the shared busy check (row 211, #426). Is a gate or a test run
# going on this host?
#
# Usage:
# scripts/slot-busy.sh [--proc-root DIR]
#
# It prints one BUSY line per matching process, or one CLEAR line. Exit codes:
# 0 CLEAR, 1 BUSY, 2 its own error. Chain it on the exit code, never on a
# filter of its output:
# bash scripts/slot-busy.sh > LOG && gate (set -o pipefail if piped)
# never `bash scripts/slot-busy.sh | tail -1 && gate`, which runs while BUSY.
#
# A process matches when its command line holds `node ... --test`, a
# `*gate*.sh`, `mutant`, a `test-*.sh` or `docker compose`/`docker-compose`.
# It skips itself and its ancestors (a gate script may run the check), and an
# agent harness's shell-snapshot wrapper, whose command line is the agent's
# command text; that shell's real children are still matched. It reads only
# /proc/<pid>/cmdline and the cwd link, never a file under a seat's directory.
# --proc-root names a stand-in for /proc in the selftests.
set -uo pipefail
PROC=/proc
while [ $# -gt 0 ]; do
case "$1" in
--proc-root) [ $# -ge 2 ] || { echo "slot-busy: error: --proc-root needs a directory" >&2; exit 2; }; PROC=$2; shift 2 ;;
-h|--help) sed -n '5,6p' "${BASH_SOURCE[0]}" | sed 's/^# \{0,1\}//' >&2; exit 2 ;;
*) echo "slot-busy: error: unknown argument: $1" >&2; exit 2 ;;
esac
done
[ -d "$PROC" ] || { echo "slot-busy: error: $PROC is not a directory" >&2; exit 2; }
# This process and its ancestors, from the real /proc.
declare -A SKIP=()
p=$$
while [ -n "$p" ] && [ "$p" != 0 ]; do
SKIP[$p]=1
p=$(awk '/^PPid:/{print $2; exit}' "/proc/$p/status" 2>/dev/null) || p=""
done
[ "${#SKIP[@]}" -ge 1 ] && [ -n "${SKIP[$$]:-}" ] || { echo "slot-busy: error: cannot read its own process" >&2; exit 2; }
RE_NODE='[ /]node[^ ]* (.* )?--test '
RE_GATE='[ /_-]gate[^ /]*\.sh '
RE_TEST='[ /]test-[^ /]*\.sh '
RE_COMPOSE='[ /]docker( compose|-compose) '
match() { # $1: the command line, NULs as spaces. Prints the rule that matched.
local c=" $1 "
if [[ "$c" == *"/shell-snapshots/snapshot-"* ]]; then return 1; fi
if [[ "$c" =~ $RE_NODE ]]; then echo "node --test"; return 0; fi
if [[ "$c" =~ $RE_GATE ]]; then echo "gate"; return 0; fi
if [[ "$c" == *mutant* ]]; then echo "mutant"; return 0; fi
if [[ "$c" =~ $RE_TEST ]]; then echo "test script"; return 0; fi
if [[ "$c" =~ $RE_COMPOSE ]]; then echo "docker compose"; return 0; fi
return 1
}
seen=0
busy=0
for d in "$PROC"/[0-9]*; do
[ -d "$d" ] || continue
pid=${d##*/}
[[ "$pid" =~ ^[0-9]+$ ]] || continue
seen=$((seen + 1))
[ -z "${SKIP[$pid]:-}" ] || continue
# A process can exit between the listing and the read; that's not an error.
cmd=$(tr '\0' ' ' < "$d/cmdline" 2>/dev/null) || continue
cmd=${cmd% }
[ -n "$cmd" ] || continue
rule=$(match "$cmd") || continue
cwd=$(readlink "$d/cwd" 2>/dev/null) || cwd="?"
printf 'BUSY %s %s cwd=%s %s\n' "$pid" "$rule" "$cwd" "${cmd:0:200}"
busy=1
done
[ "$seen" -ge 1 ] || { echo "slot-busy: error: no processes under $PROC" >&2; exit 2; }
if [ "$busy" = 1 ]; then exit 1; fi
echo "CLEAR"
exit 0
+257
View File
@@ -0,0 +1,257 @@
#!/usr/bin/env bash
# Sandboxed selftests for the push guard, its installer and the busy check
# (row 211, #426): scripts/git-hooks/pre-push, scripts/install-git-hooks.sh
# and scripts/slot-busy.sh.
#
# Pushes go from a scratch clone and its worktree to a scratch bare remote,
# never to origin. Git sees no user or system config. The busy check runs
# against a fake /proc (pids above pid_max) for the exit codes and rules, and
# against the real /proc with a sleeping stand-in whose argv is `node --test`.
set -uo pipefail
cd "$(dirname "$0")/.."
REPO="$(pwd)"
unset GIT_DIR GIT_WORK_TREE GIT_COMMON_DIR GIT_INDEX_FILE GIT_OBJECT_DIRECTORY GIT_ALTERNATE_OBJECT_DIRECTORIES
SANDBOX="$(cd "$(mktemp -d)" && pwd -P)"
STANDIN=""
cleanup() {
if [ -n "$STANDIN" ]; then kill "$STANDIN" 2>/dev/null; wait "$STANDIN" 2>/dev/null; fi
rm -rf "$SANDBOX"
}
trap cleanup EXIT
PASS=0
FAIL=0
# Status colors: terminal-only, NO_COLOR-respecting; plain when piped.
if [ -t 1 ] && [ -z "${NO_COLOR:-}" ]; then
C_OK=$'\033[0;32m'; C_FAIL=$'\033[0;31m'; C_RESET=$'\033[0m'
else
C_OK=""; C_FAIL=""; C_RESET=""
fi
check() {
if [ "$2" = "0" ]; then PASS=$((PASS+1)); echo "${C_OK}OK${C_RESET} $1"; else FAIL=$((FAIL+1)); echo "${C_FAIL}FAIL${C_RESET} $1"; fi
}
has() { [[ "$1" == *"$2"* ]]; }
# Git sees no user or system config: no hooks path, no signing, a fixed identity.
: > "$SANDBOX/gitconfig"
export GIT_CONFIG_NOSYSTEM=1 GIT_CONFIG_GLOBAL="$SANDBOX/gitconfig"
export GIT_AUTHOR_NAME=suite GIT_AUTHOR_EMAIL=suite@local GIT_COMMITTER_NAME=suite GIT_COMMITTER_EMAIL=suite@local
HOOK_SRC="$REPO/scripts/git-hooks/pre-push"
INSTALL="$REPO/scripts/install-git-hooks.sh"
BUSY="$REPO/scripts/slot-busy.sh"
B="$SANDBOX/remote.git"
C="$SANDBOX/clone"
W="$SANDBOX/wt"
O="$SANDBOX/other"
commit() { # $1: repo, $2: file and message
echo "$2" > "$1/$2" && git -C "$1" add "$2" && git -C "$1" commit -q -m "$2"
}
rtip() { git -C "$B" rev-parse -q --verify "refs/heads/$1"; }
# ---- the remote, a clone with the guard, a worktree ----
git init -q --bare -b main "$B"
git init -q -b main "$SANDBOX/seed"
commit "$SANDBOX/seed" base
git -C "$SANDBOX/seed" push -q "$B" main
git clone -q "$B" "$C"
echo "== install =="
out=$(bash "$INSTALL" --repo "$C" 2>&1); rc=$?
check "the install into a clean clone exits 0" "$rc"
check "it reports the install and a passed canary" "$(has "$out" "ok installed the push guard" && has "$out" "canary passed"; echo $?)"
check "the hook is in the common .git/hooks, with the repository's bytes, executable" \
"$(cmp -s "$HOOK_SRC" "$C/.git/hooks/pre-push" && [ -x "$C/.git/hooks/pre-push" ]; echo $?)"
check "no temporary file is left in .git/hooks" \
"$(ls -A "$C/.git/hooks" | grep -q '^\.pre-push\.install'; [ $? = 1 ]; echo $?)"
git -C "$C" worktree add -q -b seat/w "$W" origin/main
check "the worktree's git runs the common hook" \
"$([ "$(git -C "$W" rev-parse --path-format=absolute --git-path hooks/pre-push)" = "$C/.git/hooks/pre-push" ]; echo $?)"
echo "== pushes from the worktree =="
commit "$W" one
out=$(git -C "$W" push origin seat/w 2>&1); rc=$?
check "a new branch passes" "$rc"
check "the remote branch is at the pushed commit" "$([ "$(rtip seat/w)" = "$(git -C "$W" rev-parse HEAD)" ]; echo $?)"
commit "$W" two
out=$(git -C "$W" push origin seat/w 2>&1); rc=$?
check "a fast-forward passes" "$rc"
check "the remote branch moved forward" "$([ "$(rtip seat/w)" = "$(git -C "$W" rev-parse HEAD)" ]; echo $?)"
before=$(rtip seat/w)
git -C "$W" reset -q --hard HEAD~1
commit "$W" two-rewritten
after=$(git -C "$W" rev-parse HEAD)
out=$(git -C "$W" push --force origin seat/w 2>&1); rc=$?
check "push --force of a rewritten branch is refused" "$([ "$rc" -ne 0 ]; echo $?)"
check "the refusal is the guard's, and names the ref and both shas" \
"$(has "$out" "mosaic push guard: refused: refs/heads/seat/w: not a fast-forward" && has "$out" "$before" && has "$out" "$after"; echo $?)"
check "the remote ref is unchanged after the refused --force" "$([ "$(rtip seat/w)" = "$before" ]; echo $?)"
out=$(git -C "$W" push origin +seat/w:seat/w 2>&1); rc=$?
check "a +refspec push is refused" "$([ "$rc" -ne 0 ] && has "$out" "not a fast-forward"; echo $?)"
check "the remote ref is unchanged after the refused +refspec" "$([ "$(rtip seat/w)" = "$before" ]; echo $?)"
out=$(git -C "$W" push --force-with-lease origin seat/w 2>&1); rc=$?
check "push --force-with-lease is refused too" "$([ "$rc" -ne 0 ] && has "$out" "not a fast-forward"; echo $?)"
out=$(git -C "$W" push origin :seat/w 2>&1); rc=$?
check "a delete (:ref) is refused" "$([ "$rc" -ne 0 ] && has "$out" "mosaic push guard: refused: refs/heads/seat/w: a delete"; echo $?)"
out=$(git -C "$W" push --delete origin seat/w 2>&1); rc=$?
check "a delete (--delete) is refused" "$([ "$rc" -ne 0 ] && has "$out" "a delete"; echo $?)"
check "the remote branch still exists after the refused deletes" "$([ "$(rtip seat/w)" = "$before" ]; echo $?)"
# Another clone moves the remote; this clone never fetches it.
git clone -q -b seat/w "$B" "$O"
commit "$O" from-other
git -C "$O" push -q origin seat/w
moved=$(rtip seat/w)
check "the other clone moved the remote, and this clone lacks that commit" \
"$([ "$moved" != "$before" ] && ! git -C "$C" cat-file -e "$moved^{commit}" 2>/dev/null; echo $?)"
out=$(git -C "$W" push --force origin seat/w 2>&1); rc=$?
check "a push over a remote tip missing locally is refused, saying fetch first" \
"$([ "$rc" -ne 0 ] && has "$out" "isn't in this repository; fetch first"; echo $?)"
check "the remote ref is unchanged after that refusal" "$([ "$(rtip seat/w)" = "$moved" ]; echo $?)"
check "the guard fetched nothing" "$(! git -C "$C" cat-file -e "$moved^{commit}" 2>/dev/null; echo $?)"
echo "== the hook, fed by hand =="
z=0000000000000000000000000000000000000000
hookrun() { (cd "$C" && printf '%s\n' "$1" | sh "$C/.git/hooks/pre-push" origin "$B" 2>&1); }
out=$(hookrun "refs/heads/x $(git -C "$C" rev-parse HEAD) refs/heads/x"); rc=$?
check "a short ref line is refused" "$([ "$rc" -ne 0 ] && has "$out" "unreadable ref line"; echo $?)"
out=$(hookrun "refs/heads/x HEAD refs/heads/x $z"); rc=$?
check "a non-hex object name is refused" "$([ "$rc" -ne 0 ] && has "$out" "non-hex"; echo $?)"
out=$(hookrun "refs/heads/x 1234567890123456789012345678901234567890 refs/heads/x $(git -C "$C" rev-parse HEAD)"); rc=$?
check "a failed ancestor check (an unknown local object) is refused" \
"$([ "$rc" -ne 0 ] && has "$out" "the ancestor check failed"; echo $?)"
out=$(printf '' | (cd "$C" && sh "$C/.git/hooks/pre-push" origin "$B" 2>&1)); rc=$?
check "an empty push passes" "$rc"
echo "== installer refusals =="
fresh() { rm -rf "$1"; git clone -q "$B" "$1"; }
fresh "$SANDBOX/hp"
git -C "$SANDBOX/hp" config core.hooksPath .husky
out=$(bash "$INSTALL" --repo "$SANDBOX/hp" 2>&1); rc=$?
check "the install refuses with core.hooksPath set (exit 2)" "$([ "$rc" = 2 ]; echo $?)"
check "it says core.hooksPath is set" "$(has "$out" "core.hooksPath is set"; echo $?)"
check "no hook was written under the hooksPath refusal" \
"$([ ! -e "$SANDBOX/hp/.git/hooks/pre-push" ] && [ ! -e "$SANDBOX/hp/.husky/pre-push" ]; echo $?)"
fresh "$SANDBOX/df"
printf '#!/bin/sh\nexit 0\n' > "$SANDBOX/df/.git/hooks/pre-push"
chmod 0755 "$SANDBOX/df/.git/hooks/pre-push"
ln "$SANDBOX/df/.git/hooks/pre-push" "$SANDBOX/df-old"
out=$(bash "$INSTALL" --repo "$SANDBOX/df" 2>&1); rc=$?
check "the install refuses a different installed pre-push (exit 2)" \
"$([ "$rc" = 2 ] && has "$out" "a different pre-push hook is installed"; echo $?)"
check "the different hook is untouched" \
"$([ "$(cat "$SANDBOX/df/.git/hooks/pre-push")" = "$(printf '#!/bin/sh\nexit 0')" ]; echo $?)"
out=$(bash "$INSTALL" --repo "$SANDBOX/df" --force-install 2>&1); rc=$?
check "--force-install replaces it" \
"$([ "$rc" = 0 ] && has "$out" "replaced a different hook" && cmp -s "$HOOK_SRC" "$SANDBOX/df/.git/hooks/pre-push"; echo $?)"
check "the replacement was a rename: the old file keeps its bytes" \
"$([ "$(cat "$SANDBOX/df-old")" = "$(printf '#!/bin/sh\nexit 0')" ]; echo $?)"
out=$(bash "$INSTALL" --repo "$SANDBOX/df" 2>&1); rc=$?
check "a second install over the same bytes exits 0" "$([ "$rc" = 0 ] && has "$out" "the same bytes"; echo $?)"
fresh "$SANDBOX/ln"
ln -s /dev/null "$SANDBOX/ln/.git/hooks/pre-push"
out=$(bash "$INSTALL" --repo "$SANDBOX/ln" --force-install 2>&1); rc=$?
check "the install refuses a symlinked pre-push, even with --force-install" "$([ "$rc" = 2 ] && has "$out" "is a symlink"; echo $?)"
fresh "$SANDBOX/wd"
git -C "$SANDBOX/wd" worktree add -q -b seat/x "$SANDBOX/wd-wt" origin/main
out=$(bash "$INSTALL" --repo "$SANDBOX/wd-wt" 2>&1); rc=$?
check "an install named by a worktree path lands in the common .git/hooks" \
"$([ "$rc" = 0 ] && cmp -s "$HOOK_SRC" "$SANDBOX/wd/.git/hooks/pre-push"; echo $?)"
out=$(GIT_DIR="$SANDBOX/wd/.git" bash "$INSTALL" --repo "$SANDBOX/wd" 2>&1); rc=$?
check "the install refuses with GIT_DIR set" "$([ "$rc" = 2 ] && has "$out" "GIT_DIR is set"; echo $?)"
mkdir -p "$SANDBOX/plain"
out=$(bash "$INSTALL" --repo "$SANDBOX/plain" 2>&1); rc=$?
check "the install refuses a directory that isn't a checkout" "$([ "$rc" = 2 ]; echo $?)"
out=$(bash "$INSTALL" --nope 2>&1); rc=$?
check "an unknown argument exits 4" "$([ "$rc" = 4 ]; echo $?)"
echo "== slot-busy, fake /proc =="
P="$SANDBOX/proc"
fakeproc() { # $1: pid, $2: cwd, rest: argv
local pid=$1 cwd=$2; shift 2
mkdir -p "$P/$pid"
printf '%s\0' "$@" > "$P/$pid/cmdline"
ln -sfn "$cwd" "$P/$pid/cwd"
}
busyrun() { bash "$BUSY" --proc-root "$P" 2>&1; }
fakeproc 4194401 /srv node server.mjs
fakeproc 4194402 /srv grep -r gate .
fakeproc 4194403 /srv /bin/bash -c "source /home/u/.claude/shell-snapshots/snapshot-bash-1.sh && eval 'node --test x.test.mjs'"
mkdir -p "$P/4194404"; : > "$P/4194404/cmdline"
out=$(busyrun); rc=$?
check "no matching process: exit 0 and CLEAR" "$([ "$rc" = 0 ] && [ "$out" = CLEAR ]; echo $?)"
check "an agent's shell-snapshot wrapper isn't a match" "$(! has "$out" "4194403"; echo $?)"
fakeproc 4194410 /seat/a node --test packages/x/tests/a.test.mjs
out=$(busyrun); rc=$?
check "a node --test process: exit 1" "$([ "$rc" = 1 ]; echo $?)"
check "its BUSY line names pid, rule and cwd" "$(has "$out" "BUSY 4194410 node --test cwd=/seat/a node --test"; echo $?)"
check "BUSY output never says CLEAR" "$(! has "$out" "CLEAR"; echo $?)"
ran=$(bash "$BUSY" --proc-root "$P" >/dev/null && echo ran)
check "\`slot-busy > LOG && step\` doesn't run the step while BUSY" "$([ -z "$ran" ]; echo $?)"
rm -rf "${P:?}/4194410"
rule() { # $1: expected rule, rest: argv
local want=$1; shift
rm -rf "${P:?}/4194420"; fakeproc 4194420 /seat/b "$@"
out=$(busyrun); rc=$?
check "busy on: $* ($want)" "$([ "$rc" = 1 ] && has "$out" "BUSY 4194420 $want "; echo $?)"
}
rule "node --test" /usr/bin/node --experimental-strip-types --test a.test.ts
rule "gate" bash /mnt/scratch/sage-gate.sh 253
rule "gate" bash ./gate.sh
rule "mutant" bash agents/x/work/queue-1/mutants.sh M3
rule "test script" bash scripts/test-land-work.sh
rule "docker compose" docker compose -p x up -d
rule "docker compose" /usr/bin/docker-compose up
rm -rf "${P:?}/4194420"
out=$(bash "$BUSY" --proc-root "$SANDBOX/no-such-proc" 2>&1); rc=$?
check "a missing proc root: exit 2" "$([ "$rc" = 2 ]; echo $?)"
mkdir -p "$SANDBOX/empty-proc"
out=$(bash "$BUSY" --proc-root "$SANDBOX/empty-proc" 2>&1); rc=$?
check "a proc root with no processes: exit 2" "$([ "$rc" = 2 ]; echo $?)"
out=$(bash "$BUSY" --nope 2>&1); rc=$?
check "an unknown argument: exit 2" "$([ "$rc" = 2 ]; echo $?)"
echo "== slot-busy, real /proc =="
mkdir -p "$SANDBOX/bin" "$SANDBOX/standin"
cat > "$SANDBOX/bin/node" <<'EOF'
#!/bin/sh
trap 'exit 0' TERM
while :; do sleep 0.2; done
EOF
chmod +x "$SANDBOX/bin/node"
(cd "$SANDBOX/standin" && exec "$SANDBOX/bin/node" --test standin.test.mjs) &
STANDIN=$!
for _ in $(seq 50); do
has "$(tr '\0' ' ' < "/proc/$STANDIN/cmdline" 2>/dev/null)" "--test standin" && break
sleep 0.1
done
out=$(bash "$BUSY" 2>&1); rc=$?
check "with a stand-in node --test running: exit 1" "$([ "$rc" = 1 ]; echo $?)"
check "the stand-in has a BUSY line with its cwd" \
"$(printf '%s\n' "$out" | grep -q "^BUSY $STANDIN node --test cwd=$SANDBOX/standin "; echo $?)"
check "this suite, an ancestor, has no BUSY line" "$(! printf '%s\n' "$out" | grep -q "^BUSY $$ "; echo $?)"
ran=$(bash "$BUSY" >/dev/null && echo ran)
check "\`slot-busy > LOG && step\` doesn't run the step with the stand-in up" "$([ -z "$ran" ]; echo $?)"
kill "$STANDIN" 2>/dev/null; wait "$STANDIN" 2>/dev/null
gone=$STANDIN; STANDIN=""
out=$(bash "$BUSY" 2>&1); rc=$?
check "after the stand-in exits, it has no BUSY line" "$(! printf '%s\n' "$out" | grep -q "^BUSY $gone "; echo $?)"
check "the real-/proc run exits 0 or 1, never 2" "$([ "$rc" = 0 ] || [ "$rc" = 1 ]; echo $?)"
echo "---"
echo "git-guard selftests: $PASS passed, $FAIL failed"
[ "$FAIL" -eq 0 ]