From f5c160034192f28b22edd11613973051e41f8de6 Mon Sep 17 00:00:00 2001 From: Edgar Arakelyan Date: Tue, 10 Nov 2026 16:29:45 -0800 Subject: [PATCH 1/2] fix(browser): CDP addresses through a TLS-ending tunnel are https and wss (FD-5470) browser/v1 cdp takes an optional origin, where the caller's agent reaches the env, and mints the address under it; discovery under such a token answers the WebSocket it minted. browser_connect_cdp names the env's URL as the agent's sandbox reaches it, when the browser's card takes an origin. Behind Modal's tunnel, which ends TLS and adds no X-Forwarded-Proto, the browser could only see http, so agents got http and ws addresses that were refused. Co-Authored-By: Claude Opus 5.5 --- .../src/agentenv_protocol/browser_cdp.py | 34 ++++++---- .../website_browser/environment.py | 42 +++++++++--- .../tests/test_browser_cdp.py | 55 ++++++++++++++- .../task_steps/browser/connect_cdp.py | 29 +++++--- .../computer/browser_computer_test.py | 67 +++++++++++++++++-- .../task_step/test_browser_connect_cdp.py | 56 ++++++++++++---- tst/util/tls_front.py | 49 ++++++++++++++ 7 files changed, 280 insertions(+), 52 deletions(-) create mode 100644 tst/util/tls_front.py diff --git a/packages/agentenv-protocol/src/agentenv_protocol/browser_cdp.py b/packages/agentenv-protocol/src/agentenv_protocol/browser_cdp.py index bd8b6d663..0ca208cb7 100644 --- a/packages/agentenv-protocol/src/agentenv_protocol/browser_cdp.py +++ b/packages/agentenv-protocol/src/agentenv_protocol/browser_cdp.py @@ -59,11 +59,13 @@ @dataclass class Address: - """A user's CDP address: ``http_url``, the base clients discover from, and ``ws_url``, the browser's WebSocket.""" + """A user's CDP address: ``http_url``, the base clients discover from, and ``ws_url``, the browser's WebSocket; + ``named`` when it's under an origin the caller named, where its agent reaches the env.""" user: str http_url: str ws_url: str + named: bool = False def reached(request: Request) -> str: @@ -74,6 +76,12 @@ def reached(request: Request) -> str: return f"{proto}://{host}{request.headers.get('x-forwarded-prefix', '')}".rstrip("/") +def under(origin: str, request: Request) -> str: + """The address a request reached the browser at, under ``origin``, where the caller's agent reaches the env: the + origin, then the path prefix the driver forwarded.""" + return f"{origin.rstrip('/')}{request.headers.get('x-forwarded-prefix', '')}".rstrip("/") + + def websocket_form(url: str) -> str: return "wss://" + url.removeprefix("https://") if url.startswith("https://") else \ "ws://" + url.removeprefix("http://") @@ -83,31 +91,33 @@ def _hash(token: str) -> str: return hashlib.sha256(token.encode()).hexdigest() -def _address(user: str, base: str, token: str) -> Address: +def _address(user: str, base: str, named: bool, token: str) -> Address: root = f"{base}{ROUTE}{token}" - return Address(user, f"{root}/", f"{websocket_form(root)}/devtools/browser") + return Address(user, f"{root}/", f"{websocket_form(root)}/devtools/browser", named) class Tokens: """The tokens minted, one per user: in memory, so asking twice gives the same address, and in ``path`` only by - hash, with the user and the base address, so an address still works after the server restarts while the - computer's session, which can read the file, can't use one.""" + hash, with the user, the base address and whether its caller named it, so an address still works after the server + restarts while the computer's session, which can read the file, can't use one.""" def __init__(self, path: Path) -> None: self.path = path self._minted: dict[str, Address] = {} try: stored = json.loads(path.read_text()) - self._known = {digest: (entry["user"], entry["base"]) for digest, entry in stored.items()} + self._known = {digest: (entry["user"], entry["base"], entry["named"]) + for digest, entry in stored.items()} except (OSError, ValueError, TypeError, KeyError): self._known = {} - def mint(self, user: str, base: str) -> Address: - """``user``'s address under ``base``: the one minted before, else a new one.""" + def mint(self, user: str, base: str, *, named: bool = False) -> Address: + """``user``'s address under ``base``, ``named`` when its caller named where its agent reaches the env: the one + minted before, else a new one.""" if user not in self._minted: token = secrets.token_urlsafe(TOKEN_BYTES) - self._minted[user] = _address(user, base, token) - self._known[_hash(token)] = (user, base) + self._minted[user] = _address(user, base, named, token) + self._known[_hash(token)] = (user, base, named) self._save() return self._minted[user] @@ -124,8 +134,8 @@ def address(self, token: str) -> Optional[Address]: def _save(self) -> None: self.path.parent.mkdir(parents=True, exist_ok=True) part = self.path.with_name(f".{self.path.name}.part") - part.write_text(json.dumps({digest: {"user": user, "base": base} - for digest, (user, base) in self._known.items()})) + part.write_text(json.dumps({digest: {"user": user, "base": base, "named": named} + for digest, (user, base, named) in self._known.items()})) os.replace(part, self.path) diff --git a/packages/agentenv-protocol/src/agentenv_protocol/website_browser/environment.py b/packages/agentenv-protocol/src/agentenv_protocol/website_browser/environment.py index 8b708ff2b..972f61557 100644 --- a/packages/agentenv-protocol/src/agentenv_protocol/website_browser/environment.py +++ b/packages/agentenv-protocol/src/agentenv_protocol/website_browser/environment.py @@ -18,7 +18,7 @@ from ..agent_env_environment import (OP_ADD_DATA, add_data, add_websocket_route, agent_skill, extension, get_data, reset_data) -from ..browser_cdp import ROUTE, Relay, Tokens, reached, websocket_form +from ..browser_cdp import ROUTE, Relay, Tokens, reached, under, websocket_form from ..computer import AppDeclaration, ComputerAppEnvironment, Launch, install_step, ready_check from ..computer import app as computer_app from ..computer import app_data @@ -309,7 +309,8 @@ async def start_tabs_loaded(self) -> bool: "local_storage/clear, read and change its default context's cookies and local storage as Playwright's " "storageState has them, a cookie's max_age set as an expiry on the browser's clock; downloads/list and " "downloads/get are its downloads, get with its file, uploaded under a write_namespace grant or inline. cdp " - "returns a user's CDP address, http_url to discover from and ws_url to connect to, " + "returns a user's CDP address, http_url to discover from and ws_url to connect to, under origin, where the " + "caller's agent reaches the env, when it names one, else at the address the call reached the browser at, " "with a token agentenv mints in its path: each connection to it gets its own browser-level session behind a " "guard, and record lists them as cdp_connections, each with the commands the guard refused or changed. " "sites/set hands it the env's own sites, by name and domains: a start tab on one must load, its document's " @@ -333,11 +334,14 @@ async def browser_sites_set(self, sites: list[dict]) -> dict: return {} @extension(BROWSER, op="cdp") - async def browser_cdp(self, user: str, request: Request) -> dict: - """``user``'s CDP address at the address this call reached the browser at, once it's ready: the same one each - time it's asked for, as the server that minted it keeps it.""" + async def browser_cdp(self, user: str, request: Request, origin: Optional[str] = None) -> dict: + """``user``'s CDP address once the browser's ready, the same one each time it's asked for, as the server that + minted it keeps it: under ``origin``, where the caller's agent reaches the env, when the caller names it, as a + deploy names the env's other addresses, since a tunnel in front of the env may not say how it was reached; + else at the address this call reached the browser at.""" + base = under(_origin(origin), request) if origin is not None else reached(request) await self._until_ready() - address = self.cdp_tokens.mint(user, reached(request)) + address = self.cdp_tokens.mint(user, base, named=origin is not None) return {"http_url": address.http_url, "ws_url": address.ws_url} def create_app(self) -> Any: @@ -349,11 +353,11 @@ def create_app(self) -> Any: return app async def _cdp_discovery(self, request: Request) -> Response: - """``json/version`` and ``json/list`` under a CDP address, answered from the browser, with its WebSocket at the - address the client reached: a browser on a pipe has no endpoints of its own. 403 for a token agentenv didn't - mint.""" + """``json/version`` and ``json/list`` under a CDP address, answered from the browser, with its WebSocket as + minted when its caller named the origin, else at the address the client reached: a browser on a pipe has no + endpoints of its own. 403 for a token agentenv didn't mint.""" token = request.path_params["token"] - if self.cdp_tokens.address(token) is None: + if (address := self.cdp_tokens.address(token)) is None: return JSONResponse({"error": "agentenv didn't mint this CDP address"}, status_code=403) try: version = await self.devtools.call("Browser.getVersion") @@ -362,7 +366,8 @@ async def _cdp_discovery(self, request: Request) -> Response: "Browser": version.get("product"), "Protocol-Version": version.get("protocolVersion"), "User-Agent": version.get("userAgent"), "V8-Version": version.get("jsVersion"), "WebKit-Version": version.get("revision"), - "webSocketDebuggerUrl": f"{websocket_form(reached(request))}{ROUTE}{token}/devtools/browser"}) + "webSocketDebuggerUrl": address.ws_url if address.named else + f"{websocket_form(reached(request))}{ROUTE}{token}/devtools/browser"}) tabs = await self.control.tab_list() except (ConnectionError, DevToolsError, asyncio.TimeoutError) as e: return JSONResponse({"error": f"the browser isn't answering: {e}"}, status_code=503) @@ -664,6 +669,21 @@ def _mode() -> str: return "headless" if _alone() and not _x_server() else "headful" +def _origin(value: Any) -> str: + """``value`` as an origin a CDP address is minted under: an http or https URL, a path allowed, with no query, + fragment or credentials; InvalidArgument for anything else.""" + try: + parts = urlsplit(value) + valid = (value.isprintable() and parts.scheme in ("http", "https") and bool(parts.hostname) + and "@" not in parts.netloc and not parts.query and not parts.fragment + and (parts.port is None or 0 < parts.port)) + except (AttributeError, TypeError, ValueError): + valid = False + if not valid: + raise InvalidArgument("origin is an http or https URL with no query, fragment or credentials") + return value + + def _parsed(model: type[BaseModel], items: Any, what: str) -> list: """Each of ``items`` as ``model``; InvalidArgument naming the fields that don't fit and never their values, which for a cookie are its credentials.""" diff --git a/packages/agentenv-protocol/tests/test_browser_cdp.py b/packages/agentenv-protocol/tests/test_browser_cdp.py index 2e7e4a698..8ea183ec1 100644 --- a/packages/agentenv-protocol/tests/test_browser_cdp.py +++ b/packages/agentenv-protocol/tests/test_browser_cdp.py @@ -1,7 +1,8 @@ """A website browser's CDP addresses: a user's token minted once, kept by hash only, so it still works after the server restarts and the computer's session can't use the file; the relay giving each WebSocket connection its own browser-level session over one pipe, its answers and events and nobody else's, until the browser goes; the guard on -each command; and the cdp operation and discovery answered at the address the call reached.""" +each command; and the cdp operation and discovery answered at the address the call reached, or under the origin its +caller names, which behind a tunnel that ends TLS is the only way to an https address.""" from __future__ import annotations import asyncio @@ -30,6 +31,8 @@ FORWARDED = {"x-forwarded-host": "computer.test:443", "x-forwarded-proto": "https", "x-forwarded-prefix": "/svc/chromium"} +#: What the driver forwards behind a tunnel that ends TLS: the tunnel's host, and http, how the tunnel reached it. +BEHIND_TLS = {"x-forwarded-host": "t.tunnel.test", "x-forwarded-proto": "http", "x-forwarded-prefix": "/svc/chromium"} @computer_app(supports=[{"os": {"type": "linux", "display": ["x11", "none"]}, "arch": ["amd64"]}]) @@ -138,13 +141,15 @@ def test_a_users_token_is_minted_once_kept_by_hash_and_still_known_after_a_resta tokens = Tokens(path) ada = tokens.mint("ada", "https://computer.test/svc/chromium") again, bob = tokens.mint("ada", "https://elsewhere.test"), tokens.mint("bob", "https://computer.test/svc/chromium") - token = ada.http_url.split("/cdp/")[1].strip("/") + cy = tokens.mint("cy", "https://t.tunnel.test/svc/chromium", named=True) + token, cys = (address.http_url.split("/cdp/")[1].strip("/") for address in (ada, cy)) restarted = Tokens(path) assert again == ada and bob != ada and ada.http_url == f"https://computer.test/svc/chromium/cdp/{token}/" assert ada.ws_url == f"wss://computer.test/svc/chromium/cdp/{token}/devtools/browser" assert token not in path.read_text() and restarted.address(token) == ada and tokens.address("guess") is None assert restarted.mint("ada", "https://computer.test").http_url != ada.http_url + assert not ada.named and restarted.address(cys) == cy and cy.named @pytest.mark.asyncio @@ -349,4 +354,48 @@ async def tabs(): browser_methods = next(e["params"]["methods"] for e in card["capabilities"]["extensions"] if e["uri"] == "urn:agentenv:browser/v1") assert computer_app_params["agent_routes"] == ["/cdp/"] - assert browser_methods["cdp"]["request"]["properties"] == {"user": {"type": "string"}} + assert browser_methods["cdp"]["request"]["properties"] == {"user": {"type": "string"}, + "origin": {"type": "string", "default": None}} + + +@pytest.mark.asyncio +async def test_under_an_origin_its_caller_names_an_address_is_https_and_discovery_answers_wss_though_reached_over_http( + monkeypatch, tmp_path): + browser = _browser(monkeypatch, tmp_path) + + async def ready(): + return None + + async def call(method, params=None, *, session_id=None, timeout_s=None): + return {"product": "Chrome/156.0.8078.4", "protocolVersion": "1.3"} + + monkeypatch.setattr(browser, "_until_ready", ready) + monkeypatch.setattr(browser.devtools, "call", call) + asgi = browser.create_app().streamable_http_app() + async with httpx.AsyncClient(transport=httpx.ASGITransport(app=asgi), base_url="http://app") as http: + ada = (await http.post("/agentenv/ext/browser/cdp", json={"user": "ada", "origin": "https://t.tunnel.test/"}, + headers=BEHIND_TLS)).json() + bob = (await http.post("/agentenv/ext/browser/cdp", json={"user": "bob"}, headers=BEHIND_TLS)).json() + paths = [address["http_url"].split("/svc/chromium", 1)[1] for address in (ada, bob)] + seen = [(await http.get(f"{path}json/version", headers=BEHIND_TLS)).json()["webSocketDebuggerUrl"] + for path in paths] + + token = ada["http_url"].split("/cdp/")[1].strip("/") + assert ada == {"http_url": f"https://t.tunnel.test/svc/chromium/cdp/{token}/", + "ws_url": f"wss://t.tunnel.test/svc/chromium/cdp/{token}/devtools/browser"} + assert seen[0] == ada["ws_url"] + assert bob["ws_url"].startswith("ws://t.tunnel.test/") and seen[1] == bob["ws_url"] + + +@pytest.mark.asyncio +@pytest.mark.parametrize("origin", ["ftp://t.test", "t.test", "https://", "https://t.test/?q=1", "https://t.test/#top", + "https://ada:pw@t.test", "https://t.test:0", "https://t.test:99999", + "https://t.test/\n", 7]) +async def test_an_origin_that_isnt_an_http_or_https_url_is_refused_and_mints_nothing(monkeypatch, tmp_path, origin): + browser = _browser(monkeypatch, tmp_path) + asgi = browser.create_app().streamable_http_app() + async with httpx.AsyncClient(transport=httpx.ASGITransport(app=asgi), base_url="http://app") as http: + answer = await http.post("/agentenv/ext/browser/cdp", json={"user": "ada", "origin": origin}) + + assert answer.status_code == 400 and "origin is an http or https URL" in answer.text, answer.text + assert not browser.cdp_tokens.path.exists() diff --git a/src/agent_env/task_step/task_steps/browser/connect_cdp.py b/src/agent_env/task_step/task_steps/browser/connect_cdp.py index 4b88310a0..f7a4d0c91 100644 --- a/src/agent_env/task_step/task_steps/browser/connect_cdp.py +++ b/src/agent_env/task_step/task_steps/browser/connect_cdp.py @@ -8,8 +8,10 @@ from typing import ClassVar, Optional import httpx +from agentenv_protocol.client import find_extension_method from agentenv_protocol.tool_access import DISABLE, DISABLE_TOOL_EXTENSION_URI, WILDCARD from agentenv_protocol.tool_context import DEFAULT_ROLE +from agentenv_protocol.website_browser import BROWSER from agent_env.a2a_agent.a2a_agent import A2AAgent from agent_env.entity.refs import EntityRef @@ -74,7 +76,7 @@ def from_dict(cls, data: dict) -> BrowserConnectCdpTaskStep: name=data.get("name") or DEFAULT_NAME, keep_tools=data.get("keep_tools", False)) async def execute(self, context: TaskStepContext) -> TaskStepContext: - deployed, browser, _ = self._find(context) + deployed, browser, card = self._find(context) if deployed.env_id != self.env_id: raise RuntimeError(f"the website browser {browser!r} is in a computer of the multi env {self.env_id!r}, whose " "agents reach it through the multi env's gateway; browser_connect_cdp doesn't hand out " @@ -84,8 +86,11 @@ async def execute(self, context: TaskStepContext) -> TaskStepContext: if cdp_config is None or not agent.a2a_url: raise RuntimeError(f"agent {agent.agent_name!r} can't take a CDP address: its card has no " f"{A2AAgent.EXT_CDP_CONFIG}") - address = await self._call(context, "cdp", {"user": agent.agent_name}, CDP_TIMEOUT_S) - http_url, ws_url = _as_agent_reaches(address, deployed, agent) + base, theirs = _env_urls(deployed, agent) + asked = {"user": agent.agent_name, **({"origin": theirs} if _takes_origin(card) else {})} + address = await self._call(context, "cdp", asked, CDP_TIMEOUT_S) + http_url = _rebased(address["http_url"], base, theirs) + ws_url = _rebased(address["ws_url"], _ws(base), _ws(theirs)) async with httpx.AsyncClient(timeout=ADD_TIMEOUT_S) as client: added = await client.post(agent.a2a_url + cdp_config["params"]["endpoint"], json={ "name": self.name, "http_url": http_url, "ws_url": ws_url, @@ -113,19 +118,27 @@ def _deployed_agent(context: TaskStepContext, agent_name: str) -> DeployedAgent: return agent -def _as_agent_reaches(address: dict, deployed: DeployedEnv, agent: DeployedAgent) -> tuple[str, str]: - """``address``'s URLs as the agent's sandbox reaches them: under the env's URL as the agent reaches that, as - deploy_agent gives it MCP URLs. A URL anywhere else, at a gateway's public address say, is kept.""" +def _env_urls(deployed: DeployedEnv, agent: DeployedAgent) -> tuple[str, str]: + """The env's URL, and the env's URL as the agent's sandbox reaches it, as deploy_agent gives it MCP URLs: the + origin the browser mints the agent's address under, since behind a tunnel that ends TLS the browser can't tell + how it was reached.""" base = deployed.environment_url.rstrip("/") if isinstance(deployed, DeployedSandboxEnv): theirs = reachable_url(base, from_sandbox_type=deployed.sandbox_type, to_sandbox_type=agent.sandbox_type) else: theirs = host_url_for(base, agent.sandbox_type) - theirs = theirs.rstrip("/") - return _rebased(address["http_url"], base, theirs), _rebased(address["ws_url"], _ws(base), _ws(theirs)) + return base, theirs.rstrip("/") + + +def _takes_origin(card: dict) -> bool: + """Whether the browser's cdp takes an origin: one composed before it did refuses any it doesn't know.""" + method = find_extension_method(card, BROWSER, "cdp") or {} + return "origin" in method.get("request", {}).get("properties", {}) def _rebased(url: str, base: str, theirs: str) -> str: + """``url`` moved under ``theirs`` when it's under the env's URL as the step reaches it, as a gateway's CDP answers + are and a browser's that takes no origin; else kept.""" return theirs + url[len(base):] if url.startswith(base + "/") else url diff --git a/tst/integration/computer/browser_computer_test.py b/tst/integration/computer/browser_computer_test.py index 9662419b5..9a220c6d4 100644 --- a/tst/integration/computer/browser_computer_test.py +++ b/tst/integration/computer/browser_computer_test.py @@ -8,7 +8,8 @@ chromium with it though supervisord starts X again. Through its cdp address, Playwright and Puppeteer drive it two at once, each on its own session, by http_url and by ws_url; a wrong token is refused, and so is code in the computer without one; one client's close leaves the browser up for the other, which can't open file: URLs; the address outlives a -relaunch by its app server's restart, and the browser's reset and the computer's end it. browser_connect_cdp hands the echo agent its address, with the computer's +relaunch by its app server's restart, and the browser's reset and the computer's end it. Behind a front that ends TLS, as a remote provider's tunnel does, +an address asked for under the origin named is https and wss, as discovery says too, and the clients connect through it. browser_connect_cdp hands the echo agent its address, with the computer's tools hidden from its role; after reset_env that address fails until the task runs browser_connect_cdp again, whose fresh one its next turn opens the site through. Through an app server's restart, which relaunches the browser, and a driver's, the agent's address keeps working, and two agents connected at once each drive the browser through their own. browser/v1 reads @@ -28,9 +29,11 @@ import calendar import itertools import json +import os import platform import re import shlex +import ssl import subprocess import time import urllib.parse @@ -55,6 +58,7 @@ from agent_env.env.envs.computer.utils import inside from agent_env.env.store import get_env_store from agent_env.providers.sandbox_providers.local_docker.provider import LocalSandboxProvider +from agent_env.store.object_store.local.tls import LocalCA, local_ca from agent_env.store.routing import namespace_routing from agent_env.task import Task from agent_env.task.teardown import teardown_run @@ -68,6 +72,7 @@ from tst.integration.computer.browser_state import check_browser_state, check_browser_state_under_chaos from tst.integration.computer.start_tabs import host_pages, tabs_of from tst.util.a2a_test_agent import put_test_agent +from tst.util.tls_front import tls_front pytestmark = [pytest.mark.integration, pytest.mark.int_test_slow] @@ -856,6 +861,36 @@ async def test_playwright_and_puppeteer_drive_it_through_its_cdp_address_which_a assert fresh != ada and not report.failed +@pytest.mark.asyncio +async def test_behind_a_front_that_ends_tls_an_address_under_the_origin_named_is_https_and_wss_and_clients_connect( + browser_computer, cdp_clients): + ca = local_ca() + with namespace_routing(): + deployed = await Env.get(browser_computer.id).deploy( + env_params={"app_settings": {"chromium": {"start_urls": [SITE]}}}) + context = TaskStepContext(deployed_envs=[deployed]) + try: + serving = asyncio.ensure_future(inside.run(deployed, ["python3", "-c", SERVE_SITE, "900"], timeout_s=960)) + await asyncio.sleep(SERVE_START_S) + driver = urllib.parse.urlsplit(deployed.environment_url) + async with tls_front(ca, driver.hostname, driver.port) as front: + named = await _cdp_through(front, deployed, ca, "ada", origin=front) + unnamed = await _cdp_through(front, deployed, ca, "bob") + discovered = await _discovered(named["http_url"], ca) + together = await _clients(cdp_clients, "together", named, ca=ca) + serving.cancel() + finally: + report = await teardown_run(context) + + host = front.removeprefix("https://") + assert named["http_url"].startswith(f"{front}/svc/chromium/cdp/"), named + assert named["ws_url"].startswith(f"wss://{host}/svc/chromium/cdp/") and discovered == named["ws_url"], named + assert unnamed["ws_url"].startswith(f"ws://{host}/svc/chromium/cdp/"), unnamed + for client in ("playwright_http", "puppeteer_ws", "playwright_ws"): + assert together[client].get("ok", {}).get("title") == "Home", together + assert not report.failed + + @pytest.mark.asyncio async def test_browser_connect_cdp_hands_an_agent_its_address_with_the_tools_hidden_which_a_reset_ends_till_it_reruns( browser_computer): @@ -989,17 +1024,41 @@ async def _cdp(deployed, user: str) -> dict: return answer.json() -async def _clients(folder: Path, check: str, address: dict, wrong: bool = True) -> dict: +async def _clients(folder: Path, check: str, address: dict, wrong: bool = True, ca: LocalCA | None = None) -> dict: """What the CDP clients saw doing ``check`` at ``address``; for ``refused``, with its token changed unless not - ``wrong``.""" + ``wrong``; trusting ``ca`` too when given.""" argv = ["node", "connect.mjs", check, address["http_url"], address["ws_url"], SITE] + ([] if wrong else ["exact"]) - process = await asyncio.create_subprocess_exec(*argv, cwd=folder, stdout=asyncio.subprocess.PIPE, + env = {**os.environ, "NODE_EXTRA_CA_CERTS": str(ca.cert_path)} if ca else None + process = await asyncio.create_subprocess_exec(*argv, cwd=folder, env=env, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE) out, err = await asyncio.wait_for(process.communicate(), 300) assert process.returncode == 0, err.decode()[-2000:] return json.loads(out.decode().strip().splitlines()[-1]) +async def _cdp_through(front: str, deployed, ca: LocalCA, user: str, origin: str | None = None) -> dict: + """``user``'s CDP address, asked of the browser through ``front`` as agentenv asks, with the control token, + naming ``origin`` or not.""" + async with httpx.AsyncClient(timeout=120, verify=_trusting(ca)) as http: + card = (await http.get(f"{front}/.well-known/agent-env.json")).json() + app = next(child for child in card["children_environments"] if child["name"] == "chromium") + path = protocol_v1.find_extension_method(app, BROWSER, "cdp")["endpoint"] + answer = await http.post(f"{front}{path}", json={"user": user, **({"origin": origin} if origin else {})}, + headers=deployed.control_headers) + assert answer.status_code == 200, answer.text + return answer.json() + + +async def _discovered(http_url: str, ca: LocalCA) -> str: + """The WebSocket ``json/version`` under ``http_url`` names.""" + async with httpx.AsyncClient(timeout=60, verify=_trusting(ca)) as http: + return (await http.get(f"{http_url}json/version")).json()["webSocketDebuggerUrl"] + + +def _trusting(ca: LocalCA) -> ssl.SSLContext: + return ssl.create_default_context(cafile=str(ca.cert_path)) + + async def _get(deployed, path: str, **kwargs) -> httpx.Response: async with httpx.AsyncClient(timeout=30) as http: return await http.get(f"{deployed.environment_url}{path}", **kwargs) diff --git a/tst/unit/task_step/test_browser_connect_cdp.py b/tst/unit/task_step/test_browser_connect_cdp.py index 7e1616137..d80c3f331 100644 --- a/tst/unit/task_step/test_browser_connect_cdp.py +++ b/tst/unit/task_step/test_browser_connect_cdp.py @@ -1,7 +1,8 @@ -"""browser_connect_cdp asks the env's website browser for the agent's CDP address, adds it to the agent's cdp-config as -the agent's sandbox reaches it, and hides every tool the env serves from the agent's role unless kept; it refuses an -agent that takes no CDP address and an env whose browser it can't tell; and connecting under a name already connected -replaces its record. Only the HTTP boundary is mocked.""" +"""browser_connect_cdp asks the env's website browser for the agent's CDP address under the env's URL as the agent's +sandbox reaches it, or, of a browser that takes no origin, rebases the address it answers onto that; adds it to the +agent's cdp-config and hides every tool the env serves from the agent's role unless kept; it refuses an agent that takes +no CDP address and an env whose browser it can't tell; and connecting under a name already connected replaces its +record. Only the HTTP boundary is mocked.""" from __future__ import annotations @@ -18,6 +19,10 @@ _AGENT = "http://localhost:42000" _BROWSER = {"uri": "urn:agentenv:browser/v1", "params": { "endpoint": "/svc/chromium/agentenv/ext/browser", "methods": {"cdp": {"endpoint": "/svc/chromium/agentenv/ext/browser/cdp"}}}} +#: The browser's extension as one that takes an origin serves it. +_BROWSER_TAKING_ORIGIN = {**_BROWSER, "params": {**_BROWSER["params"], "methods": {"cdp": { + **_BROWSER["params"]["methods"]["cdp"], "request": {"type": "object", "required": ["user"], "properties": { + "user": {"type": "string"}, "origin": {"type": "string", "default": None}}}}}}} _TOOL_ACCESS = {"uri": "urn:agentenv:disable-tool/v1", "params": {"endpoint": "/tools/disable", "methods": {"disable": {"method": "POST"}}}} _CDP_CONFIG = {"uri": "urn:agentenv:cdp-config/v1", "params": {"endpoint": "/ext/cdp-config"}} @@ -46,6 +51,28 @@ async def test_the_agent_gets_the_browsers_address_as_its_sandbox_reaches_it_and "keep_tools": False, "role": "default", "tools_hidden": "*"}] +@pytest.mark.parametrize(("url", "sandbox_type", "origin"), [ + ("https://t.tunnel.test", "modal_vm", "https://t.tunnel.test"), + (_ENV, "local", "http://host.docker.internal:41000")]) +@pytest.mark.asyncio +async def test_a_browser_that_takes_an_origin_mints_the_agents_address_where_its_sandbox_reaches_the_env( + monkeypatch, url, sandbox_type, origin): + sent = _mock(monkeypatch, tokens=["t1"]) + deployed = _env([_app("chromium", _BROWSER_TAKING_ORIGIN)], url=url, sandbox_type=sandbox_type) + context = TaskStepContext(deployed_envs=[deployed], deployed_agents=[_agent()], metadata={}) + + await BrowserConnectCdpTaskStep(id="connect", version=None, env_id="computer", agent_name="solver").execute( + context) + + asked, added, _ = sent + ws = "wss" + origin.removeprefix("https") if origin.startswith("https") else "ws" + origin.removeprefix("http") + assert (str(asked.url), json.loads(asked.content)) == (f"{url}/svc/chromium/agentenv/ext/browser/cdp", + {"user": "solver", "origin": origin}) + assert json.loads(added.content) == {"name": "chromium", "http_url": f"{origin}/svc/chromium/cdp/t1/", + "ws_url": f"{ws}/svc/chromium/cdp/t1/devtools/browser", + "metadata": {"browser": "chromium"}} + + @pytest.mark.asyncio async def test_a_role_kept_tools_a_name_and_a_gateways_address_are_the_tasks(monkeypatch): sent = _mock(monkeypatch, tokens=["t1"], base="https://gateway.test/browser/chromium") @@ -107,16 +134,16 @@ def test_it_round_trips_and_a_blank_name_is_a_config_problem(): "step 'c': name must be a non-empty string"] -def _app(name: str) -> dict: - return {"name": name, "capabilities": {"extensions": [_BROWSER]}} +def _app(name: str, browser: dict = _BROWSER) -> dict: + return {"name": name, "capabilities": {"extensions": [browser]}} -def _env(children: list | None = None) -> DeployedSandboxEnv: +def _env(children: list | None = None, *, url: str = _ENV, sandbox_type: str = "local") -> DeployedSandboxEnv: """A browser computer's deployment: a driver serving a data plane and tool access on its own card, named after its browser app among its children, as a browser computer's driver is.""" return DeployedSandboxEnv( - env_id="computer", env_version=1, sandbox_id="sb", sandbox_type="local", control_token="control", - environment_card_url=f"{_ENV}/.well-known/agent-env.json", + env_id="computer", env_version=1, sandbox_id="sb", sandbox_type=sandbox_type, control_token="control", + environment_card_url=f"{url}/.well-known/agent-env.json", environment_card={"name": "chromium", "capabilities": {"extensions": [_TOOL_ACCESS], "operations": ["reset"]}, "children_environments": [_app("chromium")] if children is None else children}) @@ -127,16 +154,17 @@ def _agent(role: str | None = None, extensions: list | None = None) -> DeployedA def _mock(monkeypatch, *, tokens: list[str], base: str = f"{_ENV}/svc/chromium") -> list[httpx.Request]: - """Answer the browser's cdp with each of ``tokens`` in turn, at ``base``, and anything else as done; return the - requests.""" + """Answer the browser's cdp with each of ``tokens`` in turn, under the origin it names, else at ``base``, and + anything else as done; return the requests.""" sent, real, left = [], httpx.AsyncClient, iter(tokens) def handle(request: httpx.Request) -> httpx.Response: sent.append(request) if request.url.path.endswith("/browser/cdp"): - token = next(left) - ws = "wss" + base.removeprefix("https") if base.startswith("https") else "ws" + base.removeprefix("http") - return httpx.Response(200, json={"http_url": f"{base}/cdp/{token}/", + token, origin = next(left), json.loads(request.content).get("origin") + at = f"{origin}/svc/chromium" if origin else base + ws = "wss" + at.removeprefix("https") if at.startswith("https") else "ws" + at.removeprefix("http") + return httpx.Response(200, json={"http_url": f"{at}/cdp/{token}/", "ws_url": f"{ws}/cdp/{token}/devtools/browser"}) return httpx.Response(200, json={"status": "added"} if request.url.path == "/ext/cdp-config" else {"ok": True}) diff --git a/tst/util/tls_front.py b/tst/util/tls_front.py new file mode 100644 index 000000000..ac21fad02 --- /dev/null +++ b/tst/util/tls_front.py @@ -0,0 +1,49 @@ +"""A front that ends TLS for a server on this machine, as a sandbox provider's tunnel can: it presents a certificate the +local CA signs and passes each connection's bytes on to the server over plain TCP, adding nothing that says how it was +reached, so the server sees the tunnel's host over http.""" +from __future__ import annotations + +import asyncio +import contextlib +from typing import AsyncIterator + +from agent_env.store.object_store.local.tls import LocalCA, server_context + +FRONT_HOST = "127.0.0.1" +CHUNK_BYTES = 64 * 1024 + + +@contextlib.asynccontextmanager +async def tls_front(ca: LocalCA, host: str, port: int) -> AsyncIterator[str]: + """``https://127.0.0.1:``, ending TLS for the server at ``host``:``port`` until the block ends.""" + writers: set[asyncio.StreamWriter] = set() + + async def forward(reader: asyncio.StreamReader, writer: asyncio.StreamWriter) -> None: + writers.add(writer) + try: + upstream_reader, upstream_writer = await asyncio.open_connection(host, port) + except OSError: + writer.close() + return + writers.add(upstream_writer) + await asyncio.gather(_pipe(reader, upstream_writer), _pipe(upstream_reader, writer)) + + server = await asyncio.start_server(forward, FRONT_HOST, 0, ssl=server_context(ca, [FRONT_HOST])) + try: + yield f"https://{FRONT_HOST}:{server.sockets[0].getsockname()[1]}" + finally: + server.close() + for writer in writers: + writer.close() + await server.wait_closed() + + +async def _pipe(reader: asyncio.StreamReader, writer: asyncio.StreamWriter) -> None: + try: + while data := await reader.read(CHUNK_BYTES): + writer.write(data) + await writer.drain() + except (ConnectionError, OSError): + pass + finally: + writer.close() From 34328fa1314729045da439e31ef2b4b363a87803 Mon Sep 17 00:00:00 2001 From: Edgar Arakelyan Date: Tue, 10 Nov 2026 16:39:10 -0800 Subject: [PATCH 2/2] fix(browser): an origin's scheme is read in lower case An origin named HTTPS://... passed the check but its WebSocket form came out as ws://HTTPS://...; the origin is now rebuilt from its parsed parts, scheme in lower case. Co-Authored-By: Claude Opus 5.5 --- .../src/agentenv_protocol/website_browser/environment.py | 7 ++++--- packages/agentenv-protocol/tests/test_browser_cdp.py | 4 ++++ 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/packages/agentenv-protocol/src/agentenv_protocol/website_browser/environment.py b/packages/agentenv-protocol/src/agentenv_protocol/website_browser/environment.py index 972f61557..0a507c32a 100644 --- a/packages/agentenv-protocol/src/agentenv_protocol/website_browser/environment.py +++ b/packages/agentenv-protocol/src/agentenv_protocol/website_browser/environment.py @@ -670,8 +670,9 @@ def _mode() -> str: def _origin(value: Any) -> str: - """``value`` as an origin a CDP address is minted under: an http or https URL, a path allowed, with no query, - fragment or credentials; InvalidArgument for anything else.""" + """``value`` as an origin a CDP address is minted under, its scheme in lower case as the address's WebSocket form + reads it: an http or https URL, a path allowed, with no query, fragment or credentials; InvalidArgument for anything + else.""" try: parts = urlsplit(value) valid = (value.isprintable() and parts.scheme in ("http", "https") and bool(parts.hostname) @@ -681,7 +682,7 @@ def _origin(value: Any) -> str: valid = False if not valid: raise InvalidArgument("origin is an http or https URL with no query, fragment or credentials") - return value + return f"{parts.scheme}://{parts.netloc}{parts.path}" def _parsed(model: type[BaseModel], items: Any, what: str) -> list: diff --git a/packages/agentenv-protocol/tests/test_browser_cdp.py b/packages/agentenv-protocol/tests/test_browser_cdp.py index 8ea183ec1..41493acf4 100644 --- a/packages/agentenv-protocol/tests/test_browser_cdp.py +++ b/packages/agentenv-protocol/tests/test_browser_cdp.py @@ -376,6 +376,8 @@ async def call(method, params=None, *, session_id=None, timeout_s=None): ada = (await http.post("/agentenv/ext/browser/cdp", json={"user": "ada", "origin": "https://t.tunnel.test/"}, headers=BEHIND_TLS)).json() bob = (await http.post("/agentenv/ext/browser/cdp", json={"user": "bob"}, headers=BEHIND_TLS)).json() + cy = (await http.post("/agentenv/ext/browser/cdp", json={"user": "cy", "origin": "HTTPS://t.tunnel.test"}, + headers=BEHIND_TLS)).json() paths = [address["http_url"].split("/svc/chromium", 1)[1] for address in (ada, bob)] seen = [(await http.get(f"{path}json/version", headers=BEHIND_TLS)).json()["webSocketDebuggerUrl"] for path in paths] @@ -385,6 +387,8 @@ async def call(method, params=None, *, session_id=None, timeout_s=None): "ws_url": f"wss://t.tunnel.test/svc/chromium/cdp/{token}/devtools/browser"} assert seen[0] == ada["ws_url"] assert bob["ws_url"].startswith("ws://t.tunnel.test/") and seen[1] == bob["ws_url"] + assert cy["http_url"].startswith("https://t.tunnel.test/svc/chromium/cdp/") + assert cy["ws_url"].startswith("wss://t.tunnel.test/svc/chromium/cdp/") @pytest.mark.asyncio