diff --git a/.env.example b/.env.example index 0e1e7579..e97b9dc4 100644 --- a/.env.example +++ b/.env.example @@ -1,2 +1,6 @@ -# Copy to .env — auto-loaded. Only needed for remote browsers. +# Copy to .env — auto-loaded. BROWSER_USE_API_KEY=bu_your_key_here + +# Optional: restrict local browser discovery to one browser family. +# Use chrome when the agent must never attach to Brave/Edge. +BH_BROWSER_FAMILY=chrome diff --git a/SKILL.md b/SKILL.md index 3283efc2..bd844ca2 100644 --- a/SKILL.md +++ b/SKILL.md @@ -19,10 +19,19 @@ print(page_info()) PY ``` +- When the task depends on the user's existing Chrome profile, logged-in sessions, existing tabs, extensions, or GitHub PR attachment uploads, use the Codex Chrome Extension route first (`chrome:control-chrome`). It controls the real signed-in Chrome profile without opening Chrome remote-debugging setup pages. +- Use `browser-harness` raw CDP for local app verification, scraping, screenshots, and deterministic automation that does not require the user's signed-in Chrome profile. +- Respect `BH_BROWSER_FAMILY` when it is set. In this local setup it is `chrome`: do not attach to Brave or Edge, and do not use a browser tool unless it is controlling Google Chrome. - Invoke as `browser-harness`. Use heredocs for multi-line commands. - Helpers are pre-imported. `run.py` calls `ensure_daemon()` before `exec`. - First navigation is `new_tab(url)`, not `goto_url(url)`. -- The normal local flow attaches to the running Chrome/Chromium CDP endpoint. No browser ids or local profile selection. +- The normal local flow attaches to the running Chrome/Chromium CDP endpoint and reuses the user's signed-in profile when Chrome has already allowed remote debugging. +- If Chrome's default-profile HTTP discovery is locked down, the harness first probes the direct `DevToolsActivePort` websocket briefly. If that already-approved profile is reachable, use it; if Chrome is still blocked by the permission prompt, the harness auto-starts a dedicated local automation browser instead of looping on permission dialogs. +- Set `BH_LOCAL_BROWSER_MODE=default` only when you explicitly need the user's default signed-in profile and want the harness to wait for Chrome's permission prompt instead of falling back. +- Set `BH_LOCAL_BROWSER_MODE=dedicated` to always use the no-prompt local automation browser. +- Set `BH_BROWSER_FAMILY=chrome` to restrict local discovery, inspect-page opening, and dedicated browser fallback to Google Chrome only. +- On Windows, never open `chrome://...` through the default browser, shell protocol handler, `start`, or `webbrowser.open`; use the real browser executable path or ask the user to navigate manually. +- If Chrome already has an "Allow remote debugging?" popup or recently-opened inspect tab, do not open another one; wait for the user to click Allow in the existing Chrome window. ## Local Chrome diff --git a/src/browser_harness/admin.py b/src/browser_harness/admin.py index ef7744db..fa486b5d 100644 --- a/src/browser_harness/admin.py +++ b/src/browser_harness/admin.py @@ -11,6 +11,12 @@ from . import _ipc as ipc from . import auth from . import paths +from .browser_family import ( + browser_family_label, + browser_family_mode, + browser_path_allowed, + process_names_for_browser_family, +) def _process_start_time(pid): @@ -130,6 +136,9 @@ def _load_env_file(p): VERSION_CACHE = paths.config_dir() / "version-cache.json" VERSION_CACHE_TTL = 24 * 3600 DOCTOR_TEXT_LIMIT = 140 +CHROME_INSPECT_URL = "chrome://inspect/#remote-debugging" +REMOTE_DEBUG_OPEN_STATE = paths.config_dir() / "remote-debug-open.json" +REMOTE_DEBUG_OPEN_COOLDOWN_SECONDS = 5 * 60 def _log_tail(name): @@ -146,22 +155,67 @@ def _needs_chrome_remote_debugging_prompt(msg): "devtoolsactiveport not found" in lower or "enable chrome://inspect" in lower or "not live yet" in lower - or ( - "ws handshake failed" in lower - and ( - "403" in lower - or "opening handshake" in lower - or "timed out" in lower - or "timeout" in lower - ) - ) ) def _needs_chrome_permission_popup(msg): """True when Chrome is reachable but waiting on the per-session Allow popup.""" lower = (msg or "").lower() - return "permission-blocked" in lower + return ( + "permission-blocked" in lower + or "click allow in chrome" in lower + or "opening handshake" in lower + or "timed out during opening handshake" in lower + ) + + +def _remote_debug_open_cooldown_seconds(): + raw = os.environ.get("BH_REMOTE_DEBUG_OPEN_COOLDOWN_SECONDS") + if not raw: + return REMOTE_DEBUG_OPEN_COOLDOWN_SECONDS + try: + return max(0.0, float(raw)) + except ValueError: + return REMOTE_DEBUG_OPEN_COOLDOWN_SECONDS + + +def _remote_debug_open_state_read(): + try: + data = json.loads(REMOTE_DEBUG_OPEN_STATE.read_text()) + except (FileNotFoundError, OSError, ValueError): + return {} + return data if isinstance(data, dict) else {} + + +def _remote_debug_open_state_write(data): + try: + REMOTE_DEBUG_OPEN_STATE.parent.mkdir(parents=True, exist_ok=True) + REMOTE_DEBUG_OPEN_STATE.write_text(json.dumps(data)) + try: + os.chmod(REMOTE_DEBUG_OPEN_STATE, 0o600) + except OSError: + pass + except OSError: + pass + + +def _chrome_inspect_open_recently(name=None, now=None, cooldown=None): + """True when this daemon name already opened the inspect page recently.""" + name = name or NAME + now = time.time() if now is None else now + cooldown = _remote_debug_open_cooldown_seconds() if cooldown is None else cooldown + try: + opened_at = float(_remote_debug_open_state_read().get(name, 0)) + except (TypeError, ValueError): + return False + return opened_at > 0 and now - opened_at < cooldown + + +def _mark_chrome_inspect_opened(name=None, now=None): + name = name or NAME + data = _remote_debug_open_state_read() + data[name] = time.time() if now is None else now + _remote_debug_open_state_write(data) def _is_local_chrome_mode(env=None): @@ -241,7 +295,8 @@ def _doctor_short_text(value, limit=None): def _is_snap_browser(path: str) -> bool: """True when a Chrome binary path lives under /snap/ (Snap confinement on Linux).""" - return bool(path) and "/snap/" in path.lower() + normalized = str(path or "").replace("\\", "/").lower() + return "/snap/" in normalized def _doctor_snap_probe_path(path: str) -> str: @@ -309,7 +364,7 @@ def run_doctor_fix_snap(): return 0 -def ensure_daemon(wait=60.0, name=None, env=None): +def ensure_daemon(wait=180.0, name=None, env=None): """Idempotent. Self-heals stale daemon, cold Chrome, and missing Allow on chrome://inspect.""" if daemon_alive(name): # Stale daemons accept connects AND reply to meta:* (pure Python) even when the @@ -344,8 +399,21 @@ def ensure_daemon(wait=60.0, name=None, env=None): "permission-blocked: wait for the user to click Allow in the Chrome permission popup before retrying." ) if local and attempt == 0 and _needs_chrome_remote_debugging_prompt(msg): - _open_chrome_inspect() - print('browser-harness: at chrome://inspect/#remote-debugging, tick "Allow remote debugging for this browser instance" and click Allow on the popup that appears', file=sys.stderr) + if _chrome_inspect_open_recently(name): + print( + 'browser-harness: Chrome remote debugging setup was already opened recently; not opening another window. ' + 'Use the existing Chrome tab/popup, click Allow, then retry browser work.', + file=sys.stderr, + ) + restart_daemon(name) + raise RuntimeError( + "remote-debugging-setup-already-opened: use the existing Chrome prompt/window, click Allow, then retry." + ) + _mark_chrome_inspect_opened(name) + if _open_chrome_inspect(): + print(f'browser-harness: opened chrome://inspect/#remote-debugging in {browser_family_label()}. Tick "Allow remote debugging for this browser instance" and click Allow on the popup that appears', file=sys.stderr) + else: + print(f'browser-harness: open chrome://inspect/#remote-debugging manually in {browser_family_label()}. Tick "Allow remote debugging for this browser instance" and click Allow on the popup that appears', file=sys.stderr) restart_daemon(name) continue raise RuntimeError(msg or f"daemon {name or NAME} didn't come up -- check {ipc.log_path(name or NAME)}") @@ -718,6 +786,65 @@ def print_update_banner(out=None): _cache_write({**cache, "banner_shown_on": today}) +def _windows_running_chromium_binaries(): + """Executable paths for currently running Chromium-based browsers on Windows.""" + command = ( + "Get-CimInstance Win32_Process | " + "Where-Object { $_.Name -in @('chrome.exe','brave.exe','msedge.exe','helium.exe') } | " + "Select-Object -ExpandProperty ExecutablePath" + ) + try: + out = subprocess.check_output( + ["powershell", "-NoProfile", "-Command", command], + text=True, + stderr=subprocess.DEVNULL, + timeout=5, + ) + except (subprocess.SubprocessError, OSError): + return [] + return [line.strip() for line in out.splitlines() if line.strip()] + + +def _windows_chromium_binaries(): + """Known Chromium browser executable locations on Windows.""" + import os, shutil + candidates = [ + os.environ.get("BH_CHROME_PATH"), + os.environ.get("CHROME_PATH"), + *_windows_running_chromium_binaries(), + shutil.which("chrome.exe"), + shutil.which("chrome"), + shutil.which("brave.exe"), + shutil.which("brave"), + shutil.which("msedge.exe"), + shutil.which("msedge"), + shutil.which("helium.exe"), + shutil.which("helium"), + os.path.join(os.environ.get("PROGRAMFILES", r"C:\Program Files"), "Google", "Chrome", "Application", "chrome.exe"), + os.path.join(os.environ.get("PROGRAMFILES(X86)", r"C:\Program Files (x86)"), "Google", "Chrome", "Application", "chrome.exe"), + os.path.join(os.environ.get("LOCALAPPDATA", ""), "Google", "Chrome", "Application", "chrome.exe"), + os.path.join(os.environ.get("PROGRAMFILES", r"C:\Program Files"), "BraveSoftware", "Brave-Browser", "Application", "brave.exe"), + os.path.join(os.environ.get("PROGRAMFILES(X86)", r"C:\Program Files (x86)"), "BraveSoftware", "Brave-Browser", "Application", "brave.exe"), + os.path.join(os.environ.get("LOCALAPPDATA", ""), "BraveSoftware", "Brave-Browser", "Application", "brave.exe"), + os.path.join(os.environ.get("PROGRAMFILES", r"C:\Program Files"), "Microsoft", "Edge", "Application", "msedge.exe"), + os.path.join(os.environ.get("PROGRAMFILES(X86)", r"C:\Program Files (x86)"), "Microsoft", "Edge", "Application", "msedge.exe"), + os.path.join(os.environ.get("LOCALAPPDATA", ""), "Microsoft", "Edge", "Application", "msedge.exe"), + ] + found = [] + seen = set() + for p in candidates: + if not p or not os.path.exists(p): + continue + if not browser_path_allowed(p): + continue + key = os.path.normcase(os.path.abspath(p)) + if key in seen: + continue + seen.add(key) + found.append(p) + return found + + def _chrome_running(): """Cross-platform best-effort check for a running Chromium-based browser.""" import platform, subprocess @@ -725,10 +852,9 @@ def _chrome_running(): try: if system == "Windows": out = subprocess.check_output(["tasklist"], text=True, timeout=5) - names = ("chrome.exe", "msedge.exe", "helium.exe") else: out = subprocess.check_output(["ps", "-A", "-o", "comm="], text=True, timeout=5) - names = ("Google Chrome", "chrome", "chromium", "Microsoft Edge", "msedge", "helium") + names = process_names_for_browser_family(system) return any(n.lower() in out.lower() for n in names) except Exception: return False @@ -736,8 +862,16 @@ def _chrome_running(): def _open_chrome_inspect(): """Open chrome://inspect/#remote-debugging so the user can tick the checkbox.""" - import platform, subprocess, webbrowser - url = "chrome://inspect/#remote-debugging" + import platform + url = CHROME_INSPECT_URL + if platform.system() == "Windows": + for binary in _windows_chromium_binaries(): + try: + subprocess.Popen([binary, url], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + return True + except Exception: + pass + return False if platform.system() == "Darwin": try: subprocess.run([ @@ -745,13 +879,14 @@ def _open_chrome_inspect(): "-e", 'tell application "Google Chrome" to activate', "-e", f'tell application "Google Chrome" to open location "{url}"', ], timeout=5, check=False) - return + return True except Exception: pass + import webbrowser try: - webbrowser.open(url, new=2) + return bool(webbrowser.open(url, new=2)) except Exception: - pass + return False def run_doctor(): @@ -793,7 +928,7 @@ def row(label, ok, detail=""): print(f"Browser: {bname} (snap) — WARNING: Snap confinement prevents CDP binding.") print(f" Fix: Install Chrome natively (see docs/snap-linux-headless.md)") print(f" Docs: {doc_url}") - row("chrome running", chrome, "" if chrome else "start chrome/edge") + row("chrome running", chrome, "" if chrome else f"start {browser_family_label(browser_family_mode())}") 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/src/browser_harness/browser_family.py b/src/browser_harness/browser_family.py new file mode 100644 index 00000000..40e8b83a --- /dev/null +++ b/src/browser_harness/browser_family.py @@ -0,0 +1,131 @@ +import os + + +BROWSER_FAMILIES = {"any", "chrome", "chromium", "brave", "edge", "helium"} + + +def normalize_browser_family(raw): + value = (raw or "any").strip().lower().replace("_", "-") + aliases = { + "": "any", + "all": "any", + "chromium-based": "any", + "google": "chrome", + "google-chrome": "chrome", + "google chrome": "chrome", + "chrome-canary": "chrome", + "chrome canary": "chrome", + "brave-browser": "brave", + "brave browser": "brave", + "microsoft-edge": "edge", + "microsoft-edge-stable": "edge", + "microsoft edge": "edge", + "msedge": "edge", + } + value = aliases.get(value, value) + return value if value in BROWSER_FAMILIES else "any" + + +def browser_family_mode(env=None): + env = os.environ if env is None else env + return normalize_browser_family(env.get("BH_BROWSER_FAMILY") or env.get("BH_BROWSER")) + + +def browser_family_label(family=None): + family = browser_family_mode() if family is None else normalize_browser_family(family) + return { + "any": "Chrome/Chromium", + "chrome": "Google Chrome", + "chromium": "Chromium", + "brave": "Brave", + "edge": "Microsoft Edge", + "helium": "Helium", + }[family] + + +def browser_family_for_path(path): + normalized = str(path or "").replace("\\", "/").strip().lower().rstrip("/") + if not normalized: + return None + name = normalized.rsplit("/", 1)[-1] + if ( + "bravesoftware" in normalized + or "brave-browser" in normalized + or "brave browser.app" in normalized + or name in {"brave", "brave.exe", "brave-browser"} + ): + return "brave" + if ( + "microsoft/edge" in normalized + or "microsoft edge.app" in normalized + or name in {"msedge", "msedge.exe", "microsoft-edge", "microsoft-edge-stable"} + ): + return "edge" + if ( + "google/chrome" in normalized + or "google chrome.app" in normalized + or name in {"chrome", "chrome.exe", "google-chrome", "google-chrome-stable"} + ): + return "chrome" + if "chromium" in normalized or name in {"chromium", "chromium.exe", "chromium-browser"}: + return "chromium" + if "helium" in normalized or name in {"helium", "helium.exe"}: + return "helium" + return None + + +def browser_path_allowed(path, env=None): + mode = browser_family_mode(env) + if mode == "any": + return True + return browser_family_for_path(path) == mode + + +def browser_family_for_product(product): + value = str(product or "").strip().lower() + if not value: + return None + if "brave" in value: + return "brave" + if "edge" in value or "edg/" in value: + return "edge" + if "chromium" in value: + return "chromium" + if "chrome" in value: + return "chrome" + if "helium" in value: + return "helium" + return None + + +def browser_product_allowed(product, env=None): + mode = browser_family_mode(env) + if mode == "any": + return True + return browser_family_for_product(product) == mode + + +def browser_family_filter_active(env=None): + return browser_family_mode(env) != "any" + + +def process_names_for_browser_family(system, env=None): + mode = browser_family_mode(env) + windows = { + "chrome": ("chrome.exe",), + "chromium": ("chromium.exe",), + "brave": ("brave.exe",), + "edge": ("msedge.exe",), + "helium": ("helium.exe",), + } + posix = { + "chrome": ("Google Chrome", "chrome", "google-chrome", "google-chrome-stable"), + "chromium": ("chromium", "chromium-browser"), + "brave": ("Brave Browser", "brave", "brave-browser"), + "edge": ("Microsoft Edge", "msedge", "microsoft-edge", "microsoft-edge-stable"), + "helium": ("helium",), + } + table = windows if system == "Windows" else posix + if mode != "any": + return table.get(mode, ()) + return tuple(name for names in table.values() for name in names) diff --git a/src/browser_harness/daemon.py b/src/browser_harness/daemon.py index a4d7e00e..f1f9b4f9 100644 --- a/src/browser_harness/daemon.py +++ b/src/browser_harness/daemon.py @@ -4,9 +4,18 @@ from collections import deque from pathlib import Path +import websockets + from . import _ipc as ipc from . import auth from . import paths +from .browser_family import ( + browser_family_filter_active, + browser_family_label, + browser_family_mode, + browser_path_allowed, + browser_product_allowed, +) from cdp_use.client import CDPClient @@ -68,12 +77,203 @@ def _load_env_file(p): INTERNAL = ("chrome://", "chrome-untrusted://", "devtools://", "chrome-extension://", "about:") BU_API = "https://api.browser-use.com/api/v3" REMOTE_ID = os.environ.get("BU_BROWSER_ID") +LOCAL_CDP_OPEN_TIMEOUT_SECONDS = 120 +REMOTE_CDP_OPEN_TIMEOUT_SECONDS = 30 +DEFAULT_PROFILE_PROBE_TIMEOUT_SECONDS = 3 +DEDICATED_BROWSER_PORTS = (9223, 9333, 9334) def log(msg): open(LOG, "a").write(f"{msg}\n") +def _explicit_cdp_endpoint_configured(): + return bool(os.environ.get("BU_CDP_WS") or os.environ.get("BU_CDP_URL")) + + +def _cdp_open_timeout_seconds(): + raw = os.environ.get("BH_CDP_OPEN_TIMEOUT_SECONDS") + default = REMOTE_CDP_OPEN_TIMEOUT_SECONDS if _explicit_cdp_endpoint_configured() else LOCAL_CDP_OPEN_TIMEOUT_SECONDS + if not raw: + return default + try: + return max(1.0, float(raw)) + except ValueError: + return default + + +def _default_profile_probe_timeout_seconds(): + raw = os.environ.get("BH_DEFAULT_PROFILE_PROBE_TIMEOUT_SECONDS") + if not raw: + return DEFAULT_PROFILE_PROBE_TIMEOUT_SECONDS + try: + return max(1.0, float(raw)) + except ValueError: + return DEFAULT_PROFILE_PROBE_TIMEOUT_SECONDS + + +def _local_browser_mode(): + mode = (os.environ.get("BH_LOCAL_BROWSER_MODE") or "auto").strip().lower() + return mode if mode in {"auto", "default", "dedicated"} else "auto" + + +def _candidate_profiles(): + return [base for base in PROFILES if browser_path_allowed(base)] + + +def _read_json_url(url, timeout=1): + return json.loads(urllib.request.urlopen(url, timeout=timeout).read()) + + +def _dedicated_browser_ports(): + raw = os.environ.get("BH_DEDICATED_CHROME_PORT") + if not raw: + return DEDICATED_BROWSER_PORTS + try: + port = int(raw) + except ValueError: + return DEDICATED_BROWSER_PORTS + return (port, *[p for p in DEDICATED_BROWSER_PORTS if p != port]) + + +def _dedicated_user_data_dir(): + raw = os.environ.get("BH_DEDICATED_CHROME_USER_DATA_DIR") + return Path(raw).expanduser().resolve() if raw else paths.config_dir() / "automation-profile" + + +def _browser_executable_candidates(): + import platform, shutil + + for key in ("BH_CHROME_PATH", "CHROME_PATH"): + raw = (os.environ.get(key) or "").strip() + if raw: + yield raw + system = platform.system() + if system == "Windows": + roots = [ + os.environ.get("PROGRAMFILES", r"C:\Program Files"), + os.environ.get("PROGRAMFILES(X86)", r"C:\Program Files (x86)"), + os.environ.get("LOCALAPPDATA", ""), + ] + relative = [ + ("Google", "Chrome", "Application", "chrome.exe"), + ("BraveSoftware", "Brave-Browser", "Application", "brave.exe"), + ("Microsoft", "Edge", "Application", "msedge.exe"), + ] + for root in roots: + for parts in relative: + yield str(Path(root, *parts)) + for cmd in ("chrome.exe", "chrome", "brave.exe", "brave", "msedge.exe", "msedge"): + if found := shutil.which(cmd): + yield found + return + if system == "Darwin": + yield "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome" + yield "/Applications/Brave Browser.app/Contents/MacOS/Brave Browser" + yield "/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge" + for cmd in ("google-chrome-stable", "google-chrome", "chromium-browser", "chromium", "brave-browser", "microsoft-edge"): + if found := shutil.which(cmd): + yield found + + +def _browser_executable(): + seen = set() + for raw in _browser_executable_candidates(): + if not raw: + continue + p = Path(raw).expanduser() + key = os.path.normcase(str(p)) + if key in seen: + continue + seen.add(key) + if not browser_path_allowed(p): + log(f"skipping browser candidate outside BH_BROWSER_FAMILY={browser_family_mode()}: {p}") + continue + try: + if p.is_file(): + return str(p) + except OSError: + continue + return None + + +def _ws_from_cdp_http_url(cdp_url, timeout=1, require_browser_family=False): + version = _read_json_url(f"{cdp_url.rstrip('/')}/json/version", timeout=timeout) + product = version.get("Browser") + if require_browser_family and not browser_product_allowed(product): + raise RuntimeError( + f"CDP endpoint browser family mismatch for BH_BROWSER_FAMILY={browser_family_mode()}: {product}" + ) + return version["webSocketDebuggerUrl"] + + +def _launch_dedicated_browser(port): + import subprocess + + browser = _browser_executable() + if not browser: + raise RuntimeError(f"no {browser_family_label()} executable found for dedicated browser; set BH_CHROME_PATH") + profile = _dedicated_user_data_dir() + profile.mkdir(parents=True, exist_ok=True) + args = [ + browser, + f"--remote-debugging-port={port}", + f"--user-data-dir={profile}", + "--no-first-run", + "--no-default-browser-check", + "about:blank", + ] + log(f"starting dedicated browser on port {port}: {browser}") + subprocess.Popen(args, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, **ipc.spawn_kwargs()) + + +def _dedicated_browser_ws_url(): + last_err = None + for port in _dedicated_browser_ports(): + cdp_url = f"http://127.0.0.1:{port}" + try: + return _ws_from_cdp_http_url( + cdp_url, + timeout=1, + require_browser_family=browser_family_filter_active(), + ) + except Exception as e: + last_err = e + for port in _dedicated_browser_ports(): + cdp_url = f"http://127.0.0.1:{port}" + try: + _launch_dedicated_browser(port) + except Exception as e: + last_err = e + continue + deadline = time.time() + 20 + while time.time() < deadline: + try: + return _ws_from_cdp_http_url( + cdp_url, + timeout=1, + require_browser_family=browser_family_filter_active(), + ) + except Exception as e: + last_err = e + time.sleep(0.25) + raise RuntimeError(f"dedicated automation browser did not expose CDP: {last_err}") + + +async def _start_cdp_client(url, open_timeout=None): + """Start CDPClient with a longer websocket open timeout for Chrome's Allow popup.""" + client = CDPClient(url) + connect_kwargs = { + "max_size": client.max_ws_frame_size, + "open_timeout": _cdp_open_timeout_seconds() if open_timeout is None else open_timeout, + } + if client.additional_headers: + connect_kwargs["additional_headers"] = client.additional_headers + client.ws = await websockets.connect(client.url, **connect_kwargs) + client._message_handler_task = asyncio.create_task(client._handle_messages()) + return client + + async def _silent(coro): try: await coro @@ -90,7 +290,7 @@ def _ws_from_devtools_active_port(http_url: str) -> str | None: host = p.hostname or "127.0.0.1" if ":" in host: # urlparse strips IPv6 brackets; restore them for the ws:// URL host = f"[{host}]" - for base in PROFILES: + for base in _candidate_profiles(): try: active = (base / "DevToolsActivePort").read_text().splitlines() except (FileNotFoundError, NotADirectoryError): @@ -102,9 +302,9 @@ def _ws_from_devtools_active_port(http_url: str) -> str | None: return None -def get_ws_url(): +def select_ws_url(): if url := os.environ.get("BU_CDP_WS"): - return url + return url, "explicit-ws" if url := os.environ.get("BU_CDP_URL"): # HTTP DevTools endpoint (e.g. http://127.0.0.1:9333) — resolve to ws via /json/version. # Use this for a dedicated automation Chrome on a non-default profile, which avoids the @@ -114,21 +314,24 @@ def get_ws_url(): base_url = url.rstrip("/") while time.time() < deadline: try: - return json.loads(urllib.request.urlopen(f"{base_url}/json/version", timeout=5).read())["webSocketDebuggerUrl"] + return json.loads(urllib.request.urlopen(f"{base_url}/json/version", timeout=5).read())["webSocketDebuggerUrl"], "explicit-http" except urllib.error.HTTPError as e: last_err = e if e.code == 403: raise RuntimeError("permission-blocked: Chrome is reachable, but the per-session Allow remote debugging popup has not been accepted") if e.code == 404 and (ws := _ws_from_devtools_active_port(url)): - return ws + return ws, "explicit-http-devtools-active-port" time.sleep(1) except Exception as e: last_err = e time.sleep(1) raise RuntimeError(f"BU_CDP_URL={url} unreachable after 30s: {last_err} -- is the dedicated automation Chrome running?") + if _local_browser_mode() == "dedicated": + log("BH_LOCAL_BROWSER_MODE=dedicated; using dedicated automation browser") + return _dedicated_browser_ws_url(), "dedicated" deadline = time.time() + 30 while time.time() < deadline: - for base in PROFILES: + for base in _candidate_profiles(): try: active = (base / "DevToolsActivePort").read_text().splitlines() except (FileNotFoundError, NotADirectoryError): @@ -142,27 +345,42 @@ def get_ws_url(): # with a different --user-data-dir on the same port, that file is left behind # with a stale browser UUID and the WS upgrade returns 404. try: - return json.loads(urllib.request.urlopen(f"http://127.0.0.1:{port}/json/version", timeout=1).read())["webSocketDebuggerUrl"] + return json.loads(urllib.request.urlopen(f"http://127.0.0.1:{port}/json/version", timeout=1).read())["webSocketDebuggerUrl"], "local-http" except urllib.error.HTTPError as e: if e.code == 403: raise RuntimeError("permission-blocked: Chrome is reachable, but the per-session Allow remote debugging popup has not been accepted") # Chrome 147+ disables /json/* HTTP discovery on the default user-data-dir; - # the ws path Chrome wrote to DevToolsActivePort still works. + # the ws path Chrome wrote to DevToolsActivePort still works once remote + # debugging has already been allowed for this Chrome instance. In auto + # mode we still try it first with a short startup probe so signed-in + # Chrome sessions are reused instead of silently losing auth in the + # dedicated browser. if e.code == 404 and ws_path: - return f"ws://127.0.0.1:{port}{ws_path}" + return f"ws://127.0.0.1:{port}{ws_path}", "default-profile-direct" except (OSError, KeyError, ValueError): pass time.sleep(0.2) - for probe_port in (9222, 9223): - try: - with urllib.request.urlopen(f"http://127.0.0.1:{probe_port}/json/version", timeout=1) as r: - return json.loads(r.read())["webSocketDebuggerUrl"] - except urllib.error.HTTPError as e: - if e.code == 403: - raise RuntimeError("permission-blocked: Chrome is reachable, but the per-session Allow remote debugging popup has not been accepted") - except (OSError, KeyError, ValueError): - continue - raise RuntimeError(f"DevToolsActivePort not found in {[str(p) for p in PROFILES]} — enable chrome://inspect/#remote-debugging, or set BU_CDP_WS for a remote browser") + if browser_family_filter_active(): + log(f"skipping blind CDP port probe because BH_BROWSER_FAMILY={browser_family_mode()} is set") + else: + for probe_port in (9222, 9223): + try: + with urllib.request.urlopen(f"http://127.0.0.1:{probe_port}/json/version", timeout=1) as r: + return json.loads(r.read())["webSocketDebuggerUrl"], "probe-http" + except urllib.error.HTTPError as e: + if e.code == 403: + raise RuntimeError("permission-blocked: Chrome is reachable, but the per-session Allow remote debugging popup has not been accepted") + except (OSError, KeyError, ValueError): + continue + if _local_browser_mode() == "auto": + log("no reusable local CDP endpoint found; using dedicated automation browser") + return _dedicated_browser_ws_url(), "dedicated" + raise RuntimeError(f"DevToolsActivePort not found in {[str(p) for p in _candidate_profiles()]} for {browser_family_label()} — enable chrome://inspect/#remote-debugging, or set BU_CDP_WS for a remote browser") + + +def get_ws_url(): + url, _source = select_ws_url() + return url def stop_remote(): @@ -236,24 +454,10 @@ async def enable_one(d): log(f"enable {d} on {session_id}: {e}") await asyncio.gather(*(enable_one(d) for d in ("Page", "DOM", "Runtime", "Network"))) - async def start(self): - self.stop = asyncio.Event() - url = get_ws_url() - log(f"connecting to {url}") - self.cdp = CDPClient(url) - try: - await self.cdp.start() - except Exception as e: - if os.environ.get("BU_CDP_WS"): - raise RuntimeError( - f"CDP WS handshake failed: {e} -- remote browser WebSocket connection failed. " - "This can happen when network policy blocks the connection, the WS URL is wrong or expired, or the remote endpoint is down. " - "If you use Browser Use cloud, verify auth and get a fresh URL via start_remote_daemon()." - ) - raise RuntimeError(f"CDP WS handshake failed: {e} -- click Allow in Chrome if prompted, then retry") - await self.attach_first_page() + def _install_event_tap(self): orig = self.cdp._event_registry.handle_event mark_js = "if(!document.title.startsWith('\U0001F434'))document.title='\U0001F434 '+document.title" + async def tap(method, params, session_id=None): self.events.append({"method": method, "params": params, "session_id": session_id}) if method == "Page.javascriptDialogOpening": @@ -263,8 +467,43 @@ async def tap(method, params, session_id=None): elif method in ("Page.loadEventFired", "Page.domContentEventFired"): asyncio.create_task(_silent(asyncio.wait_for(self.cdp.send_raw("Runtime.evaluate", {"expression": mark_js}, session_id=self.session), timeout=2))) return await orig(method, params, session_id) + self.cdp._event_registry.handle_event = tap + async def start(self): + self.stop = asyncio.Event() + url, source = select_ws_url() + open_timeout = ( + _default_profile_probe_timeout_seconds() + if source == "default-profile-direct" and _local_browser_mode() == "auto" + else _cdp_open_timeout_seconds() + ) + log(f"connecting to {url} (source={source}, open_timeout={open_timeout:g}s)") + try: + self.cdp = await _start_cdp_client(url, open_timeout=open_timeout) + except Exception as e: + if source == "default-profile-direct" and _local_browser_mode() == "auto": + log( + "default profile direct websocket is not already permitted; " + f"falling back to dedicated automation browser after {e}" + ) + url = _dedicated_browser_ws_url() + open_timeout = _cdp_open_timeout_seconds() + log(f"connecting to {url} (source=dedicated-fallback, open_timeout={open_timeout:g}s)") + self.cdp = await _start_cdp_client(url, open_timeout=open_timeout) + await self.attach_first_page() + self._install_event_tap() + return + if os.environ.get("BU_CDP_WS"): + raise RuntimeError( + f"CDP WS handshake failed after {open_timeout:g}s: {e} -- remote browser WebSocket connection failed. " + "This can happen when network policy blocks the connection, the WS URL is wrong or expired, or the remote endpoint is down. " + "If you use Browser Use cloud, verify auth and get a fresh URL via start_remote_daemon()." + ) + raise RuntimeError(f"CDP WS handshake failed after {open_timeout:g}s: {e} -- click Allow in Chrome if prompted, then retry") + await self.attach_first_page() + self._install_event_tap() + async def handle(self, req): # Token guard for Windows TCP loopback: any local process can otherwise # connect and issue CDP commands. expected_token() is None on POSIX so diff --git a/tests/unit/test_admin.py b/tests/unit/test_admin.py index 65d7d7f3..fe01b459 100644 --- a/tests/unit/test_admin.py +++ b/tests/unit/test_admin.py @@ -30,16 +30,17 @@ def test_local_chrome_mode_is_false_when_process_env_provides_remote_cdp(monkeyp assert not admin._is_local_chrome_mode() -def test_handshake_timeout_needs_chrome_remote_debugging_prompt(): +def test_handshake_timeout_needs_chrome_permission_popup_not_inspect_setup(): msg = "CDP WS handshake failed: timed out during opening handshake" - assert admin._needs_chrome_remote_debugging_prompt(msg) + assert admin._needs_chrome_permission_popup(msg) + assert not admin._needs_chrome_remote_debugging_prompt(msg) def test_handshake_403_needs_chrome_remote_debugging_prompt(): msg = "CDP WS handshake failed: server rejected WebSocket connection: HTTP 403" - assert admin._needs_chrome_remote_debugging_prompt(msg) + assert not admin._needs_chrome_remote_debugging_prompt(msg) def test_stale_websocket_does_not_open_chrome_inspect(): @@ -48,6 +49,38 @@ def test_stale_websocket_does_not_open_chrome_inspect(): assert not admin._needs_chrome_remote_debugging_prompt(msg) +def test_chrome_inspect_recently_opened_uses_name_and_cooldown(monkeypatch, tmp_path): + monkeypatch.setattr(admin, "REMOTE_DEBUG_OPEN_STATE", tmp_path / "remote-debug-open.json") + + admin._mark_chrome_inspect_opened("default", now=1000) + + assert admin._chrome_inspect_open_recently("default", now=1100, cooldown=300) + assert not admin._chrome_inspect_open_recently("other", now=1100, cooldown=300) + assert not admin._chrome_inspect_open_recently("default", now=1401, cooldown=300) + + +def test_ensure_daemon_does_not_reopen_chrome_inspect_when_recent(monkeypatch): + class FakeProcess: + def poll(self): + return 1 + + opened = [] + restarted = [] + monkeypatch.setattr(admin, "daemon_alive", lambda name=None: False) + monkeypatch.setattr(admin, "_is_local_chrome_mode", lambda env=None: True) + monkeypatch.setattr(admin, "_log_tail", lambda name=None: "DevToolsActivePort not found") + monkeypatch.setattr(admin, "_chrome_inspect_open_recently", lambda name=None: True) + monkeypatch.setattr(admin, "_open_chrome_inspect", lambda: opened.append(True)) + monkeypatch.setattr(admin, "restart_daemon", lambda name=None: restarted.append(name)) + monkeypatch.setattr(admin.subprocess, "Popen", lambda *args, **kwargs: FakeProcess()) + + with pytest.raises(RuntimeError, match="remote-debugging-setup-already-opened"): + admin.ensure_daemon(wait=0, name="default") + + assert opened == [] + assert restarted == ["default"] + + def test_daemon_endpoint_names_discovers_valid_socket_names(tmp_path, monkeypatch): monkeypatch.setattr(admin.ipc, "IS_WINDOWS", False) monkeypatch.setattr(admin.ipc, "BH_RUNTIME_DIR", None) # shared-tmpdir mode @@ -139,6 +172,7 @@ def test_browser_connections_returns_attached_page(monkeypatch): def test_chrome_running_detects_helium_on_linux(monkeypatch): + monkeypatch.setenv("BH_BROWSER_FAMILY", "any") monkeypatch.setattr("platform.system", lambda: "Linux") monkeypatch.setattr( "subprocess.check_output", @@ -148,6 +182,79 @@ def test_chrome_running_detects_helium_on_linux(monkeypatch): assert admin._chrome_running() +def test_windows_chromium_binaries_includes_running_process_path(monkeypatch, tmp_path): + brave = tmp_path / "brave.exe" + brave.write_text("") + missing_root = tmp_path / "missing" + monkeypatch.setenv("BH_BROWSER_FAMILY", "any") + monkeypatch.setenv("PROGRAMFILES", str(missing_root)) + monkeypatch.setenv("PROGRAMFILES(X86)", str(missing_root)) + monkeypatch.setenv("LOCALAPPDATA", str(missing_root)) + monkeypatch.delenv("BH_CHROME_PATH", raising=False) + monkeypatch.delenv("CHROME_PATH", raising=False) + monkeypatch.setattr(admin, "_windows_running_chromium_binaries", lambda: [str(brave), str(brave)]) + monkeypatch.setattr("shutil.which", lambda _cmd: None) + + assert admin._windows_chromium_binaries() == [str(brave)] + + +def test_windows_chromium_binaries_respects_chrome_family(monkeypatch, tmp_path): + brave = tmp_path / "BraveSoftware" / "Brave-Browser" / "Application" / "brave.exe" + chrome = tmp_path / "Google" / "Chrome" / "Application" / "chrome.exe" + brave.parent.mkdir(parents=True) + chrome.parent.mkdir(parents=True) + brave.write_text("") + chrome.write_text("") + missing_root = tmp_path / "missing" + monkeypatch.setenv("BH_BROWSER_FAMILY", "chrome") + monkeypatch.setenv("PROGRAMFILES", str(missing_root)) + monkeypatch.setenv("PROGRAMFILES(X86)", str(missing_root)) + monkeypatch.setenv("LOCALAPPDATA", str(missing_root)) + monkeypatch.delenv("BH_CHROME_PATH", raising=False) + monkeypatch.delenv("CHROME_PATH", raising=False) + monkeypatch.setattr(admin, "_windows_running_chromium_binaries", lambda: [str(brave), str(chrome)]) + monkeypatch.setattr("shutil.which", lambda _cmd: None) + + assert admin._windows_chromium_binaries() == [str(chrome)] + + +def test_chrome_running_ignores_brave_when_chrome_family_required(monkeypatch): + monkeypatch.setenv("BH_BROWSER_FAMILY", "chrome") + monkeypatch.setattr("platform.system", lambda: "Windows") + monkeypatch.setattr( + "subprocess.check_output", + lambda *args, **kwargs: "\r\nImage Name\r\nbrave.exe\r\n", + ) + + assert not admin._chrome_running() + + +def test_open_chrome_inspect_on_windows_uses_browser_binary_not_protocol_handler(monkeypatch): + calls = [] + binary = r"C:\Program Files\BraveSoftware\Brave-Browser\Application\brave.exe" + monkeypatch.setattr("platform.system", lambda: "Windows") + monkeypatch.setattr(admin, "_windows_chromium_binaries", lambda: [binary]) + monkeypatch.setattr(admin.subprocess, "Popen", lambda args, **kwargs: calls.append((args, kwargs))) + monkeypatch.setattr( + "webbrowser.open", + lambda *args, **kwargs: (_ for _ in ()).throw(AssertionError("webbrowser.open must not be used on Windows")), + ) + + assert admin._open_chrome_inspect() + assert calls[0][0] == [binary, admin.CHROME_INSPECT_URL] + + +def test_open_chrome_inspect_on_windows_does_not_fall_back_to_webbrowser(monkeypatch): + monkeypatch.setattr("platform.system", lambda: "Windows") + monkeypatch.setattr(admin, "_windows_chromium_binaries", lambda: []) + monkeypatch.setattr( + "webbrowser.open", + lambda *args, **kwargs: (_ for _ in ()).throw(AssertionError("webbrowser.open must not be used on Windows")), + ) + + assert not admin._open_chrome_inspect() + + @pytest.mark.parametrize( "path, expected", [ diff --git a/tests/unit/test_browser_family.py b/tests/unit/test_browser_family.py new file mode 100644 index 00000000..0f31b452 --- /dev/null +++ b/tests/unit/test_browser_family.py @@ -0,0 +1,17 @@ +from browser_harness import browser_family + + +def test_edge_family_recognizes_linux_stable_binary_name(): + assert browser_family.browser_family_for_path("/usr/bin/microsoft-edge-stable") == "edge" + assert "microsoft-edge-stable" in browser_family.process_names_for_browser_family( + "Linux", + {"BH_BROWSER_FAMILY": "edge"}, + ) + + +def test_browser_product_allowed_matches_selected_family(): + env = {"BH_BROWSER_FAMILY": "chrome"} + + assert browser_family.browser_product_allowed("Chrome/149.0.0.0", env) + assert browser_family.browser_product_allowed("HeadlessChrome/149.0.0.0", env) + assert not browser_family.browser_product_allowed("Brave/1.92.0", env) diff --git a/tests/unit/test_daemon.py b/tests/unit/test_daemon.py index 90c5bc85..cca5e90a 100644 --- a/tests/unit/test_daemon.py +++ b/tests/unit/test_daemon.py @@ -1,4 +1,5 @@ import asyncio +import urllib.error from browser_harness import daemon @@ -15,12 +16,261 @@ async def send_raw(self, method, params=None, session_id=None): return {} +class _FakeEventRegistry: + async def handle_event(self, method, params, session_id=None): + return None + + +class _ConnectedCDP(_FakeCDP): + def __init__(self): + super().__init__() + self._event_registry = _FakeEventRegistry() + + def _fresh_daemon(): d = daemon.Daemon() d.cdp = _FakeCDP() return d +def test_cdp_open_timeout_defaults_to_long_local_permission_window(monkeypatch): + monkeypatch.delenv("BU_CDP_WS", raising=False) + monkeypatch.delenv("BU_CDP_URL", raising=False) + monkeypatch.delenv("BH_CDP_OPEN_TIMEOUT_SECONDS", raising=False) + + assert daemon._cdp_open_timeout_seconds() == daemon.LOCAL_CDP_OPEN_TIMEOUT_SECONDS + + +def test_cdp_open_timeout_uses_shorter_default_for_explicit_remote_endpoint(monkeypatch): + monkeypatch.setenv("BU_CDP_WS", "ws://example.test/devtools/browser/1") + monkeypatch.delenv("BU_CDP_URL", raising=False) + monkeypatch.delenv("BH_CDP_OPEN_TIMEOUT_SECONDS", raising=False) + + assert daemon._cdp_open_timeout_seconds() == daemon.REMOTE_CDP_OPEN_TIMEOUT_SECONDS + + +def test_cdp_open_timeout_honors_env_override(monkeypatch): + monkeypatch.delenv("BU_CDP_WS", raising=False) + monkeypatch.delenv("BU_CDP_URL", raising=False) + monkeypatch.setenv("BH_CDP_OPEN_TIMEOUT_SECONDS", "42") + + assert daemon._cdp_open_timeout_seconds() == 42 + + +def test_local_browser_mode_defaults_to_auto(monkeypatch): + monkeypatch.delenv("BH_LOCAL_BROWSER_MODE", raising=False) + + assert daemon._local_browser_mode() == "auto" + + +def test_local_browser_mode_rejects_unknown_values(monkeypatch): + monkeypatch.setenv("BH_LOCAL_BROWSER_MODE", "surprise") + + assert daemon._local_browser_mode() == "auto" + + +def test_dedicated_browser_ports_prefers_env_override(monkeypatch): + monkeypatch.setenv("BH_DEDICATED_CHROME_PORT", "9444") + + assert daemon._dedicated_browser_ports()[0] == 9444 + + +def test_get_ws_url_uses_dedicated_browser_when_requested(monkeypatch): + monkeypatch.delenv("BU_CDP_WS", raising=False) + monkeypatch.delenv("BU_CDP_URL", raising=False) + monkeypatch.setenv("BH_LOCAL_BROWSER_MODE", "dedicated") + monkeypatch.setattr(daemon, "_dedicated_browser_ws_url", lambda: "ws://dedicated") + + assert daemon.get_ws_url() == "ws://dedicated" + + +def test_get_ws_url_reuses_default_profile_direct_ws_in_auto_mode(monkeypatch, tmp_path): + profile = tmp_path / "Chrome" + profile.mkdir() + (profile / "DevToolsActivePort").write_text("9222\n/devtools/browser/default-profile\n") + + monkeypatch.delenv("BU_CDP_WS", raising=False) + monkeypatch.delenv("BU_CDP_URL", raising=False) + monkeypatch.setenv("BH_LOCAL_BROWSER_MODE", "auto") + monkeypatch.setattr(daemon, "PROFILES", [profile]) + + def fake_urlopen(url, timeout=1): + raise urllib.error.HTTPError(url, 404, "not found", None, None) + + monkeypatch.setattr(daemon.urllib.request, "urlopen", fake_urlopen) + + assert daemon.get_ws_url() == "ws://127.0.0.1:9222/devtools/browser/default-profile" + + +def test_get_ws_url_ignores_brave_profile_when_chrome_required(monkeypatch, tmp_path): + brave = tmp_path / "BraveSoftware" / "Brave-Browser" + brave.mkdir(parents=True) + (brave / "DevToolsActivePort").write_text("9222\n/devtools/browser/brave-profile\n") + chrome = tmp_path / "Google" / "Chrome" + chrome.mkdir(parents=True) + (chrome / "DevToolsActivePort").write_text("9333\n/devtools/browser/chrome-profile\n") + + monkeypatch.delenv("BU_CDP_WS", raising=False) + monkeypatch.delenv("BU_CDP_URL", raising=False) + monkeypatch.setenv("BH_BROWSER_FAMILY", "chrome") + monkeypatch.setenv("BH_LOCAL_BROWSER_MODE", "auto") + monkeypatch.setattr(daemon, "PROFILES", [brave, chrome]) + + def fake_urlopen(url, timeout=1): + raise urllib.error.HTTPError(url, 404, "not found", None, None) + + monkeypatch.setattr(daemon.urllib.request, "urlopen", fake_urlopen) + + assert daemon.get_ws_url() == "ws://127.0.0.1:9333/devtools/browser/chrome-profile" + + +def test_get_ws_url_skips_blind_port_probe_when_browser_family_is_restricted(monkeypatch): + calls = [] + + def fake_urlopen(url, timeout=1): + calls.append(url) + raise AssertionError("strict browser-family mode must not probe anonymous CDP ports") + + monkeypatch.delenv("BU_CDP_WS", raising=False) + monkeypatch.delenv("BU_CDP_URL", raising=False) + monkeypatch.setenv("BH_BROWSER_FAMILY", "chrome") + monkeypatch.setenv("BH_LOCAL_BROWSER_MODE", "auto") + monkeypatch.setattr(daemon, "PROFILES", []) + monkeypatch.setattr(daemon.urllib.request, "urlopen", fake_urlopen) + monkeypatch.setattr(daemon, "_dedicated_browser_ws_url", lambda: "ws://dedicated") + + assert daemon.get_ws_url() == "ws://dedicated" + assert calls == [] + + +def test_browser_executable_respects_chrome_family_filter(monkeypatch, tmp_path): + brave = tmp_path / "BraveSoftware" / "Brave-Browser" / "Application" / "brave.exe" + chrome = tmp_path / "Google" / "Chrome" / "Application" / "chrome.exe" + brave.parent.mkdir(parents=True) + chrome.parent.mkdir(parents=True) + brave.write_text("") + chrome.write_text("") + + monkeypatch.setenv("BH_BROWSER_FAMILY", "chrome") + monkeypatch.setattr(daemon, "_browser_executable_candidates", lambda: [str(brave), str(chrome)]) + + assert daemon._browser_executable() == str(chrome) + + +def test_dedicated_browser_reuse_rejects_wrong_family_endpoint(monkeypatch): + class FakeResponse: + def __init__(self, body): + self.body = body + + def read(self): + return self.body + + def fake_urlopen(url, timeout=1): + if ":9223/" in url: + return FakeResponse(b'{"Browser":"Brave/1.0","webSocketDebuggerUrl":"ws://brave"}') + if ":9333/" in url: + return FakeResponse(b'{"Browser":"Chrome/1.0","webSocketDebuggerUrl":"ws://chrome"}') + raise AssertionError(url) + + monkeypatch.setenv("BH_BROWSER_FAMILY", "chrome") + monkeypatch.setattr(daemon, "_dedicated_browser_ports", lambda: (9223, 9333)) + monkeypatch.setattr(daemon.urllib.request, "urlopen", fake_urlopen) + + assert daemon._dedicated_browser_ws_url() == "ws://chrome" + + +def test_get_ws_url_allows_default_profile_ws_when_opted_in(monkeypatch, tmp_path): + profile = tmp_path / "Chrome" + profile.mkdir() + (profile / "DevToolsActivePort").write_text("9222\n/devtools/browser/default-profile\n") + + monkeypatch.delenv("BU_CDP_WS", raising=False) + monkeypatch.delenv("BU_CDP_URL", raising=False) + monkeypatch.setenv("BH_LOCAL_BROWSER_MODE", "default") + monkeypatch.setattr(daemon, "PROFILES", [profile]) + + def fake_urlopen(url, timeout=1): + raise urllib.error.HTTPError(url, 404, "not found", None, None) + + monkeypatch.setattr(daemon.urllib.request, "urlopen", fake_urlopen) + + assert daemon.get_ws_url() == "ws://127.0.0.1:9222/devtools/browser/default-profile" + + +def test_default_profile_probe_timeout_honors_env_override(monkeypatch): + monkeypatch.setenv("BH_DEFAULT_PROFILE_PROBE_TIMEOUT_SECONDS", "7") + + assert daemon._default_profile_probe_timeout_seconds() == 7 + + +def test_daemon_start_falls_back_to_dedicated_when_auto_default_profile_is_blocked(monkeypatch): + selected = "ws://127.0.0.1:9222/devtools/browser/default-profile" + calls = [] + + async def fake_start_cdp_client(url, open_timeout=None): + calls.append((url, open_timeout)) + if len(calls) == 1: + raise TimeoutError("permission prompt still blocking") + return _ConnectedCDP() + + async def fake_attach_first_page(self): + self.session = "session-1" + self.target_id = "target-1" + return {"targetId": "target-1", "url": "about:blank", "type": "page"} + + monkeypatch.delenv("BU_CDP_WS", raising=False) + monkeypatch.delenv("BU_CDP_URL", raising=False) + monkeypatch.delenv("BH_CDP_OPEN_TIMEOUT_SECONDS", raising=False) + monkeypatch.delenv("BH_DEFAULT_PROFILE_PROBE_TIMEOUT_SECONDS", raising=False) + monkeypatch.setenv("BH_LOCAL_BROWSER_MODE", "auto") + monkeypatch.setattr(daemon, "select_ws_url", lambda: (selected, "default-profile-direct")) + monkeypatch.setattr(daemon, "_dedicated_browser_ws_url", lambda: "ws://dedicated") + monkeypatch.setattr(daemon, "_start_cdp_client", fake_start_cdp_client) + monkeypatch.setattr(daemon.Daemon, "attach_first_page", fake_attach_first_page) + + d = daemon.Daemon() + asyncio.run(d.start()) + + assert calls == [ + (selected, daemon.DEFAULT_PROFILE_PROBE_TIMEOUT_SECONDS), + ("ws://dedicated", daemon.LOCAL_CDP_OPEN_TIMEOUT_SECONDS), + ] + assert d.session == "session-1" + assert d.target_id == "target-1" + + +def test_start_cdp_client_passes_open_timeout_to_websocket(monkeypatch): + calls = [] + + class FakeCDPClient: + def __init__(self, url): + self.url = url + self.max_ws_frame_size = 12345 + self.additional_headers = None + self.ws = None + self._message_handler_task = None + + async def _handle_messages(self): + return None + + async def fake_connect(url, **kwargs): + calls.append((url, kwargs)) + return "fake-ws" + + monkeypatch.setattr(daemon, "CDPClient", FakeCDPClient) + monkeypatch.setattr(daemon.websockets, "connect", fake_connect) + + client = asyncio.run(daemon._start_cdp_client("ws://127.0.0.1:9222/devtools/browser/1", open_timeout=120)) + + assert client.ws == "fake-ws" + assert calls == [ + ( + "ws://127.0.0.1:9222/devtools/browser/1", + {"max_size": 12345, "open_timeout": 120}, + ) + ] + + def test_set_session_enables_all_four_default_domains_on_new_session(): """Regression: switch_tab() / new_tab() in helpers.py route through the `set_session` IPC, which previously only enabled Page on the new