Repository navigation
feat(gateway): site routes by real names (M8.12) - #627
earakely-scale wants to merge 8 commits into
Conversation
A site reached at one of its names through the gateway's /website/ route gets that name as its Host, so a site that builds its links and redirects from Host names itself as the browser reached it. Any other host leaves Host the site's internal address, so a forged one never reaches it; X-Forwarded-Host is unchanged. The names are the ones the site's env stores (WEBSITE_NAMES), else the ones its card declares, matched as a computer's site proxy matches them. WebSockets on the route follow the same rule. list_website_urls gives each site at its first name when the multi env's browser is a browser computer, which opens the sites there (WEBSITE_LISTED_BY_NAME), and at its internal address when it's playwright-mcp. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
An AgentEnv-Site-Root that urlsplit can't parse (an unbalanced bracket) and a Host or X-Forwarded-Host that isn't printable ASCII, which httpx can't send upstream, each failed the request with a 500. Each now names nothing, as an empty one does: the request falls back to the next header and goes through. Found by M8.12's chaos fuzz; main answers the same 500s. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… names together The names a card declares were a side effect of the reserved-path read, and list_website_urls called that read only to fill them in. Now one cached read returns both, and each caller passes the names on explicitly. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
#614's clock followers and M8.12's site names were added beside each other in the gateway's entrypoint and constructor; both are kept. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… and checks its own certificate The URI names the site, so websockets would default the port to 80 and verify TLS against the site's name; the upstream's port now defaults by its scheme, and a wss upstream is verified as the upstream. Also wraps two lines over 120 characters. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ugh an environment proxy The relay's URI now names the site, so websockets' default proxy=True would decide no_proxy by the site's name and, with a proxy in the gateway's environment, tunnel to that name: the real domain, for a site named after one. Its upstream is always internal, so the relay connects directly. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
| subprotocols=offered or None, user_agent_header=None, | ||
| open_timeout=open_timeout, max_size=WEBSOCKET_MESSAGE_MAX_BYTES) | ||
| open_timeout=open_timeout, max_size=WEBSOCKET_MESSAGE_MAX_BYTES, | ||
| proxy=None, **destination) |
There was a problem hiding this comment.
Older versions lose WebSockets
relay_websocket now always passes proxy=None, but the gateway still allows websockets>=13. The proxy argument was added in websockets 15. On versions 13 and 14, it reaches the underlying asyncio connection call and raises TypeError. Every website WebSocket fails in those installations, including sites without declared names.
Raise the dependency minimum to 15 in the gateway requirements and development dependencies, or omit this argument on older versions.
Prompt To Fix With AI
This is a comment left during a code review.
Path: src/agent_env/env/gateway/rest_proxy.py
Line: 347
Comment:
**Older versions lose WebSockets**
`relay_websocket` now always passes `proxy=None`, but the gateway still allows `websockets>=13`. The `proxy` argument was added in websockets 15. On versions 13 and 14, it reaches the underlying `asyncio` connection call and raises `TypeError`. Every website WebSocket fails in those installations, including sites without declared names.
Raise the dependency minimum to 15 in the gateway requirements and development dependencies, or omit this argument on older versions.
---
For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.There was a problem hiding this comment.
Fixed in f3dfd59: the gateway image's requirements and the dev extra now ask for websockets>=15, the first release with proxy. The lock already resolves 17.2, so only its recorded floor changes. agentenv-protocol's own >=13 stays, since its relay doesn't pass proxy.
…y=None websockets 13 and 14 hand an unknown proxy argument to asyncio's create_connection, which raises TypeError, so every site WebSocket would fail there. The gateway image and the dev extra now ask for 15; the lock already resolves 17.2, so only its recorded floor changes. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
What and why
M8.12 (FD-4959): the gateway routes sites by their real names.
Until now, a site behind the gateway's
/website/<name>/route always saw its internal address as itsHost,shop-website-backend:8000. A site that builds its links and redirects fromHost, as many real apps do, then sent chromium to an address chromium can't reach. For example,/loginredirected tohttps://shop-website-backend:8000/home.Sites built on agentenv-protocol's
WebsiteEnvironmentalready take their address fromX-Forwarded-Host, so they never showed this. Any other server did.Design note: M8.12: Gateway site routes by real names, with decisions 1a, 2a and 3a. It isn't settled until you've reviewed it.
What changes
Host. The host a client reached a site at is the one the gateway already forwards: a computer site proxy'sAgentEnv-Site-Rootorigin, else a front'sX-Forwarded-Host, else the request'sHost.Host. Case is ignored and the port is kept.Hostthe internal address, so a forged one never reaches the site.X-Forwarded-*is unchanged (decision 2a).websocketswritesHostfrom the URI, so the URI names the site and the TCP connection goes to the upstream./svc/<name>/is unchanged.WEBSITE_NAMES, else the ones its card declares.website/v1domains, read with the card the route already reads.@website(domains=...)sites declare their names only on the card, which is also where a computer's site table takes them from.*.name matches one label more, as the site proxy matches it.list_website_urlsfollows the run's browser (decision 1a).https://shop.test/, orhttp://for a.localhostname. The deploy names those sites inWEBSITE_LISTED_BY_NAME.playwright-mcp, or for a site deployed alone, sites keep their internal addresses, which is allplaywright-mcpcan reach.HEADalready worked, since Starlette adds it to any route that takesGET. A test now covers it.Decided while building, for your review
Host. A computer reaches such a site at a.localhostfallback name that depends on the run's other sites, which the gateway can't see. This is a known limitation.How it was tested
Hostrule:HEAD, case and port, a front'sX-Forwarded-Host, a wildcard, and forged origins, suffixes, ports and addresses;/svc/;list_website_urls: by name,.localhost, wildcard-only, card-only, and not listed.WEBSITE_NAMESparsing.test_docker_image_github_clone.py, which hangs here on a git credential helper. Neither touches this change.The fixture
tst/data/host_websiteis a plain Starlette server that redirects/logintohttps://<its Host>/home.Chromium, in a multi env, opens
https://shop.test/loginand lands onhttps://shop.test/home.list_website_urlsthrough the gateway's/mcpgives["https://shop.test/"].Asked straight at the gateway's route, the site sees
Shop.Testfor that origin, its internal address forhttps://evil.example, and its internal address with no origin named.X-Forwarded-Hostsays where each request came from.All of it holds again after a
docker restartof the gateway.Results:
browser_computer_test.py -k multi_envand M8.14's testmulti_env_computers_test.pygateway_test.py -k website(playwright-mcp, internal addresses)On amd64 the tiers ran again on the final code, after the second commit: 8 of 8 site and multi env browser tests, 4 of 4
multi_env_computers_test.py, 4 of 4gateway_test.py -k website. The third commit only names a constant.On arm64, two things the harness builds only for linux/amd64 couldn't run, since the VM has no emulation, and neither is touched here:
test_browser_connect_cdp_hands_an_agent_an_address_at_the_multi_envs_gateway_with_its_tools_hidden(tst/util/image_cache.pydefaults tolinux/amd64);gateway_test.py's gateway image (docker build --platform linux/amd64).Fuzz: 552 requests over raw sockets, each naming a host as
Host,X-Forwarded-HostorAgentEnv-Site-Root. The hosts included:SHOP.TEST,Shop.Test:443,shop.test:65535;%-escapes, non-ASCII and punycode names, 64-character labels and an 8000-character value;No answer was 5xx or hung. The site saw one of its names as
Hostonly when it was reached at that very name, and every good name came through as sent.Concurrency: 300 requests, 50 at a time, each naming a good name, a forged origin or none. Each one's
Hostwas its own.Site killed:
docker killon the site's container. Its route answered 502 at once, and the gateway kept listing it by name. Afterdocker start, the site was back in 1.2 s at its name, and a forged origin still got the internalHost.Gateway restarted under traffic: of about 120 requests, the roughly 10 sent during the restart got connection resets. None of the answered ones broke the rule. Afterwards chromium still landed on
https://shop.test/home, andlist_website_urlsstill gavehttps://shop.test/, including for the card-only site, whose card the restarted gateway read again.The fuzz first found 500s, and they were on main too. An
AgentEnv-Site-Rootthaturlsplitcan't parse (https://]x[) and a non-ASCIIHostorX-Forwarded-Host, which httpx can't send upstream, each failed the request.Fixed on the way
httpsupstream reached by name went to port 80 and checked the certificate against the site's name. It now defaults the port by the upstream's scheme, and verifies awssupstream as itself.proxy=None). With the site's name in the URI,websockets' defaultproxy=Truewould otherwise follow a proxy in the gateway's environment and tunnel to that name, which is the real domain for a site named after one. No agentenv deploy sets one today.proxy=Noneneeds websockets 15, so the gateway image and the dev extra now ask forwebsockets>=15. The lock already resolves 17.2, so only its recorded floor changes.SiteCard, its reserved paths and its names together, so the names are no longer a side effect of the reserved-path read.Not in scope
httplinks. Agents aren't affected.🤖 Generated with Claude Code
Update the websockets dependency minimum or keep compatibility with older supported versions before merging.
Fix with agent prompt
Summary
The gateway forwards a site's declared name as
Hostand lists sites by name for browser computers.Hostwhen it matches.Diagram
%%{init: {'theme': 'neutral'}}%% flowchart LR C[Browser request] --> G[Gateway website route] G --> N{Host matches a site name?} N -->|Yes| S[Forward the site's name as Host] N -->|No| I[Keep the internal Host] S --> U[Website backend] I --> UReviews (11) · Last reviewed commit: "Gateway: a site's WebSocket goes straigh..." · Reviewed by Greptile