8.5 KiB
Box user: non-root terminal & agent execution with a user-set password
Date: 2026-07-12 Status: Approved design, pending implementation
Problem
Everything inside a user's box container (spoon-box-<username>) runs as root:
the interactive terminal, agent turns (Claude Code / Codex / OpenCode), and
dotfiles setup. This is wrong for three reasons:
- Claude Code refuses to run certain operations as root, so agent turns hit artificial failures.
- Root-owned homes make every file mutation a foot-gun (a stray
rmhas no guardrails, tools written for normal users misbehave). - The box is pitched as "your dev machine in Spoon" — a real machine has a named user, a home they own, sudo, and a password.
Decisions (made with the user)
- sudo policy: passwordless until the user sets a password; once a password is stored, sudo requires it.
- Password UX: a "Box user" card on the
/machinepage. Saving stores the password encrypted in Convex and, when the box is running, applies it live viachpasswd— no restart needed. - Agent turns run as the user too, not just the terminal (this was the original motivation).
- The Linux username mirrors the Spoon username (sanitized, see below); the
prompt reads
gabriel@gabriel-box.
Architecture
The ownership problem (the one hard part)
The worker writes into box homes from the host side: dotfiles overlay
files, .bash_profile, the box file editor (writeBoxFile), and job repo
clones. Dev and prod differ in who that host writer is:
| Dev (rootless podman) | Prod (rootful docker) | |
|---|---|---|
| Host writer | gib (host uid) |
worker container, uid 0 |
| Appears in box as | uid 0 (default mapping) | uid 0 |
| Fix | --userns=keep-id:uid=1000,gid=1000 on the box: the host user is box uid 1000, so host writes are already user-owned |
worker chowns to 1000:1000 after each host-side write |
The uniform mechanism: after every host-side write into a home, the worker
runs chown -R 1000:1000 <paths> inside the container as root. Under
dev's keep-id mapping this is a no-op (files are already uid 1000); under prod
it repairs uid 0 ownership. One code path, no runtime branching at call sites.
The keep-id flag is added to the box run args only when the container
runtime is podman, overridable via SPOON_AGENT_BOX_USERNS (empty string
disables; any other value is passed through as --userns=<value>).
Box init (worker, ensureUserContainer)
After the run, the worker execs an idempotent root init script:
useraddthe sanitized username with uid/gid 1000, shell/bin/bash, home-d /home/<spoon-username>(no-Mskeleton copy — the mounted home persists;ensureBashProfilealready seeds login-shell wiring).usermod -aG wheel <user>(normal Fedora admin group; wheel requires a password for sudo, which is exactly the post-password behavior we want).- Sudoers drop-in
/etc/sudoers.d/spoon-box: present with<user> ALL=(ALL) NOPASSWD:ALLonly when no password is stored; absent otherwise (wheel membership then enforces password-required sudo). Written viavisudo -cvalidation;chmod 0440. - If a password is stored: apply via
chpasswdthrough stdin (never argv — argv is visible inps). - One-time home ownership migration: if
~/.spoon/chown-v1marker is absent,chown -R 1000:1000 /home/<username>and write the marker. Guards against re-walking a large home on every box recreation; later host-side writes are covered by the per-write chown above. --hostname <linux-username>-boxon therunfor a normal-machine prompt.
Username sanitization (linuxUsername() helper, unit-tested): lowercase,
invalid chars → -, must start [a-z_] (prefix u otherwise), truncated to
32 chars, fallback spoon. The home path stays keyed by the Spoon
username (everything already depends on it); only the passwd entry uses the
sanitized name, with -d pointing at the existing home.
Init failure fails the acquire; the terminal bridge already logs it and closes the WebSocket with a readable reason, and agent turns already surface acquire errors.
Execution as the user
- Terminal bridge (
terminal.ts): both job and box execs getUser: <linux-username>. - Agent turns:
streamExecInContainer/runExecInContainer/buildMarkedCommandcall sites pass-u <linux-username>. - Root remains only where needed: box init, post-write chowns, and
killBoxProcessesByMarker(root can signal user processes). - Job secrets files (0600, host-written) are included in post-write chown so agents can still read them.
Password storage (backend, Convex)
- New user-keyed
boxSettingstable (one row per user; do NOT piggyback on the dotfiles/user-environment record — the password must exist independently of whether dotfiles are configured) withboxPasswordEncrypted?: string, encrypted with the existingsecretCrypto.tsAES-256-GCM helpers. - Owner-authed mutation
setBoxPassword(min 8 / max 128 chars; empty clears the password and reverts sudo to NOPASSWD on next apply). - Worker-token-authed Node action returns the decrypted password for box
init, mirroring
getEnvironmentForJob. - A
hasBoxPasswordowner query for the UI. The password itself is write-only: no query ever returns the plaintext to a browser.
Live apply (worker route + Next proxy)
- New worker route
POST /box/set-password(same HMAC internal-token auth as the other/box/*routes), body{ password: string | null }. If the box is running:chpasswdvia stdin (orpasswd -d+ NOPASSWD drop-in restore when clearing), and toggle the sudoers drop-in to match. If not running: no-op success (init applies it at next creation). - Next API route (mirrors the existing
/box/*proxy pattern): verifies the session, writes Convex first, then calls the worker route so a running box updates live.
UI (/machine page)
A "Box user" card: shows user@host identity, whether a password is set, and
a set/change/clear password form (two fields: new password + confirm;
write-only, never displays the current value). Save → Next API → Convex +
live apply → toast. Copy explains the sudo behavior ("no password: sudo works
without one; with a password: sudo prompts for it").
Error handling
- Init script errors → acquire failure → visible terminal close reason / agent turn error (infrastructure added earlier today).
chpasswd/sudoers failure on live apply → worker route 500 with stderr text → surfaced in the card.- Convex write succeeds but live apply fails → card shows a warning that the password takes effect on next box restart (state is still consistent: init re-applies from Convex).
Testing
- Unit (worker):
linuxUsername()sanitizer; init-script builder (user/wheel/sudoers/chpasswd-presence permutations); sudoers content for password vs no-password; exec call sites pass-u/User. - Unit (backend): setBoxPassword validation, encryption round-trip, worker-token gate on the decrypt action.
- Component (next): Box user card renders states (no password / password set / apply-failed warning).
- End-to-end (manual, WS harness from today):
whoami→ username;id -u→ 1000;sudo -n truesucceeds before password; after setting a passwordsudo -n truefails andsudo trueprompts; agent turn creates a file owned by the user; dotfiles + box file editor writes remain user-editable in both dev and prod runtimes.
Migration
Existing boxes pick everything up on their next recreation (idle reap or
/machine Restart) — no data migration. The one-time home chown handles
files root already created. Docs (docs/agent-terminal.md, box docs) get a
section on the box user + password.
Out of scope
- SSH access to the box.
- Multiple users / teams per box.
- Password complexity policies beyond length bounds.
- Changing the home path layout.