diff --git a/tests/tools/test_browser_chromium_autoinstall.py b/tests/tools/test_browser_chromium_autoinstall.py new file mode 100644 index 00000000000..26eb71de8ab --- /dev/null +++ b/tests/tools/test_browser_chromium_autoinstall.py @@ -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 diff --git a/tools/browser_tool.py b/tools/browser_tool.py index 3afc63346da..ffd69e6bdef 100644 --- a/tools/browser_tool.py +++ b/tools/browser_tool.py @@ -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"):