Files
spoon/docs/superpowers/plans/2026-07-13-box-user.md
T

342 lines
19 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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-<username>` 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/<spoon-username>`; 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 <home>`, 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 <user>` 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<void>` — best-effort `chown -R 1000:1000 <paths>` 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=<value>']`). Exported for tests.
- `ensureUserContainer` gains optional `onCreated?: (containerName: string) => Promise<void>` — invoked only when a fresh container was created (not when already running), AFTER `run` succeeds. Also adds `--hostname <linuxUsername(username)>-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 8128 (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 13.
- Produces:
- `box-settings.ts`: `fetchBoxPassword(username: string): Promise<string | null>` — 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<void>` (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 8128/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 `<username>@<username>-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 - <user>` 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.**