491 lines
19 KiB
Python
491 lines
19 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``; every name is re-imported there so
|
|
``tools.browser_tool.<name>`` 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-<build>`` and
|
|
# ``chromium_headless_shell-<build>``; 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()
|