- Status: done
- Date: 2026-06-15
- Specs touched: none — realizes
TESTING.md# Full-stack control-plane integration (the headless first-run + Real PAM login rows) and exercisesFIRST_RUN.md# Identity & display names +USERS_AND_GROUPS.md# Roles end-to-end; no spec change.
Closes #166 (M1c, part of #161), the next link after brain-control-plane-stack.md (#165/M1b), which brought the dashboard /api leg live through Caddy and explicitly left M1c as "the harness helper + assertion, built directly on this slice." The brain /setup handler (brain-commits-first-then-host with rollback) and host-agent-real's usermgr (real useradd/chpasswd/gpasswd/userdel) were already complete from earlier slices — this change makes that path exercisable end-to-end on a real VM by provisioning the box prerequisites it depends on and adding the scriptable, browser-free assertion the QEMU lane drives.
The Go control-plane path needed no change — /setup (internal/api/auth.go) and the usermgr.LinuxUserManager shell-outs already implement the real first-run. The gap was entirely in the outer-loop image + harness (dev/test-qemu/): the medium-lane VM had no malmo primary group, no sudo group, and no /etc/pam.d/malmo, so the real /setup would 502 (useradd --gid malmo / gpasswd -a … sudo) and verify-password would deny (PAM falling back to /etc/pam.d/other).
Box prerequisites (build time).
mkosi.confadds thesudopackage, which provisions thesudogroup at its canonical GID so/setup'sSetRole(gpasswd -a <user> sudo) has a real group to add the first admin to. Mirrors the nspawn lane, which installssudofor the same reason, and is faithful to a real box (admins holdsudofor rescue SSH —USERS_AND_GROUPS.md# Roles).mkosi.postinst.chrootcreates themalmoprimary group at GID 3000 (getent group malmo || groupadd -g 3000 malmo, idempotent), perFIRST_RUN.md# Identity & display names.usermgrdefaultsPrimaryGrouptomalmo, so this group must exist beforeuseradd --gid malmoruns.bootstrap.shstages the canonicaldev/pam/malmointo the image at/etc/pam.d/malmo— the PAM servicehost-agent-real'spamverifierdials (Service: "malmo"). Without itpam_start("malmo")falls back to/etc/pam.d/other(deny) and a correct/etc/shadowentry still 401s on/login.
Harness assertion (medium-assertions.sh, M1c block). After the M1b control-plane checks, a new block drives the headless first-run over Caddy on :80 — the same scriptable HTTP path with no browser. The image carries no curl/jq, so a new http_post helper hand-builds the request over bash /dev/tcp (the POST analogue of the existing http_status GET helper, with Content-Type/Content-Length and the JSON body):
POST /api/v1/setup— accept200on a fresh box or409("setup has already completed") when the disk has already been through first-run (the medium lane reuses one disk across the first-boot and second-boot phases, so the admin and its brain SQLite row persist into boot 2). A502(missing group,useradd/chpasswderror) is polled briefly to ride out brain-not-ready, then surfaced with the response body for diagnosis.- Host-side account checks — the admin is a real Linux user with primary group
malmo(provesSetPassword'suseradd --gid malmo) and a member ofsudo(provesSetRole— the first admin lands insudoat creation). POST /api/v1/login— a single attempt (no retry, since the brain rate-limits failed logins) must return200, proving the account authenticates against/etc/shadowvia host-agentverify-password(PAMpam_unix, servicemalmo). This is the M1c "Done when." The brain holds no password hash; the round-trip is the only authentication path.
The block runs in the common (pre-phase) section, so it asserts on both the first-boot (fresh → 200) and second-boot (disk reuse → 409, account + shadow survived the encrypted-root reboot) phases.
TESTING.md# Full-stack control-plane integration — the "real, headless first-run" row (/setupcreates the admin through the real PAM/useradd/chpasswdpath, driven over SSH with no interactive UI) and the "Real PAM login" row (the first-run admin authenticates against/etc/shadowthrough host-agentverify-password, no brain-side password hash) are now both exercised by the medium lane.FIRST_RUN.md# Identity & display names — themalmoprimary group at GID 3000 is the box-side half of the slug → Linux-user mapping.USERS_AND_GROUPS.md# Roles — the first admin is added tosudoat creation; the assertion verifies the real group membership.- Brain-commits-first-then-host (
CLAUDE.mdload-bearing decision) — unchanged;/setupalready follows it, and the assertion's409-on-retry behaviour depends on the brain row being the durable fence.
http_postwire format + status parsing smoke-tested in isolation against a throwaway local HTTP server: correct POST request line, accurateContent-Length(byte length of the ASCII body), body delivered, full response captured viacat, and the*" 200"*/*" 409"*casematching confirmed. All three edited shell scripts passbash -n.- Brain side already covered —
/setupand/loginhandlers (200/409/422/401/502 paths, the rollback ordering) are unit-tested ininternal/api/auth_test.go;usermgr'suseradd/chpasswd/gpasswdshell-outs ininternal/hostagent/usermgr/. No Go change here, somake checkis unaffected (run as the pre-PR gate). - VM-boot acceptance pending — the end-to-end "Done when" runs only on a real boot:
sudo make test-medium-qemuon an mkosi/swtpm/QEMU host, asserting the newcontrol-plane M1c: /setup created the admin, verify-password authenticated it against /etc/shadowline on both phases. Not run in this environment (no QEMU/KVM here); it rides the same outstandingtest-medium-qemuacceptance as the M0/M1a/M1b slices.
- Run the medium lane on a real QEMU host to confirm the M1c assertion passes on both boot phases — the shared outer-loop acceptance still outstanding for M0/M1a/M1b.
- M2 (#167): full-stack integration assertions — app install end-to-end through the now-first-run box, the final link in #161.
- UID range is the Debian default, not the malmo-reserved 3000+. This slice provisions the
malmogroup at GID 3000 but does not tune/etc/login.defsUID_MIN, so the test admin gets UID 1000. Faithful UID allocation (FIRST_RUN.md# Identity & display names) is a box-build concern that does not affect the verify-password round-trip; left out to keep the change surgical. - The full first-run wizard (network pick + recovery-passphrase) headless automation is separate (deferred per #161). This is the minimal scriptable
/setuponly; the wizard's headless path is tracked on its own and must coordinate the/setuptouch-points to avoid collision.