docs: box user design spec (non-root box + user-set password)

This commit is contained in:
Gabriel Brown
2026-07-12 22:42:32 -04:00
parent b038a31520
commit aea2aa2c5c
@@ -0,0 +1,166 @@
# 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:
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 <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:
1. `useradd` the sanitized username with uid/gid 1000, shell `/bin/bash`,
home `-d /home/<spoon-username>` (no `-M` skeleton copy — the mounted home
persists; `ensureBashProfile` already seeds login-shell wiring).
2. `usermod -aG wheel <user>` (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
`<user> 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/<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.
6. `--hostname <linux-username>-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: <linux-username>`.
- Agent turns: `streamExecInContainer` / `runExecInContainer` /
`buildMarkedCommand` call 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 `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.