docs: box user implementation plan
This commit is contained in:
@@ -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-<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 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<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 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 `<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.**
|
||||
Reference in New Issue
Block a user