19 KiB
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-boxpresent 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 envSPOON_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-guardedchown -Rof home. Does NOT touch sudoers/password (those depend on stored state and are applied byapplySudoPolicy). -
buildSudoPolicyScript(args: { username: string; passwordSet: boolean }): string— writes or removes/etc/sudoers.d/spoon-box(mode 0440, validated withvisudo -cbefore install). -
CHPASSWD_COMMAND: string[](['chpasswd']) andbuildClearPasswordCommand(username: string): string[](['passwd', '-d', username]). -
Step 1: Write failing tests covering: sanitizer (lowercase, invalid →
-, digit-start prefixedu, >32 truncated, empty →spoon); init script containsuseraddguarded byid -u,-u 1000,-d /home/Gabrieluntouched-case home, wheel usermod, marker path/home/Gabriel/.spoon/chown-v1guard aroundchown -R 1000:1000; sudo script withpasswordSet:falseinstalls NOPASSWD via visudo-validated temp file, withpasswordSet:trueremoves the drop-in.
// 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
// 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,buildClearPasswordCommandfrom Task 1. -
Produces:
streamExecInContainer/runExecInContainergain optionaluser?: string(adds-u <user>to the exec argv) andrunExecInContainergains optionalinput?: string(execainput, replacingstdin: 'ignore'when present).chownInBox(args: { containerName: string; paths: string[]; redact?: (v: string) => string }): Promise<void>— best-effortchown -R 1000:1000 <paths>exec'd as root; logs (does not throw) on failure. No-op for emptypaths.boxUsernsArgs(): string[]—['--userns=keep-id:uid=1000,gid=1000']when runtime ends withpodman;SPOON_AGENT_BOX_USERNSoverrides (empty string →[], other value →['--userns=<value>']). Exported for tests.ensureUserContainergains optionalonCreated?: (containerName: string) => Promise<void>— invoked only when a fresh container was created (not when already running), AFTERrunsucceeds. Also adds--hostname <linuxUsername(username)>-boxand...boxUsernsArgs()to therunargv.
-
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.tsforboxUsernsArgs(podman default, docker default[], override set, override empty) and for the exec arg builders if extracted pure (extractexecArgv(args)helper returning the argv array so-uplacement is unit-testable:['exec', ...envFlags, '-w', cwd, ...(user ? ['-u', user] : []), name, ...command]). -
Step 2: Run to verify failure.
-
Step 3: Implement (extract
execArgvused by both exec fns; addinput/userparams;chownInBoxviarunExecInContainerwithcommand: ['chown','-R','1000:1000',...paths], catches andconsole.errors;boxUsernsArgsreadingenv.boxUserns; wire--hostname/userns/onCreatedintoensureUserContainer). -
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 ofaiSettingsNode/userDotfiles.
Interfaces (Produces):
- Schema:
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 likeuserEnvironment.getMine(reusederiveHomeUsernamefrommodel.ts), storesencryptSecret(password)or clears the field. -
boxSettingsNode.getBoxPasswordForWorker— action{ workerToken: string; username: string }→{ password: string | null }, gated by the same worker-token check used inuserDotfilesNode.getEnvironmentForJob; decrypts withdecryptSecret. -
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) andruntime/agent-runtime.tsarg types. - Test: extend
apps/agent-worker/tests/unit/user-container.test.ts, adapter tests (assertuseris forwarded toexecStream),terminal-resize.test.tsuntouched.
Interfaces:
-
Consumes: everything from Tasks 1–3.
-
Produces:
box-settings.ts:fetchBoxPassword(username: string): Promise<string | null>— ConvexHttpClient action call toapi.boxSettingsNode.getBoxPasswordForWorkerwithenv.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>(inbox.tsor a smallbox-init.ts): runsbuildBoxInitScriptas root, fetches password, applieschpasswdvia stdin when set, thenbuildSudoPolicyScript. This is theonCreatedhook passed toensureUserContainerfrom BOTH call paths (acquireUserBoxinuser-container.tsandstartBoxinbox.ts).- Terminal bridges pass
User: linuxUsername(username)in the dockerode exec create (bothbridgeandbridgeBox). worker.ts/ adapters: exec arg objects gainuser: linuxUsername(claim/workspace username);runProjectCommand, agent turnexecStreamcalls, and dotfilesrunExecInContainerinmaterializeUserHomeall pass it.killBoxProcessesByMarkerstays root.- Chown call sites (all best-effort
chownInBox): aftercloneRepository(repo dir), after secrets env-file write inworker.ts(theclaim.job.envFilePathtarget), after overlay-file writes inmaterializeUserHome, afterwriteBoxFileinbox.ts, afterensureBashProfilewhen invoked with a running box (bridgeBox: chown~/.bash_profileright after acquire).
-
Step 1: Write failing tests — user-container test asserting
onCreatedfires once per creation (mockensureUserContainerinvoking its hook); adapter test assertinguserreachesexecStreamargs. -
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 acceptsset-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-passwordbody{ 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(proxyBoxaction 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 → commitfeat(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 theuser@hostline),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 whereapplied === 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 → commitfeat(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 truefails,chpasswdtook effect (su - <user>with the password succeeds); clear password → NOPASSWD sudo returns. Run one agent turn and confirm it executes as the user (whoamiartifact or a file it creates is uid 1000). -
Step 3:
bun run ci:checkat repo root — all green. -
Step 4: Commit docs; final review of the whole diff.