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

19 KiB
Raw Permalink Blame History

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.

// 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 failurecd 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 passbun run test:unit -- box-user → all pass.
  • Step 5: Commitfeat(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.errors; boxUsernsArgs reading env.boxUserns; wire --hostname/userns/onCreated into ensureUserContainer).

  • Step 4: Run tests — extended docker-runtime tests + whole unit suite pass.

  • Step 5: Commitfeat(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:
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: Commitfeat(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: Commitfeat(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.