From 8b1f9793dc494a483f610c73e6e787531e482540 Mon Sep 17 00:00:00 2001 From: Jason Woltje Date: Mon, 12 Oct 2026 14:05:02 -0500 Subject: [PATCH] darkwing: row 77 (#1571) review round 1, changes (comment 27801) Human proof from a copy (bus checks its own human-cli path), row 61's packages/tasks import missing from COPY_PATHS, directory write bits. webui 94 pass; 19 mutants, 16 killed, 3 survivors explained. Co-Authored-By: Claude Opus 5.5 --- .../queue-77-review/r1/closure-static.txt | 12 ++ .../work/queue-77-review/r1/closure2.mjs | 20 +++ .../work/queue-77-review/r1/mutants.sh | 62 +++++++ .../work/queue-77-review/r1/mutants.txt | 19 ++ .../work/queue-77-review/r1/webui.txt | 103 +++++++++++ .../work/queue-77-review/review-r1.md | 168 ++++++++++++++++++ 6 files changed, 384 insertions(+) create mode 100644 agents/darkwing/work/queue-77-review/r1/closure-static.txt create mode 100644 agents/darkwing/work/queue-77-review/r1/closure2.mjs create mode 100755 agents/darkwing/work/queue-77-review/r1/mutants.sh create mode 100644 agents/darkwing/work/queue-77-review/r1/mutants.txt create mode 100644 agents/darkwing/work/queue-77-review/r1/webui.txt create mode 100644 agents/darkwing/work/queue-77-review/review-r1.md diff --git a/agents/darkwing/work/queue-77-review/r1/closure-static.txt b/agents/darkwing/work/queue-77-review/r1/closure-static.txt new file mode 100644 index 000000000..a219b8203 --- /dev/null +++ b/agents/darkwing/work/queue-77-review/r1/closure-static.txt @@ -0,0 +1,12 @@ +STATIC {"packages/webui/src":4,"packages/business/src":10,"packages/cli/src":9,"packages/seat/src":3,"packages/conversation/src":6,"packages/bus/src":3,"packages/harness/src":3,"packages/discord/src":6} +DYNAMIC +URLS + packages/cli/src/transport.mjs -> packages/bus/src/human-cli.mjs + packages/harness/src/bundle.mjs -> packages/harness/src/pi-extension.mjs + packages/harness/src/bundle.mjs -> packages/harness/src/claude-gate.mjs + packages/harness/src/bundle.mjs -> packages/harness/src/mcp-server.mjs + packages/harness/src/bundle.mjs -> packages/harness/src/gate.mjs + packages/harness/src/bundle.mjs -> packages/harness/src/tools.mjs + packages/cli/src/host.mjs -> packages/bus/src/process.mjs + packages/cli/src/host.mjs -> packages/cli/src/notifier-process.mjs + packages/discord/src/git.mjs -> packages/discord/bin/git-credential.mjs diff --git a/agents/darkwing/work/queue-77-review/r1/closure2.mjs b/agents/darkwing/work/queue-77-review/r1/closure2.mjs new file mode 100644 index 000000000..1faff522e --- /dev/null +++ b/agents/darkwing/work/queue-77-review/r1/closure2.mjs @@ -0,0 +1,20 @@ +// Static import closure only; report dynamic imports and import.meta.url references found in it. +import { readFileSync } from 'node:fs'; +import { dirname, resolve, relative } from 'node:path'; +const root = resolve(process.argv[2]); +const seen = new Set(), dyn = [], urls = []; +const stack = process.argv.slice(3).map((p) => resolve(root, p)); +while (stack.length) { + const f = stack.pop(); + if (seen.has(f)) continue; + seen.add(f); + const src = readFileSync(f, 'utf8').replace(/^\s*\/\/.*$/gm, ''); + for (const m of src.matchAll(/(?:\bfrom\s*|^\s*import\s+)['"](\.[^'"]+)['"]/gm)) stack.push(resolve(dirname(f), m[1])); + for (const m of src.matchAll(/\bimport\s*\(\s*['"](\.[^'"]+)['"]/g)) dyn.push(`${relative(root, f)} -> ${relative(root, resolve(dirname(f), m[1]))}`); + for (const m of src.matchAll(/new URL\(\s*['"]([^'"]+)['"]\s*,\s*import\.meta\.url/g)) urls.push(`${relative(root, f)} -> ${relative(root, resolve(dirname(f), m[1])) || '.'}`); +} +const dirs = {}; +for (const f of seen) { const d = relative(root, f).split('/').slice(0, 3).join('/'); dirs[d] = (dirs[d] ?? 0) + 1; } +console.log('STATIC', JSON.stringify(dirs)); +console.log('DYNAMIC'); dyn.forEach((x) => console.log(' ', x)); +console.log('URLS'); urls.forEach((x) => console.log(' ', x)); diff --git a/agents/darkwing/work/queue-77-review/r1/mutants.sh b/agents/darkwing/work/queue-77-review/r1/mutants.sh new file mode 100755 index 000000000..408f5d9cc --- /dev/null +++ b/agents/darkwing/work/queue-77-review/r1/mutants.sh @@ -0,0 +1,62 @@ +#!/usr/bin/env bash +# Darkwing row 77 review mutants: single-site edits to the candidate in +# WORKTREE. Each backs up one file, edits one site with +# queue-59-review/r1/mut.mjs (exactly one match, else BAD-SITE), runs the +# named webui test files and restores the backup. +# Usage: mutants.sh WORKTREE [NAME,...] (TMPDIR must be set; outputs go there) +set -u +WT="$1" ONLY="${2:-}" +MUT="$(cd "$(dirname "$0")/../../queue-59-review/r1" && pwd)/mut.mjs" +cd "$WT" || exit 1 +S=scripts/webui-service.sh CLI=packages/webui/src/cli.mjs RD=packages/webui/src/reads.mjs +SVC=tests/service.test.mjs PIN=tests/pinned-copy.test.mjs + +m() { # name file tests from to + local name="$1" file="$2" tests="$3" from="$4" to="$5" + if [ -n "$ONLY" ] && [[ ",$ONLY," != *",$name,"* ]]; then return; fi + cp -- "$file" "$TMPDIR/mut.bak" + if ! node "$MUT" "$file" "$from" "$to"; then echo "$name BAD-SITE"; cp -- "$TMPDIR/mut.bak" "$file"; return; fi + if (cd packages/webui && node --test $tests > "$TMPDIR/$name.out" 2>&1); then + echo "$name SURVIVED" + else + echo "$name killed: $(grep -E '^✖ ' "$TMPDIR/$name.out" | grep -v 'failing tests' | sort -u | sed 's/ ([0-9.]*ms)$//' | tr '\n' ';')" + fi + cp -- "$TMPDIR/mut.bak" "$file" +} + +# copy +m mv-no-T $S $PIN 'if ! mv -T "$COPY_TMP"' 'if ! mv "$COPY_TMP"' +m no-file-chmod $S $PIN ' find "$COPY_TMP" -type f -exec chmod a-w {} + +' '' +m dir-0700 $S $PIN ' chmod 0755 "$COPY_TMP" +' '' +m no-trap $S $PIN " trap 'rm -rf \"\$COPY_TMP\"' EXIT +" '' +m no-early-exists $S $PIN ' if [ -e "$target" ]; then echo "exists: $target"; return 0; fi +' '' +m no-ancestor $S $PIN ' git -C "$REPO" merge-base --is-ancestor "$id" refs/heads/refactor || die "refusing: $id is not reachable from refs/heads/refactor" +' '' +m copy-no-canonical $S $PIN ' canonical + copies_ok +' ' copies_ok +' +# install, render, uninstall +m install-no-canonical $S $SVC ' canonical + [ -n "$COPY_ID" ]' ' [ -n "$COPY_ID" ]' +m install-no-copy-needed $S $SVC ' [ -n "$COPY_ID" ] || die "install needs --copy ID, a copy made with: scripts/webui-service.sh copy COMMIT" +' '' +m render-no-checkout $S $SVC ' [ -z "$COPY_ID" ] || flags="--checkout $REPO $flags" +' '' +m failed-is-running $S $SVC ' && $3 != "failed"' '' +m silent-state-fail $S $SVC ')" || die "refusing: systemctl' ')" || exit 1 || die "refusing: systemctl' +m no-reload-skips-check $S $SVC ' state="$(running)" || exit 1 + [ -z "$state" ] || die "refusing: $UNIT_NAME is still running' ' state=""; [ "$RELOAD" = 0 ] || state="$(running)" || exit 1 + [ -z "$state" ] || die "refusing: $UNIT_NAME is still running' +m uid-length-range $S $SVC '^[0-9a-f]{64}$ ]]' '^[0-9a-f]{63,65}$ ]]' +# status +m status-drift-inverted $S $SVC 'if [ "$head" = "$copy" ]; then' 'if [ "$head" != "$copy" ]; then' +m status-any-copy $S $SVC 'if [[ "$copy" =~ ^[0-9a-f]{40}$ ]]; then' 'if [ -n "$copy" ]; then' +# serve and the reads +m queue-root-ignored $RD $PIN 'queueReader({ root: queueRoot,' 'queueReader({ root,' +m cli-checkout-ignored $CLI $PIN 'queueRoot: options.checkout ?? REPO' 'queueRoot: REPO' +m checkout-relative-ok $CLI $PIN 'dir = isAbsolute(value) && statSync(value).isDirectory();' 'dir = statSync(value).isDirectory();' diff --git a/agents/darkwing/work/queue-77-review/r1/mutants.txt b/agents/darkwing/work/queue-77-review/r1/mutants.txt new file mode 100644 index 000000000..5b1126017 --- /dev/null +++ b/agents/darkwing/work/queue-77-review/r1/mutants.txt @@ -0,0 +1,19 @@ +mv-no-T SURVIVED +no-file-chmod killed: ✖ copy writes the Console paths at the full id, read-only, and leaves an existing copy alone; +dir-0700 SURVIVED +no-trap killed: ✖ copy writes nothing when an archived path is missing at the commit; +no-early-exists SURVIVED +no-ancestor killed: ✖ copy refuses a commit off refactor, an unknown one, a worktree and a bad copies directory; +copy-no-canonical killed: ✖ copy refuses a commit off refactor, an unknown one, a worktree and a bad copies directory; +install-no-canonical killed: ✖ install needs --copy naming a copy that exists, and refuses outside the canonical checkout without --allow-worktree;✖ status names the copy and whether refactor has moved past it, and says when the unit runs from no copy; +install-no-copy-needed killed: ✖ install needs --copy naming a copy that exists, and refuses outside the canonical checkout without --allow-worktree; +render-no-checkout killed: ✖ install into --dir with --no-reload writes the file whole, then reports unchanged;✖ install needs --copy naming a copy that exists, and refuses outside the canonical checkout without --allow-worktree;✖ install --send into --dir with --no-reload writes the sending unit, and status says whether it sends;✖ render --copy runs the copy and reads the queue in the checkout; without --copy the unit is unchanged; +failed-is-running killed: ✖ install refuses while the unit is running, in any state but inactive or failed, and changes nothing;✖ uninstall refuses while the unit is running, with or without --no-reload, and removes it once it is not; +silent-state-fail killed: ✖ when the state read fails, install and uninstall refuse with one line and change nothing; +no-reload-skips-check killed: ✖ uninstall refuses while the unit is running, with or without --no-reload, and removes it once it is not;✖ when the state read fails, install and uninstall refuse with one line and change nothing; +uid-length-range killed: ✖ the uid header takes only an Authentik uid; the username header still takes a username; +status-drift-inverted killed: ✖ status names the copy and whether refactor has moved past it, and says when the unit runs from no copy; +status-any-copy killed: ✖ status names the copy and whether refactor has moved past it, and says when the unit runs from no copy; +queue-root-ignored killed: ✖ consoleReads runs the queue read under queueRoot and reads RELEASE under root;✖ serve runs from a copy: /healthz 200, the config check, roles and RELEASE from the copy, the queue from --checkout; +cli-checkout-ignored killed: ✖ serve runs from a copy: /healthz 200, the config check, roles and RELEASE from the copy, the queue from --checkout; +checkout-relative-ok killed: ✖ serve refuses a --checkout that is not an absolute directory path; diff --git a/agents/darkwing/work/queue-77-review/r1/webui.txt b/agents/darkwing/work/queue-77-review/r1/webui.txt new file mode 100644 index 000000000..4dae5477c --- /dev/null +++ b/agents/darkwing/work/queue-77-review/r1/webui.txt @@ -0,0 +1,103 @@ +✔ browser edge states: loading, empty, malformed, stale, hostile/long values, in-flight reply and appearance fallback (4394.646907ms) +Rendered contrast: {"failures":[],"count":330,"lowest":4.504658476260286} +✔ served Console browser: real board fixtures, keyboard, drafts, receipts, themes, 320px and failures (5709.932799ms) +✔ S5 pages in a browser: real broker rows, inert text, Copy, routes, failures, layout and no writes (9137.553238ms) +✔ bus routes serve the four reads from the reader view, unescaped JSON for the page to escape (489.206257ms) +✔ decisions route serves the roles and recent lists from the reader view (1167.032106ms) +✔ credentials route serves the broker credential status from the reader view (1319.114281ms) +✔ bus routes are GET-only, have no write verb, and keep the same-origin checks (252.951067ms) +✔ bus refusals map to statuses and never carry a path or a stack (76.898274ms) +✔ humanCall speaks the human transport protocol without blocking the server (2199.984461ms) +✔ busReader takes --business, else the live bus host, else refuses (532.216638ms) +✔ humanCall kills a transport that runs past its timeout (531.786336ms) +✔ serve refuses a --business value that is not a business id (542.663593ms) +✔ conversation view: full history, collapsed tools, hidden thinking, inert hostile content, malformed and reconcile markers (4489.39161ms) +✔ conversation view: a fork keeps the open branch, says so, and opens the new one on request (4239.827603ms) +✔ conversation view: a newer session with no readable history keeps the marker (1957.372203ms) +✔ conversation view: seats without history say so and offer no reply (1349.867936ms) +✔ Discord row through real board/WebUI: independent brake/liveness, no Reply, literal content (4537.764374ms) +✔ return flow through the conversation view: send, tool call, delayed result, peer message, exact long answers, relaunch (56728.881966ms) +✔ copy writes the Console paths at the full id, read-only, and leaves an existing copy alone (3549.464574ms) +✔ copy refuses a commit off refactor, an unknown one, a worktree and a bad copies directory (3673.626635ms) +✔ copy writes nothing when an archived path is missing at the commit (772.072442ms) +✔ serve runs from a copy: /healthz 200, the config check, roles and RELEASE from the copy, the queue from --checkout (1636.494489ms) +✔ serve refuses a --checkout that is not an absolute directory path (270.968051ms) +✔ consoleReads runs the queue read under queueRoot and reads RELEASE under root (63.379735ms) +✔ preview options are all three or none, and each is checked (13.216321ms) +✔ the CLI refuses a partial or invalid preview flag set at start (3350.904908ms) +✔ the CLI with all three flags serves the preview mode (355.802865ms) +✔ a request for the public name needs the header equal to the user id, else 401 and nothing read (45.591893ms) +✔ the header name matches without regard to case, and the value compare is exact (7.581488ms) +✔ the public name with the header reads the bus views, and the Host and Origin are checked first (91.177642ms) +✔ in preview mode every write answers 403 read-only, from the public name and loopback, and reaches nothing (30.567202ms) +✔ loopback requests are unchanged in preview mode, and the bus views work with the board down (9.673115ms) +✔ a proxy that rewrites Host to the loopback port gets 403 on its first request, whatever the user (37.199482ms) +✔ a repeated Host is 400 before any other check, in preview mode and without it (16.696137ms) +✔ without the preview flags the public name is refused and writes still reach the board (10.184141ms) +✔ redactor: config, dataRoot, any absolute path, Discord ids and the JSON parser quote (2.471829ms) +✔ credential state from the date alone, as the bus states it (0.288165ms) +✔ queue: rows, one row, ids and methods, notes; no path or id from a row (3360.22104ms) +✔ queue: a refused read fails closed with the store's text, redacted (1232.944177ms) +✔ queue: a store that fails to load, or a child that dies, gives a fixed message with no path or stack (3464.879943ms) +✔ business: names, vars on the allowlist, authority, credential metadata only (13.940404ms) +✔ business: missing or invalid file refuses with the module's text, redacted (23.645666ms) +✔ business without --business: the live bus host, else no-bus-host; no system config: not-configured (15.000152ms) +✔ settings: release, board, loopback address, notifier binding name only (651.12514ms) +✔ both presentations replace old activity with relaunch notice, label retained history, then resume after new activity (4650.501877ms) +✔ reported return flow and relative Age: reply sent from the inspector, then the new answer appears there without manual refresh (23113.240556ms) +✔ the send box: held roles, the draft and caret across a refresh, each receipt, one POST per Send, and the trail link; the board Reply on a 4xx and a 5xx (5971.272011ms) +✔ startServer takes send only as a function and only with the preview mode (18.489056ms) +✔ the CLI refuses --send without the preview flags, and without a configured bus (2475.985635ms) +✔ the CLI with --send and a configured bus sends through the human transport and logs one line (851.240384ms) +✔ without --send the route is read-only like every other write, and config says no send (45.110146ms) +✔ with --send every other write is still 403 read-only, and nothing reaches the board or bus (28.095422ms) +✔ config says send only on the public name with --send on (10.638806ms) +✔ a send without the signed-in user is 401 and is neither sent nor logged, loopback included (13.723438ms) +✔ each request check refuses before anything is sent, and logs the refusal without the body (27.367598ms) +✔ a send passes exactly to, body and in_reply_to, answers the id, and logs it (8.252091ms) +✔ a crafted role name cannot forge a log line (13.736446ms) +✔ a bus refusal is not sent, with its status; an outcome the bus could not confirm is unknown and never retried (19.06136ms) +✔ through the human transport the request is exactly the business, message.send and the keys (358.902005ms) +✔ against a real broker: the message is written once as a human send, and its refusals map (624.472798ms) +✔ loopback host and board origin fail closed (19.805228ms) +✔ real board fixture passes through WebUI; assets and isolated seen/reply work (588.876992ms) +✔ proxy preserves exact request bytes, status and receipt, rejects forms and malformed JSON, never follows redirect (490.717407ms) +✔ unreachable board reports URL; CLI rejects unsupported options (2120.638861ms) +✔ render prints the unit without the preview flags (37.951579ms) +✔ render carries every serve flag, the preview set included (21.765295ms) +✔ a partial preview set is refused (82.659313ms) +✔ a value the unit cannot carry is refused, for every flag (683.277932ms) +✔ the rendered unit names none of the human proof agent markers and sets no agent variable (73.817595ms) +✔ install into --dir with --no-reload writes the file whole, then reports unchanged (270.863433ms) +✔ install without --no-reload runs daemon-reload and nothing that starts the unit (197.110792ms) +✔ uninstall refuses while the unit is running, with or without --no-reload, and removes it once it is not (1029.755501ms) +✔ status prints the unit state and GET /healthz on the installed port (404.612111ms) +✔ usage: an unknown verb or flag, and start or enable, exit 4 (108.440067ms) +✔ without --send, render is the template with its placeholders filled, byte for byte (54.973513ms) +✔ render --send with the preview set ends ExecStart with --send and changes nothing else (95.150464ms) +✔ --send without the whole preview set is refused and writes nothing (316.03091ms) +✔ install --send into --dir with --no-reload writes the sending unit, and status says whether it sends (598.764706ms) +✔ uninstall and status take no --send (47.851104ms) +✔ install refuses while the unit is running, in any state but inactive or failed, and changes nothing (406.927119ms) +✔ when the state read fails, install and uninstall refuse with one line and change nothing (86.166207ms) +✔ install needs --copy naming a copy that exists, and refuses outside the canonical checkout without --allow-worktree (91.850506ms) +✔ render --copy runs the copy and reads the queue in the checkout; without --copy the unit is unchanged (34.595299ms) +✔ the uid header takes only an Authentik uid; the username header still takes a username (838.637048ms) +✔ a preview value cannot start with - or . (92.724762ms) +✔ status names the copy and whether refactor has moved past it, and says when the unit runs from no copy (476.216748ms) +✔ tokens.css and icons.svg are byte copies of docs/design (1.031506ms) +✔ JetBrains Mono files match the digests in their sources file, with the OFL text (2.301906ms) +✔ shipped strings are unchanged, and the web keeps its write boundary (3.8532ms) +✔ shell in a browser: System default, seeded states, 400px, focus and the Ctrl+K palette (7911.890688ms) +✔ five states on Board, Inbox, Tasks, Agents and Trail (2718.804104ms) +✔ Queue, Business and Settings in their five states, with no move button (5732.50335ms) +✔ seeded secrets, Discord ids and paths reach no response and no page; business refusals in the module's words (2113.350832ms) +✔ the server log names no config path when the system config refuses (373.020142ms) +ℹ tests 94 +ℹ suites 0 +ℹ pass 94 +ℹ fail 0 +ℹ cancelled 0 +ℹ skipped 0 +ℹ todo 0 +ℹ duration_ms 57731.276139 diff --git a/agents/darkwing/work/queue-77-review/review-r1.md b/agents/darkwing/work/queue-77-review/review-r1.md new file mode 100644 index 000000000..e9abbc645 --- /dev/null +++ b/agents/darkwing/work/queue-77-review/review-r1.md @@ -0,0 +1,168 @@ +# Row 77, the Console runs from a pinned copy, round 1 review (Darkwing) + +Issue #1571, request comment 27767, packet `agents/rocko/work/queue-77/` +(commit `436d5811`). Brief: `docs/plans/2026-10-12_console-pinned-copy.md` +§ "Console pinned copy, update step and service hardening". Base +`27a1a2bb`. Candidate manifest sha256 +`4927de6625a0159eb9633d679a348a77a98978cc403bd9eee78fff8c1d6482a0`, 10 +files, matching the request. + +Verdict: **changes**. The script, the unit and serve do what the brief +asks, and N1 to N5 and the uid rule hold under test. Two things stop a +copy-run Console from working once it's live, and round 2 is already +planned for both. Finding 1 is the one Sage asked me to name: the bus +refuses every human proof from a copy. Finding 2 is row 61's new import, +which the copy doesn't carry. Finding 3 is a smaller choice about the +copy's directories. Notes follow. + +## Method + +- A detached worktree at `27a1a2bb` with `git apply --index build.patch`. + `git diff --cached --binary 27a1a2bb` hashes the same as `build.patch` + (`e852a913…`). `sha256sum -c` on the manifest: the 8 code files OK in + the worktree, the 2 packet files OK in the canonical checkout. No commit + from `27a1a2bb` to `70ed77da` touches the 8 code paths. +- I read the brief, `BUILD.md`, the whole script, the `cli.mjs`, + `reads.mjs` and template diffs, the two doc diffs and both test files. +- I walked serve's static import closure from `packages/webui/src/cli.mjs` + (`r1/closure2.mjs`, output in `r1/closure-static.txt`) to check + `COPY_PATHS` against it. +- The webui suite on the candidate, then 19 mutants of my own + (`r1/mutants.sh`). Several repeat Rocko's 18 at a different edit; the + rest are sites theirs don't name. Each edits one site with + `queue-59-review/r1/mut.mjs`, runs one test file and restores the file. +- A by-hand check of what a replace-by-rename writer does to a copy + (finding 3). + +Node v26.8.1, `TMPDIR=~/darkwing-scratch/rv77/tmp`. + +## Findings + +**1. A Console run from a copy can't prove the human to a bus run from the +checkout.** The Console reads the bus by spawning the human transport, +`humanCall` in `packages/webui/src/bus.mjs:31`, with +`cli = HUMAN_CLI`. `packages/cli/src/transport.mjs:12` resolves that +relative to its own file, so from a copy it's +`/packages/bus/src/human-cli.mjs`. The bus runtime hands +`verifyHuman` its own `./human-cli.mjs` as `cliPath` +(`packages/bus/src/runtime.mjs:68`), and `packages/bus/src/human.mjs:70` +refuses unless `p.argv[1] === resolve(cliPath)`. The bus unit runs from +the checkout, so every proof from the copy fails with `human-required`. +Board, Inbox, Tasks, Agents and Trail all fail; Queue, Business and +Settings still read. + +The tests don't reach this. `pinned-copy.test.mjs` serves from a copy with +a stand-in socket and checks `/healthz`, the bus name from config and the +non-bus reads. No test spawns the copy's human-cli against a bus. + +That's the proof check doing its job, not a defect in it. Round 2, as +Sage planned: `install --copy` refuses unless the bus runs from the same +copy, with a test for each case. Row 85 (#1579) moves the bus, Console and +CLI to one copy, which is where the two meet. Until then the docs need a +line saying a copy-run Console needs the bus on the same copy. + +The TOOLS.md text "the roles, the config check and the bus child come from +the copy" is true of the files but reads as if that's fine on its own. +Round 2 should say the bus child is the human transport and the bus checks +its path. + +**2. Row 61 adds an import the copy doesn't carry.** Row 61 (#1550, mine) +is landing first. Its `packages/webui/src/reads.mjs:26` imports +`loadFields` and `FieldTableError` from `../../tasks/src/fields.mjs`, which +reads `packages/tasks/fields.json`. Neither path is in `COPY_PATHS` +(`scripts/webui-service.sh:159`), so serve from a copy fails at load once +both rows are in. `pinned-copy.test.mjs` builds its fixture from the +tree's own packages, so the rebase onto row 61 fails the serve test, +which is the guard the brief asked for. Round 2 adds +`packages/tasks/src` and `packages/tasks/fields.json`, and the comment +above the list names the reason. Sage has passed this to Rocko. + +**3. The copy's files are read-only, but its directories aren't.** The +copy takes `a-w` on every file and `0755` on the top directory; the +directories under it keep their `git archive` modes, writable by the +owner. A writer that replaces by rename still changes a file. In a +directory laid out like the copy, `sed -i` rewrote a mode 444 file (exit +0, still 444), and a new file could be added beside it. With the +directories read-only too, `sed -i` failed ("couldn't open temporary +file") and the file stayed as it was. + +The brief says "removes write permission from every file", and the +candidate does that, so this is a choice, not a miss. I'd take write +permission off the directories as well: the copy is meant to change only +by a new `copy`, and git, editors and `cp --remove-destination` all write +by rename. If round 2 does it, three things follow. The trap needs +`chmod -R u+w` before `rm -rf`, or a failed copy leaves its temp +directory. The docs' "copies are kept until someone removes them" needs +the removal command, `chmod -R u+w DIR && rm -rf DIR`. A test pins a +directory's mode. If Rocko or Sage keep files-only, the README should say +the copy guards against a stray write, not a rename. + +## Tests and mutants + +The webui suite on the candidate, 18:55:30Z to 18:56:27Z on 2026-10-12 +(`r1/webui.txt`): 94 pass, 0 fail, matching Rocko's count. I didn't rerun +the scripts suites. Rocko's gate has them green, and Sage reruns it. + +Mutants ran 19:03:22Z to 19:04:27Z (`r1/mutants.txt`), after Sage's and +Rocko's runs had finished. `build.patch` hashes the same in the worktree +afterwards. + +| Mutant | Edit | Result | +|---|---|---| +| `mv-no-T` | `mv` without `-T` | survives, see below | +| `no-file-chmod` | files keep their write bits | killed: the copy test | +| `dir-0700` | no `chmod 0755` on the copy's top directory | survives, see below | +| `no-trap` | no cleanup trap | killed: the archive failure test | +| `no-early-exists` | no `-e` check before the archive | survives, see below | +| `no-ancestor` | no `merge-base --is-ancestor` | killed: the copy refusals test | +| `copy-no-canonical` | `copy` skips `canonical` | killed: the copy refusals test | +| `install-no-canonical` | `install` skips `canonical` | killed: two tests | +| `install-no-copy-needed` | `install` runs without `--copy` | killed: the install `--copy` test | +| `render-no-checkout` | the unit gets no `--checkout` | killed: four tests | +| `failed-is-running` | a failed unit counts as running | killed: two tests | +| `silent-state-fail` | a failed state read exits 1 with no message | killed: the state read test | +| `no-reload-skips-check` | `uninstall --no-reload` skips the state read | killed: two tests | +| `uid-length-range` | the uid rule takes 63 to 65 characters | killed: the uid test | +| `status-drift-inverted` | status says the opposite about `refactor` | killed: the status test | +| `status-any-copy` | status calls any directory a copy | killed: the status test | +| `queue-root-ignored` | `consoleReads` reads the queue under `root` | killed: two tests | +| `cli-checkout-ignored` | serve drops `--checkout` | killed: the serve-from-copy test | +| `checkout-relative-ok` | serve takes a relative `--checkout` | killed: the refusal test | + +The three survivors: + +- `mv-no-T` and `no-early-exists` behave the same as the candidate in every + case a test can set up. Without `-T`, `mv` onto a missing target renames, + as `-T` does. Without the early check, the archive is written, `mv -T` + fails on the existing copy, the fallback prints `exists:`, and the trap + removes the temp directory. Same output, more work. I'd leave both. +- `dir-0700` leaves the copy's top directory at `mktemp -d`'s 0700. The unit + runs as the same user, so serve still works, and no test checks the + mode. If round 2 takes finding 3, its directory mode test kills this + one too. + +## Notes + +**N1. `exists:` trusts any directory at the target.** `copy` checks only +`[ -e "$target" ]`, and `install` checks only that +`packages/webui/src/cli.mjs` is there. A half-removed or hand-edited copy +passes both. With read-only directories (finding 3) that's harder to get +into; either way, `status` could say whether the copy's files are still +read-only. Not for this round. + +**N2. `mv -T` onto an empty directory succeeds.** If the target appears as +an empty directory between the `-e` check and the rename, `mv -T` replaces +it, and the copy lands. That's a race with nothing in practice, and the +result is still a whole copy, so I'd leave it. + +**N3. The closure check.** `COPY_PATHS` covers serve's static imports on +this base: webui 4 files, business 10, cli 9, seat 3, conversation 6, bus 3, +harness 3 and discord 6. No bare or dynamic imports. `host.mjs` refers to +`bus/src/process.mjs` and `store.mjs`, which reach `packages/tasks` and +`../schema.sql`, but serve uses only `readHostState` and never runs them. +The comment above the list is right for this base; finding 2 changes it. + +## Rocko's question + +Whether the copy's directories should lose write permission too: +finding 3. My answer is yes, with the three follow-ons above.