From c4ce73edc2d60ed4f3bbcd71d30770b5a78727dd Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Wed, 2 Sep 2026 16:02:42 -0700 Subject: [PATCH] =?UTF-8?q?refactor(browser):=20compact=20sibling=20module?= =?UTF-8?q?s=20=E2=80=94=20supervisor=20event=20dispatch=20inline,=20dialo?= =?UTF-8?q?g=20responder=20unified=20(=5Frespond/=5Frespond=5Fquiet/=5Fcdp?= =?UTF-8?q?=5Fquiet),=20dead=20ConsoleEvent=20ring=20buffer=20removed,=20b?= =?UTF-8?q?rowser=5Fuse=5Fcli=20=5Fquiet=20helper?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tests/tools/test_browser_secret_exfil.py | 1 - tools/browser_cdp_tool.py | 172 +++++---------- tools/browser_dialog_tool.py | 35 +-- tools/browser_extension_router.py | 60 ++--- tools/browser_supervisor.py | 187 +++++----------- tools/browser_supervisor_dialogs.py | 270 ++++++++--------------- tools/browser_supervisor_frames.py | 102 +++------ tools/browser_use_cli.py | 196 +++++++--------- 8 files changed, 333 insertions(+), 690 deletions(-) diff --git a/tests/tools/test_browser_secret_exfil.py b/tests/tools/test_browser_secret_exfil.py index 5a15d310a1..0d5efb4274 100644 --- a/tests/tools/test_browser_secret_exfil.py +++ b/tests/tools/test_browser_secret_exfil.py @@ -296,7 +296,6 @@ class TestBrowserSupervisorRedaction: closed_by="agent", ),), frame_tree={"top": {"frame_id": "f1", "url": "about:blank", "origin": "null", "is_oopif": False}}, - console_errors=(), active=True, cdp_url="ws://example.invalid/devtools/browser/mock", task_id="test", diff --git a/tools/browser_cdp_tool.py b/tools/browser_cdp_tool.py index 59db34baa3..dd585bb53f 100644 --- a/tools/browser_cdp_tool.py +++ b/tools/browser_cdp_tool.py @@ -32,7 +32,6 @@ _CDP_PRIVATE_PAGE_ALLOWED_METHODS = { "Page.stopLoading", } - # method → result paths that are ALWAYS opaque base64 (protocol-declared binary). # redact_sensitive_text's Fernet pattern ("gAAAA" + base64 alphabet) can match # arbitrary spans inside such payloads and corrupt the decoded bytes; the payload @@ -55,49 +54,41 @@ _CDP_FLAGGED_BINARY_PATHS: Dict[str, tuple] = { } -def _redact_cdp_output( - value: Any, - *, - always_paths: tuple = (), - flagged_paths: tuple = (), -) -> Any: +def _redact_cdp_output(value: Any, *, always_paths: tuple = (), flagged_paths: tuple = ()) -> Any: """Redact browser-originated CDP result text; opaque bytes stay byte-identical. Exemptions come ONLY from the calling method's spec as exact result paths. Path suffixes propagate only into the matching subtree, so ``base64Encoded`` is honored solely as a sibling on the trusted carrier object — never as - ambient trust a ``Runtime.evaluate`` by-value object could spoof (#94142). + ambient trust a ``Runtime.evaluate`` by-value object could spoof. """ from agent.redact import redact_sensitive_text if isinstance(value, str): return redact_sensitive_text(value, force=True) - if isinstance(value, list): - return [_redact_cdp_output(item) for item in value] - if isinstance(value, tuple): - return tuple(_redact_cdp_output(item) for item in value) - if isinstance(value, dict): - base64_flagged = value.get("base64Encoded") is True + if isinstance(value, (list, tuple)): + return type(value)(_redact_cdp_output(item) for item in value) + if not isinstance(value, dict): + return value + base64_flagged = value.get("base64Encoded") is True - def leaf(paths: tuple, key: str) -> bool: - return any(len(p) == 1 and p[0] == key for p in paths) + def leaf(paths: tuple, key: str) -> bool: + return any(len(p) == 1 and p[0] == key for p in paths) - def descend(paths: tuple, key: str) -> tuple: - return tuple(p[1:] for p in paths if len(p) > 1 and p[0] == key) + def descend(paths: tuple, key: str) -> tuple: + return tuple(p[1:] for p in paths if len(p) > 1 and p[0] == key) + + redacted: Dict[str, Any] = {} + for key, item in value.items(): + opaque = leaf(always_paths, key) or (leaf(flagged_paths, key) and base64_flagged) + if isinstance(item, str) and opaque: + redacted[key] = item + else: + redacted[key] = _redact_cdp_output( + item, always_paths=descend(always_paths, key), flagged_paths=descend(flagged_paths, key), + ) + return redacted - redacted: Dict[str, Any] = {} - for key, item in value.items(): - opaque = leaf(always_paths, key) or (leaf(flagged_paths, key) and base64_flagged) - if isinstance(item, str) and opaque: - redacted[key] = item - else: - redacted[key] = _redact_cdp_output( - item, - always_paths=descend(always_paths, key), - flagged_paths=descend(flagged_paths, key), - ) - return redacted - return value # ``websockets`` is a direct dependency; wrap so a stale env yields a clean error. try: @@ -117,13 +108,11 @@ def _run_async(coro): loop = asyncio.get_running_loop() except RuntimeError: loop = None - if loop and loop.is_running(): import concurrent.futures with concurrent.futures.ThreadPoolExecutor(max_workers=1) as pool: - future = pool.submit(asyncio.run, coro) - return future.result() + return pool.submit(asyncio.run, coro).result() return asyncio.run(coro) @@ -163,30 +152,25 @@ _METHOD_PARAM_GUARDS = { } -def _browser_cdp_private_guard( - *, - task_id: str, - method: str, - params: Dict[str, Any], -) -> Optional[str]: +def _browser_cdp_private_guard(*, task_id: str, method: str, params: Dict[str, Any]) -> Optional[str]: """Apply the browser SSRF/private-page guard to raw CDP calls. Raw CDP shares the cloud/private-network boundary of ``browser_snapshot`` / ``browser_console`` / ``browser_eval`` and must not become their bypass. + Guard probes are best-effort; a probe failure never breaks local/custom CDP + workflows. """ try: from tools import browser_tool as bt # type: ignore[import-not-found] if not bt._eval_ssrf_guard_active(task_id): # type: ignore[attr-defined] return None - guard = _METHOD_PARAM_GUARDS.get(method) if guard is not None: probe, template = guard literal = probe(bt, params or {}) if literal: return _blocked(template.format(literal), method) - if method not in _CDP_PRIVATE_PAGE_ALLOWED_METHODS: blocked_url = bt._current_page_private_url(task_id) # type: ignore[attr-defined] if blocked_url: @@ -197,17 +181,12 @@ def _browser_cdp_private_guard( method, ) except Exception as exc: # noqa: BLE001 - # Guard probes are best-effort; never break local/custom CDP workflows. logger.debug("browser_cdp: private-page guard probe failed: %s", exc) return None async def _cdp_call( - ws_url: str, - method: str, - params: Dict[str, Any], - target_id: Optional[str], - timeout: float, + ws_url: str, method: str, params: Dict[str, Any], target_id: Optional[str], timeout: float, ) -> Dict[str, Any]: """Make a single CDP call, optionally attaching to a target first. @@ -232,22 +211,18 @@ async def _cdp_call( next_id += 1 await ws.send(json.dumps({"id": call_id, **req})) deadline = asyncio.get_running_loop().time() + timeout - while True: + while True: # ignore events / out-of-order responses remaining = deadline - asyncio.get_running_loop().time() if remaining <= 0: raise TimeoutError(f"Timed out {what}") msg = json.loads(await asyncio.wait_for(ws.recv(), timeout=remaining)) if msg.get("id") == call_id: return msg - # Ignore events / out-of-order responses session_id: Optional[str] = None if target_id: msg = await _send( - { - "method": "Target.attachToTarget", - "params": {"targetId": target_id, "flatten": True}, - }, + {"method": "Target.attachToTarget", "params": {"targetId": target_id, "flatten": True}}, f"attaching to target {target_id}", ) if "error" in msg: @@ -266,11 +241,7 @@ async def _cdp_call( def _browser_cdp_via_supervisor( - task_id: str, - frame_id: str, - method: str, - params: Optional[Dict[str, Any]], - timeout: float, + task_id: str, frame_id: str, method: str, params: Optional[Dict[str, Any]], timeout: float, ) -> str: """Route a CDP call through the live supervisor session for an OOPIF frame.""" try: @@ -291,11 +262,9 @@ def _browser_cdp_via_supervisor( f"frame_tree with frame_ids you can pass here." ) - snap = supervisor.snapshot() - tree = snap.frame_tree + tree = supervisor.snapshot().frame_tree frame_info: Optional[Dict[str, Any]] = next( - (f for f in [tree.get("top"), *(tree.get("children") or [])] - if f and f.get("frame_id") == frame_id), + (f for f in [tree.get("top"), *(tree.get("children") or [])] if f and f.get("frame_id") == frame_id), None, ) if frame_info is None: @@ -304,7 +273,6 @@ def _browser_cdp_via_supervisor( raw = supervisor._frames.get(frame_id) # type: ignore[attr-defined] if raw is not None: frame_info = raw.to_dict() - if frame_info is None: return tool_error( f"frame_id {frame_id!r} not found in supervisor state. " @@ -325,10 +293,7 @@ def _browser_cdp_via_supervisor( loop = supervisor._loop # type: ignore[attr-defined] if loop is None or not loop.is_running(): - return tool_error( - "CDP supervisor loop is not running. Try reconnecting with " - "/browser connect." - ) + return tool_error("CDP supervisor loop is not running. Try reconnecting with /browser connect.") try: from agent.async_utils import safe_schedule_threadsafe @@ -337,25 +302,20 @@ def _browser_cdp_via_supervisor( loop, ) if fut is None: - return tool_error( - "CDP call via supervisor failed: loop unavailable", - cdp_docs=CDP_DOCS_URL, - ) + return tool_error("CDP call via supervisor failed: loop unavailable", cdp_docs=CDP_DOCS_URL) result_msg = fut.result(timeout=timeout + 2) except Exception as exc: return tool_error( - f"CDP call via supervisor failed: {type(exc).__name__}: {exc}", - cdp_docs=CDP_DOCS_URL, + f"CDP call via supervisor failed: {type(exc).__name__}: {exc}", cdp_docs=CDP_DOCS_URL, ) - payload: Dict[str, Any] = { + return json.dumps({ "success": True, "method": method, "frame_id": frame_id, "session_id": child_sid, "result": result_msg.get("result", {}), - } - return json.dumps(payload, ensure_ascii=False) + }, ensure_ascii=False) def browser_cdp( @@ -372,40 +332,26 @@ def browser_cdp( (OOPIF from ``browser_snapshot.frame_tree``) routes through the supervisor's live WebSocket instead — the only reliable way to evaluate inside an iframe on backends where fresh per-call connections hit signed-URL expiry - (Browserbase). Returns JSON ``{"success": True, "method", "result"}`` or - ``{"error": ...}``. + (Browserbase). Both paths share the same private-page/SSRF guard. Returns + JSON ``{"success": True, "method", "result"}`` or ``{"error": ...}``. """ effective_task_id = task_id or "default" if frame_id: - # Same private-page/SSRF boundary as the stateless path below. - blocked = _browser_cdp_private_guard( - task_id=effective_task_id, - method=method, - params=params or {}, - ) + blocked = _browser_cdp_private_guard(task_id=effective_task_id, method=method, params=params or {}) if blocked: return blocked return _browser_cdp_via_supervisor( - task_id=effective_task_id, - frame_id=frame_id, - method=method, - params=params, - timeout=timeout, + task_id=effective_task_id, frame_id=frame_id, method=method, params=params, timeout=timeout, ) if not method or not isinstance(method, str): - return tool_error( - "'method' is required (e.g. 'Target.getTargets')", - cdp_docs=CDP_DOCS_URL, - ) - + return tool_error("'method' is required (e.g. 'Target.getTargets')", cdp_docs=CDP_DOCS_URL) if not _WS_AVAILABLE: return tool_error( "The 'websockets' Python package is required but not installed. " "Install it with: pip install websockets" ) - endpoint = _resolve_cdp_endpoint() if not endpoint: return tool_error( @@ -415,7 +361,6 @@ def browser_cdp( "and does not expose CDP.", cdp_docs=CDP_DOCS_URL, ) - if not endpoint.startswith(("ws://", "wss://")): return tool_error( f"CDP endpoint is not a WebSocket URL: {endpoint!r}. " @@ -423,18 +368,11 @@ def browser_cdp( "resolver should have rewritten this. Check that a Chromium-family " "browser is actually listening on the debug port." ) - call_params: Dict[str, Any] = params or {} if not isinstance(call_params, dict): - return tool_error( - f"'params' must be an object/dict, got {type(call_params).__name__}" - ) + return tool_error(f"'params' must be an object/dict, got {type(call_params).__name__}") - blocked = _browser_cdp_private_guard( - task_id=effective_task_id, - method=method, - params=call_params, - ) + blocked = _browser_cdp_private_guard(task_id=effective_task_id, method=method, params=call_params) if blocked: return blocked @@ -445,14 +383,9 @@ def browser_cdp( safe_timeout = max(1.0, min(safe_timeout, 300.0)) try: - result = _run_async( - _cdp_call(endpoint, method, call_params, target_id, safe_timeout) - ) + result = _run_async(_cdp_call(endpoint, method, call_params, target_id, safe_timeout)) except asyncio.TimeoutError as exc: - return tool_error( - f"CDP call timed out after {safe_timeout}s: {exc}", - method=method, - ) + return tool_error(f"CDP call timed out after {safe_timeout}s: {exc}", method=method) except (TimeoutError, RuntimeError) as exc: return tool_error(str(exc), method=method) except WebSocketException as exc: @@ -463,10 +396,7 @@ def browser_cdp( ) except Exception as exc: # pragma: no cover — unexpected logger.exception("browser_cdp unexpected error") - return tool_error( - f"Unexpected error: {type(exc).__name__}: {exc}", - method=method, - ) + return tool_error(f"Unexpected error: {type(exc).__name__}: {exc}", method=method) payload: Dict[str, Any] = { "success": True, @@ -586,6 +516,8 @@ def _browser_cdp_check() -> bool: Camofox (REST-only), default local agent-browser (hidden CDP port) and cloud providers whose per-session ``cdp_url`` isn't surfaced are gated out. Thin wrapper so ``registry.register`` stays a top-level statement (AST scan). + Raw (no-I/O) gate: check_fns run at every startup; resolving the endpoint + over HTTP here would block launch on a stale endpoint. """ try: from tools.browser_tool import ( # type: ignore[import-not-found] @@ -595,11 +527,7 @@ def _browser_cdp_check() -> bool: except ImportError as exc: # pragma: no cover — defensive logger.debug("browser_cdp check: browser_tool import failed: %s", exc) return False - if not check_browser_requirements(): - return False - # Raw (no-I/O) gate: check_fns run at every startup; resolving the - # endpoint over HTTP here would block launch on a stale endpoint. - return bool(_get_cdp_override_raw()) + return bool(check_browser_requirements() and _get_cdp_override_raw()) registry.register( diff --git a/tools/browser_dialog_tool.py b/tools/browser_dialog_tool.py index c77d48201e..b1e3d7a8c5 100644 --- a/tools/browser_dialog_tool.py +++ b/tools/browser_dialog_tool.py @@ -80,31 +80,18 @@ def browser_dialog( """Respond to a pending dialog on the active task's CDP supervisor.""" supervisor = SUPERVISOR_REGISTRY.get(task_id or "default") if supervisor is None: - return json.dumps( - { - "success": False, - "error": ( - "No CDP supervisor is attached to this task. Either the " - "browser backend doesn't expose CDP (Camofox, default " - "Playwright) or no browser session has been started yet. " - "Call browser_navigate or /browser connect first." - ), - } - ) - - result = supervisor.respond_to_dialog( - action=action, - prompt_text=prompt_text, - dialog_id=dialog_id, - ) + return json.dumps({ + "success": False, + "error": ( + "No CDP supervisor is attached to this task. Either the " + "browser backend doesn't expose CDP (Camofox, default " + "Playwright) or no browser session has been started yet. " + "Call browser_navigate or /browser connect first." + ), + }) + result = supervisor.respond_to_dialog(action=action, prompt_text=prompt_text, dialog_id=dialog_id) if result.get("ok"): - return json.dumps( - { - "success": True, - "action": action, - "dialog": result.get("dialog", {}), - } - ) + return json.dumps({"success": True, "action": action, "dialog": result.get("dialog", {})}) return json.dumps({"success": False, "error": result.get("error", "unknown error")}) diff --git a/tools/browser_extension_router.py b/tools/browser_extension_router.py index c84e865513..cdf5ac7eab 100644 --- a/tools/browser_extension_router.py +++ b/tools/browser_extension_router.py @@ -51,10 +51,7 @@ def extension_controller_available(action: str) -> bool: 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, - ) + from gateway.browser_control_broker import browser_control_enabled, get_browser_control_broker if not browser_control_enabled(): return False @@ -63,17 +60,11 @@ def extension_controller_available(action: str) -> bool: return False broker = get_browser_control_broker() scope = broker.scope_for_session( - session_id=session_id, - principal_id=principal_id, - transport_family=transport_family, + 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, - ) + logger.debug("browser extension availability check failed for %s", action, exc_info=True) return False @@ -97,17 +88,11 @@ def route_browser_tool( 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: - return fallback() - - if not str(principal_id or "").strip() or not str(transport_family or "").strip(): + 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, + 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: @@ -117,25 +102,16 @@ def route_browser_tool( 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}" - ) + raise _controller_unavailable(f"bound browser controller unavailable for {action}") - controller = broker.select(scope, action) - if controller is None: - raise _controller_unavailable( - f"bound browser controller cannot execute {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 - ) - if isinstance(result, str): - return result - return json.dumps(result, ensure_ascii=False) + 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: @@ -164,23 +140,13 @@ def routed_browser_handler( Feature off (or gateway unimportable) ⇒ the legacy handler runs unchanged. """ try: - from gateway.browser_control_broker import ( - browser_control_enabled, - get_browser_control_broker, - ) + 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, - ) + logger.debug("browser extension router unavailable (%s); using legacy backend", exc) return fallback() - if not browser_control_enabled(): return fallback() - if tool_call_id is None: - tool_call_id = current_tool_call_id() - try: env_session, env_principal, env_transport = _bound_identity() except Exception: @@ -196,5 +162,5 @@ def routed_browser_handler( task_id=task_id, principal_id=principal_id or env_principal, transport_family=transport_family or env_transport, - tool_call_id=tool_call_id, + tool_call_id=current_tool_call_id() if tool_call_id is None else tool_call_id, ) diff --git a/tools/browser_supervisor.py b/tools/browser_supervisor.py index 5db78eda38..fe595aa73d 100644 --- a/tools/browser_supervisor.py +++ b/tools/browser_supervisor.py @@ -57,12 +57,9 @@ if TYPE_CHECKING: logger = logging.getLogger(__name__) -# Ring buffer of recent console-level events. -CONSOLE_HISTORY_MAX = 50 - def _redact_cdp_error_text(exc: object) -> str: - """Redact CDP endpoint credentials from an exception's string form. + """Redact CDP endpoint credentials from an exception's (or URL's) string form. ``websockets`` bakes the raw target URL (``?token=`` / ``user:pass@``) into its exception messages. Every egress point that turns such an exception into @@ -91,17 +88,8 @@ def _schedule(coro, loop, *, timeout: float): return fut.result(timeout=timeout) -# ── Data model ──────────────────────────────────────────────────────────────── - - -@dataclass -class ConsoleEvent: - """Ring buffer entry for console + exception traffic.""" - - ts: float - level: str # "log" | "error" | "warning" | "exception" - text: str - url: Optional[str] = None +def _err(exc: BaseException) -> Dict[str, Any]: + return {"ok": False, "error": f"{type(exc).__name__}: {exc}"} @dataclass(frozen=True) @@ -111,7 +99,6 @@ class SupervisorSnapshot: pending_dialogs: Tuple[PendingDialog, ...] recent_dialogs: Tuple[DialogRecord, ...] frame_tree: Dict[str, Any] - console_errors: Tuple[ConsoleEvent, ...] active: bool # False if supervisor is detached/stopped cdp_url: str task_id: str @@ -163,7 +150,6 @@ class CDPSupervisor(DialogSupervisionMixin, FrameTrackingMixin): self._pending_dialogs: Dict[str, PendingDialog] = {} self._recent_dialogs: List[DialogRecord] = [] self._frames: Dict[str, FrameInfo] = {} - self._console_events: List[ConsoleEvent] = [] self._active = False # Supervisor loop machinery — populated in start(). @@ -197,21 +183,14 @@ class CDPSupervisor(DialogSupervisionMixin, FrameTrackingMixin): self._start_error = None self._stop_requested = False self._thread = threading.Thread( - target=self._thread_main, - name=f"cdp-supervisor-{self.task_id}", - daemon=True, + target=self._thread_main, name=f"cdp-supervisor-{self.task_id}", daemon=True, ) self._thread.start() if not self._ready_event.wait(timeout=timeout): self.stop() - try: - from agent.redact import redact_cdp_url - _safe_url = redact_cdp_url(self.cdp_url) - except Exception: - _safe_url = "" raise TimeoutError( f"CDP supervisor did not attach within {timeout}s " - f"(cdp_url={_safe_url[:80]}...)" + f"(cdp_url={_redact_cdp_error_text(self.cdp_url)[:80]}...)" ) if self._start_error is not None: err = self._start_error @@ -247,7 +226,6 @@ class CDPSupervisor(DialogSupervisionMixin, FrameTrackingMixin): pending_dialogs=tuple(self._pending_dialogs.values()), recent_dialogs=tuple(self._recent_dialogs[-RECENT_DIALOGS_MAX:]), frame_tree=self._build_frame_tree_locked(), - console_errors=tuple(self._console_events[-CONSOLE_HISTORY_MAX:]), active=self._active, cdp_url=self.cdp_url, task_id=self.task_id, @@ -309,7 +287,7 @@ class CDPSupervisor(DialogSupervisionMixin, FrameTrackingMixin): except _LoopUnavailable as e: return {"ok": False, "error": str(e)} except Exception as e: - return {"ok": False, "error": f"{type(e).__name__}: {e}"} + return _err(e) return {"ok": True, "dialog": dialog.to_dict()} def evaluate_runtime( @@ -362,13 +340,12 @@ class CDPSupervisor(DialogSupervisionMixin, FrameTrackingMixin): # CDP's recursion guard with the protocol-level error ``Object # reference chain is too long``. Retry once with returnByValue=False # so Chrome returns the description string instead of failing. - if return_by_value and "reference chain is too long" in str(exc).lower(): - try: - response = _run_eval(False) - except Exception as exc2: - return {"ok": False, "error": f"{type(exc2).__name__}: {exc2}"} - else: - return {"ok": False, "error": f"{type(exc).__name__}: {exc}"} + if not (return_by_value and "reference chain is too long" in str(exc).lower()): + return _err(exc) + try: + response = _run_eval(False) + except Exception as exc2: + return _err(exc2) # Response: {"result": {"result": {"type", "value", ...}, "exceptionDetails"?}} result_payload = response.get("result", {}) if isinstance(response, dict) else {} @@ -382,7 +359,6 @@ class CDPSupervisor(DialogSupervisionMixin, FrameTrackingMixin): result_obj = result_payload.get("result", {}) result_type = result_obj.get("type", "undefined") - if "value" in result_obj: value = result_obj["value"] elif result_type == "undefined": @@ -391,7 +367,6 @@ class CDPSupervisor(DialogSupervisionMixin, FrameTrackingMixin): # Non-serializable (functions, DOM nodes…) — give the model the # browser's description so it gets *something*. value = result_obj.get("description") or result_obj.get("unserializableValue") - return {"ok": True, "result": value, "result_type": result_type} # ── Supervisor loop internals ──────────────────────────────────────────── @@ -404,13 +379,10 @@ class CDPSupervisor(DialogSupervisionMixin, FrameTrackingMixin): asyncio.set_event_loop(loop) loop.run_until_complete(self._run()) except BaseException as e: # noqa: BLE001 — propagate via _start_error - if not self._ready_event.is_set(): - self._start_error = e - self._ready_event.set() - else: + if not self._fail_start(e): logger.warning("CDP supervisor %s crashed: %s", self.task_id, e) finally: - # Flush remaining tasks before closing the loop to avoid + # Cancel + flush remaining tasks before closing the loop to avoid # "Task was destroyed but it is pending" warnings. try: pending = [t for t in asyncio.all_tasks(loop) if not t.done()] @@ -418,19 +390,23 @@ class CDPSupervisor(DialogSupervisionMixin, FrameTrackingMixin): t.cancel() if pending: loop.run_until_complete(asyncio.gather(*pending, return_exceptions=True)) - except Exception: - pass - try: loop.close() except Exception: pass with self._state_lock: self._active = False + def _fail_start(self, e: BaseException) -> bool: + """Propagate ``e`` to ``start()`` if we never got ready; True if it was consumed.""" + if self._ready_event.is_set(): + return False + self._start_error = e + self._ready_event.set() + return True + async def _close_ws(self) -> None: """Detach and close the current WebSocket, swallowing close errors.""" - ws = self._ws - self._ws = None + ws, self._ws = self._ws, None if ws is not None: try: await ws.close() @@ -442,7 +418,8 @@ class CDPSupervisor(DialogSupervisionMixin, FrameTrackingMixin): Browserbase tears down the CDP socket every time a short-lived client (e.g. agent-browser's per-command CDP client) disconnects, so on drop we - reset per-session ids, re-attach, and keep going. + reset per-session ids, re-attach, and keep going. A failure before the + first successful attach is fatal for ``start()``. """ attempt = 0 last_success_at = 0.0 @@ -451,15 +428,11 @@ class CDPSupervisor(DialogSupervisionMixin, FrameTrackingMixin): while not self._stop_requested: try: self._ws = await asyncio.wait_for( - websockets.connect(self.cdp_url, max_size=50 * 1024 * 1024), - timeout=10.0, + websockets.connect(self.cdp_url, max_size=50 * 1024 * 1024), timeout=10.0, ) except Exception as e: attempt += 1 - if not self._ready_event.is_set(): - # Never connected once — fatal for start(). - self._start_error = e - self._ready_event.set() + if self._fail_start(e): return logger.warning( "CDP supervisor %s: connect failed (attempt %s): %s", @@ -481,20 +454,14 @@ class CDPSupervisor(DialogSupervisionMixin, FrameTrackingMixin): self._active = True last_success_at = time.time() backoff = 0.5 # reset after a successful attach - if not self._ready_event.is_set(): - self._ready_event.set() + self._ready_event.set() await reader_task except BaseException as e: - if not self._ready_event.is_set(): - # Never got to ready — propagate to start(). - self._start_error = e - self._ready_event.set() + if self._fail_start(e): raise logger.warning( "CDP supervisor %s: session dropped after %.1fs: %s", - self.task_id, - time.time() - last_success_at, - _redact_cdp_error_text(e), + self.task_id, time.time() - last_success_at, _redact_cdp_error_text(e), ) finally: with self._state_lock: @@ -505,22 +472,19 @@ class CDPSupervisor(DialogSupervisionMixin, FrameTrackingMixin): await reader_task except (asyncio.CancelledError, Exception): pass - for handle in list(self._dialog_watchdogs.values()): + for handle in self._dialog_watchdogs.values(): handle.cancel() self._dialog_watchdogs.clear() await self._close_ws() if self._stop_requested: return - - logger.debug( - "CDP supervisor %s: reconnecting in %.1fs...", self.task_id, backoff, - ) + logger.debug("CDP supervisor %s: reconnecting in %.1fs...", self.task_id, backoff) await asyncio.sleep(backoff) backoff = min(backoff * 2, 10.0) async def _attach_initial_page(self) -> None: - """Find a page target, attach flattened session, enable domains, install dialog bridge.""" + """Find (or create) a page target, attach flattened, enable domains, install dialog bridge.""" resp = await self._cdp("Target.getTargets") targets = resp.get("result", {}).get("targetInfos", []) page_target = next((t for t in targets if t.get("type") == "page"), None) @@ -529,11 +493,7 @@ class CDPSupervisor(DialogSupervisionMixin, FrameTrackingMixin): target_id = created["result"]["targetId"] else: target_id = page_target["targetId"] - - attach = await self._cdp( - "Target.attachToTarget", - {"targetId": target_id, "flatten": True}, - ) + attach = await self._cdp("Target.attachToTarget", {"targetId": target_id, "flatten": True}) self._page_session_id = attach["result"]["sessionId"] await self._enable_page_domains(self._page_session_id, timeout=10.0) await self._install_dialog_bridge(self._page_session_id) @@ -565,7 +525,7 @@ class CDPSupervisor(DialogSupervisionMixin, FrameTrackingMixin): self._pending_calls.pop(call_id, None) async def _read_loop(self) -> None: - """Continuously dispatch incoming CDP frames.""" + """Continuously dispatch incoming CDP frames (responses → futures, events → handlers).""" assert self._ws is not None try: async for raw in self._ws: @@ -580,58 +540,23 @@ class CDPSupervisor(DialogSupervisionMixin, FrameTrackingMixin): fut = self._pending_calls.pop(msg["id"], None) if fut is not None and not fut.done(): if "error" in msg: - fut.set_exception( - RuntimeError(f"CDP error on id={msg['id']}: {msg['error']}") - ) + fut.set_exception(RuntimeError(f"CDP error on id={msg['id']}: {msg['error']}")) else: fut.set_result(msg) elif "method" in msg: - await self._on_event(msg["method"], msg.get("params", {}), msg.get("sessionId")) + handler = self._EVENT_HANDLERS.get(msg["method"]) + if handler is not None: + result = handler(self, msg.get("params", {}), msg.get("sessionId")) + if result is not None: + await result except Exception as e: logger.debug("CDP read loop exited: %s", e) - # ── Event dispatch ────────────────────────────────────────────────────── - - async def _on_event( - self, method: str, params: Dict[str, Any], session_id: Optional[str] - ) -> None: - handler = self._EVENT_HANDLERS.get(method) - if handler is None: - return - result = handler(self, params, session_id) - if result is not None: - await result - - # ── Console / exception ring buffer ───────────────────────────────────── - - def _on_console(self, params: Dict[str, Any], *, level_from: str) -> None: - if level_from == "exception": - details = params.get("exceptionDetails") or {} - text = str(details.get("text") or "") - url = details.get("url") - event = ConsoleEvent(ts=time.time(), level="exception", text=text, url=url) - else: - raw_level = str(params.get("type") or "log") - level = "error" if raw_level in {"error", "assert"} else ( - "warning" if raw_level == "warning" else "log" - ) - args = params.get("args") or [] - parts: List[str] = [] - for a in args[:4]: - if isinstance(a, dict): - parts.append(str(a.get("value") or a.get("description") or "")) - event = ConsoleEvent(ts=time.time(), level=level, text=" ".join(parts)) - with self._state_lock: - self._console_events.append(event) - self._console_events = _trim_ring(self._console_events, CONSOLE_HISTORY_MAX) - # CDP event → handler(self, params, session_id). Async handlers return an - # awaitable that ``_on_event`` awaits; sync handlers return None. + # awaitable that ``_read_loop`` awaits; sync handlers return None. _EVENT_HANDLERS: Dict[str, Callable[..., Any]] = { **DialogSupervisionMixin.EVENT_HANDLERS, **FrameTrackingMixin.EVENT_HANDLERS, - "Runtime.consoleAPICalled": lambda self, p, _sid: self._on_console(p, level_from="api"), - "Runtime.exceptionThrown": lambda self, p, _sid: self._on_console(p, level_from="exception"), } @@ -664,27 +589,26 @@ class _SupervisorRegistry: ) -> CDPSupervisor: """Idempotently ensure a supervisor is running for ``(task_id, cdp_url)``. - An existing supervisor bound to a different ``cdp_url`` (or unhealthy) - is stopped and replaced. + An existing supervisor bound to a different ``cdp_url`` (or unhealthy: + dead thread / stopped loop) is stopped and replaced. """ with self._lock: existing = self._by_task.get(task_id) if existing is not None: - if existing.cdp_url == cdp_url: - thread_ok = existing._thread is not None and existing._thread.is_alive() - loop_ok = existing._loop is not None and existing._loop.is_running() - if thread_ok and loop_ok: - return existing - # URL changed or unhealthy — tear down, fall through to re-create. + thread, loop = existing._thread, existing._loop + if ( + existing.cdp_url == cdp_url + and thread is not None and thread.is_alive() + and loop is not None and loop.is_running() + ): + return existing self._by_task.pop(task_id, None) if existing is not None: existing.stop() supervisor = CDPSupervisor( - task_id=task_id, - cdp_url=cdp_url, - dialog_policy=dialog_policy, - dialog_timeout_s=dialog_timeout_s, + task_id=task_id, cdp_url=cdp_url, + dialog_policy=dialog_policy, dialog_timeout_s=dialog_timeout_s, ) supervisor.start(timeout=start_timeout) with self._lock: @@ -706,9 +630,9 @@ class _SupervisorRegistry: def stop_all(self) -> None: """Stop every running supervisor. For shutdown / test teardown.""" with self._lock: - items = list(self._by_task.items()) + items = list(self._by_task.values()) self._by_task.clear() - for _, supervisor in items: + for supervisor in items: supervisor.stop() @@ -717,7 +641,6 @@ SUPERVISOR_REGISTRY = _SupervisorRegistry() __all__ = [ "CDPSupervisor", - "ConsoleEvent", "DEFAULT_DIALOG_POLICY", "DEFAULT_DIALOG_TIMEOUT_S", "DIALOG_POLICY_AUTO_ACCEPT", diff --git a/tools/browser_supervisor_dialogs.py b/tools/browser_supervisor_dialogs.py index 017b3c3152..cb825c509b 100644 --- a/tools/browser_supervisor_dialogs.py +++ b/tools/browser_supervisor_dialogs.py @@ -22,7 +22,7 @@ import json import logging import time from dataclasses import dataclass -from typing import Any, Callable, Coroutine, Dict, Optional +from typing import Any, Callable, Dict, Optional from urllib.parse import parse_qs, urlparse # Logger-name parity with the origin module (records must look unchanged). @@ -46,11 +46,9 @@ def _trim_ring(events: list, keep: int) -> list: DIALOG_POLICY_MUST_RESPOND = "must_respond" DIALOG_POLICY_AUTO_DISMISS = "auto_dismiss" DIALOG_POLICY_AUTO_ACCEPT = "auto_accept" - _VALID_POLICIES = frozenset( {DIALOG_POLICY_MUST_RESPOND, DIALOG_POLICY_AUTO_DISMISS, DIALOG_POLICY_AUTO_ACCEPT} ) - DEFAULT_DIALOG_POLICY = DIALOG_POLICY_MUST_RESPOND DEFAULT_DIALOG_TIMEOUT_S = 300.0 @@ -64,7 +62,12 @@ RECENT_DIALOGS_MAX = 20 DIALOG_BRIDGE_HOST = "hermes-dialog-bridge.invalid" DIALOG_BRIDGE_URL_PATTERN = f"http://{DIALOG_BRIDGE_HOST}/*" -# Injected into every frame via Page.addScriptToEvaluateOnNewDocument. +# Injected into every frame via Page.addScriptToEvaluateOnNewDocument. Uses a +# sync GET with query params so the Fetch interceptor never parses a body; if +# the bridge is unreachable it returns null so the page still sees *some* +# behavior (the backend auto-dismisses). onbeforeunload is left native — it +# can't be prompted synchronously without racing navigation; the native-dialog +# fallback path still surfaces it in recent_dialogs. _DIALOG_BRIDGE_SCRIPT = r""" (() => { if (window.__hermesDialogBridgeInstalled) return; @@ -73,8 +76,6 @@ _DIALOG_BRIDGE_SCRIPT = r""" function ask(kind, message, defaultPrompt) { try { const xhr = new XMLHttpRequest(); - // Use GET with query params so we don't need to worry about request - // body encoding in the Fetch interceptor. const params = new URLSearchParams({ kind: String(kind || ""), message: String(message == null ? "" : message), @@ -83,9 +84,8 @@ _DIALOG_BRIDGE_SCRIPT = r""" xhr.open("GET", ENDPOINT + "?" + params.toString(), false); // sync xhr.send(null); if (xhr.status !== 200) return null; - const body = xhr.responseText || ""; let parsed; - try { parsed = JSON.parse(body); } catch (e) { return null; } + try { parsed = JSON.parse(xhr.responseText || ""); } catch (e) { return null; } if (kind === "alert") return undefined; if (kind === "confirm") return Boolean(parsed && parsed.accept); if (kind === "prompt") { @@ -94,14 +94,9 @@ _DIALOG_BRIDGE_SCRIPT = r""" } return null; } catch (e) { - // If the bridge is unreachable, fall back to the native call so the - // page still sees *some* behavior (the backend will auto-dismiss). return null; } } - const realAlert = window.alert; - const realConfirm = window.confirm; - const realPrompt = window.prompt; window.alert = function(message) { ask("alert", message, ""); }; window.confirm = function(message) { const r = ask("confirm", message, ""); @@ -111,10 +106,6 @@ _DIALOG_BRIDGE_SCRIPT = r""" const r = ask("prompt", message, def == null ? "" : def); return r === null ? null : String(r); }; - // onbeforeunload — we can't really synchronously prompt the user from this - // event without racing navigation. Leave native behavior for now; the - // supervisor's native-dialog fallback path still surfaces them in - // recent_dialogs. })(); """ @@ -178,6 +169,14 @@ class DialogRecord: class DialogSupervisionMixin: """Dialog event handling for ``CDPSupervisor`` (all methods run on its loop).""" + async def _cdp_quiet(self, method: str, params: Dict[str, Any], *, session_id: Optional[str], + timeout: float, what: str) -> None: + """Best-effort CDP call: failures are logged at debug and swallowed.""" + try: + await self._cdp(method, params, session_id=session_id, timeout=timeout) + except Exception as e: + logger.debug("%s failed (%s): %s", method, what, e) + async def _install_dialog_bridge(self, session_id: str) -> None: """Install the dialog-bridge init script + Fetch interceptor on a session. @@ -185,110 +184,65 @@ class DialogSupervisionMixin: scoped to the bridge URL catches the XHRs, which surface as pending dialogs and are fulfilled when the agent responds. Idempotent at the CDP level (Chromium de-dupes identical add-script calls; Fetch.enable - replaces prior patterns). + replaces prior patterns). The final Runtime.evaluate injects into the + already-loaded document so existing pages pick up the override on reconnect. """ - try: - await self._cdp( - "Page.addScriptToEvaluateOnNewDocument", - {"source": _DIALOG_BRIDGE_SCRIPT, "runImmediately": True}, - session_id=session_id, - timeout=5.0, - ) - except Exception as e: - logger.debug( - "dialog bridge: addScriptToEvaluateOnNewDocument failed on sid=%s: %s", - (session_id or "")[:16], e, - ) - try: - await self._cdp( - "Fetch.enable", - { - "patterns": [ - { - "urlPattern": DIALOG_BRIDGE_URL_PATTERN, - "requestStage": "Request", - } - ], - "handleAuthRequests": False, - }, - session_id=session_id, - timeout=5.0, - ) - except Exception as e: - logger.debug( - "dialog bridge: Fetch.enable failed on sid=%s: %s", - (session_id or "")[:16], e, - ) - # Best-effort inject into the already-loaded document so existing pages - # pick up the override on reconnect. - try: - await self._cdp( - "Runtime.evaluate", - {"expression": _DIALOG_BRIDGE_SCRIPT, "returnByValue": True}, - session_id=session_id, - timeout=3.0, - ) - except Exception: - pass + sid = (session_id or "")[:16] + await self._cdp_quiet( + "Page.addScriptToEvaluateOnNewDocument", + {"source": _DIALOG_BRIDGE_SCRIPT, "runImmediately": True}, + session_id=session_id, timeout=5.0, what=f"dialog bridge sid={sid}", + ) + await self._cdp_quiet( + "Fetch.enable", + {"patterns": [{"urlPattern": DIALOG_BRIDGE_URL_PATTERN, "requestStage": "Request"}], + "handleAuthRequests": False}, + session_id=session_id, timeout=5.0, what=f"dialog bridge sid={sid}", + ) + await self._cdp_quiet( + "Runtime.evaluate", + {"expression": _DIALOG_BRIDGE_SCRIPT, "returnByValue": True}, + session_id=session_id, timeout=3.0, what=f"dialog bridge inject sid={sid}", + ) # ── Capture ────────────────────────────────────────────────────────────── - async def _on_dialog_opening( - self, params: Dict[str, Any], session_id: Optional[str] - ) -> None: - dialog = self._new_dialog( + async def _on_dialog_opening(self, params: Dict[str, Any], session_id: Optional[str]) -> None: + self._admit_dialog(self._new_dialog( type=str(params.get("type") or ""), message=str(params.get("message") or ""), default_prompt=str(params.get("defaultPrompt") or ""), session_id=session_id, frame_id=params.get("frameId"), - ) - self._admit_dialog(dialog, self._auto_handle_dialog) + )) - async def _on_fetch_paused( - self, params: Dict[str, Any], session_id: Optional[str] - ) -> None: + async def _on_fetch_paused(self, params: Dict[str, Any], session_id: Optional[str]) -> None: """Bridge XHR captured mid-flight — materialize as a pending dialog. The page's JS thread is blocked on the XHR until we Fetch.fulfillRequest - (from ``respond_to_dialog`` or the watchdog). + (from ``respond_to_dialog`` or the watchdog). Requests for other hosts + are forwarded unchanged so the page sees its own request. """ url = str(params.get("request", {}).get("url") or "") request_id = params.get("requestId") if not request_id: return if DIALOG_BRIDGE_HOST not in url: - # Not ours — forward unchanged so the page sees its own request. - try: - await self._cdp( - "Fetch.continueRequest", {"requestId": request_id}, - session_id=session_id, timeout=3.0, - ) - except Exception: - pass + await self._cdp_quiet("Fetch.continueRequest", {"requestId": request_id}, + session_id=session_id, timeout=3.0, what="passthrough") return - q = parse_qs(urlparse(url).query) - dialog = self._new_dialog( + self._admit_dialog(self._new_dialog( type=q.get("kind", [""])[0] or "alert", message=q.get("message", [""])[0], default_prompt=q.get("default_prompt", [""])[0], session_id=session_id, frame_id=params.get("frameId"), bridge_request_id=str(request_id), - ) - self._admit_dialog(dialog, self._fulfill_bridge_request) + )) - def _new_dialog( - self, - *, - type: str, - message: str, - default_prompt: str, - session_id: Optional[str], - frame_id: Optional[str], - bridge_request_id: Optional[str] = None, - ) -> PendingDialog: + def _new_dialog(self, *, type: str, message: str, default_prompt: str, session_id: Optional[str], + frame_id: Optional[str], bridge_request_id: Optional[str] = None) -> PendingDialog: self._dialog_seq += 1 return PendingDialog( id=f"d-{self._dialog_seq}", @@ -301,12 +255,8 @@ class DialogSupervisionMixin: bridge_request_id=bridge_request_id, ) - def _admit_dialog( - self, - dialog: PendingDialog, - responder: Callable[..., Coroutine[Any, Any, None]], - ) -> None: - """Apply the dialog policy: auto-respond via ``responder`` or queue + arm watchdog. + def _admit_dialog(self, dialog: PendingDialog) -> None: + """Apply the dialog policy: auto-respond, or queue + arm the watchdog. Auto policies archive FIRST (tagged ``auto_policy``) so the ``closed`` event that follows our own response isn't re-archived as ``remote``. @@ -316,61 +266,33 @@ class DialogSupervisionMixin: DIALOG_POLICY_AUTO_ACCEPT: (True, dialog.default_prompt), }.get(self.dialog_policy) if auto is not None: - accept, prompt_text = auto with self._state_lock: self._archive_dialog_locked(dialog, "auto_policy") - asyncio.create_task(responder(dialog, accept=accept, prompt_text=prompt_text)) + asyncio.create_task(self._respond_quiet(dialog, accept=auto[0], prompt_text=auto[1])) return - # must_respond → add to pending and arm watchdog. with self._state_lock: self._pending_dialogs[dialog.id] = dialog - loop = asyncio.get_running_loop() - handle = loop.call_later( + self._dialog_watchdogs[dialog.id] = asyncio.get_running_loop().call_later( self.dialog_timeout_s, lambda: asyncio.create_task(self._dialog_timeout_expired(dialog.id)), ) - self._dialog_watchdogs[dialog.id] = handle # ── Responding ─────────────────────────────────────────────────────────── - async def _respond( - self, dialog: PendingDialog, *, accept: bool, prompt_text: Optional[str] - ) -> None: - """Bridge-fulfill for XHR-captured dialogs, else native CDP. Raises on native CDP failure.""" + async def _respond(self, dialog: PendingDialog, *, accept: bool, prompt_text: Optional[str]) -> None: + """Bridge-fulfill for XHR-captured dialogs, else native CDP. + + Native path sends ``promptText`` only for prompt dialogs when + ``prompt_text`` is given, and raises on CDP failure; the bridge path + (Fetch.fulfillRequest so the page unblocks) swallows failures. + """ if dialog.bridge_request_id: - await self._fulfill_bridge_request(dialog, accept=accept, prompt_text=prompt_text or "") - else: - await self._native_handle_dialog(dialog, accept=accept, prompt_text=prompt_text) - - async def _native_handle_dialog( - self, dialog: PendingDialog, *, accept: bool, prompt_text: Optional[str] - ) -> None: - """Page.handleJavaScriptDialog; ``promptText`` sent only for prompt dialogs - when ``prompt_text`` is given. Raises on CDP failure.""" - params: Dict[str, Any] = {"accept": accept} - if prompt_text is not None and dialog.type == "prompt": - params["promptText"] = prompt_text - await self._cdp( - "Page.handleJavaScriptDialog", - params, - session_id=dialog.cdp_session_id or None, - timeout=5.0, - ) - - async def _fulfill_bridge_request( - self, dialog: PendingDialog, *, accept: bool, prompt_text: str - ) -> None: - """Resolve a bridge XHR via Fetch.fulfillRequest so the page unblocks.""" - if not dialog.bridge_request_id: - return - payload = { - "accept": bool(accept), - "prompt_text": prompt_text if dialog.type == "prompt" else "", - "dialog_id": dialog.id, - } - body = json.dumps(payload).encode() - try: - await self._cdp( + body = json.dumps({ + "accept": bool(accept), + "prompt_text": (prompt_text or "") if dialog.type == "prompt" else "", + "dialog_id": dialog.id, + }).encode() + await self._cdp_quiet( "Fetch.fulfillRequest", { "requestId": dialog.bridge_request_id, @@ -381,24 +303,23 @@ class DialogSupervisionMixin: ], "body": base64.b64encode(body).decode(), }, - session_id=dialog.cdp_session_id or None, - timeout=5.0, + session_id=dialog.cdp_session_id or None, timeout=5.0, what=f"bridge fulfill {dialog.id}", ) - except Exception as e: - logger.debug("bridge fulfill failed for %s: %s", dialog.id, e) + return + params: Dict[str, Any] = {"accept": accept} + if prompt_text is not None and dialog.type == "prompt": + params["promptText"] = prompt_text + await self._cdp("Page.handleJavaScriptDialog", params, + session_id=dialog.cdp_session_id or None, timeout=5.0) - async def _auto_handle_dialog( - self, dialog: PendingDialog, *, accept: bool, prompt_text: str - ) -> None: - """Auto-policy response for a native dialog (already archived by the caller).""" + async def _respond_quiet(self, dialog: PendingDialog, *, accept: bool, prompt_text: Optional[str]) -> None: + """Auto-policy / watchdog response (already archived by the caller); failures logged only.""" try: - await self._native_handle_dialog(dialog, accept=accept, prompt_text=prompt_text) + await self._respond(dialog, accept=accept, prompt_text=prompt_text) except Exception as e: - logger.debug("auto-handle CDP call failed for %s: %s", dialog.id, e) + logger.debug("auto response failed for %s: %s", dialog.id, e) - async def _handle_dialog_cdp( - self, dialog: PendingDialog, *, accept: bool, prompt_text: str - ) -> None: + async def _handle_dialog_cdp(self, dialog: PendingDialog, *, accept: bool, prompt_text: str) -> None: """Agent response path. The dialog is retired regardless of outcome — a CDP error usually means @@ -416,19 +337,13 @@ class DialogSupervisionMixin: return logger.warning( "CDP supervisor %s: dialog %s (%s) auto-dismissed after %ss timeout", - self.task_id, - dialog_id, - dialog.type, - self.dialog_timeout_s, + self.task_id, dialog_id, dialog.type, self.dialog_timeout_s, ) - try: - # Archive with watchdog tag BEFORE unblocking the page. - with self._state_lock: - if self._pending_dialogs.pop(dialog_id, None) is not None: - self._archive_dialog_locked(dialog, "watchdog") - await self._respond(dialog, accept=False, prompt_text=None) - except Exception as e: - logger.debug("auto-dismiss failed for %s: %s", dialog_id, e) + # Archive with watchdog tag BEFORE unblocking the page. + with self._state_lock: + if self._pending_dialogs.pop(dialog_id, None) is not None: + self._archive_dialog_locked(dialog, "watchdog") + await self._respond_quiet(dialog, accept=False, prompt_text=None) # ── Bookkeeping ────────────────────────────────────────────────────────── @@ -444,29 +359,20 @@ class DialogSupervisionMixin: def _archive_dialog_locked(self, dialog: PendingDialog, closed_by: str) -> None: """Move a pending dialog to the recent_dialogs ring buffer. Must hold state_lock.""" - record = DialogRecord( - id=dialog.id, - type=dialog.type, - message=dialog.message, - opened_at=dialog.opened_at, - closed_at=time.time(), - closed_by=closed_by, - frame_id=dialog.frame_id, - ) - self._recent_dialogs.append(record) + self._recent_dialogs.append(DialogRecord( + id=dialog.id, type=dialog.type, message=dialog.message, opened_at=dialog.opened_at, + closed_at=time.time(), closed_by=closed_by, frame_id=dialog.frame_id, + )) self._recent_dialogs = _trim_ring(self._recent_dialogs, RECENT_DIALOGS_MAX) - async def _on_dialog_closed( - self, params: Dict[str, Any], session_id: Optional[str] - ) -> None: + async def _on_dialog_closed(self, params: Dict[str, Any], session_id: Optional[str]) -> None: # ``Page.javascriptDialogClosed`` carries only ``result``/``userInput``, not # the message. Match by session id and clear the oldest native dialog on # it — the JS thread blocks while a dialog is up, so at most one is in # flight per session. Bridge dialogs resolve via Fetch.fulfillRequest. with self._state_lock: candidate_ids = [ - d.id - for d in self._pending_dialogs.values() + d.id for d in self._pending_dialogs.values() if d.cdp_session_id == session_id and d.bridge_request_id is None ] if candidate_ids: diff --git a/tools/browser_supervisor_frames.py b/tools/browser_supervisor_frames.py index 7d021be284..482c76d9c8 100644 --- a/tools/browser_supervisor_frames.py +++ b/tools/browser_supervisor_frames.py @@ -41,12 +41,7 @@ class FrameInfo: name: str = "" def to_dict(self) -> Dict[str, Any]: - d = { - "frame_id": self.frame_id, - "url": self.url, - "origin": self.origin, - "is_oopif": self.is_oopif, - } + d = {"frame_id": self.frame_id, "url": self.url, "origin": self.origin, "is_oopif": self.is_oopif} if self.cdp_session_id: d["session_id"] = self.cdp_session_id if self.parent_frame_id: @@ -63,49 +58,36 @@ class FrameTrackingMixin: """Page.enable + Runtime.enable + nested auto-attach on one session.""" await self._cdp("Page.enable", session_id=session_id, timeout=timeout) await self._cdp("Runtime.enable", session_id=session_id, timeout=timeout) - await self._cdp( - "Target.setAutoAttach", _AUTO_ATTACH_PARAMS, - session_id=session_id, timeout=timeout, - ) + await self._cdp("Target.setAutoAttach", _AUTO_ATTACH_PARAMS, session_id=session_id, timeout=timeout) - def _on_frame_attached( - self, params: Dict[str, Any], session_id: Optional[str] - ) -> None: + def _on_frame_attached(self, params: Dict[str, Any], session_id: Optional[str]) -> None: frame_id = params.get("frameId") if not frame_id: return with self._state_lock: self._frames[frame_id] = FrameInfo( - frame_id=frame_id, - url="", - origin="", - parent_frame_id=params.get("parentFrameId"), - is_oopif=False, - cdp_session_id=session_id, + frame_id=frame_id, url="", origin="", parent_frame_id=params.get("parentFrameId"), + is_oopif=False, cdp_session_id=session_id, ) - def _on_frame_navigated( - self, params: Dict[str, Any], session_id: Optional[str] - ) -> None: + def _on_frame_navigated(self, params: Dict[str, Any], session_id: Optional[str]) -> None: frame = params.get("frame") or {} frame_id = frame.get("id") if not frame_id: return with self._state_lock: - existing = self._frames.get(frame_id) + old = self._frames.get(frame_id) self._frames[frame_id] = FrameInfo( frame_id=frame_id, url=str(frame.get("url") or ""), origin=str(frame.get("securityOrigin") or frame.get("origin") or ""), - parent_frame_id=frame.get("parentId") or (existing.parent_frame_id if existing else None), - is_oopif=bool(existing.is_oopif if existing else False), - cdp_session_id=existing.cdp_session_id if existing else session_id, - name=str(frame.get("name") or (existing.name if existing else "")), + parent_frame_id=frame.get("parentId") or (old.parent_frame_id if old else None), + is_oopif=bool(old.is_oopif if old else False), + cdp_session_id=old.cdp_session_id if old else session_id, + name=str(frame.get("name") or (old.name if old else "")), ) - def _on_frame_detached( - self, params: Dict[str, Any], session_id: Optional[str] - ) -> None: + def _on_frame_detached(self, params: Dict[str, Any], session_id: Optional[str]) -> None: """Drop a frame only when it's truly gone. ``reason="swap"`` means the frame is migrating processes (e.g. promoted @@ -115,14 +97,11 @@ class FrameTrackingMixin: alive, so keep it until Target.detached + a later frameDetached clear it. """ frame_id = params.get("frameId") - if not frame_id: - return - reason = str(params.get("reason") or "remove").lower() - if reason == "swap": + if not frame_id or str(params.get("reason") or "remove").lower() == "swap": return with self._state_lock: - existing = self._frames.get(frame_id) - if existing and existing.is_oopif and existing.cdp_session_id: + old = self._frames.get(frame_id) + if old and old.is_oopif and old.cdp_session_id: return self._frames.pop(frame_id, None) @@ -132,22 +111,17 @@ class FrameTrackingMixin: target_type = info.get("type") if not sid or target_type not in {"iframe", "worker"}: return - - # Record the frame with its OOPIF session id for interaction routing. if target_type == "iframe": + # Record the frame with its OOPIF session id for interaction routing; + # origin is filled by frameNavigated on the child session. target_id = info.get("targetId") with self._state_lock: - existing = self._frames.get(target_id) + old = self._frames.get(target_id) self._frames[target_id] = FrameInfo( - frame_id=target_id, - url=str(info.get("url") or ""), - origin="", # filled by frameNavigated on the child session - parent_frame_id=(existing.parent_frame_id if existing else None), - is_oopif=True, - cdp_session_id=sid, - name=str(info.get("title") or (existing.name if existing else "")), + frame_id=target_id, url=str(info.get("url") or ""), origin="", + parent_frame_id=(old.parent_frame_id if old else None), is_oopif=True, + cdp_session_id=sid, name=str(info.get("title") or (old.name if old else "")), ) - # Enable child domains off-loop: awaiting the replies here would deadlock # because only the reader can resolve those Futures. asyncio.create_task(self._enable_child_domains(sid)) @@ -178,25 +152,21 @@ class FrameTrackingMixin: self._frames[fid] = replace(frame, cdp_session_id=None) def _build_frame_tree_locked(self) -> Dict[str, Any]: - """Build the capped frame_tree payload. Must be called under state lock.""" - frames = self._frames - empty = {"top": None, "children": [], "truncated": False} - if not frames: - return empty + """Build the capped frame_tree payload. Must be called under state lock. - # Top frame: one with no parent, preferring oopif=False. + Top frame = one with no parent, preferring oopif=False. BFS from it, + capped by FRAME_TREE_MAX_ENTRIES and FRAME_TREE_MAX_OOPIF_DEPTH for + OOPIF branches. + """ + frames = self._frames tops = [f for f in frames.values() if not f.parent_frame_id] top = next((f for f in tops if not f.is_oopif), tops[0] if tops else None) if top is None: - return empty + return {"top": None, "children": [], "truncated": False} - # BFS from top, capped by FRAME_TREE_MAX_ENTRIES and - # FRAME_TREE_MAX_OOPIF_DEPTH for OOPIF branches. children: List[Dict[str, Any]] = [] truncated = False - queue: List[Tuple[FrameInfo, int]] = [ - (f, 1) for f in frames.values() if f.parent_frame_id == top.frame_id - ] + queue: List[Tuple[FrameInfo, int]] = [(f, 1) for f in frames.values() if f.parent_frame_id == top.frame_id] visited: set[str] = {top.frame_id} while queue and len(children) < FRAME_TREE_MAX_ENTRIES: frame, depth = queue.pop(0) @@ -207,17 +177,9 @@ class FrameTrackingMixin: truncated = True continue children.append(frame.to_dict()) - for f in frames.values(): - if f.parent_frame_id == frame.frame_id and f.frame_id not in visited: - queue.append((f, depth + 1)) - if queue: - truncated = True - - return { - "top": top.to_dict(), - "children": children, - "truncated": truncated, - } + queue.extend((f, depth + 1) for f in frames.values() + if f.parent_frame_id == frame.frame_id and f.frame_id not in visited) + return {"top": top.to_dict(), "children": children, "truncated": truncated or bool(queue)} # CDP event → handler(self, params, session_id); merged into CDPSupervisor._EVENT_HANDLERS. EVENT_HANDLERS: Dict[str, Callable[..., Any]] = { diff --git a/tools/browser_use_cli.py b/tools/browser_use_cli.py index d79bd6cd5a..7ce293f898 100644 --- a/tools/browser_use_cli.py +++ b/tools/browser_use_cli.py @@ -4,6 +4,7 @@ When browser.backend is "browser-use", the model gets ``browser_exec`` tool instead of default browser tools """ +import contextlib import importlib import json import logging @@ -91,14 +92,20 @@ _URL_RE = re.compile(r"https?://[^\s'\"\\)]+", re.IGNORECASE) _FHS_BIN_DIRS = ("/usr/local/sbin", "/usr/local/bin", "/usr/sbin", "/usr/bin", "/sbin", "/bin") +def _quiet(fn: Callable[[], Any], default: Any, log_prefix: str = "") -> Any: + """``fn()``, or ``default`` on any exception (debug-logged when ``log_prefix`` is set).""" + try: + return fn() + except Exception as e: + if log_prefix: + logger.debug("%s: %s", log_prefix, e) + return default + + def _lazy_call(module: str, name: str, default: Any, log_prefix: str) -> Any: """Call ``module.name()`` resolved at call time (so tests can stub the module or patch the attribute); on any failure log ``log_prefix`` and return ``default``.""" - try: - return getattr(importlib.import_module(module), name)() - except Exception as e: - logger.debug("%s: %s", log_prefix, e) - return default + return _quiet(lambda: getattr(importlib.import_module(module), name)(), default, log_prefix) def _camofox_active(context: str = "") -> bool: @@ -106,6 +113,16 @@ def _camofox_active(context: str = "") -> bool: return _lazy_call("tools.browser_camofox", "is_camofox_mode", False, f"Camofox activity check failed{context}") +def _real_profile_consented() -> bool: + """Whether the user opted in to real-profile local browsing (config read).""" + return _lazy_call("tools.browser_tool", "_use_real_profile", False, "real-profile consent lookup failed") + + +def _lightpanda_engine_in_use() -> bool: + return _lazy_call("tools.browser_tool", "lightpanda_engine_status", (False, ""), + "lightpanda engine status unavailable")[0] + + def _set_cdp_env(env: dict, cdp: str) -> None: """Export a CDP endpoint under the BU_CDP_* contract (http(s) → URL, else WS).""" env["BU_CDP_URL" if cdp.startswith(("http://", "https://")) else "BU_CDP_WS"] = cdp @@ -142,9 +159,9 @@ def _blocked_url_in_code(code: str) -> Optional[str]: def _base_subprocess_env() -> dict: from tools.browser_tool import _build_browser_env env = _build_browser_env() - # The CLI runs under its own Python (uv tool / uvx); inherited - # PYTHONPATH/PYTHONHOME (Hermes's venv) win over its site-packages → wrong-ABI - # C-extensions (pydantic_core) and a crash. Strip both. + # The CLI runs under its own Python (uv tool / uvx); inherited PYTHONPATH/PYTHONHOME + # (Hermes's venv) win over its site-packages → wrong-ABI C-extensions (pydantic_core) + # and a crash. Strip both. env.pop("PYTHONPATH", None) env.pop("PYTHONHOME", None) env["PATH"] = _floor_subprocess_path(env.get("PATH", "")) @@ -153,24 +170,18 @@ def _base_subprocess_env() -> dict: def _floor_subprocess_path(path: str) -> str: - """Guarantee core system dirs survive onto the CLI subprocess PATH. - - Profile workers (kanban bots, cron) can inherit a PATH of only version-manager - dirs; the uv browser-use binary's POSIX sh trampoline resolves - ``dirname``/``realpath`` through PATH and dies (exit 127) without /usr/bin. - Reuses browser_tool's ``_merge_browser_path`` floor, else appends FHS bin - dirs. Windows .cmd shims don't trampoline through PATH, so no-op there. - """ + """Guarantee core system dirs survive onto the CLI subprocess PATH: profile workers + (kanban bots, cron) can inherit a PATH of only version-manager dirs, and the uv + browser-use binary's POSIX sh trampoline resolves ``dirname``/``realpath`` through + PATH and dies (exit 127) without /usr/bin. Reuses browser_tool's ``_merge_browser_path`` + floor, else appends FHS bin dirs. Windows .cmd shims don't trampoline: no-op there.""" if os.name == "nt": return path - try: + with contextlib.suppress(Exception): from tools.browser_tool import _merge_browser_path return _merge_browser_path(path or "") - except Exception: - pass parts = [p for p in (path or "").split(os.pathsep) if p] - existing = set(parts) - parts.extend(d for d in _FHS_BIN_DIRS if d not in existing and os.path.isdir(d)) + parts.extend(d for d in _FHS_BIN_DIRS if d not in set(parts) and os.path.isdir(d)) return os.pathsep.join(parts) @@ -185,6 +196,10 @@ def _read_browser_cfg() -> dict: return {} +def _use_gateway(browser_cfg: dict) -> bool: + return is_truthy_value(browser_cfg.get("use_gateway"), default=False) + + def get_browser_backend() -> str: """Return the configured browser backend key ("" = unset → default). @@ -198,16 +213,14 @@ def get_browser_backend() -> str: def is_legacy_browser_use_cloud_config(browser_cfg: dict) -> bool: - """True for pre-CLI direct-API Browser Use cloud configs""" + """True for pre-CLI direct-API Browser Use cloud configs. An explicit backend or + a non-Browser-Use cloud_provider wins; Camofox is selected via env var, not + cloud_provider, so a Camofox user with a stray BROWSER_USE_API_KEY keeps it.""" if not isinstance(browser_cfg, dict) or browser_cfg.get("backend"): - return False # an explicit backend choice wins - provider = str(browser_cfg.get("cloud_provider") or "").strip().lower() - if provider not in {"browser-use", ""}: - return False # explicit local/Browserbase/… choices win - if is_truthy_value(browser_cfg.get("use_gateway"), default=False): return False - # Camofox is selected via env var, not cloud_provider — a Camofox user - # with a stray BROWSER_USE_API_KEY must keep their explicit choice. + provider = str(browser_cfg.get("cloud_provider") or "").strip().lower() + if provider not in {"browser-use", ""} or _use_gateway(browser_cfg): + return False if _camofox_active(" during migration"): return False return bool(os.getenv("BROWSER_USE_API_KEY")) @@ -217,21 +230,17 @@ def is_browser_use_cli_mode() -> bool: """True when the Browser Use CLI replaces the built-in browser stack. Browser Use mode is the DEFAULT: unset ``browser.backend`` ("") enables it - whenever the CLI is runnable (installed binary or uvx); ``browser.backend: - off`` (or ``/browser use off``) keeps the built-in browser_* tools. Camofox - always falls back to the built-in tools regardless — Firefox-based with a - custom HTTP API and no CDP surface, so the CDP-only harness cannot drive it. + whenever the CLI is runnable (installed binary or uvx), so browsing never + silently breaks; ``browser.backend: off`` (or ``/browser use off``) keeps the + built-in browser_* tools. Camofox always falls back to the built-in tools — + Firefox-based with a custom HTTP API and no CDP surface for the harness. """ if _camofox_active(): return False backend = get_browser_backend() if backend: return backend == _BACKEND_KEY - if is_legacy_browser_use_cloud_config(_read_browser_cfg()): - return True - # Default (backend unset): Browser Use mode when the CLI can run at all; - # otherwise keep the built-in tools so browsing never silently breaks. - return _find_cli() is not None + return is_legacy_browser_use_cloud_config(_read_browser_cfg()) or _find_cli() is not None _NOTICE_STAMP_NAME = ".browser_use_default_notice" @@ -246,16 +255,12 @@ def default_downgrade_notice() -> Optional[str]: if get_browser_backend() or _camofox_active() or _find_cli() is not None: return None # explicit choice / Camofox / CLI present — nothing downgraded stamp = Path(get_hermes_home()) / "cache" / _NOTICE_STAMP_NAME - try: + with contextlib.suppress(OSError): if 0 <= time.time() - stamp.stat().st_mtime < _NOTICE_INTERVAL_S: return None - except OSError: - pass - try: + with contextlib.suppress(OSError): stamp.parent.mkdir(parents=True, exist_ok=True) stamp.touch() - except OSError: - pass return ("Browser Use CLI not found — using the built-in browser tools. " "Run `hermes tools` (Browser Automation → Browser Use) to install it, " "or `browser.backend: off` in config.yaml to silence this.") @@ -296,27 +301,25 @@ def _find_cli() -> Optional[List[str]]: def install_cli(timeout_s: int = 600) -> Tuple[bool, str]: - """Install the browser-use CLI persistently via ``uv tool install`` - (managed uv via ``ensure_uv`` → uv on PATH), linking the binary into - ``$HERMES_HOME/bin`` (``UV_TOOL_BIN_DIR``) so ``_find_cli()`` resolves it for - every profile without touching the user's PATH. Returns ``(ok, message)``; - never raises. + """Install the browser-use CLI persistently via ``uv tool install`` (managed uv + via ``ensure_uv`` → uv on PATH), linking the binary into ``$HERMES_HOME/bin`` + (``UV_TOOL_BIN_DIR``) so ``_find_cli()`` resolves it for every profile without + touching the user's PATH. Returns ``(ok, message)``; never raises. + + MANAGED-FIRST: only the managed copy short-circuits. A browser-use on PATH is a + user-level side install and must NOT prevent provisioning the canonical + Hermes-managed copy (version drift, no updates via hermes tools). """ - # MANAGED-FIRST: only the managed copy short-circuits. A browser-use on PATH - # is a user-level side install and must NOT prevent provisioning the - # canonical Hermes-managed copy (version drift, no updates via hermes tools). bin_dir = _managed_bin_dir() managed = shutil.which("browser-use", path=bin_dir) if managed: return True, f"browser-use CLI already installed ({managed})" - uv_bin: Optional[str] = None - try: + def _managed_uv() -> Optional[str]: from hermes_cli.managed_uv import ensure_uv - uv_bin = str(ensure_uv() or "") or None - except Exception as e: - logger.debug("Managed uv bootstrap unavailable: %s", e) - uv_bin = uv_bin or shutil.which("uv") + return str(ensure_uv() or "") or None + + uv_bin = _quiet(_managed_uv, None, "Managed uv bootstrap unavailable") or shutil.which("uv") if not uv_bin: return False, ("uv is not available and could not be bootstrapped. Install uv " "(https://docs.astral.sh/uv/) and run `uv tool install browser-use`.") @@ -385,9 +388,9 @@ def _native_screenshot_result(result: Dict[str, Any], path: str) -> Optional[Dic if not _should_use_native_vision_fast_path(): return None - # History-reuse cap: this data URL bakes into the tool result and is - # re-sent every later turn — same policy as the vision_analyze / - # browser_vision native embeds (256 KB / 1568 px, JPEG quality ladder). + # History-reuse cap: this data URL bakes into the tool result and is re-sent + # every later turn — same policy as the vision_analyze / browser_vision native + # embeds (256 KB / 1568 px, JPEG quality ladder). data_url = _resize_image_for_vision( Path(path), mime_type="image/png", max_base64_bytes=_EMBED_TARGET_BYTES, max_dimension=_EMBED_MAX_DIMENSION, force_jpeg=True, @@ -418,13 +421,9 @@ def _resolve_lightpanda_cdp(env: dict, task_id: Optional[str], session_name: str private to this session and the own-tab preamble is skipped.""" try: from tools.browser_tool import _get_session_info, _using_lightpanda_engine - except Exception as e: # pragma: no cover — stubbed browser_tool in tests - logger.debug("browser_tool lightpanda resolution unavailable: %s", e) - return None - try: if not _using_lightpanda_engine(): return None - except Exception as e: + except Exception as e: # stubbed browser_tool in tests / engine lookup failure logger.debug("browser engine lookup failed: %s", e) return None err = _export_session_cdp( @@ -460,29 +459,22 @@ def _resolve_backend_cdp(env: dict, task_id: Optional[str], session_name: str = logger.debug("browser_tool backend resolution unavailable: %s", e) return None - try: - override = _get_cdp_override() - except Exception: - override = "" + override = _quiet(_get_cdp_override, "") if override: _set_cdp_env(env, override) return None - try: - provider = _get_cloud_provider() - except Exception as e: - logger.debug("Cloud provider lookup failed: %s", e) - provider = None + provider = _quiet(_get_cloud_provider, None, "Cloud provider lookup failed") if provider is None: return _resolve_lightpanda_cdp(env, task_id, session_name) - # Browser Use direct-API configs: the CLI talks to BU cloud natively - # (BU_AUTOSPAWN / auth login) — the legacy provider would create a second, - # redundant session. The Nous-gateway variant (use_gateway: true) DOES resolve - # through the provider: the gateway provisions the browser server-side and - # returns its CDP URL, giving subscribers CLI mode with no raw key. + # Browser Use direct-API configs: the CLI talks to BU cloud natively (BU_AUTOSPAWN / + # auth login) — the legacy provider would create a second, redundant session. The + # Nous-gateway variant (use_gateway: true) DOES resolve through the provider: the + # gateway provisions the browser server-side and returns its CDP URL, giving + # subscribers CLI mode with no raw key. provider_key = str(getattr(provider, "name", "") or "").strip().lower() - if provider_key == _BACKEND_KEY and not is_truthy_value(_read_browser_cfg().get("use_gateway"), default=False): + if provider_key == _BACKEND_KEY and not _use_gateway(_read_browser_cfg()): env[_PRIVATE_BROWSER_SENTINEL] = "1" # named BU cloud browsers are exclusive to their daemon return None @@ -501,11 +493,6 @@ def _resolve_backend_cdp(env: dict, task_id: Optional[str], session_name: str = return err -def _real_profile_consented() -> bool: - """Whether the user opted in to real-profile local browsing (config read).""" - return _lazy_call("tools.browser_tool", "_use_real_profile", False, "real-profile consent lookup failed") - - def _resolve_real_profile_cdp(env: dict, force_local: bool) -> Optional[str]: """Point the harness at the user's real-profile copy-browser when consented. @@ -527,19 +514,14 @@ def _resolve_real_profile_cdp(env: dict, force_local: bool) -> Optional[str]: logger.debug("real-profile backend resolution unavailable: %s", e) return None - try: - if _get_cdp_override_raw(): - return None - except Exception: - pass + if _quiet(_get_cdp_override_raw, ""): + return None if not force_local: - # Only auto-upgrade genuinely-local attaches; any cloud path (provider or - # legacy BU cloud config) stays on its backend unless the model passes local=true. - try: - if _get_cloud_provider() is not None: - return None - except Exception: + # Only auto-upgrade genuinely-local attaches; any cloud path (provider, provider + # lookup failure, or legacy BU cloud config) stays on its backend unless the model + # passes local=true. + if _quiet(_get_cloud_provider, object()) is not None: return None if is_legacy_browser_use_cloud_config(_read_browser_cfg()): return None @@ -576,14 +558,13 @@ def _windows_popen_kwargs() -> dict: """Hide the console the .cmd shim would flash on Windows (as browser_tool does).""" if os.name != "nt": return {} - try: + def _flags() -> dict: from hermes_cli._subprocess_compat import windows_hide_flags si = subprocess.STARTUPINFO() si.dwFlags |= subprocess.STARTF_USESHOWWINDOW return {"creationflags": windows_hide_flags(), "startupinfo": si} - except Exception as e: - logger.debug("Windows hide-flags unavailable: %s", e) - return {} + + return _quiet(_flags, {}, "Windows hide-flags unavailable") def _clamp_timeout(timeout_s: Any) -> int: @@ -746,21 +727,12 @@ _HELPERS_DIGEST = ( # lives with the session, not in the process-wide TTL-cached check_fn. -def _lightpanda_engine_in_use() -> bool: - return _lazy_call("tools.browser_tool", "lightpanda_engine_status", (False, ""), - "lightpanda engine status unavailable")[0] - - def _description_header() -> str: """Header tailored to whether the active model can see images natively""" if _lightpanda_engine_in_use(): # no screenshots at all, whatever the model can see return _HEADER_BASE + _HEADER_TEXT_ONLY + _HEADER_LIGHTPANDA - try: - from tools.vision_tools import _should_use_native_vision_fast_path - if _should_use_native_vision_fast_path(): - return _HEADER_BASE + _HEADER_VISION - except Exception: - pass + if _lazy_call("tools.vision_tools", "_should_use_native_vision_fast_path", False, ""): + return _HEADER_BASE + _HEADER_VISION return _HEADER_BASE + _HEADER_TEXT_ONLY