mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-01 12:02:05 +00:00
Merge pull request #54357 from NousResearch/bb/browser-chromium-autoinstall
feat(browser): auto-install Chromium binary on local cold-start failure
This commit is contained in:
commit
b699d27a4a
2 changed files with 185 additions and 1 deletions
106
tests/tools/test_browser_chromium_autoinstall.py
Normal file
106
tests/tools/test_browser_chromium_autoinstall.py
Normal file
|
|
@ -0,0 +1,106 @@
|
|||
"""Tests for gated Chromium-binary auto-install on local cold start."""
|
||||
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
import tools.browser_tool as bt
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _reset_state():
|
||||
bt._chromium_autoinstall_attempted = False
|
||||
bt._cached_chromium_installed = None
|
||||
yield
|
||||
bt._chromium_autoinstall_attempted = False
|
||||
bt._cached_chromium_installed = None
|
||||
|
||||
|
||||
def _no_subprocess(monkeypatch):
|
||||
calls = []
|
||||
monkeypatch.setattr(bt.subprocess, "run", lambda *a, **k: calls.append((a, k)))
|
||||
return calls
|
||||
|
||||
|
||||
class TestGating:
|
||||
def test_disabled_lazy_installs_skips(self, monkeypatch):
|
||||
monkeypatch.setattr(bt, "_running_in_docker", lambda: False)
|
||||
monkeypatch.setattr("tools.lazy_deps._allow_lazy_installs", lambda: False)
|
||||
calls = _no_subprocess(monkeypatch)
|
||||
assert bt._maybe_autoinstall_chromium() is False
|
||||
assert calls == []
|
||||
|
||||
def test_docker_skips(self, monkeypatch):
|
||||
monkeypatch.setattr(bt, "_running_in_docker", lambda: True)
|
||||
calls = _no_subprocess(monkeypatch)
|
||||
assert bt._maybe_autoinstall_chromium() is False
|
||||
assert calls == []
|
||||
|
||||
|
||||
class TestInstall:
|
||||
def test_success_installs_binary_only_and_rechecks(self, monkeypatch):
|
||||
monkeypatch.setattr(bt, "_running_in_docker", lambda: False)
|
||||
monkeypatch.setattr("tools.lazy_deps._allow_lazy_installs", lambda: True)
|
||||
monkeypatch.setattr(bt, "_find_agent_browser", lambda: "/x/agent-browser")
|
||||
monkeypatch.setattr(bt, "_build_browser_env", lambda: {})
|
||||
monkeypatch.setattr(bt, "_chromium_installed", lambda: True)
|
||||
|
||||
captured = {}
|
||||
|
||||
def fake_run(cmd, **kw):
|
||||
captured["cmd"] = cmd
|
||||
return SimpleNamespace(returncode=0, stdout="", stderr="")
|
||||
|
||||
monkeypatch.setattr(bt.subprocess, "run", fake_run)
|
||||
|
||||
assert bt._maybe_autoinstall_chromium() is True
|
||||
assert captured["cmd"] == ["/x/agent-browser", "install"]
|
||||
assert "--with-deps" not in captured["cmd"]
|
||||
|
||||
def test_npx_form_is_binary_only(self, monkeypatch):
|
||||
monkeypatch.setattr(bt, "_running_in_docker", lambda: False)
|
||||
monkeypatch.setattr("tools.lazy_deps._allow_lazy_installs", lambda: True)
|
||||
monkeypatch.setattr(bt, "_find_agent_browser", lambda: "npx agent-browser")
|
||||
monkeypatch.setattr(bt, "_build_browser_env", lambda: {})
|
||||
monkeypatch.setattr(bt, "_chromium_installed", lambda: True)
|
||||
monkeypatch.setattr(bt.shutil, "which", lambda _: "/usr/bin/npx")
|
||||
|
||||
captured = {}
|
||||
monkeypatch.setattr(
|
||||
bt.subprocess, "run",
|
||||
lambda cmd, **kw: captured.update(cmd=cmd) or SimpleNamespace(returncode=0, stdout="", stderr=""),
|
||||
)
|
||||
|
||||
assert bt._maybe_autoinstall_chromium() is True
|
||||
assert captured["cmd"] == ["/usr/bin/npx", "-y", "agent-browser", "install"]
|
||||
assert "--with-deps" not in captured["cmd"]
|
||||
|
||||
def test_nonzero_exit_returns_false(self, monkeypatch):
|
||||
monkeypatch.setattr(bt, "_running_in_docker", lambda: False)
|
||||
monkeypatch.setattr("tools.lazy_deps._allow_lazy_installs", lambda: True)
|
||||
monkeypatch.setattr(bt, "_find_agent_browser", lambda: "/x/agent-browser")
|
||||
monkeypatch.setattr(bt, "_build_browser_env", lambda: {})
|
||||
monkeypatch.setattr(
|
||||
bt.subprocess, "run",
|
||||
lambda *a, **k: SimpleNamespace(returncode=1, stdout="", stderr="boom"),
|
||||
)
|
||||
assert bt._maybe_autoinstall_chromium() is False
|
||||
|
||||
|
||||
class TestOneShot:
|
||||
def test_second_call_does_not_reinstall(self, monkeypatch):
|
||||
monkeypatch.setattr(bt, "_running_in_docker", lambda: False)
|
||||
monkeypatch.setattr("tools.lazy_deps._allow_lazy_installs", lambda: True)
|
||||
monkeypatch.setattr(bt, "_find_agent_browser", lambda: "/x/agent-browser")
|
||||
monkeypatch.setattr(bt, "_build_browser_env", lambda: {})
|
||||
monkeypatch.setattr(bt, "_chromium_installed", lambda: True)
|
||||
|
||||
runs = []
|
||||
monkeypatch.setattr(
|
||||
bt.subprocess, "run",
|
||||
lambda *a, **k: runs.append(1) or SimpleNamespace(returncode=0, stdout="", stderr=""),
|
||||
)
|
||||
|
||||
assert bt._maybe_autoinstall_chromium() is True
|
||||
assert bt._maybe_autoinstall_chromium() is True
|
||||
assert len(runs) == 1
|
||||
|
|
@ -2173,7 +2173,12 @@ def _run_browser_command(
|
|||
# Local mode with no Chromium on disk: fail fast with an actionable
|
||||
# message instead of hanging for _command_timeout seconds per call.
|
||||
# Skip when engine=lightpanda — LP doesn't need Chromium for navigation.
|
||||
if _is_local_mode() and not _chromium_installed() and _get_browser_engine() != "lightpanda":
|
||||
if (
|
||||
_is_local_mode()
|
||||
and not _chromium_installed()
|
||||
and _get_browser_engine() != "lightpanda"
|
||||
and not _maybe_autoinstall_chromium()
|
||||
):
|
||||
if _running_in_docker():
|
||||
hint = (
|
||||
"Chromium browser is missing. You're running in Docker — pull "
|
||||
|
|
@ -3989,6 +3994,8 @@ def cleanup_all_browsers() -> None:
|
|||
_cached_command_timeout = None
|
||||
_command_timeout_resolved = False
|
||||
_cached_chromium_installed = None
|
||||
global _chromium_autoinstall_attempted
|
||||
_chromium_autoinstall_attempted = False
|
||||
_cached_browser_engine = None
|
||||
_browser_engine_resolved = False
|
||||
|
||||
|
|
@ -4091,6 +4098,77 @@ def _chromium_installed() -> bool:
|
|||
return False
|
||||
|
||||
|
||||
# One-shot per process: a 170MB download that fails (or is slow) must not be
|
||||
# retried on every browser call. Reset by _reset_browser_caches() for tests.
|
||||
_chromium_autoinstall_attempted = False
|
||||
|
||||
|
||||
def _maybe_autoinstall_chromium() -> bool:
|
||||
"""Best-effort, gated download of the Chromium *binary* on local cold start.
|
||||
|
||||
Closes the "the PR doesn't actually install the missing browser" gap for
|
||||
the common case — a Chromium binary that was simply never downloaded.
|
||||
Scope is deliberately narrow:
|
||||
|
||||
- Binary only (``agent-browser install``), never ``--with-deps`` — that
|
||||
shells ``apt`` and needs root, so missing *system libraries* stay a user
|
||||
action (the timeout/blocked hints already point there).
|
||||
- Gated by ``security.allow_lazy_installs`` (same opt-out as every other
|
||||
lazy install) and skipped in Docker, where Chromium ships in the image.
|
||||
- Attempted once per process.
|
||||
|
||||
Returns True only when Chromium is present afterwards.
|
||||
"""
|
||||
global _chromium_autoinstall_attempted
|
||||
if _chromium_autoinstall_attempted:
|
||||
return _chromium_installed()
|
||||
_chromium_autoinstall_attempted = True
|
||||
|
||||
if _running_in_docker():
|
||||
return False
|
||||
|
||||
from tools.lazy_deps import _allow_lazy_installs
|
||||
if not _allow_lazy_installs():
|
||||
return False
|
||||
|
||||
try:
|
||||
browser_cmd = _find_agent_browser()
|
||||
except FileNotFoundError:
|
||||
return False
|
||||
|
||||
if browser_cmd == "npx agent-browser":
|
||||
install_cmd = [shutil.which("npx") or "npx", "-y", "agent-browser", "install"]
|
||||
else:
|
||||
install_cmd = [browser_cmd, "install"]
|
||||
|
||||
logger.info(
|
||||
"browser: Chromium missing — auto-installing the browser binary "
|
||||
"(one-time ~170MB; disable via security.allow_lazy_installs)"
|
||||
)
|
||||
try:
|
||||
proc = subprocess.run(
|
||||
install_cmd,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=600,
|
||||
env=_build_browser_env(),
|
||||
)
|
||||
except (OSError, subprocess.SubprocessError) as e:
|
||||
logger.warning("browser: Chromium auto-install failed to start: %s", e)
|
||||
return False
|
||||
|
||||
if proc.returncode != 0:
|
||||
tail = (proc.stderr or proc.stdout or "").strip()[-300:]
|
||||
logger.warning(
|
||||
"browser: Chromium auto-install exited %s: %s", proc.returncode, tail
|
||||
)
|
||||
return False
|
||||
|
||||
global _cached_chromium_installed
|
||||
_cached_chromium_installed = None
|
||||
return _chromium_installed()
|
||||
|
||||
|
||||
def _running_in_docker() -> bool:
|
||||
"""Best-effort detection of whether we're inside a Docker container."""
|
||||
if os.path.exists("/.dockerenv"):
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue