fix(tui): align /browser connect local CDP handling
Share Chrome CDP launch helpers between the classic CLI and TUI so default /browser connect uses loopback consistently, retries local Chrome launch, and reports a copyable manual-start command instead of claiming a dead connection.
This commit is contained in:
committed by
Teknium
parent
1d4218be56
commit
f10a3df632
145
cli.py
145
cli.py
@@ -80,6 +80,11 @@ _COMMAND_SPINNER_FRAMES = ("⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧
|
|||||||
# Load .env from ~/.hermes/.env first, then project root as dev fallback.
|
# Load .env from ~/.hermes/.env first, then project root as dev fallback.
|
||||||
# User-managed env files should override stale shell exports on restart.
|
# User-managed env files should override stale shell exports on restart.
|
||||||
from hermes_constants import get_hermes_home, display_hermes_home
|
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 hermes_cli.env_loader import load_hermes_dotenv
|
||||||
from utils import base_url_host_matches
|
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)
|
logger.warning("Unknown service_tier '%s', ignoring", raw)
|
||||||
return None
|
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]:
|
def load_cli_config() -> Dict[str, Any]:
|
||||||
"""
|
"""
|
||||||
Load CLI configuration from config files.
|
Load CLI configuration from config files.
|
||||||
@@ -6606,34 +6552,7 @@ class HermesCLI:
|
|||||||
|
|
||||||
Returns True if a launch command was executed (doesn't guarantee success).
|
Returns True if a launch command was executed (doesn't guarantee success).
|
||||||
"""
|
"""
|
||||||
import subprocess as _sp
|
return try_launch_chrome_debug(port, system)
|
||||||
|
|
||||||
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
|
|
||||||
|
|
||||||
def _handle_browser_command(self, cmd: str):
|
def _handle_browser_command(self, cmd: str):
|
||||||
"""Handle /browser connect|disconnect|status — manage live Chrome CDP connection."""
|
"""Handle /browser connect|disconnect|status — manage live Chrome CDP connection."""
|
||||||
@@ -6642,13 +6561,23 @@ 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 = "http://127.0.0.1:9222"
|
_DEFAULT_CDP = DEFAULT_BROWSER_CDP_URL
|
||||||
current = os.environ.get("BROWSER_CDP_URL", "").strip()
|
current = os.environ.get("BROWSER_CDP_URL", "").strip()
|
||||||
|
|
||||||
if sub.startswith("connect"):
|
if sub.startswith("connect"):
|
||||||
# Optionally accept a custom CDP URL: /browser connect ws://host:port
|
# Optionally accept a custom CDP URL: /browser connect ws://host:port
|
||||||
connect_parts = cmd.strip().split(None, 2) # ["/browser", "connect", "ws://..."]
|
connect_parts = cmd.strip().split(None, 2) # ["/browser", "connect", "ws://..."]
|
||||||
cdp_url = connect_parts[2].strip() if len(connect_parts) > 2 else _DEFAULT_CDP
|
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
|
# Clear any existing browser sessions so the next tool call uses the new backend
|
||||||
try:
|
try:
|
||||||
@@ -6660,11 +6589,8 @@ class HermesCLI:
|
|||||||
print()
|
print()
|
||||||
|
|
||||||
# Extract port for connectivity checks
|
# Extract port for connectivity checks
|
||||||
_port = 9222
|
_host = parsed_cdp.hostname or "127.0.0.1"
|
||||||
try:
|
_port = parsed_cdp.port or (443 if parsed_cdp.scheme in {"https", "wss"} else 80)
|
||||||
_port = int(cdp_url.rsplit(":", 1)[-1].split("/")[0])
|
|
||||||
except (ValueError, IndexError):
|
|
||||||
pass
|
|
||||||
|
|
||||||
# Check if Chrome is already listening on the debug port
|
# Check if Chrome is already listening on the debug port
|
||||||
import socket
|
import socket
|
||||||
@@ -6672,7 +6598,7 @@ class HermesCLI:
|
|||||||
try:
|
try:
|
||||||
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||||
s.settimeout(1)
|
s.settimeout(1)
|
||||||
s.connect(("127.0.0.1", _port))
|
s.connect((_host, _port))
|
||||||
s.close()
|
s.close()
|
||||||
_already_open = True
|
_already_open = True
|
||||||
except (OSError, socket.timeout):
|
except (OSError, socket.timeout):
|
||||||
@@ -6690,7 +6616,7 @@ class HermesCLI:
|
|||||||
try:
|
try:
|
||||||
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||||
s.settimeout(1)
|
s.settimeout(1)
|
||||||
s.connect(("127.0.0.1", _port))
|
s.connect((_host, _port))
|
||||||
s.close()
|
s.close()
|
||||||
_already_open = True
|
_already_open = True
|
||||||
break
|
break
|
||||||
@@ -6703,33 +6629,18 @@ class HermesCLI:
|
|||||||
print(" Try again in a few seconds — the debug instance may still be starting")
|
print(" Try again in a few seconds — the debug instance may still be starting")
|
||||||
else:
|
else:
|
||||||
print(" ⚠ Could not auto-launch Chrome")
|
print(" ⚠ Could not auto-launch Chrome")
|
||||||
# Show manual instructions as fallback
|
|
||||||
_data_dir = str(_hermes_home / "chrome-debug")
|
|
||||||
sys_name = _plat.system()
|
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" Launch Chrome manually:")
|
||||||
print(f" {chrome_cmd}")
|
print(f" {manual_chrome_debug_command(_port, sys_name)}")
|
||||||
else:
|
else:
|
||||||
print(f" ⚠ Port {_port} is not reachable at {cdp_url}")
|
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
|
os.environ["BROWSER_CDP_URL"] = cdp_url
|
||||||
# Eagerly start the CDP supervisor so pending_dialogs + frame_tree
|
# Eagerly start the CDP supervisor so pending_dialogs + frame_tree
|
||||||
# show up in the next browser_snapshot. No-op if already started.
|
# show up in the next browser_snapshot. No-op if already started.
|
||||||
|
|||||||
119
hermes_cli/browser_connect.py
Normal file
119
hermes_cli/browser_connect.py
Normal file
@@ -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
|
||||||
@@ -26,8 +26,8 @@ class TestChromeDebugLaunch:
|
|||||||
captured["kwargs"] = kwargs
|
captured["kwargs"] = kwargs
|
||||||
return object()
|
return object()
|
||||||
|
|
||||||
with patch("cli.shutil.which", side_effect=lambda name: r"C:\Chrome\chrome.exe" if name == "chrome.exe" else None), \
|
with patch("hermes_cli.browser_connect.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"), \
|
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):
|
patch("subprocess.Popen", side_effect=fake_popen):
|
||||||
assert HermesCLI._try_launch_chrome_debug(9333, "Windows") is True
|
assert HermesCLI._try_launch_chrome_debug(9333, "Windows") is True
|
||||||
|
|
||||||
@@ -49,8 +49,8 @@ class TestChromeDebugLaunch:
|
|||||||
monkeypatch.delenv("ProgramFiles(x86)", raising=False)
|
monkeypatch.delenv("ProgramFiles(x86)", raising=False)
|
||||||
monkeypatch.delenv("LOCALAPPDATA", raising=False)
|
monkeypatch.delenv("LOCALAPPDATA", raising=False)
|
||||||
|
|
||||||
with patch("cli.shutil.which", return_value=None), \
|
with patch("hermes_cli.browser_connect.shutil.which", return_value=None), \
|
||||||
patch("cli.os.path.isfile", side_effect=lambda path: path == installed), \
|
patch("hermes_cli.browser_connect.os.path.isfile", side_effect=lambda path: path == installed), \
|
||||||
patch("subprocess.Popen", side_effect=fake_popen):
|
patch("subprocess.Popen", side_effect=fake_popen):
|
||||||
assert HermesCLI._try_launch_chrome_debug(9222, "Windows") is True
|
assert HermesCLI._try_launch_chrome_debug(9222, "Windows") is True
|
||||||
|
|
||||||
|
|||||||
@@ -2779,6 +2779,30 @@ def _stub_urlopen(monkeypatch, *, ok: bool):
|
|||||||
monkeypatch.setattr(urllib.request, "urlopen", _opener)
|
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):
|
def test_browser_manage_status_reads_env_var(monkeypatch):
|
||||||
"""Status returns the env var verbatim (no network I/O)."""
|
"""Status returns the env var verbatim (no network I/O)."""
|
||||||
monkeypatch.setenv("BROWSER_CDP_URL", "http://127.0.0.1:9222")
|
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"]
|
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):
|
def test_browser_manage_connect_rejects_unreachable_endpoint(monkeypatch):
|
||||||
"""An unreachable endpoint must NOT mutate the env or reap sessions."""
|
"""An unreachable endpoint must NOT mutate the env or reap sessions."""
|
||||||
monkeypatch.setenv("BROWSER_CDP_URL", "http://existing:9222")
|
monkeypatch.setenv("BROWSER_CDP_URL", "http://existing:9222")
|
||||||
|
|||||||
@@ -4751,6 +4751,24 @@ def _resolve_browser_cdp_url() -> str:
|
|||||||
return ""
|
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")
|
@method("browser.manage")
|
||||||
def _(rid, params: dict) -> dict:
|
def _(rid, params: dict) -> dict:
|
||||||
action = params.get("action", "status")
|
action = params.get("action", "status")
|
||||||
@@ -4764,7 +4782,9 @@ def _(rid, params: dict) -> dict:
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
if action == "connect":
|
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:
|
try:
|
||||||
import urllib.request
|
import urllib.request
|
||||||
from urllib.parse import urlparse
|
from urllib.parse import urlparse
|
||||||
@@ -4813,7 +4833,28 @@ def _(rid, params: dict) -> dict:
|
|||||||
except Exception:
|
except Exception:
|
||||||
continue
|
continue
|
||||||
if not ok:
|
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.
|
# Persist a normalized URL for downstream CDP resolution.
|
||||||
# Discovery-style inputs (`http://host:port` or
|
# Discovery-style inputs (`http://host:port` or
|
||||||
|
|||||||
@@ -192,6 +192,7 @@ describe('createSlashHandler', () => {
|
|||||||
|
|
||||||
it.each([
|
it.each([
|
||||||
['/browser status', 'browser.manage', { action: 'status' }],
|
['/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 }],
|
['/reload-mcp', 'reload.mcp', { session_id: null }],
|
||||||
['/stop', 'process.stop', {}],
|
['/stop', 'process.stop', {}],
|
||||||
['/fast status', 'config.get', { key: 'fast', session_id: null }],
|
['/fast status', 'config.get', { key: 'fast', session_id: null }],
|
||||||
|
|||||||
@@ -107,7 +107,7 @@ export const opsCommands: SlashCommand[] = [
|
|||||||
const requested = rest.join(' ').trim()
|
const requested = rest.join(' ').trim()
|
||||||
|
|
||||||
if (action === 'connect') {
|
if (action === 'connect') {
|
||||||
payload.url = requested || 'http://localhost:9222'
|
payload.url = requested || 'http://127.0.0.1:9222'
|
||||||
}
|
}
|
||||||
|
|
||||||
ctx.gateway
|
ctx.gateway
|
||||||
|
|||||||
Reference in New Issue
Block a user