diff --git a/.changeset/webcontainer-runner.md b/.changeset/webcontainer-runner.md
new file mode 100644
index 0000000..cb6f7e9
--- /dev/null
+++ b/.changeset/webcontainer-runner.md
@@ -0,0 +1,26 @@
+---
+'@gemstack/ai-autopilot': minor
+---
+
+Add WebContainerRunner, the in-browser sandboxed runner
+
+`WebContainerRunner` is the third real `Runner` adapter (after `LocalRunner` and
+`DockerRunner`), wrapping StackBlitz's `@webcontainer/api`. It runs untrusted,
+agent-authored code entirely inside a browser tab: an in-browser Node runtime, an
+isolated filesystem, and an instant `preview()` URL for a dev server, with nothing
+touching the host. This is the "sit on harnesses, don't compete" bet for the
+browser: the same `Runner` interface, now backed by WebContainer.
+
+It is browser-only by construction (WebContainer needs `SharedArrayBuffer`, so the
+hosting page must be cross-origin isolated), so `@webcontainer/api` is an optional
+peer dependency and is imported lazily: loading `@gemstack/ai-autopilot` in Node
+never pulls it in. Guard with the new `webContainerAvailable()` and reach for
+`DockerRunner` on the server.
+
+Because a WebContainer cannot boot in Node, boot-and-serve is proven by a headless
+Chromium harness under `packages/ai-autopilot/harness/webcontainer/` that drives
+the compiled adapter through boot, fs, exec (exit codes, cwd/env, timeout kill),
+start, a real preview URL, an in-container serve check, dispose, and reboot. The
+Node-only guards are covered by the default test suite.
+
+Part of #109 (the Flue adapter stays gated on a live Flue environment).
diff --git a/packages/ai-autopilot/harness/webcontainer/README.md b/packages/ai-autopilot/harness/webcontainer/README.md
new file mode 100644
index 0000000..c7a9c69
--- /dev/null
+++ b/packages/ai-autopilot/harness/webcontainer/README.md
@@ -0,0 +1,39 @@
+# WebContainer boot-and-serve harness
+
+`WebContainerRunner` wraps [`@webcontainer/api`](https://webcontainers.io), which
+runs **only inside a cross-origin-isolated browser** — it cannot boot in plain
+Node. So, unlike `DockerRunner` (verified by a normal `node --test` suite against
+a local daemon), the WebContainer adapter is proven by driving a **real headless
+Chromium** against the compiled adapter. This directory is that proof.
+
+## What it does
+
+1. `server.mjs` serves a tiny page over `127.0.0.1` with the cross-origin
+ isolation headers WebContainer needs (`COOP: same-origin`, `COEP:
+ require-corp`), plus the compiled adapter (`/dist`) and `@webcontainer/api`
+ (`/api`) same-origin.
+2. `index.html` imports the **real** `dist/runner/webcontainer.js` and drives it
+ exactly as an app would: boot, fs round-trip, `exec` (exit codes, cwd/env,
+ timeout kill), `start` a server, resolve its `preview()` URL, prove it serves
+ by fetching it from inside the container, then `dispose` and reboot.
+3. `drive.mjs` launches headless Chromium (via `playwright-core`), loads the
+ page, and asserts every check passed.
+
+## Run it
+
+```bash
+pnpm build # compile the adapter into dist/
+node harness/webcontainer/drive.mjs # boot-and-serve proof; exits non-zero on any failed check
+```
+
+Requirements:
+
+- **A Chromium browser.** Uses your system Google Chrome by default; falls back
+ to a Playwright Chromium (`npx playwright install chromium`).
+- **Network.** WebContainer downloads its runtime from StackBlitz on first boot,
+ so this is not offline-hermetic (another reason it is opt-in, not part of
+ `pnpm test`).
+
+The Node-only guards (`webContainerAvailable()` is false outside a browser,
+`boot()` throws a clear error in Node) are covered by `src/runner/webcontainer.test.ts`
+and do run in the default suite.
diff --git a/packages/ai-autopilot/harness/webcontainer/drive.mjs b/packages/ai-autopilot/harness/webcontainer/drive.mjs
new file mode 100644
index 0000000..f7a8bf6
--- /dev/null
+++ b/packages/ai-autopilot/harness/webcontainer/drive.mjs
@@ -0,0 +1,51 @@
+// Boot-and-serve proof for WebContainerRunner. WebContainer runs only in a
+// cross-origin-isolated browser, so this drives a real headless Chromium against
+// the compiled adapter and asserts it boots, execs, serves, and tears down.
+//
+// Prereqs: build the package (`pnpm build`) and have Google Chrome installed, or
+// a Playwright Chromium (`npx playwright install chromium`). Then:
+// node harness/webcontainer/drive.mjs
+//
+// Needs network: WebContainer downloads its runtime from StackBlitz on first boot.
+import { chromium } from 'playwright-core'
+import { startServer } from './server.mjs'
+
+async function launch() {
+ // Prefer a system Chrome (no browser download); fall back to a Playwright Chromium.
+ try {
+ return await chromium.launch({ headless: true, channel: 'chrome' })
+ } catch {
+ try {
+ return await chromium.launch({ headless: true })
+ } catch (err) {
+ console.error(
+ 'No usable Chromium. Install Google Chrome, or run `npx playwright install chromium`.\n' + err,
+ )
+ process.exit(2)
+ }
+ }
+}
+
+const { server, port } = await startServer()
+const browser = await launch()
+const page = await browser.newPage()
+page.on('console', (m) => console.log('[page]', m.text()))
+page.on('pageerror', (e) => console.log('[pageerror]', e.message))
+
+let result
+try {
+ await page.goto(`http://127.0.0.1:${port}/`, { waitUntil: 'load' })
+ await page.waitForFunction(() => window.__RESULT__ && window.__RESULT__.done, { timeout: 90000 })
+ result = await page.evaluate(() => window.__RESULT__)
+} catch (e) {
+ result = { done: false, ok: false, error: 'harness timeout: ' + e.message }
+}
+
+await browser.close()
+server.close()
+
+const passed = (result.checks ?? []).filter((c) => c.ok).length
+const total = (result.checks ?? []).length
+console.log(`\n${result.ok ? 'OK' : 'FAILED'} — ${passed}/${total} checks passed`)
+if (result.error) console.log('error:', result.error)
+process.exit(result.ok ? 0 : 1)
diff --git a/packages/ai-autopilot/harness/webcontainer/index.html b/packages/ai-autopilot/harness/webcontainer/index.html
new file mode 100644
index 0000000..1a96d23
--- /dev/null
+++ b/packages/ai-autopilot/harness/webcontainer/index.html
@@ -0,0 +1,79 @@
+
+
+
+
+ WebContainerRunner boot-and-serve harness
+
+
+
+
+
+
+
+
diff --git a/packages/ai-autopilot/harness/webcontainer/server.mjs b/packages/ai-autopilot/harness/webcontainer/server.mjs
new file mode 100644
index 0000000..20f0711
--- /dev/null
+++ b/packages/ai-autopilot/harness/webcontainer/server.mjs
@@ -0,0 +1,52 @@
+import { createServer } from 'node:http'
+import { readFile } from 'node:fs/promises'
+import { fileURLToPath } from 'node:url'
+import { dirname, join, normalize } from 'node:path'
+
+const __dirname = dirname(fileURLToPath(import.meta.url))
+const PKG_ROOT = join(__dirname, '..', '..')
+const DIST_DIR = join(PKG_ROOT, 'dist')
+// Resolve the installed @webcontainer/api so the page can serve it same-origin.
+// It is import-only (no CJS main), so resolve its ESM entry and take the dist dir.
+const API_DIR = dirname(fileURLToPath(import.meta.resolve('@webcontainer/api')))
+
+const TYPES = {
+ '.html': 'text/html',
+ '.js': 'text/javascript',
+ '.mjs': 'text/javascript',
+ '.map': 'application/json',
+ '.wasm': 'application/wasm',
+}
+
+const safeJoin = (base, rel) => join(base, normalize(rel).replace(/^(\.\.(\/|\\|$))+/, ''))
+
+/** Start the COOP/COEP static server. Resolves with its chosen port. */
+export function startServer() {
+ const server = createServer(async (req, res) => {
+ // Cross-origin isolation is what makes SharedArrayBuffer (and WebContainer) available.
+ res.setHeader('Cross-Origin-Opener-Policy', 'same-origin')
+ res.setHeader('Cross-Origin-Embedder-Policy', 'require-corp')
+ res.setHeader('Cross-Origin-Resource-Policy', 'cross-origin')
+
+ const path = new URL(req.url, 'http://localhost').pathname
+ const send = async (file) => {
+ const body = await readFile(file)
+ res.setHeader('Content-Type', TYPES['.' + file.split('.').pop()] ?? 'application/octet-stream')
+ res.end(body)
+ }
+ try {
+ if (path === '/favicon.ico') { res.statusCode = 204; return res.end() }
+ if (path === '/' || path === '/index.html') return await send(join(__dirname, 'index.html'))
+ if (path.startsWith('/dist/')) return await send(safeJoin(DIST_DIR, path.slice('/dist/'.length)))
+ if (path.startsWith('/api/')) return await send(safeJoin(API_DIR, path.slice('/api/'.length)))
+ res.statusCode = 404
+ res.end('not found')
+ } catch (err) {
+ res.statusCode = 500
+ res.end(String(err))
+ }
+ })
+ return new Promise((resolve) => {
+ server.listen(0, '127.0.0.1', () => resolve({ server, port: server.address().port }))
+ })
+}
diff --git a/packages/ai-autopilot/package.json b/packages/ai-autopilot/package.json
index 3739966..1440d7d 100644
--- a/packages/ai-autopilot/package.json
+++ b/packages/ai-autopilot/package.json
@@ -51,8 +51,18 @@
"@gemstack/ai-skills": "workspace:^",
"zod": "^4.0.0"
},
+ "peerDependencies": {
+ "@webcontainer/api": "^1.6.0"
+ },
+ "peerDependenciesMeta": {
+ "@webcontainer/api": {
+ "optional": true
+ }
+ },
"devDependencies": {
"@types/node": "^20.0.0",
+ "@webcontainer/api": "^1.6.4",
+ "playwright-core": "^1.49.0",
"typescript": "^5.4.0"
},
"author": "Suleiman Shahbari"
diff --git a/packages/ai-autopilot/src/index.ts b/packages/ai-autopilot/src/index.ts
index fde967d..af32821 100644
--- a/packages/ai-autopilot/src/index.ts
+++ b/packages/ai-autopilot/src/index.ts
@@ -28,6 +28,7 @@
* - {@link FakeRunner} — in-memory runner for tests
* - {@link LocalRunner} — real host workspace (fs + child processes); the first real adapter
* - {@link DockerRunner} — sandboxed workspace in a container (via the `docker` CLI)
+ * - {@link WebContainerRunner} — sandboxed workspace in the browser (via `@webcontainer/api`)
* - {@link runnerTools} — expose a booted session to an agent as sandbox tools
*
* Surfaces run the same autopilot in the terminal, an in-page UI, or a
@@ -129,6 +130,9 @@ export {
DockerRunner,
DockerRunnerSession,
dockerAvailable,
+ WebContainerRunner,
+ WebContainerRunnerSession,
+ webContainerAvailable,
RunnerError,
runnerTools,
type Runner,
@@ -147,6 +151,7 @@ export {
type RecordedStart,
type LocalRunnerOptions,
type DockerRunnerOptions,
+ type WebContainerRunnerOptions,
type RunnerToolsOptions,
} from './runner/index.js'
export {
diff --git a/packages/ai-autopilot/src/runner/index.ts b/packages/ai-autopilot/src/runner/index.ts
index 987e489..6680344 100644
--- a/packages/ai-autopilot/src/runner/index.ts
+++ b/packages/ai-autopilot/src/runner/index.ts
@@ -10,6 +10,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 WebContainerRunner} — sandboxed workspace in the browser (via `@webcontainer/api`)
* - {@link runnerTools} — expose a session to an agent as sandbox tools
*/
export type {
@@ -35,4 +36,10 @@ export {
} from './fake.js'
export { LocalRunner, LocalRunnerSession, type LocalRunnerOptions } from './local.js'
export { DockerRunner, DockerRunnerSession, dockerAvailable, type DockerRunnerOptions } from './docker.js'
+export {
+ WebContainerRunner,
+ WebContainerRunnerSession,
+ webContainerAvailable,
+ type WebContainerRunnerOptions,
+} from './webcontainer.js'
export { runnerTools, type RunnerToolsOptions } from './tools.js'
diff --git a/packages/ai-autopilot/src/runner/webcontainer.test.ts b/packages/ai-autopilot/src/runner/webcontainer.test.ts
new file mode 100644
index 0000000..a553bf6
--- /dev/null
+++ b/packages/ai-autopilot/src/runner/webcontainer.test.ts
@@ -0,0 +1,33 @@
+import { describe, it } from 'node:test'
+import assert from 'node:assert/strict'
+import { WebContainerRunner, webContainerAvailable } from './webcontainer.js'
+import { RunnerError } from './types.js'
+
+// These run in plain Node (no browser), so they cover the capability guard and
+// the Node-side contract. The real boot-and-serve is proven by the browser
+// harness under `harness/webcontainer/` (see its README).
+
+describe('webContainerAvailable', () => {
+ it('is false in a non-isolated (Node) context', () => {
+ assert.equal(webContainerAvailable(), false)
+ })
+})
+
+describe('WebContainerRunner', () => {
+ it('identifies its kind', () => {
+ assert.equal(new WebContainerRunner().kind, 'webcontainer')
+ })
+
+ it('constructs without importing @webcontainer/api (browser-only dep stays lazy)', () => {
+ // No throw here means the module loaded without eagerly requiring the browser dep.
+ assert.doesNotThrow(() => new WebContainerRunner({ coep: 'credentialless', preview: false }))
+ })
+
+ it('boot() rejects with a clear error when not cross-origin isolated', async () => {
+ await assert.rejects(() => new WebContainerRunner().boot(), (err: unknown) => {
+ assert.ok(err instanceof RunnerError)
+ assert.match((err as Error).message, /cross-origin isolated/)
+ return true
+ })
+ })
+})
diff --git a/packages/ai-autopilot/src/runner/webcontainer.ts b/packages/ai-autopilot/src/runner/webcontainer.ts
new file mode 100644
index 0000000..6b4b3c1
--- /dev/null
+++ b/packages/ai-autopilot/src/runner/webcontainer.ts
@@ -0,0 +1,367 @@
+import type {
+ WebContainer as WebContainerInstance,
+ WebContainerProcess,
+ SpawnOptions,
+ FileSystemAPI,
+} from '@webcontainer/api'
+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))
+
+/**
+ * True when the current context can boot a WebContainer: a browser (or worker)
+ * that is cross-origin isolated. WebContainer needs `SharedArrayBuffer`, which
+ * needs cross-origin isolation (COOP/COEP). Always `false` in plain Node — the
+ * runner analog of {@link dockerAvailable}, letting callers/tests skip when the
+ * capability is absent.
+ */
+export function webContainerAvailable(): boolean {
+ const g = globalThis as { crossOriginIsolated?: boolean }
+ return typeof g.crossOriginIsolated === 'boolean' && g.crossOriginIsolated === true
+}
+
+/**
+ * Only one WebContainer can be booted per page ({@link WebContainerInstance.boot}
+ * throws otherwise), so a live session is tracked process-wide and released on
+ * dispose. A second `boot()` while one is live is a clear error, not a crash.
+ */
+let live: WebContainerRunnerSession | null = null
+
+/** Normalize a workspace path to a canonical relative form (matches LocalFs/FakeFs). */
+function norm(path: string): string {
+ return path.replace(/^\.?\/+/, '').replace(/\/+$/, '')
+}
+
+/** Resolve `path` to a workspace-relative form, rejecting anything that escapes it. */
+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 ? parts.join('/') : '.'
+}
+
+/** A {@link RunnerFs} backed by a WebContainer's in-browser filesystem. */
+class WebContainerFs implements RunnerFs {
+ constructor(private readonly fs: FileSystemAPI) {}
+
+ async read(path: string): Promise {
+ try {
+ return await this.fs.readFile(within(path), 'utf-8')
+ } catch (err) {
+ if (err instanceof RunnerError) throw err
+ throw new RunnerError(`no such file: ${path}`)
+ }
+ }
+
+ async write(path: string, contents: string): Promise {
+ const p = within(path)
+ const slash = p.lastIndexOf('/')
+ if (slash > 0) await this.fs.mkdir(p.slice(0, slash), { recursive: true })
+ await this.fs.writeFile(p, contents)
+ }
+
+ async remove(path: string): Promise {
+ await this.fs.rm(within(path), { recursive: true, force: true })
+ }
+
+ async list(dir?: string): Promise {
+ const base = dir ? within(dir) : '.'
+ const out: string[] = []
+ const walk = async (rel: string): Promise => {
+ let entries
+ try {
+ entries = await this.fs.readdir(rel, { withFileTypes: true })
+ } catch {
+ return // missing dir → empty, mirroring LocalFs
+ }
+ for (const e of entries) {
+ const child = rel === '.' ? e.name : `${rel}/${e.name}`
+ if (e.isDirectory()) await walk(child)
+ else out.push(child)
+ }
+ }
+ await walk(base)
+ return out.sort()
+ }
+
+ async exists(path: string): Promise {
+ const p = within(path)
+ try {
+ await this.fs.readdir(p) // succeeds for a directory
+ return true
+ } catch {
+ try {
+ await this.fs.readFile(p) // succeeds for a file
+ return true
+ } catch {
+ return false
+ }
+ }
+ }
+}
+
+/**
+ * One booted workspace running inside a WebContainer: an in-browser filesystem,
+ * an in-browser Node runtime, and (optionally) a preview whose URL WebContainer
+ * emits via `server-ready` when a dev server starts listening.
+ */
+export class WebContainerRunnerSession implements RunnerSession {
+ readonly id: string
+ readonly fs: WebContainerFs
+ disposed = false
+
+ readonly preview?: (opts?: PreviewOptions) => Promise
+
+ private readonly cwd: string
+ private readonly env: Record
+ private readonly procs = new Set()
+ /** Preview URLs by port, kept current from `server-ready` / `port` events. */
+ private readonly ports = new Map()
+ /** The most recently readied server, so `preview()` with no port returns it. */
+ private lastReady?: { port: number; url: string }
+
+ constructor(
+ id: string,
+ private readonly wc: WebContainerInstance,
+ boot: BootOptions,
+ preview: boolean,
+ ) {
+ this.id = id
+ this.fs = new WebContainerFs(wc.fs)
+ this.cwd = boot.cwd ? norm(boot.cwd) : ''
+ this.env = { ...(boot.env ?? {}) }
+
+ // Track preview URLs as servers come up and ports close.
+ wc.on('server-ready', (port, url) => {
+ this.ports.set(port, url)
+ this.lastReady = { port, url }
+ })
+ wc.on('port', (port, type, url) => {
+ if (type === 'open') this.ports.set(port, url)
+ else this.ports.delete(port)
+ })
+
+ if (preview) {
+ this.preview = async (previewOpts: PreviewOptions = {}): Promise => {
+ if (this.disposed) throw new RunnerError('preview on a disposed session')
+ const deadline = Date.now() + (previewOpts.waitMs ?? 0)
+ for (;;) {
+ // A specific port, else the most recently readied server.
+ if (previewOpts.port != null) {
+ const url = this.ports.get(previewOpts.port)
+ if (url) return { url, port: previewOpts.port }
+ } else if (this.lastReady) {
+ return { url: this.lastReady.url, port: this.lastReady.port }
+ }
+ if (Date.now() >= deadline) {
+ throw new RunnerError(
+ previewOpts.port != null
+ ? `no server ready on port ${previewOpts.port}`
+ : 'no server ready yet; start a dev server first (or pass a larger waitMs)',
+ )
+ }
+ await delay(100)
+ }
+ }
+ }
+ }
+
+ /** Build spawn options from the session defaults merged with per-command overrides. */
+ private spawnOpts(opts: ExecOptions): SpawnOptions {
+ const cwd = opts.cwd ?? (this.cwd || undefined)
+ const env = { ...this.env, ...(opts.env ?? {}) }
+ const o: SpawnOptions = { output: true }
+ if (cwd) o.cwd = cwd
+ if (Object.keys(env).length) o.env = env
+ return o
+ }
+
+ /**
+ * Drain a process's combined terminal output and resolve its result. The
+ * output stream is a pseudoterminal that merges stdout and stderr, so
+ * everything lands in `stdout` and `stderr` stays empty (a WebContainer trait,
+ * not the shape LocalRunner/DockerRunner give). Enforces `timeoutMs` by killing
+ * the process and returning exit code `124`, matching the other runners.
+ */
+ private async collect(proc: WebContainerProcess, timeoutMs?: number): Promise {
+ let stdout = ''
+ const drained = proc.output
+ .pipeTo(new WritableStream({ write: chunk => void (stdout += chunk) }))
+ .catch(() => {})
+
+ let timedOut = false
+ let timer: ReturnType | undefined
+ const timeout =
+ timeoutMs != null
+ ? new Promise(res => {
+ timer = setTimeout(() => {
+ timedOut = true
+ proc.kill()
+ res(137)
+ }, timeoutMs)
+ })
+ : undefined
+
+ const exitCode = await (timeout ? Promise.race([proc.exit, timeout]) : proc.exit)
+ if (timer) clearTimeout(timer)
+ await drained
+
+ if (timedOut) {
+ return { stdout, stderr: `[ai-autopilot] command timed out after ${timeoutMs}ms`, exitCode: 124 }
+ }
+ return { stdout, stderr: '', exitCode }
+ }
+
+ async exec(command: string, opts: ExecOptions = {}): Promise {
+ if (this.disposed) throw new RunnerError('exec on a disposed session')
+ // `jsh -c` runs an arbitrary command string, mirroring `sh -c` on the other runners.
+ const proc = await this.wc.spawn('jsh', ['-c', command], this.spawnOpts(opts))
+ return await this.collect(proc, opts.timeoutMs)
+ }
+
+ /**
+ * Start a long-running command in the background and return a handle at once,
+ * without waiting for it to exit — the caller serves a {@link preview} against it.
+ */
+ async start(command: string, opts: ExecOptions = {}): Promise {
+ if (this.disposed) throw new RunnerError('start on a disposed session')
+ const proc = await this.wc.spawn('jsh', ['-c', command], this.spawnOpts(opts))
+ let stdout = ''
+ proc.output.pipeTo(new WritableStream({ write: chunk => void (stdout += chunk) })).catch(() => {})
+
+ const exit = proc.exit.then((exitCode): ExecResult => ({ stdout, stderr: '', exitCode }))
+ const handle: RunnerProcess = {
+ command,
+ exit,
+ stop: async () => {
+ try {
+ proc.kill()
+ } catch {
+ // already gone
+ }
+ await exit.catch(() => {})
+ this.procs.delete(handle)
+ },
+ }
+ exit.then(() => this.procs.delete(handle)).catch(() => {})
+ this.procs.add(handle)
+ return handle
+ }
+
+ async dispose(): Promise {
+ if (this.disposed) return
+ this.disposed = true
+ await Promise.all([...this.procs].map(p => p.stop().catch(() => {})))
+ try {
+ this.wc.teardown()
+ } catch {
+ // already torn down
+ }
+ if (live === this) live = null
+ }
+}
+
+export interface WebContainerRunnerOptions {
+ /**
+ * The COEP header WebContainer boots under. Must match the header the hosting
+ * page is served with. Default `require-corp`. See the WebContainer docs on
+ * configuring headers.
+ */
+ coep?: 'require-corp' | 'credentialless' | 'none'
+ /** Cosmetic folder name for the working directory. Auto-generated when omitted. */
+ workdirName?: string
+ /** Whether booted sessions expose a `preview`. Default `true`. */
+ preview?: boolean
+}
+
+/**
+ * A {@link Runner} that boots each workspace as a WebContainer — StackBlitz's
+ * in-browser Node runtime (`@webcontainer/api`). The sandboxed, zero-server
+ * counterpart to {@link DockerRunner}: untrusted, agent-authored code runs
+ * entirely inside the user's browser tab, with an instant preview URL for a Vike
+ * dev server, and nothing touches the host.
+ *
+ * **Browser only.** WebContainer needs `SharedArrayBuffer`, so the hosting page
+ * must be cross-origin isolated (COOP `same-origin` + COEP `require-corp`), and
+ * `@webcontainer/api` must be provided by the app's bundler — this package
+ * imports it lazily (dynamic `import`) so merely loading `@gemstack/ai-autopilot`
+ * in Node never pulls it in. Guard with {@link webContainerAvailable} and reach
+ * for {@link DockerRunner} on the server. Boot-and-serve is proven end-to-end by
+ * the headless-Chromium harness under `harness/webcontainer/`.
+ *
+ * Only one WebContainer exists per page, so only one session is live at a time;
+ * `boot()` throws if a previous session has not been disposed.
+ *
+ * ```ts
+ * if (!webContainerAvailable()) return // not cross-origin isolated
+ * const runner = new WebContainerRunner()
+ * const s = await runner.boot({ files: { 'server.js': "require('http').createServer((_,r)=>r.end('hi')).listen(3000)" } })
+ * await s.exec('node -v') // one-shot in the browser runtime
+ *
+ * const dev = await s.start('npm run dev') // long-running, returns at once
+ * const { url } = await s.preview({ port: 3000, waitMs: 10000 }) // WebContainer preview URL, once ready
+ * await dev.stop()
+ * await s.dispose() // teardown() — frees the single WebContainer slot
+ * ```
+ */
+export class WebContainerRunner implements Runner {
+ readonly kind = 'webcontainer'
+
+ private readonly opts: Required
+
+ constructor(options: WebContainerRunnerOptions = {}) {
+ this.opts = {
+ coep: options.coep ?? 'require-corp',
+ workdirName: options.workdirName ?? '',
+ preview: options.preview ?? true,
+ }
+ }
+
+ async boot(opts: BootOptions = {}): Promise {
+ if (!webContainerAvailable()) {
+ throw new RunnerError(
+ 'WebContainer needs a cross-origin isolated browser context (COOP/COEP); it cannot boot in Node',
+ )
+ }
+ if (live && !live.disposed) {
+ throw new RunnerError('a WebContainer session is already live; dispose it before booting another')
+ }
+ const { WebContainer } = await import('@webcontainer/api')
+ const bootOpts: { coep: 'require-corp' | 'credentialless' | 'none'; workdirName?: string } = { coep: this.opts.coep }
+ if (this.opts.workdirName) bootOpts.workdirName = this.opts.workdirName
+ const wc = await WebContainer.boot(bootOpts)
+ try {
+ const id = wc.workdir.split('/').pop() || wc.workdir
+ const session = new WebContainerRunnerSession(id, wc, opts, this.opts.preview)
+ live = session
+ for (const [path, contents] of Object.entries(opts.files ?? {})) {
+ await session.fs.write(path, contents)
+ }
+ return session
+ } catch (err) {
+ try {
+ wc.teardown()
+ } catch {
+ // ignore
+ }
+ live = null
+ throw err
+ }
+ }
+}
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index f655d32..d61fdb3 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -189,6 +189,12 @@ importers:
'@types/node':
specifier: ^20.0.0
version: 20.19.43
+ '@webcontainer/api':
+ specifier: ^1.6.4
+ version: 1.6.4
+ playwright-core:
+ specifier: ^1.49.0
+ version: 1.61.1
typescript:
specifier: ^5.4.0
version: 5.9.3
@@ -1175,6 +1181,9 @@ packages:
peerDependencies:
vue: ^3.5.0
+ '@webcontainer/api@1.6.4':
+ resolution: {integrity: sha512-r9sHCXg1FcC1AMgppGwAc0vYWaQhqvg282cnsuPbJEzYnWifAdCVvg+8ngJUEHyHcomhJJp+/zuytite4ITHLw==}
+
abort-controller@3.0.0:
resolution: {integrity: sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==}
engines: {node: '>=6.5'}
@@ -1907,6 +1916,11 @@ packages:
resolution: {integrity: sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==}
engines: {node: '>=16.20.0'}
+ playwright-core@1.61.1:
+ resolution: {integrity: sha512-h7Qlt6m4REp25qvIdvbDtVmD4LqVXfpRxhORv9L0jzETM05p4fuPJ3dKyuSXQxDSbXnmS79HAgi9589lGSpLkg==}
+ engines: {node: '>=18'}
+ hasBin: true
+
postcss@8.5.15:
resolution: {integrity: sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==}
engines: {node: ^10 || ^12 || >=14}
@@ -3218,6 +3232,8 @@ snapshots:
dependencies:
vue: 3.5.39(typescript@5.9.3)
+ '@webcontainer/api@1.6.4': {}
+
abort-controller@3.0.0:
dependencies:
event-target-shim: 5.0.1
@@ -3982,6 +3998,8 @@ snapshots:
pkce-challenge@5.0.1: {}
+ playwright-core@1.61.1: {}
+
postcss@8.5.15:
dependencies:
nanoid: 3.3.15