Files
hermes-agent/tui_gateway/methods_browser_control.py
T
Teknium 7fd156d862 fix(integration): publish methods_browser_control helpers/constants onto server via bind_module
The tui split's rebind() now re-targets closure cells holding this module's
functions onto server.py's globals, so _is_authenticated_identity ran with
server globals that lacked _INTERNAL_USER_ID/_INTERNAL_PROVIDER (NameError).
register() now uses bind_module like the other split modules.
2026-09-02 15:41:10 -07:00

329 lines
13 KiB
Python

"""Browser controller registration and result routing for the dashboard.
The dashboard's browser controller (the extension that physically drives a
browser) registers itself over the authenticated ``/api/ws`` JSON-RPC
gateway. Everything here is bound to the **server-minted identity** that the
dashboard auth layer stamped onto the WS connection: ``hermes_cli.web_server``
consumes the single-use ticket and records ``ws._hermes_auth_identity``, the
WS transport carries it as ``WSTransport.auth_identity``, and the client can
never name its own principal (a spoofed ``principal_id`` param is ignored and
replaced by a server-derived digest of the authenticated identity).
Registration attaches the shared transport-neutral broker
(:mod:`gateway.browser_control_broker`) with the calling transport as owner;
broker command/cancel frames are wrapped as standard Gateway ``event`` frames
(``type`` = broker method name, ``payload`` = broker params, plus the owning
``session_id``) so the dashboard consumes the same envelope as every other
gateway event. ``browser.controller.result`` resolves a pending command only
when the request arrives on the same transport that owns the session, and
only for the exact attached scope — the broker's exact-scope ``complete`` is
the last line of defense against cross-tenant completion.
Both dashboard and local API transports use the broker's shared, explicit
capability allowlist. Raw CDP, script evaluation, console access, uploads, and
other privileged surfaces are not controller capabilities.
Note on handler globals: ``HandlerRegistry.install`` (method_ctx.py) rebinds
each handler's ``__globals__`` onto server.py's namespace, so handler bodies
may only reference names server.py defines/imports (``_ok``, ``_err``,
``_sessions``, ``_sessions_lock``, ``current_transport``, ``logger``, ...).
This module's own helpers and constants reach the handlers through closure
cells of :func:`_controller_method` (rebind preserves and re-targets them).
"""
from __future__ import annotations
import hashlib
import logging
from gateway.browser_control_broker import (
BROWSER_CONTROL_PROTOCOL_VERSION,
browser_control_protocol_supported,
filter_browser_control_capabilities,
)
from hermes_cli.dashboard_auth.ws_tickets import (
INTERNAL_PROVIDER as _INTERNAL_PROVIDER,
INTERNAL_USER_ID as _INTERNAL_USER_ID,
)
from .method_ctx import HandlerRegistry, bind_module
logger = logging.getLogger(__name__)
_registry = HandlerRegistry()
method = _registry.method
#: Transport family stamped into every scope attached from this gateway. The
#: broker's exact-match contract treats it as an identity field, so an API
#: transport can never address a dashboard controller (and vice versa).
_CLOUD_TRANSPORT_FAMILY = "cloud-ticket-ws"
#: JSON-RPC error code for identity / session / flag denials (forbidden).
_ERR_FORBIDDEN = 4403
_IDENTITY_REQUIRED = "authenticated controller identity required"
_NOT_OWNED = "controller is not owned by this transport"
_NO_CONTROLLER = "no controller registered for this session"
def _is_authenticated_identity(identity: object) -> bool:
"""True for a server-minted, non-internal ``{user_id, provider}`` identity."""
if not isinstance(identity, dict):
return False
user_id = identity.get("user_id")
provider = identity.get("provider")
if not isinstance(user_id, str) or not user_id.strip():
return False
if not isinstance(provider, str) or not provider.strip():
return False
return not (user_id == _INTERNAL_USER_ID and provider == _INTERNAL_PROVIDER)
def _principal_digest(identity: dict) -> str:
"""Server-derived principal id: a digest of the server-minted identity.
The client-supplied ``principal_id`` RPC param is never trusted; the
digest is deterministic (stable across reconnects for the same user) but
unspoofable by a peer that does not hold the authenticated identity.
"""
raw = f"{identity.get('provider')}\x00{identity.get('user_id')}"
digest = hashlib.sha256(raw.encode("utf-8")).hexdigest()
return f"principal:dashboard:{digest[:32]}"
def _broker_event_writer(transport: object, session_id: str):
"""Wrap broker command/cancel frames as standard Gateway event frames.
The broker's send callback receives transport-neutral envelopes like
``{"method": "browser.controller.command", "params": {...}}``; the
dashboard speaks Gateway events, so we re-envelope them:
``{"jsonrpc": "2.0", "method": "event", "params": {"type": <method>,
"session_id": <owner>, "payload": <params>}}``.
"""
def send(frame: dict) -> None:
try:
accepted = transport.write(
{
"jsonrpc": "2.0",
"method": "event",
"params": {
"type": frame.get("method"),
"session_id": session_id,
"payload": frame.get("params"),
},
}
)
except Exception:
logger.exception(
"browser controller event write failed session=%s frame=%s",
session_id,
frame.get("method"),
)
raise
if accepted is False:
raise ConnectionError("browser controller event write failed")
return send
def _controller_method(
name: str,
*,
identity_message: str = _IDENTITY_REQUIRED,
lookup_scope: bool = True,
missing_scope_message: str = _NO_CONTROLLER,
precheck=None,
):
"""Register a handler behind the shared fail-closed (4403) controller gates.
Order: ``precheck(rid, params)`` (may return an error envelope) → the
calling transport holds a server-authenticated, non-internal identity
(``WSTransport.auth_identity``, never the RPC params) → the named session
exists and its ``transport`` is exactly the caller → when ``lookup_scope``,
a controller scope is attached for this session/principal/family and the
caller owns it. ``fn(rid, params, transport, identity, session_id, broker,
scope)`` then runs (``scope`` is ``None`` when ``lookup_scope`` is off).
"""
forbidden = _ERR_FORBIDDEN
family = _CLOUD_TRANSPORT_FAMILY
identity_ok = _is_authenticated_identity
digest = _principal_digest
not_owned = _NOT_OWNED
def dec(fn):
def handler(rid, params: dict) -> dict:
from gateway import browser_control_broker
if precheck is not None:
denied = precheck(rid, params)
if denied is not None:
return denied
transport = current_transport()
identity = getattr(transport, "auth_identity", None)
if not identity_ok(identity):
return _err(rid, forbidden, identity_message)
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 = None
if lookup_scope:
scope = broker.scope_for_session(
session_id=session_id,
principal_id=digest(identity),
transport_family=family,
)
if scope is None:
return _err(rid, forbidden, missing_scope_message)
# Defense in depth: the broker's exact-scope operations already
# reject foreign scopes; the owner check makes the "same
# transport" rule explicit at this layer too.
if not broker.is_owner(scope, transport):
return _err(rid, forbidden, not_owned)
return fn(rid, params, transport, identity, session_id, broker, scope, session)
handler.__doc__ = fn.__doc__
return method(name)(handler)
return dec
def _register_precheck(
rid,
params: dict,
_forbidden=_ERR_FORBIDDEN,
_protocol_version=BROWSER_CONTROL_PROTOCOL_VERSION,
_protocol_supported=browser_control_protocol_supported,
):
from gateway import browser_control_broker
if not browser_control_broker.browser_control_enabled():
return _err(rid, _forbidden, "browser.extension_control.enabled is not set")
if not _protocol_supported(params.get("protocol_version")):
return _err(
rid,
_forbidden,
f"unsupported browser-control protocol version; expected {_protocol_version}",
)
return None
@_controller_method(
"browser.controller.register",
identity_message="browser.controller.register requires an authenticated non-internal identity",
lookup_scope=False,
precheck=_register_precheck,
)
def _(
rid,
params: dict,
transport,
identity,
session_id,
broker,
_scope,
session,
_family=_CLOUD_TRANSPORT_FAMILY,
_forbidden=_ERR_FORBIDDEN,
_filter_capabilities=filter_browser_control_capabilities,
_digest=_principal_digest,
_event_writer=_broker_event_writer,
) -> dict:
"""Attach this connection as the browser controller for one session.
Fails closed (4403) unless the ``browser.extension_control.enabled`` flag
is on, the protocol version is supported, the shared identity/session
gates pass, and at least one requested capability survives the allowlist.
The returned ``scope`` names a server-derived ``principal_id``, the
``cloud-ticket-ws`` transport family, and the filtered capability set.
"""
from gateway import browser_control_broker
controller_id = str(params.get("controller_id") or "").strip()
browser_profile_id = str(params.get("browser_profile_id") or "").strip()
profile_id = str(session.get("profile") or "").strip()
if not controller_id or not browser_profile_id or not profile_id:
return _err(
rid,
_forbidden,
"controller_id, browser_profile_id, and server session profile are required",
)
capabilities = _filter_capabilities(params.get("capabilities"))
if not capabilities:
return _err(rid, _forbidden, "no permitted controller capabilities requested")
scope = browser_control_broker.ControllerScope(
principal_id=_digest(identity),
profile_id=profile_id,
session_id=session_id,
controller_id=controller_id,
browser_profile_id=browser_profile_id,
transport_family=_family,
capabilities=capabilities,
)
broker.attach(scope, _event_writer(transport, session_id), owner=transport)
return _ok(
rid,
{
"scope": {
"principal_id": scope.principal_id,
"profile_id": scope.profile_id,
"session_id": scope.session_id,
"controller_id": scope.controller_id,
"browser_profile_id": scope.browser_profile_id,
"transport_family": scope.transport_family,
"capabilities": sorted(scope.capabilities),
}
},
)
@_controller_method("browser.controller.result")
def _(rid, params: dict, _transport, _identity, _session_id, broker, scope, _session, _forbidden=_ERR_FORBIDDEN) -> dict:
"""Deliver one controller command result back to the broker.
Only the transport that owns the session may resolve its commands, and
only against the exact scope attached for that session (the broker's
exact-scope ``complete`` rejects any other scope). ``accepted`` is
``False`` for unknown / already-resolved / cancelled command ids — the
broker's idempotent answer, surfaced verbatim.
"""
command_id = str(params.get("command_id") or "")
if not command_id:
return _err(rid, _forbidden, "command_id required")
ok = params.get("ok") is True
accepted = broker.complete(
command_id,
scope=scope,
ok=ok,
result=params.get("result") if ok else params.get("error"),
)
return _ok(rid, {"accepted": accepted})
@_controller_method("browser.controller.heartbeat")
def _(rid, params: dict, *_gate) -> dict:
"""Acknowledge a heartbeat only for this transport's attached controller."""
return _ok(rid, {"ok": True})
@_controller_method("browser.controller.detach", missing_scope_message=_NOT_OWNED)
def _(rid, params: dict, transport, _identity, _session_id, broker, scope, _session) -> dict:
"""Hard-detach only the controller owned by this authenticated transport."""
broker.detach(scope, owner=transport, notify_controller=False)
return _ok(rid, {"detached": True})
def register(server) -> None:
"""Publish this module's helpers/constants onto ``server`` and install handlers.
``rebind`` re-targets closure cells that hold this module's functions, so
the helpers (and the constants they read) must exist in server.py's
namespace too — ``bind_module`` publishes them.
"""
bind_module(globals(), server, skip=("_",))