feat(browser): configurable backend + fix CDP bridge for websockets >= 13

- Add BROWSER_BACKEND env var: firefox | chrome | extension | playwright | auto
- Config persists to ~/.hanzo/extension/config.json
- Fix CDP bridge WebSocket handler signature for websockets >= 13 (new API)
- Add browser-based client resolution in CDP bridge
- Add /config GET/POST endpoints for extension preference sync
- Backend-aware routing: explicit backends don't fall back to Playwright
This commit is contained in:
2026-03-04 19:35:00 -08:00
parent 0de73f60d8
commit 65b01566dc
4 changed files with 236 additions and 30 deletions
@@ -24,7 +24,10 @@ from hanzo_tools.browser.browser_tool import (
BrowserTool,
browser_tool,
create_browser_tool,
get_backend,
launch_browser_server,
_load_config,
_save_config,
)
logger = logging.getLogger(__name__)
@@ -89,8 +92,8 @@ def _run_cdp_bridge_server(host: str, port: int) -> None:
try:
_cdp_bridge_loop.run_until_complete(run())
except Exception:
pass
except Exception as e:
logger.error(f"CDP bridge server crashed: {e}", exc_info=True)
def start_cdp_bridge(
@@ -180,13 +183,15 @@ def register_browser_tools(mcp_server: FastMCP, **kwargs) -> list[BaseTool]:
"""
headless = kwargs.get("headless", True)
cdp_endpoint = kwargs.get("cdp_endpoint")
backend = kwargs.get("backend")
# Auto-start CDP bridge for browser extension integration
# (not needed when backend is explicitly "playwright")
auto_cdp_bridge = kwargs.get("cdp_bridge", True)
if auto_cdp_bridge and CDP_BRIDGE_AVAILABLE:
if auto_cdp_bridge and CDP_BRIDGE_AVAILABLE and backend != "playwright":
start_cdp_bridge()
tool = create_browser_tool(headless=headless, cdp_endpoint=cdp_endpoint)
tool = create_browser_tool(headless=headless, cdp_endpoint=cdp_endpoint, backend=backend)
ToolRegistry.register_tool(mcp_server, tool)
return [tool]
@@ -59,8 +59,55 @@ except ImportError:
logger = logging.getLogger(__name__)
async def _check_extension() -> bool:
"""Check if Hanzo browser extension is connected."""
def _load_config() -> dict:
"""Load browser config from ~/.hanzo/extension/config.json."""
config_path = Path.home() / ".hanzo" / "extension" / "config.json"
try:
if config_path.exists():
return json.loads(config_path.read_text())
except Exception:
pass
return {}
def _save_config(config: dict) -> None:
"""Save browser config to ~/.hanzo/extension/config.json."""
config_dir = Path.home() / ".hanzo" / "extension"
config_dir.mkdir(parents=True, exist_ok=True)
config_path = config_dir / "config.json"
try:
config_path.write_text(json.dumps(config, indent=2))
except Exception as e:
logger.warning(f"Failed to save config: {e}")
def get_backend() -> str:
"""Get configured browser backend.
Priority: BROWSER_BACKEND env var > ~/.hanzo/extension/config.json > "auto"
Values: firefox | chrome | extension | playwright | auto
"""
# Env var takes precedence
env_val = os.environ.get("BROWSER_BACKEND", "").strip().lower()
if env_val in ("firefox", "chrome", "extension", "playwright", "auto"):
return env_val
# Then config file
config = _load_config()
file_val = config.get("backend", "").strip().lower()
if file_val in ("firefox", "chrome", "extension", "playwright", "auto"):
return file_val
return "auto"
async def _check_extension(browser: Optional[str] = None) -> bool:
"""Check if Hanzo browser extension is connected.
Args:
browser: Optional browser filter ("firefox", "chrome").
"""
try:
import aiohttp
@@ -70,19 +117,36 @@ async def _check_extension() -> bool:
) as resp:
if resp.status == 200:
data = await resp.json()
return data.get("connected", False)
if not data.get("connected", False):
return False
if browser:
# Check if requested browser is among connected clients
clients = data.get("client_list", [])
return any(
browser.lower() in c.get("browser", "").lower()
for c in clients
)
return True
except Exception:
pass
return False
async def _extension_command(action: str, **kwargs) -> Optional[dict]:
"""Send command to Hanzo browser extension."""
async def _extension_command(action: str, browser: Optional[str] = None, **kwargs) -> Optional[dict]:
"""Send command to Hanzo browser extension.
Args:
action: The action to perform.
browser: Optional browser preference ("firefox", "chrome").
**kwargs: Additional parameters.
"""
try:
import aiohttp
# Filter out None values
payload = {"action": action}
if browser:
payload["browser"] = browser
payload.update({k: v for k, v in kwargs.items() if v is not None})
async with aiohttp.ClientSession() as session:
@@ -93,6 +157,10 @@ async def _extension_command(action: str, **kwargs) -> Optional[dict]:
) as resp:
if resp.status == 200:
return await resp.json()
else:
body = await resp.text()
logger.debug(f"Extension command {action} returned {resp.status}: {body}")
return {"error": body, "status": resp.status}
except Exception as e:
logger.debug(f"Extension command failed: {e}")
return None
@@ -593,9 +661,15 @@ class BrowserTool(BaseTool):
name = "browser"
def __init__(self, headless: bool = True, cdp_endpoint: Optional[str] = None):
def __init__(
self,
headless: bool = True,
cdp_endpoint: Optional[str] = None,
backend: Optional[str] = None,
):
self.headless = headless
self.cdp_endpoint = cdp_endpoint or os.environ.get("BROWSER_CDP_ENDPOINT")
self.backend = backend or get_backend()
self.timeout = 30000
@property
@@ -743,8 +817,8 @@ CATEGORIES:
timeout = timeout or self.timeout
sel = selector or ref
# === TRY EXTENSION FIRST ===
# All actions supported by the CDP bridge server
# === BACKEND-AWARE ROUTING ===
# Actions supported by the CDP bridge / browser extension
extension_actions = {
"navigate", "navigate_back", "reload", "url", "title", "content",
"screenshot", "snapshot", "click", "dblclick", "hover",
@@ -753,10 +827,21 @@ CATEGORIES:
"evaluate", "wait", "wait_for_load",
"tabs", "new_tab", "close_tab", "select_tab",
"console", "network_requests", "status",
# Takeover actions (Phase 3)
"takeover", "release",
}
if action in extension_actions:
backend = self.backend
# Resolve browser filter from backend preference
browser_filter = backend if backend in ("firefox", "chrome") else None
# Skip extension entirely for "playwright" backend
use_extension = backend != "playwright" and action in extension_actions
if use_extension:
ext_result = await _extension_command(
action,
browser=browser_filter,
url=url,
selector=sel,
text=text,
@@ -777,9 +862,21 @@ CATEGORIES:
ext_result = ext_result["result"]
return {"success": True, "source": "extension", **ext_result}
# For explicit backends (firefox/chrome/extension), don't fall back to Playwright
if backend in ("firefox", "chrome", "extension"):
error_detail = ""
if ext_result and "error" in ext_result:
error_detail = f": {ext_result['error']}"
return {
"error": (f"Browser backend '{backend}' not available{error_detail}. "
f"Ensure the Hanzo extension is installed and connected."),
"action": action,
"backend": backend,
}
# === FALL BACK TO PLAYWRIGHT ===
if not PLAYWRIGHT_AVAILABLE:
ext_connected = await _check_extension()
ext_connected = await _check_extension(browser=browser_filter)
if ext_connected:
msg = (f"Action '{action}' is not supported by the browser extension "
f"and Playwright is not installed for fallback. "
@@ -2028,10 +2125,12 @@ CATEGORIES:
def create_browser_tool(
headless: bool = True, cdp_endpoint: Optional[str] = None
headless: bool = True,
cdp_endpoint: Optional[str] = None,
backend: Optional[str] = None,
) -> BrowserTool:
"""Create a browser tool instance."""
return BrowserTool(headless=headless, cdp_endpoint=cdp_endpoint)
return BrowserTool(headless=headless, cdp_endpoint=cdp_endpoint, backend=backend)
async def launch_browser_server(port: int = 9222, headless: bool = False) -> str:
@@ -31,23 +31,29 @@ import time
import uuid
import asyncio
import logging
from pathlib import Path
from typing import Any, Callable, Optional
from dataclasses import field, dataclass
try:
import websockets
# Prefer modern asyncio API on websockets>=14 to avoid deprecation warnings.
# Detect websockets API version.
# >= 13: new asyncio API, handler(websocket) — no path parameter.
# < 13: legacy API, handler(websocket, path).
_WS_LEGACY = False
try:
from websockets.asyncio.server import serve as ws_serve
WebSocketServerProtocol = Any
except ImportError:
from websockets.server import serve as ws_serve
WebSocketServerProtocol = Any
_WS_LEGACY = True
WEBSOCKETS_AVAILABLE = True
except ImportError:
WEBSOCKETS_AVAILABLE = False
_WS_LEGACY = False
WebSocketServerProtocol = Any
# Try to import aiohttp for HTTP API server
@@ -142,9 +148,19 @@ class CDPBridgeServer:
"websockets package required for CDP bridge. Install with: pip install websockets"
)
# Build handler compatible with installed websockets version.
# Modern API (>= 13): handler(websocket) — one positional arg.
# Legacy API (< 13): handler(websocket, path) — two positional args.
if _WS_LEGACY:
handler = self._handle_connection # already accepts (ws, path)
else:
# Wrap so the modern serve() can call handler(websocket) with one arg.
async def handler(websocket: WebSocketServerProtocol) -> None:
await self._handle_connection(websocket)
# Start WebSocket server for browser extensions
self._server = await ws_serve(
self._handle_connection,
handler,
self.host,
self.port,
)
@@ -162,8 +178,11 @@ class CDPBridgeServer:
"""Start the HTTP API server."""
app = web.Application()
app.router.add_get("/status", self._http_status)
app.router.add_get("/config", self._http_get_config)
app.router.add_post("/config", self._http_save_config)
app.router.add_post("/", self._http_command)
app.router.add_options("/", self._http_cors) # CORS preflight
app.router.add_options("/config", self._http_cors) # CORS preflight
self._http_runner = web.AppRunner(app)
await self._http_runner.setup()
@@ -191,11 +210,59 @@ class CDPBridgeServer:
{
"connected": connected,
"clients": len(self.extension_clients),
"client_list": [c.to_dict() for c in self.extension_clients.values()],
"default_client_id": self.default_client_id,
},
headers={"Access-Control-Allow-Origin": "*"},
)
@staticmethod
def _config_path() -> Path:
"""Path to ~/.hanzo/extension/config.json."""
return Path.home() / ".hanzo" / "extension" / "config.json"
async def _http_get_config(self, request: "web.Request") -> "web.Response":
"""GET /config — read ~/.hanzo/extension/config.json."""
config_path = self._config_path()
try:
if config_path.exists():
config = json.loads(config_path.read_text())
else:
config = {"backend": "auto"}
except Exception:
config = {"backend": "auto"}
return web.json_response(
config,
headers={"Access-Control-Allow-Origin": "*"},
)
async def _http_save_config(self, request: "web.Request") -> "web.Response":
"""POST /config — save to ~/.hanzo/extension/config.json."""
try:
data = await request.json()
except Exception as e:
return web.json_response(
{"error": f"Invalid JSON: {e}"},
status=400,
headers={"Access-Control-Allow-Origin": "*"},
)
config_path = self._config_path()
config_path.parent.mkdir(parents=True, exist_ok=True)
# Merge with existing config
try:
existing = json.loads(config_path.read_text()) if config_path.exists() else {}
except Exception:
existing = {}
existing.update(data)
config_path.write_text(json.dumps(existing, indent=2))
logger.info(f"Config saved to {config_path}: {existing}")
return web.json_response(
{"success": True, "config": existing},
headers={"Access-Control-Allow-Origin": "*"},
)
async def _http_command(self, request: "web.Request") -> "web.Response":
"""HTTP endpoint for sending commands to browser extension."""
try:
@@ -207,21 +274,26 @@ class CDPBridgeServer:
headers={"Access-Control-Allow-Origin": "*"},
)
# Resolve target client
# Resolve target client — supports client_id, target_id, or browser preference
target_client = self._resolve_client(
client_id=data.get("client_id"),
target_id=data.get("target_id"),
browser=data.get("browser"),
)
if not target_client:
browser_hint = data.get("browser")
if not self.extension_clients:
error_msg = "No browser extension connected"
elif browser_hint:
available = [c.browser for c in self.extension_clients.values()]
error_msg = (f"No {browser_hint} extension connected. "
f"Connected browsers: {available}")
else:
error_msg = (f"Client not found: "
f"{data.get('client_id') or data.get('target_id')}")
return web.json_response(
{
"error": (
"No browser extension connected"
if not self.extension_clients
else f"Client not found: {data.get('client_id') or data.get('target_id')}"
)
},
{"error": error_msg},
status=503,
headers={"Access-Control-Allow-Origin": "*"},
)
@@ -334,10 +406,19 @@ class CDPBridgeServer:
async def _handle_connection(
self,
websocket: WebSocketServerProtocol,
path: str,
path: str = "/",
) -> None:
"""Handle incoming WebSocket connections."""
logger.info(f"New connection from {websocket.remote_address} on {path}")
"""Handle incoming WebSocket connections.
Compatible with both legacy (websockets < 13) and modern (>= 13) APIs.
Legacy API passes (websocket, path); modern API passes only (websocket)
and path is available via websocket.request.path.
"""
# Modern websockets >= 13: path comes from websocket object, not parameter.
if hasattr(websocket, "request") and hasattr(websocket.request, "path"):
path = websocket.request.path or "/"
remote = getattr(websocket, "remote_address", None)
logger.info(f"New connection from {remote} on {path}")
try:
# First message identifies the client type
@@ -447,12 +528,20 @@ class CDPBridgeServer:
self,
client_id: Optional[str] = None,
target_id: Optional[str] = None,
browser: Optional[str] = None,
) -> Optional[ExtensionClient]:
"""Resolve which client to route to.
Priority order:
1. Explicit client_id
2. Namespaced target_id ("client_id:tab_id")
3. Browser preference ("firefox", "chrome", etc.)
4. Default (most recently active)
Args:
client_id: Explicit client ID
target_id: Namespaced target like "clientid:tabid"
browser: Preferred browser name ("firefox", "chrome")
Returns:
ExtensionClient or None
@@ -467,6 +556,19 @@ class CDPBridgeServer:
if client_id and client_id in self.extension_clients:
return self.extension_clients[client_id]
# Use browser preference — match by browser name (case-insensitive)
if browser:
browser_lower = browser.lower()
matches = [
c for c in self.extension_clients.values()
if browser_lower in c.browser.lower()
]
if matches:
# Return most recently active matching client
return max(matches, key=lambda c: c.last_active)
# No match for requested browser
return None
# Fall back to default (most recently active)
return self.default_client
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "hanzo-tools-browser"
version = "0.3.0"
version = "0.4.1"
description = "Browser automation tools with extension-first routing - uses Hanzo browser extension when available, Playwright as fallback"
readme = "README.md"
requires-python = ">=3.12"