diff --git a/src/browser_harness/admin.py b/src/browser_harness/admin.py index c72a8fb..64addee 100644 --- a/src/browser_harness/admin.py +++ b/src/browser_harness/admin.py @@ -5,6 +5,7 @@ import subprocess import sys import tempfile import time +import urllib.parse import urllib.request from pathlib import Path @@ -445,8 +446,29 @@ def _stop_cloud_browser(browser_id): pass +_NO_PROXY_OPENER = urllib.request.build_opener(urllib.request.ProxyHandler({})) + + +def cdp_urlopen(url, timeout=15): + """urlopen for CDP endpoints: never send loopback traffic through a proxy. + + urllib's default opener honors proxy env vars AND macOS system proxy + settings (scutil --proxy). On a box with a system-wide proxy (e.g. a local + gost/ISP forwarder) every 127.0.0.1 CDP probe gets routed to the proxy and + comes back 403 Forbidden — Chrome then looks unreachable/"wedged" while + curl (env proxies only) and Playwright (own websocket stack) work fine. + Loopback must always connect direct; non-loopback CDP URLs keep the + default proxy behavior (a remote/cloud CDP endpoint may legitimately need + the proxy for reachability). + """ + host = (urllib.parse.urlsplit(url).hostname or "").lower() + if host in ("127.0.0.1", "localhost", "::1"): + return _NO_PROXY_OPENER.open(url, timeout=timeout) + return urllib.request.urlopen(url, timeout=timeout) + + def _cdp_ws_from_url(cdp_url): - return json.loads(urllib.request.urlopen(f"{cdp_url}/json/version", timeout=15).read())["webSocketDebuggerUrl"] + return json.loads(cdp_urlopen(f"{cdp_url}/json/version", timeout=15).read())["webSocketDebuggerUrl"] def _has_local_gui(): diff --git a/src/browser_harness/daemon.py b/src/browser_harness/daemon.py index 0f0f255..de51934 100644 --- a/src/browser_harness/daemon.py +++ b/src/browser_harness/daemon.py @@ -5,8 +5,22 @@ from collections import deque from pathlib import Path from . import _ipc as ipc +from .admin import cdp_urlopen from cdp_use.client import CDPClient +# s4l_no_proxy_ws: the daemon's CDP traffic is loopback; never let ANY client +# in this process route it through a proxy. websockets>=14 autodetects proxies +# at dial time via urllib.request.proxy_bypass()/getproxies() (which on macOS +# include SYSTEM proxy settings from scutil --proxy), so a box-wide forwarder +# turns the CDP WS dial into a proxied CONNECT that fails ("proxy rejected +# connection: HTTP 503") — same failure class as the urllib 403 that +# cdp_urlopen fixes for the HTTP lookup. Neutralize resolution process-wide: +# the daemon's only non-loopback traffic (browser-use cloud API) works direct. +# Both lookups happen at call time via module attributes, so this holds +# regardless of import order. +urllib.request.getproxies = lambda: {} # type: ignore[assignment] +urllib.request.proxy_bypass = lambda host: 1 # type: ignore[assignment] + def _load_env(): repo_root = Path(__file__).resolve().parents[2] @@ -113,7 +127,7 @@ 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(cdp_urlopen(f"{base_url}/json/version", timeout=5).read())["webSocketDebuggerUrl"] except urllib.error.HTTPError as e: last_err = e if e.code == 404 and (ws := _ws_from_devtools_active_port(url)): @@ -139,7 +153,7 @@ def get_ws_url(): deadline = time.time() + 30 while time.time() < deadline: try: - return json.loads(urllib.request.urlopen(f"http://127.0.0.1:{port}/json/version", timeout=1).read())["webSocketDebuggerUrl"] + return json.loads(cdp_urlopen(f"http://127.0.0.1:{port}/json/version", timeout=1).read())["webSocketDebuggerUrl"] except urllib.error.HTTPError as e: # Chrome 147+ disables /json/* HTTP discovery on the default user-data-dir; # the ws path Chrome wrote to DevToolsActivePort still works. @@ -153,7 +167,7 @@ def get_ws_url(): ) 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: + with cdp_urlopen(f"http://127.0.0.1:{probe_port}/json/version", timeout=1) as r: return json.loads(r.read())["webSocketDebuggerUrl"] except (OSError, KeyError, ValueError): continue diff --git a/src/browser_harness/run.py b/src/browser_harness/run.py index 8ab1f0f..cbd3835 100644 --- a/src/browser_harness/run.py +++ b/src/browser_harness/run.py @@ -9,6 +9,7 @@ if hasattr(sys.stdout, "reconfigure"): from .admin import ( _version, + cdp_urlopen, NAME, daemon_alive, ensure_daemon, @@ -59,7 +60,7 @@ USAGE = """Usage: def _local_chrome_listening(): for port in (9222, 9223): try: - urllib.request.urlopen(f"http://127.0.0.1:{port}/json/version", timeout=0.3).close() + cdp_urlopen(f"http://127.0.0.1:{port}/json/version", timeout=0.3).close() return True except OSError: pass return False