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:
2026-09-30 06:56:52 -07:00
parent 379600adc8
commit ad606c7611
10 changed files with 216 additions and 273 deletions
+23
View File
@@ -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)
+2 -2
View File
@@ -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
+5 -5
View File
@@ -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"