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",