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>
346 lines
14 KiB
Python
346 lines
14 KiB
Python
"""Local desktop browser lane: the AITURK Chrome extension behind the IDE.
|
||
|
||
One controller registered from the loopback desktop transport serves every
|
||
desktop session of the backend; each session still gets an exact scope.
|
||
"""
|
||
|
||
import base64
|
||
import json
|
||
import threading
|
||
from types import SimpleNamespace
|
||
|
||
import pytest
|
||
|
||
from gateway.browser_control_broker import (
|
||
LOCAL_DESKTOP_PRINCIPAL,
|
||
LOCAL_DESKTOP_TRANSPORT_FAMILY,
|
||
BrowserControlBroker,
|
||
ControllerCancelled,
|
||
ControllerScope,
|
||
get_browser_control_broker,
|
||
)
|
||
from hermes_cli import web_server
|
||
from tools.browser_extension_router import route_browser_tool
|
||
from tui_gateway import server
|
||
|
||
CAPS = frozenset({"browser_navigate", "browser_snapshot", "browser_vision"})
|
||
|
||
|
||
def _template(controller_id="chrome-1", capabilities=CAPS):
|
||
return ControllerScope(
|
||
principal_id=LOCAL_DESKTOP_PRINCIPAL,
|
||
profile_id="default",
|
||
session_id=None,
|
||
controller_id=controller_id,
|
||
browser_profile_id="chrome-profile",
|
||
transport_family=LOCAL_DESKTOP_TRANSPORT_FAMILY,
|
||
capabilities=capabilities,
|
||
)
|
||
|
||
|
||
class _Writer:
|
||
def __init__(self):
|
||
self.frames = []
|
||
self.sent = threading.Event()
|
||
|
||
def send_for(self, session_id):
|
||
def send(frame):
|
||
self.frames.append((session_id, frame))
|
||
self.sent.set()
|
||
|
||
return send
|
||
|
||
|
||
def _lookup(broker, session_id):
|
||
return broker.scope_for_session(
|
||
session_id=session_id,
|
||
principal_id=LOCAL_DESKTOP_PRINCIPAL,
|
||
transport_family=LOCAL_DESKTOP_TRANSPORT_FAMILY,
|
||
)
|
||
|
||
|
||
def _dispatch_async(broker, scope, action="browser_navigate"):
|
||
outcome = {}
|
||
|
||
def run():
|
||
try:
|
||
outcome["result"] = broker.dispatch(
|
||
scope, action=action, arguments={"url": "https://example.com"}, tool_call_id="call-1"
|
||
)
|
||
except Exception as exc: # noqa: BLE001 - asserted by the tests
|
||
outcome["error"] = exc
|
||
|
||
thread = threading.Thread(target=run)
|
||
thread.start()
|
||
return thread, outcome
|
||
|
||
|
||
def test_lane_template_serves_every_session_with_its_own_exact_scope():
|
||
broker = BrowserControlBroker(command_timeout=5)
|
||
writer = _Writer()
|
||
owner = object()
|
||
assert broker.lane_registered(
|
||
session_id="a", principal_id=LOCAL_DESKTOP_PRINCIPAL, transport_family=LOCAL_DESKTOP_TRANSPORT_FAMILY
|
||
) is False
|
||
broker.attach_lane_template(_template(), writer.send_for, owner=owner)
|
||
|
||
first, second = _lookup(broker, "session-a"), _lookup(broker, "session-b")
|
||
assert first.session_id == "session-a" and second.session_id == "session-b"
|
||
assert _lookup(broker, "session-a") == first
|
||
assert broker.select(first, "browser_navigate") is not None
|
||
assert broker.select(first, "browser_click") is None
|
||
assert broker.lane_registered(
|
||
session_id="anything", principal_id=LOCAL_DESKTOP_PRINCIPAL, transport_family=LOCAL_DESKTOP_TRANSPORT_FAMILY
|
||
)
|
||
|
||
thread, outcome = _dispatch_async(broker, second)
|
||
assert writer.sent.wait(2)
|
||
session_id, frame = writer.frames[-1]
|
||
assert session_id == "session-b"
|
||
command_id = frame["params"]["command_id"]
|
||
assert broker.complete_for_owner(command_id, owner=object(), ok=True, result="x") is False
|
||
assert broker.complete_for_owner(command_id, owner=owner, ok=True, result={"success": True}) is True
|
||
thread.join(2)
|
||
assert outcome == {"result": {"success": True}}
|
||
|
||
|
||
def test_other_lanes_never_see_the_local_template():
|
||
broker = BrowserControlBroker()
|
||
broker.attach_lane_template(_template(), _Writer().send_for, owner=object())
|
||
assert broker.scope_for_session(
|
||
session_id="session-a", principal_id="principal:dashboard:x", transport_family="cloud-ticket-ws"
|
||
) is None
|
||
assert broker.scope_for_session(
|
||
session_id="session-a", principal_id=LOCAL_DESKTOP_PRINCIPAL, transport_family="cloud-ticket-ws"
|
||
) is None
|
||
|
||
|
||
def test_losing_the_transport_removes_the_lane_and_cancels_its_work():
|
||
broker = BrowserControlBroker(command_timeout=5)
|
||
writer, owner = _Writer(), object()
|
||
broker.attach_lane_template(_template(), writer.send_for, owner=owner)
|
||
scope = _lookup(broker, "session-a")
|
||
thread, outcome = _dispatch_async(broker, scope)
|
||
assert writer.sent.wait(2)
|
||
|
||
broker.disconnect_owner(owner)
|
||
thread.join(2)
|
||
assert isinstance(outcome.get("error"), ControllerCancelled)
|
||
assert _lookup(broker, "session-a") is None
|
||
assert broker.lane_registered(
|
||
session_id="session-a", principal_id=LOCAL_DESKTOP_PRINCIPAL, transport_family=LOCAL_DESKTOP_TRANSPORT_FAMILY
|
||
) is False
|
||
|
||
|
||
def test_same_controller_refresh_keeps_work_but_a_new_controller_replaces_it():
|
||
broker = BrowserControlBroker(command_timeout=5)
|
||
writer, owner = _Writer(), object()
|
||
broker.attach_lane_template(_template(), writer.send_for, owner=owner)
|
||
scope = _lookup(broker, "session-a")
|
||
thread, outcome = _dispatch_async(broker, scope)
|
||
assert writer.sent.wait(2)
|
||
command_id = writer.frames[-1][1]["params"]["command_id"]
|
||
|
||
refreshed_writer = _Writer()
|
||
broker.attach_lane_template(
|
||
_template(capabilities=CAPS | {"browser_click"}), refreshed_writer.send_for, owner=owner
|
||
)
|
||
refreshed = _lookup(broker, "session-a")
|
||
assert broker.select(refreshed, "browser_click") is not None
|
||
assert broker.complete_for_owner(command_id, owner=owner, ok=True, result="done") is True
|
||
thread.join(2)
|
||
assert outcome == {"result": "done"}
|
||
|
||
thread, outcome = _dispatch_async(broker, refreshed)
|
||
assert refreshed_writer.sent.wait(2)
|
||
broker.attach_lane_template(_template(controller_id="chrome-2"), _Writer().send_for, owner=owner)
|
||
thread.join(2)
|
||
assert isinstance(outcome.get("error"), ControllerCancelled)
|
||
assert _lookup(broker, "session-a").controller_id == "chrome-2"
|
||
|
||
|
||
def test_router_uses_local_lane_without_the_remote_feature_flag(monkeypatch):
|
||
monkeypatch.setattr("gateway.browser_control_broker.browser_control_enabled", lambda config=None: False)
|
||
monkeypatch.setattr("gateway.browser_control_broker.browser_control_local_enabled", lambda config=None: True)
|
||
broker = BrowserControlBroker(command_timeout=5)
|
||
calls = []
|
||
|
||
def fallback():
|
||
calls.append("legacy")
|
||
return "legacy"
|
||
|
||
identity = dict(
|
||
session_id="session-a",
|
||
principal_id=LOCAL_DESKTOP_PRINCIPAL,
|
||
transport_family=LOCAL_DESKTOP_TRANSPORT_FAMILY,
|
||
)
|
||
# No controller registered yet: the normal browser keeps working.
|
||
assert route_browser_tool("browser_navigate", {}, fallback=fallback, broker=broker, enabled=True, **identity) == "legacy"
|
||
|
||
owner = object()
|
||
|
||
def send_for(session_id):
|
||
def send(frame):
|
||
broker.complete_for_owner(
|
||
frame["params"]["command_id"], owner=owner, ok=True, result={"success": True, "screenshot": "eA=="}
|
||
)
|
||
|
||
return send
|
||
|
||
broker.attach_lane_template(_template(), send_for, owner=owner)
|
||
seen = []
|
||
result = route_browser_tool(
|
||
"browser_vision", {"question": "q"}, fallback=fallback, broker=broker, enabled=True,
|
||
transform=lambda value: seen.append(value) or {"envelope": True}, **identity,
|
||
)
|
||
assert result == {"envelope": True}
|
||
assert seen == [{"success": True, "screenshot": "eA=="}]
|
||
assert calls == ["legacy"]
|
||
|
||
|
||
def test_lane_switch_off_keeps_the_normal_browser(monkeypatch):
|
||
from tools import browser_extension_router as router
|
||
|
||
monkeypatch.setattr("gateway.browser_control_broker.browser_control_local_enabled", lambda config=None: False)
|
||
assert router.lane_enabled(LOCAL_DESKTOP_TRANSPORT_FAMILY) is False
|
||
monkeypatch.setattr("gateway.browser_control_broker.browser_control_local_enabled", lambda config=None: True)
|
||
assert router.lane_enabled(LOCAL_DESKTOP_TRANSPORT_FAMILY) is True
|
||
|
||
|
||
def test_local_switch_defaults_on_and_respects_explicit_off():
|
||
from gateway.browser_control_broker import browser_control_local_enabled
|
||
|
||
assert browser_control_local_enabled({}) is True
|
||
assert browser_control_local_enabled({"browser": {"extension_control": {"enabled": False}}}) is True
|
||
assert browser_control_local_enabled({"browser": {"extension_control": {"local_desktop": False}}}) is False
|
||
|
||
|
||
def test_only_the_loopback_token_marks_a_local_desktop_transport():
|
||
token_ws = SimpleNamespace(
|
||
query_params={"token": web_server._SESSION_TOKEN}, headers={},
|
||
client=SimpleNamespace(host="127.0.0.1"), url=SimpleNamespace(path="/api/ws"),
|
||
)
|
||
previous = getattr(web_server.app.state, "auth_required", False)
|
||
web_server.app.state.auth_required = False
|
||
try:
|
||
assert web_server._ws_auth_ok(token_ws) is True
|
||
assert token_ws._hermes_local_desktop is True
|
||
wrong = SimpleNamespace(
|
||
query_params={"token": "not-the-token"}, headers={},
|
||
client=SimpleNamespace(host="127.0.0.1"), url=SimpleNamespace(path="/api/ws"),
|
||
)
|
||
assert web_server._ws_auth_ok(wrong) is False
|
||
assert getattr(wrong, "_hermes_local_desktop", False) is False
|
||
finally:
|
||
web_server.app.state.auth_required = previous
|
||
|
||
|
||
def test_desktop_session_context_binds_the_local_lane_identity():
|
||
from gateway.session_context import clear_session_vars, get_session_env
|
||
|
||
server._sessions["local-context-fixture"] = {
|
||
"transport": SimpleNamespace(auth_identity=None, local_desktop=True),
|
||
"session_key": "stored-local-context",
|
||
"profile": None,
|
||
"agent": SimpleNamespace(session_id="durable-local-session"),
|
||
}
|
||
tokens = []
|
||
try:
|
||
tokens = server._set_session_context("stored-local-context")
|
||
assert get_session_env("HERMES_SESSION_ID") == "durable-local-session"
|
||
assert get_session_env("HERMES_BROWSER_CONTROL_PRINCIPAL") == LOCAL_DESKTOP_PRINCIPAL
|
||
assert get_session_env("HERMES_BROWSER_CONTROL_TRANSPORT_FAMILY") == LOCAL_DESKTOP_TRANSPORT_FAMILY
|
||
finally:
|
||
clear_session_vars(tokens)
|
||
server._sessions.pop("local-context-fixture", None)
|
||
|
||
|
||
def _rpc(method, params, transport):
|
||
return server.dispatch({"jsonrpc": "2.0", "id": 1, "method": method, "params": params}, transport)
|
||
|
||
|
||
class _Transport:
|
||
def __init__(self, local=True):
|
||
self.auth_identity = None
|
||
self.local_desktop = local
|
||
self.frames = []
|
||
self.sent = threading.Event()
|
||
|
||
def write(self, frame):
|
||
self.frames.append(frame)
|
||
self.sent.set()
|
||
return True
|
||
|
||
|
||
REGISTER = {
|
||
"protocol_version": 1,
|
||
"controller_id": "chrome-extension",
|
||
"browser_profile_id": "chrome-default",
|
||
"capabilities": ["browser_navigate", "browser_snapshot", "browser_cdp", "unknown.method"],
|
||
}
|
||
|
||
|
||
def test_local_registration_is_refused_off_the_desktop_transport(monkeypatch):
|
||
monkeypatch.setattr("gateway.browser_control_broker.browser_control_local_enabled", lambda config=None: True)
|
||
get_browser_control_broker().reset()
|
||
assert _rpc("browser.controller.local.register", REGISTER, _Transport(local=False))["error"]["code"] == 4403
|
||
monkeypatch.setattr("gateway.browser_control_broker.browser_control_local_enabled", lambda config=None: False)
|
||
assert _rpc("browser.controller.local.register", REGISTER, _Transport())["error"]["code"] == 4403
|
||
assert _rpc("browser.controller.local.register", {**REGISTER, "capabilities": ["browser_cdp"]}, _Transport())["error"]["code"] == 4403
|
||
|
||
|
||
def test_local_gateway_round_trip_and_detach(monkeypatch):
|
||
monkeypatch.setattr("gateway.browser_control_broker.browser_control_local_enabled", lambda config=None: True)
|
||
broker = get_browser_control_broker()
|
||
broker.reset()
|
||
transport = _Transport()
|
||
try:
|
||
registered = _rpc("browser.controller.local.register", REGISTER, transport)["result"]
|
||
assert registered["capabilities"] == ["browser_navigate", "browser_snapshot"]
|
||
|
||
scope = _lookup(broker, "durable-session")
|
||
thread, outcome = _dispatch_async(broker, scope)
|
||
assert transport.sent.wait(2)
|
||
event = transport.frames[-1]
|
||
assert event["method"] == "event"
|
||
assert event["params"]["type"] == "browser.controller.command"
|
||
assert event["params"]["session_id"] == "durable-session"
|
||
command_id = event["params"]["payload"]["command_id"]
|
||
|
||
foreign = _rpc("browser.controller.local.result", {"command_id": command_id, "ok": True}, _Transport())
|
||
assert foreign["result"]["accepted"] is False
|
||
done = _rpc("browser.controller.local.result", {"command_id": command_id, "ok": True, "result": {"success": True}}, transport)
|
||
assert done["result"]["accepted"] is True
|
||
thread.join(2)
|
||
assert outcome == {"result": {"success": True}}
|
||
|
||
assert _rpc("browser.controller.local.detach", {}, transport)["result"]["detached"] is True
|
||
assert _lookup(broker, "durable-session") is None
|
||
finally:
|
||
broker.reset()
|
||
|
||
|
||
def test_vision_from_controller_saves_the_jpeg_and_reuses_the_analysis(monkeypatch, tmp_path):
|
||
from tools import browser_tool
|
||
|
||
monkeypatch.setattr("hermes_constants.get_hermes_dir", lambda *parts: tmp_path)
|
||
captured = {}
|
||
|
||
def analyze(path, question, annotate, result, *, mime_type="image/png", fallback_warning=None):
|
||
captured.update(path=path, question=question, mime=mime_type, bytes=path.read_bytes())
|
||
return {"native": True}
|
||
|
||
monkeypatch.setattr(browser_tool, "_analyze_browser_screenshot", analyze)
|
||
jpeg = b"\xff\xd8\xff\xe0fixture"
|
||
out = browser_tool._browser_vision_from_controller(
|
||
{"success": True, "screenshot": base64.b64encode(jpeg).decode(), "mime": "image/jpeg"}, "Ne görünüyor?"
|
||
)
|
||
assert out == {"native": True}
|
||
assert captured["bytes"] == jpeg and captured["mime"] == "image/jpeg"
|
||
assert captured["path"].suffix == ".jpg" and captured["question"] == "Ne görünüyor?"
|
||
|
||
bad = json.loads(browser_tool._browser_vision_from_controller({"success": True, "screenshot": "%%%"}, "q"))
|
||
assert bad["success"] is False
|
||
failed = json.loads(browser_tool._browser_vision_from_controller({"success": False, "error": "Sekme kapandı"}, "q"))
|
||
assert failed == {"success": False, "error": "Sekme kapandı"}
|