# 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.**