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:
Teknium committed 2026-10-23 01:41:25 -07:00
1 parent 1ac18d2b12
commit 934f4ca1f5
19 files changed
+312 -568

No files matched your search

+13 -21
View File
@@ -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
View File
@@ -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"]),
+24 -35
View File
@@ -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"))
),
}
+1 -1
View File
@@ -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",
+5 -6
View File
@@ -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)
+3 -3
View File
@@ -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)
+5 -12
View File
@@ -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": {
+37 -77
View File
@@ -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()}
+3 -54
View File
@@ -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"):
+2 -2
View File
@@ -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
View File
@@ -359,7 +359,7 @@
},
{
"path": "hermes_cli/tools_config_post_setup.py",
"symbol": "_post_setup_agent_browser",
"symbol": "_post_setup_chromium",
"kind": "bare_which"
},
{
+49 -31
View File
@@ -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
+4 -7
View File
@@ -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
+31 -18
View File
@@ -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:
+31 -111
View File
@@ -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
+7 -21
View File
@@ -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
+17 -35
View File
@@ -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", [