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>
723 lines
26 KiB
Python
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()
|