From 91cbb9512722ed6560d73d0c97074faa1fc6aad8 Mon Sep 17 00:00:00 2001 From: Matt Van Horn Date: Sun, 10 May 2026 04:30:34 -0700 Subject: [PATCH] doctor: warn when running browser is snap-confined Linux Ubuntu/Lubuntu users typically have chromium and firefox installed as snaps. Snap confinement runs the browser in a restricted mount namespace that blocks binding to CDP port 9222 even with --no-sandbox, so browser-harness's auto-discovery fails silently or with "Connection Refused" -- exactly the failure the issue reporter (#191) hit on Lubuntu. Add _detect_snap_browser() that scans 'ps -A -o args=' on Linux for processes whose argv[0] starts with /snap/{chromium,firefox, google-chrome}/ and matches a Chromium-family basename. run_doctor emits a [WARN] line with the snap path and points at non-snap install options when the function returns a hit. Non-Linux platforms short-circuit to None. ps failures (binary missing, timeout, OSError) also return None -- the doctor degrades to the existing checks instead of failing. 8 new tests cover: non-Linux skip, ps failure skip, no-browser skip, snap chromium hit, snap firefox hit, non-browser snap ignored (vscode), non-snap chromium ignored (apt-installed), doctor emits the warning row, doctor skips the row when no snap detected. All 103 existing tests still pass. This is the smallest of the four "Suggestions for Improvement" in #191. The other three (headless bootstrap mode, python3-pip docs, _chrome_running portable-binary discovery) are larger and likely want maintainer scoping first per Alezander9's structured- timeline ask in the issue thread. Refs #191. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/browser_harness/admin.py | 44 ++++++++++++++ tests/unit/test_admin.py | 107 +++++++++++++++++++++++++++++++++++ 2 files changed, 151 insertions(+) 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