Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .changeset/docker-serve-sandbox.md
Original file line number Diff line number Diff line change
@@ -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.
14 changes: 14 additions & 0 deletions packages/framework/src/cli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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')
Expand Down
14 changes: 14 additions & 0 deletions packages/framework/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,8 @@ Options:
--serve-build <cmd> Build command before serving (e.g. "npm run build").
--serve-port <n> Port the app listens on (default: 3000).
--serve-path <path> Path to health-check once it is up (default: /).
--sandbox <where> Where --serve runs: "local" (host, default) or "docker"
(a throwaway container, so agent code never runs on the host).
--deploy <target> Deploy to this target (cloudflare, dokploy) or narrate any other.
--cf-project <name> Cloudflare Pages project name (for a Pages deploy).
--dokploy-url <url> Dokploy instance URL (required for --deploy dokploy).
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -556,6 +565,10 @@ export async function runCli(argv: string[], io: CliIO = defaultIO): Promise<num
if (buildEvent && !domainPreset) {
io.err(`note: build event "${buildEvent}" has no effect without a preset.`)
}
// The sandbox only wraps the serve verification, so it is a no-op without --serve.
if (opts.sandbox === 'docker' && !opts.serve) {
io.err(`note: --sandbox docker has no effect without --serve.`)
}

const driver: Driver = fake ? fakeDriver() : new ClaudeCodeDriver(claudeOpts)
// The fake demo defaults to a Cloudflare deploy decision so the flow ends with
Expand Down Expand Up @@ -657,6 +670,7 @@ export async function runCli(argv: string[], io: CliIO = defaultIO): Promise<num
...(deploy ? { deploy } : {}),
...(deployTarget ? { deployTarget } : {}),
...(serve ? { serve } : {}),
...(serve && opts.sandbox ? { sandbox: opts.sandbox } : {}),
...(discovered ? { extensions: discovered } : {}),
...(opts.composeExtensions ? { composeExtensions: true } : {}),
...(domainPreset ? { preset: domainPreset, ...(modeList.length ? { modes: modeList } : {}) } : {}),
Expand Down
1 change: 1 addition & 0 deletions packages/framework/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ export {
type ServeConfig,
type AppPreview,
} from './run.js'
export { snapshotWorkspace, SANDBOX_IGNORE, type SnapshotOptions } from './sandbox.js'
export {
discoverExtensions,
readProjectSignals,
Expand Down
118 changes: 95 additions & 23 deletions packages/framework/src/run.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
import {
Bootstrap,
DecisionLedger,
DockerRunner,
ExtensionRegistry,
LocalRunner,
LoopEngine,
dockerAvailable,
SkillRegistry,
builtinExtensionNames,
builtinPresetRegistry,
Expand All @@ -18,13 +20,17 @@ import {
type BootstrapEvent,
type BootstrapResult,
type BootstrapScope,
type BootstrapSteps,
type DeployTarget,
type DomainPreset,
type FrameworkDetection,
type FrameworkExtension,
type FrameworkSignals,
type LocalRunnerSession,
type LoopPassContext,
type RunnerSession,
type Verdict,
} from '@gemstack/ai-autopilot'
import { snapshotWorkspace } from './sandbox.js'
import type { Driver, DriverEvent, DriverSession } from './driver/index.js'
import { memoryFraming, type LoadedMemory } from './memory.js'
import { decideDeploy, deployWith, domainLoopChecklist, driverArchitect, driverBuild, driverChecklist, driverImprove, driverLoopPrompts } from './steps.js'
Expand Down Expand Up @@ -146,6 +152,23 @@ export interface RunFrameworkOptions {
* checklist gates on the app actually running, not just an agent review.
*/
serve?: ServeConfig
/**
* Where the {@link serve} verification runs (#229). `"local"` (default) boots the
* app on the host, adopting the agent's cwd in place. `"docker"` sandboxes it: a
* throwaway container is booted, the source is copied in fresh before each check
* (the build still runs on the host in this slice), deps install inside the
* container, and the app serves on a mapped port — so agent-authored code never
* installs or runs on the host. Requires a reachable Docker daemon; no-op without
* {@link serve}.
*/
sandbox?: 'local' | 'docker'
/**
* A pre-provisioned {@link RunnerSession} to run the serve check in, bypassing
* {@link sandbox} provisioning. Advanced / testing seam — the caller owns its
* lifecycle is handed to the run (it is disposed with the run). Omit to let
* {@link sandbox} provision one.
*/
runner?: RunnerSession
/**
* A link to the live agent session, shown on the dashboard. Either a literal
* URL, or a template with `{sessionId}` (see {@link SESSION_ID_PLACEHOLDER})
Expand Down Expand Up @@ -348,27 +371,31 @@ export async function runFramework(opts: RunFrameworkOptions): Promise<RunFramew
message: `Review policy: the ${domainPreset!.title} loop drives the ${buildEvent} review`,
})

// Boot-and-serve gate: adopt the agent's workspace so the checklist can gate
// on the app actually running (mergeChecklists unions the review with a real
// serveCheck). The runner adopts, never deletes, the driver's cwd.
let runner: LocalRunnerSession | undefined
if (opts.serve) runner = await new LocalRunner().adopt(opts.cwd)
// Boot-and-serve gate: provision a runner so the checklist can gate on the app
// actually running (mergeChecklists unions the review with a real serveCheck).
// Local adopts (never deletes) the driver's cwd in place; docker sandboxes the
// check in a throwaway container (#229). An injected runner wins over both.
const sandbox = opts.sandbox ?? 'local'
let runner: RunnerSession | undefined
if (opts.serve) runner = opts.runner ?? (await provisionServeRunner(sandbox, opts.cwd, opts.serve, emit))
const s = opts.serve
const checklist =
runner && s
? mergeChecklists(
reviewChecklist,
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}` }),
}),
)
: reviewChecklist
let checklist: NonNullable<BootstrapSteps['checklist']> = 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
Expand Down Expand Up @@ -430,12 +457,12 @@ export async function runFramework(opts: RunFrameworkOptions): Promise<RunFramew
* come up.
*/
async function startAppPreview(
runner: LocalRunnerSession,
runner: RunnerSession,
serve: ServeConfig,
emit: (event: FrameworkEvent) => void,
): Promise<AppPreview | undefined> {
if (!runner.start || !runner.preview) return undefined
let proc: Awaited<ReturnType<NonNullable<LocalRunnerSession['start']>>> | undefined
let proc: Awaited<ReturnType<NonNullable<RunnerSession['start']>>> | undefined
try {
proc = await runner.start(serve.command)
const { url } = await runner.preview({
Expand Down Expand Up @@ -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<RunnerSession> {
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<BootstrapSteps['checklist']>,
emit: (event: FrameworkEvent) => void,
): NonNullable<BootstrapSteps['checklist']> {
return async (ctx: LoopPassContext): Promise<Verdict> => {
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)
}
}
48 changes: 48 additions & 0 deletions packages/framework/src/sandbox.test.ts
Original file line number Diff line number Diff line change
@@ -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`)
}
})
68 changes: 68 additions & 0 deletions packages/framework/src/sandbox.ts
Original file line number Diff line number Diff line change
@@ -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<string> = 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<string>
/** 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<FileTree> {
const ignore = opts.ignore ?? SANDBOX_IGNORE
const maxBytes = opts.maxFileBytes ?? 1024 * 1024
const tree: FileTree = {}

async function walk(abs: string): Promise<void> {
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
}
Loading
Loading