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