diff --git a/cli.py b/cli.py index 33a4f585e2..09b31f614e 100644 --- a/cli.py +++ b/cli.py @@ -80,6 +80,11 @@ _COMMAND_SPINNER_FRAMES = ("⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧ # Load .env from ~/.hermes/.env first, then project root as dev fallback. # User-managed env files should override stale shell exports on restart. from hermes_constants import get_hermes_home, display_hermes_home +from hermes_cli.browser_connect import ( + DEFAULT_BROWSER_CDP_URL, + manual_chrome_debug_command, + try_launch_chrome_debug, +) from hermes_cli.env_loader import load_hermes_dotenv from utils import base_url_host_matches @@ -240,65 +245,6 @@ def _parse_service_tier_config(raw: str) -> str | None: logger.warning("Unknown service_tier '%s', ignoring", raw) return None - - -def _get_chrome_debug_candidates(system: str) -> list[str]: - """Return likely browser executables for local CDP auto-launch.""" - candidates: list[str] = [] - seen: set[str] = set() - - def _add_candidate(path: str | None) -> None: - if not path: - return - normalized = os.path.normcase(os.path.normpath(path)) - if normalized in seen: - return - if os.path.isfile(path): - candidates.append(path) - seen.add(normalized) - - def _add_from_path(*names: str) -> None: - for name in names: - _add_candidate(shutil.which(name)) - - if system == "Darwin": - for app in ( - "/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", - ): - _add_candidate(app) - elif system == "Windows": - _add_from_path( - "chrome.exe", "msedge.exe", "brave.exe", "chromium.exe", - "chrome", "msedge", "brave", "chromium", - ) - - for base in ( - os.environ.get("ProgramFiles"), - os.environ.get("ProgramFiles(x86)"), - os.environ.get("LOCALAPPDATA"), - ): - if not base: - continue - for parts in ( - ("Google", "Chrome", "Application", "chrome.exe"), - ("Chromium", "Application", "chrome.exe"), - ("Chromium", "Application", "chromium.exe"), - ("BraveSoftware", "Brave-Browser", "Application", "brave.exe"), - ("Microsoft", "Edge", "Application", "msedge.exe"), - ): - _add_candidate(os.path.join(base, *parts)) - else: - _add_from_path( - "google-chrome", "google-chrome-stable", "chromium-browser", - "chromium", "brave-browser", "microsoft-edge", - ) - - return candidates - - def load_cli_config() -> Dict[str, Any]: """ Load CLI configuration from config files. @@ -6606,34 +6552,7 @@ class HermesCLI: Returns True if a launch command was executed (doesn't guarantee success). """ - import subprocess as _sp - - candidates = _get_chrome_debug_candidates(system) - - if not candidates: - return False - - # Dedicated profile dir so debug Chrome won't collide with normal Chrome - data_dir = str(_hermes_home / "chrome-debug") - os.makedirs(data_dir, exist_ok=True) - - chrome = candidates[0] - try: - _sp.Popen( - [ - chrome, - f"--remote-debugging-port={port}", - f"--user-data-dir={data_dir}", - "--no-first-run", - "--no-default-browser-check", - ], - stdout=_sp.DEVNULL, - stderr=_sp.DEVNULL, - start_new_session=True, # detach from terminal - ) - return True - except Exception: - return False + return try_launch_chrome_debug(port, system) def _handle_browser_command(self, cmd: str): """Handle /browser connect|disconnect|status — manage live Chrome CDP connection.""" @@ -6642,13 +6561,23 @@ class HermesCLI: parts = cmd.strip().split(None, 1) sub = parts[1].lower().strip() if len(parts) > 1 else "status" - _DEFAULT_CDP = "http://127.0.0.1:9222" + _DEFAULT_CDP = DEFAULT_BROWSER_CDP_URL current = os.environ.get("BROWSER_CDP_URL", "").strip() if sub.startswith("connect"): # Optionally accept a custom CDP URL: /browser connect ws://host:port connect_parts = cmd.strip().split(None, 2) # ["/browser", "connect", "ws://..."] cdp_url = connect_parts[2].strip() if len(connect_parts) > 2 else _DEFAULT_CDP + parsed_cdp = urlparse(cdp_url if "://" in cdp_url else f"http://{cdp_url}") + if parsed_cdp.path.startswith("/devtools/browser/"): + cdp_url = parsed_cdp.geturl() + else: + cdp_url = parsed_cdp._replace( + path="", + params="", + query="", + fragment="", + ).geturl() # Clear any existing browser sessions so the next tool call uses the new backend try: @@ -6660,11 +6589,8 @@ class HermesCLI: print() # Extract port for connectivity checks - _port = 9222 - try: - _port = int(cdp_url.rsplit(":", 1)[-1].split("/")[0]) - except (ValueError, IndexError): - pass + _host = parsed_cdp.hostname or "127.0.0.1" + _port = parsed_cdp.port or (443 if parsed_cdp.scheme in {"https", "wss"} else 80) # Check if Chrome is already listening on the debug port import socket @@ -6672,7 +6598,7 @@ class HermesCLI: try: s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) s.settimeout(1) - s.connect(("127.0.0.1", _port)) + s.connect((_host, _port)) s.close() _already_open = True except (OSError, socket.timeout): @@ -6690,7 +6616,7 @@ class HermesCLI: try: s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) s.settimeout(1) - s.connect(("127.0.0.1", _port)) + s.connect((_host, _port)) s.close() _already_open = True break @@ -6703,33 +6629,18 @@ class HermesCLI: print(" Try again in a few seconds — the debug instance may still be starting") else: print(" ⚠ Could not auto-launch Chrome") - # Show manual instructions as fallback - _data_dir = str(_hermes_home / "chrome-debug") sys_name = _plat.system() - if sys_name == "Darwin": - chrome_cmd = ( - 'open -a "Google Chrome" --args' - f" --remote-debugging-port=9222" - f' --user-data-dir="{_data_dir}"' - " --no-first-run --no-default-browser-check" - ) - elif sys_name == "Windows": - chrome_cmd = ( - f'chrome.exe --remote-debugging-port=9222' - f' --user-data-dir="{_data_dir}"' - f" --no-first-run --no-default-browser-check" - ) - else: - chrome_cmd = ( - f"google-chrome --remote-debugging-port=9222" - f' --user-data-dir="{_data_dir}"' - f" --no-first-run --no-default-browser-check" - ) print(f" Launch Chrome manually:") - print(f" {chrome_cmd}") + print(f" {manual_chrome_debug_command(_port, sys_name)}") else: print(f" ⚠ Port {_port} is not reachable at {cdp_url}") + if not _already_open: + print() + print("Browser not connected — start Chrome with remote debugging and retry /browser connect") + print() + return + os.environ["BROWSER_CDP_URL"] = cdp_url # Eagerly start the CDP supervisor so pending_dialogs + frame_tree # show up in the next browser_snapshot. No-op if already started. diff --git a/hermes_cli/browser_connect.py b/hermes_cli/browser_connect.py new file mode 100644 index 0000000000..f28a20a17b --- /dev/null +++ b/hermes_cli/browser_connect.py @@ -0,0 +1,119 @@ +"""Shared helpers for attaching Hermes to a local Chrome CDP port.""" + +from __future__ import annotations + +import os +import platform +import shutil +import subprocess + +from hermes_constants import get_hermes_home + + +DEFAULT_BROWSER_CDP_PORT = 9222 +DEFAULT_BROWSER_CDP_URL = f"http://127.0.0.1:{DEFAULT_BROWSER_CDP_PORT}" + + +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_from_path(*names: str) -> None: + for name in names: + add(shutil.which(name)) + + if system == "Darwin": + for app in ( + "/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", + ): + add(app) + elif system == "Windows": + add_from_path( + "chrome.exe", "msedge.exe", "brave.exe", "chromium.exe", + "chrome", "msedge", "brave", "chromium", + ) + for base in ( + os.environ.get("ProgramFiles"), + os.environ.get("ProgramFiles(x86)"), + os.environ.get("LOCALAPPDATA"), + ): + if not base: + continue + for parts in ( + ("Google", "Chrome", "Application", "chrome.exe"), + ("Chromium", "Application", "chrome.exe"), + ("Chromium", "Application", "chromium.exe"), + ("BraveSoftware", "Brave-Browser", "Application", "brave.exe"), + ("Microsoft", "Edge", "Application", "msedge.exe"), + ): + add(os.path.join(base, *parts)) + else: + add_from_path( + "google-chrome", "google-chrome-stable", "chromium-browser", + "chromium", "brave-browser", "microsoft-edge", + ) + + return candidates + + +def chrome_debug_data_dir() -> str: + return str(get_hermes_home() / "chrome-debug") + + +def manual_chrome_debug_command(port: int = DEFAULT_BROWSER_CDP_PORT, system: str | None = None) -> str: + system = system or platform.system() + data_dir = chrome_debug_data_dir() + if system == "Darwin": + return ( + 'open -a "Google Chrome" --args' + f" --remote-debugging-port={port}" + f' --user-data-dir="{data_dir}"' + " --no-first-run --no-default-browser-check" + ) + if system == "Windows": + return ( + f"chrome.exe --remote-debugging-port={port}" + f' --user-data-dir="{data_dir}"' + " --no-first-run --no-default-browser-check" + ) + return ( + f"google-chrome --remote-debugging-port={port}" + f' --user-data-dir="{data_dir}"' + " --no-first-run --no-default-browser-check" + ) + + +def try_launch_chrome_debug(port: int = DEFAULT_BROWSER_CDP_PORT, system: str | None = None) -> bool: + candidates = get_chrome_debug_candidates(system or platform.system()) + if not candidates: + return False + + os.makedirs(chrome_debug_data_dir(), exist_ok=True) + try: + subprocess.Popen( + [ + candidates[0], + f"--remote-debugging-port={port}", + f"--user-data-dir={chrome_debug_data_dir()}", + "--no-first-run", + "--no-default-browser-check", + ], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + start_new_session=True, + ) + return True + except Exception: + return False diff --git a/tests/cli/test_cli_browser_connect.py b/tests/cli/test_cli_browser_connect.py index e123afe110..69503d087a 100644 --- a/tests/cli/test_cli_browser_connect.py +++ b/tests/cli/test_cli_browser_connect.py @@ -26,8 +26,8 @@ class TestChromeDebugLaunch: captured["kwargs"] = kwargs return object() - with patch("cli.shutil.which", side_effect=lambda name: r"C:\Chrome\chrome.exe" if name == "chrome.exe" else None), \ - patch("cli.os.path.isfile", side_effect=lambda path: path == r"C:\Chrome\chrome.exe"), \ + with patch("hermes_cli.browser_connect.shutil.which", side_effect=lambda name: r"C:\Chrome\chrome.exe" if name == "chrome.exe" else None), \ + patch("hermes_cli.browser_connect.os.path.isfile", side_effect=lambda path: path == r"C:\Chrome\chrome.exe"), \ patch("subprocess.Popen", side_effect=fake_popen): assert HermesCLI._try_launch_chrome_debug(9333, "Windows") is True @@ -49,8 +49,8 @@ class TestChromeDebugLaunch: monkeypatch.delenv("ProgramFiles(x86)", raising=False) monkeypatch.delenv("LOCALAPPDATA", raising=False) - with patch("cli.shutil.which", return_value=None), \ - patch("cli.os.path.isfile", side_effect=lambda path: path == installed), \ + with patch("hermes_cli.browser_connect.shutil.which", return_value=None), \ + patch("hermes_cli.browser_connect.os.path.isfile", side_effect=lambda path: path == installed), \ patch("subprocess.Popen", side_effect=fake_popen): assert HermesCLI._try_launch_chrome_debug(9222, "Windows") is True diff --git a/tests/test_tui_gateway_server.py b/tests/test_tui_gateway_server.py index 25f066dd40..4a8cf1adb5 100644 --- a/tests/test_tui_gateway_server.py +++ b/tests/test_tui_gateway_server.py @@ -2779,6 +2779,30 @@ def _stub_urlopen(monkeypatch, *, ok: bool): monkeypatch.setattr(urllib.request, "urlopen", _opener) +def _stub_urlopen_capture(monkeypatch, *, ok: bool): + urls: list[str] = [] + + class _Resp: + status = 200 + + def __enter__(self): + return self + + def __exit__(self, *_): + return False + + def _opener(url, timeout=2.0): # noqa: ARG001 — match urllib signature + urls.append(url) + if not ok: + raise OSError("probe failed") + return _Resp() + + import urllib.request + + monkeypatch.setattr(urllib.request, "urlopen", _opener) + return urls + + def test_browser_manage_status_reads_env_var(monkeypatch): """Status returns the env var verbatim (no network I/O).""" monkeypatch.setenv("BROWSER_CDP_URL", "http://127.0.0.1:9222") @@ -2856,6 +2880,79 @@ def test_browser_manage_connect_sets_env_and_cleans_twice(monkeypatch): assert cleanup_calls == ["", "http://127.0.0.1:9222"] +def test_browser_manage_connect_defaults_to_loopback(monkeypatch): + monkeypatch.delenv("BROWSER_CDP_URL", raising=False) + fake = types.SimpleNamespace( + cleanup_all_browsers=lambda: None, + _get_cdp_override=lambda: os.environ.get("BROWSER_CDP_URL", ""), + ) + with patch.dict(sys.modules, {"tools.browser_tool": fake}): + urls = _stub_urlopen_capture(monkeypatch, ok=True) + resp = server.handle_request( + {"id": "1", "method": "browser.manage", "params": {"action": "connect"}} + ) + + assert resp["result"] == {"connected": True, "url": "http://127.0.0.1:9222"} + assert urls[0] == "http://127.0.0.1:9222/json/version" + + +def test_browser_manage_connect_default_local_reports_launch_hint(monkeypatch): + monkeypatch.delenv("BROWSER_CDP_URL", raising=False) + fake = types.SimpleNamespace( + cleanup_all_browsers=lambda: None, + _get_cdp_override=lambda: os.environ.get("BROWSER_CDP_URL", ""), + ) + with patch.dict(sys.modules, {"tools.browser_tool": fake}): + _stub_urlopen(monkeypatch, ok=False) + with patch("hermes_cli.browser_connect.try_launch_chrome_debug", return_value=False): + resp = server.handle_request( + {"id": "1", "method": "browser.manage", "params": {"action": "connect"}} + ) + + assert resp["error"]["code"] == 5031 + assert "Start Chrome with remote debugging" in resp["error"]["message"] + assert "google-chrome --remote-debugging-port=9222" in resp["error"]["message"] + assert "BROWSER_CDP_URL" not in os.environ + + +def test_browser_manage_connect_default_local_retries_after_launch(monkeypatch): + monkeypatch.delenv("BROWSER_CDP_URL", raising=False) + monkeypatch.setattr(server.time, "sleep", lambda _seconds: None) + fake = types.SimpleNamespace( + cleanup_all_browsers=lambda: None, + _get_cdp_override=lambda: os.environ.get("BROWSER_CDP_URL", ""), + ) + + class _Resp: + status = 200 + + def __enter__(self): + return self + + def __exit__(self, *_): + return False + + attempts = {"n": 0} + + def _opener(_url, timeout=2.0): # noqa: ARG001 — match urllib signature + attempts["n"] += 1 + if attempts["n"] < 3: + raise OSError("not ready") + return _Resp() + + import urllib.request + + monkeypatch.setattr(urllib.request, "urlopen", _opener) + with patch.dict(sys.modules, {"tools.browser_tool": fake}): + with patch("hermes_cli.browser_connect.try_launch_chrome_debug", return_value=True): + resp = server.handle_request( + {"id": "1", "method": "browser.manage", "params": {"action": "connect"}} + ) + + assert resp["result"] == {"connected": True, "url": "http://127.0.0.1:9222"} + assert os.environ["BROWSER_CDP_URL"] == "http://127.0.0.1:9222" + + def test_browser_manage_connect_rejects_unreachable_endpoint(monkeypatch): """An unreachable endpoint must NOT mutate the env or reap sessions.""" monkeypatch.setenv("BROWSER_CDP_URL", "http://existing:9222") diff --git a/tui_gateway/server.py b/tui_gateway/server.py index b956ef0c37..d426bba62a 100644 --- a/tui_gateway/server.py +++ b/tui_gateway/server.py @@ -4751,6 +4751,24 @@ def _resolve_browser_cdp_url() -> str: return "" +def _is_default_local_cdp(parsed) -> bool: + return ( + parsed.scheme in {"http", "ws"} + and parsed.hostname in {"127.0.0.1", "localhost"} + and (parsed.port or 80) == 9222 + ) + + +def _browser_connect_error(url: str, port: int) -> str: + from hermes_cli.browser_connect import manual_chrome_debug_command + + return ( + f"Chrome is not reachable at {url}. " + "Start Chrome with remote debugging, then retry /browser connect:\n" + f"{manual_chrome_debug_command(port)}" + ) + + @method("browser.manage") def _(rid, params: dict) -> dict: action = params.get("action", "status") @@ -4764,7 +4782,9 @@ def _(rid, params: dict) -> dict: }, ) if action == "connect": - url = params.get("url", "http://localhost:9222") + from hermes_cli.browser_connect import DEFAULT_BROWSER_CDP_URL + + url = params.get("url", DEFAULT_BROWSER_CDP_URL) try: import urllib.request from urllib.parse import urlparse @@ -4813,7 +4833,28 @@ def _(rid, params: dict) -> dict: except Exception: continue if not ok: - return _err(rid, 5031, f"could not reach browser CDP at {url}") + if _is_default_local_cdp(parsed): + import platform + from hermes_cli.browser_connect import try_launch_chrome_debug + + port = parsed.port or 9222 + if try_launch_chrome_debug(port, platform.system()): + for _ in range(10): + time.sleep(0.5) + for probe in probe_urls: + try: + with urllib.request.urlopen(probe, timeout=1.0) as resp: + if 200 <= getattr(resp, "status", 200) < 300: + ok = True + break + except Exception: + continue + if ok: + break + if not ok: + return _err(rid, 5031, _browser_connect_error(url, port)) + else: + return _err(rid, 5031, f"could not reach browser CDP at {url}") # Persist a normalized URL for downstream CDP resolution. # Discovery-style inputs (`http://host:port` or diff --git a/ui-tui/src/__tests__/createSlashHandler.test.ts b/ui-tui/src/__tests__/createSlashHandler.test.ts index 1ec0dba79c..14f76b625b 100644 --- a/ui-tui/src/__tests__/createSlashHandler.test.ts +++ b/ui-tui/src/__tests__/createSlashHandler.test.ts @@ -192,6 +192,7 @@ describe('createSlashHandler', () => { it.each([ ['/browser status', 'browser.manage', { action: 'status' }], + ['/browser connect', 'browser.manage', { action: 'connect', url: 'http://127.0.0.1:9222' }], ['/reload-mcp', 'reload.mcp', { session_id: null }], ['/stop', 'process.stop', {}], ['/fast status', 'config.get', { key: 'fast', session_id: null }], diff --git a/ui-tui/src/app/slash/commands/ops.ts b/ui-tui/src/app/slash/commands/ops.ts index 7443552740..9a7fc9d79c 100644 --- a/ui-tui/src/app/slash/commands/ops.ts +++ b/ui-tui/src/app/slash/commands/ops.ts @@ -107,7 +107,7 @@ export const opsCommands: SlashCommand[] = [ const requested = rest.join(' ').trim() if (action === 'connect') { - payload.url = requested || 'http://localhost:9222' + payload.url = requested || 'http://127.0.0.1:9222' } ctx.gateway