docs: box user design spec (non-root box + user-set password)
This commit is contained in:
@@ -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.
|
||||
Reference in New Issue
Block a user