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.
138 lines
6.2 KiB
Python
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,
|
|
)
|