"""Shared helpers for attaching Hermes to a local Chromium-family CDP port.""" from __future__ import annotations import logging import os import platform import shlex import shutil import subprocess import time from dataclasses import dataclass, field from hermes_constants import get_hermes_home logger = logging.getLogger(__name__) DEFAULT_BROWSER_CDP_PORT = 9222 DEFAULT_BROWSER_CDP_URL = f"http://127.0.0.1:{DEFAULT_BROWSER_CDP_PORT}" _DARWIN_APPS = ( "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome", "/Applications/Chromium.app/Contents/MacOS/Chromium", "/Applications/Brave Browser.app/Contents/MacOS/Brave Browser", "/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge", ) _WINDOWS_BROWSER_GROUPS = ( (("chrome.exe", "chrome"), (("Google", "Chrome", "Application", "chrome.exe"),)), ( ("chromium.exe", "chromium"), (("Chromium", "Application", "chrome.exe"), ("Chromium", "Application", "chromium.exe")), ), (("brave.exe", "brave"), (("BraveSoftware", "Brave-Browser", "Application", "brave.exe"),)), (("msedge.exe", "msedge"), (("Microsoft", "Edge", "Application", "msedge.exe"),)), ) _WINDOWS_BIN_NAMES = tuple(name for names, _ in _WINDOWS_BROWSER_GROUPS for name in names) _WINDOWS_INSTALL_PARTS = tuple(parts for _, group in _WINDOWS_BROWSER_GROUPS for parts in group) _LINUX_BROWSER_GROUPS = ( ( ("google-chrome", "google-chrome-stable"), ("/opt/google/chrome/chrome", "/usr/bin/google-chrome", "/usr/bin/google-chrome-stable"), ), ( ("chromium-browser", "chromium"), ("/usr/bin/chromium-browser", "/usr/bin/chromium"), ), ( ("brave-browser", "brave-browser-stable", "brave"), ( "/usr/bin/brave-browser", "/usr/bin/brave-browser-stable", "/usr/bin/brave", "/snap/bin/brave", "/opt/brave.com/brave/brave-browser", "/opt/brave.com/brave/brave", "/opt/brave-bin/brave", ), ), ( ("microsoft-edge", "microsoft-edge-stable", "msedge"), ( "/usr/bin/microsoft-edge", "/usr/bin/microsoft-edge-stable", "/opt/microsoft/msedge/microsoft-edge", "/opt/microsoft/msedge/msedge", ), ), ) _LINUX_BIN_NAMES = tuple(name for names, _ in _LINUX_BROWSER_GROUPS for name in names) _LINUX_INSTALL_PATHS = tuple(path for _, paths in _LINUX_BROWSER_GROUPS for path in paths) def get_chrome_debug_candidates(system: str) -> list[str]: candidates: list[str] = [] seen: set[str] = set() def add(path: str | None) -> None: if not path: return normalized = os.path.normcase(os.path.normpath(path)) if normalized in seen or not os.path.isfile(path): return candidates.append(path) seen.add(normalized) def add_windows_install_paths( bases: tuple[str | None, ...], install_groups: tuple[tuple[tuple[str, ...], tuple[tuple[str, ...], ...]], ...], ) -> None: for _, group in install_groups: for base in filter(None, bases): for parts in group: add(os.path.join(base, *parts)) if system == "Darwin": for app in _DARWIN_APPS: add(app) return candidates if system == "Windows": install_bases = ( os.environ.get("ProgramFiles"), os.environ.get("ProgramFiles(x86)"), os.environ.get("LOCALAPPDATA"), ) for names, install_parts in _WINDOWS_BROWSER_GROUPS: for name in names: add(shutil.which(name)) for base in filter(None, install_bases): for parts in install_parts: add(os.path.join(base, *parts)) return candidates for names, paths in _LINUX_BROWSER_GROUPS: for name in names: add(shutil.which(name)) for path in paths: add(path) add_windows_install_paths(("/mnt/c/Program Files", "/mnt/c/Program Files (x86)"), _WINDOWS_BROWSER_GROUPS) return candidates def chrome_debug_data_dir() -> str: return str(get_hermes_home() / "chrome-debug") def _chrome_debug_args(port: int) -> list[str]: return [ f"--remote-debugging-port={port}", f"--user-data-dir={chrome_debug_data_dir()}", "--no-first-run", "--no-default-browser-check", ] def is_browser_debug_ready(url: str, timeout: float = 1.0) -> bool: """Return True when ``url`` exposes a reachable Chrome DevTools endpoint.""" import socket import urllib.request from urllib.parse import urlparse parsed = urlparse(url if "://" in url else f"http://{url}") try: port = parsed.port or (443 if parsed.scheme in {"https", "wss"} else 80) except ValueError: return False if parsed.scheme in {"ws", "wss"} and parsed.path.startswith("/devtools/browser/"): if not parsed.hostname: return False try: with socket.create_connection((parsed.hostname, port), timeout=timeout): return True except OSError: return False scheme = {"ws": "http", "wss": "https"}.get(parsed.scheme, parsed.scheme) if scheme not in {"http", "https"} or not parsed.netloc: return False root = f"{scheme}://{parsed.netloc}".rstrip("/") for probe in (f"{root}/json/version", f"{root}/json"): try: with urllib.request.urlopen(probe, timeout=timeout) as resp: if 200 <= getattr(resp, "status", 200) < 300: return True except Exception: continue return False def manual_chrome_debug_command(port: int = DEFAULT_BROWSER_CDP_PORT, system: str | None = None) -> str | None: system = system or platform.system() candidates = get_chrome_debug_candidates(system) if candidates: argv = [candidates[0], *_chrome_debug_args(port)] return subprocess.list2cmdline(argv) if system == "Windows" else shlex.join(argv) if system == "Darwin": data_dir = chrome_debug_data_dir() return ( f'open -a "Google Chrome" --args --remote-debugging-port={port} ' f'--user-data-dir="{data_dir}" --no-first-run --no-default-browser-check' ) return None def _detach_kwargs(system: str) -> dict: if system != "Windows": return {"start_new_session": True} flags = getattr(subprocess, "DETACHED_PROCESS", 0) | getattr( subprocess, "CREATE_NEW_PROCESS_GROUP", 0 ) return {"creationflags": flags} if flags else {} def _wait_for_browser_debug_ready_or_exit( proc: subprocess.Popen, port: int, timeout: float = 2.0, interval: float = 0.1, ) -> str: """Classify a launched browser as ready, exited, or still starting. We only need to wait long enough to catch the common failure mode where a candidate binary exists but exits immediately before exposing the CDP port. Slower browsers can still finish starting after this grace window. """ cdp_url = f"http://127.0.0.1:{port}" deadline = time.monotonic() + timeout while time.monotonic() < deadline: if is_browser_debug_ready(cdp_url, timeout=min(interval, 0.2)): return "ready" if proc.poll() is not None: return "exited" time.sleep(interval) return "starting" _LAUNCH_STDERR_LOG = "launch-stderr.log" _STDERR_TAIL_LIMIT = 2000 @dataclass class LaunchAttempt: """Outcome of one candidate-binary launch attempt.""" binary: str state: str # "ready" | "starting" | "exited" | "spawn-failed" returncode: int | None = None stderr_tail: str = "" @dataclass class ChromeDebugLaunch: """Structured result of ``launch_chrome_debug``. ``launched`` mirrors the legacy boolean contract: a launch command was executed and the browser is ready or still starting (it does NOT guarantee the CDP port ever opens). ``attempts`` carries per-candidate diagnostics so callers can explain *why* nothing came up. """ launched: bool = False attempts: list[LaunchAttempt] = field(default_factory=list) @property def hint(self) -> str | None: """Best user-facing explanation for a failed/soft launch, if any.""" for attempt in self.attempts: if attempt.state == "exited" and attempt.returncode == 0: name = os.path.basename(attempt.binary) return ( f"{name} exited immediately without opening the debug port — an already-running " f"{name} instance likely absorbed the launch (Chromium's single-instance " "behavior). Close ALL of its processes (including background/tray instances) " "and retry /browser connect." ) for attempt in self.attempts: if attempt.state == "exited" and attempt.stderr_tail: return ( f"{os.path.basename(attempt.binary)} exited before the debug port opened: " f"{attempt.stderr_tail.splitlines()[-1].strip()}" ) return None def _read_stderr_tail(path: str) -> str: try: with open(path, "rb") as fh: data = fh.read() return data[-_STDERR_TAIL_LIMIT:].decode("utf-8", errors="replace").strip() except OSError: return "" def launch_chrome_debug( port: int = DEFAULT_BROWSER_CDP_PORT, system: str | None = None ) -> ChromeDebugLaunch: """Launch a Chromium-family browser with remote debugging, with diagnostics. Tries each detected candidate binary in turn. A candidate that exits before the CDP port opens (crash, singleton forward to an existing instance, bad profile dir) is logged — with exit code and a stderr tail — and the next candidate is tried. """ system = system or platform.system() result = ChromeDebugLaunch() candidates = get_chrome_debug_candidates(system) if not candidates: logger.info("browser debug launch: no Chromium-family binary found (system=%s)", system) return result data_dir = chrome_debug_data_dir() os.makedirs(data_dir, exist_ok=True) stderr_path = os.path.join(data_dir, _LAUNCH_STDERR_LOG) for candidate in candidates: try: with open(stderr_path, "wb") as stderr_file: proc = subprocess.Popen( [candidate, *_chrome_debug_args(port)], stdout=subprocess.DEVNULL, stderr=stderr_file, **_detach_kwargs(system), ) except Exception as exc: result.attempts.append(LaunchAttempt(binary=candidate, state="spawn-failed")) logger.info("browser debug launch: failed to spawn %s: %s", candidate, exc) continue logger.info( "browser debug launch: spawned %s (pid=%s) with --remote-debugging-port=%d", candidate, getattr(proc, "pid", None), port, ) state = _wait_for_browser_debug_ready_or_exit(proc, port) attempt = LaunchAttempt(binary=candidate, state=state) result.attempts.append(attempt) if state != "exited": result.launched = True return result attempt.returncode = getattr(proc, "returncode", None) attempt.stderr_tail = _read_stderr_tail(stderr_path) logger.warning( "browser debug launch: %s exited (code=%s) before port %d opened%s", candidate, attempt.returncode, port, f"; stderr tail: {attempt.stderr_tail}" if attempt.stderr_tail else "", ) return result def try_launch_chrome_debug(port: int = DEFAULT_BROWSER_CDP_PORT, system: str | None = None) -> bool: return launch_chrome_debug(port, system).launched