Files
hermes-agent/tui_gateway/methods_browser_control.py
Ryan Tucker 68ed3ffd10 fix(tui_gateway): check steer authority by transport membership under fan-out
subagent.steer resolves authority by comparing the request's context-bound transport with the session's transport slot. Once a session mirrors to more than one client that slot holds a FanoutTransport, so the comparison fails for every client, the peer that commissioned the subagent included, and every steer is rejected. Fan-out without this check ships that regression, and no existing test catches it because the suite only exercises single-client sessions.

Authority now asks whether the request's transport is attached to the session, directly or through the fan-out. The single-client case is unchanged: a bare slot still compares by identity.

This widens authority. Any client attached to a mirrored session can steer that session's subagents, not only the peer that commissioned them. Narrowing it back to the commissioning peer requires recording that peer per subagent, which this change does not do. tests/tui_gateway/test_multi_client_fanout.py pins the commissioning peer's authority inside a fan-out, pins the widened case, and keeps a single-client control in which an unattached client is still refused.

The four browser.controller.* handlers gate on the session transport slot exactly as subagent.steer did, through one shared gate in the _controller_method decorator, and the conversion missed them. Once a session mirrors to more than one client the slot holds a FanoutTransport, which is identical to no peer's WSTransport, so browser.controller.register, .result, .heartbeat and .detach all answer "session is not owned by this transport" for every client, the peer that registered the controller included. Browser control is therefore unusable on any mirrored session. No existing test catches it because the browser-control suite only exercises single-client sessions.

All four now ask the same question steer asks: is the request's transport attached to this session, directly or through the fan-out. The single-client case is unchanged, because a bare slot still compares by identity.

Only registration widens. On .result, .heartbeat and .detach the broker's is_owner check sits below the session gate and compares controller.owner is owner against the transport recorded at attach time (gateway/browser_control_broker.py), so a mirrored peer that did not register the controller is still refused there, now with "controller is not owned by this transport" instead of the session message. browser.controller.register has no such check, so any client attached to a mirrored session may register a controller for it; the broker's principal lane keeps that inside one authenticated identity, and a second identity in the same lane hard-replaces the first. tests/tui_gateway/test_multi_client_fanout.py pins the registering peer's access inside a fan-out, pins the widened and broker-refused cases, and keeps a single-client control in which an unattached client is still refused on all three scope-gated handlers.
2026-09-07 22:25:12 -07:00

242 lines
11 KiB
Python

"""Browser controller registration and result routing for the dashboard.
The controller extension registers over the authenticated ``/api/ws`` gateway. Everything
binds to the SERVER-MINTED identity (``WSTransport.auth_identity``, stamped from the single-use
ticket); a client-supplied ``principal_id`` is ignored and replaced by a digest of it. Broker
frames are re-enveloped as Gateway ``event`` frames; ``result`` resolves a command only when the
request arrives on a transport ATTACHED to the session and that transport is the broker-recorded
owner of the exact attached scope (the broker's exact-scope ``complete`` is the backstop).
Capabilities come from the broker's explicit allowlist (no raw CDP/eval/uploads). Bodies are
rebound onto server.py's globals (bind_module publishes this module's helpers too), which is how
the session gate reaches ``_session_transport_contains`` with no import of its own.
"""
from __future__ import annotations
import hashlib
import logging
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 here; the broker treats it as an
# identity field, so an API transport can never address a dashboard controller.
_CLOUD_TRANSPORT_FAMILY = "cloud-ticket-ws"
_ERR_FORBIDDEN = 4403 # identity / session / flag denials
_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, provider = identity.get("user_id"), 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: stable per user, unspoofable without the minted identity."""
raw = f"{identity.get('provider')}\x00{identity.get('user_id')}"
return f"principal:dashboard:{hashlib.sha256(raw.encode('utf-8')).hexdigest()[:32]}"
def _broker_event_writer(transport: object, session_id: str):
"""Broker send callback: re-envelope ``{method, params}`` as a Gateway ``event`` frame
(``type`` = method, ``payload`` = params, plus the owning ``session_id``)."""
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) → caller holds a
server-authenticated, non-internal identity → the named session exists and the caller is
ATTACHED to it, directly or through the ``FanoutTransport`` a mirrored session holds in its
slot → when ``lookup_scope``, a scope is attached for this session/principal/family and the
caller owns it. Then
``fn(rid, params, transport, identity, session_id, broker, scope, session)`` runs.
"""
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 _is_authenticated_identity(identity):
return _err(rid, _ERR_FORBIDDEN, identity_message)
session_id = str(params.get("session_id") or "")
with _sessions_lock:
session = _sessions.get(session_id)
# Membership, not slot identity: a mirrored session holds a FanoutTransport, which is
# identical to no peer's transport, so slot identity would refuse every client here — the
# peer that registered the controller included. The broker's is_owner check below still
# keys on the transport that attached the scope.
if not _session_transport_contains(session, transport):
return _err(rid, _ERR_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=_principal_digest(identity),
transport_family=_CLOUD_TRANSPORT_FAMILY)
if scope is None:
return _err(rid, _ERR_FORBIDDEN, missing_scope_message)
# Defense in depth: the broker's exact-scope ops already reject foreign
# scopes; the owner check makes the same-transport rule explicit here too.
if not broker.is_owner(scope, transport):
return _err(rid, _ERR_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):
from gateway import browser_control_broker
if not browser_control_broker.browser_control_enabled():
return _err(rid, _ERR_FORBIDDEN, "browser.extension_control.enabled is not set")
broker_mod = browser_control_broker
if not broker_mod.browser_control_protocol_supported(params.get("protocol_version")):
expected = broker_mod.BROWSER_CONTROL_PROTOCOL_VERSION
return _err(
rid, _ERR_FORBIDDEN,
f"unsupported browser-control protocol version; expected {expected}",
)
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) -> dict:
"""Attach this connection as the browser controller for one session; fails closed (4403) unless
the flag is on, the protocol version is supported, the gates pass and a capability survives."""
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, _ERR_FORBIDDEN,
"controller_id, browser_profile_id, and server session profile are required",
)
capabilities = browser_control_broker.filter_browser_control_capabilities(
params.get("capabilities")
)
if not capabilities:
return _err(rid, _ERR_FORBIDDEN, "no permitted controller capabilities requested")
scope = browser_control_broker.ControllerScope(
principal_id=_principal_digest(identity), profile_id=profile_id, session_id=session_id,
controller_id=controller_id, browser_profile_id=browser_profile_id,
transport_family=_CLOUD_TRANSPORT_FAMILY, capabilities=capabilities)
broker.attach(scope, _broker_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) -> dict:
"""Deliver one command result to the broker; ``accepted`` is False for unknown / 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, _ERR_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 own attached controller.
The session gate admits any client attached to the session, including a fan-out peer; the
broker's ``is_owner`` check then narrows the answer to the transport that actually registered
the 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 helpers/constants onto ``server`` and install handlers (rebound to its globals)."""
bind_module(globals(), server, skip=("_",))
# ---- BEGIN PLUGIN-COMPAT (revert-scheduled; see COMPAT_MANIFEST.md) ----
# Names external plugins imported from this module before the Sep 2026 decomposition.
# Internal code MUST NOT use these (scripts/check_compat_pointers.py fails CI if it does).
# The whole block is removed by reverting the commit that added it.
_PLUGIN_COMPAT_LAZY = {
'BROWSER_CONTROL_PROTOCOL_VERSION': ('gateway.browser_control_broker', 'BROWSER_CONTROL_PROTOCOL_VERSION'),
'browser_control_protocol_supported': ('gateway.browser_control_broker', 'browser_control_protocol_supported'),
'filter_browser_control_capabilities': ('gateway.browser_control_broker', 'filter_browser_control_capabilities'),
}
def __getattr__(name): # PEP 562 — lazy so no import cycles
target = _PLUGIN_COMPAT_LAZY.get(name)
if target is None:
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
import importlib
from hermes_cli.plugin_compat import warn_once
warn_once(__name__, name, *target)
return getattr(importlib.import_module(target[0]), target[1])
# ---- END PLUGIN-COMPAT ----