fix(browser-mcp): migrate CDP bridge → zapd consumer; disk authoritative (#35)

* fix(browser-mcp): migrate CDP bridge -> zapd consumer; make disk authoritative

The MCP runtime kept pulling the buggy published wheel (hanzo-tools-browser
0.5.7) from PyPI instead of the migrated on-disk source, so every restart
re-broke the `cdp` tool. Reinstate `cdp` as a zapd-native tool, finish the
bridge->zapd migration (delete dead bridge/server modules + their tests), bump
to 0.5.8 and pin hanzo-mcp >=0.5.8, add the package to the uv workspace/sources
so disk is authoritative. 9/9 browser tests pass.

Pairs with the hanzoai/extension bridge->zapd migration (separate, still
uncommitted). The ~/.claude.json MCP invocation was also pointed at the local
editable source (machine config, not in this repo).

(cherry picked from commit 11b4d4ea8a5570d6fb0e51079fa445dba3713f71)

* fix(browser): drop dead lifecycle.py (in-process zap_server/cdp_bridge are gone)

lifecycle.py only wrapped the removed in-process ZAP server + CDP bridge; nothing
imports it under the zapd-consumer model. One way: transport is zapd_consumer,
tools are browser/cdp/playwright.

---------

Co-authored-by: hanzo-dev <[email protected]>

Co-authored-by: Antje Worring <[email protected]>

Co-authored-by: Hanzo Dev <[email protected]>
This commit is contained in:
zooqueen
2026-06-16 22:12:07 -07:00
committed by GitHub
co-authored by Hanzo Dev
parent 6f20d649be
commit 83525593e4
15 changed files with 719 additions and 3081 deletions
+36 -32
View File
@@ -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/<host>/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).
- `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.
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.
Two tools, one transport: `browser` (high-level verbs) and `cdp` (raw methods).
Both are in `TOOLS` and resolve to the same zapd provider.
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:
The router (`zapd`) is a separate always-on daemon — install/run it from
`zap-proto/zapd` (`curl … | sh` or `@hanzo/zapd`).
```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
```
**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 <python-sdk root>` 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
+1 -1
View File
@@ -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",
@@ -1,36 +1,25 @@
"""Browser automation tools — decomplected surface.
"""Browser automation tools for Hanzo AI — zapd consumer model.
Three orthogonal MCP tools, all default-enabled, all disable-able by env:
[Browser ext] --native host--> [zapd router] --unix sock--> [hanzo-mcp] --stdio--> [Agent]
* ``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``.
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``).
* ``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``.
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.browser_tool import (
)
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 @@ __all__ = [
# 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 @@ __all__ = [
"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)
@@ -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
from hanzo_tools.browser.zapd_consumer import get_consumer
consumer = get_consumer()
if consumer is None:
return False
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
return await asyncio.to_thread(consumer.resolve_browser, browser, None) is not None
except Exception:
pass
# 2) Legacy HTTP bridge
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
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.
"""Route a browser command to a provider via the local zapd router.
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).
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",
}
# ---- 2) HTTP fallback ---------------------------------------------
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"}
provider = await asyncio.to_thread(consumer.resolve_browser, browser, client_id)
except Exception as e:
logger.debug(f"Extension command failed: {e}")
return None
return {"error": str(e), "transport": "native-zap"}
if not provider:
return {"error": "no browser provider connected over zapd", "transport": "native-zap"}
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:
raw = await asyncio.to_thread(consumer.route, provider, method, str_params)
except Exception as e:
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 @@ class BrowserTool(BaseTool):
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 @@ CATEGORIES:
timeout = timeout or self.timeout
sel = selector or ref
# === LOCAL ACTIONS (handled in-process, no extension/Playwright) ===
if action == "list_mcp_instances":
# === 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
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 ZapServer
instances = ZapServer.list_mcp_instances()
return {
"success": True,
"mcp_instances": instances,
"count": len(instances),
}
provs = await asyncio.to_thread(consumer.list_providers)
except Exception as e:
return {"error": f"failed to list mcp instances: {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 == "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"}
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,
}
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)}
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 @@ CATEGORIES:
"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",
File diff suppressed because it is too large Load Diff
@@ -1,148 +1,100 @@
"""Raw Chrome DevTools Protocol method dispatch.
"""Raw Chrome DevTools Protocol method dispatch — zapd-native.
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`.
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 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
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
}
async def _route_cdp(
method: str,
params: Optional[dict],
*,
target_browser: Optional[str] = None,
tab_id: Union[str, int, None] = None,
client_id: Optional[str] = None,
timeout: float = 30.0,
) -> dict[str, Any]:
"""Route a raw CDP method to a browser provider via the local zapd router.
Mirrors ``browser_tool._extension_command`` but sends the method name
verbatim instead of mapping a high-level action. One transport, one codec.
"""
import asyncio
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:
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:
raw = await asyncio.to_thread(consumer.route, provider, method, str_params, timeout)
except Exception as e:
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}
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[
Literal["send", "tabs", "status", "list_browsers", "claim_browser", "release_browser"],
Field(description="CDP action"),
str,
Field(description="CDP action: send | tabs | status | list_browsers"),
]
async def _dispatch_raw(
method: str,
params: Optional[dict] = None,
*,
browser: Optional[str] = None,
tab_id: Optional[Union[str, int]] = 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).
Pin transport via ``BROWSER_TRANSPORT=zap|http|auto`` (default ``auto``).
"""
params = dict(params or {})
# Normalize tab id (accept "tab-123" or 123 or "123")
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
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
except Exception as e:
logger.debug("CDP dispatch HTTP fallback failed: %s", e)
return {"error": str(e), "transport": "http", "method": method}
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.
"""
"""Raw Chrome DevTools Protocol method dispatch — peer of ``browser``."""
name = "cdp"
@@ -155,7 +107,6 @@ ACTIONS:
- 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 @@ EXAMPLES:
- 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 @@ Use `playwright` for headless Playwright automation.
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",
}
@@ -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",
]
@@ -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-<pid>-<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",
]
@@ -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("<H", len(b)) + b
def _encode_frame(typ, frm, to, payload=b"", flags=0) -> bytes:
fb, tb = frm.encode(), to.encode()
body = struct.pack("<BHHHI", typ, flags, len(fb), len(tb), len(payload)) + fb + tb + payload
return struct.pack("<I", len(body)) + body
def _hello_payload(role, brand, caps) -> bytes:
b = bytes([role]) + _put_str(brand) + struct.pack("<H", len(caps))
for c in caps:
b += _put_str(c)
return b
def _encode_cmd(method: str, params: dict) -> bytes:
b = _put_str(method) + struct.pack("<H", len(params))
for k, v in params.items():
vb = v.encode() if isinstance(v, str) else bytes(v)
b += _put_str(k) + struct.pack("<I", len(vb)) + vb
return b
def _parse_providers(pay: bytes):
(n,) = struct.unpack_from("<H", pay, 0)
o = 2
out = []
for _ in range(n):
(idl,) = struct.unpack_from("<H", pay, o); o += 2
pid = pay[o : o + idl].decode(); o += idl
role = pay[o]; o += 1
(bl,) = struct.unpack_from("<H", pay, o); o += 2
brand = pay[o : o + bl].decode(); o += bl
(cn,) = struct.unpack_from("<H", pay, o); o += 2
caps = []
for _ in range(cn):
(cl,) = struct.unpack_from("<H", pay, o); o += 2
caps.append(pay[o : o + cl].decode()); o += cl
out.append({"id": pid, "role": role, "brand": brand, "caps": caps})
return out
class ZapdConsumer:
"""A persistent consumer connection to the local zapd router."""
def __init__(self, agent_id: Optional[str] = None):
self.agent_id = agent_id or f"consumer:hanzo-mcp/{os.getpid()}"
self._sock: Optional[socket.socket] = None
self._lock = threading.Lock()
def connect(self) -> 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("<I", self._recvn(4))
buf = self._recvn(length)
typ, flags, fl, tl, pl = struct.unpack_from("<BHHHI", buf, 0)
o = 11
frm = buf[o : o + fl].decode(); o += fl
to = buf[o : o + tl].decode(); o += tl
return {"t": typ, "from": frm, "to": to, "payload": buf[o : o + pl]}
def _read_until(self, want, from_id=None) -> 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
+2 -5
View File
@@ -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]
@@ -37,3 +37,77 @@ class TestBrowserTool:
"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"
@@ -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()
@@ -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
+4
View File
@@ -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
Generated
+195 -2
View File
@@ -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"