- Status: done
- Date: 2026-05-24
- Specs touched:
BRAIN_HOST_PROTOCOL.md,AUTH.md,USERS_AND_GROUPS.md,FIRST_RUN.md
Closes Tier-B point 1 of the auth slice (0011 "What's next"). POST /v1/auth/set-password in host-agent-real now writes to /etc/shadow via
useradd + chpasswd, so the brain's bootstrap path (POST /setup → SetPassword) creates a real Linux user — and the PAM verifier from 0011 can
finally succeed against the password that was just set. End-to-end dashboard
login on host-agent-real works for the first time.
setRole and deleteUser remain in-memory fakes — they're independent
follow-ups (next slices).
internal/hostagent/agent.go gains a new consumer-side interface alongside
PasswordVerifier and Publisher:
type UserManager interface {
UpsertPassword(user, password string) error
}Agent gets a UserMgr UserManager field. The setPassword handler branches:
UserMgr != nil(the real binary) — delegate toUpsertPasswordand return200 OK {}on success,500 set-password-failedon system error. The underlying error is captured in a structuredslog.Error("set-password: user-manager error", ...); the HTTP body says only"set-password failed", same posture asverify-password.UserMgr == nil(the fake binary, and existing tests) — keep the bcrypt-into-a.passwordsbehavior soFakeVerifierand the bootstrap-flow tests still pass.
This keeps cmd/host-agent (fake) untouched and contains the real /etc/shadow
write to a single new package.
New package with LinuxUserManager:
type LinuxUserManager struct {
Shell string // default "/bin/bash"
PrimaryGroup string // default "malmo"
}
func (m *LinuxUserManager) UpsertPassword(slug, password string) errorBehavior:
user.Lookup(slug). Onlyuser.UnknownUserErroris treated as "missing — create"; any other lookup failure (nss config broken, etc.) returns loud.- If missing:
useradd --create-home --shell <Shell> --gid <PrimaryGroup> <slug>. UID assignment is delegated to/etc/login.defs(UID_MIN ≥ 3000perFIRST_RUN.md# Identity & display names — set at box-build time, not forced via-u). chpasswdwith stdin"<slug>:<password>\n".chpasswdgoes through PAM, which triggers Samba sync when the host is configured withunix password sync = yes+pam password change = yes(AUTH.md# Samba password backend).
Stderr from useradd/chpasswd is wrapped into the returned error for the
structured log; never reaches the wire.
a := hostagent.New(&pamverifier.PAMVerifier{Service: "malmo"}, pub)
a.UserMgr = &usermgr.LinuxUserManager{}The startup slog.Warn was tightened: previously listed all three of
set-password / set-role / delete-user as "NOT wired to the system"; now lists
only set-role / delete-user.
internal/hostagent/agent_test.go— two new tests:TestSetPassword_DelegatesToUserMgrWhenSet— asserts the handler callsUpsertPasswordwith the right args and does not populate the in-memory bcrypt map whenUserMgris wired.TestSetPassword_UserMgrError_Returns500— asserts a 500 response and that the body never leaks system detail (no"useradd"/ no group name).
internal/hostagent/usermgr/linux_test.go— unit tests foruseraddArgs(default + override) and the empty-slug guard. No system calls.internal/hostagent/usermgr/integration_test.go—//go:build usermgrtest, exercises realuseradd+chpasswdagainst/etc/passwdand/etc/shadow. Createsmalmo-usermgrtest, asserts the shadow hash is non-empty, then rotates the password (update path). Cleans up withuserdel -r. Skips when not root. Intended for the nspawn lane.
New target test-usermgr that runs the tagged integration test under sudo.
Documented as "nspawn lane only — do NOT run on a developer laptop." .PHONY
updated.
BRAIN_HOST_PROTOCOL.md# Credential mutation endpoints —set-passwordis upsert (creates if missing, updates otherwise) in a single round-trip. Wire shape unchanged; only the back-end becomes real.AUTH.md# Identity primitive / # Password change —/etc/shadowis the source of truth; brain holds no hash;chpasswd(PAM path) is the Samba-sync-friendly mutator.USERS_AND_GROUPS.md— accounts are real Linux users with primary groupmalmo; promotion intosudois a separate operation (still fake, will land with the realsetRole).FIRST_RUN.md# Identity & display names — UID range ≥ 3000 honored via/etc/login.defs(host responsibility), not forced by host-agent.- CLAUDE.md "consumer-side interfaces" —
UserManagerlives ininternal/hostagent, not ininternal/hostagent/usermgr. - CLAUDE.md "no premature abstraction" — single concrete implementation;
interface justified by the consumer-side rule + the same nil-pointer seam
used by
Publisher/Verifier.
- No use-case folder creation.
useradd --create-homemakes/home/<slug>/but does NOT populatePhotos/,Documents/,Movies/,Music/,Notes/,Downloads/(perSTORAGE.md# What apps and users actually see). That's a separate slice — likely a sibling host-agent endpoint or an extension of this one once the storage layer lands. malmogroup must pre-exist.useradd --gid malmofails if the group is absent. The box build is expected to create it; host-agent does not. The integration test falls back tonogroup/userson a stock Debian nspawn so the test still runs before the build pipeline provisions the group.- Samba sync is unverified.
chpasswdgoes through PAM, which is the right hook for Samba'spam password changeintegration, but Samba isn't installed here yet so we don't actually exercise the sync. Dashboard login works regardless (PAM-only path). - No password complexity / length enforcement at host-agent. The brain owns
input validation (
AUTH.md); host-agent trusts what it receives. setRoleanddeleteUserstill fake even inhost-agent-real. The startup warning lists them; tests assert the existing fake behavior.- No
chpasswd -e/ hash-mode handling. We feed cleartext on stdin and let PAM hash it according to system policy (yescrypt or argon2 perAUTH.md).
- Real
setRoleinhost-agent-real(gpasswd -a/-d <slug> sudo, perUSERS_AND_GROUPS.md:30). - Real
deleteUserinhost-agent-real(userdel -r). - Use-case folder creation (
Photos/,Documents/, ...) as a follow-up host-agent op; owner TBD between this package and a futurestoragepackage. - nspawn lane wiring for
test-usermgr(provisionmalmogroup, run under systemd-nspawn instead of host root).