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