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:
committed by
Brooklyn Nicholson
parent
ed5e17f4b8
commit
baa6b2e34d
@@ -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
|
||||
|
||||
@@ -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)")
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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]:
|
||||
|
||||
Reference in New Issue
Block a user