mirror of
https://github.com/hanzoai/python-sdk.git
synced 2026-10-23 20:23:00 +00:00
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:
committed by
GitHub
co-authored by
Hanzo Dev
parent
6f20d649be
commit
83525593e4
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user