fix: normalize live Chrome CDP endpoints for browser tools

This commit is contained in:
cmcleay 2026-03-19 14:06:49 +00:00 committed by Test
parent 36a4481152
commit bb59057d5d
3 changed files with 99 additions and 3 deletions

2
cli.py
View file

@ -3926,7 +3926,7 @@ class HermesCLI:
parts = cmd.strip().split(None, 1) parts = cmd.strip().split(None, 1)
sub = parts[1].lower().strip() if len(parts) > 1 else "status" 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() current = os.environ.get("BROWSER_CDP_URL", "").strip()
if sub.startswith("connect"): if sub.startswith("connect"):

View file

@ -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

View file

@ -106,14 +106,63 @@ def _get_extraction_model() -> Optional[str]:
return os.getenv("AUXILIARY_WEB_EXTRACT_MODEL", "").strip() or None 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: 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 When ``BROWSER_CDP_URL`` is set (e.g. via ``/browser connect``), we skip
both Browserbase and the local headless launcher and connect directly to both Browserbase and the local headless launcher and connect directly to
the supplied Chrome DevTools Protocol endpoint. the supplied Chrome DevTools Protocol endpoint.
""" """
return os.environ.get("BROWSER_CDP_URL", "").strip() return _resolve_cdp_override(os.environ.get("BROWSER_CDP_URL", ""))
# ============================================================================ # ============================================================================