diff --git a/README.md b/README.md index 22ef9bc0..c85ccfe4 100644 --- a/README.md +++ b/README.md @@ -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: diff --git a/images/chromium-headful/Dockerfile b/images/chromium-headful/Dockerfile index 76c0338c..4e6af980 100644 --- a/images/chromium-headful/Dockerfile +++ b/images/chromium-headful/Dockerfile @@ -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 + diff --git a/images/chromium-headless/image/Dockerfile b/images/chromium-headless/image/Dockerfile index bf318064..3647b5f1 100644 --- a/images/chromium-headless/image/Dockerfile +++ b/images/chromium-headless/image/Dockerfile @@ -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" ] diff --git a/server/Makefile b/server/Makefile index 090559bb..b2bab4fb 100644 --- a/server/Makefile +++ b/server/Makefile @@ -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 @@ -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 @@ -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: diff --git a/server/README.md b/server/README.md index cc80cb41..bcf6dd9f 100644 --- a/server/README.md +++ b/server/README.md @@ -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 diff --git a/server/cmd/api/api/api.go b/server/cmd/api/api/api.go index 222ac9a5..d0136039 100644 --- a/server/cmd/api/api/api.go +++ b/server/cmd/api/api/api.go @@ -84,6 +84,8 @@ type ApiService struct { // playwrightDaemonCmd holds the daemon process for cleanup playwrightDaemonCmd *exec.Cmd + browserRepl *browserReplManager + webmcp webMCPClient // policy management @@ -169,6 +171,7 @@ func New( cdpMonitor: mon, otlpExport: otlpExport, webmcp: webmcpclient.NewManager(upstreamMgr), + browserRepl: newBrowserReplManager(), lifecycleCtx: ctx, lifecycleCancel: cancel, }, nil @@ -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() @@ -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)) } diff --git a/server/cmd/api/api/browser_repl.go b/server/cmd/api/api/browser_repl.go new file mode 100644 index 00000000..47826d91 --- /dev/null +++ b/server/cmd/api/api/browser_repl.go @@ -0,0 +1,755 @@ +package api + +import ( + "bufio" + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "log/slog" + "net" + "net/http" + "os" + "os/exec" + "strings" + "time" + + "github.com/google/uuid" + "github.com/kernel/kernel-images/server/lib/logger" + "github.com/kernel/kernel-images/server/lib/oapi" + "github.com/nrednav/cuid2" +) + +var errBrowserReplShuttingDown = errors.New("browser REPL is shutting down") + +const ( + defaultBrowserReplSocket = "/tmp/browser-repl.sock" + defaultBrowserReplScript = "/usr/local/lib/browser-repl/browser-repl.js" + defaultBrowserReplHeapMB = 512 + + // The daemon caps each newline-delimited request line at this size. + // API requests are marshaled into this wire format before they are sent. + maxBrowserReplRequestLineBytes = 8 * 1024 * 1024 + // Keep the HTTP envelope bounded before it is copied for strict decoding. + maxBrowserReplBodyBytes = maxBrowserReplRequestLineBytes + + // browserReplStartupTimeout is how long the API waits for a freshly + // spawned REPL child to begin accepting socket connections. + browserReplStartupTimeout = 15 * time.Second + + // browserReplShutdownGrace is how long the API waits for SIGTERM to stop + // the REPL process group before escalating to SIGKILL. + browserReplShutdownGrace = 3 * time.Second + + // browserReplResponseGrace is added to the execution timeout when + // setting the socket read deadline, giving the daemon a chance to answer + // interruptible executions before the API kills the process. The daemon + // reports daemon-side timeouts with timed_out: true at the requested + // timeout, so this only covers unwind and transport time. + browserReplResponseGrace = 2 * time.Second + + // browserReplMinTimeoutSec / browserReplMaxTimeoutSec bound timeout_sec + // per the OpenAPI schema (minimum 1, maximum 300, default 60). + browserReplMinTimeoutSec = 1 + browserReplMaxTimeoutSec = 300 + + // browserReplMaxResponseBytes caps a single daemon response line. The + // daemon caps image data at 16 MiB decoded (~21.3 MiB base64) plus text + // and metadata, so 48 MiB leaves ample headroom while still bounding + // memory on protocol corruption. + browserReplMaxResponseBytes = 48 << 20 +) + +// browserReplChild tracks the owned REPL child process. The API process is +// the sole owner and supervisor: it starts the child lazily, never adopts +// orphaned processes, and always reaps the child via the wait goroutine. +type browserReplChild struct { + id string + cmd *exec.Cmd + done chan error // receives the (single) cmd.Wait result +} + +// browserReplManager owns execution admission and the persistent Node child. +// Lifecycle synchronization stays behind its Execute and Shutdown methods. +type browserReplManager struct { + admission chan struct{} + lifecycle context.Context + stop context.CancelCauseFunc + child *browserReplChild // guarded by admission +} + +func newBrowserReplManager() *browserReplManager { + lifecycle, stop := context.WithCancelCause(context.Background()) + admission := make(chan struct{}, 1) + admission <- struct{}{} + return &browserReplManager{ + admission: admission, + lifecycle: lifecycle, + stop: stop, + } +} + +// browserReplSocketPath returns the Unix socket path for the REPL daemon. +// Overridable for tests. +func browserReplSocketPath() string { + if p := os.Getenv("BROWSER_REPL_SOCKET"); p != "" { + return p + } + return defaultBrowserReplSocket +} + +// browserReplScriptPath returns the path to the bundled REPL daemon script. +// Overridable for tests. +func browserReplScriptPath() string { + if p := os.Getenv("BROWSER_REPL_SCRIPT"); p != "" { + return p + } + return defaultBrowserReplScript +} + +func browserReplHeapMB() string { + if v := os.Getenv("BROWSER_REPL_HEAP_MB"); v != "" { + return v + } + return fmt.Sprint(defaultBrowserReplHeapMB) +} + +func (m *browserReplManager) acquire(ctx context.Context) error { + select { + case <-ctx.Done(): + return ctx.Err() + case <-m.lifecycle.Done(): + return errBrowserReplShuttingDown + case <-m.admission: + } + if err := ctx.Err(); err != nil { + m.release() + return err + } + if m.lifecycle.Err() != nil { + m.release() + return errBrowserReplShuttingDown + } + return nil +} + +func (m *browserReplManager) acquireForShutdown(ctx context.Context) error { + select { + case <-ctx.Done(): + return ctx.Err() + case <-m.admission: + return nil + } +} + +func (m *browserReplManager) release() { + m.admission <- struct{}{} +} + +func (m *browserReplManager) operationContext(ctx context.Context) (context.Context, context.CancelCauseFunc, func() bool) { + operationCtx, cancel := context.WithCancelCause(ctx) + stopPropagation := context.AfterFunc(m.lifecycle, func() { + cancel(context.Cause(m.lifecycle)) + }) + if cause := context.Cause(m.lifecycle); cause != nil { + cancel(cause) + } + return operationCtx, cancel, stopPropagation +} + +func (m *browserReplManager) Shutdown(ctx context.Context) error { + m.stop(errBrowserReplShuttingDown) + if err := m.acquireForShutdown(ctx); err != nil { + return err + } + defer m.release() + return m.terminateLocked(ctx, "api shutdown") +} + +// ensureLocked starts the REPL child if none is running. If the previous +// child died unexpectedly it is cleared and replaced with a fresh REPL and +// fresh CUID2. The caller must hold admission. +func (m *browserReplManager) ensureLocked(ctx context.Context) error { + log := logger.FromContext(ctx) + + if child := m.child; child != nil { + select { + case err := <-child.done: + // The wait goroutine already reaped the child; do not consume the + // result here. Replace the channel so the value remains observable. + log.Warn("browser REPL child exited unexpectedly; starting a fresh REPL", + "repl_id", child.id, "exit_err", err) + child.done = closedWaitChannel(err) + // The group leader exited, but descendants may still be alive. + _ = signalBrowserReplGroup(child.cmd, killSignal) + m.clearLocked(ctx, child) + default: + return nil + } + } + + return m.startLocked(ctx) +} + +// closedWaitChannel returns a channel that has already received (and closed +// over) the given wait result. +func closedWaitChannel(err error) chan error { + ch := make(chan error, 1) + ch <- err + return ch +} + +// clearLocked detaches the child handle and removes its stale socket. The +// caller must hold admission. +func (m *browserReplManager) clearLocked(ctx context.Context, child *browserReplChild) { + if m.child == child { + m.child = nil + } + removeBrowserReplSocket(logger.FromContext(ctx), browserReplSocketPath()) +} + +func removeBrowserReplSocket(log *slog.Logger, socketPath string) { + if err := os.Remove(socketPath); err != nil && !errors.Is(err, os.ErrNotExist) { + log.Warn("failed to remove stale browser REPL socket", "path", socketPath, "err", err) + } +} + +// startLocked spawns a new REPL child with a fresh CUID2 and waits for its +// socket to accept connections. The caller must hold admission. +func (m *browserReplManager) startLocked(ctx context.Context) error { + log := logger.FromContext(ctx) + socketPath := browserReplSocketPath() + + // Never adopt state from a previous process. Unlink any stale socket; + // Linux parent-death signaling handles daemons spawned by this API. + removeBrowserReplSocket(log, socketPath) + + replID := cuid2.Generate() + + cmd := exec.Command("node", "--experimental-vm-modules", "--max-old-space-size="+browserReplHeapMB(), browserReplScriptPath()) + cmd.Stdout = os.Stderr // protocol lives on the socket; child diagnostics only + cmd.Stderr = os.Stderr + cmd.Env = append(os.Environ(), + "BROWSER_REPL_SOCKET="+socketPath, + "BROWSER_REPL_ID="+replID, + ) + configureBrowserReplCmd(cmd) + + log.Info("starting browser REPL", "repl_id", replID, "socket", socketPath) + if err := cmd.Start(); err != nil { + return fmt.Errorf("failed to start browser REPL: %w", err) + } + + child := &browserReplChild{id: replID, cmd: cmd, done: make(chan error, 1)} + go func() { + child.done <- cmd.Wait() + }() + m.child = child + + deadline := time.Now().Add(browserReplStartupTimeout) + for { + conn, err := net.DialTimeout("unix", socketPath, 200*time.Millisecond) + if err == nil { + conn.Close() + log.Info("browser REPL ready", "repl_id", replID) + return nil + } + select { + case waitErr := <-child.done: + child.done = closedWaitChannel(waitErr) + _ = signalBrowserReplGroup(child.cmd, killSignal) + m.clearLocked(ctx, child) + return fmt.Errorf("browser REPL exited during startup: %w", waitErr) + case <-ctx.Done(): + m.killLocked(context.WithoutCancel(ctx), "startup cancelled") + return context.Cause(ctx) + default: + } + if time.Now().After(deadline) { + m.terminateLocked(ctx, "startup timeout") + return fmt.Errorf("browser REPL failed to start within %v", browserReplStartupTimeout) + } + time.Sleep(50 * time.Millisecond) + } +} + +// terminateLocked stops the REPL child's process group (SIGTERM, +// escalating to SIGKILL), waits for exit, removes the socket, and clears the +// in-memory handle. The next request lazily starts a fresh REPL with a new +// CUID2. Returns the child's exit error when observed (nil for a clean exit +// or when the exit could not be observed within the grace period). The caller +// must hold admission. +func (m *browserReplManager) terminateLocked(ctx context.Context, reason string) error { + child := m.child + if child == nil { + return nil + } + log := logger.FromContext(ctx) + log.Info("terminating browser REPL", "repl_id", child.id, "reason", reason) + + // SIGTERM the whole process group so any grandchildren go down too. + _ = signalBrowserReplGroup(child.cmd, termSignal) + + select { + case err := <-child.done: + child.done = closedWaitChannel(err) + // The group leader exiting does not imply descendants honored SIGTERM. + // Kill the process group before relinquishing ownership. + _ = signalBrowserReplGroup(child.cmd, killSignal) + m.clearLocked(ctx, child) + return err + case <-time.After(browserReplShutdownGrace): + } + + log.Warn("browser REPL did not exit on SIGTERM; escalating to SIGKILL", "repl_id", child.id) + _ = signalBrowserReplGroup(child.cmd, killSignal) + + var waitErr error + select { + case err := <-child.done: + child.done = closedWaitChannel(err) + waitErr = err + case <-time.After(browserReplShutdownGrace): + log.Error("browser REPL did not exit after SIGKILL", "repl_id", child.id) + } + // Re-signal after the leader is reaped: descendants remain members of the + // original process group even if the leader exited first. + _ = signalBrowserReplGroup(child.cmd, killSignal) + m.clearLocked(ctx, child) + return waitErr +} + +// killLocked SIGKILLs the REPL process group without a SIGTERM +// grace period. Use when the daemon's event loop is known to be blocked +// (e.g. an uninterruptible execution that never answered before the socket +// read deadline): a graceful signal could never be handled and would only +// add browserReplShutdownGrace of dead time to every such timeout. The caller +// must hold admission. +func (m *browserReplManager) killLocked(ctx context.Context, reason string) { + child := m.child + if child == nil { + return + } + log := logger.FromContext(ctx) + log.Info("killing browser REPL", "repl_id", child.id, "reason", reason) + _ = signalBrowserReplGroup(child.cmd, killSignal) + + select { + case err := <-child.done: + child.done = closedWaitChannel(err) + case <-time.After(browserReplShutdownGrace): + log.Error("browser REPL did not exit after SIGKILL", "repl_id", child.id) + } + _ = signalBrowserReplGroup(child.cmd, killSignal) + m.clearLocked(ctx, child) +} + +// browserReplDaemonRequest is the wire format sent to the REPL daemon. +type browserReplDaemonRequest struct { + ID string `json:"id"` + Code string `json:"code"` + TimeoutMs int `json:"timeout_ms,omitempty"` +} + +// browserReplDaemonResponse is the wire format returned by the REPL daemon. +type browserReplDaemonResponse struct { + ID string `json:"id"` + ReplID string `json:"repl_id"` + Success bool `json:"success"` + Error string `json:"error,omitempty"` + Stack *string `json:"stack,omitempty"` + Content []json.RawMessage `json:"content,omitempty"` + ContentTruncated bool `json:"content_truncated"` + // TimedOut marks a daemon-side execution timeout. The daemon cannot + // interrupt the abandoned execution, so the API must kill the child + // before serving another request (destructive timeout semantics). + TimedOut bool `json:"timed_out,omitempty"` + // Exiting marks a deterministic daemon shutdown after an uncaught + // exception: the daemon answered the in-flight execution with the + // exception details and is exiting non-zero. The API treats it like a + // timeout — terminate the handle and report repl_terminated — so the + // state loss is explicit to the caller. + Exiting bool `json:"exiting,omitempty"` + DurationMs int `json:"duration_ms"` +} + +// browserReplRequest is the already-encoded request sent over the daemon +// socket. Preparing it before touching the child makes the wire-size check a +// non-destructive API validation rather than a protocol failure after dialing. +type browserReplRequest struct { + id string + bytes []byte +} + +// prepareBrowserReplRequest encodes the daemon request with HTML escaping +// disabled. The daemon's limit applies to the line without its trailing +// newline, so the encoded request must fit before it is sent. +func prepareBrowserReplRequest(code string, timeout time.Duration) (*browserReplRequest, error) { + id := uuid.New().String() + var buf bytes.Buffer + encoder := json.NewEncoder(&buf) + encoder.SetEscapeHTML(false) + if err := encoder.Encode(browserReplDaemonRequest{ + ID: id, + Code: code, + TimeoutMs: int(timeout.Milliseconds()), + }); err != nil { + return nil, fmt.Errorf("failed to marshal request: %w", err) + } + if requestLineBytes := buf.Len() - 1; requestLineBytes > maxBrowserReplRequestLineBytes { + return nil, fmt.Errorf("code too large: encoded request is %d bytes, maximum is %d", requestLineBytes, maxBrowserReplRequestLineBytes) + } + return &browserReplRequest{id: id, bytes: buf.Bytes()}, nil +} + +type browserReplNotDispatchedError struct { + cause error +} + +func (e *browserReplNotDispatchedError) Error() string { return e.cause.Error() } +func (e *browserReplNotDispatchedError) Unwrap() error { return e.cause } + +// executeLocked sends one prepared execution to the current child and reads +// its response. The returned error is a transport/protocol failure; execution +// failures are reported inside the response. The caller must hold admission. +func (m *browserReplManager) executeLocked(ctx context.Context, request *browserReplRequest, timeout time.Duration) (*browserReplDaemonResponse, error) { + if err := context.Cause(ctx); err != nil { + return nil, &browserReplNotDispatchedError{cause: err} + } + child := m.child + if child == nil { + return nil, errors.New("no browser REPL child") + } + + conn, err := net.DialTimeout("unix", browserReplSocketPath(), 2*time.Second) + if err != nil { + return nil, fmt.Errorf("failed to connect to browser REPL: %w", err) + } + defer conn.Close() + + deadline := time.Now().Add(timeout + browserReplResponseGrace) + if ctxDeadline, ok := ctx.Deadline(); ok && ctxDeadline.Before(deadline) { + // Leave enough room to return a structured response if possible. + deadline = ctxDeadline + } + if err := conn.SetDeadline(deadline); err != nil { + return nil, fmt.Errorf("failed to set deadline: %w", err) + } + + if err := context.Cause(ctx); err != nil { + return nil, &browserReplNotDispatchedError{cause: err} + } + if _, err := conn.Write(request.bytes); err != nil { + return nil, fmt.Errorf("failed to send request: %w", err) + } + + // Read in a goroutine so context cancellation can abandon the read; the + // connection is closed on return which unblocks the goroutine. + type readResult struct { + line []byte + err error + } + readCh := make(chan readResult, 1) + go func() { + reader := bufio.NewReader(io.LimitReader(conn, browserReplMaxResponseBytes+1)) + line, err := reader.ReadBytes('\n') + readCh <- readResult{line: line, err: err} + }() + + var line []byte + select { + case <-ctx.Done(): + return nil, fmt.Errorf("request context cancelled: %w", ctx.Err()) + case res := <-readCh: + if res.err != nil { + if len(res.line) > browserReplMaxResponseBytes { + return nil, errors.New("browser REPL response exceeds maximum size") + } + if errors.Is(res.err, os.ErrDeadlineExceeded) || isTimeoutErr(res.err) { + return nil, &browserReplTimeoutError{timeout: timeout} + } + return nil, fmt.Errorf("failed to read response: %w", res.err) + } + line = res.line + } + + if len(line) > browserReplMaxResponseBytes { + return nil, errors.New("browser REPL response exceeds maximum size") + } + + var resp browserReplDaemonResponse + if err := json.Unmarshal(line, &resp); err != nil { + return nil, fmt.Errorf("failed to parse response: %w", err) + } + + if resp.ID != request.id { + return nil, fmt.Errorf("response ID mismatch: expected %s, got %s", request.id, resp.ID) + } + if resp.ReplID != child.id { + return nil, fmt.Errorf("response repl_id mismatch: expected %s, got %s", child.id, resp.ReplID) + } + + return &resp, nil +} + +// browserReplTimeoutError reports an execution that never answered before +// the API's socket read deadline (an uninterruptible execution, e.g. +// `while (true) {}`). The message matches the daemon's own timeout wording +// so both timeout paths read identically to the caller. +type browserReplTimeoutError struct { + timeout time.Duration +} + +func (e *browserReplTimeoutError) Error() string { + return fmt.Sprintf("execution timed out after %dms", e.timeout.Milliseconds()) +} + +func isTimeoutErr(err error) bool { + var netErr net.Error + return errors.As(err, &netErr) && netErr.Timeout() +} + +// browserReplTerminatedResponse builds the 200 response for a request that +// destroyed the REPL (timeout, crash, or protocol corruption). It populates +// the same optional fields as other failure paths (duration_ms and the +// content truncation flag) so clients can read them unconditionally; partial +// content is never available here because the child died without answering. +func browserReplTerminatedResponse(replID string, err error, durationMs int) oapi.ExecuteBrowserRepl200JSONResponse { + errMsg := err.Error() + terminated := true + notTruncated := false + return oapi.ExecuteBrowserRepl200JSONResponse{ + Success: false, + ReplId: replID, + Error: &errMsg, + ReplTerminated: &terminated, + DurationMs: &durationMs, + ContentTruncated: ¬Truncated, + } +} + +// StrictBrowserReplBodyMiddleware enforces additionalProperties: false on +// POST /repl. The generated strict-server decoder silently drops +// unknown fields, so without this middleware a request like +// {"code":"1","bogus":1} would be accepted despite the published schema. +// Malformed JSON and type errors are left to the strict handler's own 400 +// handling; only unknown fields are policed here. +func StrictBrowserReplBodyMiddleware(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost || r.URL.Path != "/repl" || r.Body == nil { + next.ServeHTTP(w, r) + return + } + limitedBody := http.MaxBytesReader(w, r.Body, maxBrowserReplBodyBytes) + body, err := io.ReadAll(limitedBody) + _ = r.Body.Close() + if err != nil { + var tooLarge *http.MaxBytesError + if errors.As(err, &tooLarge) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusRequestEntityTooLarge) + _ = json.NewEncoder(w).Encode(oapi.BadRequestError{ + Message: fmt.Sprintf("request body exceeds %d bytes", maxBrowserReplBodyBytes), + }) + return + } + http.Error(w, "failed to read request body", http.StatusBadRequest) + return + } + r.Body = io.NopCloser(bytes.NewReader(body)) + + dec := json.NewDecoder(bytes.NewReader(body)) + dec.DisallowUnknownFields() + var probe oapi.BrowserReplRequest + if err := dec.Decode(&probe); err != nil && strings.HasPrefix(err.Error(), "json: unknown field") { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusBadRequest) + _ = json.NewEncoder(w).Encode(oapi.BadRequestError{ + Message: fmt.Sprintf("invalid request body: %s", err.Error()), + }) + return + } + next.ServeHTTP(w, r) + }) +} + +// ExecuteBrowserRepl implements POST /repl through the REPL subsystem. +func (s *ApiService) ExecuteBrowserRepl(ctx context.Context, request oapi.ExecuteBrowserReplRequestObject) (oapi.ExecuteBrowserReplResponseObject, error) { + return s.browserRepl.Execute(ctx, request) +} + +func (m *browserReplManager) Execute(ctx context.Context, request oapi.ExecuteBrowserReplRequestObject) (oapi.ExecuteBrowserReplResponseObject, error) { + if err := m.acquire(ctx); err != nil { + return nil, err + } + defer m.release() + + operationCtx, cancelOperation, stopPropagation := m.operationContext(ctx) + defer func() { + stopPropagation() + cancelOperation(nil) + }() + if err := context.Cause(operationCtx); err != nil { + return nil, err + } + ctx = operationCtx + log := logger.FromContext(ctx) + + if request.Body == nil { + return oapi.ExecuteBrowserRepl400JSONResponse{ + BadRequestErrorJSONResponse: oapi.BadRequestErrorJSONResponse{ + Message: "request body is required", + }, + }, nil + } + + reset := request.Body.Reset != nil && *request.Body.Reset + code := request.Body.Code + if code == "" && !reset { + return oapi.ExecuteBrowserRepl400JSONResponse{ + BadRequestErrorJSONResponse: oapi.BadRequestErrorJSONResponse{ + Message: "code is required (it may be empty only when reset is true)", + }, + }, nil + } + + timeout := 60 * time.Second + if request.Body.TimeoutSec != nil { + if *request.Body.TimeoutSec < browserReplMinTimeoutSec || *request.Body.TimeoutSec > browserReplMaxTimeoutSec { + return oapi.ExecuteBrowserRepl400JSONResponse{ + BadRequestErrorJSONResponse: oapi.BadRequestErrorJSONResponse{ + Message: fmt.Sprintf("timeout_sec must be between %d and %d", browserReplMinTimeoutSec, browserReplMaxTimeoutSec), + }, + }, nil + } + timeout = time.Duration(*request.Body.TimeoutSec) * time.Second + } + + var preparedRequest *browserReplRequest + if code != "" { + var err error + preparedRequest, err = prepareBrowserReplRequest(code, timeout) + if err != nil { + return oapi.ExecuteBrowserRepl400JSONResponse{ + BadRequestErrorJSONResponse: oapi.BadRequestErrorJSONResponse{ + Message: err.Error(), + }, + }, nil + } + } + + if err := context.Cause(ctx); err != nil { + return nil, err + } + if reset { + m.terminateLocked(ctx, "explicit reset") + } + + if err := m.ensureLocked(ctx); err != nil { + log.Error("failed to start browser REPL", "error", err) + return oapi.ExecuteBrowserRepl500JSONResponse{ + InternalErrorJSONResponse: oapi.InternalErrorJSONResponse{ + Message: fmt.Sprintf("failed to start browser REPL: %v", err), + }, + }, nil + } + + replID := m.child.id + + // Reset with no code: just start a fresh REPL. + if code == "" { + return oapi.ExecuteBrowserRepl200JSONResponse{ + Success: true, + ReplId: replID, + }, nil + } + + execStart := time.Now() + resp, err := m.executeLocked(ctx, preparedRequest, timeout) + if err != nil { + var notDispatched *browserReplNotDispatchedError + if errors.As(err, ¬Dispatched) { + return nil, notDispatched.cause + } + // Any transport or protocol failure is fatal to the child: kill the + // process group, wait for exit, remove the stale socket, and clear the + // handle. The next request lazily starts a fresh REPL with a new ID. + log.Error("browser REPL execution failed; terminating child", "repl_id", replID, "error", err) + var timeoutErr *browserReplTimeoutError + if errors.As(err, &timeoutErr) { + // The daemon never answered, so its event loop is blocked and a + // graceful SIGTERM could never be handled; kill immediately. + m.killLocked(ctx, "execution timeout") + } else if waitErr := m.terminateLocked(ctx, "execution failure"); waitErr != nil { + // Surface the child's exit reason (e.g. SIGKILL from the OOM + // killer near the heap cap) instead of a bare transport error. + err = fmt.Errorf("browser REPL process terminated during execution (%v): %w", waitErr, err) + } + return browserReplTerminatedResponse(replID, err, int(time.Since(execStart).Milliseconds())), nil + } + + mapped, err := browserReplMapResponse(resp) + if err != nil { + // A response that does not decode into the public schema is protocol + // corruption; do not risk state from this child. + log.Error("browser REPL returned an undecodable response; terminating child", "repl_id", replID, "error", err) + m.terminateLocked(ctx, "protocol corruption") + return browserReplTerminatedResponse(replID, err, int(time.Since(execStart).Milliseconds())), nil + } + + if resp.TimedOut || resp.Exiting { + if resp.Exiting { + // The daemon hit an uncaught exception, answered this execution + // with the exception details, and is exiting non-zero (resuming + // after an uncaught exception is unsafe per Node semantics). + // Reap the child and report repl_terminated so the state loss is + // explicit; the next request lazily starts a fresh REPL. + log.Warn("browser REPL reported an uncaught exception and is exiting; terminating child", "repl_id", replID) + m.terminateLocked(ctx, "uncaught exception in REPL process") + } else { + // A timeout is destructive: the daemon only abandoned the + // execution, so its code is still running inside the child. Kill + // the process group, wait for exit, and clear the handle; the next + // request lazily starts a fresh REPL with a new CUID2. The + // response carries the terminated ID, repl_terminated: true, and + // the partial content the execution produced before the deadline. + log.Warn("browser REPL execution timed out; terminating child", "repl_id", replID) + m.terminateLocked(ctx, "execution timeout") + } + terminated := true + mapped.ReplTerminated = &terminated + } + return mapped, nil +} + +// browserReplMapResponse converts a daemon response into the public API +// shape, decoding typed content items through the generated union. +func browserReplMapResponse(resp *browserReplDaemonResponse) (oapi.ExecuteBrowserRepl200JSONResponse, error) { + out := oapi.ExecuteBrowserRepl200JSONResponse{ + Success: resp.Success, + ReplId: resp.ReplID, + Stack: resp.Stack, + ContentTruncated: &resp.ContentTruncated, + DurationMs: &resp.DurationMs, + } + + if resp.Error != "" { + out.Error = &resp.Error + } + + if resp.Content != nil { + content := make([]oapi.BrowserReplContent, 0, len(resp.Content)) + for i, raw := range resp.Content { + var item oapi.BrowserReplContent + if err := json.Unmarshal(raw, &item); err != nil { + return out, fmt.Errorf("failed to decode content item %d: %w", i, err) + } + content = append(content, item) + } + out.Content = &content + } + + return out, nil +} diff --git a/server/cmd/api/api/browser_repl_cells_test.go b/server/cmd/api/api/browser_repl_cells_test.go new file mode 100644 index 00000000..e978aae4 --- /dev/null +++ b/server/cmd/api/api/browser_repl_cells_test.go @@ -0,0 +1,400 @@ +package api + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/kernel/kernel-images/server/lib/oapi" + "github.com/stretchr/testify/require" +) + +func TestBrowserReplPersistentClosureIdentity(t *testing.T) { + svc := newBrowserReplSvc(t) + requireExec(t, svc, `let closureCount = 0; function incrementClosureCount() { return ++closureCount; }`, nil) + requireExec(t, svc, `repl.write(JSON.stringify(incrementClosureCount()))`, float64(1)) + requireExec(t, svc, `repl.write(JSON.stringify(incrementClosureCount()))`, float64(2)) + requireExec(t, svc, `closureCount = 10; repl.write(JSON.stringify(closureCount))`, float64(10)) + requireExec(t, svc, `repl.write(JSON.stringify(incrementClosureCount()))`, float64(11)) + requireExec(t, svc, `setTimeout(() => { closureCount += 5; }, 1)`, nil) + requireExec(t, svc, `await new Promise(resolve => setTimeout(resolve, 10)); repl.write(JSON.stringify(closureCount))`, float64(16)) +} + +func TestBrowserReplCanPersistPatchrightAndPlaywrightCoreImports(t *testing.T) { + svc := newBrowserReplSvc(t) + requireExec(t, svc, `var playwright = await import("patchright"); var playwrightReference = playwright; var vanillaPlaywright = await import("playwright-core")`, nil) + requireExec(t, svc, `repl.write(JSON.stringify({ same: playwright === playwrightReference, patchrightConnect: typeof playwright.chromium.connectOverCDP, playwrightConnect: typeof vanillaPlaywright.chromium.connectOverCDP, endpoint: process.env.CDP_ENDPOINT }))`, map[string]interface{}{ + "same": true, + "patchrightConnect": "function", + "playwrightConnect": "function", + "endpoint": "ws://127.0.0.1:9222", + }) +} + +func TestBrowserReplFunctionDeclarationsUsePersistentAccessor(t *testing.T) { + svc := newBrowserReplSvc(t) + requireExec(t, svc, `function replFunctionValue() { return 1; } function replFunctionClosure() { return replFunctionValue(); } replFunctionValue = () => 3; repl.write(JSON.stringify(replFunctionClosure()))`, float64(3)) + requireExec(t, svc, `function replFunctionValue() { return 2; }`, nil) + requireExec(t, svc, `repl.write(JSON.stringify(replFunctionClosure()))`, float64(2)) + requireExec(t, svc, `function replDuplicate() { return 1; } function replDuplicate() { return 2; } repl.write(JSON.stringify(replDuplicate()))`, float64(2)) + requireExec(t, svc, `function replFunctionNameProbe() {} repl.write(JSON.stringify(replFunctionNameProbe.name))`, "replFunctionNameProbe") +} + +func TestBrowserReplBracelessVarPreservesControlFlow(t *testing.T) { + svc := newBrowserReplSvc(t) + for _, test := range []struct { + code string + want any + }{ + {`if (false) var bracelessIfX = 1, bracelessIfY = 2; repl.write(JSON.stringify(typeof bracelessIfY))`, "undefined"}, + {`do var bracelessDoX = 1, bracelessDoY = 2; while (false); repl.write(JSON.stringify(bracelessDoX + bracelessDoY))`, float64(3)}, + {`for (const bracelessForElement of [1, 2]) var bracelessForX = bracelessForElement, bracelessForY = bracelessForElement * 2; repl.write(JSON.stringify(bracelessForY))`, float64(4)}, + {`var bracelessCommentX = 1 /* comma, stays */, bracelessCommentY = 2; repl.write(JSON.stringify(bracelessCommentX + bracelessCommentY))`, float64(3)}, + {`var replVarLog = []; if (false) var replIfNoInit; replVarLog.push('ran'); repl.write(JSON.stringify(replVarLog))`, []interface{}{"ran"}}, + {`do var replDoNoInit; while (false); repl.write(JSON.stringify(typeof replDoNoInit))`, "undefined"}, + {`for (let replForIndex = 0; replForIndex < 1; replForIndex++) var replForNoInit; repl.write(JSON.stringify(typeof replForNoInit))`, "undefined"}, + {`for (const replForOfIndex of [1]) var replForOfNoInit; repl.write(JSON.stringify(typeof replForOfNoInit))`, "undefined"}, + } { + requireExec(t, svc, test.code, test.want) + } +} + +func TestBrowserReplStrayOutputBufferResetsAndPropagatesTruncation(t *testing.T) { + svc := newBrowserReplSvc(t) + const size = 256 * 1024 + requireExec(t, svc, fmt.Sprintf(`setTimeout(() => { repl.write("a".repeat(%d)); repl.write("dropped"); }, 10)`, size), nil) + time.Sleep(50 * time.Millisecond) + + r := requireExec(t, svc, `void 0`, nil) + require.True(t, *r.ContentTruncated) + require.Len(t, *r.Content, 1) + content, err := (*r.Content)[0].AsBrowserReplTextContent() + require.NoError(t, err) + require.Len(t, content.Text, size) + + requireExec(t, svc, fmt.Sprintf(`setTimeout(() => repl.write("b".repeat(%d)), 10)`, size), nil) + time.Sleep(50 * time.Millisecond) + r = requireExec(t, svc, `void 0`, nil) + require.False(t, *r.ContentTruncated) + require.Len(t, *r.Content, 1) + content, err = (*r.Content)[0].AsBrowserReplTextContent() + require.NoError(t, err) + require.Len(t, content.Text, size) +} + +func TestBrowserReplStrayItemLimitsPropagateTruncation(t *testing.T) { + t.Run("text", func(t *testing.T) { + svc := newBrowserReplSvc(t) + requireExec(t, svc, `setTimeout(() => { for (let i = 0; i < 1500; i++) repl.write("s" + i); }, 10)`, nil) + time.Sleep(50 * time.Millisecond) + r := requireExec(t, svc, `void 0`, nil) + require.True(t, *r.ContentTruncated) + require.Len(t, *r.Content, 1000) + first, err := (*r.Content)[0].AsBrowserReplTextContent() + require.NoError(t, err) + require.Equal(t, "s500", first.Text) + }) + + t.Run("images", func(t *testing.T) { + svc := newBrowserReplSvc(t) + requireExec(t, svc, `const png = Buffer.from([137, 80, 78, 71, 0, 0, 0, 0, 0]); setTimeout(async () => { for (let i = 0; i < 1500; i++) await repl.emitImage(png); }, 10)`, nil) + time.Sleep(50 * time.Millisecond) + r := requireExec(t, svc, `void 0`, nil) + require.True(t, *r.ContentTruncated) + require.Len(t, *r.Content, 1000) + for _, item := range *r.Content { + image, err := item.AsBrowserReplImageContent() + require.NoError(t, err) + require.Equal(t, oapi.BrowserReplImageContentType("image"), image.Type) + } + }) +} + +func TestBrowserReplActiveItemLimitTruncatesEmptyWrites(t *testing.T) { + svc := newBrowserReplSvc(t) + r := executeBrowserRepl(t, svc, &oapi.ExecuteBrowserReplJSONRequestBody{ + Code: `for (let i = 0; i < 20000; i++) repl.write("")`, + }) + require.True(t, r.Success, "error: %v", r.Error) + require.True(t, *r.ContentTruncated) + require.Len(t, *r.Content, 10_000) + + largeError := executeBrowserRepl(t, svc, &oapi.ExecuteBrowserReplJSONRequestBody{ + Code: `throw new Error("x".repeat(1024 * 1024))`, + }) + require.False(t, largeError.Success) + require.LessOrEqual(t, len(*largeError.Error), 64*1024) + + stillAlive := executeBrowserRepl(t, svc, &oapi.ExecuteBrowserReplJSONRequestBody{Code: `repl.write("alive")`}) + require.True(t, stillAlive.Success, "bounded output must not destroy the REPL: %v", stillAlive.Error) + require.Equal(t, r.ReplId, stillAlive.ReplId) +} + +func TestBrowserReplNestedVarBindingsPersist(t *testing.T) { + svc := newBrowserReplSvc(t) + for _, test := range []struct { + declaration string + name string + value float64 + }{ + {`for (var browserReplForVar = 0; browserReplForVar < 3; browserReplForVar++) {}`, "browserReplForVar", 3}, + {`for (var browserReplForOfVar of [1, 2, 3]) {}`, "browserReplForOfVar", 3}, + {`{ var browserReplBlockVar = 7; }`, "browserReplBlockVar", 7}, + {`if (true) var browserReplIfVar = 9;`, "browserReplIfVar", 9}, + {`switch (1) { case 1: var browserReplSwitchVar = 11; }`, "browserReplSwitchVar", 11}, + {`try { throw new Error("expected"); } catch (error) { var browserReplCatchVar = 13; }`, "browserReplCatchVar", 13}, + } { + requireExec(t, svc, test.declaration, nil) + requireExec(t, svc, `repl.write(JSON.stringify(`+test.name+`))`, test.value) + } +} + +func TestBrowserReplCatchParameterShadowsNestedVarInitializer(t *testing.T) { + svc := newBrowserReplSvc(t) + requireExec(t, svc, `var catchShadow = "outer"`, nil) + requireExec(t, svc, `try { throw "caught" } catch (catchShadow) { var catchShadow = "inner"; repl.write(JSON.stringify(catchShadow)) }`, "inner") + requireExec(t, svc, `repl.write(JSON.stringify(catchShadow))`, "outer") +} + +func TestBrowserReplPartialDeclaratorInitialization(t *testing.T) { + svc := newBrowserReplSvc(t) + requireExecError(t, svc, `let partialDeclaratorA = 17, partialDeclaratorB = (() => { throw new Error("boom"); })();`, "boom") + requireExec(t, svc, `repl.write(JSON.stringify(partialDeclaratorA))`, float64(17)) +} + +func TestBrowserReplErrorStackUsesCellLines(t *testing.T) { + t.Run("after multiline function", func(t *testing.T) { + svc := newBrowserReplSvc(t) + r := executeBrowserRepl(t, svc, &oapi.ExecuteBrowserReplJSONRequestBody{Code: "function stackLineHelper() {\n return 1;\n}\nconst stackLineValue = stackLineHelper();\nthrow new Error(\"line probe\");"}) + require.False(t, r.Success) + require.NotNil(t, r.Stack) + require.Contains(t, *r.Stack, ".mjs:5:", *r.Stack) + }) + + t.Run("inside multiline function", func(t *testing.T) { + svc := newBrowserReplSvc(t) + r := executeBrowserRepl(t, svc, &oapi.ExecuteBrowserReplJSONRequestBody{Code: "function stackLineHelper() {\n throw new Error(\"line probe\");\n}\nstackLineHelper();"}) + require.False(t, r.Success) + require.NotNil(t, r.Stack) + require.Contains(t, *r.Stack, ".mjs:2:", *r.Stack) + }) + + t.Run("inside second multiline declarator", func(t *testing.T) { + svc := newBrowserReplSvc(t) + r := executeBrowserRepl(t, svc, &oapi.ExecuteBrowserReplJSONRequestBody{Code: "let stackDeclaratorA = 1,\n stackDeclaratorB = (() => { throw new Error(\"line2boom\") })();"}) + require.False(t, r.Success) + require.NotNil(t, r.Stack) + require.Contains(t, *r.Stack, ".mjs:2:") + }) + + svc := newBrowserReplSvc(t) + r := executeBrowserRepl(t, svc, &oapi.ExecuteBrowserReplJSONRequestBody{Code: "let stackLineProbe = 1;\nthrow new Error(\"line probe\");"}) + require.False(t, r.Success) + require.NotNil(t, r.Stack) + require.True(t, strings.Contains(*r.Stack, "browser-repl-cell-") && strings.Contains(*r.Stack, ".mjs:2:"), *r.Stack) +} + +func TestBrowserReplObjectRestDestructuring(t *testing.T) { + tests := []struct { + name string + code string + want interface{} + }{ + { + name: "var", + code: `var { a, ...rest } = { a: 1, b: 2, c: 3 }; repl.write(JSON.stringify(a + rest.b + rest.c))`, + want: float64(6), + }, + { + name: "let", + code: `let { a, ...rest } = { a: 1, b: 2 }; repl.write(JSON.stringify(a + rest.b))`, + want: float64(3), + }, + { + name: "const", + code: `const { a, ...rest } = { a: 1, b: 2 }; repl.write(JSON.stringify(a + rest.b))`, + want: float64(3), + }, + { + name: "var for-of head", + code: `for (var { a, ...rest } of [{ a: 4, b: 5 }]) {} repl.write(JSON.stringify(a + rest.b))`, + want: float64(9), + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + svc := newBrowserReplSvc(t) + requireExec(t, svc, test.code, test.want) + }) + } +} + +func TestBrowserReplConstLetSemantics(t *testing.T) { + svc := newBrowserReplSvc(t) + await_ := "await new Promise(r => setTimeout(r, 1)); " + + requireExec(t, svc, `const qaConst = 1; `+await_+`repl.write(JSON.stringify('declared'))`, "declared") + requireExecError(t, svc, `const qaConst = 2; `+await_+`repl.write(JSON.stringify(qaConst))`, "Identifier 'qaConst' has already been declared") + requireExecError(t, svc, `qaConst = 99; `+await_+`repl.write(JSON.stringify(qaConst))`, "Assignment to constant variable.") + requireExecError(t, svc, `qaConst = 99`, "Assignment to constant variable.") + requireExec(t, svc, `repl.write(JSON.stringify(qaConst))`, float64(1)) + + requireExec(t, svc, `let qaLet = 10; `+await_+`repl.write(JSON.stringify(qaLet))`, float64(10)) + requireExecError(t, svc, `let qaLet = 11; `+await_+`repl.write(JSON.stringify(qaLet))`, "Identifier 'qaLet' has already been declared") + requireExec(t, svc, `qaLet = 42; `+await_+`repl.write(JSON.stringify(qaLet))`, float64(42)) + requireExec(t, svc, `repl.write(JSON.stringify(qaLet))`, float64(42)) + + requireExec(t, svc, `class QaClass { hi() { return 'hi' } }; `+await_+`repl.write(JSON.stringify(new QaClass().hi()))`, "hi") + requireExecError(t, svc, `class QaClass {}; `+await_+`1`, "Identifier 'QaClass' has already been declared") + requireExec(t, svc, `var qaVar = 1; `+await_+`repl.write(JSON.stringify(qaVar))`, float64(1)) + requireExec(t, svc, `var qaVar = 2; `+await_+`repl.write(JSON.stringify(qaVar))`, float64(2)) + requireExec(t, svc, `function qaFn() { return 1 }; `+await_+`repl.write(JSON.stringify(qaFn()))`, float64(1)) + requireExec(t, svc, `function qaFn() { return 2 }; `+await_+`repl.write(JSON.stringify(qaFn()))`, float64(2)) + + requireExec(t, svc, `const { a: qaA, b: qaB } = { a: 1, b: 2 }; `+await_+`repl.write(JSON.stringify(qaA + qaB))`, float64(3)) + requireExecError(t, svc, `qaA = 5`, "Assignment to constant variable.") + requireExec(t, svc, `const qaFastConst = 'fc'`, nil) + requireExecError(t, svc, `const qaFastConst = 'x'; `+await_+`1`, "Identifier 'qaFastConst' has already been declared") + requireExec(t, svc, `const qaAsyncConst = 'ac'; `+await_+`repl.write(JSON.stringify(1))`, float64(1)) + requireExecError(t, svc, `const qaAsyncConst = 'x'`, "Identifier 'qaAsyncConst' has already been declared") + requireExec(t, svc, `repl.write(JSON.stringify(qaAsyncConst))`, "ac") + + requireExecError(t, svc, `let qaFailLet = (() => { throw new Error('initfail') })(); 1`, "initfail") + requireExecError(t, svc, `let qaFailLet = 2; 2`, "Identifier 'qaFailLet' has already been declared") + requireExecError(t, svc, `qaWriteTdz = 1; let qaWriteTdz = 2`, "before initialization") + requireExecError(t, svc, `qaWriteTdz = 3`, "before initialization") + requireExecError(t, svc, `qaConstWriteTdz = 1; const qaConstWriteTdz = 2`, "before initialization") + requireExecError(t, svc, `const qaFailConst = (() => { throw new Error('constinitfail') })()`, "constinitfail") + requireExecError(t, svc, `qaFailConst = 7`, "before initialization") + + requireExec(t, svc, `const qaInitEscape = globalThis[Object.getOwnPropertyNames(globalThis).find(name => name.startsWith('__browser_repl_init_'))]`, nil) + requireExecError(t, svc, `qaInitEscape.qaConst = 2`, "revoked") + requireExec(t, svc, `repl.write(JSON.stringify(typeof globalThis["__browser_repl_init_target"]))`, "undefined") + requireExec(t, svc, `repl.write(JSON.stringify(repl.id))`, browserReplTestChild(t, svc).id) +} + +func TestBrowserReplIgnoresExpressionValues(t *testing.T) { + svc := newBrowserReplSvc(t) + r := executeBrowserRepl(t, svc, &oapi.ExecuteBrowserReplJSONRequestBody{Code: `await Promise.resolve(); ({ignored: true})`}) + require.True(t, r.Success, "error: %v", r.Error) + require.Empty(t, *r.Content) +} + +func TestBrowserReplScrollDispatchesExactlyOnce(t *testing.T) { + fake := newFakeCDPServer(t) + t.Setenv("CDP_ENDPOINT", fake.wsURL()) + svc := newBrowserReplSvc(t) + + fake.mu.Lock() + fake.swallowNextWheel = true + fake.mu.Unlock() + r := executeBrowserRepl(t, svc, &oapi.ExecuteBrowserReplJSONRequestBody{Code: `await scroll(100, 100, 700, 0)`}) + require.True(t, r.Success, "error: %v", r.Error) + fake.mu.Lock() + count := fake.wheelDispatchCount + y := fake.scrollY + fake.mu.Unlock() + require.Equal(t, 1, count, "an acknowledged wheel must never be replayed") + require.Equal(t, int64(0), y, "the helper must not substitute a second scrolling mechanism") + + r = executeBrowserRepl(t, svc, &oapi.ExecuteBrowserReplJSONRequestBody{Code: `await scroll(100, 100, 700, 0)`}) + require.True(t, r.Success, "error: %v", r.Error) + fake.mu.Lock() + count = fake.wheelDispatchCount + y = fake.scrollY + fake.mu.Unlock() + require.Equal(t, 2, count) + require.Equal(t, int64(700), y) +} + +func TestBrowserReplScrollWaitsForAsyncWheelApplication(t *testing.T) { + fake := newFakeCDPServer(t) + t.Setenv("CDP_ENDPOINT", fake.wsURL()) + + svc := newBrowserReplSvc(t) + + fake.delayedWheelMs.Store(100) + + scrollY := func() int64 { + fake.mu.Lock() + defer fake.mu.Unlock() + return fake.scrollY + } + wheelCount := func() int { + fake.mu.Lock() + defer fake.mu.Unlock() + return fake.wheelDispatchCount + } + + r := executeBrowserRepl(t, svc, &oapi.ExecuteBrowserReplJSONRequestBody{Code: `await scroll(100, 100, 700, 0); "done"`}) + require.True(t, r.Success, "error: %v", r.Error) + require.Eventually(t, func() bool { return scrollY() == 700 }, 3*time.Second, 20*time.Millisecond, + "the wheel must apply exactly once") + require.Equal(t, 1, wheelCount(), "an asynchronously-applied wheel must not be retried") + + r = executeBrowserRepl(t, svc, &oapi.ExecuteBrowserReplJSONRequestBody{Code: `await scroll(100, 100, 700, 0); "done2"`}) + require.True(t, r.Success, "error: %v", r.Error) + require.Eventually(t, func() bool { return scrollY() == 1400 }, 3*time.Second, 20*time.Millisecond, + "the second scroll must move the offset by exactly one delta") + require.Equal(t, 2, wheelCount()) +} + +func TestBrowserReplAttachActivatesTarget(t *testing.T) { + fake := newFakeCDPServer(t) + t.Setenv("CDP_ENDPOINT", fake.wsURL()) + + svc := newBrowserReplSvc(t) + + r := executeBrowserRepl(t, svc, &oapi.ExecuteBrowserReplJSONRequestBody{Code: `await ensureRealTab(); "done"`}) + require.True(t, r.Success, "error: %v", r.Error) + + fake.mu.Lock() + activated := append([]string(nil), fake.activatedTargets...) + fake.mu.Unlock() + require.Contains(t, activated, "target-page-1", "attach must activate the attached target") +} + +func TestStrictBrowserReplBodyMiddleware(t *testing.T) { + next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + body, _ := io.ReadAll(r.Body) + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _, _ = w.Write(body) + }) + handler := StrictBrowserReplBodyMiddleware(next) + + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, httptest.NewRequest(http.MethodPost, "/repl", strings.NewReader(`{"code":"1","bogus":1}`))) + require.Equal(t, http.StatusBadRequest, rec.Code) + require.Contains(t, rec.Body.String(), `unknown field \"bogus\"`) + + rec = httptest.NewRecorder() + handler.ServeHTTP(rec, httptest.NewRequest(http.MethodPost, "/repl", strings.NewReader(`{"code":"1","timeout_sec":5,"reset":false}`))) + require.Equal(t, http.StatusOK, rec.Code) + require.JSONEq(t, `{"code":"1","timeout_sec":5,"reset":false}`, rec.Body.String()) + + rec = httptest.NewRecorder() + huge := `{"code":"` + strings.Repeat("x", maxBrowserReplBodyBytes) + `"}` + handler.ServeHTTP(rec, httptest.NewRequest(http.MethodPost, "/repl", strings.NewReader(huge))) + require.Equal(t, http.StatusRequestEntityTooLarge, rec.Code) + require.Equal(t, "application/json", rec.Header().Get("Content-Type")) + var tooLarge oapi.BadRequestError + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &tooLarge)) + require.Contains(t, tooLarge.Message, "request body exceeds") + + rec = httptest.NewRecorder() + handler.ServeHTTP(rec, httptest.NewRequest(http.MethodPost, "/repl", strings.NewReader(`{nope`))) + require.Equal(t, http.StatusOK, rec.Code) + + rec = httptest.NewRecorder() + handler.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/repl", nil)) + require.Equal(t, http.StatusOK, rec.Code) + rec = httptest.NewRecorder() + handler.ServeHTTP(rec, httptest.NewRequest(http.MethodPost, "/playwright/execute", strings.NewReader(`{"code":"1","bogus":1}`))) + require.Equal(t, http.StatusOK, rec.Code) +} diff --git a/server/cmd/api/api/browser_repl_harness_test.go b/server/cmd/api/api/browser_repl_harness_test.go new file mode 100644 index 00000000..ddd166e8 --- /dev/null +++ b/server/cmd/api/api/browser_repl_harness_test.go @@ -0,0 +1,193 @@ +package api + +import ( + "context" + "encoding/json" + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + "sync" + "syscall" + "testing" + + "github.com/kernel/kernel-images/server/lib/oapi" + "github.com/kernel/kernel-images/server/lib/recorder" + "github.com/stretchr/testify/require" +) + +var ( + browserReplBundleOnce sync.Once + browserReplBundlePath string + browserReplBundleErr error +) + +func ensureBrowserReplBundle(t *testing.T) string { + t.Helper() + browserReplBundleOnce.Do(func() { + if _, err := exec.LookPath("node"); err != nil { + browserReplBundleErr = fmt.Errorf("node not available: %w", err) + return + } + if _, err := exec.LookPath("esbuild"); err != nil { + browserReplBundleErr = fmt.Errorf("esbuild not available: %w", err) + return + } + stagingDir, err := os.MkdirTemp("", "browser-repl-runtime") + if err != nil { + browserReplBundleErr = err + return + } + browserReplBundlePath = filepath.Join(stagingDir, "browser-repl.js") + entries, err := os.ReadDir(filepath.Join(serverRootDir(), "runtime")) + if err != nil { + browserReplBundleErr = err + return + } + for _, entry := range entries { + if entry.IsDir() { + continue + } + name := entry.Name() + if !strings.HasSuffix(name, ".ts") && name != "package.json" && name != "package-lock.json" { + continue + } + data, readErr := os.ReadFile(filepath.Join(serverRootDir(), "runtime", name)) + if readErr != nil { + browserReplBundleErr = readErr + return + } + if writeErr := os.WriteFile(filepath.Join(stagingDir, name), data, 0o644); writeErr != nil { + browserReplBundleErr = writeErr + return + } + } + npm := exec.Command("npm", "ci", "--ignore-scripts", "--no-audit", "--no-fund", "--omit=dev") + npm.Dir = stagingDir + if out, err := npm.CombinedOutput(); err != nil { + browserReplBundleErr = fmt.Errorf("npm ci failed: %w\n%s", err, out) + return + } + + cmd := exec.Command("esbuild", + "browser-repl.ts", + "--bundle", + "--platform=node", + "--target=node22", + "--format=cjs", + "--supported:dynamic-import=true", + "--external:sharp", + "--outfile="+browserReplBundlePath, + ) + cmd.Dir = stagingDir + if out, err := cmd.CombinedOutput(); err != nil { + browserReplBundleErr = fmt.Errorf("esbuild failed: %w\n%s", err, out) + } + }) + if browserReplBundleErr != nil { + t.Skipf("cannot build browser REPL bundle: %v", browserReplBundleErr) + } + return browserReplBundlePath +} + +func serverRootDir() string { + wd, _ := os.Getwd() + return filepath.Join(wd, "..", "..", "..") +} + +func newBrowserReplSvc(t *testing.T) *ApiService { + t.Helper() + script := ensureBrowserReplBundle(t) + + t.Setenv("BROWSER_REPL_SCRIPT", script) + t.Setenv("BROWSER_REPL_SOCKET", filepath.Join(t.TempDir(), "browser-repl.sock")) + if os.Getenv("NODE_PATH") == "" { + if out, err := exec.Command("npm", "root", "-g").Output(); err == nil { + root := string(out) + for len(root) > 0 && (root[len(root)-1] == '\n' || root[len(root)-1] == '\r') { + root = root[:len(root)-1] + } + if root != "" { + t.Setenv("NODE_PATH", root) + } + } + } + + svc, err := newSvc(t, recorder.NewFFmpegManager()) + require.NoError(t, err) + t.Cleanup(func() { + _ = svc.browserRepl.Shutdown(context.Background()) + }) + return svc +} + +func browserReplTestChild(t *testing.T, svc *ApiService) *browserReplChild { + t.Helper() + require.NoError(t, svc.browserRepl.acquireForShutdown(context.Background())) + defer svc.browserRepl.release() + return svc.browserRepl.child +} + +func browserReplBusy(svc *ApiService) bool { + select { + case <-svc.browserRepl.admission: + svc.browserRepl.release() + return false + default: + return true + } +} + +func executeBrowserRepl(t *testing.T, svc *ApiService, body *oapi.ExecuteBrowserReplJSONRequestBody) oapi.ExecuteBrowserRepl200JSONResponse { + t.Helper() + resp, err := svc.ExecuteBrowserRepl(context.Background(), oapi.ExecuteBrowserReplRequestObject{Body: body}) + require.NoError(t, err) + typed, ok := resp.(oapi.ExecuteBrowserRepl200JSONResponse) + require.True(t, ok, "expected 200 response, got %T", resp) + return typed +} + +func execCode(t *testing.T, svc *ApiService, code string) oapi.ExecuteBrowserRepl200JSONResponse { + t.Helper() + return executeBrowserRepl(t, svc, &oapi.ExecuteBrowserReplJSONRequestBody{Code: code}) +} + +func requireExec(t *testing.T, svc *ApiService, code string, want any) oapi.ExecuteBrowserRepl200JSONResponse { + t.Helper() + resp := execCode(t, svc, code) + require.True(t, resp.Success, "code %q failed: %v", code, resp.Error) + if want == nil { + return resp + } + require.NotNil(t, resp.Content, "code %q emitted no content", code) + for i := len(*resp.Content) - 1; i >= 0; i-- { + text, err := (*resp.Content)[i].AsBrowserReplTextContent() + if err != nil || text.Channel != oapi.BrowserReplTextContentChannelWrite { + continue + } + var got any + require.NoError(t, json.Unmarshal([]byte(text.Text), &got), "code: %q", code) + require.Equal(t, want, got, "code: %q", code) + return resp + } + t.Fatalf("code %q emitted no repl.write content", code) + return resp +} + +func requireExecError(t *testing.T, svc *ApiService, code, contains string) oapi.ExecuteBrowserRepl200JSONResponse { + t.Helper() + resp := execCode(t, svc, code) + require.False(t, resp.Success, "expected code %q to fail", code) + require.NotNil(t, resp.Error) + require.Contains(t, *resp.Error, contains, "code: %q", code) + return resp +} + +func processAlive(pid int) bool { + proc, err := os.FindProcess(pid) + if err != nil { + return false + } + return proc.Signal(syscall.Signal(0)) == nil +} diff --git a/server/cmd/api/api/browser_repl_helpers_protocol_test.go b/server/cmd/api/api/browser_repl_helpers_protocol_test.go new file mode 100644 index 00000000..4248a7a6 --- /dev/null +++ b/server/cmd/api/api/browser_repl_helpers_protocol_test.go @@ -0,0 +1,1058 @@ +package api + +import ( + "bufio" + "bytes" + "context" + "encoding/base64" + "encoding/json" + "fmt" + "image" + "image/color" + "image/png" + "io" + "net" + "net/http" + "net/http/httptest" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + "time" + + oapi "github.com/kernel/kernel-images/server/lib/oapi" + "github.com/kernel/kernel-images/server/lib/recorder" + "github.com/stretchr/testify/require" +) + +func requireJSONWrite(t *testing.T, r oapi.ExecuteBrowserRepl200JSONResponse) any { + t.Helper() + require.NotNil(t, r.Content) + for i := len(*r.Content) - 1; i >= 0; i-- { + text, err := (*r.Content)[i].AsBrowserReplTextContent() + if err != nil || text.Channel != oapi.BrowserReplTextContentChannelWrite { + continue + } + var value any + require.NoError(t, json.Unmarshal([]byte(text.Text), &value)) + return value + } + t.Fatal("response has no repl.write content") + return nil +} + +func TestBrowserReplHelpersWithFakeCDP(t *testing.T) { + fake := newFakeCDPServer(t) + + api := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/health": + _, _ = w.Write([]byte("ok")) + case "/slow": + select { + case <-r.Context().Done(): + case <-time.After(10 * time.Second): + _, _ = w.Write([]byte("late")) + } + case "/slow-body": + w.WriteHeader(http.StatusOK) + if flusher, ok := w.(http.Flusher); ok { + flusher.Flush() + } + select { + case <-r.Context().Done(): + case <-time.After(10 * time.Second): + _, _ = w.Write([]byte("late")) + } + case "/webmcp/tools": + _ = json.NewEncoder(w).Encode(map[string]any{"tools": []any{map[string]any{ + "tool_ref": "wmcp_test", "name": "search", "description": "Search", + "input_schema": map[string]any{"type": "object"}, + "source": map[string]any{"window_id": 1, "tab_id": 2, "page_title": "Test", "page_url": "https://example.test", "frame": nil}, + }}}) + case "/webmcp/invoke": + var request map[string]any + if err := json.NewDecoder(r.Body).Decode(&request); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + if request["tool_ref"] == "wmcp_slow" { + select { + case <-r.Context().Done(): + case <-time.After(10 * time.Second): + _, _ = w.Write([]byte(`{"invocation_id":"late","status":"completed"}`)) + } + return + } + _ = json.NewEncoder(w).Encode(map[string]any{ + "invocation_id": "invocation-test", "status": "completed", "output": map[string]any{"ok": true}, + }) + default: + http.NotFound(w, r) + } + })) + t.Cleanup(api.Close) + + t.Setenv("CDP_ENDPOINT", fake.wsURL()) + t.Setenv("KERNEL_API_ENDPOINT", api.URL) + + svc := newBrowserReplSvc(t) + + r := executeBrowserRepl(t, svc, &oapi.ExecuteBrowserReplJSONRequestBody{Code: ` + const tab = await ensureRealTab(); + const nav = await gotoUrl("https://example.com/"); + const state = await waitForLoad(); + const info = await pageInfo(); + const viaSession = await cdp("Runtime.evaluate", { expression: "document.readyState", returnByValue: true }); + const viaBrowser = await cdp("Target.getTargets", undefined, null); + repl.write(JSON.stringify({ + tab: tab.targetId, + frame: nav.frameId, + state, + title: info.title, + dialog: info.dialog, + ready: viaSession.result.value, + targetCount: viaBrowser.targetInfos.length, + })) + `}) + require.True(t, r.Success, "error: %v", r.Error) + nav, ok := requireJSONWrite(t, r).(map[string]any) + require.True(t, ok, "expected object result, got %T", requireJSONWrite(t, r)) + require.Equal(t, "target-page-1", nav["tab"]) + require.Equal(t, "frame-1", nav["frame"]) + require.Equal(t, true, nav["state"]) + require.Equal(t, "Example Domain", nav["title"]) + require.Nil(t, nav["dialog"]) + require.Equal(t, "complete", nav["ready"]) + require.Equal(t, float64(3), nav["targetCount"]) + + r = executeBrowserRepl(t, svc, &oapi.ExecuteBrowserReplJSONRequestBody{Code: ` + var axSnapshot = await accessibilitySnapshot(); + var axButton = axSnapshot.nodes[0]; + var axTextbox = axSnapshot.nodes[1]; + await click(axButton); + await fillInput(axTextbox, "LAX"); + var axVisible = await waitForElement(axButton, {state: "visible", timeoutSec: 1}); + await uploadFile(axTextbox, "/tmp/example.txt"); + repl.write(JSON.stringify({ snapshot: axSnapshot, axVisible })); + `}) + require.True(t, r.Success, "error: %v", r.Error) + axResult, ok := requireJSONWrite(t, r).(map[string]any) + require.True(t, ok) + require.Equal(t, true, axResult["axVisible"]) + snapshot, ok := axResult["snapshot"].(map[string]any) + require.True(t, ok) + require.Equal(t, "https://example.com/", snapshot["url"]) + require.Equal(t, "Example Domain", snapshot["title"]) + nodes, ok := snapshot["nodes"].([]any) + require.True(t, ok) + require.Len(t, nodes, 2, "ignored nodes and nodes without backend IDs are omitted") + button := nodes[0].(map[string]any) + require.Equal(t, float64(77), button["backendNodeId"]) + require.Equal(t, "button", button["role"]) + require.Equal(t, "Search flights", button["name"]) + require.Equal(t, false, button["disabled"]) + + r = executeBrowserRepl(t, svc, &oapi.ExecuteBrowserReplJSONRequestBody{Code: ` + await click({x: 10, y: 20}, {clickCount: 2}); + await typeText("hello"); + await fillInput("#q", "world", {timeoutSec: 2}); + await pressKey("Enter"); + await pressKey("a", ["Shift"]); + await scroll(100, 100, 240, 0); + repl.write(JSON.stringify({ status: "input-ok", dispatchKeyType: typeof dispatchKey })) + `}) + require.True(t, r.Success, "error: %v", r.Error) + inputResult, ok := requireJSONWrite(t, r).(map[string]any) + require.True(t, ok) + require.Equal(t, "input-ok", inputResult["status"]) + require.Equal(t, "undefined", inputResult["dispatchKeyType"]) + + r = executeBrowserRepl(t, svc, &oapi.ExecuteBrowserReplJSONRequestBody{Code: ` + const before = (await listTabs(false)).length; + const created = await newTab("https://example.com/2"); + const current = await currentTab(); + const mid = (await listTabs(false)).length; + const switched = await switchTab("target-page-1"); + await closeTab(created); + const after = (await listTabs(false)).length; + repl.write(JSON.stringify({ before, createdId: created, currentIsCreated: current.targetId === created, mid, switchedTo: (await currentTab()).targetId, after })) + `}) + require.True(t, r.Success, "error: %v", r.Error) + tabs, ok := requireJSONWrite(t, r).(map[string]any) + require.True(t, ok) + require.Equal(t, float64(1), tabs["before"], "includeChrome=false excludes internal pages") + require.Equal(t, true, tabs["currentIsCreated"]) + require.Equal(t, float64(2), tabs["mid"]) + require.Equal(t, "target-page-1", tabs["switchedTo"]) + require.Equal(t, float64(1), tabs["after"]) + + r = executeBrowserRepl(t, svc, &oapi.ExecuteBrowserReplJSONRequestBody{Code: ` + await waitMs(50); + const found = await waitForElement("#thing", {timeoutSec: 2, state: "visible"}); + await waitForNetworkIdle(0.1, 5); + const echoed = await js("echo-me-please"); + const tools = await webmcp.listTools(); + const invocation = await browser.webmcp.invokeTool("wmcp_test", {query: "SFO"}, {timeoutSec: 2}); + const frame = await iframeTarget("frame.example"); + const noFrame = await iframeTarget("no-such-host"); + let evs = []; + for (let i = 0; i < 50 && evs.length === 0; i++) { + evs = await drainEvents(); + if (evs.length === 0) await waitMs(100); + } + repl.write(JSON.stringify({ + found, + echoed, + frameUrl: frame && frame.url, + noFrame, + eventCount: evs.length, + webmcpFrozen: Object.isFrozen(webmcp), + webmcpShared: webmcp === browser.webmcp, + webmcpMethods: [typeof webmcp.listTools, typeof webmcp.invokeTool], + webmcpTool: tools[0].tool_ref, + webmcpInvocation: invocation, + })) + `}) + require.True(t, r.Success, "error: %v", r.Error) + waits, ok := requireJSONWrite(t, r).(map[string]any) + require.True(t, ok) + require.Equal(t, true, waits["found"]) + require.Equal(t, "echo-me-please", waits["echoed"]) + require.Equal(t, "https://frame.example.com/widget", waits["frameUrl"]) + require.Nil(t, waits["noFrame"]) + require.GreaterOrEqual(t, waits["eventCount"], float64(1), "drainEvents returns buffered session events") + require.Equal(t, true, waits["webmcpFrozen"]) + require.Equal(t, true, waits["webmcpShared"]) + require.Equal(t, []any{"function", "function"}, waits["webmcpMethods"]) + require.Equal(t, "wmcp_test", waits["webmcpTool"]) + invocation, ok := waits["webmcpInvocation"].(map[string]any) + require.True(t, ok) + require.Equal(t, "invocation-test", invocation["invocation_id"]) + require.Equal(t, "completed", invocation["status"]) + + r = executeBrowserRepl(t, svc, &oapi.ExecuteBrowserReplJSONRequestBody{Code: fmt.Sprintf(` + const shot = await captureScreenshot("/tmp/fake-cdp-shot.png", false, 400); + await repl.emitImage({ path: shot }); + await uploadFile("#file", ["/tmp/fake-cdp-shot.png"]); + const body = await httpGet("%s/health"); + repl.write(JSON.stringify({ shot, body })) + `, api.URL)}) + require.True(t, r.Success, "error: %v", r.Error) + misc, ok := requireJSONWrite(t, r).(map[string]any) + require.True(t, ok) + require.Equal(t, "/tmp/fake-cdp-shot.png", misc["shot"]) + require.Equal(t, "ok", misc["body"]) + require.NotNil(t, r.Content) + sawImage := false + for _, item := range *r.Content { + if img, err := item.AsBrowserReplImageContent(); err == nil && img.Type == "image" { + sawImage = true + require.Equal(t, "image/png", img.MimeType) + } + } + require.True(t, sawImage, "the captured screenshot is emitted as image content") + + timeoutSec := 2 + started := time.Now() + timedOut := executeBrowserRepl(t, svc, &oapi.ExecuteBrowserReplJSONRequestBody{ + Code: fmt.Sprintf(`await httpGet(%q, undefined, 20)`, api.URL+"/slow-body"), + TimeoutSec: &timeoutSec, + }) + require.Less(t, time.Since(started), 2*time.Second) + require.False(t, timedOut.Success) + require.NotNil(t, timedOut.Error) + require.Contains(t, *timedOut.Error, "timed out after") + require.True(t, timedOut.ReplTerminated == nil || !*timedOut.ReplTerminated) + require.Equal(t, r.ReplId, timedOut.ReplId, "a clamped HTTP timeout must preserve the REPL") + + started = time.Now() + webmcpTimedOut := executeBrowserRepl(t, svc, &oapi.ExecuteBrowserReplJSONRequestBody{ + Code: `await webmcp.invokeTool("wmcp_slow", {}, {timeoutSec: 20})`, + TimeoutSec: &timeoutSec, + }) + require.Less(t, time.Since(started), 2*time.Second) + require.False(t, webmcpTimedOut.Success) + require.NotNil(t, webmcpTimedOut.Error) + require.Contains(t, strings.ToLower(*webmcpTimedOut.Error), "timeout") + require.True(t, webmcpTimedOut.ReplTerminated == nil || !*webmcpTimedOut.ReplTerminated) + require.Equal(t, r.ReplId, webmcpTimedOut.ReplId, "a clamped WebMCP timeout must preserve the REPL") + + r2 := executeBrowserRepl(t, svc, &oapi.ExecuteBrowserReplJSONRequestBody{Code: "repl.id"}) + require.True(t, r2.Success) + require.Equal(t, r.ReplId, r2.ReplId) +} + +func TestBrowserReplWaitForNetworkIdleAttachesBeforeObserving(t *testing.T) { + fake := newFakeCDPServer(t) + t.Setenv("CDP_ENDPOINT", fake.wsURL()) + svc := newBrowserReplSvc(t) + + started := time.Now() + r := executeBrowserRepl(t, svc, &oapi.ExecuteBrowserReplJSONRequestBody{ + Code: `repl.write(JSON.stringify(await waitForNetworkIdle(0.2, 2)))`, + }) + require.True(t, r.Success, "error: %v", r.Error) + require.Equal(t, true, requireJSONWrite(t, r)) + require.GreaterOrEqual(t, time.Since(started), 200*time.Millisecond, + "the first network-idle wait must attach and observe a complete idle interval") +} + +func TestBrowserReplCaptureScreenshotMaxDim(t *testing.T) { + fake := newFakeCDPServer(t) + var source bytes.Buffer + img := image.NewRGBA(image.Rect(0, 0, 4, 2)) + for y := 0; y < 2; y++ { + for x := 0; x < 4; x++ { + img.Set(x, y, color.RGBA{R: 255, A: 255}) + } + } + require.NoError(t, png.Encode(&source, img)) + fake.mu.Lock() + fake.screenshotData = base64.StdEncoding.EncodeToString(source.Bytes()) + fake.mu.Unlock() + t.Setenv("CDP_ENDPOINT", fake.wsURL()) + svc := newBrowserReplSvc(t) + + path := filepath.Join(t.TempDir(), "scaled.png") + r := executeBrowserRepl(t, svc, &oapi.ExecuteBrowserReplJSONRequestBody{ + Code: fmt.Sprintf(`await captureScreenshot(%q, false, 2)`, path), + }) + require.True(t, r.Success, "error: %v", r.Error) + file, err := os.Open(path) + require.NoError(t, err) + defer file.Close() + cfg, err := png.DecodeConfig(file) + require.NoError(t, err) + require.Equal(t, 2, cfg.Width) + require.Equal(t, 1, cfg.Height) +} + +func TestBrowserReplHelperErgonomics(t *testing.T) { + fake := newFakeCDPServer(t) + t.Setenv("CDP_ENDPOINT", fake.wsURL()) + svc := newBrowserReplSvc(t) + + requireExecError(t, svc, `await pressKey("a", "Control")`, "pressKey: modifiers must be an array") + requireExecError(t, svc, `await click({x: 1, y: 2}, {timeoutSec: 1})`, "timeoutSec is only supported for selector targets") + requireExecError(t, svc, `await click("#q", {bogus: true})`, "click: unknown option: bogus") + requireExecError(t, svc, `await fillInput("#q", "x", {bogus: true})`, "fillInput: unknown option: bogus") + requireExecError(t, svc, `await waitForElement("#q", {state: "ready"})`, "waitForElement: state must be") + requireExec(t, svc, `await pressKey("a", {ctrl: true}); repl.write(JSON.stringify("ok"))`, "ok") + keyEv := fake.lastKeyEventParams() + require.NotNil(t, keyEv) + require.Equal(t, float64(2), keyEv["modifiers"]) + requireExec(t, svc, `await pressKey("ENTER"); repl.write(JSON.stringify("ok"))`, "ok") + keyEv = fake.lastKeyEventParams() + require.Equal(t, "Enter", keyEv["key"]) + require.Equal(t, "Enter", keyEv["code"]) + require.Equal(t, float64(13), keyEv["windowsVirtualKeyCode"]) + requireExec(t, svc, `await pressKey("Digit1", ["shift"]); repl.write(JSON.stringify("ok"))`, "ok") + keyEv = fake.lastKeyEventParams() + require.Equal(t, "!", keyEv["key"]) + require.Equal(t, "Digit1", keyEv["code"]) + require.Equal(t, float64(8), keyEv["modifiers"]) + requireExecError(t, svc, `await pressKey("a", {bogus: true})`, "pressKey: unknown modifier") + requireExecError(t, svc, `await js("1", {target: "target-page-1"})`, "js: unknown option: target") + requireExec(t, svc, `repl.write(JSON.stringify(await js("via-target", {targetId: "target-page-1"})))`, "via-target") + functionResult := executeBrowserRepl(t, svc, &oapi.ExecuteBrowserReplJSONRequestBody{Code: ` + const generated = await js(async ({value}) => { + const resolved = await Promise.resolve(value); + return resolved + 1; + }, {arg: {value: 4}}); + repl.write(JSON.stringify(generated)); + `}) + require.True(t, functionResult.Success, "error: %v", functionResult.Error) + generated, ok := requireJSONWrite(t, functionResult).(string) + require.True(t, ok) + require.Contains(t, generated, "async ({value})") + require.Contains(t, generated, "Promise.resolve") + require.Contains(t, generated, `"value",{"type":"number","value":4}`) + timeoutSec := 3 + start := time.Now() + r := executeBrowserRepl(t, svc, &oapi.ExecuteBrowserReplJSONRequestBody{Code: `repl.write(JSON.stringify(await waitForElement("#never", {timeoutSec: 2})))`, TimeoutSec: &timeoutSec}) + require.Less(t, time.Since(start), 3*time.Second) + require.True(t, r.Success) + require.Equal(t, false, requireJSONWrite(t, r)) + require.Nil(t, r.ReplTerminated) + requireExec(t, svc, "repl.write(JSON.stringify(repl.id))", r.ReplId) +} + +func TestBrowserReplFrozenRendererRecovery(t *testing.T) { + fake := newFakeCDPServer(t) + t.Setenv("CDP_ENDPOINT", fake.wsURL()) + + svc := newBrowserReplSvc(t) + + r := executeBrowserRepl(t, svc, &oapi.ExecuteBrowserReplJSONRequestBody{Code: `repl.write(JSON.stringify((await pageInfo()).title))`}) + require.True(t, r.Success, "error: %v", r.Error) + require.Equal(t, "Example Domain", requireJSONWrite(t, r)) + + fake.hangSession.Store(true) + reset := true + timeoutSec := 5 + start := time.Now() + r = executeBrowserRepl(t, svc, &oapi.ExecuteBrowserReplJSONRequestBody{ + Code: `await pageInfo()`, + TimeoutSec: &timeoutSec, + Reset: &reset, + }) + require.Less(t, time.Since(start), 6*time.Second, + "the frozen renderer must surface a clean error at the deadline margin, "+ + "not hang past the socket read deadline (timeout + grace)") + require.False(t, r.Success) + require.NotNil(t, r.Error) + require.Contains(t, *r.Error, "renderer is unresponsive", + "the error should point at the recovery path, got: %s", *r.Error) + require.True(t, r.ReplTerminated == nil || !*r.ReplTerminated, + "a frozen renderer must not destroy the REPL") + frozenID := r.ReplId + + r = executeBrowserRepl(t, svc, &oapi.ExecuteBrowserReplJSONRequestBody{Code: `(await listTabs()).length`}) + require.True(t, r.Success, "error: %v", r.Error) + require.Equal(t, frozenID, r.ReplId, "the REPL must survive the frozen renderer") + + timeoutSec2 := 3 + start = time.Now() + r = executeBrowserRepl(t, svc, &oapi.ExecuteBrowserReplJSONRequestBody{ + Code: `await js("1")`, + TimeoutSec: &timeoutSec2, + }) + require.Less(t, time.Since(start), 3*time.Second) + require.False(t, r.Success) + require.True(t, r.ReplTerminated == nil || !*r.ReplTerminated) + require.Equal(t, frozenID, r.ReplId) + + fake.hangSession.Store(false) + r = executeBrowserRepl(t, svc, &oapi.ExecuteBrowserReplJSONRequestBody{Code: `repl.write(JSON.stringify((await pageInfo()).title))`}) + require.True(t, r.Success, "error: %v", r.Error) + require.Equal(t, "Example Domain", requireJSONWrite(t, r)) + require.Equal(t, frozenID, r.ReplId, "recovery must not replace the REPL") +} + +func TestBrowserReplCrashDuringExecutionResponse(t *testing.T) { + svc := newBrowserReplSvc(t) + + r1 := executeBrowserRepl(t, svc, &oapi.ExecuteBrowserReplJSONRequestBody{Code: "1"}) + require.True(t, r1.Success) + + r2 := executeBrowserRepl(t, svc, &oapi.ExecuteBrowserReplJSONRequestBody{ + Code: `process.kill(process.pid, "SIGKILL")`, + }) + require.False(t, r2.Success) + require.Equal(t, r1.ReplId, r2.ReplId, "the response carries the terminated REPL's ID") + require.NotNil(t, r2.ReplTerminated) + require.True(t, *r2.ReplTerminated) + require.NotNil(t, r2.Error) + require.Contains(t, *r2.Error, "terminated during execution") + require.NotNil(t, r2.DurationMs, "crash responses must include duration_ms") + require.GreaterOrEqual(t, *r2.DurationMs, 0) + require.NotNil(t, r2.ContentTruncated, "crash responses must include content_truncated") + + r3 := executeBrowserRepl(t, svc, &oapi.ExecuteBrowserReplJSONRequestBody{Code: "'fresh'"}) + require.True(t, r3.Success) + require.NotEqual(t, r1.ReplId, r3.ReplId) +} + +func TestBrowserReplStaticImportRejected(t *testing.T) { + svc := newBrowserReplSvc(t) + + for name, code := range map[string]string{ + "unused default import": `import path from "node:path"; 1`, + "used default import": `import path from "node:path"; path.basename("/x")`, + "namespace import": `import * as fs from "node:fs"`, + "named import": `import { basename } from "node:path"`, + "side-effect import": `import "node:path"`, + "export declaration": `export const x = 1`, + } { + r := executeBrowserRepl(t, svc, &oapi.ExecuteBrowserReplJSONRequestBody{Code: code}) + require.False(t, r.Success, "%s must be rejected", name) + require.NotNil(t, r.Error) + require.Contains(t, *r.Error, "static import/export is not supported", name) + require.True(t, r.ReplTerminated == nil || !*r.ReplTerminated, + "a static import error must not destroy the REPL") + } + + r := executeBrowserRepl(t, svc, &oapi.ExecuteBrowserReplJSONRequestBody{ + Code: `repl.write(JSON.stringify((await import("node:path")).basename("/a/b")))`, + }) + require.True(t, r.Success, "error: %v", r.Error) + require.Equal(t, "b", requireJSONWrite(t, r)) + + r = executeBrowserRepl(t, svc, &oapi.ExecuteBrowserReplJSONRequestBody{Code: `return 1`}) + require.False(t, r.Success) + require.Contains(t, *r.Error, "top-level return is not supported") +} + +func TestBrowserReplHeapCapConfigurable(t *testing.T) { + t.Setenv("BROWSER_REPL_HEAP_MB", "256") + + svc := newBrowserReplSvc(t) + + r := executeBrowserRepl(t, svc, &oapi.ExecuteBrowserReplJSONRequestBody{Code: "1 + 1"}) + require.True(t, r.Success, "error: %v", r.Error) + + args := browserReplTestChild(t, svc).cmd.Args + require.Contains(t, args, "--max-old-space-size=256") +} + +func TestBrowserReplEventRingBounded(t *testing.T) { + fake := newFakeCDPServer(t) + t.Setenv("CDP_ENDPOINT", fake.wsURL()) + + svc := newBrowserReplSvc(t) + + r := executeBrowserRepl(t, svc, &oapi.ExecuteBrowserReplJSONRequestBody{Code: `await ensureRealTab(); "attached"`}) + require.True(t, r.Success, "error: %v", r.Error) + + for i := 0; i < 600; i++ { + fake.queueEvent(map[string]any{ + "method": "Network.requestWillBeSent", + "params": map[string]any{"requestId": fmt.Sprintf("flood-%d", i)}, + "sessionId": "session-target-page-1", + }) + } + r = executeBrowserRepl(t, svc, &oapi.ExecuteBrowserReplJSONRequestBody{Code: ` + await js("flush"); // command round-trip flushes the queued events + await waitMs(500); // let the daemon process the flooded socket + const evs = await drainEvents(); + repl.write(JSON.stringify(evs.length)) + `}) + require.True(t, r.Success, "error: %v", r.Error) + require.Equal(t, float64(500), requireJSONWrite(t, r), "old events are dropped at the ring capacity") +} + +func TestBrowserReplWaitForEvent(t *testing.T) { + fake := newFakeCDPServer(t) + t.Setenv("CDP_ENDPOINT", fake.wsURL()) + svc := newBrowserReplSvc(t) + + r := executeBrowserRepl(t, svc, &oapi.ExecuteBrowserReplJSONRequestBody{Code: `await ensureRealTab()`}) + require.True(t, r.Success, "error: %v", r.Error) + + fake.queueEvent(map[string]any{ + "method": "Network.loadingFinished", + "params": map[string]any{"requestId": "ignored"}, + "sessionId": "session-target-page-1", + }) + fake.queueEvent(map[string]any{ + "method": "Network.loadingFinished", + "params": map[string]any{"requestId": "wanted"}, + "sessionId": "session-target-page-1", + }) + fake.queueEvent(map[string]any{ + "method": "Browser.downloadProgress", + "params": map[string]any{"guid": "download-1", "state": "completed"}, + }) + + r = executeBrowserRepl(t, svc, &oapi.ExecuteBrowserReplJSONRequestBody{Code: ` + var pageEventPending = waitForEvent("Network.loadingFinished", { + timeoutSec: 2, + predicate: event => event.params.requestId === "wanted", + }); + var browserEventPending = waitForEvent("Browser.downloadProgress", { + sessionId: null, + timeoutSec: 2, + }); + await cdp("Target.getTargets", undefined, null); + var pageEvent = await pageEventPending; + var browserEvent = await browserEventPending; + var missingEvent = await waitForEvent("Page.frameStoppedLoading", {timeoutSec: 0.05}); + repl.write(JSON.stringify({ + pageRequestId: pageEvent && pageEvent.params.requestId, + pageSessionId: pageEvent && pageEvent.sessionId, + browserGuid: browserEvent && browserEvent.params.guid, + browserSessionId: browserEvent && browserEvent.sessionId, + missingEvent, + })); + `}) + require.True(t, r.Success, "error: %v", r.Error) + result, ok := requireJSONWrite(t, r).(map[string]any) + require.True(t, ok) + require.Equal(t, "wanted", result["pageRequestId"]) + require.Equal(t, "session-target-page-1", result["pageSessionId"]) + require.Equal(t, "download-1", result["browserGuid"]) + require.Nil(t, result["browserSessionId"]) + require.Nil(t, result["missingEvent"]) +} + +func TestBrowserReplReconnectPreservesState(t *testing.T) { + fake := newFakeCDPServer(t) + t.Setenv("CDP_ENDPOINT", fake.wsURL()) + + svc := newBrowserReplSvc(t) + + r1 := executeBrowserRepl(t, svc, &oapi.ExecuteBrowserReplJSONRequestBody{ + Code: `var restartToken = "pre-restart"; await ensureRealTab(); repl.write(JSON.stringify(restartToken))`, + }) + require.True(t, r1.Success, "error: %v", r1.Error) + require.Equal(t, "pre-restart", requireJSONWrite(t, r1)) + require.Equal(t, 1, fake.connCount()) + + fake.Restart() + require.Eventually(t, func() bool { return fake.connCount() == 0 }, + 5*time.Second, 10*time.Millisecond, "daemon connection must close") + + r2 := executeBrowserRepl(t, svc, &oapi.ExecuteBrowserReplJSONRequestBody{ + Code: `const info = await pageInfo(); repl.write(JSON.stringify({ token: restartToken, title: info.title }))`, + }) + require.True(t, r2.Success, "error: %v", r2.Error) + require.Equal(t, r1.ReplId, r2.ReplId, "a Chromium restart must not change repl_id") + res, ok := requireJSONWrite(t, r2).(map[string]any) + require.True(t, ok) + require.Equal(t, "pre-restart", res["token"], "bindings survive a browser reconnect") + require.Equal(t, "Example Domain", res["title"]) + require.Equal(t, 1, fake.connCount(), "the daemon reconnected") +} + +func TestBrowserReplOutputIntegrityUnderPollution(t *testing.T) { + svc := newBrowserReplSvc(t) + + r := executeBrowserRepl(t, svc, &oapi.ExecuteBrowserReplJSONRequestBody{Code: ` + JSON.stringify = () => "PWNED"; + Array.prototype.toJSON = () => "PWNED"; + Object.prototype.toJSON = () => "PWNED"; + repl.write('"polluted"') + `}) + require.True(t, r.Success, "error: %v", r.Error) + require.Equal(t, "polluted", requireJSONWrite(t, r)) + + r = executeBrowserRepl(t, svc, &oapi.ExecuteBrowserReplJSONRequestBody{Code: `repl.write('{"a":[1,2,3],"b":"str"}')`}) + require.True(t, r.Success, "error: %v", r.Error) + res, ok := requireJSONWrite(t, r).(map[string]any) + require.True(t, ok, "expected object result, got %T", requireJSONWrite(t, r)) + require.Equal(t, []any{float64(1), float64(2), float64(3)}, res["a"]) + require.Equal(t, "str", res["b"]) + + r = executeBrowserRepl(t, svc, &oapi.ExecuteBrowserReplJSONRequestBody{Code: `repl.write("frame-ok"); "done"`}) + require.True(t, r.Success, "error: %v", r.Error) + + require.NotNil(t, r.Content) + require.Len(t, *r.Content, 1) + txt, err := (*r.Content)[0].AsBrowserReplTextContent() + require.NoError(t, err) + require.Equal(t, "frame-ok", txt.Text) +} + +func TestBrowserReplPageInfoReportsPendingDialog(t *testing.T) { + fake := newFakeCDPServer(t) + t.Setenv("CDP_ENDPOINT", fake.wsURL()) + + svc := newBrowserReplSvc(t) + + r := executeBrowserRepl(t, svc, &oapi.ExecuteBrowserReplJSONRequestBody{Code: `await ensureRealTab(); "attached"`}) + require.True(t, r.Success, "error: %v", r.Error) + + fake.frozen.Store(true) + fake.queueEvent(map[string]any{ + "method": "Page.javascriptDialogOpening", + "params": map[string]any{"type": "alert", "message": "hello-dialog"}, + "sessionId": "session-target-page-1", + }) + + r = executeBrowserRepl(t, svc, &oapi.ExecuteBrowserReplJSONRequestBody{Code: `await cdp("Target.getTargets", undefined, null); "flushed"`}) + require.True(t, r.Success, "error: %v", r.Error) + time.Sleep(200 * time.Millisecond) + + r = executeBrowserRepl(t, svc, &oapi.ExecuteBrowserReplJSONRequestBody{Code: ` + const info = await pageInfo(); + repl.write(JSON.stringify({ url: info.url, title: info.title, dialog: info.dialog })) + `}) + require.True(t, r.Success, "error: %v", r.Error) + res, ok := requireJSONWrite(t, r).(map[string]any) + require.True(t, ok) + require.Equal(t, "https://example.com/", res["url"]) + require.Equal(t, "Example Domain", res["title"]) + dialog, ok := res["dialog"].(map[string]any) + require.True(t, ok, "expected dialog payload, got %v", res["dialog"]) + require.Equal(t, "alert", dialog["type"]) + require.Equal(t, "hello-dialog", dialog["message"]) + + fake.frozen.Store(false) + fake.queueEvent(map[string]any{ + "method": "Page.javascriptDialogClosed", + "params": map[string]any{"result": true}, + "sessionId": "session-target-page-1", + }) + r = executeBrowserRepl(t, svc, &oapi.ExecuteBrowserReplJSONRequestBody{Code: `await cdp("Target.getTargets", undefined, null); "flushed"`}) + require.True(t, r.Success, "error: %v", r.Error) + time.Sleep(200 * time.Millisecond) + + r = executeBrowserRepl(t, svc, &oapi.ExecuteBrowserReplJSONRequestBody{Code: `(await pageInfo()).dialog`}) + require.True(t, r.Success, "error: %v", r.Error) +} + +func TestBrowserReplAttachRetriesStaleTarget(t *testing.T) { + fake := newFakeCDPServer(t) + t.Setenv("CDP_ENDPOINT", fake.wsURL()) + + svc := newBrowserReplSvc(t) + + r := executeBrowserRepl(t, svc, &oapi.ExecuteBrowserReplJSONRequestBody{Code: `await ensureRealTab(); "attached"`}) + require.True(t, r.Success, "error: %v", r.Error) + + fake.Restart() + require.Eventually(t, func() bool { return fake.connCount() == 0 }, + 5*time.Second, 10*time.Millisecond, "daemon connection must close") + fake.failNextAttach.Store(1) + + r = executeBrowserRepl(t, svc, &oapi.ExecuteBrowserReplJSONRequestBody{Code: `repl.write(JSON.stringify((await pageInfo()).title))`}) + require.True(t, r.Success, "a transient stale-target attach must be retried: %v", r.Error) + require.Equal(t, "Example Domain", requireJSONWrite(t, r)) + require.Equal(t, int32(0), fake.failNextAttach.Load(), "the first attach attempt failed as planned") +} + +func TestBrowserReplReusesAndReleasesAttachedSessions(t *testing.T) { + fake := newFakeCDPServer(t) + t.Setenv("CDP_ENDPOINT", fake.wsURL()) + svc := newBrowserReplSvc(t) + + r := executeBrowserRepl(t, svc, &oapi.ExecuteBrowserReplJSONRequestBody{Code: ` + await ensureRealTab(); + await switchTab("target-page-1"); + await switchTab("target-page-1"); + await switchTab("target-frame-1"); + await switchTab("target-page-1"); + `}) + require.True(t, r.Success, "error: %v", r.Error) + require.Equal(t, int32(3), fake.attachCount.Load(), "same-target switches must reuse the owned session") + require.Equal(t, int32(2), fake.detachCount.Load(), "switching targets must release the previous session") +} + +func TestBrowserReplDoesNotReplayMutationWithUnknownOutcome(t *testing.T) { + fake := newFakeCDPServer(t) + t.Setenv("CDP_ENDPOINT", fake.wsURL()) + svc := newBrowserReplSvc(t) + + r := executeBrowserRepl(t, svc, &oapi.ExecuteBrowserReplJSONRequestBody{Code: `await ensureRealTab()`}) + require.True(t, r.Success, "error: %v", r.Error) + fake.dropNextCreateTargetResponse.Store(true) + + r = executeBrowserRepl(t, svc, &oapi.ExecuteBrowserReplJSONRequestBody{Code: `await newTab()`}) + require.False(t, r.Success) + require.Contains(t, *r.Error, "Target.createTarget outcome is unknown") + fake.mu.Lock() + targetCount := len(fake.targets) + fake.mu.Unlock() + require.Equal(t, 4, targetCount, "the acknowledged-unknown mutation must have been applied exactly once") + + r = executeBrowserRepl(t, svc, &oapi.ExecuteBrowserReplJSONRequestBody{Code: `repl.write(JSON.stringify((await listTabs()).length))`}) + require.True(t, r.Success, "CDP must reconnect after surfacing the unknown outcome: %v", r.Error) + require.Equal(t, float64(3), requireJSONWrite(t, r)) +} + +func TestBrowserReplRetriesCommandOnFreshConnectionClose(t *testing.T) { + fake := newFakeCDPServer(t) + t.Setenv("CDP_ENDPOINT", fake.wsURL()) + + svc := newBrowserReplSvc(t) + + r1 := executeBrowserRepl(t, svc, &oapi.ExecuteBrowserReplJSONRequestBody{ + Code: `var retryToken = "pre-restart"; await ensureRealTab(); retryToken`, + }) + require.True(t, r1.Success, "error: %v", r1.Error) + require.Equal(t, int32(1), fake.totalConns.Load()) + + fake.Restart() + require.Eventually(t, func() bool { return fake.connCount() == 0 }, + 5*time.Second, 10*time.Millisecond, "daemon connection must close") + fake.closeNextConnsAfterFirstCommand.Store(1) + + r2 := executeBrowserRepl(t, svc, &oapi.ExecuteBrowserReplJSONRequestBody{ + Code: `const info = await pageInfo(); repl.write(JSON.stringify({ token: retryToken, title: info.title }))`, + }) + require.True(t, r2.Success, "a command on a fresh connection that died unanswered must be retried: %v", r2.Error) + require.Equal(t, r1.ReplId, r2.ReplId, "the transient close must not change repl_id") + res, ok := requireJSONWrite(t, r2).(map[string]any) + require.True(t, ok) + require.Equal(t, "pre-restart", res["token"], "bindings survive the reconnect-and-retry") + require.Equal(t, "Example Domain", res["title"]) + require.Equal(t, int32(0), fake.closeNextConnsAfterFirstCommand.Load(), "the fresh connection was dropped as planned") + require.Equal(t, int32(3), fake.totalConns.Load(), "initial + dropped + retried connections") + require.Equal(t, 1, fake.connCount(), "the retried connection is still open") +} + +const fakeReplDaemonJS = ` +const net = require('net'); +const fs = require('fs'); +const sock = process.env.BROWSER_REPL_SOCKET; +try { fs.unlinkSync(sock); } catch (e) {} +const mode = process.env.FAKE_REPL_MODE || 'ok'; +net.createServer((conn) => { + let buf = ''; + conn.on('data', (d) => { + buf += d.toString(); + const idx = buf.indexOf('\n'); + if (idx === -1) return; + const line = buf.slice(0, idx); + buf = buf.slice(idx + 1); + let req = {}; + try { req = JSON.parse(line); } catch (e) {} + const base = { + id: req.id, + repl_id: process.env.BROWSER_REPL_ID, + success: true, + content: [], + content_truncated: false, + duration_ms: 1, + }; + if (mode === 'bad-request-id') { + conn.write(JSON.stringify({ ...base, id: 'wrong-id' }) + '\n'); + } else if (mode === 'bad-repl-id') { + conn.write(JSON.stringify({ ...base, repl_id: 'wrong-repl' }) + '\n'); + } else if (mode === 'garbage') { + conn.write('this is not json\n'); + } else if (mode === 'die') { + process.exit(1); + } else { + conn.write(JSON.stringify(base) + '\n'); + } + }); +}).listen(sock); +` + +func TestBrowserReplProtocolCorruptionTerminates(t *testing.T) { + if _, err := exec.LookPath("node"); err != nil { + t.Skipf("node not available: %v", err) + } + script := filepath.Join(t.TempDir(), "fake-repl.js") + require.NoError(t, os.WriteFile(script, []byte(fakeReplDaemonJS), 0o644)) + + for _, tc := range []struct { + mode string + wantErrPart string + }{ + {"bad-request-id", "response ID mismatch"}, + {"bad-repl-id", "repl_id mismatch"}, + {"garbage", "failed to parse response"}, + {"die", "terminated during execution (exit status 1)"}, + } { + t.Run(tc.mode, func(t *testing.T) { + t.Setenv("BROWSER_REPL_SCRIPT", script) + t.Setenv("BROWSER_REPL_SOCKET", filepath.Join(t.TempDir(), "browser-repl.sock")) + t.Setenv("FAKE_REPL_MODE", tc.mode) + + svc, err := newSvc(t, recorder.NewFFmpegManager()) + require.NoError(t, err) + t.Cleanup(func() { + _ = svc.browserRepl.Shutdown(context.Background()) + }) + + resp, err := svc.ExecuteBrowserRepl(context.Background(), oapi.ExecuteBrowserReplRequestObject{ + Body: &oapi.ExecuteBrowserReplJSONRequestBody{Code: "1"}, + }) + require.NoError(t, err) + typed, ok := resp.(oapi.ExecuteBrowserRepl200JSONResponse) + require.True(t, ok, "expected 200 response, got %T", resp) + require.False(t, typed.Success) + require.NotNil(t, typed.ReplTerminated) + require.True(t, *typed.ReplTerminated, "protocol corruption must terminate the REPL") + require.NotNil(t, typed.Error) + require.Contains(t, *typed.Error, tc.wantErrPart) + require.Nil(t, svc.browserRepl.child, "no replacement starts until the next request") + }) + } +} + +func TestBrowserReplUnhandledRejectionSurvives(t *testing.T) { + svc := newBrowserReplSvc(t) + + r := executeBrowserRepl(t, svc, &oapi.ExecuteBrowserReplJSONRequestBody{Code: ` + var kept = 'state-kept'; + setTimeout(() => { Promise.reject(new Error('boom-floating')); }, 20); + repl.write(JSON.stringify('submitted')) + `}) + require.True(t, r.Success, "error: %v", r.Error) + require.Equal(t, "submitted", requireJSONWrite(t, r)) + + r = executeBrowserRepl(t, svc, &oapi.ExecuteBrowserReplJSONRequestBody{Code: `await waitMs(300); repl.write(JSON.stringify(kept))`}) + require.True(t, r.Success, "error: %v", r.Error) + require.Equal(t, "state-kept", requireJSONWrite(t, r)) + require.NotNil(t, r.Content) + sawRejection := false + for _, item := range *r.Content { + txt, err := item.AsBrowserReplTextContent() + if err == nil && txt.Channel == "stderr" && + strings.Contains(txt.Text, "unhandled promise rejection") && + strings.Contains(txt.Text, "boom-floating") { + sawRejection = true + } + } + require.True(t, sawRejection, "the floating rejection must surface as a stderr content item, got %v", r.Content) + + r2 := executeBrowserRepl(t, svc, &oapi.ExecuteBrowserReplJSONRequestBody{Code: "repl.id"}) + require.True(t, r2.Success) + require.Equal(t, r.ReplId, r2.ReplId) +} + +func TestBrowserReplUncaughtExceptionTerminates(t *testing.T) { + svc := newBrowserReplSvc(t) + + r := executeBrowserRepl(t, svc, &oapi.ExecuteBrowserReplJSONRequestBody{Code: ` + var doomed = 'will-be-lost'; + globalThis.boom = () => { throw new Error('boom-uncaught') }; + repl.write(JSON.stringify('scheduled')) + `}) + require.True(t, r.Success, "error: %v", r.Error) + require.Equal(t, "scheduled", requireJSONWrite(t, r)) + doomedID := r.ReplId + + r = executeBrowserRepl(t, svc, &oapi.ExecuteBrowserReplJSONRequestBody{Code: ` + setTimeout(() => boom(), 10); + await waitMs(5000); + 'never-reached' + `}) + require.False(t, r.Success) + require.NotNil(t, r.Error) + require.Contains(t, *r.Error, "uncaught exception") + require.Contains(t, *r.Error, "boom-uncaught") + require.NotNil(t, r.ReplTerminated) + require.True(t, *r.ReplTerminated, "an uncaught exception must report repl_terminated explicitly") + require.Equal(t, doomedID, r.ReplId, "the terminated response carries the dead REPL's ID") + + r = executeBrowserRepl(t, svc, &oapi.ExecuteBrowserReplJSONRequestBody{Code: `repl.write(JSON.stringify(typeof doomed))`}) + require.True(t, r.Success, "error: %v", r.Error) + require.Equal(t, "undefined", requireJSONWrite(t, r)) + require.NotEqual(t, doomedID, r.ReplId) + + idleID := r.ReplId + r = executeBrowserRepl(t, svc, &oapi.ExecuteBrowserReplJSONRequestBody{Code: ` + setTimeout(() => { throw new Error('boom-idle') }, 20); + 'scheduled' + `}) + require.True(t, r.Success, "error: %v", r.Error) + time.Sleep(500 * time.Millisecond) // let the timer fire and the child exit + r = executeBrowserRepl(t, svc, &oapi.ExecuteBrowserReplJSONRequestBody{Code: "repl.id"}) + require.True(t, r.Success, "error: %v", r.Error) + require.NotEqual(t, idleID, r.ReplId, "an idle-time uncaught exception must cost the REPL its ID") +} + +func TestBrowserReplRequestLineCapEnforced(t *testing.T) { + svc := newBrowserReplSvc(t) + + r := executeBrowserRepl(t, svc, &oapi.ExecuteBrowserReplJSONRequestBody{Code: "1"}) + require.True(t, r.Success) + + conn, err := net.Dial("unix", browserReplSocketPath()) + require.NoError(t, err) + defer conn.Close() + + payload := []byte(`{"id":"big","code":"` + strings.Repeat("A", 8*1024*1024+1000) + `"}` + "\n") + _, err = conn.Write(payload) + require.NoError(t, err) + _ = conn.SetReadDeadline(time.Now().Add(5 * time.Second)) + line, err := bufio.NewReader(conn).ReadBytes('\n') + require.NoError(t, err) + var resp map[string]any + require.NoError(t, json.Unmarshal(line, &resp)) + require.Equal(t, false, resp["success"]) + require.Contains(t, resp["error"], "byte limit") + + r2 := executeBrowserRepl(t, svc, &oapi.ExecuteBrowserReplJSONRequestBody{Code: "repl.id"}) + require.True(t, r2.Success) + require.Equal(t, r.ReplId, r2.ReplId) +} + +func TestBrowserReplHalfClosedClientReceivesResponse(t *testing.T) { + svc := newBrowserReplSvc(t) + + r := executeBrowserRepl(t, svc, &oapi.ExecuteBrowserReplJSONRequestBody{Code: "1"}) + require.True(t, r.Success) + + raw, err := net.Dial("unix", browserReplSocketPath()) + require.NoError(t, err) + conn := raw.(*net.UnixConn) + defer conn.Close() + + _, err = conn.Write([]byte(`{"id":"hc","code":"40 + 2","timeout_ms":5000}` + "\n")) + require.NoError(t, err) + require.NoError(t, conn.CloseWrite()) // SHUT_WR + + _ = conn.SetReadDeadline(time.Now().Add(10 * time.Second)) + data, err := io.ReadAll(conn) // reads until the daemon ends its side + require.NoError(t, err) + var resp map[string]any + require.NoError(t, json.Unmarshal(bytes.TrimSpace(data), &resp)) + require.Equal(t, "hc", resp["id"]) + require.Equal(t, true, resp["success"]) + require.NotContains(t, resp, "result") +} + +func TestBrowserReplSocketPreservesSplitUTF8(t *testing.T) { + svc := newBrowserReplSvc(t) + r := executeBrowserRepl(t, svc, &oapi.ExecuteBrowserReplJSONRequestBody{Code: "1"}) + require.True(t, r.Success) + + conn, err := net.Dial("unix", browserReplSocketPath()) + require.NoError(t, err) + defer conn.Close() + payload := []byte(`{"id":"utf8","code":"repl.write(\"café\")","timeout_ms":5000}` + "\n") + split := bytes.Index(payload, []byte("é")) + 1 + require.Greater(t, split, 1) + _, err = conn.Write(payload[:split]) + require.NoError(t, err) + _, err = conn.Write(payload[split:]) + require.NoError(t, err) + + _ = conn.SetReadDeadline(time.Now().Add(10 * time.Second)) + line, err := bufio.NewReader(conn).ReadBytes('\n') + require.NoError(t, err) + var daemonResponse browserReplDaemonResponse + require.NoError(t, json.Unmarshal(line, &daemonResponse)) + require.True(t, daemonResponse.Success, daemonResponse.Error) + require.Len(t, daemonResponse.Content, 1) + var text oapi.BrowserReplTextContent + require.NoError(t, json.Unmarshal(daemonResponse.Content[0], &text)) + require.Equal(t, "café", text.Text) +} + +func TestBrowserReplNewTabWaitsForRendererCommit(t *testing.T) { + fake := newFakeCDPServer(t) + fake.mu.Lock() + fake.targets[0].URL = "about:blank" + fake.rendererHrefs["target-page-1"] = "about:blank" + fake.mu.Unlock() + fake.delayCommit.Store(true) + t.Setenv("CDP_ENDPOINT", fake.wsURL()) + + svc := newBrowserReplSvc(t) + + r := executeBrowserRepl(t, svc, &oapi.ExecuteBrowserReplJSONRequestBody{Code: ` + const nt = await newTab("https://example.com/2"); + const href = await js("location.href"); + repl.write(JSON.stringify({ id: nt, href })) + `}) + require.True(t, r.Success, "error: %v", r.Error) + res, ok := requireJSONWrite(t, r).(map[string]any) + require.True(t, ok) + require.Equal(t, "target-page-1", res["id"], "newTab should reuse the attached blank target") + require.Equal(t, "https://example.com/2", res["href"], + "newTab must wait for the reused target's renderer-level navigation commit") +} + +func TestBrowserReplScrollTimeoutSurfacesUnknownOutcome(t *testing.T) { + fake := newFakeCDPServer(t) + t.Setenv("CDP_ENDPOINT", fake.wsURL()) + svc := newBrowserReplSvc(t) + + r := executeBrowserRepl(t, svc, &oapi.ExecuteBrowserReplJSONRequestBody{Code: `await scroll(100, 100, 240, 0)`}) + require.True(t, r.Success, "error: %v", r.Error) + + fake.hangMouseWheel.Store(true) + timeoutSec := 30 + start := time.Now() + r = executeBrowserRepl(t, svc, &oapi.ExecuteBrowserReplJSONRequestBody{ + Code: `await scroll(100, 100, 240, 0)`, + TimeoutSec: &timeoutSec, + }) + require.Less(t, time.Since(start), 15*time.Second) + require.False(t, r.Success) + require.Contains(t, *r.Error, "outcome unknown") + require.Contains(t, *r.Error, "was not retried") + require.False(t, fake.sawScrollBy.Load(), "an unknown wheel outcome must not trigger a second mechanism") + + r2 := executeBrowserRepl(t, svc, &oapi.ExecuteBrowserReplJSONRequestBody{Code: "repl.id"}) + require.True(t, r2.Success) + require.Equal(t, r.ReplId, r2.ReplId) +} diff --git a/server/cmd/api/api/browser_repl_lifecycle_test.go b/server/cmd/api/api/browser_repl_lifecycle_test.go new file mode 100644 index 00000000..8c69fef8 --- /dev/null +++ b/server/cmd/api/api/browser_repl_lifecycle_test.go @@ -0,0 +1,984 @@ +package api + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "net/http" + "net/http/httptest" + "runtime" + "strconv" + "strings" + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/coder/websocket" + "github.com/kernel/kernel-images/server/lib/oapi" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestBrowserReplValidation(t *testing.T) { + svc := newBrowserReplSvc(t) + + resp, err := svc.ExecuteBrowserRepl(context.Background(), oapi.ExecuteBrowserReplRequestObject{Body: nil}) + require.NoError(t, err) + require.IsType(t, oapi.ExecuteBrowserRepl400JSONResponse{}, resp) + + empty := "" + resp, err = svc.ExecuteBrowserRepl(context.Background(), oapi.ExecuteBrowserReplRequestObject{ + Body: &oapi.ExecuteBrowserReplJSONRequestBody{Code: empty}, + }) + require.NoError(t, err) + require.IsType(t, oapi.ExecuteBrowserRepl400JSONResponse{}, resp) + + require.Nil(t, svc.browserRepl.child) +} + +func TestBrowserReplPersistenceAndStableID(t *testing.T) { + svc := newBrowserReplSvc(t) + r := requireExec(t, svc, "var counter = 40; repl.write(JSON.stringify(counter + 2))", float64(42)) + require.NotEmpty(t, r.ReplId) + require.Equal(t, r.ReplId, requireExec(t, svc, "repl.write(JSON.stringify(counter))", float64(40)).ReplId) + requireExec(t, svc, "const added = await Promise.resolve(5); repl.write(JSON.stringify(added))", float64(5)) + requireExec(t, svc, "repl.write(JSON.stringify(added + counter))", float64(45)) + requireExec(t, svc, `const { readFileSync } = await import("fs"); repl.write(JSON.stringify(typeof readFileSync))`, "function") + requireExec(t, svc, "repl.write(JSON.stringify(repl.id))", r.ReplId) +} + +func TestBrowserReplReset(t *testing.T) { + svc := newBrowserReplSvc(t) + + r1 := executeBrowserRepl(t, svc, &oapi.ExecuteBrowserReplJSONRequestBody{Code: "var ephemeral = 1; ephemeral"}) + require.True(t, r1.Success) + oldID := r1.ReplId + oldPid := browserReplTestChild(t, svc).cmd.Process.Pid + + reset := true + r2 := executeBrowserRepl(t, svc, &oapi.ExecuteBrowserReplJSONRequestBody{Code: "", Reset: &reset}) + require.True(t, r2.Success) + require.NotEmpty(t, r2.ReplId) + require.NotEqual(t, oldID, r2.ReplId, "reset must generate a new CUID2") + require.False(t, processAlive(oldPid), "reset must kill the previous REPL process") + + r3 := requireExec(t, svc, `repl.write(JSON.stringify(typeof ephemeral))`, "undefined") + require.Equal(t, r2.ReplId, r3.ReplId) +} + +func TestBrowserReplErrorKeepsREPL(t *testing.T) { + svc := newBrowserReplSvc(t) + failed := requireExecError(t, svc, "var survives = true; throw new Error('boom')", "boom") + require.True(t, failed.ReplTerminated == nil || !*failed.ReplTerminated) + require.Equal(t, failed.ReplId, requireExec(t, svc, "repl.write(JSON.stringify(survives))", true).ReplId) +} + +func TestBrowserReplTimeoutTerminates(t *testing.T) { + svc := newBrowserReplSvc(t) + + r1 := executeBrowserRepl(t, svc, &oapi.ExecuteBrowserReplJSONRequestBody{Code: "1"}) + require.True(t, r1.Success) + oldID := r1.ReplId + oldPid := browserReplTestChild(t, svc).cmd.Process.Pid + + timeoutSec := 1 + start := time.Now() + r2 := executeBrowserRepl(t, svc, &oapi.ExecuteBrowserReplJSONRequestBody{ + Code: "while (true) {}", + TimeoutSec: &timeoutSec, + }) + elapsed := time.Since(start) + + require.False(t, r2.Success) + require.Equal(t, oldID, r2.ReplId, "a timeout response carries the terminated REPL's ID") + require.NotNil(t, r2.ReplTerminated) + require.True(t, *r2.ReplTerminated) + require.NotNil(t, r2.Error) + require.Contains(t, *r2.Error, "execution timed out after 1000ms") + require.Less(t, elapsed, 10*time.Second, "the parent must kill an uninterruptible loop promptly") + require.False(t, processAlive(oldPid), "timeout must kill the REPL process") + + require.Nil(t, svc.browserRepl.child) + + r3 := executeBrowserRepl(t, svc, &oapi.ExecuteBrowserReplJSONRequestBody{Code: "'fresh'"}) + require.True(t, r3.Success) + require.NotEqual(t, oldID, r3.ReplId, "the next request lazily starts a fresh REPL") +} + +func TestBrowserReplInterruptibleTimeoutTerminates(t *testing.T) { + svc := newBrowserReplSvc(t) + + r1 := executeBrowserRepl(t, svc, &oapi.ExecuteBrowserReplJSONRequestBody{Code: "1"}) + require.True(t, r1.Success) + oldID := r1.ReplId + oldPid := browserReplTestChild(t, svc).cmd.Process.Pid + + timeoutSec := 1 + start := time.Now() + r2 := executeBrowserRepl(t, svc, &oapi.ExecuteBrowserReplJSONRequestBody{ + Code: `setTimeout(() => repl.write("LEAKED-LATE-OUTPUT"), 2000); await new Promise(() => {})`, + TimeoutSec: &timeoutSec, + }) + elapsed := time.Since(start) + + require.False(t, r2.Success) + require.NotNil(t, r2.Error) + require.Contains(t, *r2.Error, "timed out") + require.Equal(t, oldID, r2.ReplId, "a timeout response carries the terminated REPL's ID") + require.NotNil(t, r2.ReplTerminated) + require.True(t, *r2.ReplTerminated, "an interruptible timeout is still destructive") + require.Less(t, elapsed, 15*time.Second, "a daemon-side timeout must answer promptly") + require.False(t, processAlive(oldPid), "timeout must kill the REPL process") + require.Nil(t, svc.browserRepl.child, "no replacement starts until the next request") + + time.Sleep(2500 * time.Millisecond) + r3 := executeBrowserRepl(t, svc, &oapi.ExecuteBrowserReplJSONRequestBody{Code: "'fresh'"}) + require.True(t, r3.Success) + require.NotEqual(t, oldID, r3.ReplId, "the next request lazily starts a fresh REPL") + if r3.Content != nil { + for _, item := range *r3.Content { + if txt, err := item.AsBrowserReplTextContent(); err == nil { + require.NotContains(t, txt.Text, "LEAKED-LATE-OUTPUT", + "output from a terminated execution must not leak into a later execution") + } + } + } +} + +func TestBrowserReplCrashRecovery(t *testing.T) { + svc := newBrowserReplSvc(t) + + r1 := executeBrowserRepl(t, svc, &oapi.ExecuteBrowserReplJSONRequestBody{Code: "var before = 1"}) + require.True(t, r1.Success) + + child := browserReplTestChild(t, svc) + require.NoError(t, child.cmd.Process.Kill()) + deadline := time.Now().Add(5 * time.Second) + for processAlive(child.cmd.Process.Pid) && time.Now().Before(deadline) { + time.Sleep(20 * time.Millisecond) + } + + r2 := executeBrowserRepl(t, svc, &oapi.ExecuteBrowserReplJSONRequestBody{Code: "'recovered'"}) + require.True(t, r2.Success, "error: %v", r2.Error) + require.NotEqual(t, r1.ReplId, r2.ReplId, "crash recovery must use a new CUID2") +} + +func TestBrowserReplShutdownKillsChild(t *testing.T) { + svc := newBrowserReplSvc(t) + + r1 := executeBrowserRepl(t, svc, &oapi.ExecuteBrowserReplJSONRequestBody{Code: "1"}) + require.True(t, r1.Success) + pid := browserReplTestChild(t, svc).cmd.Process.Pid + require.True(t, processAlive(pid)) + + require.NoError(t, svc.Shutdown(context.Background())) + + deadline := time.Now().Add(5 * time.Second) + for processAlive(pid) && time.Now().Before(deadline) { + time.Sleep(20 * time.Millisecond) + } + require.False(t, processAlive(pid), "API shutdown must kill the REPL child") + require.Nil(t, svc.browserRepl.child) +} + +func TestBrowserReplSerializesConcurrentRequests(t *testing.T) { + svc := newBrowserReplSvc(t) + + r1 := executeBrowserRepl(t, svc, &oapi.ExecuteBrowserReplJSONRequestBody{Code: "globalThis.seq = []; 1"}) + require.True(t, r1.Success) + + const n = 8 + results := make([]float64, n) + var wg sync.WaitGroup + for i := 0; i < n; i++ { + wg.Add(1) + go func() { + defer wg.Done() + resp, err := svc.ExecuteBrowserRepl(context.Background(), oapi.ExecuteBrowserReplRequestObject{ + Body: &oapi.ExecuteBrowserReplJSONRequestBody{Code: "var concurrentValue = seq.push(seq.length) - 1; repl.write(JSON.stringify(concurrentValue))"}, + }) + assert.NoError(t, err) + if typed, ok := resp.(oapi.ExecuteBrowserRepl200JSONResponse); ok && typed.Success && typed.Content != nil { + text, textErr := (*typed.Content)[0].AsBrowserReplTextContent() + assert.NoError(t, textErr) + var v float64 + assert.NoError(t, json.Unmarshal([]byte(text.Text), &v)) + results[int(v)] = v + } + }() + } + wg.Wait() + + seen := map[int]bool{} + for _, v := range results { + seen[int(v)] = true + } + require.Len(t, seen, n, "expected %d distinct sequential values, got %v", n, results) + for i := 0; i < n; i++ { + require.True(t, seen[i], "missing sequence value %d in %v", i, results) + } +} + +func TestBrowserReplCancelledWhileQueuedDoesNotExecute(t *testing.T) { + svc := newBrowserReplSvc(t) + initial := requireExec(t, svc, `var cancelledDispatch = 0`, nil) + + firstDone := make(chan error, 1) + go func() { + _, err := svc.ExecuteBrowserRepl(context.Background(), oapi.ExecuteBrowserReplRequestObject{ + Body: &oapi.ExecuteBrowserReplJSONRequestBody{Code: `await new Promise(resolve => setTimeout(resolve, 500))`}, + }) + firstDone <- err + }() + require.Eventually(t, func() bool { return browserReplBusy(svc) }, time.Second, 10*time.Millisecond, "first execution never acquired admission") + + queuedCtx, cancel := context.WithCancel(context.Background()) + queuedDone := make(chan error, 1) + go func() { + _, err := svc.ExecuteBrowserRepl(queuedCtx, oapi.ExecuteBrowserReplRequestObject{ + Body: &oapi.ExecuteBrowserReplJSONRequestBody{Code: `cancelledDispatch = 1`}, + }) + queuedDone <- err + }() + time.Sleep(50 * time.Millisecond) + cancel() + + select { + case err := <-queuedDone: + require.ErrorIs(t, err, context.Canceled) + case <-time.After(time.Second): + t.Fatal("cancelled queued request did not return promptly") + } + require.NoError(t, <-firstDone) + checked := requireExec(t, svc, `repl.write(JSON.stringify(cancelledDispatch))`, float64(0)) + require.Equal(t, initial.ReplId, checked.ReplId, "queued cancellation must preserve healthy REPL state") +} + +func TestBrowserReplResetKillsTermIgnoringDescendant(t *testing.T) { + if runtime.GOOS != "linux" { + t.Skip("process-group lifecycle is only enforced on Linux") + } + svc := newBrowserReplSvc(t) + r := executeBrowserRepl(t, svc, &oapi.ExecuteBrowserReplJSONRequestBody{Code: ` + var childProcess = await import("node:child_process"); + var stubbornChild = childProcess.spawn(process.execPath, ["-e", "process.on('SIGTERM', () => {}); setInterval(() => {}, 1000)"], {stdio: "ignore"}); + repl.write(JSON.stringify(stubbornChild.pid)); + `}) + require.True(t, r.Success, "error: %v", r.Error) + pid := int(requireJSONWrite(t, r).(float64)) + require.True(t, processAlive(pid)) + + reset := true + fresh := executeBrowserRepl(t, svc, &oapi.ExecuteBrowserReplJSONRequestBody{Reset: &reset}) + require.True(t, fresh.Success, "error: %v", fresh.Error) + require.Eventually(t, func() bool { return !processAlive(pid) }, 3*time.Second, 20*time.Millisecond, + "reset must kill descendants after the Node group leader exits") +} + +func TestBrowserReplIdleCrashKillsDescendantsOnReplacement(t *testing.T) { + if runtime.GOOS != "linux" { + t.Skip("process-group lifecycle is only enforced on Linux") + } + svc := newBrowserReplSvc(t) + r := executeBrowserRepl(t, svc, &oapi.ExecuteBrowserReplJSONRequestBody{Code: ` + var childProcess = await import("node:child_process"); + var idleCrashChild = childProcess.spawn(process.execPath, ["-e", "setInterval(() => {}, 1000)"], {stdio: "ignore"}); + repl.write(JSON.stringify(idleCrashChild.pid)); + `}) + require.True(t, r.Success, "error: %v", r.Error) + pid := int(requireJSONWrite(t, r).(float64)) + + child := browserReplTestChild(t, svc) + require.NotNil(t, child) + require.NoError(t, child.cmd.Process.Kill()) + require.Eventually(t, func() bool { return !processAlive(child.cmd.Process.Pid) }, 3*time.Second, 20*time.Millisecond) + + fresh := executeBrowserRepl(t, svc, &oapi.ExecuteBrowserReplJSONRequestBody{Code: `repl.write("fresh")`}) + require.True(t, fresh.Success, "error: %v", fresh.Error) + require.NotEqual(t, r.ReplId, fresh.ReplId) + require.Eventually(t, func() bool { return !processAlive(pid) }, 3*time.Second, 20*time.Millisecond, + "replacing an unexpectedly exited REPL must kill its remaining process group") +} + +func TestBrowserReplShutdownObservesDeadlineDuringExecution(t *testing.T) { + svc := newBrowserReplSvc(t) + executionDone := make(chan error, 1) + go func() { + _, err := svc.ExecuteBrowserRepl(context.Background(), oapi.ExecuteBrowserReplRequestObject{ + Body: &oapi.ExecuteBrowserReplJSONRequestBody{Code: `await new Promise(() => {})`}, + }) + executionDone <- err + }() + require.Eventually(t, func() bool { return browserReplBusy(svc) }, time.Second, 10*time.Millisecond, "execution never acquired admission") + + shutdownCtx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + start := time.Now() + err := svc.Shutdown(shutdownCtx) + require.Less(t, time.Since(start), 2*time.Second, "shutdown must not wait for the cell timeout") + require.True(t, err == nil || errors.Is(err, context.DeadlineExceeded), "unexpected shutdown error: %v", err) + + select { + case <-executionDone: + case <-time.After(5 * time.Second): + t.Fatal("cancelled execution did not unwind") + } + _, err = svc.ExecuteBrowserRepl(context.Background(), oapi.ExecuteBrowserReplRequestObject{ + Body: &oapi.ExecuteBrowserReplJSONRequestBody{Code: `repl.write("must not run")`}, + }) + require.ErrorIs(t, err, errBrowserReplShuttingDown, "shutdown must permanently stop admission") +} + +func TestBrowserReplContentOrdering(t *testing.T) { + svc := newBrowserReplSvc(t) + + code := ` + repl.write("a"); + console.log("b", 1); + console.error("c"); + repl.write({ k: 1 }); + const png = Buffer.from("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==", "base64"); + await repl.emitImage(png); + repl.write("after"); + ` + r := executeBrowserRepl(t, svc, &oapi.ExecuteBrowserReplJSONRequestBody{Code: code}) + require.True(t, r.Success, "error: %v", r.Error) + require.NotNil(t, r.Content) + require.Len(t, *r.Content, 6) + + textAt := func(i int) oapi.BrowserReplTextContent { + t.Helper() + v, err := (*r.Content)[i].AsBrowserReplTextContent() + require.NoError(t, err, "content item %d should be text", i) + return v + } + + require.Equal(t, "write", string(textAt(0).Channel)) + require.Equal(t, "a", textAt(0).Text) + require.Equal(t, "stdout", string(textAt(1).Channel)) + require.Equal(t, "b 1", textAt(1).Text) + require.Equal(t, "stderr", string(textAt(2).Channel)) + require.Equal(t, "c", textAt(2).Text) + require.Equal(t, "write", string(textAt(3).Channel)) + require.Equal(t, "{ k: 1 }", textAt(3).Text) + + img, err := (*r.Content)[4].AsBrowserReplImageContent() + require.NoError(t, err, "content item 4 should be an image") + require.Equal(t, "image/png", img.MimeType) + require.NotEmpty(t, img.DataB64) + + require.Equal(t, "write", string(textAt(5).Channel)) + require.Equal(t, "after", textAt(5).Text) +} + +func TestBrowserReplImageValidation(t *testing.T) { + svc := newBrowserReplSvc(t) + + r := executeBrowserRepl(t, svc, &oapi.ExecuteBrowserReplJSONRequestBody{ + Code: `await repl.emitImage("data:text/html;base64,PGI+eDwvYj4=")`, + }) + require.False(t, r.Success) + require.Contains(t, *r.Error, "image/*") + + r = executeBrowserRepl(t, svc, &oapi.ExecuteBrowserReplJSONRequestBody{ + Code: `await repl.emitImage(Buffer.from("not an image at all"))`, + }) + require.False(t, r.Success) + require.Contains(t, *r.Error, "unrecognized image data") + + r = executeBrowserRepl(t, svc, &oapi.ExecuteBrowserReplJSONRequestBody{ + Code: `await repl.emitImage({bytes: Buffer.from([137,80,78,71,0,0,0,0,0]), mimeType: "image/" + "x".repeat(1000)})`, + }) + require.False(t, r.Success) + require.Contains(t, *r.Error, "MIME type must be") + + r = executeBrowserRepl(t, svc, &oapi.ExecuteBrowserReplJSONRequestBody{ + Code: fmt.Sprintf(` + const png = Buffer.from(%q, "base64"); + const u8 = new Uint8Array(png); // context-realm Uint8Array + await repl.emitImage(u8); // direct Uint8Array + await repl.emitImage(u8.buffer); // direct ArrayBuffer (context realm) + await repl.emitImage(new DataView(u8.buffer)); // DataView + await repl.emitImage({ bytes: new Uint8Array(png) }); // bytes form + await repl.emitImage({ bytes: u8.buffer, mimeType: "image/png" }); + `, fakeCDPTinyPNG), + }) + require.True(t, r.Success, "error: %v", r.Error) + require.NotNil(t, r.Content) + imageCount := 0 + for _, item := range *r.Content { + if img, err := item.AsBrowserReplImageContent(); err == nil && img.Type == "image" { + imageCount++ + require.Equal(t, "image/png", img.MimeType) + } + } + require.Equal(t, 5, imageCount, "every documented ImageInput form must emit an image") + + r = executeBrowserRepl(t, svc, &oapi.ExecuteBrowserReplJSONRequestBody{Code: "'alive'"}) + require.True(t, r.Success) +} + +func TestBrowserReplTruncation(t *testing.T) { + svc := newBrowserReplSvc(t) + + r := executeBrowserRepl(t, svc, &oapi.ExecuteBrowserReplJSONRequestBody{ + Code: `repl.write("x".repeat(400 * 1024)); "ok"`, + }) + require.True(t, r.Success) + require.NotNil(t, r.ContentTruncated) + require.True(t, *r.ContentTruncated) + require.NotNil(t, r.Content) + v, err := (*r.Content)[0].AsBrowserReplTextContent() + require.NoError(t, err) + require.LessOrEqual(t, len(v.Text), 256*1024) + +} + +func TestBrowserReplImageSizeLimits(t *testing.T) { + svc := newBrowserReplSvc(t) + + const pngHeader = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==" + + r := executeBrowserRepl(t, svc, &oapi.ExecuteBrowserReplJSONRequestBody{ + Code: fmt.Sprintf(`const big = Buffer.concat([Buffer.from("%s", "base64"), Buffer.alloc(9 * 1024 * 1024)]); await repl.emitImage(big)`, pngHeader), + }) + require.False(t, r.Success) + require.NotNil(t, r.Error) + require.Contains(t, *r.Error, "per-image limit") + + r = executeBrowserRepl(t, svc, &oapi.ExecuteBrowserReplJSONRequestBody{ + Code: fmt.Sprintf(` + const mk = () => Buffer.concat([Buffer.from("%s", "base64"), Buffer.alloc(6 * 1024 * 1024)]); + await repl.emitImage(mk()); + await repl.emitImage(mk()); + await repl.emitImage(mk()); + "done" + `, pngHeader), + }) + require.True(t, r.Success, "error: %v", r.Error) + require.NotNil(t, r.ContentTruncated) + require.True(t, *r.ContentTruncated) + require.NotNil(t, r.Content) + images := 0 + sawDropNote := false + for _, item := range *r.Content { + if img, err := item.AsBrowserReplImageContent(); err == nil && img.Type == "image" { + images++ + } + if txt, err := item.AsBrowserReplTextContent(); err == nil && strings.Contains(txt.Text, "aggregate response image limit") { + sawDropNote = true + } + } + require.Equal(t, 2, images, "the third 6 MiB image exceeds the 16 MiB aggregate limit") + require.True(t, sawDropNote, "a stderr note records the dropped image") +} + +func TestBrowserReplRequestWireLimitIsNonDestructive(t *testing.T) { + svc := newBrowserReplSvc(t) + + initial := executeBrowserRepl(t, svc, &oapi.ExecuteBrowserReplJSONRequestBody{Code: "repl.id"}) + require.True(t, initial.Success, "initial request failed: %v", initial.Error) + + maxBodyCode := strings.Repeat("a", maxBrowserReplBodyBytes-64) + body, err := json.Marshal(oapi.ExecuteBrowserReplJSONRequestBody{Code: maxBodyCode}) + require.NoError(t, err) + require.LessOrEqual(t, len(body), maxBrowserReplBodyBytes) + resp, err := svc.ExecuteBrowserRepl(context.Background(), oapi.ExecuteBrowserReplRequestObject{ + Body: &oapi.ExecuteBrowserReplJSONRequestBody{Code: maxBodyCode}, + }) + require.NoError(t, err) + badRequest, ok := resp.(oapi.ExecuteBrowserRepl400JSONResponse) + require.True(t, ok, "expected a clean 400, got %T", resp) + require.Contains(t, badRequest.Message, "code too large") + + stillAlive := executeBrowserRepl(t, svc, &oapi.ExecuteBrowserReplJSONRequestBody{Code: "repl.id"}) + require.True(t, stillAlive.Success, "REPL did not survive oversized request: %v", stillAlive.Error) + require.Equal(t, initial.ReplId, stillAlive.ReplId) + + htmlCode := `"` + strings.Repeat("<", 1_300_000) + `"` + htmlBody := []byte(`{"code":` + strconv.Quote(htmlCode) + `}`) + require.LessOrEqual(t, len(htmlBody), maxBrowserReplBodyBytes) + prepared, err := prepareBrowserReplRequest(htmlCode, 60*time.Second) + require.NoError(t, err) + require.LessOrEqual(t, len(prepared.bytes)-1, maxBrowserReplRequestLineBytes) + htmlResult := executeBrowserRepl(t, svc, &oapi.ExecuteBrowserReplJSONRequestBody{Code: htmlCode}) + require.True(t, htmlResult.Success, "HTML-heavy request failed: %v", htmlResult.Error) + require.Equal(t, initial.ReplId, htmlResult.ReplId) + + oversizedHTMLCode := strings.Repeat("<", maxBrowserReplBodyBytes-64) + rawHTMLBody := []byte(`{"code":"` + oversizedHTMLCode + `"}`) + require.LessOrEqual(t, len(rawHTMLBody), maxBrowserReplBodyBytes) + resp, err = svc.ExecuteBrowserRepl(context.Background(), oapi.ExecuteBrowserReplRequestObject{ + Body: &oapi.ExecuteBrowserReplJSONRequestBody{Code: oversizedHTMLCode}, + }) + require.NoError(t, err) + badRequest, ok = resp.(oapi.ExecuteBrowserRepl400JSONResponse) + require.True(t, ok, "expected a clean HTML-heavy 400, got %T", resp) + require.Contains(t, badRequest.Message, "code too large") + stillAlive = executeBrowserRepl(t, svc, &oapi.ExecuteBrowserReplJSONRequestBody{Code: "repl.id"}) + require.True(t, stillAlive.Success, "REPL did not survive HTML-heavy request: %v", stillAlive.Error) + require.Equal(t, initial.ReplId, stillAlive.ReplId) +} + +func TestBrowserReplTimeoutSecValidation(t *testing.T) { + svc := newBrowserReplSvc(t) + + for _, v := range []int{-5, 0, 301, 100000} { + resp, err := svc.ExecuteBrowserRepl(context.Background(), oapi.ExecuteBrowserReplRequestObject{ + Body: &oapi.ExecuteBrowserReplJSONRequestBody{Code: "1", TimeoutSec: &v}, + }) + require.NoError(t, err) + require.IsType(t, oapi.ExecuteBrowserRepl400JSONResponse{}, resp, "timeout_sec=%d must be rejected", v) + } + require.Nil(t, svc.browserRepl.child, "invalid requests must not start a REPL") + + for _, v := range []int{1, 300} { + r := executeBrowserRepl(t, svc, &oapi.ExecuteBrowserReplJSONRequestBody{Code: "1", TimeoutSec: &v}) + require.True(t, r.Success, "timeout_sec=%d must be accepted: %v", v, r.Error) + } +} + +type fakeCDPTarget struct { + ID string + Type string + Title string + URL string +} + +type fakeCDPServer struct { + t *testing.T + + mu sync.Mutex + targets []fakeCDPTarget + nextSeq int + conns map[*websocket.Conn]struct{} + queuedEvents []map[string]any + + lastKeyEvent map[string]any + + frozen atomic.Bool + hangSession atomic.Bool + failNextAttach atomic.Int32 + closeNextConnsAfterFirstCommand atomic.Int32 + dropNextCreateTargetResponse atomic.Bool + totalConns atomic.Int32 + attachCount atomic.Int32 + detachCount atomic.Int32 + + rendererHrefs map[string]string + pendingHrefPolls map[string]int + delayCommit atomic.Bool + hangMouseWheel atomic.Bool + sawScrollBy atomic.Bool + swallowNextWheel bool + delayedWheelMs atomic.Int64 + activatedTargets []string + scrollY int64 + maxScrollY int64 + wheelDispatchCount int + screenshotData string + + http *httptest.Server +} + +const fakeCDPTinyPNG = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==" + +func newFakeCDPServer(t *testing.T) *fakeCDPServer { + t.Helper() + f := &fakeCDPServer{ + t: t, + conns: map[*websocket.Conn]struct{}{}, + rendererHrefs: map[string]string{"target-page-1": "https://example.com/"}, + pendingHrefPolls: map[string]int{}, + screenshotData: fakeCDPTinyPNG, + targets: []fakeCDPTarget{ + {ID: "target-page-1", Type: "page", Title: "Example Domain", URL: "https://example.com/"}, + {ID: "target-internal-1", Type: "page", Title: "New Tab", URL: "chrome://newtab/"}, + {ID: "target-frame-1", Type: "iframe", Title: "Frame", URL: "https://frame.example.com/widget"}, + }, + } + f.http = httptest.NewServer(http.HandlerFunc(f.handler)) + t.Cleanup(f.http.Close) + return f +} + +func (f *fakeCDPServer) wsURL() string { + return "ws" + strings.TrimPrefix(f.http.URL, "http") + "/devtools/browser/fake" +} + +func (f *fakeCDPServer) Restart() { + f.mu.Lock() + defer f.mu.Unlock() + for conn := range f.conns { + _ = conn.Close(websocket.StatusGoingAway, "chromium restart") + } +} + +func (f *fakeCDPServer) connCount() int { + f.mu.Lock() + defer f.mu.Unlock() + return len(f.conns) +} + +func (f *fakeCDPServer) lastKeyEventParams() map[string]any { + f.mu.Lock() + defer f.mu.Unlock() + return f.lastKeyEvent +} + +func (f *fakeCDPServer) handler(w http.ResponseWriter, r *http.Request) { + conn, err := websocket.Accept(w, r, &websocket.AcceptOptions{InsecureSkipVerify: true}) + if err != nil { + return + } + f.totalConns.Add(1) + f.mu.Lock() + f.conns[conn] = struct{}{} + f.mu.Unlock() + defer func() { + f.mu.Lock() + delete(f.conns, conn) + f.mu.Unlock() + conn.CloseNow() + }() + + ctx := r.Context() + for { + _, msg, err := conn.Read(ctx) + if err != nil { + return + } + var req struct { + ID int `json:"id"` + Method string `json:"method"` + Params json.RawMessage `json:"params"` + SessionID string `json:"sessionId"` + } + if err := json.Unmarshal(msg, &req); err != nil || req.ID == 0 { + continue + } + if f.closeNextConnsAfterFirstCommand.Load() > 0 { + f.closeNextConnsAfterFirstCommand.Add(-1) + return + } + if req.SessionID != "" && f.hangSession.Load() { + continue + } + if req.Method == "Input.dispatchMouseEvent" && f.hangMouseWheel.Load() && + strings.Contains(string(req.Params), "mouseWheel") { + continue + } + result, events, dispatchErr := f.dispatch(req.Method, req.Params, req.SessionID) + if req.Method == "Target.createTarget" && f.dropNextCreateTargetResponse.CompareAndSwap(true, false) { + return + } + var resp map[string]any + if dispatchErr != nil { + resp = map[string]any{"id": req.ID, "error": map[string]any{"code": -32601, "message": dispatchErr.Error()}} + } else { + resp = map[string]any{"id": req.ID, "result": result} + } + data, _ := json.Marshal(resp) + if err := conn.Write(ctx, websocket.MessageText, data); err != nil { + return + } + for _, ev := range events { + data, _ := json.Marshal(ev) + if err := conn.Write(ctx, websocket.MessageText, data); err != nil { + return + } + } + for _, ev := range f.takeQueuedEvents() { + data, _ := json.Marshal(ev) + if err := conn.Write(ctx, websocket.MessageText, data); err != nil { + return + } + } + } +} + +func (f *fakeCDPServer) queueEvent(ev map[string]any) { + f.mu.Lock() + defer f.mu.Unlock() + f.queuedEvents = append(f.queuedEvents, ev) +} + +func (f *fakeCDPServer) takeQueuedEvents() []map[string]any { + f.mu.Lock() + defer f.mu.Unlock() + evs := f.queuedEvents + f.queuedEvents = nil + return evs +} + +func (f *fakeCDPServer) dispatch(method string, params json.RawMessage, sessionID string) (any, []map[string]any, error) { + switch method { + case "Target.getTargets": + f.mu.Lock() + defer f.mu.Unlock() + infos := make([]map[string]any, 0, len(f.targets)) + for _, tgt := range f.targets { + infos = append(infos, map[string]any{ + "targetId": tgt.ID, + "type": tgt.Type, + "title": tgt.Title, + "url": tgt.URL, + "attached": true, + }) + } + return map[string]any{"targetInfos": infos}, nil, nil + case "Target.attachToTarget": + f.attachCount.Add(1) + var p struct { + TargetID string `json:"targetId"` + } + _ = json.Unmarshal(params, &p) + if f.failNextAttach.Load() > 0 { + f.failNextAttach.Add(-1) + return nil, nil, fmt.Errorf("No target with given id found") + } + sid := "session-" + p.TargetID + ev := map[string]any{ + "method": "Page.loadEventFired", + "params": map[string]any{"timestamp": 1}, + "sessionId": sid, + } + return map[string]any{"sessionId": sid}, []map[string]any{ev}, nil + case "Target.detachFromTarget": + f.detachCount.Add(1) + return map[string]any{}, nil, nil + case "Target.activateTarget": + var p struct { + TargetID string `json:"targetId"` + } + _ = json.Unmarshal(params, &p) + f.mu.Lock() + f.activatedTargets = append(f.activatedTargets, p.TargetID) + f.mu.Unlock() + return map[string]any{}, nil, nil + case "Target.createTarget": + var p struct { + URL string `json:"url"` + } + _ = json.Unmarshal(params, &p) + if p.URL == "" { + p.URL = "about:blank" + } + f.mu.Lock() + f.nextSeq++ + id := fmt.Sprintf("target-created-%d", f.nextSeq) + f.targets = append(f.targets, fakeCDPTarget{ID: id, Type: "page", Title: p.URL, URL: p.URL}) + if f.delayCommit.Load() { + f.rendererHrefs[id] = "about:blank" + f.pendingHrefPolls[id] = 3 + } else { + f.rendererHrefs[id] = p.URL + } + f.mu.Unlock() + return map[string]any{"targetId": id}, nil, nil + case "Target.closeTarget": + var p struct { + TargetID string `json:"targetId"` + } + _ = json.Unmarshal(params, &p) + f.mu.Lock() + for i, tgt := range f.targets { + if tgt.ID == p.TargetID { + f.targets = append(f.targets[:i], f.targets[i+1:]...) + break + } + } + f.mu.Unlock() + return map[string]any{}, nil, nil + case "Page.enable", "DOM.enable", "Runtime.enable", "Network.enable": + return map[string]any{}, nil, nil + case "Accessibility.getFullAXTree": + return map[string]any{"nodes": []any{ + map[string]any{ + "backendDOMNodeId": 77, + "role": map[string]any{"value": "button"}, + "name": map[string]any{"value": " Search flights "}, + "properties": []any{map[string]any{"name": "disabled", "value": map[string]any{"value": false}}}, + }, + map[string]any{ + "backendDOMNodeId": 78, + "role": map[string]any{"value": "textbox"}, + "name": map[string]any{"value": "Destination"}, + "value": map[string]any{"value": "SFO"}, + }, + map[string]any{"backendDOMNodeId": 79, "ignored": true}, + map[string]any{"role": map[string]any{"value": "StaticText"}}, + }}, nil, nil + case "Page.navigate": + var p struct { + URL string `json:"url"` + } + _ = json.Unmarshal(params, &p) + if sessionID != "" && p.URL != "" { + targetID := strings.TrimPrefix(sessionID, "session-") + f.mu.Lock() + if f.delayCommit.Load() { + f.pendingHrefPolls[targetID] = 3 + } else { + f.rendererHrefs[targetID] = p.URL + } + for i := range f.targets { + if f.targets[i].ID == targetID { + f.targets[i].URL = p.URL + break + } + } + f.mu.Unlock() + } + return map[string]any{"frameId": "frame-1", "loaderId": "loader-1"}, nil, nil + case "Page.getLayoutMetrics": + return map[string]any{ + "cssLayoutViewport": map[string]any{"clientWidth": 800, "clientHeight": 600}, + "cssContentSize": map[string]any{"width": 800, "height": 2000}, + }, nil, nil + case "Page.captureScreenshot": + f.mu.Lock() + data := f.screenshotData + f.mu.Unlock() + return map[string]any{"data": data}, nil, nil + case "Input.dispatchMouseEvent", "Input.insertText": + if method == "Input.dispatchMouseEvent" && strings.Contains(string(params), "mouseWheel") { + var p struct { + DeltaY float64 `json:"deltaY"` + } + _ = json.Unmarshal(params, &p) + f.mu.Lock() + f.wheelDispatchCount++ + swallow := f.swallowNextWheel + if swallow { + f.swallowNextWheel = false + } + delayMs := f.delayedWheelMs.Load() + if !swallow && delayMs <= 0 { + f.scrollY += int64(p.DeltaY) + } + f.mu.Unlock() + if !swallow && delayMs > 0 { + delta := int64(p.DeltaY) + time.AfterFunc(time.Duration(delayMs)*time.Millisecond, func() { + f.mu.Lock() + f.scrollY += delta + f.mu.Unlock() + }) + } + } + return map[string]any{}, nil, nil + case "Input.dispatchKeyEvent": + var p map[string]any + _ = json.Unmarshal(params, &p) + f.mu.Lock() + f.lastKeyEvent = p + f.mu.Unlock() + return map[string]any{}, nil, nil + case "DOM.getDocument": + return map[string]any{"root": map[string]any{"nodeId": 1}}, nil, nil + case "DOM.resolveNode": + var p struct { + BackendNodeID int `json:"backendNodeId"` + } + _ = json.Unmarshal(params, &p) + if p.BackendNodeID <= 0 { + return nil, nil, fmt.Errorf("No node with given id found") + } + return map[string]any{"object": map[string]any{"objectId": fmt.Sprintf("backend-node-%d", p.BackendNodeID)}}, nil, nil + case "DOM.querySelector": + return map[string]any{"nodeId": 42}, nil, nil + case "DOM.setFileInputFiles", "Runtime.releaseObject": + return map[string]any{}, nil, nil + case "Runtime.callFunctionOn": + var p struct { + FunctionDeclaration string `json:"functionDeclaration"` + } + _ = json.Unmarshal(params, &p) + var value any = true + switch { + case strings.Contains(p.FunctionDeclaration, "resolveBackendClickTarget"): + value = map[string]any{"status": "ready", "x": 30, "y": 40} + case strings.Contains(p.FunctionDeclaration, "resolveBackendFillTarget"): + value = map[string]any{"status": "ready"} + } + return map[string]any{"result": map[string]any{"type": "object", "value": value}}, nil, nil + case "Runtime.evaluate": + if f.frozen.Load() { + return nil, nil, fmt.Errorf("renderer is frozen behind a modal dialog") + } + var p struct { + Expression string `json:"expression"` + } + _ = json.Unmarshal(params, &p) + return map[string]any{ + "result": map[string]any{"type": "object", "value": f.evalExpression(p.Expression, sessionID)}, + }, nil, nil + } + return nil, nil, fmt.Errorf("fake CDP: unhandled method %s", method) +} + +func (f *fakeCDPServer) evalExpression(expr string, sessionID string) any { + switch { + case expr == "location.href": + targetID := strings.TrimPrefix(sessionID, "session-") + f.mu.Lock() + defer f.mu.Unlock() + if remaining, ok := f.pendingHrefPolls[targetID]; ok { + if remaining > 1 { + f.pendingHrefPolls[targetID] = remaining - 1 + return "about:blank" + } + delete(f.pendingHrefPolls, targetID) + url := "" + for _, tgt := range f.targets { + if tgt.ID == targetID { + url = tgt.URL + break + } + } + f.rendererHrefs[targetID] = url + return url + } + if href, ok := f.rendererHrefs[targetID]; ok { + return href + } + return "about:blank" + case strings.Contains(expr, "scrollingElement"): + f.mu.Lock() + defer f.mu.Unlock() + return map[string]any{ + "x": 0, + "y": f.scrollY, + "maxX": 0, + "maxY": f.maxScrollY, + } + case strings.Contains(expr, "window.scrollBy"): + f.sawScrollBy.Store(true) + return true + case strings.Contains(expr, "location.href"): + return map[string]any{ + "url": "https://example.com/", + "title": "Example Domain", + "viewport": map[string]any{"width": 800, "height": 600}, + "scroll": map[string]any{"x": 0, "y": 0}, + "page": map[string]any{"width": 800, "height": 2000}, + "ready_state": "complete", + } + case expr == "document.readyState": + return "complete" + case strings.HasPrefix(expr, "(function elementState"): + return !strings.Contains(expr, `"#never"`) + case strings.HasPrefix(expr, "(function resolveFillTarget"): + return map[string]any{"status": "ready"} + case strings.HasPrefix(expr, "(async function resolveClickTarget"): + return map[string]any{"status": "ready", "x": 10, "y": 20} + case strings.HasPrefix(expr, "(function (selector, value)"), + strings.HasPrefix(expr, "(function (selector, key, opts)"): + return true + default: + return expr + } +} diff --git a/server/cmd/api/api/browser_repl_proc_linux.go b/server/cmd/api/api/browser_repl_proc_linux.go new file mode 100644 index 00000000..a199d138 --- /dev/null +++ b/server/cmd/api/api/browser_repl_proc_linux.go @@ -0,0 +1,30 @@ +//go:build linux + +package api + +import ( + "os/exec" + "syscall" +) + +var ( + termSignal = syscall.SIGTERM + killSignal = syscall.SIGKILL +) + +// configureBrowserReplCmd puts the REPL child in its own process group so a +// reset or timeout also terminates subprocesses. Parent-death signaling stops +// the daemon if the API process exits unexpectedly. +func configureBrowserReplCmd(cmd *exec.Cmd) { + cmd.SysProcAttr = &syscall.SysProcAttr{ + Setpgid: true, + Pdeathsig: syscall.SIGKILL, + } +} + +func signalBrowserReplGroup(cmd *exec.Cmd, sig syscall.Signal) error { + if cmd == nil || cmd.Process == nil { + return nil + } + return syscall.Kill(-cmd.Process.Pid, sig) +} diff --git a/server/cmd/api/api/browser_repl_proc_other.go b/server/cmd/api/api/browser_repl_proc_other.go new file mode 100644 index 00000000..0dd7e6a2 --- /dev/null +++ b/server/cmd/api/api/browser_repl_proc_other.go @@ -0,0 +1,30 @@ +//go:build !linux + +package api + +import ( + "os/exec" + "syscall" +) + +// Fallback process management for non-Linux builds (development only; the +// production images run Linux). There is no parent-death signaling here, and +// "group" signaling degrades to signaling the child process itself. +var ( + termSignal = syscall.SIGTERM + killSignal = syscall.SIGKILL +) + +func configureBrowserReplCmd(cmd *exec.Cmd) {} + +func signalBrowserReplGroup(cmd *exec.Cmd, sig syscall.Signal) error { + if cmd == nil || cmd.Process == nil { + return nil + } + if sig == termSignal { + // Best effort graceful stop; escalated by the caller if it fails. + _ = cmd.Process.Signal(sig) + return nil + } + return cmd.Process.Kill() +} diff --git a/server/cmd/api/main.go b/server/cmd/api/main.go index b3384c6f..c6574556 100644 --- a/server/cmd/api/main.go +++ b/server/cmd/api/main.go @@ -251,6 +251,8 @@ func main() { // api_call event emission. Off until the telemetry handlers flip it on. r.Use(api.TelemetryHTTPMiddleware(telemetrySession.Publish)) r.Use(api.WebMCPRequestSizeMiddleware) + // Enforce additionalProperties: false on POST /repl. + r.Use(api.StrictBrowserReplBodyMiddleware) strictHandler := oapi.NewStrictHandlerWithOptions(apiService, []oapi.StrictMiddlewareFunc{ api.TelemetryStrictMiddleware(), }, oapi.StrictHTTPServerOptions{ diff --git a/server/docs/repl.md b/server/docs/repl.md new file mode 100644 index 00000000..0620888e --- /dev/null +++ b/server/docs/repl.md @@ -0,0 +1,287 @@ +# Browser REPL + +`POST /repl` evaluates JavaScript in a persistent Node.js runtime associated with one browser instance. The runtime keeps top-level bindings between calls and includes browser-control helpers as both bare globals and properties of the frozen `browser` object. The same frozen WebMCP client is available as `webmcp` and `browser.webmcp`. + +The endpoint is unrestricted code execution inside the browser VM, not a sandbox. Code can access Node built-ins, installed packages, files, environment variables, processes, and the network. + +## Request and response + +```http +POST /repl +Content-Type: application/json +``` + +```json +{ + "code": "const title = (await pageInfo()).title; repl.write(title)", + "timeout_sec": 60, + "reset": false +} +``` + +`code` is required, but may be empty when `reset` is `true`. `timeout_sec` is an integer from 1 through 300 and defaults to 60. `reset` defaults to `false`. Unknown request fields are rejected. + +Execution success and JavaScript failures both return HTTP 200: + +```json +{ + "success": true, + "repl_id": "tz4a98xxat96iws9zmbrgj3a", + "content": [ + {"type": "text", "channel": "write", "text": "Example Domain"} + ], + "content_truncated": false, + "duration_ms": 12 +} +``` + +`success` and `repl_id` are always present in an execution result. `content`, `content_truncated`, `duration_ms`, `error`, `stack`, and `repl_terminated` are included when applicable. Content items are ordered and are either text items like the example or image items shaped as `{type: "image", mime_type: "image/png", data_b64: "..."}`. + +Invalid requests return HTTP 400, request bodies over 8 MiB return HTTP 413, and failure to start the REPL returns HTTP 500. These errors use the API's standard `{message}` error body. + +## Evaluation + +- JavaScript only; TypeScript is not supported. +- Top-level `await` and dynamic `import()` are supported. Static imports/exports and top-level `return` are rejected; CommonJS `require` is not preloaded. +- Expression values are not returned automatically. A successful execution may produce zero output. +- Top-level `var`, `let`, `const`, function, and class bindings persist across calls. Top-level `var` declarations inside control-flow statements persist too; function locals and nested block-scoped declarations do not. +- Normal JavaScript redeclaration rules apply across cells. `var` and function declarations may redeclare one another, while lexical declarations conflict with every prior declaration. +- Persistent names are live bindings: closures and timers observe assignments made by later cells. Declared function names are preserved, although `Function.prototype.toString()` may expose an internal generated alias. +- Calls are serialized. Concurrent requests never execute at the same time, but callers that require a particular order should await each call because admission order is not a public FIFO guarantee. +- Canceling a request while it is waiting for admission does not execute its code or replace healthy state. Cancellation after dispatch terminates the REPL because the execution outcome may be unknown. API shutdown rejects queued work, cancels active work, and terminates the child. +- Syntax errors and ordinary exceptions return `success: false` with `error` and, when available, `stack`; they do not terminate the REPL. A failed lexical initializer reserves its name in the temporal dead zone until reset. Earlier declarators that initialized before the failure remain initialized. +- A settled unhandled promise rejection does not terminate the REPL; it is emitted on `stderr`, immediately or with the next execution when it occurs between cells. An uncaught exception, timeout, crash, OOM, or protocol failure does terminate it. Such responses set `repl_terminated: true` when the API can return the terminated process's result; the next request starts a fresh REPL with a new `repl_id`. + +Use `{ "code": "", "reset": true }` to explicitly replace the REPL and clear all state. + +## Runtime globals + +The context preloads `repl`, captured `console` methods, every browser helper, `browser`, `webmcp`, timers, `queueMicrotask`, `Buffer`, `process`, `fetch`, `URL`, `URLSearchParams`, text encoders/decoders, abort controllers/signals, `structuredClone`, `atob`, `btoa`, and `crypto`. Node built-ins and installed packages are available through dynamic `import()`. + +`repl`, `browser`, and `webmcp` are frozen objects. `webmcp === browser.webmcp`, and each bare browser helper is the same function exposed on `browser`. + +## Output + +Output is optional. Code may produce no content, use `repl.write(...)` or `repl.emitImage(...)`, call console methods, or combine those mechanisms. + +`repl.write(value)` creates a `{type: "text", channel: "write"}` item without appending a newline, and non-string values receive a bounded inspection. + +```js +const info = await pageInfo(); +repl.write({url: info.url, title: info.title}); +``` + +Expression values are intentionally ignored. + +`console.log`, `console.info`, `console.debug`, `console.dir`, and `console.table` are captured as `stdout`; `console.warn`, `console.error`, and `console.trace` use `stderr`. Console output does not append a newline. + +Output produced by timers or settled promise rejections between executions is buffered and prepended to the next execution. The buffer retains the newest 1,000 items. + +`repl.emitImage(input)` creates ordered image output. It accepts an `image/*` base64 data URL; PNG, JPEG, or WebP `Buffer`, `ArrayBuffer` view, or `ArrayBuffer` data; `{bytes, mimeType?}`; or `{path, mimeType?}`. Without an explicit MIME type, byte and file inputs must be recognizable as PNG, JPEG, or WebP. An explicit MIME type must be a short `image/*` value. `captureScreenshot()` writes a VM-local file; it can optionally be included in the response: + +```js +const path = await captureScreenshot("/tmp/page.png"); +await repl.emitImage({path}); +``` + +`repl.id` is the CUID2 of the state-holding process and matches the response's `repl_id`. + +## Browser helpers + +Every helper below is available directly and under `browser`, for example `await gotoUrl(url)` and `await browser.gotoUrl(url)`. + +- **`cdp(method, params?, sessionId?)`** — Send an unrestricted DevTools Protocol command. Omit `sessionId` for the attached target session; `Target.*`, `Browser.*`, `SystemInfo.*`, and `Storage.*` commands are automatically routed browser-wide. Pass a session ID explicitly for another attached target, or `null` to force browser-level routing. After a connection loss, only observational or idempotent setup commands may retry; mutations and page evaluation throw with an unknown outcome instead of risking duplicate execution. +- **`drainEvents()`** — Return and remove all buffered DevTools events across sessions. The connection-wide event ring retains at most the newest 500 events. Items have `{method, params, sessionId?, time}`, where `time` is the wall-clock observation time in Unix milliseconds. +- **`waitForEvent(method, options?)`** — Arm a one-shot DevTools event waiter before triggering an action. It matches the attached page session by default; use `sessionId: null` for a browser-level event or a session ID for another target. `predicate(event)` receives `{method, params, sessionId?, time}`. It returns that event or `null` after `timeoutSec` (default `30`), while connection and predicate failures throw. Attach a page with `ensureRealTab()` or `newTab()` before using the default session. +- **`gotoUrl(url)`** — Navigate the attached target and return the raw `Page.navigate` result. It does not wait for document load; use `waitForLoad()`, a rendered-state wait, or a pre-armed CDP event when synchronization is required. +- **`pageInfo()`** — Return `{url, title, viewport: {width, height}, scroll: {x, y}, page: {width, height}, ready_state, dialog}`. A pending JavaScript dialog freezes renderer evaluation, so in that case the helper returns the dialog and best-effort browser-level URL/title instead of the viewport/document fields. +- **`accessibilitySnapshot()`** — Return a flat `{url, title, nodes}` projection of Chromium's computed accessibility tree. Ignored nodes and nodes without a DOM backend ID are omitted. Each node has `backendNodeId`, role, whitespace-normalized accessible name, optional value, and available `checked`, `pressed`, `selected`, `expanded`, or `disabled` state. `checked` and `pressed` may be `"mixed"`. `backendNodeId` is Chromium's `DOM.BackendNodeId`; it can be passed directly to element helpers but becomes stale when navigation or DOM replacement removes that node. +- **`click(target, options?)`** — Click a CSS selector, an accessibility node or `{backendNodeId}`, or finite viewport coordinates `{x, y}`. Selector and backend-node clicks wait for a visible, enabled, stable, unobscured target, scroll it into view, and dispatch physical mouse input. Hidden duplicate selector matches are ignored; multiple visible matches are rejected. Coordinate clicks dispatch immediately. Options are `button: "left" | "right" | "middle"`, positive-integer `clickCount`, and element-only `timeoutSec` (default `10`). The helper does not wait for the action's resulting navigation or UI state. +- **`typeText(text)`** — Insert text into the currently focused element with CDP `Input.insertText`; it is text insertion, not a sequence of physical key presses. +- **`fillInput(target, text, options?)`** — Target a selector, accessibility node, or `{backendNodeId}`; wait until it is visible, enabled, and editable; scroll and focus it; optionally clear it; type with physical-style key events; then dispatch `input` and `change`. Options are `clearFirst` (default `true`) and `timeoutSec` (default `10`). +- **`pressKey(key, modifiers?)`** — Send one physical-style key-down/optional-char/key-up sequence using a self-contained US keyboard layout. Multi-character key names are case-insensitive and common aliases such as `Return`, `Esc`, and `Spacebar` are normalized. Single characters retain their exact case. Modifiers may be the DevTools bitfield (`1=Alt`, `2=Control`, `4=Meta`, `8=Shift`), an array such as `["Control"]`, or an object such as `{ctrl: true}`. Unknown named keys and modifiers throw. +- **`scroll(x, y, dy?, dx?)`** — Dispatch one wheel event at viewport coordinates. Vertical `dy` defaults to `-300`; horizontal `dx` defaults to `0`. It never retries or substitutes another scrolling mechanism when the outcome is unknown; verify the resulting scroll state explicitly. +- **`captureScreenshot(path?, fullPage?, maxDim?)`** — Capture a PNG to a VM-local path and return that path, overwriting an existing file. The default is `/tmp/shot.png`; `fullPage` defaults to `false`. When set, positive-integer `maxDim` post-processes the pixels so neither dimension exceeds the limit, without enlargement. It does not emit the image automatically. +- **`listTabs(includeChrome?)`** — List page targets as `{targetId, title, url}`. Internal browser pages are included by default; pass `false` to exclude them. +- **`currentTab()`** — Return `{targetId, title, url}` for the attached tab. +- **`switchTab(target)`** — Attach to a target ID or an object with `targetId` (including results from `listTabs()`, `currentTab()`, or `iframeTarget()`), and return the DevTools session ID. Selector and backend-node helpers subsequently operate on this attached target. +- **`newTab(url?)`** — Reuse the attached blank/new-tab page when possible; otherwise create and attach a blank tab. Navigate when `url` is supplied and return the target ID. +- **`closeTab(target?)`** — Close a target ID, an object with `targetId`, or the currently attached target when omitted. It waits up to five seconds, best effort, for the target to disappear from the browser target list. +- **`ensureRealTab()`** — Keep or attach to an existing non-internal page and return its tab metadata; return `null` if none exists. +- **`iframeTarget(urlSubstring)`** — Find an out-of-process iframe target and return `{targetId, url, title, type}`, or return `null`. Use that `targetId` with `js(..., {targetId})` to inspect or manipulate cross-origin frame content. +- **`waitMs(milliseconds?)`** — Sleep for a number of milliseconds, defaulting to `1000`. Prefer rendered state or authoritative events for synchronization. +- **`waitForLoad(timeoutSec?)`** — Poll until `document.readyState === "complete"`; return `true` when loaded or `false` after `timeoutSec` (default `15`). +- **`waitForElement(target, options?)`** — Poll until a selector, accessibility node, or `{backendNodeId}` reaches `state: "attached" | "detached" | "visible" | "hidden"`; return `true` on success or `false` after `timeoutSec` (default `10`). State defaults to `"visible"`, and all selector matches are considered so a hidden duplicate cannot mask a visible match. +- **`waitForNetworkIdle(idleSec?, timeoutSec?)`** — Return `true` once no tracked requests for the attached target remain in flight for the idle interval, or `false` on timeout. Defaults to 0.5 idle seconds and a 30-second timeout. +- **`js(expressionOrFunction, options?)`** — Evaluate submitted page code exactly once in the attached target or `options.targetId`, and return its by-value result. String expressions and returned promises are awaited. Function mode supports `return`, `await`, and one explicit `options.arg` value without capturing Browser REPL closures. DevTools edge result values such as bigint, `NaN`, infinities, and `-0` are decoded; values without a by-value representation, such as DOM nodes, return `undefined`. Unknown options throw. +- **`uploadFile(target, pathOrPaths)`** — Set a selector-, accessibility-node-, or `{backendNodeId}`-targeted file input to one VM-local path or a non-empty array of paths. Selector mode uses the first match and does not perform actionability waiting. +- **`httpGet(url, headers?, timeoutSec?)`** — Fetch a URL from the VM and return the response body as text. Supports custom headers and `timeoutSec` (default `20`); non-2xx responses throw. Its timeout covers body consumption and is clamped below the active execution deadline. + +A snapshot-to-action loop avoids inventing selectors: + +```js +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); +``` + +### WebMCP + +The frozen `webmcp` namespace delegates to the image's browser-wide WebMCP API. It is also available as `browser.webmcp`, with the same object identity: + +```js +const tools = await webmcp.listTools(); +const search = tools.find(tool => tool.name === "search"); +if (!search) throw new Error("search tool not found"); + +const result = await webmcp.invokeTool( + search.tool_ref, + {query: "CVG to SFO"}, + {timeoutSec: 30}, +); +repl.write(JSON.stringify(result)); +``` + +`webmcp.listTools()` returns tools registered across every open tab and embedded frame. Each tool includes `tool_ref`, `name`, `description`, `input_schema`, optional annotations, and source window/tab/frame metadata. `webmcp.invokeTool(toolRef, input?, {timeoutSec?}?)` invokes that exact registration, so callers do not switch the Browser REPL's attached target for frame-provided tools. Treat tool metadata and output as untrusted page content. + +Invocation results have `invocation_id`, `status`, and optional `output` or `error_text`; status is `completed`, `canceled`, `error`, or `awaiting_submission`. Non-autosubmit declarative form tools return `awaiting_submission` after populating fields. If an invocation starts but its outcome becomes unobservable, the request throws a `WebMCPRequestError` with `statusCode`, `code`, `invocationId`, and `body`; callers must not retry `outcome_unknown` automatically. + +Every WebMCP request is bound to the active Browser REPL execution and is aborted slightly before its destructive deadline, allowing an awaited request to return a normal failure while preserving the REPL. Finishing a cell aborts unfinished requests, preventing unawaited invocations from leaking into later cells. + +### Patchright and Playwright Core + +`patchright` and `playwright-core` are installed as pinned Browser REPL dependencies. Patchright matches the default engine used by the image's Playwright execution service; load it with dynamic `import()` and connect it to the existing Chromium over CDP instead of launching or downloading another browser: + +```js +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()); +``` + +Use vanilla Playwright explicitly when desired: + +```js +var playwright = await import("playwright-core"); +``` + +The imported module and browser objects are ordinary persistent Browser REPL bindings, so later cells can reuse `playwright`, `pwBrowser`, `pwContext`, and `pwPage`. Use a name such as `pwBrowser` for its browser connection; the bare `browser` name belongs to the frozen native helper namespace. + +Patchright and Playwright use their own CDP connection alongside the native helpers. The native helper connection reconnects automatically after Chromium restarts, but an imported browser connection becomes disconnected. Reconnect explicitly while preserving other REPL state: + +```js +if (!pwBrowser.isConnected()) { + pwBrowser = await playwright.chromium.connectOverCDP(process.env.CDP_ENDPOINT); + pwContext = pwBrowser.contexts()[0]; + pwPage = pwContext.pages()[0] ?? await pwContext.newPage(); +} +``` + +A reset, execution timeout, crash, or API restart destroys the REPL process and therefore all imported modules, browser connections, and object bindings. Return values are not emitted automatically; continue to use `repl.write(...)`, console methods, or `repl.emitImage(...)` for output. + +### Installing additional packages + +Packages installed globally through the process execution API are immediately available to bare dynamic imports. Pin a version when reproducibility matters: + +```http +POST /process/exec +Content-Type: application/json + +{"command":"npm","args":["install","-g","example-package@1.2.3"]} +``` + +Then use the package in the Browser REPL without `require`: + +```js +var examplePackage = await import("example-package"); +``` + +The installation lasts for the browser VM's lifetime. Node caches imported modules within the REPL process; after replacing an installed version, reset the REPL before importing it again. Do not install into `/usr/local/lib/browser-repl`, because that directory contains the REPL's own locked runtime dependencies. + +### Page JavaScript + +`js()` has two explicit, single-execution modes. A string is evaluated directly as an expression: + +```js +const title = await js("document.title"); +const status = await js("fetch('/health').then(response => response.status)"); +``` + +String mode does not accept top-level `return` or top-level `await` syntax. Use a page function for statement bodies, `return`, or `await`: + +```js +const data = await js(async () => { + const response = await fetch("/api/data"); + return response.json(); +}); +``` + +Page functions are serialized, invoked once in the page, and do not capture bindings from the Browser REPL. Pass one explicit by-value argument with `options.arg`: + +```js +const selector = "main"; +const text = await js( + ({ selector, limit }) => document.querySelector(selector)?.innerText.slice(0, limit) ?? null, + { arg: { selector, limit: 1000 } }, +); +``` + +Arguments may contain `undefined`, `null`, booleans, strings, numbers (including `NaN`, infinities, and `-0`), bigint, arrays, and plain objects. Function values, symbol values, cycles, and non-plain class instances are rejected. Page exceptions and rejected promises throw from `js()`. + +Use `options.targetId` for another target: + +```js +const frame = await iframeTarget("checkout.example"); +const title = await js(() => document.title, { targetId: frame.targetId }); +``` + +Page functions execute in the web page, not the persistent Node Browser REPL. Navigation replaces their page execution context. Keep reusable automation functions in the Browser REPL and have them call `js()` with explicit arguments. + +### Iframes + +Same-origin frames are directly accessible from top-page JavaScript through `iframe.contentDocument`. Cross-site frames commonly run as separate DevTools targets; use `iframeTarget()` and `js(..., {targetId})` to evaluate inside them without relying on top-page same-origin access: + +```js +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, +}); +``` + +Not every iframe is a separate target. For lower-level frame cases, use `cdp()` with `Page.getFrameTree`, `Page.createIsolatedWorld`, and `Runtime.evaluate`; unrestricted CDP access remains the escape hatch for inspecting and manipulating frame execution contexts. Selector helpers operate on the currently attached target, while coordinate `click({x, y})` can interact with the composed viewport. + +Wait helpers and DevTools commands clamp internal deadlines below the request's `timeout_sec`, allowing waits to return `false` or `null` and command failures to return cleanly before the destructive execution timeout. + +Pre-arm `waitForEvent()` when synchronization depends on a DevTools event that could fire before its triggering command returns: + +```js +if (!(await ensureRealTab())) await newTab(); +const completed = waitForEvent("Browser.downloadProgress", { + sessionId: null, + timeoutSec: 30, + predicate: event => event.params.state === "completed", +}); +await click('[aria-label="Download"]'); +const event = await completed; +if (!event) throw new Error("download did not complete"); +``` + +## Limits + +- 8 MiB HTTP body and encoded daemon request line +- 8 MiB per image +- 16 MiB aggregate image data per response +- 256 KiB aggregate text per response +- 64 KiB error and 256 KiB stack text per response +- 10,000 ordered output items per execution +- 1,000 output items buffered between executions +- 48 MiB daemon response + +Dropping or truncating content sets `content_truncated`. An individual image over 8 MiB throws; exceeding the aggregate image or item limits drops later content. Exceeding the daemon response limit is treated as protocol failure and terminates the REPL. + +`BROWSER_REPL_HEAP_MB` configures V8 old-space only. It is not a total RSS, CPU, or subprocess-tree quota. The Browser REPL has the same unrestricted process access and VM-level resource boundary as `/process/exec`; browser-VM/container resource controls remain the total process-tree budget. diff --git a/server/e2e/e2e_browser_repl_test.go b/server/e2e/e2e_browser_repl_test.go new file mode 100644 index 00000000..c2594265 --- /dev/null +++ b/server/e2e/e2e_browser_repl_test.go @@ -0,0 +1,645 @@ +package e2e + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "os/exec" + "testing" + "time" + + instanceoapi "github.com/kernel/kernel-images/server/lib/oapi" + "github.com/stretchr/testify/require" +) + +func replError(r *instanceoapi.BrowserReplResult) string { + if r.Error != nil { + return *r.Error + } + return "" +} + +func replJSONWrite(t *testing.T, r *instanceoapi.BrowserReplResult) any { + t.Helper() + require.NotNil(t, r.Content) + for i := len(*r.Content) - 1; i >= 0; i-- { + text, err := (*r.Content)[i].AsBrowserReplTextContent() + if err != nil || text.Channel != instanceoapi.BrowserReplTextContentChannelWrite { + continue + } + var value any + require.NoError(t, json.Unmarshal([]byte(text.Text), &value)) + return value + } + t.Fatal("response has no repl.write content") + return nil +} + +func executeBrowserRepl(t *testing.T, ctx context.Context, client *instanceoapi.ClientWithResponses, body instanceoapi.ExecuteBrowserReplJSONRequestBody) *instanceoapi.BrowserReplResult { + t.Helper() + rsp, err := client.ExecuteBrowserReplWithResponse(ctx, body) + require.NoError(t, err, "Browser REPL request error: %v", err) + require.Equal(t, http.StatusOK, rsp.StatusCode(), "unexpected status for Browser REPL: %s body=%s", rsp.Status(), string(rsp.Body)) + require.NotNil(t, rsp.JSON200, "expected JSON200 response, got nil") + return rsp.JSON200 +} + +func restartChromium(t *testing.T, ctx context.Context, c *TestContainer, client *instanceoapi.ClientWithResponses) { + t.Helper() + args := []string{"-c", "/etc/supervisor/supervisord.conf", "restart", "chromium"} + rsp, err := client.ProcessExecWithResponse(ctx, instanceoapi.ProcessExecJSONRequestBody{ + Command: "supervisorctl", + Args: &args, + }) + require.NoError(t, err, "supervisorctl restart request error: %v", err) + require.Equal(t, http.StatusOK, rsp.StatusCode(), "supervisorctl restart unexpected status: %s body=%s", rsp.Status(), string(rsp.Body)) + require.NotNil(t, rsp.JSON200) + if rsp.JSON200.ExitCode != nil { + require.Equal(t, 0, *rsp.JSON200.ExitCode, "supervisorctl restart chromium failed: stderr=%v", rsp.JSON200.StderrB64) + } + require.NoError(t, c.WaitDevTools(ctx), "DevTools not ready after chromium restart") +} + +func TestBrowserReplAPI(t *testing.T) { + t.Parallel() + + if _, err := exec.LookPath("docker"); err != nil { + t.Skipf("docker not available: %v", err) + } + + for _, image := range []struct { + name string + image string + }{ + {"Headless", headlessImage}, + {"Headful", headfulImage}, + } { + t.Run(image.name, func(t *testing.T) { + t.Parallel() + runBrowserReplAPI(t, image.image) + }) + } +} + +func runBrowserReplAPI(t *testing.T, image string) { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) + defer cancel() + + c := NewTestContainer(t, image) + require.NoError(t, c.Start(ctx, ContainerConfig{}), "failed to start container") + defer c.Stop(ctx) + + require.NoError(t, c.WaitReady(ctx), "api not ready") + + client, err := c.APIClient() + require.NoError(t, err) + + t.Run("persistence and stable repl id", func(t *testing.T) { + r1 := executeBrowserRepl(t, ctx, client, instanceoapi.ExecuteBrowserReplJSONRequestBody{ + Code: `var counter = 40; counter + 2`, + }) + require.True(t, r1.Success, "error: %s", replError(r1)) + require.NotEmpty(t, r1.ReplId) + + r2 := executeBrowserRepl(t, ctx, client, instanceoapi.ExecuteBrowserReplJSONRequestBody{ + Code: `const added = await Promise.resolve(2); repl.write(JSON.stringify(counter + added))`, + }) + require.True(t, r2.Success, "error: %s", replError(r2)) + require.Equal(t, r1.ReplId, r2.ReplId, "repl_id must be stable across calls") + + resultBytes, _ := replJSONWrite(t, r2).(float64) + require.Equal(t, float64(42), resultBytes) + }) + + t.Run("patchright and playwright core imports persist", func(t *testing.T) { + r1 := executeBrowserRepl(t, ctx, client, instanceoapi.ExecuteBrowserReplJSONRequestBody{ + Code: ` + var playwright = await import("patchright"); + var vanillaPlaywright = await import("playwright-core"); + var globallyInstalledEsbuild = await import("esbuild"); + var pwBrowserConnection = await playwright.chromium.connectOverCDP(process.env.CDP_ENDPOINT); + var pwContext = pwBrowserConnection.contexts()[0]; + var pwImportedPage = await pwContext.newPage(); + await pwImportedPage.setContent("Playwright in REPL
ready
"); + var pwImportedPageIdentity = pwImportedPage; + repl.write(JSON.stringify({ + patchrightConnect: typeof playwright.chromium.connectOverCDP, + playwrightConnect: typeof vanillaPlaywright.chromium.connectOverCDP, + globalPackageImport: typeof (globallyInstalledEsbuild.transform ?? globallyInstalledEsbuild.default?.transform), + title: await pwImportedPage.title(), + })); + `, + }) + require.True(t, r1.Success, "error: %s", replError(r1)) + first, ok := replJSONWrite(t, r1).(map[string]interface{}) + require.True(t, ok) + require.Equal(t, "function", first["patchrightConnect"]) + require.Equal(t, "function", first["playwrightConnect"]) + require.Equal(t, "function", first["globalPackageImport"]) + require.Equal(t, "Playwright in REPL", first["title"]) + + r2 := executeBrowserRepl(t, ctx, client, instanceoapi.ExecuteBrowserReplJSONRequestBody{ + Code: ` + repl.write(JSON.stringify({ + samePage: pwImportedPage === pwImportedPageIdentity, + title: await pwImportedPage.title(), + })); + await pwImportedPage.close(); + `, + }) + require.True(t, r2.Success, "error: %s", replError(r2)) + require.Equal(t, r1.ReplId, r2.ReplId) + second, ok := replJSONWrite(t, r2).(map[string]interface{}) + require.True(t, ok) + require.Equal(t, true, second["samePage"]) + require.Equal(t, "Playwright in REPL", second["title"]) + + reset := true + resetResult := executeBrowserRepl(t, ctx, client, instanceoapi.ExecuteBrowserReplJSONRequestBody{Code: "", Reset: &reset}) + require.True(t, resetResult.Success, "error: %s", replError(resetResult)) + }) + + t.Run("browser helpers", func(t *testing.T) { + r := executeBrowserRepl(t, ctx, client, instanceoapi.ExecuteBrowserReplJSONRequestBody{ + Code: ` + if (!(await ensureRealTab())) await newTab(); + var helperLoadPending = waitForEvent("Page.loadEventFired", {timeoutSec: 5}); + await gotoUrl("data:text/html,Browser REPL Helper"); + var helperLoadEvent = await helperLoadPending; + await waitForLoad(); + const info = await pageInfo(); + repl.write(JSON.stringify({title: info.title, eventMethod: helperLoadEvent && helperLoadEvent.method})); + `, + }) + require.True(t, r.Success, "error: %s", replError(r)) + helperResult, ok := replJSONWrite(t, r).(map[string]interface{}) + require.True(t, ok) + require.Equal(t, "Browser REPL Helper", helperResult["title"]) + require.Equal(t, "Page.loadEventFired", helperResult["eventMethod"]) + require.NotNil(t, r.Content) + require.NotEmpty(t, *r.Content) + }) + + t.Run("selector interaction and element states", func(t *testing.T) { + r := executeBrowserRepl(t, ctx, client, instanceoapi.ExecuteBrowserReplJSONRequestBody{ + Code: ` + await ensureRealTab(); + await gotoUrl("data:text/html,"); + await waitForLoad(); + await js(() => { + document.body.innerHTML = ` + "`" + ` + + + + + +
+ ` + "`" + `; + globalThis.__clickCount = 0; + document.querySelectorAll(".action")[1].addEventListener("click", () => { + globalThis.__clickCount++; + const status = document.querySelector("#status"); + status.hidden = false; + setTimeout(() => { status.hidden = true; }, 250); + }); + }); + + const attached = await waitForElement(".action", {state: "attached", timeoutSec: 2}); + await click(".action"); + const visible = await waitForElement("#status", {state: "visible", timeoutSec: 2}); + await fillInput(".field", "hello", {timeoutSec: 2}); + const hidden = await waitForElement("#status", {state: "hidden", timeoutSec: 2}); + await js(() => setTimeout(() => document.querySelector("#remove-me").remove(), 50)); + const detached = await waitForElement("#remove-me", {state: "detached", timeoutSec: 2}); + const snapshot = await accessibilitySnapshot(); + const snapshotButton = snapshot.nodes.find(node => node.role === "button" && node.name === "Search"); + const snapshotField = snapshot.nodes.find(node => node.role === "textbox"); + if (!snapshotButton || !snapshotField) throw new Error("accessibility snapshot omitted controls"); + const backendVisible = await waitForElement(snapshotButton, {state: "visible", timeoutSec: 2}); + await click(snapshotButton); + await fillInput(snapshotField, "world", {timeoutSec: 2}); + const state = await js(() => ({ + clickCount: globalThis.__clickCount, + value: [...document.querySelectorAll(".field")].find(element => !element.hidden).value, + })); + repl.write(JSON.stringify({ + attached, visible, hidden, detached, backendVisible, + snapshotRole: snapshotButton.role, + backendNodeId: snapshotButton.backendNodeId, + ...state, + })); + `, + }) + require.True(t, r.Success, "error: %s", replError(r)) + state, ok := replJSONWrite(t, r).(map[string]interface{}) + require.True(t, ok) + require.Equal(t, true, state["attached"]) + require.Equal(t, true, state["visible"]) + require.Equal(t, true, state["hidden"]) + require.Equal(t, true, state["detached"]) + require.Equal(t, true, state["backendVisible"]) + require.Equal(t, "button", state["snapshotRole"]) + require.Greater(t, state["backendNodeId"].(float64), float64(0)) + require.Equal(t, float64(2), state["clickCount"]) + require.Equal(t, "world", state["value"]) + }) + + t.Run("cross-origin iframe target evaluation", func(t *testing.T) { + r := executeBrowserRepl(t, ctx, client, instanceoapi.ExecuteBrowserReplJSONRequestBody{ + Code: ` + await gotoUrl("data:text/html,"); + await waitForLoad(); + await js(() => { + const iframe = document.createElement("iframe"); + iframe.src = "http://127.0.0.1:10001/spec.yaml"; + document.body.append(iframe); + }); + var crossOriginFrame = null; + for (let attempt = 0; attempt < 50 && !crossOriginFrame; attempt++) { + crossOriginFrame = await iframeTarget("127.0.0.1:10001/spec.yaml"); + if (!crossOriginFrame) await waitMs(100); + } + if (!crossOriginFrame) throw new Error("cross-origin iframe target did not appear"); + var crossOriginState = await js( + () => ({documentNodeType: document.nodeType}), + {targetId: crossOriginFrame.targetId}, + ); + repl.write(JSON.stringify({target: crossOriginFrame, state: crossOriginState})); + `, + }) + require.True(t, r.Success, "error: %s", replError(r)) + result, ok := replJSONWrite(t, r).(map[string]interface{}) + require.True(t, ok) + target, ok := result["target"].(map[string]interface{}) + require.True(t, ok) + require.Equal(t, "iframe", target["type"]) + require.Contains(t, target["url"], "127.0.0.1:10001/spec.yaml") + state, ok := result["state"].(map[string]interface{}) + require.True(t, ok) + require.Equal(t, float64(9), state["documentNodeType"]) + }) + + t.Run("page evaluation modes", func(t *testing.T) { + r := executeBrowserRepl(t, ctx, client, instanceoapi.ExecuteBrowserReplJSONRequestBody{ + Code: ` + await ensureRealTab(); + await gotoUrl("data:text/html,page-evaluation
hello
"); + await waitForLoad(); + await js("globalThis.__browserReplEvaluationCount = 0"); + const result = { + expression: await js("1 + 1"), + promiseExpression: await js("Promise.resolve(3)"), + asyncFunction: await js(async () => { + globalThis.__browserReplEvaluationCount++; + const value = await Promise.resolve(4); + return { value, count: globalThis.__browserReplEvaluationCount }; + }), + argument: await js(({ a, b, edge }) => ({ + sum: a + b, + nan: Number.isNaN(edge.nan), + negativeZero: Object.is(edge.negativeZero, -0), + bigint: edge.bigint.toString(), + }), { arg: { a: 2, b: 3, edge: { nan: NaN, negativeZero: -0, bigint: 42n } } }), + }; + repl.write(JSON.stringify(result)); + `, + }) + require.True(t, r.Success, "error: %s", replError(r)) + result, ok := replJSONWrite(t, r).(map[string]interface{}) + require.True(t, ok) + require.Equal(t, float64(2), result["expression"]) + require.Equal(t, float64(3), result["promiseExpression"]) + asyncResult, ok := result["asyncFunction"].(map[string]interface{}) + require.True(t, ok) + require.Equal(t, float64(4), asyncResult["value"]) + require.Equal(t, float64(1), asyncResult["count"], "the page function executes exactly once") + argument, ok := result["argument"].(map[string]interface{}) + require.True(t, ok) + require.Equal(t, float64(5), argument["sum"]) + require.Equal(t, true, argument["nan"]) + require.Equal(t, true, argument["negativeZero"]) + require.Equal(t, "42", argument["bigint"]) + }) + + t.Run("US keyboard normalization", func(t *testing.T) { + r := executeBrowserRepl(t, ctx, client, instanceoapi.ExecuteBrowserReplJSONRequestBody{ + Code: ` + await ensureRealTab(); + await gotoUrl("data:text/html,"); + await waitForElement("#q", {state: "visible", timeoutSec: 10}); + await js(() => { + globalThis.__keyEvents = []; + const input = document.querySelector("#q"); + input.addEventListener("keydown", event => { + globalThis.__keyEvents.push({ key: event.key, code: event.code, keyCode: event.keyCode, shift: event.shiftKey }); + }); + input.focus(); + }); + await pressKey("ENTER"); + await pressKey("Digit1", ["shift"]); + await pressKey("Esc"); + const events = await js(() => globalThis.__keyEvents); + repl.write(JSON.stringify(events)); + `, + }) + require.True(t, r.Success, "error: %s", replError(r)) + events, ok := replJSONWrite(t, r).([]interface{}) + require.True(t, ok) + require.Len(t, events, 3) + for i, expected := range []struct { + key string + code string + }{ + {key: "Enter", code: "Enter"}, + {key: "!", code: "Digit1"}, + {key: "Escape", code: "Escape"}, + } { + event, ok := events[i].(map[string]interface{}) + require.True(t, ok) + require.Equal(t, expected.key, event["key"]) + require.Equal(t, expected.code, event["code"]) + } + }) + + t.Run("tab management and screenshots", func(t *testing.T) { + r := executeBrowserRepl(t, ctx, client, instanceoapi.ExecuteBrowserReplJSONRequestBody{ + Code: ` + const before = (await listTabs(false)).length; + const tab = await newTab("data:text/html,Screenshot Test
ready
"); + await waitForLoad(); + const tabs = await listTabs(); + const shot = await captureScreenshot("/tmp/e2e-repl.png", false, 800); + await repl.emitImage({ path: shot }); + await closeTab(tab); + ({ before, after: tabs.length, shot }); + `, + }) + require.True(t, r.Success, "error: %s", replError(r)) + require.NotNil(t, r.Content) + var sawImage bool + for _, item := range *r.Content { + if img, err := item.AsBrowserReplImageContent(); err == nil && img.Type == "image" { + sawImage = true + require.Equal(t, "image/png", img.MimeType) + require.NotEmpty(t, img.DataB64) + } + } + require.True(t, sawImage, "expected an emitted screenshot image in content") + }) + + t.Run("pending dialogs reported by pageInfo", func(t *testing.T) { + timeoutSec := 60 + r := executeBrowserRepl(t, ctx, client, instanceoapi.ExecuteBrowserReplJSONRequestBody{ + TimeoutSec: &timeoutSec, + Code: ` + await ensureRealTab(); + await gotoUrl("data:text/html,dlg

x

"); + const seen = []; + for (const [type, source] of [ + ["alert", 'alert("hello-alert")'], + ["confirm", 'confirm("hello-confirm")'], + ["prompt", 'prompt("hello-prompt")'], + ]) { + await cdp("Runtime.evaluate", { expression: "setTimeout(() => { " + source + "; }, 100)" }); + await waitMs(1000); + let info = null; + for (let i = 0; i < 20; i++) { + info = await pageInfo(); + if (info.dialog) break; + await waitMs(200); + } + seen.push({ want: type, got: info.dialog && info.dialog.type, message: info.dialog && info.dialog.message, url: info.url }); + if (info.dialog) { + await cdp("Page.handleJavaScriptDialog", { accept: true }); + await waitMs(300); + } + } + repl.write(JSON.stringify(seen)); + `, + }) + require.True(t, r.Success, "error: %s", replError(r)) + seen, ok := replJSONWrite(t, r).([]interface{}) + require.True(t, ok, "expected array result, got %T", replJSONWrite(t, r)) + require.Len(t, seen, 3) + for i, want := range []string{"alert", "confirm", "prompt"} { + entry, ok := seen[i].(map[string]interface{}) + require.True(t, ok) + require.Equal(t, want, entry["want"]) + require.Equal(t, want, entry["got"], "pageInfo must report the pending %s dialog", want) + require.Contains(t, entry["message"], "hello-"+want) + require.Contains(t, entry["url"], "data:text/html", "pageInfo still reports last-known target metadata") + } + }) + + t.Run("stale pre-attach dialog does not brick the endpoint", func(t *testing.T) { + timeoutSec := 60 + r := executeBrowserRepl(t, ctx, client, instanceoapi.ExecuteBrowserReplJSONRequestBody{ + TimeoutSec: &timeoutSec, + Code: ` + await ensureRealTab(); + await gotoUrl("data:text/html,stale-dialog

x

"); + await js("setTimeout(() => alert('stale'), 50)"); + await waitMs(500); + "dialog-open" + `, + }) + require.True(t, r.Success, "error: %s", replError(r)) + + killArgs := []string{"-f", "browser-repl.js"} + killRsp, err := client.ProcessExecWithResponse(ctx, instanceoapi.ProcessExecJSONRequestBody{ + Command: "pkill", + Args: &killArgs, + }) + require.NoError(t, err, "pkill request error: %v", err) + require.Equal(t, http.StatusOK, killRsp.StatusCode(), "pkill unexpected status: %s", killRsp.Status()) + time.Sleep(time.Second) + + shortTimeout := 10 + r2 := executeBrowserRepl(t, ctx, client, instanceoapi.ExecuteBrowserReplJSONRequestBody{ + TimeoutSec: &shortTimeout, + Code: `await pageInfo()`, + }) + require.False(t, r2.Success, "pageInfo on a frozen renderer must fail") + require.NotNil(t, r2.Error) + require.True(t, r2.ReplTerminated == nil || !*r2.ReplTerminated, + "a frozen renderer must not destroy the REPL: %s", replError(r2)) + + r3 := executeBrowserRepl(t, ctx, client, instanceoapi.ExecuteBrowserReplJSONRequestBody{ + Code: `(await listTabs()).length`, + }) + require.True(t, r3.Success, "error: %s", replError(r3)) + require.Equal(t, r2.ReplId, r3.ReplId, "the REPL must survive the frozen renderer") + + r4 := executeBrowserRepl(t, ctx, client, instanceoapi.ExecuteBrowserReplJSONRequestBody{ + TimeoutSec: &timeoutSec, + Code: ` + await cdp("Page.reload"); + await waitMs(1000); + const info = await pageInfo(); + repl.write(JSON.stringify({ url: info.url, title: info.title })) + `, + }) + require.True(t, r4.Success, "error: %s", replError(r4)) + require.Equal(t, r2.ReplId, r4.ReplId, "recovery must not replace the REPL") + recovered, ok := replJSONWrite(t, r4).(map[string]interface{}) + require.True(t, ok, "expected object result, got %T", replJSONWrite(t, r4)) + require.Contains(t, recovered["url"], "data:text/html") + }) + + t.Run("chromium restart preserves repl id and bindings", func(t *testing.T) { + r1 := executeBrowserRepl(t, ctx, client, instanceoapi.ExecuteBrowserReplJSONRequestBody{ + Code: ` + var restartToken = "pre-restart"; + var playwright = await import("patchright"); + var pwRestartBrowser = await playwright.chromium.connectOverCDP(process.env.CDP_ENDPOINT); + await ensureRealTab(); + repl.write(JSON.stringify({ token: restartToken, playwrightConnected: pwRestartBrowser.isConnected() })); + `, + }) + require.True(t, r1.Success, "error: %s", replError(r1)) + before, ok := replJSONWrite(t, r1).(map[string]interface{}) + require.True(t, ok) + require.Equal(t, "pre-restart", before["token"]) + require.Equal(t, true, before["playwrightConnected"]) + + restartChromium(t, ctx, c, client) + + timeoutSec := 60 + r2 := executeBrowserRepl(t, ctx, client, instanceoapi.ExecuteBrowserReplJSONRequestBody{ + Code: ` + var restartInfo = await pageInfo(); + for (var pwDisconnectAttempt = 0; pwDisconnectAttempt < 20 && pwRestartBrowser.isConnected(); pwDisconnectAttempt++) { + await waitMs(50); + } + var stalePlaywrightDisconnected = !pwRestartBrowser.isConnected(); + var pwReplacementBrowser = await playwright.chromium.connectOverCDP(process.env.CDP_ENDPOINT); + var pwReplacementContext = pwReplacementBrowser.contexts()[0]; + var pwReplacementPage = await pwReplacementContext.newPage(); + await pwReplacementPage.setContent("Playwright reconnected"); + var pwReplacementTitle = await pwReplacementPage.title(); + await pwReplacementPage.close(); + repl.write(JSON.stringify({ + token: restartToken, + url: restartInfo.url, + stalePlaywrightDisconnected, + playwrightTitle: pwReplacementTitle, + })); + `, + TimeoutSec: &timeoutSec, + }) + require.True(t, r2.Success, "error: %s", replError(r2)) + require.Equal(t, r1.ReplId, r2.ReplId, "a Chromium restart must not change repl_id") + res, ok := replJSONWrite(t, r2).(map[string]interface{}) + require.True(t, ok, "expected object output, got %T", replJSONWrite(t, r2)) + require.Equal(t, "pre-restart", res["token"], "bindings survive a Chromium restart") + require.Equal(t, true, res["stalePlaywrightDisconnected"], "the old Playwright connection becomes stale") + require.Equal(t, "Playwright reconnected", res["playwrightTitle"], "Playwright can reconnect inside the same REPL") + + reset := true + resetResult := executeBrowserRepl(t, ctx, client, instanceoapi.ExecuteBrowserReplJSONRequestBody{Code: "", Reset: &reset}) + require.True(t, resetResult.Success, "error: %s", replError(resetResult)) + }) + + t.Run("dialogs stay pending after a chromium restart", func(t *testing.T) { + restartChromium(t, ctx, c, client) + + timeoutSec := 60 + r := executeBrowserRepl(t, ctx, client, instanceoapi.ExecuteBrowserReplJSONRequestBody{ + TimeoutSec: &timeoutSec, + Code: ` + await ensureRealTab(); + await gotoUrl("data:text/html,dlg-restart

x

"); + await cdp("Runtime.evaluate", { expression: "setTimeout(() => { alert('post-restart'); }, 100)" }); + await waitMs(1000); + let restartDlgInfo = null; + for (let i = 0; i < 20; i++) { + restartDlgInfo = await pageInfo(); + if (restartDlgInfo.dialog) break; + await waitMs(200); + } + const restartDlgType = restartDlgInfo.dialog && restartDlgInfo.dialog.type; + if (restartDlgInfo.dialog) { + await cdp("Page.handleJavaScriptDialog", { accept: true }); + } + repl.write(JSON.stringify(restartDlgType)); + `, + }) + require.True(t, r.Success, "error: %s", replError(r)) + require.Equal(t, "alert", replJSONWrite(t, r), "pageInfo must report a dialog opened after a chromium restart") + }) + + t.Run("reset clears bindings and changes repl id", func(t *testing.T) { + before := executeBrowserRepl(t, ctx, client, instanceoapi.ExecuteBrowserReplJSONRequestBody{ + Code: `repl.write(JSON.stringify(repl.id))`, + }) + require.True(t, before.Success) + + reset := true + r := executeBrowserRepl(t, ctx, client, instanceoapi.ExecuteBrowserReplJSONRequestBody{ + Code: "", + Reset: &reset, + }) + require.True(t, r.Success) + require.NotEqual(t, before.ReplId, r.ReplId, "reset must produce a new repl_id") + + r2 := executeBrowserRepl(t, ctx, client, instanceoapi.ExecuteBrowserReplJSONRequestBody{ + Code: `repl.write(JSON.stringify(typeof counter))`, + }) + require.True(t, r2.Success) + require.Equal(t, "undefined", replJSONWrite(t, r2), "reset must clear prior bindings") + }) + + runBrowserReplTimeoutCases(t, ctx, client) +} + +func runBrowserReplTimeoutCases(t *testing.T, ctx context.Context, client *instanceoapi.ClientWithResponses) { + t.Run("uninterruptible loop", func(t *testing.T) { + warm := executeBrowserRepl(t, ctx, client, instanceoapi.ExecuteBrowserReplJSONRequestBody{Code: "1"}) + require.True(t, warm.Success) + + timeoutSec := 2 + start := time.Now() + r := executeBrowserRepl(t, ctx, client, instanceoapi.ExecuteBrowserReplJSONRequestBody{ + Code: "while (true) {}", + TimeoutSec: &timeoutSec, + }) + elapsed := time.Since(start) + + require.False(t, r.Success) + require.Equal(t, warm.ReplId, r.ReplId, "timeout response carries the terminated REPL's ID") + require.NotNil(t, r.ReplTerminated) + require.True(t, *r.ReplTerminated) + require.NotNil(t, r.Error) + require.Contains(t, *r.Error, "execution timed out after 2000ms", "timeout paths share one message") + require.Less(t, elapsed, 15*time.Second, "timeout must kill the REPL promptly") + + fresh := executeBrowserRepl(t, ctx, client, instanceoapi.ExecuteBrowserReplJSONRequestBody{Code: "'fresh'"}) + require.True(t, fresh.Success) + require.NotEqual(t, warm.ReplId, fresh.ReplId, "the next request starts a fresh REPL with a new ID") + fmt.Println("timeout recovery complete, new repl_id:", fresh.ReplId) + }) + + t.Run("unresolved promise", func(t *testing.T) { + warm := executeBrowserRepl(t, ctx, client, instanceoapi.ExecuteBrowserReplJSONRequestBody{Code: "1"}) + require.True(t, warm.Success) + + timeoutSec := 2 + start := time.Now() + r := executeBrowserRepl(t, ctx, client, instanceoapi.ExecuteBrowserReplJSONRequestBody{ + Code: "await new Promise(() => {})", + TimeoutSec: &timeoutSec, + }) + elapsed := time.Since(start) + + require.False(t, r.Success) + require.Equal(t, warm.ReplId, r.ReplId, "timeout response carries the terminated REPL's ID") + require.NotNil(t, r.ReplTerminated) + require.True(t, *r.ReplTerminated, "an interruptible timeout is still destructive") + require.Less(t, elapsed, 30*time.Second, "timeout must kill the REPL promptly") + + fresh := executeBrowserRepl(t, ctx, client, instanceoapi.ExecuteBrowserReplJSONRequestBody{Code: "'fresh'"}) + require.True(t, fresh.Success) + require.NotEqual(t, warm.ReplId, fresh.ReplId, "the next request starts a fresh REPL with a new ID") + }) +} diff --git a/server/lib/events/category_gen.go b/server/lib/events/category_gen.go index a6cfa172..2b0e9310 100644 --- a/server/lib/events/category_gen.go +++ b/server/lib/events/category_gen.go @@ -64,6 +64,7 @@ var categoryByOperationID = map[string]oapi.TelemetryEventCategory{ "DownloadRecording": oapi.TelemetryEventCategory("platform"), "DragMouse": oapi.TelemetryEventCategory("control"), "EnableScaleToZero": oapi.TelemetryEventCategory("platform"), + "ExecuteBrowserRepl": oapi.TelemetryEventCategory("control"), "ExecutePlaywrightCode": oapi.TelemetryEventCategory("control"), "FileInfo": oapi.TelemetryEventCategory("platform"), "GetMousePosition": oapi.TelemetryEventCategory("control"), diff --git a/server/lib/oapi/oapi.go b/server/lib/oapi/oapi.go index 211097ef..a25d741c 100644 --- a/server/lib/oapi/oapi.go +++ b/server/lib/oapi/oapi.go @@ -2402,6 +2402,57 @@ func (e BrowserProxyErrorEventDataCode) Valid() bool { } } +// Defines values for BrowserReplImageContentType. +const ( + Image BrowserReplImageContentType = "image" +) + +// Valid indicates whether the value is a known member of the BrowserReplImageContentType enum. +func (e BrowserReplImageContentType) Valid() bool { + switch e { + case Image: + return true + default: + return false + } +} + +// Defines values for BrowserReplTextContentChannel. +const ( + BrowserReplTextContentChannelStderr BrowserReplTextContentChannel = "stderr" + BrowserReplTextContentChannelStdout BrowserReplTextContentChannel = "stdout" + BrowserReplTextContentChannelWrite BrowserReplTextContentChannel = "write" +) + +// Valid indicates whether the value is a known member of the BrowserReplTextContentChannel enum. +func (e BrowserReplTextContentChannel) Valid() bool { + switch e { + case BrowserReplTextContentChannelStderr: + return true + case BrowserReplTextContentChannelStdout: + return true + case BrowserReplTextContentChannelWrite: + return true + default: + return false + } +} + +// Defines values for BrowserReplTextContentType. +const ( + Text BrowserReplTextContentType = "text" +) + +// Valid indicates whether the value is a known member of the BrowserReplTextContentType enum. +func (e BrowserReplTextContentType) Valid() bool { + switch e { + case Text: + return true + default: + return false + } +} + // Defines values for BrowserServiceCrashedEventCategory. const ( BrowserServiceCrashedEventCategorySystem BrowserServiceCrashedEventCategory = "system" @@ -2803,16 +2854,16 @@ func (e ProcessStreamEventEvent) Valid() bool { // Defines values for ProcessStreamEventStream. const ( - Stderr ProcessStreamEventStream = "stderr" - Stdout ProcessStreamEventStream = "stdout" + ProcessStreamEventStreamStderr ProcessStreamEventStream = "stderr" + ProcessStreamEventStreamStdout ProcessStreamEventStream = "stdout" ) // Valid indicates whether the value is a known member of the ProcessStreamEventStream enum. func (e ProcessStreamEventStream) Valid() bool { switch e { - case Stderr: + case ProcessStreamEventStreamStderr: return true - case Stdout: + case ProcessStreamEventStreamStdout: return true default: return false @@ -5835,6 +5886,96 @@ type BrowserProxyErrorEventData struct { // BrowserProxyErrorEventDataCode Proxy-layer error code: the `X-Kernel-Proxy-Error` response header value from a branded 5xx error page served by the metro egress host-proxy. Values mirror what the proxy emits: destination_blocked, provider_blacklisted, provider_unreachable, proxy_unavailable, upstream_timeout, upstream_dns_failure, upstream_connect_failed. Unknown header values are dropped. type BrowserProxyErrorEventDataCode string +// BrowserReplContent Ordered discriminated union of execution output items. +type BrowserReplContent struct { + union json.RawMessage +} + +// BrowserReplImageContent defines model for BrowserReplImageContent. +type BrowserReplImageContent struct { + DataB64 []byte `json:"data_b64"` + MimeType string `json:"mime_type"` + Type BrowserReplImageContentType `json:"type"` +} + +// BrowserReplImageContentType defines model for BrowserReplImageContent.Type. +type BrowserReplImageContentType string + +// BrowserReplRequest Request to execute code in the Browser REPL +type BrowserReplRequest struct { + // Code JavaScript evaluated in a persistent Node.js runtime. + // Top-level bindings persist until the API process exits, the REPL is + // reset, or the REPL is terminated after a crash or timeout. Persistent names + // are live context-global accessors: closures and timers observe later-cell + // assignments. Function declarations use the same accessor path, including + // same-cell closures and assignments. Braceless multi-declarator `var` + // statements retain their single-statement control-flow semantics. `var` in + // top-level nested statements persists; function and nested-block locals do + // not. Function `.name` is preserved; `Function.prototype.toString()` may + // expose the generated internal alias. Lexical names are reserved after + // linking, so retry a failed declaration with a new name or reset the REPL. + // A failed lexical initializer leaves that name in the TDZ; assignments + // cannot initialize it. Static top-level imports are rejected; use dynamic + // `import()`. Expression values are not returned automatically. Output is + // optional; code may produce no content, call `repl.write(...)` or + // `repl.emitImage(...)`, use console methods, or combine those mechanisms. + // May be empty only when reset is true. The HTTP body is limited to 8 MiB, + // and the API rejects a fully encoded daemon request over the daemon's 8 MiB + // request-line limit without terminating the REPL. + Code string `json:"code"` + + // Reset Terminate the current REPL, start a fresh one, and then evaluate code. + Reset *bool `json:"reset,omitempty"` + + // TimeoutSec Maximum execution time in seconds. Default is 60. + TimeoutSec *int `json:"timeout_sec,omitempty"` +} + +// BrowserReplResult Result of Browser REPL code execution +type BrowserReplResult struct { + // Content Optional ordered text/image output produced by the execution; omitted or empty when no output was produced + Content *[]BrowserReplContent `json:"content,omitempty"` + + // ContentTruncated True if text or image output was dropped or truncated due to response limits, including the 1,000-item cap on stray output buffered between executions + ContentTruncated *bool `json:"content_truncated,omitempty"` + + // DurationMs Wall-clock execution time in milliseconds + DurationMs *int `json:"duration_ms,omitempty"` + + // Error Error message if execution failed + Error *string `json:"error,omitempty"` + + // ReplId CUID2 identifying the exact state-holding REPL process used for this + // execution. Stable across calls and Chromium reconnects; changes after + // an API restart, explicit reset, execution timeout, or REPL crash. + ReplId string `json:"repl_id"` + + // ReplTerminated True if the REPL identified by repl_id was terminated by this request + // (timeout, protocol corruption, or a REPL crash/uncaught exception). + // The next request lazily starts a fresh REPL with a new repl_id. + ReplTerminated *bool `json:"repl_terminated,omitempty"` + + // Stack Stack trace if execution failed + Stack *string `json:"stack,omitempty"` + + // Success Whether the code executed successfully + Success bool `json:"success"` +} + +// BrowserReplTextContent defines model for BrowserReplTextContent. +type BrowserReplTextContent struct { + // Channel write = repl.write, stdout = console.log/info/debug, stderr = console.warn/error + Channel BrowserReplTextContentChannel `json:"channel"` + Text string `json:"text"` + Type BrowserReplTextContentType `json:"type"` +} + +// BrowserReplTextContentChannel write = repl.write, stdout = console.log/info/debug, stderr = console.warn/error +type BrowserReplTextContentChannel string + +// BrowserReplTextContentType defines model for BrowserReplTextContent.Type. +type BrowserReplTextContentType string + // BrowserServiceCrashedEvent A managed service exited unexpectedly. Intentional stops (e.g. operator-initiated shutdown) do not produce this event — only unexpected exits and terminal restart-give-up transitions do. type BrowserServiceCrashedEvent struct { Category BrowserServiceCrashedEventCategory `json:"category"` @@ -7068,6 +7209,9 @@ type StartRecordingJSONRequestBody = StartRecordingRequest // StopRecordingJSONRequestBody defines body for StopRecording for application/json ContentType. type StopRecordingJSONRequestBody = StopRecordingRequest +// ExecuteBrowserReplJSONRequestBody defines body for ExecuteBrowserRepl for application/json ContentType. +type ExecuteBrowserReplJSONRequestBody = BrowserReplRequest + // PatchTelemetryJSONRequestBody defines body for PatchTelemetry for application/json ContentType. type PatchTelemetryJSONRequestBody = BrowserTelemetryConfig @@ -8249,6 +8393,95 @@ func (t *BrowserCdpCommandEventData) UnmarshalJSON(b []byte) error { return err } +// AsBrowserReplTextContent returns the union data inside the BrowserReplContent as a BrowserReplTextContent +func (t BrowserReplContent) AsBrowserReplTextContent() (BrowserReplTextContent, error) { + var body BrowserReplTextContent + err := json.Unmarshal(t.union, &body) + return body, err +} + +// FromBrowserReplTextContent overwrites any union data inside the BrowserReplContent as the provided BrowserReplTextContent +func (t *BrowserReplContent) FromBrowserReplTextContent(v BrowserReplTextContent) error { + v.Type = "text" + b, err := json.Marshal(v) + t.union = b + return err +} + +// MergeBrowserReplTextContent performs a merge with any union data inside the BrowserReplContent, using the provided BrowserReplTextContent +func (t *BrowserReplContent) MergeBrowserReplTextContent(v BrowserReplTextContent) error { + v.Type = "text" + b, err := json.Marshal(v) + if err != nil { + return err + } + + merged, err := runtime.JSONMerge(t.union, b) + t.union = merged + return err +} + +// AsBrowserReplImageContent returns the union data inside the BrowserReplContent as a BrowserReplImageContent +func (t BrowserReplContent) AsBrowserReplImageContent() (BrowserReplImageContent, error) { + var body BrowserReplImageContent + err := json.Unmarshal(t.union, &body) + return body, err +} + +// FromBrowserReplImageContent overwrites any union data inside the BrowserReplContent as the provided BrowserReplImageContent +func (t *BrowserReplContent) FromBrowserReplImageContent(v BrowserReplImageContent) error { + v.Type = "image" + b, err := json.Marshal(v) + t.union = b + return err +} + +// MergeBrowserReplImageContent performs a merge with any union data inside the BrowserReplContent, using the provided BrowserReplImageContent +func (t *BrowserReplContent) MergeBrowserReplImageContent(v BrowserReplImageContent) error { + v.Type = "image" + b, err := json.Marshal(v) + if err != nil { + return err + } + + merged, err := runtime.JSONMerge(t.union, b) + t.union = merged + return err +} + +func (t BrowserReplContent) Discriminator() (string, error) { + var discriminator struct { + Discriminator string `json:"type"` + } + err := json.Unmarshal(t.union, &discriminator) + return discriminator.Discriminator, err +} + +func (t BrowserReplContent) ValueByDiscriminator() (interface{}, error) { + discriminator, err := t.Discriminator() + if err != nil { + return nil, err + } + switch discriminator { + case "image": + return t.AsBrowserReplImageContent() + case "text": + return t.AsBrowserReplTextContent() + default: + return nil, errors.New("unknown discriminator value: " + discriminator) + } +} + +func (t BrowserReplContent) MarshalJSON() ([]byte, error) { + b, err := t.union.MarshalJSON() + return b, err +} + +func (t *BrowserReplContent) UnmarshalJSON(b []byte) error { + err := t.union.UnmarshalJSON(b) + return err +} + // AsBrowserConsoleLogEvent returns the union data inside the KnownBrowserTelemetryEvent as a BrowserConsoleLogEvent func (t KnownBrowserTelemetryEvent) AsBrowserConsoleLogEvent() (BrowserConsoleLogEvent, error) { var body BrowserConsoleLogEvent @@ -9639,6 +9872,11 @@ type ClientInterface interface { StopRecording(ctx context.Context, body StopRecordingJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) + // ExecuteBrowserReplWithBody request with any body + ExecuteBrowserReplWithBody(ctx context.Context, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) + + ExecuteBrowserRepl(ctx context.Context, body ExecuteBrowserReplJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) + // DisableScaleToZero request DisableScaleToZero(ctx context.Context, reqEditors ...RequestEditorFn) (*http.Response, error) @@ -10623,6 +10861,30 @@ func (c *Client) StopRecording(ctx context.Context, body StopRecordingJSONReques return c.Client.Do(req) } +func (c *Client) ExecuteBrowserReplWithBody(ctx context.Context, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewExecuteBrowserReplRequestWithBody(c.Server, contentType, body) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := c.applyEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return c.Client.Do(req) +} + +func (c *Client) ExecuteBrowserRepl(ctx context.Context, body ExecuteBrowserReplJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewExecuteBrowserReplRequest(c.Server, body) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := c.applyEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return c.Client.Do(req) +} + func (c *Client) DisableScaleToZero(ctx context.Context, reqEditors ...RequestEditorFn) (*http.Response, error) { req, err := NewDisableScaleToZeroRequest(c.Server) if err != nil { @@ -12819,6 +13081,46 @@ func NewStopRecordingRequestWithBody(server string, contentType string, body io. return req, nil } +// NewExecuteBrowserReplRequest calls the generic ExecuteBrowserRepl builder with application/json body +func NewExecuteBrowserReplRequest(server string, body ExecuteBrowserReplJSONRequestBody) (*http.Request, error) { + var bodyReader io.Reader + buf, err := json.Marshal(body) + if err != nil { + return nil, err + } + bodyReader = bytes.NewReader(buf) + return NewExecuteBrowserReplRequestWithBody(server, "application/json", bodyReader) +} + +// NewExecuteBrowserReplRequestWithBody generates requests for ExecuteBrowserRepl with any type of body +func NewExecuteBrowserReplRequestWithBody(server string, contentType string, body io.Reader) (*http.Request, error) { + var err error + + serverURL, err := url.Parse(server) + if err != nil { + return nil, err + } + + operationPath := fmt.Sprintf("/repl") + if operationPath[0] == '/' { + operationPath = "." + operationPath + } + + queryURL, err := serverURL.Parse(operationPath) + if err != nil { + return nil, err + } + + req, err := http.NewRequest("POST", queryURL.String(), body) + if err != nil { + return nil, err + } + + req.Header.Add("Content-Type", contentType) + + return req, nil +} + // NewDisableScaleToZeroRequest generates requests for DisableScaleToZero func NewDisableScaleToZeroRequest(server string) (*http.Request, error) { var err error @@ -13402,6 +13704,11 @@ type ClientWithResponsesInterface interface { StopRecordingWithResponse(ctx context.Context, body StopRecordingJSONRequestBody, reqEditors ...RequestEditorFn) (*StopRecordingResponse, error) + // ExecuteBrowserReplWithBodyWithResponse request with any body + ExecuteBrowserReplWithBodyWithResponse(ctx context.Context, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*ExecuteBrowserReplResponse, error) + + ExecuteBrowserReplWithResponse(ctx context.Context, body ExecuteBrowserReplJSONRequestBody, reqEditors ...RequestEditorFn) (*ExecuteBrowserReplResponse, error) + // DisableScaleToZeroWithResponse request DisableScaleToZeroWithResponse(ctx context.Context, reqEditors ...RequestEditorFn) (*DisableScaleToZeroResponse, error) @@ -14632,6 +14939,31 @@ func (r StopRecordingResponse) StatusCode() int { return 0 } +type ExecuteBrowserReplResponse struct { + Body []byte + HTTPResponse *http.Response + JSON200 *BrowserReplResult + JSON400 *BadRequestError + JSON413 *BadRequestError + JSON500 *InternalError +} + +// Status returns HTTPResponse.Status +func (r ExecuteBrowserReplResponse) Status() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Status + } + return http.StatusText(0) +} + +// StatusCode returns HTTPResponse.StatusCode +func (r ExecuteBrowserReplResponse) StatusCode() int { + if r.HTTPResponse != nil { + return r.HTTPResponse.StatusCode + } + return 0 +} + type DisableScaleToZeroResponse struct { Body []byte HTTPResponse *http.Response @@ -15525,6 +15857,23 @@ func (c *ClientWithResponses) StopRecordingWithResponse(ctx context.Context, bod return ParseStopRecordingResponse(rsp) } +// ExecuteBrowserReplWithBodyWithResponse request with arbitrary body returning *ExecuteBrowserReplResponse +func (c *ClientWithResponses) ExecuteBrowserReplWithBodyWithResponse(ctx context.Context, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*ExecuteBrowserReplResponse, error) { + rsp, err := c.ExecuteBrowserReplWithBody(ctx, contentType, body, reqEditors...) + if err != nil { + return nil, err + } + return ParseExecuteBrowserReplResponse(rsp) +} + +func (c *ClientWithResponses) ExecuteBrowserReplWithResponse(ctx context.Context, body ExecuteBrowserReplJSONRequestBody, reqEditors ...RequestEditorFn) (*ExecuteBrowserReplResponse, error) { + rsp, err := c.ExecuteBrowserRepl(ctx, body, reqEditors...) + if err != nil { + return nil, err + } + return ParseExecuteBrowserReplResponse(rsp) +} + // DisableScaleToZeroWithResponse request returning *DisableScaleToZeroResponse func (c *ClientWithResponses) DisableScaleToZeroWithResponse(ctx context.Context, reqEditors ...RequestEditorFn) (*DisableScaleToZeroResponse, error) { rsp, err := c.DisableScaleToZero(ctx, reqEditors...) @@ -17575,6 +17924,53 @@ func ParseStopRecordingResponse(rsp *http.Response) (*StopRecordingResponse, err return response, nil } +// ParseExecuteBrowserReplResponse parses an HTTP response from a ExecuteBrowserReplWithResponse call +func ParseExecuteBrowserReplResponse(rsp *http.Response) (*ExecuteBrowserReplResponse, error) { + bodyBytes, err := io.ReadAll(rsp.Body) + defer func() { _ = rsp.Body.Close() }() + if err != nil { + return nil, err + } + + response := &ExecuteBrowserReplResponse{ + Body: bodyBytes, + HTTPResponse: rsp, + } + + switch { + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 200: + var dest BrowserReplResult + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON200 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 400: + var dest BadRequestError + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON400 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 413: + var dest BadRequestError + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON413 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 500: + var dest InternalError + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON500 = &dest + + } + + return response, nil +} + // ParseDisableScaleToZeroResponse parses an HTTP response from a DisableScaleToZeroWithResponse call func ParseDisableScaleToZeroResponse(rsp *http.Response) (*DisableScaleToZeroResponse, error) { bodyBytes, err := io.ReadAll(rsp.Body) @@ -18049,6 +18445,9 @@ type ServerInterface interface { // Stop the recording // (POST /recording/stop) StopRecording(w http.ResponseWriter, r *http.Request) + // Execute JavaScript in the Browser REPL + // (POST /repl) + ExecuteBrowserRepl(w http.ResponseWriter, r *http.Request) // Idempotently disable scale to zero on this VM. // (POST /scaletozero/disable) DisableScaleToZero(w http.ResponseWriter, r *http.Request) @@ -18382,6 +18781,12 @@ func (_ Unimplemented) StopRecording(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusNotImplemented) } +// Execute JavaScript in the Browser REPL +// (POST /repl) +func (_ Unimplemented) ExecuteBrowserRepl(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNotImplemented) +} + // Idempotently disable scale to zero on this VM. // (POST /scaletozero/disable) func (_ Unimplemented) DisableScaleToZero(w http.ResponseWriter, r *http.Request) { @@ -19428,6 +19833,20 @@ func (siw *ServerInterfaceWrapper) StopRecording(w http.ResponseWriter, r *http. handler.ServeHTTP(w, r) } +// ExecuteBrowserRepl operation middleware +func (siw *ServerInterfaceWrapper) ExecuteBrowserRepl(w http.ResponseWriter, r *http.Request) { + + handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + siw.Handler.ExecuteBrowserRepl(w, r) + })) + + for _, middleware := range siw.HandlerMiddlewares { + handler = middleware(handler) + } + + handler.ServeHTTP(w, r) +} + // DisableScaleToZero operation middleware func (siw *ServerInterfaceWrapper) DisableScaleToZero(w http.ResponseWriter, r *http.Request) { @@ -19851,6 +20270,9 @@ func HandlerWithOptions(si ServerInterface, options ChiServerOptions) http.Handl r.Group(func(r chi.Router) { r.Post(options.BaseURL+"/recording/stop", wrapper.StopRecording) }) + r.Group(func(r chi.Router) { + r.Post(options.BaseURL+"/repl", wrapper.ExecuteBrowserRepl) + }) r.Group(func(r chi.Router) { r.Post(options.BaseURL+"/scaletozero/disable", wrapper.DisableScaleToZero) }) @@ -22028,6 +22450,50 @@ func (response StopRecording500JSONResponse) VisitStopRecordingResponse(w http.R return json.NewEncoder(w).Encode(response) } +type ExecuteBrowserReplRequestObject struct { + Body *ExecuteBrowserReplJSONRequestBody +} + +type ExecuteBrowserReplResponseObject interface { + VisitExecuteBrowserReplResponse(w http.ResponseWriter) error +} + +type ExecuteBrowserRepl200JSONResponse BrowserReplResult + +func (response ExecuteBrowserRepl200JSONResponse) VisitExecuteBrowserReplResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(200) + + return json.NewEncoder(w).Encode(response) +} + +type ExecuteBrowserRepl400JSONResponse struct{ BadRequestErrorJSONResponse } + +func (response ExecuteBrowserRepl400JSONResponse) VisitExecuteBrowserReplResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(400) + + return json.NewEncoder(w).Encode(response) +} + +type ExecuteBrowserRepl413JSONResponse Error + +func (response ExecuteBrowserRepl413JSONResponse) VisitExecuteBrowserReplResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(413) + + return json.NewEncoder(w).Encode(response) +} + +type ExecuteBrowserRepl500JSONResponse struct{ InternalErrorJSONResponse } + +func (response ExecuteBrowserRepl500JSONResponse) VisitExecuteBrowserReplResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(500) + + return json.NewEncoder(w).Encode(response) +} + type DisableScaleToZeroRequestObject struct { } @@ -22519,6 +22985,9 @@ type StrictServerInterface interface { // Stop the recording // (POST /recording/stop) StopRecording(ctx context.Context, request StopRecordingRequestObject) (StopRecordingResponseObject, error) + // Execute JavaScript in the Browser REPL + // (POST /repl) + ExecuteBrowserRepl(ctx context.Context, request ExecuteBrowserReplRequestObject) (ExecuteBrowserReplResponseObject, error) // Idempotently disable scale to zero on this VM. // (POST /scaletozero/disable) DisableScaleToZero(ctx context.Context, request DisableScaleToZeroRequestObject) (DisableScaleToZeroResponseObject, error) @@ -24068,6 +24537,37 @@ func (sh *strictHandler) StopRecording(w http.ResponseWriter, r *http.Request) { } } +// ExecuteBrowserRepl operation middleware +func (sh *strictHandler) ExecuteBrowserRepl(w http.ResponseWriter, r *http.Request) { + var request ExecuteBrowserReplRequestObject + + var body ExecuteBrowserReplJSONRequestBody + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + sh.options.RequestErrorHandlerFunc(w, r, fmt.Errorf("can't decode JSON body: %w", err)) + return + } + request.Body = &body + + handler := func(ctx context.Context, w http.ResponseWriter, r *http.Request, request interface{}) (interface{}, error) { + return sh.ssi.ExecuteBrowserRepl(ctx, request.(ExecuteBrowserReplRequestObject)) + } + for _, middleware := range sh.middlewares { + handler = middleware(handler, "ExecuteBrowserRepl") + } + + response, err := handler(r.Context(), w, r, request) + + if err != nil { + sh.options.ResponseErrorHandlerFunc(w, r, err) + } else if validResponse, ok := response.(ExecuteBrowserReplResponseObject); ok { + if err := validResponse.VisitExecuteBrowserReplResponse(w); err != nil { + sh.options.ResponseErrorHandlerFunc(w, r, err) + } + } else if response != nil { + sh.options.ResponseErrorHandlerFunc(w, r, fmt.Errorf("unexpected response type: %T", response)) + } +} + // DisableScaleToZero operation middleware func (sh *strictHandler) DisableScaleToZero(w http.ResponseWriter, r *http.Request) { var request DisableScaleToZeroRequestObject @@ -24320,601 +24820,638 @@ func (sh *strictHandler) GetWebMCPTools(w http.ResponseWriter, r *http.Request) // Base64 encoded, gzipped, json marshaled Swagger object var swaggerSpec = []string{ - "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", + "5jKQAwA=", } // GetSwagger returns the content of the embedded swagger specification file diff --git a/server/lib/oapi/oapi_test.go b/server/lib/oapi/oapi_test.go new file mode 100644 index 00000000..c460f2ea --- /dev/null +++ b/server/lib/oapi/oapi_test.go @@ -0,0 +1,94 @@ +package oapi + +import ( + "bytes" + "encoding/json" + "net/http" + "strings" + "testing" +) + +type sseTestWriter struct { + header http.Header + status int + body bytes.Buffer + flushes int +} + +func (w *sseTestWriter) Header() http.Header { return w.header } + +func (w *sseTestWriter) WriteHeader(status int) { w.status = status } + +func (w *sseTestWriter) Write(p []byte) (int, error) { return w.body.Write(p) } + +func (w *sseTestWriter) Flush() { w.flushes++ } + +func TestGeneratedSSEResponsesFlushAndDisableBuffering(t *testing.T) { + tests := []struct { + name string + visit func(http.ResponseWriter) error + }{ + { + name: "filesystem events", + visit: func(w http.ResponseWriter) error { + return (StreamFsEvents200TexteventStreamResponse{Body: strings.NewReader("data: fs\n\n")}).VisitStreamFsEventsResponse(w) + }, + }, + { + name: "logs", + visit: func(w http.ResponseWriter) error { + return (LogsStream200TexteventStreamResponse{Body: strings.NewReader("data: logs\n\n")}).VisitLogsStreamResponse(w) + }, + }, + { + name: "process stdout", + visit: func(w http.ResponseWriter) error { + return (ProcessStdoutStream200TexteventStreamResponse{Body: strings.NewReader("data: stdout\n\n")}).VisitProcessStdoutStreamResponse(w) + }, + }, + { + name: "telemetry", + visit: func(w http.ResponseWriter) error { + return (StreamTelemetryEvents200TexteventStreamResponse{Body: strings.NewReader("data: telemetry\n\n")}).VisitStreamTelemetryEventsResponse(w) + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + w := &sseTestWriter{header: make(http.Header)} + if err := tt.visit(w); err != nil { + t.Fatalf("visit response: %v", err) + } + if w.status != http.StatusOK { + t.Fatalf("status = %d, want %d", w.status, http.StatusOK) + } + if got := w.header.Get("Content-Type"); got != "text/event-stream" { + t.Fatalf("Content-Type = %q", got) + } + if got := w.header.Get("Cache-Control"); got != "no-cache" { + t.Fatalf("Cache-Control = %q", got) + } + if got := w.header.Get("X-Accel-Buffering"); got != "no" { + t.Fatalf("X-Accel-Buffering = %q", got) + } + if w.flushes != 1 { + t.Fatalf("flushes = %d, want 1", w.flushes) + } + }) + } +} + +func TestGeneratedOpenAPISpecMatchesSourceDescription(t *testing.T) { + swagger, err := GetSwagger() + if err != nil { + t.Fatalf("get swagger: %v", err) + } + data, err := json.Marshal(swagger) + if err != nil { + t.Fatalf("marshal swagger: %v", err) + } + if !bytes.Contains(data, []byte("including the 1,000-item cap on stray output buffered between executions")) { + t.Fatal("embedded OpenAPI spec is missing the stray-output item limit description") + } +} diff --git a/server/openapi.yaml b/server/openapi.yaml index 0244eefb..76705930 100644 --- a/server/openapi.yaml +++ b/server/openapi.yaml @@ -1458,6 +1458,58 @@ paths: $ref: "#/components/responses/BadRequestError" "500": $ref: "#/components/responses/InternalError" + /repl: + post: + summary: Execute JavaScript in the Browser REPL + description: | + Execute code in the Browser REPL, a persistent Node.js runtime preloaded with browser-control + helpers (gotoUrl, pageInfo, accessibilitySnapshot, click, waitForEvent, captureScreenshot, tab management, + and more), the browser-wide `webmcp` client, plus an unrestricted `cdp()` escape hatch. `webmcp` + and `browser.webmcp` share one frozen client whose requests are scoped to the active execution. + Pinned `patchright` and `playwright-core` packages can be loaded with dynamic `import()` and + connected to `process.env.CDP_ENDPOINT`; their module and browser objects persist like other + bindings. Patchright matches the image's default Playwright execution engine. + Top-level bindings persist + across calls until the API process exits, the REPL is reset, or the REPL is + terminated after a crash or timeout. Persistent names are live context-global + accessors, so closures and timers observe later-cell assignments; function declarations + use the same accessor path, including same-cell closures and assignments. `var` in + top-level nested statements persists, while function and nested-block locals do not. + Lexical names are reserved after linking, so retry a failed declaration with a new + name or reset the REPL. Expression values are not returned automatically. + Output is optional: code may produce no content, call `repl.write(...)` or + `repl.emitImage(...)`, use console methods, or combine those mechanisms. + + The runtime starts lazily on the first request and is owned directly by the API + process: an API restart kills it, and the next request starts a fresh REPL with a + new CUID2 `repl_id`. A timeout is destructive (JavaScript cannot be interrupted + safely), so a timed-out execution terminates the REPL and the next request lazily + starts a new one. + + This endpoint is unrestricted code execution inside the browser VM, equivalent in + trust level to the process and Playwright execution APIs. It is not sandboxed. + operationId: executeBrowserRepl + x-telemetry-category: control + requestBody: + description: JSON request bodies are limited to 8 MiB before strict decoding. The API rejects a marshaled daemon request over the daemon's 8 MiB newline-delimited request-line limit as a non-destructive 400. + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/BrowserReplRequest" + responses: + "200": + description: Code executed (success or structured failure) + content: + application/json: + schema: + $ref: "#/components/schemas/BrowserReplResult" + "400": + $ref: "#/components/responses/BadRequestError" + "413": + $ref: "#/components/responses/BadRequestError" + "500": + $ref: "#/components/responses/InternalError" /telemetry: get: summary: Get telemetry configuration @@ -7116,6 +7168,122 @@ components: type: string description: Standard error from the execution additionalProperties: false + BrowserReplRequest: + type: object + description: Request to execute code in the Browser REPL + required: [code] + properties: + code: + type: string + description: | + JavaScript evaluated in a persistent Node.js runtime. + Top-level bindings persist until the API process exits, the REPL is + reset, or the REPL is terminated after a crash or timeout. Persistent names + are live context-global accessors: closures and timers observe later-cell + assignments. Function declarations use the same accessor path, including + same-cell closures and assignments. Braceless multi-declarator `var` + statements retain their single-statement control-flow semantics. `var` in + top-level nested statements persists; function and nested-block locals do + not. Function `.name` is preserved; `Function.prototype.toString()` may + expose the generated internal alias. Lexical names are reserved after + linking, so retry a failed declaration with a new name or reset the REPL. + A failed lexical initializer leaves that name in the TDZ; assignments + cannot initialize it. Static top-level imports are rejected; use dynamic + `import()`. Expression values are not returned automatically. Output is + optional; code may produce no content, call `repl.write(...)` or + `repl.emitImage(...)`, use console methods, or combine those mechanisms. + May be empty only when reset is true. The HTTP body is limited to 8 MiB, + and the API rejects a fully encoded daemon request over the daemon's 8 MiB + request-line limit without terminating the REPL. + timeout_sec: + type: integer + description: Maximum execution time in seconds. Default is 60. + default: 60 + minimum: 1 + maximum: 300 + reset: + type: boolean + description: Terminate the current REPL, start a fresh one, and then evaluate code. + default: false + additionalProperties: false + BrowserReplResult: + type: object + description: Result of Browser REPL code execution + required: [success, repl_id] + properties: + success: + type: boolean + description: Whether the code executed successfully + repl_id: + type: string + description: | + CUID2 identifying the exact state-holding REPL process used for this + execution. Stable across calls and Chromium reconnects; changes after + an API restart, explicit reset, execution timeout, or REPL crash. + error: + type: string + description: Error message if execution failed + stack: + type: string + description: Stack trace if execution failed + content: + type: array + description: Optional ordered text/image output produced by the execution; omitted or empty when no output was produced + items: + $ref: "#/components/schemas/BrowserReplContent" + content_truncated: + type: boolean + description: True if text or image output was dropped or truncated due to response limits, including the 1,000-item cap on stray output buffered between executions + repl_terminated: + type: boolean + description: | + True if the REPL identified by repl_id was terminated by this request + (timeout, protocol corruption, or a REPL crash/uncaught exception). + The next request lazily starts a fresh REPL with a new repl_id. + duration_ms: + type: integer + description: Wall-clock execution time in milliseconds + additionalProperties: false + BrowserReplContent: + description: Ordered discriminated union of execution output items. + oneOf: + - $ref: "#/components/schemas/BrowserReplTextContent" + - $ref: "#/components/schemas/BrowserReplImageContent" + discriminator: + propertyName: type + mapping: + text: "#/components/schemas/BrowserReplTextContent" + image: "#/components/schemas/BrowserReplImageContent" + BrowserReplTextContent: + type: object + required: [type, channel, text] + properties: + type: + type: string + enum: [text] + channel: + type: string + description: >- + write = repl.write, stdout = console.log/info/debug, + stderr = console.warn/error + enum: [write, stdout, stderr] + text: + type: string + additionalProperties: false + BrowserReplImageContent: + type: object + required: [type, mime_type, data_b64] + properties: + type: + type: string + enum: [image] + mime_type: + type: string + pattern: "^image/" + data_b64: + type: string + contentEncoding: base64 + additionalProperties: false SleepAction: type: object description: Pause execution for a specified duration. diff --git a/server/runtime/browser-cdp-client.ts b/server/runtime/browser-cdp-client.ts new file mode 100644 index 00000000..efc4d162 --- /dev/null +++ b/server/runtime/browser-cdp-client.ts @@ -0,0 +1,746 @@ + +export interface CdpEvent { + method: string; + params: unknown; + sessionId?: string; + time: number; +} + +export interface CdpTarget { + targetId: string; + type: string; + title: string; + url: string; + attached: boolean; +} + +export interface PendingDialog { + type: string; + message: string; + since: number; +} + +interface PendingCommand { + resolve: (value: unknown) => void; + reject: (error: Error) => void; + method: string; + timer: ReturnType; +} + +interface PendingEventWaiter { + method: string; + sessionId: string | undefined; + predicate?: (event: CdpEvent) => boolean; + accept: (event: CdpEvent) => void; + reject: (error: Error) => void; +} + +const EVENT_RING_CAPACITY = 500; +const CONNECT_TIMEOUT_MS = 10_000; +// Individual CDP commands must not hang forever: Chromium occasionally never +// answers a command (e.g. Input.dispatchMouseEvent mouseWheel on a +// non-scrollable page), and an unanswered command would otherwise wedge the +// serialized execution queue until the outer execution timeout kills the +// whole REPL. +const COMMAND_TIMEOUT_MS = 30_000; +// COMMAND_DEADLINE_MARGIN_MS is how far below the executing request's +// deadline a CDP command's effective timeout is clamped, leaving room for +// the error to unwind and the response to be written before the daemon's +// execution timer fires and destructively kills the REPL. A renderer frozen +// behind a modal JavaScript dialog never answers session-routed commands; +// without this clamp every such command burned a whole REPL per attempt. +const COMMAND_DEADLINE_MARGIN_MS = 500; +// ENABLE_DOMAINS_BUDGET_MS bounds the total time attach() spends enabling +// CDP domains, and ENABLE_DOMAIN_COMMAND_TIMEOUT_MS each individual enable. +// A frozen renderer never answers Page.enable, so without a budget attach +// alone could consume the entire execution deadline and the command the +// caller actually wanted (e.g. Page.handleJavaScriptDialog or Page.reload, +// both answered browser-side) would never be sent. +const ENABLE_DOMAINS_BUDGET_MS = 10_000; +const ENABLE_DOMAIN_COMMAND_TIMEOUT_MS = 5_000; +// DIALOG_DISMISS_TIMEOUT_MS bounds the best-effort dismissal of a dialog +// that was already open when the runtime attached. +const DIALOG_DISMISS_TIMEOUT_MS = 5_000; +// RECONNECT_RETRY_DELAY_MS gives a just-restarted Chromium (or the DevTools +// proxy in front of it) a beat before the single reconnect-and-retry of a +// command whose connection died before answering anything. +const RECONNECT_RETRY_DELAY_MS = 150; + +// Only observational or idempotent setup commands may be replayed after a +// connection closes before their acknowledgement arrives. Mutations and +// Runtime.evaluate are exact-once: Chromium may have applied them already. +const SAFE_RETRY_METHODS = new Set([ + 'Accessibility.getFullAXTree', + 'Browser.getVersion', + 'DOM.enable', + 'DOM.getBoxModel', + 'DOM.getContentQuads', + 'DOM.getDocument', + 'DOM.querySelector', + 'DOM.querySelectorAll', + 'DOM.resolveNode', + 'Network.enable', + 'Page.captureScreenshot', + 'Page.enable', + 'Page.getFrameTree', + 'Page.getLayoutMetrics', + 'Runtime.enable', + 'SystemInfo.getInfo', + 'Target.getTargets', +]); + +export class CdpCommandTimeoutError extends Error { + readonly cdpCommandTimeout = true; +} + +export function isCdpCommandTimeout(err: unknown): boolean { + return err instanceof CdpCommandTimeoutError || (err as any)?.cdpCommandTimeout === true; +} + +export class CdpOutcomeUnknownError extends Error { + readonly outcomeUnknown = true; + + constructor(method: string, cause: unknown) { + super( + `CDP ${method} outcome is unknown because the connection closed before its response; ` + + 'the command was not retried', + { cause }, + ); + } +} + +const INTERNAL_URL_PREFIXES = [ + 'chrome://', + 'chrome-extension://', + 'chrome-untrusted://', + 'devtools://', + 'edge://', + 'about:', +]; + +export function isInternalUrl(url: string): boolean { + return INTERNAL_URL_PREFIXES.some((p) => url.startsWith(p)); +} + +export class CdpClient { + private readonly endpoint: string; + private ws: WebSocket | null = null; + private connecting: Promise | null = null; + private nextId = 1; + private pending = new Map(); + private events: CdpEvent[] = []; + private eventWaiters = new Set(); + + sessionId: string | null = null; + targetId: string | null = null; + + pendingDialog: PendingDialog | null = null; + + executionDeadlineMs: number | null = null; + + private rendererResponsive = true; + + onDialogAutoDismissed?: (dialog: PendingDialog) => void; + + private inFlightRequests = new Set(); + private lastNetworkActivity = 0; + + constructor(endpoint: string) { + this.endpoint = endpoint; + } + + get connected(): boolean { + return this.ws !== null && this.ws.readyState === WebSocket.OPEN; + } + + private async resolveBrowserWsUrl(): Promise { + let parsed: URL; + try { + parsed = new URL(this.endpoint); + } catch { + return this.endpoint; + } + if (parsed.pathname && parsed.pathname !== '/') { + return this.endpoint; + } + const httpBase = `${parsed.protocol === 'wss:' ? 'https' : 'http'}://${parsed.host}`; + try { + const res = await fetch(`${httpBase}/json/version`); + if (res.ok) { + const info = (await res.json()) as { webSocketDebuggerUrl?: string }; + if (info.webSocketDebuggerUrl) { + return info.webSocketDebuggerUrl; + } + } + } catch { + // Fall through to the raw endpoint. + } + return this.endpoint; + } + + async ensureConnected(): Promise { + if (this.connected) return; + if (this.connecting) return this.connecting; + + this.connecting = (async () => { + const url = await this.resolveBrowserWsUrl(); + await this.openSocket(url); + })(); + + try { + await this.connecting; + } finally { + this.connecting = null; + } + } + + private openSocket(url: string): Promise { + return new Promise((resolve, reject) => { + const ws = new WebSocket(url); + const timer = setTimeout(() => { + try { + ws.close(); + } catch { + // ignore + } + reject(new Error(`timed out connecting to CDP endpoint ${this.endpoint}`)); + }, CONNECT_TIMEOUT_MS); + + ws.addEventListener('open', () => { + clearTimeout(timer); + this.ws = ws; + resolve(); + }); + ws.addEventListener('error', () => { + clearTimeout(timer); + reject(new Error(`failed to connect to CDP endpoint ${this.endpoint}`)); + }); + ws.addEventListener('message', (event) => { + this.onMessage(event.data); + }); + ws.addEventListener('close', () => { + this.onClose(ws); + }); + }); + } + + private onClose(closed: WebSocket): void { + if (this.ws !== closed) return; + this.ws = null; + this.sessionId = null; + this.targetId = null; + this.pendingDialog = null; + this.rendererResponsive = true; + this.inFlightRequests.clear(); + const err = new Error('CDP connection closed before the command response arrived'); + (err as any).connectionClosedBeforeResponse = true; + for (const [, p] of this.pending) { + clearTimeout(p.timer); + p.reject(err); + } + this.pending.clear(); + for (const waiter of [...this.eventWaiters]) waiter.reject(err); + } + + private onMessage(data: unknown): void { + if (typeof data !== 'string') return; + let msg: any; + try { + msg = JSON.parse(data); + } catch { + return; + } + + if (typeof msg.id === 'number') { + const p = this.pending.get(msg.id); + if (!p) return; + this.pending.delete(msg.id); + clearTimeout(p.timer); + if (msg.error) { + p.reject(new Error(`CDP ${p.method} failed: ${msg.error.message} (code ${msg.error.code})`)); + } else { + p.resolve(msg.result); + } + return; + } + + if (typeof msg.method === 'string') { + this.onEvent(msg.method, msg.params, msg.sessionId); + } + } + + private onEvent(method: string, params: any, sessionId?: string): void { + const event: CdpEvent = { method, params, sessionId, time: Date.now() }; + this.events.push(event); + if (this.events.length > EVENT_RING_CAPACITY) { + this.events.splice(0, this.events.length - EVENT_RING_CAPACITY); + } + + if (sessionId && sessionId === this.sessionId) { + if (method === 'Page.javascriptDialogOpening') { + this.pendingDialog = { + type: params?.type ?? 'alert', + message: params?.message ?? '', + since: Date.now(), + }; + } else if (method === 'Page.javascriptDialogClosed') { + this.pendingDialog = null; + } + } + + if (method.startsWith('Network.') && sessionId === this.sessionId) { + this.lastNetworkActivity = Date.now(); + const requestId = params?.requestId; + if (method === 'Network.requestWillBeSent' && requestId) { + this.inFlightRequests.add(requestId); + } else if ( + (method === 'Network.loadingFinished' || method === 'Network.loadingFailed') && + requestId + ) { + this.inFlightRequests.delete(requestId); + } + } + + if (method === 'Target.detachedFromTarget') { + if (params?.sessionId && params.sessionId === this.sessionId) { + this.sessionId = null; + this.targetId = null; + this.pendingDialog = null; + } + } + + for (const waiter of [...this.eventWaiters]) { + if (waiter.method !== method || waiter.sessionId !== sessionId) continue; + try { + if (!waiter.predicate || waiter.predicate(event)) waiter.accept(event); + } catch (err) { + waiter.reject(err instanceof Error ? err : new Error(String(err))); + } + } + } + + waitForEvent( + method: string, + options: { + sessionId?: string; + timeoutMs?: number; + predicate?: (event: CdpEvent) => boolean; + } = {}, + ): Promise { + const { timeout } = this.effectiveCommandTimeout(options.timeoutMs); + if (timeout <= 0) return Promise.resolve(null); + + return new Promise((resolve, reject) => { + let settled = false; + const finish = (error?: Error, event?: CdpEvent | null) => { + if (settled) return; + settled = true; + clearTimeout(timer); + this.eventWaiters.delete(waiter); + if (error) reject(error); + else resolve(event ?? null); + }; + const waiter: PendingEventWaiter = { + method, + sessionId: options.sessionId, + predicate: options.predicate, + accept: (event) => finish(undefined, event), + reject: (error) => finish(error), + }; + const timer = setTimeout(() => finish(undefined, null), timeout); + if (typeof timer.unref === 'function') timer.unref(); + this.eventWaiters.add(waiter); + void this.ensureConnected().catch((err) => + finish(err instanceof Error ? err : new Error(String(err))), + ); + }); + } + + async browserCommand(method: string, params?: unknown): Promise { + return this.send(method, params, undefined); + } + + async sessionCommand(method: string, params?: unknown, timeoutMs?: number): Promise { + try { + return await this.sessionCommandOnce(method, params, timeoutMs); + } catch (err: any) { + if (!err?.connectionClosedBeforeResponse) { + throw err; + } + if (!SAFE_RETRY_METHODS.has(method)) { + throw new CdpOutcomeUnknownError(method, err); + } + // The attached session died with the connection. Re-attach and replay + // only commands whose effects are observational or idempotent. + return this.sessionCommandOnce(method, params, timeoutMs); + } + } + + private async sessionCommandOnce(method: string, params?: unknown, timeoutMs?: number): Promise { + await this.ensureAttached(); + if (!this.rendererResponsive) { + // The previous attach hit a frozen renderer (e.g. a dialog left open + // by a previous REPL). Retry the domain enables: once the renderer + // unfreezes (dialog dismissed, page reloaded) the session recovers + // without a reattach. + this.rendererResponsive = await this.enableDomains(this.sessionId!); + } + return this.send(method, params, this.sessionId!, timeoutMs); + } + + async send(method: string, params?: unknown, sessionId?: string, timeoutMs?: number): Promise { + try { + return await this.sendOnce(method, params, sessionId, timeoutMs); + } catch (err: any) { + // Session-routed commands belong to the dead connection's session; + // their retry (with re-attach) is sessionCommand's job. Foreign + // sessions (evaluateOnTarget) surface the error unchanged. + if (sessionId !== undefined || !err?.connectionClosedBeforeResponse) { + throw err; + } + if (!SAFE_RETRY_METHODS.has(method)) { + throw new CdpOutcomeUnknownError(method, err); + } + await new Promise((resolve) => setTimeout(resolve, RECONNECT_RETRY_DELAY_MS)); + return this.sendOnce(method, params, sessionId, timeoutMs); + } + } + + private async sendOnce(method: string, params?: unknown, sessionId?: string, timeoutMs?: number): Promise { + await this.ensureConnected(); + const id = this.nextId++; + const payload: Record = { id, method }; + if (params !== undefined) payload.params = params; + if (sessionId) payload.sessionId = sessionId; + + const { timeout, clampedByDeadline } = this.effectiveCommandTimeout(timeoutMs); + const rendererHint = + sessionId && !this.rendererResponsive + ? ' (the page renderer is unresponsive — a modal JavaScript dialog may be blocking it; ' + + 'recover with cdp("Page.handleJavaScriptDialog", { accept: true }) or cdp("Page.reload"))' + : ''; + if (timeout <= 0) { + throw new CdpCommandTimeoutError( + `CDP ${method} could not run: the execution deadline has already been reached${rendererHint}`, + ); + } + + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + if (this.pending.delete(id)) { + let message = `CDP ${method} timed out after ${timeout}ms`; + if (clampedByDeadline) { + message += ' (bounded by the execution timeout)'; + } + message += rendererHint; + const error = new CdpCommandTimeoutError(message); + if (!SAFE_RETRY_METHODS.has(method)) { + error.message += ' (outcome unknown; command was not retried)'; + (error as any).outcomeUnknown = true; + } + reject(error); + } + }, timeout); + if (typeof timer.unref === 'function') timer.unref(); + this.pending.set(id, { resolve: resolve as (v: unknown) => void, reject, method, timer }); + try { + this.ws!.send(JSON.stringify(payload)); + } catch (err: any) { + clearTimeout(timer); + this.pending.delete(id); + const sendErr = new Error(`failed to send CDP ${method}: ${err?.message ?? err}`); + (sendErr as any).connectionClosedBeforeResponse = true; + reject(sendErr); + } + }); + } + + private effectiveCommandTimeout(overrideMs?: number): { timeout: number; clampedByDeadline: boolean } { + let timeout = overrideMs ?? COMMAND_TIMEOUT_MS; + const exec = this.executionDeadlineMs; + if (exec !== null) { + const remaining = exec - COMMAND_DEADLINE_MARGIN_MS - Date.now(); + if (remaining < timeout) { + return { timeout: remaining, clampedByDeadline: true }; + } + } + return { timeout, clampedByDeadline: false }; + } + + async listTargets(): Promise { + await this.ensureConnected(); + const res = await this.browserCommand<{ targetInfos: any[] }>('Target.getTargets'); + return (res.targetInfos ?? []).map((t) => ({ + targetId: t.targetId, + type: t.type, + title: t.title ?? '', + url: t.url ?? '', + attached: !!t.attached, + })); + } + + async attach(targetId: string): Promise { + await this.ensureConnected(); + if (this.targetId === targetId && this.sessionId) { + return this.sessionId; + } + const previousSessionId = this.sessionId; + const res = await this.browserCommand<{ sessionId: string }>('Target.attachToTarget', { + targetId, + flatten: true, + }); + this.sessionId = res.sessionId; + this.targetId = targetId; + this.pendingDialog = null; + this.inFlightRequests.clear(); + this.lastNetworkActivity = Date.now(); + // Make the attached target the foreground tab. In headless Chromium a + // hidden tab's JavaScript dialogs are auto-cancelled + // (Page.javascriptDialogClosed with result:false fires immediately + // after opening), which breaks the documented dialog semantics — and + // which tab is active after a Chromium restart is not deterministic. + // Best-effort: activation can be rejected for some target types. + try { + await this.browserCommand('Target.activateTarget', { targetId }); + } catch { + // Ignore: dialog semantics degrade to Chromium's default for the tab. + } + this.rendererResponsive = await this.enableDomains(this.sessionId); + await this.dismissStaleDialog(); + if (previousSessionId && previousSessionId !== this.sessionId) { + try { + await this.browserCommand('Target.detachFromTarget', { sessionId: previousSessionId }); + } catch { + // Best effort. A connection failure already invalidated both sessions; + // other detach failures must not discard the newly attached target. + } + } + return this.sessionId; + } + + private async enableDomains(sessionId: string): Promise { + const start = Date.now(); + for (const method of ['Page.enable', 'DOM.enable', 'Runtime.enable', 'Network.enable']) { + const remaining = ENABLE_DOMAINS_BUDGET_MS - (Date.now() - start); + if (remaining <= 0) { + return false; + } + try { + await this.send(method, undefined, sessionId, Math.min(remaining, ENABLE_DOMAIN_COMMAND_TIMEOUT_MS)); + } catch (err) { + if (isCdpCommandTimeout(err)) { + return false; + } + // Domain unsupported on this target; ignore. + } + } + return true; + } + + private async dismissStaleDialog(): Promise { + if (!this.sessionId) return; + if (this.rendererResponsive && !this.pendingDialog) return; + let dismissed: PendingDialog | null = null; + try { + await this.send( + 'Page.handleJavaScriptDialog', + { accept: true }, + this.sessionId, + DIALOG_DISMISS_TIMEOUT_MS, + ); + dismissed = this.pendingDialog ?? { type: 'unknown', message: '', since: Date.now() }; + } catch { + // No dialog is showing (or the command could not be answered in + // time). Leave pendingDialog untouched so pageInfo still reports a + // detected dialog and the caller can retry the dismissal explicitly. + } + if (dismissed) { + this.pendingDialog = null; + this.onDialogAutoDismissed?.(dismissed); + if (!this.rendererResponsive) { + // The dismissed dialog may have been what froze the renderer. + this.rendererResponsive = await this.enableDomains(this.sessionId); + } + } + } + + async ensureAttached(): Promise { + await this.ensureConnected(); + if (this.sessionId && this.targetId) { + return; + } + // A target can be destroyed between listing and attaching (target swap + // during navigation); re-list and retry once instead of surfacing the + // raw CDP error to the caller. + for (let attempt = 0; attempt < 2; attempt++) { + const targets = await this.listTargets(); + const pages = targets.filter((t) => t.type === 'page'); + const pick = + pages.find((t) => t.targetId === this.targetId) ?? + pages.find((t) => !isInternalUrl(t.url)) ?? + pages[0]; + if (!pick) { + break; + } + try { + await this.attach(pick.targetId); + return; + } catch (err: any) { + if (attempt === 0 && String(err?.message ?? err).includes('No target with given id found')) { + continue; + } + throw err; + } + } + const created = await this.browserCommand<{ targetId: string }>('Target.createTarget', { + url: 'about:blank', + }); + await this.attach(created.targetId); + } + + async waitForNavigationCommit(targetId: string, timeoutMs: number): Promise { + const deadline = Date.now() + timeoutMs; + for (;;) { + try { + if (this.targetId === targetId && this.sessionId) { + const res = await this.send( + 'Runtime.evaluate', + { expression: 'location.href', returnByValue: true }, + this.sessionId, + 1_000, + ); + const href = res?.result?.value; + if (typeof href === 'string' && href !== '' && href !== 'about:blank') { + return; + } + } else { + const targets = await this.listTargets(); + if (!targets.some((t) => t.targetId === targetId)) { + return; + } + } + } catch { + // Renderer busy or target gone; keep polling until the deadline. + } + if (Date.now() > deadline) { + return; + } + await new Promise((resolve) => setTimeout(resolve, 50)); + } + } + + async waitForTargetGone(targetId: string, timeoutMs: number): Promise { + const deadline = Date.now() + timeoutMs; + for (;;) { + try { + const targets = await this.listTargets(); + if (!targets.some((t) => t.targetId === targetId)) { + return; + } + } catch { + return; + } + if (Date.now() > deadline) { + return; + } + await new Promise((resolve) => setTimeout(resolve, 50)); + } + } + + async ensureRealTab(): Promise { + await this.ensureAttached(); + const targets = await this.listTargets(); + const current = targets.find((t) => t.targetId === this.targetId); + if (current && current.type === 'page' && !isInternalUrl(current.url)) { + return current; + } + const real = targets.find((t) => t.type === 'page' && !isInternalUrl(t.url)); + if (real) { + await this.attach(real.targetId); + return real; + } + const created = await this.browserCommand<{ targetId: string }>('Target.createTarget', { + url: 'about:blank', + }); + await this.attach(created.targetId); + const after = await this.listTargets(); + return ( + after.find((t) => t.targetId === created.targetId) ?? { + targetId: created.targetId, + type: 'page', + title: '', + url: 'about:blank', + attached: true, + } + ); + } + + async evaluateOnTarget(targetId: string, expression: string): Promise { + await this.ensureConnected(); + const res = await this.browserCommand<{ sessionId: string }>('Target.attachToTarget', { + targetId, + flatten: true, + }); + const sessionId = res.sessionId; + try { + return await this.evaluate(sessionId, expression); + } finally { + try { + await this.browserCommand('Target.detachFromTarget', { sessionId }); + } catch { + // ignore + } + } + } + + async evaluate(sessionId: string, expression: string): Promise { + const evalRes = await this.send( + 'Runtime.evaluate', + { expression, awaitPromise: true, returnByValue: true }, + sessionId, + ); + if (evalRes.exceptionDetails || evalRes.result?.subtype === 'error') { + const desc = + evalRes.result?.description ?? + evalRes.exceptionDetails?.exception?.description ?? + evalRes.exceptionDetails?.text ?? + 'evaluation failed'; + throw new Error(desc); + } + if ('value' in (evalRes.result ?? {})) { + return evalRes.result.value as T; + } + return this.decodeUnserializable(evalRes.result?.unserializableValue) as T; + } + + private decodeUnserializable(value: unknown): unknown { + if (value === 'NaN') return Number.NaN; + if (value === 'Infinity') return Number.POSITIVE_INFINITY; + if (value === '-Infinity') return Number.NEGATIVE_INFINITY; + if (value === '-0') return -0; + if (typeof value === 'string' && /^-?\d+n$/.test(value)) return BigInt(value.slice(0, -1)); + return undefined; + } + + drainEvents(): CdpEvent[] { + const events = this.events; + this.events = []; + return events; + } + + networkIdleState(): { inFlight: number; lastActivity: number } { + return { inFlight: this.inFlightRequests.size, lastActivity: this.lastNetworkActivity }; + } + + close(): void { + if (this.ws) { + try { + this.ws.close(); + } catch { + // ignore + } + } + this.onClose(this.ws as WebSocket); + this.ws = null; + } +} diff --git a/server/runtime/browser-helpers.ts b/server/runtime/browser-helpers.ts new file mode 100644 index 00000000..e818a599 --- /dev/null +++ b/server/runtime/browser-helpers.ts @@ -0,0 +1,1158 @@ + +import { writeFileSync } from 'fs'; +import sharp from 'sharp'; +import { CdpClient, isInternalUrl, type CdpEvent } from './browser-cdp-client'; +import { + buildFunctionCallExpression, + normalizeJsOptions, + type JsOptions, + type PageFunction, +} from './page-evaluation'; +import { resolveUSKey, supportedUSKeyNames } from './us-keyboard-layout'; + +const DEFAULT_TIMEOUT_MS = 30_000; + +// Bound wedged wheel commands well below the outer execution timeout. +const SCROLL_COMMAND_TIMEOUT_MS = 5_000; + +// Leave time for helper errors to beat the destructive execution deadline. +const EXECUTION_DEADLINE_MARGIN_MS = 500; + +type MouseButton = 'left' | 'right' | 'middle'; +type ElementWaitState = 'attached' | 'detached' | 'visible' | 'hidden'; + +interface ClickPoint { + x: number; + y: number; +} + +interface BackendNodeTarget { + backendNodeId: number; +} + +type ElementTarget = string | BackendNodeTarget; + +export interface AccessibilityNode extends BackendNodeTarget { + role: string; + name: string; + value?: string; + checked?: boolean | 'mixed'; + pressed?: boolean | 'mixed'; + selected?: boolean; + expanded?: boolean; + disabled?: boolean; +} + +export interface AccessibilitySnapshot { + url: string; + title: string; + nodes: AccessibilityNode[]; +} + +interface ClickOptions { + button?: MouseButton; + clickCount?: number; + timeoutSec?: number; +} + +interface WaitForElementOptions { + state?: ElementWaitState; + timeoutSec?: number; +} + +interface WaitForEventOptions { + sessionId?: string | null; + timeoutSec?: number; + predicate?: (event: CdpEvent) => boolean; +} + +interface FillInputOptions { + clearFirst?: boolean; + timeoutSec?: number; +} + +function optionsObject(raw: unknown, helper: string): Record { + if (raw === undefined) return {}; + if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) { + throw new Error(`${helper}: options must be an object`); + } + return raw as Record; +} + +function rejectUnknownOptions( + options: Record, + allowed: readonly string[], + helper: string, +): void { + for (const key of Object.keys(options)) { + if (!allowed.includes(key)) { + throw new Error(`${helper}: unknown option: ${key}`); + } + } +} + +function backendNodeId(target: unknown, helper: string): number | null { + if (!target || typeof target !== 'object' || !Object.prototype.hasOwnProperty.call(target, 'backendNodeId')) { + return null; + } + const id = (target as Record).backendNodeId; + if (!Number.isInteger(id) || (id as number) <= 0) { + throw new Error(`${helper}: backendNodeId must be a positive integer`); + } + return id as number; +} + +function accessibilityControlState(node: any): Partial { + const state: Partial = {}; + for (const property of node.properties ?? []) { + const name = property.name as 'checked' | 'pressed' | 'selected' | 'expanded' | 'disabled'; + const observed = property.value?.value; + if (name === 'checked' || name === 'pressed') { + if (observed === 'mixed') state[name] = 'mixed'; + else if (observed === true || observed === 'true') state[name] = true; + else if (observed === false || observed === 'false') state[name] = false; + } else if ( + (name === 'selected' || name === 'expanded' || name === 'disabled') && + typeof observed === 'boolean' + ) { + state[name] = observed; + } + } + return state; +} + +function nonNegativeSeconds(value: unknown, fallback: number, helper: string): number { + if (value === undefined) return fallback; + if (typeof value !== 'number' || !Number.isFinite(value) || value < 0) { + throw new Error(`${helper}: timeoutSec must be a non-negative finite number`); + } + return value; +} + +const MODIFIER_SUGAR: Record = { + alt: 'Alt', + ctrl: 'Control', + control: 'Control', + meta: 'Meta', + shift: 'Shift', +}; + +function normalizeKeyModifiers(modifiers?: string[] | Record): string[] { + if (modifiers === undefined || modifiers === null) { + return []; + } + if (Array.isArray(modifiers)) { + return modifiers.map((name) => { + const canonical = MODIFIER_SUGAR[String(name).toLowerCase()]; + if (canonical === undefined) { + throw new Error(`pressKey: unknown modifier: ${name} (expected Alt, Control, Meta, or Shift)`); + } + return canonical; + }); + } + if (typeof modifiers === 'object') { + const out: string[] = []; + for (const [name, on] of Object.entries(modifiers)) { + if (!on) continue; + const canonical = MODIFIER_SUGAR[name.toLowerCase()]; + if (canonical === undefined) { + throw new Error(`pressKey: unknown modifier: ${name} (expected Alt, Control, Meta, or Shift)`); + } + if (!out.includes(canonical)) { + out.push(canonical); + } + } + return out; + } + throw new Error( + 'pressKey: modifiers must be an array drawn from Alt, Control, Meta, Shift (or an object like {ctrl: true})', + ); +} + +export class BrowserHelpers { + private readonly client: CdpClient; + + executionDeadlineMs: number | null = null; + onLog?: (message: string) => void; + + constructor(client: CdpClient) { + this.client = client; + } + + private waitDeadline(timeoutMs: number): { deadline: number; clamped: boolean } { + const own = Date.now() + timeoutMs; + const exec = this.executionDeadlineMs; + if (exec !== null && exec - EXECUTION_DEADLINE_MARGIN_MS < own) { + return { deadline: exec - EXECUTION_DEADLINE_MARGIN_MS, clamped: true }; + } + return { deadline: own, clamped: false }; + } + + // Escape hatch + events + + cdp = async (method: string, params?: unknown, sessionId?: string | null): Promise => { + if (sessionId === null) { + return this.client.browserCommand(method, params); + } + if (typeof sessionId === 'string') { + return this.client.send(method, params, sessionId); + } + // Default: attached session for session-scoped domains. Target.* and + // Browser.* style commands must be sent browser-level; callers should + // pass null explicitly, but route obviously browser-scoped domains for + // ergonomics. + if (/^(Target|Browser|SystemInfo|Storage)\./.test(method)) { + return this.client.browserCommand(method, params); + } + return this.client.sessionCommand(method, params); + }; + + drainEvents = async (): Promise => { + await this.client.ensureAttached(); + return this.client.drainEvents(); + }; + + waitForEvent = ( + method: string, + rawOptions?: WaitForEventOptions, + ): Promise => { + if (typeof method !== 'string' || method.trim() === '') { + throw new Error('waitForEvent: method must be a non-empty string'); + } + const options = optionsObject(rawOptions, 'waitForEvent'); + rejectUnknownOptions(options, ['sessionId', 'timeoutSec', 'predicate'], 'waitForEvent'); + const timeoutSec = nonNegativeSeconds(options.timeoutSec, 30, 'waitForEvent'); + const predicate = options.predicate; + if (predicate !== undefined && typeof predicate !== 'function') { + throw new Error('waitForEvent: predicate must be a function'); + } + + let sessionId: string | undefined; + if (Object.prototype.hasOwnProperty.call(options, 'sessionId')) { + if ( + options.sessionId !== null && + (typeof options.sessionId !== 'string' || options.sessionId === '') + ) { + throw new Error('waitForEvent: sessionId must be a non-empty string or null'); + } + sessionId = options.sessionId === null ? undefined : options.sessionId as string; + } else { + if (!this.client.sessionId) { + throw new Error( + 'waitForEvent: no target is attached; call ensureRealTab() or newTab() before arming a page event', + ); + } + sessionId = this.client.sessionId; + } + + return this.client.waitForEvent(method, { + sessionId, + timeoutMs: timeoutSec * 1000, + predicate: predicate as ((event: CdpEvent) => boolean) | undefined, + }); + }; + + // Navigation + page state + + gotoUrl = async (url: string): Promise => { + return this.client.sessionCommand('Page.navigate', { url }); + }; + + accessibilitySnapshot = async (): Promise => { + await this.client.ensureAttached(); + const { nodes = [] } = await this.client.sessionCommand('Accessibility.getFullAXTree'); + const info = await this.pageInfo(); + return { + url: String(info.url ?? ''), + title: String(info.title ?? ''), + nodes: nodes + .filter((node: any) => !node.ignored && node.backendDOMNodeId) + .map((node: any) => ({ + backendNodeId: node.backendDOMNodeId, + role: String(node.role?.value ?? ''), + name: String(node.name?.value ?? '').replace(/\s+/g, ' ').trim(), + ...(node.value ? { value: String(node.value.value) } : {}), + ...accessibilityControlState(node), + })), + }; + }; + + pageInfo = async (): Promise> => { + // A pending modal JavaScript dialog freezes the renderer main thread, so + // Runtime.evaluate would block until the CDP command timeout and the + // dialog field would be unreachable exactly when it matters. Report the + // dialog plus last-known target metadata (from the browser-level target + // list, which does not block) instead of evaluating in the page. + const pending = this.client.pendingDialog; + if (pending) { + const info: Record = { + dialog: { type: pending.type, message: pending.message }, + }; + try { + const targets = await this.client.listTargets(); + const current = targets.find((t) => t.targetId === this.client.targetId); + if (current) { + info.url = current.url; + info.title = current.title; + } + } catch { + // Best effort: the dialog itself is the critical payload. + } + return info; + } + const evalRes = await this.client.sessionCommand('Runtime.evaluate', { + expression: `(() => ({ + url: location.href, + title: document.title, + viewport: { width: window.innerWidth, height: window.innerHeight }, + scroll: { x: window.scrollX, y: window.scrollY }, + page: { + width: document.documentElement ? document.documentElement.scrollWidth : 0, + height: document.documentElement ? document.documentElement.scrollHeight : 0, + }, + ready_state: document.readyState, + }))()`, + returnByValue: true, + }); + const value = evalRes.result?.value ?? {}; + const dialog = this.client.pendingDialog; + return { + ...value, + dialog: dialog ? { type: dialog.type, message: dialog.message } : null, + }; + }; + + // Input + + click = async (target: string | ClickPoint | BackendNodeTarget, rawOptions?: ClickOptions): Promise => { + const options = optionsObject(rawOptions, 'click'); + rejectUnknownOptions(options, ['button', 'clickCount', 'timeoutSec'], 'click'); + + const button = options.button ?? 'left'; + if (button !== 'left' && button !== 'right' && button !== 'middle') { + throw new Error('click: button must be left, right, or middle'); + } + const clickCount = options.clickCount ?? 1; + if (!Number.isInteger(clickCount) || (clickCount as number) <= 0) { + throw new Error('click: clickCount must be a positive integer'); + } + + let point: ClickPoint; + if (typeof target === 'string') { + if (target.length === 0) throw new Error('click: selector must not be empty'); + point = await this.waitForClickablePoint( + target, + nonNegativeSeconds(options.timeoutSec, 10, 'click'), + ); + } else { + const nodeId = backendNodeId(target, 'click'); + if (nodeId !== null) { + point = await this.waitForClickableBackendNode( + nodeId, + nonNegativeSeconds(options.timeoutSec, 10, 'click'), + ); + } else { + const coordinate = target as ClickPoint; + if ( + typeof coordinate.x !== 'number' || + !Number.isFinite(coordinate.x) || + typeof coordinate.y !== 'number' || + !Number.isFinite(coordinate.y) + ) { + throw new Error('click: target must be a selector, accessibility node, or finite {x, y} coordinates'); + } + if (options.timeoutSec !== undefined) { + throw new Error('click: timeoutSec is only supported for selector targets or accessibility-node targets'); + } + point = { x: coordinate.x, y: coordinate.y }; + } + } + + await this.client.sessionCommand('Input.dispatchMouseEvent', { + type: 'mouseMoved', + x: point.x, + y: point.y, + button: 'none', + }); + const buttons = button === 'left' ? 1 : button === 'right' ? 2 : 4; + for (let count = 1; count <= (clickCount as number); count++) { + await this.client.sessionCommand('Input.dispatchMouseEvent', { + type: 'mousePressed', + x: point.x, + y: point.y, + button, + buttons, + clickCount: count, + }); + await this.client.sessionCommand('Input.dispatchMouseEvent', { + type: 'mouseReleased', + x: point.x, + y: point.y, + button, + buttons: 0, + clickCount: count, + }); + } + }; + + typeText = async (text: string): Promise => { + await this.client.sessionCommand('Input.insertText', { text }); + }; + + fillInput = async ( + target: ElementTarget, + text: string, + rawOptions?: FillInputOptions, + ): Promise => { + if (typeof target === 'string' && target.length === 0) { + throw new Error('fillInput: selector must be a non-empty string'); + } + if (typeof target !== 'string' && backendNodeId(target, 'fillInput') === null) { + throw new Error('fillInput: target must be a selector or accessibility node'); + } + if (typeof text !== 'string') { + throw new Error('fillInput: text must be a string'); + } + const options = optionsObject(rawOptions, 'fillInput'); + rejectUnknownOptions(options, ['clearFirst', 'timeoutSec'], 'fillInput'); + const clearFirst = options.clearFirst ?? true; + if (typeof clearFirst !== 'boolean') { + throw new Error('fillInput: clearFirst must be a boolean'); + } + const timeoutSec = nonNegativeSeconds(options.timeoutSec, 10, 'fillInput'); + if (typeof target === 'string') { + await this.waitForFillTarget(target, timeoutSec); + } else { + await this.waitForFillBackendNode(backendNodeId(target, 'fillInput')!, timeoutSec); + } + + if (clearFirst) { + const modifiers = process.platform === 'darwin' ? 4 : 2; + const selectAll = { + key: 'a', + code: 'KeyA', + modifiers, + windowsVirtualKeyCode: 65, + nativeVirtualKeyCode: 65, + }; + await this.client.sessionCommand('Input.dispatchKeyEvent', { type: 'rawKeyDown', ...selectAll }); + await this.client.sessionCommand('Input.dispatchKeyEvent', { type: 'keyUp', ...selectAll }); + await this.pressKey('Backspace'); + } + for (const char of text) { + await this.pressKey(char); + } + await this.evaluateInPage(`(() => { + const el = document.activeElement; + if (!el) return; + el.dispatchEvent(new Event('input', { bubbles: true })); + el.dispatchEvent(new Event('change', { bubbles: true })); + })()`); + }; + + private static readonly MODIFIER_BITS: Record = { + Alt: 1, + Control: 2, + Meta: 4, + Shift: 8, + }; + + pressKey = async ( + key: string, + modifiers?: number | string[] | Record, + ): Promise => { + let modifierBits = 0; + if (typeof modifiers === 'number') { + if (!Number.isInteger(modifiers) || modifiers < 0 || modifiers > 15) { + throw new Error('pressKey: numeric modifiers must be a bitfield from 0 to 15'); + } + modifierBits = modifiers; + } else { + for (const m of normalizeKeyModifiers(modifiers)) { + const bit = BrowserHelpers.MODIFIER_BITS[m]; + if (bit === undefined) { + throw new Error(`pressKey: unknown modifier: ${m} (expected Alt, Control, Meta, or Shift)`); + } + modifierBits |= bit; + } + } + + let def; + try { + def = resolveUSKey(key, (modifierBits & BrowserHelpers.MODIFIER_BITS.Shift) !== 0); + } catch { + throw new Error( + `unknown key: ${key} (use one Unicode character or a supported US-layout key such as ${ + supportedUSKeyNames().slice(0, 20).join(', ') + })`, + ); + } + + const base = { + code: def.code, + key: def.key, + windowsVirtualKeyCode: def.keyCode, + nativeVirtualKeyCode: def.keyCode, + modifiers: modifierBits, + location: def.location, + isKeypad: def.location === 3, + unmodifiedText: def.unmodifiedText, + }; + const shortcut = (modifierBits & (1 | 2 | 4)) !== 0; + const printable = [...def.key].length === 1 && !!def.text && !shortcut; + await this.client.sessionCommand('Input.dispatchKeyEvent', { + ...base, + type: 'keyDown', + ...(!printable && def.text && !shortcut ? { text: def.text } : {}), + }); + if (printable) { + await this.client.sessionCommand('Input.dispatchKeyEvent', { + ...base, + type: 'char', + text: def.text, + }); + } + await this.client.sessionCommand('Input.dispatchKeyEvent', { ...base, type: 'keyUp' }); + }; + + scroll = async (x: number, y: number, dy = -300, dx = 0): Promise => { + await this.client.sessionCommand( + 'Input.dispatchMouseEvent', + { + type: 'mouseWheel', + x, + y, + deltaX: dx, + deltaY: dy, + }, + SCROLL_COMMAND_TIMEOUT_MS, + ); + }; + + // Screenshots + + captureScreenshot = async ( + path?: string, + fullPage = false, + maxDim?: number, + ): Promise => { + const outPath = path ?? '/tmp/shot.png'; + const shot = await this.client.sessionCommand('Page.captureScreenshot', { + format: 'png', + captureBeyondViewport: fullPage, + }); + let bytes = Buffer.from(shot.data, 'base64'); + if (maxDim !== undefined) { + if (!Number.isInteger(maxDim) || maxDim <= 0) { + throw new Error('captureScreenshot: maxDim must be a positive integer'); + } + bytes = await sharp(bytes) + .resize({ width: maxDim, height: maxDim, fit: 'inside', withoutEnlargement: true }) + .png() + .toBuffer(); + } + writeFileSync(outPath, bytes); + return outPath; + }; + + // Tabs + + listTabs = async (includeChrome = true): Promise[]> => { + const targets = await this.client.listTargets(); + return targets + .filter((t) => t.type === 'page') + .filter((t) => includeChrome || !isInternalUrl(t.url)) + .map((t) => ({ targetId: t.targetId, title: t.title, url: t.url })); + }; + + currentTab = async (): Promise> => { + await this.client.ensureAttached(); + const targets = await this.client.listTargets(); + const current = targets.find((t) => t.targetId === this.client.targetId); + if (!current) { + throw new Error('attached target no longer exists'); + } + return { targetId: current.targetId, url: current.url, title: current.title }; + }; + + private targetId(target: unknown): string { + if (typeof target === 'string') return target; + if (target && typeof target === 'object' && typeof (target as any).targetId === 'string') { + return (target as any).targetId; + } + throw new Error('expected a targetId string or a tab object returned by currentTab/listTabs'); + } + + switchTab = async (target: unknown): Promise => { + return this.client.attach(this.targetId(target)); + }; + + newTab = async (url = 'about:blank'): Promise => { + if (url !== 'about:blank') { + try { + const current = await this.currentTab(); + const currentUrl = String(current.url ?? ''); + if ( + currentUrl === '' || + currentUrl === 'about:blank' || + currentUrl.startsWith('about:blank#') || + /^(chrome:\/\/(newtab|new-tab-page)|edge:\/\/newtab|about:newtab)/.test(currentUrl) + ) { + await this.gotoUrl(url); + await this.client.waitForNavigationCommit(current.targetId as string, 5_000); + return current.targetId as string; + } + } catch { + // No attached reusable tab; create one below. + } + } + await this.client.ensureConnected(); + const created = await this.client.browserCommand<{ targetId: string }>('Target.createTarget', { + url: 'about:blank', + }); + await this.client.attach(created.targetId); + if (url !== 'about:blank') { + await this.gotoUrl(url); + await this.client.waitForNavigationCommit(created.targetId, 5_000); + } + return created.targetId; + }; + + closeTab = async (target?: unknown): Promise => { + await this.client.ensureConnected(); + const id = target === undefined ? this.client.targetId : this.targetId(target); + if (!id) { + throw new Error('no tab is attached and no target id was provided'); + } + await this.client.browserCommand('Target.closeTarget', { targetId: id }); + if (id === this.client.targetId) { + this.client.sessionId = null; + this.client.targetId = null; + } + // Target.closeTarget resolves before the target is fully destroyed; + // wait (best effort) so an immediate listTabs call no longer counts the + // closed tab. + await this.client.waitForTargetGone(id, 5_000); + }; + + ensureRealTab = async (): Promise | null> => { + const tabs = await this.listTabs(false); + if (tabs.length === 0) return null; + try { + const current = await this.currentTab(); + if (!isInternalUrl(String(current.url ?? ''))) return current; + } catch { + // No usable attached target; attach the first real page below. + } + await this.switchTab(tabs[0]); + return tabs[0]; + }; + + iframeTarget = async (urlSubstring: string): Promise | null> => { + const targets = await this.client.listTargets(); + const match = targets.find((t) => t.type === 'iframe' && t.url.includes(urlSubstring)); + if (!match) return null; + return { targetId: match.targetId, url: match.url, title: match.title, type: match.type }; + }; + + // Waiting + + waitMs = async (milliseconds = 1_000): Promise => { + await new Promise((resolve) => setTimeout(resolve, milliseconds)); + }; + + waitForLoad = async (timeoutSec = 15): Promise => { + const { deadline } = this.waitDeadline(timeoutSec * 1000); + while (Date.now() <= deadline) { + const res = await this.client.sessionCommand('Runtime.evaluate', { + expression: 'document.readyState', + returnByValue: true, + }); + if (res.result?.value === 'complete') return true; + await this.waitMs(300); + } + return false; + }; + + waitForElement = async ( + target: ElementTarget, + rawOptions?: WaitForElementOptions, + ): Promise => { + if (typeof target === 'string' && target.length === 0) { + throw new Error('waitForElement: selector must be a non-empty string'); + } + if (typeof target !== 'string' && backendNodeId(target, 'waitForElement') === null) { + throw new Error('waitForElement: target must be a selector or accessibility node'); + } + const options = optionsObject(rawOptions, 'waitForElement'); + rejectUnknownOptions(options, ['state', 'timeoutSec'], 'waitForElement'); + const state = options.state ?? 'visible'; + if (state !== 'attached' && state !== 'detached' && state !== 'visible' && state !== 'hidden') { + throw new Error('waitForElement: state must be attached, detached, visible, or hidden'); + } + const timeoutSec = nonNegativeSeconds(options.timeoutSec, 10, 'waitForElement'); + const { deadline } = this.waitDeadline(timeoutSec * 1000); + for (;;) { + const found = typeof target === 'string' ? await this.evaluateInPage( + `(function elementState(selector, state) { + const elements = [...document.querySelectorAll(selector)]; + const visible = (el) => { + if (!el.isConnected || el.getClientRects().length === 0) return false; + if (typeof el.checkVisibility === 'function') { + return el.checkVisibility({ checkOpacity: true, checkVisibilityCSS: true }); + } + const style = getComputedStyle(el); + return style.display !== 'none' && style.visibility !== 'hidden' && style.opacity !== '0'; + }; + if (state === 'attached') return elements.length > 0; + if (state === 'detached') return elements.length === 0; + const visibleCount = elements.filter(visible).length; + return state === 'visible' ? visibleCount > 0 : visibleCount === 0; + })(${JSON.stringify(target)}, ${JSON.stringify(state)})`, + ) : await this.backendNodeMatchesState(backendNodeId(target, 'waitForElement')!, state); + if (found) return true; + if (Date.now() >= deadline) return false; + await this.waitMs(Math.min(100, Math.max(0, deadline - Date.now()))); + } + }; + + waitForNetworkIdle = async (idleSec = 0.5, timeoutSec = 30): Promise => { + await this.client.ensureAttached(); + const { deadline } = this.waitDeadline(timeoutSec * 1000); + for (;;) { + const { inFlight, lastActivity } = this.client.networkIdleState(); + const now = Date.now(); + if (inFlight === 0 && now - lastActivity >= idleSec * 1000) { + return true; + } + if (now > deadline) { + return false; + } + await this.waitMs(100); + } + }; + + // JavaScript evaluation + uploads + + js = async ( + expressionOrFunction: string | PageFunction, + rawOptions?: JsOptions, + ): Promise => { + const options = normalizeJsOptions(rawOptions); + let expression: string; + if (typeof expressionOrFunction === 'string') { + if (Object.prototype.hasOwnProperty.call(options, 'arg')) { + throw new Error('js: arg is only supported when evaluating a page function'); + } + expression = expressionOrFunction; + } else if (typeof expressionOrFunction === 'function') { + expression = buildFunctionCallExpression(expressionOrFunction, options.arg); + } else { + throw new Error('js: expected a JavaScript expression string or page function'); + } + + if (options.targetId) { + return this.client.evaluateOnTarget(options.targetId, expression); + } + return this.evaluateInPage(expression); + }; + + uploadFile = async (target: ElementTarget, path: string | string[]): Promise => { + const paths = typeof path === 'string' ? [path] : path; + if (!Array.isArray(paths) || paths.length === 0 || paths.some((item) => typeof item !== 'string')) { + throw new Error('uploadFile requires a VM-local file path or a non-empty array of paths'); + } + if (typeof target === 'string') { + if (target.length === 0) throw new Error('uploadFile: selector must be a non-empty string'); + const doc = await this.client.sessionCommand('DOM.getDocument', { depth: 0 }); + const queried = await this.client.sessionCommand('DOM.querySelector', { + nodeId: doc.root.nodeId, + selector: target, + }); + if (!queried.nodeId) { + throw new Error(`no element matches selector: ${target}`); + } + await this.client.sessionCommand('DOM.setFileInputFiles', { + nodeId: queried.nodeId, + files: paths, + }); + return; + } + await this.client.sessionCommand('DOM.setFileInputFiles', { + backendNodeId: backendNodeId(target, 'uploadFile'), + files: paths, + }); + }; + + // HTTP + + httpGet = async ( + url: string, + headers?: Record, + timeoutSec = 20, + ): Promise => { + if (typeof timeoutSec !== 'number' || !Number.isFinite(timeoutSec) || timeoutSec < 0) { + throw new Error('httpGet: timeoutSec must be a non-negative finite number'); + } + const { deadline } = this.waitDeadline(timeoutSec * 1000); + const timeoutMs = Math.max(0, deadline - Date.now()); + if (timeoutMs === 0) { + throw new Error(`GET ${url} timed out before it could start`); + } + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), timeoutMs); + try { + const res = await fetch(url, { + headers: { 'user-agent': 'Mozilla/5.0', 'accept-encoding': 'gzip', ...(headers ?? {}) }, + signal: controller.signal, + }); + if (!res.ok) { + throw new Error(`GET ${url} failed with status ${res.status}`); + } + return await res.text(); + } catch (err) { + if (controller.signal.aborted) { + throw new Error(`GET ${url} timed out after ${timeoutMs}ms`); + } + throw err; + } finally { + clearTimeout(timer); + } + }; + + // Internals + + private backendNodeGone(error: unknown): boolean { + return /could not find node|no node with given id|could not resolve backend node/i.test( + String((error as any)?.message ?? error), + ); + } + + private async callOnBackendNode( + id: number, + functionDeclaration: string, + args: unknown[] = [], + ): Promise { + const resolved = await this.client.sessionCommand('DOM.resolveNode', { backendNodeId: id }); + const objectId = resolved.object?.objectId; + if (!objectId) throw new Error(`could not resolve backend node ${id}`); + try { + const result = await this.client.sessionCommand('Runtime.callFunctionOn', { + objectId, + functionDeclaration, + arguments: args.map((value) => ({ value })), + awaitPromise: true, + returnByValue: true, + }); + if (result.exceptionDetails || result.result?.subtype === 'error') { + const description = + result.result?.description ?? + result.exceptionDetails?.exception?.description ?? + result.exceptionDetails?.text ?? + `backend node ${id} evaluation failed`; + throw new Error(description); + } + return result.result?.value; + } finally { + try { + await this.client.sessionCommand('Runtime.releaseObject', { objectId }); + } catch { + // The object is already gone when its target or connection closes. + } + } + } + + private async waitForClickableBackendNode(id: number, timeoutSec: number): Promise { + const { deadline } = this.waitDeadline(timeoutSec * 1000); + let lastStatus = 'not found'; + for (;;) { + try { + const result = await this.callOnBackendNode( + id, + `async function resolveBackendClickTarget() { + const el = this; + const visible = (candidate) => { + if (!(candidate instanceof Element) || !candidate.isConnected || candidate.getClientRects().length === 0) return false; + if (typeof candidate.checkVisibility === 'function') { + return candidate.checkVisibility({ checkOpacity: true, checkVisibilityCSS: true }); + } + const style = getComputedStyle(candidate); + return style.display !== 'none' && style.visibility !== 'hidden' && style.opacity !== '0'; + }; + if (!visible(el)) return { status: 'not visible' }; + if (el.matches(':disabled') || el.getAttribute('aria-disabled') === 'true') return { status: 'disabled' }; + el.scrollIntoView({ block: 'center', inline: 'center', behavior: 'instant' }); + const before = el.getBoundingClientRect(); + await new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve))); + if (!visible(el)) return { status: 'detached or hidden' }; + const after = el.getBoundingClientRect(); + const stable = + Math.abs(before.x - after.x) < 0.25 && + Math.abs(before.y - after.y) < 0.25 && + Math.abs(before.width - after.width) < 0.25 && + Math.abs(before.height - after.height) < 0.25; + if (!stable) return { status: 'moving' }; + const left = Math.max(0, after.left); + const right = Math.min(innerWidth, after.right); + const top = Math.max(0, after.top); + const bottom = Math.min(innerHeight, after.bottom); + if (right <= left || bottom <= top) return { status: 'outside viewport' }; + const x = left + (right - left) / 2; + const y = top + (bottom - top) / 2; + const hit = document.elementFromPoint(x, y); + if (!hit || (hit !== el && !el.contains(hit))) { + return { status: 'intercepted', hit: hit ? hit.tagName.toLowerCase() : null }; + } + return { status: 'ready', x, y }; + }`, + ) as { status?: string; x?: number; y?: number }; + if (result?.status === 'ready' && typeof result.x === 'number' && typeof result.y === 'number') { + return { x: result.x, y: result.y }; + } + lastStatus = result?.status ?? 'not actionable'; + } catch (error) { + if (!this.backendNodeGone(error)) throw error; + lastStatus = 'not found'; + } + if (Date.now() >= deadline) { + throw new Error(`click: backend node ${id} was not actionable within ${timeoutSec}s (${lastStatus})`); + } + await this.waitMs(Math.min(50, Math.max(0, deadline - Date.now()))); + } + } + + private async waitForFillBackendNode(id: number, timeoutSec: number): Promise { + const { deadline } = this.waitDeadline(timeoutSec * 1000); + let lastStatus = 'not found'; + for (;;) { + try { + const result = await this.callOnBackendNode( + id, + `function resolveBackendFillTarget() { + const el = this; + const visible = (candidate) => { + if (!(candidate instanceof Element) || !candidate.isConnected || candidate.getClientRects().length === 0) return false; + if (typeof candidate.checkVisibility === 'function') { + return candidate.checkVisibility({ checkOpacity: true, checkVisibilityCSS: true }); + } + const style = getComputedStyle(candidate); + return style.display !== 'none' && style.visibility !== 'hidden' && style.opacity !== '0'; + }; + if (!visible(el)) return { status: 'not visible' }; + if (el.matches(':disabled') || el.getAttribute('aria-disabled') === 'true') return { status: 'disabled' }; + const tag = el.tagName; + const inputType = tag === 'INPUT' ? (el.getAttribute('type') || 'text').toLowerCase() : null; + const textInput = tag === 'INPUT' && ![ + 'button', 'checkbox', 'color', 'file', 'hidden', 'image', 'radio', 'range', 'reset', 'submit', + ].includes(inputType); + const editable = ((textInput || tag === 'TEXTAREA') && !el.readOnly) || el.isContentEditable; + if (!editable) return { status: 'not editable' }; + el.scrollIntoView({ block: 'center', inline: 'center', behavior: 'instant' }); + el.focus(); + if (!el.isConnected || (document.activeElement !== el && !el.contains(document.activeElement))) { + return { status: 'could not focus' }; + } + return { status: 'ready' }; + }`, + ) as { status?: string }; + if (result?.status === 'ready') return; + lastStatus = result?.status ?? 'not editable'; + } catch (error) { + if (!this.backendNodeGone(error)) throw error; + lastStatus = 'not found'; + } + if (Date.now() >= deadline) { + throw new Error(`fillInput: backend node ${id} was not editable within ${timeoutSec}s (${lastStatus})`); + } + await this.waitMs(Math.min(50, Math.max(0, deadline - Date.now()))); + } + } + + private async backendNodeMatchesState(id: number, state: ElementWaitState): Promise { + try { + return Boolean(await this.callOnBackendNode( + id, + `function backendNodeMatchesState(state) { + const el = this; + const attached = el instanceof Element && el.isConnected; + if (state === 'attached') return attached; + if (state === 'detached') return !attached; + if (!attached || el.getClientRects().length === 0) return state === 'hidden'; + let visible; + if (typeof el.checkVisibility === 'function') { + visible = el.checkVisibility({ checkOpacity: true, checkVisibilityCSS: true }); + } else { + const style = getComputedStyle(el); + visible = style.display !== 'none' && style.visibility !== 'hidden' && style.opacity !== '0'; + } + return state === 'visible' ? visible : !visible; + }`, + [state], + )); + } catch (error) { + if (!this.backendNodeGone(error)) throw error; + return state === 'detached' || state === 'hidden'; + } + } + + private async waitForClickablePoint(selector: string, timeoutSec: number): Promise { + const { deadline } = this.waitDeadline(timeoutSec * 1000); + let lastStatus = 'not found'; + for (;;) { + const result = await this.evaluateInPage( + `(async function resolveClickTarget(selector) { + const visible = (el) => { + if (!el.isConnected || el.getClientRects().length === 0) return false; + if (typeof el.checkVisibility === 'function') { + return el.checkVisibility({ checkOpacity: true, checkVisibilityCSS: true }); + } + const style = getComputedStyle(el); + return style.display !== 'none' && style.visibility !== 'hidden' && style.opacity !== '0'; + }; + const candidates = [...document.querySelectorAll(selector)].filter(visible); + if (candidates.length === 0) return { status: 'not visible' }; + if (candidates.length > 1) return { status: 'multiple', count: candidates.length }; + const el = candidates[0]; + if (el.matches(':disabled') || el.getAttribute('aria-disabled') === 'true') { + return { status: 'disabled' }; + } + el.scrollIntoView({ block: 'center', inline: 'center', behavior: 'instant' }); + const before = el.getBoundingClientRect(); + await new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve))); + if (!el.isConnected || !visible(el)) return { status: 'detached or hidden' }; + const after = el.getBoundingClientRect(); + const stable = + Math.abs(before.x - after.x) < 0.25 && + Math.abs(before.y - after.y) < 0.25 && + Math.abs(before.width - after.width) < 0.25 && + Math.abs(before.height - after.height) < 0.25; + if (!stable) return { status: 'moving' }; + const left = Math.max(0, after.left); + const right = Math.min(innerWidth, after.right); + const top = Math.max(0, after.top); + const bottom = Math.min(innerHeight, after.bottom); + if (right <= left || bottom <= top) return { status: 'outside viewport' }; + const x = left + (right - left) / 2; + const y = top + (bottom - top) / 2; + const hit = document.elementFromPoint(x, y); + if (!hit || (hit !== el && !el.contains(hit))) { + return { + status: 'intercepted', + hit: hit ? hit.tagName.toLowerCase() : null, + }; + } + return { status: 'ready', x, y }; + })(${JSON.stringify(selector)})`, + ) as { status?: string; count?: number; x?: number; y?: number }; + + if (result?.status === 'ready' && typeof result.x === 'number' && typeof result.y === 'number') { + return { x: result.x, y: result.y }; + } + if (result?.status === 'multiple') { + throw new Error( + `click: selector ${JSON.stringify(selector)} matches ${result.count} visible elements; use a more specific selector`, + ); + } + lastStatus = result?.status ?? 'not actionable'; + if (Date.now() >= deadline) { + throw new Error( + `click: selector ${JSON.stringify(selector)} was not actionable within ${timeoutSec}s (${lastStatus})`, + ); + } + await this.waitMs(Math.min(50, Math.max(0, deadline - Date.now()))); + } + } + + private async waitForFillTarget(selector: string, timeoutSec: number): Promise { + const { deadline } = this.waitDeadline(timeoutSec * 1000); + let lastStatus = 'not found'; + for (;;) { + const result = await this.evaluateInPage( + `(function resolveFillTarget(selector) { + const visible = (el) => { + if (!el.isConnected || el.getClientRects().length === 0) return false; + if (typeof el.checkVisibility === 'function') { + return el.checkVisibility({ checkOpacity: true, checkVisibilityCSS: true }); + } + const style = getComputedStyle(el); + return style.display !== 'none' && style.visibility !== 'hidden' && style.opacity !== '0'; + }; + const candidates = [...document.querySelectorAll(selector)].filter(visible); + if (candidates.length === 0) return { status: 'not visible' }; + if (candidates.length > 1) return { status: 'multiple', count: candidates.length }; + const el = candidates[0]; + if (el.matches(':disabled') || el.getAttribute('aria-disabled') === 'true') { + return { status: 'disabled' }; + } + const tag = el.tagName; + const inputType = tag === 'INPUT' ? (el.getAttribute('type') || 'text').toLowerCase() : null; + const textInput = tag === 'INPUT' && ![ + 'button', 'checkbox', 'color', 'file', 'hidden', 'image', 'radio', 'range', 'reset', 'submit', + ].includes(inputType); + const editable = + ((textInput || tag === 'TEXTAREA') && !el.readOnly) || + el.isContentEditable; + if (!editable) return { status: 'not editable' }; + el.scrollIntoView({ block: 'center', inline: 'center', behavior: 'instant' }); + el.focus(); + if (!el.isConnected || (document.activeElement !== el && !el.contains(document.activeElement))) { + return { status: 'could not focus' }; + } + return { status: 'ready' }; + })(${JSON.stringify(selector)})`, + ) as { status?: string; count?: number }; + + if (result?.status === 'ready') return; + if (result?.status === 'multiple') { + throw new Error( + `fillInput: selector ${JSON.stringify(selector)} matches ${result.count} visible elements; use a more specific selector`, + ); + } + lastStatus = result?.status ?? 'not editable'; + if (Date.now() >= deadline) { + throw new Error( + `fillInput: selector ${JSON.stringify(selector)} was not editable within ${timeoutSec}s (${lastStatus})`, + ); + } + await this.waitMs(Math.min(50, Math.max(0, deadline - Date.now()))); + } + } + + private async evaluateInPage(expression: string): Promise { + await this.client.ensureAttached(); + return this.client.evaluate(this.client.sessionId!, expression); + } +} + +export function buildBrowserGlobals(helpers: BrowserHelpers): Record { + const namespace = { + cdp: helpers.cdp, + drainEvents: helpers.drainEvents, + waitForEvent: helpers.waitForEvent, + gotoUrl: helpers.gotoUrl, + pageInfo: helpers.pageInfo, + accessibilitySnapshot: helpers.accessibilitySnapshot, + click: helpers.click, + typeText: helpers.typeText, + fillInput: helpers.fillInput, + pressKey: helpers.pressKey, + scroll: helpers.scroll, + captureScreenshot: helpers.captureScreenshot, + listTabs: helpers.listTabs, + currentTab: helpers.currentTab, + switchTab: helpers.switchTab, + newTab: helpers.newTab, + closeTab: helpers.closeTab, + ensureRealTab: helpers.ensureRealTab, + iframeTarget: helpers.iframeTarget, + waitMs: helpers.waitMs, + waitForLoad: helpers.waitForLoad, + waitForElement: helpers.waitForElement, + waitForNetworkIdle: helpers.waitForNetworkIdle, + js: helpers.js, + uploadFile: helpers.uploadFile, + httpGet: helpers.httpGet, + }; + const browser = Object.freeze({ ...namespace }); + return { browser, ...namespace }; +} diff --git a/server/runtime/browser-repl.ts b/server/runtime/browser-repl.ts new file mode 100644 index 00000000..ff52e699 --- /dev/null +++ b/server/runtime/browser-repl.ts @@ -0,0 +1,738 @@ +// Persistent, unrestricted JavaScript daemon owned by the API process. + +import { AsyncLocalStorage } from 'async_hooks'; +import { createServer, Socket } from 'net'; +import { StringDecoder } from 'string_decoder'; +import { unlinkSync, existsSync, promises as fsp } from 'fs'; +import vm from 'vm'; +import util from 'util'; +import { CdpClient } from './browser-cdp-client'; +import { BrowserHelpers, buildBrowserGlobals } from './browser-helpers'; +import { CellRuntime } from './cell-runtime'; +import { createWebMCPClient } from './webmcp'; + +const SOCKET_PATH = process.env.BROWSER_REPL_SOCKET || '/tmp/browser-repl.sock'; +const REPL_ID = process.env.BROWSER_REPL_ID || 'unknown'; +const CDP_ENDPOINT = process.env.CDP_ENDPOINT || 'ws://127.0.0.1:9222'; +// Keep the endpoint discoverable by dynamically imported browser clients even +// when the image relies on the runtime's default rather than an explicit env. +process.env.CDP_ENDPOINT = CDP_ENDPOINT; +const KERNEL_API_ENDPOINT = + process.env.KERNEL_API_ENDPOINT || `http://127.0.0.1:${process.env.PORT || '10001'}`; +const WEBMCP_DEADLINE_MARGIN_MS = 500; + +// Output limits (decoded bytes unless noted). +const MAX_TEXT_BYTES = 256 * 1024; // combined text per response +const MAX_ERROR_BYTES = 64 * 1024; +const MAX_STACK_BYTES = 256 * 1024; +const MAX_IMAGE_BYTES = 8 * 1024 * 1024; // per emitted image +const MAX_TOTAL_IMAGE_BYTES = 16 * 1024 * 1024; // aggregate image data per response +const MAX_REQUEST_BYTES = 8 * 1024 * 1024; // incoming request line +const MAX_CONTENT_ITEMS = 10_000; // ordered items in one execution response +const MAX_STRAY_ITEMS = 1000; // buffered output produced outside an execution + +// Private references retained before any user code runs so global/prototype +// modification inside the context cannot corrupt protocol framing or result +// serialization. +const safeStringify = JSON.stringify; +const safeInspect = util.inspect; +const safeFormat = util.format; + +// Content collection + +type TextChannel = 'write' | 'stdout' | 'stderr'; + +interface TextItem { + type: 'text'; + channel: TextChannel; + text: string; +} + +interface ImageItem { + type: 'image'; + mime_type: string; + data_b64: string; +} + +type ContentItem = TextItem | ImageItem; + +class Collector { + items: ContentItem[] = []; + truncated = false; + private textBytes = 0; + private imageBytes = 0; + + constructor( + private readonly maxItems?: number, + private readonly keepLatestItems = false, + ) {} + + addText(channel: TextChannel, text: string): void { + if (!this.reserveItem()) return; + const bytes = Buffer.byteLength(text); + if (this.textBytes + bytes > MAX_TEXT_BYTES) { + const remaining = MAX_TEXT_BYTES - this.textBytes; + if (remaining > 0) { + this.items.push({ + type: 'text', + channel, + text: Buffer.from(text, 'utf8').subarray(0, remaining).toString('utf8'), + }); + this.textBytes = MAX_TEXT_BYTES; + this.enforceItemLimit(); + } + this.truncated = true; + return; + } + this.textBytes += bytes; + this.items.push({ type: 'text', channel, text }); + this.enforceItemLimit(); + } + + addImage(mimeType: string, bytes: Buffer): boolean { + if (!this.reserveItem()) return false; + if (this.imageBytes + bytes.length > MAX_TOTAL_IMAGE_BYTES) { + this.truncated = true; + return false; + } + this.imageBytes += bytes.length; + this.items.push({ type: 'image', mime_type: mimeType, data_b64: bytes.toString('base64') }); + this.enforceItemLimit(); + return true; + } + + adopt(item: ContentItem): void { + if (item.type === 'text') { + this.addText(item.channel, item.text); + } else { + this.addImage(item.mime_type, Buffer.from(item.data_b64, 'base64')); + } + } + + drainInto(target: Collector): void { + for (const item of this.items) target.adopt(item); + target.truncated ||= this.truncated; + } + + private reserveItem(): boolean { + if (this.maxItems === undefined || this.items.length < this.maxItems) return true; + this.truncated = true; + if (!this.keepLatestItems) return false; + const removed = this.items.shift(); + if (removed?.type === 'text') this.textBytes -= Buffer.byteLength(removed.text); + else if (removed) this.imageBytes -= Buffer.from(removed.data_b64, 'base64').length; + return true; + } + + private enforceItemLimit(): void { + if (this.maxItems === undefined) return; + while (this.items.length > this.maxItems) { + const removed = this.items.shift(); + if (!removed) return; + this.truncated = true; + if (removed.type === 'text') this.textBytes -= Buffer.byteLength(removed.text); + else this.imageBytes -= Buffer.from(removed.data_b64, 'base64').length; + } + } +} + +let activeCollector: Collector | null = null; +let strayCollector = new Collector(MAX_STRAY_ITEMS, true); + +function currentCollector(): Collector { + return activeCollector ?? strayCollector; +} + +function boundedProtocolText(value: unknown, maxBytes: number): string { + let text: string; + try { + text = String(value); + } catch { + return ''; + } + if (Buffer.byteLength(text) <= maxBytes) return text; + return Buffer.from(text, 'utf8').subarray(0, maxBytes).toString('utf8'); +} + +function boundedInspect(value: unknown): string { + return safeInspect(value, { + depth: 4, + maxArrayLength: 100, + maxStringLength: 8192, + breakLength: 120, + compact: true, + }); +} + +function writeOutput(channel: TextChannel, text: string): void { + currentCollector().addText(channel, text); +} + +// repl namespace + console capture + +const IMAGE_MAGIC: Array<{ mime: string; matches: (b: Buffer) => boolean }> = [ + { mime: 'image/png', matches: (b) => b.length > 8 && b.readUInt32BE(0) === 0x89504e47 }, + { mime: 'image/jpeg', matches: (b) => b.length > 3 && b[0] === 0xff && b[1] === 0xd8 && b[2] === 0xff }, + { + mime: 'image/webp', + matches: (b) => b.length > 12 && b.subarray(0, 4).toString('ascii') === 'RIFF' && b.subarray(8, 12).toString('ascii') === 'WEBP', + }, +]; + +function sniffImageMime(bytes: Buffer): string | null { + for (const candidate of IMAGE_MAGIC) { + if (candidate.matches(bytes)) return candidate.mime; + } + return null; +} + +function isImageMime(mime: unknown): mime is string { + return typeof mime === 'string' && mime.length <= 128 && /^image\/[A-Za-z0-9.+-]+$/.test(mime); +} + +// ArrayBuffer slot checks work across the VM and daemon realms. +function bytesToBuffer(raw: unknown): Buffer { + if (Buffer.isBuffer(raw)) { + return raw; + } + if (ArrayBuffer.isView(raw)) { + return Buffer.from(raw.buffer, raw.byteOffset, raw.byteLength); + } + if (util.types.isArrayBuffer(raw)) { + return Buffer.from(new Uint8Array(raw)); + } + throw new Error('repl.emitImage: bytes must be a Buffer, Uint8Array, or ArrayBuffer'); +} + +async function normalizeImageInput(input: unknown): Promise<{ bytes: Buffer; mime: string }> { + if (typeof input === 'string') { + const match = /^data:([^;,]+);base64,(.*)$/s.exec(input); + if (!match) { + throw new Error('repl.emitImage: string input must be an image/* data URL'); + } + const mime = match[1]; + if (!isImageMime(mime)) { + throw new Error('repl.emitImage: data URL MIME type must be a short image/* media type'); + } + return { bytes: Buffer.from(match[2], 'base64'), mime }; + } + + if (Buffer.isBuffer(input) || ArrayBuffer.isView(input) || util.types.isArrayBuffer(input)) { + const bytes = bytesToBuffer(input); + const mime = sniffImageMime(bytes); + if (!mime) throw new Error('repl.emitImage: unrecognized image data (expected PNG, JPEG, or WebP)'); + return { bytes, mime }; + } + + if (input && typeof input === 'object') { + const obj = input as Record; + const explicitMime = obj.mimeType ?? (obj as any).mime_type; + if (explicitMime !== undefined && !isImageMime(explicitMime)) { + throw new Error('repl.emitImage: MIME type must be a short image/* media type'); + } + if (obj.bytes !== undefined) { + const bytes = bytesToBuffer(obj.bytes); + const mime = (explicitMime as string | undefined) ?? sniffImageMime(bytes); + if (!mime) throw new Error('repl.emitImage: unrecognized image data (expected PNG, JPEG, or WebP)'); + return { bytes, mime }; + } + if (typeof obj.path === 'string') { + const bytes = await fsp.readFile(obj.path); + const mime = (explicitMime as string | undefined) ?? sniffImageMime(bytes); + if (!mime) { + throw new Error(`repl.emitImage: ${obj.path} is not a recognized image (expected PNG, JPEG, or WebP)`); + } + return { bytes, mime }; + } + } + + throw new Error( + 'repl.emitImage: unsupported input (expected a data URL, Buffer, Uint8Array, ArrayBuffer, { bytes, mimeType? }, or { path, mimeType? })', + ); +} + +const repl = Object.freeze({ + id: REPL_ID, + write(value: unknown): void { + writeOutput('write', typeof value === 'string' ? value : boundedInspect(value)); + }, + async emitImage(input: unknown): Promise { + const { bytes, mime } = await normalizeImageInput(input); + if (bytes.length > MAX_IMAGE_BYTES) { + throw new Error( + `repl.emitImage: image is ${bytes.length} bytes, exceeding the ${MAX_IMAGE_BYTES} byte per-image limit`, + ); + } + const added = currentCollector().addImage(mime, bytes); + if (!added) { + writeOutput( + 'stderr', + `repl.emitImage: dropped a ${bytes.length} byte image; aggregate response image limit reached`, + ); + } + }, +}); + +const consoleCapture = { + log: (...args: unknown[]) => writeOutput('stdout', safeFormat(...args)), + info: (...args: unknown[]) => writeOutput('stdout', safeFormat(...args)), + debug: (...args: unknown[]) => writeOutput('stdout', safeFormat(...args)), + warn: (...args: unknown[]) => writeOutput('stderr', safeFormat(...args)), + error: (...args: unknown[]) => writeOutput('stderr', safeFormat(...args)), + dir: (...args: unknown[]) => writeOutput('stdout', safeFormat(...args)), + trace: (...args: unknown[]) => writeOutput('stderr', safeFormat(...args)), + table: (...args: unknown[]) => writeOutput('stdout', safeFormat(...args)), +}; + +// Persistent evaluation context + +const cdpClient = new CdpClient(CDP_ENDPOINT); +const helpers = new BrowserHelpers(cdpClient); +const webmcpExecution = new AsyncLocalStorage(); +const webmcp = createWebMCPClient({ + apiBaseUrl: KERNEL_API_ENDPOINT, + signalProvider: () => { + const executionSignal = webmcpExecution.getStore(); + if (!executionSignal) { + throw new Error('webmcp calls require an active Browser REPL execution'); + } + const executionDeadline = helpers.executionDeadlineMs; + if (executionDeadline === null) return executionSignal; + + const remainingMs = executionDeadline - WEBMCP_DEADLINE_MARGIN_MS - Date.now(); + if (remainingMs <= 0) { + return AbortSignal.abort(new Error('WebMCP request exceeded the Browser REPL execution deadline')); + } + return AbortSignal.any([executionSignal, AbortSignal.timeout(remainingMs)]); + }, +}); +const browserGlobals = buildBrowserGlobals(helpers); +const browserNamespace = Object.freeze({ + ...(browserGlobals.browser as Record), + webmcp, +}); + +// Operational notes from helpers (e.g. a fallback activating) surface as +// stderr content items in the active (or next) execution. +helpers.onLog = (message) => writeOutput('stderr', `browser-repl: ${message}`); + +// A dialog dismissed at attach time was left open before the runtime +// attached (typically by a previous REPL that was killed); surface the +// automatic dismissal so it is visible in the execution's output. +cdpClient.onDialogAutoDismissed = (dialog) => { + const detail = dialog.message ? `, message: ${JSON.stringify(dialog.message)}` : ''; + writeOutput( + 'stderr', + `browser-repl: dismissed a pre-existing JavaScript dialog (type: ${dialog.type}${detail}) left open before attach`, + ); +}; + +// Cross-realm global handle captured right after context creation; cell +// bindings are exposed here after each SourceTextModule evaluation. +let contextGlobal: Record; + +const context: vm.Context = vm.createContext( + { + console: consoleCapture, + repl, + ...browserGlobals, + browser: browserNamespace, + webmcp, + // Node conveniences. This endpoint is unrestricted code execution; the + // context is a state container, not a sandbox. + setTimeout, + clearTimeout, + setInterval, + clearInterval, + queueMicrotask, + Buffer, + process, + fetch, + URL, + URLSearchParams, + TextEncoder, + TextDecoder, + AbortController, + AbortSignal, + structuredClone, + atob, + btoa, + crypto, + }, + { name: `browser-repl-${REPL_ID}` }, +); + +contextGlobal = vm.runInContext('globalThis', context) as Record; + +const cellRuntime = new CellRuntime(context, contextGlobal); + +async function evaluate(code: string): Promise { + await cellRuntime.evaluate(code); +} + +// Request handling + +interface ExecuteRequest { + id: string; + code: string; + timeout_ms?: number; +} + +interface ExecuteResponse { + id: string; + repl_id: string; + success: boolean; + error?: string; + stack?: string; + content: ContentItem[]; + content_truncated: boolean; + timed_out?: boolean; + exiting?: boolean; + duration_ms: number; +} + +async function executeRequest( + request: ExecuteRequest, + respond: (response: ExecuteResponse) => void, +): Promise { + const start = Date.now(); + const collector = new Collector(MAX_CONTENT_ITEMS); + // Track the in-flight execution so the uncaughtException handler can + // answer it with a deterministic failure (including partial content) + // before exiting, instead of leaving the caller with a bare EOF. + activeExecution = { request, collector, respond, start }; + + // Swap the buffer before adopting it so output produced after this point + // belongs to the next execution, never to a stale drained collector. The + // collector owns its counters and truncation bit, so draining cannot leave + // cumulative limits behind or hide dropped output. + const drainedStray = strayCollector; + strayCollector = new Collector(MAX_STRAY_ITEMS, true); + drainedStray.drainInto(collector); + + activeCollector = collector; + const executionAbortController = new AbortController(); + const timeoutMs = request.timeout_ms ?? 60_000; + // Let wait-style helpers and the CDP client clamp their internal + // deadlines to just below this execution's deadline, so a routine helper + // timeout (or a renderer frozen behind a modal dialog) surfaces as a + // clean error instead of tying the destructive execution timeout. + helpers.executionDeadlineMs = start + timeoutMs; + cdpClient.executionDeadlineMs = start + timeoutMs; + let timer: ReturnType | undefined; + let timedOut = false; + + try { + const timeoutPromise = new Promise((_, reject) => { + timer = setTimeout(() => { + timedOut = true; + const error = new Error(`execution timed out after ${timeoutMs}ms`); + executionAbortController.abort(error); + reject(error); + }, timeoutMs); + if (typeof timer.unref === 'function') timer.unref(); + }); + const evaluation = webmcpExecution.run( + executionAbortController.signal, + () => evaluate(request.code), + ); + await Promise.race([evaluation, timeoutPromise]); + return { + id: request.id, + repl_id: REPL_ID, + success: true, + content: collector.items, + content_truncated: collector.truncated, + duration_ms: Date.now() - start, + }; + } catch (err: any) { + return { + id: request.id, + repl_id: REPL_ID, + success: false, + error: boundedProtocolText(err?.message ?? err, MAX_ERROR_BYTES), + stack: typeof err?.stack === 'string' ? boundedProtocolText(err.stack, MAX_STACK_BYTES) : undefined, + content: collector.items, + content_truncated: collector.truncated, + // A timed-out execution is merely abandoned, not interrupted: its code + // is still running. The API parent must kill this process (it does, + // destructively, per the spec's timeout semantics) before serving + // another execution. + timed_out: timedOut || undefined, + duration_ms: Date.now() - start, + }; + } finally { + if (timer) clearTimeout(timer); + if (!executionAbortController.signal.aborted) { + executionAbortController.abort(new Error('Browser REPL execution finished')); + } + helpers.executionDeadlineMs = null; + cdpClient.executionDeadlineMs = null; + activeCollector = null; + activeExecution = null; + } +} + +// Serialize executions as defense in depth; the Go handler already holds a +// mutex, but the daemon must never interleave two executions. +let executionChain: Promise = Promise.resolve(); + +// The execution currently running, so the uncaughtException handler can +// deliver a deterministic failure response (with partial content) before +// exiting instead of leaving the caller with a bare EOF. +let activeExecution: { + request: ExecuteRequest; + collector: Collector; + respond: (response: ExecuteResponse) => void; + start: number; +} | null = null; + +// Set once the daemon has decided to exit (uncaughtException): queued +// execution continuations must not write further responses. +let processExiting = false; + +function enqueueExecution(request: ExecuteRequest, respond: (response: ExecuteResponse) => void): void { + executionChain = executionChain.then(async () => { + let response: ExecuteResponse; + try { + response = await executeRequest(request, respond); + } catch (err: any) { + response = { + id: request.id, + repl_id: REPL_ID, + success: false, + error: `internal daemon error: ${boundedProtocolText(err?.message ?? err, MAX_ERROR_BYTES)}`, + content: [], + content_truncated: false, + duration_ms: 0, + }; + } + if (!processExiting) { + respond(response); + } + }); +} + +function handleConnection(socket: Socket): void { + let buffer = ''; + let bufferedBytes = 0; + const decoder = new StringDecoder('utf8'); + // The server sets allowHalfOpen, so a client that half-closes (SHUT_WR) + // after sending its request still receives the execution response. The + // daemon ends its own side once the client has ended and every queued + // response has been flushed. + let clientEnded = false; + let pendingWrites = 0; + // Requests accepted but whose response has not been flushed yet. The + // client's FIN arrives while its execution is still queued, so the + // socket must stay open until that response is written. + let pendingRequests = 0; + + const maybeEnd = () => { + if (clientEnded && pendingWrites === 0 && pendingRequests === 0) { + socket.end(); + } + }; + + const respond = (response: ExecuteResponse, onFlushed?: () => void) => { + pendingWrites++; + try { + socket.write(safeStringify(response) + '\n', () => { + pendingWrites--; + onFlushed?.(); + maybeEnd(); + }); + } catch (err: any) { + pendingWrites--; + onFlushed?.(); + process.stderr.write(`[browser-repl] failed to write response: ${err?.message ?? err}\n`); + } + }; + + const rejectOversized = () => { + // end() flushes the rejection before closing (unlike destroy()). + respond({ + id: 'unknown', + repl_id: REPL_ID, + success: false, + error: `request exceeds the ${MAX_REQUEST_BYTES} byte limit`, + content: [], + content_truncated: false, + duration_ms: 0, + }); + socket.end(); + }; + + socket.on('data', (data) => { + bufferedBytes += data.length; + buffer += decoder.write(data); + + let newlineIndex: number; + while ((newlineIndex = buffer.indexOf('\n')) !== -1) { + const line = buffer.slice(0, newlineIndex); + buffer = buffer.slice(newlineIndex + 1); + bufferedBytes -= Buffer.byteLength(line) + 1; + // The size cap applies per accumulated line, independent of how the + // request was chunked: a single write containing the newline is + // rejected exactly like a slow flood that never sends one. + if (Buffer.byteLength(line) > MAX_REQUEST_BYTES) { + rejectOversized(); + return; + } + if (!line.trim()) continue; + + let request: ExecuteRequest; + try { + request = JSON.parse(line); + } catch { + respond({ + id: 'unknown', + repl_id: REPL_ID, + success: false, + error: 'invalid JSON request', + content: [], + content_truncated: false, + duration_ms: 0, + }); + continue; + } + + if (!request.id || typeof request.code !== 'string') { + respond({ + id: (request as any)?.id || 'unknown', + repl_id: REPL_ID, + success: false, + error: 'invalid request: missing id or code', + content: [], + content_truncated: false, + duration_ms: 0, + }); + continue; + } + + pendingRequests++; + enqueueExecution(request, (response) => respond(response, () => pendingRequests--)); + } + + if (bufferedBytes > MAX_REQUEST_BYTES) { + rejectOversized(); + return; + } + }); + + socket.on('end', () => { + clientEnded = true; + maybeEnd(); + }); + + socket.on('error', (err) => { + process.stderr.write(`[browser-repl] socket error: ${err.message}\n`); + }); +} + +// Lifecycle + +// Settled rejections are reportable without invalidating process state. +function onUnhandledRejection(reason: unknown): void { + let detail: string; + try { + const stack = (reason as any)?.stack; + detail = typeof stack === 'string' ? stack : boundedInspect(reason); + } catch { + try { + detail = String(reason); + } catch { + detail = ''; + } + } + writeOutput( + 'stderr', + 'browser-repl: unhandled promise rejection (the REPL survives; only the rejected promise is settled):\n' + + detail, + ); +} + +// Continuing after an uncaught exception is unsafe; preserve evidence and exit. +function onUncaughtException(err: unknown): void { + processExiting = true; + const stack = (err as any)?.stack; + const message = String((err as any)?.message ?? err); + process.stderr.write( + `[browser-repl] uncaught exception; terminating deterministically (repl_id=${REPL_ID}): ${ + typeof stack === 'string' ? stack : message + }\n`, + ); + const inFlight = activeExecution; + activeExecution = null; + if (inFlight) { + try { + inFlight.respond({ + id: inFlight.request.id, + repl_id: REPL_ID, + success: false, + error: `uncaught exception in browser REPL process: ${boundedProtocolText(message, MAX_ERROR_BYTES)}`, + stack: typeof stack === 'string' ? boundedProtocolText(stack, MAX_STACK_BYTES) : undefined, + content: inFlight.collector.items, + content_truncated: inFlight.collector.truncated, + exiting: true, + duration_ms: Date.now() - inFlight.start, + }); + } catch { + // The socket is gone; the API reports the child exit instead. + } + } + // Give the stderr log and the in-flight response a bounded window to + // flush, then exit non-zero. The socket server keeps the event loop + // alive, so the unref'd timer always fires. + setTimeout(() => process.exit(1), 100).unref(); +} + +function shutdown(signal: string): void { + process.stderr.write(`[browser-repl] received ${signal}, shutting down (repl_id=${REPL_ID})\n`); + try { + cdpClient.close(); + } catch { + // ignore + } + try { + if (existsSync(SOCKET_PATH)) { + unlinkSync(SOCKET_PATH); + } + } catch { + // ignore + } + process.exit(0); +} + +async function main(): Promise { + try { + if (existsSync(SOCKET_PATH)) { + unlinkSync(SOCKET_PATH); + } + } catch { + // ignore + } + + process.on('SIGTERM', () => shutdown('SIGTERM')); + process.on('SIGINT', () => shutdown('SIGINT')); + process.on('unhandledRejection', onUnhandledRejection); + process.on('uncaughtException', onUncaughtException); + + // allowHalfOpen: a client that half-closes (SHUT_WR) after sending its + // request still receives the execution response; handleConnection ends + // the server side once the final queued response is flushed. + const server = createServer({ allowHalfOpen: true }, handleConnection); + server.on('error', (err) => { + process.stderr.write(`[browser-repl] server error: ${err.message}\n`); + process.exit(1); + }); + + server.listen(SOCKET_PATH, () => { + process.stderr.write(`[browser-repl] listening on ${SOCKET_PATH} (repl_id=${REPL_ID})\n`); + }); +} + +main().catch((err) => { + process.stderr.write(`[browser-repl] fatal error: ${err?.stack ?? err}\n`); + process.exit(1); +}); diff --git a/server/runtime/cell-analysis.ts b/server/runtime/cell-analysis.ts new file mode 100644 index 00000000..18d1ee10 --- /dev/null +++ b/server/runtime/cell-analysis.ts @@ -0,0 +1,362 @@ +import { parseModule, type ESTree } from 'meriyah'; + +export type CellBindingKind = 'var' | 'function' | 'let' | 'const' | 'class'; + +export interface CellBinding { + name: string; + kind: CellBindingKind; +} + +export interface SourceEdit { + start: number; + end: number; + text: string; +} + +export interface CellAnalysis { + source: string; + bindings: CellBinding[]; + edits: SourceEdit[]; + hoistedFunctions: Array<{ name: string; alias: string }>; +} + +export const STATIC_IMPORT_ERROR = + 'static import/export is not supported in the browser REPL; use dynamic import() instead'; +export const TOP_LEVEL_RETURN_ERROR = + 'top-level return is not supported in the browser REPL'; + +function range(node: ESTree._Node): [number, number] { + if (node.start === undefined || node.end === undefined) throw new Error(`Meriyah node has no range: ${node}`); + return [node.start, node.end]; +} + +function collectPatternNames(pattern: ESTree.Pattern | null, out: string[]): void { + if (!pattern) return; + switch (pattern.type) { + case 'Identifier': + out.push(pattern.name); + return; + case 'ObjectPattern': + for (const property of pattern.properties) { + if (property.type === 'RestElement') collectPatternNames(asPattern(property.argument), out); + else if (property.type === 'Property') collectPatternNames(asPattern(property.value), out); + } + return; + case 'ArrayPattern': + for (const element of pattern.elements) { + if (element) collectPatternNames(asPattern(element), out); + } + return; + case 'AssignmentPattern': + collectPatternNames(asPattern(pattern.left), out); + return; + case 'RestElement': + collectPatternNames(asPattern(pattern.argument), out); + return; + case 'MemberExpression': + return; + } +} + +function addVariableBindings(statement: ESTree.VariableDeclaration, bindings: CellBinding[]): void { + for (const declaration of statement.declarations) { + const names: string[] = []; + collectPatternNames(asPattern(declaration.id), names); + for (const name of names) bindings.push({ name, kind: statement.kind as CellBindingKind }); + } +} + +function propertyText(property: ESTree.Property, source: string): string { + const [start, end] = range(property.key); + return source.slice(start, end); +} + +function asPattern(node: ESTree.Node): ESTree.Pattern { + switch (node.type) { + case 'Identifier': + case 'ObjectPattern': + case 'ArrayPattern': + case 'AssignmentPattern': + case 'RestElement': + case 'MemberExpression': + return node; + default: + throw new Error(`unsupported binding pattern: ${node.type}`); + } +} + +const DEFAULT_INITIALIZATION_TARGET = 'globalThis["__browser_repl_init_target"]'; + +function globalPattern( + pattern: ESTree.Pattern, + source: string, + initialize: boolean, + initializationTarget: string, +): string { + switch (pattern.type) { + case 'Identifier': + // Lexical declarations initialize through the private target. `var` + // writes stay as identifier assignments so JavaScript lexical lookup is + // preserved: inside `catch (error)`, `var error = value` initializes the + // catch binding rather than bypassing it to write the persistent global. + return initialize + ? `${initializationTarget}[${JSON.stringify(pattern.name)}]` + : pattern.name; + case 'AssignmentPattern': + return `${globalPattern(asPattern(pattern.left), source, initialize, initializationTarget)} = ${source.slice(...range(pattern.right!))}`; + case 'RestElement': + return `...${globalPattern(asPattern(pattern.argument), source, initialize, initializationTarget)}`; + case 'ArrayPattern': + return `[${pattern.elements.map((element) => element ? globalPattern(asPattern(element), source, initialize, initializationTarget) : '').join(', ')}]`; + case 'ObjectPattern': + return `{${pattern.properties.map((property) => { + if (property.type === 'RestElement') return globalPattern(asPattern(property), source, initialize, initializationTarget); + if (property.type !== 'Property') throw new Error(`unsupported object pattern property: ${property.type}`); + const key = propertyText(property, source); + const target = globalPattern(asPattern(property.value), source, initialize, initializationTarget); + return `${property.computed ? `[${key}]` : key}: ${target}`; + }).join(', ')}}`; + case 'MemberExpression': + return source.slice(...range(pattern)); + } +} + +function declaratorReplacement( + declaration: ESTree.VariableDeclarator, + statement: ESTree.VariableDeclaration, + source: string, + initialize: boolean, + initializationTarget: string, +): string { + // A `var x;` has already been initialized by prepareBindings and is a + // no-op. Keep a syntactic expression for it: statement-position lowering + // must remain one statement even when the declaration has no initializer. + if (!declaration.init && statement.kind === 'var') return '(void 0)'; + const target = globalPattern(asPattern(declaration.id), source, initialize, initializationTarget); + const value = declaration.init ? source.slice(...range(declaration.init)) : 'undefined'; + return `(${target} = ${value})`; +} + +function variableReplacement( + statement: ESTree.VariableDeclaration, + source: string, + expressionPosition: boolean, + initialize: boolean, + initializationTarget: string, +): string { + const assignments = statement.declarations.map((declaration) => + declaratorReplacement(declaration, statement, source, initialize, initializationTarget), + ); + return expressionPosition ? assignments.join(', ') : `${assignments.join(', ')};`; +} + +function variableEdits( + statement: ESTree.VariableDeclaration, + source: string, + edits: SourceEdit[], + initialize: boolean, + initializationTarget: string, +): void { + const first = statement.declarations[0]; + if (!first) return; + edits.push({ start: statement.start!, end: first.start!, text: '' }); + for (let index = 0; index < statement.declarations.length; index++) { + const declaration = statement.declarations[index]; + edits.push({ + start: declaration.start!, + end: declaration.end!, + text: declaratorReplacement(declaration, statement, source, initialize, initializationTarget), + }); + } + // Meriyah includes an explicit semicolon in the declaration range. If the + // source used ASI, add the terminator needed by a statement-position + // assignment while leaving all original line breaks untouched. + if (source[statement.end! - 1] !== ';') { + edits.push({ start: statement.end!, end: statement.end!, text: ';' }); + } +} + +function addStatement( + statement: ESTree.Statement, + source: string, + bindings: CellBinding[], + edits: SourceEdit[], + initializationTarget: string, +): void { + switch (statement.type) { + case 'VariableDeclaration': + if (statement.kind === 'var') { + addVariableBindings(statement, bindings); + variableEdits(statement, source, edits, false, initializationTarget); + } + return; + case 'BlockStatement': + for (const child of statement.body) addStatement(child, source, bindings, edits, initializationTarget); + return; + case 'IfStatement': + addStatement(statement.consequent, source, bindings, edits, initializationTarget); + if (statement.alternate) addStatement(statement.alternate, source, bindings, edits, initializationTarget); + return; + case 'ForStatement': + if (statement.init?.type === 'VariableDeclaration' && statement.init.kind === 'var') { + addVariableBindings(statement.init, bindings); + edits.push({ + start: range(statement.init)[0], + end: range(statement.init)[1], + text: variableReplacement(statement.init, source, true, false, initializationTarget), + }); + } + addStatement(statement.body, source, bindings, edits, initializationTarget); + return; + case 'ForInStatement': + case 'ForOfStatement': + if (statement.left.type === 'VariableDeclaration' && statement.left.kind === 'var') { + addVariableBindings(statement.left, bindings); + // The parser rejects multi-declarator for-in/of heads, so this is + // always a single assignment target. + const target = globalPattern(asPattern(statement.left.declarations[0].id), source, false, initializationTarget); + edits.push({ start: range(statement.left)[0], end: range(statement.left)[1], text: target }); + } + addStatement(statement.body, source, bindings, edits, initializationTarget); + return; + case 'WhileStatement': + case 'DoWhileStatement': + case 'WithStatement': + addStatement(statement.body, source, bindings, edits, initializationTarget); + return; + case 'SwitchStatement': + for (const clause of statement.cases) { + for (const child of clause.consequent) addStatement(child, source, bindings, edits, initializationTarget); + } + return; + case 'TryStatement': + addStatement(statement.block, source, bindings, edits, initializationTarget); + if (statement.handler) addStatement(statement.handler.body, source, bindings, edits, initializationTarget); + if (statement.finalizer) addStatement(statement.finalizer, source, bindings, edits, initializationTarget); + return; + case 'LabeledStatement': + addStatement(statement.body, source, bindings, edits, initializationTarget); + return; + case 'FunctionDeclaration': + case 'ClassDeclaration': + case 'EmptyStatement': + case 'ExpressionStatement': + case 'BreakStatement': + case 'ContinueStatement': + case 'DebuggerStatement': + case 'ReturnStatement': + case 'ThrowStatement': + case 'ImportDeclaration': + // Meriyah's broad ESTree Statement union includes these declaration forms, + // but analyzeCell rejects them before traversal. Keep the boundary explicit. + case 'ClassExpression': + case 'ExportAllDeclaration': + case 'ExportDefaultDeclaration': + case 'ExportNamedDeclaration': + return; + default: + assertNever(statement); + } +} + +function assertNever(value: never): never { + throw new Error(`Unhandled Meriyah statement: ${(value as { type: string }).type}`); +} + +function declarationEdit(statement: ESTree.ClassDeclaration, source: string, initializationTarget: string): SourceEdit { + const [start, end] = range(statement); + return { + start, + end, + text: `${initializationTarget}[${JSON.stringify(statement.id!.name)}] = (${source.slice(start, end)});`, + }; +} + +export function analyzeCell( + source: string, + initializationTarget = DEFAULT_INITIALIZATION_TARGET, +): CellAnalysis { + let ast: ESTree.Program; + try { + ast = parseModule(source, { next: true, ranges: true }); + } catch (error: unknown) { + const message = String(error instanceof Error ? error.message : error); + // Meriyah is exact-pinned. Keep this assertion close to the parser boundary + // so a dependency upgrade cannot silently change the public diagnostic. + if (/return statement/.test(message)) throw new SyntaxError(TOP_LEVEL_RETURN_ERROR); + throw error; + } + + for (const statement of ast.body) { + if (statement.type === 'ImportDeclaration' || statement.type.startsWith('Export')) { + throw new SyntaxError(STATIC_IMPORT_ERROR); + } + } + + const bindings: CellBinding[] = []; + const edits: SourceEdit[] = []; + const functionDeclarations: ESTree.FunctionDeclaration[] = []; + for (const statement of ast.body) { + if (statement.type === 'VariableDeclaration') { + addVariableBindings(statement, bindings); + if (statement.kind === 'var') variableEdits(statement, source, edits, false, initializationTarget); + else variableEdits(statement, source, edits, true, initializationTarget); + } else if (statement.type === 'FunctionDeclaration' && statement.id) { + bindings.push({ name: statement.id.name, kind: 'function' }); + functionDeclarations.push(statement); + } else if (statement.type === 'ClassDeclaration' && statement.id) { + bindings.push({ name: statement.id.name, kind: 'class' }); + edits.push(declarationEdit(statement, source, initializationTarget)); + } else { + addStatement(statement, source, bindings, edits, initializationTarget); + } + } + + // A module-local function binding would shadow the persistent accessor. + // Rename only the declaration identifier: references in bodies remain free + // identifiers and therefore resolve through the accessor at call time. + const usedNames = bindingNames(bindings); + const hoistedByName = new Map(); + for (const statement of functionDeclarations) { + if (!statement.id) throw new Error('function declaration disappeared during analysis'); + const aliasBase = `__browser_repl_function_${statement.id.name}`; + let alias = aliasBase; + while (usedNames.has(alias)) alias += '_'; + usedNames.add(alias); + edits.push({ start: statement.id.start!, end: statement.id.end!, text: alias }); + // Duplicate function declarations are valid; only the last declaration + // should initialize the single persistent binding. + hoistedByName.set(statement.id.name, { name: statement.id.name, alias }); + } + const hoistedFunctions = [...hoistedByName.values()]; + + return { source, bindings, edits, hoistedFunctions }; +} + +export function applyEdits(source: string, edits: SourceEdit[]): string { + const ordered = [...edits].sort((a, b) => a.start - b.start); + let result = ''; + let cursor = 0; + for (const edit of ordered) { + if (edit.start < cursor) throw new Error('overlapping cell source edits'); + result += source.slice(cursor, edit.start) + edit.text; + cursor = edit.end; + } + return result + source.slice(cursor); +} + +export function bindingNames(bindings: CellBinding[]): Set { + return new Set(bindings.map((binding) => binding.name)); +} + +export function isLexicalKind(kind: CellBindingKind): boolean { + return kind === 'let' || kind === 'const' || kind === 'class'; +} + +export function canRedeclare(existing: CellBindingKind, incoming: CellBindingKind): boolean { + return !isLexicalKind(existing) && !isLexicalKind(incoming); +} + +export function alreadyDeclaredError(name: string): SyntaxError { + return new SyntaxError(`Identifier '${name}' has already been declared; use a new name or reset the REPL to retry`); +} diff --git a/server/runtime/cell-runtime.ts b/server/runtime/cell-runtime.ts new file mode 100644 index 00000000..30afb270 --- /dev/null +++ b/server/runtime/cell-runtime.ts @@ -0,0 +1,184 @@ +import vm from 'vm'; +import { randomBytes } from 'node:crypto'; +import { + alreadyDeclaredError, + analyzeCell, + applyEdits, + canRedeclare, + type CellAnalysis, + type CellBinding, + type CellBindingKind, +} from './cell-analysis'; + +type PersistentBinding = CellBinding & { initialized: boolean; value?: unknown }; + +export class CellRuntime { + private readonly declarations = new Map(); + private readonly values = new Map(); + private sequence = 0; + + constructor( + private readonly context: vm.Context, + private readonly contextGlobal: Record, + ) {} + + async evaluate(source: string): Promise { + const cellSequence = this.sequence++; + const initializationTargetName = `__browser_repl_init_${cellSequence}_${randomBytes(16).toString('hex')}`; + const initializationTarget = `globalThis[${JSON.stringify(initializationTargetName)}]`; + const analysis = analyzeCell(source, initializationTarget); + this.precheck(analysis.bindings); + + const generated = this.buildSource(analysis, initializationTarget); + + const module = new vm.SourceTextModule(generated, { + context: this.context, + identifier: `browser-repl-cell-${cellSequence}.mjs`, + // buildSource keeps its accessor prelude on one physical line. This + // makes generated line 2 correspond to user line 1. + lineOffset: -1, + initializeImportMeta(meta) { + meta.url = 'file:///browser-repl-cell.mjs'; + }, + importModuleDynamically: async (specifier) => { + const namespace = await import(specifier); + const names = Object.keys(namespace); + const imported = new vm.SyntheticModule(names, function () { + for (const name of names) this.setExport(name, namespace[name]); + }, { context: this.context, identifier: `browser-repl-import-${specifier}` }); + await imported.link(() => { + throw new Error('dynamic import module unexpectedly requested a static dependency'); + }); + await imported.evaluate(); + return imported; + }, + }); + + await module.link(() => { + throw new Error('static import is not supported in the browser REPL'); + }); + + this.prepareBindings(analysis.bindings); + this.register(analysis.bindings); + const initialization = this.createInitializationTarget(analysis.bindings); + Object.defineProperty(this.contextGlobal, initializationTargetName, { + configurable: true, + enumerable: false, + value: initialization.target, + }); + try { + await module.evaluate(); + } finally { + // Revoke before deleting the property so code that retained the proxy + // during evaluation cannot repair a failed lexical initializer later. + initialization.revoke(); + delete this.contextGlobal[initializationTargetName]; + } + + return; + } + + private precheck(bindings: CellBinding[]): void { + const cellDeclarations = new Map(); + for (const binding of bindings) { + const existing = this.declarations.get(binding.name) ?? cellDeclarations.get(binding.name); + if (existing !== undefined && !canRedeclare(existing, binding.kind)) { + throw alreadyDeclaredError(binding.name); + } + cellDeclarations.set(binding.name, binding.kind); + } + } + + private register(bindings: CellBinding[]): void { + for (const binding of bindings) this.declarations.set(binding.name, binding.kind); + } + + private createInitializationTarget(bindings: CellBinding[]): { + target: Record; + revoke: () => void; + } { + const pending = new Set( + bindings.filter((binding) => binding.kind !== 'var').map((binding) => binding.name), + ); + const { proxy, revoke } = Proxy.revocable(Object.create(null), { + set: (_target, property, value) => { + if (typeof property !== 'string') return false; + if (!pending.has(property)) throw new TypeError('persistent binding initialization target is internal'); + const binding = this.values.get(property); + if (!binding) throw new ReferenceError(`Unknown persistent binding '${property}'`); + pending.delete(property); + binding.value = value; + binding.initialized = true; + return true; + }, + }); + return { target: proxy, revoke }; + } + + private prepareBindings(bindings: CellBinding[]): void { + const current = new Map(); + for (const binding of bindings) current.set(binding.name, binding.kind); + + for (const [name, kind] of current) { + const old = this.values.get(name); + if (old) { + old.kind = kind; + // A var redeclaration does not reset an existing value. Function and + // class declarations are initialized by generated module code. + if (kind === 'function' || kind === 'class') { + old.initialized = false; + old.value = undefined; + } + this.exposeGlobal(old); + continue; + } + + const binding: PersistentBinding = { + name, + kind, + initialized: kind === 'var', + value: undefined, + }; + this.values.set(name, binding); + this.exposeGlobal(binding); + } + } + + private buildSource(analysis: CellAnalysis, initializationTarget: string): string { + const body = applyEdits(analysis.source, analysis.edits); + const prelude = analysis.hoistedFunctions + .map(({ name, alias }) => + `Object.defineProperty(${alias}, "name", { value: ${JSON.stringify(name)}, configurable: true }); ` + + `${initializationTarget}[${JSON.stringify(name)}] = ${alias};`, + ) + .join(' '); + return `${prelude}\n${body}`; + } + + private exposeGlobal(binding: PersistentBinding): void { + const existing = Object.getOwnPropertyDescriptor(this.contextGlobal, binding.name); + if (existing?.configurable === false) { + if (binding.kind !== 'const' && existing.writable) this.contextGlobal[binding.name] = binding.value; + return; + } + + Object.defineProperty(this.contextGlobal, binding.name, { + configurable: false, + enumerable: true, + get: () => { + if (!binding.initialized) throw new ReferenceError(`Cannot access '${binding.name}' before initialization`); + return binding.value; + }, + set: (value: unknown) => { + if (binding.kind !== 'var' && !binding.initialized) { + throw new ReferenceError(`Cannot access '${binding.name}' before initialization`); + } + if (binding.kind === 'const' && binding.initialized) { + throw new TypeError('Assignment to constant variable.'); + } + binding.value = value; + binding.initialized = true; + }, + }); + } +} diff --git a/server/runtime/package-lock.json b/server/runtime/package-lock.json new file mode 100644 index 00000000..ff5653bf --- /dev/null +++ b/server/runtime/package-lock.json @@ -0,0 +1,711 @@ +{ + "name": "@kernel/browser-repl-runtime", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "@kernel/browser-repl-runtime", + "dependencies": { + "meriyah": "7.3.1", + "patchright": "1.62.3", + "playwright-core": "1.62.1", + "sharp": "0.34.5" + }, + "devDependencies": { + "@types/node": "^22.15.21", + "typescript": "^5.6.3" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.11.3", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.3.tgz", + "integrity": "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==", + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@img/colour": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz", + "integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@img/sharp-darwin-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.5.tgz", + "integrity": "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-arm64": "1.2.4" + } + }, + "node_modules/@img/sharp-darwin-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.5.tgz", + "integrity": "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-x64": "1.2.4" + } + }, + "node_modules/@img/sharp-libvips-darwin-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.2.4.tgz", + "integrity": "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-darwin-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.2.4.tgz", + "integrity": "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.2.4.tgz", + "integrity": "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==", + "cpu": [ + "arm" + ], + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.2.4.tgz", + "integrity": "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==", + "cpu": [ + "arm64" + ], + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-ppc64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.2.4.tgz", + "integrity": "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==", + "cpu": [ + "ppc64" + ], + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-riscv64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.2.4.tgz", + "integrity": "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==", + "cpu": [ + "riscv64" + ], + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-s390x": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.2.4.tgz", + "integrity": "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==", + "cpu": [ + "s390x" + ], + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.2.4.tgz", + "integrity": "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==", + "cpu": [ + "x64" + ], + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.2.4.tgz", + "integrity": "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==", + "cpu": [ + "arm64" + ], + "libc": [ + "musl" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.2.4.tgz", + "integrity": "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==", + "cpu": [ + "x64" + ], + "libc": [ + "musl" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-linux-arm": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.5.tgz", + "integrity": "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==", + "cpu": [ + "arm" + ], + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.5.tgz", + "integrity": "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==", + "cpu": [ + "arm64" + ], + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm64": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-ppc64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.34.5.tgz", + "integrity": "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==", + "cpu": [ + "ppc64" + ], + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-ppc64": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-riscv64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.34.5.tgz", + "integrity": "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==", + "cpu": [ + "riscv64" + ], + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-riscv64": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-s390x": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.34.5.tgz", + "integrity": "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==", + "cpu": [ + "s390x" + ], + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-s390x": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.5.tgz", + "integrity": "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==", + "cpu": [ + "x64" + ], + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-x64": "1.2.4" + } + }, + "node_modules/@img/sharp-linuxmusl-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.5.tgz", + "integrity": "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==", + "cpu": [ + "arm64" + ], + "libc": [ + "musl" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-arm64": "1.2.4" + } + }, + "node_modules/@img/sharp-linuxmusl-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.5.tgz", + "integrity": "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==", + "cpu": [ + "x64" + ], + "libc": [ + "musl" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-x64": "1.2.4" + } + }, + "node_modules/@img/sharp-wasm32": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.34.5.tgz", + "integrity": "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==", + "cpu": [ + "wasm32" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", + "optional": true, + "dependencies": { + "@emnapi/runtime": "^1.7.0" + }, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.5.tgz", + "integrity": "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-ia32": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.34.5.tgz", + "integrity": "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==", + "cpu": [ + "ia32" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.5.tgz", + "integrity": "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@types/node": { + "version": "22.15.21", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.15.21.tgz", + "integrity": "sha512-EV/37Td6c+MgKAbkcLG6vqZ2zEYHD7bvSrzqqs2RIhbA6w3x+Dqz8MZM3sP6kGTeLrdoOgKZe+Xja7tUB2DNkQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/meriyah": { + "version": "7.3.1", + "resolved": "https://registry.npmjs.org/meriyah/-/meriyah-7.3.1.tgz", + "integrity": "sha512-642iQ3T0ZBXw+qrFo9m50CsdzSz9Tn0HtSKN3WxWlTXfcHUZwunxU4gMoMDIQzzOhwtkLOLeBs9GXs7NwDI2nA==", + "license": "ISC", + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/patchright": { + "version": "1.62.3", + "resolved": "https://registry.npmjs.org/patchright/-/patchright-1.62.3.tgz", + "integrity": "sha512-TMpWzcZVWUmOe1251PHWpk0gTP2d7+mvS00h1CLL2IQAUKX7UexKfPqEjdKkH8F2TT5AlyrSbwZKr5lu1dThAQ==", + "license": "Apache-2.0", + "dependencies": { + "patchright-core": "1.62.3" + }, + "bin": { + "patchright": "cli.js" + }, + "engines": { + "node": ">=20" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/patchright-core": { + "version": "1.62.3", + "resolved": "https://registry.npmjs.org/patchright-core/-/patchright-core-1.62.3.tgz", + "integrity": "sha512-RQf0M2THMf4TL9HNNUxdYbd4Oe3DOVPni6G/bJYsEpD9F1cgqEpWiTJIh+pfuK9JwGRo4Du6lzagJosMUXbh/Q==", + "license": "Apache-2.0", + "bin": { + "patchright-core": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/playwright-core": { + "version": "1.62.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.62.1.tgz", + "integrity": "sha512-wPYSwEBJY9GHraISXqyqtx0na0LpO3XEX7jNDhntbex7tzUS7kLnZsOlFruFJB4Hi/rhDMjXGqHewDZ68nYZVw==", + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/sharp": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.5.tgz", + "integrity": "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==", + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "@img/colour": "^1.0.0", + "detect-libc": "^2.1.2", + "semver": "^7.7.3" + }, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-darwin-arm64": "0.34.5", + "@img/sharp-darwin-x64": "0.34.5", + "@img/sharp-libvips-darwin-arm64": "1.2.4", + "@img/sharp-libvips-darwin-x64": "1.2.4", + "@img/sharp-libvips-linux-arm": "1.2.4", + "@img/sharp-libvips-linux-arm64": "1.2.4", + "@img/sharp-libvips-linux-ppc64": "1.2.4", + "@img/sharp-libvips-linux-riscv64": "1.2.4", + "@img/sharp-libvips-linux-s390x": "1.2.4", + "@img/sharp-libvips-linux-x64": "1.2.4", + "@img/sharp-libvips-linuxmusl-arm64": "1.2.4", + "@img/sharp-libvips-linuxmusl-x64": "1.2.4", + "@img/sharp-linux-arm": "0.34.5", + "@img/sharp-linux-arm64": "0.34.5", + "@img/sharp-linux-ppc64": "0.34.5", + "@img/sharp-linux-riscv64": "0.34.5", + "@img/sharp-linux-s390x": "0.34.5", + "@img/sharp-linux-x64": "0.34.5", + "@img/sharp-linuxmusl-arm64": "0.34.5", + "@img/sharp-linuxmusl-x64": "0.34.5", + "@img/sharp-wasm32": "0.34.5", + "@img/sharp-win32-arm64": "0.34.5", + "@img/sharp-win32-ia32": "0.34.5", + "@img/sharp-win32-x64": "0.34.5" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD", + "optional": true + }, + "node_modules/typescript": { + "version": "5.6.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.6.3.tgz", + "integrity": "sha512-hjcS1mhfuyi4WW8IWtjP7brDrG2cuDZukyrYrSauoXGNgx0S7zceP07adYkJycEr56BOUTNPzbInooiN3fn1qw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + } + } +} diff --git a/server/runtime/package.json b/server/runtime/package.json new file mode 100644 index 00000000..df2e7a87 --- /dev/null +++ b/server/runtime/package.json @@ -0,0 +1,17 @@ +{ + "name": "@kernel/browser-repl-runtime", + "private": true, + "scripts": { + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "meriyah": "7.3.1", + "patchright": "1.62.3", + "playwright-core": "1.62.1", + "sharp": "0.34.5" + }, + "devDependencies": { + "@types/node": "^22.15.21", + "typescript": "^5.6.3" + } +} diff --git a/server/runtime/page-evaluation.ts b/server/runtime/page-evaluation.ts new file mode 100644 index 00000000..036085e9 --- /dev/null +++ b/server/runtime/page-evaluation.ts @@ -0,0 +1,138 @@ +const functionToString = Function.prototype.toString; +const reflectApply = Reflect.apply; +const FunctionConstructor = Function; +const objectToString = Object.prototype.toString; + +export type PageFunction = (arg: any) => unknown; + +export interface JsOptions { + arg?: unknown; + targetId?: string; +} + +type SerializedArgument = + | { type: 'undefined' } + | { type: 'null' } + | { type: 'boolean'; value: boolean } + | { type: 'string'; value: string } + | { type: 'number'; value: number | 'NaN' | 'Infinity' | '-Infinity' | '-0' } + | { type: 'bigint'; value: string } + | { type: 'array'; value: SerializedArgument[] } + | { type: 'object'; value: Array<[string, SerializedArgument]> }; + +function serializeArgument(value: unknown, seen = new Set()): SerializedArgument { + if (value === undefined) return { type: 'undefined' }; + if (value === null) return { type: 'null' }; + if (typeof value === 'boolean') return { type: 'boolean', value }; + if (typeof value === 'string') return { type: 'string', value }; + if (typeof value === 'number') { + if (Number.isNaN(value)) return { type: 'number', value: 'NaN' }; + if (value === Infinity) return { type: 'number', value: 'Infinity' }; + if (value === -Infinity) return { type: 'number', value: '-Infinity' }; + if (Object.is(value, -0)) return { type: 'number', value: '-0' }; + return { type: 'number', value }; + } + if (typeof value === 'bigint') return { type: 'bigint', value: value.toString() }; + if (typeof value !== 'object') { + throw new Error(`js: arg contains unsupported ${typeof value} value`); + } + if (seen.has(value)) { + throw new Error('js: arg must not contain cycles'); + } + seen.add(value); + try { + if (Array.isArray(value)) { + return { type: 'array', value: value.map((item) => serializeArgument(item, seen)) }; + } + if (reflectApply(objectToString, value, []) !== '[object Object]') { + throw new Error('js: arg must contain only plain objects and arrays'); + } + const entries: Array<[string, SerializedArgument]> = []; + for (const key of Object.keys(value)) { + entries.push([key, serializeArgument((value as Record)[key], seen)]); + } + return { type: 'object', value: entries }; + } finally { + seen.delete(value); + } +} + +function isFunctionExpression(source: string): boolean { + try { + FunctionConstructor(`return (${source}\n)`); + return true; + } catch { + return false; + } +} + +function normalizeFunctionSource(fn: PageFunction): string { + let source = reflectApply(functionToString, fn, []).trim(); + if (source.includes('[native code]') || source.startsWith('class ')) { + throw new Error('js: page function is not serializable'); + } + if (isFunctionExpression(source)) return source; + + source = source.startsWith('async ') + ? `async function ${source.slice('async '.length)}` + : `function ${source}`; + if (!isFunctionExpression(source)) { + throw new Error('js: page function is not serializable'); + } + return source; +} + +function jsonForExpression(value: unknown): string { + return JSON.stringify(value).replace(/\u2028/g, '\\u2028').replace(/\u2029/g, '\\u2029'); +} + +const reviveArgumentSource = `function revive(node) { + switch (node.type) { + case 'undefined': return undefined; + case 'null': return null; + case 'boolean': + case 'string': return node.value; + case 'number': + if (node.value === 'NaN') return NaN; + if (node.value === 'Infinity') return Infinity; + if (node.value === '-Infinity') return -Infinity; + if (node.value === '-0') return -0; + return node.value; + case 'bigint': return BigInt(node.value); + case 'array': return node.value.map(revive); + case 'object': { + const out = {}; + for (const [key, value] of node.value) { + Object.defineProperty(out, key, { + value: revive(value), enumerable: true, configurable: true, writable: true, + }); + } + return out; + } + default: throw new Error('invalid serialized Browser REPL argument'); + } +}`; + +export function buildFunctionCallExpression(fn: PageFunction, arg: unknown): string { + const source = normalizeFunctionSource(fn); + const payload = jsonForExpression(serializeArgument(arg)); + return `(function (payload) { + ${reviveArgumentSource} + return (${source})(revive(payload)); + })(${payload})`; +} + +export function normalizeJsOptions(options: unknown): JsOptions { + if (options === undefined) return {}; + if (options === null || typeof options !== 'object' || Array.isArray(options)) { + throw new Error('js: options must be an object with optional arg and targetId fields'); + } + const keys = Object.keys(options); + const unknown = keys.find((key) => key !== 'arg' && key !== 'targetId'); + if (unknown) throw new Error(`js: unknown option: ${unknown}`); + const normalized = options as JsOptions; + if (normalized.targetId !== undefined && typeof normalized.targetId !== 'string') { + throw new Error('js: targetId must be a target id string (see iframeTarget/listTabs)'); + } + return normalized; +} diff --git a/server/runtime/tsconfig.json b/server/runtime/tsconfig.json new file mode 100644 index 00000000..fdbb542a --- /dev/null +++ b/server/runtime/tsconfig.json @@ -0,0 +1,17 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "strict": true, + "skipLibCheck": true, + "types": ["node"] + }, + "include": [ + "browser-cdp-client.ts", + "browser-helpers.ts", + "browser-repl.ts", + "cell-analysis.ts", + "cell-runtime.ts" + ] +} diff --git a/server/runtime/us-keyboard-layout.ts b/server/runtime/us-keyboard-layout.ts new file mode 100644 index 00000000..0f9e61f1 --- /dev/null +++ b/server/runtime/us-keyboard-layout.ts @@ -0,0 +1,208 @@ +export interface KeyDefinition { + key: string; + code: string; + keyCode: number; + text?: string; + shiftKey?: string; + shiftKeyCode?: number; + shiftText?: string; + location?: number; +} + +export interface ResolvedKeyDefinition { + key: string; + code: string; + keyCode: number; + text: string; + unmodifiedText: string; + location: number; +} + +const definitions = new Map(); + +function define(name: string, definition: KeyDefinition): void { + definitions.set(name, definition); +} + +const shiftedDigits = ')!@#$%^&*('; +for (let i = 0; i <= 9; i++) { + const key = String(i); + const definition: KeyDefinition = { + key, + code: `Digit${i}`, + keyCode: 48 + i, + shiftKey: shiftedDigits[i], + }; + define(key, definition); + define(`Digit${i}`, definition); +} + +for (let i = 0; i < 26; i++) { + const lower = String.fromCharCode(97 + i); + const upper = String.fromCharCode(65 + i); + const definition: KeyDefinition = { + key: lower, + code: `Key${upper}`, + keyCode: 65 + i, + shiftKey: upper, + }; + define(lower, definition); + define(upper, { ...definition, key: upper }); + define(`Key${upper}`, definition); +} + +for (const [name, keyCode, code, key, shiftKey] of [ + ['Semicolon', 186, 'Semicolon', ';', ':'], + ['Equal', 187, 'Equal', '=', '+'], + ['Comma', 188, 'Comma', ',', '<'], + ['Minus', 189, 'Minus', '-', '_'], + ['Period', 190, 'Period', '.', '>'], + ['Slash', 191, 'Slash', '/', '?'], + ['Backquote', 192, 'Backquote', '`', '~'], + ['BracketLeft', 219, 'BracketLeft', '[', '{'], + ['Backslash', 220, 'Backslash', '\\', '|'], + ['BracketRight', 221, 'BracketRight', ']', '}'], + ['Quote', 222, 'Quote', "'", '"'], +] as const) { + const definition: KeyDefinition = { keyCode, code, key, shiftKey }; + define(name, definition); + define(key, definition); + define(shiftKey, { ...definition, key: shiftKey }); +} + +for (const [name, keyCode, code, key, location = 0, text] of [ + ['Abort', 3, 'Abort', 'Cancel'], + ['Help', 6, 'Help', 'Help'], + ['Backspace', 8, 'Backspace', 'Backspace'], + ['Tab', 9, 'Tab', 'Tab'], + ['Enter', 13, 'Enter', 'Enter', 0, '\r'], + ['ShiftLeft', 16, 'ShiftLeft', 'Shift', 1], + ['ShiftRight', 16, 'ShiftRight', 'Shift', 2], + ['ControlLeft', 17, 'ControlLeft', 'Control', 1], + ['ControlRight', 17, 'ControlRight', 'Control', 2], + ['AltLeft', 18, 'AltLeft', 'Alt', 1], + ['AltRight', 18, 'AltRight', 'Alt', 2], + ['Pause', 19, 'Pause', 'Pause'], + ['CapsLock', 20, 'CapsLock', 'CapsLock'], + ['Escape', 27, 'Escape', 'Escape'], + ['Convert', 28, 'Convert', 'Convert'], + ['NonConvert', 29, 'NonConvert', 'NonConvert'], + ['Space', 32, 'Space', ' ', 0, ' '], + ['PageUp', 33, 'PageUp', 'PageUp'], + ['PageDown', 34, 'PageDown', 'PageDown'], + ['End', 35, 'End', 'End'], + ['Home', 36, 'Home', 'Home'], + ['ArrowLeft', 37, 'ArrowLeft', 'ArrowLeft'], + ['ArrowUp', 38, 'ArrowUp', 'ArrowUp'], + ['ArrowRight', 39, 'ArrowRight', 'ArrowRight'], + ['ArrowDown', 40, 'ArrowDown', 'ArrowDown'], + ['Select', 41, 'Select', 'Select'], + ['Open', 43, 'Open', 'Execute'], + ['PrintScreen', 44, 'PrintScreen', 'PrintScreen'], + ['Insert', 45, 'Insert', 'Insert'], + ['Delete', 46, 'Delete', 'Delete'], + ['MetaLeft', 91, 'MetaLeft', 'Meta', 1], + ['MetaRight', 92, 'MetaRight', 'Meta', 2], + ['ContextMenu', 93, 'ContextMenu', 'ContextMenu'], + ['NumLock', 144, 'NumLock', 'NumLock'], + ['ScrollLock', 145, 'ScrollLock', 'ScrollLock'], + ['AudioVolumeMute', 173, 'AudioVolumeMute', 'AudioVolumeMute'], + ['AudioVolumeDown', 174, 'AudioVolumeDown', 'AudioVolumeDown'], + ['AudioVolumeUp', 175, 'AudioVolumeUp', 'AudioVolumeUp'], + ['MediaTrackNext', 176, 'MediaTrackNext', 'MediaTrackNext'], + ['MediaTrackPrevious', 177, 'MediaTrackPrevious', 'MediaTrackPrevious'], + ['MediaStop', 178, 'MediaStop', 'MediaStop'], + ['MediaPlayPause', 179, 'MediaPlayPause', 'MediaPlayPause'], + ['AltGraph', 225, 'AltGraph', 'AltGraph'], +] as const) { + define(name, { keyCode, code, key, location, text }); +} + +define('\r', definitions.get('Enter')!); +define('\n', definitions.get('Enter')!); +define(' ', definitions.get('Space')!); +define('Shift', { keyCode: 16, code: 'ShiftLeft', key: 'Shift', location: 1 }); +define('Control', { keyCode: 17, code: 'ControlLeft', key: 'Control', location: 1 }); +define('Alt', { keyCode: 18, code: 'AltLeft', key: 'Alt', location: 1 }); +define('Meta', { keyCode: 91, code: 'MetaLeft', key: 'Meta', location: 1 }); + +for (let i = 1; i <= 24; i++) { + define(`F${i}`, { keyCode: 111 + i, code: `F${i}`, key: `F${i}` }); +} + +for (const [name, keyCode, key, shiftKey, shiftKeyCode] of [ + ['Numpad0', 45, 'Insert', '0', 96], + ['Numpad1', 35, 'End', '1', 97], + ['Numpad2', 40, 'ArrowDown', '2', 98], + ['Numpad3', 34, 'PageDown', '3', 99], + ['Numpad4', 37, 'ArrowLeft', '4', 100], + ['Numpad5', 12, 'Clear', '5', 101], + ['Numpad6', 39, 'ArrowRight', '6', 102], + ['Numpad7', 36, 'Home', '7', 103], + ['Numpad8', 38, 'ArrowUp', '8', 104], + ['Numpad9', 33, 'PageUp', '9', 105], +] as const) { + define(name, { keyCode, code: name, key, shiftKey, shiftKeyCode, location: 3 }); +} + +define('NumpadEnter', { keyCode: 13, code: 'NumpadEnter', key: 'Enter', text: '\r', location: 3 }); +define('NumpadMultiply', { keyCode: 106, code: 'NumpadMultiply', key: '*', location: 3 }); +define('NumpadAdd', { keyCode: 107, code: 'NumpadAdd', key: '+', location: 3 }); +define('NumpadSubtract', { keyCode: 109, code: 'NumpadSubtract', key: '-', location: 3 }); +define('NumpadDecimal', { keyCode: 46, code: 'NumpadDecimal', key: '\0', shiftKey: '.', shiftKeyCode: 110, location: 3 }); +define('NumpadDivide', { keyCode: 111, code: 'NumpadDivide', key: '/', location: 3 }); +define('NumpadEqual', { keyCode: 187, code: 'NumpadEqual', key: '=', location: 3 }); + +const aliases: Record = { + esc: 'Escape', + return: 'Enter', + spacebar: 'Space', + del: 'Delete', + cmd: 'Meta', + command: 'Meta', + ctrl: 'Control', +}; + +const namedKeys = new Map(); +for (const name of definitions.keys()) { + if ([...name].length > 1) namedKeys.set(name.toLowerCase(), name); +} + +function normalizeKeyName(input: string): string { + if ([...input].length === 1) return input; + const lower = input.toLowerCase(); + return aliases[lower] ?? namedKeys.get(lower) ?? input; +} + +export function resolveUSKey(input: string, shift: boolean): ResolvedKeyDefinition { + const normalized = normalizeKeyName(input); + let definition = definitions.get(normalized); + if (!definition && [...input].length === 1) { + definition = { key: input, code: '', keyCode: 0, text: input }; + } + if (!definition) { + throw new Error(`unknown key: ${input}`); + } + + const key = shift && definition.shiftKey !== undefined ? definition.shiftKey : definition.key; + const keyCode = shift && definition.shiftKeyCode !== undefined + ? definition.shiftKeyCode + : definition.keyCode; + const unmodifiedText = definition.text ?? (definition.key.length === 1 ? definition.key : ''); + const text = shift && definition.shiftText !== undefined + ? definition.shiftText + : definition.text ?? (key.length === 1 ? key : ''); + + return { + key, + code: definition.code, + keyCode, + text, + unmodifiedText, + location: definition.location ?? 0, + }; +} + +export function supportedUSKeyNames(): string[] { + return [...definitions.keys()].filter((name) => [...name].length > 1).sort(); +} diff --git a/server/runtime/webmcp.test.ts b/server/runtime/webmcp.test.ts index 337fe6ae..11abae14 100644 --- a/server/runtime/webmcp.test.ts +++ b/server/runtime/webmcp.test.ts @@ -41,6 +41,35 @@ test('lists browser-wide tools through the image API', async () => { assert.equal(requests[0].init?.signal, controller.signal); }); +test('resolves a fresh execution signal for every request', async () => { + const first = new AbortController(); + const second = new AbortController(); + let active = first.signal; + const signals: Array = []; + const client = createWebMCPClient({ + apiBaseUrl: 'http://127.0.0.1:10001', + signalProvider: () => active, + fetchImpl: async (_url, init) => { + signals.push(init?.signal); + return jsonResponse({tools: []}); + }, + }); + + await client.listTools(); + active = second.signal; + await client.listTools(); + + assert.deepEqual(signals, [first.signal, second.signal]); + assert.throws( + () => createWebMCPClient({ + apiBaseUrl: 'http://127.0.0.1:10001', + signal: first.signal, + signalProvider: () => second.signal, + }), + /signal or signalProvider, not both/, + ); +}); + test('invokes an exact tool reference with input and timeout', async () => { let request: {url: string; init?: RequestInit} | undefined; const client = createWebMCPClient({ diff --git a/server/runtime/webmcp.ts b/server/runtime/webmcp.ts index 91745770..5d040593 100644 --- a/server/runtime/webmcp.ts +++ b/server/runtime/webmcp.ts @@ -71,6 +71,7 @@ export class WebMCPRequestError extends Error { interface WebMCPClientOptions { apiBaseUrl: string; signal?: AbortSignal; + signalProvider?: () => AbortSignal; fetchImpl?: typeof fetch; } @@ -91,12 +92,17 @@ async function responseBody(response: Response): Promise { export function createWebMCPClient({ apiBaseUrl, signal, + signalProvider, fetchImpl = fetch, }: WebMCPClientOptions): WebMCPClient { + if (signal && signalProvider) { + throw new Error('WebMCP client accepts signal or signalProvider, not both'); + } const baseUrl = apiBaseUrl.replace(/\/+$/, ''); const request = async (path: string, init?: RequestInit): Promise => { - const response = await fetchImpl(`${baseUrl}${path}`, {...init, signal}); + const requestSignal = signalProvider ? signalProvider() : signal; + const response = await fetchImpl(`${baseUrl}${path}`, {...init, signal: requestSignal}); const body = await responseBody(response); if (!response.ok) throw new WebMCPRequestError(response.status, body); return body; @@ -119,14 +125,25 @@ export function createWebMCPClient({ headers: {'content-type': 'application/json'}, body: JSON.stringify(payload), }); + if (!isRecord(body) || typeof body.invocation_id !== 'string') { + throw new Error('WebMCP invocation response is invalid'); + } + const status = body.status; if ( - !isRecord(body) || - typeof body.invocation_id !== 'string' || - typeof body.status !== 'string' + status !== 'completed' && + status !== 'canceled' && + status !== 'error' && + status !== 'awaiting_submission' ) { throw new Error('WebMCP invocation response is invalid'); } - return body as WebMCPInvocationResult; + const result: WebMCPInvocationResult = { + invocation_id: body.invocation_id, + status, + }; + if (Object.prototype.hasOwnProperty.call(body, 'output')) result.output = body.output; + if (typeof body.error_text === 'string') result.error_text = body.error_text; + return result; }, };