feat(browser): auto-install the Browser Use CLI instead of silently downgrading

The Browser Use CLI became the default browser backend, but nothing
provisioned it: users without uv/uvx (field report from DongyangHe on
macOS) silently fell back to the built-in browser tools with no notice.

- install_cli() in tools/browser_use_cli.py: uv tool install browser-use
  via the managed uv (bootstrapped on demand), linked into
  $HERMES_HOME/bin (UV_TOOL_BIN_DIR)
- _find_cli() now also probes $HERMES_HOME/bin for browser-use/uvx —
  Hermes' managed uv is not on the user's PATH
- hermes tools post_setup actually installs (Camofox standard) instead
  of printing instructions
- install.sh / install.ps1 provision the CLI at install time
  (best-effort, non-fatal, honors --skip-browser)
- CLI startup shows a one-line notice (24h rate-limited) when the
  default backend downgraded to the built-in tools
This commit is contained in:
Teknium
2026-08-11 02:06:07 -07:00
committed by Brooklyn Nicholson
parent ed5e17f4b8
commit baa6b2e34d
6 changed files with 398 additions and 16 deletions
+22
View File
@@ -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
+16 -5
View File
@@ -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)")
+44
View File
@@ -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
+37
View File
@@ -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
+130 -3
View File
@@ -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
+149 -8
View File
@@ -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]: