diff --git a/cli.py b/cli.py index c72a3fc26e..77642dadb6 100644 --- a/cli.py +++ b/cli.py @@ -7358,6 +7358,25 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin, CLIBillingMixin): # logged at DEBUG by the advisory module. pass + def _show_browser_backend_notice(self): + """One-time hint when the default Browser Use backend isn't runnable. + + Browser Use mode is the default browser backend, but it silently + falls back to the built-in browser tools when neither the + browser-use CLI nor uvx can be found. Surface that downgrade once + per 24h so users know why browsing behaves differently and how to + fix it (rate limiting lives in default_downgrade_notice()). + """ + try: + from tools.browser_use_cli import default_downgrade_notice + + notice = default_downgrade_notice() + if notice: + self._console_print(f"[yellow]⚠ {notice}[/yellow]") + except Exception: + # Never let a hint block startup. + logger.debug("browser backend notice failed", exc_info=True) + def finalize_preloaded_skills(self) -> None: """Join the background --skills preload and fold it into the prompt. @@ -15295,6 +15314,9 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin, CLIBillingMixin): # Surface any active supply-chain security advisories right after the # welcome banner. Quiet/single-query paths call this themselves. self._show_security_advisories() + # Surface a silent browser-backend downgrade (default Browser Use + # mode with no runnable CLI) — one line, rate-limited to 24h. + self._show_browser_backend_notice() # First-run: a completely unconfigured install must route into # provider onboarding, not a chat that cannot work. Previously a diff --git a/hermes_cli/tools_config.py b/hermes_cli/tools_config.py index d713364dcd..a32b5368cf 100644 --- a/hermes_cli/tools_config.py +++ b/hermes_cli/tools_config.py @@ -1772,12 +1772,23 @@ def _run_post_setup(post_setup_key: str): elif post_setup_key == "browser_use_cli": if shutil.which("browser-use"): _print_success(" browser-use CLI found on PATH") - elif shutil.which("uvx"): - _print_info(" browser-use CLI not installed — it will run via `uvx browser-use`") - _print_info(" For a persistent install: uv tool install browser-use") else: - _print_warning(" browser-use CLI not found and uvx is unavailable") - _print_info(" Install with: uv tool install browser-use (https://docs.astral.sh/uv/)") + _print_info(" Installing browser-use CLI (uv tool install browser-use)...") + try: + from tools.browser_use_cli import install_cli + + ok, message = install_cli() + except Exception as exc: # pragma: no cover — defensive + ok, message = False, f"install failed: {exc}" + if ok: + _print_success(f" {message}") + else: + for line in str(message).splitlines(): + _print_warning(f" {line[:200]}") + if shutil.which("uvx"): + _print_info(" Falling back to zero-install runs via `uvx browser-use`") + else: + _print_info(" Install manually: uv tool install browser-use (https://docs.astral.sh/uv/)") _print_info(" Local Chrome needs remote debugging: chrome://inspect/#remote-debugging") _print_info(" Cloud browsers: browser-use auth login (or set BROWSER_USE_API_KEY)") diff --git a/scripts/install.ps1 b/scripts/install.ps1 index a0fa6591b5..1b6ea68da0 100644 --- a/scripts/install.ps1 +++ b/scripts/install.ps1 @@ -3156,6 +3156,50 @@ function Install-NodeDeps { $tuiLog = "$env:TEMP\hermes-npm-tui-$(Get-Random).log" [void](_Run-NpmInstall "TUI" $tuiDir $tuiLog $npmExe) } + + Install-BrowserUseCli +} + +# The Browser Use CLI is the default browser backend when it is runnable +# (tools/browser_use_cli.py). Provision it at install time so fresh installs +# don't silently fall back to the built-in browser tools. Best-effort: any +# failure is non-fatal (browser_exec can still run via uvx, and `hermes tools` +# can install it later). +function Install-BrowserUseCli { + if (-not $script:UvCmd) { Resolve-UvCmd } + if (-not $script:UvCmd) { + Write-Info "Skipping Browser Use CLI install (uv unavailable)" + return + } + $managedBin = Join-Path $HermesHome "bin" + $managedBu = Join-Path $managedBin "browser-use.exe" + if ((Get-Command browser-use -ErrorAction SilentlyContinue) -or (Test-Path $managedBu)) { + Write-Success "Browser Use CLI already installed" + return + } + + Write-Info "Installing Browser Use CLI (default browser backend)..." + $prevEAP = $ErrorActionPreference + $ErrorActionPreference = "Continue" + try { + # UV_TOOL_BIN_DIR keeps the binary inside Hermes' managed bin dir, + # where the browser tool resolves it — no reliance on the user PATH. + $env:UV_TOOL_BIN_DIR = $managedBin + $env:UV_NO_CONFIG = "1" + & $script:UvCmd tool install browser-use 2>&1 | Out-Null + if ($LASTEXITCODE -eq 0) { + Write-Success "Browser Use CLI installed" + } else { + Write-Warn "Browser Use CLI install failed (exit $LASTEXITCODE) -- browser automation falls back to built-in tools." + Write-Info "Install later with: uv tool install browser-use (or via 'hermes tools')" + } + } catch { + Write-Warn "Browser Use CLI install failed: $_" + } finally { + $ErrorActionPreference = $prevEAP + Remove-Item Env:\UV_TOOL_BIN_DIR -ErrorAction SilentlyContinue + Remove-Item Env:\UV_NO_CONFIG -ErrorAction SilentlyContinue + } } # Clear the cached Electron download + any half-written unpacked output so the diff --git a/scripts/install.sh b/scripts/install.sh index 23bb54ca6d..a1c6d4d9dd 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -2400,6 +2400,40 @@ install_node_deps() { restore_dirty_lockfiles "$INSTALL_DIR" } +install_browser_use_cli() { + # The Browser Use CLI is the default browser backend when it is runnable + # (tools/browser_use_cli.py). Provision it here so fresh installs don't + # silently fall back to the built-in browser tools. Best-effort: any + # failure is non-fatal because browser_exec can still run via uvx and + # `hermes tools` can install it later. + if [ "$SKIP_BROWSER" = true ]; then + log_info "Skipping Browser Use CLI install (--skip-browser)" + return 0 + fi + if [ "$DISTRO" = "termux" ]; then + return 0 + fi + if [ -z "$UV_CMD" ]; then + log_info "Skipping Browser Use CLI install (uv unavailable)" + return 0 + fi + if command -v browser-use >/dev/null 2>&1 || [ -x "$HERMES_HOME/bin/browser-use" ]; then + log_success "Browser Use CLI already installed" + return 0 + fi + + log_info "Installing Browser Use CLI (default browser backend)..." + # UV_TOOL_BIN_DIR keeps the binary inside Hermes' managed bin dir, where + # the browser tool resolves it — no reliance on the user's PATH. + if run_with_timeout 600 env UV_NO_CONFIG=1 UV_TOOL_BIN_DIR="$HERMES_HOME/bin" \ + "$UV_CMD" tool install browser-use >/dev/null 2>&1; then + log_success "Browser Use CLI installed" + else + log_warn "Browser Use CLI install failed — browser automation falls back to built-in tools." + log_info "Install later with: $UV_CMD tool install browser-use (or via 'hermes tools')" + fi +} + run_setup_wizard() { if [ "$RUN_SETUP" = false ]; then log_info "Skipping setup wizard (--skip-setup)" @@ -3222,6 +3256,8 @@ run_stage_body() { require_install_dir check_node install_node_deps + install_uv + install_browser_use_cli ;; path) detect_os @@ -3337,6 +3373,7 @@ main() { setup_venv install_deps install_node_deps + install_browser_use_cli setup_path copy_config_templates run_setup_wizard diff --git a/tests/tools/test_browser_use_cli.py b/tests/tools/test_browser_use_cli.py index b7bd2c675e..48fb460a0a 100644 --- a/tests/tools/test_browser_use_cli.py +++ b/tests/tools/test_browser_use_cli.py @@ -151,19 +151,19 @@ class TestFindCli: def test_prefers_installed_binary(self, monkeypatch): monkeypatch.setattr( bu_cli.shutil, "which", - lambda name: "/usr/local/bin/browser-use" if name == "browser-use" else "/usr/local/bin/uvx", + lambda name, path=None: "/usr/local/bin/browser-use" if name == "browser-use" and path is None else ("/usr/local/bin/uvx" if path is None else None), ) assert bu_cli._find_cli_unpatched() == ["/usr/local/bin/browser-use"] def test_falls_back_to_uvx(self, monkeypatch): monkeypatch.setattr( bu_cli.shutil, "which", - lambda name: "/usr/local/bin/uvx" if name == "uvx" else None, + lambda name, path=None: "/usr/local/bin/uvx" if name == "uvx" and path is None else None, ) assert bu_cli._find_cli_unpatched() == ["/usr/local/bin/uvx", "browser-use"] def test_none_when_neither_available(self, monkeypatch): - monkeypatch.setattr(bu_cli.shutil, "which", lambda name: None) + monkeypatch.setattr(bu_cli.shutil, "which", lambda name, path=None: None) assert bu_cli._find_cli_unpatched() is None @@ -691,3 +691,130 @@ class TestBrowserExec: monkeypatch.setattr(bu_cli, "_MIN_TIMEOUT_S", 1) result = json.loads(bu_cli.browser_exec("print(1)", timeout_s=1)) assert "timed out" in result["error"] + + +class TestFindCliManagedBin: + """_find_cli probes $HERMES_HOME/bin after PATH (managed uv/uvx/browser-use).""" + + def test_managed_bin_browser_use_found(self, tmp_path, monkeypatch): + bin_dir = tmp_path / "home" / "bin" + bin_dir.mkdir(parents=True) + bu = bin_dir / "browser-use" + bu.write_text("#!/bin/sh\n") + bu.chmod(bu.stat().st_mode | stat.S_IXUSR) + monkeypatch.setenv("HERMES_HOME", str(tmp_path / "home")) + monkeypatch.setenv("PATH", str(tmp_path / "empty")) + assert bu_cli._find_cli_unpatched() == [str(bu)] + + def test_managed_bin_uvx_fallback(self, tmp_path, monkeypatch): + bin_dir = tmp_path / "home" / "bin" + bin_dir.mkdir(parents=True) + uvx = bin_dir / "uvx" + uvx.write_text("#!/bin/sh\n") + uvx.chmod(uvx.stat().st_mode | stat.S_IXUSR) + monkeypatch.setenv("HERMES_HOME", str(tmp_path / "home")) + monkeypatch.setenv("PATH", str(tmp_path / "empty")) + assert bu_cli._find_cli_unpatched() == [str(uvx), "browser-use"] + + def test_nothing_found(self, tmp_path, monkeypatch): + monkeypatch.setenv("HERMES_HOME", str(tmp_path / "home")) + monkeypatch.setenv("PATH", str(tmp_path / "empty")) + assert bu_cli._find_cli_unpatched() is None + + +class TestInstallCli: + def test_already_installed_on_path(self, tmp_path, monkeypatch): + cli = _fake_cli(tmp_path, "") + monkeypatch.setattr(bu_cli.shutil, "which", lambda name, path=None: cli if name == "browser-use" and path is None else None) + ok, msg = bu_cli.install_cli() + assert ok is True + assert "already installed" in msg + + def test_no_uv_anywhere_fails_with_guidance(self, tmp_path, monkeypatch): + monkeypatch.setenv("HERMES_HOME", str(tmp_path / "home")) + monkeypatch.setenv("PATH", str(tmp_path / "empty")) + import sys as _sys + import types as _types + fake = _types.ModuleType("hermes_cli.managed_uv") + fake.ensure_uv = lambda **kw: None + monkeypatch.setitem(_sys.modules, "hermes_cli.managed_uv", fake) + ok, msg = bu_cli.install_cli() + assert ok is False + assert "uv" in msg + + def test_successful_install_via_fake_uv(self, tmp_path, monkeypatch): + home = tmp_path / "home" + bin_dir = home / "bin" + bin_dir.mkdir(parents=True) + monkeypatch.setenv("HERMES_HOME", str(home)) + monkeypatch.setenv("PATH", str(tmp_path / "empty")) + # install_cli verifies via _find_cli(), which the tests/tools conftest + # pins to None — restore the real resolver for this test. + monkeypatch.setattr(bu_cli, "_find_cli", bu_cli._find_cli_unpatched) + # fake uv: `uv tool install browser-use` drops a binary into UV_TOOL_BIN_DIR. + # Absolute /bin/chmod: PATH is emptied above, so bare chmod won't resolve. + uv = tmp_path / "uv" + uv.write_text( + "#!/bin/sh\n" + 'target="$UV_TOOL_BIN_DIR/browser-use"\n' + 'echo "#!/bin/sh" > "$target"\n' + '/bin/chmod +x "$target"\n' + ) + uv.chmod(uv.stat().st_mode | stat.S_IXUSR) + import sys as _sys + import types as _types + fake = _types.ModuleType("hermes_cli.managed_uv") + fake.ensure_uv = lambda **kw: str(uv) + monkeypatch.setitem(_sys.modules, "hermes_cli.managed_uv", fake) + ok, msg = bu_cli.install_cli() + assert ok is True, msg + assert (bin_dir / "browser-use").exists() + + def test_failed_install_surfaces_stderr_tail(self, tmp_path, monkeypatch): + home = tmp_path / "home" + monkeypatch.setenv("HERMES_HOME", str(home)) + monkeypatch.setenv("PATH", str(tmp_path / "empty")) + uv = tmp_path / "uv" + uv.write_text('#!/bin/sh\necho "no network" >&2\nexit 1\n') + uv.chmod(uv.stat().st_mode | stat.S_IXUSR) + import sys as _sys + import types as _types + fake = _types.ModuleType("hermes_cli.managed_uv") + fake.ensure_uv = lambda **kw: str(uv) + monkeypatch.setitem(_sys.modules, "hermes_cli.managed_uv", fake) + ok, msg = bu_cli.install_cli() + assert ok is False + assert "no network" in msg + + +class TestDefaultDowngradeNotice: + def _isolate(self, tmp_path, monkeypatch): + monkeypatch.setenv("HERMES_HOME", str(tmp_path / "home")) + monkeypatch.setattr("hermes_cli.config.read_raw_config", lambda: {}) + + def test_notice_when_default_and_cli_missing(self, tmp_path, monkeypatch): + self._isolate(tmp_path, monkeypatch) + monkeypatch.setattr(bu_cli, "_find_cli", lambda: None) + notice = bu_cli.default_downgrade_notice() + assert notice is not None + assert "hermes tools" in notice + + def test_rate_limited_within_24h(self, tmp_path, monkeypatch): + self._isolate(tmp_path, monkeypatch) + monkeypatch.setattr(bu_cli, "_find_cli", lambda: None) + assert bu_cli.default_downgrade_notice() is not None + assert bu_cli.default_downgrade_notice() is None + + def test_no_notice_when_cli_runnable(self, tmp_path, monkeypatch): + self._isolate(tmp_path, monkeypatch) + monkeypatch.setattr(bu_cli, "_find_cli", lambda: ["/usr/bin/browser-use"]) + assert bu_cli.default_downgrade_notice() is None + + def test_no_notice_on_explicit_backend(self, tmp_path, monkeypatch): + monkeypatch.setenv("HERMES_HOME", str(tmp_path / "home")) + monkeypatch.setattr( + "hermes_cli.config.read_raw_config", + lambda: {"browser": {"backend": bu_cli.BACKEND_DISABLED}}, + ) + monkeypatch.setattr(bu_cli, "_find_cli", lambda: None) + assert bu_cli.default_downgrade_notice() is None diff --git a/tools/browser_use_cli.py b/tools/browser_use_cli.py index a09e1d01a1..62610ded11 100644 --- a/tools/browser_use_cli.py +++ b/tools/browser_use_cli.py @@ -11,7 +11,8 @@ import re import shutil import subprocess import time -from typing import Any, Dict, List, Optional +from pathlib import Path +from typing import Any, Dict, List, Optional, Tuple from utils import is_truthy_value @@ -134,19 +135,159 @@ def is_browser_use_cli_mode() -> bool: return _find_cli() is not None +_NOTICE_STAMP_NAME = ".browser_use_default_notice" +_NOTICE_INTERVAL_S = 24 * 3600 + + +def default_downgrade_notice() -> Optional[str]: + """One-line notice when the default Browser Use backend silently downgraded. + + Returns the notice string when ``browser.backend`` is unset (Browser Use + would be the default) but the CLI is not runnable, so the session fell + back to the built-in browser tools. Rate-limited to once per 24h via a + stamp file so it nudges without nagging. Returns ``None`` otherwise. + """ + try: + if get_browser_backend(): + return None # explicit choice — nothing downgraded + try: + from tools.browser_camofox import is_camofox_mode + + if is_camofox_mode(): + return None + except Exception: + pass + if _find_cli() is not None: + return None + + from hermes_constants import get_hermes_home + + stamp = Path(get_hermes_home()) / "cache" / _NOTICE_STAMP_NAME + try: + if 0 <= time.time() - stamp.stat().st_mtime < _NOTICE_INTERVAL_S: + return None + except OSError: + pass + try: + stamp.parent.mkdir(parents=True, exist_ok=True) + stamp.touch() + except OSError: + pass + return ( + "Browser Use CLI not found — using the built-in browser tools. " + "Run `hermes tools` (Browser Automation → Browser Use) to install it, " + "or `browser.backend: off` in config.yaml to silence this." + ) + except Exception as e: # pragma: no cover — a notice must never break startup + logger.debug("browser-use downgrade notice failed: %s", e) + return None + + +def _managed_bin_dir() -> Optional[str]: + """Hermes' own bin dir ($HERMES_HOME/bin) — where install.sh puts uv/uvx + and where install_cli() links the browser-use binary.""" + try: + from hermes_constants import get_hermes_home + + return str(Path(get_hermes_home()) / "bin") + except Exception as e: # pragma: no cover — defensive + logger.debug("Could not resolve managed bin dir: %s", e) + return None + + def _find_cli() -> Optional[List[str]]: """Locate the browser-use CLI, or None when it can't be run. - Prefers an installed browser-use binary; falls back to running it - through uvx + Prefers an installed browser-use binary (PATH, then Hermes' managed + $HERMES_HOME/bin); falls back to running it through uvx (PATH, then + managed). The managed probes matter because Hermes bootstraps its own + uv into $HERMES_HOME/bin, which is not on the user's PATH. + """ + bin_dir = _managed_bin_dir() + for probe_path in (None, bin_dir): + if probe_path is None or probe_path: + direct = shutil.which("browser-use", path=probe_path) + if direct: + return [direct] + for probe_path in (None, bin_dir): + if probe_path is None or probe_path: + uvx = shutil.which("uvx", path=probe_path) + if uvx: + return [uvx, "browser-use"] + return None + + +def install_cli(timeout_s: int = 600) -> Tuple[bool, str]: + """Install the browser-use CLI persistently via ``uv tool install``. + + Resolution order for uv: Hermes' managed uv (bootstrapped on demand via + ``hermes_cli.managed_uv.ensure_uv``) → uv on PATH. The binary is linked + into ``$HERMES_HOME/bin`` (``UV_TOOL_BIN_DIR``) so ``_find_cli()`` + resolves it for every profile without touching the user's PATH. + + Returns ``(ok, message)`` — never raises. """ direct = shutil.which("browser-use") if direct: - return [direct] - uvx = shutil.which("uvx") - if uvx: - return [uvx, "browser-use"] - return None + return True, f"browser-use CLI already installed ({direct})" + bin_dir = _managed_bin_dir() + if bin_dir: + managed = shutil.which("browser-use", path=bin_dir) + if managed: + return True, f"browser-use CLI already installed ({managed})" + + uv_bin: Optional[str] = None + try: + from hermes_cli.managed_uv import ensure_uv + + uv_bin = str(ensure_uv() or "") or None + except Exception as e: + logger.debug("Managed uv bootstrap unavailable: %s", e) + if not uv_bin: + uv_bin = shutil.which("uv") + if not uv_bin: + return False, ( + "uv is not available and could not be bootstrapped. Install uv " + "(https://docs.astral.sh/uv/) and run `uv tool install browser-use`." + ) + + env = dict(os.environ) + env["UV_NO_CONFIG"] = "1" + if bin_dir: + try: + Path(bin_dir).mkdir(parents=True, exist_ok=True) + env["UV_TOOL_BIN_DIR"] = bin_dir + except OSError as e: + logger.debug("Could not prepare %s: %s", bin_dir, e) + + try: + result = subprocess.run( + [uv_bin, "tool", "install", "browser-use"], + capture_output=True, + text=True, + encoding="utf-8", + errors="replace", + env=env, + timeout=timeout_s, + ) + except subprocess.TimeoutExpired: + return False, f"`uv tool install browser-use` timed out after {timeout_s}s" + except Exception as e: + return False, f"Failed to run `uv tool install browser-use`: {e}" + + if result.returncode != 0: + tail = "\n".join( + (result.stderr or result.stdout or "").strip().splitlines()[-3:] + ) + return False, f"`uv tool install browser-use` failed:\n{tail}" + + found = _find_cli() + if not found or len(found) != 1: + return False, ( + "install reported success but the browser-use binary is still " + "not resolvable — run `uv tool install browser-use` manually" + ) + return True, f"browser-use CLI installed ({found[0]})" def _workspace_dir(task_id: Optional[str]) -> Optional[str]: