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]>
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
27a1a2bbwithgit apply --index build.patch.git diff --cached --binary 27a1a2bbhashes the same asbuild.patch(e852a913…).sha256sum -con the manifest: the 8 code files OK in the worktree, the 2 packet files OK in the canonical checkout. No commit from27a1a2bbto70ed77datouches the 8 code paths. - I read the brief,
BUILD.md, the whole script, thecli.mjs,reads.mjsand 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 inr1/closure-static.txt) to checkCOPY_PATHSagainst 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 withqueue-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-Tandno-early-existsbehave the same as the candidate in every case a test can set up. Without-T,mvonto a missing target renames, as-Tdoes. Without the early check, the archive is written,mv -Tfails on the existing copy, the fallback printsexists:, and the trap removes the temp directory. Same output, more work. I'd leave both.dir-0700leaves the copy's top directory atmktemp -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.