Skip to content

Add persistent Browser REPL API - #368

Open
rgarcia wants to merge 34 commits into
mainfrom
rgarcia/browser-repl
Open

Add persistent Browser REPL API#368
rgarcia wants to merge 34 commits into
mainfrom
rgarcia/browser-repl

Conversation

@rgarcia

@rgarcia rgarcia commented Sep 4, 2026

Copy link
Copy Markdown
Contributor

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:

const browser = await kernel.browsers.create();

await browser.repl({
  code: `
    async function search(query) {
      await fillInput('input[aria-label="Search"]', query);
      await pressKey("ENTER");
      if (!await waitForElement("main", {state: "visible", timeoutSec: 15})) {
        throw new Error("results did not render");
      }
    }
  `,
});

await browser.repl({code: `await search("Kernel browsers")`});

The result is a compact, agent-native control surface with:

  • persistent JavaScript declarations and closures across calls
  • direct browser interaction without requiring a separate automation framework
  • exact-once page-function evaluation
  • semantic waits and actionability-aware input
  • unrestricted CDP, tab, target, iframe, filesystem, process, and network access
  • deterministic lifecycle and failure semantics

The API

POST /repl
Content-Type: application/json
{
  "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:

await browser.repl({code});

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:

// Cell 1
let searches = 0;
const selectors = {
  query: 'input[aria-label="Search"]',
  results: "main",
};

async function runSearch(query) {
  searches++;
  await fillInput(selectors.query, query);
  await pressKey("ENTER");
  return waitForElement(selectors.results, {
    state: "visible",
    timeoutSec: 15,
  });
}
// Cell 2
const ready = await runSearch("cloud browser");
repl.write(JSON.stringify({ready, searches}));

The runtime preserves var, let, const, function, and class bindings; mutation; closures; timers; destructuring; function hoisting; TDZ behavior; and partial initialization semantics. Top-level await and dynamic import() are supported.

This is deliberately JavaScript-only. TypeScript, static imports/exports, and top-level return are rejected.

Browser control built in

Helpers are available as bare globals and through the frozen browser namespace:

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

The surface covers:

Area Helpers
Navigation and state gotoUrl, pageInfo, accessibilitySnapshot, waitForLoad, waitForNetworkIdle, waitForEvent
Interaction click, fillInput, typeText, pressKey, scroll, dispatchKey
Page evaluation js
Tabs and targets listTabs, currentTab, switchTab, newTab, closeTab, ensureRealTab, iframeTarget
Inspection and escape hatches cdp, drainEvents, captureScreenshot, uploadFile, httpGet

accessibilitySnapshot() returns a compact projection of Chromium's computed accessibility tree. Every DOM-backed node carries backendNodeId, role, accessible name, optional value, and control states. Snapshot nodes can be passed directly to click, fillInput, waitForElement, and uploadFile, preserving the same actionability and physical-input behavior as selector actions:

const snapshot = await accessibilitySnapshot();
const submit = snapshot.nodes.find(node => node.role === "button" && node.name === "Submit");
if (!submit) throw new Error("Submit button not found");
await click(submit);

backendNodeId deliberately follows Chromium's DOM.BackendNodeId terminology and raw DOM.* command parameter, rather than introducing a REPL-only identity. It becomes stale when navigation or DOM replacement removes the node.

WebMCP first, direct control when needed

The same frozen browser-wide client is available as webmcp and browser.webmcp:

const tools = await webmcp.listTools();
const search = tools.find(tool => tool.name === "search");

if (search) {
  const result = await webmcp.invokeTool(
    search.tool_ref,
    {query: "CVG to SFO"},
    {timeoutSec: 30},
  );
  repl.write(JSON.stringify(result));
}

This delegates to the existing Go WebMCP manager, which discovers native tools across every tab and embedded frame, owns live tool_ref routing, and preserves awaiting_submission and outcome_unknown safety. Frame-provided tools do not require switching the REPL's attached target.

WebMCP calls are scoped to the originating cell with AsyncLocalStorage and 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, inspect and act on the computed accessibility tree when semantics are exposed, use selector control otherwise, opt into Patchright or Playwright Core when their broader API helps, use coordinates for visual interaction, and keep unrestricted CDP as the final escape hatch.

Patchright and Playwright Core are opt-in persistent libraries

The REPL ships lockfile-pinned patchright and playwright-core packages without downloading another browser. Patchright matches the image's default Playwright execution engine. Users can dynamically import it, connect to the existing Chromium, and retain ordinary browser objects across cells:

var playwright = await import("patchright");
var pwBrowser = await playwright.chromium.connectOverCDP(process.env.CDP_ENDPOINT);
var pwContext = pwBrowser.contexts()[0];
var pwPage = pwContext.pages()[0] ?? await pwContext.newPage();

await pwPage.goto("https://example.com");
repl.write(await pwPage.title());

Vanilla Playwright remains explicit with var playwright = await import("playwright-core"). The native frozen browser namespace remains the default, so imported connections use names such as pwBrowser. Imported 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.

Additional packages can be installed through /process/exec with npm install -g package@version, then loaded using ordinary bare dynamic imports such as await import("package"). Global installation keeps user packages separate from the REPL's own locked runtime dependency directory.

One click API: semantic or coordinate

await click('button[aria-label="Search"]');
await click({x: 420, y: 315});

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-only timeoutSec.

Semantic synchronization instead of blind sleeps

const ready = await waitForElement(
  '[role="option"][aria-label*="(SFO)"]',
  {state: "visible", timeoutSec: 10},
);

if (!ready) throw new Error("SFO option did not appear");

waitForElement() supports attached, detached, visible, and hidden. It considers all matches, so hidden responsive duplicates cannot mask visible controls. Expected polling timeouts return false; protocol and high-level contract failures throw.

Fast, exact-once page JavaScript

js() supports both direct expressions and serialized page functions:

const title = await js("document.title");

const data = await js(
  async ({selector, limit}) => {
    await Promise.resolve();
    return document.querySelector(selector)?.innerText.slice(0, limit) ?? null;
  },
  {arg: {selector: "main", limit: 1000}},
);

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, and return. 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:

const frame = await iframeTarget("checkout.example");
if (!frame) throw new Error("checkout frame not found");

const heading = await js(
  () => document.querySelector("h1")?.textContent,
  {targetId: frame.targetId},
);

For physical selector interaction, attach to that target and restore the parent afterward:

const parent = await currentTab();
const frame = await iframeTarget("checkout.example");

try {
  await switchTab(frame.targetId);
  await fillInput('input[name="cardholder"]', "Jane Doe");
  await click('button[aria-label="Continue"]');
} finally {
  await switchTab(parent.targetId);
}

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:

repl.write("structured answer");
console.log("diagnostic output");
console.error("warning output");

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

The response preserves call order across:

  • write text
  • captured stdout
  • captured stderr
  • PNG, JPEG, or WebP image content

Screenshots 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.

Event JavaScript state repl_id
Successful call Preserved Unchanged
Syntax error or ordinary exception Preserved Unchanged
Chromium restart Preserved; browser reconnects lazily Unchanged
Explicit reset Cleared Replaced
Execution timeout Child process group destroyed Replaced on next request
Crash, OOM, uncaught asynchronous exception, or protocol corruption Child process group destroyed Replaced on next request

Timeouts 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

SDK / HTTP client
      │
      ▼
POST /repl
      │
      ▼
kernel-images API ── owns lifecycle, timeout, reset, serialization
      │ Unix socket / newline-delimited JSON
      ▼
persistent Node runtime
      │
      ├── vm.SourceTextModule cells + live context-global bindings
      ├── explicit text/image output collection
      └── persistent CDP client
              │
              ▼
       DevTools proxy :9222 → Chromium :9223

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.SyntheticModule adapts 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.Context is 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:

  • API-owned process groups and Linux parent-death signaling
  • destructive recovery after unsafe failures
  • strict request decoding and bounded request/response sizes
  • private serializer references resilient to prototype pollution
  • a V8 old-space limit and explicit protocol/output caps (the browser VM/container remains the total RSS/CPU/process-tree boundary, matching unrestricted process execution)
  • protocol traffic kept off process stdout

Validation

Automated coverage includes:

  • declaration persistence, mutation, redeclaration, TDZ, closures, destructuring, partial failure, catch-parameter shadowing, 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 execution, ordered mixed content, image handling, pollution resistance, and output limits
  • exact-once js() execution and page-value serialization
  • keyboard normalization through a self-contained US layout
  • compact accessibility snapshots plus actionability-aware selector and backend-node clicking/filling
  • attached, detached, visible, and hidden waits
  • nested cross-origin iframe target evaluation and physical selector input
  • browser-wide WebMCP discovery/invocation delegation, shared frozen namespace identity, and per-execution cancellation
  • pre-armed page/session/browser CDP event waiting with predicate filtering and non-destructive timeout results
  • cancellation-aware request admission and shutdown, complete process-group reset, bounded output-item/error framing, split-UTF-8 transport decoding, owned-session release, and unknown-outcome protection for CDP mutations
  • pinned Patchright and Playwright Core imports, live CDP control, cross-cell object identity, and explicit reconnection after Chromium restart
  • tabs, dialogs, browser reconnects, uploads, screenshots, network idle, raw CDP, and event draining
  • OpenAPI generation with SSE regression checks
  • both Chromium image variants

Commands run locally after synchronizing with current main:

cd server && make test-runtime
cd server && go test ./cmd/api/api ./lib/oapi -count=1
cd server && go vet ./...
cd server && go test ./e2e -run '^$'

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

  • Public contract: server/openapi.yaml
  • API ownership and lifecycle: server/cmd/api/api/browser_repl.go
  • Persistent evaluator: server/runtime/browser-repl.ts, cell-runtime.ts, cell-analysis.ts
  • Browser/CDP surface: server/runtime/browser-cdp-client.ts, browser-helpers.ts
  • Page-function serialization: server/runtime/page-evaluation.ts
  • Keyboard fidelity: server/runtime/us-keyboard-layout.ts
  • Complete behavior reference: server/docs/repl.md
  • Agent operational guidance: server/docs/repl-agent-guidance.md

rgarcia added 25 commits August 2, 2026 13:57
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.
Comment thread server/e2e/e2e_browser_repl_test.go
Comment thread server/e2e/e2e_browser_repl_test.go
# Conflicts:
#	server/Makefile
#	server/cmd/api/api/api.go
#	server/cmd/api/main.go
#	server/lib/oapi/oapi.go
@socket-security

socket-security Bot commented Sep 4, 2026

Copy link
Copy Markdown

Review the following changes in direct dependencies. Learn more about Socket for GitHub.

Diff Package Supply Chain
Security
Vulnerability Quality Maintenance License
Addednpm/​sharp@​0.34.5927910093100
Addednpm/​playwright-core@​1.62.1100100799980
Addednpm/​patchright@​1.62.31001001009480
Addednpm/​@​types/​node@​22.15.211001008196100
Addednpm/​typescript@​5.6.31001009010090
Addednpm/​meriyah@​7.3.110010010095100

View full report

Comment thread server/runtime/browser-helpers.ts
Comment thread server/runtime/browser-helpers.ts
Comment thread server/runtime/browser-helpers.ts
Comment thread server/e2e/e2e_browser_repl_test.go Outdated

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Stale Bugbot comment from a previous run.

Comment thread server/runtime/browser-repl.ts
@hiroTamada
hiroTamada self-requested a review September 9, 2026 13:17

@hiroTamada hiroTamada left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

reviewed at 77e9275 — the persistent-runtime approach is understandable and the existing tests pass, but these reproduced correctness issues need fixes before approval.

lifecycle

  • server/cmd/api/api/browser_repl.go:495–497,567–579 — a request canceled while waiting for the mutex still dispatches code, then treats cancellation as a fatal transport error and destroys healthy REPL state. make admission cancellation-aware and check cancellation before touching the child.
  • server/cmd/api/api/browser_repl.go:232–236 — termination returns when Node exits, even if descendants ignore SIGTERM. reproduced a subprocess surviving a successful reset. ensure the remaining process group is terminated before clearing ownership.
  • server/cmd/api/api/api.go:443–448 — shutdown waits on the execution mutex without observing its deadline. an active cell can hold shutdown past the configured ten-second budget. provide independent shutdown cancellation and stop admitting new work.

runtime and protocol

  • server/runtime/cell-analysis.ts:97–98 — lowering nested var initializers to globalThis bypasses same-named catch parameters, modifying the outer variable instead. preserve lexical resolution of initializer writes; add a catch-shadowing regression test.
  • server/runtime/browser-repl.ts:62–82,370 — active collectors have no item cap, and empty writes consume no text-byte budget. 1.2 million empty writes exceeded the 48 MiB response limit and destroyed the REPL instead of truncating output. bound item count or serialized size.
  • server/runtime/browser-repl.ts:535–536 — independently decoding socket chunks corrupts split UTF-8 characters: a split é produced caf�� instead of café. use a streaming decoder or decode complete buffered lines.

browser helpers

  • server/runtime/browser-cdp-client.ts:358–372 — connectionNeverAnswered does not prove a command was not applied. dropping the acknowledgement after Target.createTarget caused a replay and two creations with a successful response. restrict retries to safe commands and surface unknown outcomes for mutations.
  • server/runtime/browser-cdp-client.ts:444–451 — attach replaces the stored session without detaching the previous one. in real Chromium, repeated switchTab calls produced three console events for one console.log. reuse or release owned target sessions.
  • server/runtime/browser-helpers.ts:841–858 — return res.text() clears the abort timer before body consumption finishes. a stalled body bypassed the helper timeout and triggered destructive cell timeout. use return await res.text().

resource question

  • the 512 MiB setting limits V8 old space, not total RSS or subprocess memory; there is no dedicated CPU quota or REPL-attributed CPU/RSS telemetry here. should this runtime have process-tree budgets and consumption metrics before rollout?

minor contract gap

  • server/openapi.yaml:1500–1510 / server/cmd/api/api/browser_repl.go:469 — document the 413 response and return the standard JSON error shape rather than plain text.

validation

all 55 existing REPL Go tests passed, along with six WebMCP tests and OpenAPI regression tests. lifecycle race subset and wrapper tests passed. targeted regression probes reproduced the findings; a local Chromium smoke test passed navigation, selector actions, Unicode input, waits, page evaluation, and screenshots. Docker image E2E was not run; the E2E package compiled. temporary probes were removed from the checkout.

@rgarcia

rgarcia commented Sep 9, 2026

Copy link
Copy Markdown
Contributor Author

Addressed the findings from the review at 77e9275 in e8b70e1:

  • Admission now observes request cancellation while waiting, rechecks cancellation before dispatch, and does not destroy healthy state for code that was never sent.
  • Shutdown stops new admissions and independently cancels an active execution before waiting on the lifecycle mutex, so its context bounds the wait.
  • Reset/termination now SIGKILLs the remaining process group even when the Node group leader exits cleanly after SIGTERM; regression coverage uses a SIGTERM-ignoring descendant.
  • Nested var initializer lowering now preserves lexical lookup through catch-parameter shadowing.
  • Execution output is capped at 10,000 items; error, stack, and MIME framing are bounded too. Empty-write and oversized-error regressions preserve the REPL.
  • Socket request decoding now uses a streaming UTF-8 decoder; a deliberately split é round-trips as café.
  • CDP reconnect replay is allowlisted to observational/idempotent setup commands. Mutations, page evaluation, and input report outcome unknown and are never replayed. The prior wheel retry/fallback paths were removed for the same exact-once invariant.
  • Same-target attachment is reused; switching targets detaches the previous owned session.
  • httpGet keeps its abort timer active through body consumption (return await res.text()), with a headers-first/stalled-body regression.
  • /repl now documents 413 and the strict-body middleware returns the standard JSON error shape.

On resources: BROWSER_REPL_HEAP_MB is now explicitly documented as a V8 old-space limit, not a total RSS/CPU/subprocess limit. /repl is intentionally the same unrestricted VM trust/resource class as /process/exec; the browser VM/container remains the total process-tree boundary. A dedicated per-REPL cgroup plus attributed CPU/RSS telemetry would be a platform-level follow-up rather than something this process-local heap flag can honestly provide.

Also added accessibilitySnapshot() and live backend-node actions in this commit; the fresh headless image E2E passes the snapshot → click/fillInput path.

@rgarcia
rgarcia requested a review from hiroTamada September 9, 2026 15:52

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes using default effort and found 2 potential issues.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit 973c293. Configure here.


waitMs = async (milliseconds = 1_000): Promise<void> => {
await new Promise((resolve) => setTimeout(resolve, milliseconds));
};

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

waitMs can destroy the REPL

Medium Severity

waitMs does not clamp to the active execution deadline, unlike the other wait helpers and CDP commands. A default waitMs() near the end of a cell, or any sleep longer than the remaining timeout_sec, races the destructive timer and can replace the REPL instead of returning a normal helper failure.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 973c293. Configure here.

Comment thread server/runtime/browser-helpers.ts

@hiroTamada hiroTamada left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

approved — the original regression checks now pass.

remaining follow-up: reset now kills descendants, but recovery after an idle Node crash still leaves them running. reproduced this on 0f71c0e: server/cmd/api/api/browser_repl.go:169–175 clears the dead child’s handle without killing its remaining process group. kill the remaining group before clearing ownership in this path too.

resource limits and dedicated CPU/RSS telemetry remain separate follow-up work.

@rgarcia

rgarcia commented Sep 9, 2026

Copy link
Copy Markdown
Contributor Author

Lifecycle simplification in 4fcd797:

  • restored server/cmd/wrapper/main.go exactly to main; the Browser REPL no longer adds a PID 1 reaper
  • removed /proc/net/unix socket-owner discovery and the associated rogue-daemon tests
  • retained Linux process groups and Pdeathsig, so normal reset, timeout, shutdown, and API death still terminate the Node daemon
  • retained the small non-Linux compile fallback
  • addressed the idle-crash follow-up: when the Node group leader has already exited, replacement now SIGKILLs its remaining process group before clearing ownership

Accepted narrow tradeoff: an abrupt API death can leave a zero-RSS zombie until the disposable VM exits, and an independently/manual-started daemon is unlinked rather than hunted through /proc. Normal owned-child lifecycle behavior is unchanged.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants