Files
jason.woltje c515273db7 row 240: installer I4, root's host check, its pins and the boot unit; I3's N1, N4 and N5 (#1746, Vikunja #432)
Lands Darkwing's round 1 candidate, manifest 573ece95a4a9 (ad1b23ab2 on darkwing/row240-r3, base
c771a36f4), 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 fix b45852daa is test-only. The second run on b45852daa was green:
singles 37/0, mutants M1-M8 8 of 8 killed, cli 639/0, scripts 585/0. Since c771a36f4 no commit
touches the nine paths. Sage's gate on 060a17b54 plus 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.
2026-10-21 01:56:47 -05:00

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's tests/*.test.mjs glob picks it up.
  • docs/guides/host-run-sheet.md section 4: the install lines that replace "Install them from row 240's run sheet lines once it lands", plus rollback, drift recovery and --upstream before a re-pin window. Sections 3, 6, 8 and 10 for the I3 carry items below.
  • scripts/stack-upgrade.sh and packages/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's stack-upgrade.sh default. r6's apt hook line says case1-hostcheck.sh; the run sheet's hook uses the installed name.
  • stat is a fake too. r6 lists seven fakes; a test can't make a root-owned file, so item 5's stat reads 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 --port fails.
  • Item 4 runs under timeout 60. A hung systemd-run would 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 -S exit 1 (no owner) passes, exit 0 fails with the owner, any other exit fails with the code;
    • a failed aa-status fails item 2 and counts as loaded, so --enforce tries the unload.
  • --root DIR is the test seam (r6 :840). Root refuses it; a non-root user needs it. Both exit 2. Root runs with PATH=/usr/sbin:/usr/bin:/sbin:/bin.
  • DRIFT is append-only (<stamp> <failed line>), and --enforce never 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.
  • --upstream exits 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 --check before root removes DRIFT) are mine. r6 says only that root removes DRIFT in 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.