Files
hermes-agent/gateway/browser_control_broker.py
Teknium 2776813df3 compat(plugins): temporary import-path shims for external plugins — ONE commit, revert on schedule
The Sep 2026 decomposition (PR #102117) makes internal import paths a non-API: names now live in
the focused modules that define them. This commit is the ONLY thing keeping the old paths alive,
so external plugins have time to update. It is deliberately a single, unsquashed commit:

    git revert <this sha>

removes every shim, stub and manifest at once on the announced date. Nothing in-tree may depend on
these pointers: scripts/check_compat_pointers.py (wired into lint.yml) fails CI if it does.

What it adds (see COMPAT_MANIFEST.md, compat_manifest.json):
- 332 facade modules get one delimited `PLUGIN-COMPAT` block appended at the end of the file
- 1,172 moved names resolved lazily via a module `__getattr__` (PEP 562) — never a top-level import,
  so no import cycles; facades that already had `__getattr__` get a chained one
- 592 third-party/stdlib names the old modules used to expose, with their original import statements
- 266 public definitions that had been deleted as unused, restored byte-for-byte from the pre-decomposition
  tree (+40 private helpers and 16 imports pulled in only because a restored definition needs them)
- 3 deleted modules recreated as re-export stubs (gateway/startup_watchdog, hermes_cli/observability/
  relay_runtime, tools/environments/modal_utils)
- private names (`_x`) get no pointer: they were never API (3,792 skipped)

Verified: all 335 touched modules import under a fresh HERMES_HOME and every manifest name resolves;
the lint reports zero in-tree uses; ruff clean; targeted suites unchanged.
2026-09-03 17:13:22 -07:00

526 lines
26 KiB
Python

"""Transport-neutral browser-control broker core: binds an identity-scoped *controller*
(the party driving a browser) to *callers* on any transport. Tickets are short-lived,
single-use, identity-bound and consumed exactly once; ``select`` matches every stable
identity field plus the current capability set; ``complete`` is single-shot; ``detach``
fails pending work closed while ``disconnect`` only marks the transport offline. State
changes happen under one RLock; the send callback runs *outside* it so a controller may
``complete`` from inside its own send."""
from __future__ import annotations
import logging
import secrets
import threading
import time
from dataclasses import dataclass, field
from typing import Any, Callable, Dict, Optional
logger = logging.getLogger(__name__)
_OWNER_UNSET = object()
#: Default lifetime of a minted registration ticket, in clock seconds.
DEFAULT_TICKET_TTL = 30.0
#: Default wall time a dispatch waits for the controller to complete.
DEFAULT_COMMAND_TIMEOUT = 30.0
#: Maximum cancel frames retained while a same-identity controller is offline.
MAX_DEFERRED_CANCELS = 512
#: Current wire protocol version; registration requires this exact int (bools rejected).
BROWSER_CONTROL_PROTOCOL_VERSION = 1
#: Exact controller capability allowlist shared by every transport (raw CDP/eval/console stay out).
BROWSER_CONTROL_CAPABILITIES = frozenset({
"controller.noop", "browser_back", "browser_click", "browser_navigate", "browser_press", "browser_screenshot",
"browser_scroll", "browser_snapshot", "browser_tab_activate", "browser_tabs", "browser_type",
})
#: Privileged capabilities: fail-closed unless Developer Mode is on AND explicitly negotiated.
BROWSER_CONTROL_DEVELOPER_CAPABILITIES = frozenset({"browser_cdp", "browser_evaluate"})
#: Artifact transport capabilities; non-developer because only a store-validated ``artifact_id`` travels.
BROWSER_CONTROL_ARTIFACT_CAPABILITIES = frozenset({"browser_artifact_download", "browser_artifact_upload"})
#: Wire method names for controller frames; transports carry them verbatim.
FRAME_COMMAND = "browser.controller.command"
FRAME_CANCEL = "browser.controller.cancel"
def browser_control_protocol_supported(value: Any) -> bool:
"""Return whether ``value`` names the exact supported wire version."""
return type(value) is int and value == BROWSER_CONTROL_PROTOCOL_VERSION
def _extension_control_flag(config: Optional[dict], key: str) -> bool:
"""Read ``browser.extension_control.<key>`` as a literal ``True`` (default off)."""
if config is None:
try:
# Hot path (every browser tool call): the read-only loader skips load_config()'s deepcopy.
from hermes_cli.config import load_config_readonly
config = load_config_readonly()
except Exception:
return False
browser = config.get("browser") if isinstance(config, dict) else None
extension_control = browser.get("extension_control") if isinstance(browser, dict) else None
return isinstance(extension_control, dict) and extension_control.get(key, False) is True
def browser_control_developer_mode(config: Optional[dict] = None) -> bool:
"""Explicit Developer Mode flag; gates ``browser_evaluate``/raw CDP only."""
return _extension_control_flag(config, "developer_mode")
def browser_control_enabled(config: Optional[dict] = None) -> bool:
"""Return the explicit browser-control feature flag (disabled by default)."""
return _extension_control_flag(config, "enabled")
def filter_browser_control_capabilities(value: Any, *, developer_mode: Optional[bool] = None) -> frozenset:
"""Permitted subset of a capability list (non-list -> empty); developer caps only in Developer Mode."""
if not isinstance(value, list):
return frozenset()
allowed = BROWSER_CONTROL_CAPABILITIES | BROWSER_CONTROL_ARTIFACT_CAPABILITIES
if (browser_control_developer_mode() if developer_mode is None else developer_mode) is True:
allowed |= BROWSER_CONTROL_DEVELOPER_CAPABILITIES
return frozenset(c for c in value if isinstance(c, str) and c in allowed)
class BrowserControlError(Exception):
"""Base class for broker contract failures."""
class ControllerTicketInvalid(BrowserControlError):
"""A registration ticket is unknown, already consumed, or expired."""
class ControllerUnavailable(BrowserControlError):
"""No attached controller exactly matches the requested scope/capability."""
class ControllerCancelled(BrowserControlError):
"""A pending command was cancelled (explicitly or by detach)."""
class ControllerTimeout(BrowserControlError):
"""The controller did not complete the command before the timeout."""
class ControllerRejected(BrowserControlError):
"""The controller completed the command with ``ok=False``."""
@dataclass(frozen=True)
class ControllerScope:
"""Exact controller identity plus capability set; equality is over all fields."""
principal_id: Optional[str] = None
profile_id: Optional[str] = None
session_id: Optional[str] = None
controller_id: Optional[str] = None
browser_profile_id: Optional[str] = None
transport_family: Optional[str] = None
capabilities: frozenset = frozenset()
#: Stable identity fields; negotiated ``capabilities`` are deliberately excluded.
_IDENTITY_FIELDS = ("principal_id", "profile_id", "session_id", "controller_id", "browser_profile_id", "transport_family")
def _same_scope_identity(first: ControllerScope, second: ControllerScope) -> bool:
return all(getattr(first, name) == getattr(second, name) for name in _IDENTITY_FIELDS)
@dataclass(frozen=True)
class Ticket:
"""Opaque, single-use registration credential."""
value: str
expires_at: float
@dataclass
class _TicketRecord:
scope: ControllerScope
expires_at: float
consumed: bool = False
@dataclass
class _Controller:
scope: ControllerScope
send: Callable[[dict], None]
owner: Any = None
connected: bool = True
deferred_cancels: list[dict] = field(default_factory=list)
# Serializes command/cancel writes with detach or replacement; never held with broker state.
send_lock: threading.Lock = field(default_factory=threading.Lock)
@dataclass
class _PendingCommand:
scope: ControllerScope
command_id: str
tool_call_id: Optional[str]
event: threading.Event = field(default_factory=threading.Event)
done: bool = False
cancelled: bool = False
ok: bool = False
result: Any = None
def _cancel_frame(pending: _PendingCommand) -> dict:
return {"method": FRAME_CANCEL, "params": {"command_id": pending.command_id, "tool_call_id": pending.tool_call_id}}
class BrowserControlBroker:
"""Thread-safe broker core; ``clock`` is injectable (default ``time.monotonic``)."""
def __init__(self, *, ticket_ttl: float = DEFAULT_TICKET_TTL, command_timeout: float = DEFAULT_COMMAND_TIMEOUT,
clock: Optional[Callable[[], float]] = None, developer_mode: Optional[bool] = None) -> None:
self._ticket_ttl = ticket_ttl
self._command_timeout = command_timeout
self._clock = clock if clock is not None else time.monotonic
self._lock = threading.RLock()
self._tickets: Dict[str, _TicketRecord] = {}
self._controllers: Dict[ControllerScope, _Controller] = {}
self._pending: Dict[str, _PendingCommand] = {}
# None defers to live config on every selection (so flipping developer_mode off REVOKES
# raw CDP/eval from attached controllers without restart); a bool pins the gate.
self._developer_mode_pinned: Optional[bool] = None if developer_mode is None else developer_mode is True
# Artifact stores keyed by profile id; ``None`` is the default slot.
self._artifact_stores: Dict[Optional[str], Any] = {}
@property
def developer_mode(self) -> bool:
"""Whether privileged capabilities may be selected/dispatched (live config unless pinned)."""
if self._developer_mode_pinned is not None:
return self._developer_mode_pinned
try:
return browser_control_developer_mode()
except Exception:
return False
def attach_artifact_store(self, store: Any, *, profile_id: Optional[str] = None) -> None:
"""Attach a store exposing ``validate(artifact_id, *, scope) -> receipt`` for one profile
(``None`` = default slot); ``store=None`` clears the slot. Artifact actions fail closed without one."""
if store is None:
self._artifact_stores.pop(profile_id, None)
else:
self._artifact_stores[profile_id] = store
def _artifact_store_for_scope(self, scope: "ControllerScope") -> Any:
store = self._artifact_stores.get(getattr(scope, "profile_id", None) or None)
return store if store is not None else self._artifact_stores.get(None)
def mint_ticket(self, scope: ControllerScope) -> Ticket:
"""Mint a short-lived, single-use ticket bound to ``scope``."""
now = self._clock()
with self._lock:
self._tickets = {v: rec for v, rec in self._tickets.items() if rec.expires_at > now}
value = secrets.token_urlsafe(32)
self._tickets[value] = record = _TicketRecord(scope=scope, expires_at=now + self._ticket_ttl)
return Ticket(value=value, expires_at=record.expires_at)
def consume_ticket(self, value: str) -> ControllerScope:
"""Exchange a ticket for its scope exactly once; unknown/consumed/expired -> ControllerTicketInvalid."""
now = self._clock()
with self._lock:
record = self._tickets.get(value)
if record is None:
raise ControllerTicketInvalid("unknown ticket")
if record.consumed:
raise ControllerTicketInvalid("ticket already consumed")
if now > record.expires_at:
raise ControllerTicketInvalid("ticket expired")
record.consumed = True
return record.scope
def _controller_for_identity_locked(self, scope: ControllerScope) -> Optional[_Controller]:
"""Attached controller sharing ``scope``'s stable identity (any capabilities)."""
return next((c for c in self._controllers.values() if _same_scope_identity(c.scope, scope)), None)
def _live_controller(self, scope: ControllerScope) -> Optional[_Controller]:
with self._lock:
controller = self._controller_for_identity_locked(scope)
return controller if controller is not None and controller.connected else None
def attach(self, scope: ControllerScope, send: Callable[[dict], None], *, owner: Any = None) -> None:
"""Attach or refresh the controller for one stable identity: a same-identity reconnect refreshes send
and capabilities without cancelling pending work; a different controller/browser profile in the same
authenticated session lane hard-replaces it."""
while True:
with self._lock:
existing = self._controller_for_identity_locked(scope)
lane_scopes = [
c for c in self._controllers if not _same_scope_identity(c, scope)
and (c.principal_id, c.profile_id, c.session_id, c.transport_family)
== (scope.principal_id, scope.profile_id, scope.session_id, scope.transport_family)
]
if existing is None and not lane_scopes:
self._controllers[scope] = _Controller(scope=scope, send=send, owner=owner)
return
# Hard replacement, not a recoverable reconnect: terminalize the
# lane siblings before inserting so session lookup stays unique.
if lane_scopes:
for lane_scope in lane_scopes:
self.detach(lane_scope, notify_controller=False)
continue
with existing.send_lock:
with self._lock:
if self._controllers.get(existing.scope) is not existing:
continue
self._controllers.pop(existing.scope, None)
existing.scope, existing.send, existing.owner, existing.connected = scope, send, owner, False
for pending in self._pending_for_scope_locked(scope):
pending.scope = scope
deferred, existing.deferred_cancels = existing.deferred_cancels, []
self._controllers[scope] = existing
for index, frame in enumerate(deferred):
try:
send(frame)
continue
except Exception:
logger.exception("failed to flush deferred browser-controller cancel")
with self._lock:
if self._controllers.get(scope) is existing:
existing.deferred_cancels = deferred[index:][-MAX_DEFERRED_CANCELS:]
raise ConnectionError("browser controller reconnect could not flush deferred cancels")
with self._lock:
if self._controllers.get(scope) is existing:
existing.connected = True
return
def select(self, scope: ControllerScope, capability: str) -> Optional[_Controller]:
"""Connected controller matching identity whose *current* negotiated set holds ``capability`` (the
caller's set is not authoritative); developer capabilities are also gated on LIVE Developer Mode."""
if capability in BROWSER_CONTROL_DEVELOPER_CAPABILITIES and not self.developer_mode:
return None
controller = self._live_controller(scope)
return controller if controller is not None and capability in controller.scope.capabilities else None
def is_owner(self, scope: ControllerScope, owner: Any) -> bool:
"""Whether ``owner`` is the exact live transport for ``scope`` (capability-independent)."""
controller = self._live_controller(scope)
return controller is not None and controller.owner is owner
def disconnect(self, scope: ControllerScope, *, owner: Any = _OWNER_UNSET) -> bool:
"""Mark one exact controller transport offline without cancelling work."""
with self._lock:
controller = self._controller_for_identity_locked(scope)
if controller is None:
return False
with controller.send_lock:
with self._lock:
owned = owner is _OWNER_UNSET or controller.owner is owner
if self._controllers.get(controller.scope) is not controller or not owned:
return False
controller.connected, controller.owner = False, None
return True
def detach(self, scope: ControllerScope, *, owner: Any = _OWNER_UNSET, notify_controller: bool = True) -> None:
"""Remove the controller for ``scope`` and fail its pending work closed (ControllerCancelled)."""
with self._lock:
controller = self._controllers.get(scope)
if controller is None or (owner is not _OWNER_UNSET and controller.owner != owner):
return
with controller.send_lock:
with self._lock:
if self._controllers.get(scope) is not controller or (owner is not _OWNER_UNSET and controller.owner != owner):
return
self._controllers.pop(scope, None)
pendings = self._pending_for_scope_locked(scope)
for pending in pendings:
self._resolve_pending(pending, cancelled=True)
# Hold the old generation's send lock through cancellation so a
# command frame can never overtake its terminal cancel frame.
if notify_controller:
self._emit_cancel_frames(controller, pendings)
def dispatch(
self, scope: ControllerScope, *, action: str, arguments: Optional[dict] = None, tool_call_id: Optional[str] = None,
) -> Any:
"""Send one controller command and block for completion; raises ControllerUnavailable/Cancelled/Timeout/
Rejected. Artifact actions also need an attached store and an approved ``artifact_id`` (only the id travels)."""
controller = self.select(scope, action)
if controller is None:
raise ControllerUnavailable(f"no controller for scope {scope!r} with capability {action!r}")
arguments = dict(arguments or {})
if action in BROWSER_CONTROL_ARTIFACT_CAPABILITIES:
self._validate_artifact_reference(scope, action, arguments)
command_id = secrets.token_hex(16)
frame = {"method": FRAME_COMMAND, "params": {
"command_id": command_id, "action": action, "arguments": arguments, "controller_id": scope.controller_id,
"browser_profile_id": scope.browser_profile_id, "tool_call_id": tool_call_id,
}}
pending = _PendingCommand(scope=controller.scope, command_id=command_id, tool_call_id=tool_call_id)
with controller.send_lock:
with self._lock:
# select() ran outside the send lock; revalidate the live
# controller so disconnect/replacement can't strand a command.
if self._controller_for_identity_locked(scope) is not controller or not controller.connected:
raise ControllerUnavailable(f"controller for scope {scope!r} detached before dispatch")
pending.scope = controller.scope
self._pending[command_id] = pending
try:
controller.send(frame)
except Exception:
# Never left the building: unreserve the id, surface the error.
with self._lock:
self._pending.pop(command_id, None)
raise
if not pending.event.wait(timeout=self._command_timeout):
with self._lock:
# Event.wait() may return False at the exact boundary where a
# completion already won and removed the pending command.
timed_out = not pending.done and self._pending.get(command_id) is pending
if timed_out:
pending.done = True
del self._pending[command_id]
if timed_out:
with controller.send_lock:
with self._lock:
active = self._controller_for_identity_locked(scope) or controller
if not active.connected:
self._defer_cancel_locked(active, pending)
active = None
if active is not None:
self._emit_cancel_frames(active, [pending])
raise ControllerTimeout(f"controller did not complete command {command_id!r} within {self._command_timeout}s")
if pending.cancelled:
raise ControllerCancelled(f"command {command_id!r} was cancelled")
if not pending.ok:
raise ControllerRejected(f"controller rejected command {command_id!r}: {pending.result!r}")
return pending.result
def complete(self, command_id: str, *, scope: Optional[ControllerScope] = None, ok: bool, result: Any = None) -> bool:
"""Resolve a pending command by id; ``False`` when none is pending. Safe from inside the send callback."""
with self._lock:
pending = self._pending.get(command_id)
if pending is None or pending.done or (scope is not None and pending.scope != scope):
return False
pending.done, pending.ok, pending.result = True, ok is True, result
del self._pending[command_id]
pending.event.set()
return True
def cancel(self, scope: ControllerScope, *, tool_call_id: Optional[str]) -> bool:
"""Cancel the pending command matching ``scope`` + tool_call_id (one cancel frame); ``False`` if none."""
controller = self._live_controller(scope)
if controller is None:
return False
with controller.send_lock:
with self._lock:
if self._controller_for_identity_locked(scope) is not controller or not controller.connected:
return False
target = next((p for p in self._pending_for_scope_locked(scope)
if p.tool_call_id == tool_call_id and not p.done), None)
if target is None:
return False
self._resolve_pending(target, cancelled=True)
self._emit_cancel_frames(controller, [target])
return True
def _resolve_pending(self, pending: _PendingCommand, *, cancelled: bool) -> None:
pending.cancelled, pending.done = cancelled, True
del self._pending[pending.command_id]
pending.event.set()
def _validate_artifact_reference(self, scope: ControllerScope, action: str, arguments: dict) -> None:
"""Fail closed unless ``arguments`` carries a store-approved artifact id (failures -> ControllerRejected)."""
store = self._artifact_store_for_scope(scope)
if store is None:
raise ControllerRejected(f"{action} requires an attached artifact store")
artifact_id = arguments.get("artifact_id")
if not isinstance(artifact_id, str) or not artifact_id.strip():
raise ControllerRejected(f"{action} requires a non-empty artifact_id")
try:
store.validate(artifact_id.strip(), scope=scope)
except ControllerRejected:
raise
except Exception as exc:
raise ControllerRejected(f"{action} rejected artifact reference {artifact_id!r}: {exc}") from exc
def _defer_cancel_locked(self, controller: _Controller, pending: _PendingCommand) -> None:
controller.deferred_cancels.append(_cancel_frame(pending))
if len(controller.deferred_cancels) > MAX_DEFERRED_CANCELS:
del controller.deferred_cancels[:-MAX_DEFERRED_CANCELS]
def _pending_for_scope_locked(self, scope: ControllerScope) -> list[_PendingCommand]:
return [p for p in list(self._pending.values()) if _same_scope_identity(p.scope, scope)]
def _emit_cancel_frames(self, controller: _Controller, pendings: list[_PendingCommand]) -> None:
"""Send cancel frames (caller holds ``send_lock``, never the broker lock)."""
for pending in pendings:
try:
controller.send(_cancel_frame(pending))
except Exception:
logger.exception("failed to emit cancel frame for command %r", pending.command_id)
def _lane_scopes(self, session_id, task_id, principal_id, transport_family) -> list[ControllerScope]:
"""Attached scopes bound to one session lane (session + principal + transport)."""
key = tuple(str(v or "").strip() for v in (session_id or task_id, principal_id, transport_family))
if not all(key):
return []
with self._lock:
return [s for s in self._controllers if (s.session_id, s.principal_id, s.transport_family) == key]
def scope_for_session(self, *, session_id: Optional[str] = None, task_id: Optional[str] = None,
principal_id: Optional[str] = None, transport_family: Optional[str] = None) -> Optional[ControllerScope]:
"""One unambiguous attached scope for a server-owned session (session id is only a hint; the caller
supplies its server-derived principal + transport family). Missing/ambiguous identity fails closed."""
matches = self._lane_scopes(session_id, task_id, principal_id, transport_family)
return matches[0] if len(matches) == 1 else None
def lane_registered(self, *, session_id: Optional[str] = None, task_id: Optional[str] = None,
principal_id: Optional[str] = None, transport_family: Optional[str] = None) -> bool:
"""Whether ANY controller (even offline) registered for this lane: "bound but unavailable" fails closed
vs "never registered" (caller keeps the legacy backend). Ambiguous lanes report True."""
return bool(self._lane_scopes(session_id, task_id, principal_id, transport_family))
def disconnect_owner(self, owner: Any) -> int:
"""Mark every controller owned by one lost transport offline."""
with self._lock:
scopes = [s for s, c in self._controllers.items() if c.owner is owner]
return sum(int(self.disconnect(scope, owner=owner)) for scope in scopes)
def reset(self) -> None:
"""Fail all live work closed and clear tickets (tests/shutdown)."""
with self._lock:
scopes = list(self._controllers)
for scope in scopes:
self.detach(scope)
with self._lock:
self._tickets.clear()
# Pending entries whose controller a concurrent teardown removed.
for pending in list(self._pending.values()):
self._resolve_pending(pending, cancelled=True)
@property
def ticket_ttl_seconds(self) -> float:
"""Configured lifetime for newly minted one-shot tickets."""
return self._ticket_ttl
@property
def pending_count(self) -> int:
"""Number of commands awaiting completion (diagnostics/tests)."""
with self._lock:
return len(self._pending)
_GLOBAL_BROKER = BrowserControlBroker()
def get_browser_control_broker() -> BrowserControlBroker:
"""Process-local broker shared by API and dashboard Gateway transports."""
return _GLOBAL_BROKER
# ---- 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.
BROWSER_CONTROL_ALL_CAPABILITIES = frozenset(
BROWSER_CONTROL_CAPABILITIES
| BROWSER_CONTROL_ARTIFACT_CAPABILITIES
| BROWSER_CONTROL_DEVELOPER_CAPABILITIES
)
# ---- END PLUGIN-COMPAT ----