Files
hermes-agent/tui_gateway/methods_browser.py
T
Teknium 99433742dc refactor(tui): extract the prompt turn into prompt_turn.py; compact methods_*/session_*/agent_callbacks; split _start_agent_build into scope/wiring helpers
- server.py 7663 -> 5319: _run_prompt_submit and its goal/loop/voice/scope
  phases move to tui_gateway/prompt_turn.py (bound via method_ctx.bind_module);
  _start_agent_build split into _bind/_release_build_profile_scopes,
  _deferred_build_agent_kwargs, _wire_session_agent, _start_session_services;
  _load_enabled_toolsets split (_enabled_mcp_server_names, _resolve_explicit_toolsets).
- methods_slash: _LIVE_SLASH_OUTPUT dispatch table; methods_tools: _SLASH_BUILTINS,
  _guarded; tool_progress: _PROGRESS_HANDLERS; methods_config_set:
  _REASONING_DISPLAY_WORDS; methods_voice: _VOICE_TOGGLE_ACTIONS.
- Unified: _denied_source (methods_session) replaces _WORKER_SOURCES in
  methods_profiles; _compress_live_with_feedback / _compute_host_slash shared by
  the slash mirror and /compress; _end_voice_chat shared by stop-phrase paths;
  _watcher_mtime_ns; _reaper_session_is_detached_idle; _notif_* helpers.
- Dead: _profile_dir_or_err, _resume_info, _slash_builtin_table (refs.py: 0 hits).
- acp_adapter/server.py: docstring compaction only (AST-identical).
- Every file semantically reviewed hunk-by-hunk for wire/log/lock/order parity.
2026-09-02 14:09:13 -07:00

235 lines
9.3 KiB
Python

"""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/<id>`` 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/<id>`` 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/<id> 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=("_",))