From aea2aa2c5ce0a8b1f89ce362f3a80f5ee4ad70b6 Mon Sep 17 00:00:00 2001 From: Gabriel Brown Date: Sun, 12 Jul 2026 22:42:32 -0400 Subject: [PATCH] docs: box user design spec (non-root box + user-set password) --- .../specs/2026-07-12-box-user-design.md | 166 ++++++++++++++++++ 1 file changed, 166 insertions(+) create mode 100644 docs/superpowers/specs/2026-07-12-box-user-design.md diff --git a/docs/superpowers/specs/2026-07-12-box-user-design.md b/docs/superpowers/specs/2026-07-12-box-user-design.md new file mode 100644 index 0000000..afb3ac4 --- /dev/null +++ b/docs/superpowers/specs/2026-07-12-box-user-design.md @@ -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-`) 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.