refactor(browser): move cloud-provider/backend selection to tools/browser_tool_cloud.py and agent-browser/Chromium discovery+install to tools/browser_tool_install.py
This commit is contained in:
@@ -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."
|
||||
),
|
||||
|
||||
+41
-810
@@ -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.<name>)
|
||||
_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.<name>)
|
||||
_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: <name>``.
|
||||
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-<build>`` and
|
||||
# ``chromium_headless_shell-<build>``; 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
|
||||
# ============================================================================
|
||||
|
||||
@@ -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.<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.
|
||||
"""
|
||||
|
||||
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: <name>``.
|
||||
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",
|
||||
)
|
||||
@@ -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.<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()
|
||||
Reference in New Issue
Block a user