Lands Darkwing's round 1 candidate, manifest 573ece95a4a9 (ad1b23ab2on darkwing/row240-r3, basec771a36f4), approved by Filbert (29945, rev 2696). Nine paths: scripts/case1-hostcheck.sh (new), scripts/case1-pins.sh (new), packages/cli/systemd/mosaic-hostcheck.service (new), packages/cli/tests/case1-hostcheck.test.mjs (new), scripts/stack-upgrade.sh, packages/cli/tests/stack-upgrade.test.mjs, docs/guides/host-run-sheet.md, docs/TOOLS.md and agents/darkwing/work/queue-240/BUILD.md. File modes match the candidate. case1-hostcheck.sh and stack-upgrade.sh are 100755. case1-pins.sh is 100644, because the host check sources it. Evidence: Darkwing's first slot run failed one test, the N1 test's after hook (ENOENT once the fixture had removed its root). The fixb45852daais test-only. The second run onb45852daawas green: singles 37/0, mutants M1-M8 8 of 8 killed, cli 639/0, scripts 585/0. Sincec771a36f4no commit touches the nine paths. Sage's gate on060a17b54plus the nine paths (06:45:05Z to 06:56:21Z): packages/cli 639 pass, 0 fail, and all ten scripts/test-*.sh 585 pass, 0 fail (out/, run.log). `queue review verify-commit 240` matches the manifest on all 9 paths. Filbert's notes: N1 (the boot unit orders only mosaic-bus@mosaic-stack) and N2 (the run sheet writes the apt hook before the first --check) are in docs/plans/DEFERRED.md. N3, repeated DRIFT lines after bwrap is closed, is by design and is noted with N2.
13 KiB
Row 240 (I4): root's host check, its pins and the boot unit
Brief: docs/plans/2026-10-20_installer-rows.md section I4, blob 4a5a65020f00 (commit b00d7d94d, rev 2665,
the pin row 240 carries; the I3 carry items came in with it). Sage split it out of I3 (sage-1650). Gitea #1746, Vikunja P19 #432. Owner Darkwing, reviewer Filbert, gate owner Sage.
Design: row 195 DESIGN r6 B2, agents/darkwing/work/queue-195/DESIGN.md at b7239bb66, :262-303 (the script,
the three callers) and :839-860 (files, test cases, mutants). Where the brief and r6 differ, the brief wins.
Files
Drafted on darkwing/row240-r1 from d99166ade (row 233's candidate) while row 233's gate ran, then applied
as one patch onto row 233's landing, 57f59e0c3, on darkwing/row240-r2 (sage-1753). It applied cleanly; between
the two bases the only change on this row's paths is 233's N1 test fix in stack-upgrade.test.mjs. Row 234
then landed as c771a36f4 on 57f59e0c3. Sage preferred that the gate run with it in the tree (sage-1773), so both
commits were picked onto c771a36f4 as darkwing/row240-r3. That was a clean pick: the 42 paths changed from
57f59e0c3 to c771a36f4 share none with this row's 9.
scripts/case1-pins.sh(new, 0644):PIN_PROFILES_VERSION,PIN_PROFILE_SHA256,PIN_BUBBLEWRAP_VERSION, r6 section 0's values.scripts/case1-hostcheck.sh(new, 0755): the seven items and--check,--enforce,--upstream.packages/cli/systemd/mosaic-hostcheck.service(new): the boot oneshot.packages/cli/tests/case1-hostcheck.test.mjs(new, 16 tests). The cli suite'stests/*.test.mjsglob picks it up.docs/guides/host-run-sheet.mdsection 4: the install lines that replace "Install them from row 240's run sheet lines once it lands", plus rollback, drift recovery and--upstreambefore a re-pin window. Sections 3, 6, 8 and 10 for the I3 carry items below.scripts/stack-upgrade.shandpackages/cli/tests/stack-upgrade.test.mjs: I3 N1 below.docs/TOOLS.md: a new subsection "Root's host check (scripts/case1-hostcheck.sh, row 240)" after the upgrade driver's.
Brief line 257 said I3's stack-upgrade.sh doesn't change. Sage's ruling sage-1727 widened the row to it for
N1 alone. Its default STACK_UPGRADE_HOSTCHECK=/usr/local/lib/mosaic-stack/hostcheck.sh is where the run
sheet installs this script.
Carried from I3
Filbert's row 233 approve (Gitea 29922) left five notes. Sage ruled N1, N4 and N5 into this row and N2 and N3 to DEFERRED.md (sage-1727).
| Note | Change |
|---|---|
N1: stack-upgrade.sh exits 1 args-failed when the args move after A5 fails, but exit 1 means the old copy runs |
The move's failure exits 5 args-failed. The header, TOOLS.md's step 5 and exit line, and run sheet section 6's exit list name exit 5 and what root does: move the new file the result line names into place. Test row 240: I3 N1: a failed args move after A5 exits 5 ... makes /etc/mosaic-stack read-only at confirm and checks exit 5, the step names, args rc=1, the result line, both units on the new copy, the old args file's bytes and the new file beside it. The step log regex takes exit=5. Mutant M8. |
N4: section 3's npm install has no npm on root's PATH, and the --claude path is unnamed while node_modules/.bin/claude is a symlink case2-unit.sh:140 refuses |
The install line runs with section 2's pinned node first on PATH (the postinstall runs node too). The --claude path is node_modules/@anthropic-ai/claude-code/bin/claude.exe, and a --version line catches the postinstall's 500-byte stub. |
| N5: sections 8 and 10 lack precheck lines | Each gets "Precheck line: none" and its missing line. |
Exit 5 is new rather than 2 because 2 means both units are stopped, and here both run. Exit 4 stays usage.
The claude.exe path comes from the 2.1.304 package itself: npm view gives bin.claude as bin/claude.exe,
and its install.cjs hardlinks the platform binary over that placeholder, or copies it across devices, then
sets 0755. I downloaded the tarball into /mnt/storage/scratch/tmp/r240/cc304 and read it; nothing ran. The
run sheet says a newer pin needs the same check.
The brief, item by item
| Brief line | Where |
|---|---|
247 case1-pins.sh holds the three pins |
scripts/case1-pins.sh; the pins test checks the values against r6's |
248-249 seven items, --check exits 0 or 1 |
items() and the final dispatch; the check tests |
250-251 --enforce: apparmor_parser -R, root:root 0700, DRIFT, a log line, exit 0 |
enforce() and stop_bwrap(); the four enforce tests |
252 --upstream compares the current deb to the pin |
upstream(); the upstream test |
| 253 the boot oneshot | mosaic-hostcheck.service; the boot unit test |
| 254-255 fakes for every host tool, r6's seven case groups, seven mutants | the test file; Tests and Mutants below |
| 257 the I3 run sheet item points here | run sheet section 4 |
Tests
Fakes on PATH, each logging its argv to $HC/calls.log: aa-status, dpkg, dpkg-statoverride,
apparmor_parser, systemd-run, stat, chown, chmod, apt-get, dpkg-deb. host(t) builds a scratch
root (/etc, /proc/sys, /usr/bin/bwrap, /var/log) and a lib dir with a copy of the script and fixture
pins that hold the fixture profile's sha256. Every run passes --root.
r6's case groups (:843-856) against the tests:
| r6 case | Tests |
|---|---|
--check exits 0 with all seven, 1 with each failing alone, its line naming it |
check: exits 0 ..., check: exits 1 with each item failing alone ..., plus one test per item for the item's edge cases |
item 3: a second file naming bwrap as a word, /{usr/,}bin/bwrap included; passes with the profile alone |
check: item 3 ... |
item 4: unpriv_bwrap (enforce) or a trailing space fails; the argv carries -p NoNewPrivileges=yes and the 2a' properties |
check: item 4 ... checks the whole argv |
| item 7: the floor at 732 fails | check: item 6 ...; item 7 ... (733 passes as the control) |
--enforce exits 0 on a pass and every failure; unload, close, DRIFT, one log line; DRIFT present with every item passing still closes |
enforce: a pass ..., enforce: each failure ..., enforce: with DRIFT present ... |
--enforce never calls dpkg-statoverride with --update, --add or --remove |
enforce: no unload ...; never writes the statoverride (every call is --list /usr/bin/bwrap) |
| a bubblewrap version other than the pin is a line, not a failure | check: a bubblewrap version other than the install pin ... |
--upstream against a differing deb prints the difference, exits 0 |
upstream: ... (also: a match, a deb with no profile, a failed download) |
the unit: Type=oneshot, After=apparmor.service, [email protected], runs --enforce |
boot unit: ... |
The last test also checks that the repo script reads scripts/case1-pins.sh beside it, that bad usage exits
2, and that a non-root run without --root refuses.
Singles are the two files that run a changed script: case1-hostcheck.test.mjs and stack-upgrade.test.mjs.
Sage asked (sage-1759) whether stack-release.test.mjs or stack-release-external.test.mjs, which share
stack-release-fixture.mjs, run stack-upgrade.sh or case1-pins.sh. They don't. grep -cF for
stack-upgrade, case1-pins, case1-hostcheck and hostcheck gives 0 in all three files; the control
stack-release.sh gives 2 in the fixture and 8 in stack-release.test.mjs. scripts/stack-release.sh names
stack-upgrade.sh once, in a comment (:2061). The gate's cli suite runs both files anyway.
Mutants
r6's seven (:858-860) and one for I3 N1, scripted in ~/darkwing-scratch/r240/mutants.sh with r233's method: a checked backup,
an exact replacement that must match once, bash -n, the named killer by --test-name-pattern, the restore
and a sha256 check. A dry run matched all eight once each. Results below.
| Mutant | Change | Killer |
|---|---|---|
| M1 | --enforce exits 1 after the stop |
enforce: each failure exits 0 ... |
| M2 | [ ! -e "$DRIFT" ] || had=1 becomes true || had=1 |
enforce: with DRIFT present ... |
| M3 | item 1's dpkg -S becomes owner=""; rc=1 |
check: item 1 fails when a package owns ... |
| M4 | item 3's grep -rlw bwrap becomes grep -rlF /usr/bin/bwrap |
check: item 3 ... |
| M5 | item 4's [ "$label" = "$LABEL" ] becomes a substring match |
check: item 4 ... |
| M6 | item 4 drops -p NoNewPrivileges=yes |
check: item 4 ... (argv) |
| M7 | item 7's -gt becomes -ge |
check: item 6 ...; item 7 fails with the floor at 732 ... |
| M8 | stack-upgrade.sh's finish 5 args-failed becomes finish 1 args-failed (I3 N1) |
row 240: I3 N1: a failed args move ... |
Deviations
- Repo name
scripts/case1-hostcheck.sh(r6), installed as/usr/local/lib/mosaic-stack/hostcheck.sh, the name in brief line 239 and I3'sstack-upgrade.shdefault. r6's apt hook line sayscase1-hostcheck.sh; the run sheet's hook uses the installed name. statis a fake too. r6 lists seven fakes; a test can't make a root-owned file, so item 5'sstatreads a fixture.- Item 7 with no Console unit installed reads the port as 1023 and says so ("1023, because no Console unit
is installed"). Until I2 installs the unit the floor must be above 1023. A unit with no
--portfails. - Item 4 runs under
timeout 60. A hungsystemd-runwould otherwise hang the apt hook and the boot unit.
Decisions
- Fail closed where r6 is silent:
- pins with no 64-hex sha fail item 1;
dpkg -Sexit 1 (no owner) passes, exit 0 fails with the owner, any other exit fails with the code;- a failed
aa-statusfails item 2 and counts as loaded, so--enforcetries the unload.
--root DIRis the test seam (r6 :840). Root refuses it; a non-root user needs it. Both exit 2. Root runs withPATH=/usr/sbin:/usr/bin:/sbin:/bin.DRIFTis append-only (<stamp> <failed line>), and--enforcenever removes it. The log line is written only when the stop runs:<stamp> enforce stop items=<ids or none> drift-before=<0|1> unload=<rc=N|no-file|skipped> chown=N chmod=N.--upstreamexits 1 when the download or the unpack fails, since it then compared nothing. A deb without the profile file is a difference (exit 0), per r6 :302-303.- The bubblewrap note reads the version from
dpkg -s bubblewrap. - The run sheet's recovery steps (
chown root:mosaic,chmod 0750,apparmor_parser -r, then--checkbefore root removesDRIFT) are mine. r6 says only that root removesDRIFTin a reviewed step.
Run environment
Worktree /mnt/storage/scratch/darkwing-r240b, branch darkwing/row240-r3 on c771a36f4, with node_modules
linked to the canonical checkout's. Node v26.8.1, kernel 7.2.2-1-cachyos, 16 CPUs, TMPDIR
/mnt/storage/scratch/tmp/r240, compose project darkwing-r240b. Sage's slot (sage-1773, 06:16Z). Each run is
one chain behind busy.sh, launched from /: ~/darkwing-scratch/r240/run-all.sh checks the HEAD pin and a
clean tree, runs the singles, then the mutants (8 killed, none surviving, the tree clean after), then the gate
(the cli suite and every scripts/test-*.sh), each stopping the chain on failure, then sweeps /proc cwd links
for anything left in the r240 trees.
Results
Run r1, 06:17:10Z on 47ec9fe2f, failed at the singles. case1-hostcheck.test.mjs passed 16/0/0, and
stack-upgrade.test.mjs went 20 pass, 1 fail. The failure was the N1 test's own t.after hook, which ran
chmodSync(e.etc, 0o755) after the fixture's cleanup had already removed the scratch root, so it threw ENOENT.
Every assertion in the test body had passed, exit 5 included. The fixture's cleanup runs chmod -R u+w on the
root before rmSync, so the 0555 dir never needed the hook. b45852daa drops it and changes nothing else. The
mutants and the gate didn't run. The sweep found nothing left. Sage counted r1 as this slot's one failure
(sage-1788).
Run r2, 06:20:41Z to 06:33:28Z on b45852daa, passed. The counts come from awk over the .out files
(~/darkwing-scratch/r233/sum.sh):
| Step | Result |
|---|---|
case1-hostcheck.test.mjs |
16/0/0 |
stack-upgrade.test.mjs |
21/0/0 |
| c0 control | exit 0 |
| M1-M8 | 8/8 killed, none survived, every restore's sha256 matched, tree clean after |
packages/cli suite |
639/0/0 |
scripts/test-*.sh |
auth 15, conductor 17, config 31, discord 78, extension-package 18, foundation 44, land-work 86, queue 156 node + 28 selftest, release 14, task 98; 585 pass, 0 fail |
| Gate total | 11 suites, 1224 pass, 0 fail |
| Sweep | nothing left in the r240 trees; the compose network was removed |
This commit adds only these BUILD.md lines. No test reads BUILD.md.