feat(browser): add authenticated control broker

This commit is contained in:
abundantbeing
2026-08-12 16:37:11 +07:00
committed by kshitij
parent f33b260afa
commit 5df1d0e113
16 changed files with 3155 additions and 24 deletions
+383 -1
View File
@@ -70,6 +70,22 @@ _PROFILE_REJECTED = object()
_api_request_profile: ContextVar[Optional[str]] = ContextVar(
"api_server_request_profile", default=None
)
_api_request_browser_control_principal: ContextVar[str] = ContextVar(
"api_server_browser_control_principal", default=""
)
_api_request_browser_control_transport_family: ContextVar[str] = ContextVar(
"api_server_browser_control_transport_family", default=""
)
#: 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"})
_BROWSER_CONTROL_WS_PROTOCOL = "hermes-browser-control-v1"
_BROWSER_CONTROL_TICKET_PROTOCOL_PREFIX = "hermes-browser-control-ticket."
def _approval_event_choices(*, smart_denied: bool, allow_permanent: bool) -> list[str]:
if smart_denied:
@@ -95,6 +111,11 @@ from gateway.platforms.base import (
from agent.redact import redact_sensitive_text
from agent.interrupt_compat import request_hard_interrupt
from gateway.readiness import collect_runtime_readiness
from gateway.browser_control_broker import (
ControllerScope,
TicketInvalid,
get_browser_control_broker,
)
from agent.secret_scope import UnscopedSecretError as _UnscopedSecretError
from agent.secret_scope import get_secret as _scoped_get_secret
@@ -1494,6 +1515,11 @@ class APIServerAdapter(BasePlatformAdapter):
# Shutdown counts this reservation so the request cannot slip through
# the drain between its first await and _run_agent()/task registration.
self._pending_agent_requests: int = 0
# Phase 4 browser-control broker core: transport-neutral ticket /
# controller / command lifecycle shared with the dashboard Gateway
# transport. This adapter only maps HTTP registration and the
# controller WebSocket onto the broker; it owns no broker state.
self._browser_control_broker = get_browser_control_broker()
def active_agent_work_count(self) -> int:
"""Return all live agent work owned by this API adapter.
@@ -2063,7 +2089,18 @@ class APIServerAdapter(BasePlatformAdapter):
token = _api_request_profile.set(profile)
try:
with self._profile_scope(profile):
return await handler(request)
resolved_profile = profile or "default"
principal_token = _api_request_browser_control_principal.set(
self._derive_browser_control_principal(resolved_profile)
)
family_token = _api_request_browser_control_transport_family.set(
self._browser_control_transport_family(request)
)
try:
return await handler(request)
finally:
_api_request_browser_control_transport_family.reset(family_token)
_api_request_browser_control_principal.reset(principal_token)
finally:
_api_request_profile.reset(token)
@@ -2082,6 +2119,12 @@ class APIServerAdapter(BasePlatformAdapter):
("GET", "/v1/models", self._handle_models),
("GET", "/api/model/options", self._handle_model_options),
("GET", "/v1/capabilities", self._handle_capabilities),
# Phase 4 authenticated browser-control surface: POST registration
# mints a short-lived ticket; the controller then opens the WS with
# that ticket. Both are gated on browser.extension_control.enabled
# and API-key auth (see the handlers for the exact status ladder).
("POST", "/v1/browser-control/register", self._handle_browser_control_register),
("GET", "/v1/browser-control/ws", self._handle_browser_control_ws),
("GET", "/v1/skills", self._handle_skills),
("GET", "/v1/toolsets", self._handle_toolsets),
("GET", "/api/sessions", self._handle_list_sessions),
@@ -3213,6 +3256,21 @@ 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": {
"enabled": self._browser_control_enabled(),
"protocol_version": _BROWSER_CONTROL_PROTOCOL_VERSION,
"capabilities": list(_BROWSER_CONTROL_CAPABILITIES),
"real_browser_actions": False,
"transports": {
"local_vps": "websocket-subprotocol-ticket",
"cloud": "authenticated-gateway-rpc",
},
},
},
"endpoints": {
"health": {"method": "GET", "path": "/health"},
@@ -3239,9 +3297,307 @@ class APIServerAdapter(BasePlatformAdapter):
"session_chat": {"method": "POST", "path": "/api/sessions/{session_id}/chat"},
"session_chat_stream": {"method": "POST", "path": "/api/sessions/{session_id}/chat/stream"},
"session_model_lock": {"method": "POST", "path": "/api/sessions/{session_id}/model"},
"browser_control_register": {"method": "POST", "path": "/v1/browser-control/register"},
"browser_control_ws": {"method": "GET", "path": "/v1/browser-control/ws"},
},
})
# ------------------------------------------------------------------
# Phase 4 browser-extension control (authenticated local/VPS API)
# ------------------------------------------------------------------
async def _handle_browser_control_register(self, request: "web.Request") -> "web.Response":
"""POST /v1/browser-control/register — mint a controller ticket.
The extension controller proves itself with the same Bearer API key
every other API-server client uses, then receives a short-lived,
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
``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.
Status ladder: 404 when the feature is disabled, 403 when no API key
is configured at all (registration can never be authenticated), 401
for a missing/invalid Bearer token, 201 on success.
"""
if not self._browser_control_enabled():
return web.json_response(
_openai_error(
"Browser control is not enabled on this server.",
code="browser_control_disabled",
),
status=404,
)
if not self._api_key:
logger.warning(
"browser-control registration rejected: no API key configured; "
"set API_SERVER_KEY to enable authenticated browser control."
)
return web.json_response(
_openai_error(
"Browser control registration requires a configured API key.",
err_type="gateway_auth_error",
code="browser_control_auth_required",
),
status=403,
)
auth_err = self._check_auth(request)
if auth_err:
return auth_err
try:
payload = await request.json()
except Exception:
return web.json_response(
_openai_error("Request body must be valid JSON."), status=400
)
if not isinstance(payload, dict):
return web.json_response(
_openai_error("Request body must be a JSON object."), status=400
)
controller_id = str(payload.get("controller_id") or "").strip()
browser_profile_id = str(payload.get("browser_profile_id") or "").strip()
session_id = str(payload.get("session_id") or "").strip()
if not controller_id or not browser_profile_id or not session_id:
return web.json_response(
_openai_error(
"controller_id, browser_profile_id, and session_id are required.",
code="browser_control_invalid_registration",
),
status=400,
)
db = await self._ensure_session_db_async()
if db is None:
return web.json_response(
_openai_error(
"Session database unavailable.",
code="session_db_unavailable",
),
status=503,
)
session = await asyncio.to_thread(db.get_session, session_id)
if not session:
return web.json_response(
_openai_error(
"Browser control may register only for an existing server session.",
err_type="gateway_auth_error",
code="browser_control_session_forbidden",
),
status=403,
)
profile = _api_request_profile.get() or "default"
capabilities = frozenset(
capability
for capability in payload.get("capabilities") or []
if isinstance(capability, str)
and capability in _BROWSER_CONTROL_CAPABILITIES
)
scope = ControllerScope(
principal_id=self._derive_browser_control_principal(profile),
profile_id=profile,
session_id=session_id or None,
controller_id=controller_id,
browser_profile_id=browser_profile_id,
transport_family=self._browser_control_transport_family(request),
capabilities=capabilities,
)
ticket = self._browser_control_broker.mint_ticket(scope)
ticket_ttl = self._browser_control_broker.ticket_ttl_seconds
return web.json_response(
{
"protocol_version": _BROWSER_CONTROL_PROTOCOL_VERSION,
"ticket": ticket.value,
"ticket_expires_at": time.time() + ticket_ttl,
"ticket_expires_in_seconds": ticket_ttl,
"ws_path": "/v1/browser-control/ws",
"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),
},
},
status=201,
)
async def _handle_browser_control_ws(self, request: "web.Request") -> "web.WebSocketResponse":
"""GET /v1/browser-control/ws — controller WebSocket (one-shot ticket).
A ticket-bearing ``Sec-WebSocket-Protocol`` token is exchanged exactly
once for the identity scope minted at registration; query-string,
unknown, already-consumed, or expired tickets are rejected with 401
before upgrade. The socket then attaches to the shared broker under
that scope, forwards broker command/cancel frames onto the aiohttp loop
thread-safely, and accepts controller result/cancel frames. Completion
is exact-scope checked, and owner-aware teardown cannot detach a newer
replacement controller generation.
"""
# Re-check at upgrade time so disabling the feature immediately closes
# the admission gate without consuming still-live one-shot tickets.
if not self._browser_control_enabled():
raise web.HTTPNotFound()
# Credentials in the request target are liable to appear in access
# logs. Accept the one-shot ticket only as a WebSocket subprotocol;
# reject the former query-string shape without consuming it.
if request.query.get("ticket"):
raise web.HTTPUnauthorized()
requested_protocols = [
value.strip()
for value in request.headers.get("Sec-WebSocket-Protocol", "").split(",")
if value.strip()
]
ticket_protocols = [
value
for value in requested_protocols
if value.startswith(_BROWSER_CONTROL_TICKET_PROTOCOL_PREFIX)
]
if (
_BROWSER_CONTROL_WS_PROTOCOL not in requested_protocols
or len(ticket_protocols) != 1
):
raise web.HTTPUnauthorized()
ticket_value = ticket_protocols[0][len(_BROWSER_CONTROL_TICKET_PROTOCOL_PREFIX) :]
if not ticket_value:
raise web.HTTPUnauthorized()
try:
scope = self._browser_control_broker.consume_ticket(ticket_value)
except TicketInvalid:
raise web.HTTPUnauthorized() from None
except Exception:
logger.exception("browser-control WS ticket consumption failed")
raise web.HTTPUnauthorized() from None
ws = web.WebSocketResponse(
heartbeat=30.0,
protocols=(_BROWSER_CONTROL_WS_PROTOCOL,),
)
await ws.prepare(request)
loop = asyncio.get_running_loop()
def _send(frame: dict) -> None:
"""Broker send callback: forward a frame onto the aiohttp loop.
Called from broker dispatch threads; aiohttp socket writes must
happen on the event loop. Waiting for the write here preserves
command ordering and lets a closed socket fail the dispatch
instead of silently dropping the frame.
"""
if ws.closed:
raise ConnectionError("browser-control websocket is closed")
try:
on_loop = asyncio.get_running_loop() is loop
except RuntimeError:
on_loop = False
if on_loop:
loop.create_task(ws.send_json(frame))
return
future = asyncio.run_coroutine_threadsafe(ws.send_json(frame), loop)
future.result(timeout=10.0)
self._browser_control_broker.attach(scope, _send, owner=ws)
try:
async for msg in ws:
if msg.type == web.WSMsgType.TEXT:
try:
frame = msg.json()
except Exception:
continue
if isinstance(frame, dict):
self._handle_browser_control_frame(scope, frame)
elif msg.type in (web.WSMsgType.CLOSE, web.WSMsgType.ERROR):
break
finally:
self._browser_control_broker.detach(
scope,
owner=ws,
notify_controller=False,
)
return ws
def _handle_browser_control_frame(self, scope: "ControllerScope", frame: dict) -> None:
"""Apply one controller→broker frame with exact-scope checks."""
method = frame.get("method")
params = frame.get("params")
if not isinstance(params, dict):
return
if method == "browser.controller.result":
command_id = params.get("command_id")
if isinstance(command_id, str) and command_id:
# Broker resolves only the pending command whose scope equals
# this socket's scope; a stranger's command id is a no-op.
ok = params.get("ok") is True
self._browser_control_broker.complete(
command_id,
scope=scope,
ok=ok,
result=params.get("result") if ok else params.get("error"),
)
elif method == "browser.controller.cancel":
tool_call_id = params.get("tool_call_id")
if isinstance(tool_call_id, str) and tool_call_id:
self._browser_control_broker.cancel(scope, tool_call_id=tool_call_id)
def _browser_control_enabled(self) -> bool:
"""Phase 4 feature flag; False unless explicitly enabled.
Reads ``browser.extension_control.enabled`` from the global config
(defaults to False). Tests monkeypatch this method directly to force
the feature on/off without touching config.
"""
try:
from gateway.browser_control_broker import browser_control_enabled as _flag
return _flag()
except Exception:
return False
def _derive_browser_control_principal(self, profile: str) -> str:
"""Server-derived controller principal (non-reversible digest).
The principal is bound to the credential that authenticated the
registration request — the expected API key for the request's
profile — so a client cannot impersonate another controller by
echoing an id in the registration body.
"""
key = self._expected_api_key() or self._api_key or ""
digest = hashlib.sha256(f"{profile}\x00{key}".encode("utf-8")).hexdigest()
return f"principal:{profile}:{digest[:32]}"
def _browser_control_transport_family(self, request: "web.Request") -> str:
"""Local vs remote API family, decided by the loopback peer.
A controller speaking to a localhost listener is in the same trust
domain as the host and gets the ``local-api`` family; anything else
is ``remote-api``. The broker treats the family as part of exact
identity, so a remote controller can never satisfy a local-only
dispatch (and vice versa).
"""
host = None
try:
transport = request.transport
if transport is not None:
peer = transport.get_extra_info("peername")
if isinstance(peer, tuple) and peer:
host = peer[0]
elif isinstance(peer, str):
host = peer
except Exception:
host = None
if host in ("127.0.0.1", "::1", "localhost"):
return "local-api"
return "remote-api"
async def _handle_skills(self, request: "web.Request") -> "web.Response":
"""GET /v1/skills — list installed skills visible to the API-server agent.
@@ -6308,6 +6664,8 @@ class APIServerAdapter(BasePlatformAdapter):
chat_id: str = "",
session_key: str = "",
session_id: str = "",
browser_control_principal: str = "",
browser_control_transport_family: str = "",
) -> list:
"""Bind session contextvars for an API-server agent run.
@@ -6331,6 +6689,8 @@ class APIServerAdapter(BasePlatformAdapter):
chat_id=chat_id,
session_key=session_key,
session_id=session_id,
browser_control_principal=browser_control_principal,
browser_control_transport_family=browser_control_transport_family,
async_delivery=False,
cron_session="",
)
@@ -6391,6 +6751,12 @@ class APIServerAdapter(BasePlatformAdapter):
# run_in_executor threads, so the profile scope must be re-entered
# inside _run() from this explicit value.
request_profile = _api_request_profile.get()
request_browser_control_principal = (
_api_request_browser_control_principal.get()
)
request_browser_control_transport_family = (
_api_request_browser_control_transport_family.get()
)
def _run():
from gateway.session_context import clear_session_vars
@@ -6400,6 +6766,10 @@ class APIServerAdapter(BasePlatformAdapter):
chat_id=session_id or "",
session_key=gateway_session_key or session_id or "",
session_id=session_id or "",
browser_control_principal=request_browser_control_principal,
browser_control_transport_family=(
request_browser_control_transport_family
),
)
agent = None
try:
@@ -6833,6 +7203,12 @@ class APIServerAdapter(BasePlatformAdapter):
# Background task outlives the HTTP response (and thus the middleware
# profile scope). Capture now and re-enter inside the task/executor.
request_profile = _api_request_profile.get()
request_browser_control_principal = (
_api_request_browser_control_principal.get()
)
request_browser_control_transport_family = (
_api_request_browser_control_transport_family.get()
)
async def _run_and_close():
try:
@@ -6922,6 +7298,12 @@ class APIServerAdapter(BasePlatformAdapter):
chat_id=session_id or "",
session_key=approval_session_key,
session_id=session_id or "",
browser_control_principal=(
request_browser_control_principal
),
browser_control_transport_family=(
request_browser_control_transport_family
),
)
register_gateway_notify(approval_session_key, _approval_notify)
# /v1/runs runs its own agent lifecycle (no