mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-11-06 05:17:15 +00:00
refactor(setup): browser setup, doctor and status surfaces drop agent-browser and core Camofox
hermes tools: the Camofox and separate Browser Use rows and the agent_browser,
browser_use_cli and camofox post-setup hooks are gone; Local Browser's hook is
chromium (pm.ensure("chromium"); Termux and the Docker image get a hint).
Provider picker logic keyed on browser.backend is removed. Doctor's agent-browser
rows become one Chromium row via chromium_executable(); doctor --live, Nous
subscription browser checks and setup summary rows follow.
This commit is contained in:
1 parent
1ac18d2b12
commit
934f4ca1f5
19 files changed
+312
-568
No files matched your search
+13
-21
@@ -53,31 +53,23 @@ def _http_get(url: str, headers: Optional[dict] = None, timeout: Optional[float]
|
||||
|
||||
|
||||
def _browser_available() -> bool:
|
||||
"""Is the local browser automation backend (agent-browser) installed?"""
|
||||
try:
|
||||
from tools.browser_tool_install import _find_agent_browser
|
||||
return bool(_find_agent_browser(validate=False))
|
||||
except Exception:
|
||||
return False
|
||||
"""Is there a Chromium for browser_exec's local launcher?"""
|
||||
return chromium_executable() is not None
|
||||
|
||||
|
||||
def _launch_browser_probe(timeout: float) -> tuple:
|
||||
"""Launch a browser, open about:blank, close. Returns (ok, detail). Uses Playwright directly (what
|
||||
agent-browser drives underneath) so the probe owns the full lifecycle and always cleans up."""
|
||||
"""Launch Chromium through browser_exec's own launcher, wait for its CDP endpoint, stop it.
|
||||
Returns (ok, detail); the probe owns the whole lifecycle and always cleans up."""
|
||||
from tools.browser_launcher import launch_chromium, stop_browser
|
||||
|
||||
key = "doctor-live-probe"
|
||||
try:
|
||||
from playwright.sync_api import sync_playwright
|
||||
except ImportError:
|
||||
return (False, "playwright not installed")
|
||||
with sync_playwright() as p:
|
||||
browser = p.chromium.launch(
|
||||
channel="chromium", executable_path=chromium_executable(),
|
||||
headless=True, timeout=timeout * 1000,
|
||||
)
|
||||
try:
|
||||
browser.new_page().goto("about:blank", timeout=timeout * 1000)
|
||||
finally:
|
||||
browser.close()
|
||||
return (True, "launched + about:blank + closed")
|
||||
browser, error = launch_chromium(key, ephemeral=True, timeout=timeout)
|
||||
finally:
|
||||
stop_browser(key)
|
||||
if browser is None:
|
||||
return (False, error or "launch failed")
|
||||
return (True, "launched + CDP ready + stopped")
|
||||
|
||||
|
||||
def _probe_mcp_server(name: str, config: dict, timeout: float):
|
||||
|
||||
+44
-74
@@ -1,4 +1,4 @@
|
||||
"""External-tool checks for hermes doctor: terminal backends, git/rg, Node + agent-browser, npm audit, tool availability.
|
||||
"""External-tool checks for hermes doctor: terminal backends, git/rg, Node + Chromium, npm audit, tool availability.
|
||||
Split out of ``hermes_cli/doctor.py``, which re-exports every name so ``hermes_cli.doctor.<name>`` keeps resolving (and monkeypatching)."""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -82,12 +82,6 @@ def _run_ok(cmd: list[str], timeout: int, **kw) -> bool:
|
||||
return False
|
||||
|
||||
|
||||
def _termux_browser_setup_steps(node_installed: bool) -> list[str]:
|
||||
steps = [] if node_installed else ["pkg install nodejs"]
|
||||
steps += ["npm install -g agent-browser", "agent-browser install"]
|
||||
return [f"{i}) {step}" for i, step in enumerate(steps, 1)]
|
||||
|
||||
|
||||
_TERMUX_INSTALL_ALL_FALLBACK_NOTES = (
|
||||
"Termux uses the Hermes APT package: pkg install hermes-agent.",
|
||||
"Matrix E2EE extra is excluded on Termux (python-olm currently fails to build).",
|
||||
@@ -317,70 +311,51 @@ def _check_terminal_backend(should_fix: bool, f: Finding) -> None:
|
||||
_check_plugin_backend(terminal_env, f.issues)
|
||||
|
||||
|
||||
def _check_agent_browser(should_fix: bool) -> bool:
|
||||
"""Read the runtime's installed selection; only --fix may acquire through PM."""
|
||||
try:
|
||||
from tools.browser_tool_install import _find_agent_browser
|
||||
resolved = _find_agent_browser(validate=False)
|
||||
except Exception:
|
||||
resolved = None
|
||||
if not resolved and should_fix and not _is_termux():
|
||||
def _chromium_not_needed_reason() -> str:
|
||||
"""Why this profile's config needs no local Chromium (``""`` when it does). Config-only, no I/O: a CDP
|
||||
override attaches to the user's browser, Lightpanda is its own engine, and a cloud provider hosts one."""
|
||||
from hermes_cli.config import cfg_get, load_config_readonly
|
||||
|
||||
config = load_config_readonly()
|
||||
if os.environ.get("BROWSER_CDP_URL") or cfg_get(config, "browser", "cdp_url"):
|
||||
return "a CDP override is set"
|
||||
if str(cfg_get(config, "browser", "engine") or "").strip().lower() == "lightpanda":
|
||||
return "browser.engine is lightpanda"
|
||||
provider = str(cfg_get(config, "browser", "cloud_provider") or "").strip().lower()
|
||||
return f"cloud provider {provider} is selected" if provider not in ("", "local") else ""
|
||||
|
||||
|
||||
def _check_chromium(should_fix: bool) -> None:
|
||||
"""The Chromium browser_exec launches for a local session (``chromium_executable()``: browser.executable_path,
|
||||
pm, the Docker image's baked build, a Termux system build). Only --fix may acquire it through PM."""
|
||||
import pm
|
||||
from hermes_cli.browser_runtime import chromium_executable, chromium_missing_hint
|
||||
|
||||
found = chromium_executable()
|
||||
if not found and should_fix and not _is_termux():
|
||||
try:
|
||||
import pm
|
||||
from tools.browser_tool_install import _find_agent_browser
|
||||
pm.ensure("agent-browser", explicit=True)
|
||||
resolved = _find_agent_browser(validate=False)
|
||||
except Exception as exc:
|
||||
check_warn("agent-browser install failed", f"({exc})")
|
||||
if resolved:
|
||||
check_ok("agent-browser", f"({resolved})")
|
||||
return True
|
||||
if _is_termux():
|
||||
_termux_browser_hints("agent-browser is not installed (expected in the tested Termux path)",
|
||||
"Install it manually later with: npm install -g agent-browser && agent-browser install", node_installed=True)
|
||||
pm.ensure("chromium", explicit=True)
|
||||
except (RuntimeError, OSError) as exc: # pm.InstallError, a download or worker failure
|
||||
check_warn("Chromium install failed", f"({exc})")
|
||||
found = chromium_executable()
|
||||
if found:
|
||||
check_ok("Chromium", f"({found})")
|
||||
elif reason := _chromium_not_needed_reason():
|
||||
check_info(f"Chromium not installed (not needed: {reason})")
|
||||
else:
|
||||
check_warn("agent-browser not installed", "(run: hermes pm install agent-browser)")
|
||||
return False
|
||||
|
||||
|
||||
def _termux_browser_hints(*lines: str, node_installed: bool) -> None:
|
||||
for line in lines:
|
||||
check_info(line)
|
||||
check_info("Termux browser setup:")
|
||||
for step in _termux_browser_setup_steps(node_installed=node_installed):
|
||||
check_info(step)
|
||||
|
||||
|
||||
def _check_chromium() -> None:
|
||||
"""Playwright Chromium presence, using the exact predicate browser_tool uses to hide browser_* tools.
|
||||
|
||||
Lazy import: browser_tool is ~150KB; an import failure is a separate bug surfaced elsewhere. Camofox, a
|
||||
CDP override, a cloud provider, or Lightpanda all bypass the local Chromium requirement (no warning).
|
||||
"""
|
||||
try:
|
||||
from tools.browser_tool import _is_camofox_mode
|
||||
from tools.browser_tool_cloud import _get_cloud_provider
|
||||
from tools.browser_tool_cdp import _get_cdp_override_raw
|
||||
from tools.browser_tool_install import _chromium_installed
|
||||
from tools.browser_tool_lightpanda_fallback import _using_lightpanda_engine
|
||||
except Exception:
|
||||
return
|
||||
if _is_camofox_mode() or bool(_get_cdp_override_raw()) or _get_cloud_provider() is not None or _using_lightpanda_engine():
|
||||
return
|
||||
if not check_bool(_chromium_installed(), ("Playwright Chromium", "(browser engine)"),
|
||||
("Playwright Chromium not installed", "(browser_* tools will be hidden from the agent)")):
|
||||
check_info("Install with: hermes pm install chromium")
|
||||
check_warn("Chromium not installed", "(browser_exec cannot start a local browser)")
|
||||
check_info(chromium_missing_hint())
|
||||
|
||||
|
||||
def _check_lightpanda() -> None:
|
||||
"""Lightpanda engine (browser.engine / AGENT_BROWSER_ENGINE); independent of Node since Browser Use mode spawns ``lightpanda serve`` itself."""
|
||||
"""Lightpanda engine (browser.engine); browser_exec spawns ``lightpanda serve`` itself."""
|
||||
try:
|
||||
from tools.browser_tool_lightpanda_fallback import _using_lightpanda_engine, lightpanda_engine_status
|
||||
from tools.browser_lightpanda import LIGHTPANDA_INSTALL_HINT, find_lightpanda_binary
|
||||
except Exception:
|
||||
from tools.browser_lightpanda import (
|
||||
LIGHTPANDA_INSTALL_HINT, find_lightpanda_binary, lightpanda_engine_status, using_lightpanda_engine)
|
||||
except ImportError: # a broken browser module is surfaced by the tool-availability rows
|
||||
return
|
||||
# _using_lightpanda_engine() is a cached config read — a failure there is exceptional, not hidden.
|
||||
if not _using_lightpanda_engine():
|
||||
# using_lightpanda_engine() is a cached config read — a failure there is exceptional, not hidden.
|
||||
if not using_lightpanda_engine():
|
||||
return
|
||||
try:
|
||||
used, reason = lightpanda_engine_status()
|
||||
@@ -396,17 +371,15 @@ def _check_lightpanda() -> None:
|
||||
|
||||
@doctor_check()
|
||||
def _check_node_and_browser(should_fix: bool, f: Finding) -> None:
|
||||
"""Node.js, agent-browser resolution, Playwright Chromium, Lightpanda engine."""
|
||||
"""Node.js, the Chromium browser_exec launches, Lightpanda engine."""
|
||||
# Only PM's Node counts; Termux's APT distribution is the one that relies on `pkg install nodejs`.
|
||||
if _pm_tool_path("node") or (_is_termux() and _safe_which("node")):
|
||||
check_ok("Node.js")
|
||||
elif _is_termux():
|
||||
_termux_browser_hints("Node.js not found (browser tools are optional in the tested Termux path)",
|
||||
"Install Node.js on Termux with: pkg install nodejs", node_installed=False)
|
||||
check_info("Node.js not found (optional; install on Termux with: pkg install nodejs)")
|
||||
else:
|
||||
check_warn("Node.js not found", "(optional; PM agent-browser is a native executable)")
|
||||
if _check_agent_browser(should_fix) and not _is_termux():
|
||||
_check_chromium()
|
||||
check_warn("Node.js not found", "(optional; run: hermes pm install node)")
|
||||
_check_chromium(should_fix)
|
||||
_check_lightpanda()
|
||||
|
||||
|
||||
@@ -482,10 +455,7 @@ def _check_npm_audit(should_fix: bool, f: Finding) -> None:
|
||||
except Exception:
|
||||
whatsapp_bridge_dir = PROJECT_ROOT / "scripts" / "whatsapp-bridge"
|
||||
for npm_dir, label, audit_extra in (
|
||||
# NOT agent-browser: it is now a pm-managed precompiled binary
|
||||
# (see AgentBrowser in pm/packages.py) with no node_modules/lockfile
|
||||
# of its own to audit. This row is the root package.json itself
|
||||
# (js-yaml, semver, eslint tooling). See #122223.
|
||||
# The root package.json itself (js-yaml, semver, eslint tooling). See #122223.
|
||||
(PROJECT_ROOT, "Root npm package", ["--workspaces=false"]),
|
||||
(PROJECT_ROOT, "web workspace", ["--workspace", "web"]),
|
||||
(PROJECT_ROOT, "ui-tui workspace", ["--workspace", "ui-tui"]),
|
||||
|
||||
@@ -61,7 +61,7 @@ _FEATURES: dict[str, _FeatureSpec] = {
|
||||
),
|
||||
"browser": _FeatureSpec(
|
||||
"Browser automation", True, "browser-use", "browser-use", ("browser", "cloud_provider"),
|
||||
"Browser automation (Browser Use)", "Browser Use/Browserbase key or Camofox",
|
||||
"Browser automation (Browser Use)", "Browser Use/Browserbase key",
|
||||
("BROWSER_USE_API_KEY", "BROWSERBASE_API_KEY"),
|
||||
),
|
||||
"modal": _FeatureSpec("Modal execution", False, "modal", "modal"),
|
||||
@@ -184,36 +184,31 @@ def _toolset_enabled(config: dict[str, object], toolset_key: str) -> bool:
|
||||
return False
|
||||
|
||||
|
||||
def _has_agent_browser() -> bool:
|
||||
# Read the runtime's choice; a broken resolver is not permission to
|
||||
# advertise an unchecked binary through a second discovery ladder.
|
||||
try:
|
||||
from tools.browser_tool_install import _find_agent_browser
|
||||
_find_agent_browser(validate=False)
|
||||
except (ImportError, OSError):
|
||||
return False
|
||||
return True
|
||||
def _has_browser_harness() -> bool:
|
||||
"""browser_exec's engine (browser-harness, a core venv dependency) is importable: the only local
|
||||
requirement for a cloud browser provider, which hosts its own Chromium."""
|
||||
from tools.browser_use_cli import _find_cli
|
||||
|
||||
return _find_cli() is not None
|
||||
|
||||
|
||||
def _local_browser_runnable() -> bool:
|
||||
"""True when the *local* browser backend would actually start: the CLI must be present AND a
|
||||
Chromium build on disk (else agent-browser hangs until the command timeout) unless the Lightpanda
|
||||
engine is selected. Mirrors the local-mode tail of tools.browser_tool_install.check_browser_requirements."""
|
||||
if not _has_agent_browser():
|
||||
def _local_browser_runnable(browser_cfg: dict[str, object]) -> bool:
|
||||
"""True when the *local* browser would actually start: browser-harness plus the engine it attaches
|
||||
to — ``lightpanda serve`` when ``browser.engine`` selects it, else a Chromium the launcher can run."""
|
||||
if not _has_browser_harness():
|
||||
return False
|
||||
try:
|
||||
from tools.browser_tool_install import _chromium_installed
|
||||
from tools.browser_tool_lightpanda_fallback import _using_lightpanda_engine
|
||||
except Exception:
|
||||
return True # runtime probe unavailable: fall back to binary presence rather than crashing
|
||||
return _using_lightpanda_engine() or _chromium_installed()
|
||||
if str(browser_cfg.get("engine") or "auto").strip().lower() == "lightpanda":
|
||||
return True
|
||||
from hermes_cli.browser_runtime import chromium_executable
|
||||
|
||||
return chromium_executable() is not None
|
||||
|
||||
|
||||
# kind -> (default provider, provider -> display label)
|
||||
_PROVIDER_LABELS = {
|
||||
"browser": ("local", {
|
||||
"browserbase": "Browserbase", "browser-use": "Browser Use", "firecrawl": "Firecrawl",
|
||||
"camofox": "Camofox", "local": "Local browser",
|
||||
"local": "Local browser",
|
||||
}),
|
||||
"tts": ("edge", {
|
||||
"openai": "OpenAI TTS", "elevenlabs": "ElevenLabs", "edge": "Edge TTS", "xai": "xAI TTS",
|
||||
@@ -359,33 +354,27 @@ def _audio_features(
|
||||
|
||||
|
||||
def _browser_feature(
|
||||
browser_cfg: dict[str, object], tool_enabled: bool, managed: bool, selected: Optional[str], browser_gw: bool, direct_firecrawl: bool,
|
||||
browser_cfg: dict[str, object], tool_enabled: bool, managed: bool, browser_gw: bool, direct_firecrawl: bool,
|
||||
) -> NousFeatureState:
|
||||
"""Resolve browser availability using the same precedence as runtime."""
|
||||
explicit = "cloud_provider" in browser_cfg
|
||||
provider = normalize_browser_cloud_provider(browser_cfg.get("cloud_provider") if explicit else None)
|
||||
if provider == "nous" or browser_gw:
|
||||
provider = "browser-use"
|
||||
# CAMOFOX_URL is the server address, not a selection: an explicit different choice wins over it.
|
||||
direct_camofox = _any_env("CAMOFOX_URL") and (selected is None or selected == "camofox")
|
||||
direct_browserbase = bool(get_env_value("BROWSERBASE_API_KEY") and get_env_value("BROWSERBASE_PROJECT_ID")) and not browser_gw
|
||||
direct_browser_use = _any_env("BROWSER_USE_API_KEY") and not browser_gw
|
||||
# local_available = the agent-browser CLI is present, the only local requirement for cloud providers.
|
||||
local_available = _has_agent_browser()
|
||||
local_runnable = _local_browser_runnable()
|
||||
local_available = _has_browser_harness()
|
||||
local_runnable = _local_browser_runnable(browser_cfg)
|
||||
browser_use_managed = bool(tool_enabled and local_available and managed and not direct_browser_use)
|
||||
|
||||
if explicit:
|
||||
cloud_available = {
|
||||
"camofox": direct_camofox, "browserbase": local_available and direct_browserbase,
|
||||
"browserbase": local_available and direct_browserbase,
|
||||
"browser-use": local_available and (managed or direct_browser_use), "firecrawl": local_available and direct_firecrawl,
|
||||
}
|
||||
current = provider if provider in cloud_available else "local"
|
||||
available = bool(cloud_available.get(current, local_runnable))
|
||||
managed_now = browser_use_managed if current == "browser-use" else False
|
||||
# Never-configured autodetect: CAMOFOX_URL activates Camofox when no selection was stored.
|
||||
elif direct_camofox:
|
||||
current, available, managed_now = "camofox", True, False
|
||||
elif managed or direct_browser_use:
|
||||
current, available, managed_now = "browser-use", bool(local_available), browser_use_managed
|
||||
elif direct_browserbase:
|
||||
@@ -464,7 +453,7 @@ def get_nous_subscription_features(config: Optional[dict[str, object]] = None, *
|
||||
"tts": tts,
|
||||
"stt": stt,
|
||||
"browser": _browser_feature(
|
||||
_section(config, "browser"), enabled["browser"], managed["browser"], selected["browser"], use_gateway["browser"], direct_firecrawl,
|
||||
_section(config, "browser"), enabled["browser"], managed["browser"], use_gateway["browser"], direct_firecrawl,
|
||||
),
|
||||
"modal": _modal_feature(_section(config, "terminal"), enabled["terminal"], managed["modal"], managed_tools_flag),
|
||||
}
|
||||
@@ -524,7 +513,7 @@ def apply_nous_managed_defaults(config: dict[str, object], *, enabled_toolsets:
|
||||
|
||||
|
||||
def _get_gateway_direct_credentials() -> dict[str, bool]:
|
||||
"""tool_key -> has_direct_credentials. Env-configured keyless local backends (SearXNG, CAMOFOX_URL)
|
||||
"""tool_key -> has_direct_credentials. Env-configured keyless local backends (SearXNG)
|
||||
count as configured so they are never classified "unconfigured" and pre-checked; Whisper shares
|
||||
the audio key with TTS."""
|
||||
fal_direct = fal_key_is_configured()
|
||||
@@ -539,7 +528,7 @@ def _get_gateway_direct_credentials() -> dict[str, bool]:
|
||||
"tts": audio_direct or _any_env("ELEVENLABS_API_KEY"),
|
||||
"stt": audio_direct or _any_env("GROQ_API_KEY", "MISTRAL_API_KEY"),
|
||||
"browser": (
|
||||
_any_env("BROWSER_USE_API_KEY", "CAMOFOX_URL")
|
||||
_any_env("BROWSER_USE_API_KEY")
|
||||
or bool(get_env_value("BROWSERBASE_API_KEY") and get_env_value("BROWSERBASE_PROJECT_ID"))
|
||||
),
|
||||
}
|
||||
|
||||
@@ -30,7 +30,7 @@ _SCOPED_PROVIDER_REGISTRARS: tuple[tuple[str, str, str, str, str, str, dict[str,
|
||||
"agent.browser_provider:BrowserProvider", "browser provider",
|
||||
"Register an :class:`agent.browser_provider.BrowserProvider`; "
|
||||
"``provider.name`` is matched by ``browser.cloud_provider`` (consulted by "
|
||||
"``tools.browser_tool_cloud._get_cloud_provider``).", {}),
|
||||
"``tools.browser_session_cloud.get_cloud_provider``).", {}),
|
||||
("register_terminal_environment_provider", "terminal_environment_provider",
|
||||
"agent.terminal_env_registry", "agent.terminal_env_provider:TerminalEnvironmentProvider",
|
||||
"terminal environment provider",
|
||||
|
||||
@@ -25,11 +25,10 @@ _STT_SUMMARY_DEFAULT = ("Local Whisper", "faster_whisper", "run 'hermes tools'
|
||||
|
||||
# Browser "missing" hint keyed by the configured provider; anything else gets the generic hint.
|
||||
_BROWSER_MISSING_HINTS = {
|
||||
"Browserbase": "npm install -g agent-browser and set BROWSERBASE_API_KEY/BROWSERBASE_PROJECT_ID",
|
||||
"Browser Use": "npm install -g agent-browser and set BROWSER_USE_API_KEY",
|
||||
"Camofox": "CAMOFOX_URL",
|
||||
"Local browser": "hermes pm install agent-browser"}
|
||||
_BROWSER_MISSING_DEFAULT = "npm install -g agent-browser, set CAMOFOX_URL, or configure Browser Use or Browserbase"
|
||||
"Browserbase": "BROWSERBASE_API_KEY/BROWSERBASE_PROJECT_ID",
|
||||
"Browser Use": "BROWSER_USE_API_KEY",
|
||||
"Local browser": "Chromium: run 'hermes pm install chromium'"}
|
||||
_BROWSER_MISSING_DEFAULT = "Chromium (run 'hermes pm install chromium'), or configure Browser Use or Browserbase"
|
||||
_WEB_MISSING = ("EXA_API_KEY, PARALLEL_API_KEY, FIRECRAWL_API_KEY/FIRECRAWL_API_URL, TAVILY_API_KEY, "
|
||||
"PERPLEXITY_API_KEY, KEENABLE_API_KEY, or SEARXNG_URL")
|
||||
|
||||
@@ -112,7 +111,7 @@ def _web_row(config, feats):
|
||||
|
||||
|
||||
def _browser_row(config, feats):
|
||||
# Browser tools (local Chromium, Camofox, Browserbase, Browser Use, or Firecrawl)
|
||||
# Browser tools (local Chromium, Lightpanda, Browserbase, Browser Use, or Firecrawl)
|
||||
hint = _BROWSER_MISSING_HINTS.get(feats.browser.current_provider, _BROWSER_MISSING_DEFAULT)
|
||||
return _managed_or_provider_row(feats.browser, "Browser Automation", "Nous Browser Use", hint)
|
||||
|
||||
|
||||
@@ -39,12 +39,12 @@ def build_tools_parser(subparsers, *, cmd_tools: Callable) -> None:
|
||||
"post-setup", help="Run a provider's post-setup install hook (npm/pip/binary)",
|
||||
description="Run the install/bootstrap hook a tool backend declares — the\n"
|
||||
"same step `hermes tools` runs after you pick a provider that\n"
|
||||
"needs extra dependencies (browser Chromium, Camofox, cua-driver,\n"
|
||||
"needs extra dependencies (browser Chromium, Lightpanda, cua-driver,\n"
|
||||
"KittenTTS/Piper, ddgs, xAI, Codex). Stable,\n"
|
||||
"non-interactive target the dashboard spawns to drive backend\n"
|
||||
"setup. Keys: agent_browser, camofox, cua_driver, kittentts,\n"
|
||||
"setup. Keys: chromium, lightpanda, cua_driver, kittentts,\n"
|
||||
"piper, ddgs, xai_grok, openai_codex.")
|
||||
tools_postsetup_p.add_argument(
|
||||
"post_setup_key", metavar="KEY",
|
||||
help="Post-setup hook key (e.g. agent_browser, camofox, kittentts)")
|
||||
help="Post-setup hook key (e.g. chromium, cua_driver, kittentts)")
|
||||
tools_parser.set_defaults(func=cmd_tools)
|
||||
@@ -337,25 +337,18 @@ TOOL_CATEGORIES = {
|
||||
# Cloud provider rows (Browserbase, Browser Use, Firecrawl) come from plugins.browser.<vendor> via
|
||||
# _plugin_browser_providers(); only non-provider setup-flow rows live here. "Local Browser" MUST stay
|
||||
# first so a fresh install's Enter lands on the free local backend (index 0), never on the paid Nous row.
|
||||
# Lightpanda is local too (cloud_provider: local, browser.engine: lightpanda — Browser Use mode spawns
|
||||
# ``lightpanda serve``, built-in tools use ``agent-browser --engine lightpanda``; no Chromium).
|
||||
# Camofox short-circuits the cloud dispatch via _is_camofox_mode().
|
||||
# Lightpanda is local too (cloud_provider: local, browser.engine: lightpanda — browser_exec spawns
|
||||
# ``lightpanda serve``; no Chromium).
|
||||
"providers": [
|
||||
_row("Local Browser", "★ recommended · free", "Headless Chromium, no API key needed", browser_provider="local",
|
||||
browser_engine="auto", post_setup="agent_browser"),
|
||||
browser_engine="auto", post_setup="chromium"),
|
||||
_row("Lightpanda", "free · local · no Chromium", "Zig headless browser spawned by Hermes, text-only (no screenshots)",
|
||||
browser_provider="local", browser_engine="lightpanda", post_setup="lightpanda"),
|
||||
# Cloud hook installs only the agent-browser CLI: Browser Use hosts its own Chromium, so the
|
||||
# local-Chromium install and readiness gate must not apply (with "agent_browser" this row read
|
||||
# "needs setup" forever on machines without a local Chromium build).
|
||||
# Browser Use hosts its own Chromium, so the local-Chromium install and readiness gate must not
|
||||
# apply (with "chromium" this row read "needs setup" forever on machines without a local build).
|
||||
_row("Nous Subscription (Browser Use cloud)", "subscription", "Managed Browser Use billed to your subscription",
|
||||
browser_provider="browser-use", **_NOUS, managed_nous_feature="browser",
|
||||
override_env_vars=["BROWSER_USE_API_KEY"], post_setup="browserbase"),
|
||||
_row("Camofox", "free · local", "Anti-detection browser (Firefox/Camoufox)",
|
||||
[_key("CAMOFOX_URL", "Camofox server URL", "https://github.com/jo-inc/camofox-browser", default="http://localhost:9377")],
|
||||
browser_provider="camofox", post_setup="camofox"),
|
||||
_row("Browser Use", "free · local · cloud", "New SOTA web harness (CLI 3.0)", browser_backend="browser-use",
|
||||
post_setup="browser_use_cli"),
|
||||
],
|
||||
},
|
||||
"computer_use": {
|
||||
|
||||
@@ -26,24 +26,20 @@ def _info_lines(*lines: str) -> None:
|
||||
_print_info(f" {line}")
|
||||
|
||||
|
||||
def _ensure_browser_use_cli(*, verbose_hints: bool = False) -> None:
|
||||
"""Confirm the Browser Use CLI engine is runnable. It is browser-harness, a core dependency of
|
||||
Hermes's own venv, so there is nothing to download; a miss means the venv needs a re-sync.
|
||||
Primary driver engine for EVERY browser backend except Camofox (Firefox-based, no CDP surface)."""
|
||||
def _ensure_browser_use_cli() -> None:
|
||||
"""Confirm browser_exec's engine is runnable. It is browser-harness, a core dependency of
|
||||
Hermes's own venv, so there is nothing to download; a miss means the venv needs a re-sync."""
|
||||
from tools.browser_use_cli import _find_cli
|
||||
|
||||
if _find_cli() is not None:
|
||||
_print_success(" Browser Use CLI ready (browser-harness, bundled with Hermes)")
|
||||
_print_success(" browser_exec ready (browser-harness, bundled with Hermes)")
|
||||
else:
|
||||
_print_warning(" browser-harness is missing from Hermes's Python environment")
|
||||
_print_info(" Re-sync it with: hermes update")
|
||||
if verbose_hints:
|
||||
_info_lines("Local Chrome needs remote debugging: chrome://inspect/#remote-debugging",
|
||||
"Cloud browsers: set BROWSER_USE_API_KEY")
|
||||
|
||||
|
||||
def _post_setup_lightpanda() -> None:
|
||||
# Browser Use mode spawns ``lightpanda serve``; built-in tools go through agent-browser. No Chromium needed.
|
||||
# browser_exec spawns ``lightpanda serve`` and attaches over CDP. No Chromium needed.
|
||||
_ensure_browser_use_cli()
|
||||
from tools.browser_lightpanda import LIGHTPANDA_INSTALL_HINT, find_lightpanda_binary
|
||||
|
||||
@@ -57,44 +53,29 @@ def _post_setup_lightpanda() -> None:
|
||||
_print_info(" Lightpanda has no native Windows build; run Hermes under WSL2.")
|
||||
|
||||
|
||||
def _post_setup_agent_browser(post_setup_key: str) -> None:
|
||||
"""PM owns the driver and Chromium; Termux and Docker own their native payloads."""
|
||||
# Every non-Camofox backend drives through the Browser Use CLI — install it here too.
|
||||
def _post_setup_chromium() -> None:
|
||||
"""Local Browser row: browser_exec launches pm's Chromium. Termux (system Chromium) and the Hermes
|
||||
Docker image (its baked build) own their payloads, so a miss there is a hint, never a download."""
|
||||
import pm
|
||||
from hermes_cli.browser_runtime import BAKED_EXECUTABLE_FILE, chromium_executable, chromium_missing_hint
|
||||
from hermes_constants import is_termux
|
||||
|
||||
_ensure_browser_use_cli()
|
||||
try:
|
||||
from tools.browser_tool_install import (
|
||||
_browser_install_hint, _chromium_installed, _running_in_docker, _find_agent_browser)
|
||||
from hermes_constants import is_termux
|
||||
except Exception as exc: # pragma: no cover — defensive
|
||||
_print_warning(f" Could not check Chromium status: {exc}")
|
||||
return
|
||||
|
||||
termux = is_termux()
|
||||
docker = _running_in_docker()
|
||||
if termux or docker:
|
||||
if chromium_executable() is None and not (is_termux() or BAKED_EXECUTABLE_FILE.exists()):
|
||||
try:
|
||||
_find_agent_browser(validate=False)
|
||||
except FileNotFoundError:
|
||||
_print_warning(f" agent-browser is missing. Install it explicitly: {_browser_install_hint()}")
|
||||
pm.ensure("chromium", explicit=True)
|
||||
except (RuntimeError, OSError) as exc: # pm.InstallError, a download or worker failure
|
||||
_print_warning(f" Chromium install failed: {exc}")
|
||||
_info_lines("Retry with: hermes tools post-setup chromium")
|
||||
return
|
||||
if docker and post_setup_key == "agent_browser" and not _chromium_installed():
|
||||
_print_warning(" Chromium is missing but you're running in Docker.")
|
||||
_info_lines("Pull the latest image to get the bundled Chromium:",
|
||||
" docker pull ghcr.io/nousresearch/hermes-agent:latest")
|
||||
found = chromium_executable()
|
||||
if found is None:
|
||||
_print_warning(f" {chromium_missing_hint()}")
|
||||
return
|
||||
|
||||
try:
|
||||
import pm
|
||||
# Chromium is a declared dependency; do not acquire it a second time.
|
||||
pm.ensure("agent-browser", explicit=True)
|
||||
except Exception as exc:
|
||||
_print_warning(f" agent-browser install failed: {exc}")
|
||||
_info_lines("Retry with: hermes tools post-setup " + post_setup_key)
|
||||
return
|
||||
_print_success(" Managed agent-browser and Chromium are ready")
|
||||
_print_success(f" Chromium ready: {found}")
|
||||
|
||||
# OS libraries are host-owned. Never download another package manager to install them.
|
||||
if post_setup_key == "agent_browser" and sys.platform == "linux":
|
||||
if sys.platform == "linux" and not is_termux():
|
||||
_info_lines("Chromium also needs system libraries supplied by your distribution.")
|
||||
if shutil.which("apt-get") and _module_installed("playwright"):
|
||||
command = shlex.join([sys.executable, "-m", "playwright", "install-deps", "chromium"])
|
||||
@@ -104,18 +85,6 @@ def _post_setup_agent_browser(post_setup_key: str) -> None:
|
||||
" https://playwright.dev/python/docs/browsers#install-system-dependencies")
|
||||
|
||||
|
||||
def _post_setup_camofox() -> None:
|
||||
from tools.browser_camofox import check_camofox_available
|
||||
|
||||
_info_lines("Camofox is an externally managed server; Hermes does not install or start it.")
|
||||
if check_camofox_available():
|
||||
_print_success(" Configured Camofox server is reachable")
|
||||
return
|
||||
_print_warning(" Camofox server is not reachable. Start your server and check CAMOFOX_URL.")
|
||||
_info_lines("Server setup: https://github.com/jo-inc/camofox-browser",
|
||||
"Docker: docker run -p 9377:9377 -e CAMOFOX_PORT=9377 jo-inc/camofox-browser")
|
||||
|
||||
|
||||
# The hook key is the UI provider identifier; extra names belong to pyproject.toml.
|
||||
def _python_hook(module, extra, label, installing, on_install=(), always=()) -> dict:
|
||||
return {"module": module, "extra": extra, "label": label, "installing": installing,
|
||||
@@ -270,10 +239,9 @@ _POST_SETUP_AUTH_READY: dict = {
|
||||
# post_setup key -> hook. Unknown keys are a silent no-op (callers validate against valid_post_setup_keys()).
|
||||
_POST_SETUP_HOOKS: dict = {
|
||||
"lightpanda": _post_setup_lightpanda,
|
||||
"agent_browser": lambda: _post_setup_agent_browser("agent_browser"),
|
||||
"browserbase": lambda: _post_setup_agent_browser("browserbase"),
|
||||
"browser_use_cli": lambda: _ensure_browser_use_cli(verbose_hints=True),
|
||||
"camofox": _post_setup_camofox,
|
||||
"chromium": _post_setup_chromium,
|
||||
# Cloud browser rows (plugins.browser.*): the vendor hosts Chromium; browser_exec attaches over CDP.
|
||||
"browserbase": lambda: _ensure_browser_use_cli(),
|
||||
"cua_driver": lambda: install_cua_driver(upgrade=False),
|
||||
"xai_grok": _post_setup_xai_grok,
|
||||
"openai_codex": _post_setup_openai_codex,
|
||||
@@ -353,19 +321,19 @@ def _module_installed(module_name: str) -> bool:
|
||||
return False
|
||||
|
||||
|
||||
def _agent_browser_installed() -> bool:
|
||||
"""True when everything ``_run_post_setup("agent_browser")`` installs is present: the agent-browser CLI
|
||||
*and* the Chromium build it drives (or the Lightpanda engine, which needs no Chromium), so "Run
|
||||
setup" flips to installed only when re-running it would be a no-op."""
|
||||
from hermes_cli.nous_subscription import _local_browser_runnable
|
||||
def _chromium_installed() -> bool:
|
||||
"""Everything ``_run_post_setup("chromium")`` provides is present: browser-harness and a Chromium, so
|
||||
"Run setup" flips to installed only when re-running it would be a no-op."""
|
||||
from hermes_cli.browser_runtime import chromium_executable
|
||||
|
||||
return _local_browser_runnable()
|
||||
return _browser_harness_installed() and chromium_executable() is not None
|
||||
|
||||
|
||||
def _camofox_installed() -> bool:
|
||||
"""Readiness belongs to the configured external server, not root node_modules."""
|
||||
from tools.browser_camofox import check_camofox_available
|
||||
return check_camofox_available()
|
||||
def _browser_harness_installed() -> bool:
|
||||
"""Installed-check for the ``browserbase`` hook: cloud providers host their own Chromium, so
|
||||
browser_exec's engine is the whole local contract."""
|
||||
from tools.browser_use_cli import _find_cli
|
||||
return _find_cli() is not None
|
||||
|
||||
|
||||
def _lightpanda_installed() -> bool:
|
||||
@@ -377,21 +345,13 @@ def _lightpanda_installed() -> bool:
|
||||
return False
|
||||
|
||||
|
||||
def _cloud_agent_browser_installed() -> bool:
|
||||
"""Installed-check for the ``browserbase`` hook: cloud providers host their own Chromium, so
|
||||
presence of the agent-browser CLI is the whole contract."""
|
||||
from hermes_cli.nous_subscription import _has_agent_browser
|
||||
return _has_agent_browser()
|
||||
|
||||
|
||||
# post_setup_key -> predicate(): True when the install side-effect is satisfied. Used by
|
||||
# ``provider_readiness_status`` to mark a keyless post_setup row "ready" vs "needs_setup"; mirrors the
|
||||
# installed-checks the hooks perform. Credential bootstraps (``xai_grok``, ``openai_codex``) are absent —
|
||||
# they live in ``_POST_SETUP_AUTH_READY`` as auth checks. Late-bound lambdas so tests can monkeypatch the underlying predicates.
|
||||
_POST_SETUP_READY: dict = {
|
||||
**{key: (lambda m=spec["module"]: _module_installed(m)) for key, spec in _PYTHON_POST_SETUP_HOOKS.items()},
|
||||
"agent_browser": lambda: _agent_browser_installed(),
|
||||
"browserbase": lambda: _cloud_agent_browser_installed(),
|
||||
"camofox": lambda: _camofox_installed(),
|
||||
"chromium": lambda: _chromium_installed(),
|
||||
"browserbase": lambda: _browser_harness_installed(),
|
||||
"lightpanda": lambda: _lightpanda_installed(),
|
||||
"cua_driver": lambda: _cua_driver_install_ready()}
|
||||
@@ -250,11 +250,7 @@ def _toolset_needs_configuration_prompt(ts_key: str, config: dict, *, force_fres
|
||||
section = config.get(ts_key, {})
|
||||
if not isinstance(section, dict):
|
||||
return True
|
||||
if selection_key in section:
|
||||
return False
|
||||
# Browser's "Browser Use" row writes browser.backend and leaves cloud_provider unset. Presence is no
|
||||
# test of a choice here: browser.backend exists on every install after the defaults merge ("" = unset).
|
||||
return not (ts_key == "browser" and _browser_backend(config))
|
||||
return selection_key not in section
|
||||
if ts_key == "image_gen": # in-tree FAL backend OR any available plugin image gen provider satisfies
|
||||
return not fal_key_is_configured() and not _any_plugin_provider_available("agent.image_gen_registry")
|
||||
if ts_key == "video_gen": # no in-tree fallback — every video backend is a plugin
|
||||
@@ -432,8 +428,6 @@ def _managed_provider_active(provider: dict, config: dict, managed_feature: str,
|
||||
and not is_truthy_value(gen_cfg.get("use_gateway"), default=False)):
|
||||
return False
|
||||
return feature.managed_by_nous
|
||||
# Browser Use mode is a driver on top of the provider (attaches to its CDP endpoint), so the browser
|
||||
# provider row stays active alongside the Browser Use row.
|
||||
if _has_marker(provider, "web_backend"):
|
||||
# Search and extract pick their route separately: the managed row serves whichever one
|
||||
# resolves to the gateway (a ``nous`` override, or the shared ``nous`` selection).
|
||||
@@ -445,56 +439,17 @@ def _managed_provider_active(provider: dict, config: dict, managed_feature: str,
|
||||
return feature.managed_by_nous
|
||||
|
||||
|
||||
def _browser_use_default_active(config: dict) -> bool:
|
||||
"""``browser.backend`` unset: Browser Use mode is the default, so the row is active whenever the
|
||||
effective mode resolves on (legacy direct-API cloud config, or CLI runnable and no Camofox)."""
|
||||
browser_cfg = config.get("browser") if isinstance(config, dict) else None
|
||||
try:
|
||||
from tools.browser_use_cli import _find_cli, is_legacy_browser_use_cloud_config
|
||||
|
||||
if is_legacy_browser_use_cloud_config(browser_cfg or {}):
|
||||
return True
|
||||
try:
|
||||
from tools.browser_camofox import is_camofox_mode
|
||||
|
||||
if is_camofox_mode():
|
||||
return False
|
||||
except Exception:
|
||||
pass
|
||||
return _find_cli() is not None
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def _browser_provider_active(provider: dict, config: dict) -> bool:
|
||||
# Browser Use mode composes with the provider (driver over its CDP endpoint) — don't deactivate the row.
|
||||
if provider["browser_provider"] != cfg_get(config, "browser", "cloud_provider"):
|
||||
return False
|
||||
# Two local rows differ only by engine ("Local Browser" vs "Lightpanda"): config.yaml is the picker's
|
||||
# source of truth here, the AGENT_BROWSER_ENGINE env var is not consulted.
|
||||
# source of truth here.
|
||||
if provider.get("browser_engine"):
|
||||
engine = str(cfg_get(config, "browser", "engine") or "auto").strip().lower()
|
||||
return engine == provider["browser_engine"]
|
||||
return True
|
||||
|
||||
|
||||
def _browser_backend(config: dict) -> str:
|
||||
"""``browser.backend`` as a string; ``""`` when unset or empty (YAML 1.1 parses an unquoted ``off`` as False)."""
|
||||
backend = cfg_get(config, "browser", "backend")
|
||||
return "off" if backend is False else (backend or "")
|
||||
|
||||
|
||||
def _browser_backend_active(provider: dict, config: dict) -> bool:
|
||||
backend = _browser_backend(config)
|
||||
if backend == provider["browser_backend"]:
|
||||
return True
|
||||
if backend:
|
||||
return False # explicit other choice ("off", …) wins
|
||||
if provider["browser_backend"] != "browser-use":
|
||||
return False
|
||||
return _browser_use_default_active(config)
|
||||
|
||||
|
||||
def _imagegen_backend_active(provider: dict, config: dict) -> bool:
|
||||
image_cfg = config.get("image_gen", {})
|
||||
return (
|
||||
@@ -510,7 +465,6 @@ _ACTIVE_CHECKS: tuple[tuple[str, Callable[[dict, dict], bool]], ...] = (
|
||||
("tts_provider", lambda p, c: cfg_get(c, "tts", "provider") == p["tts_provider"]),
|
||||
("stt_provider", lambda p, c: (cfg_get(c, "stt", "provider") or "local") == p["stt_provider"]),
|
||||
("browser_provider", _browser_provider_active),
|
||||
("browser_backend", _browser_backend_active),
|
||||
("web_backend", _web_backend_active),
|
||||
("computer_use_backend", lambda p, c: cfg_get(c, "computer_use", "backend") == p["computer_use_backend"]),
|
||||
("imagegen_backend", _imagegen_backend_active))
|
||||
@@ -749,13 +703,10 @@ def _write_provider_config(provider: dict, config: dict, *, managed_feature) ->
|
||||
if "browser_provider" in provider:
|
||||
bp = provider["browser_provider"]
|
||||
if bp or managed_feature:
|
||||
# Browser Use mode (browser.backend) composes with the provider — keep the driver choice intact.
|
||||
_select_into(config, "browser", "cloud_provider", bp, managed_feature)
|
||||
else:
|
||||
config.setdefault("browser", {}).pop("use_gateway", None)
|
||||
if provider.get("browser_backend"):
|
||||
config.setdefault("browser", {})["backend"] = provider["browser_backend"]
|
||||
# Local engine rows ("Local Browser" resets to auto, "Lightpanda" sets lightpanda); composes with browser.backend.
|
||||
# Local engine rows ("Local Browser" resets to auto, "Lightpanda" sets lightpanda).
|
||||
if provider.get("browser_engine"):
|
||||
config.setdefault("browser", {})["engine"] = provider["browser_engine"]
|
||||
|
||||
@@ -887,8 +838,6 @@ def _print_provider_selection(provider: dict, managed_feature, *, reconfigure: b
|
||||
_print_success(" Browser set to local mode")
|
||||
elif bp:
|
||||
_print_success(f" Browser cloud provider set to: {bp}")
|
||||
if provider.get("browser_backend"):
|
||||
_print_success(" Browser set to Browser Use (browser_exec via CLI 3.0)")
|
||||
if provider.get("browser_engine") and provider["browser_engine"] != "auto":
|
||||
_print_success(f" Browser engine set to: {provider['browser_engine']}")
|
||||
if provider.get("web_backend"):
|
||||
|
||||
@@ -890,8 +890,8 @@ def _refresh_cua_driver_after_update() -> None:
|
||||
|
||||
|
||||
def _install_default_tools_after_update() -> None:
|
||||
"""Give the install its optional default tools: the PM defaults (agent-browser +
|
||||
Chromium, cua-driver). The Browser Use CLI engine (browser-harness) is a venv dependency.
|
||||
"""Give the install its optional default tools: the PM defaults (Chromium,
|
||||
cua-driver). browser_exec's engine (browser-harness) is a venv dependency.
|
||||
|
||||
Runs at the end of both the installers (via the source completion) and
|
||||
``hermes update``: a source update re-syncs only the venv, so a tool that became
|
||||
|
||||
+1
-1
@@ -359,7 +359,7 @@
|
||||
},
|
||||
{
|
||||
"path": "hermes_cli/tools_config_post_setup.py",
|
||||
"symbol": "_post_setup_agent_browser",
|
||||
"symbol": "_post_setup_chromium",
|
||||
"kind": "bare_which"
|
||||
},
|
||||
{
|
||||
|
||||
@@ -25,7 +25,6 @@ from hermes_cli import doctor_tools
|
||||
from hermes_cli import doctor_state
|
||||
from hermes_cli import doctor_platform
|
||||
from hermes_cli import doctor_config
|
||||
from tools import browser_tool_install as bt_install
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
@@ -824,9 +823,8 @@ def test_run_doctor_accepts_kimi_coding_cn_provider(monkeypatch, tmp_path):
|
||||
assert "model.provider 'kimi-coding-cn' is not a recognised provider" not in out
|
||||
|
||||
|
||||
def _doctor_env_for_agent_browser(monkeypatch, tmp_path):
|
||||
"""Shared fixture setup for the agent-browser npx-resolution
|
||||
branch in run_doctor (hermes_cli/doctor.py ~1557-1605)."""
|
||||
def _doctor_env_for_browser(monkeypatch, tmp_path):
|
||||
"""Shared fixture setup for the Node + Chromium rows of run_doctor."""
|
||||
home = tmp_path / ".hermes"
|
||||
home.mkdir(parents=True, exist_ok=True)
|
||||
(home / "config.yaml").write_text("memory: {}\n", encoding="utf-8")
|
||||
@@ -858,63 +856,82 @@ def _doctor_env_for_agent_browser(monkeypatch, tmp_path):
|
||||
pass
|
||||
|
||||
|
||||
def test_run_doctor_reports_installed_agent_browser(monkeypatch, tmp_path):
|
||||
_doctor_env_for_agent_browser(monkeypatch, tmp_path)
|
||||
def test_run_doctor_reports_installed_chromium(monkeypatch, tmp_path):
|
||||
_doctor_env_for_browser(monkeypatch, tmp_path)
|
||||
|
||||
monkeypatch.setattr(bt_install, "_find_agent_browser", lambda **_kw: "/pm/agent-browser")
|
||||
monkeypatch.setattr("hermes_cli.browser_runtime.chromium_executable", lambda **_kw: "/pm/chromium/chrome")
|
||||
|
||||
buf = io.StringIO()
|
||||
with contextlib.redirect_stdout(buf):
|
||||
doctor_mod.run_doctor(Namespace(fix=False))
|
||||
out = buf.getvalue()
|
||||
|
||||
assert "agent-browser" in out
|
||||
assert "/pm/agent-browser" in out
|
||||
assert "agent-browser not installed" not in out
|
||||
assert "Chromium" in out
|
||||
assert "/pm/chromium/chrome" in out
|
||||
assert "Chromium not installed" not in out
|
||||
|
||||
|
||||
def test_doctor_fix_does_not_claim_success_without_published_binary(monkeypatch, tmp_path):
|
||||
from hermes_cli import doctor_tools
|
||||
_doctor_env_for_agent_browser(monkeypatch, tmp_path)
|
||||
_doctor_env_for_browser(monkeypatch, tmp_path)
|
||||
|
||||
def missing(**kwargs):
|
||||
raise FileNotFoundError("no published browser")
|
||||
|
||||
monkeypatch.setattr(bt_install, "_find_agent_browser", missing)
|
||||
monkeypatch.setattr("hermes_cli.browser_runtime.chromium_executable", lambda **_kw: None)
|
||||
monkeypatch.setattr("hermes_cli.doctor_tools._is_termux", lambda: False)
|
||||
monkeypatch.setattr("pm.ensure", lambda *a, **kw: None)
|
||||
|
||||
buf = io.StringIO()
|
||||
with contextlib.redirect_stdout(buf):
|
||||
assert doctor_tools._check_agent_browser(True) is False
|
||||
doctor_tools._check_chromium(True)
|
||||
out = buf.getvalue()
|
||||
|
||||
assert "agent-browser install failed" in out
|
||||
assert "no published browser" in out
|
||||
assert "Chromium not installed" in out
|
||||
assert "hermes pm install chromium" in out
|
||||
|
||||
|
||||
def test_doctor_fix_reports_pm_install_failure(monkeypatch, tmp_path):
|
||||
from hermes_cli import doctor_tools
|
||||
import pm
|
||||
_doctor_env_for_agent_browser(monkeypatch, tmp_path)
|
||||
|
||||
def missing(**kwargs):
|
||||
raise FileNotFoundError("no published browser")
|
||||
_doctor_env_for_browser(monkeypatch, tmp_path)
|
||||
|
||||
def refuse(*args, **kwargs):
|
||||
raise pm.InstallError("agent-browser", "offline")
|
||||
raise pm.InstallError("chromium", "offline")
|
||||
|
||||
monkeypatch.setattr(bt_install, "_find_agent_browser", missing)
|
||||
monkeypatch.setattr("hermes_cli.browser_runtime.chromium_executable", lambda **_kw: None)
|
||||
monkeypatch.setattr("hermes_cli.doctor_tools._is_termux", lambda: False)
|
||||
monkeypatch.setattr(pm, "ensure", refuse)
|
||||
|
||||
buf = io.StringIO()
|
||||
with contextlib.redirect_stdout(buf):
|
||||
assert doctor_tools._check_agent_browser(True) is False
|
||||
doctor_tools._check_chromium(True)
|
||||
out = buf.getvalue()
|
||||
|
||||
assert "agent-browser install failed" in out
|
||||
assert "Chromium install failed" in out
|
||||
assert "offline" in out
|
||||
|
||||
|
||||
@pytest.mark.parametrize("browser_yaml,reason", [
|
||||
("browser:\n cdp_url: http://127.0.0.1:9222\n", "CDP override"),
|
||||
("browser:\n engine: lightpanda\n", "lightpanda"),
|
||||
("browser:\n cloud_provider: browserbase\n", "browserbase"),
|
||||
])
|
||||
def test_missing_chromium_is_info_when_config_needs_none(monkeypatch, tmp_path, browser_yaml, reason):
|
||||
"""A CDP override, Lightpanda or a cloud provider runs no local Chromium: no warning row."""
|
||||
from hermes_cli import doctor_tools
|
||||
from hermes_constants import get_hermes_home
|
||||
|
||||
(get_hermes_home() / "config.yaml").write_text(browser_yaml, encoding="utf-8")
|
||||
monkeypatch.delenv("BROWSER_CDP_URL", raising=False)
|
||||
monkeypatch.setattr("hermes_cli.browser_runtime.chromium_executable", lambda **_kw: None)
|
||||
|
||||
buf = io.StringIO()
|
||||
with contextlib.redirect_stdout(buf):
|
||||
doctor_tools._check_chromium(False)
|
||||
out = buf.getvalue()
|
||||
|
||||
assert "not needed" in out and reason in out
|
||||
assert "hermes pm install chromium" not in out
|
||||
|
||||
|
||||
def test_run_doctor_kimi_cn_env_is_detected_and_probe_is_null_safe(monkeypatch, tmp_path):
|
||||
home = tmp_path / ".hermes"
|
||||
home.mkdir(parents=True, exist_ok=True)
|
||||
@@ -1485,10 +1502,10 @@ class TestStagedRuntimeVenv:
|
||||
def test_run_doctor_reports_shadowed_lightpanda_engine(monkeypatch, tmp_path):
|
||||
helper = TestDoctorMemoryProviderSection()
|
||||
|
||||
monkeypatch.setattr("tools.browser_tool_lightpanda_fallback._using_lightpanda_engine", lambda: True)
|
||||
monkeypatch.setattr("tools.browser_lightpanda.using_lightpanda_engine", lambda: True, raising=False)
|
||||
monkeypatch.setattr(
|
||||
"tools.browser_tool_lightpanda_fallback.lightpanda_engine_status",
|
||||
lambda: (False, "cloud provider Browserbase is selected"),
|
||||
"tools.browser_lightpanda.lightpanda_engine_status",
|
||||
lambda: (False, "cloud provider Browserbase is selected"), raising=False,
|
||||
)
|
||||
out = helper._run_doctor_and_capture(monkeypatch, tmp_path)
|
||||
assert "browser.engine=lightpanda is shadowed" in out
|
||||
@@ -1500,8 +1517,9 @@ def test_run_doctor_reports_shadowed_lightpanda_engine(monkeypatch, tmp_path):
|
||||
def test_run_doctor_warns_when_lightpanda_binary_missing(monkeypatch, tmp_path):
|
||||
helper = TestDoctorMemoryProviderSection()
|
||||
|
||||
monkeypatch.setattr("tools.browser_tool_lightpanda_fallback._using_lightpanda_engine", lambda: True)
|
||||
monkeypatch.setattr("tools.browser_tool_lightpanda_fallback.lightpanda_engine_status", lambda: (True, "Browser Use mode"))
|
||||
monkeypatch.setattr("tools.browser_lightpanda.using_lightpanda_engine", lambda: True, raising=False)
|
||||
monkeypatch.setattr("tools.browser_lightpanda.lightpanda_engine_status",
|
||||
lambda: (True, "Hermes spawns `lightpanda serve`"), raising=False)
|
||||
monkeypatch.setattr("tools.browser_lightpanda.find_lightpanda_binary", lambda: None)
|
||||
out = helper._run_doctor_and_capture(monkeypatch, tmp_path)
|
||||
assert "Lightpanda selected but binary not found" in out
|
||||
|
||||
@@ -32,7 +32,7 @@ def _run_audit_one(capsys, audit_extra, audit_stdout):
|
||||
issues: list[str] = []
|
||||
completed = subprocess.CompletedProcess([], 0, stdout=audit_stdout, stderr="")
|
||||
with patch.object(doctor_tools.subprocess, "run", return_value=completed):
|
||||
doctor_tools._audit_one("npm", "C:/fake/root", "Browser tools (agent-browser)",
|
||||
doctor_tools._audit_one("npm", "C:/fake/root", "Root npm package",
|
||||
audit_extra, issues)
|
||||
return capsys.readouterr().out, issues
|
||||
|
||||
@@ -46,7 +46,7 @@ def test_root_remedy_never_prescribes_local_audit_fix(capsys):
|
||||
assert "npm audit fix" not in out
|
||||
assert "lockfile bump" in out
|
||||
assert issues == [
|
||||
"Browser tools (agent-browser) has 2 npm vulnerabilities; wait for an upstream lockfile fix, then update "
|
||||
"Root npm package has 2 npm vulnerabilities; wait for an upstream lockfile fix, then update "
|
||||
"your installation (Docker: pull a rebuilt image; recreating the same image restores the same dependencies)"
|
||||
]
|
||||
|
||||
@@ -71,11 +71,8 @@ def test_clean_tree_reports_no_known_vulnerabilities(capsys):
|
||||
|
||||
|
||||
def test_root_audit_row_is_not_mislabeled_as_agent_browser(monkeypatch, tmp_path, capsys):
|
||||
"""agent-browser is a pm-managed precompiled binary (pm/packages.py::AgentBrowser,
|
||||
staged via BinaryPackage.stage()) with no node_modules/lockfile of its own left after
|
||||
staging, so there is nothing under it for npm to audit. The root package.json row
|
||||
(js-yaml, semver, eslint tooling) must not claim to be auditing agent-browser (#122223).
|
||||
"""
|
||||
"""The root package.json row (js-yaml, semver, eslint tooling) names itself, not a browser
|
||||
driver it never audits (#122223)."""
|
||||
import hermes_cli.doctor as doctor_mod
|
||||
from gateway.platforms import whatsapp_common
|
||||
|
||||
|
||||
@@ -7,9 +7,10 @@ from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import os
|
||||
import sys
|
||||
import threading
|
||||
from http.server import BaseHTTPRequestHandler, HTTPServer
|
||||
from types import SimpleNamespace
|
||||
from types import ModuleType, SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
@@ -18,10 +19,9 @@ from hermes_cli.doctor_live import (
|
||||
maybe_run_live_checks,
|
||||
run_live_checks,
|
||||
)
|
||||
from tools import browser_tool_install as bt_install
|
||||
|
||||
# Captured before the autouse fixture below stubs doctor_live._browser_available
|
||||
# to a constant, so TestBrowserAvailableNpxRung can exercise the real function.
|
||||
# to a constant, so TestBrowserAvailable can exercise the real function.
|
||||
_real_browser_available = doctor_live._browser_available
|
||||
|
||||
# The exact environment Clash Verge / mihomo exports (issue #118159): a bracketed-IPv6
|
||||
@@ -256,29 +256,42 @@ def test_run_live_checks_leaves_a_clean_environment_untouched(monkeypatch):
|
||||
|
||||
|
||||
class TestBrowserAvailable:
|
||||
"""Live probes use the same passive selection as browser execution."""
|
||||
"""Live probes use the launcher's own Chromium selection."""
|
||||
|
||||
def _block_path_and_node_modules_checks(self, monkeypatch, tmp_path):
|
||||
monkeypatch.setattr("shutil.which", lambda *a, **k: None)
|
||||
monkeypatch.setattr("hermes_cli.doctor.HERMES_HOME", tmp_path / "home")
|
||||
monkeypatch.setattr("hermes_cli.doctor.PROJECT_ROOT", tmp_path / "root")
|
||||
@pytest.mark.parametrize("found,available", [("/pm/chromium/chrome", True), (None, False)])
|
||||
def test_follows_chromium_selection(self, monkeypatch, found, available):
|
||||
monkeypatch.setattr(doctor_live, "chromium_executable", lambda **_kw: found)
|
||||
|
||||
def test_true_when_installed_browser_resolves(self, monkeypatch, tmp_path):
|
||||
self._block_path_and_node_modules_checks(monkeypatch, tmp_path)
|
||||
assert _real_browser_available() is available
|
||||
|
||||
monkeypatch.setattr(bt_install, "_find_agent_browser", lambda **_kw: "/pm/agent-browser")
|
||||
|
||||
assert _real_browser_available() is True
|
||||
class TestLaunchBrowserProbe:
|
||||
"""The probe launches through browser_exec's launcher and always stops what it started."""
|
||||
|
||||
def test_false_when_nothing_resolves(self, monkeypatch, tmp_path):
|
||||
self._block_path_and_node_modules_checks(monkeypatch, tmp_path)
|
||||
def _fake_launcher(self, monkeypatch, result):
|
||||
calls = []
|
||||
fake = ModuleType("tools.browser_launcher")
|
||||
fake.launch_chromium = lambda key, **kw: calls.append(("launch", key, kw)) or result
|
||||
fake.stop_browser = lambda key: calls.append(("stop", key))
|
||||
monkeypatch.setitem(sys.modules, "tools.browser_launcher", fake)
|
||||
return calls
|
||||
|
||||
def _raise(**_kw):
|
||||
raise FileNotFoundError("agent-browser CLI not found")
|
||||
def test_success_stops_the_browser(self, monkeypatch):
|
||||
calls = self._fake_launcher(monkeypatch, (SimpleNamespace(cdp_url="ws://x"), None))
|
||||
|
||||
monkeypatch.setattr(bt_install, "_find_agent_browser", _raise)
|
||||
ok, detail = doctor_live._launch_browser_probe(5.0)
|
||||
|
||||
assert _real_browser_available() is False
|
||||
assert ok is True and "CDP ready" in detail
|
||||
assert [c[0] for c in calls] == ["launch", "stop"]
|
||||
assert calls[0][2]["timeout"] == 5.0 and calls[0][2]["ephemeral"] is True
|
||||
|
||||
def test_launch_error_is_reported_and_still_stopped(self, monkeypatch):
|
||||
calls = self._fake_launcher(monkeypatch, (None, "Chromium exited: missing libnss3"))
|
||||
|
||||
ok, detail = doctor_live._launch_browser_probe(5.0)
|
||||
|
||||
assert ok is False and "libnss3" in detail
|
||||
assert [c[0] for c in calls] == ["launch", "stop"]
|
||||
|
||||
|
||||
class TestFailureIsolation:
|
||||
|
||||
@@ -1,14 +1,10 @@
|
||||
"""Tests for Nous subscription feature detection."""
|
||||
|
||||
import shutil
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
|
||||
from hermes_cli.nous_account import NousPortalAccountInfo, NousToolAccessInfo
|
||||
from hermes_cli import nous_subscription as ns
|
||||
from tools import tool_backend_helpers
|
||||
from tools import browser_tool_install as bt_install
|
||||
from tools.image_generation_catalog import DEFAULT_MODEL as FAL_DEFAULT_MODEL
|
||||
|
||||
|
||||
@@ -50,7 +46,7 @@ def test_get_nous_subscription_features_recognizes_direct_exa_backend(monkeypatc
|
||||
ns, "get_nous_portal_account_info", lambda: _account(logged_in=False)
|
||||
)
|
||||
monkeypatch.setattr(ns, "_toolset_enabled", lambda config, key: key == "web")
|
||||
monkeypatch.setattr(ns, "_has_agent_browser", lambda: False)
|
||||
monkeypatch.setattr(ns, "_has_browser_harness", lambda: False)
|
||||
monkeypatch.setattr(ns, "resolve_openai_audio_api_key", lambda: "")
|
||||
monkeypatch.setattr(ns, "has_direct_modal_credentials", lambda: False)
|
||||
|
||||
@@ -76,7 +72,7 @@ def test_get_nous_subscription_features_recognizes_keyless_tavily_backend(monkey
|
||||
ns, "get_nous_portal_account_info", lambda: _account(logged_in=False)
|
||||
)
|
||||
monkeypatch.setattr(ns, "_toolset_enabled", lambda config, key: key == "web")
|
||||
monkeypatch.setattr(ns, "_has_agent_browser", lambda: False)
|
||||
monkeypatch.setattr(ns, "_has_browser_harness", lambda: False)
|
||||
monkeypatch.setattr(ns, "resolve_openai_audio_api_key", lambda: "")
|
||||
monkeypatch.setattr(ns, "has_direct_modal_credentials", lambda: False)
|
||||
|
||||
@@ -96,7 +92,7 @@ def test_keyless_tavily_search_backend_without_shared_backend(monkeypatch):
|
||||
ns, "get_nous_portal_account_info", lambda: _account(logged_in=False)
|
||||
)
|
||||
monkeypatch.setattr(ns, "_toolset_enabled", lambda config, key: key == "web")
|
||||
monkeypatch.setattr(ns, "_has_agent_browser", lambda: False)
|
||||
monkeypatch.setattr(ns, "_has_browser_harness", lambda: False)
|
||||
monkeypatch.setattr(ns, "resolve_openai_audio_api_key", lambda: "")
|
||||
monkeypatch.setattr(ns, "has_direct_modal_credentials", lambda: False)
|
||||
|
||||
@@ -115,7 +111,7 @@ def test_unconfigured_web_without_keys_is_unavailable(monkeypatch):
|
||||
ns, "get_nous_portal_account_info", lambda: _account(logged_in=False)
|
||||
)
|
||||
monkeypatch.setattr(ns, "_toolset_enabled", lambda config, key: key == "web")
|
||||
monkeypatch.setattr(ns, "_has_agent_browser", lambda: False)
|
||||
monkeypatch.setattr(ns, "_has_browser_harness", lambda: False)
|
||||
monkeypatch.setattr(ns, "resolve_openai_audio_api_key", lambda: "")
|
||||
monkeypatch.setattr(ns, "has_direct_modal_credentials", lambda: False)
|
||||
|
||||
@@ -124,37 +120,32 @@ def test_unconfigured_web_without_keys_is_unavailable(monkeypatch):
|
||||
assert features.web.available is False
|
||||
assert features.web.active is False
|
||||
assert features.web.explicit_configured is False
|
||||
def _stub_browser_probes(monkeypatch, *, has_agent_browser, chromium, lightpanda=False):
|
||||
"""Common monkeypatches for local-browser readiness scenarios.
|
||||
|
||||
``chromium`` / ``lightpanda`` drive the runtime probes that
|
||||
``_local_browser_runnable`` reuses from the ``tools.browser_tool_*`` siblings (lazy import,
|
||||
so patching the module attributes is enough).
|
||||
"""
|
||||
def _stub_browser_probes(monkeypatch, *, harness, chromium):
|
||||
"""Common monkeypatches for local-browser readiness scenarios: ``harness`` drives browser_exec's
|
||||
engine lookup, ``chromium`` the launcher's Chromium selection (both lazy imports, so patching the
|
||||
module attributes is enough)."""
|
||||
monkeypatch.setattr(ns, "get_env_value", lambda name: "")
|
||||
monkeypatch.setattr(
|
||||
ns, "get_nous_portal_account_info", lambda: _account(logged_in=False)
|
||||
)
|
||||
monkeypatch.setattr(ns, "_toolset_enabled", lambda config, key: key == "browser")
|
||||
monkeypatch.setattr(ns, "_has_agent_browser", lambda: has_agent_browser)
|
||||
monkeypatch.setattr("tools.browser_use_cli._find_cli", lambda: ["harness"] if harness else None)
|
||||
monkeypatch.setattr(ns, "resolve_openai_audio_api_key", lambda: "")
|
||||
monkeypatch.setattr(ns, "has_direct_modal_credentials", lambda: False)
|
||||
monkeypatch.setattr(ns, "is_managed_tool_gateway_ready", lambda vendor: False)
|
||||
monkeypatch.setattr("tools.browser_tool_install._chromium_installed", lambda: chromium)
|
||||
monkeypatch.setattr(
|
||||
"tools.browser_tool_lightpanda_fallback._using_lightpanda_engine", lambda: lightpanda
|
||||
"hermes_cli.browser_runtime.chromium_executable", lambda **_kw: "/pm/chromium/chrome" if chromium else None
|
||||
)
|
||||
|
||||
|
||||
def test_local_browser_unavailable_without_chromium(monkeypatch):
|
||||
"""agent-browser present but Chromium absent must NOT advertise local browser.
|
||||
"""browser-harness present but Chromium absent must NOT advertise local browser.
|
||||
|
||||
The runtime (``check_browser_requirements``) refuses local mode without a
|
||||
Chromium build, so the setup/status surface must report unavailable too —
|
||||
otherwise the user sees "Browser Automation available" and the first real
|
||||
call fails. Regression for the false-positive setup bug.
|
||||
The launcher refuses a local session without a Chromium build, so the setup/status surface
|
||||
must report unavailable too — otherwise the user sees "Browser Automation available" and the
|
||||
first real call fails. Regression for the false-positive setup bug.
|
||||
"""
|
||||
_stub_browser_probes(monkeypatch, has_agent_browser=True, chromium=False)
|
||||
_stub_browser_probes(monkeypatch, harness=True, chromium=False)
|
||||
|
||||
features = ns.get_nous_subscription_features(
|
||||
{"browser": {"cloud_provider": "local"}}
|
||||
@@ -166,10 +157,20 @@ def test_local_browser_unavailable_without_chromium(monkeypatch):
|
||||
assert features.browser.current_provider == "Local browser"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("harness,chromium,engine,available", [
|
||||
(True, True, "auto", True),
|
||||
(True, False, "lightpanda", True), # Lightpanda is its own engine: no Chromium needed
|
||||
(False, True, "auto", False), # browser_exec's engine missing: nothing can drive a browser
|
||||
])
|
||||
def test_local_browser_availability_follows_engine_and_chromium(monkeypatch, harness, chromium, engine, available):
|
||||
_stub_browser_probes(monkeypatch, harness=harness, chromium=chromium)
|
||||
|
||||
features = ns.get_nous_subscription_features(
|
||||
{"browser": {"cloud_provider": "local", "engine": engine}}
|
||||
)
|
||||
|
||||
|
||||
|
||||
assert features.browser.available is available
|
||||
assert features.browser.current_provider == "Local browser"
|
||||
|
||||
|
||||
def _capture_checklist(monkeypatch, *, selected_idx):
|
||||
@@ -199,7 +200,7 @@ def test_logged_in_entitled_account_yields_a_state_for_every_feature(monkeypatch
|
||||
monkeypatch.setattr(ns, "get_nous_portal_account_info", lambda **kw: _pool_account())
|
||||
monkeypatch.setattr(ns, "is_managed_tool_gateway_ready", lambda gateway: True)
|
||||
monkeypatch.setattr(ns, "get_env_value", lambda name: "")
|
||||
monkeypatch.setattr(ns, "_has_agent_browser", lambda: False)
|
||||
monkeypatch.setattr(ns, "_has_browser_harness", lambda: False)
|
||||
monkeypatch.setattr(ns, "resolve_openai_audio_api_key", lambda: "")
|
||||
monkeypatch.setattr(ns, "has_direct_modal_credentials", lambda: False)
|
||||
|
||||
@@ -324,13 +325,13 @@ def test_prompt_enable_tool_gateway_never_offers_explicit_backend(monkeypatch):
|
||||
|
||||
|
||||
def test_gateway_direct_credentials_honor_env_configured_local_backends(monkeypatch):
|
||||
"""SEARXNG_URL / CAMOFOX_URL are env-configured keyless local backends
|
||||
with no stored selection — they must still count as direct credentials
|
||||
"""SEARXNG_URL is an env-configured keyless local backend
|
||||
with no stored selection — it must still count as a direct credential
|
||||
so the tool is offered unchecked, never pre-checked (#92647)."""
|
||||
monkeypatch.setattr(
|
||||
ns,
|
||||
"get_env_value",
|
||||
lambda name: "http://localhost:9377" if name in ("SEARXNG_URL", "CAMOFOX_URL") else "",
|
||||
lambda name: "http://localhost:9377" if name == "SEARXNG_URL" else "",
|
||||
)
|
||||
monkeypatch.setattr(ns, "fal_key_is_configured", lambda: False)
|
||||
monkeypatch.setattr(ns, "resolve_openai_audio_api_key", lambda: None)
|
||||
@@ -338,7 +339,6 @@ def test_gateway_direct_credentials_honor_env_configured_local_backends(monkeypa
|
||||
direct = ns._get_gateway_direct_credentials()
|
||||
|
||||
assert direct["web"] is True
|
||||
assert direct["browser"] is True
|
||||
assert direct["image_gen"] is False
|
||||
|
||||
|
||||
@@ -468,83 +468,3 @@ def test_configured_image_section_is_left_alone(monkeypatch):
|
||||
# ---------------------------------------------------------------------------
|
||||
# ensure_nous_portal_access — inline login gate for `hermes tools`
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# STT — managed-by-Nous detection (Phase 4 follow-up)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
def _block_legacy_agent_browser_checks(monkeypatch):
|
||||
"""Make the legacy checks (PATH lookup + local node_modules/.bin) find nothing."""
|
||||
real_which = shutil.which
|
||||
monkeypatch.setattr(
|
||||
shutil,
|
||||
"which",
|
||||
lambda cmd, *args, **kwargs: (
|
||||
None if cmd == "agent-browser" else real_which(cmd, *args, **kwargs)
|
||||
),
|
||||
)
|
||||
monkeypatch.setattr("hermes_constants.agent_browser_runnable", lambda path: False)
|
||||
|
||||
|
||||
def test_has_agent_browser_uses_passive_runtime_resolution(monkeypatch):
|
||||
"""Readiness shares the runtime resolver without acquiring a package."""
|
||||
_block_legacy_agent_browser_checks(monkeypatch)
|
||||
|
||||
calls = []
|
||||
|
||||
def fake_find_agent_browser(*, validate=True):
|
||||
calls.append({"validate": validate})
|
||||
return "/prepared/agent-browser"
|
||||
|
||||
monkeypatch.setattr(bt_install, "_find_agent_browser", fake_find_agent_browser)
|
||||
|
||||
assert ns._has_agent_browser() is True
|
||||
# A readiness probe must resolve without spawning the daemon.
|
||||
assert calls and all(call["validate"] is False for call in calls)
|
||||
|
||||
|
||||
def test_has_agent_browser_false_when_nothing_resolvable(monkeypatch):
|
||||
_block_legacy_agent_browser_checks(monkeypatch)
|
||||
|
||||
def raise_not_found(*, validate=True):
|
||||
raise FileNotFoundError("agent-browser CLI not found")
|
||||
|
||||
monkeypatch.setattr(bt_install, "_find_agent_browser", raise_not_found)
|
||||
|
||||
assert ns._has_agent_browser() is False
|
||||
|
||||
|
||||
def test_has_agent_browser_import_failure_does_not_run_another_resolver(monkeypatch):
|
||||
"""A broken runtime resolver cannot advertise an unchecked fallback."""
|
||||
monkeypatch.setitem(sys.modules, "tools.browser_tool_install", None)
|
||||
real_which = shutil.which
|
||||
monkeypatch.setattr(
|
||||
shutil,
|
||||
"which",
|
||||
lambda cmd, *args, **kwargs: (
|
||||
"/fake/bin/agent-browser"
|
||||
if cmd == "agent-browser"
|
||||
else real_which(cmd, *args, **kwargs)
|
||||
),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"hermes_constants.agent_browser_runnable",
|
||||
lambda path: path == "/fake/bin/agent-browser",
|
||||
)
|
||||
|
||||
assert ns._has_agent_browser() is False
|
||||
@@ -59,12 +59,8 @@ class TestPostSetupGate:
|
||||
|
||||
|
||||
class TestBrowserBackendPrompt:
|
||||
"""Regression: `_toolset_needs_configuration_prompt` for the browser toolset
|
||||
only checked `browser.cloud_provider` (set by `browser_provider` rows),
|
||||
ignoring `browser.backend` (set by the `browser_backend` "Browser Use" row).
|
||||
This made the provider picker re-appear every time `hermes tools` was
|
||||
opened, even when Browser Use was already configured.
|
||||
"""
|
||||
"""`_toolset_needs_configuration_prompt` for the browser toolset keys on `browser.cloud_provider`
|
||||
(written by every Browser Automation row), so a configured browser never re-prompts."""
|
||||
|
||||
|
||||
def test_browser_cloud_provider_set_skips_provider_picker(self, monkeypatch, tmp_path):
|
||||
@@ -82,22 +78,13 @@ class TestBrowserBackendPrompt:
|
||||
config = {"browser": None}
|
||||
assert tools_config._toolset_needs_configuration_prompt("browser", config) is True
|
||||
|
||||
def test_browser_backend_off_still_skips_prompt(self, monkeypatch, tmp_path):
|
||||
"""YAML 1.1 parses unquoted `off` as boolean False — the helper must
|
||||
normalise it, and the gate should still treat it as 'configured'."""
|
||||
from hermes_cli import tools_config
|
||||
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
||||
config = {"browser": {"backend": False}} # what YAML `off` becomes
|
||||
assert tools_config._toolset_needs_configuration_prompt("browser", config) is False
|
||||
|
||||
|
||||
class TestBrowserBackendPromptThroughLoader:
|
||||
"""The browser gate must hold through the real config loader.
|
||||
|
||||
`load_config()` merges ``DEFAULT_CONFIG``, where ``browser.backend`` is ``""`` — so the key is
|
||||
present on every install and a presence test would suppress the picker everywhere. These pin the
|
||||
behaviour against the merged dict a real ``hermes tools`` run feeds the gate.
|
||||
`load_config()` merges ``DEFAULT_CONFIG``; a selection key the defaults filled in would suppress the
|
||||
picker everywhere. These pin the behaviour against the merged dict a real ``hermes tools`` run feeds
|
||||
the gate.
|
||||
"""
|
||||
|
||||
def _home(self, tmp_path, body: str):
|
||||
@@ -110,13 +97,12 @@ class TestBrowserBackendPromptThroughLoader:
|
||||
|
||||
monkeypatch.setenv("HERMES_HOME", str(self._home(tmp_path, "cli: {}\n")))
|
||||
config = load_config()
|
||||
assert config["browser"]["backend"] == "" # defaults merge fills the key
|
||||
assert tools_config._toolset_needs_configuration_prompt("browser", config) is True
|
||||
|
||||
def test_explicit_backend_skips_prompt(self, monkeypatch, tmp_path):
|
||||
def test_explicit_cloud_provider_skips_prompt(self, monkeypatch, tmp_path):
|
||||
from hermes_cli import tools_config
|
||||
from hermes_cli.config import load_config
|
||||
|
||||
monkeypatch.setenv("HERMES_HOME", str(self._home(tmp_path, "browser:\n backend: browser-use\n")))
|
||||
monkeypatch.setenv("HERMES_HOME", str(self._home(tmp_path, "browser:\n cloud_provider: browser-use\n")))
|
||||
config = load_config()
|
||||
assert tools_config._toolset_needs_configuration_prompt("browser", config) is False
|
||||
@@ -64,12 +64,12 @@ def test_setup_model_provider_preserves_auxiliary_choices_written_by_picker(tmp_
|
||||
def test_setup_summary_local_browser_unavailable_without_chromium(
|
||||
tmp_path, monkeypatch, capsys
|
||||
):
|
||||
"""End-to-end: agent-browser present but no Chromium in local mode must
|
||||
"""End-to-end: browser-harness present but no Chromium in local mode must
|
||||
render as unavailable with an install hint — not a false 'available'.
|
||||
|
||||
Unlike the mocked-feature tests above, this drives the real
|
||||
``get_nous_subscription_features`` so the surface stays aligned with the
|
||||
runtime gate in ``tools.browser_tool_install.check_browser_requirements``.
|
||||
launcher's Chromium selection (``hermes_cli.browser_runtime.chromium_executable``).
|
||||
"""
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
||||
_clear_provider_env(monkeypatch)
|
||||
@@ -83,13 +83,12 @@ def test_setup_summary_local_browser_unavailable_without_chromium(
|
||||
save_config(cfg)
|
||||
|
||||
# Only stub the readiness probes; the feature resolver itself is real.
|
||||
monkeypatch.setattr("hermes_cli.nous_subscription._has_agent_browser", lambda: True)
|
||||
monkeypatch.setattr("hermes_cli.nous_subscription._has_browser_harness", lambda: True)
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.nous_subscription.get_nous_portal_account_info",
|
||||
lambda *a, **k: None,
|
||||
)
|
||||
monkeypatch.setattr("tools.browser_tool_install._chromium_installed", lambda: False)
|
||||
monkeypatch.setattr("tools.browser_tool_lightpanda_fallback._using_lightpanda_engine", lambda: False)
|
||||
monkeypatch.setattr("hermes_cli.browser_runtime.chromium_executable", lambda **_kw: None)
|
||||
monkeypatch.setattr(
|
||||
"agent.auxiliary_client.get_available_vision_backends", list
|
||||
)
|
||||
@@ -98,4 +97,4 @@ def test_setup_summary_local_browser_unavailable_without_chromium(
|
||||
output = capsys.readouterr().out
|
||||
|
||||
assert "Browser Automation (Local browser)" not in output
|
||||
assert "hermes pm install agent-browser" in output
|
||||
assert "hermes pm install chromium" in output
|
||||
@@ -1,7 +1,6 @@
|
||||
"""Tests for hermes_cli.tools_config platform tool persistence."""
|
||||
|
||||
import logging
|
||||
import subprocess
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import patch
|
||||
|
||||
@@ -413,7 +412,7 @@ def test_a_saved_disabled_mcp_server_does_not_enable_every_other_server(listed):
|
||||
|
||||
|
||||
|
||||
class TestAgentBrowserPostSetup:
|
||||
class TestChromiumPostSetup:
|
||||
"""Cloud isolation, image ownership, and failed setup remain observable."""
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
@@ -421,57 +420,40 @@ class TestAgentBrowserPostSetup:
|
||||
with patch("hermes_cli.tools_config_post_setup._ensure_browser_use_cli") as stub:
|
||||
yield stub
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _stub_package_install(self):
|
||||
with patch("pm.ensure") as ensure:
|
||||
yield ensure
|
||||
|
||||
|
||||
|
||||
@pytest.mark.parametrize("failure", ["pm", "timeout"])
|
||||
def test_install_failure_reports_error(self, failure):
|
||||
@pytest.mark.parametrize("failure", ["pm", "worker"])
|
||||
def test_install_failure_reports_error(self, failure, tmp_path):
|
||||
import pm
|
||||
|
||||
error = (pm.InstallError("agent-browser", "fatal: network error") if failure == "pm"
|
||||
else subprocess.TimeoutExpired(cmd=["agent-browser"], timeout=600))
|
||||
error = (pm.InstallError("chromium", "fatal: network error") if failure == "pm"
|
||||
else RuntimeError("worker exited unsuccessfully"))
|
||||
with patch("pm.ensure", side_effect=error), patch(
|
||||
"tools.browser_tool_install._running_in_docker", return_value=False
|
||||
"hermes_cli.browser_runtime.chromium_executable", return_value=None
|
||||
), patch("hermes_cli.browser_runtime.BAKED_EXECUTABLE_FILE", tmp_path / "absent"), patch(
|
||||
"hermes_constants.is_termux", return_value=False
|
||||
), patch("hermes_cli.tools_config_post_setup._print_warning") as warn, patch(
|
||||
"hermes_cli.tools_config_post_setup._print_info"
|
||||
) as info:
|
||||
_run_post_setup("agent_browser")
|
||||
_run_post_setup("chromium")
|
||||
|
||||
assert any(str(error) in c.args[0] for c in warn.call_args_list)
|
||||
assert any("hermes tools post-setup agent_browser" in c.args[0] for c in info.call_args_list)
|
||||
assert any("hermes tools post-setup chromium" in c.args[0] for c in info.call_args_list)
|
||||
|
||||
|
||||
class TestBrowserUseCliInstalledForAllNonCamofoxBackends:
|
||||
"""The Browser Use CLI is the primary driver engine for every browser
|
||||
backend except Camofox — so EVERY browser picker selection except
|
||||
Camofox must attempt the CLI install, not just the explicit
|
||||
"Browser Use" row."""
|
||||
class TestBrowserExecEngineCheckedForEveryBrowserRow:
|
||||
"""browser-harness is browser_exec's engine for every browser row, so each hook confirms it."""
|
||||
|
||||
@pytest.mark.parametrize("key", ["agent_browser", "browserbase", "browser_use_cli"])
|
||||
@pytest.mark.parametrize("key", ["chromium", "browserbase", "lightpanda"])
|
||||
def test_browser_post_setup_attempts_cli_install(self, key):
|
||||
with patch("hermes_cli.tools_config_post_setup._ensure_browser_use_cli") as ensure, patch(
|
||||
"shutil.which", return_value=None
|
||||
), patch("subprocess.run"), patch("pm.ensure"): # the managed driver install is PM's, not this test's
|
||||
), patch("subprocess.run"), patch("pm.ensure"), patch(
|
||||
"hermes_cli.browser_runtime.chromium_executable", return_value="/pm/chrome"
|
||||
), patch("tools.browser_lightpanda.find_lightpanda_binary", return_value=None):
|
||||
_run_post_setup(key)
|
||||
ensure.assert_called_once()
|
||||
|
||||
def test_camofox_post_setup_never_touches_browser_use(self):
|
||||
"""Camofox is Firefox-based with no CDP surface; the CDP-only
|
||||
browser-use harness cannot drive it, so its setup must not pull
|
||||
the CLI in."""
|
||||
with patch("hermes_cli.tools_config_post_setup._ensure_browser_use_cli") as ensure, patch(
|
||||
"hermes_constants.find_node_executable", return_value=None
|
||||
), patch("subprocess.run"):
|
||||
_run_post_setup("camofox")
|
||||
ensure.assert_not_called()
|
||||
|
||||
def test_ensure_helper_missing_harness_is_non_fatal(self):
|
||||
"""A missing harness must warn and point at `hermes update`, never raise — the built-in
|
||||
tools remain available."""
|
||||
"""A missing harness must warn and point at `hermes update`, never raise."""
|
||||
from hermes_cli.tools_config import _ensure_browser_use_cli
|
||||
|
||||
with patch("tools.browser_use_cli._find_cli", return_value=None), patch(
|
||||
|
||||
@@ -2,8 +2,6 @@
|
||||
|
||||
import os
|
||||
import sys
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
from threading import Thread
|
||||
from types import ModuleType
|
||||
from unittest.mock import patch
|
||||
|
||||
@@ -14,17 +12,22 @@ from hermes_cli.tools_config_post_setup import _run_post_setup
|
||||
|
||||
|
||||
@pytest.mark.platforms('linux')
|
||||
@pytest.mark.parametrize('provider,docker,playwright', [
|
||||
('browserbase', False, False), ('agent_browser', False, True),
|
||||
('agent_browser', False, False), ('agent_browser', True, False),
|
||||
('browser_use_cli', False, False),
|
||||
@pytest.mark.parametrize('provider,docker_image,playwright', [
|
||||
('browserbase', False, False), ('chromium', False, True),
|
||||
('chromium', False, False), ('chromium', True, False),
|
||||
])
|
||||
def test_browser_setup_respects_provider_and_native_owner(monkeypatch, capsys, provider, docker, playwright):
|
||||
def test_browser_setup_respects_provider_and_native_owner(monkeypatch, capsys, tmp_path, provider, docker_image,
|
||||
playwright):
|
||||
baked = tmp_path / 'chromium-executable-path'
|
||||
if docker_image:
|
||||
baked.write_text('/opt/hermes/chromium/chrome', encoding='utf-8')
|
||||
found = iter([None, None if docker_image else '/pm/chromium/chrome'])
|
||||
with (
|
||||
patch('tools.browser_use_cli._find_cli', return_value=['harness']) as install,
|
||||
patch('tools.browser_tool_install._running_in_docker', return_value=docker),
|
||||
patch('tools.browser_tool_install._find_agent_browser', return_value='/image/agent-browser'),
|
||||
patch('tools.browser_tool_install._chromium_installed', return_value=False) as chromium,
|
||||
patch('hermes_cli.browser_runtime.BAKED_EXECUTABLE_FILE', baked),
|
||||
patch('hermes_cli.browser_runtime.chromium_executable', side_effect=lambda **_kw: next(found)) as chromium,
|
||||
patch('hermes_constants.is_termux', return_value=False),
|
||||
patch('hermes_platform.host.runtime.is_termux', return_value=False),
|
||||
patch('importlib.util.find_spec', return_value=object() if playwright else None),
|
||||
patch('shutil.which', return_value='/usr/bin/apt-get'),
|
||||
patch('pm.ensure') as ensure, patch('subprocess.run') as run,
|
||||
@@ -36,14 +39,15 @@ def test_browser_setup_respects_provider_and_native_owner(monkeypatch, capsys, p
|
||||
run.assert_not_called()
|
||||
if provider == 'browserbase':
|
||||
chromium.assert_not_called()
|
||||
if provider == 'browser_use_cli' or docker:
|
||||
if provider == 'browserbase' or docker_image:
|
||||
ensure.assert_not_called()
|
||||
else:
|
||||
ensure.assert_called_once_with('agent-browser', explicit=True)
|
||||
ensure.assert_called_once_with('chromium', explicit=True)
|
||||
output = capsys.readouterr().out
|
||||
if docker:
|
||||
assert 'Docker' in output
|
||||
elif provider == 'agent_browser':
|
||||
if docker_image:
|
||||
assert 'image' in output
|
||||
elif provider == 'chromium':
|
||||
assert '/pm/chromium/chrome' in output
|
||||
assert 'system' in output and 'npx' not in output
|
||||
if playwright:
|
||||
assert sys.executable in output and '-m playwright install-deps chromium' in output
|
||||
@@ -51,46 +55,19 @@ def test_browser_setup_respects_provider_and_native_owner(monkeypatch, capsys, p
|
||||
assert 'https://playwright.dev/python/docs/browsers#install-system-dependencies' in output
|
||||
|
||||
|
||||
@pytest.mark.parametrize("status", [200, 503])
|
||||
def test_camofox_setup_leaves_external_server_and_config_owned_by_user(monkeypatch, capsys, status):
|
||||
from hermes_cli.config import get_hermes_home
|
||||
@pytest.mark.parametrize('harness,chromium,ready', [
|
||||
(True, '/pm/chromium/chrome', True), (True, None, False), (False, '/pm/chromium/chrome', False),
|
||||
])
|
||||
def test_local_browser_row_ready_only_when_setup_would_be_a_noop(harness, chromium, ready):
|
||||
from hermes_cli.tools_config_post_setup import _POST_SETUP_READY
|
||||
|
||||
class Health(BaseHTTPRequestHandler):
|
||||
def do_GET(self):
|
||||
assert self.path == "/health"
|
||||
self.send_response(status)
|
||||
self.end_headers()
|
||||
self.wfile.write(b"{}")
|
||||
|
||||
def log_message(self, format, *args):
|
||||
pass
|
||||
|
||||
config_path = get_hermes_home() / "config.yaml"
|
||||
config_path.write_text("browser:\n cloud_provider: camofox\n", encoding="utf-8")
|
||||
before = config_path.read_bytes()
|
||||
with ThreadingHTTPServer(("127.0.0.1", 0), Health) as server:
|
||||
thread = Thread(target=server.serve_forever, daemon=True)
|
||||
thread.start()
|
||||
monkeypatch.setenv("CAMOFOX_URL", f"http://127.0.0.1:{server.server_port}")
|
||||
try:
|
||||
with (
|
||||
patch("hermes_constants.find_node_executable", return_value="/external/npm"),
|
||||
patch("subprocess.run") as run,
|
||||
patch("pm.ensure") as ensure,
|
||||
):
|
||||
_run_post_setup("camofox")
|
||||
run.assert_not_called()
|
||||
ensure.assert_not_called()
|
||||
assert _POST_SETUP_READY["camofox"]() is (status == 200)
|
||||
finally:
|
||||
server.shutdown()
|
||||
thread.join(timeout=5)
|
||||
|
||||
assert config_path.read_bytes() == before
|
||||
output = capsys.readouterr().out
|
||||
assert "externally managed" in output
|
||||
assert ("reachable" if status == 200 else "not reachable") in output
|
||||
with (
|
||||
patch('tools.browser_use_cli._find_cli', return_value=['harness'] if harness else None),
|
||||
patch('hermes_cli.browser_runtime.chromium_executable', return_value=chromium),
|
||||
):
|
||||
assert _POST_SETUP_READY['chromium']() is ready
|
||||
# Cloud rows need only browser_exec's engine: the vendor hosts Chromium.
|
||||
assert _POST_SETUP_READY['browserbase']() is harness
|
||||
|
||||
|
||||
@pytest.mark.parametrize("key,extra", [
|
||||
|
||||
Reference in new issue
Block a user