# 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-`) runs as root: the interactive terminal, agent turns (Claude Code / Codex / OpenCode), and dotfiles setup. This is wrong for three reasons: 1. Claude Code refuses to run certain operations as root, so agent turns hit artificial failures. 2. Root-owned homes make every file mutation a foot-gun (a stray `rm` has no guardrails, tools written for normal users misbehave). 3. 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 `/machine` page. Saving stores the password encrypted in Convex and, when the box is running, applies it live via `chpasswd` — 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 ` **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=`). ### Box init (worker, `ensureUserContainer`) After the `run`, the worker execs an idempotent root init script: 1. `useradd` the sanitized username with uid/gid 1000, shell `/bin/bash`, home `-d /home/` (no `-M` skeleton copy — the mounted home persists; `ensureBashProfile` already seeds login-shell wiring). 2. `usermod -aG wheel ` (normal Fedora admin group; wheel requires a password for sudo, which is exactly the post-password behavior we want). 3. Sudoers drop-in `/etc/sudoers.d/spoon-box`: present with ` ALL=(ALL) NOPASSWD:ALL` **only when no password is stored**; absent otherwise (wheel membership then enforces password-required sudo). Written via `visudo -c` validation; `chmod 0440`. 4. If a password is stored: apply via `chpasswd` **through stdin** (never argv — argv is visible in `ps`). 5. One-time home ownership migration: if `~/.spoon/chown-v1` marker is absent, `chown -R 1000:1000 /home/` 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. 6. `--hostname -box` on the `run` for 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 get `User: `. - Agent turns: `streamExecInContainer` / `runExecInContainer` / `buildMarkedCommand` call sites pass `-u `. - 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 `boxSettings` table (one row per user; do NOT piggyback on the dotfiles/user-environment record — the password must exist independently of whether dotfiles are configured) with `boxPasswordEncrypted?: string`, encrypted with the existing `secretCrypto.ts` AES-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 `hasBoxPassword` owner 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: `chpasswd` via stdin (or `passwd -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 true` succeeds before password; after setting a password `sudo -n true` fails and `sudo true` prompts; 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.