From 78cb3d90c2899a6738d5f88287f59d4b90fdacc9 Mon Sep 17 00:00:00 2001 From: Gabriel Brown Date: Mon, 13 Jul 2026 09:49:59 -0400 Subject: [PATCH 1/8] feat(worker): box-user script builders + username sanitizer --- apps/agent-worker/src/runtime/box-user.ts | 75 ++++++++++++++++++ apps/agent-worker/tests/unit/box-user.test.ts | 79 +++++++++++++++++++ 2 files changed, 154 insertions(+) create mode 100644 apps/agent-worker/src/runtime/box-user.ts create mode 100644 apps/agent-worker/tests/unit/box-user.test.ts diff --git a/apps/agent-worker/src/runtime/box-user.ts b/apps/agent-worker/src/runtime/box-user.ts new file mode 100644 index 0000000..16edf98 --- /dev/null +++ b/apps/agent-worker/src/runtime/box-user.ts @@ -0,0 +1,75 @@ +// The per-box Linux user. Terminal sessions and agent turns exec as this user +// instead of root (Claude Code refuses some operations as root, and a real +// machine has a named user); sudo is passwordless until the user stores a +// password, after which wheel membership makes sudo prompt for it. + +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 everywhere; 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). `-o` allows +// the uid even if the image ever gains a uid-1000 user; `-M` skips skeleton +// copy since the mounted home persists across containers. +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 ${BOX_UID} -o -M -d ${home} -s /bin/bash ${user}`, + 'fi', + `usermod -aG wheel ${user}`, + `if [ ! -f ${marker} ]; then`, + ` chown -R ${BOX_UID}:${BOX_UID} ${home}`, + ` mkdir -p "$(dirname ${marker})" && touch ${marker} && chown ${BOX_UID}:${BOX_UID} ${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, +]; diff --git a/apps/agent-worker/tests/unit/box-user.test.ts b/apps/agent-worker/tests/unit/box-user.test.ts new file mode 100644 index 0000000..cd6e0c8 --- /dev/null +++ b/apps/agent-worker/tests/unit/box-user.test.ts @@ -0,0 +1,79 @@ +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', + ]); + }); +}); From 37a82098c2a2a643032f89205af65296b61b10ab Mon Sep 17 00:00:00 2001 From: Gabriel Brown Date: Mon, 13 Jul 2026 09:52:45 -0400 Subject: [PATCH 2/8] feat(worker): exec-as-user, stdin input, chown + userns plumbing --- apps/agent-worker/src/env.ts | 4 + apps/agent-worker/src/runtime/docker.ts | 111 +++++++++++++++--- .../tests/unit/docker-runtime.test.ts | 59 ++++++++++ turbo.json | 1 + 4 files changed, 156 insertions(+), 19 deletions(-) diff --git a/apps/agent-worker/src/env.ts b/apps/agent-worker/src/env.ts index 07e79fb..86c3048 100644 --- a/apps/agent-worker/src/env.ts +++ b/apps/agent-worker/src/env.ts @@ -37,6 +37,10 @@ export const env = { '', // How long a per-user box container survives with no active jobs/terminals. boxIdleMs: intEnv('SPOON_AGENT_BOX_IDLE_MS', 1_800_000), + // Override for the box container's --userns flag. Unset → keep-id mapping + // under podman (host user becomes the box user), nothing under docker. + // Empty string disables entirely; any other value passes through verbatim. + boxUserns: process.env.SPOON_AGENT_BOX_USERNS, // Dev-only: exit if the parent dev runner dies, so the worker never orphans // and holds port 3921 across restarts. Set by scripts/dev-agent-worker. devWatchdog: process.env.SPOON_AGENT_DEV_WATCHDOG === '1', diff --git a/apps/agent-worker/src/runtime/docker.ts b/apps/agent-worker/src/runtime/docker.ts index a4b3d75..d4ac50e 100644 --- a/apps/agent-worker/src/runtime/docker.ts +++ b/apps/agent-worker/src/runtime/docker.ts @@ -5,6 +5,7 @@ import Docker from 'dockerode'; import { execa } from 'execa'; import { env } from '../env'; +import { BOX_UID, linuxUsername } from './box-user'; type CommandResult = { exitCode: number; @@ -26,6 +27,21 @@ const environmentArgs = (environment: Record) => const networkArgs = () => (env.network ? ['--network', env.network] : []); +// Box user namespace mapping. Under rootless podman, keep-id maps the host +// user onto the box user's uid so host-side writes (clones, dotfiles, file +// editor) are already user-owned inside the box. Rootful docker has no +// equivalent — ownership is repaired with targeted chowns instead (see +// chownInBox). SPOON_AGENT_BOX_USERNS overrides: empty disables, any other +// value passes through. +export const boxUsernsArgs = (): string[] => { + if (env.boxUserns !== undefined) { + return env.boxUserns ? [`--userns=${env.boxUserns}`] : []; + } + return containerRuntime().endsWith('podman') + ? [`--userns=keep-id:uid=${BOX_UID},gid=${BOX_UID}`] + : []; +}; + const containerRuntime = () => env.containerRuntime; // `docker run` reuses a stale local `:latest` forever, so without an explicit @@ -173,6 +189,10 @@ export const ensureUserContainer = async (args: { username: string; workdir: string; containerHome: string; + // Runs once per fresh container, after `run` succeeds — box-user init + // (useradd, sudo policy, password) hooks in here so every creation path + // (acquire, explicit start) sets the user up without duplicating the logic. + onCreated?: (containerName: string) => Promise; }): Promise => { await ensureJobImagePulled(); const name = userContainerName(args.username); @@ -196,10 +216,13 @@ export const ensureUserContainer = async (args: { '--init', '--name', name, + '--hostname', + `${linuxUsername(args.username)}-box`, '--memory', '4g', '--cpus', '2', + ...boxUsernsArgs(), ...networkArgs(), '-v', jobWorkspaceVolumeSpec(args.workdir, args.containerHome), @@ -211,6 +234,7 @@ export const ensureUserContainer = async (args: { ], { stdin: 'ignore' }, ); + if (args.onCreated) await args.onCreated(name); return name; }; @@ -236,7 +260,12 @@ export const inspectUserBoxStatus = async ( { reject: false, stdin: 'ignore' }, ); if (result.exitCode !== 0) { - return { running: false, image: null, startedAt: null, memoryLimitBytes: null }; + return { + running: false, + image: null, + startedAt: null, + memoryLimitBytes: null, + }; } const [runningField, imageField, startedAtField, memoryField] = result.stdout .trim() @@ -335,6 +364,25 @@ export const killBoxProcessesByMarker = async (args: { ); }; +// Shared ` exec` argv. `user` runs the command as that container +// user instead of root — terminals and agent turns pass the box user; init, +// chown and kill paths stay root. +export const execArgv = (args: { + containerName: string; + command: string[]; + environment: Record; + containerCwd: string; + user?: string; +}): string[] => [ + 'exec', + ...environmentArgs(args.environment), + '-w', + args.containerCwd, + ...(args.user ? ['-u', args.user] : []), + args.containerName, + ...args.command, +]; + export const streamExecInContainer = async (args: { containerName: string; command: string[]; @@ -342,21 +390,16 @@ export const streamExecInContainer = async (args: { containerCwd: string; redact: (value: string) => string; timeoutMs: number; + user?: string; onStdoutLine?: (line: string) => Promise; onStderrLine?: (line: string) => Promise; }): Promise => { - const subprocess = execa( - containerRuntime(), - [ - 'exec', - ...environmentArgs(args.environment), - '-w', - args.containerCwd, - args.containerName, - ...args.command, - ], - { all: true, reject: false, stdin: 'ignore', timeout: args.timeoutMs }, - ); + const subprocess = execa(containerRuntime(), execArgv(args), { + all: true, + reject: false, + stdin: 'ignore', + timeout: args.timeoutMs, + }); return streamSubprocess( subprocess, args.redact, @@ -372,20 +415,50 @@ export const runExecInContainer = async (args: { containerCwd: string; redact: (value: string) => string; timeoutMs: number; + user?: string; + // Piped to the command's stdin (e.g. `chpasswd` credentials, which must + // never appear in argv). Without it stdin is closed. + input?: string; }): Promise => { + const result = await execa(containerRuntime(), execArgv(args), { + all: true, + reject: false, + timeout: args.timeoutMs, + ...(args.input == null + ? { stdin: 'ignore' as const } + : { input: args.input }), + }); + return normalizeRunResult(result, result.all, args.redact); +}; + +// Best-effort ownership repair after host-side writes into a box home. Under +// dev podman (keep-id) this is a no-op — files are already the box uid; under +// prod docker it fixes uid-0 ownership left by the worker container. Failures +// are logged, not thrown: a stopped box just means the next creation's +// marker-guarded chown (or the next call) picks it up. +export const chownInBox = async (args: { + containerName: string; + paths: string[]; +}): Promise => { + if (args.paths.length === 0) return; const result = await execa( containerRuntime(), [ 'exec', - ...environmentArgs(args.environment), - '-w', - args.containerCwd, args.containerName, - ...args.command, + 'chown', + '-R', + `${BOX_UID}:${BOX_UID}`, + ...args.paths, ], - { all: true, reject: false, stdin: 'ignore', timeout: args.timeoutMs }, + { all: true, reject: false, stdin: 'ignore' }, ); - return normalizeRunResult(result, result.all, args.redact); + const normalized = normalizeRunResult(result, result.all, (value) => value); + if (normalized.exitCode !== 0) { + console.error( + `chown in ${args.containerName} failed (${String(normalized.exitCode)}): ${normalized.output}`, + ); + } }; export const stopWorkspaceContainer = async (containerName: string) => { diff --git a/apps/agent-worker/tests/unit/docker-runtime.test.ts b/apps/agent-worker/tests/unit/docker-runtime.test.ts index 62bcc5d..161bcbc 100644 --- a/apps/agent-worker/tests/unit/docker-runtime.test.ts +++ b/apps/agent-worker/tests/unit/docker-runtime.test.ts @@ -44,6 +44,65 @@ describe('Docker runtime', () => { ); }); + test('boxUsernsArgs maps the host user onto the box uid under podman', async () => { + process.env.SPOON_AGENT_CONTAINER_RUNTIME = 'podman'; + const { boxUsernsArgs } = await loadVolumeSpec(); + expect(boxUsernsArgs()).toEqual(['--userns=keep-id:uid=1000,gid=1000']); + }); + + test('boxUsernsArgs adds nothing under docker', async () => { + process.env.SPOON_AGENT_CONTAINER_RUNTIME = 'docker'; + const { boxUsernsArgs } = await loadVolumeSpec(); + expect(boxUsernsArgs()).toEqual([]); + }); + + test('SPOON_AGENT_BOX_USERNS overrides: value passes through, empty disables', async () => { + process.env.SPOON_AGENT_CONTAINER_RUNTIME = 'podman'; + process.env.SPOON_AGENT_BOX_USERNS = 'host'; + let { boxUsernsArgs } = await loadVolumeSpec(); + expect(boxUsernsArgs()).toEqual(['--userns=host']); + + process.env.SPOON_AGENT_BOX_USERNS = ''; + ({ boxUsernsArgs } = await loadVolumeSpec()); + expect(boxUsernsArgs()).toEqual([]); + delete process.env.SPOON_AGENT_BOX_USERNS; + }); + + test('execArgv places -u after the workdir and before the container name', async () => { + const { execArgv } = await loadVolumeSpec(); + expect( + execArgv({ + containerName: 'spoon-box-gabriel', + command: ['ls', '-la'], + environment: { HOME: '/home/gabriel' }, + containerCwd: '/home/gabriel', + user: 'gabriel', + }), + ).toEqual([ + 'exec', + '-e', + 'HOME=/home/gabriel', + '-w', + '/home/gabriel', + '-u', + 'gabriel', + 'spoon-box-gabriel', + 'ls', + '-la', + ]); + }); + + test('execArgv omits -u when no user is given', async () => { + const { execArgv } = await loadVolumeSpec(); + const argv = execArgv({ + containerName: 'c', + command: ['true'], + environment: {}, + containerCwd: '/', + }); + expect(argv).not.toContain('-u'); + }); + test('treats a spawn failure (no exitCode) as a non-zero exit, not empty success', async () => { const { normalizeRunResult } = await loadVolumeSpec(); // This is what execa returns with `reject: false` when the runtime binary is diff --git a/turbo.json b/turbo.json index 534ccf9..626a182 100644 --- a/turbo.json +++ b/turbo.json @@ -41,6 +41,7 @@ "SPOON_AGENT_TERMINAL_SECRET", "SPOON_AGENT_TERMINAL_IDLE_MS", "SPOON_AGENT_BOX_IDLE_MS", + "SPOON_AGENT_BOX_USERNS", "SPOON_AGENT_DEV_WATCHDOG", "SPOON_AGENT_RUNTIME", "SPOON_AGENT_CONTAINER_RUNTIME", From a956d63e162fb453c0ddda4d9d0d4ed9c6833001 Mon Sep 17 00:00:00 2001 From: Gabriel Brown Date: Mon, 13 Jul 2026 09:56:08 -0400 Subject: [PATCH 3/8] feat(backend): boxSettings table + box password actions --- packages/backend/convex/boxPassword.ts | 30 ++++++++ packages/backend/convex/boxSettings.ts | 77 +++++++++++++++++++ packages/backend/convex/boxSettingsNode.ts | 45 +++++++++++ packages/backend/convex/schema.ts | 12 +++ .../backend/tests/unit/box-password.test.ts | 34 ++++++++ 5 files changed, 198 insertions(+) create mode 100644 packages/backend/convex/boxPassword.ts create mode 100644 packages/backend/convex/boxSettings.ts create mode 100644 packages/backend/convex/boxSettingsNode.ts create mode 100644 packages/backend/tests/unit/box-password.test.ts diff --git a/packages/backend/convex/boxPassword.ts b/packages/backend/convex/boxPassword.ts new file mode 100644 index 0000000..6f18cd9 --- /dev/null +++ b/packages/backend/convex/boxPassword.ts @@ -0,0 +1,30 @@ +// Pure validation for the box user's password, shared by the Node action and +// unit tests. Kept free of Convex imports so it tests without the harness. + +export type BoxPasswordValidation = + | { ok: true; value: string | null } + | { ok: false; error: string }; + +// null/'' is an explicit clear (sudo reverts to NOPASSWD). The applied value +// travels to `chpasswd` as a `user:password` stdin line, so control characters +// (including newline/tab) are rejected; colons are fine — chpasswd splits on +// the first colon only. +export const normalizeBoxPassword = ( + password: string | null | undefined, +): BoxPasswordValidation => { + if (password == null || password === '') return { ok: true, value: null }; + if (password.length < 8) { + return { ok: false, error: 'Password must be at least 8 characters.' }; + } + if (password.length > 128) { + return { ok: false, error: 'Password must be at most 128 characters.' }; + } + // eslint-disable-next-line no-control-regex -- rejecting control chars is the point + if (/[\u0000-\u001f\u007f]/.test(password)) { + return { + ok: false, + error: 'Password must not contain control characters.', + }; + } + return { ok: true, value: password }; +}; diff --git a/packages/backend/convex/boxSettings.ts b/packages/backend/convex/boxSettings.ts new file mode 100644 index 0000000..1533c24 --- /dev/null +++ b/packages/backend/convex/boxSettings.ts @@ -0,0 +1,77 @@ +import { v } from 'convex/values'; + +import { internalMutation, internalQuery, query } from './_generated/server'; +import { deriveHomeUsername, getRequiredUserId } from './model'; + +// Per-user box account settings. The password itself is write-only from the +// client's perspective: browsers only ever learn whether one is set; the +// plaintext flows exclusively to the worker via boxSettingsNode. + +export const hasMine = query({ + args: {}, + handler: async (ctx) => { + const ownerId = await getRequiredUserId(ctx); + const row = await ctx.db + .query('boxSettings') + .withIndex('by_owner', (q) => q.eq('ownerId', ownerId)) + .unique(); + return { hasPassword: Boolean(row?.boxPasswordEncrypted) }; + }, +}); + +// Resolve the authed caller's owner id + home username (same derivation as +// userEnvironment.getMine) for the Node action, which cannot touch ctx.db. +export const resolveOwnerInternal = internalQuery({ + args: {}, + handler: async (ctx) => { + const ownerId = await getRequiredUserId(ctx); + const [user, settings] = await Promise.all([ + ctx.db.get(ownerId), + ctx.db + .query('userEnvironment') + .withIndex('by_owner', (q) => q.eq('ownerId', ownerId)) + .unique(), + ]); + return { + ownerId, + username: settings?.homeUsername ?? deriveHomeUsername(user?.name), + }; + }, +}); + +// Worker-facing lookup at box creation. `.first()` (not `.unique()`): derived +// usernames are not guaranteed globally unique, and a throw here would brick +// box creation for both colliding users. +export const getByUsernameInternal = internalQuery({ + args: { username: v.string() }, + handler: async (ctx, args) => + await ctx.db + .query('boxSettings') + .withIndex('by_username', (q) => q.eq('username', args.username)) + .first(), +}); + +export const upsertMineInternal = internalMutation({ + args: { + ownerId: v.id('users'), + username: v.string(), + boxPasswordEncrypted: v.optional(v.string()), + }, + handler: async (ctx, args) => { + const existing = await ctx.db + .query('boxSettings') + .withIndex('by_owner', (q) => q.eq('ownerId', args.ownerId)) + .unique(); + const patch = { + username: args.username, + // Explicit undefined clears the field on patch (password removed). + boxPasswordEncrypted: args.boxPasswordEncrypted, + updatedAt: Date.now(), + }; + if (existing) { + await ctx.db.patch(existing._id, patch); + } else { + await ctx.db.insert('boxSettings', { ownerId: args.ownerId, ...patch }); + } + }, +}); diff --git a/packages/backend/convex/boxSettingsNode.ts b/packages/backend/convex/boxSettingsNode.ts new file mode 100644 index 0000000..8cdbd4c --- /dev/null +++ b/packages/backend/convex/boxSettingsNode.ts @@ -0,0 +1,45 @@ +'use node'; + +import { ConvexError, v } from 'convex/values'; + +import { internal } from './_generated/api'; +import { action } from './_generated/server'; +import { normalizeBoxPassword } from './boxPassword'; +import { decryptSecret, encryptSecret } from './secretCrypto'; +import { assertWorkerToken } from './workerAuth'; + +/** Authed: store (or clear, with null/'') the caller's box password. */ +export const setBoxPassword = action({ + args: { password: v.union(v.string(), v.null()) }, + handler: async (ctx, args): Promise<{ hasPassword: boolean }> => { + const normalized = normalizeBoxPassword(args.password); + if (!normalized.ok) throw new ConvexError(normalized.error); + const { ownerId, username } = await ctx.runQuery( + internal.boxSettings.resolveOwnerInternal, + {}, + ); + await ctx.runMutation(internal.boxSettings.upsertMineInternal, { + ownerId, + username, + boxPasswordEncrypted: + normalized.value == null ? undefined : encryptSecret(normalized.value), + }); + return { hasPassword: normalized.value != null }; + }, +}); + +/** Worker-facing: the stored password (decrypted) for a box username. */ +export const getBoxPasswordForWorker = action({ + args: { workerToken: v.string(), username: v.string() }, + handler: async (ctx, args): Promise<{ password: string | null }> => { + assertWorkerToken(args.workerToken); + const row = await ctx.runQuery(internal.boxSettings.getByUsernameInternal, { + username: args.username, + }); + return { + password: row?.boxPasswordEncrypted + ? decryptSecret(row.boxPasswordEncrypted) + : null, + }; + }, +}); diff --git a/packages/backend/convex/schema.ts b/packages/backend/convex/schema.ts index eefcb09..1d81a3d 100644 --- a/packages/backend/convex/schema.ts +++ b/packages/backend/convex/schema.ts @@ -376,6 +376,18 @@ const applicationTables = { setupCommand: v.optional(v.string()), updatedAt: v.number(), }).index('by_owner', ['ownerId']), + // Per-user box account settings (the Linux user inside spoon-box-). + boxSettings: defineTable({ + ownerId: v.id('users'), + // Resolved Spoon home username at the time the row was written — the + // worker looks the password up by this at box creation (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']), aiProviderProfiles: defineTable({ ownerId: v.id('users'), name: v.string(), diff --git a/packages/backend/tests/unit/box-password.test.ts b/packages/backend/tests/unit/box-password.test.ts new file mode 100644 index 0000000..4f28e89 --- /dev/null +++ b/packages/backend/tests/unit/box-password.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it } from 'vitest'; + +import { normalizeBoxPassword } from '../../convex/boxPassword'; + +describe('normalizeBoxPassword', () => { + it('treats null/empty as an explicit clear', () => { + expect(normalizeBoxPassword(null)).toEqual({ ok: true, value: null }); + expect(normalizeBoxPassword(undefined)).toEqual({ ok: true, value: null }); + expect(normalizeBoxPassword('')).toEqual({ ok: true, value: null }); + }); + + it('enforces the 8..128 length bounds', () => { + expect(normalizeBoxPassword('short')).toMatchObject({ ok: false }); + expect(normalizeBoxPassword('a'.repeat(7))).toMatchObject({ ok: false }); + expect(normalizeBoxPassword('a'.repeat(8))).toEqual({ + ok: true, + value: 'a'.repeat(8), + }); + expect(normalizeBoxPassword('a'.repeat(128))).toMatchObject({ ok: true }); + expect(normalizeBoxPassword('a'.repeat(129))).toMatchObject({ ok: false }); + }); + + it('rejects control characters that would corrupt the chpasswd stdin line', () => { + expect(normalizeBoxPassword('has\nnewline1')).toMatchObject({ ok: false }); + expect(normalizeBoxPassword('has\ttab-char1')).toMatchObject({ ok: false }); + }); + + it('allows colons and spaces (chpasswd splits on the first colon only)', () => { + expect(normalizeBoxPassword('pa:ss wo:rd')).toEqual({ + ok: true, + value: 'pa:ss wo:rd', + }); + }); +}); From 58bd101793d488e0a2a72116bde89d9fe7596139 Mon Sep 17 00:00:00 2001 From: Gabriel Brown Date: Mon, 13 Jul 2026 10:04:14 -0400 Subject: [PATCH 4/8] feat(worker): run terminals and agent turns as the box user --- apps/agent-worker/src/box-init.ts | 94 +++++++++++++++++++ apps/agent-worker/src/box-settings.ts | 30 ++++++ apps/agent-worker/src/box.ts | 34 ++++++- apps/agent-worker/src/runtime/box-user.ts | 4 +- .../src/runtime/claude-adapter.ts | 12 ++- .../agent-worker/src/runtime/codex-adapter.ts | 8 +- .../src/runtime/opencode-adapter.ts | 8 +- apps/agent-worker/src/terminal.ts | 20 +++- apps/agent-worker/src/user-container.ts | 7 +- apps/agent-worker/src/user-environment.ts | 11 ++- apps/agent-worker/src/worker.ts | 22 ++++- .../tests/unit/claude-adapter.test.ts | 21 ++++- .../tests/unit/user-container.test.ts | 14 +++ 13 files changed, 263 insertions(+), 22 deletions(-) create mode 100644 apps/agent-worker/src/box-init.ts create mode 100644 apps/agent-worker/src/box-settings.ts diff --git a/apps/agent-worker/src/box-init.ts b/apps/agent-worker/src/box-init.ts new file mode 100644 index 0000000..f70faad --- /dev/null +++ b/apps/agent-worker/src/box-init.ts @@ -0,0 +1,94 @@ +import { fetchBoxPassword } from './box-settings'; +import { + buildBoxInitScript, + buildClearPasswordCommand, + buildSudoPolicyScript, + CHPASSWD_COMMAND, + linuxUsername, +} from './runtime/box-user'; +import { runExecInContainer } from './runtime/docker'; + +const noRedact = (value: string) => value; + +// Root exec with loud failure — a broken init would leave a root terminal, so +// surface the script output instead of continuing silently. +const execRoot = async ( + containerName: string, + command: string[], + label: string, + input?: string, + redact: (value: string) => string = noRedact, +): Promise => { + const result = await runExecInContainer({ + containerName, + command, + environment: {}, + containerCwd: '/', + redact, + timeoutMs: 120_000, + input, + }); + if (result.exitCode !== 0) { + throw new Error(`Box ${label} failed: ${result.output}`); + } +}; + +/** + * One-time (per container) box-user setup, run as root right after `run`: + * create the Linux user at the mounted home, apply the stored password (via + * chpasswd stdin — never argv), and set the sudo policy (NOPASSWD drop-in + * only while no password is stored). Used as ensureUserContainer's onCreated + * hook by every creation path. + */ +export const initializeBoxUser = async ( + containerName: string, + spoonUsername: string, + containerHome: string, +): Promise => { + const user = linuxUsername(spoonUsername); + await execRoot( + containerName, + ['bash', '-c', buildBoxInitScript({ username: user, home: containerHome })], + 'user init', + ); + const password = await fetchBoxPassword(spoonUsername); + await applyBoxPassword(containerName, spoonUsername, password); +}; + +/** + * Apply (or clear, with null) the box password + matching sudo policy on a + * running container. Shared by init and the live /box/set-password route. + */ +export const applyBoxPassword = async ( + containerName: string, + spoonUsername: string, + password: string | null, +): Promise => { + const user = linuxUsername(spoonUsername); + if (password == null) { + await execRoot( + containerName, + buildClearPasswordCommand(user), + 'password clear', + ); + } else { + await execRoot( + containerName, + CHPASSWD_COMMAND, + 'password apply', + `${user}:${password}\n`, + // chpasswd never echoes the password, but redact defensively in case a + // future runtime error message includes stdin. + (value) => value.replaceAll(password, '[redacted]'), + ); + } + await execRoot( + containerName, + [ + 'bash', + '-c', + buildSudoPolicyScript({ username: user, passwordSet: password != null }), + ], + 'sudo policy', + ); +}; diff --git a/apps/agent-worker/src/box-settings.ts b/apps/agent-worker/src/box-settings.ts new file mode 100644 index 0000000..2f553bd --- /dev/null +++ b/apps/agent-worker/src/box-settings.ts @@ -0,0 +1,30 @@ +import { ConvexHttpClient } from 'convex/browser'; + +import { api } from '@spoon/backend/convex/_generated/api.js'; + +import { env } from './env'; + +// Lazy: unit tests mock ./env with a partial object, and the constructor +// validates the URL eagerly. +let client: ConvexHttpClient | undefined; +const getClient = () => (client ??= new ConvexHttpClient(env.convexUrl)); + +/** + * The user's stored box password (decrypted), or null when none is set. Also + * null on ANY error: a box must still start when Convex is unreachable — the + * user just gets the passwordless-sudo default until the next recreation. + */ +export const fetchBoxPassword = async ( + username: string, +): Promise => { + try { + const result = await getClient().action( + api.boxSettingsNode.getBoxPasswordForWorker, + { workerToken: env.workerToken, username }, + ); + return result.password; + } catch (error) { + console.error(`Failed to fetch box password for ${username}:`, error); + return null; + } +}; diff --git a/apps/agent-worker/src/box.ts b/apps/agent-worker/src/box.ts index 3a3e854..085f064 100644 --- a/apps/agent-worker/src/box.ts +++ b/apps/agent-worker/src/box.ts @@ -1,9 +1,11 @@ -import { mkdir, readFile, readdir, stat, writeFile } from 'node:fs/promises'; +import { mkdir, readdir, readFile, stat, writeFile } from 'node:fs/promises'; import path from 'node:path'; +import { applyBoxPassword, initializeBoxUser } from './box-init'; import { env } from './env'; import { assertContainedRealPath } from './path-containment'; import { + chownInBox, ensureUserContainer, inspectUserBoxStatus, stopWorkspaceContainer, @@ -41,7 +43,25 @@ export const getBoxStatus = async (username: string): Promise => { export const startBox = async (username: string): Promise => { const { homeDir, containerHome } = boxHomePaths(username); - await ensureUserContainer({ username, workdir: homeDir, containerHome }); + await ensureUserContainer({ + username, + workdir: homeDir, + containerHome, + onCreated: (name) => initializeBoxUser(name, username, containerHome), + }); +}; + +// Live password apply for the /box/set-password route: only touches the +// container when it's running — otherwise the stored password is picked up by +// the next creation's init. +export const setBoxUserPassword = async ( + username: string, + password: string | null, +): Promise<{ applied: boolean }> => { + const status = await inspectUserBoxStatus(username); + if (!status.running) return { applied: false }; + await applyBoxPassword(userContainerName(username), username, password); + return { applied: true }; }; export const stopBox = async (username: string): Promise => { @@ -145,9 +165,17 @@ export const writeBoxFile = async ( relPath: string, content: string, ): Promise<{ success: true }> => { - const { homeDir } = boxHomePaths(username); + const { homeDir, containerHome } = boxHomePaths(username); const target = await safeHomePath(homeDir, relPath, { forWrite: true }); await mkdir(path.dirname(target), { recursive: true }); await writeFile(target, content); + // Host-side write: repair ownership so the box user can edit the file in a + // shell too (no-op under dev keep-id; best-effort when the box is stopped — + // the next creation's marker chown won't rerun, but a later editor write or + // manual sudo chown recovers, and read access is unaffected). + await chownInBox({ + containerName: userContainerName(username), + paths: [path.posix.join(containerHome, relPath)], + }); return { success: true }; }; diff --git a/apps/agent-worker/src/runtime/box-user.ts b/apps/agent-worker/src/runtime/box-user.ts index 16edf98..320601a 100644 --- a/apps/agent-worker/src/runtime/box-user.ts +++ b/apps/agent-worker/src/runtime/box-user.ts @@ -37,8 +37,10 @@ export const buildBoxInitScript = (args: { 'fi', `usermod -aG wheel ${user}`, `if [ ! -f ${marker} ]; then`, + // Marker is created BEFORE the recursive chown so ~/.spoon itself ends up + // user-owned by the same sweep. + ` mkdir -p "$(dirname ${marker})" && touch ${marker}`, ` chown -R ${BOX_UID}:${BOX_UID} ${home}`, - ` mkdir -p "$(dirname ${marker})" && touch ${marker} && chown ${BOX_UID}:${BOX_UID} ${marker}`, 'fi', ].join('\n'); }; diff --git a/apps/agent-worker/src/runtime/claude-adapter.ts b/apps/agent-worker/src/runtime/claude-adapter.ts index eb90b2d..d9a569d 100644 --- a/apps/agent-worker/src/runtime/claude-adapter.ts +++ b/apps/agent-worker/src/runtime/claude-adapter.ts @@ -1,17 +1,18 @@ -import path from 'node:path'; import { randomUUID } from 'node:crypto'; +import path from 'node:path'; import type { NormalizedAgentEvent } from '../agent-events'; +import type { AdapterFactory, TurnResult } from './agent-runtime'; +import type { AdapterWorkspace } from './provider'; import { normalizeClaudeJsonLine } from '../agent-events'; import { env } from '../env'; -import type { AdapterFactory, TurnResult } from './agent-runtime'; import { writeJsonFile } from './auth'; +import { linuxUsername } from './box-user'; import { buildMarkedCommand, killBoxProcessesByMarker, streamExecInContainer, } from './docker'; -import type { AdapterWorkspace } from './provider'; import { claudeEnv, claudeModel, isClaudeOAuthProfile } from './provider'; export const createClaudeAdapter: AdapterFactory = (deps) => { @@ -23,7 +24,9 @@ export const createClaudeAdapter: AdapterFactory = (deps) => { if (!isClaudeOAuthProfile(workspace.claim)) return; const secret = workspace.claim.aiProviderProfile?.secret; if (!secret) { - throw new Error('Claude auth profile is missing credentials.json contents.'); + throw new Error( + 'Claude auth profile is missing credentials.json contents.', + ); } const credentialsPath = path.join( workspace.homeDir, @@ -84,6 +87,7 @@ export const createClaudeAdapter: AdapterFactory = (deps) => { try { result = await execStream({ containerName: workspace.boxName, + user: linuxUsername(workspace.username), containerCwd: workspace.containerRepo, command, environment: { ...aiEnv, ...secretEnv }, diff --git a/apps/agent-worker/src/runtime/codex-adapter.ts b/apps/agent-worker/src/runtime/codex-adapter.ts index 4f93b5f..3906f58 100644 --- a/apps/agent-worker/src/runtime/codex-adapter.ts +++ b/apps/agent-worker/src/runtime/codex-adapter.ts @@ -1,18 +1,19 @@ +import { randomUUID } from 'node:crypto'; import { mkdir, readFile, writeFile } from 'node:fs/promises'; import path from 'node:path'; -import { randomUUID } from 'node:crypto'; import type { NormalizedAgentEvent } from '../agent-events'; +import type { AdapterFactory, TurnResult } from './agent-runtime'; +import type { AdapterWorkspace } from './provider'; import { normalizeCodexJsonLine } from '../agent-events'; import { prepareCodexWorkspaceFiles } from '../codex-runtime'; import { env } from '../env'; -import type { AdapterFactory, TurnResult } from './agent-runtime'; +import { linuxUsername } from './box-user'; import { buildMarkedCommand, killBoxProcessesByMarker, streamExecInContainer, } from './docker'; -import type { AdapterWorkspace } from './provider'; import { codexModelArgs, isCodexLoginProfile, @@ -142,6 +143,7 @@ export const createCodexAdapter: AdapterFactory = (deps) => { try { result = await execStream({ containerName: workspace.boxName, + user: linuxUsername(workspace.username), containerCwd: workspace.containerRepo, command, environment: { ...aiEnv, ...secretEnv }, diff --git a/apps/agent-worker/src/runtime/opencode-adapter.ts b/apps/agent-worker/src/runtime/opencode-adapter.ts index 5bf4fc2..8f3e45f 100644 --- a/apps/agent-worker/src/runtime/opencode-adapter.ts +++ b/apps/agent-worker/src/runtime/opencode-adapter.ts @@ -1,17 +1,18 @@ +import { randomUUID } from 'node:crypto'; import { mkdir, writeFile } from 'node:fs/promises'; import path from 'node:path'; -import { randomUUID } from 'node:crypto'; import type { NormalizedAgentEvent } from '../agent-events'; +import type { AdapterFactory, TurnResult } from './agent-runtime'; +import type { AdapterWorkspace } from './provider'; import { normalizeOpenCodeRunLine } from '../agent-events'; import { env } from '../env'; -import type { AdapterFactory, TurnResult } from './agent-runtime'; +import { linuxUsername } from './box-user'; import { buildMarkedCommand, killBoxProcessesByMarker, streamExecInContainer, } from './docker'; -import type { AdapterWorkspace } from './provider'; import { opencodeModel, providerEnvironment } from './provider'; // Normalize + pretty-print an auth JSON blob before writing it with owner-only @@ -97,6 +98,7 @@ export const createOpenCodeAdapter: AdapterFactory = (deps) => { try { result = await execStream({ containerName: workspace.boxName, + user: linuxUsername(workspace.username), containerCwd: workspace.containerRepo, command, environment: { ...aiEnv, ...secretEnv }, diff --git a/apps/agent-worker/src/terminal.ts b/apps/agent-worker/src/terminal.ts index e7e8350..86dc9cf 100644 --- a/apps/agent-worker/src/terminal.ts +++ b/apps/agent-worker/src/terminal.ts @@ -6,7 +6,8 @@ import { WebSocketServer } from 'ws'; import type { BoxHandle } from './user-container'; import { boxHomePaths } from './box'; import { env } from './env'; -import { getDockerClient } from './runtime/docker'; +import { linuxUsername } from './runtime/box-user'; +import { chownInBox, getDockerClient } from './runtime/docker'; import { openExecStream } from './runtime/exec-stream'; import { parseBoxTokenUsername, @@ -64,6 +65,11 @@ type ShellBridgeOptions = { acquire: () => Promise; // Host-side preparation before the shell starts (e.g. seed .bash_profile). prepare?: () => Promise; + // Runs after the box is acquired (so the container exists) — ownership + // repair for anything `prepare` wrote host-side. + postAcquire?: (boxName: string) => Promise; + // Container user the shell runs as (the box user, not root). + user: string; // Directory the login shell starts in (container path). cwd: string; // Env entries appended after TERM (e.g. HOME, and job secrets for job terms). @@ -140,6 +146,8 @@ const runShellBridge = async (ws: WebSocket, opts: ShellBridgeOptions) => { return; } + if (opts.postAcquire) await opts.postAcquire(boxName); + // The exec create is a plain API call, but the attach is NOT dockerode's // `exec.start({ hijack: true })`: docker-modem's hijack waits for node's http // client `'upgrade'` event, which bun never emits, so under bun it hangs @@ -157,6 +165,7 @@ const runShellBridge = async (ws: WebSocket, opts: ShellBridgeOptions) => { AttachStdout: true, AttachStderr: true, Tty: true, + User: opts.user, Cmd: SHELL_CMD, Env: ['TERM=xterm-256color', ...opts.envFlags], WorkingDir: opts.cwd, @@ -214,6 +223,7 @@ const bridge = async (ws: WebSocket, jobId: string) => { workdir: workspace.workdir, containerHome: workspace.containerHome, }), + user: linuxUsername(workspace.username), cwd: workspace.containerRepo, envFlags: [ `HOME=${workspace.containerHome}`, @@ -228,8 +238,16 @@ const bridgeBox = async (ws: WebSocket, username: string) => { const { homeDir, containerHome } = boxHomePaths(username); await runShellBridge(ws, { prepare: () => ensureBashProfile(homeDir), + // prepare's host-side .bash_profile write may be root-owned under prod + // docker; repair once the container is known to exist. + postAcquire: (boxName) => + chownInBox({ + containerName: boxName, + paths: [`${containerHome}/.bash_profile`], + }), acquire: () => acquireUserBox({ username, workdir: homeDir, containerHome }), + user: linuxUsername(username), cwd: containerHome, envFlags: [`HOME=${containerHome}`], }); diff --git a/apps/agent-worker/src/user-container.ts b/apps/agent-worker/src/user-container.ts index d56ed0d..9cc6809 100644 --- a/apps/agent-worker/src/user-container.ts +++ b/apps/agent-worker/src/user-container.ts @@ -1,3 +1,4 @@ +import { initializeBoxUser } from './box-init'; import { env } from './env'; import { ensureUserContainer, @@ -92,7 +93,11 @@ export const acquireUserBox = (args: { // cached "initialized" flag then hands out handles to a dead container // until restart. ensureUserContainer is idempotent — an inspect when the // box is running, a recreate when it isn't. - box.name = await ensureUserContainer(args); + box.name = await ensureUserContainer({ + ...args, + onCreated: (containerName) => + initializeBoxUser(containerName, args.username, args.containerHome), + }); return handle; } catch (error) { handle.release(); diff --git a/apps/agent-worker/src/user-environment.ts b/apps/agent-worker/src/user-environment.ts index 921fb36..4d1180d 100644 --- a/apps/agent-worker/src/user-environment.ts +++ b/apps/agent-worker/src/user-environment.ts @@ -8,7 +8,8 @@ import { api } from '@spoon/backend/convex/_generated/api.js'; import { env } from './env'; import { assertContainedRealPath } from './path-containment'; -import { runExecInContainer } from './runtime/docker'; +import { linuxUsername } from './runtime/box-user'; +import { chownInBox, runExecInContainer } from './runtime/docker'; const client = new ConvexHttpClient(env.convexUrl); @@ -102,6 +103,9 @@ export const materializeUserHome = async (args: { environment: { HOME: containerHome }, redact, timeoutMs: env.jobTimeoutMs, + // The clone and setup command run as the box user so ~/.dotfiles and + // anything the setup writes stay editable from the terminal. + user: linuxUsername(userEnv.username), }); await mkdir(path.dirname(markerPath), { recursive: true }); await writeFile(markerPath, configHash); @@ -109,6 +113,7 @@ export const materializeUserHome = async (args: { } // Editable overlay tree (wins over the repo/setup output). + const writtenContainerPaths: string[] = []; for (const file of userEnv.files) { const target = await assertContainedRealPath(homeDir, file.path, { forWrite: true, @@ -116,5 +121,9 @@ export const materializeUserHome = async (args: { await mkdir(path.dirname(target), { recursive: true }); await writeFile(target, file.content); if (file.isExecutable) await chmod(target, 0o755); + writtenContainerPaths.push(path.posix.join(containerHome, file.path)); } + // Overlay files are written host-side — repair ownership so the box user + // can edit them in a shell (no-op under dev keep-id). + await chownInBox({ containerName: boxName, paths: writtenContainerPaths }); }; diff --git a/apps/agent-worker/src/worker.ts b/apps/agent-worker/src/worker.ts index 279e4cc..2648d9f 100644 --- a/apps/agent-worker/src/worker.ts +++ b/apps/agent-worker/src/worker.ts @@ -14,15 +14,17 @@ import type { Id } from '@spoon/backend/convex/_generated/dataModel.js'; import { api } from '@spoon/backend/convex/_generated/api.js'; import type { NormalizedAgentEvent } from './agent-events'; +import type { AgentRuntimeName } from './runtime/agent-runtime'; import type { AdapterWorkspace, Claim } from './runtime/provider'; import type { BoxHandle } from './user-container'; -import type { AgentRuntimeName } from './runtime/agent-runtime'; import { getAdapter } from './runtime/agent-runtime'; import { resolveTurnOutcome, shouldAppendCompletedContent, } from './runtime/turn-outcome'; + import './runtime/register'; + import { env } from './env'; import { cloneRepository, @@ -36,7 +38,9 @@ import { createHeartbeatController } from './heartbeat-controller'; import { removeMaterializedEnv, writeMaterializedEnv } from './materialize-env'; import { assertContainedRealPath } from './path-containment'; import { createRedactor, truncate } from './redact'; +import { linuxUsername } from './runtime/box-user'; import { + chownInBox, listWorkspaceContainerNames, runExecInContainer, } from './runtime/docker'; @@ -576,6 +580,7 @@ const runProjectCommand = async (args: { phase: 'install' | 'check' | 'test'; claim: Claim; boxName: string; + username: string; containerHome: string; containerCwd: string; repoDir: string; @@ -594,6 +599,7 @@ const runProjectCommand = async (args: { environment: { HOME: args.containerHome, ...secretEnv }, redact: args.redact, timeoutMs: env.jobTimeoutMs, + user: linuxUsername(args.username), }) : await run('bash', ['-lc', args.command], { cwd: args.repoDir, @@ -728,6 +734,12 @@ const materializeEnvFile = async (workspace: ActiveWorkspace) => { claim.job.envFilePath, claim.secrets, ); + // Host-side 0600 write — the agent now runs as the box user, which must be + // able to read it. No-op under dev keep-id. + await chownInBox({ + containerName: workspace.boxName, + paths: [path.posix.join(workspace.containerRepo, claim.job.envFilePath)], + }); await appendEvent( claim.job._id, 'info', @@ -923,6 +935,9 @@ const runClaim = async (claim: Claim) => { redact, timeoutMs: env.jobTimeoutMs, }); + // The clone happens host-side; hand it to the box user so the agent (and + // the user's shell) can write to it. No-op under dev keep-id. + await chownInBox({ containerName: boxName, paths: [containerRepo] }); const workspace: ActiveWorkspace = { claim, workdir: homeDir, @@ -1116,6 +1131,7 @@ export const runWorkspaceCommand = async (jobId: string, command: string) => { phase: command.includes('test') ? 'test' : 'check', claim: workspace.claim, boxName: workspace.boxName, + username: workspace.username, containerHome: workspace.containerHome, containerCwd: workspace.containerRepo, repoDir: workspace.repoDir, @@ -1183,7 +1199,9 @@ export const replyToInteraction = async ( ) => { // Every runtime now runs its CLI non-interactively inside the box, so no // runtime emits an interaction request that expects a reply. - throw new Error('Interactive replies are not supported by exec-based runtimes.'); + throw new Error( + 'Interactive replies are not supported by exec-based runtimes.', + ); }; export const sendWorkspaceMessage = async ( diff --git a/apps/agent-worker/tests/unit/claude-adapter.test.ts b/apps/agent-worker/tests/unit/claude-adapter.test.ts index c60d938..f71cfb9 100644 --- a/apps/agent-worker/tests/unit/claude-adapter.test.ts +++ b/apps/agent-worker/tests/unit/claude-adapter.test.ts @@ -1,7 +1,6 @@ import { mkdtemp, readFile, rm, stat } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import path from 'node:path'; - import { afterEach, describe, expect, test, vi } from 'vitest'; import type { NormalizedAgentEvent } from '../../src/agent-events'; @@ -98,7 +97,9 @@ describe('ClaudeCodeAdapter prepareAuth', () => { tempDirs.push(homeDir); const adapter = createClaudeAdapter(); - await adapter.prepareAuth(makeOAuthWorkspace(homeDir, 'anthropic_oauth_json')); + await adapter.prepareAuth( + makeOAuthWorkspace(homeDir, 'anthropic_oauth_json'), + ); const credentialsPath = path.join(homeDir, '.claude', '.credentials.json'); const contents = await readFile(credentialsPath, 'utf8'); @@ -111,7 +112,9 @@ describe('ClaudeCodeAdapter prepareAuth', () => { tempDirs.push(homeDir); const adapter = createClaudeAdapter(); - await adapter.prepareAuth(makeOAuthWorkspace(homeDir, 'opencode_auth_json')); + await adapter.prepareAuth( + makeOAuthWorkspace(homeDir, 'opencode_auth_json'), + ); const credentialsPath = path.join(homeDir, '.claude', '.credentials.json'); await expect(stat(credentialsPath)).rejects.toThrow(); @@ -174,4 +177,16 @@ describe('ClaudeCodeAdapter', () => { expect(command[3]).toContain("'claude' '-p'"); expect(command[3]).toContain("'--output-format' 'stream-json'"); }); + + test('execs the turn as the box user, not root', async () => { + const { createClaudeAdapter } = await loadAdapter(); + let capturedUser: string | undefined; + const execStream = vi.fn((args: { user?: string }) => { + capturedUser = args.user; + return Promise.resolve({ exitCode: 0, output: '' }); + }) as unknown as ExecStreamFn; + const adapter = createClaudeAdapter({ execStream }); + await adapter.runTurn(makeWorkspace(), 'do it', () => Promise.resolve()); + expect(capturedUser).toBe('tester'); + }); }); diff --git a/apps/agent-worker/tests/unit/user-container.test.ts b/apps/agent-worker/tests/unit/user-container.test.ts index 930ae21..559651f 100644 --- a/apps/agent-worker/tests/unit/user-container.test.ts +++ b/apps/agent-worker/tests/unit/user-container.test.ts @@ -22,6 +22,20 @@ describe('box registry', () => { vi.clearAllMocks(); }); + test('passes an onCreated hook so a fresh box gets its user initialized', async () => { + const module = await load(); + const docker = await import('../../src/runtime/docker'); + await module.acquireUserBox({ + username: 'ada', + workdir: '/w', + containerHome: '/home/ada', + }); + const call = vi.mocked(docker.ensureUserContainer).mock.calls[0]?.[0] as { + onCreated?: unknown; + }; + expect(call.onCreated).toBeTypeOf('function'); + }); + test('re-ensures the container on every acquire so an externally removed box self-heals', async () => { const module = await load(); const docker = await import('../../src/runtime/docker'); From 78aae034b5d8a157bcbbda272e4216dad20b897e Mon Sep 17 00:00:00 2001 From: Gabriel Brown Date: Mon, 13 Jul 2026 10:07:06 -0400 Subject: [PATCH 5/8] feat(worker): live box password apply route --- apps/agent-worker/src/server.ts | 24 ++++++++++++++++++- .../agent-worker/tests/unit/box-route.test.ts | 3 ++- 2 files changed, 25 insertions(+), 2 deletions(-) diff --git a/apps/agent-worker/src/server.ts b/apps/agent-worker/src/server.ts index a65ea78..38e4009 100644 --- a/apps/agent-worker/src/server.ts +++ b/apps/agent-worker/src/server.ts @@ -8,6 +8,7 @@ import { listBoxTree, readBoxFile, restartBox, + setBoxUserPassword, startBox, stopBox, writeBoxFile, @@ -66,7 +67,9 @@ const jobRoute = (pathname: string) => { }; export const boxRoute = (pathname: string) => { - const match = /^\/box\/(status|lifecycle|tree|file)$/.exec(pathname); + const match = /^\/box\/(status|lifecycle|tree|file|set-password)$/.exec( + pathname, + ); return match?.[1] ? { action: match[1] } : null; }; @@ -136,6 +139,25 @@ export const startWorkerServer = () => { ); return; } + if (request.method === 'POST' && box.action === 'set-password') { + const body = await parseJson<{ password?: string | null }>(request); + // Convex owns validation/storage; this route only applies to the + // live container. Re-check bounds anyway so a bad caller can't + // push a malformed chpasswd line. + const password = body.password ?? null; + if ( + password !== null && + (password.length < 8 || + password.length > 128 || + // eslint-disable-next-line no-control-regex -- rejecting control chars is the point + /[\u0000-\u001f\u007f]/.test(password)) + ) { + sendJson(response, 400, { error: 'Invalid password' }); + return; + } + sendJson(response, 200, await setBoxUserPassword(user, password)); + return; + } sendJson(response, 404, { error: 'Not found' }); return; } diff --git a/apps/agent-worker/tests/unit/box-route.test.ts b/apps/agent-worker/tests/unit/box-route.test.ts index 09c1a9e..2d10695 100644 --- a/apps/agent-worker/tests/unit/box-route.test.ts +++ b/apps/agent-worker/tests/unit/box-route.test.ts @@ -13,12 +13,13 @@ const load = async () => { describe('boxRoute', () => { afterEach(() => vi.resetModules()); - test('matches the four box actions', async () => { + test('matches the five box actions', async () => { const { boxRoute } = await load(); expect(boxRoute('/box/status')).toEqual({ action: 'status' }); expect(boxRoute('/box/lifecycle')).toEqual({ action: 'lifecycle' }); expect(boxRoute('/box/tree')).toEqual({ action: 'tree' }); expect(boxRoute('/box/file')).toEqual({ action: 'file' }); + expect(boxRoute('/box/set-password')).toEqual({ action: 'set-password' }); }); test('rejects unknown, nested, and trailing paths', async () => { From 134bab93a5c8c594c9cb580eb4787378295bf772 Mon Sep 17 00:00:00 2001 From: Gabriel Brown Date: Mon, 13 Jul 2026 10:08:48 -0400 Subject: [PATCH 6/8] feat(next): box password API --- .../src/app/api/box/set-password/route.ts | 45 +++++++++++++++++++ apps/next/src/lib/agent-worker-proxy.ts | 2 +- 2 files changed, 46 insertions(+), 1 deletion(-) create mode 100644 apps/next/src/app/api/box/set-password/route.ts diff --git a/apps/next/src/app/api/box/set-password/route.ts b/apps/next/src/app/api/box/set-password/route.ts new file mode 100644 index 0000000..6d31a64 --- /dev/null +++ b/apps/next/src/app/api/box/set-password/route.ts @@ -0,0 +1,45 @@ +import { NextResponse } from 'next/server'; +import { proxyBox, withBox } from '@/lib/agent-worker-proxy'; +import { convexAuthNextjsToken } from '@convex-dev/auth/nextjs/server'; +import { fetchAction } from 'convex/nextjs'; + +import { api } from '@spoon/backend/convex/_generated/api.js'; + +// Set (or clear, with null/'') the box user's password. Convex is the source +// of truth and is written FIRST (box creation re-applies from it); the worker +// call then applies it to the live container. A worker failure downgrades to +// applied:false — the password still takes effect on the next box restart. +export const POST = async (request: Request) => + await withBox(async (username) => { + const body = (await request.json().catch(() => ({}))) as { + password?: string | null; + }; + const password = + typeof body.password === 'string' && body.password.length > 0 + ? body.password + : null; + + const token = await convexAuthNextjsToken(); + const stored = await fetchAction( + api.boxSettingsNode.setBoxPassword, + { password }, + { token }, + ); + + let applied = false; + try { + const workerResponse = await proxyBox(username, 'set-password', { + method: 'POST', + body: JSON.stringify({ password }), + }); + if (workerResponse.ok) { + applied = ((await workerResponse.json()) as { applied?: boolean }) + .applied + ? true + : false; + } + } catch { + // Stored but not live-applied; init picks it up at the next creation. + } + return NextResponse.json({ hasPassword: stored.hasPassword, applied }); + }); diff --git a/apps/next/src/lib/agent-worker-proxy.ts b/apps/next/src/lib/agent-worker-proxy.ts index 0d0b217..d1ec08b 100644 --- a/apps/next/src/lib/agent-worker-proxy.ts +++ b/apps/next/src/lib/agent-worker-proxy.ts @@ -181,7 +181,7 @@ export const resolveBoxUsername = async (): Promise< // ever operates on the caller's own box. export const proxyBox = async ( username: string, - action: 'status' | 'lifecycle' | 'tree' | 'file', + action: 'status' | 'lifecycle' | 'tree' | 'file' | 'set-password', init?: RequestInit, search?: URLSearchParams, ) => { From 028fbebcd69f3abebaeba1d968382844a10ec288 Mon Sep 17 00:00:00 2001 From: Gabriel Brown Date: Mon, 13 Jul 2026 10:11:11 -0400 Subject: [PATCH 7/8] feat(next): box user card on /machine --- .../src/components/machine/box-user-card.tsx | 187 ++++++++++++++++++ .../src/components/machine/machine-shell.tsx | 3 + .../tests/component/box-user-card.test.tsx | 86 ++++++++ 3 files changed, 276 insertions(+) create mode 100644 apps/next/src/components/machine/box-user-card.tsx create mode 100644 apps/next/tests/component/box-user-card.test.tsx diff --git a/apps/next/src/components/machine/box-user-card.tsx b/apps/next/src/components/machine/box-user-card.tsx new file mode 100644 index 0000000..3fa6bcd --- /dev/null +++ b/apps/next/src/components/machine/box-user-card.tsx @@ -0,0 +1,187 @@ +'use client'; + +import { useState } from 'react'; +import { useQuery } from 'convex/react'; +import { KeyRound, Loader2, UserRound } from 'lucide-react'; +import { toast } from 'sonner'; + +import { api } from '@spoon/backend/convex/_generated/api.js'; +import { + Button, + Card, + CardContent, + CardHeader, + CardTitle, + Input, +} from '@spoon/ui'; + +// Mirrors the worker's linuxUsername (runtime/box-user.ts) so the identity +// line shows the actual passwd entry. +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); +}; + +/** + * The box's Linux account: identity (user@host) and the user-settable + * password. Password is write-only — the UI only ever knows whether one is + * set. Saving stores it (encrypted) and applies it to a running box live; + * clearing reverts sudo to passwordless. + */ +export const BoxUserCard = ({ username }: { username: string }) => { + const passwordState = useQuery(api.boxSettings.hasMine, {}); + const hasPassword = passwordState?.hasPassword ?? false; + const user = linuxUsername(username || 'spoon'); + + const [password, setPassword] = useState(''); + const [confirm, setConfirm] = useState(''); + const [saving, setSaving] = useState(false); + const [pendingRestart, setPendingRestart] = useState(false); + + const tooShort = password.length > 0 && password.length < 8; + const mismatch = confirm.length > 0 && confirm !== password; + const canSave = + !saving && password.length >= 8 && password.length <= 128 && !mismatch; + + const submit = (next: string | null) => { + setSaving(true); + void (async () => { + try { + const response = await fetch('/api/box/set-password', { + method: 'POST', + body: JSON.stringify({ password: next }), + }); + if (!response.ok) throw new Error(await response.text()); + const data = (await response.json()) as { + hasPassword: boolean; + applied: boolean; + }; + setPassword(''); + setConfirm(''); + setPendingRestart(!data.applied); + toast.success( + next === null ? 'Password removed.' : 'Password updated.', + ); + } catch (error) { + console.error(error); + toast.error('Could not update the box password.'); + } finally { + setSaving(false); + } + })(); + }; + + return ( + + + + + Box user + + + +
+
+

+ Identity +

+

+ {user}@{user}-box +

+
+
+

+ Password +

+

{hasPassword ? 'Set' : 'Not set'}

+
+
+ +

+ Without a password, sudo works + without prompting. Once you set one, sudo asks for it — like a normal + machine. +

+ +
{ + event.preventDefault(); + if (canSave) submit(password); + }} + > +
+ + setPassword(event.target.value)} + className='w-56' + /> +
+
+ + setConfirm(event.target.value)} + className='w-56' + /> +
+ + {hasPassword ? ( + + ) : null} +
+ + {tooShort ? ( +

+ Password must be at least 8 characters. +

+ ) : null} + {mismatch ? ( +

Passwords do not match.

+ ) : null} + {pendingRestart ? ( +

+ Saved — your machine is not running, so it takes effect the next + time it starts. +

+ ) : null} +
+
+ ); +}; diff --git a/apps/next/src/components/machine/machine-shell.tsx b/apps/next/src/components/machine/machine-shell.tsx index ec65bab..195a312 100644 --- a/apps/next/src/components/machine/machine-shell.tsx +++ b/apps/next/src/components/machine/machine-shell.tsx @@ -18,6 +18,7 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from '@spoon/ui'; import type { BoxStatus, LifecycleAction } from './box-status-card'; import { BoxStatusCard } from './box-status-card'; +import { BoxUserCard } from './box-user-card'; const STATUS_POLL_MS = 10_000; @@ -261,6 +262,8 @@ export const MachineShell = () => { onAction={runLifecycle} /> + + setActiveTab(value as 'files' | 'terminal')} diff --git a/apps/next/tests/component/box-user-card.test.tsx b/apps/next/tests/component/box-user-card.test.tsx new file mode 100644 index 0000000..e1b325c --- /dev/null +++ b/apps/next/tests/component/box-user-card.test.tsx @@ -0,0 +1,86 @@ +import { cleanup, fireEvent, render, screen } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { BoxUserCard } from '../../src/components/machine/box-user-card'; + +const { mockUseQuery, fetchSpy } = vi.hoisted(() => ({ + mockUseQuery: vi.fn(), + fetchSpy: vi.fn(), +})); + +vi.mock('convex/react', () => ({ + useQuery: mockUseQuery, +})); + +vi.mock('sonner', () => ({ + toast: { error: vi.fn(), success: vi.fn() }, +})); + +describe('BoxUserCard', () => { + beforeEach(() => { + vi.stubGlobal('fetch', fetchSpy); + fetchSpy.mockResolvedValue( + new Response(JSON.stringify({ hasPassword: true, applied: true })), + ); + }); + afterEach(() => { + cleanup(); + vi.unstubAllGlobals(); + vi.clearAllMocks(); + }); + + it('shows the identity line and not-set state', () => { + mockUseQuery.mockReturnValue({ hasPassword: false }); + render(); + expect(screen.getByText('gabriel@gabriel-box')).toBeInTheDocument(); + expect(screen.getByText('Not set')).toBeInTheDocument(); + expect(screen.getByText('Set password')).toBeInTheDocument(); + expect(screen.queryByText('Remove password')).not.toBeInTheDocument(); + }); + + it('offers change/remove when a password is set', () => { + mockUseQuery.mockReturnValue({ hasPassword: true }); + render(); + expect(screen.getByText('Set')).toBeInTheDocument(); + expect(screen.getByText('Change password')).toBeInTheDocument(); + expect(screen.getByText('Remove password')).toBeInTheDocument(); + }); + + it('blocks submit on mismatched confirmation', () => { + mockUseQuery.mockReturnValue({ hasPassword: false }); + render(); + fireEvent.change(screen.getByLabelText('New password'), { + target: { value: 'longenough' }, + }); + fireEvent.change(screen.getByLabelText('Confirm'), { + target: { value: 'different1' }, + }); + expect(screen.getByText('Passwords do not match.')).toBeInTheDocument(); + expect(screen.getByText('Set password').closest('button')!.disabled).toBe( + true, + ); + expect(fetchSpy).not.toHaveBeenCalled(); + }); + + it('posts the password and shows the restart note when not applied live', async () => { + mockUseQuery.mockReturnValue({ hasPassword: false }); + fetchSpy.mockResolvedValue( + new Response(JSON.stringify({ hasPassword: true, applied: false })), + ); + render(); + fireEvent.change(screen.getByLabelText('New password'), { + target: { value: 'longenough' }, + }); + fireEvent.change(screen.getByLabelText('Confirm'), { + target: { value: 'longenough' }, + }); + fireEvent.click(screen.getByText('Set password')); + expect( + await screen.findByText(/takes effect the next time it starts/), + ).toBeInTheDocument(); + expect(fetchSpy).toHaveBeenCalledWith( + '/api/box/set-password', + expect.objectContaining({ method: 'POST' }), + ); + }); +}); From 4505471149b06a5c4d7c39f140deeeca2c2677ed Mon Sep 17 00:00:00 2001 From: Gabriel Brown Date: Mon, 13 Jul 2026 10:36:25 -0400 Subject: [PATCH 8/8] fix(worker): keep-id compat, zombie tmux clients, nvm in box image - Box init + chownInBox exec explicitly as root: under podman keep-id the container's default user is the mapped uid, which cannot useradd/chown. - Init renames an existing uid-1000 passwd entry (podman keep-id injects one named after the HOST user) instead of adding a duplicate that would win the getpwuid lookup and make whoami report the wrong name. - Terminal connections tag their shell with a per-connection marker and reap the process group on WebSocket close: closing the attach socket never killed the exec'd shell, so every disconnect leaked an attached tmux client (session stuck at the smallest zombie's size; enough dead clients starve tmux rendering for live ones). - Job image: nvm 0.39.5 at /usr/local/nvm (user-writable) with /etc/profile.d/nvm.sh, matching what user dotfiles expect; docs for the box user + ownership model. --- apps/agent-worker/src/box-init.ts | 3 ++ apps/agent-worker/src/runtime/box-user.ts | 19 +++++++---- apps/agent-worker/src/runtime/docker.ts | 4 +++ apps/agent-worker/src/terminal.ts | 32 ++++++++++++++++--- apps/agent-worker/tests/unit/box-user.test.ts | 10 ++++-- .../tests/unit/terminal-resize.test.ts | 15 +++++++++ docker/agent-job.Dockerfile | 10 ++++++ docs/agent-terminal.md | 25 +++++++++++++++ 8 files changed, 106 insertions(+), 12 deletions(-) diff --git a/apps/agent-worker/src/box-init.ts b/apps/agent-worker/src/box-init.ts index f70faad..7213bd7 100644 --- a/apps/agent-worker/src/box-init.ts +++ b/apps/agent-worker/src/box-init.ts @@ -27,6 +27,9 @@ const execRoot = async ( redact, timeoutMs: 120_000, input, + // Explicit: under podman keep-id the container's DEFAULT user is the + // mapped uid (1000), not root, and useradd/chpasswd/sudoers need root. + user: 'root', }); if (result.exitCode !== 0) { throw new Error(`Box ${label} failed: ${result.output}`); diff --git a/apps/agent-worker/src/runtime/box-user.ts b/apps/agent-worker/src/runtime/box-user.ts index 320601a..215a968 100644 --- a/apps/agent-worker/src/runtime/box-user.ts +++ b/apps/agent-worker/src/runtime/box-user.ts @@ -18,11 +18,15 @@ export const linuxUsername = (spoonUsername: string): string => { 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). `-o` allows -// the uid even if the image ever gains a uid-1000 user; `-M` skips skeleton -// copy since the mounted home persists across containers. +// Idempotent root init: ensure a SINGLE uid-1000 passwd entry with our name, +// 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). +// +// The uid-1000 entry may already exist: podman --userns=keep-id injects one +// named after the HOST user. A second entry at the same uid would win the +// getpwuid lookup (whoami would report the host username), so an existing +// entry is RENAMED rather than duplicated. `-M` skips skeleton copy since the +// mounted home persists across containers. export const buildBoxInitScript = (args: { username: string; home: string; @@ -32,7 +36,10 @@ export const buildBoxInitScript = (args: { const marker = shellQuote(`${args.home}/.spoon/chown-v1`); return [ 'set -e', - `if ! id -u ${user} >/dev/null 2>&1; then`, + `existing="$(getent passwd ${BOX_UID} | cut -d: -f1 || true)"`, + `if [ -n "$existing" ] && [ "$existing" != ${user} ]; then`, + ` usermod -l ${user} -d ${home} -s /bin/bash "$existing"`, + `elif [ -z "$existing" ]; then`, ` useradd -u ${BOX_UID} -o -M -d ${home} -s /bin/bash ${user}`, 'fi', `usermod -aG wheel ${user}`, diff --git a/apps/agent-worker/src/runtime/docker.ts b/apps/agent-worker/src/runtime/docker.ts index d4ac50e..fd08455 100644 --- a/apps/agent-worker/src/runtime/docker.ts +++ b/apps/agent-worker/src/runtime/docker.ts @@ -445,6 +445,10 @@ export const chownInBox = async (args: { containerRuntime(), [ 'exec', + // Explicit root: under podman keep-id the container default user is the + // mapped uid, which cannot chown files it does not own. + '-u', + 'root', args.containerName, 'chown', '-R', diff --git a/apps/agent-worker/src/terminal.ts b/apps/agent-worker/src/terminal.ts index 86dc9cf..a2d097a 100644 --- a/apps/agent-worker/src/terminal.ts +++ b/apps/agent-worker/src/terminal.ts @@ -1,3 +1,4 @@ +import { randomUUID } from 'node:crypto'; import type { Server } from 'node:http'; import type { Duplex } from 'node:stream'; import type { WebSocket } from 'ws'; @@ -7,7 +8,11 @@ import type { BoxHandle } from './user-container'; import { boxHomePaths } from './box'; import { env } from './env'; import { linuxUsername } from './runtime/box-user'; -import { chownInBox, getDockerClient } from './runtime/docker'; +import { + chownInBox, + getDockerClient, + killBoxProcessesByMarker, +} from './runtime/docker'; import { openExecStream } from './runtime/exec-stream'; import { parseBoxTokenUsername, @@ -53,10 +58,19 @@ const closeWithReason = (ws: WebSocket, code: number, reason: string) => { // The login-shell command shared by the job and box terminals: prefer a // resumable tmux session, else fall back to a plain interactive login shell. -const SHELL_CMD = [ +// +// The marker comment keeps a per-connection tag in the wrapper bash's argv so +// the bridge can kill THIS connection's process group when the WebSocket +// closes. Without it, every disconnect leaks an attached tmux client (the +// exec process survives the closed attach socket); the session then stays +// sized to the smallest zombie client, and once enough dead clients' ptys +// fill up, tmux stops rendering for live ones entirely. tmux/bash must NOT be +// exec'd — that would replace the marker-carrying argv. +export const buildTerminalShellCommand = (marker: string): string[] => [ '/bin/bash', '-lc', - 'if command -v tmux >/dev/null 2>&1; then exec tmux new-session -A -s spoon; else exec bash -il; fi', + `# ${marker} +if command -v tmux >/dev/null 2>&1; then tmux new-session -A -s spoon; else bash -il; fi`, ]; type ShellBridgeOptions = { @@ -107,6 +121,7 @@ const runShellBridge = async (ws: WebSocket, opts: ShellBridgeOptions) => { else pendingInput.push(data); }); + const marker = `spoon-term-${randomUUID()}`; const handleHolder: { current?: BoxHandle } = {}; let released = false; // Read through a function so TS doesn't narrow `released` to a constant — the @@ -117,6 +132,15 @@ const runShellBridge = async (ws: WebSocket, opts: ShellBridgeOptions) => { released = true; execHolder.current?.stream.end(); execHolder.current?.stream.destroy(); + // Closing the attach socket does NOT kill the exec'd shell — reap this + // connection's process group so dead tmux clients don't accumulate. The + // tmux server daemonized into its own group and survives. + const boxName = handleHolder.current?.boxName; + if (boxName && execHolder.current) { + void killBoxProcessesByMarker({ containerName: boxName, marker }).catch( + () => undefined, + ); + } handleHolder.current?.release(); }; ws.on('close', cleanup); @@ -166,7 +190,7 @@ const runShellBridge = async (ws: WebSocket, opts: ShellBridgeOptions) => { AttachStderr: true, Tty: true, User: opts.user, - Cmd: SHELL_CMD, + Cmd: buildTerminalShellCommand(marker), Env: ['TERM=xterm-256color', ...opts.envFlags], WorkingDir: opts.cwd, }); diff --git a/apps/agent-worker/tests/unit/box-user.test.ts b/apps/agent-worker/tests/unit/box-user.test.ts index cd6e0c8..ef2b630 100644 --- a/apps/agent-worker/tests/unit/box-user.test.ts +++ b/apps/agent-worker/tests/unit/box-user.test.ts @@ -31,8 +31,14 @@ describe('buildBoxInitScript', () => { username: 'gabriel', home: '/home/Gabriel', }); - it('creates the user idempotently with uid 1000 at the given home', () => { - expect(script).toContain("id -u 'gabriel'"); + it('renames an existing uid-1000 entry instead of duplicating it', () => { + // podman --userns=keep-id injects a passwd entry named after the HOST user + // at the mapped uid; a second uid-1000 entry would win the getpwuid lookup + // and make whoami report the host username. + expect(script).toContain('getent passwd 1000'); + expect(script).toContain("usermod -l 'gabriel'"); + }); + it('creates the user with uid 1000 at the given home when none exists', () => { expect(script).toContain('useradd'); expect(script).toContain('-u 1000'); expect(script).toContain("-d '/home/Gabriel'"); diff --git a/apps/agent-worker/tests/unit/terminal-resize.test.ts b/apps/agent-worker/tests/unit/terminal-resize.test.ts index 4a7d1fe..7eb907d 100644 --- a/apps/agent-worker/tests/unit/terminal-resize.test.ts +++ b/apps/agent-worker/tests/unit/terminal-resize.test.ts @@ -44,3 +44,18 @@ describe('parseResizeMessage', () => { expect(parseResizeMessage(msg, false)).toEqual({ cols: 1000, rows: 1 }); }); }); + +describe('buildTerminalShellCommand', () => { + test('embeds the connection marker without exec-ing the shell away', async () => { + const { buildTerminalShellCommand } = await load(); + const command = buildTerminalShellCommand('spoon-term-abc-123'); + expect(command[0]).toBe('/bin/bash'); + const script = command[2] ?? ''; + // Marker must stay in the wrapper's argv for pgrep-based cleanup, so the + // inner shell/tmux must NOT be exec'd (exec would replace the argv). + expect(script).toContain('# spoon-term-abc-123'); + expect(script).not.toContain('exec tmux'); + expect(script).not.toContain('exec bash'); + expect(script).toContain('tmux new-session -A -s spoon'); + }); +}); diff --git a/docker/agent-job.Dockerfile b/docker/agent-job.Dockerfile index 0ea3eae..0a1af7e 100644 --- a/docker/agent-job.Dockerfile +++ b/docker/agent-job.Dockerfile @@ -50,6 +50,16 @@ RUN dnf install -y --setopt=install_weak_deps=False --nodocs \ RUN npm install -g pnpm yarn bun@1.3.10 opencode-ai@1.17.9 @openai/codex@0.142.0 @anthropic-ai/claude-code@2.1.207 \ && npm cache clean --force +# nvm, system-wide: user dotfiles source /etc/profile.d/nvm.sh (workstation +# parity), and NVM_DIR must be writable by the box user (uid 1000) so +# `nvm install` works from the shell. PROFILE=/dev/null stops the installer +# from editing root's rc files. +ENV NVM_DIR=/usr/local/nvm +RUN mkdir -p "$NVM_DIR" \ + && wget -qO- https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.5/install.sh | PROFILE=/dev/null bash \ + && printf 'export NVM_DIR=/usr/local/nvm\n[ -s "$NVM_DIR/nvm.sh" ] && . "$NVM_DIR/nvm.sh"\n[ -s "$NVM_DIR/bash_completion" ] && . "$NVM_DIR/bash_completion"\n' > /etc/profile.d/nvm.sh \ + && chown -R 1000:1000 "$NVM_DIR" + # oh-my-posh prompt (binary only; we ship our own /etc/spoon/omp.json theme). RUN curl -fsSL https://ohmyposh.dev/install.sh | bash -s -- -d /usr/local/bin \ && oh-my-posh version diff --git a/docs/agent-terminal.md b/docs/agent-terminal.md index f96b8ba..a977733 100644 --- a/docs/agent-terminal.md +++ b/docs/agent-terminal.md @@ -108,6 +108,31 @@ Docker mode. Prod (Docker socket mounted) works as-is. is intended for the single-user self-hosted deployment; do not expose the worker domain without TLS, and keep the deployment single-tenant. +## The box user + +Terminals and agent turns run as a named non-root user (uid 1000), not root — +Claude Code refuses some operations as root, and a real machine has a named +account. The Linux username mirrors your Spoon username (lowercased/sanitized; +the passwd entry may differ from the home folder name), the hostname is +`-box`, and the account is in `wheel`. + +**sudo & password:** with no password set, sudo works without prompting (a +NOPASSWD drop-in at `/etc/sudoers.d/spoon-box`). Set a password from the +**/machine → Box user** card: it is stored encrypted in Convex, applied live to +a running box (`chpasswd` over stdin via `POST /box/set-password`), and +re-applied at every box creation — at which point the NOPASSWD drop-in is +removed and wheel membership makes sudo prompt. Clearing the password reverts +to passwordless sudo. Passwords are 8-128 chars and write-only (the UI only +ever learns whether one is set). + +**File ownership:** the worker writes into homes from the host (clones, +dotfiles, the file editor). Under dev rootless podman the box runs with +`--userns=keep-id:uid=1000,gid=1000` so the host user IS the box user and +ownership just works (`SPOON_AGENT_BOX_USERNS` overrides; empty disables). +Under prod rootful docker the worker repairs ownership with targeted +`chown -R 1000:1000` execs after each host-side write, plus a one-time +marker-guarded (`~/.spoon/chown-v1`) home chown at first box creation. + ## Tools in the shell The box image ships `bash`, `tmux`, `neovim`, `git`, `ripgrep`, `jq`, `python3`,