Add persistent Browser REPL API - #368
Open
rgarcia wants to merge 28 commits into
Open
Conversation
Design for POST /browser/execute: a persistent Node.js execution runtime preloaded with browser-control helpers, owned directly by the API process with lazy startup, CUID2 repl_id values, destructive timeouts, and typed ordered text/image content.
Three bundled TypeScript modules backing /browser/execute: - browser-repl.ts: Unix-socket server speaking newline-delimited JSON, a persistent vm context (state container, not a sandbox), top-level await via an acorn-based declaration rewrite (Node REPL style), dynamic import() through the main context loader, TypeScript via esbuild, and ordered text/image output capture with response limits (8 MiB per image, 16 MiB aggregate images, 256 KiB text, 256 KiB serialized result). - browser-cdp-client.ts: raw CDP client over Node's built-in WebSocket with command correlation, per-command timeouts, a bounded event ring, dialog/network tracking, lazy connection, and automatic reattachment after Chromium restarts (JS bindings and repl_id survive). - browser-helpers.ts: the complete v1 helper surface (cdp escape hatch, navigation, page info, input, screenshots, tab management, waiting, evaluation, uploads, http_get, and recording helpers that delegate to the Kernel recording API), exposed as bare globals and on a frozen browser namespace. acorn v8.18.0 (MIT) is vendored under server/runtime/vendor so the daemon does not depend on image-global packages for parsing.
The API process is the sole owner and supervisor of the REPL child: - Lazy startup on the first request with a fresh CUID2 repl_id, stale socket removal, and socket-poll readiness. - Child runs in its own process group with Linux parent-death signaling (SIGKILL), so an API crash can never orphan a REPL; orphaned REPLs are never adopted. - Graceful API shutdown explicitly terminates the process group (SIGTERM, escalating to SIGKILL) and reaps it. - Destructive timeouts: when the execution timeout or HTTP deadline fires, the API kills the process group, removes the socket, clears the handle, and returns success=false with the terminated repl_id and repl_terminated=true. The next request lazily starts a fresh REPL. - Request ID and repl_id validation on every daemon response; mismatches or undecodable responses terminate the child as protocol corruption. - reset: true terminates the current REPL and evaluates in a fresh one; empty code is valid only with reset. - Executions are serialized with browserReplMu, defense in depth on top of the daemon's internal queue. OpenAPI gains the endpoint, request/response schemas, and the ordered BrowserExecutionContent discriminated union (text/write|stdout|stderr and image items); lib/oapi is regenerated with the pinned oapi-codegen. Unit tests cover persistence, repl_id stability, reset, timeout kill, crash recovery, shutdown, serialization, content ordering, image validation, and truncation, building the real daemon bundle with esbuild.
- Both headful and headless Dockerfiles bundle the runtime to /usr/local/lib/browser-repl.js (CJS, esbuild kept external so the daemon can transform user TypeScript at runtime; vendored acorn is inlined). - e2e tests cover persistence and repl_id stability, browser helpers against example.com, tab management with screenshot emission, reset, and destructive timeout with lazy recovery. - server/README.md documents the endpoint, lifecycle, output limits, helper surface, and the explicit not-a-sandbox security model.
Addresses review findings against plans/persistent-browser-repl.md: - Interruptible timeouts (e.g. an unresolved promise) were reported by the daemon as ordinary failures, so the child survived and the abandoned execution kept running, leaking its output into later executions' content. The daemon now marks its timeout responses with timed_out: true, and the API treats them exactly like transport failures: kill the process group, return the terminated repl_id with repl_terminated: true (plus the partial content produced before the deadline), and lazily start a fresh REPL on the next request. - NaN/Infinity/-0 results no longer serialize to JSON null; they surface via result_repr. - timeout_sec is validated server-side per the schema (1..300); values outside the range get a 400 instead of silently becoming 60s or holding the REPL mutex for arbitrary durations. Test-plan coverage added: - Unresolved-promise timeout: destructive termination, fresh repl_id, and no output leakage into the next execution (unit + e2e). - Chromium-restart recovery: same repl_id and preserved bindings after reconnect, unit-level against a fake CDP endpoint and e2e via supervisorctl restart. - Every seeded helper exercised at least once against a fake CDP WebSocket endpoint (unit). - Per-image (8 MiB) and aggregate (16 MiB) image size limits. - NaN/Infinity/-0 and error-as-result serialization. - timeout_sec bounds. - Both REPL e2e suites now run against headful and headless images.
Medium:
- page_info short-circuits while a modal JS dialog is pending, returning
the dialog plus last-known target metadata instead of blocking on
Runtime.evaluate behind the frozen renderer (30s CDP timeout).
- Result serialization no longer invokes user code: a hand-rolled
serializer ignores toJSON/prototype pollution inside the context, so the
result payload cannot be corrupted. Non-JSON values (undefined, Map,
RegExp, class instances, circular structures) now route to the bounded
repr instead of serializing lossily ({} / null).
- ensureAttached re-lists targets and retries once when a target is
destroyed between listing and attaching (target-swap race) instead of
surfacing a raw CDP error.
Low:
- POST /browser/execute rejects unknown request fields with 400, matching
the schema's additionalProperties: false.
- The container wrapper (PID 1) now reaps orphaned grandchildren, so
REPL children killed via pdeathsig after an abrupt API kill no longer
linger as zombies.
- A child dying mid-execution (e.g. OOM near the heap cap) reports its
exit reason in the error instead of a bare transport EOF.
- new_tab waits (best effort) for the initial navigation commit and
close_tab for target destruction, so immediate follow-up calls observe
consistent state.
- Uninterruptible timeouts now SIGKILL immediately at the read deadline
(no hopeless SIGTERM grace) and share the daemon's timeout message;
a 5s while(true) timeout returns in ~7s instead of ~11s.
Tests: unit coverage for pollution integrity, result fidelity, pending
dialogs, stale-target attach retry, protocol corruption (mismatched
ids/garbage/child death) via a fake daemon, and the unknown-fields
middleware; e2e coverage for alert/confirm/prompt dialogs, custom
recorder ids, unknown-field rejection, and the unified timeout message.
Plan doc updated for the new semantics.
- wait_for_element rejects a non-object opts argument with a clear error
instead of silently ignoring it and waiting out the default timeout.
- Wait-style helpers (wait_for_load, wait_for_element,
wait_for_network_idle) clamp their internal deadline to just below the
executing request's deadline, so a routine wait miss surfaces the
helper's clean error instead of tying the destructive execution timeout
and killing the REPL.
- press_key validates modifiers with a descriptive error (no more cryptic
'not iterable' TypeError or per-character string iteration) and accepts
the {ctrl/alt/shift/meta: true} object sugar.
- Add regression coverage for the two QA findings plus the remaining
review gaps: BROWSER_REPL_HEAP_MB propagation to --max-old-space-size
and the bounded CDP event ring dropping old events at capacity.
High: a stale modal JS dialog opened before REPL attach froze the renderer and bricked /browser/execute — every fresh REPL burned on an 8s session-attach timeout with no in-API recovery. Session attach and session-routed CDP calls now carry an internal deadline clamped below the request deadline, so a frozen renderer yields a clean helper error (naming recovery options) instead of a destructive timeout; browser- level helpers keep working and callers recover via Page.reload, new_tab, or close_tab without restarting Chromium. Medium: repl.emitImage rejected a direct Uint8Array because 'instanceof Uint8Array' fails across vm-context realms. Use ArrayBuffer.isView (realm-safe) so all documented ImageInput forms work from user code. Low fixes: - Crash/OOM error responses now populate duration_ms and truncation flags like the timeout path. - js(code, target) validates that target is a string and throws a clear error instead of surfacing raw CDP 'Invalid parameters'. - Static import/export rejection is now unconditional: the esbuild transform runs with preserveValueImports and the acorn check runs on the transformed output, so unused imports preceded by TypeScript-only syntax can no longer be silently elided. Type-only imports remain allowed. - Document the data:-URL mouse-wheel scroll no-op (upstream Chromium headless behavior). Regression coverage: TestBrowserReplFrozenRendererRecovery, TestBrowserReplCrashDuringExecutionResponse, TestBrowserReplStaticImportRejected (incl. TS-preceding forms and type-only imports), extended image-validation and helper-ergonomics tests, and an e2e stale pre-attach dialog subtest. Verified with go vet, full -race unit suite, make build, e2e TestBrowserRepl* on fresh headless+headful images, and live container smoke.
…4 findings - Surface unhandledRejection as a bounded stderr item; REPL survives - uncaughtException answers the in-flight execution with exiting:true, mapped to repl_terminated:true; next request lazily starts a fresh REPL - Bound scroll() mouseWheel dispatch to 5s with in-page scrollBy fallback on CDP timeout, surfaced via helpers.onLog - waitForNavigationCommit polls renderer-level location.href (about:blank race) - Enforce 8 MiB request-line cap per accumulated line, independent of chunking - allowHalfOpen daemon: flush final queued response before ending our side - Accept recorder_id as alias for the recording API's id field Adds 7 regression tests; verified live against a fresh container.
… orphan kill
Medium — const/let in async-wrapper mode: top-level await used to degrade
const/let/class declarations to mutable, redeclarable global assignments.
The runtime now tracks every top-level binding in a registry across both
evaluation paths, raises redeclaration as an early SyntaxError before any
user code runs (no matter which path declared the name), and exports
async-mode let/const/class bindings via __replDefine__: const is backed by
an accessor whose setter throws TypeError "Assignment to constant
variable.", let/class become writable non-configurable properties. Names
stay reserved when an initializer throws, matching script instantiation.
Low — trailing-block implicit results: documented that with top-level
await/return only a trailing expression statement yields an implicit
result; a trailing try/if/for yields undefined (pinned by a test).
Low — first mouseWheel after navigation: Chromium (both variants) answers
the dispatch normally but never scrolls, so the timeout fallback could not
detect it. scroll() now probes the window scroll offset around the
dispatch and retries a no-op dispatch once when the window itself is
scrollable in a requested direction, surfacing the retry as a stderr
content item. Unscrollable/edge/inner-element cases stay unverified and
behave exactly as before. Verified live: the first scroll after
goto_url("https://example.com") now lands at 700 instead of 0.
Low — orphaned REPL leak: lazy startup unlinked a stale socket but never
killed the orphaned daemon holding it. When the API finds a live listener
it does not own, it locates the owning process via /proc/net/unix and
/proc/<pid>/fd and SIGKILLs it before removing the socket file.
Also fix an e2e subtest that relied on the old redeclaration bug (a second
top-level `const info` in a shared REPL).
Regression tests: async const/let/class/var/function semantics across
both paths, cross-path conflicts, failed-initializer reservation,
trailing-block pinning, swallowed-wheel retry (fake CDP), and
orphaned-daemon kill. Full non-e2e suite with -race passes; focused
TestBrowserRepl e2e passes on freshly built headless and headful images.
…eath path, activate target on attach - scroll(): poll the scroll offset through a 250ms settle window before declaring a mouseWheel dispatch had no effect. Chromium applies the wheel asynchronously after the CDP command answers, so the immediate probe false-positived and the swallowed-wheel retry double-scrolled every scroll on both variants. Verified live: cumulative offsets are now exactly 700/1400 with no retry. - browser_repl: kill orphaned REPL daemons before unlinking the socket on every lazy-start path (reapBrowserReplSocket shared by clearBrowserReplLocked and startBrowserReplLocked). Previously the child-death replacement path unlinked first, making the orphan undetectable via /proc/net/unix and leaking it for the container lifetime. Verified live: orphan SIGKILLed on the child-death path. - attach: best-effort Target.activateTarget so the attached tab is the foreground tab; headless Chromium auto-cancels hidden tabs' JS dialogs and the foreground tab after a Chromium restart is not deterministic, which made page_info dialog reporting flaky. Regression tests: async-wheel scroll (fake CDP delayed application), attach activates target, orphan killed on the child-death path, and an e2e dialog-after-chromium-restart probe on both image variants.
…l limitations docs Fresh QA cycle-6 findings: 1. Flaky 'CDP connection closed' on the first browser-helper call after a Chromium restart: the DevTools proxy can accept the WebSocket and then close it while the browser behind it is still coming up, rejecting the in-flight command. Tag connection-closed failures with whether the connection ever answered a command; when it never did, the command almost certainly never reached the browser, so reconnect and retry exactly once (browser-level commands in send(), session commands via re-attach in sessionCommand()). Regression test simulates a proxy that accepts then drops the first post-restart connection mid-command. 2. Plan docs: the data:-URL mouse-wheel no-op and the first-wheel-after-navigation swallow were observed only on non-activated (hidden) tabs and no longer reproduce now that attach foregrounds the tab via Target.activateTarget. Update Known Limitations and the scroll helper table entry accordingly; the settle/retry logic stays as defense in depth.
# Conflicts: # server/Makefile # server/cmd/api/api/api.go # server/cmd/api/main.go # server/lib/oapi/oapi.go
|
Review the following changes in direct dependencies. Learn more about Socket for GitHub.
|
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes using default effort and found 1 potential issue.
❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.
Reviewed by Cursor Bugbot for commit 9249def. Configure here.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.

A persistent JavaScript control plane for every Kernel browser
This PR adds
POST /repl: a stateful Browser REPL that lives alongside Chromium inside each browser VM.Instead of sending a complete automation program on every call—or rebuilding state from serialized results—an agent can teach the browser instance reusable JavaScript once, call it incrementally, inspect rendered state, and keep going:
The result is a compact, agent-native control surface with:
The API
{ "code": "const info = await pageInfo(); repl.write(info.title)", "timeout_sec": 60, "reset": false }Successful responses identify the exact state-holding process and contain ordered, typed output:
{ "success": true, "repl_id": "<cuid2>", "duration_ms": 12, "content_truncated": false, "content": [ { "type": "text", "channel": "write", "text": "Example Domain" } ] }The endpoint is scoped to an individual browser instance, so the intended generated SDK surface is:
rather than exposing details of the instance transport.
Why persistence matters
Each request is evaluated as a fresh JavaScript module cell, while top-level bindings remain live across subsequent cells:
The runtime preserves
var,let,const, function, and class bindings; mutation; closures; timers; destructuring; function hoisting; TDZ behavior; and partial initialization semantics. Top-levelawaitand dynamicimport()are supported.This is deliberately JavaScript-only. TypeScript, static imports/exports, and top-level
returnare rejected.Browser control built in
Helpers are available as bare globals and through the frozen
browsernamespace:The surface covers:
gotoUrl,pageInfo,waitForLoad,waitForNetworkIdleclick,fillInput,typeText,pressKey,scroll,dispatchKeyjslistTabs,currentTab,switchTab,newTab,closeTab,ensureRealTab,iframeTargetcdp,drainEvents,captureScreenshot,uploadFile,httpGetWebMCP first, direct control when needed
The same frozen browser-wide client is available as
webmcpandbrowser.webmcp:This delegates to the existing Go WebMCP manager, which discovers native tools across every tab and embedded frame, owns live
tool_refrouting, and preservesawaiting_submissionandoutcome_unknownsafety. Frame-provided tools do not require switching the REPL's attached target.WebMCP calls are scoped to the originating cell with
AsyncLocalStorageand an execution-specific abort signal. HTTP waits are clamped below the destructive cell deadline, and finishing a cell aborts unfinished requests, so an unawaited invocation cannot leak into a later execution.The intended automation ladder is: use page-declared WebMCP tools when available, semantic selector control otherwise, opt into Playwright Core when its broader API helps, use coordinates for visual interaction, and keep unrestricted CDP as the final escape hatch.
Playwright Core is an opt-in persistent library
The REPL ships a lockfile-pinned
playwright-corepackage without downloading another browser. Users can dynamically import it, connect to the image's existing Chromium, and retain ordinary Playwright objects across cells:The native frozen
browsernamespace remains the default, so imported connections use names such aspwBrowser. Imported Playwright connections become stale when Chromium restarts and can reconnect explicitly within the same REPL while all other JavaScript state survives. Reset, timeout, crash, or API restart clears the connection and bindings with the rest of the REPL process.One click API: semantic or coordinate
Selector clicks wait for one visible, enabled, stable, unobscured match, scroll it into view, hit-test it, and dispatch physical mouse input through Chromium. Coordinate clicks remain the direct computer-use escape hatch. Options support
button,clickCount, and selector-onlytimeoutSec.Semantic synchronization instead of blind sleeps
waitForElement()supportsattached,detached,visible, andhidden. It considers all matches, so hidden responsive duplicates cannot mask visible controls. Expected polling timeouts returnfalse; protocol and high-level contract failures throw.Fast, exact-once page JavaScript
js()supports both direct expressions and serialized page functions:A submitted expression or function is invoked once—there is no parse-error fallback that can repeat side effects. Function mode naturally supports statements,
await, andreturn. Arguments and results preserve arrays, plain objects, bigint,NaN, infinities, and-0.Page functions do not capture Browser REPL closures; data crosses explicitly through
options.arg.Cross-origin and nested iframe control
Same-origin frames are available through
iframe.contentDocument. Cross-site frames commonly run as separate Chromium targets and can be inspected without relying on top-page same-origin access:For physical selector interaction, attach to that target and restore the parent afterward:
For lower-level frame cases, callers retain unrestricted access to
Page.getFrameTree,Page.createIsolatedWorld,Runtime.evaluate, and the rest of CDP.Output is explicit, optional, and ordered
Expression values are intentionally ignored. Successful code may produce no output or combine several channels:
The response preserves call order across:
writetextstdoutstderrScreenshots remain file-oriented unless explicitly emitted. Output and protocol limits are bounded, and truncation is reported with
content_truncated.Failure and lifecycle contract
The API process directly owns one lazily started Node child and is its sole supervisor.
repl_idresetTimeouts are destructive because abandoned JavaScript cannot safely coexist with a later cell. A destructive failure response carries the terminated
repl_id; the replacement process starts only on the next request.Calls are serialized so executions cannot interleave. Clients that require a specific sequence should await each call because strict request-arrival FIFO ordering is not part of the public contract.
Architecture
Meriyah analyzes declarations and binding patterns. Each cell is lowered to live context-global accessors, preserving binding identity rather than copying snapshots between modules.
vm.SyntheticModuleadapts dynamic imports into the cell context.Chromium connectivity is lazy: pure Node code still works while the browser is unavailable, and a Chromium restart does not discard JavaScript state.
Security model
This endpoint is deliberately unrestricted remote code execution inside the browser VM.
vm.Contextis a state container, not a sandbox. Code can access Node built-ins, installed packages, files, environment variables, processes, the network, and unrestricted CDP.Integrity comes from process and protocol boundaries rather than pretending evaluation is isolated:
Validation
Automated coverage includes:
repl_idbehavior across normal calls, browser restart, timeout, crash, OOM, and API shutdownjs()execution and page-value serializationattached,detached,visible, andhiddenwaitsCommands run locally after synchronizing with current
main:Live Docker E2E also passed for the Browser REPL suite, including selector actions, wait states, Chromium restart recovery, and cross-origin iframe control.
Three independent agents additionally completed rendered Google Flights searches through
/repl, extracting and validating visible flight results without entering a booking flow.Review map
server/openapi.yamlserver/cmd/api/api/browser_repl.goserver/runtime/browser-repl.ts,cell-runtime.ts,cell-analysis.tsserver/runtime/browser-cdp-client.ts,browser-helpers.tsserver/runtime/page-evaluation.tsserver/runtime/us-keyboard-layout.tsserver/docs/repl.mdserver/docs/repl-agent-guidance.mdplans/persistent-browser-repl.md