feat(browser): enable extension controller actions

This commit is contained in:
abundantbeing
2026-08-13 00:22:07 +07:00
committed by kshitij
parent 5df1d0e113
commit c9fd5223f6
7 changed files with 274 additions and 28 deletions
+35 -13
View File
@@ -80,10 +80,21 @@ _api_request_browser_control_transport_family: ContextVar[str] = ContextVar(
#: Phase 4 browser-extension control protocol version (advertised in
#: /v1/capabilities and echoed in registration responses).
_BROWSER_CONTROL_PROTOCOL_VERSION = 1
#: Capabilities this phase actually grants a controller. Only the no-op
#: probe is real until the action protocol ships; any requested capability
#: outside this set is filtered out rather than advertised.
_BROWSER_CONTROL_CAPABILITIES = frozenset({"controller.noop"})
#: Exact Phase 6 browser-extension action allowlist. Any requested capability
#: outside this set is filtered out rather than advertised or dispatched.
_BROWSER_CONTROL_CAPABILITIES = frozenset({
"controller.noop",
"browser_back",
"browser_click",
"browser_navigate",
"browser_press",
"browser_screenshot",
"browser_scroll",
"browser_snapshot",
"browser_tab_activate",
"browser_tabs",
"browser_type",
})
_BROWSER_CONTROL_WS_PROTOCOL = "hermes-browser-control-v1"
_BROWSER_CONTROL_TICKET_PROTOCOL_PREFIX = "hermes-browser-control-ticket."
@@ -3256,16 +3267,14 @@ class APIServerAdapter(BasePlatformAdapter):
"session_continuity_header": "X-Hermes-Session-Id",
"session_key_header": "X-Hermes-Session-Key",
"cors": bool(self._cors_origins),
# Phase 4 browser-extension control. Always advertised (so
# clients can feature-detect), but truthful: disabled until
# browser.extension_control.enabled is set, and Phase 4
# exposes no real browser actions — only the no-op controller
# capability is ever granted.
# Browser-extension control is always advertised so clients
# can feature-detect it, but remains disabled until
# browser.extension_control.enabled is explicitly set.
"browser_extension_control": {
"enabled": self._browser_control_enabled(),
"protocol_version": _BROWSER_CONTROL_PROTOCOL_VERSION,
"capabilities": list(_BROWSER_CONTROL_CAPABILITIES),
"real_browser_actions": False,
"real_browser_actions": True,
"transports": {
"local_vps": "websocket-subprotocol-ticket",
"cloud": "authenticated-gateway-rpc",
@@ -3314,8 +3323,8 @@ class APIServerAdapter(BasePlatformAdapter):
single-use ticket to open the controller WebSocket. Identity is NOT
taken from the request body: the scope principal is derived
server-side from the authenticated key/profile as a non-reversible
digest, and the capability set is filtered to what this phase
actually grants (``controller.noop``), so a spoofed
digest, and the capability set is filtered to the exact Phase 6
action allowlist, so a spoofed
``principal_id`` or inflated capability list in the payload is
ignored rather than honored. The named session must already exist in
the active profile's server-owned SessionDB before a ticket is minted.
@@ -3514,7 +3523,9 @@ class APIServerAdapter(BasePlatformAdapter):
except Exception:
continue
if isinstance(frame, dict):
self._handle_browser_control_frame(scope, frame)
reply = self._handle_browser_control_frame(scope, frame)
if isinstance(reply, dict):
await ws.send_json(reply)
elif msg.type in (web.WSMsgType.CLOSE, web.WSMsgType.ERROR):
break
finally:
@@ -3531,6 +3542,17 @@ class APIServerAdapter(BasePlatformAdapter):
params = frame.get("params")
if not isinstance(params, dict):
return
if method == "browser.controller.heartbeat":
nonce = str(params.get("nonce") or "").strip()
if not nonce or len(nonce) > 128:
return
# Echo only the caller's opaque nonce on the already authenticated,
# exact-scope controller socket. This proves the socket path is live
# without granting a new capability or touching broker commands.
return {
"method": "browser.controller.heartbeat",
"params": {"nonce": nonce, "ok": True},
}
if method == "browser.controller.result":
command_id = params.get("command_id")
if isinstance(command_id, str) and command_id:
+35 -2
View File
@@ -15961,6 +15961,26 @@ def _ws_auth_mode() -> str:
return "loopback"
_GATEWAY_WS_PROTOCOL = "hermes-gateway-v1"
_GATEWAY_WS_TICKET_PROTOCOL_PREFIX = "hermes-gateway-ticket."
def _gateway_ws_ticket_from_subprotocol(ws: "WebSocket") -> tuple[str, str]:
"""Return ``(ticket, reason)`` from an unambiguous gateway protocol set."""
raw = str(ws.headers.get("sec-websocket-protocol", "") or "")
protocols = [value.strip() for value in raw.split(",") if value.strip()]
ticket_protocols = [
value for value in protocols
if value.startswith(_GATEWAY_WS_TICKET_PROTOCOL_PREFIX)
]
if not ticket_protocols:
return "", "none"
if _GATEWAY_WS_PROTOCOL not in protocols or len(ticket_protocols) != 1:
return "", "invalid"
ticket = ticket_protocols[0][len(_GATEWAY_WS_TICKET_PROTOCOL_PREFIX):]
return (ticket, "ok") if ticket else ("", "invalid")
def _ws_auth_reason(ws: "WebSocket") -> tuple[Optional[str], str]:
"""Validate WS-upgrade auth; return ``(reason, credential)``.
@@ -16030,7 +16050,10 @@ def _ws_auth_reason(ws: "WebSocket") -> tuple[Optional[str], str]:
)
return "internal_invalid", "internal"
ticket = ws.query_params.get("ticket", "")
protocol_ticket, protocol_reason = _gateway_ws_ticket_from_subprotocol(ws)
if protocol_reason == "invalid":
return "ticket_invalid", "ticket-subprotocol"
ticket = protocol_ticket or ws.query_params.get("ticket", "")
if not ticket:
return "no_credential", "none"
@@ -16047,6 +16070,12 @@ def _ws_auth_reason(ws: "WebSocket") -> tuple[Optional[str], str]:
"user_id": info.get("user_id"),
"provider": info.get("provider"),
}
if protocol_ticket:
# Select only the stable public protocol during accept. The
# ticket-bearing protocol is a credential and must never be
# reflected back to the browser or retained after admission.
ws._hermes_ws_subprotocol = _GATEWAY_WS_PROTOCOL
return None, "ticket-subprotocol"
return None, "ticket"
except TicketInvalid as exc:
audit_log(
@@ -17171,7 +17200,11 @@ async def gateway_ws(ws: WebSocket) -> None:
# onto the WS object by _ws_auth_reason; carry it into the gateway
# transport where it becomes the identity authority for privileged RPCs
# (browser.controller.register). None on the legacy token path.
await handle_ws(ws, auth_identity=getattr(ws, "_hermes_auth_identity", None))
await handle_ws(
ws,
auth_identity=getattr(ws, "_hermes_auth_identity", None),
subprotocol=getattr(ws, "_hermes_ws_subprotocol", None),
)
# ---------------------------------------------------------------------------
+61 -10
View File
@@ -12,6 +12,18 @@ from gateway.platforms.api_server import APIServerAdapter
API_KEY = "-".join(("fixture", "neutral", "api", "key", "123"))
CONTROL_PROTOCOL = "hermes-browser-control-v1"
REAL_BROWSER_CAPABILITIES = {
"browser_back",
"browser_click",
"browser_navigate",
"browser_press",
"browser_screenshot",
"browser_scroll",
"browser_snapshot",
"browser_tab_activate",
"browser_tabs",
"browser_type",
}
class _SessionDB:
@@ -70,6 +82,33 @@ def _registration_body(**overrides):
return payload
@pytest.mark.asyncio
async def test_phase6_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,
}
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()}
@@ -112,15 +151,13 @@ async def test_capabilities_are_truthful_and_disabled_by_default(monkeypatch):
data = await response.json()
control = data["features"]["browser_extension_control"]
assert control == {
"enabled": False,
"protocol_version": 1,
"capabilities": ["controller.noop"],
"real_browser_actions": False,
"transports": {
"local_vps": "websocket-subprotocol-ticket",
"cloud": "authenticated-gateway-rpc",
},
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",
@@ -260,12 +297,26 @@ async def test_local_api_ticket_ws_noop_round_trip_filters_spoofed_identity_and_
assert registration["ws_path"] == "/v1/browser-control/ws"
assert registration["scope"]["principal_id"] != "spoofed-client-principal"
assert registration["scope"]["transport_family"] == "local-api"
assert registration["scope"]["capabilities"] == ["controller.noop"]
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"],
@@ -14,6 +14,21 @@ from tui_gateway.methods_browser_control import _broker_event_writer, _principal
def _fake_ticket_ws(ticket):
return SimpleNamespace(
query_params={"ticket": ticket},
headers={},
client=SimpleNamespace(host="203.0.113.7"),
url=SimpleNamespace(path="/api/ws"),
)
def _fake_ticket_subprotocol_ws(ticket):
return SimpleNamespace(
query_params={},
headers={
"sec-websocket-protocol": (
f"{web_server._GATEWAY_WS_PROTOCOL}, "
f"{web_server._GATEWAY_WS_TICKET_PROTOCOL_PREFIX}{ticket}"
)
},
client=SimpleNamespace(host="203.0.113.7"),
url=SimpleNamespace(path="/api/ws"),
)
@@ -43,6 +58,19 @@ def test_dashboard_ticket_identity_is_carried_forward_without_trusting_rpc_param
assert web_server._ws_auth_ok(_fake_ticket_ws(ticket)) is False
def test_dashboard_ticket_subprotocol_carries_the_same_server_identity(gated_dashboard):
_reset_for_tests()
ticket = mint_ticket(user_id="subprotocol-user", provider="provider-fixture")
ws = _fake_ticket_subprotocol_ws(ticket)
assert web_server._ws_auth_ok(ws) is True
assert ws._hermes_auth_identity == {
"user_id": "subprotocol-user",
"provider": "provider-fixture",
}
assert ws._hermes_ws_subprotocol == web_server._GATEWAY_WS_PROTOCOL
def test_ws_transport_records_only_server_authenticated_identity():
loop = SimpleNamespace()
identity = {"user_id": "user-fixture", "provider": "provider-fixture"}
@@ -216,6 +244,28 @@ def test_cloud_gateway_noop_round_trip_is_bound_to_ticket_identity_and_session_t
transport_family="cloud-ticket-ws",
)
assert scope is not None
heartbeat_response = server.dispatch(
{
"jsonrpc": "2.0",
"id": 42,
"method": "browser.controller.heartbeat",
"params": {"session_id": "session-fixture"},
},
transport,
)
assert heartbeat_response["result"] == {"ok": True}
foreign_transport = Transport()
foreign_heartbeat = server.dispatch(
{
"jsonrpc": "2.0",
"id": 43,
"method": "browser.controller.heartbeat",
"params": {"session_id": "session-fixture"},
},
foreign_transport,
)
assert foreign_heartbeat["error"]["code"] == 4403
outcome = {}
def dispatch_noop():
@@ -176,7 +176,13 @@ def insecure_explicit_host_app():
web_server.app.state.auth_required = prev_required
def _fake_ws(*, query: dict, client_host: str = "127.0.0.1", path: str = "/api/pty"):
def _fake_ws(
*,
query: dict,
client_host: str = "127.0.0.1",
path: str = "/api/pty",
protocols: tuple[str, ...] = (),
):
"""Build a stand-in for starlette.WebSocket good enough for _ws_auth_ok."""
class _QP:
@@ -188,6 +194,7 @@ def _fake_ws(*, query: dict, client_host: str = "127.0.0.1", path: str = "/api/p
return SimpleNamespace(
query_params=_QP(query),
headers={"sec-websocket-protocol": ", ".join(protocols)} if protocols else {},
client=SimpleNamespace(host=client_host),
url=SimpleNamespace(path=path),
)
@@ -213,6 +220,45 @@ class TestWsAuthOkGated:
# Single-use — second consumption fails.
assert web_server._ws_auth_ok(ws_two) is False
def test_ticket_subprotocol_is_single_use_and_selects_only_the_public_protocol(self, gated_app):
ticket = mint_ticket(user_id="subprotocol-user", provider="stub")
protocols = (
web_server._GATEWAY_WS_PROTOCOL,
f"{web_server._GATEWAY_WS_TICKET_PROTOCOL_PREFIX}{ticket}",
)
ws_one = _fake_ws(query={}, path="/api/ws", protocols=protocols)
ws_two = _fake_ws(query={}, path="/api/ws", protocols=protocols)
assert web_server._ws_auth_ok(ws_one) is True
assert ws_one._hermes_auth_identity == {
"user_id": "subprotocol-user",
"provider": "stub",
}
assert ws_one._hermes_ws_subprotocol == web_server._GATEWAY_WS_PROTOCOL
assert ticket not in ws_one._hermes_ws_subprotocol
assert web_server._ws_auth_ok(ws_two) is False
def test_ticket_subprotocol_rejects_missing_public_protocol_or_ambiguous_tickets(self, gated_app):
first = mint_ticket(user_id="u1", provider="stub")
missing_public = _fake_ws(
query={},
path="/api/ws",
protocols=(f"hermes-gateway-ticket.{first}",),
)
assert web_server._ws_auth_ok(missing_public) is False
second = mint_ticket(user_id="u2", provider="stub")
ambiguous = _fake_ws(
query={},
path="/api/ws",
protocols=(
"hermes-gateway-v1",
f"hermes-gateway-ticket.{first}",
f"hermes-gateway-ticket.{second}",
),
)
assert web_server._ws_auth_ok(ambiguous) is False
def test_legacy_token_rejected_in_gated_mode(self, gated_app):
"""Critical: gated mode must NOT honour the legacy token path
+36
View File
@@ -303,6 +303,42 @@ def _(
return _ok(rid, {"accepted": accepted})
@method("browser.controller.heartbeat")
def _(
rid,
params: dict,
_family=_CLOUD_TRANSPORT_FAMILY,
_forbidden=_ERR_FORBIDDEN,
_identity_ok=_is_authenticated_identity,
_digest=_principal_digest,
) -> dict:
"""Acknowledge a heartbeat only for this transport's attached controller."""
from gateway import browser_control_broker
transport = current_transport()
identity = getattr(transport, "auth_identity", None)
if not _identity_ok(identity):
return _err(rid, _forbidden, "authenticated controller identity required")
session_id = str(params.get("session_id") or "")
with _sessions_lock:
session = _sessions.get(session_id)
if session is None or session.get("transport") is not transport:
return _err(rid, _forbidden, "session is not owned by this transport")
broker = browser_control_broker.get_browser_control_broker()
scope = broker.scope_for_session(
session_id=session_id,
principal_id=_digest(identity),
transport_family=_family,
)
if scope is None:
return _err(rid, _forbidden, "no controller registered for this session")
controller = broker.select(scope, "controller.noop")
if controller is None or controller.owner is not transport:
return _err(rid, _forbidden, "controller is not owned by this transport")
return _ok(rid, {"ok": True})
def register(server) -> None:
"""Bind this module's handlers onto ``server``'s globals and registry."""
_registry.install(server)
+10 -2
View File
@@ -292,7 +292,12 @@ def _disable_nagle(ws: Any) -> None:
_log.debug("ws TCP_NODELAY skip: %s", exc)
async def handle_ws(ws: Any, *, auth_identity: dict | None = None) -> None:
async def handle_ws(
ws: Any,
*,
auth_identity: dict | None = None,
subprotocol: str | None = None,
) -> None:
"""Run one WebSocket session. Wire-compatible with ``tui_gateway.entry``.
*auth_identity* is the server-minted ``{user_id, provider}`` recorded at
@@ -311,7 +316,10 @@ async def handle_ws(ws: Any, *, auth_identity: dict | None = None) -> None:
disconnect_reason = "not_connected"
try:
await ws.accept()
if subprotocol:
await ws.accept(subprotocol=subprotocol)
else:
await ws.accept()
disconnect_reason = "connected"
# Push small streamed frames out immediately instead of letting Nagle
# batch them — keeps the live token cadence intact for GUI clients.