diff --git a/LLM.md b/LLM.md index 6ba4ef406..ddee4dab4 100644 --- a/LLM.md +++ b/LLM.md @@ -97,46 +97,50 @@ SQLite-based with optional vector search (sqlite-vec). ## Browser Tool (hanzo-tools-browser) -Two-process architecture (since 0.5.0): `hanzo-tools-browser` hosts the -ZAP server directly inside the MCP process. Browser extensions discover -it on the lowest free port from `[9999, 9998, 9997, 9996, 9995]` (POSIX -flock ensures multi-MCP coexistence under one ext). +**hanzo-mcp is a `zapd` *consumer*** — it hosts NO server. It connects to the +one shared local router `~/.zap/run/zapd.sock` (see `~/work/zap`), lists +providers, and routes opaque commands to a `browser:*` provider (the real Chrome/ +Firefox extension, connected via the native host). No in-process server, no mDNS, +no `9999-9995`, no `:9224` HTTP bridge, no `BROWSER_TRANSPORT`, no Playwright +fallback in native-browser mode. The old `zap_server.py`/`cdp_bridge_server.py` +in-process model is removed. ```python -from hanzo_tools.browser import ( - BrowserTool, # the MCP tool - ZapServer, # raw server (for advanced use) - get_or_start_server, # bootstrap + return singleton - get_server, # return current singleton or None -) +from hanzo_tools.browser.zapd_consumer import ZapdConsumer, get_consumer +c = get_consumer() # connects to ~/.zap/run/zapd.sock +c.resolve_browser("chrome", None) # -> "browser:chrome//default" +c.route(provider, "Target.getTargets", {}) # opaque command, raw result bytes ``` Key files: -- `hanzo_tools/browser/zap_server.py` — wire format, server, leases, - cluster registry (`~/.hanzo/extension/config.json:mcp_instances`). -- `hanzo_tools/browser/browser_tool.py` — `_extension_command` tries - ZAP first, falls back to legacy HTTP bridge on `:9224`. New actions - `list_mcp_instances`, `claim_browser`, `release_browser`. -- `hanzo_tools/browser/cdp_bridge_server.py` — legacy node-bridge - replacement (kept for non-ZAP callers). - -Env vars: -- `BROWSER_TRANSPORT=zap|http|auto` — pin transport (default auto). -- `HANZO_ZAP_DISABLED=1` — don't auto-start the ZAP server. -- `HANZO_ZAP_PORTS=9999,9998` — override the candidate port list. -- `HANZO_AGENT_LABEL=...` — attach to the cluster registry entry. -- `HANZO_CDP_BRIDGE_ENABLED=1` — opt back into the legacy HTTP bridge. - -Tests live in `pkg/hanzo-tools-browser/tests/test_zap_server.py` (30 -cases: wire format, lifecycle, RPC, leases, multi-MCP). Latency bench -in `test_zap_bench.py`. The full suite runs with: - -```bash -cd pkg/hanzo-tools-browser -uv venv .venv --python 3.12 -.venv/bin/python -m pip install -e . -.venv/bin/python -m pytest tests/ -v -``` +- `hanzo_tools/browser/zapd_consumer.py` — the ZAP router-envelope codec + + consumer (connect / hello / providers.list / route). Mirrors `zapd/src/frame.rs`. +- `hanzo_tools/browser/browser_tool.py` — `_extension_command` / `_check_extension` + route via `zapd_consumer`. `register_browser_tools` no longer starts a server. +- `hanzo_tools/browser/cdp_tool.py` — the `cdp` tool, a method-oriented peer of + `browser`. Sends a raw CDP method by name (`Target.getTargets`, `Page.navigate`) + through the SAME `zapd_consumer` route — the method goes on the wire verbatim, + so there is no `{"action":"cdp"}` envelope for the extension to reject. + +Two tools, one transport: `browser` (high-level verbs) and `cdp` (raw methods). +Both are in `TOOLS` and resolve to the same zapd provider. + +The router (`zapd`) is a separate always-on daemon — install/run it from +`zap-proto/zapd` (`curl … | sh` or `@hanzo/zapd`). + +**MCP must be sourced from disk, not PyPI.** The published `hanzo-tools-browser` +wheel (≤0.5.7) still ships the removed in-process HTTP-bridge model and breaks +`cdp` with `Unknown method: cdp`. The Claude Code MCP entry in `~/.claude.json` +therefore uses `uvx --from pkg/hanzo-mcp --with-editable pkg/hanzo-tools-browser` +so the on-disk source (≥0.5.8) is authoritative. `--with ` does +NOT work — uv ignores the workspace sources for `--from` deps and pulls the buggy +wheel from the index. `hanzo-mcp` pins `hanzo-tools-browser>=0.5.8` as a guard. + +Router/transport tests live with the router (`zap-proto/zapd`: `cargo test` + +`tests/e2e.py`). The Python consumer + `cdp` routing are unit-tested in +`tests/test_browser_tools.py` (zapd mocked) and verified end-to-end against a +live `zapd`. The old `test_zap_server.py`/`zap_server.py` (in-process server, +leases, multi-MCP) and `cdp_bridge_server.py` are removed. ## API Tool diff --git a/pkg/hanzo-mcp/pyproject.toml b/pkg/hanzo-mcp/pyproject.toml index 325d6572e..034fb0cba 100644 --- a/pkg/hanzo-mcp/pyproject.toml +++ b/pkg/hanzo-mcp/pyproject.toml @@ -33,7 +33,7 @@ dependencies = [ "hanzo-tools>=0.3.0", "hanzo-tools-fs>=0.1.0", "hanzo-tools-shell>=0.6.1", - "hanzo-tools-browser[playwright]>=0.5.7", # 3 peer tools (browser/cdp/playwright); 0.5.6 was DOA + "hanzo-tools-browser[playwright]>=0.5.8", # 3 peer tools (browser/cdp/playwright) over zapd consumer (~/.zap/run/zapd.sock) "hanzo-tools-memory>=0.2.0", "hanzo-tools-todo>=0.1.0", "hanzo-tools-reasoning>=0.1.0", diff --git a/pkg/hanzo-tools-browser/hanzo_tools/browser/__init__.py b/pkg/hanzo-tools-browser/hanzo_tools/browser/__init__.py index c27cdb7c1..8ee063b69 100644 --- a/pkg/hanzo-tools-browser/hanzo_tools/browser/__init__.py +++ b/pkg/hanzo-tools-browser/hanzo_tools/browser/__init__.py @@ -1,36 +1,25 @@ -"""Browser automation tools — decomplected surface. - -Three orthogonal MCP tools, all default-enabled, all disable-able by env: - - * ``browser`` — high-level action surface. Auto-routes through the - in-process ZAP server (browser extension), the legacy - CDP HTTP bridge, or Playwright. Disable with - ``HANZO_BROWSER_TOOL_DISABLED=1``. - - * ``cdp`` — raw Chrome DevTools Protocol method dispatch. Same - transports as ``browser`` minus the Playwright fallback. - Disable with ``HANZO_CDP_TOOL_DISABLED=1``. - - * ``playwright`` — Playwright-pinned action surface. Same actions as - ``browser`` but never touches the extension or CDP - bridge. Disable with ``HANZO_PLAYWRIGHT_TOOL_DISABLED=1``. - -Independent transport knobs (orthogonal to which tools are surfaced): - - * ``HANZO_ZAP_DISABLED=1`` — don't auto-start the ZAP server. - * ``HANZO_CDP_BRIDGE_ENABLED=1`` — opt back into the legacy HTTP bridge. - * ``BROWSER_TRANSPORT=zap|http|auto`` — pin transport (default ``auto``). - * ``BROWSER_BACKEND=firefox|chrome|extension|playwright|auto`` — backend - preference for ``browser``. - -Lifecycle (ZAP server, CDP bridge threads) lives in ``lifecycle.py``. +"""Browser automation tools for Hanzo AI — zapd consumer model. + + [Browser ext] --native host--> [zapd router] --unix sock--> [hanzo-mcp] --stdio--> [Agent] + +hanzo-mcp hosts NO server. It connects to the one shared local router at +``~/.zap/run/zapd.sock`` as a *consumer*, lists providers, and routes opaque +CDP commands to a ``browser:*`` provider (the real Chrome/Firefox extension, +connected via its native-messaging host). No in-process server, no mDNS, no +well-known port pool, no :9224 HTTP bridge, no Playwright fallback in +native-browser mode. ``zapd`` is a separate always-on daemon (see ``~/work/zap``). + +Three peer tools are exposed, all sharing one transport (``zapd_consumer``): +- ``browser`` — high-level, action-oriented (navigate/click/screenshot/tabs …). + Auto-routes through the extension over zapd; falls back to + Playwright only for actions the extension can't serve. +- ``cdp`` — low-level, method-oriented (send any CDP method by name). +- ``playwright`` — the same action surface as ``browser`` but pinned to the + Playwright backend (never touches the extension). """ -from __future__ import annotations - import logging -import os -from typing import TYPE_CHECKING +from typing import Optional from mcp.server import FastMCP @@ -48,117 +37,12 @@ ) from hanzo_tools.browser.cdp_tool import CdpTool from hanzo_tools.browser.playwright_tool import PlaywrightTool -from hanzo_tools.browser.lifecycle import ( - CDP_BRIDGE_AVAILABLE, - ensure_zap_server, - start_cdp_bridge, - stop_cdp_bridge, - stop_zap_server, -) -from hanzo_tools.browser.zap_server import ( - ZapClient, - ZapServer, - get_or_start_server, - get_server, - shutdown_server, -) - -if TYPE_CHECKING: - from hanzo_tools.browser.cdp_bridge_server import ( - CDPBridgeClient, - CDPBridgeServer, - ) - -# Re-export CDP-bridge classes when available (legacy callers). -try: - from hanzo_tools.browser.cdp_bridge_server import ( - CDPBridgeClient, - CDPBridgeServer, - ) -except ImportError: # pragma: no cover - CDPBridgeClient = None # type: ignore[assignment] - CDPBridgeServer = None # type: ignore[assignment] +from hanzo_tools.browser.zapd_consumer import ZapdConsumer, get_consumer logger = logging.getLogger(__name__) - -# === Tools registry — gated by env ==================================== - -def _env_disabled(*names: str) -> bool: - return any(os.environ.get(n, "").lower() in ("1", "true", "yes") for n in names) - - -def _resolve_tools() -> list[type[BaseTool]]: - """Build TOOLS list at import time based on env flags. - - Each tool is independently disable-able. Default: all three on. - """ - tools: list[type[BaseTool]] = [] - - if not _env_disabled("HANZO_BROWSER_TOOL_DISABLED"): - tools.append(BrowserTool) - if not _env_disabled("HANZO_CDP_TOOL_DISABLED"): - tools.append(CdpTool) - if not _env_disabled("HANZO_PLAYWRIGHT_TOOL_DISABLED"): - tools.append(PlaywrightTool) - - return tools - - -TOOLS: list[type[BaseTool]] = _resolve_tools() - - -# === Registration entry point ========================================== - -def register_browser_tools(mcp_server: FastMCP, **kwargs) -> list[BaseTool]: - """Register browser tools with the MCP server. - - Starts the in-process ZAP server (unless ``HANZO_ZAP_DISABLED=1``) so - the browser extension can discover this MCP via mDNS. The legacy CDP - HTTP bridge (port 9223/9224) stays off by default — opt in with - ``HANZO_CDP_BRIDGE_ENABLED=1`` or ``cdp_bridge=True`` kwarg. - - Which tools get registered is controlled by env flags: - * HANZO_BROWSER_TOOL_DISABLED - * HANZO_CDP_TOOL_DISABLED - * HANZO_PLAYWRIGHT_TOOL_DISABLED - """ - headless = kwargs.get("headless", True) - cdp_endpoint = kwargs.get("cdp_endpoint") - backend = kwargs.get("backend") - - # Canonical lifecycle: in-process ZAP server. - if backend != "playwright": - ensure_zap_server() - - # Optional legacy lifecycle: CDP HTTP bridge. - if kwargs.get( - "cdp_bridge", - os.environ.get("HANZO_CDP_BRIDGE_ENABLED", "").lower() in ("1", "true", "yes"), - ) and CDP_BRIDGE_AVAILABLE and backend != "playwright": - start_cdp_bridge() - - registered: list[BaseTool] = [] - for tool_class in TOOLS: - if tool_class is BrowserTool: - tool = create_browser_tool( - headless=headless, cdp_endpoint=cdp_endpoint, backend=backend - ) - elif tool_class is PlaywrightTool: - # PlaywrightTool forces backend internally; respect headless+endpoint. - tool = PlaywrightTool(headless=headless, cdp_endpoint=cdp_endpoint) - else: - # CdpTool, future peers — no-arg constructor. - tool = tool_class() - ToolRegistry.register_tool(mcp_server, tool) - registered.append(tool) - return registered - - -def register_tools(mcp_server: FastMCP, **kwargs) -> list[BaseTool]: - """Standard entry point called by tool-discovery hosts.""" - return register_browser_tools(mcp_server, **kwargs) - +# Tools list for entry point discovery (see pyproject [hanzo.tools]). +TOOLS = [BrowserTool, CdpTool, PlaywrightTool] __all__ = [ # Tools (the three peers) @@ -168,24 +52,12 @@ def register_tools(mcp_server: FastMCP, **kwargs) -> list[BaseTool]: # Factory + module-level instance (existing public API) "browser_tool", "create_browser_tool", - # Browser pool + # Browser pool / Playwright server "BrowserPool", "launch_browser_server", - # ZAP (canonical) - "ZapServer", - "ZapClient", - "get_or_start_server", - "get_server", - "shutdown_server", - # CDP Bridge (legacy fallback) - "CDPBridgeServer", - "CDPBridgeClient", - "CDP_BRIDGE_AVAILABLE", - "start_cdp_bridge", - "stop_cdp_bridge", - # Lifecycle (now in lifecycle.py) - "ensure_zap_server", - "stop_zap_server", + # zapd consumer (the one canonical transport) + "ZapdConsumer", + "get_consumer", # Availability check "PLAYWRIGHT_AVAILABLE", # Backend helper @@ -195,3 +67,39 @@ def register_tools(mcp_server: FastMCP, **kwargs) -> list[BaseTool]: "register_browser_tools", "register_tools", ] + + +def register_browser_tools(mcp_server: FastMCP, **kwargs) -> list[BaseTool]: + """Register the browser tools with the MCP server. + + hanzo-mcp is a zapd *consumer* — it connects to the shared local router at + ``~/.zap/run/zapd.sock`` on demand and hosts no in-process server. zapd is a + separate always-on daemon, so there is nothing to start here. + + Args: + mcp_server: The FastMCP server instance + **kwargs: Forwarded to the browser tool (headless, cdp_endpoint, backend) + + Returns: + List of registered tools + """ + headless = kwargs.get("headless", True) + cdp_endpoint = kwargs.get("cdp_endpoint") + backend = kwargs.get("backend") + + tools: list[BaseTool] = [ + create_browser_tool(headless=headless, cdp_endpoint=cdp_endpoint, backend=backend), + CdpTool(), + PlaywrightTool(headless=headless, cdp_endpoint=cdp_endpoint), + ] + for tool in tools: + ToolRegistry.register_tool(mcp_server, tool) + return tools + + +def register_tools(mcp_server: FastMCP, **kwargs) -> list[BaseTool]: + """Register all browser tools with the MCP server. + + Standard entry point called by the tool discovery system. + """ + return register_browser_tools(mcp_server, **kwargs) diff --git a/pkg/hanzo-tools-browser/hanzo_tools/browser/browser_tool.py b/pkg/hanzo-tools-browser/hanzo_tools/browser/browser_tool.py index 8935806b8..61729994f 100644 --- a/pkg/hanzo-tools-browser/hanzo_tools/browser/browser_tool.py +++ b/pkg/hanzo-tools-browser/hanzo_tools/browser/browser_tool.py @@ -116,43 +116,18 @@ def _normalize_tab_id(tab_id: Union[str, int, None]) -> Union[str, int, None]: async def _check_extension(browser: Optional[str] = None) -> bool: - """Check if Hanzo browser extension is connected. + """Check if a browser provider is connected to the local zapd router.""" + import asyncio - Tries the local ZAP server first (in-process, microseconds), falls back - to the legacy HTTP bridge on :9224. - """ - # 1) ZAP — if our own MCP holds an extension client locally - try: - from hanzo_tools.browser.zap_server import get_server - - srv = get_server() - if srv is not None and srv.has_client(browser=browser): - return True - except Exception: - pass + from hanzo_tools.browser.zapd_consumer import get_consumer - # 2) Legacy HTTP bridge + consumer = get_consumer() + if consumer is None: + return False try: - import aiohttp - - async with aiohttp.ClientSession() as session: - async with session.get( - "http://localhost:9224/status", timeout=aiohttp.ClientTimeout(total=1) - ) as resp: - if resp.status == 200: - data = await resp.json() - if not data.get("connected", False): - return False - if browser: - clients = data.get("client_list", []) - return any( - browser.lower() in c.get("browser", "").lower() - for c in clients - ) - return True + return await asyncio.to_thread(consumer.resolve_browser, browser, None) is not None except Exception: - pass - return False + return False def _zap_method_for(action: str) -> str: @@ -337,113 +312,37 @@ async def _extension_command( client_id: Optional[str] = None, **kwargs, ) -> Optional[dict]: - """Send command to Hanzo browser extension. - - Path order: - 1. Local in-process ZAP server (microsecond round-trip; preferred). - 2. Legacy HTTP bridge on :9224 (kept as fallback for non-ZAP MCP clients). + """Route a browser command to a provider via the local zapd router. - The transport can be pinned with ``BROWSER_TRANSPORT=zap|http|auto`` — - default is ``auto`` which means "ZAP if a connected extension matches, - else HTTP". + hanzo-mcp is a zapd *consumer*: it connects to ``~/.zap/run/zapd.sock``, + lists providers, and routes opaque commands to a ``browser:*`` provider. + No in-process server, no HTTP bridge, no :9224, no Playwright fallback. """ - transport = os.environ.get("BROWSER_TRANSPORT", "auto").strip().lower() - if transport not in {"zap", "http", "auto"}: - transport = "auto" + import asyncio - # ---- 1) ZAP path --------------------------------------------------- - if transport in {"zap", "auto"}: - try: - from hanzo_tools.browser.zap_server import get_server + from hanzo_tools.browser.zapd_consumer import get_consumer - srv = get_server() - if srv is not None and srv.has_client(browser=browser): - method = _zap_method_for(action) - params = _zap_params(action, tab_id=tab_id, **kwargs) - try: - raw = await srv.send( - method, - params, - browser=browser, - client_id=client_id, - ) - except Exception as e: - if transport == "zap": - return {"error": str(e), "transport": "zap"} - logger.debug("zap dispatch failed, falling back to http: %s", e) - else: - # CDP Runtime.evaluate returns {result: {type, value}}; - # unwrap so the caller sees the value directly. - result = raw - if method == "Runtime.evaluate" and isinstance(raw, dict): - # Surface an evaluation error (commonly page CSP - # blocking Function()/eval) instead of silently - # flattening it to a null value — that null was - # indistinguishable from a legitimate null result. - err = raw.get("error") - exc = raw.get("exceptionDetails") - if err or exc: - msg = err or ( - exc.get("text") if isinstance(exc, dict) else str(exc) - ) - return { - "success": False, - "transport": "zap", - "error": msg, - "exceptionDetails": exc, - "result": None, - } - cdp = raw.get("result", raw) - if isinstance(cdp, dict) and "value" in cdp: - result = cdp["value"] - elif isinstance(cdp, dict) and cdp.get("type") == "undefined": - result = None - return { - "success": True, - "transport": "zap", - "result": result, - } - except ImportError: - # zap_server module unavailable — only HTTP path remains. - pass + consumer = get_consumer() + if consumer is None: + return {"error": "zapd not reachable (~/.zap/run/zapd.sock)", "transport": "native-zap"} - if transport == "zap": - return { - "error": "ZAP transport selected but no extension client matched", - "transport": "zap", - } + try: + provider = await asyncio.to_thread(consumer.resolve_browser, browser, client_id) + except Exception as e: + return {"error": str(e), "transport": "native-zap"} + if not provider: + return {"error": "no browser provider connected over zapd", "transport": "native-zap"} - # ---- 2) HTTP fallback --------------------------------------------- + method = _zap_method_for(action) + params = _zap_params(action, tab_id=tab_id, **kwargs) or {} + str_params = {k: (v if isinstance(v, str) else str(v)) for k, v in params.items() if v is not None} try: - import aiohttp - - payload: dict[str, Any] = {"action": action} - if browser: - payload["browser"] = browser - norm_tab = _normalize_tab_id(tab_id) - if norm_tab is not None: - payload["tabId"] = norm_tab - if client_id: - payload["clientId"] = client_id - payload.update({k: v for k, v in kwargs.items() if v is not None}) - - async with aiohttp.ClientSession() as session: - async with session.post( - "http://localhost:9224", - json=payload, - timeout=aiohttp.ClientTimeout(total=30), - ) as resp: - if resp.status == 200: - body = await resp.json() - body.setdefault("transport", "http") - return body - else: - text = await resp.text() - logger.debug(f"Extension command {action} returned {resp.status}: {text}") - return {"error": text, "status": resp.status, "transport": "http"} + raw = await asyncio.to_thread(consumer.route, provider, method, str_params) except Exception as e: - logger.debug(f"Extension command failed: {e}") - return None + return {"error": str(e), "transport": "native-zap"} + + text = raw.decode("utf-8", errors="replace") if isinstance(raw, (bytes, bytearray)) else raw + return {"success": True, "transport": "native-zap", "source": "zapd", "provider": provider, "result": text} # Device presets - user-friendly aliases + specific devices @@ -958,28 +857,8 @@ def __init__( self.cdp_endpoint = cdp_endpoint or os.environ.get("BROWSER_CDP_ENDPOINT") self.backend = backend or get_backend() self.timeout = 30000 - - # Lifecycle (ZAP server, CDP bridge) lives in `lifecycle.py`. - # Import lazily to avoid a circular import on package load — - # __init__.py already pulls in this module before lifecycle. - if self.backend != "playwright": - try: - from hanzo_tools.browser.lifecycle import ( - CDP_BRIDGE_AVAILABLE, - ensure_zap_server, - start_cdp_bridge, - ) - - ensure_zap_server() # idempotent + respects HANZO_ZAP_DISABLED - - if ( - os.environ.get("HANZO_CDP_BRIDGE_ENABLED", "").lower() - in ("1", "true", "yes") - and CDP_BRIDGE_AVAILABLE - ): - start_cdp_bridge() - except Exception as e: - logger.debug("browser lifecycle bootstrap failed: %s", e) + # No server to start: native-browser commands route through the shared + # zapd router (~/.zap/run/zapd.sock) on demand via zapd_consumer. @property def description(self) -> str: @@ -1129,64 +1008,29 @@ async def execute( timeout = timeout or self.timeout sel = selector or ref - # === LOCAL ACTIONS (handled in-process, no extension/Playwright) === - if action == "list_mcp_instances": - try: - from hanzo_tools.browser.zap_server import ZapServer - - instances = ZapServer.list_mcp_instances() - return { - "success": True, - "mcp_instances": instances, - "count": len(instances), - } - except Exception as e: - return {"error": f"failed to list mcp instances: {e}"} + # === LOCAL ACTIONS (answered from the zapd provider list) === + if action in ("list_mcp_instances", "list_browsers", "browsers"): + from hanzo_tools.browser.zapd_consumer import get_consumer - if action == "claim_browser": + consumer = get_consumer() + if consumer is None: + return {"error": "zapd not reachable (~/.zap/run/zapd.sock)", "transport": "native-zap"} try: - from hanzo_tools.browser.zap_server import ( - DEFAULT_LEASE_TTL, - get_server, - ) - - srv = get_server() - if srv is None: - return {"error": "zap server not running"} - client = srv.resolve_client( - client_id=client_id, browser=target_browser - ) - if client is None: - return {"error": "no matching extension client"} - ttl = float(timeout) / 1000 if timeout else DEFAULT_LEASE_TTL - lease = srv.claim(client.client_id, ttl=ttl) - return { - "success": True, - "client_id": lease.client_id, - "holder": lease.holder, - "expires_at": lease.expires_at, - } + provs = await asyncio.to_thread(consumer.list_providers) except Exception as e: - return {"error": str(e)} - - if action == "release_browser": - try: - from hanzo_tools.browser.zap_server import get_server - - srv = get_server() - if srv is None: - return {"error": "zap server not running"} - # Without an explicit client_id, drop every lease this MCP holds. - if client_id: - released = srv.release(client_id) - return {"success": released, "client_id": client_id} - released_all = [] - for c in list(srv.clients): - if srv.release(c.client_id): - released_all.append(c.client_id) - return {"success": True, "released": released_all} - except Exception as e: - return {"error": str(e)} + return {"error": str(e), "transport": "native-zap"} + browsers = [p for p in provs if p.get("id", "").startswith("browser:")] + return {"success": True, "transport": "native-zap", "browsers": browsers, "count": len(browsers)} + + if action in ("claim_browser", "release_browser"): + # Exclusive leases were an in-process-server concept. The shared + # zapd router has no lease frame — target a specific provider with + # `target_browser` ("chrome"|"firefox") or `client_id` instead. + return { + "success": True, + "transport": "native-zap", + "note": "zapd is a shared router with no exclusive lease; pass target_browser or client_id to address a specific provider.", + } # === BACKEND-AWARE ROUTING === # Actions supported by the CDP bridge / browser extension @@ -1209,10 +1053,11 @@ async def execute( "inject_script", "inject_css", "local_storage", "cookies", "tabs", "new_tab", "select_tab", - # Multi-browser routing (bridge v1.9.0+) — list connected - # providers and persist a default-browser pick. Without one - # the bridge auto-prefers firefox > safari > edge > chrome. - "list_browsers", "browsers", "set_default_browser", "use_browser", + # Multi-browser routing — persist a default-browser pick. Without + # one the extension auto-prefers firefox > safari > edge > chrome. + # (list_browsers/browsers are answered locally from the zapd + # provider list above, not routed to a provider.) + "set_default_browser", "use_browser", "console", "network_requests", "status", # Takeover actions (Phase 3) "takeover", "release", diff --git a/pkg/hanzo-tools-browser/hanzo_tools/browser/cdp_bridge_server.py b/pkg/hanzo-tools-browser/hanzo_tools/browser/cdp_bridge_server.py deleted file mode 100644 index b55440656..000000000 --- a/pkg/hanzo-tools-browser/hanzo_tools/browser/cdp_bridge_server.py +++ /dev/null @@ -1,1094 +0,0 @@ -""" -CDP Bridge Server for Hanzo Browser Extension Integration. - -This server acts as a bridge between: -1. hanzo-mcp's browser tool (via HTTP API on port 9224) -2. The Hanzo browser extension (via WebSocket on port 9223) - -MULTI-CLIENT SUPPORT: -- Multiple browser extensions can connect simultaneously -- Each client has a unique client_id (uuid sent on registration) -- Target IDs are namespaced: ":" -- Commands can specify client_id or target_id for routing -- Default client = most recently active - -ARCHITECTURE: -- WebSocket server (port 9223): Browser extensions connect here -- HTTP API server (port 9224): hanzo-mcp sends commands here - -The bridge auto-starts when hanzo-mcp loads browser tools. -No manual setup required. - -Environment Variables: - HANZO_CDP_BRIDGE_PORT: Port for the WebSocket server (default: 9223) - HANZO_CDP_BRIDGE_HOST: Host to bind to (default: localhost) - HANZO_CDP_HTTP_PORT: Port for the HTTP API (default: 9224) -""" - -import os -import json -import time -import uuid -import asyncio -import logging -from typing import Any, Callable, Optional -from pathlib import Path -from dataclasses import field, dataclass - -try: - import websockets - - # 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 -try: - from aiohttp import web - - AIOHTTP_AVAILABLE = True -except ImportError: - AIOHTTP_AVAILABLE = False - web = None - -logger = logging.getLogger(__name__) - - -@dataclass -class ExtensionClient: - """Represents a connected browser extension client.""" - - client_id: str - websocket: WebSocketServerProtocol - browser: str = "unknown" - profile: str = "default" - user_agent: str = "" - capabilities: list = field(default_factory=list) - connected_at: float = field(default_factory=time.time) - last_active: float = field(default_factory=time.time) - - def to_dict(self) -> dict: - return { - "client_id": self.client_id, - "browser": self.browser, - "profile": self.profile, - "capabilities": self.capabilities, - "connected_at": self.connected_at, - "last_active": self.last_active, - } - - -class CDPBridgeServer: - """WebSocket + HTTP server that bridges hanzo-mcp and browser extensions. - - Supports multiple browser extension clients simultaneously. - - Architecture: - - WebSocket (port 9223): Browser extensions connect here as CDP providers - - HTTP API (port 9224): hanzo-mcp sends commands here - """ - - def __init__( - self, - host: str = "localhost", - port: int = 9223, - http_port: int = 9224, - ): - self.host = host - self.port = port - self.http_port = http_port - - # Multi-client registry: client_id -> ExtensionClient - self.extension_clients: dict[str, ExtensionClient] = {} - # Reverse lookup: websocket -> client_id - self._ws_to_client_id: dict[WebSocketServerProtocol, str] = {} - - self.mcp_clients: set[WebSocketServerProtocol] = set() - self.pending_requests: dict[int, asyncio.Future] = {} - self.request_id = 0 - self._server = None - self._http_server = None - self._http_runner = None - - @property - def default_client_id(self) -> Optional[str]: - """Get the default client (most recently active).""" - if not self.extension_clients: - return None - # Return the client with most recent last_active timestamp - return max( - self.extension_clients.keys(), - key=lambda cid: self.extension_clients[cid].last_active, - ) - - @property - def default_client(self) -> Optional[ExtensionClient]: - """Get the default ExtensionClient.""" - cid = self.default_client_id - return self.extension_clients.get(cid) if cid else None - - async def start(self) -> None: - """Start the WebSocket and HTTP servers.""" - if not WEBSOCKETS_AVAILABLE: - raise ImportError( - "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( - handler, - self.host, - self.port, - ) - logger.info(f"CDP Bridge WebSocket started on ws://{self.host}:{self.port}") - - # Start HTTP API server for hanzo-mcp - if AIOHTTP_AVAILABLE: - await self._start_http_server() - else: - logger.warning( - "aiohttp not installed, HTTP API disabled. Install with: pip install aiohttp" - ) - - async def _start_http_server(self) -> None: - """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() - self._http_server = web.TCPSite(self._http_runner, self.host, self.http_port) - await self._http_server.start() - logger.info( - f"CDP Bridge HTTP API started on http://{self.host}:{self.http_port}" - ) - - async def _http_cors(self, request: "web.Request") -> "web.Response": - """Handle CORS preflight requests.""" - return web.Response( - status=200, - headers={ - "Access-Control-Allow-Origin": "*", - "Access-Control-Allow-Methods": "GET, POST, OPTIONS", - "Access-Control-Allow-Headers": "Content-Type", - }, - ) - - async def _http_status(self, request: "web.Request") -> "web.Response": - """HTTP endpoint for status check.""" - connected = len(self.extension_clients) > 0 - return web.json_response( - { - "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: - data = await request.json() - except Exception as e: - return web.json_response( - {"error": f"Invalid JSON: {e}"}, - status=400, - headers={"Access-Control-Allow-Origin": "*"}, - ) - - # 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": error_msg}, - status=503, - headers={"Access-Control-Allow-Origin": "*"}, - ) - - # Update target client's last_active - target_client.last_active = time.time() - - # Map HTTP action to CDP method. - action = data.get("action", "") - # Raw passthrough: the `cdp` action carries the real wire method in the - # `method` field (this is what CdpTool's HTTP fallback sends). Without - # this, _action_to_method("cdp") falls through to the literal "cdp", - # which the extension rejects with "Unknown method: cdp". - if action == "cdp": - method = data.get("method") or "" - if not method: - return web.json_response( - { - "error": "cdp action requires a 'method' (e.g. 'Runtime.evaluate')", - "client_id": target_client.client_id, - }, - status=400, - headers={"Access-Control-Allow-Origin": "*"}, - ) - else: - method = self._action_to_method(action) - params = self._build_params(data) - - # Forward to extension and wait for response - request_id = self._next_request_id() - - future: asyncio.Future = asyncio.get_event_loop().create_future() - self.pending_requests[request_id] = future - - try: - await target_client.websocket.send( - json.dumps( - { - "id": request_id, - "method": method, - "params": params, - } - ) - ) - - # Wait for response with timeout - response = await asyncio.wait_for(future, timeout=30.0) - - # Unwrap CDP-specific result formats for consistency with Playwright - raw_result = response.get("result", {}) - if method == "Runtime.evaluate" and isinstance(raw_result, dict): - # An evaluation error (e.g. page CSP blocking Function()/eval) - # must NOT be flattened to a silent `null`. The extension - # surfaces it via `error` / `exceptionDetails`; propagate it so - # the caller sees *why* evaluate failed instead of a bare null. - err = raw_result.get("error") - exc = raw_result.get("exceptionDetails") - if err or exc: - msg = err or ( - exc.get("text") if isinstance(exc, dict) else str(exc) - ) - return web.json_response( - { - "success": False, - "client_id": target_client.client_id, - "error": msg, - "exceptionDetails": exc, - "result": None, - }, - headers={"Access-Control-Allow-Origin": "*"}, - ) - # CDP returns {result: {type, value}} — extract the value - cdp_result = raw_result.get("result", {}) - if isinstance(cdp_result, dict) and "value" in cdp_result: - raw_result = cdp_result["value"] - elif isinstance(cdp_result, dict) and cdp_result.get("type") == "undefined": - raw_result = None - - # Return result - return web.json_response( - { - "success": True, - "client_id": target_client.client_id, - "result": raw_result, - }, - headers={"Access-Control-Allow-Origin": "*"}, - ) - - except asyncio.TimeoutError: - self.pending_requests.pop(request_id, None) - return web.json_response( - { - "error": "Request timeout", - "client_id": target_client.client_id, - }, - status=504, - headers={"Access-Control-Allow-Origin": "*"}, - ) - - except Exception as e: - self.pending_requests.pop(request_id, None) - return web.json_response( - { - "error": str(e), - }, - status=500, - headers={"Access-Control-Allow-Origin": "*"}, - ) - - def _action_to_method(self, action: str) -> str: - """Map HTTP action to CDP/wire method. - - Aligned with hanzo_tools.browser.browser_tool._zap_method_for(). Same - action namespace, same expectations. If an action is already a wire - method (contains "."), it is forwarded as-is to allow direct - Page.navigate / DOM.querySelector / etc. usage. - """ - # Passthrough for already-qualified wire methods. - if "." in action: - return action - action_map = { - # Navigation / lifecycle - "navigate": "Page.navigate", - "reload": "Page.reload", - "go_back": "Page.goBack", - "go_forward": "Page.goForward", - "print_pdf": "Page.printToPDF", - "wait_for_navigation": "hanzo.waitForNavigation", - "wait_for_load_state": "Page.waitForLoadState", - # Tabs / targets - "tabs": "Target.getTargets", - "new_tab": "Target.createTarget", - "close_tab": "Target.closeTarget", - "activate_tab": "Target.activateTarget", - "url": "hanzo.url", - "title": "hanzo.title", - "tab_info": "hanzo.tabInfo", - "list_tabs": "hanzo.listTabs", - "history": "hanzo.getHistory", - # Observation - "screenshot": "hanzo.screenshot", - "page_info": "hanzo.getPageInfo", - "ax_tree": "Accessibility.getFullAXTree", - "status": "Browser.getVersion", - # DOM read - "get_text": "hanzo.getText", - "get_html": "hanzo.getHTML", - "get_attribute": "hanzo.getAttribute", - "get_element_info": "hanzo.getElementInfo", - "query_one": "DOM.querySelector", - "query_all": "hanzo.querySelectorAll", - "list_form": "hanzo.listForm", - "computed_styles": "hanzo.getComputedStyles", - "bounding_rects": "hanzo.getBoundingRects", - # DOM write — selector-based - "click": "hanzo.click", - "dblclick": "hanzo.dblclick", - "hover": "hanzo.hover", - "fill": "hanzo.fill", - "check": "hanzo.check", - "uncheck": "hanzo.uncheck", - "select": "hanzo.select", - "type": "hanzo.type", - "clear": "hanzo.clear", - "focus": "DOM.focus", - "scroll_into_view": "DOM.scrollIntoView", - "set_text": "hanzo.setText", - "set_html": "hanzo.setHTML", - "set_attribute": "hanzo.setAttribute", - "remove_attribute": "hanzo.removeAttribute", - # DOM write — CSP-safe text/label-based (RECOMMENDED for forms) - "click_text": "hanzo.clickByText", - "fill_label": "hanzo.fillByLabel", - "find_by_text": "hanzo.findByText", - "submit_form": "hanzo.submitForm", - "upload_file": "hanzo.uploadFile", - # Keyboard / mouse - "press": "hanzo.press", - "press_key": "Input.dispatchKeyEvent", - "mouse_event": "Input.dispatchMouseEvent", - "scroll": "hanzo.scroll", - "scroll_wheel": "Input.scrollWheel", - # Wait / observe - "wait_for_text": "hanzo.waitForText", - "wait_for_mutation": "hanzo.waitForMutation", - "wait_for_selector": "hanzo.waitForSelector", - "observe_start": "hanzo.observe", - "observe_read": "hanzo.observeRead", - "observe_stop": "hanzo.observeStop", - # Dialog - "dialog_accept": "hanzo.dialogAccept", - # Scripting - "evaluate": "Runtime.evaluate", - "inject_script": "hanzo.injectScript", - "inject_css": "hanzo.injectCSS", - # Cookies / storage - "cookies": "hanzo.getCookies", - "local_storage_get": "hanzo.getLocalStorage", - "local_storage_set": "hanzo.setLocalStorage", - # HTTP fetch via browser - "fetch": "hanzo.fetch", - } - return action_map.get(action, action) - - # Keys the HTTP transport itself owns — never forward these as method params. - _ROUTING_KEYS = frozenset({ - "action", "method", "client_id", "target_id", "browser", "params", - }) - - def _build_params(self, data: dict) -> dict: - """Build CDP params from HTTP request data. - - Accepts BOTH: - - flat keys at top level: {"action": "x", "tabId": 1, "target": "top"} - - nested params object: {"action": "x", "params": {"tabId": 1, "target": "top"}} - - Snake_case → camelCase normalisation handled inline for the four - keys (tab_id, full_page, code, expression) that have historic aliases. - - Strategy: pass through every non-routing key. The extension's - canonical dispatcher (executeMethod) is the source of truth on what - each method accepts — we don't need a whitelist here, because - unknown keys are simply ignored downstream. A whitelist creates - silent drops which is what bit us with `target`, `label`, `key`, - `intent`, `name`, etc. on the 1.9.16+ methods. - """ - params: dict = {} - - # 1) Pull from nested "params" object first (per-MCP-style callers) - nested = data.get("params") if isinstance(data.get("params"), dict) else {} - for k, v in nested.items(): - if k in self._ROUTING_KEYS: - continue - params[k] = v - - # 2) Then overlay flat top-level keys (per-curl-style callers). - # Top-level wins so explicit overrides are honoured. - for k, v in data.items(): - if k in self._ROUTING_KEYS: - continue - params[k] = v - - # 3) Snake_case aliases → camelCase (historical compatibility) - if "tab_id" in params and "tabId" not in params: - params["tabId"] = params.pop("tab_id") - if "full_page" in params and "fullPage" not in params: - params["fullPage"] = params.pop("full_page") - if "code" in params and "expression" not in params: - params["expression"] = params.pop("code") - - return params - - async def stop(self) -> None: - """Stop the WebSocket and HTTP servers.""" - if self._http_runner: - await self._http_runner.cleanup() - logger.info("CDP Bridge HTTP API stopped") - - if self._server: - self._server.close() - await self._server.wait_closed() - logger.info("CDP Bridge WebSocket stopped") - - async def _handle_connection( - self, - websocket: WebSocketServerProtocol, - path: str = "/", - ) -> None: - """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 - message = await websocket.recv() - data = json.loads(message) - - if data.get("type") == "register": - role = data.get("role") - - if role == "cdp-provider": - # This is a browser extension - # Client can provide its own ID or we generate one - client_id = data.get("client_id") or str(uuid.uuid4())[:8] - - client = ExtensionClient( - client_id=client_id, - websocket=websocket, - browser=data.get("browser", "unknown"), - profile=data.get("profile", "default"), - user_agent=data.get("userAgent", ""), - capabilities=data.get("capabilities", []), - ) - - self.extension_clients[client_id] = client - self._ws_to_client_id[websocket] = client_id - - logger.info( - f"Browser extension registered: {client_id} ({client.browser}/{client.profile})" - ) - - # Send back the assigned client_id - await websocket.send( - json.dumps( - { - "type": "registered", - "client_id": client_id, - } - ) - ) - - # Notify MCP clients - for mcp in self.mcp_clients: - await mcp.send( - json.dumps( - { - "type": "provider_connected", - "client_id": client_id, - "browser": client.browser, - "profile": client.profile, - "capabilities": client.capabilities, - "total_clients": len(self.extension_clients), - } - ) - ) - - elif role == "mcp-client": - # This is hanzo-mcp or another MCP tool - self.mcp_clients.add(websocket) - logger.info("MCP client connected") - - # Send status with all connected clients - await websocket.send( - json.dumps( - { - "type": "status", - "connected": len(self.extension_clients) > 0, - "clients": [ - c.to_dict() for c in self.extension_clients.values() - ], - "default_client_id": self.default_client_id, - } - ) - ) - - # Handle subsequent messages - async for message in websocket: - await self._route_message(websocket, message) - - except websockets.exceptions.ConnectionClosed: - logger.info(f"Connection closed: {websocket.remote_address}") - finally: - # Clean up - if websocket in self._ws_to_client_id: - client_id = self._ws_to_client_id.pop(websocket) - self.extension_clients.pop(client_id, None) - logger.info(f"Extension client disconnected: {client_id}") - - # Notify MCP clients - for mcp in self.mcp_clients: - try: - await mcp.send( - json.dumps( - { - "type": "provider_disconnected", - "client_id": client_id, - "remaining_clients": len(self.extension_clients), - } - ) - ) - except Exception: - pass - - elif websocket in self.mcp_clients: - self.mcp_clients.discard(websocket) - - def _resolve_client( - 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 - """ - # Parse target_id if provided (format: "client_id:tab_id") - if target_id and ":" in target_id: - cid = target_id.split(":")[0] - if cid in self.extension_clients: - return self.extension_clients[cid] - - # Use explicit client_id - 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 - - async def _route_message( - self, - sender: WebSocketServerProtocol, - message: str, - ) -> None: - """Route messages between extensions and MCP clients.""" - data = json.loads(message) - - # Check if message is from an extension - if sender in self._ws_to_client_id: - client_id = self._ws_to_client_id[sender] - # Update last_active - if client_id in self.extension_clients: - self.extension_clients[client_id].last_active = time.time() - - # Message from extension (response or event) - if "id" in data and data["id"] in self.pending_requests: - # This is a response to a pending request - future = self.pending_requests.pop(data["id"]) - # Add source client_id to response - data["_client_id"] = client_id - future.set_result(data) - elif data.get("type") == "event": - # Add source client_id to event - data["_client_id"] = client_id - # Broadcast event to all MCP clients - for mcp in self.mcp_clients: - try: - await mcp.send(json.dumps(data)) - except Exception: - pass - - elif sender in self.mcp_clients: - # Message from MCP client (command) - # Resolve target client - target_client = self._resolve_client( - client_id=data.get("client_id"), - target_id=data.get("target_id"), - ) - - if not target_client: - # No extension connected or specified client not found - await sender.send( - json.dumps( - { - "id": data.get("id"), - "error": { - "code": -32000, - "message": ( - "No browser extension connected" - if not self.extension_clients - else f"Client not found: {data.get('client_id') or data.get('target_id')}" - ), - }, - } - ) - ) - return - - # Update target client's last_active - target_client.last_active = time.time() - - # Forward to target extension and wait for response - request_id = data.get("id", self._next_request_id()) - data["id"] = request_id - - future: asyncio.Future = asyncio.get_event_loop().create_future() - self.pending_requests[request_id] = future - - try: - await target_client.websocket.send(json.dumps(data)) - - # Wait for response with timeout - response = await asyncio.wait_for(future, timeout=30.0) - # Include client_id in response - response["client_id"] = target_client.client_id - await sender.send(json.dumps(response)) - - except asyncio.TimeoutError: - self.pending_requests.pop(request_id, None) - await sender.send( - json.dumps( - { - "id": request_id, - "error": { - "code": -32001, - "message": f"Request timeout (client: {target_client.client_id})", - }, - } - ) - ) - except Exception as e: - self.pending_requests.pop(request_id, None) - await sender.send( - json.dumps( - {"id": request_id, "error": {"code": -32603, "message": str(e)}} - ) - ) - - def _next_request_id(self) -> int: - """Generate next request ID.""" - self.request_id += 1 - return self.request_id - - -class CDPBridgeClient: - """Client for connecting to CDP Bridge Server from hanzo-mcp.""" - - def __init__( - self, - host: str = "localhost", - port: int = 9223, - ): - self.host = host - self.port = port - self._websocket: Optional[WebSocketServerProtocol] = None - self._request_id = 0 - self._pending: dict[int, asyncio.Future] = {} - self._event_handlers: list[Callable] = [] - self._clients: list[dict] = [] - self._default_client_id: Optional[str] = None - - @property - def clients(self) -> list[dict]: - """List of connected browser extension clients.""" - return self._clients - - @property - def default_client_id(self) -> Optional[str]: - """ID of the default (most recently active) client.""" - return self._default_client_id - - async def connect(self) -> bool: - """Connect to the CDP bridge server.""" - if not WEBSOCKETS_AVAILABLE: - logger.warning("websockets not available, CDP bridge disabled") - return False - - try: - import websockets - - uri = f"ws://{self.host}:{self.port}/cdp" - self._websocket = await websockets.connect(uri) - - # Register as MCP client - await self._websocket.send( - json.dumps({"type": "register", "role": "mcp-client"}) - ) - - # Wait for status response - status_msg = await self._websocket.recv() - status = json.loads(status_msg) - if status.get("type") == "status": - self._clients = status.get("clients", []) - self._default_client_id = status.get("default_client_id") - - # Start message handler - asyncio.create_task(self._message_loop()) - - logger.info(f"Connected to CDP bridge at {uri}") - return True - - except Exception as e: - logger.warning(f"Failed to connect to CDP bridge: {e}") - return False - - async def disconnect(self) -> None: - """Disconnect from the bridge server.""" - if self._websocket: - await self._websocket.close() - self._websocket = None - - async def _message_loop(self) -> None: - """Process incoming messages.""" - if not self._websocket: - return - - try: - async for message in self._websocket: - data = json.loads(message) - - # Handle status updates - if data.get("type") == "provider_connected": - # New client connected - self._clients.append( - { - "client_id": data.get("client_id"), - "browser": data.get("browser"), - "profile": data.get("profile"), - "capabilities": data.get("capabilities"), - } - ) - elif data.get("type") == "provider_disconnected": - # Client disconnected - cid = data.get("client_id") - self._clients = [ - c for c in self._clients if c.get("client_id") != cid - ] - - # Handle responses - if "id" in data and data["id"] in self._pending: - future = self._pending.pop(data["id"]) - if "error" in data: - future.set_exception(Exception(data["error"]["message"])) - else: - future.set_result(data.get("result")) - - elif data.get("type") == "event": - for handler in self._event_handlers: - try: - handler(data) - except Exception: - pass - - except Exception as e: - logger.error(f"Message loop error: {e}") - - async def send( - self, - method: str, - params: dict = None, - client_id: str = None, - target_id: str = None, - ) -> Any: - """Send a CDP command and wait for response. - - Args: - method: CDP method name - params: Method parameters - client_id: Optional specific client to target - target_id: Optional namespaced target (client_id:tab_id) - - Returns: - Result from the extension - """ - if not self._websocket: - raise Exception("Not connected to CDP bridge") - - self._request_id += 1 - request_id = self._request_id - - future: asyncio.Future = asyncio.get_event_loop().create_future() - self._pending[request_id] = future - - payload = {"id": request_id, "method": method, "params": params or {}} - - # Add routing hints - if client_id: - payload["client_id"] = client_id - if target_id: - payload["target_id"] = target_id - - await self._websocket.send(json.dumps(payload)) - - return await asyncio.wait_for(future, timeout=30.0) - - def on_event(self, handler: Callable) -> None: - """Register an event handler.""" - self._event_handlers.append(handler) - - # High-level commands - - async def navigate( - self, - url: str, - tab_id: int = None, - client_id: str = None, - ) -> None: - """Navigate to a URL.""" - await self.send( - "Page.navigate", - {"url": url, "tabId": tab_id}, - client_id=client_id, - ) - - async def screenshot( - self, - tab_id: int = None, - full_page: bool = False, - format: str = "png", - client_id: str = None, - ) -> str: - """Take a screenshot, returns base64 data.""" - result = await self.send( - "hanzo.screenshot", - {"tabId": tab_id, "fullPage": full_page, "format": format}, - client_id=client_id, - ) - return result.get("data", "") - - async def click( - self, - selector: str, - tab_id: int = None, - client_id: str = None, - ) -> bool: - """Click an element by selector.""" - result = await self.send( - "hanzo.click", - {"selector": selector, "tabId": tab_id}, - client_id=client_id, - ) - return result.get("success", False) - - async def fill( - self, - selector: str, - value: str, - tab_id: int = None, - client_id: str = None, - ) -> bool: - """Fill an input element.""" - result = await self.send( - "hanzo.fill", - {"selector": selector, "value": value, "tabId": tab_id}, - client_id=client_id, - ) - return result.get("success", False) - - async def evaluate( - self, - expression: str, - tab_id: int = None, - client_id: str = None, - ) -> Any: - """Evaluate JavaScript in the page.""" - return await self.send( - "Runtime.evaluate", - {"expression": expression, "tabId": tab_id}, - client_id=client_id, - ) - - async def list_clients(self) -> list[dict]: - """Get list of connected browser extension clients.""" - # Refresh from server - result = await self.send("hanzo.listClients", {}) - return result.get("clients", self._clients) - - -async def main(): - """Run the CDP bridge server.""" - host = os.environ.get("HANZO_CDP_BRIDGE_HOST", "localhost") - port = int(os.environ.get("HANZO_CDP_BRIDGE_PORT", "9223")) - http_port = int(os.environ.get("HANZO_CDP_HTTP_PORT", "9224")) - - server = CDPBridgeServer(host=host, port=port, http_port=http_port) - await server.start() - - print(f"CDP Bridge Server running:") - print(f" WebSocket: ws://{host}:{port} (browser extensions connect here)") - print(f" HTTP API: http://{host}:{http_port} (hanzo-mcp sends commands here)") - print() - print("Waiting for browser extension(s) to connect...") - print("Supports multiple browsers simultaneously") - print("Press Ctrl+C to stop") - - try: - await asyncio.Future() # Run forever - except KeyboardInterrupt: - print("\nShutting down...") - await server.stop() - - -if __name__ == "__main__": - logging.basicConfig(level=logging.INFO) - asyncio.run(main()) diff --git a/pkg/hanzo-tools-browser/hanzo_tools/browser/cdp_tool.py b/pkg/hanzo-tools-browser/hanzo_tools/browser/cdp_tool.py index aec9c2c88..82aa8f400 100644 --- a/pkg/hanzo-tools-browser/hanzo_tools/browser/cdp_tool.py +++ b/pkg/hanzo-tools-browser/hanzo_tools/browser/cdp_tool.py @@ -1,148 +1,100 @@ -"""Raw Chrome DevTools Protocol method dispatch. - -Decomplected from BrowserTool: `browser` is *action-oriented* (high-level -verbs like `navigate`, `click`); `cdp` is *method-oriented* (sends a CDP -method by name with raw params). Same backing transports (in-process ZAP -server → legacy HTTP bridge), no Playwright fallback — for that, use -`browser` or `playwright`. - -Use this tool when you need a CDP method the high-level surface doesn't -expose, want to inspect raw protocol responses, or are wiring something -to the protocol directly. - -Example:: - - cdp(action="send", method="Page.navigate", params={"url": "https://example.com"}) - cdp(action="send", method="Runtime.evaluate", params={"expression": "1+1"}) - cdp(action="tabs") # list connected tabs (Target.getTargets) - cdp(action="status") # connection status - cdp(action="list_browsers") # which providers (firefox/chrome/safari) are connected +"""Raw Chrome DevTools Protocol method dispatch — zapd-native. + +Peer of ``browser`` (action-oriented). ``cdp`` is *method-oriented*: it sends a +CDP method by name with raw params straight to a connected browser provider over +the shared local zapd router (``~/.zap/run/zapd.sock``). Same backing transport +as ``browser`` — no in-process server, no :9224 HTTP bridge, no Playwright +fallback. The method name goes on the wire verbatim (``Target.getTargets``, +``Page.navigate`` …) so the extension's CDP dispatch handles it directly; there +is no ``{"action": "cdp"}`` envelope to misroute. + +Use ``browser`` for high-level verbs (navigate, click, screenshot). +Use ``cdp`` when you need a CDP method the high-level surface doesn't expose. """ - from __future__ import annotations import json -import logging -import os -from typing import Any, Annotated, Literal, Optional, Union +from typing import Any, Union, Optional, Annotated from pydantic import Field from mcp.server import FastMCP from hanzo_tools.core import BaseTool +from hanzo_tools.browser.zapd_consumer import get_consumer -logger = logging.getLogger(__name__) +# Sugared actions → the bare CDP method they map to. +_SUGARED: dict[str, str] = { + "tabs": "Target.getTargets", + "status": "Browser.getVersion", + "list_browsers": "", # handled locally via the zapd provider list +} -CdpAction = Annotated[ - Literal["send", "tabs", "status", "list_browsers", "claim_browser", "release_browser"], - Field(description="CDP action"), -] - -async def _dispatch_raw( +async def _route_cdp( method: str, - params: Optional[dict] = None, + params: Optional[dict], *, - browser: Optional[str] = None, - tab_id: Optional[Union[str, int]] = None, + target_browser: Optional[str] = None, + tab_id: Union[str, int, None] = None, client_id: Optional[str] = None, timeout: float = 30.0, -) -> dict: - """Dispatch a raw CDP method to the connected browser provider. - - Path order — same as BrowserTool: - 1. In-process ZAP server (microsecond round-trip; preferred). - 2. Legacy HTTP bridge on :9224 (kept as fallback for non-ZAP clients). +) -> dict[str, Any]: + """Route a raw CDP method to a browser provider via the local zapd router. - Pin transport via ``BROWSER_TRANSPORT=zap|http|auto`` (default ``auto``). + Mirrors ``browser_tool._extension_command`` but sends the method name + verbatim instead of mapping a high-level action. One transport, one codec. """ - params = dict(params or {}) + import asyncio - # Normalize tab id (accept "tab-123" or 123 or "123") + consumer = get_consumer() + if consumer is None: + return {"error": "zapd not reachable (~/.zap/run/zapd.sock)", "transport": "native-zap", "method": method} + + try: + provider = await asyncio.to_thread(consumer.resolve_browser, target_browser, client_id) + except Exception as e: + return {"error": str(e), "transport": "native-zap", "method": method} + if not provider: + return {"error": "no browser provider connected over zapd", "transport": "native-zap", "method": method} + + wire: dict[str, Any] = dict(params or {}) if tab_id is not None: - t = tab_id - if isinstance(t, str) and t.startswith("tab-"): - t = t[4:] - try: - t = int(t) - except (TypeError, ValueError): - pass - params.setdefault("tabId", t) - - transport = os.environ.get("BROWSER_TRANSPORT", "auto").strip().lower() - if transport not in {"zap", "http", "auto"}: - transport = "auto" - - # 1) ZAP path - if transport in {"zap", "auto"}: - try: - from hanzo_tools.browser.zap_server import get_server - - srv = get_server() - if srv is not None and srv.has_client(browser=browser): - try: - raw = await srv.send( - method, params, browser=browser, client_id=client_id - ) - return {"success": True, "transport": "zap", "method": method, "result": raw} - except Exception as e: - if transport == "zap": - return {"error": str(e), "transport": "zap", "method": method} - logger.debug("zap dispatch failed, falling back to http: %s", e) - except ImportError: - pass - - if transport == "zap": - return { - "error": "ZAP transport selected but no extension client matched", - "transport": "zap", - "method": method, - } - - # 2) HTTP fallback — legacy CDP bridge speaks raw CDP via a `cdp` action + wire.setdefault("tabId", tab_id) + str_params = {k: (v if isinstance(v, str) else str(v)) for k, v in wire.items() if v is not None} + try: - import aiohttp - - payload: dict[str, Any] = {"action": "cdp", "method": method, "params": params} - if browser: - payload["browser"] = browser - if client_id: - payload["clientId"] = client_id - - async with aiohttp.ClientSession() as session: - async with session.post( - "http://localhost:9224", - json=payload, - timeout=aiohttp.ClientTimeout(total=timeout), - ) as resp: - body = await resp.text() - try: - import json - - parsed = json.loads(body) - except Exception: - parsed = {"raw": body} - parsed.setdefault("transport", "http") - parsed.setdefault("method", method) - if resp.status == 200: - parsed.setdefault("success", True) - else: - parsed.setdefault("status", resp.status) - parsed.setdefault("error", parsed.get("error") or body[:200]) - return parsed + raw = await asyncio.to_thread(consumer.route, provider, method, str_params, timeout) except Exception as e: - logger.debug("CDP dispatch HTTP fallback failed: %s", e) - return {"error": str(e), "transport": "http", "method": method} + return {"error": str(e), "transport": "native-zap", "method": method} + text = raw.decode("utf-8", errors="replace") if isinstance(raw, (bytes, bytearray)) else raw + return {"success": True, "transport": "native-zap", "source": "zapd", "provider": provider, "method": method, "result": text} -class CdpTool(BaseTool): - """Raw Chrome DevTools Protocol method dispatch. - Peer of ``browser`` (action-oriented) and ``playwright`` (Playwright API). - Sends any CDP method directly to a connected browser via the ZAP server - (extension) or legacy CDP HTTP bridge. Does NOT fall back to Playwright. - """ +async def _list_browsers() -> dict[str, Any]: + """List browser providers connected to the local zapd router.""" + import asyncio + + consumer = get_consumer() + if consumer is None: + return {"error": "zapd not reachable (~/.zap/run/zapd.sock)", "transport": "native-zap"} + try: + provs = await asyncio.to_thread(consumer.list_providers) + except Exception as e: + return {"error": str(e), "transport": "native-zap"} + browsers = [p for p in provs if p.get("id", "").startswith("browser:")] + return {"success": True, "transport": "native-zap", "browsers": browsers, "count": len(browsers)} + + +CdpAction = Annotated[ + str, + Field(description="CDP action: send | tabs | status | list_browsers"), +] + + +class CdpTool(BaseTool): + """Raw Chrome DevTools Protocol method dispatch — peer of ``browser``.""" name = "cdp" @@ -155,7 +107,6 @@ def description(self) -> str: - tabs : Target.getTargets — list connected tabs - status : Browser.getVersion — connection + version - list_browsers : list extension providers (firefox/chrome/safari/edge) connected -- claim_browser / release_browser : exclusive-lease management EXAMPLES: - cdp(action="send", method="Page.navigate", params={"url": "https://example.com"}) @@ -164,12 +115,42 @@ def description(self) -> str: - cdp(action="status") Use `browser` for high-level verbs (navigate, click, screenshot). -Use `playwright` for headless Playwright automation. """ async def call(self, ctx, action: str = "send", **kwargs) -> dict[str, Any]: return await self.execute(action=action, **kwargs) + async def execute( + self, + action: str = "send", + method: Optional[str] = None, + params: Optional[dict] = None, + tab_id: Optional[Union[str, int]] = None, + target_browser: Optional[str] = None, + client_id: Optional[str] = None, + timeout: Optional[float] = None, + ) -> dict[str, Any]: + t = float(timeout) if timeout else 30.0 + + if action == "list_browsers": + return await _list_browsers() + + if action in _SUGARED: + return await _route_cdp( + _SUGARED[action], {}, + target_browser=target_browser, tab_id=tab_id, client_id=client_id, timeout=t, + ) + + if action == "send": + if not method: + return {"error": "method required for action=send (e.g. 'Page.navigate')", "action": "send"} + return await _route_cdp( + method, params, + target_browser=target_browser, tab_id=tab_id, client_id=client_id, timeout=t, + ) + + return {"error": f"unknown action '{action}'. Try: send, tabs, status, list_browsers"} + def register(self, mcp_server: FastMCP) -> None: """Register the cdp tool with an MCP server.""" tool_instance = self @@ -212,109 +193,3 @@ async def cdp( timeout=timeout, ) return json.dumps(result, indent=2, default=str) - - async def execute( - self, - action: str = "send", - # Raw CDP - method: Optional[str] = None, - params: Optional[dict] = None, - # Routing - tab_id: Optional[Union[str, int]] = None, - target_browser: Optional[str] = None, - client_id: Optional[str] = None, - # Timeout - timeout: Optional[float] = None, - ) -> dict[str, Any]: - t = float(timeout) if timeout else 30.0 - - # === Local actions (handled in-process) ===================== - if action == "list_browsers": - try: - from hanzo_tools.browser.zap_server import get_server - - srv = get_server() - if srv is None: - return {"error": "zap server not running"} - clients = [] - for c in srv.clients: - clients.append( - { - "client_id": c.client_id, - "browser": getattr(c, "browser", None), - "label": getattr(c, "label", None), - } - ) - return {"success": True, "browsers": clients, "count": len(clients)} - except Exception as e: - return {"error": str(e)} - - if action == "claim_browser": - try: - from hanzo_tools.browser.zap_server import DEFAULT_LEASE_TTL, get_server - - srv = get_server() - if srv is None: - return {"error": "zap server not running"} - client = srv.resolve_client(client_id=client_id, browser=target_browser) - if client is None: - return {"error": "no matching extension client"} - lease = srv.claim(client.client_id, ttl=t) - return { - "success": True, - "client_id": lease.client_id, - "holder": lease.holder, - "expires_at": lease.expires_at, - } - except Exception as e: - return {"error": str(e)} - - if action == "release_browser": - try: - from hanzo_tools.browser.zap_server import get_server - - srv = get_server() - if srv is None: - return {"error": "zap server not running"} - if client_id: - return {"success": srv.release(client_id), "client_id": client_id} - released = [c.client_id for c in list(srv.clients) if srv.release(c.client_id)] - return {"success": True, "released": released} - except Exception as e: - return {"error": str(e)} - - # === Sugared CDP methods ================================== - sugared = { - "tabs": ("Target.getTargets", {}), - "status": ("Browser.getVersion", {}), - } - if action in sugared: - m, p = sugared[action] - return await _dispatch_raw( - m, - p, - browser=target_browser, - tab_id=tab_id, - client_id=client_id, - timeout=t, - ) - - # === Raw send ============================================== - if action == "send": - if not method: - return { - "error": "method required for action=send (e.g. 'Page.navigate')", - "action": "send", - } - return await _dispatch_raw( - method, - params, - browser=target_browser, - tab_id=tab_id, - client_id=client_id, - timeout=t, - ) - - return { - "error": f"unknown action '{action}'. Try: send, tabs, status, list_browsers, claim_browser, release_browser", - } diff --git a/pkg/hanzo-tools-browser/hanzo_tools/browser/lifecycle.py b/pkg/hanzo-tools-browser/hanzo_tools/browser/lifecycle.py deleted file mode 100644 index 682b96ba2..000000000 --- a/pkg/hanzo-tools-browser/hanzo_tools/browser/lifecycle.py +++ /dev/null @@ -1,210 +0,0 @@ -"""Lifecycle helpers for the browser package: ZAP server + legacy CDP bridge. - -Decomplected out of ``__init__.py`` so the package's namespace is a pure -re-export surface. The MCP server (or any host) imports ``_ensure_zap_server`` -and ``start_cdp_bridge`` from here when it wants the long-lived background -threads bound. Tools themselves never touch lifecycle directly. - -Two background lifecycles, isolated: - - * ZAP server — canonical. One MCP = one ZAP server bound to the lowest - free port from 9999..9995. Browser extension discovers - it via mDNS. Lifetime = MCP lifetime. - - * CDP bridge — legacy HTTP fallback on :9223/:9224 for non-ZAP clients. - Opt-in: ``HANZO_CDP_BRIDGE_ENABLED=1``. -""" - -from __future__ import annotations - -import asyncio -import logging -import os -import threading -from typing import TYPE_CHECKING, Optional - -if TYPE_CHECKING: - from hanzo_tools.browser.cdp_bridge_server import CDPBridgeServer - -logger = logging.getLogger(__name__) - -# CDP bridge availability check -try: - from hanzo_tools.browser.cdp_bridge_server import ( - WEBSOCKETS_AVAILABLE as CDP_BRIDGE_AVAILABLE, - CDPBridgeServer, - ) -except ImportError: # pragma: no cover - CDP_BRIDGE_AVAILABLE = False - CDPBridgeServer = None # type: ignore[assignment] - -# === Global state (one of each per process) ============================ - -_zap_thread: Optional[threading.Thread] = None -_zap_loop: Optional[asyncio.AbstractEventLoop] = None -_zap_started_event: Optional[threading.Event] = None - -_cdp_bridge_server: Optional["CDPBridgeServer"] = None -_cdp_bridge_thread: Optional[threading.Thread] = None -_cdp_bridge_loop: Optional[asyncio.AbstractEventLoop] = None - - -# === ZAP (canonical) ==================================================== - - -def _run_zap_server(host: str) -> None: - """Run the ZAP server in a dedicated background thread.""" - global _zap_loop, _zap_started_event - - from hanzo_tools.browser.zap_server import get_or_start_server - - _zap_loop = asyncio.new_event_loop() - asyncio.set_event_loop(_zap_loop) - - async def _bootstrap() -> None: - srv = await get_or_start_server( - host=host, - agent_label=os.environ.get("HANZO_AGENT_LABEL"), - ) - if _zap_started_event is not None: - _zap_started_event.set() - if srv is None: - return - while True: - await asyncio.sleep(3600) - - try: - _zap_loop.run_until_complete(_bootstrap()) - except Exception as e: - logger.error("ZAP server thread crashed: %s", e, exc_info=True) - - -def ensure_zap_server() -> bool: - """Start the in-process ZAP server if not already running. - - Returns True if the server is alive after this call. - Idempotent — safe to call multiple times. - """ - global _zap_thread, _zap_started_event - - if _zap_thread is not None and _zap_thread.is_alive(): - from hanzo_tools.browser.zap_server import get_server - - return get_server() is not None - - if os.environ.get("HANZO_ZAP_DISABLED", "").lower() in ("1", "true", "yes"): - return False - - host = os.environ.get("HANZO_ZAP_HOST", "127.0.0.1") - - _zap_started_event = threading.Event() - _zap_thread = threading.Thread( - target=_run_zap_server, - args=(host,), - daemon=True, - name="hanzo-zap-server", - ) - _zap_thread.start() - _zap_started_event.wait(timeout=2.0) - - from hanzo_tools.browser.zap_server import get_server - - return get_server() is not None - - -def stop_zap_server() -> None: - """Stop the in-process ZAP server (best-effort, non-blocking).""" - global _zap_thread, _zap_loop, _zap_started_event - - if _zap_loop is not None: - try: - from hanzo_tools.browser.zap_server import shutdown_server - - asyncio.run_coroutine_threadsafe(shutdown_server(), _zap_loop) - except Exception: - pass - _zap_loop = None - _zap_thread = None - _zap_started_event = None - - -# === CDP bridge (legacy) =============================================== - - -def _run_cdp_bridge_server(host: str, port: int) -> None: - """Run CDP bridge server in a background thread.""" - global _cdp_bridge_server, _cdp_bridge_loop - - _cdp_bridge_loop = asyncio.new_event_loop() - asyncio.set_event_loop(_cdp_bridge_loop) - - _cdp_bridge_server = CDPBridgeServer(host=host, port=port) # type: ignore[misc] - - async def run() -> None: - await _cdp_bridge_server.start() # type: ignore[union-attr] - while True: - await asyncio.sleep(1) - - try: - _cdp_bridge_loop.run_until_complete(run()) - except Exception as e: - logger.error("CDP bridge server crashed: %s", e, exc_info=True) - - -def start_cdp_bridge(host: str = "localhost", port: int = 9223) -> bool: - """Start the legacy CDP bridge server (opt-in fallback transport). - - Enables HTTP communication between hanzo-mcp's tools (port 9224) and - the Hanzo browser extension (WebSocket on `port`, default 9223). - Set ``HANZO_CDP_BRIDGE_DISABLED=1`` to refuse to start. - """ - global _cdp_bridge_thread - - if os.environ.get("HANZO_CDP_BRIDGE_DISABLED", "").lower() in ("1", "true", "yes"): - return False - if not CDP_BRIDGE_AVAILABLE: - return False - if _cdp_bridge_thread is not None and _cdp_bridge_thread.is_alive(): - return True - - host = os.environ.get("HANZO_CDP_BRIDGE_HOST", host) - port = int(os.environ.get("HANZO_CDP_BRIDGE_PORT", str(port))) - - try: - _cdp_bridge_thread = threading.Thread( - target=_run_cdp_bridge_server, - args=(host, port), - daemon=True, - name="cdp-bridge-server", - ) - _cdp_bridge_thread.start() - logger.info("CDP bridge started on ws://%s:%d", host, port) - return True - except Exception as e: - logger.warning("Failed to start CDP bridge: %s", e) - return False - - -def stop_cdp_bridge() -> None: - """Stop the CDP bridge server.""" - global _cdp_bridge_server, _cdp_bridge_thread, _cdp_bridge_loop - - if _cdp_bridge_loop is not None and _cdp_bridge_server is not None: - try: - asyncio.run_coroutine_threadsafe( - _cdp_bridge_server.stop(), _cdp_bridge_loop - ) - except Exception: - pass - _cdp_bridge_server = None - _cdp_bridge_thread = None - _cdp_bridge_loop = None - - -__all__ = [ - "CDP_BRIDGE_AVAILABLE", - "ensure_zap_server", - "stop_zap_server", - "start_cdp_bridge", - "stop_cdp_bridge", -] diff --git a/pkg/hanzo-tools-browser/hanzo_tools/browser/zap_server.py b/pkg/hanzo-tools-browser/hanzo_tools/browser/zap_server.py deleted file mode 100644 index 7b39cd102..000000000 --- a/pkg/hanzo-tools-browser/hanzo_tools/browser/zap_server.py +++ /dev/null @@ -1,621 +0,0 @@ -"""ZAP (Zero-latency Agent Protocol) server for hanzo-tools-browser. - -Wire format and constants come from the canonical ``zap-protocol`` package -so every implementation in the stack stays byte-identical. - -Discovery is mDNS-only per HIP-0069: the server binds an OS-assigned -ephemeral port and advertises it under ``_hanzo._tcp.local.`` via -``zap-mdns``. There is no well-known port pool, no lockfile arbitration, -and no shared config registry — clients (browser extensions, sibling -MCPs, agents) browse mDNS to find every live ZAP service on the LAN. - -One ZapServer per hanzo-mcp process. Lifetime = MCP lifetime. -""" - -from __future__ import annotations - -import asyncio -import logging -import os -import time -import uuid -from dataclasses import dataclass, field -from typing import Any, Awaitable, Callable, Optional - -logger = logging.getLogger(__name__) - -# Wire format — vendored here. Mirrors the TypeScript reference at -# extension/packages/browser/src/shared/zap.ts. Kept self-contained so a -# vanilla `pip install hanzo-tools-browser` works without external state -# (an earlier `zap-protocol` PyPI package was a `zap-schema` stub that -# doesn't expose `zap.protocol`; rather than chase that, the wire spec is -# small enough to inline). If you change anything here, also update the -# TS reference and `extension/packages/mcp/src/zap-server.ts`. -import json as _json -import struct as _struct -from typing import Tuple as _Tuple - -ZAP_MAGIC = b"\x5a\x41\x50\x01" -HEADER_SIZE = 9 # 4 magic + 1 type + 4 length BE -MAX_MESSAGE_SIZE = 16 * 1024 * 1024 # 16 MiB - -MSG_HANDSHAKE = 0x01 -MSG_HANDSHAKE_OK = 0x02 -MSG_REQUEST = 0x10 -MSG_RESPONSE = 0x11 -MSG_PING = 0xFE -MSG_PONG = 0xFF - - -def encode(msg_type: int, payload) -> bytes: - body = _json.dumps(payload, separators=(",", ":"), ensure_ascii=False).encode("utf-8") - if len(body) > MAX_MESSAGE_SIZE: - raise ValueError(f"ZAP payload exceeds MAX_MESSAGE_SIZE ({len(body)} > {MAX_MESSAGE_SIZE})") - return ZAP_MAGIC + _struct.pack("!BL", msg_type & 0xFF, len(body)) + body - - -def decode(frame: bytes): - if len(frame) < HEADER_SIZE or frame[:4] != ZAP_MAGIC: - return None - msg_type = frame[4] - (length,) = _struct.unpack("!L", frame[5:9]) - if length > MAX_MESSAGE_SIZE or len(frame) < HEADER_SIZE + length: - return None - try: - payload = _json.loads(frame[HEADER_SIZE : HEADER_SIZE + length].decode("utf-8")) - except (_json.JSONDecodeError, UnicodeDecodeError): - return None - return msg_type, payload - -# Browser-resolution preference (mirror cdp-bridge-server.ts) -DEFAULT_BROWSER_PREFERENCE: list[str] = ["firefox", "safari", "edge", "chrome"] - -# How long an exclusive browser lease lasts unless explicitly extended. -DEFAULT_LEASE_TTL = 60.0 - - -# --------------------------------------------------------------------------- -# Client tracking -# --------------------------------------------------------------------------- - - -@dataclass -class ZapClient: - """A connected browser extension.""" - - client_id: str - browser: str - version: str - capabilities: list[str] - ws: Any # websockets.WebSocketServerProtocol — typed Any to avoid hard dep - connected_at: float = field(default_factory=time.time) - last_active: float = field(default_factory=time.time) - - def to_dict(self) -> dict: - return { - "client_id": self.client_id, - "browser": self.browser, - "version": self.version, - "capabilities": self.capabilities, - "connected_at": self.connected_at, - "last_active": self.last_active, - } - - -@dataclass -class BrowserLease: - """An exclusive lease on a browser client (sub-agent claim/release).""" - - client_id: str - holder: str - expires_at: float - - @property - def expired(self) -> bool: - return time.time() >= self.expires_at - - -# --------------------------------------------------------------------------- -# Server -# --------------------------------------------------------------------------- - - -class ZapServer: - """Single-port ZAP server hosted inside a hanzo-mcp process. - - One instance per Python MCP. Lifetime = MCP lifetime. Concurrent - extensions register and dispatch independently. - """ - - def __init__( - self, - host: str = "127.0.0.1", - agent_label: Optional[str] = None, - server_id: Optional[str] = None, - request_timeout: float = 30.0, - ): - self.host = host - self.agent_label = agent_label or os.environ.get("HANZO_AGENT_LABEL", "") - # server_id is `mcp-py--<4hex>`. PID alone collides across hosts on - # the same LAN; the random suffix makes the name globally unique so - # zeroconf never has to auto-rename to "(2)". - self.server_id = server_id or f"mcp-py-{os.getpid()}-{uuid.uuid4().hex[:4]}" - self.request_timeout = request_timeout - - self._port: Optional[int] = None - self._server: Any = None # websockets server - self._mdns_handle: Any = None - self._clients: dict[str, ZapClient] = {} - self._ws_to_id: dict[Any, str] = {} - self._pending: dict[str, asyncio.Future] = {} - self._req_counter = 0 - self._leases: dict[str, BrowserLease] = {} # client_id -> lease - self._tools_manifest: list[dict] = [ - { - "name": "browser", - "description": "Hanzo browser tool (Python MCP, ZAP-native)", - "inputSchema": {"type": "object"}, - } - ] - # Inbound RPC handler — set by hanzo-mcp at startup to expose its - # full tool surface to extensions over the same socket. Without it, - # incoming MSG_REQUEST calls get a noop ack (legacy behaviour). - self._request_handler: Optional[Callable[[str, dict], Awaitable[Any]]] = None - - # ---- lifecycle ------------------------------------------------------ - - async def start(self) -> Optional[int]: - """Bind to an OS-assigned port and advertise it via mDNS. - - Discovery is mDNS-only (HIP-0069); the OS picks the port, mDNS - carries it. No well-known port pool, no lockfile arbitration — - every MCP gets its own ephemeral port and is found by browsing - ``_hanzo._tcp.local.``. - - Returns the bound port, or ``None`` if either ``websockets`` or - ``zap-mdns`` is missing. - """ - try: - import websockets # noqa: F401 - except ImportError: - logger.warning("websockets not installed; ZAP server disabled") - return None - try: - import zap_mdns - except ImportError: - logger.error( - "zap-mdns not installed; the server is unreachable without it. " - "`pip install zap-mdns`." - ) - return None - - from websockets.asyncio.server import serve as _serve - - async def _handler(websocket): - await self._handle_connection(websocket) - - # Bind to port 0 → OS picks an ephemeral port. The actual port - # comes back via the server's sockets attribute. - self._server = await _serve(_handler, self.host, 0) - sockets = getattr(self._server, "sockets", None) or [] - if not sockets: - logger.error("zap: server has no sockets after start()") - return None - self._port = sockets[0].getsockname()[1] - logger.info( - "ZAP server listening on ws://%s:%d (mcp=%s, agent=%s)", - self.host, - self._port, - self.server_id, - self.agent_label or "?", - ) - - # mDNS publish — the only way clients find this server. - # zeroconf.register_service blocks briefly setting up multicast, - # so run it on a worker thread to avoid asyncio EventLoopBlocked. - try: - self._mdns_handle = await asyncio.to_thread( - zap_mdns.publish, - port=self._port, - server_id=self.server_id, - agent_label=self.agent_label or "", - version="zap/1", - capabilities=["mcp", "browser-bridge"], - # Advertise the bind address so the URL clients receive - # actually reaches this server. zap-mdns defaults to the - # outbound LAN IP via _local_ip(), which is wrong when we - # bind loopback (browser extension dials LAN IP → ECONNREFUSED). - host=self.host, - ) - logger.info("mDNS published %s on :%d", zap_mdns.SERVICE_TYPE, self._port) - except Exception as e: - logger.warning("mDNS publish failed: %s: %s", type(e).__name__, e) - self._mdns_handle = None - - return self._port - - async def stop(self) -> None: - """Gracefully shut down: retract mDNS, close clients, drop sockets.""" - # Retract mDNS announcement first so consumers see us go away. - if self._mdns_handle is not None: - try: - self._mdns_handle.close() - except Exception: - pass - self._mdns_handle = None - - if self._server is not None: - self._server.close() - try: - await self._server.wait_closed() - except Exception: - pass - self._server = None - - for client in list(self._clients.values()): - try: - await client.ws.close() - except Exception: - pass - self._clients.clear() - self._ws_to_id.clear() - self._port = None - - # ---- public API ----------------------------------------------------- - - def set_tools(self, tools: list[dict]) -> None: - """Replace the advertised tool manifest (sent to clients on handshake).""" - self._tools_manifest = list(tools) - - def set_request_handler( - self, handler: Optional[Callable[[str, dict], Awaitable[Any]]] - ) -> None: - """Register / replace the inbound RPC handler. Receives (method, params), - returns a JSON-serialisable result. Used by hanzo-mcp to expose its - full tool surface to extensions over the same socket.""" - self._request_handler = handler - - @property - def port(self) -> Optional[int]: - return self._port - - @property - def clients(self) -> list[ZapClient]: - return list(self._clients.values()) - - def has_client(self, browser: Optional[str] = None) -> bool: - if not self._clients: - return False - if not browser: - return True - b = browser.lower() - return any(b in c.browser.lower() for c in self._clients.values()) - - def resolve_client( - self, - client_id: Optional[str] = None, - browser: Optional[str] = None, - ) -> Optional[ZapClient]: - """Pick which connected extension to dispatch to. - - Priority: explicit client_id > browser preference > most-recent-active - > default browser preference list. - """ - if client_id and client_id in self._clients: - return self._clients[client_id] - - candidates = list(self._clients.values()) - if not candidates: - return None - - if browser: - b = browser.lower() - matches = [c for c in candidates if b in c.browser.lower()] - if not matches: - return None - return max(matches, key=lambda c: c.last_active) - - # No explicit selector: respect global default preference list. - for pref in DEFAULT_BROWSER_PREFERENCE: - matches = [c for c in candidates if pref in c.browser.lower()] - if matches: - return max(matches, key=lambda c: c.last_active) - - return max(candidates, key=lambda c: c.last_active) - - async def send( - self, - method: str, - params: Optional[dict] = None, - *, - browser: Optional[str] = None, - client_id: Optional[str] = None, - timeout: Optional[float] = None, - ) -> Any: - """Send a method request to a connected extension and await result. - - Raises ``RuntimeError`` if no client matches. - """ - client = self.resolve_client(client_id=client_id, browser=browser) - if client is None: - raise RuntimeError( - f"No ZAP-connected browser extension" - + (f" matching '{browser}'" if browser else "") - ) - - # Honour leases: if a different holder has a non-expired lease on this - # client, reject. - lease = self._leases.get(client.client_id) - if lease and not lease.expired and lease.holder != self.server_id: - raise RuntimeError( - f"browser leased by {lease.holder} until {time.ctime(lease.expires_at)}" - ) - - req_id = self._next_req_id() - future: asyncio.Future = asyncio.get_event_loop().create_future() - self._pending[req_id] = future - client.last_active = time.time() - - try: - await client.ws.send( - encode( - MSG_REQUEST, - {"id": req_id, "method": method, "params": params or {}}, - ) - ) - return await asyncio.wait_for( - future, timeout=timeout or self.request_timeout - ) - finally: - self._pending.pop(req_id, None) - - # ---- leases --------------------------------------------------------- - - def claim(self, client_id: str, ttl: float = DEFAULT_LEASE_TTL) -> BrowserLease: - """Take an exclusive lease on a browser client for ``ttl`` seconds. - - Raises ``RuntimeError`` if already held by someone else. - """ - lease = self._leases.get(client_id) - if lease and not lease.expired and lease.holder != self.server_id: - raise RuntimeError( - f"already leased by {lease.holder} until {time.ctime(lease.expires_at)}" - ) - new = BrowserLease( - client_id=client_id, - holder=self.server_id, - expires_at=time.time() + ttl, - ) - self._leases[client_id] = new - return new - - def release(self, client_id: str) -> bool: - """Release a lease this server holds. Returns True if released.""" - lease = self._leases.get(client_id) - if lease and lease.holder == self.server_id: - del self._leases[client_id] - return True - return False - - # ---- cluster discovery --------------------------------------------- - # Cross-MCP visibility comes from mDNS, not a shared file. Use - # ``zap_mdns.browse()`` to enumerate live MCPs on the LAN. - - @staticmethod - def list_mcp_instances(timeout: float = 1.5) -> list[dict]: - """Browse ``_hanzo._tcp.local.`` for every live ZAP service.""" - try: - import zap_mdns - except ImportError: - return [] - return [ - { - "server_id": s.server_id, - "host": s.host, - "port": s.port, - "url": s.url, - "agent_label": s.agent_label, - "version": s.version, - "capabilities": list(s.capabilities or []), - } - for s in zap_mdns.browse(timeout=timeout) - ] - - # ---- ws handler ----------------------------------------------------- - - async def _handle_connection(self, websocket: Any) -> None: - try: - async for raw in websocket: - if not isinstance(raw, (bytes, bytearray)): - # Spec is binary frames; ignore stray text. - continue - decoded = decode(bytes(raw)) - if decoded is None: - logger.debug("zap: malformed frame from %s", websocket) - continue - msg_type, payload = decoded - await self._dispatch(websocket, msg_type, payload or {}) - except Exception as e: - # websockets normalises connection-closed via exception flow; - # don't spam logs. - logger.debug("zap connection ended: %s", e) - finally: - cid = self._ws_to_id.pop(websocket, None) - if cid: - existing = self._clients.get(cid) - if existing is not None and existing.ws is websocket: - self._clients.pop(cid, None) - self._leases.pop(cid, None) - logger.info("zap: client disconnected %s", cid) - else: - logger.debug( - "zap: stale ws %s for client %s — newer connection holds the slot", - websocket, - cid, - ) - - async def _dispatch(self, websocket: Any, msg_type: int, payload: dict) -> None: - if msg_type == MSG_HANDSHAKE: - client_id = payload.get("clientId") or f"ext-{int(time.time() * 1000)}" - client = ZapClient( - client_id=client_id, - browser=payload.get("browser", "unknown"), - version=payload.get("version", "0"), - capabilities=list(payload.get("capabilities") or []), - ws=websocket, - ) - self._clients[client_id] = client - self._ws_to_id[websocket] = client_id - logger.info( - "zap: client connected %s (%s v%s, %d caps)", - client_id, - client.browser, - client.version, - len(client.capabilities), - ) - await websocket.send( - encode( - MSG_HANDSHAKE_OK, - { - "serverId": self.server_id, - "name": "hanzo-mcp", - "agentLabel": self.agent_label, - "tools": self._tools_manifest, - }, - ) - ) - return - - if msg_type == MSG_PING: - await websocket.send(encode(MSG_PONG, {})) - return - - if msg_type == MSG_PONG: - return - - # MSG_RESPONSE: extension is answering an RPC we sent. - if msg_type == MSG_RESPONSE: - req_id = payload.get("id") - future = self._pending.get(req_id) if req_id else None - if future is None or future.done(): - return - if "error" in payload and payload["error"]: - err = payload["error"] - msg = err.get("message") if isinstance(err, dict) else str(err) - future.set_exception(RuntimeError(msg or "ZAP error")) - else: - future.set_result(payload.get("result")) - return - - # MSG_REQUEST: extension is calling US — most commonly because the - # extension wants to invoke an MCP tool exposed by this server. Route - # via the registered request_handler (set by hanzo-mcp at startup). - # Without a handler, fall back to noop ack (legacy notifications). - if msg_type == MSG_REQUEST: - req_id = payload.get("id") - method = payload.get("method", "") - params = payload.get("params") or {} - cid = self._ws_to_id.get(websocket) - if cid and cid in self._clients: - self._clients[cid].last_active = time.time() - if req_id is None: - # Notification — no response expected. - return - handler = self._request_handler - if handler is None: - await websocket.send( - encode( - MSG_RESPONSE, - {"id": req_id, "result": {"ack": True, "method": method}}, - ) - ) - return - try: - result = await handler(method, params) - await websocket.send( - encode(MSG_RESPONSE, {"id": req_id, "result": result}) - ) - except Exception as e: - await websocket.send( - encode( - MSG_RESPONSE, - { - "id": req_id, - "error": { - "code": -1, - "message": f"{type(e).__name__}: {e}", - }, - }, - ) - ) - return - - logger.debug("zap: unknown msg type 0x%02x", msg_type) - - def _next_req_id(self) -> str: - self._req_counter += 1 - return f"py-{self._req_counter}" - - -# --------------------------------------------------------------------------- -# Process-wide singleton (one ZAP server per hanzo-mcp) -# --------------------------------------------------------------------------- - - -_singleton: Optional[ZapServer] = None -_singleton_lock = asyncio.Lock() - - -async def get_or_start_server( - *, - host: str = "127.0.0.1", - agent_label: Optional[str] = None, -) -> Optional[ZapServer]: - """Return the process-wide ZAP server, starting it if needed. - - Returns ``None`` if either ``websockets`` or ``zap-mdns`` is missing. - """ - global _singleton - async with _singleton_lock: - if _singleton is not None and _singleton.port is not None: - return _singleton - srv = ZapServer(host=host, agent_label=agent_label) - port = await srv.start() - if port is None: - return None - _singleton = srv - return srv - - -def get_server() -> Optional[ZapServer]: - """Return the current singleton (or None if not started).""" - return _singleton - - -async def shutdown_server() -> None: - global _singleton - async with _singleton_lock: - if _singleton is not None: - await _singleton.stop() - _singleton = None - - -__all__ = [ - "ZapClient", - "ZapServer", - "BrowserLease", - "DEFAULT_BROWSER_PREFERENCE", - "DEFAULT_LEASE_TTL", - "MSG_HANDSHAKE", - "MSG_HANDSHAKE_OK", - "MSG_REQUEST", - "MSG_RESPONSE", - "MSG_PING", - "MSG_PONG", - "ZAP_MAGIC", - "encode", - "decode", - "get_or_start_server", - "get_server", - "shutdown_server", -] diff --git a/pkg/hanzo-tools-browser/hanzo_tools/browser/zapd_consumer.py b/pkg/hanzo-tools-browser/hanzo_tools/browser/zapd_consumer.py new file mode 100644 index 000000000..13e72470a --- /dev/null +++ b/pkg/hanzo-tools-browser/hanzo_tools/browser/zapd_consumer.py @@ -0,0 +1,179 @@ +"""hanzo-mcp as a zapd consumer — native ZAP, no in-process server. + +The browser tool no longer hosts anything. It connects to the one shared local +router at ``~/.zap/run/zapd.sock`` as a *consumer*, lists providers, and routes +opaque commands to a ``browser:*`` provider. No WebSocket, no mDNS, no CDP +bridge, no :9224, no Playwright fallback for native-browser mode. + +The wire is the binary ZAP router envelope (mirrors zapd's ``frame.rs``); the +browser command payload is the compact binary codec shared with the extension. +""" +from __future__ import annotations + +import os +import socket +import struct +import threading +from typing import Optional + +# Envelope types (match frame.rs). +HELLO, WELCOME, PROVIDERS_LIST, PROVIDERS = 1, 2, 3, 4 +PEER_CONNECTED, PEER_DISCONNECTED, ERROR = 5, 6, 7 +ROUTE, RESPONSE, EVENT = 16, 17, 18 +ROLE_CONSUMER = 2 + + +def socket_path() -> str: + p = os.environ.get("ZAP_SOCK") + if p: + return p + xrd = os.environ.get("XDG_RUNTIME_DIR") + if xrd: + return os.path.join(xrd, "zap", "zapd.sock") + return os.path.expanduser("~/.zap/run/zapd.sock") + + +def _put_str(s: str) -> bytes: + b = s.encode() + return struct.pack(" bytes: + fb, tb = frm.encode(), to.encode() + body = struct.pack(" bytes: + b = bytes([role]) + _put_str(brand) + struct.pack(" bytes: + b = _put_str(method) + struct.pack(" bool: + if self._sock is not None: + return True + try: + s = socket.socket(socket.AF_UNIX) + s.settimeout(5) + s.connect(socket_path()) + s.sendall(_encode_frame(HELLO, self.agent_id, "", _hello_payload(ROLE_CONSUMER, "hanzo", []))) + self._sock = s + self._read_until(WELCOME) + return True + except OSError: + self._sock = None + return False + + def _recvn(self, n) -> bytes: + buf = b"" + while len(buf) < n: + c = self._sock.recv(n - len(buf)) + if not c: + raise EOFError("zapd closed") + buf += c + return buf + + def _read_frame(self) -> dict: + (length,) = struct.unpack(" dict: + for _ in range(100): + f = self._read_frame() + if f["t"] == want and (from_id is None or f["from"] == from_id): + return f + if f["t"] == ERROR: + raise RuntimeError(f["payload"].decode(errors="replace")) + raise TimeoutError(f"no frame type {want}") + + def list_providers(self, brand: str = "") -> list: + with self._lock: + if not self.connect(): + return [] + payload = _put_str(brand) if brand else b"" + self._sock.sendall(_encode_frame(PROVIDERS_LIST, self.agent_id, "", payload)) + f = self._read_until(PROVIDERS) + return _parse_providers(f["payload"]) + + def resolve_browser(self, browser: Optional[str], client_id: Optional[str]) -> Optional[str]: + provs = [p for p in self.list_providers() if p["id"].startswith("browser:")] + if client_id: + for p in provs: + if p["id"] == client_id: + return p["id"] + if browser: + for p in provs: + # match "browser:chrome/..." against browser="chrome" + if p["id"].split(":", 1)[1].split("/", 1)[0] == browser.lower(): + return p["id"] + return None + return provs[0]["id"] if provs else None + + def route(self, provider_id: str, method: str, params: dict, timeout: float = 30.0) -> bytes: + with self._lock: + if not self.connect(): + raise RuntimeError("zapd not reachable") + self._sock.settimeout(timeout) + self._sock.sendall(_encode_frame(ROUTE, self.agent_id, provider_id, _encode_cmd(method, params))) + f = self._read_until(RESPONSE, from_id=provider_id) + return f["payload"] + + def close(self): + if self._sock is not None: + try: + self._sock.close() + finally: + self._sock = None + + +_consumer: Optional[ZapdConsumer] = None + + +def get_consumer() -> Optional[ZapdConsumer]: + """Return the shared consumer if zapd is reachable, else None.""" + global _consumer + if _consumer is None: + _consumer = ZapdConsumer() + return _consumer if _consumer.connect() else None diff --git a/pkg/hanzo-tools-browser/pyproject.toml b/pkg/hanzo-tools-browser/pyproject.toml index a90873e6b..2003be08b 100644 --- a/pkg/hanzo-tools-browser/pyproject.toml +++ b/pkg/hanzo-tools-browser/pyproject.toml @@ -4,8 +4,8 @@ build-backend = "setuptools.build_meta" [project] name = "hanzo-tools-browser" -version = "0.5.7" -description = "Browser automation tools with ZAP-native extension routing — Python MCP hosts the ZAP server on an OS-assigned port and advertises via mDNS (HIP-0069). No node bridge, no port pool, no lockfile." +version = "0.5.8" +description = "Browser automation tools — zapd consumer. The MCP hosts no server; it routes CDP commands to the browser extension through the shared local zapd router (~/.zap/run/zapd.sock). No in-process server, no HTTP bridge, no mDNS, no port pool." readme = "README.md" requires-python = ">=3.12" license = { text = "MIT" } @@ -15,9 +15,6 @@ dependencies = [ "hanzo-tools>=0.3.0", "mcp>=1.25.0", "pydantic>=2.12.5", - "aiohttp>=3.9.0", - "websockets>=12.0", - "zap-mdns>=0.1.0", ] [project.optional-dependencies] diff --git a/pkg/hanzo-tools-browser/tests/test_browser_tools.py b/pkg/hanzo-tools-browser/tests/test_browser_tools.py index 0e5baae87..db5a2bb2e 100644 --- a/pkg/hanzo-tools-browser/tests/test_browser_tools.py +++ b/pkg/hanzo-tools-browser/tests/test_browser_tools.py @@ -37,3 +37,77 @@ def test_has_description(self, tool): "browser" in tool.description.lower() or "playwright" in tool.description.lower() ) + + +class TestCdpTool: + """Tests for the zapd-native `cdp` tool (method-oriented peer of browser).""" + + @pytest.fixture + def tool(self): + from hanzo_tools.browser.cdp_tool import CdpTool + + return CdpTool() + + def test_registered_in_tools(self): + from hanzo_tools.browser import TOOLS + from hanzo_tools.browser.cdp_tool import CdpTool + + assert CdpTool in TOOLS + + def test_name(self, tool): + assert tool.name == "cdp" + + @pytest.mark.asyncio + async def test_send_requires_method(self, tool): + result = await tool.execute(action="send") + assert "error" in result and "method" in result["error"] + + @pytest.mark.asyncio + async def test_zapd_unreachable_is_reported(self, tool, monkeypatch): + # No zapd → a clear native-zap error, never a stale "server not running". + monkeypatch.setattr( + "hanzo_tools.browser.cdp_tool.get_consumer", lambda: None + ) + result = await tool.execute(action="tabs") + assert result.get("transport") == "native-zap" + assert "zapd" in result["error"] + + @pytest.mark.asyncio + async def test_routes_bare_method_not_cdp_envelope(self, tool, monkeypatch): + """Regression: `cdp` must put the real CDP method on the wire. + + The old HTTP-bridge path sent {"action": "cdp", "method": ...} which the + extension dispatch rejected with "Unknown method: cdp". The zapd path + routes the method name verbatim. + """ + sent = {} + + class FakeConsumer: + def resolve_browser(self, browser, client_id): + return "browser:chrome/host/default" + + def route(self, provider, method, params, timeout=30.0): + sent["provider"] = provider + sent["method"] = method + sent["params"] = params + return b'{"targetInfos": []}' + + monkeypatch.setattr( + "hanzo_tools.browser.cdp_tool.get_consumer", lambda: FakeConsumer() + ) + + result = await tool.execute(action="tabs") + # The method on the wire is the real CDP method, never "cdp". + assert sent["method"] == "Target.getTargets" + assert sent["method"] != "cdp" + assert result["success"] is True + assert result["transport"] == "native-zap" + + result = await tool.execute(action="status") + assert sent["method"] == "Browser.getVersion" + + await tool.execute( + action="send", method="Page.navigate", params={"url": "https://example.com"} + ) + assert sent["method"] == "Page.navigate" + assert sent["params"]["url"] == "https://example.com" diff --git a/pkg/hanzo-tools-browser/tests/test_zap_bench.py b/pkg/hanzo-tools-browser/tests/test_zap_bench.py deleted file mode 100644 index e75dff0f3..000000000 --- a/pkg/hanzo-tools-browser/tests/test_zap_bench.py +++ /dev/null @@ -1,80 +0,0 @@ -"""Latency benchmarks for ZAP vs HTTP-bridge transports. - -This isn't strict pass/fail — it asserts a generous upper bound on ZAP -median round-trip and prints both numbers for comparison so PR reviewers -can see the win. -""" - -from __future__ import annotations - -import asyncio -import json -import statistics -import time -from typing import Callable - -import pytest - -from hanzo_tools.browser import zap_server as zs -from tests.test_zap_server import MockExtensionClient, _free_ports # type: ignore - - -pytestmark = pytest.mark.asyncio - - -def _percentile(values: list[float], p: float) -> float: - if not values: - return 0.0 - s = sorted(values) - idx = max(0, min(len(s) - 1, int(round(p / 100.0 * (len(s) - 1))))) - return s[idx] - - -async def _measure(label: str, n: int, op: Callable[[], asyncio.Future]) -> dict: - # warm-up - for _ in range(5): - await op() - samples: list[float] = [] - for _ in range(n): - t0 = time.perf_counter() - await op() - samples.append((time.perf_counter() - t0) * 1000.0) - return { - "label": label, - "n": n, - "min_ms": min(samples), - "p50_ms": statistics.median(samples), - "p95_ms": _percentile(samples, 95), - "max_ms": max(samples), - } - - -async def test_zap_round_trip_under_5ms_median(tmp_path, monkeypatch): - """ZAP `evaluate('1+1')` median round-trip must be sub-5ms locally.""" - monkeypatch.setenv("HOME", str(tmp_path)) - ports = _free_ports(5) - - srv = zs.ZapServer(ports=ports) - port = await srv.start() - assert port is not None - client = MockExtensionClient(port, browser="firefox", client_id="bench") - await client.connect() - await asyncio.sleep(0.02) - - # Extension responds immediately to evaluate("1+1") with the value. - client.response_handler = lambda m, p: {"result": {"type": "number", "value": 2}} - - try: - result = await _measure( - "zap", - n=200, - op=lambda: srv.send("Runtime.evaluate", {"expression": "1+1"}), - ) - # Print so CI logs surface the number. - print(f"\n[ZAP bench] {json.dumps(result, indent=2)}") - # Generous bound: 5ms p50 on local loopback is safe even on a - # busy laptop; production target is <1ms. - assert result["p50_ms"] < 5.0, f"ZAP p50 too high: {result}" - finally: - await client.close() - await srv.stop() diff --git a/pkg/hanzo-tools-browser/tests/test_zap_server.py b/pkg/hanzo-tools-browser/tests/test_zap_server.py deleted file mode 100644 index 2cd888d3b..000000000 --- a/pkg/hanzo-tools-browser/tests/test_zap_server.py +++ /dev/null @@ -1,436 +0,0 @@ -"""Tests for hanzo_tools.browser.zap_server. - -Covers: -- Wire format encode/decode round-trip and parity with shared/zap.ts. -- Server bind on OS-assigned port (HIP-0069: mDNS-only discovery). -- Mock extension client: register, send response to RPC, ping/pong. -- Browser resolution priority (client_id > browser > default preference). -- Browser leases (claim / release / reject when held by other holder). -""" - -from __future__ import annotations - -import asyncio -import json -import struct -import time - -import pytest -import websockets -from websockets.asyncio.client import connect as ws_connect - -from hanzo_tools.browser import zap_server as zs - -# --------------------------------------------------------------------------- -# Wire format -# --------------------------------------------------------------------------- - - -class TestWireFormat: - def test_magic(self): - assert zs.ZAP_MAGIC == b"\x5a\x41\x50\x01" - - def test_constants_match_extension(self): - # Must stay locked to shared/zap.ts canonical values. - assert zs.MSG_HANDSHAKE == 0x01 - assert zs.MSG_HANDSHAKE_OK == 0x02 - assert zs.MSG_REQUEST == 0x10 - assert zs.MSG_RESPONSE == 0x11 - assert zs.MSG_PING == 0xFE - assert zs.MSG_PONG == 0xFF - - def test_encode_layout(self): - from zap.protocol import HEADER_SIZE - - frame = zs.encode(zs.MSG_REQUEST, {"id": "x", "method": "y"}) - assert frame[:4] == zs.ZAP_MAGIC - assert frame[4] == zs.MSG_REQUEST - (length,) = struct.unpack(">I", frame[5:9]) - assert length == len(frame) - HEADER_SIZE - assert json.loads(frame[9:].decode()) == {"id": "x", "method": "y"} - - def test_round_trip(self): - for msg_type in ( - zs.MSG_HANDSHAKE, - zs.MSG_HANDSHAKE_OK, - zs.MSG_REQUEST, - zs.MSG_RESPONSE, - zs.MSG_PING, - zs.MSG_PONG, - ): - payload = {"a": 1, "b": [1, 2, 3], "c": {"nested": True}} - decoded = zs.decode(zs.encode(msg_type, payload)) - assert decoded is not None - assert decoded[0] == msg_type - assert decoded[1] == payload - - def test_decode_rejects_bad_magic(self): - bad = b"\xff\xff\xff\xff" + b"\x10" + struct.pack(">I", 0) - assert zs.decode(bad) is None - - def test_decode_rejects_short_frame(self): - assert zs.decode(b"") is None - assert zs.decode(b"\x5a\x41\x50") is None - - def test_decode_handles_empty_payload(self): - frame = zs.encode(zs.MSG_PING, {}) - decoded = zs.decode(frame) - assert decoded is not None and decoded[1] == {} - - -# --------------------------------------------------------------------------- -# Helpers — mock extension client -# --------------------------------------------------------------------------- - - -class MockExtensionClient: - """Minimal browser-extension client speaking ZAP wire format. - - Registers on connect, exposes ``response_handler`` so tests can react - to inbound MSG_REQUEST and reply with MSG_RESPONSE. - """ - - def __init__( - self, port: int, *, browser: str = "firefox", client_id: str | None = None - ): - self.port = port - self.browser = browser - self.client_id = client_id or f"ext-test-{int(time.time() * 1000)}" - self.ws: websockets.ClientConnection | None = None - self.received: list[tuple[int, dict]] = [] - self.handshake_ok: dict | None = None - self._task: asyncio.Task | None = None - self.response_handler: callable | None = None - - async def connect(self) -> None: - self.ws = await ws_connect(f"ws://127.0.0.1:{self.port}") - await self.ws.send( - zs.encode( - zs.MSG_HANDSHAKE, - { - "clientId": self.client_id, - "clientType": "browser_extension", - "browser": self.browser, - "version": "test-0.0", - "capabilities": ["navigate", "evaluate", "click"], - }, - ) - ) - # Wait for HANDSHAKE_OK - raw = await asyncio.wait_for(self.ws.recv(), timeout=2.0) - decoded = zs.decode(bytes(raw)) - assert decoded is not None and decoded[0] == zs.MSG_HANDSHAKE_OK - self.handshake_ok = decoded[1] - self._task = asyncio.create_task(self._run()) - - async def _run(self) -> None: - assert self.ws is not None - try: - async for raw in self.ws: - if not isinstance(raw, (bytes, bytearray)): - continue - decoded = zs.decode(bytes(raw)) - if decoded is None: - continue - msg_type, payload = decoded - self.received.append((msg_type, payload or {})) - if msg_type == zs.MSG_REQUEST and self.response_handler is not None: - method = payload.get("method") if payload else "" - params = payload.get("params") if payload else {} - req_id = payload.get("id") if payload else None - try: - result = self.response_handler(method, params) - if asyncio.iscoroutine(result): - result = await result - await self.ws.send( - zs.encode(zs.MSG_RESPONSE, {"id": req_id, "result": result}) - ) - except Exception as e: - await self.ws.send( - zs.encode( - zs.MSG_RESPONSE, - { - "id": req_id, - "error": {"code": -1, "message": str(e)}, - }, - ) - ) - elif msg_type == zs.MSG_PING: - await self.ws.send(zs.encode(zs.MSG_PONG, {})) - except Exception: - pass - - async def close(self) -> None: - if self._task: - self._task.cancel() - if self.ws: - await self.ws.close() - - -# --------------------------------------------------------------------------- -# Server fixtures -# --------------------------------------------------------------------------- - - -@pytest.fixture -async def server(): - """A live ZapServer on an OS-assigned port. mDNS publish is best-effort - — if zap-mdns is unavailable the start() returns None, and the test - is skipped (CI without zeroconf shouldn't hard-fail).""" - srv = zs.ZapServer() - port = await srv.start() - if port is None: - pytest.skip("ZapServer.start() returned None — zap-mdns/websockets missing") - yield srv - await srv.stop() - - -@pytest.fixture -async def client(server): - c = MockExtensionClient(server.port) - await c.connect() - # Allow the server to register the connection - await asyncio.sleep(0.05) - yield c - await c.close() - - -# --------------------------------------------------------------------------- -# Bind / shutdown -# --------------------------------------------------------------------------- - - -class TestServerLifecycle: - async def test_binds_on_ephemeral_port(self): - srv = zs.ZapServer() - port = await srv.start() - if port is None: - pytest.skip("zap-mdns/websockets missing") - try: - assert isinstance(port, int) and port > 0 - assert srv.port == port - finally: - await srv.stop() - - async def test_two_servers_get_distinct_ports(self): - a = zs.ZapServer() - b = zs.ZapServer() - try: - port_a = await a.start() - port_b = await b.start() - if port_a is None or port_b is None: - pytest.skip("zap-mdns/websockets missing") - assert port_a != port_b - finally: - await a.stop() - await b.stop() - - async def test_stop_clears_port(self): - srv = zs.ZapServer() - port = await srv.start() - if port is None: - pytest.skip("zap-mdns/websockets missing") - await srv.stop() - assert srv.port is None - - -# --------------------------------------------------------------------------- -# Handshake / client registry -# --------------------------------------------------------------------------- - - -class TestClientRegistry: - async def test_handshake_registers_client(self, server, client): - assert server.has_client() - assert server.has_client(browser="firefox") - assert not server.has_client(browser="chrome") - assert len(server.clients) == 1 - assert server.clients[0].browser == "firefox" - assert server.clients[0].client_id == client.client_id - - async def test_handshake_ok_includes_server_id_and_tools(self, server, client): - assert client.handshake_ok is not None - assert client.handshake_ok["serverId"] == server.server_id - assert isinstance(client.handshake_ok["tools"], list) - assert any(t["name"] == "browser" for t in client.handshake_ok["tools"]) - - async def test_disconnect_removes_client(self, server): - c = MockExtensionClient(server.port) - await c.connect() - await asyncio.sleep(0.05) - assert server.has_client() - await c.close() - # Allow server cleanup - await asyncio.sleep(0.1) - assert not server.has_client() - - -# --------------------------------------------------------------------------- -# RPC dispatch -# --------------------------------------------------------------------------- - - -class TestRpcDispatch: - async def test_send_round_trips_to_extension(self, server, client): - async def handler(method, params): - assert method == "Page.navigate" - assert params == {"url": "https://example.com"} - return {"frameId": "main"} - - client.response_handler = handler - result = await server.send("Page.navigate", {"url": "https://example.com"}) - assert result == {"frameId": "main"} - - async def test_extension_error_propagates(self, server, client): - async def handler(method, params): - raise RuntimeError("nope") - - client.response_handler = handler - with pytest.raises(RuntimeError, match="nope"): - await server.send("hanzo.click", {"selector": "#x"}) - - async def test_send_with_browser_filter(self, server): - c1 = MockExtensionClient(server.port, browser="firefox", client_id="ff-1") - c2 = MockExtensionClient(server.port, browser="chrome", client_id="ch-1") - await c1.connect() - await c2.connect() - await asyncio.sleep(0.05) - - c1.response_handler = lambda m, p: "from-firefox" - c2.response_handler = lambda m, p: "from-chrome" - - try: - assert await server.send("any", {}, browser="firefox") == "from-firefox" - assert await server.send("any", {}, browser="chrome") == "from-chrome" - finally: - await c1.close() - await c2.close() - - async def test_no_match_raises(self, server, client): - # client is firefox; ask for chrome - with pytest.raises(RuntimeError, match="No ZAP-connected"): - await server.send("any", {}, browser="chrome") - - async def test_ping_pong(self, server, client): - # Send ping and verify server echoes pong; just ensure no crash. - await client.ws.send(zs.encode(zs.MSG_PING, {})) - # Receive pong - await asyncio.sleep(0.05) - # Server replies with MSG_PONG (we caught it in client.received via _run) - types = [t for t, _ in client.received] - assert zs.MSG_PONG in types - - async def test_extension_initiated_request_acked(self, server, client): - # Extension sends MSG_REQUEST as a notification (server must ack) - await client.ws.send( - zs.encode( - zs.MSG_REQUEST, - { - "id": "evt-1", - "method": "notifications/elementSelected", - "params": {"x": 1}, - }, - ) - ) - await asyncio.sleep(0.1) - # Find the response - responses = [p for t, p in client.received if t == zs.MSG_RESPONSE] - assert any(r.get("id") == "evt-1" for r in responses) - - -# --------------------------------------------------------------------------- -# Resolution priority -# --------------------------------------------------------------------------- - - -class TestResolution: - async def test_resolve_explicit_client_id(self, server): - c1 = MockExtensionClient(server.port, browser="firefox", client_id="ff-1") - c2 = MockExtensionClient(server.port, browser="firefox", client_id="ff-2") - await c1.connect() - await c2.connect() - await asyncio.sleep(0.05) - try: - picked = server.resolve_client(client_id="ff-2") - assert picked is not None and picked.client_id == "ff-2" - finally: - await c1.close() - await c2.close() - - async def test_default_prefers_firefox(self, server): - chrome = MockExtensionClient(server.port, browser="chrome", client_id="ch") - firefox = MockExtensionClient(server.port, browser="firefox", client_id="ff") - await chrome.connect() - await firefox.connect() - await asyncio.sleep(0.05) - try: - picked = server.resolve_client() - assert picked is not None and "firefox" in picked.browser - finally: - await chrome.close() - await firefox.close() - - -# --------------------------------------------------------------------------- -# Leases -# --------------------------------------------------------------------------- - - -class TestLeases: - async def test_claim_and_release(self, server, client): - lease = server.claim(client.client_id, ttl=10) - assert lease.client_id == client.client_id - assert lease.holder == server.server_id - assert server.release(client.client_id) is True - - async def test_claim_rejects_when_held_by_other(self, server, client): - # First grab from server - server.claim(client.client_id, ttl=10) - # Synthesise a different "holder" by mutating server_id; this is the - # closest we can get without spinning up a second ZapServer. - original = server.server_id - try: - server.server_id = "other-mcp" - with pytest.raises(RuntimeError, match="already leased"): - server.claim(client.client_id, ttl=10) - finally: - server.server_id = original - - async def test_release_only_works_for_holder(self, server, client): - server.claim(client.client_id, ttl=10) - original = server.server_id - try: - server.server_id = "other-mcp" - assert server.release(client.client_id) is False - finally: - server.server_id = original - - async def test_send_blocked_by_other_holder(self, server, client): - # Hold with a different holder - from hanzo_tools.browser.zap_server import BrowserLease - - server._leases[client.client_id] = BrowserLease( - client_id=client.client_id, - holder="someone-else", - expires_at=time.time() + 30, - ) - with pytest.raises(RuntimeError, match="leased by"): - await server.send("any", {}) - - -# --------------------------------------------------------------------------- -# Cluster discovery (mDNS — best-effort, may be empty in CI) -# --------------------------------------------------------------------------- - - -class TestClusterDiscovery: - async def test_list_mcp_instances_returns_list(self, server): - # mDNS browse may return [] in containers without multicast routing. - # We just assert the call shape is correct. - instances = zs.ZapServer.list_mcp_instances(timeout=0.5) - assert isinstance(instances, list) - for entry in instances: - assert "server_id" in entry - assert "host" in entry - assert "port" in entry - assert "url" in entry diff --git a/pyproject.toml b/pyproject.toml index c0d41e444..948ff22b7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -485,11 +485,15 @@ members = [ "pkg/hanzo-memory", "pkg/hanzo-s3", "pkg/hanzo-tools-ui", + "pkg/hanzo-tools-browser", ] [tool.uv.sources] hanzo-memory = { workspace = true } hanzo-tools-ui = { workspace = true } +# Browser tool routes through the local zapd router; the on-disk source is the +# single truth (not a published wheel) so the MCP and the repo never diverge. +hanzo-tools-browser = { workspace = true } [tool.ruff.lint.extend-per-file-ignores] # Allow print statements in scripts and examples diff --git a/uv.lock b/uv.lock index 5e3364888..9160f6a5a 100644 --- a/uv.lock +++ b/uv.lock @@ -11,6 +11,7 @@ resolution-markers = [ members = [ "hanzo-memory", "hanzo-s3", + "hanzo-tools-browser", "hanzo-tools-ui", "hanzoai", ] @@ -320,6 +321,74 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/8e/0d/52d98722666d6fc6c3dd4c76df339501d6efd40e0ff95e6186a7b7f0befd/black-26.3.1-py3-none-any.whl", hash = "sha256:2bd5aa94fc267d38bb21a70d7410a89f1a1d318841855f698746f8e7f51acd1b", size = 207542, upload-time = "2026-03-12T03:36:01.668Z" }, ] +[[package]] +name = "blake3" +version = "1.0.8" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/75/aa/abcd75e9600987a0bc6cfe9b6b2ff3f0e2cb08c170addc6e76035b5c4cb3/blake3-1.0.8.tar.gz", hash = "sha256:513cc7f0f5a7c035812604c2c852a0c1468311345573de647e310aca4ab165ba", size = 117308, upload-time = "2025-10-14T06:47:48.83Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ed/a0/b7b6dff04012cfd6e665c09ee446f749bd8ea161b00f730fe1bdecd0f033/blake3-1.0.8-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:d8da4233984d51471bd4e4366feda1d90d781e712e0a504ea54b1f2b3577557b", size = 347983, upload-time = "2025-10-14T06:45:47.214Z" }, + { url = "https://files.pythonhosted.org/packages/5b/a2/264091cac31d7ae913f1f296abc20b8da578b958ffb86100a7ce80e8bf5c/blake3-1.0.8-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1257be19f2d381c868a34cc822fc7f12f817ddc49681b6d1a2790bfbda1a9865", size = 325415, upload-time = "2025-10-14T06:45:48.482Z" }, + { url = "https://files.pythonhosted.org/packages/ee/7d/85a4c0782f613de23d114a7a78fcce270f75b193b3ff3493a0de24ba104a/blake3-1.0.8-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:269f255b110840e52b6ce9db02217e39660ebad3e34ddd5bca8b8d378a77e4e1", size = 371296, upload-time = "2025-10-14T06:45:49.674Z" }, + { url = "https://files.pythonhosted.org/packages/e3/20/488475254976ed93fab57c67aa80d3b40df77f7d9db6528c9274bff53e08/blake3-1.0.8-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:66ca28a673025c40db3eba21a9cac52f559f83637efa675b3f6bd8683f0415f3", size = 374516, upload-time = "2025-10-14T06:45:51.23Z" }, + { url = "https://files.pythonhosted.org/packages/7b/21/2a1c47fedb77fb396512677ec6d46caf42ac6e9a897db77edd0a2a46f7bb/blake3-1.0.8-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:bcb04966537777af56c1f399b35525aa70a1225816e121ff95071c33c0f7abca", size = 447911, upload-time = "2025-10-14T06:45:52.637Z" }, + { url = "https://files.pythonhosted.org/packages/cb/7d/db0626df16029713e7e61b67314c4835e85c296d82bd907c21c6ea271da2/blake3-1.0.8-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e5b5da177d62cc4b7edf0cea08fe4dec960c9ac27f916131efa890a01f747b93", size = 505420, upload-time = "2025-10-14T06:45:54.445Z" }, + { url = "https://files.pythonhosted.org/packages/5b/55/6e737850c2d58a6d9de8a76dad2ae0f75b852a23eb4ecb07a0b165e6e436/blake3-1.0.8-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:38209b10482c97e151681ea3e91cc7141f56adbbf4820a7d701a923124b41e6a", size = 394189, upload-time = "2025-10-14T06:45:55.719Z" }, + { url = "https://files.pythonhosted.org/packages/5b/94/eafaa5cdddadc0c9c603a6a6d8339433475e1a9f60c8bb9c2eed2d8736b6/blake3-1.0.8-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:504d1399b7fb91dfe5c25722d2807990493185faa1917456455480c36867adb5", size = 388001, upload-time = "2025-10-14T06:45:57.067Z" }, + { url = "https://files.pythonhosted.org/packages/17/81/735fa00d13de7f68b25e1b9cb36ff08c6f165e688d85d8ec2cbfcdedccc5/blake3-1.0.8-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:c84af132aa09abeadf9a0118c8fb26f4528f3f42c10ef8be0fcf31c478774ec4", size = 550302, upload-time = "2025-10-14T06:45:58.657Z" }, + { url = "https://files.pythonhosted.org/packages/0e/c6/d1fe8bdea4a6088bd54b5a58bc40aed89a4e784cd796af7722a06f74bae7/blake3-1.0.8-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:a25db3d36b55f5ed6a86470155cc749fc9c5b91c949b8d14f48658f9d960d9ec", size = 554211, upload-time = "2025-10-14T06:46:00.269Z" }, + { url = "https://files.pythonhosted.org/packages/55/d1/ca74aa450cbe10e396e061f26f7a043891ffa1485537d6b30d3757e20995/blake3-1.0.8-cp312-cp312-win32.whl", hash = "sha256:e0fee93d5adcd44378b008c147e84f181f23715307a64f7b3db432394bbfce8b", size = 228343, upload-time = "2025-10-14T06:46:01.533Z" }, + { url = "https://files.pythonhosted.org/packages/4d/42/bbd02647169e3fbed27558555653ac2578c6f17ccacf7d1956c58ef1d214/blake3-1.0.8-cp312-cp312-win_amd64.whl", hash = "sha256:6a6eafc29e4f478d365a87d2f25782a521870c8514bb43734ac85ae9be71caf7", size = 215704, upload-time = "2025-10-14T06:46:02.79Z" }, + { url = "https://files.pythonhosted.org/packages/55/b8/11de9528c257f7f1633f957ccaff253b706838d22c5d2908e4735798ec01/blake3-1.0.8-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:46dc20976bd6c235959ef0246ec73420d1063c3da2839a9c87ca395cf1fd7943", size = 347771, upload-time = "2025-10-14T06:46:04.248Z" }, + { url = "https://files.pythonhosted.org/packages/50/26/f7668be55c909678b001ecacff11ad7016cd9b4e9c7cc87b5971d638c5a9/blake3-1.0.8-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:d17eb6382634b3a5bc0c0e0454d5265b0becaeeadb6801ed25150b39a999d0cc", size = 325431, upload-time = "2025-10-14T06:46:06.136Z" }, + { url = "https://files.pythonhosted.org/packages/77/57/e8a85fa261894bf7ce7af928ff3408aab60287ab8d58b55d13a3f700b619/blake3-1.0.8-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:19fc6f2b7edab8acff6895fc6e38c19bd79f4c089e21153020c75dfc7397d52d", size = 370994, upload-time = "2025-10-14T06:46:07.398Z" }, + { url = "https://files.pythonhosted.org/packages/62/cd/765b76bb48b8b294fea94c9008b0d82b4cfa0fa2f3c6008d840d01a597e4/blake3-1.0.8-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:4f54cff7f15d91dc78a63a2dd02a3dccdc932946f271e2adb4130e0b4cf608ba", size = 374372, upload-time = "2025-10-14T06:46:08.698Z" }, + { url = "https://files.pythonhosted.org/packages/36/7a/32084eadbb28592bb07298f0de316d2da586c62f31500a6b1339a7e7b29b/blake3-1.0.8-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e7e12a777f6b798eb8d06f875d6e108e3008bd658d274d8c676dcf98e0f10537", size = 447627, upload-time = "2025-10-14T06:46:10.002Z" }, + { url = "https://files.pythonhosted.org/packages/a7/f4/3788a1d86e17425eea147e28d7195d7053565fc279236a9fd278c2ec495e/blake3-1.0.8-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ddfc59b0176fb31168f08d5dd536e69b1f4f13b5a0f4b0c3be1003efd47f9308", size = 507536, upload-time = "2025-10-14T06:46:11.614Z" }, + { url = "https://files.pythonhosted.org/packages/fe/01/4639cba48513b94192681b4da472cdec843d3001c5344d7051ee5eaef606/blake3-1.0.8-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a2336d5b2a801a7256da21150348f41610a6c21dae885a3acb1ebbd7333d88d8", size = 394105, upload-time = "2025-10-14T06:46:12.808Z" }, + { url = "https://files.pythonhosted.org/packages/21/ae/6e55c19c8460fada86cd1306a390a09b0c5a2e2e424f9317d2edacea439f/blake3-1.0.8-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e4072196547484c95a5a09adbb952e9bb501949f03f9e2a85e7249ef85faaba8", size = 386928, upload-time = "2025-10-14T06:46:16.284Z" }, + { url = "https://files.pythonhosted.org/packages/ee/6c/05b7a5a907df1be53a8f19e7828986fc6b608a44119641ef9c0804fbef15/blake3-1.0.8-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:0eab3318ec02f8e16fe549244791ace2ada2c259332f0c77ab22cf94dfff7130", size = 550003, upload-time = "2025-10-14T06:46:17.791Z" }, + { url = "https://files.pythonhosted.org/packages/b4/03/f0ea4adfedc1717623be6460b3710fcb725ca38082c14274369803f727e1/blake3-1.0.8-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:a33b9a1fb6d1d559a8e0d04b041e99419a6bb771311c774f6ff57ed7119c70ed", size = 553857, upload-time = "2025-10-14T06:46:19.088Z" }, + { url = "https://files.pythonhosted.org/packages/cc/6f/e5410d2e2a30c8aba8389ffc1c0061356916bf5ecd0a210344e7b69b62ab/blake3-1.0.8-cp313-cp313-win32.whl", hash = "sha256:e171b169cb7ea618e362a4dddb7a4d4c173bbc08b9ba41ea3086dd1265530d4f", size = 228315, upload-time = "2025-10-14T06:46:20.391Z" }, + { url = "https://files.pythonhosted.org/packages/79/ef/d9c297956dfecd893f29f59e7b22445aba5b47b7f6815d9ba5dcd73fcae6/blake3-1.0.8-cp313-cp313-win_amd64.whl", hash = "sha256:3168c457255b5d2a2fc356ba696996fcaff5d38284f968210d54376312107662", size = 215477, upload-time = "2025-10-14T06:46:21.542Z" }, + { url = "https://files.pythonhosted.org/packages/20/ba/eaa7723d66dd8ab762a3e85e139bb9c46167b751df6e950ad287adb8fb61/blake3-1.0.8-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:b4d672c24dc15ec617d212a338a4ca14b449829b6072d09c96c63b6e6b621aed", size = 347289, upload-time = "2025-10-14T06:46:22.772Z" }, + { url = "https://files.pythonhosted.org/packages/47/b3/6957f6ee27f0d5b8c4efdfda68a1298926a88c099f4dd89c711049d16526/blake3-1.0.8-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:1af0e5a29aa56d4fba904452ae784740997440afd477a15e583c38338e641f41", size = 324444, upload-time = "2025-10-14T06:46:24.729Z" }, + { url = "https://files.pythonhosted.org/packages/13/da/722cebca11238f3b24d3cefd2361c9c9ea47cfa0ad9288eeb4d1e0b7cf93/blake3-1.0.8-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ef153c5860d5bf1cc71aece69b28097d2a392913eb323d6b52555c875d0439fc", size = 370441, upload-time = "2025-10-14T06:46:26.29Z" }, + { url = "https://files.pythonhosted.org/packages/2e/d5/2f7440c8e41c0af995bad3a159e042af0f4ed1994710af5b4766ca918f65/blake3-1.0.8-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e8ae3689f0c7bfa6ce6ae45cab110e4c3442125c4c23b28f1f097856de26e4d1", size = 374312, upload-time = "2025-10-14T06:46:27.451Z" }, + { url = "https://files.pythonhosted.org/packages/a6/6c/fb6a7812e60ce3e110bcbbb11f167caf3e975c589572c41e1271f35f2c41/blake3-1.0.8-cp313-cp313t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3fb83532f7456ddeb68dae1b36e1f7c52f9cb72852ac01159bbcb1a12b0f8be0", size = 447007, upload-time = "2025-10-14T06:46:29.056Z" }, + { url = "https://files.pythonhosted.org/packages/13/3b/c99b43fae5047276ea9d944077c190fc1e5f22f57528b9794e21f7adedc6/blake3-1.0.8-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6ae7754c7d96e92a70a52e07c732d594cf9924d780f49fffd3a1e9235e0f5ba7", size = 507323, upload-time = "2025-10-14T06:46:30.661Z" }, + { url = "https://files.pythonhosted.org/packages/fc/bb/ba90eddd592f8c074a0694cb0a744b6bd76bfe67a14c2b490c8bdfca3119/blake3-1.0.8-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4bacaae75e98dee3b7da6c5ee3b81ee21a3352dd2477d6f1d1dbfd38cdbf158a", size = 393449, upload-time = "2025-10-14T06:46:31.805Z" }, + { url = "https://files.pythonhosted.org/packages/25/ed/58a2acd0b9e14459cdaef4344db414d4a36e329b9720921b442a454dd443/blake3-1.0.8-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9456c829601d72852d8ba0af8dae0610f7def1d59f5942efde1e2ef93e8a8b57", size = 386844, upload-time = "2025-10-14T06:46:33.195Z" }, + { url = "https://files.pythonhosted.org/packages/4a/04/fed09845b18d90862100c8e48308261e2f663aab25d3c71a6a0bdda6618b/blake3-1.0.8-cp313-cp313t-musllinux_1_1_aarch64.whl", hash = "sha256:497ef8096ec4ac1ffba9a66152cee3992337cebf8ea434331d8fd9ce5423d227", size = 549550, upload-time = "2025-10-14T06:46:35.23Z" }, + { url = "https://files.pythonhosted.org/packages/d6/65/1859fddfabc1cc72548c2269d988819aad96d854e25eae00531517925901/blake3-1.0.8-cp313-cp313t-musllinux_1_1_x86_64.whl", hash = "sha256:511133bab85ff60ed143424ce484d08c60894ff7323f685d7a6095f43f0c85c3", size = 553805, upload-time = "2025-10-14T06:46:36.532Z" }, + { url = "https://files.pythonhosted.org/packages/c1/c7/2969352017f62378e388bb07bb2191bc9a953f818dc1cd6b9dd5c24916e1/blake3-1.0.8-cp313-cp313t-win32.whl", hash = "sha256:9c9fbdacfdeb68f7ca53bb5a7a5a593ec996eaf21155ad5b08d35e6f97e60877", size = 228068, upload-time = "2025-10-14T06:46:37.826Z" }, + { url = "https://files.pythonhosted.org/packages/d8/fc/923e25ac9cadfff1cd20038bcc0854d0f98061eb6bc78e42c43615f5982d/blake3-1.0.8-cp313-cp313t-win_amd64.whl", hash = "sha256:3cec94ed5676821cf371e9c9d25a41b4f3ebdb5724719b31b2749653b7cc1dfa", size = 215369, upload-time = "2025-10-14T06:46:39.054Z" }, + { url = "https://files.pythonhosted.org/packages/2e/2a/9f13ea01b03b1b4751a1cc2b6c1ef4b782e19433a59cf35b59cafb2a2696/blake3-1.0.8-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:2c33dac2c6112bc23f961a7ca305c7e34702c8177040eb98d0389d13a347b9e1", size = 347016, upload-time = "2025-10-14T06:46:40.318Z" }, + { url = "https://files.pythonhosted.org/packages/06/8e/8458c4285fbc5de76414f243e4e0fcab795d71a8b75324e14959aee699da/blake3-1.0.8-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c445eff665d21c3b3b44f864f849a2225b1164c08654beb23224a02f087b7ff1", size = 324496, upload-time = "2025-10-14T06:46:42.355Z" }, + { url = "https://files.pythonhosted.org/packages/49/fa/b913eb9cc4af708c03e01e6b88a8bb3a74833ba4ae4b16b87e2829198e06/blake3-1.0.8-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a47939f04b89c5c6ff1e51e883e5efab1ea1bf01a02f4d208d216dddd63d0dd8", size = 370654, upload-time = "2025-10-14T06:46:43.907Z" }, + { url = "https://files.pythonhosted.org/packages/7f/4f/245e0800c33b99c8f2b570d9a7199b51803694913ee4897f339648502933/blake3-1.0.8-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:73e0b4fa25f6e3078526a592fb38fca85ef204fd02eced6731e1cdd9396552d4", size = 374693, upload-time = "2025-10-14T06:46:45.186Z" }, + { url = "https://files.pythonhosted.org/packages/a2/a6/8cb182c8e482071dbdfcc6ec0048271fd48bcb78782d346119ff54993700/blake3-1.0.8-cp314-cp314-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4b0543c57eb9d6dac9d4bced63e9f7f7b546886ac04cec8da3c3d9c8f30cbbb7", size = 447673, upload-time = "2025-10-14T06:46:46.358Z" }, + { url = "https://files.pythonhosted.org/packages/06/b7/1cbbb5574d2a9436d1b15e7eb5b9d82e178adcaca71a97b0fddaca4bfe3a/blake3-1.0.8-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ed972ebd553c0c25363459e9fc71a38c045d8419e365b59acd8cd791eff13981", size = 507233, upload-time = "2025-10-14T06:46:48.109Z" }, + { url = "https://files.pythonhosted.org/packages/9c/45/b55825d90af353b3e26c653bab278da9d6563afcf66736677f9397e465be/blake3-1.0.8-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3bafdec95dfffa3f6571e529644744e280337df15ddd9728f224ba70c5779b23", size = 393852, upload-time = "2025-10-14T06:46:49.511Z" }, + { url = "https://files.pythonhosted.org/packages/34/73/9058a1a457dd20491d1b37de53d6876eff125e1520d9b2dd7d0acbc88de2/blake3-1.0.8-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2d78f06f3fb838b34c330e2987090376145cbe5944d8608a0c4779c779618f7b", size = 386442, upload-time = "2025-10-14T06:46:51.205Z" }, + { url = "https://files.pythonhosted.org/packages/30/6d/561d537ffc17985e276e08bf4513f1c106f1fdbef571e782604dc4e44070/blake3-1.0.8-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:dd03ff08d1b6e4fdda1cd03826f971ae8966ef6f683a8c68aa27fb21904b5aa9", size = 549929, upload-time = "2025-10-14T06:46:52.494Z" }, + { url = "https://files.pythonhosted.org/packages/03/2f/dbe20d2c57f1a67c63be4ba310bcebc707b945c902a0bde075d2a8f5cd5c/blake3-1.0.8-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:4e02a3c499e35bf51fc15b2738aca1a76410804c877bcd914752cac4f71f052a", size = 553750, upload-time = "2025-10-14T06:46:54.194Z" }, + { url = "https://files.pythonhosted.org/packages/6b/da/c6cb712663c869b2814870c2798e57289c4268c5ac5fb12d467fce244860/blake3-1.0.8-cp314-cp314-win32.whl", hash = "sha256:a585357d5d8774aad9ffc12435de457f9e35cde55e0dc8bc43ab590a6929e59f", size = 228404, upload-time = "2025-10-14T06:46:56.807Z" }, + { url = "https://files.pythonhosted.org/packages/dc/b6/c7dcd8bc3094bba1c4274e432f9e77a7df703532ca000eaa550bd066b870/blake3-1.0.8-cp314-cp314-win_amd64.whl", hash = "sha256:9ab5998e2abd9754819753bc2f1cf3edf82d95402bff46aeef45ed392a5468bf", size = 215460, upload-time = "2025-10-14T06:46:58.15Z" }, + { url = "https://files.pythonhosted.org/packages/75/3c/6c8afd856c353176836daa5cc33a7989e8f54569e9d53eb1c53fc8f80c34/blake3-1.0.8-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:e2df12f295f95a804338bd300e8fad4a6f54fd49bd4d9c5893855a230b5188a8", size = 347482, upload-time = "2025-10-14T06:47:00.189Z" }, + { url = "https://files.pythonhosted.org/packages/6a/35/92cd5501ce8e1f5cabdc0c3ac62d69fdb13ff0b60b62abbb2b6d0a53a790/blake3-1.0.8-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:63379be58438878eeb76ebe4f0efbeaabf42b79f2cff23b6126b7991588ced67", size = 324376, upload-time = "2025-10-14T06:47:01.413Z" }, + { url = "https://files.pythonhosted.org/packages/11/33/503b37220a3e2e31917ef13722efd00055af51c5e88ae30974c733d7ece6/blake3-1.0.8-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:88d527c247f9609dc1d45a08fd243e39f0d5300d54c57e048de24d4fa9240ebb", size = 370220, upload-time = "2025-10-14T06:47:02.573Z" }, + { url = "https://files.pythonhosted.org/packages/3e/df/fe817843adf59516c04d44387bd643b422a3b0400ea95c6ede6a49920737/blake3-1.0.8-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:506a47897a11ebe8f3cdeb52f1365d6a2f83959e98ccb0c830f8f73277d4d358", size = 373454, upload-time = "2025-10-14T06:47:03.784Z" }, + { url = "https://files.pythonhosted.org/packages/d1/4d/90a2a623575373dfc9b683f1bad1bf017feafa5a6d65d94fb09543050740/blake3-1.0.8-cp314-cp314t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e5122a61b3b004bbbd979bdf83a3aaab432da3e2a842d7ddf1c273f2503b4884", size = 447102, upload-time = "2025-10-14T06:47:04.958Z" }, + { url = "https://files.pythonhosted.org/packages/93/ff/4e8ce314f60115c4c657b1fdbe9225b991da4f5bcc5d1c1f1d151e2f39d6/blake3-1.0.8-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0171e85d56dec1219abdae5f49a0ed12cb3f86a454c29160a64fd8a8166bba37", size = 506791, upload-time = "2025-10-14T06:47:06.82Z" }, + { url = "https://files.pythonhosted.org/packages/44/88/2963a1f18aab52bdcf35379b2b48c34bbc462320c37e76960636b8602c36/blake3-1.0.8-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:003f61e8c41dd9931edddf1cc6a1bb680fb2ac0ad15493ef4a1df9adc59ce9df", size = 393717, upload-time = "2025-10-14T06:47:09.085Z" }, + { url = "https://files.pythonhosted.org/packages/45/d1/a848ed8e8d4e236b9b16381768c9ae99d92890c24886bb4505aa9c3d2033/blake3-1.0.8-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d2c3151955efb09ba58cd3e1263521e15e9e3866a40d6bd3556d86fc968e8f95", size = 386150, upload-time = "2025-10-14T06:47:10.363Z" }, + { url = "https://files.pythonhosted.org/packages/96/09/e3eb5d60f97c01de23d9f434e6e1fc117efb466eaa1f6ddbbbcb62580d6e/blake3-1.0.8-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:5eb25bca3cee2e0dd746a214784fb36be6a43640c01c55b6b4e26196e72d076c", size = 549120, upload-time = "2025-10-14T06:47:11.713Z" }, + { url = "https://files.pythonhosted.org/packages/14/ad/3d9661c710febb8957dd685fdb3e5a861aa0ac918eda3031365ce45789e2/blake3-1.0.8-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:ab4e1dea4fa857944944db78e8f20d99ee2e16b2dea5a14f514fb0607753ac83", size = 553264, upload-time = "2025-10-14T06:47:13.317Z" }, + { url = "https://files.pythonhosted.org/packages/11/55/e332a5b49edf377d0690e95951cca21a00c568f6e37315f9749efee52617/blake3-1.0.8-cp314-cp314t-win32.whl", hash = "sha256:67f1bc11bf59464ef092488c707b13dd4e872db36e25c453dfb6e0c7498df9f1", size = 228116, upload-time = "2025-10-14T06:47:14.516Z" }, + { url = "https://files.pythonhosted.org/packages/b0/5c/dbd00727a3dd165d7e0e8af40e630cd7e45d77b525a3218afaff8a87358e/blake3-1.0.8-cp314-cp314t-win_amd64.whl", hash = "sha256:421b99cdf1ff2d1bf703bc56c454f4b286fce68454dd8711abbcb5a0df90c19a", size = 215133, upload-time = "2025-10-14T06:47:16.069Z" }, +] + [[package]] name = "build" version = "1.4.0" @@ -1067,6 +1136,73 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f7/ec/67fbef5d497f86283db54c22eec6f6140243aae73265799baaaa19cd17fb/ghp_import-2.1.0-py3-none-any.whl", hash = "sha256:8337dd7b50877f163d4c0289bc1f1c7f127550241988d568c1db512c4324a619", size = 11034, upload-time = "2022-05-02T15:47:14.552Z" }, ] +[[package]] +name = "greenlet" +version = "3.5.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6d/6e/802acd792aebb2256fbbee8cacf2727faaeb6f240ac11008f09eae4414bc/greenlet-3.5.1.tar.gz", hash = "sha256:5a56aeb7d5d9cc4b3a735efb5095bd4b4f6f0e4f93e5ca876d0e2315137b7829", size = 197356, upload-time = "2026-05-20T15:05:03.917Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c4/37/4549f149c9797c21b32c2683c33522af22522099de128b2406672526d005/greenlet-3.5.1-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:fa4f98af3a528f0c3fd592a26df7f376f93329c8f4d987f6bb979057af8bf5e2", size = 286220, upload-time = "2026-05-20T13:07:28.463Z" }, + { url = "https://files.pythonhosted.org/packages/38/ff/a4f436709716965eaab9f36ea7b906c8a927fbe32fb1372a2071d964f6b1/greenlet-3.5.1-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ffea73584b216150eab159b6d12348fb253e68757974de1e2c40d8a318ac89ed", size = 601585, upload-time = "2026-05-20T14:00:06.141Z" }, + { url = "https://files.pythonhosted.org/packages/65/ad/54bc3fcee3ad368a61b19b67d88117f7a8c29727bf71fffdeda81fbd946e/greenlet-3.5.1-cp312-cp312-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1072b4f9edcc1e192d9283a66a3e68d6b84c561de33a83d7858beb9ba1effe10", size = 614215, upload-time = "2026-05-20T14:05:42.675Z" }, + { url = "https://files.pythonhosted.org/packages/7c/6c/de5b1b388cd2d9fbdfeab324863daba37d54e6e233ddbefd70b385a8c591/greenlet-3.5.1-cp312-cp312-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:89101bfd5011e069be974903cb3a4e4523845e4ece2d62dcd8d358933c0ef249", size = 620094, upload-time = "2026-05-20T14:09:09.18Z" }, + { url = "https://files.pythonhosted.org/packages/40/69/b91cda0647df839483201545913514c2827ebea5e5ccdf931842763bc127/greenlet-3.5.1-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:add5217d68b31130f0beca584d7fef4878327d2e31642b66618a14eef312b63b", size = 611358, upload-time = "2026-05-20T13:14:26.37Z" }, + { url = "https://files.pythonhosted.org/packages/4a/43/1204baffab8a6476464795a7ccf394a3248d4f22c9f87173a15b36b6d971/greenlet-3.5.1-cp312-cp312-manylinux_2_39_riscv64.whl", hash = "sha256:e6cd99ea59dd5d89f0c956606571d79bfe6f68c9eb7f4a4083a41a7f1587edee", size = 422782, upload-time = "2026-05-20T14:01:39.597Z" }, + { url = "https://files.pythonhosted.org/packages/59/90/3cf77e080350cd02fa307bb2abf05df48f4482c240275bbd2c203ba8bb1c/greenlet-3.5.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a5ea42a752d47a145eae922b605cd1634665ac3d5ec1e72402d5048e8d60d207", size = 1570475, upload-time = "2026-05-20T14:02:25.29Z" }, + { url = "https://files.pythonhosted.org/packages/65/2c/18cece62045e74598c3c393f70dce4a63f56222015ba29a5d4eeb04f764c/greenlet-3.5.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:c5551170cf4f5ff5623e9af81323751979fee2c731e2287b61f73cd27257b823", size = 1635625, upload-time = "2026-05-20T13:14:34.027Z" }, + { url = "https://files.pythonhosted.org/packages/30/f5/310d104ddf41eb5a70f4c268d22508dfb0c3c8e86fec152be34d0d2ed819/greenlet-3.5.1-cp312-cp312-win_amd64.whl", hash = "sha256:3c8bb982ad117d29478ef8f5533e97df21f1e2befd17a299257b0c96d1371c0b", size = 238791, upload-time = "2026-05-20T13:10:39.018Z" }, + { url = "https://files.pythonhosted.org/packages/62/90/ceca11f504cd23a8047a3dea31919adc48df9b626dd0c13f0d858734fdfd/greenlet-3.5.1-cp312-cp312-win_arm64.whl", hash = "sha256:80eb4b04dadc4e67df3fae179a32c4706a3f495bc7f22fc8a81115d5f5512188", size = 235580, upload-time = "2026-05-20T13:08:45.056Z" }, + { url = "https://files.pythonhosted.org/packages/27/69/7f7e5372d998b81001899b1c0823c957aa413ba0f2662e65821611cc31e4/greenlet-3.5.1-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:51518ff74664078fc51bffcc6fc529b0df5ae58da192691cee765d45ce944a2b", size = 285060, upload-time = "2026-05-20T13:08:51.899Z" }, + { url = "https://files.pythonhosted.org/packages/b1/bf/387f9b6b865fd2ae0d0be09e0004827295a01b71be76ed350dd1e28a91a4/greenlet-3.5.1-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1ffdb3c0bb002c99cd8f298957e046c3dbf6006b5b7cdf11a4e19194624a0a0a", size = 604370, upload-time = "2026-05-20T14:00:07.492Z" }, + { url = "https://files.pythonhosted.org/packages/32/f5/169ce3d4e4c67291bd18f8cbe0299c9f3e45102c7f1fb3c14780c93e4532/greenlet-3.5.1-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7715a5a2c3378ba602c3a440558261e13a820bb53a82693aacd7b7f6d964e283", size = 616987, upload-time = "2026-05-20T14:05:44.237Z" }, + { url = "https://files.pythonhosted.org/packages/19/ba/c24110c55dffa55aa6e1d98b45310da33801aeba7686ff0190fe5d46fd32/greenlet-3.5.1-cp313-cp313-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d40a890035c0058cadbdc4af7569800fd28a0e527a0fdbb7b5f9418f176846ce", size = 622911, upload-time = "2026-05-20T14:09:10.598Z" }, + { url = "https://files.pythonhosted.org/packages/ee/e5/7f2e41d5273be07e77560d61ea4e56485b4d6c316d2a84518c62d1364061/greenlet-3.5.1-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:dc71ff466927a201b08305acac451ebe1aedfcea002f62f1f2f2ac2ac1e6a135", size = 613911, upload-time = "2026-05-20T13:14:27.539Z" }, + { url = "https://files.pythonhosted.org/packages/ec/7b/d20db2e8a5ad6c038702f3179b136f93f0a3d1a21a0c0777f3e470cdf4b2/greenlet-3.5.1-cp313-cp313-manylinux_2_39_riscv64.whl", hash = "sha256:67821bb03e4e98664490edb787ff6af501194c29bbee0f5c1dfdcf1dc3d9d436", size = 425228, upload-time = "2026-05-20T14:01:40.837Z" }, + { url = "https://files.pythonhosted.org/packages/c5/a4/fbdc67579b73615a1f91615e814303cc71e06128f7baaba87be79b8fb90c/greenlet-3.5.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:cd443683db272ebaaca03af98c0b063ab30db70ea8a31a1559f35e3f7b744ccd", size = 1570689, upload-time = "2026-05-20T14:02:27.225Z" }, + { url = "https://files.pythonhosted.org/packages/e6/b4/77abbe35078be39718a46cd49caf16bceb35662f97a34101dca28aa98e47/greenlet-3.5.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:089fff7a6ce8d9316d1f65ebc00273a56be258c1725b32b94de90a3a979557e1", size = 1635602, upload-time = "2026-05-20T13:14:36.344Z" }, + { url = "https://files.pythonhosted.org/packages/37/f7/129f27ca700845b8ee8ca88ce7f43435a1239c2eddb7677fc938822762cf/greenlet-3.5.1-cp313-cp313-win_amd64.whl", hash = "sha256:110a1ca7b49b014b097f6078272c3f4ed31af45b254de5228b79adba879f6af9", size = 238683, upload-time = "2026-05-20T13:11:50.57Z" }, + { url = "https://files.pythonhosted.org/packages/6d/5c/a485a36e87df8d8fd0632ee01511244f5156a20ed3746cc6599340326395/greenlet-3.5.1-cp313-cp313-win_arm64.whl", hash = "sha256:f16ba1efc0715b680a18b8123d90dad887c6112ae3555b4b5c32c149540c6b4e", size = 235499, upload-time = "2026-05-20T13:12:42.028Z" }, + { url = "https://files.pythonhosted.org/packages/8a/cb/c62454606daf5640369c94d8a9dd540599b1bfc090e2d2180cb77f4038d2/greenlet-3.5.1-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:d8ab31c9de8651a2facdd5c5bb0011f2380dd1a7af78ce2adf4b56095294fc07", size = 285579, upload-time = "2026-05-20T13:08:56.396Z" }, + { url = "https://files.pythonhosted.org/packages/ec/71/c4270398c2eba968a6071af1dfbdcaeee6ec1c24bc8b435b8cc452700da6/greenlet-3.5.1-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5e300185139abc337ade480c327183adf42a875ac7181bfe66d7d4efea31fbea", size = 651106, upload-time = "2026-05-20T14:00:09.448Z" }, + { url = "https://files.pythonhosted.org/packages/1a/ab/71e34b78a44ec271fb5f550c17bc46d301ddc5953890d935f270b0dcdb5a/greenlet-3.5.1-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7ffdb990dcaa0234cf9845aead5df2e3c3a8b6507d409274dd87e0d5ab05ffc2", size = 663478, upload-time = "2026-05-20T14:05:45.88Z" }, + { url = "https://files.pythonhosted.org/packages/c6/2d/2d80842910da44f78c286532d084b8a5c3717c844ae80ceb3858738ae89a/greenlet-3.5.1-cp314-cp314-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:6c09df69dc1712d131332054a858a3e5cca400967fa3a672e2324fbb0971448c", size = 667767, upload-time = "2026-05-20T14:09:12.15Z" }, + { url = "https://files.pythonhosted.org/packages/77/96/4efd6fa5c62c85426a0c19077a586258ebc3a2a146ff2493e4312a697a22/greenlet-3.5.1-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2f82b3597e9d83b63408affed0b48fd0f54935edac4302237b9a837be0dae33c", size = 660800, upload-time = "2026-05-20T13:14:29.129Z" }, + { url = "https://files.pythonhosted.org/packages/e9/d3/dad2eecedfbb1ed7050a20dcfae40c1442b74bc7423608be2c7e03ee7133/greenlet-3.5.1-cp314-cp314-manylinux_2_39_riscv64.whl", hash = "sha256:a4764e0bfc6a4d114c865b32520805c16a990ef5f286a514413b05d5ecd6a23d", size = 470786, upload-time = "2026-05-20T14:01:42.064Z" }, + { url = "https://files.pythonhosted.org/packages/7a/e0/6c71401a25cac7000261304e866a2f2cc04dc74810d40e2f118aa4799495/greenlet-3.5.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c0141e37414c10164e702b8fb1473304221ad98f71600850c6ef7ff4880feba0", size = 1617518, upload-time = "2026-05-20T14:02:28.662Z" }, + { url = "https://files.pythonhosted.org/packages/41/26/c5c06643e8c0af9e7bf18e16cb51d0ab7625155f0392e1c9015d66d556cd/greenlet-3.5.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:50ae25a67bea74ea41fb14b960bc532df73eb713417b2d61892dced82fe8d3bc", size = 1681593, upload-time = "2026-05-20T13:14:39.417Z" }, + { url = "https://files.pythonhosted.org/packages/8a/bd/e11a108317485075e68af9d23039619b86b28130c3b50d227d42edece64b/greenlet-3.5.1-cp314-cp314-win_amd64.whl", hash = "sha256:8a17c42330e261299766b75ac1ea32caa437a9453c8f65d16a13140db378ecd3", size = 239800, upload-time = "2026-05-20T13:09:30.128Z" }, + { url = "https://files.pythonhosted.org/packages/47/f8/8e8e8417b7bf28639a5a56356ef934d0375e1d0c70a57e04d7701e870ffe/greenlet-3.5.1-cp314-cp314-win_arm64.whl", hash = "sha256:7b5f5fae05b8ac6d176a61b60c394a8cbdc2b5b91b81793066e68745cf165e54", size = 236862, upload-time = "2026-05-20T13:09:10.498Z" }, + { url = "https://files.pythonhosted.org/packages/90/12/41bf27fde4d3605d3773ae57751eda182b8be2f5398011c041173b1d9534/greenlet-3.5.1-cp314-cp314t-macosx_11_0_universal2.whl", hash = "sha256:ea8da1e900d758d078810d4255d8c6aa572181896a31ec79d779eb79c3adc9ad", size = 293637, upload-time = "2026-05-20T13:12:35.529Z" }, + { url = "https://files.pythonhosted.org/packages/44/44/ba14b23e9757707050c2f397d305bbcae62e5d7cad122f8b6baec5ae4a1f/greenlet-3.5.1-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a19570c52a21420dcbc94e661994bc325c0b5b11304540fed514586da5dc8f2e", size = 650840, upload-time = "2026-05-20T14:00:11.079Z" }, + { url = "https://files.pythonhosted.org/packages/a8/37/5ddc2b686a6844f91abecef43411842426da2e1573f60b49ecf2547f4ae1/greenlet-3.5.1-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3d955c89b75eeca4723d7cc14135f393cd47c32e2a6cb4a8e4c6e760a26b0986", size = 656416, upload-time = "2026-05-20T14:05:47.118Z" }, + { url = "https://files.pythonhosted.org/packages/8c/46/5987dcd1a2570ba84f3b187536b2ca3ae97613387e57f5cfa99df068fe5e/greenlet-3.5.1-cp314-cp314t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ea37d5a157eb9493820d3792ac4ece28619a394391d2b9f2f78057d396ff0f0f", size = 656607, upload-time = "2026-05-20T14:09:13.949Z" }, + { url = "https://files.pythonhosted.org/packages/e1/f0/d17510297c35a2992712f0bf84de3779749999f7d3d63aa1f09db7c62dbe/greenlet-3.5.1-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:de2daaaebd1a5aa88c49045b6baf9310b3263796bd88db713edf37cf53e7bb4e", size = 654397, upload-time = "2026-05-20T13:14:30.696Z" }, + { url = "https://files.pythonhosted.org/packages/2c/c1/6da0a9ddcc29d7e51ef14883fa3dc1e53b3f4ffba00582106c7bf55da1d8/greenlet-3.5.1-cp314-cp314t-manylinux_2_39_riscv64.whl", hash = "sha256:8d8a23250ea3ec7b36de8fa4b541e9e2db3ee82915cc060ab0631609ad8b28de", size = 488287, upload-time = "2026-05-20T14:01:43.143Z" }, + { url = "https://files.pythonhosted.org/packages/37/eb/147387705bb89092645b012586e7273cb5ed3c90ef7eaf3a69173eaf0209/greenlet-3.5.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:3bfbd69cc349e43bf3a8ae1c85548ff0718efc887615c2db16c3833d7b0b072d", size = 1614469, upload-time = "2026-05-20T14:02:30.192Z" }, + { url = "https://files.pythonhosted.org/packages/a6/4e/37ee0da7732b7aa9896f17e15579a9df34b9fcb9dd494f0adfa749af6623/greenlet-3.5.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:4378720dd888136c27215a0214d32a4d37c3852765d45bc37aad0623423cfd78", size = 1675115, upload-time = "2026-05-20T13:14:40.972Z" }, + { url = "https://files.pythonhosted.org/packages/57/f3/97dfcf4a6eb5077f8a672234216fb5923eb89f2cab7081cb10b2cf75b605/greenlet-3.5.1-cp314-cp314t-win_amd64.whl", hash = "sha256:45718441607f9325d948db98cbc691276059316d0358c188c246da4e1d4d23d2", size = 245246, upload-time = "2026-05-20T13:12:22.646Z" }, + { url = "https://files.pythonhosted.org/packages/5d/73/d7f72e34b582f694f4a9b248162db7b09cc458a259ba8f0c0bfa1a34ea7d/greenlet-3.5.1-cp315-cp315-macosx_11_0_universal2.whl", hash = "sha256:2baee5ca02031757ffe8cc3d69f0cc0aec7065ce362622da74f32d3bcab1c541", size = 285575, upload-time = "2026-05-20T13:12:07.043Z" }, + { url = "https://files.pythonhosted.org/packages/df/59/fa9c6e87dc8ad27a95dabe2f29f372b733d05a8a67470f6c901ed9975655/greenlet-3.5.1-cp315-cp315-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9b1ec3274918a81d3ea778b9e75b56b72b33f300edb6cf7f3a7fe1dae56683de", size = 656428, upload-time = "2026-05-20T14:00:12.556Z" }, + { url = "https://files.pythonhosted.org/packages/f6/f9/e753408871eaa61dfe35e619cfc67512b036fde99893685d50eea9e07146/greenlet-3.5.1-cp315-cp315-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:111e2390ffffc47d5840b01711dd7fac07d4c09283d0283e7f3264b14e284c64", size = 667064, upload-time = "2026-05-20T14:05:48.662Z" }, + { url = "https://files.pythonhosted.org/packages/dc/74/807a047255bf1e09303627c46dc043dca596b6958a354d904f32ab382005/greenlet-3.5.1-cp315-cp315-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:10a9a1c0bfbc93d41156ffcb90c75fbc05544054faf15dcc1fdf9765f8b607f0", size = 672962, upload-time = "2026-05-20T14:09:15.532Z" }, + { url = "https://files.pythonhosted.org/packages/96/27/5565b5b40389f1c7753003a07e21892fda8660926787036d5bc0308b8113/greenlet-3.5.1-cp315-cp315-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e630136e905fe5ff43e86945ae41220b6d1470956a39220e708110ac48d01ea5", size = 665697, upload-time = "2026-05-20T13:14:32.943Z" }, + { url = "https://files.pythonhosted.org/packages/76/32/19d4e13225193c29b13e308015223f7d75fd3d8623d49dd19040d2ce8ec1/greenlet-3.5.1-cp315-cp315-manylinux_2_39_riscv64.whl", hash = "sha256:ef08c1567c78074b22d1a200183d52d04a14df447bf70bcbb6a3507a48e776fc", size = 476047, upload-time = "2026-05-20T14:01:44.39Z" }, + { url = "https://files.pythonhosted.org/packages/cf/82/e7de4178c0c2d1c9a5a3be3cc0b33e46a85b3ee4a77c071bf7ad8600e079/greenlet-3.5.1-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:975eac34b44a7077ca4d421348455b94f0f518246a7f14bc6d2fdcfe5b584368", size = 1621256, upload-time = "2026-05-20T14:02:31.91Z" }, + { url = "https://files.pythonhosted.org/packages/00/10/f2dddcf7dacac17dfc68691809589adad06135eb28930429cf58a6467a2f/greenlet-3.5.1-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:9ab3c3a0b2ae6198e67c898dad5215a49f9ae0d0081b3c3ec59f333e39eeca26", size = 1685956, upload-time = "2026-05-20T13:14:42.55Z" }, + { url = "https://files.pythonhosted.org/packages/22/17/4a232b32133230ada52f70e9d7f5b65b0caef8772f01849bd8d149e7e4ca/greenlet-3.5.1-cp315-cp315-win_amd64.whl", hash = "sha256:cbfc69be86e10dcfef5b1e6269d1d6926552aa89ee39e1de3353360c1b6989ab", size = 239802, upload-time = "2026-05-20T13:13:15.481Z" }, + { url = "https://files.pythonhosted.org/packages/c2/ae/4e623a7e6d4d2a5f4cb8e4c82de4169fc637942caae68d6e676b8a128ac5/greenlet-3.5.1-cp315-cp315-win_arm64.whl", hash = "sha256:92fd6d44ac5e5a887c8a5dc4a8ba0ba908527c31c12f78c6bc7dcfe8aab279f6", size = 236853, upload-time = "2026-05-20T13:15:37.301Z" }, + { url = "https://files.pythonhosted.org/packages/7a/57/816d9cff29119da3505b3d6a5e14a8af89006ac36f47f891ff293ee05af1/greenlet-3.5.1-cp315-cp315t-macosx_11_0_universal2.whl", hash = "sha256:a6fdf2433a5441ef9a95464f7c3e674775da1c8c1177fff311cee1acad4626ed", size = 293877, upload-time = "2026-05-20T13:10:19.078Z" }, + { url = "https://files.pythonhosted.org/packages/23/a1/59b0a7c7d140ff1a75626680b9a9899b79a9176cab298b394968fb023295/greenlet-3.5.1-cp315-cp315t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7546556f0d649f99f6a361098a55f761181bb2ea12ff150bb16d26092ad88244", size = 655333, upload-time = "2026-05-20T14:00:14.758Z" }, + { url = "https://files.pythonhosted.org/packages/72/1b/5efe127597625042218939d01855109f352779050768b670b52edcc16a6c/greenlet-3.5.1-cp315-cp315t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d5ee3ea898009fa898f85f9982255d35278c477bebe185beca249cab42d4526c", size = 659443, upload-time = "2026-05-20T14:05:50.159Z" }, + { url = "https://files.pythonhosted.org/packages/c9/9d/1dcdf7b95ab3cf8c7b6d7277c18a5e167312f2b362ddfcc5d5e6d8d84b43/greenlet-3.5.1-cp315-cp315t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a57b0d05a0448eed231d59c0ceb287dde984551e54cbc51ac2d4865712838e9c", size = 659998, upload-time = "2026-05-20T14:09:16.912Z" }, + { url = "https://files.pythonhosted.org/packages/6c/6d/c404246ea4d22d097a7426d0efb5b781bd7eb67715f09e79001bd552ab18/greenlet-3.5.1-cp315-cp315t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a5c81f74d204d3edd136ebfd50dce53acbb776995d721a0fe801626cfc93b8cd", size = 658356, upload-time = "2026-05-20T13:14:35.091Z" }, + { url = "https://files.pythonhosted.org/packages/05/7e/c4959664fc231d587d66d8e81f2095e98056ba1954beafdcbe635e251052/greenlet-3.5.1-cp315-cp315t-manylinux_2_39_riscv64.whl", hash = "sha256:b0703c2cef53e01baec47f7a3868009913ad71ec678bbecb42a6f40895e4ce62", size = 494470, upload-time = "2026-05-20T14:01:45.611Z" }, + { url = "https://files.pythonhosted.org/packages/51/02/f8ee37fb6d2219329f350af241c27fcf12df57e723d11f6fc6d3bacdadaa/greenlet-3.5.1-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:2c18ef16bf6d4dd410e4dd52996888ea1497be26892fe5bbc73580aba4287b8e", size = 1619216, upload-time = "2026-05-20T14:02:33.403Z" }, + { url = "https://files.pythonhosted.org/packages/93/c5/3dc9475ace2c7a3680da12372cddd7f1ac874eb410a1ac48d3e9dab83782/greenlet-3.5.1-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:17d86354f0ae6b61bf9be5148d0dd34e06c3cb7c602c671f79f29ac3b150e659", size = 1678427, upload-time = "2026-05-20T13:14:43.71Z" }, + { url = "https://files.pythonhosted.org/packages/df/4e/750c15c317a41ffb36f0bf40b933e3d744a7dede61889f74443ea69690cf/greenlet-3.5.1-cp315-cp315t-win_amd64.whl", hash = "sha256:e7516cf6ae6b8a582c2770a0caed47b8a48373ed732c33d69a72913ae6ac923e", size = 245225, upload-time = "2026-05-20T13:13:59.366Z" }, + { url = "https://files.pythonhosted.org/packages/4f/fd/d3baea2eeb7b617efd47e87ca06e2ec2c6118d303aa9e918e0ce16eadc10/greenlet-3.5.1-cp315-cp315t-win_arm64.whl", hash = "sha256:5028648bf2253ec4745add746129d3904121fa7fe871a76bed23c5720573ce0a", size = 239590, upload-time = "2026-05-20T13:13:37.382Z" }, +] + [[package]] name = "griffelib" version = "2.0.0" @@ -1113,6 +1249,7 @@ name = "hanzo-memory" version = "1.0.1" source = { editable = "pkg/hanzo-memory" } dependencies = [ + { name = "blake3" }, { name = "httpx" }, { name = "mcp" }, { name = "numpy" }, @@ -1157,6 +1294,7 @@ test = [ [package.metadata] requires-dist = [ { name = "black", marker = "extra == 'dev'", specifier = ">=24.10.0" }, + { name = "blake3", specifier = ">=0.4.0" }, { name = "factory-boy", marker = "extra == 'test'", specifier = ">=3.3.0" }, { name = "faker", marker = "extra == 'test'", specifier = ">=30.0.0" }, { name = "httpx", specifier = ">=0.28.0" }, @@ -1229,6 +1367,30 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/5c/07/6ebbcf371aafa5f2d171de2916ef92c73978b927b34a8863af53e1b1a80b/hanzo_tools-0.3.0-py3-none-any.whl", hash = "sha256:c7b0f6f7c3089f06329bc1aaca39fbce4b7108fdbd048e2bbc450a3aff9941f2", size = 11928, upload-time = "2025-12-27T18:56:37.528Z" }, ] +[[package]] +name = "hanzo-tools-browser" +version = "0.5.8" +source = { editable = "pkg/hanzo-tools-browser" } +dependencies = [ + { name = "hanzo-tools" }, + { name = "mcp" }, + { name = "pydantic" }, +] + +[package.optional-dependencies] +playwright = [ + { name = "playwright" }, +] + +[package.metadata] +requires-dist = [ + { name = "hanzo-tools", specifier = ">=0.3.0" }, + { name = "mcp", specifier = ">=1.25.0" }, + { name = "playwright", marker = "extra == 'playwright'", specifier = ">=1.49.0" }, + { name = "pydantic", specifier = ">=2.12.5" }, +] +provides-extras = ["playwright"] + [[package]] name = "hanzo-tools-core" version = "0.3.0" @@ -1279,7 +1441,7 @@ provides-extras = ["server", "dev"] [[package]] name = "hanzoai" -version = "2.2.0" +version = "2.2.1" source = { editable = "." } dependencies = [ { name = "anyio" }, @@ -1326,7 +1488,7 @@ requires-dist = [ { name = "h11", specifier = ">=0.16.0" }, { name = "hanzo-llm", marker = "extra == 'llm'", specifier = ">=1.0.0" }, { name = "httpx", specifier = ">=0.23.0,<1" }, - { name = "pydantic", specifier = ">=1.9.0,<3" }, + { name = "pydantic", specifier = ">=2.10,<3" }, { name = "sniffio" }, { name = "typing-extensions", specifier = ">=4.10,<5" }, { name = "urllib3", specifier = ">=2.6.0" }, @@ -2708,6 +2870,25 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/63/d7/97f7e3a6abb67d8080dd406fd4df842c2be0efaf712d1c899c32a075027c/platformdirs-4.9.4-py3-none-any.whl", hash = "sha256:68a9a4619a666ea6439f2ff250c12a853cd1cbd5158d258bd824a7df6be2f868", size = 21216, upload-time = "2026-03-05T18:34:12.172Z" }, ] +[[package]] +name = "playwright" +version = "1.60.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "greenlet" }, + { name = "pyee" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/21/f0/832bd9677194908da118064eef20082f2791e3d18215cc6d9391ee2c5a67/playwright-1.60.0-py3-none-macosx_10_13_x86_64.whl", hash = "sha256:6a8cd0fec171fb3089e95e898c8bc8a6f35dea0b78b399e12fcc19427e91b1d7", size = 43474635, upload-time = "2026-05-18T12:00:31.969Z" }, + { url = "https://files.pythonhosted.org/packages/59/7b/e1d32ae8a3ed937ec2be3721c5f728b13d731a0b7c6442e0b3bec5094ac0/playwright-1.60.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:39b5420ba6145045b69ced4c5c47d4d9fe5bddfc8ff816c518913afcb25ec7a5", size = 42261327, upload-time = "2026-05-18T12:00:35.638Z" }, + { url = "https://files.pythonhosted.org/packages/d7/bc/23de499ded6411c188a20c5a0dea6f0cd4ed5d2b3cc6042a5dbd3ed609aa/playwright-1.60.0-py3-none-macosx_11_0_universal2.whl", hash = "sha256:2581d0e6a3392c71f91b27460c7fd093356818dc430f48153896c8aeeaef7705", size = 43474636, upload-time = "2026-05-18T12:00:39.294Z" }, + { url = "https://files.pythonhosted.org/packages/22/7b/1d679f4fced4ea94efadd17103856d8c565384f68382a1681264e46f5925/playwright-1.60.0-py3-none-manylinux1_x86_64.whl", hash = "sha256:1c2bfae7884fb3fb05b853290eab8f343d524e5016f2f1def702acbbdf14c93e", size = 47467220, upload-time = "2026-05-18T12:00:43.179Z" }, + { url = "https://files.pythonhosted.org/packages/84/c2/1528d267d4442bd2c6b8eaeab819dd52c2030bf80e89293f0ba1f687473b/playwright-1.60.0-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:43e66564125ee31b07a58cefb21e256d62d67d8d1713e6858df7a3019d8ed353", size = 47154856, upload-time = "2026-05-18T12:00:46.715Z" }, + { url = "https://files.pythonhosted.org/packages/bb/4e/b008b6440a7a1624378041da94829956d4b8f7ab9ef5aad22d0dc3f2e26d/playwright-1.60.0-py3-none-win32.whl", hash = "sha256:ec94e416ea320711e0ad4bf185dcbf41833672961e90773e1885255d7db7b7e7", size = 37902157, upload-time = "2026-05-18T12:00:50.374Z" }, + { url = "https://files.pythonhosted.org/packages/55/f0/0541524133104f9cc20bf900870ff4a736b76a23483f3a55295ddfa58409/playwright-1.60.0-py3-none-win_amd64.whl", hash = "sha256:9566821ce6030a1f9e7146a24e19355ab0d98805fd0f9be50bb3d8fef1750c02", size = 37902159, upload-time = "2026-05-18T12:00:53.728Z" }, + { url = "https://files.pythonhosted.org/packages/80/c8/210f282d278e4709cdd71b12a31af45a30a22ab3207b387e29b37e478713/playwright-1.60.0-py3-none-win_arm64.whl", hash = "sha256:6e4f6700a4c2250efff8e690a81d66e3855754fb587b6b87cf5c784014f91537", size = 34037981, upload-time = "2026-05-18T12:00:57.584Z" }, +] + [[package]] name = "pluggy" version = "1.6.0" @@ -3087,6 +3268,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/00/4b/ccc026168948fec4f7555b9164c724cf4125eac006e176541483d2c959be/pydantic_settings-2.13.1-py3-none-any.whl", hash = "sha256:d56fd801823dbeae7f0975e1f8c8e25c258eb75d278ea7abb5d9cebb01b56237", size = 58929, upload-time = "2026-02-19T13:45:06.034Z" }, ] +[[package]] +name = "pyee" +version = "13.0.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/8b/04/e7c1fe4dc78a6fdbfd6c337b1c3732ff543b8a397683ab38378447baa331/pyee-13.0.1.tar.gz", hash = "sha256:0b931f7c14535667ed4c7e0d531716368715e860b988770fc7eb8578d1f67fc8", size = 31655, upload-time = "2026-02-14T21:12:28.044Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/c4/b4d4827c93ef43c01f599ef31453ccc1c132b353284fc6c87d535c233129/pyee-13.0.1-py3-none-any.whl", hash = "sha256:af2f8fede4171ef667dfded53f96e2ed0d6e6bd7ee3bb46437f77e3b57689228", size = 15659, upload-time = "2026-02-14T21:12:26.263Z" }, +] + [[package]] name = "pygments" version = "2.19.2"