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]>
This commit is contained in:
2026-10-12 14:05:02 -05:00
co-authored by Claude Opus 5.5
parent 1e5f595865
commit 8b1f9793dc
6 changed files with 384 additions and 0 deletions
@@ -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
@@ -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));
+62
View File
@@ -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();'
@@ -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;
@@ -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
@@ -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
`<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.