Files
spoon/docs/superpowers/specs/2026-07-12-box-user-design.md
T

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:

  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.