Skip to content

feat(gateway): relay a website browser's CDP addresses at /browser/<key>/cdp/<token>/ - #500

Open
earakely-scale wants to merge 3 commits into
mainfrom
edgararakelyan/fd-4958-gateway-cdp
Open

earakely-scale wants to merge 3 commits into
mainfrom
edgararakelyan/fd-4958-gateway-cdp

Conversation

@earakely-scale

@earakely-scale earakely-scale commented Nov 5, 2026 •

Copy link
Copy Markdown
Collaborator

What and why

The gateway gets a route, /browser/<key>/cdp/<token>/…, that relays a website browser's CDP addresses. It covers discovery (json/version, json/list) and the WebSocket, in both directions. Today a CDP address is the browser's own address, so an agent can only use it if it can reach the browser's host directly. In a multi env, agents reach everything through the gateway's one address. This is FD-4958 (M8.11). Its scope and six decisions are recorded there, along with a diagram of the discovery options.

What the gateway does (new env/gateway/browser_proxy.py, wired into gateway.py):

  1. Finds the browser from its children's cards (decision 1).
    • A /svc child is a browser when its card, or one of its child cards, offers browser/v1 cdp and opens /cdp/ to agents in computer-app/v1's agent_routes. A browser computer's chromium app is one.
    • /browser/<key>/… forwards to <child>/svc/<app>/cdp/<token>/….
    • The card is read on first use, then cached. A key with no browser gets a 404, and one with several gets a 409.
  2. Rewrites discovery and the cdp answer (decisions 2, 3 and 6).
    • Fields: json/version's and json/list's address fields, and the http_url and ws_url that browser/v1 cdp returns through /svc/<key>/.
    • Rule: everything before /cdp/<token>/ becomes the address the caller reached the gateway at, plus /browser/<key>. That address is the Host it sent, checked to be a host and port, with the scheme from X-Forwarded-Proto when a tunnel sets it.
    • Method: the gateway parses the JSON and rewrites named fields, never by byte substitution. The rule cuts at the path, so it holds whatever host the address was minted at.
    • Unchanged: the browser and the driver.
    • Precedent: the state-of-the-art review found this is what CDP-aware gateways do. Selenium Grid rewrites se:cdp to the Grid's address, and Browserless rewrites discovery from EXTERNAL.
  3. Relays the WebSocket frame for frame (decision 4). The relay is the gateway's own, modelled on the driver's _both_ways.
    • Close codes and subprotocols pass through, and a code that can't be sent becomes 1000.
    • Pings: both legs are pinged every 20 s, uvicorn's ws_ping_interval on the client side and the websockets client upstream.
    • Refusals: an address the browser didn't mint is refused before the connection opens. So is a key with no browser, or a browser that doesn't answer.
  4. Records each connection in the trajectory. It writes one browser_connection event when the connection ends.
    • Fields: browser, path, role (from AgentEnv-Role, as for tool calls, so default for most CDP clients; decision 5), opened_at, closed_at, close_code, closed_by (client, browser or gateway), and status for a refusal.
    • No token: the event never carries it.
  5. Keeps tokens out of the logs. A filter takes the token out of each CDP address in uvicorn's access and handshake lines and httpx's request lines.
  6. The image gains websockets. Without a WebSocket library, uvicorn can't accept an upgrade. The image is published by source digest, so the next release publishes a new one, and the hub picks it up through the gateway's promotion path (FD-4549).

Not in this PR:

  • putting a browser into a multi env (M8.13);
  • recording browser-to-site requests (M8.20 and M5.5);
  • site WebSockets;
  • live WebSocket checks through each provider's tunnel (M8.25).

Also: cdp_clients, the Playwright and Puppeteer fixture, moves from browser_computer_test.py to tst/integration/computer/conftest.py, since the new test uses it too.

How it was tested

  • ruff check: clean.

  • Unit tests (tst/unit/env/gateway/browser_route_test.py): the real gateway app on loopback, in front of a stand-in browser computer that serves a driver-shaped card, a cdp answer minted at another host, discovery and a WebSocket. They check:

    • the cdp answer, json/version and json/list all name the gateway, and wss comes back when X-Forwarded-Proto: https;
    • a WebSocket is relayed both ways, and a close code the browser sends (4000) reaches the client;
    • a relay that fails before either side closes closes both sides with 1011, and the event records the failure's type, never its message;
    • a forged token gets a 403 at the handshake;
    • each attempt is an event with its role and no token;
    • a child with no browser, a closed CDP route or two browsers is refused, and its /svc traffic goes through untouched;
    • the rewrite rule, browser detection, the public base (bad Host values refused), log redaction, and the mirrored names against agentenv_protocol's.

    With the rest of the gateway suite, the gateway provider and the browser_connect_cdp step: 1016 passed.

  • Integration (new tst/integration/computer/browser_gateway_test.py): the gateway image built from its folder and run as a container, with agentenv's chromium, deployed alone as a browser computer, as its /svc child.

    • Playwright by http_url, through discovery, and Puppeteer and Playwright by ws_url each open a site through the gateway.
    • A forged token is refused.
    • There are 3 browser_connection events, closed by the client, plus one refusal with status 403. Neither they nor docker logs contain the token.
    • A gateway restart closes a live connection with a code, and a browser relaunch closes it with 1012. A new connection then answers Browser.getVersion after each.
  • VMs (agent-environments-admin, amd64 c6i.2xlarge and arm64 c7g.2xlarge):

    • Gateway unit tests: 758/758 on both, at e99a3074.
    • Integration: browser_gateway_test.py and all of browser_computer_test.py, for the moved fixture, 10/10 on amd64. On arm64, likewise 10/10: the 3 echo-agent tests need qemu there, because build_or_reuse builds their image for amd64, and they passed once it was installed.
    • Idle probe (not committed): a connection through the gateway by the address discovery gave sat idle for 300 s, with the client sending no pings of its own. It stayed open and then answered, on both arches. A VM has nothing that drops idle connections, so this shows the gateway doesn't time one out. Whether the 20 s pings outlast a real tunnel's idle timeout is M8.25.
    • File limit: running the gateway suite in one process needs more than the default 1024 open files, because each Gateway keeps its trajectory file open until the process exits. That's existing behaviour, not something this PR adds. CI runs the suite across workers.

Size: 777 lines added, about 400 of them tests.

🤖 Generated with Claude Code

RetriggerView in GreptileConfidence Score: 5/5

The PR appears safe to merge; no new blocking issue was found.

Summary

The PR adds browser discovery and a two-way CDP WebSocket relay through the gateway.

  • CDP addresses from child browsers now lead back through the gateway.
  • CDP WebSocket sessions relay through the gateway and leave a token-free record.

No new actionable issues were found.

Diagram

sequenceDiagram
    participant Client
    participant Gateway
    participant Browser
    Client->>Gateway: Request CDP address
    Gateway->>Browser: Forward cdp call
    Browser-->>Gateway: Browser address
    Gateway-->>Client: Gateway address
    Client->>Gateway: Discover and connect
    Gateway->>Browser: Open upstream WebSocket
    Client->>Gateway: CDP messages
    Gateway->>Browser: Relay messages
    Browser-->>Gateway: Replies
    Gateway-->>Client: Relay replies
    Gateway->>Gateway: Record connection when it ends
Loading

Reviews (3) · Last reviewed commit: "test(gateway): the browser route tests w..." · Reviewed by Greptile

…ey>/cdp/<token>/

The gateway finds the website browser behind a /svc child from the child's card and relays its CDP discovery and
WebSocket both ways, each leg pinged every 20 s. Every address the browser hands out, in json/version and json/list or
in its cdp answer on the /svc route, comes back at the gateway: everything before /cdp/<token>/ becomes the address the
caller reached the gateway at and /browser/<key>, so a client following one stays on the gateway. Each connection is a
browser_connection trajectory event, attributed by role and without its token, which uvicorn's and httpx's log lines
don't show either. The gateway image gains websockets.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@earakely-scale
earakely-scale requested a review from a team as a code owner November 5, 2026 08:09
earakely-scale and others added 2 commits November 5, 2026 00:16
…oth sides with 1011 and records why

A send that raised used to vanish into the relay's gather, and the event said the gateway closed it with 1001. Now
both sides are closed as 1011 and the event and a warning carry the failure's type, never its message.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… which follows the client's close

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@earakely-scale

Copy link
Copy Markdown
Collaborator Author

🤖 Automated Claude routine — Env Pod PR Context Review. This is a bot, not Edgar Arakelyan; not a human review.

Context from a sibling PR rather than from this diff: #443 is also open against main and introduces a helper for the same question public_base answers here — "what address did the client reach the gateway at" — in the same package, resolved differently.

  • Here, src/agent_env/env/gateway/browser_proxy.py:106 takes the host from Host alone, and reads x-forwarded-proto only for the scheme (:109).
  • In feat(gateway): forwarded headers and triggers by route (FD-4683) #443, src/agent_env/env/gateway/rest_proxy.py:128-137 (forwarded_address) prefers a front's X-Forwarded-Host over Host (:133), takes X-Forwarded-Proto for the scheme the same way, and also forwards X-Forwarded-Prefix from route_prefix() (:144).

Neither is on main yet, so neither PR can see the other. If both land, the gateway would derive its own external address two ways: whenever something in front sets X-Forwarded-Host, /svc and /website would report one address upstream while the CDP rewrite here mints another — and a client following the rewritten ws_url would leave the gateway, which is the case this module says it exists to prevent.

Two smaller things from the same comparison, if they aren't already deliberate:

Trusting Host alone is a defensible choice against a spoofed X-Forwarded-Host — so this is less "wrong" than "worth the two PRs agreeing on one rule before both are on main".


Generated by Claude Code

This branch has not been deployed

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

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant