Migrate callers to declared extra names instead of adding legacy alias maps. Preserve lazy-install refusal behavior, isolate reimported test homes, and exercise the real callback boundaries. The relevant integrated batch passes 607 tests.
342 lines
16 KiB
Python
342 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, "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 the provisioned Playwright 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(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 pm import lazy_installs_allowed
|
|
if not lazy_installs_allowed():
|
|
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-sig") 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
|
|
try:
|
|
from tools.vision_tools import check_vision_requirements
|
|
except ImportError:
|
|
return False
|
|
return check_vision_requirements()
|