Skip to content

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

Merged
earakely-scale merged 7 commits into
mainfrom
edgararakelyan/fd-4958-gateway-cdp
Nov 5, 2026
Merged

earakely-scale merged 7 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 and kept once it names a browser. A card naming none is read again next time, since a computer lists an app whose server isn't answering yet by its name alone. 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). That needs the gateway's card to bring in the app's card: rewrite_child_card drops a child's nested cards today, so browser_connect_cdp can't find a browser in a multi env yet;
  • recording browser-to-site requests (M8.20 and M5.5);
  • site WebSockets;
  • live WebSocket checks through each provider's tunnel (M8.25).

Docs: AGENTS.md's code map describes the route beside the gateway's site route.

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;
    • a browser whose server wasn't answering at the first read is found once its card names it;
    • 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: 760 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.
    • With the browser's app server stopped when the gateway first reads its card, /browser/web/… answers 404, and once the server is back the same request answers with the address at the gateway. Against the proxy before d1933e25, this test fails: the second request still gets a 404.
    • Killing the app server, and then the driver, behind the gateway each closes a live connection, recorded as closed by the browser. A new connection works after each recovery.
  • VMs (agent-environments-admin, amd64 c6i.2xlarge and arm64 c7g.2xlarge):

    • At dd27a3da: gateway unit tests 760/760 on both arches. Integration (browser_gateway_test.py's 3 tests, all of browser_computer_test.py, the idle probe) 12/12 on amd64. On arm64, 9/9 without the 3 echo-agent tests, which need qemu and passed with it at e99a3074.
    • 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 blocking findings remain.

Summary

Adds a gateway route for browser CDP discovery and WebSocket connections.

  • CDP addresses from child browsers now point back through the gateway.
  • Browser CDP WebSockets now pass through the gateway and leave connection records.

No new findings. The changes since the previous review are empty, and the full-PR rules check found no violations. The earlier, unnumbered finding about hidden relay errors is fixed: _both_ways checks failed tasks, closes both connections with 1011, and records only the error type.

Diagram

sequenceDiagram
    participant Client
    participant Gateway
    participant Browser
    Client->>Gateway: Request CDP address through /svc/key/
    Gateway->>Browser: Forward request
    Browser-->>Gateway: Browser CDP addresses
    Gateway-->>Client: Addresses at /browser/key/cdp/token/
    Client->>Gateway: Request discovery or open WebSocket
    Gateway->>Browser: Forward to the browser's CDP route
    Browser-->>Gateway: Discovery answer or WebSocket messages
    Gateway-->>Client: Rewritten discovery or relayed messages
    Note over Gateway: Record connection outcome without the token
Loading

Reviews (7) · Last reviewed commit: "ci: run the Local backends workflow on t..." · 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

earakely-scale and others added 4 commits November 5, 2026 07:58
A computer lists an app whose server isn't answering by its name alone, so a first read in that window cached "no
browser" for the gateway's life: /browser/<key> answered 404 and the cdp answer went through unrewritten. Now a card
naming none is read again next time.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…s found once up, and a dying app server or driver closes a live connection

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… didn't start

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@earakely-scale
earakely-scale merged commit fd65de0 into main Nov 5, 2026
34 checks passed
@earakely-scale
earakely-scale deleted the edgararakelyan/fd-4958-gateway-cdp branch November 5, 2026 17:28
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