Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
37 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
77e9275
Add Patchright and CDP event waiting
rgarcia Sep 9, 2026
e8b70e1
Add accessibility snapshots and harden browser REPL
rgarcia Sep 9, 2026
973c293
Merge remote-tracking branch 'origin/main' into rgarcia/browser-repl
rgarcia Sep 9, 2026
0f71c0e
Remove redundant Browser REPL key helper
rgarcia Sep 9, 2026
2cd52e0
Simplify Browser REPL image packaging
rgarcia Sep 9, 2026
4fcd797
Simplify browser REPL process cleanup
rgarcia Sep 9, 2026
e4e48db
Encapsulate browser REPL lifecycle
rgarcia Sep 10, 2026
e5fb0c8
Consolidate browser REPL documentation
rgarcia Sep 10, 2026
e5a6cb9
Remove accessibility snapshot license copy
rgarcia Sep 10, 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
15 changes: 15 additions & 0 deletions images/chromium-headful/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -389,6 +389,21 @@ 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 \
&& 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
15 changes: 15 additions & 0 deletions images/chromium-headless/image/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -285,4 +285,19 @@ 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 \
&& 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" ]
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
71 changes: 71 additions & 0 deletions server/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,77 @@ 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/dir/table`, `stderr` =
`console.warn/error/trace`) and images (`repl.emitImage`, base64 with MIME
sniffing). Limits: 8 MiB per image, 16 MiB aggregate image data, and 256 KiB
combined text per response. An oversized individual image throws; aggregate
output truncation sets `content_truncated`. 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.
- Pinned `patchright` and `playwright-core` packages are available through
dynamic `import()`. Patchright matches the image's default Playwright
execution engine. Connect either package to `process.env.CDP_ENDPOINT` to
use ordinary browser, context, and page objects as persistent REPL bindings;
reconnect those objects explicitly after Chromium restarts. Other packages
installed through `/process/exec` with `npm install -g package@version` are
available to bare dynamic `import("package")` calls.
- 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
7 changes: 6 additions & 1 deletion server/cmd/api/api/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,8 @@ type ApiService struct {
// playwrightDaemonCmd holds the daemon process for cleanup
playwrightDaemonCmd *exec.Cmd

browserRepl *browserReplManager

webmcp webMCPClient

// policy management
Expand Down Expand Up @@ -169,6 +171,7 @@ func New(
cdpMonitor: mon,
otlpExport: otlpExport,
webmcp: webmcpclient.NewManager(upstreamMgr),
browserRepl: newBrowserReplManager(),
lifecycleCtx: ctx,
lifecycleCancel: cancel,
}, nil
Expand Down Expand Up @@ -431,6 +434,8 @@ func (s *ApiService) ListRecorders(ctx context.Context, _ oapi.ListRecordersRequ
}

func (s *ApiService) Shutdown(ctx context.Context) error {
replErr := s.browserRepl.Shutdown(ctx)

_ = s.webmcp.Close()
s.monitorMu.Lock()
s.lifecycleCancel()
Expand All @@ -439,5 +444,5 @@ func (s *ApiService) Shutdown(ctx context.Context) error {
s.monitorMu.Unlock()
// The OTLP export sink is stopped by main after the servers drain, so any
// events they emit on the way down are still exported (mirrors s2Writer).
return s.recordManager.StopAll(ctx)
return errors.Join(replErr, s.recordManager.StopAll(ctx))
}
Loading