# 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`, `Before=mosaic-bus@mosaic-stack.service`, 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 (` `), and `--enforce` never removes it. The log line is written only when the stop runs: ` enforce stop items= drift-before=<0|1> unload= 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.