Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
7ecffa2
Add persistent browser REPL API specification
rgarcia Aug 2, 2026
3d201ca
Add persistent browser REPL Node runtime
rgarcia Aug 2, 2026
a8386b3
Add POST /browser/execute with API-owned REPL child lifecycle
rgarcia Aug 2, 2026
37ef26e
Bundle browser REPL in both images; add e2e tests and docs
rgarcia Aug 2, 2026
1e4b8b6
Make REPL timeouts destructive; close spec test-plan gaps
rgarcia Aug 2, 2026
eea5216
Address QA findings for the persistent browser REPL
rgarcia Aug 2, 2026
27953ed
Fix REPL helper ergonomics; close remaining QA review gaps
rgarcia Aug 2, 2026
7f09606
Harden browser REPL against frozen renderers; close QA findings
rgarcia Aug 2, 2026
c4ba4f2
Handle process-level REPL failures deterministically; close QA cycle-…
rgarcia Aug 2, 2026
55c3ecc
Close fresh QA cycle-5 findings: const/let semantics, scroll swallow,…
rgarcia Aug 3, 2026
d084c69
Close fresh QA findings: scroll settle window, orphan kill on child-d…
rgarcia Aug 3, 2026
05481b4
Retry first command on a never-answered CDP connection; correct scrol…
rgarcia Aug 3, 2026
7253af5
Replace vendored Acorn with pinned dependency
rgarcia Aug 3, 2026
d2eb55e
Implement SourceTextModule browser REPL cells
rgarcia Aug 10, 2026
a51eb77
Document JavaScript module-cell REPL semantics
rgarcia Aug 10, 2026
89a422b
Fix browser REPL binding identity and var persistence
rgarcia Aug 10, 2026
eb64aec
Fix browser REPL cell invariants and generated docs
rgarcia Aug 10, 2026
f8ddab4
Fix persistent browser REPL cell rewrites
rgarcia Aug 10, 2026
beebea0
Fix browser REPL rewrite and output invariants
rgarcia Aug 10, 2026
18056d3
Fix browser REPL object rest destructuring
rgarcia Aug 10, 2026
e25e4ac
Fix browser REPL request and stray output limits
rgarcia Aug 10, 2026
4ebf79e
Fix stray image output item limit
rgarcia Aug 10, 2026
c1b0db6
Regenerate OpenAPI server with SSE flushing
rgarcia Aug 10, 2026
3a3b907
Reduce browser REPL review surface
rgarcia Aug 10, 2026
1f32d64
Add persistent browser REPL API
rgarcia Sep 2, 2026
d4aeac8
Merge remote-tracking branch 'origin/main' into rgarcia/browser-repl
rgarcia Sep 4, 2026
9249def
Integrate WebMCP with browser REPL
rgarcia Sep 4, 2026
f079941
Support Playwright Core in browser REPL
rgarcia Sep 4, 2026
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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -98,7 +98,7 @@ Deployed successfully!

## Connect to the browser via Chrome DevTools Protocol

Port `9222` is exposed via `ncat`, allowing you to connect Chrome DevTools Protocol-based browser frameworks like Playwright and Puppeteer (and CDP-based SDKs like Browser Use). You can use these frameworks to drive the browser in the cloud. You can also disconnect from the browser and reconnect to it.
Port `9222` is exposed via `ncat`, allowing you to connect Chrome DevTools Protocol-based browser frameworks like Playwright and Puppeteer. You can use these frameworks to drive the browser in the cloud. You can also disconnect from the browser and reconnect to it.

First, fetch the browser's CDP websocket endpoint:

Expand Down
22 changes: 22 additions & 0 deletions images/chromium-headful/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -389,6 +389,28 @@ RUN esbuild /tmp/playwright-daemon.ts \
--external:esbuild \
&& rm /tmp/playwright-daemon.ts /tmp/page-target-id-cache.ts /tmp/webmcp.ts

# Copy and install the browser REPL's pinned runtime dependencies before bundling.
COPY server/runtime/ /tmp/browser-repl/
RUN npm ci --ignore-scripts --no-audit --no-fund --omit=dev --prefix /tmp/browser-repl \
&& mkdir -p /usr/local/lib/browser-repl /usr/local/share/doc/kernel-images/meriyah /usr/local/share/doc/kernel-images/playwright-core /usr/local/share/doc/kernel-images/sharp \
&& cp /tmp/browser-repl/node_modules/meriyah/LICENSE.md /usr/local/share/doc/kernel-images/meriyah/LICENSE.md \
&& cp /tmp/browser-repl/node_modules/meriyah/package.json /usr/local/share/doc/kernel-images/meriyah/package.json \
&& cp /tmp/browser-repl/node_modules/playwright-core/LICENSE /usr/local/share/doc/kernel-images/playwright-core/LICENSE \
&& cp /tmp/browser-repl/node_modules/playwright-core/NOTICE /usr/local/share/doc/kernel-images/playwright-core/NOTICE \
&& cp /tmp/browser-repl/node_modules/playwright-core/package.json /usr/local/share/doc/kernel-images/playwright-core/package.json \
&& cp /tmp/browser-repl/node_modules/sharp/LICENSE /usr/local/share/doc/kernel-images/sharp/LICENSE \
&& cp /tmp/browser-repl/node_modules/sharp/package.json /usr/local/share/doc/kernel-images/sharp/package.json \
&& cp -a /tmp/browser-repl/node_modules /usr/local/lib/browser-repl/node_modules \
&& esbuild /tmp/browser-repl/browser-repl.ts \
--bundle \
--platform=node \
--target=node22 \
--format=cjs \
--supported:dynamic-import=true \
--external:sharp \
--outfile=/usr/local/lib/browser-repl/browser-repl.js \
&& rm -rf /tmp/browser-repl

RUN useradd -m -s /bin/bash kernel

# Bake the envoy forward-proxy CA cert into the image (system trust store +
Expand Down
22 changes: 22 additions & 0 deletions images/chromium-headless/image/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -285,4 +285,26 @@ RUN esbuild /tmp/playwright-daemon.ts \
--external:esbuild \
&& rm /tmp/playwright-daemon.ts /tmp/page-target-id-cache.ts /tmp/webmcp.ts

# Copy and install the browser REPL's pinned runtime dependencies before bundling.
COPY server/runtime/ /tmp/browser-repl/
RUN npm ci --ignore-scripts --no-audit --no-fund --omit=dev --prefix /tmp/browser-repl \
&& mkdir -p /usr/local/lib/browser-repl /usr/local/share/doc/kernel-images/meriyah /usr/local/share/doc/kernel-images/playwright-core /usr/local/share/doc/kernel-images/sharp \
&& cp /tmp/browser-repl/node_modules/meriyah/LICENSE.md /usr/local/share/doc/kernel-images/meriyah/LICENSE.md \
&& cp /tmp/browser-repl/node_modules/meriyah/package.json /usr/local/share/doc/kernel-images/meriyah/package.json \
&& cp /tmp/browser-repl/node_modules/playwright-core/LICENSE /usr/local/share/doc/kernel-images/playwright-core/LICENSE \
&& cp /tmp/browser-repl/node_modules/playwright-core/NOTICE /usr/local/share/doc/kernel-images/playwright-core/NOTICE \
&& cp /tmp/browser-repl/node_modules/playwright-core/package.json /usr/local/share/doc/kernel-images/playwright-core/package.json \
&& cp /tmp/browser-repl/node_modules/sharp/LICENSE /usr/local/share/doc/kernel-images/sharp/LICENSE \
&& cp /tmp/browser-repl/node_modules/sharp/package.json /usr/local/share/doc/kernel-images/sharp/package.json \
&& cp -a /tmp/browser-repl/node_modules /usr/local/lib/browser-repl/node_modules \
&& esbuild /tmp/browser-repl/browser-repl.ts \
--bundle \
--platform=node \
--target=node22 \
--format=cjs \
--supported:dynamic-import=true \
--external:sharp \
--outfile=/usr/local/lib/browser-repl/browser-repl.js \
&& rm -rf /tmp/browser-repl

ENTRYPOINT [ "/wrapper" ]
161 changes: 161 additions & 0 deletions plans/persistent-browser-repl.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,161 @@
# Browser REPL API

**Status: Implemented**

## Decision

`POST /repl` evaluates JavaScript in the Browser REPL, a persistent Node runtime preloaded with browser helpers. The public contract is a browser runtime, not a CDP executor; raw CDP remains available through `cdp()`.

The API process directly owns one lazily started REPL child. State is intentionally lost after API restart, explicit reset, timeout, crash, OOM, or protocol corruption. Each new child receives a CUID2 `repl_id`.

This endpoint is unrestricted code execution inside the browser VM. Its `vm.Context` stores state but is not a security boundary.

## Public API surface

The REPL is scoped to an individual browser instance, so its HTTP path does not repeat the browser namespace:

```text
POST /repl
operationId: executeBrowserRepl
```

The OpenAPI contract uses `BrowserReplRequest`, `BrowserReplResult`, `BrowserReplContent`, `BrowserReplTextContent`, and `BrowserReplImageContent`. The generated Go handler is `ExecuteBrowserRepl`, and strict request-body decoding is applied specifically to `POST /repl`.

This naming is intended to support an object-oriented SDK surface without exposing the instance transport:

```ts
const browser = await kernel.browsers.create();
await browser.repl({code: "const x = 41"});
await browser.repl({code: "x + 1"});
```

SDK implementation lives outside this repository.

## HTTP contract

Request:

```json
{
"code": "const title = (await pageInfo()).title; repl.write(title); title",
"timeout_sec": 60,
"reset": false
}
```

- `code` is JavaScript and may be empty only when `reset` is true.
- `timeout_sec` is an integer from 1 through 300, defaulting to 60.
- Unknown fields are rejected.
- The API rejects request bodies and marshaled daemon envelopes above 8 MiB.

Successful responses contain `success`, `repl_id`, ordered `content`, `content_truncated`, and `duration_ms`. Failures may additionally contain `error`, `stack`, and `repl_terminated`.

`repl_id` stays stable across normal calls and Chromium reconnects. A destructive failure response carries the terminated ID; no replacement starts until the next request.

### Ordered content

Content is an ordered union:

```text
{text, channel: write|stdout|stderr, text}
{image, mime_type: image/*, data_b64}
```

Expression values are not returned automatically. Output is optional: an execution may produce zero content, use `repl.write(...)` or `repl.emitImage(...)`, call console methods, or combine them.

Limits:

- 8 MiB per image
- 16 MiB aggregate images per response
- 256 KiB text per response
- 1,000 buffered items produced between executions
- 48 MiB daemon response

Dropping or truncating output sets `content_truncated`.

## Evaluation model

Each request is one JavaScript cell evaluated as a fresh `vm.SourceTextModule`. Meriyah, installed from an exact lockfile, identifies declarations, binding patterns, and static module syntax.

The runtime supports:

- top-level `await`
- optional text and image output through `repl` and console methods
- persistent `var`, `let`, `const`, function, and class bindings
- dynamic `import()`

Static imports/exports and top-level `return` are rejected. TypeScript is not supported.

A declaration registry performs cross-cell early-error checks before effects. `var` and function may redeclare one another; lexical declarations conflict with every prior declaration. Meriyah-guided lowering backs persistent names with context-global accessors, so closures, timers, and later cells share one binding rather than per-cell snapshots. Synthetic modules adapt dynamic imports into the cell context.

Lowering covers destructuring, nested top-level `var`, loop declaration heads, function hoisting, mutation, TDZ behavior, and partial multi-declarator initialization. Lexical names remain reserved after a failed initializer. Ordinary exceptions do not reset the process; initialized bindings remain observable according to JavaScript semantics.

`Function.prototype.toString()` may expose a generated function alias, but declared function `.name` is preserved.

## Runtime globals

Helpers are available both as bare globals and through a frozen `browser` object:

```js
await gotoUrl("https://example.com");
await browser.gotoUrl("https://example.com");
```

The image's browser-wide WebMCP client is exposed with the same frozen identity as `webmcp` and `browser.webmcp`. It delegates to the existing loopback `/webmcp` API rather than duplicating tool tracking in the REPL's CDP client. Each request is bound to the active execution and clamped below its destructive deadline; unfinished invocations are aborted when the cell ends.

`playwright-core` is an exact, lockfile-pinned runtime dependency available through dynamic `import("playwright-core")`. Callers may connect it to `process.env.CDP_ENDPOINT` and retain the resulting Playwright module, browser connection, context, and page objects across cells. This is opt-in rather than preloaded, and imported Playwright connections must be recreated after Chromium restarts. The frozen native `browser` namespace remains authoritative and is not replaced with a Playwright object.

The complete helper reference, including signatures, behavior, and examples, lives in [`server/docs/repl.md`](../server/docs/repl.md). It covers navigation and page state, input, screenshots, tabs and iframe targets, waiting, page JavaScript, uploads, HTTP, raw CDP, and event draining. Wait helpers and CDP commands clamp their deadlines below the request deadline so routine helper failures return cleanly instead of destructively timing out the REPL.

### REPL helpers

```ts
type Repl = {
readonly id: string;
write(value: unknown): void;
emitImage(image: ImageInput): Promise<void>;
};
```

These helpers are optional. `repl.write` emits a dedicated `write` content item without adding a newline and formats non-string values with bounded inspection. Console log/info/debug are captured as stdout and warn/error as stderr. Expression values are intentionally ignored, and an execution may produce no output. `emitImage` accepts PNG, JPEG, or WebP bytes, an image data URL, or a VM-local path. Screenshots remain file-oriented and can optionally be emitted:

```js
const path = await captureScreenshot("/tmp/page.png");
await repl.emitImage({path});
```

## Process and browser lifecycle

```text
client -> API -> Node child -> DevTools proxy :9222 -> Chromium :9223
```

The API serializes calls and is the sole supervisor. Startup creates a process group, configures Linux parent-death signaling, and waits for the Unix socket. Shutdown kills and waits for the group. A stale foreign listener is located through `/proc/net/unix` plus `/proc/<pid>/fd`, killed, and replaced; the API never adopts state from an earlier process.

The browser connection is lazy. Pure Node code works while Chromium is unavailable. The runtime maintains one browser WebSocket, one attached target/session, bounded events, network state, and pending dialog state. Chromium restart clears browser connection state only; the next helper reconnects without changing JavaScript bindings or `repl_id`.

Attach activates the target for deterministic dialog and input behavior. Domain enables and session commands are bounded so a renderer frozen behind a dialog returns a recovery error while browser-level tab commands remain available. A stale frozen tab can be reloaded, replaced, or closed.

## Failure semantics

Timeouts are destructive because abandoned JavaScript cannot safely coexist with later executions. On daemon timeout or API read deadline, the API kills the process group, waits, removes the socket, clears the child handle, and reports the terminated ID. The same reset applies to crash, OOM, mismatched IDs, malformed responses, and protocol corruption.

Unhandled promise rejections are bounded stderr output and do not reset state. Uncaught exceptions may leave Node inconsistent, so the daemon reports the active failure when possible and exits; the API marks the REPL terminated.

The child and API both serialize requests. Transport is newline-delimited JSON over a Unix socket, one connection per call. Request and response IDs plus `repl_id` must match. Socket half-close still permits the queued response to flush.

## Security and integrity

Callers can access Node built-ins, installed packages, filesystem, network, environment, processes, and CDP. Service integrity comes from bounded inputs/outputs, private serializer references resistant to prototype pollution, process destruction after unsafe failures, a configurable heap cap, and keeping protocol traffic off process stdout.

## Verification

Coverage includes:

- binding persistence, mutation, redeclaration, TDZ, closures, destructuring, partial failure, top-level await, dynamic import, and reset
- stable/new `repl_id` behavior across normal calls, browser restart, timeout, crash, OOM, and API shutdown
- zero-output executions, ordered text/images, and absence of output leakage after terminated executions
- edge-value representation, pollution resistance, and all output limits
- every seeded browser helper, reconnect, tabs, input, iframe, dialog, network idle, uploads, and screenshots
- pinned Playwright Core import, cross-cell object identity, live CDP control, and explicit reconnection after Chromium restart
- both headless and headful images plus OpenAPI regeneration and SSE regression checks
8 changes: 6 additions & 2 deletions server/Makefile
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
SHELL := /bin/bash
.PHONY: oapi-generate build dev test test-unit test-runtime test-e2e clean
.PHONY: oapi-generate runtime-typecheck build dev test test-unit test-runtime test-e2e clean

BIN_DIR ?= $(CURDIR)/bin
RECORDING_DIR ?= $(CURDIR)/recordings
Expand All @@ -14,6 +14,10 @@ $(RECORDING_DIR):
# 1. Convert 3.1 → 3.0 since oapi-codegen doesn't support 3.1 yet (https://github.com/oapi-codegen/oapi-codegen/issues/373)
# 2. Run oapi-codegen with our config (version pinned via go.mod tool directive)
# 3. go mod tidy to pull deps
runtime-typecheck:
npm ci --ignore-scripts --no-audit --no-fund --prefix ./runtime
npm run typecheck --prefix ./runtime

oapi-generate:
pnpm i -g @apiture/openapi-down-convert
openapi-down-convert --input openapi.yaml --output openapi-3.0.yaml --allOf
Expand All @@ -39,7 +43,7 @@ test-unit:
go vet ./...
go test -v -race $$(go list ./... | grep -v /e2e$$)

test-runtime:
test-runtime: runtime-typecheck
node --test runtime/*.test.ts

test-e2e:
Expand Down
68 changes: 68 additions & 0 deletions server/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,74 @@ export OUTPUT_DIR=/tmp/recordings
- **YAML Spec**: `GET /spec.yaml`
- **JSON Spec**: `GET /spec.json`

### Browser REPL

`POST /repl` evaluates JavaScript in the Browser REPL, a persistent Node.js
runtime preloaded with browser-control helpers and an unrestricted `cdp()`
escape hatch. See [`docs/repl.md`](docs/repl.md) for the execution model,
output guidance, examples, failure semantics, limits, and a reference for every
helper.

- The runtime starts lazily on the first request and is owned directly by the
API process. API restart/shutdown kills it (with Linux parent-death
signaling as a backstop); an API restart therefore loses all REPL state.
- Each REPL process gets a CUID2 `repl_id`, returned in every response. It is
stable across calls and Chromium reconnects, and changes after an API
restart, `reset: true`, an execution timeout, or a REPL crash.
- Top-level `await`, persistent `let`/`const`/`var`/function/class bindings,
and dynamic `import()` are supported.
Persistent names are live context-global accessors, so closures and timers
observe later-cell assignments. Function declarations are lowered through
those accessors too, including same-cell closures and assignments. `var`
declarations in top-level nested statements persist, including object/array
rest destructuring and `for...of` declaration heads; locals inside functions
or nested lexical blocks do not.
Braceless multi-declarator `var` statements retain their single-statement
control-flow semantics. Lexical names are reserved after linking: retry a
failed declaration with a new name or use `reset: true`. Function `.name` is
preserved; `Function.prototype.toString()` may expose the generated internal
alias. Static top-level imports are rejected; use dynamic `import()` instead.
Expression values are not returned automatically: use `repl.write(...)` for
final text and `repl.emitImage(...)` for images. Console methods are captured
for debugging and intermediate values. Top-level `return` is rejected.
- A timeout is destructive (JavaScript cannot be interrupted safely): the API
kills the REPL process group and responds with `repl_terminated: true` and
the terminated REPL's ID. The next request lazily starts a fresh REPL.
- Output is an ordered `content` array of typed items: text (`write` =
`repl.write`, `stdout` = `console.log/info/debug`, `stderr` =
`console.warn/error`) and images (`repl.emitImage`, base64 with MIME
sniffing). Limits: 8 MiB per image, 16 MiB aggregate image data, 256 KiB
combined text per response; violations set `content_truncated` instead of
failing silently; stray
output, including images emitted between executions, is capped at 1,000
items and reports `content_truncated` when older items are discarded.
Request bodies are limited to 8 MiB before strict decoding, and the API
rejects any marshaled daemon request that would exceed
the daemon's 8 MiB newline-delimited request-line limit without terminating
the REPL. HTML-sensitive code is sent without JSON HTML escaping.
- `captureScreenshot()` stays file-oriented (returns a VM path); emit it
explicitly with `await repl.emitImage({ path })`.
- Helpers are exposed as bare globals and on the frozen `browser` namespace.
See [`docs/repl.md`](docs/repl.md#browser-helpers) for every helper's
signature and behavior.
- A pinned `playwright-core` package is available through
`await import("playwright-core")`. Connect it to `process.env.CDP_ENDPOINT`
to use ordinary Playwright browser, context, and page objects as persistent
REPL bindings; reconnect those objects explicitly after Chromium restarts.
- The REPL connects to the browser through the DevTools proxy on
`ws://127.0.0.1:9222`, lazily on the first browser helper call; pure
Node.js code runs fine while Chromium is down, and the connection is
re-established automatically after a Chromium restart.

**Security**: this endpoint is unrestricted code execution inside the browser
VM (filesystem, network, processes, environment), equivalent in trust level
to the process and Playwright execution APIs. The `vm` context is a state
container, not a sandbox.

The daemon sources live in `server/runtime/` (`browser-repl.ts`,
`browser-cdp-client.ts`, `browser-helpers.ts`) and are bundled to
`/usr/local/lib/browser-repl.js` in both browser images.

## 🔧 Development

### Code Generation
Expand Down
Loading
Loading