"""Browser connect/disconnect helpers for the browser.* RPCs (CDP probing, no network I/O on status). Bodies are rebound onto server.py's globals at install time (see method_ctx.bind_module), so they reference server.py globals bare. """ from __future__ import annotations from .method_ctx import HandlerRegistry, bind_module _registry = HandlerRegistry() def _resolve_browser_cdp_url() -> str: """Configured browser CDP override without network I/O. ``/browser status`` must be fast: ``tools.browser_tool._get_cdp_override`` runs an HTTP probe with a multi-second timeout for discovery-style URLs. Mirrors its precedence (env var, then ``browser.cdp_url``) minus the WS-resolution step, so the answer reflects user intent even when the host is unreachable; ``browser_navigate`` normalizes on the next tool call. """ env_url = os.environ.get("BROWSER_CDP_URL", "").strip() if env_url: return env_url try: from hermes_cli.config import read_raw_config cfg = read_raw_config() browser_cfg = cfg.get("browser", {}) if isinstance(cfg, dict) else {} if isinstance(browser_cfg, dict): return str(browser_cfg.get("cdp_url", "") or "").strip() except Exception: pass return "" def _is_default_local_cdp(parsed) -> bool: """Match the discovery-style local default; never the concrete WS form — a ``ws://127.0.0.1:9222/devtools/browser/`` is connectable as-is and collapsing it to bare ``http://...:9222`` would break the connect.""" try: port = parsed.port or 80 except ValueError: return False return ( parsed.scheme in {"http", "ws"} and parsed.hostname in {"127.0.0.1", "localhost"} and port == 9222 and parsed.path in {"", "/", "/json", "/json/version"} ) def _http_ok(url: str, timeout: float) -> bool: import urllib.request try: with urllib.request.urlopen(url, timeout=timeout) as resp: return 200 <= getattr(resp, "status", 200) < 300 except Exception: return False def _probe_urls(parsed) -> list[str]: scheme = {"ws": "http", "wss": "https"}.get(parsed.scheme, parsed.scheme) root = f"{scheme}://{parsed.netloc}".rstrip("/") return [f"{root}/json/version", f"{root}/json"] def _normalize_cdp_url(parsed) -> str: # Concrete ``/devtools/browser/`` endpoints stay as-is; discovery-style inputs # collapse to ``scheme://host:port`` so ``_resolve_cdp_override`` can append ``/json/version``. if parsed.path.startswith("/devtools/browser/"): return parsed.geturl() return parsed._replace(path="", params="", query="", fragment="").geturl() def _failure_messages(url: str, port: int, system: str) -> list[str]: from hermes_cli.browser_connect import manual_chrome_debug_command command = manual_chrome_debug_command(port, system) hint = ( ["Start a Chromium-family browser with remote debugging, then retry /browser connect:", command] if command else [ "No supported Chromium-family browser executable was found in this environment.", f"Install one or start a Chromium-family browser with --remote-debugging-port={port}, then retry /browser connect.", ] ) return [ f"Browser CDP is not reachable at {url}.", *hint, "Browser not connected — start a Chromium-family browser with remote debugging and retry /browser connect", ] def _browser_connect(rid, params: dict) -> dict: import platform from hermes_cli.browser_connect import DEFAULT_BROWSER_CDP_URL from tools.browser_tool import cleanup_all_browsers from urllib.parse import urlparse raw_url = params.get("url") if raw_url is not None and not isinstance(raw_url, str): return _err(rid, 4015, f"browser url must be a string, got {type(raw_url).__name__}") url = (raw_url or "").strip() or DEFAULT_BROWSER_CDP_URL sid = params.get("session_id") or "" system = platform.system() messages: list[str] = [] def announce(message: str, *, level: str = "info") -> None: messages.append(message) # Without a session id the TUI prints `messages` from the response; an event would double-render. if sid: _emit("browser.progress", sid, {"message": message, "level": level}) parsed = urlparse(url if "://" in url else f"http://{url}") if parsed.scheme not in {"http", "https", "ws", "wss"}: return _err(rid, 4015, f"unsupported browser url: {url}") if not parsed.hostname: return _err(rid, 4015, f"missing host in browser url: {url}") try: port = parsed.port or (443 if parsed.scheme in {"https", "wss"} else 80) except ValueError: return _err(rid, 4015, f"invalid port in browser url: {url}") # Normalize default-local to 127.0.0.1:9222 so comparisons + messaging match what we persist. if _is_default_local_cdp(parsed): url = DEFAULT_BROWSER_CDP_URL parsed = urlparse(url) port = parsed.port or 9222 try: # Hosted ws[s]://.../devtools/browser/ endpoints don't serve the HTTP discovery # path: check TCP reachability only and let browser_navigate handshake. if parsed.scheme in {"ws", "wss"} and parsed.path.startswith("/devtools/browser/"): import socket try: with socket.create_connection((parsed.hostname, port), timeout=2.0): pass except OSError as e: return _err(rid, 5031, f"could not reach browser CDP at {url}: {e}") elif _is_default_local_cdp(parsed): from hermes_cli.browser_connect import ( discover_local_cdp_url, find_free_debug_port, launch_chrome_debug, local_port_in_use, ) # Dual-stack discovery: when another app squats the IPv4 loopback on the debug # port, a browser bound there comes up on [::1] only. An IPv4-only probe misses # it AND hangs against squatters that accept TCP but never answer HTTP. discovered = discover_local_cdp_url(port, timeout=2.0) launch_port = port if discovered is None: if local_port_in_use(port): launch_port = find_free_debug_port(port) announce( f"Port {port} is occupied by another application that isn't a CDP browser " "(an IDE debugger or dev server may be using it) — launching a debug browser " f"on port {launch_port} instead..." ) else: announce("Chromium-family browser isn't running with remote debugging — attempting to launch...") launch = launch_chrome_debug(launch_port, system) if launch.launched: # Bounded wait: the whole connect must finish inside the client RPC timeout. deadline = time.monotonic() + 10.0 while time.monotonic() < deadline: discovered = discover_local_cdp_url(launch_port, timeout=1.0) if discovered: break time.sleep(0.5) if discovered: announce(f"Chromium-family browser launched and listening on port {launch_port}") else: hint = launch.hint if hint: announce(hint, level="error") for line in _failure_messages(url, launch_port, system)[1:]: announce(line, level="error") return _ok(rid, {"connected": False, "url": url, "messages": messages}) else: announce(f"Chromium-family browser is already listening at {discovered}") # Adopt whatever loopback/port answered ([::1] and/or an alternate port when 9222 was squatted). url = discovered parsed = urlparse(url) elif not any(_http_ok(p, timeout=2.0) for p in _probe_urls(parsed)): return _err(rid, 5031, f"could not reach browser CDP at {url}") normalized = _normalize_cdp_url(parsed) # Reap BEFORE publishing the new env (an in-flight tool call sees the old supervisor # closed) and AFTER (the default task's cached supervisor drains against the new URL). cleanup_all_browsers() os.environ["BROWSER_CDP_URL"] = normalized cleanup_all_browsers() except Exception as e: return _err(rid, 5031, str(e)) payload: dict[str, object] = {"connected": True, "url": normalized} if messages: payload["messages"] = messages return _ok(rid, payload) def _browser_disconnect(rid) -> dict: # Reap, drop the override, reap again — same swap window as ``_browser_connect``. def reap() -> None: try: from tools.browser_tool import cleanup_all_browsers cleanup_all_browsers() except Exception: pass reap() os.environ.pop("BROWSER_CDP_URL", None) reap() return _ok(rid, {"connected": False}) def register(server) -> None: """Publish this module's helpers + handlers onto ``server``, rebound to its globals.""" bind_module(globals(), server, skip=("_",))