From 2a33dd22b71b65a0523eda297a294f47df3d96c8 Mon Sep 17 00:00:00 2001 From: Suleiman Shahbari Date: Sun, 5 Jul 2026 22:18:45 +0300 Subject: [PATCH] feat(framework): run the --serve verification in a Docker sandbox (#229) --- .changeset/docker-serve-sandbox.md | 9 ++ packages/framework/src/cli.test.ts | 14 +++ packages/framework/src/cli.ts | 14 +++ packages/framework/src/index.ts | 1 + packages/framework/src/run.ts | 118 +++++++++++++++++----- packages/framework/src/sandbox.test.ts | 48 +++++++++ packages/framework/src/sandbox.ts | 68 +++++++++++++ packages/framework/src/serve-gate.test.ts | 61 +++++++++++ 8 files changed, 310 insertions(+), 23 deletions(-) create mode 100644 .changeset/docker-serve-sandbox.md create mode 100644 packages/framework/src/sandbox.test.ts create mode 100644 packages/framework/src/sandbox.ts diff --git a/.changeset/docker-serve-sandbox.md b/.changeset/docker-serve-sandbox.md new file mode 100644 index 0000000..f97bc65 --- /dev/null +++ b/.changeset/docker-serve-sandbox.md @@ -0,0 +1,9 @@ +--- +'@gemstack/framework': minor +--- + +feat(framework): run the `--serve` verification in a Docker sandbox (#229) + +`framework --serve ... --sandbox docker` now boots the app inside a throwaway container instead of on the host: the source is copied in, deps install and the dev server runs in the container, and the health check hits a mapped port. So agent-authored code never installs or runs on your machine to be verified. `--sandbox local` (the default) is unchanged — it adopts the host cwd in place. + +This is the first slice of #229: only the serve verification is sandboxed; the build itself still runs on the host (the container is re-seeded with the latest source before each check). Requires a reachable Docker daemon — a run that asks for the sandbox without one fails fast with a clear message; `--sandbox docker` without `--serve` is a no-op note. `runFramework` gains `sandbox` and an injectable `runner` option. diff --git a/packages/framework/src/cli.test.ts b/packages/framework/src/cli.test.ts index 243a99c..379092e 100644 --- a/packages/framework/src/cli.test.ts +++ b/packages/framework/src/cli.test.ts @@ -88,6 +88,13 @@ test('parseArgs defaults autoPreset on, and --no-auto-preset turns it off (#204) assert.equal(parseArgs(['--no-auto-preset', 'x']).autoPreset, false) }) +test('parseArgs reads --sandbox and rejects an unknown value (#229)', () => { + assert.equal(parseArgs(['--sandbox', 'docker', 'x']).sandbox, 'docker') + assert.equal(parseArgs(['--sandbox', 'local', 'x']).sandbox, 'local') + assert.equal(parseArgs(['x']).sandbox, undefined) + assert.match(parseArgs(['--sandbox', 'vm', 'x']).error!, /invalid --sandbox/) +}) + test('workspaceSummary describes an empty vs an existing workspace', async () => { const empty = await mkdtemp(join(tmpdir(), 'framework-ws-')) try { @@ -195,6 +202,13 @@ test('runCli notes --kind given without a preset (#265)', async () => { assert.ok(err.some(l => /build event "bug-fix" has no effect without a preset/.test(l))) }) +test('runCli notes --sandbox docker given without --serve (#229)', async () => { + const { io, err } = capture() + const code = await runCli(['--fake', '--no-dashboard', '--sandbox', 'docker'], io) + assert.equal(code, 0) + assert.ok(err.some(l => /--sandbox docker has no effect without --serve/.test(l))) +}) + test('chooseSessionLink defaults a live run to the claude.ai/code session list (#212)', () => { assert.equal(chooseSessionLink({ sessionLink: undefined }, false), CLAUDE_CODE_SESSION_LIST) assert.equal(CLAUDE_CODE_SESSION_LIST, 'https://claude.ai/code') diff --git a/packages/framework/src/cli.ts b/packages/framework/src/cli.ts index 82f9cfe..26f327e 100644 --- a/packages/framework/src/cli.ts +++ b/packages/framework/src/cli.ts @@ -109,6 +109,8 @@ Options: --serve-build Build command before serving (e.g. "npm run build"). --serve-port Port the app listens on (default: 3000). --serve-path Path to health-check once it is up (default: /). + --sandbox Where --serve runs: "local" (host, default) or "docker" + (a throwaway container, so agent code never runs on the host). --deploy Deploy to this target (cloudflare, dokploy) or narrate any other. --cf-project Cloudflare Pages project name (for a Pages deploy). --dokploy-url Dokploy instance URL (required for --deploy dokploy). @@ -159,6 +161,7 @@ export interface CliOptions { serveBuild?: string | undefined servePort?: number servePath?: string | undefined + sandbox?: 'local' | 'docker' | undefined port?: number dashboard: boolean composeExtensions: boolean @@ -283,6 +286,12 @@ export function parseArgs(argv: string[]): CliOptions { else opts.servePort = n break } + case '--sandbox': { + const where = argv[++i] + if (where !== 'local' && where !== 'docker') opts.error = `invalid --sandbox: expected "local" or "docker"` + else opts.sandbox = where + break + } case '--session-link': opts.sessionLink = argv[++i] break @@ -556,6 +565,10 @@ export async function runCli(argv: string[], io: CliIO = defaultIO): Promise emit({ kind: 'log', message: `serve: ${message}` }), - }), - ) - : reviewChecklist + let checklist: NonNullable = reviewChecklist + if (runner && s) { + const check = serveCheck(runner, { + serve: s.command, + ...(s.install ? { install: s.install } : {}), + ...(s.build ? { build: s.build } : {}), + ...(s.port !== undefined ? { port: s.port } : {}), + ...(s.waitMs !== undefined ? { waitMs: s.waitMs } : {}), + ...(s.healthPath ? { healthPath: s.healthPath } : {}), + onProgress: message => emit({ kind: 'log', message: `serve: ${message}` }), + }) + // The build runs on the host, so a sandboxed container must be re-seeded with + // the latest host source before every check (each pass changes it). Local reads + // the host dir live, so it needs no sync. + const serveStep = sandbox === 'docker' && !opts.runner ? syncThenServe(runner, opts.cwd, check, emit) : check + checklist = mergeChecklists(reviewChecklist, serveStep) + } // A real driver writes files to the workspace, so the build/improve steps can // detect an empty workspace and hard-scaffold it (#182). The fake driver writes @@ -430,12 +457,12 @@ export async function runFramework(opts: RunFrameworkOptions): Promise void, ): Promise { if (!runner.start || !runner.preview) return undefined - let proc: Awaited>> | undefined + let proc: Awaited>> | undefined try { proc = await runner.start(serve.command) const { url } = await runner.preview({ @@ -463,3 +490,48 @@ async function startAppPreview( return undefined } } + +/** + * Provision the runner the serve gate verifies in (#229). `local` adopts the host + * cwd in place (dispose leaves it); `docker` boots a throwaway container the check + * seeds and tears down. Fails fast with a clear message when docker is requested + * but not reachable, so the run never limps on unsandboxed by surprise. + */ +async function provisionServeRunner( + sandbox: 'local' | 'docker', + cwd: string, + serve: ServeConfig, + emit: (event: FrameworkEvent) => void, +): Promise { + if (sandbox === 'docker') { + if (!(await dockerAvailable())) { + throw new Error( + 'sandbox: --sandbox docker was requested but Docker is not reachable (need a running daemon and the `docker` CLI on PATH).', + ) + } + emit({ kind: 'log', message: 'sandbox: booting a Docker container for the serve check' }) + // preview() publishes the container's fixed port, so it must match the port the + // serve check previews on (serve.port, default 3000). + return new DockerRunner({ previewPort: serve.port ?? 3000 }).boot() + } + return new LocalRunner().adopt(cwd) +} + +/** + * Wrap a serve check so the sandbox is re-seeded with the host source before it + * runs. The build happens on the host in this slice, so an isolated container has + * to be synced each pass to see what the agent just wrote. + */ +function syncThenServe( + runner: RunnerSession, + cwd: string, + check: NonNullable, + emit: (event: FrameworkEvent) => void, +): NonNullable { + return async (ctx: LoopPassContext): Promise => { + const files = await snapshotWorkspace(cwd) + for (const [path, contents] of Object.entries(files)) await runner.fs.write(path, contents) + emit({ kind: 'log', message: `serve: synced ${Object.keys(files).length} file(s) into the sandbox` }) + return check(ctx) + } +} diff --git a/packages/framework/src/sandbox.test.ts b/packages/framework/src/sandbox.test.ts new file mode 100644 index 0000000..b55ca15 --- /dev/null +++ b/packages/framework/src/sandbox.test.ts @@ -0,0 +1,48 @@ +import { strict as assert } from 'node:assert' +import { test } from 'node:test' +import { mkdtemp, mkdir, writeFile, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { snapshotWorkspace, SANDBOX_IGNORE } from './sandbox.js' + +test('snapshotWorkspace copies source (incl. nested) and skips build/VCS dirs', async () => { + const dir = await mkdtemp(join(tmpdir(), 'fw-snap-')) + try { + await writeFile(join(dir, 'package.json'), '{"name":"x"}') + await mkdir(join(dir, 'src'), { recursive: true }) + await writeFile(join(dir, 'src', 'app.js'), 'export const a = 1\n') + // Dirs that must never be copied: the sandbox installs/builds its own. + await mkdir(join(dir, 'node_modules', 'left-pad'), { recursive: true }) + await writeFile(join(dir, 'node_modules', 'left-pad', 'index.js'), 'module.exports = 1') + await mkdir(join(dir, '.git'), { recursive: true }) + await writeFile(join(dir, '.git', 'config'), '[core]') + await mkdir(join(dir, 'dist'), { recursive: true }) + await writeFile(join(dir, 'dist', 'out.js'), 'built') + + const tree = await snapshotWorkspace(dir) + assert.deepEqual(Object.keys(tree).sort(), ['package.json', 'src/app.js']) + assert.equal(tree['src/app.js'], 'export const a = 1\n') + } finally { + await rm(dir, { recursive: true, force: true }) + } +}) + +test('snapshotWorkspace skips binary files and oversized files', async () => { + const dir = await mkdtemp(join(tmpdir(), 'fw-snap-')) + try { + await writeFile(join(dir, 'keep.txt'), 'hello') + await writeFile(join(dir, 'logo.png'), Buffer.from([0x89, 0x50, 0x00, 0x01, 0x02])) // has a NUL → binary + await writeFile(join(dir, 'big.txt'), 'x'.repeat(2048)) + + const tree = await snapshotWorkspace(dir, { maxFileBytes: 1024 }) + assert.deepEqual(Object.keys(tree), ['keep.txt']) + } finally { + await rm(dir, { recursive: true, force: true }) + } +}) + +test('SANDBOX_IGNORE lists the build/VCS/cache dirs', () => { + for (const name of ['node_modules', '.git', 'dist', '.framework']) { + assert.ok(SANDBOX_IGNORE.has(name), `${name} is ignored`) + } +}) diff --git a/packages/framework/src/sandbox.ts b/packages/framework/src/sandbox.ts new file mode 100644 index 0000000..efe3545 --- /dev/null +++ b/packages/framework/src/sandbox.ts @@ -0,0 +1,68 @@ +import { readdir, readFile } from 'node:fs/promises' +import { join, relative, sep } from 'node:path' +import type { FileTree } from '@gemstack/ai-autopilot' + +/** + * Directory names never copied into a sandbox: build output, VCS, and caches. The + * sandbox installs its own deps, so `node_modules` is copied by nobody — it is + * rebuilt inside the container from the seeded `package.json`. + */ +export const SANDBOX_IGNORE: ReadonlySet = new Set([ + 'node_modules', + '.git', + 'dist', + 'build', + 'out', + '.next', + '.turbo', + '.vite', + '.cache', + 'coverage', + '.framework', +]) + +/** Options for {@link snapshotWorkspace}. */ +export interface SnapshotOptions { + /** Directory names to skip. Default {@link SANDBOX_IGNORE}. */ + ignore?: ReadonlySet + /** Skip files larger than this many bytes (default 1 MiB) — assets belong in the repo, not the sandbox seed. */ + maxFileBytes?: number +} + +/** + * Read a host workspace into a Runner {@link FileTree} (relative path → contents), + * so a fresh sandbox container can be seeded with just the source the driver wrote. + * This is the Docker analog of `LocalRunner.adopt`: Local adopts the host dir in + * place, Docker cannot, so we copy the source in. + * + * Text only, and deliberately shallow on cost: build/VCS/cache dirs are skipped, + * oversized files are skipped, and a file containing a NUL byte is treated as + * binary and skipped (its absence does not stop the app from booting for a health + * check). This is a first-slice serve-verification seed, not a faithful mirror. + */ +export async function snapshotWorkspace(dir: string, opts: SnapshotOptions = {}): Promise { + const ignore = opts.ignore ?? SANDBOX_IGNORE + const maxBytes = opts.maxFileBytes ?? 1024 * 1024 + const tree: FileTree = {} + + async function walk(abs: string): Promise { + const entries = await readdir(abs, { withFileTypes: true }) + for (const entry of entries) { + if (ignore.has(entry.name)) continue + const child = join(abs, entry.name) + if (entry.isDirectory()) { + await walk(child) + continue + } + if (!entry.isFile()) continue // skip symlinks, sockets, devices + const buf = await readFile(child) + if (buf.byteLength > maxBytes) continue + if (buf.includes(0)) continue // binary + // Always use POSIX separators: the key is a path inside a Linux container. + tree[relative(dir, child).split(sep).join('/')] = buf.toString('utf8') + } + } + + await walk(dir) + return tree +} diff --git a/packages/framework/src/serve-gate.test.ts b/packages/framework/src/serve-gate.test.ts index 9c073a4..38013c6 100644 --- a/packages/framework/src/serve-gate.test.ts +++ b/packages/framework/src/serve-gate.test.ts @@ -8,6 +8,7 @@ import { FakeDriver } from './driver/index.js' import { runFramework } from './run.js' import { FAKE_SIGNALS } from './fake-script.js' import type { FrameworkEvent } from './events.js' +import { FakeRunner, dockerAvailable } from '@gemstack/ai-autopilot' /** An ephemeral free port so parallel test runs do not collide. */ function freePort(): Promise { @@ -87,6 +88,66 @@ test('serve gate: the app is left running with a preview link after a successful } }) +test('serve gate: an injected runner is used as-is, bypassing sandbox provisioning', async () => { + const dir = await mkdtemp(join(tmpdir(), 'fw-serve-')) + try { + await writeFile(join(dir, 'server.js'), `require('http').createServer((_,res)=>res.end('ok')).listen(3000)\n`) + const runner = await new FakeRunner().boot({ files: {} }) + const events: FrameworkEvent[] = [] + // sandbox:'docker' is set, but the injected runner wins: no container is booted + // and no host→container sync runs. The serve check runs on the injected runner. + await runFramework({ + intent: 'a tiny http service', + driver: new FakeDriver({ turns: CLEAN_REVIEW }), + cwd: dir, + signals: FAKE_SIGNALS, + maxPasses: 1, + sandbox: 'docker', + runner, + serve: { command: 'node server.js', port: 3000, waitMs: 500 }, + onEvent: e => events.push(e), + }) + // The serve command started on the injected runner — it was used, not provisioned away. + assert.ok(runner.startCalls.some(c => c.command === 'node server.js')) + // Provisioning was bypassed: no container-boot log, no sync log. + assert.ok(!events.some(e => e.kind === 'log' && e.message.includes('Docker container'))) + assert.ok(!events.some(e => e.kind === 'log' && e.message.includes('synced'))) + } finally { + await rm(dir, { recursive: true, force: true }) + } +}) + +// Real Docker end-to-end: install-free tiny server, booted and served inside a +// throwaway container. Skips when Docker is not reachable (so CI without a daemon +// stays green); the local dev machine with a daemon exercises the real path. +test('serve gate (docker sandbox): boots and serves the app inside a container', async t => { + if (!(await dockerAvailable())) { + t.skip('docker not available') + return + } + const dir = await mkdtemp(join(tmpdir(), 'fw-serve-')) + try { + // Listens on the container-internal port (3000); Docker maps it to a host port. + await writeFile(join(dir, 'server.js'), `require('http').createServer((_,res)=>res.end('served in docker')).listen(3000)\n`) + const events: FrameworkEvent[] = [] + const { result } = await runFramework({ + intent: 'a tiny http service', + driver: new FakeDriver({ turns: CLEAN_REVIEW }), + cwd: dir, + signals: FAKE_SIGNALS, + maxPasses: 1, + sandbox: 'docker', + serve: { command: 'node server.js', port: 3000, waitMs: 20_000 }, + onEvent: e => events.push(e), + }) + assert.equal(result.productionGrade, true) + assert.ok(events.some(e => e.kind === 'log' && e.message.includes('Docker container'))) + assert.ok(events.some(e => e.kind === 'log' && e.message.includes('synced'))) + } finally { + await rm(dir, { recursive: true, force: true }) + } +}) + test('serve gate: a server that never boots blocks, even when the agent review is clean', async () => { const dir = await mkdtemp(join(tmpdir(), 'fw-serve-')) const port = await freePort()