mirror of
https://github.com/hanzoai/python-sdk.git
synced 2026-10-23 20:23:00 +00:00
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:
@@ -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
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user