mirror of
https://github.com/hanzoai/python-sdk.git
synced 2026-10-23 20:23:00 +00:00
The browser tool takes a seat on the embedded ZAP router
hanzo-mcp said HELLO as consumer:hanzo-mcp/<pid> to a separate zapd daemon, which the router that replaced it refuses as bad_id. Every process that speaks ZAP now embeds the router (zapd 1.1 on PyPI) and the kernel lock elects one; hanzo-mcp stands for it at startup and takes the seat mcp/hanzo-<pid>. A browser is a browser/<host>/<name> node, found by engine or name and reached with Node.call, and the extension's command codec is unchanged. hanzo-mcp pair prints the code the extension's popup takes. The daemon spawn and socket probing are gone, and so is the zap-proto dependency.
This commit is contained in:
@@ -40,8 +40,29 @@ def _parse_timeout_arg(timeout_str: str) -> float:
|
||||
raise ValueError(f"Invalid timeout format: '{timeout_str}'")
|
||||
|
||||
|
||||
def _pair(args: list[str]) -> None:
|
||||
"""`hanzo-mcp pair [--reset]`: the code the Hanzo extension's popup takes,
|
||||
once per browser. `--reset` mints a new one; every browser pairs again."""
|
||||
from hanzo_tools.browser.zapd_consumer import pair
|
||||
|
||||
print(pair(reset="--reset" in args))
|
||||
|
||||
|
||||
def _seat() -> None:
|
||||
"""Stand for this user's ZAP router and take this process's seat, so a
|
||||
paired browser can reach it before the first browser call."""
|
||||
try:
|
||||
from hanzo_tools.browser.zapd_consumer import get_consumer
|
||||
except ImportError:
|
||||
return
|
||||
get_consumer()
|
||||
|
||||
|
||||
def main() -> None:
|
||||
"""Run the CLI for the Hanzo AI server."""
|
||||
if len(sys.argv) > 1 and sys.argv[1] == "pair":
|
||||
_pair(sys.argv[2:])
|
||||
return
|
||||
# Handle 'serve' subcommand for Claude Code compatibility
|
||||
# Claude Code calls: hanzo-mcp serve --enable-agent
|
||||
# We support both: hanzo-mcp [serve] [options]
|
||||
@@ -524,6 +545,7 @@ def main() -> None:
|
||||
host=host,
|
||||
port=port,
|
||||
)
|
||||
_seat()
|
||||
dev_server.run(transport=transport)
|
||||
return
|
||||
|
||||
@@ -547,6 +569,7 @@ def main() -> None:
|
||||
port=port,
|
||||
)
|
||||
|
||||
_seat()
|
||||
try:
|
||||
# Transport will be automatically cast to Literal['stdio', 'sse'] by the server
|
||||
server.run(transport=transport)
|
||||
|
||||
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "hanzo-mcp"
|
||||
version = "0.15.16"
|
||||
version = "0.15.18"
|
||||
description = "The Zen of Hanzo MCP: One server to rule them all. The ultimate MCP that orchestrates all others."
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.12"
|
||||
@@ -34,7 +34,7 @@ dependencies = [
|
||||
"hanzo-tools-fs>=0.3.4", # 0.3.4 returns image files as native MCP ImageContent
|
||||
"hanzo-tools-shell>=0.6.5", # 0.6.5 pairs with hanzo-tools>=0.3.0 (ToolError)
|
||||
"hanzo-tools-core>=0.3.1", # 0.3.1 is the EMPTY shim — no longer shadows canonical hanzo-tools.core
|
||||
"hanzo-tools-browser[playwright]>=0.5.11", # 0.5.11 screenshots downscale before they reach the wire
|
||||
"hanzo-tools-browser[playwright]>=0.5.12", # 0.5.12 reaches the browser over the embedded ZAP router
|
||||
"hanzo-tools-memory>=0.2.0",
|
||||
"hanzo-tools-todo>=0.1.0",
|
||||
"hanzo-tools-reasoning>=0.1.0",
|
||||
|
||||
@@ -1,13 +1,11 @@
|
||||
"""Browser automation tools for Hanzo AI — zapd consumer model.
|
||||
"""Browser automation tools for Hanzo AI, over the ZAP router.
|
||||
|
||||
[Browser ext] --native host--> [zapd router] --unix sock--> [hanzo-mcp] --stdio--> [Agent]
|
||||
[Browser ext] --door (ws, paired)--> [zapd, embedded] <--seat-- [hanzo-mcp] --stdio--> [Agent]
|
||||
|
||||
hanzo-mcp hosts NO server. It connects to the one shared local router at
|
||||
``~/.zap/run/zapd.sock`` as a *consumer*, lists providers, and routes opaque
|
||||
CDP commands to a ``browser:*`` provider (the real Chrome/Firefox extension,
|
||||
connected via its native-messaging host). No in-process server, no mDNS, no
|
||||
well-known port pool, no :9224 HTTP bridge, no Playwright fallback in
|
||||
native-browser mode. ``zapd`` is a separate always-on daemon (see ``~/work/zap``).
|
||||
Every ZAP process embeds the router and the kernel elects one by lock; there is
|
||||
no daemon. hanzo-mcp takes the seat ``mcp/hanzo-<pid>``, and the Hanzo
|
||||
extension, paired once (``hanzo-mcp pair``), is a ``browser/<host>/<name>``
|
||||
node that answers the commands routed to it.
|
||||
|
||||
Three peer tools are exposed, all sharing one transport (``zapd_consumer``):
|
||||
- ``browser`` — high-level, action-oriented (navigate/click/screenshot/tabs …).
|
||||
@@ -72,10 +70,6 @@ __all__ = [
|
||||
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)
|
||||
|
||||
@@ -148,16 +148,13 @@ 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 a browser provider is connected to the local zapd router."""
|
||||
"""Whether a browser is on this user's ZAP router."""
|
||||
import asyncio
|
||||
|
||||
from hanzo_tools.browser.zapd_consumer import get_consumer
|
||||
|
||||
consumer = get_consumer()
|
||||
if consumer is None:
|
||||
return False
|
||||
try:
|
||||
return await asyncio.to_thread(consumer.resolve_browser, browser, None) is not None
|
||||
return await asyncio.to_thread(get_consumer().resolve_browser, browser, None) is not None
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
@@ -188,7 +185,7 @@ def _zap_params(
|
||||
params: dict[str, Any] = {}
|
||||
if action in ("screenshot", "annotate"):
|
||||
# Shrink where the pixels are. A 4480x1440 PNG is ~740 KB of base64 on the
|
||||
# native-messaging hop; asking the browser for a 1280px JPEG means that
|
||||
# router hop; asking the browser for a 1280px JPEG means that
|
||||
# payload is never built, let alone carried.
|
||||
params["format"] = "png" if full_res else "jpeg"
|
||||
if quality is not None:
|
||||
@@ -331,26 +328,18 @@ async def _extension_command(
|
||||
detail: Optional[dict] = None,
|
||||
**kwargs,
|
||||
) -> Optional[dict]:
|
||||
"""Route a browser command to a provider via the local zapd router.
|
||||
|
||||
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.
|
||||
"""
|
||||
"""Route a browser command to a ``browser/…`` node on this user's ZAP router."""
|
||||
import asyncio
|
||||
|
||||
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"}
|
||||
from hanzo_tools.browser.zapd_consumer import UNPAIRED, get_consumer
|
||||
|
||||
try:
|
||||
consumer = get_consumer()
|
||||
provider = await asyncio.to_thread(consumer.resolve_browser, browser, client_id)
|
||||
except Exception as e:
|
||||
return {"error": str(e), "transport": "native-zap"}
|
||||
if not provider:
|
||||
return {"error": "no browser provider connected over zapd", "transport": "native-zap"}
|
||||
return {"error": UNPAIRED, "transport": "native-zap"}
|
||||
|
||||
detail = detail or {}
|
||||
method = _zap_method_for(action)
|
||||
@@ -936,8 +925,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
|
||||
# No server to start: native-browser commands route through the shared
|
||||
# zapd router (~/.zap/run/zapd.sock) on demand via zapd_consumer.
|
||||
# No server to start: commands reach the browser through this user's
|
||||
# ZAP router, via zapd_consumer.
|
||||
|
||||
@property
|
||||
def description(self) -> str:
|
||||
@@ -1071,14 +1060,10 @@ class BrowserTool(BaseTool):
|
||||
if action == "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:
|
||||
provs = await asyncio.to_thread(consumer.list_providers)
|
||||
browsers = await asyncio.to_thread(get_consumer().browsers)
|
||||
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)}
|
||||
|
||||
# A wait with nothing to wait for is a pause; no browser needs asking.
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
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
|
||||
this user's ZAP router (``zapd``, embedded). 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
|
||||
@@ -24,7 +24,7 @@ from mcp.server import FastMCP
|
||||
from hanzo_tools.core import BaseTool, capture
|
||||
from hanzo_tools.core.unified import _result_to_mcp
|
||||
from hanzo_tools.browser.browser_tool import _extract_b64
|
||||
from hanzo_tools.browser.zapd_consumer import get_consumer
|
||||
from hanzo_tools.browser.zapd_consumer import UNPAIRED, get_consumer
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -53,16 +53,13 @@ async def _route_cdp(
|
||||
"""
|
||||
import asyncio
|
||||
|
||||
consumer = get_consumer()
|
||||
if consumer is None:
|
||||
return {"error": "zapd not reachable (~/.zap/run/zapd.sock)", "transport": "native-zap", "method": method}
|
||||
|
||||
try:
|
||||
consumer = get_consumer()
|
||||
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}
|
||||
return {"error": UNPAIRED, "transport": "native-zap", "method": method}
|
||||
|
||||
wire: dict[str, Any] = dict(params or {})
|
||||
if tab_id is not None:
|
||||
@@ -92,17 +89,13 @@ async def _route_cdp(
|
||||
|
||||
|
||||
async def _list_browsers() -> dict[str, Any]:
|
||||
"""List browser providers connected to the local zapd router."""
|
||||
"""The browsers on this user's ZAP 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)
|
||||
browsers = await asyncio.to_thread(get_consumer().browsers)
|
||||
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)}
|
||||
|
||||
|
||||
|
||||
@@ -1,225 +1,98 @@
|
||||
"""hanzo-mcp as a zapd consumer — native ZAP, no in-process server.
|
||||
"""hanzo-mcp's seat on the ZAP router.
|
||||
|
||||
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.
|
||||
Every process that speaks ZAP embeds the router (``zapd.embed()``) and the
|
||||
kernel elects one of them by lock; there is no daemon to start. This process
|
||||
stands for router and takes the seat ``mcp/hanzo-<pid>``. A browser is a
|
||||
``browser/<host>/<name>`` node, paired once through the router's door
|
||||
(``hanzo-mcp pair``), and a command reaches it with ``Node.call``.
|
||||
|
||||
The router envelope and its control bodies (HELLO/WELCOME/PROVIDERS/ROUTE/…) are
|
||||
NOT reimplemented here — they come from the canonical ``zap`` package
|
||||
(``zap.frame`` + :class:`zap.ZapClient`), the one Python copy of the wire. This
|
||||
module is a thin *policy* layer over that client: lazy connect, provider
|
||||
resolution for ``browser:*``, and the method+params convenience for routing.
|
||||
|
||||
The only codec that lives here is the end-to-end **browser command payload**
|
||||
(:func:`_encode_cmd`). The router forwards it opaquely; the peer that decodes it
|
||||
is the browser extension (``extension/.../shared/native-zap.ts``), whose
|
||||
``decodeCmd`` reads an *untagged* ``method + params(key→u32-len value)`` body.
|
||||
That is deliberately NOT ``zap.frame.encode_cmd`` — the canonical helper inserts
|
||||
a per-value type tag (to round-trip str vs bytes between Python peers) which the
|
||||
extension does not read. Encoding with the tagged helper would shift every
|
||||
param by one byte and corrupt the command, so this peer keeps the untagged form.
|
||||
The one codec here is the browser command payload (:func:`_encode_cmd`). The
|
||||
router forwards it opaquely; the extension's ``decodeCmd`` reads it.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import shutil
|
||||
import socket as _socket
|
||||
import struct
|
||||
import subprocess
|
||||
import time
|
||||
from typing import Optional
|
||||
|
||||
from zap import frame
|
||||
from zap.client import ZapClient
|
||||
import zapd
|
||||
|
||||
BROWSER = "browser/"
|
||||
|
||||
# What a caller is told when no browser is on the router.
|
||||
UNPAIRED = (
|
||||
"no browser on the ZAP router: run `hanzo-mcp pair` and paste the code into "
|
||||
"the Hanzo extension's popup"
|
||||
)
|
||||
|
||||
|
||||
def socket_path() -> str:
|
||||
"""The brand-neutral zapd socket; delegates to the canonical resolver."""
|
||||
return frame.socket_path()
|
||||
|
||||
|
||||
def _socket_live(path: str) -> bool:
|
||||
"""True iff a listener currently accepts connections on the UDS at ``path``."""
|
||||
if not path or not os.path.exists(path):
|
||||
return False
|
||||
s = _socket.socket(_socket.AF_UNIX, _socket.SOCK_STREAM)
|
||||
try:
|
||||
s.settimeout(0.5)
|
||||
s.connect(path)
|
||||
return True
|
||||
except OSError:
|
||||
return False
|
||||
finally:
|
||||
s.close()
|
||||
|
||||
|
||||
def _find_zapd() -> Optional[str]:
|
||||
"""Locate the one ``zapd`` binary. ``ZAP_ZAPD``/``ZAPD_BIN`` override, then
|
||||
PATH, then the standard install prefixes (install.sh drops it in
|
||||
~/.local/bin), then a local source checkout's release build."""
|
||||
for env in ("ZAP_ZAPD", "ZAPD_BIN"):
|
||||
p = os.environ.get(env)
|
||||
if p and os.path.exists(p):
|
||||
return p
|
||||
hit = shutil.which("zapd")
|
||||
if hit:
|
||||
return hit
|
||||
for c in (
|
||||
"~/.local/bin/zapd",
|
||||
"~/.cargo/bin/zapd",
|
||||
"/usr/local/bin/zapd",
|
||||
"~/work/zapd/target/release/zapd",
|
||||
):
|
||||
c = os.path.expanduser(c)
|
||||
if os.path.exists(c):
|
||||
return c
|
||||
return None
|
||||
|
||||
|
||||
def ensure_zapd_running(timeout: float = 6.0) -> bool:
|
||||
"""Guarantee the shared zapd router is listening, auto-starting it if not.
|
||||
|
||||
This mirrors zapd's own host-mode connect-or-spawn (``host.rs``): a ZAP
|
||||
consumer never needs a human to have started the daemon. The OS unit
|
||||
(systemd/launchd, and socket-activation) is the primary auto-starter; this
|
||||
is the no-service fallback so the tool self-heals on a bare machine.
|
||||
|
||||
Idempotent and race-safe by construction: the router is a *singleton* — it
|
||||
binds the well-known socket behind an advisory lock (``broker.rs``), so any
|
||||
number of processes may launch ``zapd`` concurrently and every loser exits
|
||||
cleanly. There is therefore no consumer-side lock here — the one-and-only
|
||||
singleton invariant lives in zapd, not in each SDK. Returns True once the
|
||||
socket accepts connections, False if ``zapd`` can't be found or is slow.
|
||||
"""
|
||||
sock = socket_path()
|
||||
if _socket_live(sock):
|
||||
return True
|
||||
exe = _find_zapd()
|
||||
if not exe:
|
||||
return False
|
||||
try:
|
||||
subprocess.Popen(
|
||||
[exe, "--log", "info"],
|
||||
stdin=subprocess.DEVNULL,
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
start_new_session=True, # detach so the router outlives this process
|
||||
)
|
||||
except OSError:
|
||||
return False
|
||||
deadline = time.monotonic() + timeout
|
||||
while time.monotonic() < deadline:
|
||||
if _socket_live(sock):
|
||||
return True
|
||||
time.sleep(0.1)
|
||||
return False
|
||||
def _put_str(s: str) -> bytes:
|
||||
b = s.encode()
|
||||
return struct.pack("<H", len(b)) + b
|
||||
|
||||
|
||||
def _encode_cmd(method: str, params: dict) -> bytes:
|
||||
"""Untagged browser command body — peer of the extension's ``decodeCmd``.
|
||||
"""The browser command body, peer of the extension's ``decodeCmd``.
|
||||
|
||||
Layout (little-endian): ``method(u16 len + bytes)`` then ``u16`` param count,
|
||||
each param ``key(u16 len + bytes) + value(u32 len + bytes)``. No type tag —
|
||||
see the module docstring for why this is not ``frame.encode_cmd``.
|
||||
Little-endian: ``method(u16 len + bytes)``, ``u16`` param count, then each
|
||||
param ``key(u16 len + bytes) + value(u32 len + bytes)``.
|
||||
"""
|
||||
b = frame._put_str(method) + struct.pack("<H", len(params))
|
||||
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 += frame._put_str(k) + struct.pack("<I", len(vb)) + vb
|
||||
b += _put_str(k) + struct.pack("<I", len(vb)) + vb
|
||||
return b
|
||||
|
||||
|
||||
def pair(reset: bool = False) -> str:
|
||||
"""This user's pairing code for the browser extension; ``reset`` rotates it."""
|
||||
return zapd.pair(reset=reset)
|
||||
|
||||
|
||||
class ZapdConsumer:
|
||||
"""A persistent consumer connection to the local zapd router."""
|
||||
"""This process's node on the router, and the browsers it can reach."""
|
||||
|
||||
def __init__(self, agent_id: Optional[str] = None):
|
||||
self.agent_id = agent_id or f"consumer:hanzo-mcp/{os.getpid()}"
|
||||
self._client: Optional[ZapClient] = None
|
||||
def __init__(self, name: Optional[str] = None):
|
||||
zapd.embed()
|
||||
self.node = zapd.Node(name or f"mcp/hanzo-{os.getpid()}")
|
||||
|
||||
def _dial(self) -> bool:
|
||||
try:
|
||||
self._client = ZapClient.connect(
|
||||
id=self.agent_id, role="consumer", brand="hanzo", timeout=5.0
|
||||
)
|
||||
return True
|
||||
except OSError:
|
||||
self._client = None
|
||||
return False
|
||||
@property
|
||||
def id(self) -> str:
|
||||
return self.node.id
|
||||
|
||||
def connect(self) -> bool:
|
||||
if self._client is not None:
|
||||
return True
|
||||
if self._dial():
|
||||
return True
|
||||
# zapd wasn't listening. Auto-start the shared singleton router and dial
|
||||
# once more, so the browser tool "just works" with no manual daemon and
|
||||
# no knowledge that zapd exists. ensure_zapd_running is a no-op when the
|
||||
# OS unit already has it up; the singleton invariant lives in zapd, so a
|
||||
# spawn race is safe.
|
||||
if not ensure_zapd_running():
|
||||
return False
|
||||
return self._dial()
|
||||
def list_providers(self) -> list[dict]:
|
||||
"""Every node on the router: ``{"id", "role", "brand", "caps", "attrs"}``."""
|
||||
return [dict(n) for n in self.node.nodes()]
|
||||
|
||||
def list_providers(self, brand: str = "") -> list:
|
||||
# Self-heal: a cached client whose socket died (e.g. the router was
|
||||
# restarted) raises BrokenPipeError/OSError on use. Drop it and
|
||||
# reconnect ONCE rather than staying wedged forever.
|
||||
for attempt in (1, 2):
|
||||
if not self.connect():
|
||||
return []
|
||||
assert self._client is not None
|
||||
try:
|
||||
provs = self._client.providers_list(brand=brand)
|
||||
return [{"id": p.id, "role": p.role, "brand": p.brand, "caps": p.caps} for p in provs]
|
||||
except (BrokenPipeError, ConnectionError, OSError):
|
||||
self.close()
|
||||
if attempt == 2:
|
||||
return []
|
||||
return []
|
||||
def browsers(self) -> list[dict]:
|
||||
return [n for n in self.list_providers() if n["id"].startswith(BROWSER)]
|
||||
|
||||
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:")]
|
||||
"""The browser to address: ``client_id`` exactly, else the first whose
|
||||
engine or name is ``browser``, else the first one."""
|
||||
found = self.browsers()
|
||||
if client_id:
|
||||
for p in provs:
|
||||
if p["id"] == client_id:
|
||||
return p["id"]
|
||||
return next((b["id"] for b in found if b["id"] == client_id), None)
|
||||
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"]
|
||||
want = browser.lower()
|
||||
for b in found:
|
||||
name = b["id"].rsplit("/", 1)[-1]
|
||||
if b.get("attrs", {}).get("engine") == want or name.split("-", 1)[0] == want:
|
||||
return b["id"]
|
||||
return None
|
||||
return provs[0]["id"] if provs else None
|
||||
return found[0]["id"] if found else None
|
||||
|
||||
def route(self, provider_id: str, method: str, params: dict, timeout: float = 30.0) -> bytes:
|
||||
# Self-heal on a dead cached socket (see list_providers): reconnect once.
|
||||
for attempt in (1, 2):
|
||||
if not self.connect():
|
||||
raise RuntimeError("zapd not reachable")
|
||||
assert self._client is not None
|
||||
try:
|
||||
return self._client.route(provider_id, _encode_cmd(method, params), timeout=timeout)
|
||||
except (BrokenPipeError, ConnectionError, OSError):
|
||||
self.close()
|
||||
if attempt == 2:
|
||||
raise
|
||||
raise RuntimeError("zapd not reachable")
|
||||
|
||||
def close(self):
|
||||
if self._client is not None:
|
||||
try:
|
||||
self._client.close()
|
||||
finally:
|
||||
self._client = None
|
||||
return self.node.call(provider_id, _encode_cmd(method, params), timeout=timeout)
|
||||
|
||||
|
||||
_consumer: Optional[ZapdConsumer] = None
|
||||
|
||||
|
||||
def get_consumer() -> Optional[ZapdConsumer]:
|
||||
"""Return the shared consumer if zapd is reachable, else None."""
|
||||
def get_consumer() -> ZapdConsumer:
|
||||
"""This process's seat, taken on first use (hanzo-mcp takes it at startup)."""
|
||||
global _consumer
|
||||
if _consumer is None:
|
||||
_consumer = ZapdConsumer()
|
||||
return _consumer if _consumer.connect() else None
|
||||
return _consumer
|
||||
|
||||
@@ -4,8 +4,8 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "hanzo-tools-browser"
|
||||
version = "0.5.11"
|
||||
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."
|
||||
version = "0.5.12"
|
||||
description = "Browser automation tools: drive the user's own browser through the Hanzo extension, over the ZAP router every ZAP process embeds."
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.12"
|
||||
license = { text = "MIT" }
|
||||
@@ -17,9 +17,9 @@ dependencies = [
|
||||
# capture() downscales with PIL; without it a screenshot is a path and no pixels.
|
||||
"pillow>=10.0.0",
|
||||
"pydantic>=2.12.5",
|
||||
# Canonical ZAP router envelope (zap.frame + ZapClient). The zapd wire is
|
||||
# imported from here, never reimplemented in this package.
|
||||
"zap-proto>=1.2.0",
|
||||
# The ZAP router, embedded: the lock elects one process per user login,
|
||||
# and this one takes a seat on it. The one implementation; never ported.
|
||||
"zapd>=1.1.3",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
|
||||
@@ -65,15 +65,27 @@ class TestCdpTool:
|
||||
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
|
||||
)
|
||||
async def test_no_router_is_reported(self, tool, monkeypatch):
|
||||
# A router that never answers is the router's own error, named.
|
||||
class NoRouter:
|
||||
def resolve_browser(self, browser, client_id):
|
||||
raise TimeoutError("zapd: no router on this machine")
|
||||
|
||||
monkeypatch.setattr("hanzo_tools.browser.cdp_tool.get_consumer", lambda: NoRouter())
|
||||
result = await tool.execute(action="tabs")
|
||||
assert result.get("transport") == "native-zap"
|
||||
assert "zapd" in result["error"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_no_browser_says_how_to_pair(self, tool, monkeypatch):
|
||||
class Alone:
|
||||
def resolve_browser(self, browser, client_id):
|
||||
return None
|
||||
|
||||
monkeypatch.setattr("hanzo_tools.browser.cdp_tool.get_consumer", lambda: Alone())
|
||||
result = await tool.execute(action="tabs")
|
||||
assert "hanzo-mcp pair" 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.
|
||||
@@ -86,7 +98,7 @@ class TestCdpTool:
|
||||
|
||||
class FakeConsumer:
|
||||
def resolve_browser(self, browser, client_id):
|
||||
return "browser:chrome/host/default"
|
||||
return "browser/host/chrome-1a2b"
|
||||
|
||||
def route(self, provider, method, params, timeout=30.0):
|
||||
sent["provider"] = provider
|
||||
@@ -116,31 +128,36 @@ class TestCdpTool:
|
||||
|
||||
|
||||
class TestZapdWire:
|
||||
"""The consumer delegates the router envelope to canonical ``zap.frame`` and
|
||||
keeps only the untagged browser-command codec the extension peer decodes."""
|
||||
"""The router is the embedded zapd; the one codec here is the browser
|
||||
command body the extension's decodeCmd reads."""
|
||||
|
||||
def test_uses_canonical_frame(self):
|
||||
# No hand-rolled envelope: socket_path and the client come from zap.
|
||||
def test_the_router_is_embedded_not_spawned(self):
|
||||
from hanzo_tools.browser import zapd_consumer as zc
|
||||
from zap import frame
|
||||
from zap.client import ZapClient
|
||||
|
||||
assert zc.socket_path() == frame.socket_path()
|
||||
assert zc.ZapClient is ZapClient
|
||||
# The duplicated framing primitives are gone from this module.
|
||||
for gone in ("_encode_frame", "_hello_payload", "_parse_providers", "_read_frame"):
|
||||
assert not hasattr(zc, gone), f"{gone} should be deleted (use zap.frame)"
|
||||
assert zc.zapd.__name__ == "zapd"
|
||||
for gone in ("ensure_zapd_running", "_find_zapd", "_socket_live", "socket_path", "ZapClient"):
|
||||
assert not hasattr(zc, gone), f"{gone} is the retired daemon path"
|
||||
|
||||
def test_a_browser_is_found_by_engine_or_name(self):
|
||||
from hanzo_tools.browser.zapd_consumer import ZapdConsumer
|
||||
|
||||
c = ZapdConsumer.__new__(ZapdConsumer)
|
||||
nodes = [
|
||||
{"id": "mcp/spark/hanzo-42", "attrs": {}},
|
||||
{"id": "browser/spark/firefox-9c1d", "attrs": {"engine": "firefox"}},
|
||||
{"id": "browser/spark/chrome-1a2b", "attrs": {"engine": "chrome"}},
|
||||
]
|
||||
c.list_providers = lambda: nodes
|
||||
assert c.resolve_browser(None, None) == "browser/spark/firefox-9c1d"
|
||||
assert c.resolve_browser("chrome", None) == "browser/spark/chrome-1a2b"
|
||||
assert c.resolve_browser("safari", None) is None
|
||||
assert c.resolve_browser(None, "browser/spark/chrome-1a2b") == "browser/spark/chrome-1a2b"
|
||||
assert c.resolve_browser(None, "mcp/spark/hanzo-42") is None
|
||||
|
||||
def test_cmd_codec_is_extension_compatible_untagged(self):
|
||||
"""``_encode_cmd`` must match the extension's ``decodeCmd`` byte layout:
|
||||
method + u16 count + per-param(key + u32 len + value), NO type tag.
|
||||
|
||||
This is intentionally NOT ``zap.frame.encode_cmd`` (which inserts a
|
||||
per-value tag byte). Decode it here exactly as native-zap.ts does and
|
||||
confirm the round trip; then confirm the tagged helper differs.
|
||||
"""
|
||||
"""``_encode_cmd`` matches the extension's ``decodeCmd`` byte layout:
|
||||
method + u16 count + per-param(key + u32 len + value), no type tag."""
|
||||
from hanzo_tools.browser.zapd_consumer import _encode_cmd
|
||||
from zap import frame
|
||||
|
||||
method, params = "Page.navigate", {"url": "https://example.com", "tabId": "7"}
|
||||
buf = _encode_cmd(method, params)
|
||||
@@ -160,7 +177,3 @@ class TestZapdWire:
|
||||
assert o == len(buf)
|
||||
assert out == params
|
||||
|
||||
# The canonical (tagged) helper is a different, longer wire — proving we
|
||||
# were right not to use it for this peer.
|
||||
assert frame.encode_cmd(method, params) != buf
|
||||
assert len(frame.encode_cmd(method, params)) == len(buf) + len(params)
|
||||
|
||||
@@ -12,6 +12,7 @@ import inspect
|
||||
import pytest
|
||||
from mcp.server import FastMCP
|
||||
|
||||
from hanzo_tools.browser.zapd_consumer import UNPAIRED
|
||||
from hanzo_tools.browser.browser_tool import ACTIONS, CORE, TOPICS, BrowserTool, _help
|
||||
|
||||
CORE_ACTIONS = ("navigate", "snapshot", "click", "fill", "type", "press", "read", "screenshot", "evaluate", "wait", "tabs", "help")
|
||||
@@ -25,7 +26,7 @@ class FakeConsumer:
|
||||
self.replies = replies or {}
|
||||
|
||||
def resolve_browser(self, browser, client_id):
|
||||
return "browser:chrome/host/default"
|
||||
return "browser/host/chrome-1a2b"
|
||||
|
||||
def route(self, provider, method, params, timeout=30.0):
|
||||
self.sent.append((method, params))
|
||||
@@ -214,9 +215,13 @@ class TestRouting:
|
||||
class TestRefsNeedTheExtension:
|
||||
@pytest.mark.asyncio
|
||||
async def test_no_playwright_stand_in_for_a_ref(self, monkeypatch):
|
||||
monkeypatch.setattr("hanzo_tools.browser.zapd_consumer.get_consumer", lambda: None)
|
||||
class Alone:
|
||||
def resolve_browser(self, browser, client_id):
|
||||
return None
|
||||
|
||||
monkeypatch.setattr("hanzo_tools.browser.zapd_consumer.get_consumer", lambda: Alone())
|
||||
out = await BrowserTool(backend="auto").execute(action="click", selector="@e2")
|
||||
assert out == {"error": "zapd not reachable (~/.zap/run/zapd.sock)", "action": "click", "backend": "auto"}
|
||||
assert out == {"error": UNPAIRED, "action": "click", "backend": "auto"}
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_playwright_backend_refuses_a_ref_by_name(self):
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
"""The seat on a real router, each node in its own process and the runtime
|
||||
directory the test's own, so the router of whoever runs the tests is never
|
||||
touched."""
|
||||
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import textwrap
|
||||
|
||||
import pytest
|
||||
|
||||
zapd = pytest.importorskip("zapd")
|
||||
|
||||
SEAT = textwrap.dedent(
|
||||
"""
|
||||
import sys, time
|
||||
from hanzo_tools.browser.zapd_consumer import get_consumer
|
||||
me = get_consumer()
|
||||
deadline = time.monotonic() + 10
|
||||
while time.monotonic() < deadline:
|
||||
found = me.resolve_browser("chrome", None)
|
||||
if found:
|
||||
break
|
||||
time.sleep(0.05)
|
||||
print(me.id)
|
||||
print(found)
|
||||
"""
|
||||
)
|
||||
|
||||
BROWSER = textwrap.dedent(
|
||||
"""
|
||||
import sys, zapd
|
||||
zapd.embed()
|
||||
me = zapd.Node("browser/chrome-test")
|
||||
sys.stdin.read()
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
def test_the_seat_is_an_mcp_node_that_finds_the_browser(tmp_path):
|
||||
run = tmp_path / "run"
|
||||
run.mkdir()
|
||||
if len(str(run / "zap" / "zapd.sock")) > 100:
|
||||
pytest.skip("the runtime path is too long for a unix socket here")
|
||||
env = dict(os.environ, XDG_RUNTIME_DIR=str(run), XDG_STATE_HOME=str(tmp_path / "state"), ZAP_LOG="zapd=warn")
|
||||
browser = subprocess.Popen([sys.executable, "-c", BROWSER], stdin=subprocess.PIPE, env=env)
|
||||
try:
|
||||
out = subprocess.run(
|
||||
[sys.executable, "-c", SEAT], env=env, capture_output=True, text=True, timeout=30
|
||||
).stdout.split()
|
||||
finally:
|
||||
browser.stdin.close()
|
||||
browser.kill()
|
||||
browser.wait()
|
||||
host = zapd.host()
|
||||
assert out[0].startswith(f"mcp/{host}/hanzo-")
|
||||
assert out[1] == f"browser/{host}/chrome-test"
|
||||
Reference in New Issue
Block a user