From 5ea8a63aba527964f411a2dce7faf0e2fb83fd1e Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Wed, 2 Sep 2026 22:39:53 -0700 Subject: [PATCH] refactor(tools): simplify browser backend modules (browser_use_cli, camofox, cdp, lightpanda, real-profile, install, cloud) --- tools/browser_camofox.py | 164 ++++----- tools/browser_camofox_state.py | 22 +- tools/browser_cdp_tool.py | 339 ++++++----------- tools/browser_lightpanda.py | 214 +++-------- tools/browser_tool_cdp.py | 135 +++---- tools/browser_tool_cloud.py | 251 ++++--------- tools/browser_tool_eval_policy.py | 220 ++++------- tools/browser_tool_install.py | 363 +++++------------- tools/browser_tool_lightpanda_fallback.py | 184 +++------- tools/browser_tool_real_profile.py | 339 +++++------------ tools/browser_use_cli.py | 429 ++++++++-------------- 11 files changed, 849 insertions(+), 1811 deletions(-) diff --git a/tools/browser_camofox.py b/tools/browser_camofox.py index 2a653f69df..c4ad0fb993 100644 --- a/tools/browser_camofox.py +++ b/tools/browser_camofox.py @@ -1,14 +1,11 @@ """Camofox browser backend — local anti-detection browser via REST API. -Camofox-browser (https://github.com/jo-inc/camofox-browser) is a self-hosted -Node.js server wrapping Camoufox (Firefox fork with C++ fingerprint spoofing). -Its REST API maps 1:1 to our browser tool interface: accessibility snapshots -with element refs, click/type/scroll by ref, screenshots. - -Setup: ``npm install && npm start`` in a camofox-browser checkout, or -``docker run -p 9377:9377 -e CAMOFOX_PORT=9377 jo-inc/camofox-browser``; then set -``CAMOFOX_URL=http://localhost:9377`` in ``~/.hermes/.env`` (Docker: see -``CAMOFOX_REWRITE_LOOPBACK_URLS`` in :func:`_rewrite_loopback_url_for_camofox`). +Camofox-browser (https://github.com/jo-inc/camofox-browser) is a self-hosted Node.js +server wrapping Camoufox (Firefox fork with C++ fingerprint spoofing); its REST API maps +1:1 to our browser tool interface (accessibility snapshots with element refs, click/type/ +scroll by ref, screenshots). Setup: ``npm start`` in a checkout or ``docker run -p 9377:9377 +-e CAMOFOX_PORT=9377 jo-inc/camofox-browser``, then ``CAMOFOX_URL=http://localhost:9377`` in +``~/.hermes/.env`` (Docker: see ``CAMOFOX_REWRITE_LOOPBACK_URLS`` below). """ from __future__ import annotations @@ -46,8 +43,7 @@ _cmd_timeout_resolved = False def _get_command_timeout() -> int: - """``browser.command_timeout`` (floor 5s, default 30s), cached after first read; - mirrors :func:`tools.browser_tool._get_command_timeout`.""" + """``browser.command_timeout`` (floor 5s, default 30s), cached after first read.""" global _cached_cmd_timeout, _cmd_timeout_resolved if _cmd_timeout_resolved: return _cached_cmd_timeout # type: ignore[return-value] @@ -76,8 +72,8 @@ def get_camofox_url() -> str: def _config_cdp_url() -> str: - """Persistent ``browser.cdp_url`` from config.yaml, or "". Read here rather than - via ``browser_tool._get_cdp_override`` (circular import).""" + """Persistent ``browser.cdp_url`` from config.yaml, or "" (read here, not via + ``browser_tool._get_cdp_override`` — circular import).""" try: from hermes_cli.config import read_raw_config # late-bound: tests patch the source module browser_cfg = read_raw_config().get("browser", {}) @@ -91,11 +87,10 @@ def _config_cdp_url() -> str: def is_camofox_mode() -> bool: """True when the Camofox backend is selected and no CDP override is active. - Selection is ``browser.cloud_provider: camofox``; ``CAMOFOX_URL`` is only the - address and never overrides a different stored selection (legacy: with no - selection ever written, a set ``CAMOFOX_URL`` still activates Camofox). A CDP - override (``BROWSER_CDP_URL`` env or ``browser.cdp_url`` config, same precedence - as ``browser_tool._get_cdp_override()``) wins so tools drive the real CDP browser. + Selection is ``browser.cloud_provider: camofox``; ``CAMOFOX_URL`` is only the address + and never overrides a different stored selection (legacy: with no selection ever + written, a set ``CAMOFOX_URL`` still activates Camofox). A CDP override (``BROWSER_CDP_URL`` + env or ``browser.cdp_url``, same precedence as ``browser_tool._get_cdp_override()``) wins. """ if os.getenv("BROWSER_CDP_URL", "").strip() or _config_cdp_url(): return False @@ -154,18 +149,19 @@ def _managed_persistence_enabled(camofox_cfg: Optional[Dict[str, Any]] = None) - return bool(camofox_cfg.get("managed_persistence")) -def _secret_or_cfg(secret_name: str, camofox_cfg: Dict[str, Any], cfg_key: str) -> str: - """Secret-scope value first, then the ``browser.camofox`` config key, else "".""" - return (get_secret(secret_name, "") or "").strip() or str(camofox_cfg.get(cfg_key) or "").strip() +def _env_or_cfg(env_name: str, camofox_cfg: Dict[str, Any], cfg_key: str, *, secret: bool = False) -> str: + """Env/secret-scope value first, then the ``browser.camofox`` config key, else "".""" + raw = get_secret(env_name, "") if secret else os.getenv(env_name, "") + return (raw or "").strip() or str(camofox_cfg.get(cfg_key) or "").strip() def _camofox_identity_override(task_id: Optional[str], camofox_cfg: Dict[str, Any]) -> Optional[Dict[str, str]]: - """Externally configured identity (integrations owning the visible Camofox - browser share a user ID so Hermes uses the same profile), or None.""" - user_id = _secret_or_cfg("CAMOFOX_USER_ID", camofox_cfg, "user_id") + """Externally configured identity (integrations owning the visible Camofox browser + share a user ID so Hermes uses the same profile), or None.""" + user_id = _env_or_cfg("CAMOFOX_USER_ID", camofox_cfg, "user_id", secret=True) if not user_id: return None - session_key = _secret_or_cfg("CAMOFOX_SESSION_KEY", camofox_cfg, "session_key") + session_key = _env_or_cfg("CAMOFOX_SESSION_KEY", camofox_cfg, "session_key", secret=True) return {"user_id": user_id, "session_key": session_key or f"task_{(task_id or 'default')[:16]}"} @@ -181,14 +177,7 @@ def _flag(env_name: str, camofox_cfg: Dict[str, Any], cfg_key: str) -> bool: return bool(camofox_cfg.get(cfg_key)) -def _loopback_rewrite_host(camofox_cfg: Dict[str, Any]) -> str: - """Return the host alias used when rewriting loopback page URLs.""" - return (os.getenv("CAMOFOX_LOOPBACK_HOST_ALIAS", "").strip() - or str(camofox_cfg.get("loopback_host_alias") or "").strip() or "host.docker.internal") - - def _is_loopback_hostname(hostname: Optional[str]) -> bool: - """Return True for localhost/127.0.0.0/8/::1-style hostnames.""" if not hostname: return False host = hostname.strip().strip("[]").lower() @@ -203,12 +192,11 @@ def _is_loopback_hostname(hostname: Optional[str]) -> bool: def _rewrite_loopback_url_for_camofox(url: str) -> tuple[str, Optional[Dict[str, str]]]: """Rewrite loopback page URLs for Docker-hosted Camofox, if configured. - ``CAMOFOX_URL`` may point at a host-published Docker port, but page URLs are - opened by the browser *inside* the container, where loopback is the container, - not the host. Opt-in (``CAMOFOX_REWRITE_LOOPBACK_URLS`` / config) because - non-Docker installs run the browser on the host. Returns ``(rewritten_url, - metadata)``; ``metadata`` is present only when a rewrite happened so the tool - result can disclose the change to the model. + ``CAMOFOX_URL`` may point at a host-published Docker port, but page URLs are opened by + the browser *inside* the container, where loopback is the container, not the host. + Opt-in (``CAMOFOX_REWRITE_LOOPBACK_URLS`` / config) because non-Docker installs run the + browser on the host. Returns ``(rewritten_url, metadata)``; ``metadata`` is present only + when a rewrite happened so the tool result can disclose the change to the model. """ camofox_cfg = _get_camofox_config() if not _flag("CAMOFOX_REWRITE_LOOPBACK_URLS", camofox_cfg, "rewrite_loopback_urls"): @@ -218,7 +206,7 @@ def _rewrite_loopback_url_for_camofox(url: str) -> tuple[str, Optional[Dict[str, parsed = urlsplit(url) except ValueError: return url, None - alias = _loopback_rewrite_host(camofox_cfg) + alias = _env_or_cfg("CAMOFOX_LOOPBACK_HOST_ALIAS", camofox_cfg, "loopback_host_alias") or "host.docker.internal" if parsed.scheme not in {"http", "https"} or not _is_loopback_hostname(parsed.hostname) or not alias: return url, None @@ -302,9 +290,9 @@ def _drop_session(task_id: Optional[str]) -> Optional[Dict[str, Any]]: def camofox_soft_cleanup(task_id: Optional[str] = None) -> bool: - """Drop only the local tracking entry (``True``) for managed profiles, which - must survive across agent tasks; ``False`` for ephemeral sessions so the caller - falls back to :func:`camofox_close`.""" + """Drop only the local tracking entry (``True``) for managed profiles, which must + survive across agent tasks; ``False`` for ephemeral sessions so the caller falls back + to :func:`camofox_close`.""" camofox_cfg = _get_camofox_config() if _managed_persistence_enabled(camofox_cfg) or _camofox_identity_override(task_id, camofox_cfg): _drop_session(task_id) @@ -325,22 +313,19 @@ def _request(method: str, path: str, timeout: Optional[int] = None, **kwargs: An def _post(path: str, body: dict, timeout: Optional[int] = None) -> dict: - """POST JSON to camofox and return parsed response.""" return _request("post", path, timeout, json=body).json() def _get(path: str, params: dict = None, timeout: Optional[int] = None) -> dict: - """GET from camofox and return parsed response.""" return _request("get", path, timeout, params=params).json() def _get_raw(path: str, params: dict = None, timeout: Optional[int] = None) -> requests.Response: - """GET from camofox and return raw response (for binary data).""" + """GET and return the raw response (for binary data).""" return _request("get", path, timeout, params=params) def _delete(path: str, body: dict = None, timeout: Optional[int] = None) -> dict: - """DELETE to camofox and return parsed response.""" return _request("delete", path, timeout, json=body).json() @@ -354,13 +339,13 @@ def _user_params(session: Dict[str, Any]) -> Dict[str, str]: return {"userId": session["user_id"]} -def _raw_snapshot(session: Dict[str, Any]) -> str: - return _get(_tab_path(session, "snapshot"), params=_user_params(session)).get("snapshot", "") +def _snapshot_data(session: Dict[str, Any]) -> dict: + return _get(_tab_path(session, "snapshot"), params=_user_params(session)) def _parse_snapshot_images(snapshot: str) -> list[Dict[str, str]]: - """Images from an accessibility snapshot: ``img "alt" [eN]`` entries with the - URL on the following ``/url:`` line (Camofox has no /images endpoint).""" + """Images from an accessibility snapshot: ``img "alt" [eN]`` entries with the URL on + the following ``/url:`` line (Camofox has no /images endpoint).""" images = [] lines = snapshot.split("\n") for i, line in enumerate(lines): @@ -377,10 +362,10 @@ def _parse_snapshot_images(snapshot: str) -> list[Dict[str, str]]: def _fetch_snapshot(session: Dict[str, Any]) -> tuple[str, int]: - """``(snapshot_text, refs_count)`` truncated like the main browser tool (line - boundaries, full tree stored to cache/web, read_file pointer appended). - ``browser_tool`` imports this module, so import lazily.""" - data = _get(_tab_path(session, "snapshot"), params=_user_params(session)) + """``(snapshot_text, refs_count)`` truncated like the main browser tool (line boundaries, + full tree stored to cache/web, read_file pointer appended). Lazy import: ``browser_tool`` + imports this module.""" + data = _snapshot_data(session) snapshot = data.get("snapshot", "") from tools.browser_tool import _truncate_snapshot, get_browser_snapshot_threshold threshold = get_browser_snapshot_threshold() @@ -448,11 +433,10 @@ def camofox_navigate(url: str, task_id: Optional[str] = None) -> str: def _camofox_private_page_block(session: Dict[str, Any], task_id: Optional[str], action: str) -> Optional[str]: """Blocked payload when the current page is private/internal, else None. - Mirrors the ``_camofox_eval`` guard in browser_tool.py: page-state reads on a - non-local backend can leak an intranet/metadata page the terminal can't reach. - Only active when the SSRF guard applies (non-local backend, not a local sidecar, - ``allow_private_urls`` unset); fail-open on probe failure like sibling guards. - ``browser_tool`` imports this module, so import lazily. + Mirrors the ``_camofox_eval`` guard in browser_tool.py: page-state reads on a non-local + backend can leak an intranet/metadata page the terminal can't reach. Only active when + the SSRF guard applies (non-local backend, not a local sidecar, ``allow_private_urls`` + unset); fail-open on probe failure like sibling guards. Lazy import (cycle). """ from tools.browser_tool import _camofox_current_page_private_url, _eval_ssrf_guard_active if not _eval_ssrf_guard_active(task_id or "default"): @@ -475,31 +459,32 @@ def _require_tab(task_id: Optional[str], action: Optional[str] = None) -> tuple[ return session, None -def camofox_snapshot(full: bool = False, task_id: Optional[str] = None, user_task: Optional[str] = None) -> str: - """Accessibility tree snapshot. ``user_task`` is deprecated and ignored — - oversized snapshots always truncate-and-store (no LLM summarization).""" +def _with_tab(task_id: Optional[str], guard_action: Optional[str], body: Callable[[Dict[str, Any]], str]) -> str: + """Require a tab (+ private-page guard when ``guard_action`` is set), then run ``body(session)``; + any exception becomes a ``tool_error``.""" try: - session, blocked = _require_tab(task_id, "read a page snapshot") + session, blocked = _require_tab(task_id, guard_action) if blocked: return blocked - snapshot, refs_count = _fetch_snapshot(session) - return json.dumps({"success": True, "snapshot": snapshot, "element_count": refs_count}) + return body(session) except Exception as e: return tool_error(str(e), success=False) def _tab_action(task_id: Optional[str], guard_action: Optional[str], suffix: str, body: Dict[str, Any], result: Callable[[dict], dict]) -> str: - """Simple tab action: require a tab (+ private-page guard when ``guard_action`` - is set), POST ``body`` to ``/tabs//``, build the result.""" - try: - session, blocked = _require_tab(task_id, guard_action) - if blocked: - return blocked - data = _post(_tab_path(session, suffix), {"userId": session["user_id"], **body}) - return json.dumps(result(data)) - except Exception as e: - return tool_error(str(e), success=False) + """Simple tab action: POST ``body`` to ``/tabs//``, build the result.""" + return _with_tab(task_id, guard_action, lambda session: json.dumps( + result(_post(_tab_path(session, suffix), {"userId": session["user_id"], **body})))) + + +def camofox_snapshot(full: bool = False, task_id: Optional[str] = None, user_task: Optional[str] = None) -> str: + """Accessibility tree snapshot. ``user_task`` is deprecated and ignored — + oversized snapshots always truncate-and-store (no LLM summarization).""" + def body(session): + snapshot, refs_count = _fetch_snapshot(session) + return json.dumps({"success": True, "snapshot": snapshot, "element_count": refs_count}) + return _with_tab(task_id, "read a page snapshot", body) def camofox_click(ref: str, task_id: Optional[str] = None) -> str: @@ -518,9 +503,9 @@ def camofox_type(ref: str, text: str, task_id: Optional[str] = None) -> str: clean_ref = ref.lstrip("@") _post(_tab_path(session, "type"), {"userId": session["user_id"], "ref": clean_ref, "text": text}) from agent.display import redact_browser_typed_text_for_display, redact_tool_args_for_display - # Match browser_tool.browser_type: the raw text is typed into the page, but - # the returned display value is run through the secret-pattern redactor so - # API keys / tokens don't leak into tool progress or chat history. + # Match browser_tool.browser_type: the raw text is typed into the page, but the + # returned display value is run through the secret-pattern redactor so API keys / + # tokens don't leak into tool progress or chat history. display_text = (redact_tool_args_for_display("browser_type", {"text": text}) or {})["text"] response = {"success": True, "typed": display_text, "element": clean_ref} return json.dumps(redact_browser_typed_text_for_display(response, text)) @@ -558,15 +543,10 @@ def camofox_close(task_id: Optional[str] = None) -> str: def camofox_get_images(task_id: Optional[str] = None) -> str: """Get images on the current page via Camofox (parsed from the snapshot).""" - try: - session, blocked = _require_tab(task_id, "extract page images") - if blocked: - return blocked - - images = _parse_snapshot_images(_raw_snapshot(session)) + def body(session): + images = _parse_snapshot_images(_snapshot_data(session).get("snapshot", "")) return json.dumps({"success": True, "images": images, "count": len(images)}) - except Exception as e: - return tool_error(str(e), success=False) + return _with_tab(task_id, "extract page images", body) def _vision_llm_settings() -> tuple[float, float]: @@ -591,11 +571,7 @@ def _save_screenshot(content: bytes) -> str: def camofox_vision(question: str, annotate: bool = False, task_id: Optional[str] = None) -> str: """Take a screenshot and analyze it with vision AI via Camofox.""" - try: - session, blocked = _require_tab(task_id, "capture a screenshot") - if blocked: - return blocked - + def body(session): resp = _get_raw(_tab_path(session, "screenshot"), params=_user_params(session)) screenshot_path = _save_screenshot(resp.content) img_b64 = base64.b64encode(resp.content).decode("utf-8") @@ -603,7 +579,8 @@ def camofox_vision(question: str, annotate: bool = False, task_id: Optional[str] annotation_context = "" if annotate: try: - annotation_context = f"\n\nAccessibility tree (element refs for interaction):\n{_raw_snapshot(session)[:3000]}" + snapshot = _snapshot_data(session).get("snapshot", "") + annotation_context = f"\n\nAccessibility tree (element refs for interaction):\n{snapshot[:3000]}" except Exception: pass @@ -625,8 +602,7 @@ def camofox_vision(question: str, annotate: bool = False, task_id: Optional[str] analysis = (response.choices[0].message.content or "").strip() if response.choices else "" # Redact secrets the vision LLM may have read from the screenshot. return json.dumps({"success": True, "analysis": redact_sensitive_text(analysis), "screenshot_path": screenshot_path}) - except Exception as e: - return tool_error(str(e), success=False) + return _with_tab(task_id, "capture a screenshot", body) def camofox_console(clear: bool = False, task_id: Optional[str] = None) -> str: diff --git a/tools/browser_camofox_state.py b/tools/browser_camofox_state.py index 85d821d000..f8517259ee 100644 --- a/tools/browser_camofox_state.py +++ b/tools/browser_camofox_state.py @@ -1,8 +1,7 @@ """Hermes-managed Camofox state helpers. -With managed persistence enabled, Hermes sends a deterministic userId derived -from the active profile so Camofox maps it to the same persistent browser -profile directory across restarts. +With managed persistence enabled, Hermes sends a deterministic userId derived from the +active profile so Camofox maps it to the same persistent browser profile across restarts. """ from __future__ import annotations @@ -13,25 +12,16 @@ from typing import Dict, Optional from hermes_constants import get_hermes_home -CAMOFOX_STATE_DIR_NAME = "browser_auth" -CAMOFOX_STATE_SUBDIR = "camofox" - def get_camofox_state_dir() -> Path: """Return the profile-scoped root directory for Camofox persistence.""" - return get_hermes_home() / CAMOFOX_STATE_DIR_NAME / CAMOFOX_STATE_SUBDIR + return get_hermes_home() / "browser_auth" / "camofox" def get_camofox_identity(task_id: Optional[str] = None) -> Dict[str, str]: - """Return the stable Hermes-managed Camofox identity for this profile. - - The user identity is profile-scoped (same Hermes profile = same userId); the - session key is scoped to the logical browser task so new tabs within the - same profile reuse the same identity contract. - """ + """Stable Hermes-managed Camofox identity: userId is profile-scoped, session key is + scoped to the logical browser task so new tabs in the same profile reuse it.""" scope_root = str(get_camofox_state_dir()) user_digest = uuid.uuid5(uuid.NAMESPACE_URL, f"camofox-user:{scope_root}").hex[:10] - session_digest = uuid.uuid5( - uuid.NAMESPACE_URL, f"camofox-session:{scope_root}:{task_id or 'default'}" - ).hex[:16] + session_digest = uuid.uuid5(uuid.NAMESPACE_URL, f"camofox-session:{scope_root}:{task_id or 'default'}").hex[:16] return {"user_id": f"hermes_{user_digest}", "session_key": f"task_{session_digest}"} diff --git a/tools/browser_cdp_tool.py b/tools/browser_cdp_tool.py index dd585bb53f..f39cf15c95 100644 --- a/tools/browser_cdp_tool.py +++ b/tools/browser_cdp_tool.py @@ -1,10 +1,10 @@ #!/usr/bin/env python3 """Raw Chrome DevTools Protocol (CDP) passthrough tool ``browser_cdp``. -Sends arbitrary CDP commands to the browser's DevTools WebSocket when a CDP URL -is configured (``/browser connect`` → ``BROWSER_CDP_URL``, ``browser.cdp_url``, -or a CDP-backed cloud session). Escape hatch for operations the main browser -tools don't cover. Method reference: https://chromedevtools.github.io/devtools-protocol/ +Sends arbitrary CDP commands to the browser's DevTools WebSocket when a CDP URL is +configured (``/browser connect`` → ``BROWSER_CDP_URL``, ``browser.cdp_url``, or a +CDP-backed cloud session). Escape hatch for operations the main browser tools don't +cover. Method reference: https://chromedevtools.github.io/devtools-protocol/ """ from __future__ import annotations @@ -20,22 +20,17 @@ logger = logging.getLogger(__name__) CDP_DOCS_URL = "https://chromedevtools.github.io/devtools-protocol/" -# Browser/target inspection that never reads page body/cookies/DOM/storage — -# stays usable so the model can list tabs or navigate away from a blocked page. +# Browser/target inspection that never reads page body/cookies/DOM/storage — stays +# usable so the model can list tabs or navigate away from a blocked page. _CDP_PRIVATE_PAGE_ALLOWED_METHODS = { - "Browser.getVersion", - "Target.getTargets", - "Target.attachToTarget", - "Target.detachFromTarget", - "Page.navigate", - "Page.reload", - "Page.stopLoading", + "Browser.getVersion", "Target.getTargets", "Target.attachToTarget", "Target.detachFromTarget", + "Page.navigate", "Page.reload", "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 -# is not free text the model reads, so redaction protects no secret there. +# redact_sensitive_text's Fernet pattern ("gAAAA" + base64 alphabet) can match arbitrary +# spans inside such payloads and corrupt the decoded bytes; the payload is not free text +# the model reads, so redaction protects no secret there. _CDP_ALWAYS_BINARY_PATHS: Dict[str, tuple] = { "Page.captureScreenshot": (("data",),), "Page.printToPDF": (("data",),), @@ -57,10 +52,10 @@ _CDP_FLAGGED_BINARY_PATHS: Dict[str, tuple] = { 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. + 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. """ from agent.redact import redact_sensitive_text @@ -133,22 +128,18 @@ def _blocked(message: str, method: str) -> str: def _navigate_private_target(bt: Any, params: Dict[str, Any]) -> Optional[str]: """Blocked URL literal for ``Page.navigate`` params, else ``None``.""" + from tools.browser_tool_eval_policy import _url_blocked + target_url = str(params.get("url") or "").strip() - if target_url and (bt._is_always_blocked_url(target_url) or not bt._is_safe_url(target_url)): - return target_url - return None + return target_url if target_url and _url_blocked(bt, target_url) else None # method → (probe(bt, params) -> blocked literal | None, error template) _METHOD_PARAM_GUARDS = { - "Page.navigate": ( - _navigate_private_target, - "Blocked: CDP Page.navigate target is a private or internal address ({}).", - ), - "Runtime.evaluate": ( - lambda bt, params: bt._expression_targets_private_url(str(params.get("expression") or "")), - "Blocked: CDP Runtime.evaluate expression targets a private or internal address ({}).", - ), + "Page.navigate": (_navigate_private_target, + "Blocked: CDP Page.navigate target is a private or internal address ({})."), + "Runtime.evaluate": (lambda bt, params: bt._expression_targets_private_url(str(params.get("expression") or "")), + "Blocked: CDP Runtime.evaluate expression targets a private or internal address ({})."), } @@ -156,9 +147,8 @@ def _browser_cdp_private_guard(*, task_id: str, method: str, params: Dict[str, A """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. + ``browser_console`` / ``browser_eval`` and must not become their bypass. 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] @@ -190,19 +180,15 @@ async def _cdp_call( ) -> Dict[str, Any]: """Make a single CDP call, optionally attaching to a target first. - With ``target_id``, ``Target.attachToTarget(flatten=True)`` multiplexes a - page-level session over the browser-level WebSocket; without it ``method`` - runs at browser level (``Target.*``, ``Browser.*``, ``Storage.*`` …). + With ``target_id``, ``Target.attachToTarget(flatten=True)`` multiplexes a page-level + session over the browser-level WebSocket; without it ``method`` runs at browser level. """ assert websockets is not None # guarded by _WS_AVAILABLE at call-site - async with websockets.connect( - ws_url, - max_size=None, # CDP responses (e.g. DOM.getDocument) can be large - open_timeout=timeout, - close_timeout=5, - ping_interval=None, # CDP server doesn't expect pings - ) as ws: + # max_size=None: CDP responses (e.g. DOM.getDocument) can be large; ping_interval=None: CDP + # servers don't expect pings. + async with websockets.connect(ws_url, max_size=None, open_timeout=timeout, close_timeout=5, + ping_interval=None) as ws: next_id = 1 async def _send(req: Dict[str, Any], what: str) -> Dict[str, Any]: @@ -219,21 +205,17 @@ async def _cdp_call( if msg.get("id") == call_id: return msg - session_id: Optional[str] = None + req: Dict[str, Any] = {"method": method, "params": params or {}} if target_id: - msg = await _send( - {"method": "Target.attachToTarget", "params": {"targetId": target_id, "flatten": True}}, - f"attaching to target {target_id}", - ) + msg = await _send({"method": "Target.attachToTarget", "params": {"targetId": target_id, "flatten": True}}, + f"attaching to target {target_id}") if "error" in msg: raise RuntimeError(f"Target.attachToTarget failed: {msg['error']}") session_id = msg.get("result", {}).get("sessionId") if not session_id: raise RuntimeError("Target.attachToTarget did not return a sessionId") - - req: Dict[str, Any] = {"method": method, "params": params or {}} - if session_id: req["sessionId"] = session_id + msg = await _send(req, f"waiting for response to {method}") if "error" in msg: raise RuntimeError(f"CDP error: {msg['error']}") @@ -247,26 +229,18 @@ def _browser_cdp_via_supervisor( try: from tools.browser_supervisor import SUPERVISOR_REGISTRY # type: ignore[import-not-found] except Exception as exc: # pragma: no cover — defensive - return tool_error( - f"CDP supervisor is not available: {exc}. frame_id routing requires " - f"a running supervisor attached via /browser connect or an active " - f"Browserbase session." - ) + return tool_error(f"CDP supervisor is not available: {exc}. frame_id routing requires a running " + "supervisor attached via /browser connect or an active Browserbase session.") supervisor = SUPERVISOR_REGISTRY.get(task_id) if supervisor is None: - return tool_error( - f"No CDP supervisor is attached for task={task_id!r}. Call " - f"browser_navigate or /browser connect first so the supervisor " - f"can attach. Once attached, browser_snapshot will populate " - f"frame_tree with frame_ids you can pass here." - ) + return tool_error(f"No CDP supervisor is attached for task={task_id!r}. Call browser_navigate or " + "/browser connect first so the supervisor can attach. Once attached, browser_snapshot " + "will populate frame_tree with frame_ids you can pass here.") 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), - None, - ) + (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: # frame_tree is capped at 30 entries — check the raw frames dict too. with supervisor._state_lock: # type: ignore[attr-defined] @@ -274,22 +248,15 @@ def _browser_cdp_via_supervisor( 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. " - f"Call browser_snapshot to see current frame_tree." - ) + return tool_error(f"frame_id {frame_id!r} not found in supervisor state. " + "Call browser_snapshot to see current frame_tree.") child_sid = frame_info.get("session_id") if not child_sid: - # Same-origin iframes have no dedicated session; the agent reaches them - # via contentWindow/contentDocument from the parent instead. - return tool_error( - f"frame_id {frame_id!r} is not an out-of-process iframe (no " - f"dedicated CDP session). For same-origin iframes, use " - f"`browser_cdp(method='Runtime.evaluate', params={{'expression': " - f"\"document.querySelector('iframe').contentDocument.title\"}})` " - f"at the top-level page instead." - ) + # Same-origin iframes have no dedicated session; reach them via contentWindow/contentDocument. + return tool_error(f"frame_id {frame_id!r} is not an out-of-process iframe (no dedicated CDP session). " + "For same-origin iframes, use `browser_cdp(method='Runtime.evaluate', params={'expression': " + "\"document.querySelector('iframe').contentDocument.title\"})` at the top-level page instead.") loop = supervisor._loop # type: ignore[attr-defined] if loop is None or not loop.is_running(): @@ -298,24 +265,15 @@ def _browser_cdp_via_supervisor( try: from agent.async_utils import safe_schedule_threadsafe fut = safe_schedule_threadsafe( - supervisor._cdp(method, params or {}, session_id=child_sid, timeout=timeout), # type: ignore[attr-defined] - loop, - ) + supervisor._cdp(method, params or {}, session_id=child_sid, timeout=timeout), loop) # type: ignore[attr-defined] if fut is None: 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, - ) + return tool_error(f"CDP call via supervisor failed: {type(exc).__name__}: {exc}", cdp_docs=CDP_DOCS_URL) - return json.dumps({ - "success": True, - "method": method, - "frame_id": frame_id, - "session_id": child_sid, - "result": result_msg.get("result", {}), - }, ensure_ascii=False) + return json.dumps({"success": True, "method": method, "frame_id": frame_id, "session_id": child_sid, + "result": result_msg.get("result", {})}, ensure_ascii=False) def browser_cdp( @@ -328,12 +286,11 @@ def browser_cdp( ) -> str: """Send a raw CDP command (see ``CDP_DOCS_URL``). - ``target_id`` attaches a fresh stateless connection to a tab; ``frame_id`` - (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). Both paths share the same private-page/SSRF guard. Returns - JSON ``{"success": True, "method", "result"}`` or ``{"error": ...}``. + ``target_id`` attaches a fresh stateless connection to a tab; ``frame_id`` (OOPIF from + ``browser_snapshot.frame_tree``) routes through the supervisor's live WebSocket instead + — the only reliable way to evaluate inside an iframe where fresh per-call connections + hit signed-URL expiry (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" @@ -341,33 +298,23 @@ def browser_cdp( 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, - ) + return _browser_cdp_via_supervisor(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) if not _WS_AVAILABLE: - return tool_error( - "The 'websockets' Python package is required but not installed. " - "Install it with: pip install websockets" - ) + 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( - "No CDP endpoint is available. Run '/browser connect' to attach " - "to a running Chrome, Brave, Chromium, or Edge browser, or set " - "'browser.cdp_url' in config.yaml. The Camofox backend is REST-only " - "and does not expose CDP.", - cdp_docs=CDP_DOCS_URL, - ) + return tool_error("No CDP endpoint is available. Run '/browser connect' to attach to a running Chrome, " + "Brave, Chromium, or Edge browser, or set 'browser.cdp_url' in config.yaml. The Camofox " + "backend is REST-only 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}. " - "Expected ws://... or wss://... — the /browser connect " - "resolver should have rewritten this. Check that a Chromium-family " - "browser is actually listening on the debug port." - ) + return tool_error(f"CDP endpoint is not a WebSocket URL: {endpoint!r}. Expected ws://... or wss://... — " + "the /browser connect 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__}") @@ -389,24 +336,15 @@ def browser_cdp( except (TimeoutError, RuntimeError) as exc: return tool_error(str(exc), method=method) except WebSocketException as exc: - return tool_error( - f"WebSocket error talking to CDP at {endpoint}: {exc}. The " - "browser may have disconnected — try '/browser connect' again.", - method=method, - ) + return tool_error(f"WebSocket error talking to CDP at {endpoint}: {exc}. The browser may have " + "disconnected — try '/browser connect' again.", method=method) 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) - payload: Dict[str, Any] = { - "success": True, - "method": method, - "result": _redact_cdp_output( - result, - always_paths=_CDP_ALWAYS_BINARY_PATHS.get(method, ()), - flagged_paths=_CDP_FLAGGED_BINARY_PATHS.get(method, ()), - ), - } + payload: Dict[str, Any] = {"success": True, "method": method, "result": _redact_cdp_output( + result, always_paths=_CDP_ALWAYS_BINARY_PATHS.get(method, ()), + flagged_paths=_CDP_FLAGGED_BINARY_PATHS.get(method, ()))} if target_id: payload["target_id"] = target_id return json.dumps(payload, ensure_ascii=False) @@ -415,95 +353,54 @@ def browser_cdp( BROWSER_CDP_SCHEMA: Dict[str, Any] = { "name": "browser_cdp", "description": ( - "Send a raw Chrome DevTools Protocol (CDP) command. Escape hatch for " - "browser operations not covered by browser_navigate, browser_click, " - "browser_console, etc.\n\n" - "**Requires a reachable CDP endpoint.** Available when the user has " - "run '/browser connect' to attach to a running Chrome, Brave, Chromium, " - "or Edge browser, or when 'browser.cdp_url' is set in config.yaml. " - "Not currently wired up for cloud backends (Browserbase, Browser Use, " - "Firecrawl) — those expose CDP per session but live-session routing is " - "a follow-up. Camofox is REST-only and will never support CDP. If the " - "tool is in your toolset at all, a CDP endpoint is already reachable.\n\n" - f"**CDP method reference:** {CDP_DOCS_URL} — use web_extract on a " - "method's URL (e.g. '/tot/Page/#method-handleJavaScriptDialog') " - "to look up parameters and return shape.\n\n" + "Send a raw Chrome DevTools Protocol (CDP) command. Escape hatch for browser operations not covered " + "by browser_navigate, browser_click, browser_console, etc.\n\n" + "**Requires a reachable CDP endpoint.** Available when the user has run '/browser connect' to attach " + "to a running Chrome, Brave, Chromium, or Edge browser, or when 'browser.cdp_url' is set in " + "config.yaml. Not currently wired up for cloud backends (Browserbase, Browser Use, Firecrawl) — " + "those expose CDP per session but live-session routing is a follow-up. Camofox is REST-only and " + "will never support CDP. If the tool is in your toolset at all, a CDP endpoint is already reachable.\n\n" + f"**CDP method reference:** {CDP_DOCS_URL} — use web_extract on a method's URL " + "(e.g. '/tot/Page/#method-handleJavaScriptDialog') to look up parameters and return shape.\n\n" "**Common patterns:**\n" "- List tabs: method='Target.getTargets', params={}\n" "- Handle a native JS dialog: method='Page.handleJavaScriptDialog', " "params={'accept': true, 'promptText': ''}, target_id=\n" "- Get all cookies: method='Network.getAllCookies', params={}\n" - "- Eval in a specific tab: method='Runtime.evaluate', " - "params={'expression': '...', 'returnByValue': true}, " + "- Eval in a specific tab: method='Runtime.evaluate', params={'expression': '...', 'returnByValue': true}, " "target_id=\n" "- Set viewport for a tab: method='Emulation.setDeviceMetricsOverride', " - "params={'width': 1280, 'height': 720, 'deviceScaleFactor': 1, " - "'mobile': false}, target_id=\n\n" + "params={'width': 1280, 'height': 720, 'deviceScaleFactor': 1, 'mobile': false}, target_id=\n\n" "**Usage rules:**\n" - "- Browser-level methods (Target.*, Browser.*, Storage.*): omit " - "target_id and frame_id.\n" - "- Page-level methods (Page.*, Runtime.*, DOM.*, Emulation.*, " - "Network.* scoped to a tab): pass target_id from Target.getTargets.\n" - "- **Cross-origin iframe scope** (Runtime.evaluate inside an OOPIF, " - "Page.* targeting a frame target, etc.): pass frame_id from the " - "browser_snapshot frame_tree output. This routes through the CDP " - "supervisor's live connection — the only reliable way on " - "Browserbase where stateless CDP calls hit signed-URL expiry.\n" - "- Each stateless call (without frame_id) is independent — sessions " - "and event subscriptions do not persist between calls. For stateful " - "workflows, prefer the dedicated browser tools or use frame_id " + "- Browser-level methods (Target.*, Browser.*, Storage.*): omit target_id and frame_id.\n" + "- Page-level methods (Page.*, Runtime.*, DOM.*, Emulation.*, Network.* scoped to a tab): pass " + "target_id from Target.getTargets.\n" + "- **Cross-origin iframe scope** (Runtime.evaluate inside an OOPIF, Page.* targeting a frame target, " + "etc.): pass frame_id from the browser_snapshot frame_tree output. This routes through the CDP " + "supervisor's live connection — the only reliable way on Browserbase where stateless CDP calls hit " + "signed-URL expiry.\n" + "- Each stateless call (without frame_id) is independent — sessions and event subscriptions do not " + "persist between calls. For stateful workflows, prefer the dedicated browser tools or use frame_id " "routing." ), "parameters": { "type": "object", "properties": { - "method": { - "type": "string", - "description": ( - "CDP method name, e.g. 'Target.getTargets', " - "'Runtime.evaluate', 'Page.handleJavaScriptDialog'." - ), - }, - "params": { - "type": "object", - "description": ( - "Method-specific parameters as a JSON object. Omit or " - "pass {} for methods that take no parameters." - ), - "properties": {}, - "additionalProperties": True, - }, - "target_id": { - "type": "string", - "description": ( - "Optional. Target/tab ID from Target.getTargets result " - "(each entry's 'targetId'). Use for page-level methods " - "at the top-level tab scope. Mutually exclusive with " - "frame_id." - ), - }, - "frame_id": { - "type": "string", - "description": ( - "Optional. Out-of-process iframe (OOPIF) frame_id from " - "browser_snapshot.frame_tree.children[] where " - "is_oopif=true. When set, routes the call through the " - "CDP supervisor's live session for that iframe. " - "Essential for Runtime.evaluate inside cross-origin " - "iframes, especially on Browserbase where fresh " - "per-call CDP connections can't keep up with signed " - "URL rotation. For same-origin iframes, use parent " - "contentWindow/contentDocument from Runtime.evaluate " - "at the top-level page instead." - ), - }, - "timeout": { - "type": "number", - "description": ( - "Timeout in seconds (default 30, max 300)." - ), - "default": 30, - }, + "method": {"type": "string", "description": ( + "CDP method name, e.g. 'Target.getTargets', 'Runtime.evaluate', 'Page.handleJavaScriptDialog'.")}, + "params": {"type": "object", "properties": {}, "additionalProperties": True, "description": ( + "Method-specific parameters as a JSON object. Omit or pass {} for methods that take no parameters.")}, + "target_id": {"type": "string", "description": ( + "Optional. Target/tab ID from Target.getTargets result (each entry's 'targetId'). Use for " + "page-level methods at the top-level tab scope. Mutually exclusive with frame_id.")}, + "frame_id": {"type": "string", "description": ( + "Optional. Out-of-process iframe (OOPIF) frame_id from browser_snapshot.frame_tree.children[] " + "where is_oopif=true. When set, routes the call through the CDP supervisor's live session for " + "that iframe. Essential for Runtime.evaluate inside cross-origin iframes, especially on " + "Browserbase where fresh per-call CDP connections can't keep up with signed URL rotation. For " + "same-origin iframes, use parent contentWindow/contentDocument from Runtime.evaluate at the " + "top-level page instead.")}, + "timeout": {"type": "number", "default": 30, "description": "Timeout in seconds (default 30, max 300)."}, }, "required": ["method"], }, @@ -511,14 +408,10 @@ BROWSER_CDP_SCHEMA: Dict[str, Any] = { def _browser_cdp_check() -> bool: - """Availability check: offered only when a static CDP URL is set. - - 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. - """ + """Availability check: offered only when a static CDP URL is set (Camofox is REST-only; + the default local agent-browser hides its CDP port; cloud per-session ``cdp_url`` isn't + surfaced). Raw (no-I/O) gate: check_fns run at every startup, and resolving the endpoint + over HTTP here would block launch on a stale endpoint.""" try: from tools.browser_tool import ( # type: ignore[import-not-found] _get_cdp_override_raw, @@ -535,18 +428,12 @@ registry.register( toolset="browser-cdp", schema=BROWSER_CDP_SCHEMA, handler=lambda args, **kw: routed_browser_handler( - "browser_cdp", - args, + "browser_cdp", args, fallback=lambda: browser_cdp( - method=args.get("method", ""), - params=args.get("params"), - target_id=args.get("target_id"), - frame_id=args.get("frame_id"), - timeout=args.get("timeout", 30.0), - task_id=kw.get("task_id"), + method=args.get("method", ""), params=args.get("params"), target_id=args.get("target_id"), + frame_id=args.get("frame_id"), timeout=args.get("timeout", 30.0), task_id=kw.get("task_id"), ), - task_id=kw.get("task_id"), - session_id=kw.get("session_id"), + task_id=kw.get("task_id"), session_id=kw.get("session_id"), ), check_fn=_browser_cdp_check, emoji="🧪", diff --git a/tools/browser_lightpanda.py b/tools/browser_lightpanda.py index ca98d2dadb..b8418d3aec 100644 --- a/tools/browser_lightpanda.py +++ b/tools/browser_lightpanda.py @@ -1,12 +1,9 @@ """Lightpanda local engine for Browser Use mode. -With ``browser.engine: lightpanda``, Browser Use mode spawns one ``lightpanda -serve`` per browser session and points ``browser_exec`` at its CDP endpoint -(``BU_CDP_URL``); the built-in ``browser_*`` tools keep going through -``agent-browser --engine lightpanda``. ``tools.browser_tool`` owns the session -cache, inactivity reaper and atexit sweep, calling :func:`launch_lightpanda`, -:func:`stop_lightpanda` and :func:`reap_orphaned_lightpanda` (for processes a -crashed Hermes left behind). +With ``browser.engine: lightpanda``, Browser Use mode spawns one ``lightpanda serve`` per +browser session and points ``browser_exec`` at its CDP endpoint (``BU_CDP_URL``); the +built-in ``browser_*`` tools keep going through ``agent-browser --engine lightpanda``. +``tools.browser_tool`` owns the session cache, inactivity reaper and atexit sweep. """ import json @@ -24,9 +21,7 @@ from typing import Dict, Optional, Tuple logger = logging.getLogger(__name__) LIGHTPANDA_INSTALL_URL = "https://lightpanda.io/docs/run-locally/installation/one-liner" -LIGHTPANDA_INSTALL_HINT = ( - f"Install Lightpanda from {LIGHTPANDA_INSTALL_URL} and make sure " "`lightpanda` is on PATH" -) +LIGHTPANDA_INSTALL_HINT = f"Install Lightpanda from {LIGHTPANDA_INSTALL_URL} and make sure " "`lightpanda` is on PATH" _READY_TIMEOUT_S = 10.0 _POLL_INTERVAL_S = 0.1 @@ -55,12 +50,9 @@ class LightpandaServer: def _home_candidates() -> list: home = Path.home() - candidates = [ - home / ".lightpanda" / "lightpanda", home / ".local" / "bin" / "lightpanda" - ] + candidates = [home / ".lightpanda" / "lightpanda", home / ".local" / "bin" / "lightpanda"] try: from hermes_constants import get_hermes_home - candidates.append(Path(get_hermes_home()) / "bin" / "lightpanda") except Exception as e: # pragma: no cover - defensive logger.debug("hermes home unavailable for lightpanda lookup: %s", e) @@ -68,30 +60,19 @@ def _home_candidates() -> list: def find_lightpanda_binary() -> Optional[str]: - """Return the lightpanda executable, or None. - - Order: PATH (with the same Homebrew/managed-node fallbacks agent-browser - gets), then the locations the Lightpanda installer and agent-browser use - (``~/.lightpanda/lightpanda``, ``~/.local/bin/lightpanda``), then - ``$HERMES_HOME/bin/lightpanda``. Lightpanda has no Windows build. - """ + """Return the lightpanda executable, or None. Order: PATH (with agent-browser's Homebrew/managed-node + fallbacks), then installer/agent-browser locations, then ``$HERMES_HOME/bin``. No Windows build.""" if os.name == "nt": logger.debug("Lightpanda has no Windows build") return None path_env = os.environ.get("PATH", "") try: from tools.browser_tool import _merge_browser_path - path_env = _merge_browser_path(path_env) except Exception as e: logger.debug("browser PATH merge unavailable: %s", e) - found = shutil.which("lightpanda", path=path_env) - if found: - return found - for candidate in _home_candidates(): - if candidate.is_file() and os.access(candidate, os.X_OK): - return str(candidate) - return None + return shutil.which("lightpanda", path=path_env) or next( + (str(c) for c in _home_candidates() if c.is_file() and os.access(c, os.X_OK)), None) def _pick_free_loopback_port() -> int: @@ -103,48 +84,40 @@ def _pick_free_loopback_port() -> int: def _state_dir() -> Path: from hermes_constants import get_hermes_home - path = Path(get_hermes_home()) / "cache" / "browser-use" / "lightpanda" path.mkdir(parents=True, exist_ok=True) return path -def _record_path(session_name: str) -> Path: - return _state_dir() / f"{session_name}.json" - - def _browser_env() -> dict: try: from tools.browser_tool import _build_browser_env - return _build_browser_env() except Exception as e: logger.debug("credential-scrubbed browser env unavailable: %s", e) return os.environ.copy() -def _cdp_ready(url: str) -> bool: +def _cdp_ready(url: str, timeout: float = 0.2) -> bool: try: from hermes_cli.browser_connect import is_browser_debug_ready - - return is_browser_debug_ready(url, timeout=0.2) + return is_browser_debug_ready(url, timeout=timeout) except Exception as e: logger.debug("CDP readiness probe failed for %s: %s", url, e) return False def _read_log_tail(path: str) -> str: + """Last non-empty line of the child's stderr log ('' when unreadable).""" try: - with open(path, "rb") as fh: - data = fh.read() + text = Path(path).read_bytes()[-_STDERR_TAIL_LIMIT:].decode("utf-8", errors="replace") except OSError: return "" - text = data[-_STDERR_TAIL_LIMIT:].decode("utf-8", errors="replace").strip() - lines = [line for line in text.splitlines() if line.strip()] - return lines[-1] if lines else "" + return next((ln for ln in reversed(text.strip().splitlines()) if ln.strip()), "") -def _terminate(proc: subprocess.Popen) -> None: +def _terminate(proc: subprocess.Popen, what: str = "lightpanda") -> None: + """poll -> terminate -> wait(5) -> kill; shared with the real-profile Chrome cleanup.""" try: if proc.poll() is None: proc.terminate() @@ -153,13 +126,12 @@ def _terminate(proc: subprocess.Popen) -> None: except Exception: proc.kill() except Exception as e: - logger.debug("lightpanda terminate failed: %s", e) + logger.debug("%s terminate failed: %s", what, e) def _safe_start_time(pid: int) -> Optional[int]: try: from tools.process_registry import ProcessRegistry - return ProcessRegistry._safe_host_start_time(pid) except Exception: return None @@ -168,84 +140,36 @@ def _safe_start_time(pid: int) -> Optional[int]: def _tree_kill(pid: int, expected_start) -> None: """Tree-kill ``pid`` via ProcessRegistry, verifying its start time first.""" from tools.process_registry import ProcessRegistry - ProcessRegistry._terminate_host_pid(pid, expected_start=expected_start) -def _kill_server(server: LightpandaServer) -> None: - """Tree-kill a live server, falling back to plain terminate on any failure.""" - try: - _tree_kill(server.proc.pid, server.start_time) - except Exception as e: - logger.debug("lightpanda tree-kill failed for %s: %s", server.session_name, e) - _terminate(server.proc) - try: - server.proc.wait(timeout=5) - except Exception: - _terminate(server.proc) - - def _write_record(server: LightpandaServer) -> None: - record = { - "pid": server.proc.pid, - "port": server.port, - "owner_pid": os.getpid(), - "start_time": server.start_time, - "started_at": time.time(), - } + record = {"pid": server.proc.pid, "port": server.port, "owner_pid": os.getpid(), + "start_time": server.start_time, "started_at": time.time()} try: - _record_path(server.session_name).write_text(json.dumps(record), encoding="utf-8") + (_state_dir() / f"{server.session_name}.json").write_text(json.dumps(record), encoding="utf-8") except OSError as e: logger.debug("could not write lightpanda record for %s: %s", server.session_name, e) -def _unlink_record(session_name: str) -> None: - try: - _record_path(session_name).unlink(missing_ok=True) - except OSError as e: - logger.debug("could not remove lightpanda record for %s: %s", session_name, e) - - -def launch_lightpanda( - session_name: str, *, block_private_networks: bool = False -) -> Tuple[Optional[LightpandaServer], Optional[str]]: - """Start ``lightpanda serve`` on a free loopback port for ``session_name``. - - Returns ``(server, None)`` once ``/json/version`` answers, or - ``(None, error)`` with an actionable message. The child's stderr goes to - ``$HERMES_HOME/cache/browser-use/lightpanda/.log`` so a chatty - process can never block on a pipe; only the tail is read on failure. - """ +def launch_lightpanda(session_name: str, *, block_private_networks: bool = False) -> Tuple[Optional[LightpandaServer], Optional[str]]: + """Start ``lightpanda serve`` on a free loopback port; ``(server, None)`` once ``/json/version`` answers, + else ``(None, error)``. stderr goes to ``/.log`` so a chatty child never blocks on a pipe.""" binary = find_lightpanda_binary() if not binary: if os.name == "nt": - return None, ( - "browser.engine is 'lightpanda' but Lightpanda has no Windows " - "build. Set browser.engine to auto (or run Hermes under WSL2)." - ) - return None, ( - "browser.engine is 'lightpanda' but no lightpanda binary was found " - "on PATH, ~/.lightpanda or ~/.local/bin. " - f"{LIGHTPANDA_INSTALL_HINT}, or set browser.engine to auto." - ) + return None, ("browser.engine is 'lightpanda' but Lightpanda has no Windows " + "build. Set browser.engine to auto (or run Hermes under WSL2).") + return None, ("browser.engine is 'lightpanda' but no lightpanda binary was found on PATH, ~/.lightpanda " + f"or ~/.local/bin. {LIGHTPANDA_INSTALL_HINT}, or set browser.engine to auto.") port = _pick_free_loopback_port() - argv = [binary, "serve", "--host", "127.0.0.1", "--port", str(port)] - if block_private_networks: - argv.append("--block-private-networks") + argv = [binary, "serve", "--host", "127.0.0.1", "--port", str(port)] + (["--block-private-networks"] if block_private_networks else []) log_path = str(_state_dir() / f"{session_name}.log") - - # find_lightpanda_binary() returns None on nt, so no Windows spawn branch is needed. try: with open(log_path, "wb") as log_file: - proc = subprocess.Popen( - argv, - stdin=subprocess.DEVNULL, - stdout=subprocess.DEVNULL, - stderr=log_file, - env=_browser_env(), - start_new_session=True, - ) + proc = subprocess.Popen(argv, stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, + stderr=log_file, env=_browser_env(), start_new_session=True) except (OSError, subprocess.SubprocessError) as e: return None, f"Failed to launch lightpanda serve ({binary}): {e}" @@ -255,36 +179,21 @@ def launch_lightpanda( rc = proc.poll() if rc is not None: tail = _read_log_tail(log_path) - detail = f": {tail}" if tail else "" - return None, ( - f"lightpanda serve exited with code {rc} before {url}/json/version " - f"answered{detail}" - ) + return None, f"lightpanda serve exited with code {rc} before {url}/json/version answered{f': {tail}' if tail else ''}" if _cdp_ready(url): break if time.monotonic() >= deadline: _terminate(proc) tail = _read_log_tail(log_path) detail = f" (last stderr line: {tail})" if tail else "" - return None, ( - f"lightpanda serve did not expose {url}/json/version within " - f"{int(_READY_TIMEOUT_S)}s{detail}" - ) + return None, f"lightpanda serve did not expose {url}/json/version within {int(_READY_TIMEOUT_S)}s{detail}" time.sleep(_POLL_INTERVAL_S) - server = LightpandaServer( - session_name=session_name, - port=port, - proc=proc, - log_path=log_path, - start_time=_safe_start_time(proc.pid), - ) + server = LightpandaServer(session_name, port, proc, log_path, _safe_start_time(proc.pid)) _write_record(server) with _servers_lock: _servers[session_name] = server - logger.info( - "Started lightpanda serve (pid %s, port %s) for session %s", proc.pid, port, session_name - ) + logger.info("Started lightpanda serve (pid %s, port %s) for session %s", proc.pid, port, session_name) return server, None @@ -294,12 +203,23 @@ def get_server(session_name: str) -> Optional[LightpandaServer]: def stop_lightpanda(session_name: str) -> None: - """Stop the server for ``session_name`` (tree-kill) and drop its record.""" + """Stop the server for ``session_name`` (tree-kill, plain terminate on failure) and drop its record.""" with _servers_lock: server = _servers.pop(session_name, None) if server is not None and server.is_alive(): - _kill_server(server) - _unlink_record(session_name) + try: + _tree_kill(server.proc.pid, server.start_time) + except Exception as e: + logger.debug("lightpanda tree-kill failed for %s: %s", server.session_name, e) + _terminate(server.proc) + try: + server.proc.wait(timeout=5) + except Exception: + _terminate(server.proc) + try: + (_state_dir() / f"{session_name}.json").unlink(missing_ok=True) + except OSError as e: + logger.debug("could not remove lightpanda record for %s: %s", session_name, e) if server is not None: logger.debug("Stopped lightpanda serve for session %s", session_name) @@ -319,33 +239,23 @@ def _is_lightpanda_process(pid: int, port, start_time) -> bool: """True only when ``pid`` is verifiably the ``lightpanda serve`` we recorded.""" try: import psutil - proc = psutil.Process(pid) if "lightpanda" not in proc.name().lower(): return False cmdline = proc.cmdline() if "serve" not in cmdline or str(port) not in cmdline: return False + if start_time: + from gateway.status import get_process_start_time + return get_process_start_time(pid) == start_time except Exception: return False - if start_time: - try: - from gateway.status import get_process_start_time - - return get_process_start_time(pid) == start_time - except Exception: - return False return True def reap_orphaned_lightpanda() -> int: - """Kill ``lightpanda serve`` processes whose owning Hermes is gone. - - Records are written by :func:`launch_lightpanda`; a live owner (another - Hermes process, or this one still tracking the session) is never - touched, and a PID is only signalled after psutil confirms it is still - a ``lightpanda serve`` on the recorded port. Returns the reap count. - """ + """Kill ``lightpanda serve`` processes whose owning Hermes is gone; return the count. A live owner is + never touched; a PID is only signalled after psutil confirms it is still ``lightpanda serve`` on the recorded port.""" try: state_dir = _state_dir() except Exception as e: @@ -372,14 +282,12 @@ def reap_orphaned_lightpanda() -> int: elif owner_pid and _pid_exists(int(owner_pid)): continue pid = record.get("pid") - if not pid or not _is_lightpanda_process(int(pid), record.get("port"), record.get("start_time")): - record_path.unlink(missing_ok=True) - continue - try: - _tree_kill(int(pid), record.get("start_time")) - reaped += 1 - logger.info("Reaped orphaned lightpanda serve pid %s (session %s)", pid, session_name) - except Exception as e: - logger.debug("orphan lightpanda kill failed for pid %s: %s", pid, e) + if pid and _is_lightpanda_process(int(pid), record.get("port"), record.get("start_time")): + try: + _tree_kill(int(pid), record.get("start_time")) + reaped += 1 + logger.info("Reaped orphaned lightpanda serve pid %s (session %s)", pid, session_name) + except Exception as e: + logger.debug("orphan lightpanda kill failed for pid %s: %s", pid, e) record_path.unlink(missing_ok=True) return reaped diff --git a/tools/browser_tool_cdp.py b/tools/browser_tool_cdp.py index 520ad23864..ec4dbf38f3 100644 --- a/tools/browser_tool_cdp.py +++ b/tools/browser_tool_cdp.py @@ -1,12 +1,9 @@ """User-supplied CDP endpoint resolution (browser.cdp_url / real-profile), dialog-policy config and the per-task CDP supervisor lifecycle. -Split out of ``tools/browser_tool.py``; every name is re-imported there so -``tools.browser_tool.`` keeps resolving (and monkeypatching). Origin -symbols and module state are read/written through ``_bt`` (the origin module, -resolved per call by :func:`tools.browser_tool_origin.origin_module`) so -``patch("tools.browser_tool.X")`` is honoured and no import cycle exists. -""" +Split out of ``tools/browser_tool.py``; every name is re-imported there (tests monkeypatch ``tools.browser_tool.``). +Origin symbols/state go through ``_bt`` (origin module, resolved per call) — no import cycle.""" +import contextlib import os from typing import Tuple @@ -16,32 +13,25 @@ from tools.browser_tool_origin import origin_module as _origin def _resolve_cdp_override(cdp_url: str) -> str: """Normalize a user-supplied CDP endpoint into a concrete websocket URL. - Full ``ws://.../devtools/browser/...`` endpoints pass through; HTTP - discovery roots and bare ``ws://host:port`` are resolved via - ``/json/version`` → ``webSocketDebuggerUrl`` (falls back to the raw value - with a warning if discovery fails). + Full ``ws://.../devtools/browser/...`` endpoints pass through; HTTP discovery roots and bare ``ws://host:port`` + resolve via ``/json/version`` → ``webSocketDebuggerUrl`` (falls back to the raw value with a warning). """ _bt = _origin() raw = (cdp_url or "").strip() if not raw: return "" - lowered = raw.lower() if "/devtools/browser/" in lowered: return raw discovery_url = raw if lowered.startswith(("ws://", "wss://")): - if raw.count(":") == 2 and raw.rstrip("/").rsplit(":", 1)[-1].isdigit() and "/" not in raw.split(":", 2)[-1]: - discovery_url = ("http://" if lowered.startswith("ws://") else "https://") + raw.split("://", 1)[1] - else: + if not (raw.count(":") == 2 and raw.rstrip("/").rsplit(":", 1)[-1].isdigit() and "/" not in raw.split(":", 2)[-1]): return raw + discovery_url = ("http://" if lowered.startswith("ws://") else "https://") + raw.split("://", 1)[1] + version_url = discovery_url if discovery_url.lower().endswith("/json/version") else discovery_url.rstrip("/") + "/json/version" - if discovery_url.lower().endswith("/json/version"): - version_url = discovery_url - else: - version_url = discovery_url.rstrip("/") + "/json/version" - + san = _bt._sanitize_url_for_logs try: import requests # lazy — shared module object, test patches still apply @@ -49,94 +39,62 @@ def _resolve_cdp_override(cdp_url: str) -> str: response.raise_for_status() payload = response.json() except Exception as exc: - _bt.logger.warning( - "Failed to resolve CDP endpoint %s via %s: %s", - _bt._sanitize_url_for_logs(raw), - _bt._sanitize_url_for_logs(version_url), - _bt._sanitize_url_for_logs(exc), - ) + _bt.logger.warning("Failed to resolve CDP endpoint %s via %s: %s", san(raw), san(version_url), san(exc)) return raw - ws_url = str(payload.get("webSocketDebuggerUrl") or "").strip() if ws_url: - _bt.logger.info( - "Resolved CDP endpoint %s -> %s", - _bt._sanitize_url_for_logs(raw), - _bt._sanitize_url_for_logs(ws_url), - ) + _bt.logger.info("Resolved CDP endpoint %s -> %s", san(raw), san(ws_url)) return ws_url - - _bt.logger.warning( - "CDP discovery at %s did not return webSocketDebuggerUrl; using raw endpoint", - _bt._sanitize_url_for_logs(version_url), - ) + _bt.logger.warning("CDP discovery at %s did not return webSocketDebuggerUrl; using raw endpoint", san(version_url)) return raw def _get_cdp_override_raw() -> str: """Return the *configured* CDP override without any network I/O. - Precedence: ``BROWSER_CDP_URL`` env (live ``/browser connect`` override), - then ``browser.cdp_url`` in config.yaml. Callers that only need to know - *whether* an override exists (check_fn gates, ``_is_local_mode`` / - ``_is_local_backend``, ``hermes doctor``) MUST use this, not - :func:`_get_cdp_override`: that one does a 10s HTTP discovery, and a stale - ``cdp_url`` pointing at a dead Chrome would stall every startup's schema - build with no error — no side effects during schema build. + Precedence: ``BROWSER_CDP_URL`` env (live ``/browser connect``), then ``browser.cdp_url``. Is-it-configured + gates (check_fns, ``_is_local_mode`` / ``_is_local_backend``, ``hermes doctor``) MUST use this, not + :func:`_get_cdp_override`: its 10s HTTP discovery against a stale ``cdp_url`` would stall every startup's + schema build with no error. """ - _bt = _origin() env_override = os.environ.get("BROWSER_CDP_URL", "").strip() - if env_override: - return env_override - return _bt._browser_cfg( - "cdp_url", "", lambda v: str(v or "").strip(), "browser.cdp_url from config" - ) + return env_override or _origin()._browser_cfg("cdp_url", "", lambda v: str(v or "").strip(), "browser.cdp_url from config") def _get_cdp_override() -> str: - """Return the resolved CDP URL override, or "" (skips cloud AND local launch). + """Resolved CDP URL override, or "" (skips cloud AND local launch). - May perform an HTTP ``/json/version`` discovery request — only call on - paths about to *connect* (session creation, supervisor attach); pure - is-it-configured gates must use :func:`_get_cdp_override_raw`. + May perform HTTP ``/json/version`` discovery — only call on paths about to *connect*; pure gates must use + :func:`_get_cdp_override_raw`. """ _bt = _origin() - raw = _bt._get_cdp_override_raw() - if not raw: - return "" - return _bt._resolve_cdp_override(raw) + return _bt._resolve_cdp_override(raw) if (raw := _bt._get_cdp_override_raw()) else "" def _get_dialog_policy_config() -> Tuple[str, float]: - """Read ``browser.dialog_policy`` + ``browser.dialog_timeout_s`` from config. - - Returns a ``(policy, timeout_s)`` tuple, falling back to the supervisor's - defaults when keys are absent or invalid. - """ - # Defer imports so browser_tool can be imported in minimal environments. + """Read ``browser.dialog_policy`` + ``browser.dialog_timeout_s``; supervisor defaults when absent/invalid.""" _bt = _origin() - from tools.browser_supervisor import ( - DEFAULT_DIALOG_POLICY, DEFAULT_DIALOG_TIMEOUT_S, _VALID_POLICIES - ) + # Deferred so browser_tool imports in minimal environments. + from tools.browser_supervisor import DEFAULT_DIALOG_POLICY, DEFAULT_DIALOG_TIMEOUT_S, _VALID_POLICIES + policy, timeout_s = DEFAULT_DIALOG_POLICY, DEFAULT_DIALOG_TIMEOUT_S try: from hermes_cli.config import read_raw_config cfg = read_raw_config() browser_cfg = cfg.get("browser", {}) if isinstance(cfg, dict) else {} if not isinstance(browser_cfg, dict): - return DEFAULT_DIALOG_POLICY, DEFAULT_DIALOG_TIMEOUT_S - policy = str(browser_cfg.get("dialog_policy") or DEFAULT_DIALOG_POLICY) - if policy not in _VALID_POLICIES: - _bt.logger.debug("Invalid browser.dialog_policy=%r; using default", policy) - policy = DEFAULT_DIALOG_POLICY + return policy, timeout_s + candidate = str(browser_cfg.get("dialog_policy") or DEFAULT_DIALOG_POLICY) + if candidate in _VALID_POLICIES: + policy = candidate + else: + _bt.logger.debug("Invalid browser.dialog_policy=%r; using default", candidate) timeout_raw = browser_cfg.get("dialog_timeout_s") - try: - timeout_s = float(timeout_raw) if timeout_raw is not None else DEFAULT_DIALOG_TIMEOUT_S - if timeout_s <= 0: - timeout_s = DEFAULT_DIALOG_TIMEOUT_S - except (TypeError, ValueError): - timeout_s = DEFAULT_DIALOG_TIMEOUT_S + with contextlib.suppress(TypeError, ValueError): + parsed = float(timeout_raw) if timeout_raw is not None else DEFAULT_DIALOG_TIMEOUT_S + if parsed > 0: + timeout_s = parsed return policy, timeout_s except Exception: return DEFAULT_DIALOG_POLICY, DEFAULT_DIALOG_TIMEOUT_S @@ -145,18 +103,14 @@ def _get_dialog_policy_config() -> Tuple[str, float]: def _ensure_cdp_supervisor(task_id: str) -> None: """Start a CDP supervisor for ``task_id`` if an endpoint is reachable. - Idempotent (``SupervisorRegistry.get_or_start`` skips an existing - ``(task_id, cdp_url)`` and restarts on URL change), so safe on every - navigate / ``/browser connect``. URL precedence: the CDP override, then the - session's own ``cdp_url`` (cloud providers). Swallows all errors — a failed - attach must not break the session; snapshots just lack - ``pending_dialogs`` / ``frame_tree``. + Idempotent (``get_or_start`` skips an existing ``(task_id, cdp_url)`` and restarts on URL change), so safe on + every navigate / ``/browser connect``. URL precedence: the CDP override, then the session's own ``cdp_url`` + (cloud providers, e.g. Browserbase). Swallows all errors — a failed attach must not break the session; + snapshots just lack ``pending_dialogs`` / ``frame_tree``. """ _bt = _origin() cdp_url = _bt._get_cdp_override() if not cdp_url: - # Fallback: active session may carry a per-session CDP URL from a - # cloud provider (Browserbase sets this). with _bt._cleanup_lock: session_info = _bt._active_sessions.get(task_id, {}) maybe = str(session_info.get("cdp_url") or "") @@ -168,21 +122,16 @@ def _ensure_cdp_supervisor(task_id: str) -> None: from tools.browser_supervisor import SUPERVISOR_REGISTRY # type: ignore[import-not-found] policy, timeout_s = _bt._get_dialog_policy_config() - SUPERVISOR_REGISTRY.get_or_start( - task_id=task_id, cdp_url=cdp_url, dialog_policy=policy, dialog_timeout_s=timeout_s - ) + SUPERVISOR_REGISTRY.get_or_start(task_id=task_id, cdp_url=cdp_url, dialog_policy=policy, dialog_timeout_s=timeout_s) except Exception as exc: - _bt.logger.debug( - "CDP supervisor attach for task=%s failed (non-fatal): %s", task_id, exc - ) + _bt.logger.debug("CDP supervisor attach for task=%s failed (non-fatal): %s", task_id, exc) def _stop_cdp_supervisor(task_id: str) -> None: """Stop the CDP supervisor for ``task_id`` if one exists. No-op otherwise.""" - _bt = _origin() try: from tools.browser_supervisor import SUPERVISOR_REGISTRY # type: ignore[import-not-found] SUPERVISOR_REGISTRY.stop(task_id) except Exception as exc: - _bt.logger.debug("CDP supervisor stop for task=%s failed (non-fatal): %s", task_id, exc) + _origin().logger.debug("CDP supervisor stop for task=%s failed (non-fatal): %s", task_id, exc) diff --git a/tools/browser_tool_cloud.py b/tools/browser_tool_cloud.py index 7621b8308d..46d9b6d918 100644 --- a/tools/browser_tool_cloud.py +++ b/tools/browser_tool_cloud.py @@ -1,63 +1,52 @@ """Cloud browser provider resolution (explicit browser.cloud_provider, auto-detect, per-profile cache), backend/engine selection and headed-mode flags. -Split out of ``tools/browser_tool.py``; every name is re-imported there so -``tools.browser_tool.`` keeps resolving (and monkeypatching). Origin -symbols and module state are read/written through ``_bt`` (the origin module, -resolved per call by :func:`tools.browser_tool_origin.origin_module`) so -``patch("tools.browser_tool.X")`` is honoured and no import cycle exists. -""" +Split out of ``tools/browser_tool.py``; every name is re-imported there (tests monkeypatch ``tools.browser_tool.``). +Origin symbols/state go through ``_bt`` (origin module, resolved per call) — no import cycle.""" from __future__ import annotations import os -from typing import Optional +from typing import Callable, Optional from agent.browser_provider import BrowserProvider as CloudBrowserProvider from tools.browser_tool_origin import origin_module as _origin -def _is_legacy_provider_registry_overridden() -> bool: - """True when a test has patched ``_PROVIDER_REGISTRY`` to a custom value. +def _memo(_bt, resolved_attr: str, cache_attr: str, compute: Callable[[], object]): + """Process-lifetime cache on ``_bt``: the resolved flag is set BEFORE computing, then the final value is stored.""" + if not getattr(_bt, resolved_attr): + setattr(_bt, resolved_attr, True) + setattr(_bt, cache_attr, compute()) + return getattr(_bt, cache_attr) - Each registered value is compared by identity against the canonical class - in ``_DEFAULT_PROVIDER_REGISTRY`` (extra keys count too); adding a built-in - provider only requires extending that default dict. - """ + +def _is_legacy_provider_registry_overridden() -> bool: + """True when a test has patched ``_PROVIDER_REGISTRY`` (identity check per key; extra keys count too).""" _bt = _origin() try: - for key, default_cls in _bt._DEFAULT_PROVIDER_REGISTRY.items(): - if _bt._PROVIDER_REGISTRY.get(key) is not default_cls: - return True - # Extra keys not in the default registry → also an override. - return len(_bt._PROVIDER_REGISTRY) != len(_bt._DEFAULT_PROVIDER_REGISTRY) + return len(_bt._PROVIDER_REGISTRY) != len(_bt._DEFAULT_PROVIDER_REGISTRY) or any( + _bt._PROVIDER_REGISTRY.get(key) is not default_cls for key, default_cls in _bt._DEFAULT_PROVIDER_REGISTRY.items() + ) except Exception: return False def _ensure_browser_plugins_loaded() -> None: - """Idempotently trigger plugin discovery so the browser registry is populated. - - ``model_tools`` normally does this as an import side effect, but - ``_get_cloud_provider`` is also reached from standalone scripts and test - harnesses that never import it; cheap on repeat calls. - """ - _bt = _origin() + """Idempotently trigger plugin discovery (standalone scripts/tests may never import ``model_tools``).""" try: from hermes_cli.plugins import _ensure_plugins_discovered _ensure_plugins_discovered() except Exception as exc: - _bt.logger.debug("Browser plugin discovery failed (non-fatal): %s", exc) + _origin().logger.debug("Browser plugin discovery failed (non-fatal): %s", exc) def _get_cloud_provider() -> Optional[CloudBrowserProvider]: """Return the provider cached for the active Hermes profile.""" _bt = _origin() - scope = _bt.hermes_home_key() with _bt._cloud_provider_cache_lock: - # Tests and legacy reset paths clear the boolean. Treat that as a full - # reset even if a previous scoped resolution remains mirrored here. + # A cleared boolean (tests / legacy reset) is a full reset even if a scoped resolution is still mirrored here. if not _bt._cloud_provider_resolved: _bt._cached_cloud_provider_scope = None _bt._cached_cloud_providers.clear() @@ -75,15 +64,11 @@ def _get_cloud_provider() -> Optional[CloudBrowserProvider]: resolved = _bt._resolve_cloud_provider_uncached() after_generation = _bt._browser_registry_generation(scope=scope) if before_generation != after_generation: - # A force reload replaced/unloaded this profile's provider - # while resolution was in progress. Discard the stale result - # and resolve against the new registry generation. + # A force reload changed this profile's registry mid-resolution: discard and resolve again. continue if _bt._cloud_provider_resolved: _bt._cached_cloud_provider_scope = scope - for stale_key in [ - key for key in _bt._cached_cloud_providers if key[0] == scope - ]: + for stale_key in [key for key in _bt._cached_cloud_providers if key[0] == scope]: _bt._cached_cloud_providers.pop(stale_key, None) _bt._cached_cloud_providers[cache_key] = resolved return resolved @@ -92,11 +77,9 @@ def _get_cloud_provider() -> Optional[CloudBrowserProvider]: def _instantiate_explicit_cloud_provider(provider_key: str) -> Optional[CloudBrowserProvider]: """Build the provider named by ``browser.cloud_provider``. - Test fixtures that patch ``_PROVIDER_REGISTRY`` drive the legacy dict; - otherwise the plugin registry is consulted (after idempotent discovery). - Strict selection: a stored-but-unregistered name raises ``ValueError`` - (never a silent reroute to auto-detect). Any other instantiation error is - logged and yields None so the next call retries. + A patched ``_PROVIDER_REGISTRY`` (test fixtures) drives the legacy dict; otherwise the plugin registry is + consulted. Strict: an unregistered name raises ``ValueError`` (never a silent reroute to auto-detect); any + other instantiation error is logged and yields None so the next call retries. """ _bt = _origin() try: @@ -110,32 +93,22 @@ def _instantiate_explicit_cloud_provider(provider_key: str) -> Optional[CloudBro from tools.tool_backend_helpers import selection_error raise ValueError(selection_error( - "browser", - f"'{provider_key}'", - "no registered browser plugin has that name (install " - "the corresponding plugin or fix the config key " - "spelling)", + "browser", f"'{provider_key}'", + "no registered browser plugin has that name (install the corresponding plugin or fix the config key spelling)", )) return resolved except ValueError: raise except Exception: - _bt.logger.warning( - "Failed to instantiate explicit cloud_provider %r; will retry on next call", - provider_key, - exc_info=True, - ) + _bt.logger.warning("Failed to instantiate explicit cloud_provider %r; will retry on next call", provider_key, exc_info=True) return None def _autodetect_cloud_provider() -> Optional[CloudBrowserProvider]: - """Auto-detect: Browser Use (managed Nous gateway or API key), then Browserbase. + """Auto-detect: Browser Use, then Browserbase; never raises. - Uses the legacy class names bound on this module so tests that - ``monkeypatch.setattr(browser_tool, "BrowserUseProvider", ...)`` keep - driving this branch. Third-party plugins are intentionally NOT reachable - from auto-detect — only via explicit ``browser.cloud_provider: ``. - Never raises (a failure must not poison the cache). + Uses the class names bound on the origin module so tests that monkeypatch ``BrowserUseProvider`` keep driving + this branch. Third-party plugins are only reachable via explicit ``browser.cloud_provider: ``. """ _bt = _origin() try: @@ -151,15 +124,11 @@ def _autodetect_cloud_provider() -> Optional[CloudBrowserProvider]: def _resolve_cloud_provider_uncached() -> Optional[CloudBrowserProvider]: """Return the configured cloud browser provider, or None for local mode. - Reads ``browser.cloud_provider`` and pins the result in the cache only when - it is definitive (explicit ``local``/``camofox``, or a resolved provider). - Explicit selection routes through :mod:`agent.browser_registry` so - third-party plugins participate; auto-detect (only when no selection was - ever written) walks Browser Use then Browserbase. A transient None - (unreadable config, missing credentials) is NOT cached so it can self-heal. + Pins the cache only when definitive (explicit ``local``/``camofox`` or a resolved provider); a transient None + (unreadable config, missing credentials) is NOT cached so it can self-heal. Auto-detect runs only when no + selection was ever written. """ _bt = _origin() - resolved: Optional[CloudBrowserProvider] = None provider_key = None try: @@ -182,15 +151,13 @@ def _resolve_cloud_provider_uncached() -> Optional[CloudBrowserProvider]: except ValueError: raise except Exception as e: - # Config may be temporarily unreadable; still try auto-detect so - # env-based / managed-gateway credentials can resolve. Don't pin cache. + # Config may be temporarily unreadable; still try auto-detect (env/managed creds). Don't pin cache. _bt.logger.debug("Could not read cloud_provider from config: %s", e) if resolved is None and provider_key is None: resolved = _bt._autodetect_cloud_provider() if resolved is None: return None - _bt._cached_cloud_provider = resolved _bt._cloud_provider_resolved = True return _bt._cached_cloud_provider @@ -199,21 +166,16 @@ def _resolve_cloud_provider_uncached() -> Optional[CloudBrowserProvider]: def _is_local_mode() -> bool: """Return True when the browser tool will use a local browser backend.""" _bt = _origin() - if _bt._get_cdp_override_raw(): - return False - return _bt._get_cloud_provider() is None + return not _bt._get_cdp_override_raw() and _bt._get_cloud_provider() is None def _is_local_backend() -> bool: - """Return True when the browser runs locally AND the terminal is also local. + """True when the browser runs locally AND the terminal is also local. - SSRF protection only matters when the browser can reach networks the user's - terminal cannot: cloud backends, and a local browser paired with a - containerized terminal (docker/modal/daytona/ssh/singularity). A CDP - override is never trusted as local (that Chrome may live off-host) and MUST - be checked before the Camofox short-circuit so Camofox + override still - fails the local check; ``_is_local_mode`` treats overrides the same way — - keep the two in agreement. + SSRF protection only matters when the browser can reach networks the terminal cannot (cloud backends, + containerized terminals). A CDP override is never trusted as local (that Chrome may live off-host) and MUST + be checked before the Camofox short-circuit; ``_is_local_mode`` treats overrides the same way — keep the two + in agreement. """ _bt = _origin() if _bt._get_cdp_override_raw(): @@ -222,147 +184,82 @@ def _is_local_backend() -> bool: return True if _bt._get_cloud_provider() is not None: return False - # Scope-aware: under gateway multiplexing the routed profile's terminal - # backend lives in the per-turn terminal scope, not the process env. + # Scope-aware: under gateway multiplexing the routed profile's terminal backend lives in the per-turn scope. from tools.terminal_scope import terminal_env - terminal_backend = terminal_env("TERMINAL_ENV", "local").strip().lower() - return terminal_backend in ("local", "") + return terminal_env("TERMINAL_ENV", "local").strip().lower() in ("local", "") def _get_browser_engine() -> str: """Return the browser engine: ``auto`` (no ``--engine`` flag), ``lightpanda`` or ``chrome``. - ``browser.engine`` first, then ``AGENT_BROWSER_ENGINE``, then ``auto``; - cached. Lightpanda is much faster on navigation but has no graphical - renderer (no screenshots). + ``browser.engine`` first, then ``AGENT_BROWSER_ENGINE``, then ``auto``; cached. Lightpanda is faster on + navigation but has no graphical renderer (no screenshots). """ _bt = _origin() - if _bt._browser_engine_resolved: - return _bt._cached_browser_engine - _bt._browser_engine_resolved = True - # Config file takes priority; env var only if config didn't set a value. - _bt._cached_browser_engine = _bt._browser_cfg( - "engine", "auto", - lambda v: str(v).strip().lower() if v and str(v).strip() else "auto", - "browser.engine from config", - ) - if _bt._cached_browser_engine == "auto": - env_val = os.environ.get("AGENT_BROWSER_ENGINE", "").strip().lower() - if env_val: - _bt._cached_browser_engine = env_val + def compute() -> str: + engine = _bt._browser_cfg("engine", "auto", lambda v: str(v).strip().lower() if v and str(v).strip() else "auto", "browser.engine from config") + if engine == "auto": + engine = os.environ.get("AGENT_BROWSER_ENGINE", "").strip().lower() or engine + # agent-browser only accepts "chrome" and "lightpanda". + _VALID_ENGINES = {"auto", "lightpanda", "chrome"} + if engine not in _VALID_ENGINES: + _bt.logger.warning("Unknown browser engine %r (valid: %s), falling back to 'auto'", engine, ", ".join(sorted(_VALID_ENGINES))) + engine = "auto" + return engine - # Validate: agent-browser only accepts "chrome" and "lightpanda". - _VALID_ENGINES = {"auto", "lightpanda", "chrome"} - if _bt._cached_browser_engine not in _VALID_ENGINES: - _bt.logger.warning( - "Unknown browser engine %r (valid: %s), falling back to 'auto'", - _bt._cached_browser_engine, ", ".join(sorted(_VALID_ENGINES)), - ) - _bt._cached_browser_engine = "auto" - - return _bt._cached_browser_engine + return _memo(_bt, "_browser_engine_resolved", "_cached_browser_engine", compute) def _is_headed_mode() -> bool: - """Return True when the browser should launch in headed (visible) mode. - - Reads ``config["browser"]["headed"]`` with ``AGENT_BROWSER_HEADED`` env - var as fallback. Result is cached after the first call. - """ + """True when the browser should launch headed: ``browser.headed``, else ``AGENT_BROWSER_HEADED``; cached.""" _bt = _origin() - if _bt._headed_mode_resolved: - return _bt._cached_headed_mode # type: ignore[return-value] - _bt._headed_mode_resolved = True - _bt._cached_headed_mode = _bt._browser_cfg( - "headed", False, - lambda v: False if v is None else str(v).strip().lower() in ("true", "1", "yes"), - "browser.headed from config", - ) - if not _bt._cached_headed_mode: - env_val = os.environ.get("AGENT_BROWSER_HEADED", "").strip() - if env_val and env_val.lower() in ("true", "1", "yes"): - _bt._cached_headed_mode = True + def compute() -> bool: + headed = _bt._browser_cfg("headed", False, lambda v: False if v is None else str(v).strip().lower() in ("true", "1", "yes"), "browser.headed from config") + return headed or os.environ.get("AGENT_BROWSER_HEADED", "").strip().lower() in ("true", "1", "yes") - return _bt._cached_headed_mode + return _memo(_bt, "_headed_mode_resolved", "_cached_headed_mode", compute) def _should_inject_engine(engine: str) -> bool: - """Return True when the engine flag should be added to agent-browser commands. - - Only inject ``--engine`` for non-cloud, non-camofox local sessions where - the engine is explicitly set (not ``auto``). - """ + """True when ``--engine`` should be added: explicit (non-``auto``) engine on a non-cloud, non-camofox local session.""" _bt = _origin() - if engine == "auto": - return False - if _bt._is_camofox_mode(): - return False - return _bt._is_local_mode() + return engine != "auto" and not _bt._is_camofox_mode() and _bt._is_local_mode() def _auto_local_for_private_urls() -> bool: - """``browser.auto_local_for_private_urls`` (default True), cached for the process. - - When on, ``browser_navigate`` routes private/loopback/LAN URLs to a local - Chromium sidecar even with a cloud provider configured; public URLs keep - using the cloud provider in the same conversation. - """ + """``browser.auto_local_for_private_urls`` (default True), cached: route private/LAN URLs to a local sidecar even with a cloud provider.""" _bt = _origin() - if _bt._auto_local_for_private_urls_resolved: - return _bt._cached_auto_local_for_private_urls - - _bt._auto_local_for_private_urls_resolved = True - _bt._cached_auto_local_for_private_urls = _bt._browser_cfg( - "auto_local_for_private_urls", _bt._cached_auto_local_for_private_urls, bool, - "auto_local_for_private_urls from config", + return _memo( + _bt, "_auto_local_for_private_urls_resolved", "_cached_auto_local_for_private_urls", + lambda: _bt._browser_cfg("auto_local_for_private_urls", _bt._cached_auto_local_for_private_urls, bool, "auto_local_for_private_urls from config"), ) - return _bt._cached_auto_local_for_private_urls def _use_real_profile() -> bool: - """Return whether the user consented to real-profile local browsing. + """Whether the user consented to real-profile local browsing. - Reads ``browser.use_real_profile`` (default False) on EVERY call — it is a - consent switch, so flipping it off must take effect without a restart, and - in a multiplexed gateway each profile's config must decide for itself. - The read is one YAML load per local session creation (not per command), - so there is no hot-path cost to keeping it uncached. + Read on EVERY call: it is a consent switch (flipping it off must not need a restart) and each multiplexed + profile must decide for itself. One YAML load per local session creation, so no hot-path cost. """ - _bt = _origin() - return _bt._browser_cfg("use_real_profile", False, bool, "use_real_profile from config") + return _origin()._browser_cfg("use_real_profile", False, bool, "use_real_profile from config") def _allow_private_urls() -> bool: - """Return whether the browser is allowed to navigate to private/internal addresses. + """Whether the browser may navigate to private/internal addresses (default False: SSRF protection on). - Reads ``config["browser"]["allow_private_urls"]``. Single-profile calls - cache the result for the process lifetime; multiplexed profile turns resolve - their context-local config on each call. Defaults to ``False`` (SSRF - protection active). + Single-profile calls cache for the process lifetime; multiplexed profile turns (ContextVar-scoped config) + resolve on every call so one profile's opt-out is never reused by another. """ _bt = _origin() - - # The profile multiplexer scopes config with a ContextVar while sharing - # this module. Never reuse another profile's private-network opt-out. if _bt.get_hermes_home_override() is not None: return _bt._resolve_allow_private_urls() - - if _bt._allow_private_urls_resolved: - return _bt._cached_allow_private_urls - - _bt._allow_private_urls_resolved = True - _bt._cached_allow_private_urls = _bt._resolve_allow_private_urls() - return _bt._cached_allow_private_urls + return _memo(_bt, "_allow_private_urls_resolved", "_cached_allow_private_urls", _bt._resolve_allow_private_urls) def _resolve_allow_private_urls() -> bool: """Read the browser private-URL toggle from the active config scope.""" _bt = _origin() - return _bt._browser_cfg( - "allow_private_urls", False, - lambda v: _bt.is_truthy_value(v, default=False), - "allow_private_urls from config", - ) + return _bt._browser_cfg("allow_private_urls", False, lambda v: _bt.is_truthy_value(v, default=False), "allow_private_urls from config") diff --git a/tools/browser_tool_eval_policy.py b/tools/browser_tool_eval_policy.py index 6d8d2aab60..72f8c95890 100644 --- a/tools/browser_tool_eval_policy.py +++ b/tools/browser_tool_eval_policy.py @@ -2,8 +2,7 @@ and the opt-in sensitive-primitive denylist. Origin-module symbols are resolved lazily through ``tools.browser_tool`` (``_bt``) -so ``patch("tools.browser_tool.X")`` in tests keeps working; this module must not -import ``tools.browser_tool`` at import time (cycle). +so ``patch("tools.browser_tool.X")`` keeps working; never import it at import time (cycle). """ import re @@ -14,66 +13,41 @@ from tools.browser_tool_origin import origin_module as _origin def _eval_ssrf_guard_active(effective_task_id: str) -> bool: """Return True when eval-driven private-network access must be guarded. - Matches the gating used by ``browser_navigate`` / ``browser_snapshot`` / - ``browser_vision``: the SSRF guard is only meaningful for non-local - backends (cloud browser, or a containerized terminal whose browser-on-host - can reach internal networks the terminal can't), and is skipped for local - sidecar sessions and when ``allow_private_urls`` is set. + Same gating as ``browser_navigate`` / ``browser_snapshot`` / ``browser_vision``: the SSRF guard + only matters for non-local backends (cloud browser, or a containerized terminal whose + browser-on-host reaches networks the terminal can't); skipped for local sidecars / ``allow_private_urls``. """ _bt = _origin() - return ( - not _bt._is_local_backend() - and not _bt._is_local_sidecar_key(effective_task_id) - and not _bt._allow_private_urls() - ) + return not _bt._is_local_backend() and not _bt._is_local_sidecar_key(effective_task_id) and not _bt._allow_private_urls() -# URL-shaped literals embedded in a JS expression (http/https only). Used to -# pre-screen ``browser_console(expression=...)`` calls that fetch/XHR/navigate -# to a private host directly — that path never updates ``location.href`` so the -# post-eval page-URL recheck below can't see it. +def _url_blocked(_bt, url: str) -> bool: + """True when ``url`` hits the always-blocked cloud-metadata floor or fails the SSRF guard.""" + return _bt._is_always_blocked_url(url) or not _bt._is_safe_url(url) + + +# URL-shaped literals embedded in a JS expression (http/https only). fetch/XHR/navigate +# to a private host never updates ``location.href``, so the post-eval page-URL recheck +# can't see it; pre-screen the literals instead. _JS_URL_LITERAL_RE = re.compile(r"""https?://[^\s'"`)\]<>]+""", re.IGNORECASE) def _expression_targets_private_url(expression: str) -> Optional[str]: - """Return the first private/always-blocked URL literal in a JS expression. - - Best-effort: scans for ``http(s)://...`` literals (fetch/XHR/navigation - targets the agent may have embedded) and returns the first one that targets - a private/internal address or the always-blocked cloud-metadata floor. - Returns ``None`` when no such literal is found. - """ + """Return the first private/always-blocked ``http(s)://`` literal in a JS expression (best-effort), else None.""" _bt = _origin() - if not isinstance(expression, str): - return None - for match in _JS_URL_LITERAL_RE.findall(expression): - candidate = match.rstrip(".,;") - if _bt._is_always_blocked_url(candidate) or not _bt._is_safe_url(candidate): - return candidate - return None + literals = _JS_URL_LITERAL_RE.findall(expression) if isinstance(expression, str) else [] + return next((c for c in (m.rstrip(".,;") for m in literals) if _url_blocked(_bt, c)), None) def _current_page_private_url(effective_task_id: str) -> Optional[str]: - """Return the current page URL when it targets a private/internal address. - - Reads ``window.location.href`` via a low-cost eval and returns it when the - page has been navigated (e.g. via ``location.href = '...'`` in a prior - eval) to an address the SSRF guard would reject. Returns ``None`` when the - page is public, the URL can't be determined, or the check errors (fail-open - on probe failure, matching the snapshot/vision guards). - """ + """Return the current page URL when it targets a private/internal address (e.g. after a prior + ``location.href = '...'`` eval). Fail-open on probe failure, matching the snapshot/vision guards.""" _bt = _origin() try: - url_result = _bt._run_browser_command( - effective_task_id, "eval", ["window.location.href"], timeout=5, _engine_override="auto" - ) + url_result = _bt._run_browser_command(effective_task_id, "eval", ["window.location.href"], timeout=5, _engine_override="auto") if url_result.get("success"): - current_url = ( - url_result.get("data", {}).get("result", "") .strip().strip('"').strip("'") - ) - if current_url and ( - _bt._is_always_blocked_url(current_url) or not _bt._is_safe_url(current_url) - ): + current_url = url_result.get("data", {}).get("result", "").strip().strip('"').strip("'") + if current_url and _url_blocked(_bt, current_url): return current_url except Exception as exc: _bt.logger.debug("_current_page_private_url: probe failed (%s)", exc) @@ -93,78 +67,51 @@ _RISKY_BROWSER_EVAL_PATTERNS: tuple[tuple[re.Pattern[str], str], ...] = ( ) -_JS_STRING_LITERAL_RE = re.compile( - r"""'(?:\\.|[^'\\])*'|\"(?:\\.|[^\"\\])*\"|`(?:\\.|[^`\\])*`""", re.S -) +_JS_STRING_LITERAL_RE = re.compile(r"""'(?:\\.|[^'\\])*'|\"(?:\\.|[^\"\\])*\"|`(?:\\.|[^`\\])*`""", re.S) _SENSITIVE_BROWSER_EVAL_TOKENS: tuple[tuple[str, str], ...] = ( ("cookie", "document.cookie"), - ("localStorage", "web storage"), - ("sessionStorage", "web storage"), - ("indexedDB", "IndexedDB"), - ("caches", "Cache Storage"), - ("clipboard", "navigator sensitive API"), - ("credentials", "navigator sensitive API"), + ("localStorage", "web storage"), ("sessionStorage", "web storage"), + ("indexedDB", "IndexedDB"), ("caches", "Cache Storage"), + ("clipboard", "navigator sensitive API"), ("credentials", "navigator sensitive API"), ("serviceWorker", "navigator sensitive API"), - ("fetch", "network request"), - ("XMLHttpRequest", "network request"), - ("WebSocket", "network request"), - ("EventSource", "network request"), + ("fetch", "network request"), ("XMLHttpRequest", "network request"), + ("WebSocket", "network request"), ("EventSource", "network request"), ("sendBeacon", "network beacon"), ) -def _allow_unsafe_browser_evaluate() -> bool: - """Return whether sensitive browser JS evaluation is explicitly allowed. - - When true, ``browser_console(expression=...)`` runs without the - sensitive-primitive denylist even if ``browser.restrict_evaluate`` is set. - """ +def _browser_eval_flag(key: str) -> bool: + """Read boolean ``browser.`` (default False) through the origin's config reader.""" _bt = _origin() - return _bt._browser_cfg( - "allow_unsafe_evaluate", False, - lambda v: _bt.is_truthy_value(v, default=False), - "browser.allow_unsafe_evaluate from config", - ) + return _bt._browser_cfg(key, False, lambda v: _bt.is_truthy_value(v, default=False), f"browser.{key} from config") + + +def _allow_unsafe_browser_evaluate() -> bool: + """Whether sensitive browser JS evaluation is explicitly allowed (overrides ``restrict_evaluate``).""" + return _browser_eval_flag("allow_unsafe_evaluate") def _restrict_browser_evaluate() -> bool: - """Return whether the sensitive-primitive eval denylist is enabled. + """Whether the sensitive-primitive eval denylist is enabled (off by default). - Off by default. ``browser_console(expression=...)`` is the agent's only - programmatic page-inspection path, and the denylist blocks the *names* of - common primitives (``fetch``, ``cookie``, ``querySelector(...input...)``) - rather than any actual exfiltration — which also blocks a large class of - legitimate DOM extraction (any selector or page script text containing - those words). Egress itself is still gated by the SSRF/private-URL guards - in ``_browser_eval`` regardless of this setting. Users who want the - strict vocabulary denylist (e.g. when browsing hostile pages with a - logged-in profile) opt in with ``browser.restrict_evaluate: true``; - ``browser.allow_unsafe_evaluate: true`` overrides it back off. + It blocks the *names* of common primitives (``fetch``, ``cookie``, ``querySelector(...input...)``), + not actual exfiltration, so it also blocks much legitimate DOM extraction; egress is still gated + by the SSRF/private-URL guards in ``_browser_eval`` regardless. Opt in via + ``browser.restrict_evaluate: true`` (e.g. hostile pages with a logged-in profile). """ - _bt = _origin() - return _bt._browser_cfg( - "restrict_evaluate", False, - lambda v: _bt.is_truthy_value(v, default=False), - "browser.restrict_evaluate from config", - ) + return _browser_eval_flag("restrict_evaluate") def _decode_js_string_literal(literal: str) -> str: - """Best-effort decode of a JavaScript string literal for policy checks. - - This is not a JS parser. It only normalizes common escaped property names - such as ``document["co\\x6fkie"]`` before the fail-closed sensitive-token - check below. - """ + """Best-effort decode of a JS string literal (not a parser): normalizes escapes like ``"co\\x6fkie"``.""" if len(literal) < 2: return literal - body = literal[1:-1] try: - return bytes(body, "utf-8").decode("unicode_escape") + return bytes(literal[1:-1], "utf-8").decode("unicode_escape") except Exception: - return body + return literal[1:-1] def _decoded_js_string_literals(expression: str) -> list[str]: @@ -172,87 +119,48 @@ def _decoded_js_string_literals(expression: str) -> list[str]: def _sensitive_browser_eval_token_reason(expression: str) -> Optional[str]: - """Return a risk reason for direct or quoted sensitive browser primitives. - - ``browser_console(expression=...)`` executes in the page origin. A denylist - that only searches direct spellings like ``document.cookie`` and ``fetch(`` - misses equivalent JavaScript property access such as ``document["cookie"]`` - or ``globalThis["fetch"](...)``. Treat sensitive primitive names as risky - whether they appear as identifiers or decoded string-literal property names. - Concatenating all string literals catches simple obfuscations like - ``document["coo" + "kie"]`` while the config opt-in preserves the escape - hatch for trusted pages. - """ - string_literals = _decoded_js_string_literals(expression) - concatenated_literals = "".join(string_literals).lower() - for token, reason in _SENSITIVE_BROWSER_EVAL_TOKENS: - if re.search(rf"\b{re.escape(token)}\b", expression, re.I): - return reason - token_lower = token.lower() - if any(token_lower in literal.lower() for literal in string_literals): - return reason - if token_lower in concatenated_literals: - return reason - return None + """Risk reason for direct or quoted sensitive primitives: direct spellings alone miss + ``document["cookie"]`` / ``globalThis["fetch"]``, so tokens are also matched inside the decoded, + concatenated string literals (catches ``document["coo" + "kie"]``).""" + literals = "".join(_decoded_js_string_literals(expression)).lower() + return next((reason for token, reason in _SENSITIVE_BROWSER_EVAL_TOKENS + if re.search(rf"\b{re.escape(token)}\b", expression, re.I) or token.lower() in literals), None) def _risky_browser_eval_reason(expression: str) -> Optional[str]: """Return a human-readable reason if a JS expression uses risky primitives.""" if not expression: return None - for pattern, reason in _RISKY_BROWSER_EVAL_PATTERNS: - if pattern.search(expression): - return reason - return _sensitive_browser_eval_token_reason(expression) + hit = next((reason for pattern, reason in _RISKY_BROWSER_EVAL_PATTERNS if pattern.search(expression)), None) + return hit or _sensitive_browser_eval_token_reason(expression) def _enforce_browser_eval_policy(expression: str) -> Optional[str]: - """Block sensitive browser JS evaluation when the opt-in denylist is on. - - The denylist is opt-in (``browser.restrict_evaluate: true``) because it - gates on primitive *names*, which cripples legitimate DOM extraction — - see ``_restrict_browser_evaluate``. Network egress to private/internal - addresses is enforced separately in ``_browser_eval`` and does not depend - on this policy. - """ + """Block sensitive browser JS evaluation when the opt-in denylist is on (opt-in because it gates on + primitive *names*; private-address egress is enforced separately in ``_browser_eval``).""" _bt = _origin() - if not _bt._restrict_browser_evaluate(): - return None - if _bt._allow_unsafe_browser_evaluate(): + if not _bt._restrict_browser_evaluate() or _bt._allow_unsafe_browser_evaluate(): return None reason = _risky_browser_eval_reason(expression) if not reason: return None - return ( - "Blocked: browser_console(expression=...) tried to use sensitive browser " - f"JavaScript primitive ({reason}) while browser.restrict_evaluate is " - "enabled. Use browser_snapshot/browser_get_images/browser_console " - "without expression for normal inspection, or set " - "browser.restrict_evaluate: false in config.yaml to allow " - "programmatic evaluation." - ) + return ("Blocked: browser_console(expression=...) tried to use sensitive browser " + f"JavaScript primitive ({reason}) while browser.restrict_evaluate is " + "enabled. Use browser_snapshot/browser_get_images/browser_console " + "without expression for normal inspection, or set " + "browser.restrict_evaluate: false in config.yaml to allow programmatic evaluation.") def _camofox_current_page_private_url(tab_id: str, user_id: str) -> Optional[str]: - """Return the Camofox page URL when it targets a private/internal address. - - Camofox analogue of ``_current_page_private_url`` (evaluate endpoint instead - of the agent-browser CLI). Returns ``None`` when the page is public, the URL - can't be determined, or the probe errors (fail-open on probe failure, - matching the snapshot/vision guards — do not change to fail-closed without - also changing the sibling). - """ + """Camofox analogue of ``_current_page_private_url`` (evaluate endpoint instead of the CLI). Fail-open + on probe failure, matching the snapshot/vision guards — do not make fail-closed without the sibling.""" _bt = _origin() try: from tools.browser_camofox import _post - - data = _post( - f"/tabs/{tab_id}/evaluate", - body={"expression": "window.location.href", "userId": user_id}, - ) + data = _post(f"/tabs/{tab_id}/evaluate", body={"expression": "window.location.href", "userId": user_id}) current_url = str(data.get("result") if isinstance(data, dict) else data or "") current_url = current_url.strip().strip('"').strip("'") - if current_url and (_bt._is_always_blocked_url(current_url) or not _bt._is_safe_url(current_url)): + if current_url and _url_blocked(_bt, current_url): return current_url except Exception as exc: _bt.logger.debug("_camofox_current_page_private_url: probe failed (%s)", exc) diff --git a/tools/browser_tool_install.py b/tools/browser_tool_install.py index 9038b5f3b9..54d47338f9 100644 --- a/tools/browser_tool_install.py +++ b/tools/browser_tool_install.py @@ -1,12 +1,9 @@ """agent-browser / Chromium discovery and install: PATH merging, npx resolution, candidate binaries, Chromium detection + auto-install, requirement checks. -Split out of ``tools/browser_tool.py``; every name is re-imported there so -``tools.browser_tool.`` keeps resolving (and monkeypatching). Origin -symbols and module state are read/written through ``_bt`` (the origin module, -resolved per call by :func:`tools.browser_tool_origin.origin_module`) so -``patch("tools.browser_tool.X")`` is honoured and no import cycle exists. -""" +Split out of ``tools/browser_tool.py``; every name is re-imported there (tests monkeypatch ``tools.browser_tool.``). +Origin symbols/state go through ``_bt`` (origin module, resolved per call) — no import cycle.""" +import contextlib import functools import os import shutil @@ -20,63 +17,44 @@ from tools.browser_tool_origin import origin_module as _origin @functools.lru_cache(maxsize=1) def _discover_homebrew_node_dirs() -> tuple[str, ...]: - """Find Homebrew versioned Node.js bin directories (e.g. node@20, node@24). - - When Node is installed via ``brew install node@24`` and NOT linked into - /opt/homebrew/bin, agent-browser isn't discoverable on the default PATH. - This function finds those directories so they can be prepended. - """ - dirs: list[str] = [] + """Homebrew versioned Node bin dirs (node@20, ...) that ``brew`` may not link into /opt/homebrew/bin.""" homebrew_opt = "/opt/homebrew/opt" - if not os.path.isdir(homebrew_opt): - return tuple(dirs) try: - for entry in os.listdir(homebrew_opt): - if entry.startswith("node") and entry != "node": - bin_dir = os.path.join(homebrew_opt, entry, "bin") - if os.path.isdir(bin_dir): - dirs.append(bin_dir) + entries = os.listdir(homebrew_opt) if os.path.isdir(homebrew_opt) else [] except OSError: - pass - return tuple(dirs) + entries = [] + return tuple( + bin_dir + for entry in entries + if entry.startswith("node") and entry != "node" + if os.path.isdir(bin_dir := os.path.join(homebrew_opt, entry, "bin")) + ) def _browser_candidate_path_dirs() -> list[str]: """Return ordered browser CLI PATH candidates shared by discovery and execution.""" _bt = _origin() - hermes_home = _bt.get_hermes_home() - hermes_node_bin = str(hermes_home / "node" / "bin") - hermes_node_root = str(hermes_home / "node") - hermes_nm_bin = str(hermes_home / "node_modules" / ".bin") - return [hermes_node_bin, hermes_node_root, hermes_nm_bin, *list(_bt._discover_homebrew_node_dirs()), *_bt._SANE_PATH_DIRS] + home = _bt.get_hermes_home() + managed = (home / "node" / "bin", home / "node", home / "node_modules" / ".bin") + return [*map(str, managed), *_bt._discover_homebrew_node_dirs(), *_bt._SANE_PATH_DIRS] def _merge_browser_path(existing_path: str = "") -> str: """Prepend browser-specific PATH fallbacks without reordering existing entries.""" - _bt = _origin() path_parts = [p for p in (existing_path or "").split(os.pathsep) if p] - existing_parts = set(path_parts) prefix_parts: list[str] = [] - - for part in _bt._browser_candidate_path_dirs(): - if not part or part in existing_parts or part in prefix_parts: - continue - if os.path.isdir(part): + for part in _origin()._browser_candidate_path_dirs(): + if part and part not in path_parts and part not in prefix_parts and os.path.isdir(part): prefix_parts.append(part) - return os.pathsep.join(prefix_parts + path_parts) def _browser_install_hint() -> str: - _bt = _origin() - if _bt._is_termux_environment(): - return "npm install -g agent-browser && agent-browser install" - return "npm install -g agent-browser && agent-browser install --with-deps" + return "npm install -g agent-browser && agent-browser install" + ("" if _origin()._is_termux_environment() else " --with-deps") def _is_npx_agent_browser_sentinel(browser_cmd: str) -> bool: - _bt = _origin() - return browser_cmd.strip() == _bt.NPX_AGENT_BROWSER_SENTINEL + return browser_cmd.strip() == _origin().NPX_AGENT_BROWSER_SENTINEL def _requires_real_termux_browser_install(browser_cmd: str) -> bool: @@ -85,11 +63,7 @@ def _requires_real_termux_browser_install(browser_cmd: str) -> bool: def _termux_browser_install_error() -> str: - _bt = _origin() - return ( - "Local browser automation on Termux cannot rely on the bare npx fallback. " - f"Install agent-browser explicitly first: {_bt._browser_install_hint()}" - ) + return f"Local browser automation on Termux cannot rely on the bare npx fallback. Install agent-browser explicitly first: {_origin()._browser_install_hint()}" def _agent_browser_candidate_present(path: str | None) -> bool: @@ -101,32 +75,25 @@ def _agent_browser_candidate_present(path: str | None) -> bool: def _resolve_npx_bin() -> Optional[str]: - """Resolve a runnable npx, preferring the Hermes-managed/Homebrew extended PATH. + """Resolve a runnable npx, extended (Hermes-managed/Homebrew) PATH first. - Bare PATH first would let a broken system npx shadow a healthy managed one - with no recovery, so every candidate is validated with ``node_tool_runnable`` - before being trusted. + Bare PATH first would let a broken system npx shadow a healthy managed one, + so every candidate is validated with ``node_tool_runnable`` before use. """ _bt = _origin() extended_path = _bt._merge_browser_path("") - if extended_path: - extended_npx = shutil.which("npx", path=extended_path) - if extended_npx and _bt.node_tool_runnable(extended_npx): - return extended_npx - npx_path = shutil.which("npx") - if npx_path and _bt.node_tool_runnable(npx_path): - return npx_path + for path in ([extended_path] if extended_path else []) + [None]: + npx = shutil.which("npx", path=path) + if npx and _bt.node_tool_runnable(npx): + return npx return None def _agent_browser_candidates(extended_path: str): - """Yield agent-browser lookup candidates in resolution order (lazily — each is a filesystem probe). + """Yield agent-browser lookup candidates lazily: ambient PATH → extended PATH → repo-local node_modules/.bin. - Order: ambient PATH (global install) → extended PATH (Hermes-managed Node, - macOS versioned Homebrew, Termux/system dirs) → repo-local node_modules/.bin. - The local lookup goes through ``shutil.which`` with an explicit path so - Windows resolves the ``.cmd`` shim (CreateProcess cannot run npm's - extensionless POSIX shim — WinError 193) while POSIX keeps the plain one. + The local lookup uses ``shutil.which`` with an explicit path so Windows resolves the ``.cmd`` shim + (CreateProcess cannot run npm's extensionless POSIX shim — WinError 193). """ yield shutil.which("agent-browser") if extended_path: @@ -137,37 +104,26 @@ def _agent_browser_candidates(extended_path: str): def _find_agent_browser(*, validate: bool = True) -> str: - """ - Find the agent-browser CLI executable. + """Find the agent-browser CLI: PATH, Homebrew/managed dirs, local node_modules/.bin, npx fallback, lazy install. - Checks in order: current PATH, Homebrew/common bin dirs, Hermes-managed - node, local node_modules/.bin/, npx fallback, then a lazy install. - - Every candidate is validated with ``agent_browser_runnable`` before it is - cached. A bare ``shutil.which`` hit is NOT trusted: agent-browser's npm - postinstall re-points a global symlink at our local node_modules binary, - which disappears on the next ``hermes update`` and leaves a dangling link - that ``which`` still reports but exec fails on (exit 127). Validating lets a - dead candidate fall through instead of being cached and killing every - browser tool. ``validate=False`` (schema-time check_fn) only tests presence - and never caches. - - Raises: - FileNotFoundError: If agent-browser is not installed + A bare ``shutil.which`` hit is NOT trusted: agent-browser's npm postinstall re-points a global symlink at our + local node_modules binary, which vanishes on the next ``hermes update`` and leaves a dangling link ``which`` + still reports (exec fails with 127). Candidates are validated with ``agent_browser_runnable`` before caching + so a dead one falls through. ``validate=False`` (schema-time check_fn) only tests presence and never caches. + Raises FileNotFoundError when agent-browser is not installed. """ _bt = _origin() + + def _not_found(cached: bool) -> FileNotFoundError: + return FileNotFoundError(f"agent-browser CLI not found{' (cached)' if cached else ''}. Install it with: {_bt._browser_install_hint()}\nOr ensure npx is available in your PATH.") + if _bt._agent_browser_resolved: if _bt._cached_agent_browser is None: - raise FileNotFoundError( - "agent-browser CLI not found (cached). Install it with: " - f"{_bt._browser_install_hint()}\n" - "Or ensure npx is available in your PATH." - ) + raise _not_found(cached=True) return _bt._cached_agent_browser def _accept(candidate: str) -> str: - # _agent_browser_resolved is set at each accept site (not before the - # search) so a concurrent reader never sees resolved=True with a None cache. + # Set resolved at each accept site (not before the search) so a concurrent reader never sees resolved=True with a None cache. if validate: _bt._cached_agent_browser = candidate _bt._agent_browser_resolved = True @@ -178,11 +134,9 @@ def _find_agent_browser(*, validate: bool = True) -> str: for candidate in _bt._agent_browser_candidates(extended_path): if candidate and ok(candidate): return _accept(candidate) - # npx fallback (also searches the extended PATH) if _bt._resolve_npx_bin(): return _accept(_bt.NPX_AGENT_BROWSER_SENTINEL) - if not validate: raise FileNotFoundError("agent-browser CLI not found") @@ -190,74 +144,40 @@ def _find_agent_browser(*, validate: bool = True) -> str: try: from hermes_cli.dep_ensure import ensure_dependency if ensure_dependency("browser"): - candidates = [ - shutil.which("agent-browser"), - shutil.which("agent-browser", path=extended_path) if extended_path else None, - shutil.which("agent-browser", path=str(_bt.get_hermes_home() / "node_modules" / ".bin")), - shutil.which("agent-browser", path=str(_bt.get_hermes_home() / "node" / "bin")), - shutil.which("agent-browser", path=str(_bt.get_hermes_home() / "node")), - ] - for recheck in candidates: + home = _bt.get_hermes_home() + managed = (home / "node_modules" / ".bin", home / "node" / "bin", home / "node") + for path in (None, *([extended_path] if extended_path else []), *map(str, managed)): + recheck = shutil.which("agent-browser", path=path) if recheck and _bt.agent_browser_runnable(recheck): return _accept(recheck) except Exception: pass _bt._agent_browser_resolved = True - raise FileNotFoundError( - "agent-browser CLI not found. Install it with: " - f"{_bt._browser_install_hint()}\n" - "Or ensure npx is available in your PATH." - ) + raise _not_found(cached=False) def warm_agent_browser_npx_cache(timeout: float = 60.0) -> bool: - """Best-effort pre-fetch of the agent-browser npm package via npx. + """Best-effort pre-fetch of the agent-browser npm package via npx (``hermes update`` / ``doctor --fix``). - agent-browser resolves lazily via ``npx agent-browser`` (not a root - package.json dependency), so the first invocation in a session would pay - npx's registry fetch; ``hermes update`` / ``hermes doctor --fix`` call this - to warm the cache first. Runs with the credential-scrubbed env every other - agent-browser spawn uses (registry-fetched npm code must never see the - operator keyring), in its own process group, and tree-kills on timeout so - a surviving descendant cannot hold the capture pipe open. - Never raises; True only when npx actually exited 0. + Runs with the credential-scrubbed env every other agent-browser spawn uses (registry-fetched npm code must + never see the operator keyring), in its own process group, and tree-kills on timeout so a surviving + descendant cannot hold the capture pipe open. Never raises; True only when npx exited 0. """ _bt = _origin() npx_bin = _bt._resolve_npx_bin() if not npx_bin: return False - env = _bt._build_browser_env() env["PATH"] = _bt._merge_browser_path(env.get("PATH", "")) - - popen_kwargs: dict = { - "stdout": subprocess.PIPE, - "stderr": subprocess.PIPE, - "text": True, - "env": env, - "creationflags": _bt.windows_hide_flags(), - } + popen_kwargs: dict = {"stdout": subprocess.PIPE, "stderr": subprocess.PIPE, "text": True, "env": env} if os.name == "posix": - popen_kwargs["start_new_session"] = True + popen_kwargs.update(creationflags=_bt.windows_hide_flags(), start_new_session=True) else: - popen_kwargs["creationflags"] |= getattr(subprocess, "CREATE_NEW_PROCESS_GROUP", 0) - - cmd = [ - npx_bin, - # --ignore-scripts: AGENT_BROWSER_NPX_SPEC is a floating ^0.26.0 - # range, not an exact pin — a compromised future 0.26.x patch must - # not get to run its own install-time lifecycle scripts here. - "--ignore-scripts", - # --prefer-offline: once cached, repeat `hermes update`/`doctor - # --fix` runs shouldn't hit the registry just to re-confirm - # "latest" is still latest — that would defeat the point of - # warming the cache in the first place. - "--prefer-offline", - "-y", - _bt.AGENT_BROWSER_NPX_SPEC, - "--version", - ] + popen_kwargs["creationflags"] = _bt.windows_hide_flags() | getattr(subprocess, "CREATE_NEW_PROCESS_GROUP", 0) + # --ignore-scripts: AGENT_BROWSER_NPX_SPEC is a floating range; a compromised future patch must not run + # install-time lifecycle scripts here. --prefer-offline: once cached, repeat runs must not re-hit the registry. + cmd = [npx_bin, "--ignore-scripts", "--prefer-offline", "-y", _bt.AGENT_BROWSER_NPX_SPEC, "--version"] try: proc = subprocess.Popen(cmd, stdin=subprocess.DEVNULL, **popen_kwargs) except Exception: @@ -265,144 +185,87 @@ def warm_agent_browser_npx_cache(timeout: float = 60.0) -> bool: try: proc.communicate(timeout=timeout) return proc.returncode == 0 - except subprocess.TimeoutExpired: - _bt._kill_process_tree(proc) - try: - proc.communicate(timeout=5) - except Exception: - pass - return False - except Exception: + except Exception as exc: _bt._kill_process_tree(proc) + if isinstance(exc, subprocess.TimeoutExpired): + with contextlib.suppress(Exception): + proc.communicate(timeout=5) return False def _chromium_search_roots() -> List[str]: - """Directories to scan for a Chromium / headless-shell build, in the order - agent-browser and Playwright probe them: ``PLAYWRIGHT_BROWSERS_PATH``, then - Playwright's per-OS default cache.""" - roots: List[str] = [] + """Chromium / headless-shell scan roots in agent-browser/Playwright probe order: ``PLAYWRIGHT_BROWSERS_PATH``, then the per-OS default cache.""" env_path = os.environ.get("PLAYWRIGHT_BROWSERS_PATH", "").strip() - if env_path and env_path != "0": - roots.append(env_path) home = os.path.expanduser("~") + roots: List[str] = [env_path] if env_path and env_path != "0" else [] roots.append(os.path.join(home, ".cache", "ms-playwright")) if sys.platform == "darwin": roots.append(os.path.join(home, "Library", "Caches", "ms-playwright")) if sys.platform == "win32": - local = os.environ.get("LOCALAPPDATA") or os.path.join( - home, "AppData", "Local" - ) + local = os.environ.get("LOCALAPPDATA") or os.path.join(home, "AppData", "Local") roots.append(os.path.join(local, "ms-playwright")) return roots -def _chromium_installed() -> bool: - """Return True when a usable Chromium (or headless-shell) build is on disk. +def _has_chromium_build(root: str) -> bool: + """True when ``root`` holds a Playwright ``chromium-*`` / ``chromium_headless_shell-*`` dir (agent-browser accepts either).""" + try: + return any(e.startswith(("chromium-", "chromium_headless_shell-")) for e in os.listdir(root)) + except OSError: + return False - Checks ``AGENT_BROWSER_EXECUTABLE_PATH``, then system Chrome/Chromium on - PATH, then Playwright's cache (``chromium-*`` / ``chromium_headless_shell-*`` - dirs). Without a binary the CLI hangs on first use until the command - timeout fires, so the tool must not be advertised. + +def _chromium_installed() -> bool: + """True when a usable Chromium (or headless-shell) build is on disk; cached. + + Checks ``AGENT_BROWSER_EXECUTABLE_PATH``, then system Chrome/Chromium on PATH, then Playwright's cache. + Without a binary the CLI hangs on first use until the command timeout fires, so the tool must not be advertised. """ _bt = _origin() if _bt._cached_chromium_installed is not None: return _bt._cached_chromium_installed - - # 1. AGENT_BROWSER_EXECUTABLE_PATH — explicit user-configured browser ab_path = os.environ.get("AGENT_BROWSER_EXECUTABLE_PATH", "").strip() - if ab_path and (os.path.isfile(ab_path) or shutil.which(ab_path)): - _bt._cached_chromium_installed = True - return True - - # 2. System Chrome/Chromium in PATH (common names) - system_chrome = ( - shutil.which("google-chrome") - or shutil.which("chromium") - or shutil.which("chromium-browser") - or shutil.which("chrome") + _bt._cached_chromium_installed = bool( + (ab_path and (os.path.isfile(ab_path) or shutil.which(ab_path))) + or any(shutil.which(name) for name in ("google-chrome", "chromium", "chromium-browser", "chrome")) + or any(root and os.path.isdir(root) and _has_chromium_build(root) for root in _bt._chromium_search_roots()) ) - if system_chrome: - _bt._cached_chromium_installed = True - return True - - # 3. Playwright browser cache (legacy — chromium-* / chromium_headless_shell-* dirs) - for root in _bt._chromium_search_roots(): - if not root or not os.path.isdir(root): - continue - try: - entries = os.listdir(root) - except OSError: - continue - # Playwright names them ``chromium-`` and - # ``chromium_headless_shell-``; agent-browser accepts either. - for entry in entries: - if entry.startswith("chromium-") or entry.startswith( - "chromium_headless_shell-" - ): - _bt._cached_chromium_installed = True - return True - - _bt._cached_chromium_installed = False - return False + return _bt._cached_chromium_installed def _maybe_autoinstall_chromium() -> bool: """Best-effort, gated download of the Chromium *binary* on local cold start. - Binary only (``agent-browser install``), never ``--with-deps`` — that shells - ``apt`` and needs root, so missing system libraries stay a user action. - Gated by ``security.allow_lazy_installs``, skipped in Docker (Chromium ships - in the image), attempted once per process. True only when Chromium is - present afterwards. + Binary only (``agent-browser install``), never ``--with-deps`` — that shells ``apt`` and needs root. Gated by + ``security.allow_lazy_installs``, skipped in Docker (Chromium ships in the image), attempted once per process. """ _bt = _origin() if _bt._chromium_autoinstall_attempted: return _bt._chromium_installed() _bt._chromium_autoinstall_attempted = True - if _bt._running_in_docker(): return False - from tools.lazy_deps import _allow_lazy_installs if not _allow_lazy_installs(): return False - try: browser_cmd = _bt._find_agent_browser() except FileNotFoundError: return False - + install_cmd = [browser_cmd, "install"] if _bt._is_npx_agent_browser_sentinel(browser_cmd): - install_cmd = [ - _bt._resolve_npx_bin() or "npx", "--ignore-scripts", "-y", _bt.AGENT_BROWSER_NPX_SPEC, "install", - ] - else: - install_cmd = [browser_cmd, "install"] + install_cmd = [_bt._resolve_npx_bin() or "npx", "--ignore-scripts", "-y", _bt.AGENT_BROWSER_NPX_SPEC, "install"] - _bt.logger.info( - "browser: Chromium missing — auto-installing the browser binary " - "(one-time ~170MB; disable via security.allow_lazy_installs)" - ) + _bt.logger.info("browser: Chromium missing — auto-installing the browser binary (one-time ~170MB; disable via security.allow_lazy_installs)") try: - proc = subprocess.run( - install_cmd, - capture_output=True, - text=True, encoding='utf-8', errors='replace', - timeout=600, - env=_bt._build_browser_env(), - ) + proc = subprocess.run(install_cmd, capture_output=True, text=True, encoding='utf-8', errors='replace', timeout=600, env=_bt._build_browser_env()) except (OSError, subprocess.SubprocessError) as e: _bt.logger.warning("browser: Chromium auto-install failed to start: %s", e) return False - if proc.returncode != 0: tail = (proc.stderr or proc.stdout or "").strip()[-300:] - _bt.logger.warning( - "browser: Chromium auto-install exited %s: %s", proc.returncode, tail - ) + _bt.logger.warning("browser: Chromium auto-install exited %s: %s", proc.returncode, tail) return False - _bt._cached_chromium_installed = None return _bt._chromium_installed() @@ -421,67 +284,41 @@ def _running_in_docker() -> bool: def check_browser_requirements() -> bool: """Whether the browser tools should be advertised. - Local mode needs the ``agent-browser`` CLI plus a Chromium build (except - Lightpanda-only text workflows); cloud mode needs the CLI plus provider - credentials (the provider hosts its own Chromium). + Local mode needs the ``agent-browser`` CLI plus a Chromium build (except Lightpanda-only text workflows); + cloud mode needs the CLI plus provider credentials (the provider hosts its own Chromium). """ - # Browser Use CLI backend — browser_exec replaces the whole browser_* - # surface (including browser_cdp/browser_dialog, whose check_fns funnel - # through here), so hide these tools from the model. _bt = _origin() + # Browser Use CLI backend: browser_exec replaces the whole browser_* surface (incl. browser_cdp/browser_dialog check_fns). if _bt._is_browser_use_cli_mode(): return False - - # Camofox backend — only needs the server URL, no agent-browser CLI + # Camofox only needs the server URL, no agent-browser CLI. if _bt._is_camofox_mode(): return True - - # CDP override mode can connect to an existing remote/local browser endpoint - # without requiring the local agent-browser binary on PATH. - # Raw (no-I/O) check: this runs during tool-schema assembly at startup, - # where a stale endpoint must not cost a blocking HTTP probe. + # CDP override needs no local binary. Raw (no-I/O) check: this runs during schema build, where a stale endpoint must not cost a blocking probe. if _bt._get_cdp_override_raw(): return True - - # The agent-browser CLI is required for local launch and cloud-provider flows. - # Tool-schema assembly runs during Desktop startup; do not execute - # ``agent-browser --version`` here, because Windows .cmd shims route through - # cmd.exe and can flash a console before the user invokes any browser tool. - # Actual browser execution paths still validate the candidate before use. + # Do not exec ``agent-browser --version`` here: Windows .cmd shims flash a console during Desktop startup. Execution paths still validate. try: browser_cmd = _bt._find_agent_browser(validate=False) except FileNotFoundError: return False - - # On Termux, the bare npx fallback is too fragile to treat as a satisfied - # local browser dependency. Require a real install (global or local) so the - # browser tool is not advertised as available when it will likely fail on - # first use. + # Termux: the bare npx fallback is too fragile to advertise as a satisfied local dependency. if _bt._requires_real_termux_browser_install(browser_cmd): return False - - # In cloud mode, also require provider credentials. Cloud browsers - # don't need a local Chromium binary. + # Cloud mode also requires provider credentials; no local Chromium needed. provider = _bt._get_cloud_provider() if provider is not None: return provider.is_configured() - - # Local mode with Lightpanda can provide text/navigation tools without a - # local Chromium install. Chrome fallback, screenshots, and browser_vision - # will still return actionable Chromium install errors if invoked. + # Lightpanda provides text/navigation tools without Chromium; screenshots/vision still return install errors. if _bt._using_lightpanda_engine(): return True - - # Local Chrome mode: agent-browser needs a Chromium build on disk. Without - # it the CLI hangs on first use until the command timeout fires. + # Local Chrome mode needs Chromium on disk or the CLI hangs until the command timeout. return _bt._chromium_installed() def check_browser_vision_requirements() -> bool: - """Advertise ``browser_vision`` only with BOTH a working browser AND a vision - backend — otherwise it fails at call time with a cryptic provider error.""" - _bt = _origin() - if not _bt.check_browser_requirements(): + """Advertise ``browser_vision`` only with BOTH a working browser AND a vision backend.""" + if not _origin().check_browser_requirements(): return False try: from tools.vision_tools import check_vision_requirements diff --git a/tools/browser_tool_lightpanda_fallback.py b/tools/browser_tool_lightpanda_fallback.py index 15cadb5765..55fb80c7a9 100644 --- a/tools/browser_tool_lightpanda_fallback.py +++ b/tools/browser_tool_lightpanda_fallback.py @@ -2,8 +2,7 @@ that Lightpanda cannot serve (screenshots, empty snapshots, failed commands). Origin-module symbols are resolved lazily through ``tools.browser_tool`` (``_bt``) -so ``patch("tools.browser_tool.X")`` keeps working; never import ``tools.browser_tool`` -at import time (cycle). +so ``patch("tools.browser_tool.X")`` keeps working; never import it at import time (cycle). """ import json @@ -13,21 +12,23 @@ import subprocess from typing import Any, Dict, List, Optional, Tuple from tools.browser_tool_origin import origin_module as _origin +# Commands where Chrome can meaningfully produce a different result. Session-management +# commands (close, record) are tied to the engine's daemon and can't be retried elsewhere. +_FALLBACK_ELIGIBLE = frozenset({"open", "snapshot", "screenshot", "eval", "click", + "fill", "scroll", "back", "press", "console", "errors"}) + def _using_lightpanda_engine() -> bool: """Return True when local browser commands are configured for Lightpanda.""" - _bt = _origin() - return _bt._get_browser_engine() == "lightpanda" + return _origin()._get_browser_engine() == "lightpanda" def lightpanda_engine_status() -> Tuple[bool, str]: """Whether ``browser.engine: lightpanda`` is actually in effect, and why. - ``(False, "")`` when the engine isn't lightpanda; otherwise the reason names - the setting shadowing it or the driver running it. Mirrors the precedence - of ``_should_inject_engine`` / ``browser_use_cli._resolve_backend_cdp`` - with config-only gates (no network I/O) so ``/browser status`` and - ``hermes doctor`` can call it. + ``(False, "")`` when the engine isn't lightpanda; else the reason names the setting shadowing + it or the driver running it. Mirrors ``_should_inject_engine`` / ``_resolve_backend_cdp`` + precedence with config-only gates (no network I/O) for ``/browser status`` / ``hermes doctor``. """ _bt = _origin() if not _bt._using_lightpanda_engine(): @@ -36,9 +37,8 @@ def lightpanda_engine_status() -> Tuple[bool, str]: return False, "a CDP override is active (/browser connect or browser.cdp_url)" if _bt._is_camofox_mode(): return False, "Camofox is the selected browser (CAMOFOX_URL)" - # Real-profile is checked before the cloud provider: in browser_exec the - # real-profile resolution runs before backend resolution, so with both - # set it is the real-profile toggle that actually claims the session. + # Real-profile before cloud provider: browser_exec resolves real-profile before + # the backend, so with both set the real-profile toggle claims the session. if _bt._use_real_profile(): return False, "browser.use_real_profile is on (Lightpanda cannot load a Chromium profile)" try: @@ -50,75 +50,37 @@ def lightpanda_engine_status() -> Tuple[bool, str]: name = provider.provider_name() except Exception: name = type(provider).__name__ - return False, ( - f"cloud provider {name} is selected (browser.cloud_provider, or " - "auto-detected from credentials)" - ) - bu_mode = _bt._is_browser_use_cli_mode() - if bu_mode: - try: - from tools.browser_use_cli import ( - _read_browser_cfg, is_legacy_browser_use_cloud_config - ) - - if is_legacy_browser_use_cloud_config(_read_browser_cfg()): - return False, "Browser Use cloud (BROWSER_USE_API_KEY) is selected" - except Exception as e: - _bt.logger.debug("legacy Browser Use cloud check failed: %s", e) - if bu_mode: - return True, "Browser Use mode: Hermes spawns `lightpanda serve` per session" - return True, "built-in browser tools: agent-browser --engine lightpanda" + return False, f"cloud provider {name} is selected (browser.cloud_provider, or auto-detected from credentials)" + if not _bt._is_browser_use_cli_mode(): + return True, "built-in browser tools: agent-browser --engine lightpanda" + try: + from tools.browser_use_cli import _read_browser_cfg, is_legacy_browser_use_cloud_config + if is_legacy_browser_use_cloud_config(_read_browser_cfg()): + return False, "Browser Use cloud (BROWSER_USE_API_KEY) is selected" + except Exception as e: + _bt.logger.debug("legacy Browser Use cloud check failed: %s", e) + return True, "Browser Use mode: Hermes spawns `lightpanda serve` per session" def _lightpanda_fallback_reason(engine: str, command: str, result: Dict[str, Any]) -> Optional[str]: - """User-visible reason a Lightpanda result needs the Chrome fallback, or None. - - The string is copied into the fallback result so users can see when Hermes - silently switched engines. - """ - _bt = _origin() - if engine != "lightpanda": + """User-visible reason a Lightpanda result needs the Chrome fallback (copied into the result), or None.""" + if engine != "lightpanda" or command not in _FALLBACK_ELIGIBLE: return None - - # Only retry commands where Chrome can meaningfully produce a different - # result. Session-management commands (close, record) are tied to the - # engine's daemon and can't be retried on a different engine. - _FALLBACK_ELIGIBLE = {"open", "snapshot", "screenshot", "eval", "click", - "fill", "scroll", "back", "press", "console", "errors"} - if command not in _FALLBACK_ELIGIBLE: - return None - - # Explicit failure if not result.get("success"): - error = str(result.get("error") or "command failed").strip() - return f"Lightpanda {command!r} failed ({error}); retried with Chrome." - + return f"Lightpanda {command!r} failed ({str(result.get('error') or 'command failed').strip()}); retried with Chrome." data = result.get("data", {}) - - if command == "snapshot": - snap = data.get("snapshot", "") - # Empty or near-empty snapshots indicate Lightpanda couldn't render - if not snap or len(snap.strip()) < 20: - return "Lightpanda returned an empty/too-short snapshot; retried with Chrome." - - if command == "screenshot": - # Lightpanda returns a placeholder PNG with its panda logo. - # Since Lightpanda resized it to 1920x1080, the placeholder is - # ~17 KB. Real Chromium screenshots are typically 100 KB+. - path = data.get("path", "") - if path: - try: - size = os.path.getsize(path) - if size < 20480: - _bt.logger.debug("Lightpanda screenshot is suspiciously small (%d bytes), " - "triggering Chrome fallback", size) - return ( - f"Lightpanda screenshot was suspiciously small ({size} bytes); " - "retried with Chrome." - ) - except OSError: - return "Lightpanda screenshot file was missing/unreadable; retried with Chrome." - + if command == "snapshot" and len((data.get("snapshot", "") or "").strip()) < 20: # couldn't render + return "Lightpanda returned an empty/too-short snapshot; retried with Chrome." + if command == "screenshot" and data.get("path", ""): + # Lightpanda returns a ~17 KB placeholder PNG (panda logo at 1920x1080); real Chromium is 100 KB+. + try: + size = os.path.getsize(data["path"]) + except OSError: + return "Lightpanda screenshot file was missing/unreadable; retried with Chrome." + if size < 20480: + _origin().logger.debug("Lightpanda screenshot is suspiciously small (%d bytes), " + "triggering Chrome fallback", size) + return f"Lightpanda screenshot was suspiciously small ({size} bytes); " "retried with Chrome." return None @@ -129,24 +91,15 @@ def _needs_lightpanda_fallback(engine: str, command: str, result: Dict[str, Any] def _annotate_lightpanda_fallback(result: Dict[str, Any], reason: str) -> Dict[str, Any]: """Add a user-visible Chrome fallback warning to a browser command result.""" - warning = ( - "⚠ Lightpanda fallback: Chrome was used for this browser action. " f"{reason}" - ) - annotated = dict(result) - annotated["fallback_warning"] = warning - annotated["browser_engine"] = "chrome" - annotated["browser_engine_fallback"] = { - "from": "lightpanda", "to": "chrome", "reason": reason - } + warning = "⚠ Lightpanda fallback: Chrome was used for this browser action. " f"{reason}" + fields = {"fallback_warning": warning, "browser_engine": "chrome", + "browser_engine_fallback": {"from": "lightpanda", "to": "chrome", "reason": reason}} + annotated = {**result, **fields} data = annotated.get("data") if isinstance(data, dict): - data = dict(data) - data.setdefault("fallback_warning", warning) - data.setdefault("browser_engine", "chrome") - data.setdefault( - "browser_engine_fallback", {"from": "lightpanda", "to": "chrome", "reason": reason} - ) - annotated["data"] = data + annotated["data"] = data = dict(data) + for key, value in fields.items(): + data.setdefault(key, dict(value) if isinstance(value, dict) else value) return annotated @@ -159,33 +112,23 @@ def _copy_fallback_warning(target: Dict[str, Any], result: Dict[str, Any]) -> Di return target -def _run_chrome_fallback_command( - task_id: str, command: str, args: List[str], timeout: int -) -> Dict[str, Any]: +def _run_chrome_fallback_command(task_id: str, command: str, args: List[str], timeout: int) -> Dict[str, Any]: """Run a browser command in a temporary Chrome session at the current URL. - agent-browser locks the engine when a named daemon starts, so ``--engine - chrome`` on the Lightpanda session is ignored: use a fresh temp Chrome - session, navigate it to the current URL, run ``command``, tear it down. + agent-browser locks the engine when a named daemon starts, so ``--engine chrome`` on the + Lightpanda session is ignored: fresh temp Chrome session -> same URL -> ``command`` -> tear down. """ _bt = _origin() import uuid - - # 1. Grab the current URL from the Lightpanda session. ``get url`` is not - # fallback-eligible, so an error cannot recursively trigger this helper. - # Keep the explicit Lightpanda override so Chromium-only environment flags - # are stripped while querying the already-running Lightpanda daemon. - url_result = _bt._run_browser_command( - task_id, "get", ["url"], timeout=10, _engine_override="lightpanda" - ) - current_url = None - if url_result.get("success"): - current_url = str(url_result.get("data", {}).get("url", "")).strip() + # 1. Current URL from the Lightpanda session. ``get url`` is not fallback-eligible, + # so this can't recurse; the explicit override strips Chromium-only env flags. + url_result = _bt._run_browser_command(task_id, "get", ["url"], timeout=10, _engine_override="lightpanda") + current_url = str(url_result.get("data", {}).get("url", "")).strip() if url_result.get("success") else None if not current_url: _bt.logger.warning("Chrome fallback: could not determine current URL from LP session") return {"success": False, "error": "Chrome fallback failed: could not determine current URL"} - # 2. Create a temporary Chrome session (bypasses _get_session_info's cache). + # 2. Temporary Chrome session (bypasses _get_session_info's cache). tmp_session = f"h_cfb_{uuid.uuid4().hex[:8]}" try: browser_cmd = _bt._find_agent_browser() @@ -194,17 +137,11 @@ def _run_chrome_fallback_command( if not _bt._chromium_installed(): if _bt._running_in_docker(): - hint = ( - "Chrome fallback requires Chromium, but it is missing. " - "You're running in Docker — pull the latest image: " - "docker pull ghcr.io/nousresearch/hermes-agent:latest" - ) + hint = ("Chrome fallback requires Chromium, but it is missing. You're running in Docker — " + "pull the latest image: docker pull ghcr.io/nousresearch/hermes-agent:latest") else: - hint = ( - "Chrome fallback requires Chromium, but it is missing. Install it with: " - "npx agent-browser install --with-deps " - "(or: npx playwright install --with-deps chromium)" - ) + hint = ("Chrome fallback requires Chromium, but it is missing. Install it with: " + "npx agent-browser install --with-deps (or: npx playwright install --with-deps chromium)") return {"success": False, "error": hint} base_args = _bt._agent_browser_argv(browser_cmd) + ["--engine", "chrome", "--session", tmp_session, "--json"] @@ -224,7 +161,7 @@ def _run_chrome_fallback_command( proc.wait() return {"success": False, "error": f"Chrome fallback '{cmd}' timed out"} try: - with open(stdout_path, "r", encoding="utf-8") as f: + with open(stdout_path, encoding="utf-8") as f: stdout = f.read().strip() if stdout: return json.loads(stdout.split("\n")[-1]) @@ -250,9 +187,6 @@ def _run_chrome_fallback_command( shutil.rmtree(task_socket_dir, ignore_errors=True) -def _chrome_fallback_screenshot( - task_id: str, args: List[str], timeout: int -) -> Dict[str, Any]: +def _chrome_fallback_screenshot(task_id: str, args: List[str], timeout: int) -> Dict[str, Any]: """Take a screenshot using a temporary Chrome session.""" - _bt = _origin() - return _bt._run_chrome_fallback_command(task_id, "screenshot", args, timeout) + return _origin()._run_chrome_fallback_command(task_id, "screenshot", args, timeout) diff --git a/tools/browser_tool_real_profile.py b/tools/browser_tool_real_profile.py index 29a976d63b..35745324bb 100644 --- a/tools/browser_tool_real_profile.py +++ b/tools/browser_tool_real_profile.py @@ -2,9 +2,8 @@ hermes-owned copy, launch the real browser binary on it, and attach agent-browser. State (``_REAL_PROFILE_SESSION``, ``_real_profile_cdp_lock``, ``_real_profile_cdp_cache``, -``_real_profile_chrome_procs``) lives in ``tools.browser_tool``. Origin-module symbols are -resolved lazily through ``tools.browser_tool`` (``_bt``) so ``patch("tools.browser_tool.X")`` -keeps working; never import ``tools.browser_tool`` at import time (cycle). +``_real_profile_chrome_procs``) lives in ``tools.browser_tool``; origin symbols are read +through ``_bt`` so ``patch("tools.browser_tool.X")`` keeps working (never import it at import time). """ import os @@ -15,328 +14,204 @@ import time from typing import Optional, Tuple from tools.browser_tool_origin import origin_module as _origin +_RP = "browser.use_real_profile is on, but " + def _terminate_real_profile_chrome() -> None: - """Terminate real-browser processes launched for real-profile sessions (idempotent, atexit-safe). - - agent-browser only ATTACHED to them, so its own session cleanup never kills them. - """ + """Terminate real-browser processes launched for real-profile sessions (idempotent, atexit-safe); + agent-browser only ATTACHED to them, so its own session cleanup never kills them.""" + from tools.browser_lightpanda import _terminate _bt = _origin() while _bt._real_profile_chrome_procs: - proc = _bt._real_profile_chrome_procs.pop() - try: - if proc.poll() is None: - proc.terminate() - try: - proc.wait(timeout=5) - except Exception: - proc.kill() - except Exception as e: - _bt.logger.debug("real-profile chrome terminate failed: %s", e) + _terminate(_bt._real_profile_chrome_procs.pop(), what="real-profile chrome") def _cdp_http_ready(http_cdp: str) -> bool: """True when an ``http://host:port`` CDP discovery root answers.""" - try: - from hermes_cli.browser_connect import is_browser_debug_ready - - return is_browser_debug_ready(http_cdp, timeout=1.0) - except Exception: - return False + from tools.browser_lightpanda import _cdp_ready + return _cdp_ready(http_cdp, timeout=1.0) -def _agent_browser_get_cdp(session_name: str) -> Optional[str]: - """HTTP CDP discovery root of an agent-browser session (converted from its - ``ws://`` cdp-url), or None when it isn't running / unparsable.""" +def _agent_browser_session_cmd(session_name: str, *cmd: str, log_label: str) -> Optional[subprocess.CompletedProcess]: + """Run ``agent-browser --session ``; None when agent-browser is missing or the run fails.""" _bt = _origin() try: browser_cmd = _bt._find_agent_browser() except FileNotFoundError: return None try: - proc = subprocess.run( - [*_bt._agent_browser_argv(browser_cmd), "--session", session_name, "get", "cdp-url"], - capture_output=True, text=True, timeout=15, env=_bt._build_browser_env(), - ) + return subprocess.run([*_bt._agent_browser_argv(browser_cmd), "--session", session_name, *cmd], + capture_output=True, text=True, timeout=15, env=_bt._build_browser_env()) except (subprocess.SubprocessError, OSError) as e: - _bt.logger.debug("real-profile get cdp-url failed: %s", e) + _bt.logger.debug("real-profile %s failed: %s", log_label, e) return None - out = (proc.stdout or "").strip() - m = re.search(r"ws://127\.0\.0\.1:(\d+)/", out) - if not m: + + +def _agent_browser_get_cdp(session_name: str) -> Optional[str]: + """HTTP CDP discovery root of an agent-browser session (from its ``ws://`` cdp-url), or None.""" + proc = _agent_browser_session_cmd(session_name, "get", "cdp-url", log_label="get cdp-url") + m = re.search(r"ws://127\.0\.0\.1:(\d+)/", (proc.stdout or "").strip()) if proc is not None else None + return f"http://127.0.0.1:{m.group(1)}" if m else None + + +def _read_devtools_port(data_dir: str) -> Optional[str]: + """First line of Chrome's ``DevToolsActivePort`` in ``data_dir`` (None when unreadable).""" + try: + with open(os.path.join(data_dir, "DevToolsActivePort"), encoding="utf-8") as fh: + return fh.readline().strip() + except OSError: return None - return f"http://127.0.0.1:{m.group(1)}" def _cdp_on_data_dir(http_cdp: str, data_dir: str) -> bool: - """True when the CDP endpoint's browser runs on ``data_dir``. - - Chrome writes its live debug port to ``DevToolsActivePort`` in the - user-data-dir; a port match proves the browser is our profile copy, not a - throwaway temp dir a raced/stale launch fell back to. - """ + """True when the CDP endpoint's browser runs on ``data_dir`` (DevToolsActivePort match proves it + is our profile copy, not a throwaway temp dir a raced/stale launch fell back to).""" m = re.search(r":(\d+)", http_cdp or "") - if not m: - return False - try: - with open(os.path.join(data_dir, "DevToolsActivePort"), encoding="utf-8") as fh: - port_line = fh.readline().strip() - return port_line == m.group(1) - except OSError: - return False + return bool(m) and _read_devtools_port(data_dir) == m.group(1) def _agent_browser_close_session(session_name: str) -> None: """Best-effort close of an agent-browser session (stale/wrong-dir cleanup).""" - _bt = _origin() - try: - browser_cmd = _bt._find_agent_browser() - except FileNotFoundError: - return - try: - subprocess.run( - [*_bt._agent_browser_argv(browser_cmd), "--session", session_name, "close"], - capture_output=True, text=True, timeout=15, env=_bt._build_browser_env(), - ) - except (subprocess.SubprocessError, OSError) as e: - _bt.logger.debug("real-profile session close failed: %s", e) + _agent_browser_session_cmd(session_name, "close", log_label="session close") _REAL_PROFILE_CHROME_FLAGS = ( - "--remote-debugging-port=0", - "--no-first-run", - "--no-default-browser-check", - "--disable-background-networking", - "--disable-component-update", - "--disable-default-apps", - "--disable-hang-monitor", - "--disable-popup-blocking", - "--disable-prompt-on-repost", - "--disable-sync", - "--disable-features=Translate", - "--no-startup-window", + "--remote-debugging-port=0", "--no-first-run", "--no-default-browser-check", + "--disable-background-networking", "--disable-component-update", "--disable-default-apps", + "--disable-hang-monitor", "--disable-popup-blocking", "--disable-prompt-on-repost", + "--disable-sync", "--disable-features=Translate", "--no-startup-window", ) def _real_profile_unsupported_reason(browser) -> Optional[str]: - """Fail-closed message when the detected default browser can't be used, else None. + """Fail-closed message when the default browser can't be used, else None. - A recognized pre-release channel (Beta/Dev/Canary) lives in a - channel-specific profile dir we don't resolve; normalizing it to the stable - family would drive a DIFFERENT profile/account (wrong-principal bug), so - refuse rather than guess. + A pre-release channel lives in a profile dir we don't resolve; normalizing to the stable + family would drive a DIFFERENT profile/account (wrong-principal bug), so refuse rather than guess. """ from hermes_cli.browser_connect import UNSUPPORTED_CHANNEL - if browser is None: - return ( - "browser.use_real_profile is on, but your default browser is not a " - "supported Chromium browser (Chrome, Edge, Brave, Brave Origin, " - "Chromium). " - "Real-profile browsing requires a Chromium default; set one or turn " - "the toggle off." - ) + return (_RP + "your default browser is not a supported Chromium browser (Chrome, Edge, Brave, " + "Brave Origin, Chromium). Real-profile browsing requires a Chromium default; set one or turn the toggle off.") if browser == UNSUPPORTED_CHANNEL: - return ( - "browser.use_real_profile is on, but your default browser is a " - "pre-release Chromium channel (Beta / Dev / Canary), which " - "real-profile browsing does not support. Set your default to a " - "stable Chrome / Edge / Brave / Brave Origin / Chromium, or turn " - "the toggle off." - ) + return (_RP + "your default browser is a pre-release Chromium channel (Beta / Dev / Canary), which " + "real-profile browsing does not support. Set your default to a " + "stable Chrome / Edge / Brave / Brave Origin / Chromium, or turn the toggle off.") return None def _real_profile_snapshot_error(err: str) -> str: - """User-facing message for a failed profile snapshot. - - A locked profile surfaces the guidance verbatim (it already says whether - closing is armed) plus the exact approved-close command; the agent must ASK - the user before running it — it quits their browser. - """ + """User-facing message for a failed profile snapshot; a locked profile adds the approved-close + command, which the agent must ASK the user about first (it quits their browser).""" from hermes_cli.browser_connect import _PROFILE_LOCKED_PREFIX - if err and err.startswith(_PROFILE_LOCKED_PREFIX): - body = err[len(_PROFILE_LOCKED_PREFIX):] - return ( - body + " To close it (only after the user approves — it " - "quits their browser and loses unsaved tabs), run: " - "`hermes browser close-profile`, then retry." - ) - return f"browser.use_real_profile is on, but {err}" + return (err[len(_PROFILE_LOCKED_PREFIX):] + " To close it (only after the user approves — it " + "quits their browser and loses unsaved tabs), run: `hermes browser close-profile`, then retry.") + return f"{_RP}{err}" def _launch_real_profile_chrome(real_binary: str, copy_dir: str) -> Tuple[Optional[int], Optional[str]]: """Launch the user's REAL browser binary on the profile COPY; return (debug_port, error). - agent-browser's own launch path force-adds --use-mock-keychain / - --password-store=basic, which makes macOS Chrome drop every - keychain-encrypted cookie — the copy would launch signed out. Launching the - real binary ourselves with NO mock-keychain switches keeps the OS keychain - path intact; agent-browser attaches afterwards via ``--cdp ``. - - Headless by default: real-profile browsing is a background capability and a - focus-stealing window defeats it. Chrome's NEW headless mode shares the - profile's normal cookie store (legacy --headless does not), and cookie - decryption is unaffected by headless (the drop comes from mock-keychain, not - headless). Users opt into a window via browser.headed / AGENT_BROWSER_HEADED; - on a display-less Linux host we force headless regardless so the launch - doesn't die at startup. Waits for Chrome to write DevToolsActivePort. + agent-browser's own launch force-adds --use-mock-keychain / --password-store=basic, which makes + macOS Chrome drop every keychain-encrypted cookie (signed-out copy); launching the real binary + ourselves keeps the OS keychain path intact and agent-browser attaches via ``--cdp ``. + Headless by default (a focus-stealing window defeats a background capability); Chrome's NEW + headless shares the profile's cookie store (legacy --headless does not). browser.headed / + AGENT_BROWSER_HEADED opts into a window, except on a display-less Linux host (launch would die). """ _bt = _origin() - port_file = os.path.join(copy_dir, "DevToolsActivePort") try: - os.unlink(port_file) # stale port from a previous launch confuses reuse probes + os.unlink(os.path.join(copy_dir, "DevToolsActivePort")) # stale port confuses reuse probes except OSError: pass chrome_argv = [real_binary, f"--user-data-dir={copy_dir}", *_REAL_PROFILE_CHROME_FLAGS] _has_display = bool(os.environ.get("DISPLAY") or os.environ.get("WAYLAND_DISPLAY")) - _want_headed = _bt._is_headed_mode() and (_has_display or not sys.platform.startswith("linux")) - if not _want_headed: + if not (_bt._is_headed_mode() and (_has_display or not sys.platform.startswith("linux"))): chrome_argv.append("--headless=new") try: - chrome_proc = subprocess.Popen( - chrome_argv, - stdout=subprocess.DEVNULL, - stderr=subprocess.DEVNULL, - stdin=subprocess.DEVNULL, - start_new_session=True, - env=_bt._build_browser_env(), - ) + chrome_proc = subprocess.Popen(chrome_argv, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, + stdin=subprocess.DEVNULL, start_new_session=True, env=_bt._build_browser_env()) except (subprocess.SubprocessError, OSError) as e: - return None, f"browser.use_real_profile is on, but the launch failed: {e}" + return None, f"{_RP}the launch failed: {e}" _bt._real_profile_chrome_procs.append(chrome_proc) deadline = time.monotonic() + 30.0 while time.monotonic() < deadline: - try: - with open(port_file, encoding="utf-8") as fh: - line = fh.readline().strip() - if line.isdigit(): - return int(line), None - except OSError: - pass + line = _read_devtools_port(copy_dir) or "" + if line.isdigit(): + return int(line), None if chrome_proc.poll() is not None: _bt._terminate_real_profile_chrome() - return None, ( - "browser.use_real_profile is on, but Chrome exited during " - "startup (another instance may hold the profile copy)." - ) + return None, _RP + "Chrome exited during startup (another instance may hold the profile copy)." time.sleep(0.25) _bt._terminate_real_profile_chrome() - return None, ( - "browser.use_real_profile is on, but the real-profile browser " - "did not expose a debug port in time. Retry, or turn the toggle off." - ) + return None, _RP + "the real-profile browser did not expose a debug port in time. Retry, or turn the toggle off." def _attach_agent_browser_to_real_profile(port: int, copy_dir: str) -> Tuple[Optional[str], Optional[str]]: - """Make agent-browser ATTACH to the running Chrome (never launch its own). + """Make agent-browser ATTACH to the running Chrome (never launch its own); returns ``(http_cdp, error)``. - Returns ``(http_cdp, error)``. The daemon may answer with the endpoint of a - browser IT spawned (throwaway temp profile) instead of the Chrome we - launched; the DevToolsActivePort file OUR Chrome wrote is authoritative, so - on disagreement we trust ours. + The daemon may answer with the endpoint of a browser IT spawned (throwaway temp profile); + the DevToolsActivePort OUR Chrome wrote is authoritative on disagreement. """ _bt = _origin() try: browser_cmd = _bt._find_agent_browser() except FileNotFoundError as e: - return None, ( - "browser.use_real_profile is on, but the local browser engine " - f"(agent-browser) is not installed: {e}" - ) - argv = [ - *_bt._agent_browser_argv(browser_cmd), - "--session", _bt._REAL_PROFILE_SESSION, - "--cdp", str(port), - "open", "about:blank", - ] + return None, f"{_RP}the local browser engine (agent-browser) is not installed: {e}" + argv = [*_bt._agent_browser_argv(browser_cmd), "--session", _bt._REAL_PROFILE_SESSION, + "--cdp", str(port), "open", "about:blank"] try: - proc = subprocess.run( - argv, capture_output=True, text=True, - timeout=_bt._get_open_command_timeout(first_open=True), - env=_bt._build_browser_env(), - ) + proc = subprocess.run(argv, capture_output=True, text=True, + timeout=_bt._get_open_command_timeout(first_open=True), env=_bt._build_browser_env()) except subprocess.TimeoutExpired: - return None, ( - "browser.use_real_profile is on, but the real-profile browser " - "took too long to start. Retry, or turn the toggle off." - ) + return None, _RP + "the real-profile browser took too long to start. Retry, or turn the toggle off." except (subprocess.SubprocessError, OSError) as e: - return None, f"browser.use_real_profile is on, but the launch failed: {e}" + return None, f"{_RP}the launch failed: {e}" if proc.returncode != 0: tail = (proc.stderr or proc.stdout or "").strip().splitlines() - reason = tail[-1] if tail else f"exit {proc.returncode}" - return None, ( - f"browser.use_real_profile is on, but the real-profile browser " - f"failed to start: {reason}" - ) + return None, f"{_RP}the real-profile browser failed to start: {tail[-1] if tail else f'exit {proc.returncode}'}" cdp = _bt._agent_browser_get_cdp(_bt._REAL_PROFILE_SESSION) - try: - with open(os.path.join(copy_dir, "DevToolsActivePort"), encoding="utf-8") as fh: - our_port = fh.readline().strip() - m = re.search(r":(\d+)", cdp or "") - if m and m.group(1) != our_port: - cdp = f"http://127.0.0.1:{our_port}" - except (OSError, ValueError): - pass + our_port = _read_devtools_port(copy_dir) + if our_port is not None and (m := re.search(r":(\d+)", cdp or "")) and m.group(1) != our_port: + cdp = f"http://127.0.0.1:{our_port}" if not cdp: - return None, ( - "browser.use_real_profile is on, but the real-profile browser " - "started without exposing a devtools endpoint. Retry, or turn " - "the toggle off." - ) + return None, _RP + "the real-profile browser started without exposing a devtools endpoint. Retry, or turn the toggle off." return cdp, None def _real_profile_cdp() -> tuple: """Resolve ``(cdp_url, error)`` for consented real-profile browsing. - Snapshots the user's default-Chromium profile into a hermes-owned copy - (auth/login state only), launches the real browser binary on that copy and - returns the HTTP CDP endpoint for agent-browser / the browser-use harness to - attach to. The copy is a non-default dir, so it sidesteps the Chrome ≥136 - default-profile remote-debugging block and never contends with the user's - running browser. - - A single shared agent-browser session is reused across calls (its CDP URL - is cached and re-validated). Returns ``(None, message)`` fail-closed when - the default browser is non-Chromium or the snapshot/launch fails; - ``(None, None)`` when consent is off. + Snapshot -> launch real binary on the copy -> return its HTTP CDP endpoint. The copy is a + non-default dir, so it sidesteps the Chrome >=136 default-profile remote-debugging block and + never contends with the user's running browser. One shared agent-browser session is reused + across calls (cached, re-validated). ``(None, message)`` fail-closed; ``(None, None)`` when consent is off. """ _bt = _origin() if not _bt._use_real_profile(): - # Consent is off. A snapshot store from a previous consented run holds - # copies of the user's cookies/logins — delete it so revoking consent - # actually removes the credential copies. Cheap and idempotent. + # Consent is off: delete any snapshot store (copies of cookies/logins) so + # revoking consent actually removes the credential copies. try: from hermes_cli.browser_connect import cleanup_real_profile_snapshots - cleanup_real_profile_snapshots() except Exception as e: _bt.logger.debug("real-profile cleanup-on-consent-off failed: %s", e) _bt._real_profile_cdp_cache.pop("cdp", None) return None, None - # Lightpanda rejects ``--profile`` outright. Detect it BEFORE default-browser - # detection so even a host with no Chromium default reports the actionable - # conflict (the engine setting) rather than a generic launch failure. + # Lightpanda rejects ``--profile``; check BEFORE default-browser detection so a + # host with no Chromium default still reports the actionable engine conflict. if _bt._using_lightpanda_engine(): - return None, ( - "browser.use_real_profile is on, but browser.engine is set to " - "'lightpanda', which cannot load a real Chromium profile. Set " - "browser.engine to 'auto' or 'chrome' to use real-profile browsing, " - "or turn the toggle off." - ) + return None, (_RP + "browser.engine is set to 'lightpanda', which cannot load a real Chromium profile. " + "Set browser.engine to 'auto' or 'chrome' to use real-profile browsing, or turn the toggle off.") - from hermes_cli.browser_connect import ( - chromium_executable, detect_default_chromium, real_profile_copy_dir, snapshot_real_profile - ) + from hermes_cli.browser_connect import (chromium_executable, detect_default_chromium, + real_profile_copy_dir, snapshot_real_profile) with _bt._real_profile_cdp_lock: - # Reuse a live copy-browser from an earlier call this process made. cached = _bt._real_profile_cdp_cache.get("cdp") if cached and _bt._cdp_http_ready(cached): return cached, None @@ -347,35 +222,23 @@ def _real_profile_cdp() -> tuple: if unsupported: return None, unsupported - # Reuse BEFORE writing anything. A shared copy-browser may already be up - # from a previous hermes process; if it is driving OUR copy dir, hand it - # back untouched. CRITICAL: the snapshot overlay (which truncates and - # rewrites Cookies / Login Data) must NOT run while that browser holds - # the user-data-dir open — doing so corrupts the live databases. So - # resolve the copy dir as a PATH only (no copy), probe reuse, and return - # early on a hit; the overlay happens solely on the relaunch path below. + # Reuse BEFORE writing anything. CRITICAL: the snapshot overlay (truncates/rewrites + # Cookies / Login Data) must NOT run while a live copy-browser (maybe from a previous + # hermes process) holds the user-data-dir open — that corrupts the databases. copy_dir = real_profile_copy_dir(browser) existing = _bt._agent_browser_get_cdp(_bt._REAL_PROFILE_SESSION) if existing and _bt._cdp_http_ready(existing) and _bt._cdp_on_data_dir(existing, copy_dir): _bt._real_profile_cdp_cache["cdp"] = existing return existing, None - if existing: - # Stale/wrong-dir session (throwaway-temp fallback, or an old copy): - # close it so nothing holds the dir open before we overlay + relaunch. + if existing: # stale/wrong-dir session: close it so nothing holds the dir open _bt._agent_browser_close_session(_bt._REAL_PROFILE_SESSION) - # No live browser owns the dir now — safe to (re)snapshot + overlay. - snap_dir, err = snapshot_real_profile(browser) - if err or not snap_dir: + copy_dir, err = snapshot_real_profile(browser) + if err or not copy_dir: return None, _real_profile_snapshot_error(err) - copy_dir = snap_dir - real_binary = chromium_executable(browser) if real_binary is None: - return None, ( - "browser.use_real_profile is on, but the real browser binary for " - f"'{browser}' could not be found. Reinstall it or turn the toggle off." - ) + return None, f"{_RP}the real browser binary for '{browser}' could not be found. Reinstall it or turn the toggle off." port, err = _launch_real_profile_chrome(real_binary, copy_dir) if port is None: return None, err diff --git a/tools/browser_use_cli.py b/tools/browser_use_cli.py index 7ce293f898..2cbefa02e7 100644 --- a/tools/browser_use_cli.py +++ b/tools/browser_use_cli.py @@ -27,16 +27,14 @@ BACKEND_DISABLED = "off" # Cloud daemon names become the BU_NAME env var _SESSION_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$") -# Set on the env dict by the CDP resolvers when the resolved browser is EXCLUSIVE -# to this named session (per-name provider / named BU cloud / Lightpanda). -# Popped before the subprocess launches — never exported to the CLI. +# Set on the env dict by the CDP resolvers when the resolved browser is EXCLUSIVE to this named session +# (per-name provider / named BU cloud / Lightpanda). Popped before the subprocess launches — never exported. _PRIVATE_BROWSER_SENTINEL = "_HERMES_BU_PRIVATE_BROWSER" -# Prepended to the model's code for named sessions on SHARED browsers (local -# Chrome / CDP override): the harness daemon attaches to the first existing page -# at startup, so two fresh named daemons can land on the SAME tab. Steering each -# onto a tab it created prevents clobbering before their first new_tab(). Runs -# once per daemon (marker keyed by BU_NAME + daemon pid). +# Prepended to the model's code for named sessions on SHARED browsers (local Chrome / CDP override): the +# harness daemon attaches to the first existing page at startup, so two fresh named daemons can land on the +# SAME tab. Steering each onto a tab it created prevents clobbering. Runs once per daemon (marker keyed by +# BU_NAME + daemon pid). _OWN_TAB_PREAMBLE = """\ # hermes: pin this named session to its own tab (once per daemon process) def _hermes_ensure_own_tab(): @@ -77,18 +75,11 @@ _MIN_TIMEOUT_S = 5 _MAX_TIMEOUT_S = 1800 _STDERR_CAP_CHARS = 4000 -# Filesystem-safe task ids for per-task workspace dirs. -_TASK_ID_SAFE_RE = re.compile(r"[^A-Za-z0-9._-]+") - -# Screenshot paths printed by capture_screenshot(): POSIX absolute or Windows -# drive-letter absolute (Browser Use on Windows prints native paths). -_IMAGE_PATH_RE = re.compile( - r"((?:[A-Za-z]:[\\/]|/)[^\s\"']+?\.(?:png|jpe?g|webp))", re.IGNORECASE -) - +_TASK_ID_SAFE_RE = re.compile(r"[^A-Za-z0-9._-]+") # filesystem-safe task ids +# Screenshot paths printed by capture_screenshot(): POSIX or Windows drive-letter absolute. +_IMAGE_PATH_RE = re.compile(r"((?:[A-Za-z]:[\\/]|/)[^\s\"']+?\.(?:png|jpe?g|webp))", re.IGNORECASE) # http(s) URL literals in exec code checked against browser_navigate's policy _URL_RE = re.compile(r"https?://[^\s'\"\\)]+", re.IGNORECASE) - _FHS_BIN_DIRS = ("/usr/local/sbin", "/usr/local/bin", "/usr/sbin", "/usr/bin", "/sbin", "/bin") @@ -99,30 +90,23 @@ def _quiet(fn: Callable[[], Any], default: Any, log_prefix: str = "") -> Any: except Exception as e: if log_prefix: logger.debug("%s: %s", log_prefix, e) - return default + 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``.""" + """Call ``module.name()`` resolved at call time (tests stub the module / patch the + attribute); on any failure log ``log_prefix`` and return ``default``.""" return _quiet(lambda: getattr(importlib.import_module(module), name)(), default, log_prefix) def _camofox_active(context: str = "") -> bool: - """True when the Camofox backend is selected; False (logged) if the probe fails.""" 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 @@ -159,9 +143,8 @@ 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); an inherited PYTHONPATH/PYTHONHOME + # (Hermes's venv) wins over its site-packages → wrong-ABI C-extensions and a crash. env.pop("PYTHONPATH", None) env.pop("PYTHONHOME", None) env["PATH"] = _floor_subprocess_path(env.get("PATH", "")) @@ -170,11 +153,10 @@ 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, 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.""" + """Guarantee core system dirs on the CLI subprocess PATH: profile workers (kanban bots, cron) can inherit + a PATH of only version-manager dirs, and the uv binary's POSIX sh trampoline resolves ``dirname``/``realpath`` + via PATH (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 with contextlib.suppress(Exception): @@ -201,11 +183,8 @@ def _use_gateway(browser_cfg: dict) -> bool: def get_browser_backend() -> str: - """Return the configured browser backend key ("" = unset → default). - - YAML 1.1 parses an unquoted ``off`` as False — a hand-edited ``backend: off`` - must mean BACKEND_DISABLED, not "unset" (True has no meaning → unset). - """ + """Configured browser backend key ("" = unset → default). YAML 1.1 parses an + unquoted ``off`` as False — that must mean BACKEND_DISABLED, not "unset".""" raw = _read_browser_cfg().get("backend") if isinstance(raw, bool): return BACKEND_DISABLED if raw is False else "" @@ -219,22 +198,16 @@ def is_legacy_browser_use_cloud_config(browser_cfg: dict) -> bool: if not isinstance(browser_cfg, dict) or browser_cfg.get("backend"): return False 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"): + if provider not in {"browser-use", ""} or _use_gateway(browser_cfg) or _camofox_active(" during migration"): return False return bool(os.getenv("BROWSER_USE_API_KEY")) 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), 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. - """ + """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`` keeps the built-in browser_* tools. Camofox always falls back to the built-in + tools (Firefox, custom HTTP API, no CDP surface for the harness).""" if _camofox_active(): return False backend = get_browser_backend() @@ -243,27 +216,21 @@ def is_browser_use_cli_mode() -> bool: return is_legacy_browser_use_cloud_config(_read_browser_cfg()) or _find_cli() is not None -_NOTICE_STAMP_NAME = ".browser_use_default_notice" -_NOTICE_INTERVAL_S = 24 * 3600 - - def default_downgrade_notice() -> Optional[str]: - """One-line notice when ``browser.backend`` is unset (Browser Use would be the - default) but the CLI is not runnable, so the session fell back to the built-in - tools. Rate-limited to once per 24h via a stamp file.""" + """One-line notice when ``browser.backend`` is unset but the CLI is not runnable, so + the session fell back to the built-in tools. Rate-limited to once per 24h via a stamp file.""" try: 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 + stamp = Path(get_hermes_home()) / "cache" / ".browser_use_default_notice" with contextlib.suppress(OSError): - if 0 <= time.time() - stamp.stat().st_mtime < _NOTICE_INTERVAL_S: + if 0 <= time.time() - stamp.stat().st_mtime < 24 * 3600: return None with contextlib.suppress(OSError): stamp.parent.mkdir(parents=True, exist_ok=True) stamp.touch() - 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.") + 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.") except Exception as e: # pragma: no cover — a notice must never break startup logger.debug("browser-use downgrade notice failed: %s", e) return None @@ -274,24 +241,17 @@ def _managed_bin_dir() -> str: return str(Path(get_hermes_home()) / "bin") -def _user_local_bin_dir() -> Optional[str]: - """User-level tool dir (~/.local/bin; uv's tool bin dir on Windows) — Desktop/TUI - workers may start with a minimal PATH that omits it.""" - if os.name == "nt": - base = os.environ.get("APPDATA") - return str(Path(base) / "uv" / "bin") if base else None - return str(Path(os.path.expanduser("~")) / ".local" / "bin") - - def _find_cli() -> Optional[List[str]]: - """Locate the browser-use CLI, or None when it can't be run. - - MANAGED-FIRST: Hermes' own ``$HERMES_HOME/bin`` copy (installed/updated via - ``install_cli()``) always wins so every session drives one Hermes-controlled - binary; PATH and the user-level tool dir (manual ``uv tool install``, minimal - Desktop/TUI PATHs) are fallbacks; uvx zero-install (same probe order) is last. - """ - probe_paths = [p for p in (_managed_bin_dir(), None, _user_local_bin_dir()) if p is None or p] # None = PATH + """Locate the browser-use CLI, or None when it can't be run. MANAGED-FIRST: Hermes' own ``$HERMES_HOME/bin`` + copy always wins so every session drives one Hermes-controlled binary; PATH and the user-level tool dir + (~/.local/bin, or uv's %APPDATA%/uv/bin on Windows — Desktop/TUI workers may start with a minimal PATH + that omits it) are fallbacks; uvx zero-install (same probe order) is last.""" + if os.name == "nt": + appdata = os.environ.get("APPDATA") + user_bin = str(Path(appdata) / "uv" / "bin") if appdata else None + else: + user_bin = str(Path(os.path.expanduser("~")) / ".local" / "bin") + probe_paths = [p for p in (_managed_bin_dir(), None, user_bin) if p is None or p] # None = PATH for name, argv in (("browser-use", lambda b: [b]), ("uvx", lambda b: [b, "browser-use"])): for probe_path in probe_paths: found = shutil.which(name, path=probe_path) @@ -301,15 +261,10 @@ 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. - - 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). - """ + """Install the browser-use CLI 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. + 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 block provisioning the canonical copy (version drift).""" bin_dir = _managed_bin_dir() managed = shutil.which("browser-use", path=bin_dir) if managed: @@ -323,9 +278,7 @@ def install_cli(timeout_s: int = 600) -> Tuple[bool, str]: 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`.") - - env = dict(os.environ) - env["UV_NO_CONFIG"] = "1" + env = {**os.environ, "UV_NO_CONFIG": "1"} try: Path(bin_dir).mkdir(parents=True, exist_ok=True) env["UV_TOOL_BIN_DIR"] = bin_dir @@ -333,10 +286,8 @@ def install_cli(timeout_s: int = 600) -> Tuple[bool, str]: logger.debug("Could not prepare %s: %s", bin_dir, e) try: - result = subprocess.run( - [uv_bin, "tool", "install", "browser-use"], capture_output=True, text=True, - encoding="utf-8", errors="replace", env=env, timeout=timeout_s, - ) + result = subprocess.run([uv_bin, "tool", "install", "browser-use"], capture_output=True, text=True, + encoding="utf-8", errors="replace", env=env, timeout=timeout_s) except subprocess.TimeoutExpired: return False, f"`uv tool install browser-use` timed out after {timeout_s}s" except Exception as e: @@ -348,8 +299,8 @@ def install_cli(timeout_s: int = 600) -> Tuple[bool, str]: found = _find_cli() if not found or len(found) != 1: - return False, ("install reported success but the browser-use binary is still " - "not resolvable — run `uv tool install browser-use` manually") + return False, ("install reported success but the browser-use binary is still not resolvable — " + "run `uv tool install browser-use` manually") return True, f"browser-use CLI installed ({found[0]})" @@ -369,8 +320,8 @@ def _workspace_dir(task_id: Optional[str]) -> Optional[str]: def _find_screenshot(stdout: str, since: float) -> Optional[str]: - """Last screenshot path printed during this exec that exists and was - written after the exec started, or None.""" + """Last screenshot path printed during this exec that exists and was written after + the exec started, or None.""" for path in reversed(_IMAGE_PATH_RE.findall(stdout or "")): try: if os.path.isfile(path) and os.path.getmtime(path) >= since - 1: @@ -388,13 +339,10 @@ 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). - 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, - ) + # 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. + 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) text = json.dumps(result, ensure_ascii=False) attached = text + "\n\nThe screenshot from this call is attached — inspect it with your native vision." return { @@ -414,11 +362,9 @@ def _backend_cache_key(task_id: Optional[str], session_name: str = "") -> str: def _resolve_lightpanda_cdp(env: dict, task_id: Optional[str], session_name: str = "") -> Optional[str]: - """Point the harness at a Hermes-spawned ``lightpanda serve`` (only when - ``browser.engine`` is ``lightpanda`` and nothing with higher precedence claimed - the session). Each cache key gets its own process via the legacy - ``_get_session_info()`` (cache, inactivity reaper, atexit), so the browser is - private to this session and the own-tab preamble is skipped.""" + """Point the harness at a Hermes-spawned ``lightpanda serve`` (``browser.engine: lightpanda`` and + nothing of higher precedence claimed the session). Each cache key gets its own process via the + legacy ``_get_session_info()`` (cache, reaper, atexit): private browser, own-tab preamble skipped.""" try: from tools.browser_tool import _get_session_info, _using_lightpanda_engine if not _using_lightpanda_engine(): @@ -440,15 +386,12 @@ def _resolve_lightpanda_cdp(env: dict, task_id: Optional[str], session_name: str def _resolve_backend_cdp(env: dict, task_id: Optional[str], session_name: str = "") -> Optional[str]: """Point the harness at the configured backend's CDP endpoint; error string on failure. - Precedence (first hit wins): (1) ``BU_CDP_WS``/``BU_CDP_URL`` already in env - (operator override, untouched); (2) ``BROWSER_CDP_URL`` env / ``browser.cdp_url`` - config (``/browser connect``, same precedence as the built-in tools); (3) a - cloud provider via the legacy ``_get_session_info()`` so browser_exec shares the - SAME session machinery (per-task cache, expiry replacement, reaper, atexit); - (4) ``browser.engine: lightpanda``; (5) nothing → None, the harness attaches to - local Chrome (or BU cloud via BU_AUTOSPAWN for legacy configs). ``session_name`` - (BU_NAME) keys the provider cache so each name gets its OWN cloud browser and - the same name reuses one — what makes named sessions concurrent-safe. + Precedence: (1) ``BU_CDP_WS``/``BU_CDP_URL`` already in env (operator override); (2) ``BROWSER_CDP_URL`` + env / ``browser.cdp_url`` (``/browser connect``); (3) a cloud provider via the legacy ``_get_session_info()`` + so browser_exec shares the SAME session machinery (per-task cache, expiry, reaper, atexit); + (4) ``browser.engine: lightpanda``; (5) nothing → None: the harness attaches to local Chrome (or BU + cloud via BU_AUTOSPAWN for legacy configs). ``session_name`` (BU_NAME) keys the provider cache so + each name gets its OWN cloud browser — what makes named sessions concurrent-safe. """ if _has_cdp_env(env): return None @@ -468,11 +411,9 @@ def _resolve_backend_cdp(env: dict, task_id: Optional[str], session_name: str = 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. provider_key = str(getattr(provider, "name", "") or "").strip().lower() 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 @@ -486,25 +427,20 @@ def _resolve_backend_cdp(env: dict, task_id: Optional[str], session_name: str = f"Cloud browser provider {provider_name} returned no CDP endpoint, so Browser Use mode " "cannot drive it. Switch to the built-in browser tools for this provider.", ) - # A provider browser keyed bu-named- is exclusive to this session — - # the own-tab preamble would just leak a blank tab into it. + # A provider browser keyed bu-named- is exclusive to this session — the + # own-tab preamble would just leak a blank tab into it. if err is None and session_name: env[_PRIVATE_BROWSER_SENTINEL] = "1" return err 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. - - With ``browser.use_real_profile`` on, local browsing means the user's default - Chromium with their logins — Hermes launches on a SNAPSHOT of the real profile - (hermes_cli.browser_connect). Two ways in: the effective backend is already - local (no provider, CDP override, or legacy BU cloud config) → silent upgrade; - or ``force_local`` (consent-gated ``local`` arg) → the user's browser even under - a cloud backend. Operator overrides (BU_CDP_* env, /browser connect, - ``browser.cdp_url``) own the session either way. Fail closed: a real-profile - launch error is returned so a consented user is never silently downgraded. - """ + """Point the harness at the user's real-profile copy-browser (a SNAPSHOT of their default Chromium + profile, hermes_cli.browser_connect) when consented. Two ways in: the effective backend is already local + (no provider, CDP override, or legacy BU cloud config) → silent upgrade; or ``force_local`` (consent-gated + ``local`` arg) → the user's browser even under a cloud backend. Operator overrides (BU_CDP_* env, + /browser connect, ``browser.cdp_url``) own the session either way. Fail closed: a launch error is + returned so a consented user is never silently downgraded.""" if not _real_profile_consented() or _has_cdp_env(env): return None @@ -517,14 +453,11 @@ def _resolve_real_profile_cdp(env: dict, force_local: bool) -> Optional[str]: if _quiet(_get_cdp_override_raw, ""): return None - if not force_local: - # 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 + # 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 not force_local and (_quiet(_get_cloud_provider, object()) is not None + or is_legacy_browser_use_cloud_config(_read_browser_cfg())): + return None cdp, err = _real_profile_cdp() if err: @@ -535,22 +468,17 @@ def _resolve_real_profile_cdp(env: dict, force_local: bool) -> Optional[str]: def _route_backend(env: dict, session: str, task_id: Optional[str], local: bool) -> Optional[str]: - """Resolve where the harness connects; returns an error string or None. - - Real-profile consent runs BEFORE provider resolution so a real-profile hit - short-circuits the cloud path via the BU_CDP_* env contract. Named sessions - compose with the backend: BU_NAME namespaces the harness daemon (IPC socket, - log, pid) and on provider backends additionally keys its own cloud browser. - """ + """Resolve where the harness connects; returns an error string or None. Real-profile consent runs + BEFORE provider resolution so a hit short-circuits the cloud path via the BU_CDP_* env contract. Named + sessions compose with the backend: BU_NAME namespaces the harness daemon (IPC socket, log, pid) and on + provider backends additionally keys its own cloud browser.""" rp_err = _resolve_real_profile_cdp(env, force_local=local) if rp_err: return rp_err - # local=True is only served by the real-profile route; consent off (schema - # normally hidden, but be explicit) must not pretend. + # local=True is only served by the real-profile route; consent off must not pretend. if local and not _has_cdp_env(env) and not _real_profile_consented(): - return ("local=true was requested but browser.use_real_profile is off. " - "Enable it in config.yaml (browser.use_real_profile: true) or " - "the desktop Settings → Browser section, then retry.") + return ("local=true was requested but browser.use_real_profile is off. Enable it in config.yaml " + "(browser.use_real_profile: true) or the desktop Settings → Browser section, then retry.") return _resolve_backend_cdp(env, task_id, session_name=session) @@ -558,12 +486,12 @@ def _windows_popen_kwargs() -> dict: """Hide the console the .cmd shim would flash on Windows (as browser_tool does).""" if os.name != "nt": return {} + 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} - return _quiet(_flags, {}, "Windows hide-flags unavailable") @@ -587,10 +515,9 @@ def browser_exec(code: str, session: str = "", timeout_s: int = _DEFAULT_TIMEOUT cmd = _find_cli() if not cmd: - return tool_error("browser-use CLI not found on PATH, and uvx is unavailable for a " - "zero-install run. Install it with `uv tool install browser-use` " - "(or `pipx install browser-use`), then run `browser-use --doctor` " - "to verify the setup.") + return tool_error("browser-use CLI not found on PATH, and uvx is unavailable for a zero-install run. " + "Install it with `uv tool install browser-use` (or `pipx install browser-use`), " + "then run `browser-use --doctor` to verify the setup.") env = _base_subprocess_env() if session: @@ -602,10 +529,9 @@ def browser_exec(code: str, session: str = "", timeout_s: int = _DEFAULT_TIMEOUT if route_err: return tool_error(route_err) - # SHARED browser (local Chrome / CDP override): pin each named session to its - # own tab (see _OWN_TAB_PREAMBLE). Private per-name browsers skip this — no one - # to collide with, and the extra tab would leak. - private_browser = env.pop(_PRIVATE_BROWSER_SENTINEL, None) + # SHARED browser (local Chrome / CDP override): pin each named session to its own tab (see + # _OWN_TAB_PREAMBLE). Private per-name browsers skip this — nothing to collide with. + private_browser = env.pop(_PRIVATE_BROWSER_SENTINEL, None) # always pop: never exported to the CLI if session and not private_browser: code = _OWN_TAB_PREAMBLE + code @@ -613,8 +539,8 @@ def browser_exec(code: str, session: str = "", timeout_s: int = _DEFAULT_TIMEOUT if workspace: env["BH_AGENT_WORKSPACE"] = workspace - # BU_AUTOSPAWN makes the CLI start a Browser Use cloud browser when no - # local Chrome/CDP endpoint is reachable (their API key authenticates it) + # BU_AUTOSPAWN makes the CLI start a Browser Use cloud browser when no local + # Chrome/CDP endpoint is reachable (their API key authenticates it) if "BU_AUTOSPAWN" not in env and is_legacy_browser_use_cloud_config(_read_browser_cfg()): env["BU_AUTOSPAWN"] = "1" @@ -626,10 +552,9 @@ def browser_exec(code: str, session: str = "", timeout_s: int = _DEFAULT_TIMEOUT **_windows_popen_kwargs(), ) except subprocess.TimeoutExpired: - return tool_error(f"browser-use exec timed out after {timeout}s. The daemon may " - f"still be working; retry with a larger timeout_s (max {_MAX_TIMEOUT_S}), " - "or split the work into several calls that append to workspace files — " - "anything already written to the workspace is preserved.") + return tool_error(f"browser-use exec timed out after {timeout}s. The daemon may still be working; retry " + f"with a larger timeout_s (max {_MAX_TIMEOUT_S}), or split the work into several calls that " + "append to workspace files — anything already written to the workspace is preserved.") except OSError as e: return tool_error(f"Failed to launch browser-use CLI: {e}") @@ -654,82 +579,63 @@ def browser_exec(code: str, session: str = "", timeout_s: int = _DEFAULT_TIMEOUT _HEADER_BASE = ( - "Drive a real web browser via the Browser Use CLI: `code` runs as full " - "Python (stdlib available) with pre-imported browser helpers; stdout " - "comes back in the result. Start `code` with a one-line comment " - "describing the step for the user in plain language, max 60 chars " - "(e.g. `# Searching Amazon for paper towels`) — the UI shows it as the " - "step label.\n\n" - "STATE: the browser session and workspace persist across calls; Python " - "variables do NOT (fresh interpreter each call). The workspace dir is " - "$BH_AGENT_WORKSPACE (also `workspace` in every result); functions " - "defined in agent_helpers.py there are auto-imported into every call. " - "For multi-item tasks ('all N products / every entry'), append each " - "batch to a JSON/CSV file in the workspace, then read it back and " - "aggregate in code — dedupe/count/sort with Python, not in your head — " - "and verify the collected count against what was asked before " - "answering.\n\n" - "Batch each sub-procedure (navigate, wait, extract, act) into one call " - "— do not spend a call per action — but for long extractions prefer " - "several medium calls that append to workspace files over one giant " - "call, so progress survives timeouts." + "Drive a real web browser via the Browser Use CLI: `code` runs as full Python (stdlib available) " + "with pre-imported browser helpers; stdout comes back in the result. Start `code` with a one-line " + "comment describing the step for the user in plain language, max 60 chars " + "(e.g. `# Searching Amazon for paper towels`) — the UI shows it as the step label.\n\n" + "STATE: the browser session and workspace persist across calls; Python variables do NOT (fresh " + "interpreter each call). The workspace dir is $BH_AGENT_WORKSPACE (also `workspace` in every result); " + "functions defined in agent_helpers.py there are auto-imported into every call. For multi-item tasks " + "('all N products / every entry'), append each batch to a JSON/CSV file in the workspace, then read it " + "back and aggregate in code — dedupe/count/sort with Python, not in your head — and verify the " + "collected count against what was asked before answering.\n\n" + "Batch each sub-procedure (navigate, wait, extract, act) into one call — do not spend a call per " + "action — but for long extractions prefer several medium calls that append to workspace files over " + "one giant call, so progress survives timeouts." ) _HEADER_VISION = ( - " Screenshots are attached to your context automatically: when the exec " - "output contains a capture_screenshot() path, the image arrives with " - "this tool's result and you inspect it directly with your own vision — " - "never send browser screenshots to a separate vision tool." + " Screenshots are attached to your context automatically: when the exec output contains a " + "capture_screenshot() path, the image arrives with this tool's result and you inspect it directly " + "with your own vision — never send browser screenshots to a separate vision tool." ) _HEADER_TEXT_ONLY = ( - " Your model cannot view images, so work text-first: page_info() for " - "state, js() for reading/extracting DOM text, fill_input(selector, " - "text) for inputs, and js(\"document.querySelector('…').click()\") for " - "clicks — skip the screenshot-driven workflow described below." + " Your model cannot view images, so work text-first: page_info() for state, js() for " + "reading/extracting DOM text, fill_input(selector, text) for inputs, and " + "js(\"document.querySelector('…').click()\") for clicks — skip the screenshot-driven workflow described below." ) -# Appended when the local engine is Lightpanda (browser.engine): no graphical -# renderer, and one CDP connection holds one page — a second -# Target.createTarget fails with TargetAlreadyLoaded (drop the new_tab() -# sentence once lightpanda-io/browser#1962 lands). +# Appended when the local engine is Lightpanda: no graphical renderer, and one CDP +# connection holds one page — a second Target.createTarget fails with +# TargetAlreadyLoaded (drop the new_tab() sentence once lightpanda-io/browser#1962 lands). _HEADER_LIGHTPANDA = ( - " The local engine is Lightpanda (no graphical renderer, one page per " - "session): capture_screenshot() is unavailable, so work text-first; " - "navigate with new_tab(url) exactly once, then goto_url(url) for every " - "later navigation — a second new_tab() fails with TargetAlreadyLoaded." + " The local engine is Lightpanda (no graphical renderer, one page per session): capture_screenshot() " + "is unavailable, so work text-first; navigate with new_tab(url) exactly once, then goto_url(url) for " + "every later navigation — a second new_tab() fails with TargetAlreadyLoaded." ) # Pinned quick-reference for the CLI's pre-imported helpers, replacing the live -# ``browser-use skill`` fetch (which would ship uncontrolled third-party text into -# every schema: version drift, supply-chain exposure, byte-unstable prompt). A/B -# benchmarked: header-only matched the full skill dump at ~equal tokens. +# ``browser-use skill`` fetch (uncontrolled third-party text in every schema: version +# drift, supply-chain exposure, byte-unstable prompt). A/B benchmarked ~equal. _HELPERS_DIGEST = ( - "\n\nHELPERS (pre-imported): new_tab(url) opens/navigates (use for the " - "FIRST navigation), goto_url(url) navigates the current tab, " - "wait_for_load() after navigation, page_info() summarizes the current " - "page state, js(expr) evaluates a JS expression and returns its value " - "(js('document.title'); wrap function bodies as js('(() => {...})()') — " - "a bare '() => {...}' returns the function itself, uncalled), " - "fill_input(selector, text) types into inputs, click_at_xy(x, y) clicks " - "viewport coordinates, capture_screenshot() saves and prints a " - "screenshot path, cdp('Domain.method', **kwargs) is raw CDP — " - "cdp('Accessibility.getFullAXTree')['nodes'] lists every element's " - "role/name/backendDOMNodeId (filter in Python before printing; it is " - "thousands of nodes), then cdp('DOM.getBoxModel', backendNodeId=n) gives " - "click coordinates. ensure_real_tab() recovers from a stale/internal " - "tab. Login walls: stop and ask the user; never guess credentials." + "\n\nHELPERS (pre-imported): new_tab(url) opens/navigates (use for the FIRST navigation), goto_url(url) " + "navigates the current tab, wait_for_load() after navigation, page_info() summarizes the current page " + "state, js(expr) evaluates a JS expression and returns its value (js('document.title'); wrap function " + "bodies as js('(() => {...})()') — a bare '() => {...}' returns the function itself, uncalled), " + "fill_input(selector, text) types into inputs, click_at_xy(x, y) clicks viewport coordinates, " + "capture_screenshot() saves and prints a screenshot path, cdp('Domain.method', **kwargs) is raw CDP — " + "cdp('Accessibility.getFullAXTree')['nodes'] lists every element's role/name/backendDOMNodeId (filter " + "in Python before printing; it is thousands of nodes), then cdp('DOM.getBoxModel', backendNodeId=n) " + "gives click coordinates. ensure_real_tab() recovers from a stale/internal tab. Login walls: stop and " + "ask the user; never guess credentials." ) -# NOTE: browser_exec is additionally gated at tool-definition time — sessions whose -# toolsets lack ``terminal`` never see it (model_tools._compute_tool_definitions). -# The check_fn below only answers "is Browser Use mode configured"; surface policy -# lives with the session, not in the process-wide TTL-cached check_fn. - 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 + if _lazy_call("tools.browser_tool", "lightpanda_engine_status", (False, ""), + "lightpanda engine status unavailable")[0]: # no screenshots, whatever the model sees return _HEADER_BASE + _HEADER_TEXT_ONLY + _HEADER_LIGHTPANDA if _lazy_call("tools.vision_tools", "_should_use_native_vision_fast_path", False, ""): return _HEADER_BASE + _HEADER_VISION @@ -738,19 +644,17 @@ def _description_header() -> str: def _dynamic_schema_overrides() -> dict: overrides: dict = {"description": _description_header() + _HELPERS_DIGEST} - # ``local`` exists ONLY when the user consented to real-profile browsing — - # everyone else's schema carries zero extra surface. The caller memoizes on - # config.yaml mtime, so toggling consent applies next session, not mid-chat. + # ``local`` exists ONLY when the user consented to real-profile browsing — everyone + # else's schema carries zero extra surface. The caller memoizes on config.yaml mtime, + # so toggling consent applies next session, not mid-chat. if _real_profile_consented(): props = dict(BROWSER_EXEC_SCHEMA["parameters"]["properties"]) props["local"] = { - "type": "boolean", - "description": ("Drive the user's own local browser (a Hermes-managed copy of " - "their real default-Chromium profile, logins/cookies included) " - "instead of the configured cloud browser backend. Use when the " - "user asks to act as themselves — their accounts, their " - "sessions. No-op when the backend is already local. Default false."), - "default": False, + "type": "boolean", "default": False, + "description": ("Drive the user's own local browser (a Hermes-managed copy of their real " + "default-Chromium profile, logins/cookies included) instead of the configured " + "cloud browser backend. Use when the user asks to act as themselves — their " + "accounts, their sessions. No-op when the backend is already local. Default false."), } overrides["parameters"] = {**BROWSER_EXEC_SCHEMA["parameters"], "properties": props} return overrides @@ -758,38 +662,25 @@ def _dynamic_schema_overrides() -> dict: BROWSER_EXEC_SCHEMA = { "name": "browser_exec", - # Static fallback, used only when the CLI (and uvx) is unavailable - "description": ( - _HEADER_BASE - + _HELPERS_DIGEST - + "\n\n(The browser-use CLI is not installed yet. Install it with " - "`uv tool install browser-use`.)" - ), + # Static fallback description, used only when the CLI (and uvx) is unavailable + "description": (_HEADER_BASE + _HELPERS_DIGEST + + "\n\n(The browser-use CLI is not installed yet. Install it with `uv tool install browser-use`.)"), "parameters": { "type": "object", "properties": { - "code": { - "type": "string", - "description": "Python code to execute using the pre-imported browser helpers. Use print(...) for any data you need back.", - }, - "session": { - "type": "string", - "description": "Named isolated browser session — its own daemon and (on cloud backends) own browser, so concurrent tasks don't share tabs. Reuse the same name on every related call; omit for the shared default session.", - }, - "timeout_s": { - "type": "integer", - "description": f"Max seconds to wait for the code to finish (default {_DEFAULT_TIMEOUT_S}, max {_MAX_TIMEOUT_S}).", - "default": _DEFAULT_TIMEOUT_S, - }, + "code": {"type": "string", "description": "Python code to execute using the pre-imported browser helpers. Use print(...) for any data you need back."}, + "session": {"type": "string", "description": "Named isolated browser session — its own daemon and (on cloud backends) own browser, so concurrent tasks don't share tabs. Reuse the same name on every related call; omit for the shared default session."}, + "timeout_s": {"type": "integer", "default": _DEFAULT_TIMEOUT_S, + "description": f"Max seconds to wait for the code to finish (default {_DEFAULT_TIMEOUT_S}, max {_MAX_TIMEOUT_S})."}, }, "required": ["code"], }, } -# --------------------------------------------------------------------------- -# Registry -# --------------------------------------------------------------------------- +# browser_exec is additionally gated at tool-definition time — sessions whose toolsets +# lack ``terminal`` never see it (model_tools._compute_tool_definitions). check_fn only +# answers "is Browser Use mode configured"; surface policy lives with the session. from tools.registry import registry registry.register( @@ -797,10 +688,8 @@ registry.register( toolset="browser-use", schema=BROWSER_EXEC_SCHEMA, handler=lambda args, **kw: browser_exec( - code=args.get("code", ""), - session=args.get("session", "") or "", - timeout_s=args.get("timeout_s", _DEFAULT_TIMEOUT_S), - task_id=kw.get("task_id"), + code=args.get("code", ""), session=args.get("session", "") or "", + timeout_s=args.get("timeout_s", _DEFAULT_TIMEOUT_S), task_id=kw.get("task_id"), local=bool(args.get("local", False)), ), check_fn=is_browser_use_cli_mode,