Files
stack/agents/darkwing/work/queue-77-review/review-r1.md
T
jason.woltjeandClaude Opus 5.5 8b1f9793dc 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 <[email protected]>
2026-10-12 14:05:02 -05:00

9.1 KiB

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 <copy>/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.