fix(hanzo-tools-browser): 0.5.2 — import wire format from zap-protocol

The custom encode/decode and message-type constants in zap_server.py
were a byte-identical duplicate of zap-protocol (~/work/zap/zap-py).
Drop the duplicates, import from canonical:

  from zap.protocol import (
      ZAP_MAGIC, HEADER_SIZE, MAX_MESSAGE_SIZE,
      MSG_HANDSHAKE, MSG_HANDSHAKE_OK,
      MSG_REQUEST, MSG_RESPONSE,
      MSG_PING, MSG_PONG,
      ZAP_PORTS as DEFAULT_ZAP_PORTS,
      encode, decode,
  )

Single source of truth for the wire — any change (MSG_STREAM, frame
versioning, future Cap'n Proto migration) lands once and propagates.

zap-protocol added as a dependency. Verified at install:
  ZAP encode unified: True
  ZAP decode unified: True
This commit is contained in:
2026-05-09 14:48:36 -07:00
parent 65cb749fa6
commit a3bc5f5bcf
2 changed files with 58 additions and 55 deletions
@@ -40,19 +40,21 @@ from typing import Any, Awaitable, Callable, Optional
logger = logging.getLogger(__name__)
# Wire format constants (canonical: shared/zap.ts)
ZAP_MAGIC = b"\x5a\x41\x50\x01"
HEADER_SIZE = 9 # 4 magic + 1 type + 4 length
MSG_HANDSHAKE = 0x01
MSG_HANDSHAKE_OK = 0x02
MSG_REQUEST = 0x10
MSG_RESPONSE = 0x11
MSG_PING = 0xFE
MSG_PONG = 0xFF
# Port preference (lowest first; matches DEFAULT_ZAP_PORTS in shared/zap.ts)
DEFAULT_ZAP_PORTS: list[int] = [9999, 9998, 9997, 9996, 9995]
# Wire format — single source of truth is the `zap-protocol` package
# (~/work/zap/zap-py). Import the constants instead of redefining them so
# any future change (e.g. MSG_STREAM, MAX_MESSAGE_SIZE) lands here for free.
from zap.protocol import (
ZAP_MAGIC,
HEADER_SIZE,
MAX_MESSAGE_SIZE,
MSG_HANDSHAKE,
MSG_HANDSHAKE_OK,
MSG_REQUEST,
MSG_RESPONSE,
MSG_PING,
MSG_PONG,
ZAP_PORTS as DEFAULT_ZAP_PORTS,
)
# Browser-resolution preference (mirror cdp-bridge-server.ts)
DEFAULT_BROWSER_PREFERENCE: list[str] = ["firefox", "safari", "edge", "chrome"]
@@ -61,37 +63,8 @@ DEFAULT_BROWSER_PREFERENCE: list[str] = ["firefox", "safari", "edge", "chrome"]
DEFAULT_LEASE_TTL = 60.0
# ---------------------------------------------------------------------------
# Wire format
# ---------------------------------------------------------------------------
def encode(msg_type: int, payload: Any) -> bytes:
"""Encode a ZAP frame.
JSON-encoded payload, length-prefixed, big-endian.
"""
body = json.dumps(payload, separators=(",", ":")).encode("utf-8")
return ZAP_MAGIC + bytes([msg_type]) + struct.pack(">I", len(body)) + body
def decode(data: bytes) -> Optional[tuple[int, Any]]:
"""Decode a single ZAP frame. Returns ``None`` for malformed input.
Caller must hand a complete frame (websockets.recv() guarantees this for
binary messages — frame == one ws message).
"""
if len(data) < HEADER_SIZE:
return None
if data[0:4] != ZAP_MAGIC:
return None
msg_type = data[4]
(length,) = struct.unpack(">I", data[5:9])
if len(data) < HEADER_SIZE + length:
return None
body = data[HEADER_SIZE : HEADER_SIZE + length]
payload = json.loads(body.decode("utf-8")) if length else None
return msg_type, payload
# Wire encode/decode are imported from zap-protocol — see header import block.
from zap.protocol import encode, decode # noqa: F401 (re-exported for callers)
# ---------------------------------------------------------------------------
@@ -277,6 +250,10 @@ class ZapServer:
"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 ------------------------------------------------------
@@ -332,20 +309,20 @@ class ZapServer:
# owns the zeroconf dep) isn't installed.
self._mdns_handle = None
try:
import hanzo_zap_mdns
import zap_mdns
# zeroconf's register_service blocks the event loop briefly
# while it sets up the multicast socket; from inside an
# asyncio coroutine that triggers EventLoopBlocked. Run on
# a worker thread so it never sees our loop.
self._mdns_handle = await asyncio.to_thread(
hanzo_zap_mdns.publish,
zap_mdns.publish,
port=port,
server_id=self.server_id,
agent_label=self.agent_label or "",
version="zap/1",
capabilities=["mcp", "browser-bridge"],
)
logger.info("mDNS published _hanzo-zap._tcp.local. on :%d", port)
logger.info("mDNS published %s on :%d", hanzo_zap_mdns.SERVICE_TYPE, port)
except ImportError:
logger.debug("hanzo-zap-mdns not installed; mDNS publish skipped")
except Exception as _e:
@@ -387,6 +364,18 @@ class ZapServer:
# ---- 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
@@ -645,20 +634,32 @@ class ZapServer:
future.set_result(payload.get("result"))
return
# MSG_REQUEST: extension is calling US (notifications/elementSelected,
# etc.). Acknowledge so the protocol doesn't block.
# 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", "")
# Update last_active
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()
# Notifications are fire-and-forget; everything else gets a stub
# acknowledgement (caller can still install custom handlers via
# ``on_request`` if needed).
if req_id is not None:
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)
+3 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "hanzo-tools-browser"
version = "0.5.0"
version = "0.5.2"
description = "Browser automation tools with ZAP-native extension routing — Python MCP hosts the ZAP server directly, no node bridge in the critical path. Playwright remains as offline fallback."
readme = "README.md"
requires-python = ">=3.12"
@@ -17,6 +17,8 @@ dependencies = [
"pydantic>=2.12.5",
"aiohttp>=3.9.0",
"websockets>=12.0",
"zap-mdns>=0.1.0",
"zap-protocol>=0.2.1",
]
[project.optional-dependencies]