The Desktop serve backend polls /api/fs/default-cwd, whose git-branch probe captured git output with text=True and no encoding. subprocess then decodes with locale.getencoding() - cp936 on zh-CN Windows - so a UTF-8 branch name or git's localized stderr raised UnicodeDecodeError inside communicate()'s _readerthread on every poll ([gateway-crash] ... 'gbk' codec can't decode). Pin encoding='utf-8', errors='replace' on the remaining in-process captures whose children emit UTF-8: the git branch probe, the self-repo guard's git alias read, the Bot Mode DM transport (a Hermes CLI child, stdio forced to UTF-8; builds on the salvaged errors='replace'), the agent-browser npx probe, the lightpanda help probe, and the cua-driver stderr drain thread. Fixes #83851
341 lines
16 KiB
Python
341 lines
16 KiB
Python
"""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,
|
|
"encoding": "utf-8", "errors": "replace", "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()
|