From bb59057d5df2aca11e65bf9b0031d5ace1155f54 Mon Sep 17 00:00:00 2001 From: cmcleay <269285594+mcleay@users.noreply.github.com> Date: Thu, 19 Mar 2026 14:06:49 +0000 Subject: [PATCH] fix: normalize live Chrome CDP endpoints for browser tools --- cli.py | 2 +- tests/tools/test_browser_cdp_override.py | 47 +++++++++++++++++++++ tools/browser_tool.py | 53 +++++++++++++++++++++++- 3 files changed, 99 insertions(+), 3 deletions(-) create mode 100644 tests/tools/test_browser_cdp_override.py diff --git a/cli.py b/cli.py index 2be21b902..86076c780 100755 --- a/cli.py +++ b/cli.py @@ -3926,7 +3926,7 @@ class HermesCLI: parts = cmd.strip().split(None, 1) sub = parts[1].lower().strip() if len(parts) > 1 else "status" - _DEFAULT_CDP = "ws://localhost:9222" + _DEFAULT_CDP = "http://localhost:9222" current = os.environ.get("BROWSER_CDP_URL", "").strip() if sub.startswith("connect"): diff --git a/tests/tools/test_browser_cdp_override.py b/tests/tools/test_browser_cdp_override.py new file mode 100644 index 000000000..a29971fab --- /dev/null +++ b/tests/tools/test_browser_cdp_override.py @@ -0,0 +1,47 @@ +from unittest.mock import Mock, patch + + +HOST = "example-host" +PORT = 9223 +WS_URL = f"ws://{HOST}:{PORT}/devtools/browser/abc123" +HTTP_URL = f"http://{HOST}:{PORT}" +VERSION_URL = f"{HTTP_URL}/json/version" + + +class TestResolveCdpOverride: + def test_keeps_full_devtools_websocket_url(self): + from tools.browser_tool import _resolve_cdp_override + + assert _resolve_cdp_override(WS_URL) == WS_URL + + def test_resolves_http_discovery_endpoint_to_websocket(self): + from tools.browser_tool import _resolve_cdp_override + + response = Mock() + response.raise_for_status.return_value = None + response.json.return_value = {"webSocketDebuggerUrl": WS_URL} + + with patch("tools.browser_tool.requests.get", return_value=response) as mock_get: + resolved = _resolve_cdp_override(HTTP_URL) + + assert resolved == WS_URL + mock_get.assert_called_once_with(VERSION_URL, timeout=10) + + def test_resolves_bare_ws_hostport_to_discovery_websocket(self): + from tools.browser_tool import _resolve_cdp_override + + response = Mock() + response.raise_for_status.return_value = None + response.json.return_value = {"webSocketDebuggerUrl": WS_URL} + + with patch("tools.browser_tool.requests.get", return_value=response) as mock_get: + resolved = _resolve_cdp_override(f"ws://{HOST}:{PORT}") + + assert resolved == WS_URL + mock_get.assert_called_once_with(VERSION_URL, timeout=10) + + def test_falls_back_to_raw_url_when_discovery_fails(self): + from tools.browser_tool import _resolve_cdp_override + + with patch("tools.browser_tool.requests.get", side_effect=RuntimeError("boom")): + assert _resolve_cdp_override(HTTP_URL) == HTTP_URL diff --git a/tools/browser_tool.py b/tools/browser_tool.py index dbc0d7e6c..0abf760af 100644 --- a/tools/browser_tool.py +++ b/tools/browser_tool.py @@ -106,14 +106,63 @@ def _get_extraction_model() -> Optional[str]: return os.getenv("AUXILIARY_WEB_EXTRACT_MODEL", "").strip() or None +def _resolve_cdp_override(cdp_url: str) -> str: + """Normalize a user-supplied CDP endpoint into a concrete connectable URL. + + Accepts: + - full websocket endpoints: ws://host:port/devtools/browser/... + - HTTP discovery endpoints: http://host:port or http://host:port/json/version + - bare websocket host:port values like ws://host:port + + For discovery-style endpoints we fetch /json/version and return the + webSocketDebuggerUrl so downstream tools always receive a concrete browser + websocket instead of an ambiguous host:port URL. + """ + raw = (cdp_url or "").strip() + if not raw: + return "" + + lowered = raw.lower() + if "/devtools/browser/" in lowered: + return raw + + discovery_url = raw + if lowered.startswith("ws://") or lowered.startswith("wss://"): + if raw.count(":") == 2 and raw.rstrip("/").rsplit(":", 1)[-1].isdigit() and "/" not in raw.split(":", 2)[-1]: + discovery_url = ("http://" if lowered.startswith("ws://") else "https://") + raw.split("://", 1)[1] + else: + return raw + + if discovery_url.lower().endswith("/json/version"): + version_url = discovery_url + else: + version_url = discovery_url.rstrip("/") + "/json/version" + + try: + response = requests.get(version_url, timeout=10) + response.raise_for_status() + payload = response.json() + except Exception as exc: + logger.warning("Failed to resolve CDP endpoint %s via %s: %s", raw, version_url, exc) + return raw + + ws_url = str(payload.get("webSocketDebuggerUrl") or "").strip() + if ws_url: + logger.info("Resolved CDP endpoint %s -> %s", raw, ws_url) + return ws_url + + logger.warning("CDP discovery at %s did not return webSocketDebuggerUrl; using raw endpoint", version_url) + return raw + + def _get_cdp_override() -> str: - """Return a user-supplied CDP URL override, or empty string. + """Return a normalized user-supplied CDP URL override, or empty string. When ``BROWSER_CDP_URL`` is set (e.g. via ``/browser connect``), we skip both Browserbase and the local headless launcher and connect directly to the supplied Chrome DevTools Protocol endpoint. """ - return os.environ.get("BROWSER_CDP_URL", "").strip() + return _resolve_cdp_override(os.environ.get("BROWSER_CDP_URL", "")) # ============================================================================