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
24 changes: 23 additions & 1 deletion packages/agentenv-protocol/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -150,7 +150,8 @@ app without one, so a server that mounted is a server whose tool calls are bound
To answer a call before the tool runs, override `on_tool_call(self, context)`: return `None` to let
it proceed, or an `mcp.types.CallToolResult` (`isError=True` for a refusal) to send that as the
tool's reply. An `@extension` method may declare the same parameter; it is filled from the request's
`AgentEnv-Role` header. Code that acts as a caller outside any dispatch, such as a test calling a
`AgentEnv-Role` header. It may also declare a parameter annotated `starlette.requests.Request`, which
gets the HTTP request itself, its headers say, and stays out of the advertised schema. Code that acts as a caller outside any dispatch, such as a test calling a
handler directly or a seeding script, binds a context it builds:
`with bound(ToolContext.as_caller("alice")): ...`.

Expand Down Expand Up @@ -781,6 +782,11 @@ requests to the app's server, taking the control token:
closes, the other then closed with its code; refused before they open without the token, or with no
such app or one not answering. The `computer` extra brings `websockets` for this.

An app's `agent_routes` (`/cdp/` for a website browser) are path prefixes of its own server that
agents reach this way without the control token: the driver reads them from the app's card
(`computer-app/v1`'s `agent_routes`) and lets a call under one through, while the app checks it
itself. A path with a `.` or `..` segment, escaped or not, never counts as under one.

`MailApp.declaration()` returns what the class declares, an `AppDeclaration`, which a `computer_app`
env stores (`browser: true` for a website browser), and `python -m agentenv_protocol.computer._declare <folder> <file> <class>` prints it as
JSON, or a driver's `DriverDeclaration`, without the class's own dependencies installed.
Expand Down Expand Up @@ -860,6 +866,22 @@ are the profile's template. The base class adds:
- `files/choose {paths}`, files in `~/Uploads` for the file chooser open on it, which headless, and so
alone, it intercepts;
- `page/state`, the active tab's id, URL and title and the dialog or file chooser open on it.
- `cdp {user}`, `user`'s CDP address once the browser is ready, `http_url` to discover from and
`ws_url` to connect to, both at the address the call reached it at with a token agentenv mints in
the path: the same one each time it's asked for. Through the driver they're under
`/svc/<app>/cdp/<token>/`. `user` is a name the caller chooses, an agent's `deploy_agent` step id.
The server keeps each token in memory and only its SHA-256, with its user and base address, in
`.agentenv/cdp-tokens.json`, which code in the computer's session can read but not use; an address
outlives a relaunch of the browser and of the server, and the computer's reset ends it.
- **Its CDP relay**, at those addresses: `json/version` and `json/list` (also with a trailing slash,
and `json`) answered from the browser, since a browser on a pipe has none, with its WebSocket at the
address the client reached it at; and `devtools/browser`, where each WebSocket connection gets a
browser-level session of its own over the pipe (`Target.attachToBrowserTarget`), with its answers
and the events of its sessions and those it attaches, and no one else's. Only flattened sessions are
served, as Playwright, Puppeteer and Browser Use use. A token agentenv didn't mint is refused, and
when the browser goes, each connection is closed with 1012 so its client can connect again.
`record` lists each connection as `cdp_connections`: its user, when it opened and when it closed.
Puppeteer connects by `ws_url`: its `browserURL` drops the address's path.
- **Its skill**: sites by their real names, files to upload in `~/Uploads` and downloads in
`~/Downloads`, folders it makes.

Expand Down
3 changes: 2 additions & 1 deletion packages/agentenv-protocol/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -36,10 +36,11 @@ agent = [
"uvicorn>=0.30",
]
# A computer app's own server: ComputerAppEnvironment serves its card and computer-app/v1 over mcp's FastMCP, on a Unix
# socket through uvicorn.
# socket through uvicorn, and a website browser's CDP relay over WebSockets.
app = [
"mcp>=1.25,<2",
"uvicorn>=0.30",
"websockets>=13",
]
# A computer driver: ComputerDriverEnvironment serves its tools over mcp's FastMCP, crops and encodes frames, records
# the display as H.264 with PyAV (computer-recording/v1), and relays WebSockets to its apps on /svc/<app>/.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@
from pydantic.fields import FieldInfo
from starlette.requests import Request
from starlette.responses import Response
from starlette.routing import Route
from starlette.routing import Route, WebSocketRoute

from .errors import AgentEnvError
from .tool_access import DISABLE as TOOL_DISABLE, ENABLE as TOOL_ENABLE, PATHS as TOOL_ACCESS_PATHS, ToolAccess, caller_role
Expand Down Expand Up @@ -797,6 +797,19 @@ def _is_tool_context_annotation(annotation: Any) -> bool:
return any(t is ToolContext for t in _unwrap_annotation(annotation))


def _is_request_annotation(annotation: Any) -> bool:
if isinstance(annotation, str):
return Request.__name__ in _string_annotation_names(annotation)
return any(t is Request for t in _unwrap_annotation(annotation))


def _request_slot(fn: Callable) -> str | None:
"""Name of an extension handler's parameter annotated ``Request``, which receives the HTTP request, or None."""
hints = _resolved_hints(fn)
return next((name for name, param in inspect.signature(fn).parameters.items() if name not in ("self", "cls")
and _is_request_annotation(hints.get(name, param.annotation))), None)


def _fastmcp_context_type() -> type | None:
"""FastMCP's Context class when mcp is loaded, else None. mcp is an optional dependency, so it is
looked up rather than imported; a Context-annotated handler has necessarily imported it already."""
Expand Down Expand Up @@ -1034,7 +1047,8 @@ def _schema_from_signature(fn: Callable) -> dict:
if name in ("self", "cls") or param.kind in (inspect.Parameter.VAR_POSITIONAL, inspect.Parameter.VAR_KEYWORD):
continue
annotation = hints.get(name, param.annotation)
if _is_tool_context_annotation(annotation) or _is_fastmcp_context_annotation(annotation):
if _is_tool_context_annotation(annotation) or _is_fastmcp_context_annotation(annotation) or \
_is_request_annotation(annotation):
continue
prop = _schema_for_annotation(annotation)
if param.default is inspect.Parameter.empty:
Expand Down Expand Up @@ -1505,12 +1519,12 @@ def _coerce_params(fn: Callable, params: dict) -> dict:

def _extension_handler(fn: Callable, http_method: str) -> Callable:
"""REST handler for an @extension route: bind request params to the handler and return JSON. A
``ToolContext`` parameter is filled from the request headers and never bound
from the params."""
slot = _tool_context_slot(fn)
``ToolContext`` parameter is filled from the request headers, and a ``Request`` one with the request itself;
neither is bound from the params."""
slot, request_slot = _tool_context_slot(fn), _request_slot(fn)
signature = inspect.signature(fn)
if slot:
signature = signature.replace(parameters=[p for p in signature.parameters.values() if p.name != slot])
signature = signature.replace(parameters=[p for p in signature.parameters.values()
if p.name not in (slot, request_slot)])
op = fn.__name__ or "invoke"

async def handler(request: Request) -> Response:
Expand All @@ -1530,7 +1544,8 @@ async def handler(request: Request) -> Response:
except TypeError as e:
return _error_response(AgentEnvError("invalid_params", str(e)))
with bound(context_from_http(request, op, params)) as tool_context:
call_kwargs = {**params, slot: tool_context} if slot else params
call_kwargs = {**params, **({slot: tool_context} if slot else {}),
**({request_slot: request} if request_slot else {})}
try:
result = await _maybe_await(fn(**call_kwargs))
except AgentEnvError as e:
Expand Down Expand Up @@ -1574,6 +1589,16 @@ async def guarded(request: Request) -> Response:
return guard


def add_websocket_route(app: Any, path: str, endpoint: Callable) -> None:
"""``endpoint`` served at ``path`` as a WebSocket route of ``app``, an mcp or a standalone fastmcp FastMCP, which
have no API for one."""
for routes in ("_custom_starlette_routes", "_additional_http_routes"):
if isinstance(getattr(app, routes, None), list):
getattr(app, routes).append(WebSocketRoute(path, endpoint))
return
raise TypeError(f"WebSocket routes go on an mcp or a fastmcp FastMCP, not a {type(app).__name__}")


def _tool_access_handler(access: ToolAccess, action: str) -> Callable:
async def handler(request: Request) -> Response:
raw = await request.body()
Expand Down
241 changes: 241 additions & 0 deletions packages/agentenv-protocol/src/agentenv_protocol/browser_cdp.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,241 @@
"""The CDP addresses a website browser hands agents: a token agentenv mints for each user, of which only a hash is kept
where the computer's session can read it, discovery answered from the browser, and a relay that gives each WebSocket
connection its own browser-level session over the browser's one debugging pipe."""
from __future__ import annotations

import asyncio
import contextlib
import hashlib
import json
import logging
import os
import secrets
from dataclasses import dataclass
from datetime import datetime, timezone
from pathlib import Path
from typing import TYPE_CHECKING, Optional

from starlette.requests import Request
from starlette.websockets import WebSocket, WebSocketDisconnect

if TYPE_CHECKING:
from .website_browser import DevToolsPipe

logger = logging.getLogger(__name__)

#: How many random bytes a token holds.
TOKEN_BYTES = 32
#: The route agents reach the relay under, through the driver's /svc/<app>/.
ROUTE = "/cdp/"
#: The close code and reason a connection gets when its browser goes away, which a client may connect again after.
RELAUNCHED = (1012, "the browser relaunched: connect again")
#: The close code and reason a connection gets when its client falls behind by more than ``OUTBOX_LIMIT_BYTES``.
TOO_SLOW = (1013, "the client fell too far behind: connect again")
#: How much a connection's client may have waiting to be sent to it before it is closed; a single answer bigger than
#: this still goes when nothing else waits.
OUTBOX_LIMIT_BYTES = 64 << 20
#: CDP's own answer to a command for a session the connection doesn't have.
NO_SESSION = {"code": -32001, "message": "Session with given id not found."}


@dataclass
class Address:
"""A user's CDP address: ``http_url``, the base clients discover from, and ``ws_url``, the browser's WebSocket."""

user: str
http_url: str
ws_url: str


def reached(request: Request) -> str:
"""The address a request reached the browser at, scheme, host and path prefix: what the driver forwarded, else the
request's own."""
proto = request.headers.get("x-forwarded-proto") or request.url.scheme
host = request.headers.get("x-forwarded-host") or request.headers.get("host") or request.url.netloc
return f"{proto}://{host}{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://")


def _hash(token: str) -> str:
return hashlib.sha256(token.encode()).hexdigest()


def _address(user: str, base: str, token: str) -> Address:
root = f"{base}{ROUTE}{token}"
return Address(user, f"{root}/", f"{websocket_form(root)}/devtools/browser")


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."""

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()}
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."""
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._save()
return self._minted[user]

def address(self, token: str) -> Optional[Address]:
"""The address ``token`` was minted for, or None for one that wasn't."""
known = self._known.get(_hash(token))
return _address(*known, token) if known is not None else None

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()}))
os.replace(part, self.path)


class _Connection:
"""One client's WebSocket, its sessions over the pipe (its browser-level one and those it attached), its unanswered
commands, and what waits to be sent to it."""

def __init__(self, websocket: WebSocket, user: str, session: str) -> None:
self.websocket, self.user, self.session = websocket, user, session
self.sessions = {session}
self.commands: set[asyncio.Future] = set()
self.outbox: asyncio.Queue[str | tuple[int, str]] = asyncio.Queue()
self.waiting_bytes = 0
self.closing = False


class Relay:
"""Each WebSocket connection given its own browser-level session over ``devtools``, its commands sent on it and the
answers and events of its sessions sent back, so several clients share one browser; and a record of who connected
when."""

def __init__(self, devtools: DevToolsPipe) -> None:
self.devtools = devtools
self.connections: list[dict] = []
self._by_session: dict[str, _Connection] = {}
self._open: set[_Connection] = set()
devtools.listen(self._event)

async def serve(self, websocket: WebSocket, user: str) -> None:
"""``websocket``, a client of ``user``'s, relayed over a browser-level session of its own until either ends."""
try:
session = (await self.devtools.call("Target.attachToBrowserTarget"))["sessionId"]
except Exception as e:
await websocket.close(code=1013, reason=f"the browser isn't answering: {type(e).__name__}")
return
connection = _Connection(websocket, user, session)
self._by_session[session] = connection
self._open.add(connection)
record = {"user": user, "opened_at": _now()}
self.connections.append(record)
await websocket.accept()
sending = asyncio.ensure_future(self._send(connection))
try:
while True:
message = await websocket.receive()
if message["type"] == "websocket.disconnect":
break
text = message.get("text") if message.get("text") is not None else message["bytes"].decode()
command = asyncio.ensure_future(self._command(connection, json.loads(text)))
connection.commands.add(command)
command.add_done_callback(connection.commands.discard)
except (WebSocketDisconnect, ValueError):
pass
finally:
record["closed_at"] = _now()
connection.closing = True
self._end(connection)
for task in [*connection.commands, sending]:
task.cancel()
await asyncio.gather(*connection.commands, sending, return_exceptions=True)
with contextlib.suppress(Exception):
await self.devtools.call("Target.detachFromTarget", {"sessionId": session})

def ended(self) -> None:
"""The browser has gone: every connection is closed with a reason a client may connect again after."""
for connection in list(self._open):
self._close(connection, RELAUNCHED)

async def _command(self, connection: _Connection, message: dict) -> None:
session = message.get("sessionId") or connection.session
if session not in connection.sessions:
answer = {"error": NO_SESSION}
else:
try:
answer = await self.devtools.request(message.get("method", ""), message.get("params") or {},
session_id=session)
except ConnectionError as e:
answer = {"error": {"code": -32000, "message": str(e)}}
reply = {"id": message.get("id"), **{key: answer[key] for key in ("result", "error") if key in answer}}
if message.get("sessionId"):
reply["sessionId"] = message["sessionId"]
self._put(connection, reply)

def _event(self, message: dict) -> None:
connection = self._by_session.get(message.get("sessionId"))
if connection is None:
return
params = message.get("params") or {}
if message.get("method") == "Target.attachedToTarget" and params.get("sessionId"):
connection.sessions.add(params["sessionId"])
self._by_session[params["sessionId"]] = connection
elif message.get("method") == "Target.detachedFromTarget" and params.get("sessionId"):
connection.sessions.discard(params["sessionId"])
self._by_session.pop(params["sessionId"], None)
if message["sessionId"] == connection.session:
message = {key: value for key, value in message.items() if key != "sessionId"}
self._put(connection, message)

def _put(self, connection: _Connection, message: dict) -> None:
"""``message`` waiting to be sent to ``connection``'s client, which is closed instead, with what waited dropped,
once it has fallen too far behind."""
if connection.closing:
return
text = json.dumps(message)
if connection.waiting_bytes and connection.waiting_bytes + len(text) > OUTBOX_LIMIT_BYTES:
self._close(connection, TOO_SLOW)
return
connection.waiting_bytes += len(text)
connection.outbox.put_nowait(text)

def _close(self, connection: _Connection, close: tuple[int, str]) -> None:
"""``connection`` closed with ``close`` once what's being sent to it has gone, and nothing else sent."""
connection.closing = True
self._end(connection)
while not connection.outbox.empty():
connection.outbox.get_nowait()
connection.waiting_bytes = 0
connection.outbox.put_nowait(close)

def _end(self, connection: _Connection) -> None:
self._open.discard(connection)
for session in connection.sessions:
self._by_session.pop(session, None)

async def _send(self, connection: _Connection) -> None:
with contextlib.suppress(WebSocketDisconnect, RuntimeError):
while True:
message = await connection.outbox.get()
if isinstance(message, tuple):
await connection.websocket.close(code=message[0], reason=message[1])
return
connection.waiting_bytes -= len(message)
await connection.websocket.send_text(message)


def _now() -> str:
return datetime.now(timezone.utc).isoformat(timespec="seconds").replace("+00:00", "Z")
Loading
Loading