diff --git a/docs/superpowers/plans/2026-07-13-box-user.md b/docs/superpowers/plans/2026-07-13-box-user.md new file mode 100644 index 0000000..2e66e48 --- /dev/null +++ b/docs/superpowers/plans/2026-07-13-box-user.md @@ -0,0 +1,341 @@ +# Box User Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Terminal sessions and agent turns inside `spoon-box-` run as a named non-root user (uid 1000) with wheel/sudo and a user-settable password stored encrypted in Convex. + +**Architecture:** The worker creates the Linux user at box init via an idempotent root exec script; dev podman maps the host user onto uid 1000 with `--userns=keep-id`, prod docker repairs host-written file ownership with targeted in-container chowns. Password lives in a new `boxSettings` Convex table (AES-256-GCM via existing `secretCrypto`), applied at init and live via a new `/box/set-password` worker route; a "Box user" card on `/machine` drives it. + +**Tech Stack:** bun + TypeScript worker (execa/dockerode), Convex backend, Next 15 App Router, vitest. + +**Spec:** `docs/superpowers/specs/2026-07-12-box-user-design.md` + +## Global Constraints + +- Box user uid/gid: `1000:1000`, fixed. +- sudo: NOPASSWD drop-in `/etc/sudoers.d/spoon-box` present only while no password is stored; wheel membership always. +- Password bounds: min 8, max 128 chars; `null`/empty clears. +- Password plaintext never in argv (stdin only), never returned to a browser. +- Home path stays `/home/`; only the passwd entry name is sanitized. +- podman-only run flag: `--userns=keep-id:uid=1000,gid=1000`, override env `SPOON_AGENT_BOX_USERNS` (empty disables, other values pass through). +- All commits run the repo pre-commit hooks; finish with full `bun run ci:check`. + +--- + +### Task 1: `box-user.ts` — sanitizer + script builders (worker, pure) + +**Files:** + +- Create: `apps/agent-worker/src/runtime/box-user.ts` +- Test: `apps/agent-worker/tests/unit/box-user.test.ts` + +**Interfaces (Produces):** + +- `linuxUsername(spoonUsername: string): string` +- `BOX_UID = 1000` +- `buildBoxInitScript(args: { username: string; home: string }): string` — idempotent root script: useradd (uid 1000, `-d `, bash), wheel, marker-guarded `chown -R` of home. Does NOT touch sudoers/password (those depend on stored state and are applied by `applySudoPolicy`). +- `buildSudoPolicyScript(args: { username: string; passwordSet: boolean }): string` — writes or removes `/etc/sudoers.d/spoon-box` (mode 0440, validated with `visudo -c` before install). +- `CHPASSWD_COMMAND: string[]` (`['chpasswd']`) and `buildClearPasswordCommand(username: string): string[]` (`['passwd', '-d', username]`). + +- [ ] **Step 1: Write failing tests** covering: sanitizer (lowercase, invalid → `-`, digit-start prefixed `u`, >32 truncated, empty → `spoon`); init script contains `useradd` guarded by `id -u`, `-u 1000`, `-d /home/Gabriel` untouched-case home, wheel usermod, marker path `/home/Gabriel/.spoon/chown-v1` guard around `chown -R 1000:1000`; sudo script with `passwordSet:false` installs NOPASSWD via visudo-validated temp file, with `passwordSet:true` removes the drop-in. + +```ts +// apps/agent-worker/tests/unit/box-user.test.ts +import { describe, expect, it } from 'vitest'; + +import { + buildBoxInitScript, + buildClearPasswordCommand, + buildSudoPolicyScript, + linuxUsername, +} from '../../src/runtime/box-user'; + +describe('linuxUsername', () => { + it('lowercases and passes through simple names', () => { + expect(linuxUsername('gabriel')).toBe('gabriel'); + expect(linuxUsername('Gabriel')).toBe('gabriel'); + }); + it('replaces invalid characters with dashes', () => { + expect(linuxUsername('g@b r!el')).toBe('g-b-r-el'); + }); + it('prefixes names that do not start with [a-z_]', () => { + expect(linuxUsername('1337')).toBe('u1337'); + expect(linuxUsername('-dash')).toBe('u-dash'); + }); + it('truncates to 32 chars and falls back to spoon', () => { + expect(linuxUsername('a'.repeat(40))).toHaveLength(32); + expect(linuxUsername('')).toBe('spoon'); + expect(linuxUsername('@@@')).toBe('u---'); + }); +}); + +describe('buildBoxInitScript', () => { + const script = buildBoxInitScript({ + username: 'gabriel', + home: '/home/Gabriel', + }); + it('creates the user idempotently with uid 1000 at the given home', () => { + expect(script).toContain("id -u 'gabriel'"); + expect(script).toContain('useradd'); + expect(script).toContain('-u 1000'); + expect(script).toContain("-d '/home/Gabriel'"); + expect(script).toContain('-s /bin/bash'); + }); + it('adds wheel membership', () => { + expect(script).toContain("usermod -aG wheel 'gabriel'"); + }); + it('chowns the home once, guarded by the marker', () => { + expect(script).toContain('/home/Gabriel/.spoon/chown-v1'); + expect(script).toContain("chown -R 1000:1000 '/home/Gabriel'"); + }); +}); + +describe('buildSudoPolicyScript', () => { + it('installs a visudo-validated NOPASSWD drop-in when no password is set', () => { + const script = buildSudoPolicyScript({ + username: 'gabriel', + passwordSet: false, + }); + expect(script).toContain('gabriel ALL=(ALL) NOPASSWD:ALL'); + expect(script).toContain('visudo -c'); + expect(script).toContain('/etc/sudoers.d/spoon-box'); + expect(script).toContain('chmod 0440'); + }); + it('removes the drop-in when a password is set', () => { + const script = buildSudoPolicyScript({ + username: 'gabriel', + passwordSet: true, + }); + expect(script).toContain('rm -f /etc/sudoers.d/spoon-box'); + expect(script).not.toContain('NOPASSWD'); + }); +}); + +describe('password commands', () => { + it('clears via passwd -d', () => { + expect(buildClearPasswordCommand('gabriel')).toEqual([ + 'passwd', + '-d', + 'gabriel', + ]); + }); +}); +``` + +- [ ] **Step 2: Run to verify failure** — `cd apps/agent-worker && bun run test:unit -- box-user` → module not found. +- [ ] **Step 3: Implement** + +```ts +// apps/agent-worker/src/runtime/box-user.ts +// The per-box Linux user (Phase: box-user). Terminal sessions and agent turns +// exec as this user instead of root; sudo is passwordless until the user +// stores a password (then wheel membership enforces password-required sudo). + +export const BOX_UID = 1000; + +const shellQuote = (value: string) => `'${value.replaceAll("'", "'\\''")}'`; + +// Linux-safe username derived from the Spoon username. The home path keeps +// the Spoon username; only the passwd entry uses this. +export const linuxUsername = (spoonUsername: string): string => { + const cleaned = spoonUsername + .toLowerCase() + .replaceAll(/[^a-z0-9_-]/g, '-') + .slice(0, 32); + if (!cleaned) return 'spoon'; + return /^[a-z_]/.test(cleaned) ? cleaned : `u${cleaned}`.slice(0, 32); +}; + +// Idempotent root init: create the user at the (already mounted) home, grant +// wheel, and chown the home ONCE (marker-guarded — later host-side writes are +// repaired by targeted chowns, so a big home is never re-walked). +export const buildBoxInitScript = (args: { + username: string; + home: string; +}): string => { + const user = shellQuote(args.username); + const home = shellQuote(args.home); + const marker = shellQuote(`${args.home}/.spoon/chown-v1`); + return [ + 'set -e', + `if ! id -u ${user} >/dev/null 2>&1; then`, + ` useradd -u 1000 -o -M -d ${home} -s /bin/bash ${user}`, + 'fi', + `usermod -aG wheel ${user}`, + `if [ ! -f ${marker} ]; then`, + ` chown -R 1000:1000 ${home}`, + ` mkdir -p "$(dirname ${marker})" && touch ${marker} && chown 1000:1000 ${marker}`, + 'fi', + ].join('\n'); +}; + +// Sudo policy: NOPASSWD drop-in only while no password is stored; with a +// password, wheel membership makes sudo prompt for it. The drop-in is staged +// to a temp file and checked with `visudo -c` before install so a bad write +// can never brick sudo. +export const buildSudoPolicyScript = (args: { + username: string; + passwordSet: boolean; +}): string => { + if (args.passwordSet) { + return 'rm -f /etc/sudoers.d/spoon-box'; + } + const line = `${args.username} ALL=(ALL) NOPASSWD:ALL`; + return [ + 'set -e', + `printf '%s\\n' ${shellQuote(line)} > /etc/sudoers.d/spoon-box.tmp`, + 'visudo -c -f /etc/sudoers.d/spoon-box.tmp', + 'chmod 0440 /etc/sudoers.d/spoon-box.tmp', + 'mv /etc/sudoers.d/spoon-box.tmp /etc/sudoers.d/spoon-box', + ].join('\n'); +}; + +// `chpasswd` reads `user:password` from stdin, so the password never appears +// in argv (visible in `ps`) or in a shell script body. +export const CHPASSWD_COMMAND = ['chpasswd']; + +export const buildClearPasswordCommand = (username: string): string[] => [ + 'passwd', + '-d', + username, +]; +``` + +- [ ] **Step 4: Run to verify pass** — `bun run test:unit -- box-user` → all pass. +- [ ] **Step 5: Commit** — `feat(worker): box-user script builders + username sanitizer` + +### Task 2: docker.ts plumbing — exec-as-user, stdin input, chown helper, box run flags + +**Files:** + +- Modify: `apps/agent-worker/src/runtime/docker.ts` +- Test: `apps/agent-worker/tests/unit/docker-runtime.test.ts` (extend) + +**Interfaces:** + +- Consumes: `linuxUsername`, `BOX_UID`, `buildBoxInitScript`, `buildSudoPolicyScript`, `CHPASSWD_COMMAND`, `buildClearPasswordCommand` from Task 1. +- Produces: + - `streamExecInContainer` / `runExecInContainer` gain optional `user?: string` (adds `-u ` to the exec argv) and `runExecInContainer` gains optional `input?: string` (execa `input`, replacing `stdin: 'ignore'` when present). + - `chownInBox(args: { containerName: string; paths: string[]; redact?: (v: string) => string }): Promise` — best-effort `chown -R 1000:1000 ` exec'd as root; logs (does not throw) on failure. No-op for empty `paths`. + - `boxUsernsArgs(): string[]` — `['--userns=keep-id:uid=1000,gid=1000']` when runtime ends with `podman`; `SPOON_AGENT_BOX_USERNS` overrides (empty string → `[]`, other value → `['--userns=']`). Exported for tests. + - `ensureUserContainer` gains optional `onCreated?: (containerName: string) => Promise` — invoked only when a fresh container was created (not when already running), AFTER `run` succeeds. Also adds `--hostname -box` and `...boxUsernsArgs()` to the `run` argv. +- New env in `apps/agent-worker/src/env.ts`: `boxUserns: process.env.SPOON_AGENT_BOX_USERNS` (raw, may be empty string — distinguish unset). + +- [ ] **Step 1: Write failing tests** in `docker-runtime.test.ts` for `boxUsernsArgs` (podman default, docker default `[]`, override set, override empty) and for the exec arg builders if extracted pure (extract `execArgv(args)` helper returning the argv array so `-u` placement is unit-testable: `['exec', ...envFlags, '-w', cwd, ...(user ? ['-u', user] : []), name, ...command]`). +- [ ] **Step 2: Run to verify failure.** +- [ ] **Step 3: Implement** (extract `execArgv` used by both exec fns; add `input`/`user` params; `chownInBox` via `runExecInContainer` with `command: ['chown','-R','1000:1000',...paths]`, catches and `console.error`s; `boxUsernsArgs` reading `env.boxUserns`; wire `--hostname`/userns/`onCreated` into `ensureUserContainer`). +- [ ] **Step 4: Run tests** — extended docker-runtime tests + whole unit suite pass. +- [ ] **Step 5: Commit** — `feat(worker): exec-as-user, stdin input, chown + userns plumbing` + +### Task 3: Convex — `boxSettings` table + node actions + +**Files:** + +- Modify: `packages/backend/convex/schema.ts` +- Create: `packages/backend/convex/boxSettings.ts` (query + internal query/mutation) +- Create: `packages/backend/convex/boxSettingsNode.ts` ('use node' actions) +- Test: follow the existing backend test layout (`packages/backend/tests/…`, convex-test) mirroring the closest existing coverage of `aiSettingsNode`/`userDotfiles`. + +**Interfaces (Produces):** + +- Schema: + +```ts +boxSettings: defineTable({ + ownerId: v.id('users'), + // Resolved Spoon home username at the time the password was set — the + // worker looks the row up by this (usernames are not indexed anywhere else). + username: v.string(), + boxPasswordEncrypted: v.optional(v.string()), + updatedAt: v.number(), +}) + .index('by_owner', ['ownerId']) + .index('by_username', ['username']), +``` + +- `boxSettings.hasMine` — owner query → `{ hasPassword: boolean }`. +- `boxSettings.getByUsernameInternal` / `boxSettings.upsertMineInternal` — internal plumbing for the node actions. +- `boxSettingsNode.setBoxPassword` — authed action `{ password: string | null }`; validates 8–128 (null/'' clears), resolves the caller's username exactly like `userEnvironment.getMine` (reuse `deriveHomeUsername` from `model.ts`), stores `encryptSecret(password)` or clears the field. +- `boxSettingsNode.getBoxPasswordForWorker` — action `{ workerToken: string; username: string }` → `{ password: string | null }`, gated by the same worker-token check used in `userDotfilesNode.getEnvironmentForJob`; decrypts with `decryptSecret`. + +- [ ] **Step 1: Write failing backend tests** (validation bounds, clear semantics, worker-token gate rejects bad token, round-trip returns the plaintext). +- [ ] **Step 2: Run to verify failure** (`cd packages/backend && bun run test` — use the package's existing test script name). +- [ ] **Step 3: Implement schema + functions; run `bash scripts/convex-codegen`.** +- [ ] **Step 4: Run tests + typecheck.** +- [ ] **Step 5: Commit** — `feat(backend): boxSettings table + box password actions` + +### Task 4: Worker — box init wiring (user creation, password at creation, exec-as-user everywhere) + +**Files:** + +- Create: `apps/agent-worker/src/box-settings.ts` (Convex fetch) +- Modify: `apps/agent-worker/src/user-container.ts`, `apps/agent-worker/src/box.ts`, `apps/agent-worker/src/terminal.ts`, `apps/agent-worker/src/worker.ts`, `apps/agent-worker/src/user-environment.ts`, adapters (`runtime/claude-adapter.ts`, `runtime/codex-adapter.ts`, `runtime/opencode-adapter.ts`) and `runtime/agent-runtime.ts` arg types. +- Test: extend `apps/agent-worker/tests/unit/user-container.test.ts`, adapter tests (assert `user` is forwarded to `execStream`), `terminal-resize.test.ts` untouched. + +**Interfaces:** + +- Consumes: everything from Tasks 1–3. +- Produces: + - `box-settings.ts`: `fetchBoxPassword(username: string): Promise` — ConvexHttpClient action call to `api.boxSettingsNode.getBoxPasswordForWorker` with `env.workerToken`; returns null on any error (box must still start when Convex is down; log the error). + - `initializeBoxUser(containerName: string, spoonUsername: string, home: string): Promise` (in `box.ts` or a small `box-init.ts`): runs `buildBoxInitScript` as root, fetches password, applies `chpasswd` via stdin when set, then `buildSudoPolicyScript`. This is the `onCreated` hook passed to `ensureUserContainer` from BOTH call paths (`acquireUserBox` in `user-container.ts` and `startBox` in `box.ts`). + - Terminal bridges pass `User: linuxUsername(username)` in the dockerode exec create (both `bridge` and `bridgeBox`). + - `worker.ts` / adapters: exec arg objects gain `user: linuxUsername(claim/workspace username)`; `runProjectCommand`, agent turn `execStream` calls, and dotfiles `runExecInContainer` in `materializeUserHome` all pass it. `killBoxProcessesByMarker` stays root. + - Chown call sites (all best-effort `chownInBox`): after `cloneRepository` (repo dir), after secrets env-file write in `worker.ts` (the `claim.job.envFilePath` target), after overlay-file writes in `materializeUserHome`, after `writeBoxFile` in `box.ts`, after `ensureBashProfile` when invoked with a running box (bridgeBox: chown `~/.bash_profile` right after acquire). + +- [ ] **Step 1: Write failing tests** — user-container test asserting `onCreated` fires once per creation (mock `ensureUserContainer` invoking its hook); adapter test asserting `user` reaches `execStream` args. +- [ ] **Step 2: Run to verify failure.** +- [ ] **Step 3: Implement the wiring.** +- [ ] **Step 4: Full worker unit suite + typecheck + lint pass.** +- [ ] **Step 5: Commit** — `feat(worker): run terminals and agent turns as the box user` + +### Task 5: Worker — `/box/set-password` route + +**Files:** + +- Modify: `apps/agent-worker/src/server.ts` (route regex + handler), `apps/agent-worker/src/box.ts` (`setBoxUserPassword`) +- Test: extend `apps/agent-worker/tests/unit/box-route.test.ts` (regex accepts `set-password`). + +**Interfaces:** + +- Produces: `setBoxUserPassword(username: string, password: string | null): Promise<{ applied: boolean }>` — if box not running → `{ applied: false }`; else chpasswd/clear + sudo policy toggle → `{ applied: true }`. Route: `POST /box/set-password` body `{ password: string | null }`, 400 on invalid length, 200 `{ applied }`. + +- [ ] Steps: failing route-regex test → implement → suite green → commit `feat(worker): live box password apply route`. + +### Task 6: Next — proxy + API route + +**Files:** + +- Modify: `apps/next/src/lib/agent-worker-proxy.ts` (`proxyBox` action union + `'set-password'`) +- Create: `apps/next/src/app/api/box/set-password/route.ts` + +**Interfaces:** + +- Produces: `POST /api/box/set-password` `{ password: string | null }` → `withBox` → validate 8–128/null → `fetchAction(api.boxSettingsNode.setBoxPassword, { password }, { token })` (Convex is source of truth, written first) → `proxyBox(username, 'set-password', { method: 'POST', body: JSON.stringify({ password }) })` → respond `{ hasPassword, applied }`; worker failure downgrades to `{ applied: false }` with 200 (spec: card shows "takes effect on restart"). + +- [ ] Steps: implement (route has no unit test — component test in Task 7 covers the card; the route mirrors existing `/api/box/*` one-screen handlers) → typecheck/lint → commit `feat(next): box password API`. + +### Task 7: Next — Box user card on /machine + +**Files:** + +- Create: `apps/next/src/components/machine/box-user-card.tsx` +- Modify: `apps/next/src/components/machine/machine-shell.tsx` (render the card) +- Test: `apps/next/tests/component/box-user-card.test.tsx` + +**Interfaces:** + +- Consumes: `api.boxSettings.hasMine` (convex react query), `api.userEnvironment.getMine` (username for the `user@host` line), `POST /api/box/set-password`. +- Behavior: shows `@-box`; "Password: set / not set"; form (password + confirm, min 8) with Save and — when set — a "Remove password" button; on save success where `applied === false`, shows the "takes effect next restart" note; explains the sudo policy in one sentence of muted copy. + +- [ ] Steps: failing component test (renders not-set state; mismatch confirm blocks submit; renders applied-false warning) → implement card + wire into `machine-shell.tsx` → component suite green → commit `feat(next): box user card on /machine`. + +### Task 8: Docs + end-to-end verification + +**Files:** + +- Modify: `docs/agent-terminal.md` (+ the box docs section touched in the docs rewrite) — box user, sudo policy, password flow, ownership model (keep-id vs chown), `SPOON_AGENT_BOX_USERNS`. + +- [ ] **Step 1: Docs.** +- [ ] **Step 2: Live verification (dev)** — recreate the box, then via the WS harness: `whoami` → username; `id -u` → 1000; `sudo -n true` → ok; `touch ~/x && ls -l` → user-owned; box file editor write → file user-editable in the terminal. Set a password via the UI (or curl the API route), then: `sudo -n true` fails, `chpasswd` took effect (`su - ` with the password succeeds); clear password → NOPASSWD sudo returns. Run one agent turn and confirm it executes as the user (`whoami` artifact or a file it creates is uid 1000). +- [ ] **Step 3: `bun run ci:check` at repo root — all green.** +- [ ] **Step 4: Commit docs; final review of the whole diff.**