"""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``. Facade-owned state is read through ``_bt`` (``tools.browser_tool``, resolved per call) — no import cycle.""" import contextlib import functools import os import shutil import subprocess import sys from pathlib import Path from typing import List, Optional from hermes_cli._subprocess_compat import windows_hide_flags from hermes_constants import agent_browser_runnable, get_hermes_home, is_termux as _is_termux_environment, node_tool_runnable from tools.browser_tool_origin import origin_module as _origin from tools import browser_tool_cdp as _cdp from tools import browser_tool_cloud as _cloud from tools import browser_tool_lifecycle as _lifecycle from tools import browser_tool_lightpanda_fallback as _lp @functools.lru_cache(maxsize=1) def _discover_homebrew_node_dirs() -> tuple[str, ...]: """Homebrew versioned Node bin dirs (node@20, ...) that ``brew`` may not link into /opt/homebrew/bin.""" homebrew_opt = "/opt/homebrew/opt" try: entries = os.listdir(homebrew_opt) if os.path.isdir(homebrew_opt) else [] except OSError: 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() home = get_hermes_home() managed = (home / "node" / "bin", home / "node", home / "node_modules" / ".bin") return [*map(str, managed), *_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.""" path_parts = [p for p in (existing_path or "").split(os.pathsep) if p] prefix_parts: list[str] = [] for part in _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: return "npm install -g agent-browser && agent-browser install" + ("" if _is_termux_environment() else " --with-deps") def _is_npx_agent_browser_sentinel(browser_cmd: str) -> bool: return browser_cmd.strip() == _origin().NPX_AGENT_BROWSER_SENTINEL def _requires_real_termux_browser_install(browser_cmd: str) -> bool: return _is_termux_environment() and _cloud._is_local_mode() and _is_npx_agent_browser_sentinel(browser_cmd) def _termux_browser_install_error() -> str: return f"Local browser automation on Termux cannot rely on the bare npx fallback. Install agent-browser explicitly first: {_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, extended (Hermes-managed/Homebrew) PATH first. 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. """ extended_path = _merge_browser_path("") for path in ([extended_path] if extended_path else []) + [None]: npx = shutil.which("npx", path=path) if npx and node_tool_runnable(npx): return npx return None def _agent_browser_candidates(extended_path: str): """Yield agent-browser lookup candidates lazily: ambient PATH → extended PATH → repo-local node_modules/.bin. 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: 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: PATH, Homebrew/managed dirs, local node_modules/.bin, npx fallback, lazy install. 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: " f"{_browser_install_hint()}\nOr ensure npx is available in your PATH.") def _accept(candidate: str) -> str: # 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 return candidate if _bt._agent_browser_resolved: if _bt._cached_agent_browser is None: raise _not_found(cached=True) return _bt._cached_agent_browser 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(_bt.NPX_AGENT_BROWSER_SENTINEL) if not validate: raise FileNotFoundError("agent-browser CLI not found") try: # Nothing found — try lazy installation before giving up. from hermes_cli.dep_ensure import ensure_dependency if ensure_dependency("browser"): home = 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 agent_browser_runnable(recheck): return _accept(recheck) except Exception: pass _bt._agent_browser_resolved = True 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 (``hermes update`` / ``doctor --fix``). 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. agent-browser is no longer a root package.json dependency (#43564) — it resolves lazily via ``npx agent-browser`` instead, which keeps it out of the npm workspace install graph entirely (nothing to prune it anymore) but means the first real invocation in a session would otherwise pay npx's registry-lookup/fetch cost. Calling this during ``hermes update`` (or ``hermes doctor --fix``) warms npx's own cache ahead of time, restoring the "available before any session starts" property agent-browser had while it was an eager root dependency — without re-entangling it with the workspace graph. """ _bt = _origin() npx_bin = _resolve_npx_bin() if not npx_bin: return False env = _bt._build_browser_env() env["PATH"] = _merge_browser_path(env.get("PATH", "")) popen_kwargs: dict = {"stdout": subprocess.PIPE, "stderr": subprocess.PIPE, "text": True, "env": env} if os.name == "posix": popen_kwargs.update(creationflags=windows_hide_flags(), start_new_session=True) else: popen_kwargs["creationflags"] = 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: return False try: proc.communicate(timeout=timeout) return proc.returncode == 0 except Exception as exc: _lifecycle._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]: """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() 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") roots.append(os.path.join(local, "ms-playwright")) return roots 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 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 ab_path = os.environ.get("AGENT_BROWSER_EXECUTABLE_PATH", "").strip() _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 _chromium_search_roots()) ) 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. 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 _chromium_installed() _bt._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 install_cmd = [browser_cmd, "install"] if _is_npx_agent_browser_sentinel(browser_cmd): install_cmd = [_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)") try: proc = subprocess.run(install_cmd, capture_output=True, text=True, encoding='utf-8', errors='replace', timeout=600, env=_bt._build_browser_env(), stdin=subprocess.DEVNULL) 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 _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). """ _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 only needs the server URL, no agent-browser CLI. if _bt._is_camofox_mode(): return True # 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 _cdp._get_cdp_override_raw(): return True # Do not exec ``agent-browser --version`` here: Windows .cmd shims flash a console during Desktop startup. Execution paths still validate. try: browser_cmd = _find_agent_browser(validate=False) except FileNotFoundError: return False # Termux: the bare npx fallback is too fragile to advertise as a satisfied local dependency. if _requires_real_termux_browser_install(browser_cmd): return False # Cloud mode also requires provider credentials; no local Chromium needed. provider = _cloud._get_cloud_provider() if provider is not None: return provider.is_available() # Lightpanda provides text/navigation tools without Chromium; screenshots/vision still return install errors. if _lp._using_lightpanda_engine(): return True # Local Chrome mode needs Chromium on disk or the CLI hangs until the command timeout. return _chromium_installed() def check_browser_vision_requirements() -> bool: """Advertise ``browser_vision`` only with BOTH a working browser AND a vision backend. Without the vision check, the tool stays in the model's tool list even when no vision provider is configured, then fails at call time with a cryptic provider-side error like ``unknown variant `image_url`, expected `text``` (issue #31179). """ if not check_browser_requirements(): return False from tools.vision_tools import check_vision_requirements return check_vision_requirements()