diff --git a/.changeset/docker-runner.md b/.changeset/docker-runner.md new file mode 100644 index 0000000..915580d --- /dev/null +++ b/.changeset/docker-runner.md @@ -0,0 +1,11 @@ +--- +'@gemstack/ai-autopilot': minor +--- + +Runner: `DockerRunner` — the first sandboxed adapter, running agent-authored code in a container. + +`DockerRunner` boots each workspace as a container via the `docker` CLI (no npm dependency), so untrusted, agent-authored code runs isolated from the host: its own filesystem, process space, and — with `preview` — a published port mapped to an ephemeral host port. It satisfies the same `Runner` contract as `LocalRunner` (fs / exec / start / preview / dispose), so it drops in behind the seam unchanged: `new DockerRunner({ image?, previewPort?, previewHost? })`. + +Where `LocalRunner` runs commands unsandboxed on the host (trusted dev/CI), `DockerRunner` is the one to reach for when the code is untrusted. It requires a running Docker daemon and the `docker` CLI on `PATH`; the default `node:20-alpine` base image carries `node`/`npm` and a POSIX shell. `dockerAvailable()` reports whether a daemon is reachable so callers (and the test suite) can skip cleanly when it isn't. + +WebContainer and Flue remain the still-parked sandboxed adapters (#109). diff --git a/packages/ai-autopilot/src/runner/docker.test.ts b/packages/ai-autopilot/src/runner/docker.test.ts new file mode 100644 index 0000000..3fc7749 --- /dev/null +++ b/packages/ai-autopilot/src/runner/docker.test.ts @@ -0,0 +1,196 @@ +import { describe, it } from 'node:test' +import assert from 'node:assert/strict' +import { execFileSync } from 'node:child_process' +import { DockerRunner, dockerAvailable } from './docker.js' +import { RunnerError } from './types.js' + +// Real containers need a daemon; skip the whole suite (green) when one isn't reachable. +const skip = (await dockerAvailable()) ? false : 'no docker daemon available' + +/** A server bound to 0.0.0.0 so Docker's published port can reach it from the host. */ +const httpServer = (port: number, body: string): string => + `const http=require('http');http.createServer((_,res)=>{res.writeHead(200);res.end(${JSON.stringify(body)})}).listen(${port},'0.0.0.0')` + +describe('DockerRunner.boot', { skip }, () => { + it('seeds a container workspace with files, including nested paths', async () => { + const s = await new DockerRunner().boot({ files: { './pages/+Page.jsx': 'PAGE', 'app.ts': 'APP' } }) + try { + assert.equal(await s.fs.read('pages/+Page.jsx'), 'PAGE') + assert.equal(await s.fs.read('/app.ts'), 'APP') // leading slash normalized + } finally { + await s.dispose() + } + }) + + it('gives each session a distinct id and container', async () => { + const runner = new DockerRunner() + const a = await runner.boot() + const b = await runner.boot() + try { + assert.notEqual(a.id, b.id) + assert.notEqual(a.container, b.container) + } finally { + await a.dispose() + await b.dispose() + } + }) +}) + +describe('DockerRunnerSession.fs', { skip }, () => { + it('writes, reads, checks existence, lists recursively, and removes', async () => { + const s = await new DockerRunner().boot() + try { + assert.equal(await s.fs.exists('a.txt'), false) + await s.fs.write('src/a.txt', 'A') + await s.fs.write('src/b.txt', 'B') + await s.fs.write('root.txt', 'R') + assert.equal(await s.fs.exists('src/a.txt'), true) + assert.deepEqual(await s.fs.list('src'), ['src/a.txt', 'src/b.txt']) + assert.deepEqual(await s.fs.list(), ['root.txt', 'src/a.txt', 'src/b.txt']) + await s.fs.remove('src/a.txt') + assert.equal(await s.fs.exists('src/a.txt'), false) + } finally { + await s.dispose() + } + }) + + it('throws reading a missing file', async () => { + const s = await new DockerRunner().boot() + try { + await assert.rejects(() => s.fs.read('nope.txt'), RunnerError) + } finally { + await s.dispose() + } + }) + + it('refuses paths that escape the workspace', async () => { + const s = await new DockerRunner().boot() + try { + await assert.rejects(() => s.fs.read('../../etc/passwd'), RunnerError) + await assert.rejects(() => s.fs.write('../evil.txt', 'x'), RunnerError) + } finally { + await s.dispose() + } + }) +}) + +describe('DockerRunnerSession.exec', { skip }, () => { + it('runs a real command inside the container and captures stdout + exit code', async () => { + const s = await new DockerRunner().boot({ files: { 'app.js': "process.stdout.write('hi')" } }) + try { + const r = await s.exec('node app.js') + assert.equal(r.stdout, 'hi') + assert.equal(r.exitCode, 0) + } finally { + await s.dispose() + } + }) + + it('reports a non-zero exit code', async () => { + const s = await new DockerRunner().boot() + try { + assert.equal((await s.exec('node -e "process.exit(3)"')).exitCode, 3) + } finally { + await s.dispose() + } + }) + + it('honors cwd and env overrides', async () => { + const s = await new DockerRunner().boot({ files: { 'sub/probe.js': 'process.stdout.write(process.env.FOO||"")' } }) + try { + const r = await s.exec('node probe.js', { cwd: 'sub', env: { FOO: 'bar' } }) + assert.equal(r.stdout, 'bar') + } finally { + await s.dispose() + } + }) + + it('runs isolated from the host filesystem', async () => { + const s = await new DockerRunner().boot() + try { + // The workspace is empty inside the container no matter what sits in the host cwd. + assert.deepEqual(await s.fs.list(), []) + assert.equal((await s.exec('ls package.json 2>/dev/null; true')).stdout.trim(), '') + } finally { + await s.dispose() + } + }) + + it('kills a command that exceeds its timeout', async () => { + const s = await new DockerRunner().boot() + try { + const r = await s.exec('node -e "setTimeout(()=>{},10000)"', { timeoutMs: 1000 }) + assert.equal(r.exitCode, 124) + assert.match(r.stderr, /timed out/) + } finally { + await s.dispose() + } + }) +}) + +describe('DockerRunnerSession.preview', { skip }, () => { + it('omits the preview method when previews are disabled', async () => { + const s = await new DockerRunner({ preview: false }).boot() + try { + assert.equal(s.preview, undefined) + } finally { + await s.dispose() + } + }) + + it('rejects a port that was not published at boot', async () => { + const s = await new DockerRunner().boot() // publishes previewPort 3000 + try { + await assert.rejects(() => s.preview!({ port: 9999 }), RunnerError) + } finally { + await s.dispose() + } + }) +}) + +describe('DockerRunnerSession.dispose', { skip }, () => { + it('force-removes the container and blocks further exec/preview (idempotent)', async () => { + const s = await new DockerRunner().boot() + const container = s.container + await s.dispose() + assert.equal(s.disposed, true) + // The container is actually gone, not just flagged disposed. + const left = execFileSync('docker', ['ps', '-aq', '--filter', `name=${container}`], { encoding: 'utf8' }).trim() + assert.equal(left, '') + await s.dispose() // idempotent + await assert.rejects(() => s.exec('ls'), RunnerError) + await assert.rejects(() => s.preview!(), RunnerError) + }) +}) + +describe('DockerRunnerSession.start (boot and serve)', { skip }, () => { + it('serves a real background server that preview can reach, then stop kills it', async () => { + const s = await new DockerRunner().boot({ files: { 'server.js': httpServer(3000, 'hello from docker') } }) + try { + const proc = await s.start('node server.js') + assert.equal(proc.command, 'node server.js') + + const preview = await s.preview!({ port: 3000, waitMs: 8000 }) + const res = await fetch(preview.url) + assert.equal(await res.text(), 'hello from docker') // the app is actually serving from inside the container + + await proc.stop() + await assert.rejects(fetch(preview.url)) // host port no longer forwards + } finally { + await s.dispose() + } + }) + + it('start does not block on a long-running process', async () => { + const s = await new DockerRunner().boot({ files: { 'server.js': httpServer(3000, 'x') } }) + try { + const proc = await s.start('node server.js') + let exited = false + void proc.exit.then(() => (exited = true)) + assert.equal(exited, false) // still running right after start + await proc.stop() + } finally { + await s.dispose() + } + }) +}) diff --git a/packages/ai-autopilot/src/runner/docker.ts b/packages/ai-autopilot/src/runner/docker.ts new file mode 100644 index 0000000..b43119a --- /dev/null +++ b/packages/ai-autopilot/src/runner/docker.ts @@ -0,0 +1,346 @@ +import { spawn } from 'node:child_process' +import type { + Runner, + RunnerSession, + RunnerFs, + RunnerProcess, + BootOptions, + ExecOptions, + ExecResult, + Preview, + PreviewOptions, +} from './types.js' +import { RunnerError } from './types.js' + +const delay = (ms: number): Promise => new Promise(res => setTimeout(res, ms)) + +/** The workspace root inside every container. */ +const WORKSPACE = '/workspace' + +/** A monotonic counter so container names stay unique within a process. */ +let seq = 0 + +/** + * Run the `docker` CLI once (no shell), capturing its output. `input`, when + * given, is piped to the process's stdin. Rejects only when the binary itself + * can't be spawned — a non-zero exit is returned in {@link ExecResult}. + */ +function docker(args: string[], input?: string): Promise { + return new Promise((resolvePromise, reject) => { + const child = spawn('docker', args) + let stdout = '' + let stderr = '' + child.stdout?.on('data', d => (stdout += d)) + child.stderr?.on('data', d => (stderr += d)) + child.on('error', err => reject(new RunnerError(`docker CLI unavailable: ${(err as Error).message}`))) + child.on('close', code => resolvePromise({ stdout, stderr, exitCode: code ?? 1 })) + child.stdin?.end(input ?? '') + }) +} + +/** True when a Docker daemon is reachable — lets callers/tests skip when it isn't. */ +export async function dockerAvailable(): Promise { + try { + return (await docker(['info'])).exitCode === 0 + } catch { + return false + } +} + +/** + * Resolve once the server inside the container is accepting connections on + * `port`, or after `timeoutMs`. The probe runs *inside* the container on purpose: + * Docker Desktop's host-side port proxy pre-binds the published port, so a + * host-side TCP connect succeeds before the real server is up — a false ready. + * Probing from inside (no proxy) reflects the actual listener. + */ +async function waitForContainerPort(container: string, port: number, timeoutMs: number): Promise { + const deadline = Date.now() + timeoutMs + const probe = `require('net').connect(${port},'127.0.0.1',function(){process.exit(0)}).on('error',function(){process.exit(1)})` + for (;;) { + const r = await docker(['exec', container, 'node', '-e', probe]).catch(() => ({ exitCode: 1 }) as ExecResult) + if (r.exitCode === 0 || Date.now() >= deadline) return + await delay(100) + } +} + +/** Normalize a workspace path to a canonical relative form (matches LocalFs/FakeFs). */ +function norm(path: string): string { + return path.replace(/^\.?\/+/, '').replace(/\/+$/, '') +} + +/** Resolve `path` to an absolute container path under {@link WORKSPACE}, rejecting escapes. */ +function within(path: string): string { + const parts: string[] = [] + for (const seg of norm(path).split('/')) { + if (seg === '' || seg === '.') continue + if (seg === '..') { + if (parts.length === 0) throw new RunnerError(`path escapes the workspace: ${path}`) + parts.pop() + } else parts.push(seg) + } + return parts.length ? `${WORKSPACE}/${parts.join('/')}` : WORKSPACE +} + +/** Flatten an env map into repeated `-e KEY=VALUE` docker flags. */ +function envFlags(env: Record): string[] { + return Object.entries(env).flatMap(([k, v]) => ['-e', `${k}=${v}`]) +} + +export interface DockerRunnerOptions { + /** Base image each workspace boots from. Default `node:20-alpine`. */ + image?: string + /** Whether booted sessions expose a `preview`. Default `true`. */ + preview?: boolean + /** + * The in-container port `preview` publishes. Fixed at boot because Docker maps + * ports when the container starts. Default `3000` (autopilot's serve default). + */ + previewPort?: number + /** Origin returned by `preview()`, joined with the mapped host port. Default `http://localhost`. */ + previewHost?: string +} + +type ResolvedOptions = Required> + +/** A {@link RunnerFs} backed by a container's filesystem, driven through `docker exec`. */ +class DockerFs implements RunnerFs { + constructor(private readonly container: string) {} + + async read(path: string): Promise { + const r = await docker(['exec', this.container, 'cat', within(path)]) + if (r.exitCode !== 0) throw new RunnerError(`no such file: ${path}`) + return r.stdout + } + + async write(path: string, contents: string): Promise { + // Pass the path as $0 so it can never be interpreted as shell; mkdir -p its parent, then take stdin. + const r = await docker( + ['exec', '-i', this.container, 'sh', '-c', 'mkdir -p "$(dirname "$0")" && cat > "$0"', within(path)], + contents, + ) + if (r.exitCode !== 0) throw new RunnerError(`write failed: ${path}: ${r.stderr.trim()}`) + } + + async remove(path: string): Promise { + await docker(['exec', this.container, 'rm', '-rf', within(path)]) + } + + async list(dir?: string): Promise { + const base = dir ? within(dir) : WORKSPACE + const r = await docker(['exec', this.container, 'find', base, '-type', 'f']) + if (r.exitCode !== 0) return [] // missing dir → empty, mirroring LocalFs + return r.stdout + .split('\n') + .filter(Boolean) + .map(p => p.slice(WORKSPACE.length + 1)) // strip the '/workspace/' prefix + .sort() + } + + async exists(path: string): Promise { + return (await docker(['exec', this.container, 'test', '-e', within(path)])).exitCode === 0 + } +} + +/** + * One booted workspace running inside a container: a real filesystem, a real + * shell, and (optionally) a preview whose host port Docker assigned at boot. + */ +export class DockerRunnerSession implements RunnerSession { + readonly id: string + readonly fs: DockerFs + /** The container's name — also its handle for `docker exec`/`rm`. */ + readonly container: string + disposed = false + + readonly preview?: (opts?: PreviewOptions) => Promise + + private readonly cwd: string + private readonly env: Record + private readonly procs = new Set() + /** Distinguishes background processes so `stop` can target the right one. */ + private startSeq = 0 + + constructor(id: string, container: string, boot: BootOptions, opts: ResolvedOptions) { + this.id = id + this.container = container + this.fs = new DockerFs(container) + this.cwd = boot.cwd ? norm(boot.cwd) : '' + this.env = { ...(boot.env ?? {}) } + if (opts.preview) { + this.preview = async (previewOpts: PreviewOptions = {}): Promise => { + if (this.disposed) throw new RunnerError('preview on a disposed session') + const wanted = previewOpts.port ?? opts.previewPort + if (wanted !== opts.previewPort) { + throw new RunnerError( + `preview port ${wanted} was not published at boot; construct DockerRunner({ previewPort: ${wanted} })`, + ) + } + // Docker assigned an ephemeral host port at boot; ask which one, then hand back the reachable URL. + const r = await docker(['port', this.container, `${opts.previewPort}/tcp`]) + const mapping = r.stdout.split('\n').map(s => s.trim()).filter(Boolean)[0] + if (!mapping) throw new RunnerError(`no published host port for ${opts.previewPort}`) + const hostPort = Number(mapping.slice(mapping.lastIndexOf(':') + 1)) + // Probe the container port, not the host port — the host proxy pre-binds and lies about readiness. + if (previewOpts.waitMs && previewOpts.waitMs > 0) await waitForContainerPort(this.container, opts.previewPort, previewOpts.waitMs) + return { url: `${opts.previewHost}:${hostPort}`, port: hostPort } + } + } + } + + private args(command: string, opts: ExecOptions, flags: string[] = []): string[] { + const cwd = within(opts.cwd ?? (this.cwd || '.')) + const env = { ...this.env, ...(opts.env ?? {}) } + return ['exec', ...flags, '-w', cwd, ...envFlags(env), this.container, 'sh', '-c', command] + } + + /** + * Start a long-running command in the background and return a handle at once. + * The command records its own pid to a file (via `exec`, so the pid is the + * command's, not the shell's) so `stop` can signal it; `dispose` force-removes + * the container as a backstop for anything the signal misses. + */ + async start(command: string, opts: ExecOptions = {}): Promise { + if (this.disposed) throw new RunnerError('start on a disposed session') + const pidfile = `/tmp/ai-autopilot-start-${++this.startSeq}.pid` + const child = spawn('docker', this.args(`echo $$ > ${pidfile}; exec ${command}`, opts)) + let stdout = '' + let stderr = '' + child.stdout?.on('data', d => (stdout += d)) + child.stderr?.on('data', d => (stderr += d)) + + let resolveExit!: (r: ExecResult) => void + const exit = new Promise(res => (resolveExit = res)) + let settled = false + const settle = (r: ExecResult): void => { + if (settled) return + settled = true + resolveExit(r) + } + child.on('close', (code, signal) => settle({ stdout, stderr, exitCode: code ?? (signal ? 137 : 0) })) + child.on('error', err => settle({ stdout, stderr: stderr + `\n[ai-autopilot] failed to spawn: ${(err as Error).message}`, exitCode: 1 })) + + // Signal the recorded in-container pid; ignore failures (already gone / container removed). + const killInside = (sig: string): Promise => + docker(['exec', this.container, 'sh', '-c', `kill -${sig} "$(cat ${pidfile} 2>/dev/null)" 2>/dev/null; true`]).catch(() => {}) + + const proc: RunnerProcess = { + command, + exit, + stop: async () => { + if (!settled) { + await killInside('TERM') + const raced = await Promise.race([exit, delay(2000).then(() => 'timeout' as const)]) + if (raced === 'timeout') await killInside('KILL') + child.kill('SIGKILL') // drop the host-side exec client too + await exit + } + this.procs.delete(proc) + }, + } + exit.then(() => this.procs.delete(proc)) + this.procs.add(proc) + return proc + } + + async exec(command: string, opts: ExecOptions = {}): Promise { + if (this.disposed) throw new RunnerError('exec on a disposed session') + return await new Promise((resolvePromise, reject) => { + const child = spawn('docker', this.args(command, opts)) + let stdout = '' + let stderr = '' + let timedOut = false + // Enforce the timeout host-side (like LocalRunner); dispose reaps any in-container remnant. + const timer = + opts.timeoutMs != null + ? setTimeout(() => { + timedOut = true + child.kill('SIGKILL') + }, opts.timeoutMs) + : undefined + child.stdout?.on('data', d => (stdout += d)) + child.stderr?.on('data', d => (stderr += d)) + child.on('error', err => { + if (timer) clearTimeout(timer) + reject(new RunnerError(`failed to spawn: ${(err as Error).message}`)) + }) + child.on('close', (code, signal) => { + if (timer) clearTimeout(timer) + if (timedOut) { + resolvePromise({ stdout, stderr: stderr + `\n[ai-autopilot] command timed out after ${opts.timeoutMs}ms`, exitCode: 124 }) + return + } + // docker exec propagates the in-container exit code as its own. + resolvePromise({ stdout, stderr, exitCode: code ?? (signal ? 137 : 1) }) + }) + }) + } + + async dispose(): Promise { + if (this.disposed) return + this.disposed = true + await Promise.all([...this.procs].map(p => p.stop().catch(() => {}))) + await docker(['rm', '-f', this.container]).catch(() => {}) + } +} + +/** + * A {@link Runner} that boots each workspace as a container via the `docker` CLI + * — the sandboxed counterpart to {@link LocalRunner}. Untrusted, agent-authored + * code runs isolated from the host: its own filesystem, process space, and + * (with `preview`) a published port mapped to an ephemeral host port. + * + * Requires a running Docker daemon and the `docker` CLI on `PATH`; it shells out + * to them and pulls no npm dependency. The base image needs a POSIX shell plus + * `node`/`npm` — the default `node:20-alpine` has both. + * + * ```ts + * const runner = new DockerRunner() + * const s = await runner.boot({ files: { 'app.js': "console.log('hi')" } }) + * await s.exec('node app.js') // one-shot inside the container + * + * const dev = await s.start('npm run dev') // long-running, returns at once + * const { url } = await s.preview({ port: 3000, waitMs: 8000 }) // mapped host URL, once reachable + * await dev.stop() + * await s.dispose() // docker rm -f — stops everything, frees the port + * ``` + * + * The dev server must bind `0.0.0.0` inside the container (not `127.0.0.1`), or + * Docker's published port can't reach it from the host. + */ +export class DockerRunner implements Runner { + readonly kind = 'docker' + + private readonly image: string + private readonly opts: ResolvedOptions + + constructor(options: DockerRunnerOptions = {}) { + this.image = options.image ?? 'node:20-alpine' + this.opts = { + preview: options.preview ?? true, + previewPort: options.previewPort ?? 3000, + previewHost: options.previewHost ?? 'http://localhost', + } + } + + async boot(opts: BootOptions = {}): Promise { + const id = `${Date.now().toString(36)}-${++seq}` + const name = `ai-autopilot-${id}` + // Publish the preview port to an ephemeral host port on localhost; keep the container alive to exec into. + const publish = this.opts.preview ? ['-p', `127.0.0.1:0:${this.opts.previewPort}`] : [] + const run = await docker(['run', '-d', '--name', name, '-w', WORKSPACE, ...publish, this.image, 'tail', '-f', '/dev/null']) + if (run.exitCode !== 0) { + throw new RunnerError(`failed to boot container: ${(run.stderr.trim() || run.stdout.trim()) ?? ''}`) + } + try { + const session = new DockerRunnerSession(id, name, opts, this.opts) + for (const [path, contents] of Object.entries(opts.files ?? {})) { + await session.fs.write(path, contents) + } + return session + } catch (err) { + await docker(['rm', '-f', name]).catch(() => {}) + throw err + } + } +} diff --git a/packages/ai-autopilot/src/runner/index.ts b/packages/ai-autopilot/src/runner/index.ts index f576bad..987e489 100644 --- a/packages/ai-autopilot/src/runner/index.ts +++ b/packages/ai-autopilot/src/runner/index.ts @@ -9,6 +9,7 @@ * * - {@link FakeRunner} — in-memory runner for tests (the runner analog of `AiFake`) * - {@link LocalRunner} — real host workspace (fs + child processes); the first real adapter + * - {@link DockerRunner} — sandboxed workspace in a container (via the `docker` CLI) * - {@link runnerTools} — expose a session to an agent as sandbox tools */ export type { @@ -33,4 +34,5 @@ export { type RecordedStart, } from './fake.js' export { LocalRunner, LocalRunnerSession, type LocalRunnerOptions } from './local.js' +export { DockerRunner, DockerRunnerSession, dockerAvailable, type DockerRunnerOptions } from './docker.js' export { runnerTools, type RunnerToolsOptions } from './tools.js'