diff --git a/tests/test_managed_runtime_resolution.py b/tests/test_managed_runtime_resolution.py index e3943e351a..bb136069f4 100644 --- a/tests/test_managed_runtime_resolution.py +++ b/tests/test_managed_runtime_resolution.py @@ -92,7 +92,7 @@ _ALLOWED: dict[tuple[str, str], str] = { ("hermes_cli/main.py", "npm"): ( "Same _ensure_tui_node() gate as node." ), - ("tools/browser_tool.py", "npx"): ( + ("tools/browser_tool_install.py", "npx"): ( "agent-browser runs via `npx`, resolved against the extended browser " "PATH that _merge_browser_path() already seeds with the managed dirs." ), diff --git a/tools/browser_tool.py b/tools/browser_tool.py index 96bed4f8b5..1f584f1060 100644 --- a/tools/browser_tool.py +++ b/tools/browser_tool.py @@ -19,7 +19,6 @@ real-profile CDP, snapshot store); their names are re-imported here so """ import atexit -import functools import json import logging import os @@ -32,14 +31,14 @@ import time from typing import Dict, Any, Optional, List, Tuple, Union from pathlib import Path from agent.redact import redact_cdp_url -from hermes_constants import ( +from hermes_constants import ( # noqa: F401 (test-patchable surface, read via origin by sibling modules) agent_browser_runnable, get_hermes_home, get_hermes_home_override, hermes_home_key, node_tool_runnable, ) -from utils import env_int, is_truthy_value +from utils import env_int, is_truthy_value # noqa: F401 (read via origin by sibling modules) from hermes_cli.config import DEFAULT_CONFIG, cfg_get from hermes_cli._subprocess_compat import windows_hide_flags @@ -142,7 +141,7 @@ from plugins.browser.browser_use.provider import ( # noqa: F401 from plugins.browser.firecrawl.provider import ( # noqa: F401 FirecrawlBrowserProvider as FirecrawlProvider, ) -from tools.tool_backend_helpers import normalize_browser_cloud_provider +from tools.tool_backend_helpers import normalize_browser_cloud_provider # noqa: F401 (read via origin) # Camofox local anti-detection browser backend (optional). # When CAMOFOX_URL is set, all browser operations route through the # camofox REST API instead of the agent-browser CLI. @@ -176,51 +175,26 @@ _SANE_PATH_DIRS = ( _SANE_PATH = os.pathsep.join(_SANE_PATH_DIRS) -@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_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) - except OSError: - pass - return tuple(dirs) - - -def _browser_candidate_path_dirs() -> list[str]: - """Return ordered browser CLI PATH candidates shared by discovery and execution.""" - hermes_home = 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(_discover_homebrew_node_dirs()), *_SANE_PATH_DIRS] - - -def _merge_browser_path(existing_path: str = "") -> str: - """Prepend browser-specific PATH fallbacks without reordering existing entries.""" - 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 _browser_candidate_path_dirs(): - if not part or part in existing_parts or part in prefix_parts: - continue - if os.path.isdir(part): - prefix_parts.append(part) - - return os.pathsep.join(prefix_parts + path_parts) +from tools.browser_tool_install import ( # noqa: F401 (re-exported; tests patch tools.browser_tool.) + _discover_homebrew_node_dirs, + _browser_candidate_path_dirs, + _merge_browser_path, + _browser_install_hint, + _is_npx_agent_browser_sentinel, + _requires_real_termux_browser_install, + _termux_browser_install_error, + _agent_browser_candidate_present, + _resolve_npx_bin, + _agent_browser_candidates, + _find_agent_browser, + warm_agent_browser_npx_cache, + _chromium_search_roots, + _chromium_installed, + _maybe_autoinstall_chromium, + _running_in_docker, + check_browser_requirements, + check_browser_vision_requirements, +) # Throttle screenshot cleanup to avoid repeated full directory scans. _last_screenshot_cleanup_by_dir: dict[str, float] = {} @@ -655,190 +629,25 @@ _cached_browser_engine: Optional[str] = None _browser_engine_resolved = False -def _is_legacy_provider_registry_overridden() -> bool: - """True when a test has patched ``_PROVIDER_REGISTRY`` to a custom value. +from tools.browser_tool_cloud import ( # noqa: F401 (re-exported; tests patch tools.browser_tool.) + _is_legacy_provider_registry_overridden, + _ensure_browser_plugins_loaded, + _get_cloud_provider, + _instantiate_explicit_cloud_provider, + _autodetect_cloud_provider, + _resolve_cloud_provider_uncached, + _is_local_mode, + _is_local_backend, + _get_browser_engine, + _is_headed_mode, + _should_inject_engine, + _auto_local_for_private_urls, + _use_real_profile, + _allow_private_urls, + _resolve_allow_private_urls, +) - 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. - """ - try: - for key, default_cls in _DEFAULT_PROVIDER_REGISTRY.items(): - if _PROVIDER_REGISTRY.get(key) is not default_cls: - return True - # Extra keys not in the default registry → also an override. - return len(_PROVIDER_REGISTRY) != len(_DEFAULT_PROVIDER_REGISTRY) - 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. - """ - try: - from hermes_cli.plugins import _ensure_plugins_discovered - - _ensure_plugins_discovered() - except Exception as exc: - 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.""" - global _cached_cloud_provider, _cloud_provider_resolved - global _cached_cloud_provider_scope - - scope = hermes_home_key() - with _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. - if not _cloud_provider_resolved: - _cached_cloud_provider_scope = None - _cached_cloud_providers.clear() - while True: - before_generation = _browser_registry_generation(scope=scope) - cache_key = (scope, before_generation) - if cache_key in _cached_cloud_providers: - _cached_cloud_provider = _cached_cloud_providers[cache_key] - _cloud_provider_resolved = True - _cached_cloud_provider_scope = scope - return _cached_cloud_provider - - _cached_cloud_provider = None - _cloud_provider_resolved = False - resolved = _resolve_cloud_provider_uncached() - after_generation = _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. - continue - if _cloud_provider_resolved: - _cached_cloud_provider_scope = scope - for stale_key in [ - key for key in _cached_cloud_providers if key[0] == scope - ]: - _cached_cloud_providers.pop(stale_key, None) - _cached_cloud_providers[cache_key] = resolved - return resolved - - -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. - """ - try: - if _is_legacy_provider_registry_overridden(): - factory = _PROVIDER_REGISTRY.get(provider_key) - resolved = factory() if factory is not None else None - else: - _ensure_browser_plugins_loaded() - resolved = _registry_get_browser_provider(provider_key) - if resolved is None: - 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)", - )) - return resolved - except ValueError: - raise - except Exception: - 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. - - 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). - """ - try: - for cls in (BrowserUseProvider, BrowserbaseProvider): - fallback_provider = cls() - if fallback_provider.is_configured(): - return fallback_provider - except Exception: # pragma: no cover - defensive: never poison cache - logger.debug("Cloud provider auto-detect failed", exc_info=True) - return None - - -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. - """ - global _cached_cloud_provider, _cloud_provider_resolved - - resolved: Optional[CloudBrowserProvider] = None - provider_key = None - try: - from hermes_cli.config import read_raw_config - browser_cfg = read_raw_config().get("browser", {}) - if isinstance(browser_cfg, dict) and "cloud_provider" in browser_cfg: - provider_key = normalize_browser_cloud_provider(browser_cfg.get("cloud_provider")) - if provider_key in ("local", "camofox"): - # Camofox runs through the built-in browser tools, not a cloud provider. - _cached_cloud_provider = None - _cloud_provider_resolved = True - return None - if provider_key == "nous": - # Managed "Nous Subscription" is serviced by the Browser Use provider. - provider_key = "browser-use" - if provider_key: - resolved = _instantiate_explicit_cloud_provider(provider_key) - if resolved is None: - return None - 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. - logger.debug("Could not read cloud_provider from config: %s", e) - - if resolved is None and provider_key is None: - resolved = _autodetect_cloud_provider() - if resolved is None: - return None - - _cached_cloud_provider = resolved - _cloud_provider_resolved = True - return _cached_cloud_provider - - -from hermes_constants import is_termux as _is_termux_environment - - -def _browser_install_hint() -> str: - if _is_termux_environment(): - return "npm install -g agent-browser && agent-browser install" - return "npm install -g agent-browser && agent-browser install --with-deps" +from hermes_constants import is_termux as _is_termux_environment # noqa: F401 (read via origin) # Sentinel _find_agent_browser returns/caches to mean "resolve via npx" rather @@ -855,133 +664,14 @@ NPX_AGENT_BROWSER_SENTINEL = "npx agent-browser" AGENT_BROWSER_NPX_SPEC = "agent-browser@^0.26.0" -def _is_npx_agent_browser_sentinel(browser_cmd: str) -> bool: - return browser_cmd.strip() == NPX_AGENT_BROWSER_SENTINEL - - -def _requires_real_termux_browser_install(browser_cmd: str) -> bool: - return _is_termux_environment() and _is_local_mode() and _is_npx_agent_browser_sentinel(browser_cmd) - - -def _termux_browser_install_error() -> str: - return ( - "Local browser automation on Termux cannot rely on the bare npx fallback. " - f"Install agent-browser explicitly first: {_browser_install_hint()}" - ) - - -def _is_local_mode() -> bool: - """Return True when the browser tool will use a local browser backend.""" - if _get_cdp_override_raw(): - return False - return _get_cloud_provider() is None - - -def _is_local_backend() -> bool: - """Return 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. - """ - if _get_cdp_override_raw(): - return False - if _is_camofox_mode(): - return True - if _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. - from tools.terminal_scope import terminal_env - - terminal_backend = terminal_env("TERMINAL_ENV", "local").strip().lower() - return terminal_backend in ("local", "") - - _auto_local_for_private_urls_resolved = False _cached_auto_local_for_private_urls: bool = True -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). - """ - global _cached_browser_engine, _browser_engine_resolved - if _browser_engine_resolved: - return _cached_browser_engine - - _browser_engine_resolved = True - # Config file takes priority; env var only if config didn't set a value. - _cached_browser_engine = _browser_cfg( - "engine", "auto", - lambda v: str(v).strip().lower() if v and str(v).strip() else "auto", - "browser.engine from config", - ) - if _cached_browser_engine == "auto": - env_val = os.environ.get("AGENT_BROWSER_ENGINE", "").strip().lower() - if env_val: - _cached_browser_engine = env_val - - # Validate: agent-browser only accepts "chrome" and "lightpanda". - _VALID_ENGINES = {"auto", "lightpanda", "chrome"} - if _cached_browser_engine not in _VALID_ENGINES: - logger.warning( - "Unknown browser engine %r (valid: %s), falling back to 'auto'", - _cached_browser_engine, ", ".join(sorted(_VALID_ENGINES)), - ) - _cached_browser_engine = "auto" - - return _cached_browser_engine - - _cached_headed_mode: Optional[bool] = None _headed_mode_resolved = False -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. - """ - global _cached_headed_mode, _headed_mode_resolved - if _headed_mode_resolved: - return _cached_headed_mode # type: ignore[return-value] - - _headed_mode_resolved = True - _cached_headed_mode = _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 _cached_headed_mode: - env_val = os.environ.get("AGENT_BROWSER_HEADED", "").strip() - if env_val and env_val.lower() in ("true", "1", "yes"): - _cached_headed_mode = True - - return _cached_headed_mode - - -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``). - """ - if engine == "auto": - return False - if _is_camofox_mode(): - return False - return _is_local_mode() - - from tools.browser_tool_lightpanda_fallback import ( # noqa: F401 _using_lightpanda_engine, lightpanda_engine_status, @@ -994,37 +684,6 @@ from tools.browser_tool_lightpanda_fallback import ( # noqa: F401 ) -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. - """ - global _auto_local_for_private_urls_resolved, _cached_auto_local_for_private_urls - if _auto_local_for_private_urls_resolved: - return _cached_auto_local_for_private_urls - - _auto_local_for_private_urls_resolved = True - _cached_auto_local_for_private_urls = _browser_cfg( - "auto_local_for_private_urls", _cached_auto_local_for_private_urls, bool, - "auto_local_for_private_urls from config", - ) - return _cached_auto_local_for_private_urls - - -def _use_real_profile() -> bool: - """Return 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. - """ - return _browser_cfg("use_real_profile", False, bool, "use_real_profile from config") - - # Session name for the single shared real-profile copy-browser. All consented # local browsing attaches to this one agent-browser session so concurrent # tasks reuse the same copy-browser instead of each launching a rival Chromium @@ -1270,38 +929,6 @@ def _last_session_key(task_id: str) -> str: return task_id -def _allow_private_urls() -> bool: - """Return whether the browser is allowed to navigate to private/internal addresses. - - 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). - """ - global _cached_allow_private_urls, _allow_private_urls_resolved - - # The profile multiplexer scopes config with a ContextVar while sharing - # this module. Never reuse another profile's private-network opt-out. - if get_hermes_home_override() is not None: - return _resolve_allow_private_urls() - - if _allow_private_urls_resolved: - return _cached_allow_private_urls - - _allow_private_urls_resolved = True - _cached_allow_private_urls = _resolve_allow_private_urls() - return _cached_allow_private_urls - - -def _resolve_allow_private_urls() -> bool: - """Read the browser private-URL toggle from the active config scope.""" - return _browser_cfg( - "allow_private_urls", False, - lambda v: is_truthy_value(v, default=False), - "allow_private_urls from config", - ) - - def _socket_safe_tmpdir() -> str: """Short temp dir for Unix domain sockets. @@ -1886,190 +1513,6 @@ def _get_session_info(task_id: Optional[str] = None) -> Dict[str, Any]: return session_info -def _agent_browser_candidate_present(path: str | None) -> bool: - if not path: - return False - if " " in path and path.split()[0].endswith("npx"): - return True - return os.path.exists(path) and (os.name == "nt" or os.access(path, os.X_OK)) - - -def _resolve_npx_bin() -> Optional[str]: - """Resolve a runnable npx, preferring the Hermes-managed/Homebrew extended PATH. - - 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. - """ - extended_path = _merge_browser_path("") - if extended_path: - extended_npx = shutil.which("npx", path=extended_path) - if extended_npx and node_tool_runnable(extended_npx): - return extended_npx - npx_path = shutil.which("npx") - if npx_path and node_tool_runnable(npx_path): - return npx_path - return None - - -def _agent_browser_candidates(extended_path: str): - """Yield agent-browser lookup candidates in resolution order (lazily — each is a filesystem probe). - - 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. - """ - yield shutil.which("agent-browser") - if extended_path: - yield shutil.which("agent-browser", path=extended_path) - local_bin_dir = Path(__file__).parent.parent / "node_modules" / ".bin" - if local_bin_dir.is_dir(): - yield shutil.which("agent-browser", path=str(local_bin_dir)) - - -def _find_agent_browser(*, validate: bool = True) -> str: - """ - Find the agent-browser CLI executable. - - 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 - """ - global _cached_agent_browser, _agent_browser_resolved - if _agent_browser_resolved: - if _cached_agent_browser is None: - raise FileNotFoundError( - "agent-browser CLI not found (cached). Install it with: " - f"{_browser_install_hint()}\n" - "Or ensure npx is available in your PATH." - ) - return _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. - global _cached_agent_browser, _agent_browser_resolved - if validate: - _cached_agent_browser = candidate - _agent_browser_resolved = True - return candidate - - ok = agent_browser_runnable if validate else _agent_browser_candidate_present - extended_path = _merge_browser_path("") - for candidate in _agent_browser_candidates(extended_path): - if candidate and ok(candidate): - return _accept(candidate) - - # npx fallback (also searches the extended PATH) - if _resolve_npx_bin(): - return _accept(NPX_AGENT_BROWSER_SENTINEL) - - if not validate: - raise FileNotFoundError("agent-browser CLI not found") - - # Nothing found — try lazy installation before giving up. - 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(get_hermes_home() / "node_modules" / ".bin")), - shutil.which("agent-browser", path=str(get_hermes_home() / "node" / "bin")), - shutil.which("agent-browser", path=str(get_hermes_home() / "node")), - ] - for recheck in candidates: - if recheck and agent_browser_runnable(recheck): - return _accept(recheck) - except Exception: - pass - - _agent_browser_resolved = True - raise FileNotFoundError( - "agent-browser CLI not found. Install it with: " - f"{_browser_install_hint()}\n" - "Or ensure npx is available in your PATH." - ) - - -def warm_agent_browser_npx_cache(timeout: float = 60.0) -> bool: - """Best-effort pre-fetch of the agent-browser npm package via npx. - - 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. - """ - npx_bin = _resolve_npx_bin() - if not npx_bin: - return False - - env = _build_browser_env() - env["PATH"] = _merge_browser_path(env.get("PATH", "")) - - popen_kwargs: dict = { - "stdout": subprocess.PIPE, - "stderr": subprocess.PIPE, - "text": True, - "env": env, - "creationflags": windows_hide_flags(), - } - if os.name == "posix": - popen_kwargs["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", - AGENT_BROWSER_NPX_SPEC, - "--version", - ] - try: - proc = subprocess.Popen(cmd, stdin=subprocess.DEVNULL, **popen_kwargs) - except Exception: - return False - try: - proc.communicate(timeout=timeout) - return proc.returncode == 0 - except subprocess.TimeoutExpired: - _kill_process_tree(proc) - try: - proc.communicate(timeout=5) - except Exception: - pass - return False - except Exception: - _kill_process_tree(proc) - return False - - from tools.browser_tool_snapshot import ( # noqa: F401 _store_full_snapshot, _truncate_snapshot, @@ -3548,223 +2991,11 @@ def browser_vision(question: str, annotate: bool = False, task_id: Optional[str] _cached_chromium_installed: Optional[bool] = None -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] = [] - 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.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" - ) - 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. - - 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. - """ - global _cached_chromium_installed - if _cached_chromium_installed is not None: - return _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)): - _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") - ) - if system_chrome: - _cached_chromium_installed = True - return True - - # 3. Playwright browser cache (legacy — chromium-* / chromium_headless_shell-* dirs) - for root in _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-" - ): - _cached_chromium_installed = True - return True - - _cached_chromium_installed = False - return False - - # One-shot per process: a 170MB download that fails (or is slow) must not be # retried on every browser call. Reset by _reset_browser_caches() for tests. _chromium_autoinstall_attempted = False -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. - """ - global _chromium_autoinstall_attempted - if _chromium_autoinstall_attempted: - return _chromium_installed() - _chromium_autoinstall_attempted = True - - if _running_in_docker(): - return False - - from tools.lazy_deps import _allow_lazy_installs - if not _allow_lazy_installs(): - return False - - try: - browser_cmd = _find_agent_browser() - except FileNotFoundError: - return False - - if _is_npx_agent_browser_sentinel(browser_cmd): - install_cmd = [ - _resolve_npx_bin() or "npx", "--ignore-scripts", "-y", AGENT_BROWSER_NPX_SPEC, "install", - ] - else: - install_cmd = [browser_cmd, "install"] - - 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=_build_browser_env(), - ) - except (OSError, subprocess.SubprocessError) as e: - 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:] - logger.warning( - "browser: Chromium auto-install exited %s: %s", proc.returncode, tail - ) - return False - - global _cached_chromium_installed - _cached_chromium_installed = None - return _chromium_installed() - - -def _running_in_docker() -> bool: - """Best-effort detection of whether we're inside a Docker container.""" - if os.path.exists("/.dockerenv"): - return True - try: - with open("/proc/1/cgroup", "rt", encoding="utf-8") as fp: - return "docker" in fp.read() - except OSError: - return False - - -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). - """ - # 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. - if _is_browser_use_cli_mode(): - return False - - # Camofox backend — only needs the server URL, no agent-browser CLI - if _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. - if _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. - try: - browser_cmd = _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. - if _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. - provider = _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. - if _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. - return _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.""" - if not check_browser_requirements(): - return False - try: - from tools.vision_tools import check_vision_requirements - except ImportError: - return False - return check_vision_requirements() - - # ============================================================================ # Module Test # ============================================================================ diff --git a/tools/browser_tool_cloud.py b/tools/browser_tool_cloud.py new file mode 100644 index 0000000000..7621b8308d --- /dev/null +++ b/tools/browser_tool_cloud.py @@ -0,0 +1,368 @@ +"""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. +""" + +from __future__ import annotations + +import os +from typing import 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. + + 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. + """ + _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) + 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() + 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) + + +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. + if not _bt._cloud_provider_resolved: + _bt._cached_cloud_provider_scope = None + _bt._cached_cloud_providers.clear() + while True: + before_generation = _bt._browser_registry_generation(scope=scope) + cache_key = (scope, before_generation) + if cache_key in _bt._cached_cloud_providers: + _bt._cached_cloud_provider = _bt._cached_cloud_providers[cache_key] + _bt._cloud_provider_resolved = True + _bt._cached_cloud_provider_scope = scope + return _bt._cached_cloud_provider + + _bt._cached_cloud_provider = None + _bt._cloud_provider_resolved = False + 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. + 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 + ]: + _bt._cached_cloud_providers.pop(stale_key, None) + _bt._cached_cloud_providers[cache_key] = resolved + return resolved + + +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. + """ + _bt = _origin() + try: + if _bt._is_legacy_provider_registry_overridden(): + factory = _bt._PROVIDER_REGISTRY.get(provider_key) + resolved = factory() if factory is not None else None + else: + _bt._ensure_browser_plugins_loaded() + resolved = _bt._registry_get_browser_provider(provider_key) + if resolved is None: + 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)", + )) + 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, + ) + return None + + +def _autodetect_cloud_provider() -> Optional[CloudBrowserProvider]: + """Auto-detect: Browser Use (managed Nous gateway or API key), then Browserbase. + + 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). + """ + _bt = _origin() + try: + for cls in (_bt.BrowserUseProvider, _bt.BrowserbaseProvider): + fallback_provider = cls() + if fallback_provider.is_configured(): + return fallback_provider + except Exception: # pragma: no cover - defensive: never poison cache + _bt.logger.debug("Cloud provider auto-detect failed", exc_info=True) + return None + + +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. + """ + _bt = _origin() + + resolved: Optional[CloudBrowserProvider] = None + provider_key = None + try: + from hermes_cli.config import read_raw_config + browser_cfg = read_raw_config().get("browser", {}) + if isinstance(browser_cfg, dict) and "cloud_provider" in browser_cfg: + provider_key = _bt.normalize_browser_cloud_provider(browser_cfg.get("cloud_provider")) + if provider_key in ("local", "camofox"): + # Camofox runs through the built-in browser tools, not a cloud provider. + _bt._cached_cloud_provider = None + _bt._cloud_provider_resolved = True + return None + if provider_key == "nous": + # Managed "Nous Subscription" is serviced by the Browser Use provider. + provider_key = "browser-use" + if provider_key: + resolved = _bt._instantiate_explicit_cloud_provider(provider_key) + if resolved is None: + return None + 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. + _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 + + +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 + + +def _is_local_backend() -> bool: + """Return 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. + """ + _bt = _origin() + if _bt._get_cdp_override_raw(): + return False + if _bt._is_camofox_mode(): + 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. + from tools.terminal_scope import terminal_env + + terminal_backend = terminal_env("TERMINAL_ENV", "local").strip().lower() + return terminal_backend 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). + """ + _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 + + # 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 + + +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. + """ + _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 + + return _bt._cached_headed_mode + + +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``). + """ + _bt = _origin() + if engine == "auto": + return False + if _bt._is_camofox_mode(): + return False + return _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. + """ + _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 _bt._cached_auto_local_for_private_urls + + +def _use_real_profile() -> bool: + """Return 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. + """ + _bt = _origin() + return _bt._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. + + 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). + """ + _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 + + +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", + ) diff --git a/tools/browser_tool_install.py b/tools/browser_tool_install.py new file mode 100644 index 0000000000..9038b5f3b9 --- /dev/null +++ b/tools/browser_tool_install.py @@ -0,0 +1,490 @@ +"""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. +""" + +import functools +import os +import shutil +import subprocess +import sys +from pathlib import Path +from typing import List, Optional + +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_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) + except OSError: + pass + return tuple(dirs) + + +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] + + +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): + 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" + + +def _is_npx_agent_browser_sentinel(browser_cmd: str) -> bool: + _bt = _origin() + return browser_cmd.strip() == _bt.NPX_AGENT_BROWSER_SENTINEL + + +def _requires_real_termux_browser_install(browser_cmd: str) -> bool: + _bt = _origin() + return _bt._is_termux_environment() and _bt._is_local_mode() and _bt._is_npx_agent_browser_sentinel(browser_cmd) + + +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()}" + ) + + +def _agent_browser_candidate_present(path: str | None) -> bool: + if not path: + return False + if " " in path and path.split()[0].endswith("npx"): + return True + return os.path.exists(path) and (os.name == "nt" or os.access(path, os.X_OK)) + + +def _resolve_npx_bin() -> Optional[str]: + """Resolve a runnable npx, preferring the Hermes-managed/Homebrew extended PATH. + + 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. + """ + _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 + return None + + +def _agent_browser_candidates(extended_path: str): + """Yield agent-browser lookup candidates in resolution order (lazily — each is a filesystem probe). + + 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. + """ + yield shutil.which("agent-browser") + if extended_path: + yield shutil.which("agent-browser", path=extended_path) + local_bin_dir = Path(__file__).parent.parent / "node_modules" / ".bin" + if local_bin_dir.is_dir(): + yield shutil.which("agent-browser", path=str(local_bin_dir)) + + +def _find_agent_browser(*, validate: bool = True) -> str: + """ + Find the agent-browser CLI executable. + + 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 + """ + _bt = _origin() + 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." + ) + 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. + if validate: + _bt._cached_agent_browser = candidate + _bt._agent_browser_resolved = True + return candidate + + ok = _bt.agent_browser_runnable if validate else _bt._agent_browser_candidate_present + extended_path = _bt._merge_browser_path("") + 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") + + # Nothing found — try lazy installation before giving up. + 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: + 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." + ) + + +def warm_agent_browser_npx_cache(timeout: float = 60.0) -> bool: + """Best-effort pre-fetch of the agent-browser npm package via npx. + + 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. + """ + _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(), + } + if os.name == "posix": + popen_kwargs["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", + ] + try: + proc = subprocess.Popen(cmd, stdin=subprocess.DEVNULL, **popen_kwargs) + except Exception: + return False + 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: + _bt._kill_process_tree(proc) + 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] = [] + 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.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" + ) + 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. + + 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. + """ + _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") + ) + 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 + + +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. + """ + _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 + + 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"] + + _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(), + ) + 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 + ) + return False + + _bt._cached_chromium_installed = None + return _bt._chromium_installed() + + +def _running_in_docker() -> bool: + """Best-effort detection of whether we're inside a Docker container.""" + if os.path.exists("/.dockerenv"): + return True + try: + with open("/proc/1/cgroup", "rt", encoding="utf-8") as fp: + return "docker" in fp.read() + except OSError: + return False + + +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). + """ + # 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() + if _bt._is_browser_use_cli_mode(): + return False + + # Camofox backend — 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. + 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. + 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. + 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. + 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. + 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. + 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(): + return False + try: + from tools.vision_tools import check_vision_requirements + except ImportError: + return False + return check_vision_requirements()