diff --git a/src/browser_harness/admin.py b/src/browser_harness/admin.py index f4b634d0..c7f3477c 100644 --- a/src/browser_harness/admin.py +++ b/src/browser_harness/admin.py @@ -646,6 +646,38 @@ def _chrome_running(): return False +def _detect_snap_browser(): + """On Linux, return the snap-confined browser path if a Chromium-based + browser is running from /snap/. Returns None on non-Linux, when no + matching process is found, or when the process check fails. Snap + confinement on Ubuntu/Lubuntu prevents binding to the CDP port (9222) + even with --no-sandbox, so the harness's auto-discovery fails silently + or with "Connection Refused"; surface this as a doctor warning so users + can install a non-snap browser before debugging the connection error. + """ + import platform, subprocess + if platform.system() != "Linux": + return None + try: + out = subprocess.check_output( + ["ps", "-A", "-o", "args="], text=True, timeout=5, + ) + except Exception: + return None + snap_path_markers = ("/snap/chromium/", "/snap/firefox/", "/snap/google-chrome/") + browser_basenames = ("chrome", "chromium", "msedge", "firefox") + for line in out.splitlines(): + first = line.strip().split() + if not first: + continue + path = first[0] + if any(marker in path for marker in snap_path_markers): + basename = path.rsplit("/", 1)[-1].lower() + if any(b in basename for b in browser_basenames): + return path + return None + + def _open_chrome_inspect(): """Open chrome://inspect/#remote-debugging so the user can tick the checkbox.""" import platform, subprocess, webbrowser @@ -695,6 +727,18 @@ def row(label, ok, detail=""): else: print(" latest release (could not reach github)") row("chrome running", chrome, "" if chrome else "start chrome/edge") + snap_path = _detect_snap_browser() + if snap_path: + # Snap confinement runs the browser in a restricted mount namespace + # that blocks binding to CDP port 9222. The user will see the + # connection fail later; flagging it here saves the troubleshooting + # round-trip. Issue #191 has the long-form context. + print( + " [WARN] snap-confined browser detected — " + f"{snap_path}: install a non-snap chromium/chrome (e.g. " + "https://www.google.com/chrome/?platform=linux or `apt install " + "chromium-browser` from a non-snap PPA) for reliable CDP attach" + ) row("daemon alive", daemon, "" if daemon else "see install.md") row("active browser connections", bool(connections), str(len(connections))) for conn in connections: diff --git a/tests/unit/test_admin.py b/tests/unit/test_admin.py index 6826b399..ffc81a3a 100644 --- a/tests/unit/test_admin.py +++ b/tests/unit/test_admin.py @@ -137,6 +137,7 @@ def test_run_doctor_prints_active_browser_connections_and_active_pages(monkeypat monkeypatch.setattr(admin, "_version", lambda: "0.1.0") monkeypatch.setattr(admin, "_install_mode", lambda: "git") monkeypatch.setattr(admin, "_chrome_running", lambda: True) + monkeypatch.setattr(admin, "_detect_snap_browser", lambda: None) monkeypatch.setattr(admin, "daemon_alive", lambda: True) monkeypatch.setattr(admin, "browser_connections", lambda: [ { @@ -537,3 +538,109 @@ def test_process_start_time_returns_none_for_invalid_pid(): ) # 2**31 - 1 is the largest pid_t; in practice no live process at that PID. assert admin._process_start_time((1 << 31) - 1) is None + + +def test_detect_snap_browser_returns_none_on_non_linux(monkeypatch): + monkeypatch.setattr("platform.system", lambda: "Darwin") + assert admin._detect_snap_browser() is None + + +def test_detect_snap_browser_returns_none_when_ps_fails(monkeypatch): + monkeypatch.setattr("platform.system", lambda: "Linux") + + def boom(*args, **kwargs): + raise OSError("ps not found") + + monkeypatch.setattr("subprocess.check_output", boom) + assert admin._detect_snap_browser() is None + + +def test_detect_snap_browser_returns_none_when_no_browser_running(monkeypatch): + monkeypatch.setattr("platform.system", lambda: "Linux") + monkeypatch.setattr( + "subprocess.check_output", + lambda *args, **kwargs: "/usr/lib/systemd/systemd\n/usr/bin/dbus-daemon\n", + ) + assert admin._detect_snap_browser() is None + + +def test_detect_snap_browser_flags_snap_chromium(monkeypatch): + monkeypatch.setattr("platform.system", lambda: "Linux") + monkeypatch.setattr( + "subprocess.check_output", + lambda *args, **kwargs: ( + "/usr/lib/systemd/systemd\n" + "/snap/chromium/2876/usr/lib/chromium-browser/chromium --type=renderer\n" + "/usr/bin/dbus-daemon\n" + ), + ) + path = admin._detect_snap_browser() + assert path is not None + assert "/snap/chromium/" in path + + +def test_detect_snap_browser_flags_snap_firefox(monkeypatch): + monkeypatch.setattr("platform.system", lambda: "Linux") + monkeypatch.setattr( + "subprocess.check_output", + lambda *args, **kwargs: "/snap/firefox/4848/usr/lib/firefox/firefox\n", + ) + path = admin._detect_snap_browser() + assert path is not None + assert "/snap/firefox/" in path + + +def test_detect_snap_browser_ignores_non_browser_snaps(monkeypatch): + monkeypatch.setattr("platform.system", lambda: "Linux") + monkeypatch.setattr( + "subprocess.check_output", + lambda *args, **kwargs: "/snap/code/198/usr/share/code/code\n", + ) + assert admin._detect_snap_browser() is None + + +def test_detect_snap_browser_ignores_non_snap_chromium(monkeypatch): + monkeypatch.setattr("platform.system", lambda: "Linux") + monkeypatch.setattr( + "subprocess.check_output", + lambda *args, **kwargs: "/usr/bin/chromium --type=renderer\n", + ) + assert admin._detect_snap_browser() is None + + +def test_run_doctor_emits_snap_warning_when_snap_browser_detected(monkeypatch, capsys): + monkeypatch.setattr(admin, "_version", lambda: "0.1.0") + monkeypatch.setattr(admin, "_install_mode", lambda: "git") + monkeypatch.setattr(admin, "_chrome_running", lambda: True) + monkeypatch.setattr( + admin, "_detect_snap_browser", + lambda: "/snap/chromium/2876/usr/lib/chromium-browser/chromium", + ) + monkeypatch.setattr(admin, "daemon_alive", lambda: True) + monkeypatch.setattr(admin, "browser_connections", lambda: []) + monkeypatch.setattr(admin, "_latest_release_tag", lambda: "0.1.0") + monkeypatch.setattr("shutil.which", lambda _cmd: None) + monkeypatch.delenv("BROWSER_USE_API_KEY", raising=False) + + admin.run_doctor() + + out = capsys.readouterr().out + assert "[WARN] snap-confined browser detected" in out + assert "/snap/chromium/" in out + + +def test_run_doctor_skips_snap_warning_when_no_snap_browser(monkeypatch, capsys): + monkeypatch.setattr(admin, "_version", lambda: "0.1.0") + monkeypatch.setattr(admin, "_install_mode", lambda: "git") + monkeypatch.setattr(admin, "_chrome_running", lambda: True) + monkeypatch.setattr(admin, "_detect_snap_browser", lambda: None) + monkeypatch.setattr(admin, "daemon_alive", lambda: True) + monkeypatch.setattr(admin, "browser_connections", lambda: []) + monkeypatch.setattr(admin, "_latest_release_tag", lambda: "0.1.0") + monkeypatch.setattr("shutil.which", lambda _cmd: None) + monkeypatch.delenv("BROWSER_USE_API_KEY", raising=False) + + admin.run_doctor() + + out = capsys.readouterr().out + assert "snap-confined" not in out