Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 22 additions & 12 deletions packages/agentenv-protocol/src/agentenv_protocol/browser_cdp.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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://")
Expand All @@ -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()}
Comment thread
greptile-apps[bot] marked this conversation as resolved.
Resolved
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]

Expand All @@ -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)


Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 "
Expand All @@ -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:
Expand All @@ -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")
Expand All @@ -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)
Expand Down Expand Up @@ -664,6 +669,22 @@ 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, 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)
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 f"{parts.scheme}://{parts.netloc}{parts.path}"


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."""
Expand Down
59 changes: 56 additions & 3 deletions packages/agentenv-protocol/tests/test_browser_cdp.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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"]}])
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -349,4 +354,52 @@ 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()
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]

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"]
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
@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()
29 changes: 21 additions & 8 deletions src/agent_env/task_step/task_steps/browser/connect_cdp.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 "
Expand All @@ -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,
Expand Down Expand Up @@ -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


Expand Down
Loading
Loading