Files
hermes-agent/tools/browser_extension_router.py
Teknium 14791b4d4e simplify(compat): approval — drop 43 facade re-exports + _command_detection_variants late-bind seam, repoint 30 callers + 46 test files
tools/approval.py no longer re-exports sibling names (approval_context/prompt/floors/detection/
human_wait/smart/gateway_wait); it imports only what it uses. Siblings reference sibling-defined
names directly (module-attribute reads on tools.approval_context so patching the defining module
still works); only facade-owned state (_lock, _gateway_queues, _permanent_approved, _denied,
_denial_breaker_addendum, _gateway_notify_cb) is still read back through tools.approval.
approval_detection calls its own _command_detection_variants instead of late-binding through the facade.
2026-09-03 13:49:57 -07:00

138 lines
6.2 KiB
Python

"""Registry-level browser extension router.
Agent-side half of browser-extension-control: decides, per ``browser_*`` handler
invocation, whether an attached extension controller (via
:mod:`gateway.browser_control_broker`) or the legacy backend executes it.
Routing contract (see ``tests/tools/test_browser_extension_router.py``):
- Feature off ⇒ legacy, broker never touched, ``fallback()`` called exactly once.
- No server-bound identity ⇒ legacy.
- Bound identity ⇒ authoritative extension lane; missing/ambiguous scope,
disconnect, or capability mismatch fail closed (never jump to another browser).
- Selected controller ⇒ authoritative; its errors propagate, legacy never retried.
- ``args`` is never mutated.
:func:`routed_browser_handler` resolves the flag and broker lazily on every call
so importing this module never pulls in the gateway and config changes apply
without restart.
"""
from __future__ import annotations
import json
import logging
from typing import Any, Callable, Dict, Optional, Tuple
logger = logging.getLogger(__name__)
def _bound_identity() -> Tuple[Optional[str], Optional[str], Optional[str]]:
"""(session_id, principal_id, transport_family) from the session context."""
from gateway.session_context import get_session_env
return tuple( # type: ignore[return-value]
get_session_env(key, "") or None
for key in ("HERMES_SESSION_ID", "HERMES_BROWSER_CONTROL_PRINCIPAL",
"HERMES_BROWSER_CONTROL_TRANSPORT_FAMILY")
)
def _controller_unavailable(message: str) -> Exception:
from gateway.browser_control_broker import ControllerUnavailable
return ControllerUnavailable(message)
def extension_controller_available(action: str) -> bool:
"""Whether this request owns one exact controller capable of ``action``.
Runs during tool-schema assembly inside the request's session context;
consults the process-local broker directly and fails closed on any gap."""
try:
from gateway.browser_control_broker import browser_control_enabled, get_browser_control_broker
if not browser_control_enabled():
return False
session_id, principal_id, transport_family = identity = _bound_identity()
if not all(identity):
return False
broker = get_browser_control_broker()
scope = broker.scope_for_session(session_id=session_id, principal_id=principal_id, transport_family=transport_family)
return scope is not None and broker.select(scope, action) is not None
except Exception:
logger.debug("browser extension availability check failed for %s", action, exc_info=True)
return False
def route_browser_tool(
action: str, args: Dict[str, Any], *, fallback: Callable[[], Any], broker: Any, enabled: bool,
session_id: Optional[str] = None, task_id: Optional[str] = None, principal_id: Optional[str] = None,
transport_family: Optional[str] = None, tool_call_id: Optional[str] = "",
) -> Any:
"""Route one browser action through the extension-control broker.
``broker`` exposes ``scope_for_session(**identity)``, ``select(scope, cap)``
and ``dispatch(scope, *, action, arguments, tool_call_id)``. ``fallback`` is
called exactly once when the feature is off or no server-bound identity
exists; once a controller is selected its result/exception is final.
"""
if not enabled or not str(principal_id or "").strip() or not str(transport_family or "").strip():
return fallback()
identity = dict(session_id=session_id, task_id=task_id, principal_id=principal_id, transport_family=transport_family)
scope = broker.scope_for_session(**identity)
if scope is None:
# A stamped identity only becomes authoritative once a controller has
# registered for the lane; unregistered lanes keep the legacy backend,
# registered-but-offline lanes fail closed.
lane_bound = getattr(broker, "lane_registered", None)
if callable(lane_bound) and not lane_bound(**identity):
return fallback()
raise _controller_unavailable(f"bound browser controller unavailable for {action}")
if broker.select(scope, action) is None:
raise _controller_unavailable(f"bound browser controller cannot execute {action}")
# Controller is authoritative: never retry the legacy backend. Registry
# handlers must return a string; keep string results byte-identical and
# serialize decoded JSON values at this boundary.
result = broker.dispatch(scope, action=action, arguments=args, tool_call_id=tool_call_id)
return result if isinstance(result, str) else json.dumps(result, ensure_ascii=False)
def current_tool_call_id() -> str:
"""Active tool_call_id bound by the agent executor, or ``""`` when none."""
try:
from tools.approval_context import _approval_tool_call_id
return _approval_tool_call_id.get() or ""
except Exception:
return ""
def routed_browser_handler(
action: str, args: Dict[str, Any], *, fallback: Callable[[], Any], task_id: Optional[str] = None,
session_id: Optional[str] = None, principal_id: Optional[str] = None,
transport_family: Optional[str] = None, tool_call_id: Optional[str] = None,
) -> Any:
"""Lazy registry-handler route wrapper for ``browser_*`` tools.
Feature off (or gateway unimportable) ⇒ the legacy handler runs unchanged."""
try:
from gateway.browser_control_broker import browser_control_enabled, get_browser_control_broker
except Exception as exc: # pragma: no cover - defensive, gateway always present
logger.debug("browser extension router unavailable (%s); using legacy backend", exc)
return fallback()
if not browser_control_enabled():
return fallback()
try:
env_session, env_principal, env_transport = _bound_identity()
except Exception:
env_session = env_principal = env_transport = None
return route_browser_tool(
action, args, fallback=fallback, broker=get_browser_control_broker(), enabled=True,
session_id=session_id or env_session, task_id=task_id, principal_id=principal_id or env_principal,
transport_family=transport_family or env_transport,
tool_call_id=current_tool_call_id() if tool_call_id is None else tool_call_id,
)