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.
242 lines
11 KiB
Python
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 ----
|