Files
aiturk-hermes-ide/tests/gateway/test_browser_control_api.py
yilsemandClaude Opus 5.5 86a6621936
Server CI and Windows package / verify (push) Successful in 3m33s
Server CI and Windows package / windows-package (push) Successful in 7m36s
feat(chrome): let the IDE agent use the user's Chrome and beta.42
The AITURK Chrome extension now registers the user's Chrome as the desktop
backend's browser controller, the way the ChatGPT extension lets its agent
work in the user's browser. The agent's existing browser tools (navigate,
snapshot, click, type, scroll, back, press, get_images, vision) run in its
own background tabs grouped as "AITURK", or a tab the user hands over.

Backend: a local desktop lane in the browser-control broker. One controller
registered from the loopback desktop transport is offered to every desktop
session as a lane template; each session still gets its own exact scope on
first use. The lane has its own switch (browser.extension_control.local_desktop,
on by default); with no extension registered, or after it disconnects, the
normal browser backend runs unchanged. browser_vision turns the extension's
capture into the usual native/auxiliary vision result.

Bridge: forwards controller commands only to a connection that registered,
refuses unknown or oversized commands, relays results.

Extension: service-worker controller with its own native connection,
trusted chrome.debugger input (press/release only: a hover move waits 5 s on
background tabs), refusal of password and payment-card fields and of
browser, extension, file and Web Store pages, panel switch, Stop, tab
hand-over, recent actions, Alt+Shift+A and right-click "send to AITURK".
Cancelling Chrome's debugging bar stops the agent. Existing panel flows and
privacy rules are unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 08:38:52 +03:00

723 lines
26 KiB
Python

import asyncio
import concurrent.futures
import time
import pytest
from aiohttp import WSServerHandshakeError, web
from aiohttp.test_utils import TestClient, TestServer
from gateway.browser_control_broker import (
ControllerCancelled,
ControllerRejected,
ControllerScope,
)
from gateway.config import PlatformConfig
from gateway.platforms.api_server import (
APIServerAdapter,
_browser_controller_ws_sender,
)
from tools.browser_extension_router import route_browser_tool
API_KEY = "-".join(("fixture", "neutral", "api", "key", "123"))
CONTROL_PROTOCOL = "hermes-browser-control-v1"
REAL_BROWSER_CAPABILITIES = {
"browser_back",
"browser_click",
"browser_get_images",
"browser_navigate",
"browser_press",
"browser_screenshot",
"browser_scroll",
"browser_snapshot",
"browser_tab_activate",
"browser_tabs",
"browser_type",
"browser_vision",
}
class _SessionDB:
def __init__(self):
self.sessions = {
"session-fixture": {"id": "session-fixture", "source": "api_server"},
"remote-session-fixture": {
"id": "remote-session-fixture",
"source": "api_server",
},
}
def get_session(self, session_id):
return self.sessions.get(session_id)
def _ticket_protocol(ticket):
return f"hermes-browser-control-ticket.{ticket}"
def _adapter(*, key=API_KEY):
adapter = APIServerAdapter(
PlatformConfig(enabled=True, extra={"key": key} if key else {})
)
adapter._session_db = _SessionDB()
return adapter
def _app(adapter):
app = web.Application()
app.router.add_get("/v1/capabilities", adapter._handle_capabilities)
app.router.add_post(
"/v1/browser-control/register", adapter._handle_browser_control_register
)
app.router.add_get(
"/v1/browser-control/ws", adapter._handle_browser_control_ws
)
return app
def _registration_body(**overrides):
payload = {
"protocol_version": 1,
"controller_id": "controller-fixture",
"browser_profile_id": "browser-profile-fixture",
"session_id": "session-fixture",
"capabilities": ["controller.noop", "browser_navigate"],
"principal_id": "spoofed-client-principal",
"product": {
"id": "chromium",
"engine": "chromium",
"label": "Chromium browser",
},
}
payload.update(overrides)
return payload
@pytest.mark.asyncio
async def test_registration_grants_only_the_exact_real_action_allowlist(monkeypatch):
adapter = _adapter()
monkeypatch.setattr(adapter, "_browser_control_enabled", lambda: True)
requested = [
"controller.noop",
*sorted(REAL_BROWSER_CAPABILITIES),
"browser_cdp",
"browser_evaluate",
"browser_upload",
"arbitrary.capability",
]
async with TestClient(TestServer(_app(adapter))) as client:
response = await client.post(
"/v1/browser-control/register",
json=_registration_body(capabilities=requested),
headers={"Authorization": f"Bearer {API_KEY}"},
)
assert response.status == 201
registration = await response.json()
assert set(registration["scope"]["capabilities"]) == {
"controller.noop",
*REAL_BROWSER_CAPABILITIES,
}
@pytest.mark.asyncio
@pytest.mark.parametrize(
("overrides", "code"),
[
({"protocol_version": 2}, "browser_control_protocol_unsupported"),
({"protocol_version": True}, "browser_control_protocol_unsupported"),
({"capabilities": []}, "browser_control_no_capabilities"),
(
{"capabilities": ["browser_cdp", "arbitrary.capability"]},
"browser_control_no_capabilities",
),
],
)
async def test_registration_rejects_unsupported_protocol_or_empty_capability_intersection(
monkeypatch, overrides, code
):
adapter = _adapter()
monkeypatch.setattr(adapter, "_browser_control_enabled", lambda: True)
async with TestClient(TestServer(_app(adapter))) as client:
response = await client.post(
"/v1/browser-control/register",
json=_registration_body(**overrides),
headers={"Authorization": f"Bearer {API_KEY}"},
)
body = await response.json()
assert response.status == 400
assert body["error"]["code"] == code
def test_route_table_advertises_registration_and_controller_ws_without_replacing_existing_routes():
adapter = _adapter()
routes = {(method, path) for method, path, _handler in adapter._http_route_table()}
assert ("POST", "/v1/browser-control/register") in routes
assert ("GET", "/v1/browser-control/ws") in routes
assert ("POST", "/v1/chat/completions") in routes
def test_ws_sender_treats_wait_timeout_as_in_flight_and_real_error_as_failure(monkeypatch):
class WS:
closed = False
async def send_json(self, _frame):
return None
class Future:
def __init__(self, error, *, done=False):
self.error = error
self._done = done
self.callbacks = []
def result(self, timeout=None):
if self.error is not None:
raise self.error
return None
def add_done_callback(self, callback):
self.callbacks.append(callback)
def done(self):
return self._done
timeout_future = Future(concurrent.futures.TimeoutError())
def return_timeout(coro, _loop):
coro.close()
return timeout_future
monkeypatch.setattr(asyncio, "run_coroutine_threadsafe", return_timeout)
sender = _browser_controller_ws_sender(WS(), object(), wait_timeout=0.01)
sender({"method": "browser.controller.command"})
assert len(timeout_future.callbacks) == 1
error_future = Future(ConnectionError("socket write failed"))
def return_error(coro, _loop):
coro.close()
return error_future
monkeypatch.setattr(asyncio, "run_coroutine_threadsafe", return_error)
sender = _browser_controller_ws_sender(WS(), object(), wait_timeout=0.01)
with pytest.raises(ConnectionError, match="socket write failed"):
sender({"method": "browser.controller.command"})
completed_timeout = Future(concurrent.futures.TimeoutError(), done=True)
def return_completed_timeout(coro, _loop):
coro.close()
return completed_timeout
monkeypatch.setattr(
asyncio,
"run_coroutine_threadsafe",
return_completed_timeout,
)
sender = _browser_controller_ws_sender(WS(), object(), wait_timeout=0.01)
with pytest.raises(concurrent.futures.TimeoutError):
sender({"method": "browser.controller.command"})
def test_api_agent_context_binds_server_principal_and_transport_family():
from gateway.session_context import clear_session_vars, get_session_env
adapter = _adapter()
tokens = adapter._bind_api_server_session(
session_id="session-fixture",
browser_control_principal="principal-fixture",
browser_control_transport_family="local-api",
)
try:
assert (
get_session_env("HERMES_BROWSER_CONTROL_PRINCIPAL")
== "principal-fixture"
)
assert (
get_session_env("HERMES_BROWSER_CONTROL_TRANSPORT_FAMILY")
== "local-api"
)
finally:
clear_session_vars(tokens)
@pytest.mark.asyncio
async def test_capabilities_are_truthful_and_disabled_by_default(monkeypatch):
adapter = _adapter()
monkeypatch.setattr(adapter, "_browser_control_enabled", lambda: False)
async with TestClient(TestServer(_app(adapter))) as client:
response = await client.get(
"/v1/capabilities", headers={"Authorization": f"Bearer {API_KEY}"}
)
assert response.status == 200
data = await response.json()
control = data["features"]["browser_extension_control"]
assert control["enabled"] is False
assert control["protocol_version"] == 1
assert set(control["capabilities"]) == {"controller.noop", *REAL_BROWSER_CAPABILITIES}
assert control["real_browser_actions"] is True
assert control["transports"] == {
"local_vps": "websocket-subprotocol-ticket",
"cloud": "authenticated-gateway-rpc",
}
assert data["endpoints"]["browser_control_register"] == {
"method": "POST",
"path": "/v1/browser-control/register",
}
assert data["endpoints"]["browser_control_ws"] == {
"method": "GET",
"path": "/v1/browser-control/ws",
}
@pytest.mark.asyncio
async def test_api_middleware_stamps_server_control_identity_for_agent_entry():
from gateway.platforms.api_server import (
_api_request_browser_control_principal,
_api_request_browser_control_transport_family,
)
adapter = _adapter()
async def inspect(_request):
return web.json_response(
{
"principal": _api_request_browser_control_principal.get(),
"transport_family": (
_api_request_browser_control_transport_family.get()
),
}
)
app = web.Application(middlewares=[adapter._make_profile_prefix_middleware()])
app.router.add_get("/inspect", inspect)
async with TestClient(TestServer(app)) as client:
response = await client.get("/inspect")
body = await response.json()
assert body == {
"principal": adapter._derive_browser_control_principal("default"),
"transport_family": "local-api",
}
@pytest.mark.asyncio
async def test_registration_requires_enabled_feature_and_configured_bearer_auth(monkeypatch):
disabled = _adapter()
monkeypatch.setattr(disabled, "_browser_control_enabled", lambda: False)
async with TestClient(TestServer(_app(disabled))) as client:
response = await client.post(
"/v1/browser-control/register",
json=_registration_body(),
headers={"Authorization": f"Bearer {API_KEY}"},
)
assert response.status == 404
unkeyed = _adapter(key="")
monkeypatch.setattr(unkeyed, "_browser_control_enabled", lambda: True)
async with TestClient(TestServer(_app(unkeyed))) as client:
response = await client.post(
"/v1/browser-control/register", json=_registration_body()
)
assert response.status == 403
assert (await response.json())["error"]["code"] == "browser_control_auth_required"
keyed = _adapter()
monkeypatch.setattr(keyed, "_browser_control_enabled", lambda: True)
async with TestClient(TestServer(_app(keyed))) as client:
response = await client.post(
"/v1/browser-control/register", json=_registration_body()
)
assert response.status == 401
response = await client.post(
"/v1/browser-control/register",
json=_registration_body(session_id=""),
headers={"Authorization": f"Bearer {API_KEY}"},
)
assert response.status == 400
response = await client.post(
"/v1/browser-control/register",
json=_registration_body(session_id="not-a-server-session"),
headers={"Authorization": f"Bearer {API_KEY}"},
)
assert response.status == 403
assert (await response.json())["error"]["code"] == (
"browser_control_session_forbidden"
)
@pytest.mark.asyncio
async def test_controller_ws_rechecks_feature_flag_before_consuming_ticket(monkeypatch):
adapter = _adapter()
monkeypatch.setattr(adapter, "_browser_control_enabled", lambda: True)
async with TestClient(TestServer(_app(adapter))) as client:
response = await client.post(
"/v1/browser-control/register",
json=_registration_body(),
headers={"Authorization": f"Bearer {API_KEY}"},
)
ticket = (await response.json())["ticket"]
monkeypatch.setattr(adapter, "_browser_control_enabled", lambda: False)
with pytest.raises(WSServerHandshakeError) as disabled:
await client.ws_connect(
"/v1/browser-control/ws",
protocols=[CONTROL_PROTOCOL, _ticket_protocol(ticket)],
)
assert disabled.value.status == 404
# Neither a missing protocol nor the legacy query-string shape may
# consume the one-shot credential.
monkeypatch.setattr(adapter, "_browser_control_enabled", lambda: True)
with pytest.raises(WSServerHandshakeError) as query_ticket:
await client.ws_connect(f"/v1/browser-control/ws?ticket={ticket}")
assert query_ticket.value.status == 401
ws = await client.ws_connect(
"/v1/browser-control/ws",
protocols=[CONTROL_PROTOCOL, _ticket_protocol(ticket)],
)
await ws.close()
@pytest.mark.asyncio
async def test_local_api_ticket_ws_noop_round_trip_filters_spoofed_identity_and_disabled_actions(monkeypatch):
adapter = _adapter()
monkeypatch.setattr(adapter, "_browser_control_enabled", lambda: True)
async with TestClient(TestServer(_app(adapter))) as client:
response = await client.post(
"/v1/browser-control/register",
json=_registration_body(),
headers={"Authorization": f"Bearer {API_KEY}"},
)
assert response.status == 201
registration = await response.json()
assert registration["protocol_version"] == 1
assert registration["ticket"]
assert registration["ticket_expires_at"] > time.time()
assert 0 < registration["ticket_expires_in_seconds"] <= 30
assert registration["ws_path"] == "/v1/browser-control/ws"
assert registration["scope"]["principal_id"] != "spoofed-client-principal"
assert registration["scope"]["transport_family"] == "local-api"
assert set(registration["scope"]["capabilities"]) == {
"controller.noop",
"browser_navigate",
}
ws = await client.ws_connect(
"/v1/browser-control/ws",
protocols=[CONTROL_PROTOCOL, _ticket_protocol(registration["ticket"])],
)
await ws.send_json(
{
"method": "browser.controller.heartbeat",
"params": {"nonce": "heartbeat-api-fixture"},
}
)
heartbeat = await ws.receive_json(timeout=2.0)
assert heartbeat == {
"method": "browser.controller.heartbeat",
"params": {"nonce": "heartbeat-api-fixture", "ok": True},
}
scope = ControllerScope(
principal_id=registration["scope"]["principal_id"],
profile_id=registration["scope"]["profile_id"],
session_id=registration["scope"]["session_id"],
controller_id=registration["scope"]["controller_id"],
browser_profile_id=registration["scope"]["browser_profile_id"],
transport_family=registration["scope"]["transport_family"],
capabilities=frozenset(registration["scope"]["capabilities"]),
)
pending = asyncio.create_task(
asyncio.to_thread(
adapter._browser_control_broker.dispatch,
scope,
action="controller.noop",
arguments={"echo": "local-api"},
tool_call_id="tool-call-fixture",
)
)
command = await ws.receive_json(timeout=2.0)
assert command["method"] == "browser.controller.command"
assert command["params"]["action"] == "controller.noop"
await ws.send_json(
{
"method": "browser.controller.result",
"params": {
"command_id": command["params"]["command_id"],
"ok": True,
"result": {"echo": "local-api"},
},
}
)
assert await asyncio.wait_for(pending, timeout=2.0) == {"echo": "local-api"}
rejected = asyncio.create_task(
asyncio.to_thread(
adapter._browser_control_broker.dispatch,
scope,
action="controller.noop",
arguments={"echo": "reject"},
tool_call_id="tool-call-rejected",
)
)
rejected_command = await ws.receive_json(timeout=2.0)
await ws.send_json(
{
"method": "browser.controller.result",
"params": {
"command_id": rejected_command["params"]["command_id"],
"ok": "false",
"error": {"code": "controller_rejected", "message": "fixture rejection"},
},
}
)
with pytest.raises(ControllerRejected, match="controller_rejected"):
await asyncio.wait_for(rejected, timeout=2.0)
await ws.close()
with pytest.raises(WSServerHandshakeError) as replay:
await client.ws_connect(
"/v1/browser-control/ws",
protocols=[CONTROL_PROTOCOL, _ticket_protocol(registration["ticket"])],
)
assert replay.value.status == 401
@pytest.mark.asyncio
async def test_real_browser_action_routes_through_controller_without_legacy_fallback(monkeypatch):
adapter = _adapter()
monkeypatch.setattr(adapter, "_browser_control_enabled", lambda: True)
async with TestClient(TestServer(_app(adapter))) as client:
response = await client.post(
"/v1/browser-control/register",
json=_registration_body(capabilities=["browser_snapshot"]),
headers={"Authorization": f"Bearer {API_KEY}"},
)
assert response.status == 201
registration = await response.json()
ws = await client.ws_connect(
"/v1/browser-control/ws",
protocols=[CONTROL_PROTOCOL, _ticket_protocol(registration["ticket"])],
)
legacy_calls = []
pending = asyncio.create_task(
asyncio.to_thread(
route_browser_tool,
"browser_snapshot",
{"include": "accessibility"},
fallback=lambda: legacy_calls.append(True) or "legacy-result",
broker=adapter._browser_control_broker,
enabled=True,
session_id="session-fixture",
principal_id=registration["scope"]["principal_id"],
transport_family="local-api",
tool_call_id="tool-call-real-action",
)
)
command = await ws.receive_json(timeout=2.0)
assert command["method"] == "browser.controller.command"
assert command["params"]["action"] == "browser_snapshot"
assert command["params"]["arguments"] == {"include": "accessibility"}
await ws.send_json(
{
"method": "browser.controller.result",
"params": {
"command_id": command["params"]["command_id"],
"ok": True,
"result": {
"title": "Example Domain",
"url": "https://example.test/",
"refs": [],
},
},
}
)
assert await asyncio.wait_for(pending, timeout=2.0) == (
'{"title": "Example Domain", "url": "https://example.test/", "refs": []}'
)
assert legacy_calls == []
await ws.close()
@pytest.mark.asyncio
async def test_local_api_same_identity_reconnect_completes_command_started_on_old_socket(monkeypatch):
adapter = _adapter()
monkeypatch.setattr(adapter, "_browser_control_enabled", lambda: True)
async with TestClient(TestServer(_app(adapter))) as client:
first_response = await client.post(
"/v1/browser-control/register",
json=_registration_body(capabilities=["browser_snapshot"]),
headers={"Authorization": f"Bearer {API_KEY}"},
)
first = await first_response.json()
first_ws = await client.ws_connect(
"/v1/browser-control/ws",
protocols=[CONTROL_PROTOCOL, _ticket_protocol(first["ticket"])],
)
pending = asyncio.create_task(
asyncio.to_thread(
route_browser_tool,
"browser_snapshot",
{},
fallback=lambda: "legacy-result",
broker=adapter._browser_control_broker,
enabled=True,
session_id="session-fixture",
principal_id=first["scope"]["principal_id"],
transport_family="local-api",
tool_call_id="tool-call-reconnect",
)
)
command = await first_ws.receive_json(timeout=2.0)
await first_ws.close()
await asyncio.sleep(0)
assert not pending.done()
second_response = await client.post(
"/v1/browser-control/register",
json=_registration_body(capabilities=["browser_snapshot"]),
headers={"Authorization": f"Bearer {API_KEY}"},
)
second = await second_response.json()
second_ws = await client.ws_connect(
"/v1/browser-control/ws",
protocols=[CONTROL_PROTOCOL, _ticket_protocol(second["ticket"])],
)
await second_ws.send_json(
{
"method": "browser.controller.result",
"params": {
"command_id": command["params"]["command_id"],
"ok": True,
"result": {"reconnected": True},
},
}
)
assert await asyncio.wait_for(pending, timeout=2.0) == '{"reconnected": true}'
await second_ws.close()
@pytest.mark.asyncio
async def test_local_api_explicit_detach_is_hard_and_stale_socket_cannot_detach_refresh(monkeypatch):
adapter = _adapter()
monkeypatch.setattr(adapter, "_browser_control_enabled", lambda: True)
async with TestClient(TestServer(_app(adapter))) as client:
first_response = await client.post(
"/v1/browser-control/register",
json=_registration_body(capabilities=["controller.noop"]),
headers={"Authorization": f"Bearer {API_KEY}"},
)
first = await first_response.json()
first_ws = await client.ws_connect(
"/v1/browser-control/ws",
protocols=[CONTROL_PROTOCOL, _ticket_protocol(first["ticket"])],
)
second_response = await client.post(
"/v1/browser-control/register",
json=_registration_body(capabilities=["controller.noop"]),
headers={"Authorization": f"Bearer {API_KEY}"},
)
second = await second_response.json()
second_ws = await client.ws_connect(
"/v1/browser-control/ws",
protocols=[CONTROL_PROTOCOL, _ticket_protocol(second["ticket"])],
)
await first_ws.send_json(
{"method": "browser.controller.detach", "params": {}}
)
with pytest.raises(asyncio.TimeoutError):
await first_ws.receive_json(timeout=0.05)
pending = asyncio.create_task(
asyncio.to_thread(
adapter._browser_control_broker.dispatch,
ControllerScope(
principal_id=second["scope"]["principal_id"],
profile_id=second["scope"]["profile_id"],
session_id=second["scope"]["session_id"],
controller_id=second["scope"]["controller_id"],
browser_profile_id=second["scope"]["browser_profile_id"],
transport_family=second["scope"]["transport_family"],
capabilities=frozenset(second["scope"]["capabilities"]),
),
action="controller.noop",
tool_call_id="tool-call-explicit-detach",
)
)
command = await second_ws.receive_json(timeout=2.0)
await second_ws.send_json(
{"method": "browser.controller.detach", "params": {}}
)
detached = await second_ws.receive_json(timeout=2.0)
assert detached == {
"method": "browser.controller.detach",
"params": {"ok": True},
}
with pytest.raises(ControllerCancelled):
await asyncio.wait_for(pending, timeout=2.0)
assert command["method"] == "browser.controller.command"
await first_ws.close()
await second_ws.close()
@pytest.mark.asyncio
async def test_remote_api_uses_the_same_authenticated_noop_round_trip(monkeypatch):
adapter = _adapter()
monkeypatch.setattr(adapter, "_browser_control_enabled", lambda: True)
monkeypatch.setattr(
adapter,
"_browser_control_transport_family",
lambda request: "remote-api",
)
async with TestClient(TestServer(_app(adapter))) as client:
response = await client.post(
"/v1/browser-control/register",
json=_registration_body(session_id="remote-session-fixture"),
headers={"Authorization": f"Bearer {API_KEY}"},
)
registration = await response.json()
assert response.status == 201
assert registration["scope"]["transport_family"] == "remote-api"
ws = await client.ws_connect(
"/v1/browser-control/ws",
protocols=[CONTROL_PROTOCOL, _ticket_protocol(registration["ticket"])],
)
scope = ControllerScope(
principal_id=registration["scope"]["principal_id"],
profile_id=registration["scope"]["profile_id"],
session_id=registration["scope"]["session_id"],
controller_id=registration["scope"]["controller_id"],
browser_profile_id=registration["scope"]["browser_profile_id"],
transport_family="remote-api",
capabilities=frozenset(registration["scope"]["capabilities"]),
)
pending = asyncio.create_task(
asyncio.to_thread(
adapter._browser_control_broker.dispatch,
scope,
action="controller.noop",
arguments={"family": "remote-api"},
tool_call_id="tool-call-remote",
)
)
command = await ws.receive_json(timeout=2.0)
await ws.send_json(
{
"method": "browser.controller.result",
"params": {
"command_id": command["params"]["command_id"],
"ok": True,
"result": {"family": "remote-api"},
},
}
)
assert await asyncio.wait_for(pending, timeout=2.0) == {
"family": "remote-api"
}
await ws.close()