refactor(zap): consume canonical zap-proto, drop hand-rolled wire

hanzo-tools-browser: the zapd router envelope (frame.rs mirror) is no
longer reimplemented — zapd_consumer now uses zap.frame + zap.ZapClient
(dep zap-proto>=1.2.0). The end-to-end browser command codec stays local
and untagged to match the extension's decodeCmd (frame.encode_cmd is
tagged and would corrupt it); a new test proves the byte layout.

hanzo-zap: wire.py now delegates the zero-copy codec (Message/Builder/
ObjectBuilder/object readers) to canonical zap.wire instead of a 3rd
hand-rolled copy; the hanzo cloud-service schema + handshake + frame I/O
remain as a thin layer. dep zap-proto>=1.2.0. 114 tests pass.
This commit is contained in:
2026-06-21 01:29:06 -07:00
parent 490d0b4a84
commit ce23c4e4ad
5 changed files with 178 additions and 229 deletions
@@ -5,137 +5,76 @@ 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.
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.
"""
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
from zap import frame
from zap.client import ZapClient
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
"""The brand-neutral zapd socket; delegates to the canonical resolver."""
return frame.socket_path()
def _encode_cmd(method: str, params: dict) -> bytes:
b = _put_str(method) + struct.pack("<H", len(params))
"""Untagged 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``.
"""
b = frame._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
b += frame._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):
import os
self.agent_id = agent_id or f"consumer:hanzo-mcp/{os.getpid()}"
self._sock: Optional[socket.socket] = None
self._lock = threading.Lock()
self._client: Optional[ZapClient] = None
def connect(self) -> bool:
if self._sock is not None:
if self._client 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)
self._client = ZapClient.connect(
id=self.agent_id, role="consumer", brand="hanzo", timeout=5.0
)
return True
except OSError:
self._sock = None
self._client = 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"])
if not self.connect():
return []
assert self._client is not None
provs = self._client.providers_list(brand=brand)
return [{"id": p.id, "role": p.role, "brand": p.brand, "caps": p.caps} for p in provs]
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:")]
@@ -152,20 +91,17 @@ class ZapdConsumer:
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"]
if not self.connect():
raise RuntimeError("zapd not reachable")
assert self._client is not None
return self._client.route(provider_id, _encode_cmd(method, params), timeout=timeout)
def close(self):
if self._sock is not None:
if self._client is not None:
try:
self._sock.close()
self._client.close()
finally:
self._sock = None
self._client = None
_consumer: Optional[ZapdConsumer] = None
+3
View File
@@ -15,6 +15,9 @@ dependencies = [
"hanzo-tools>=0.3.0",
"mcp>=1.25.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",
]
[project.optional-dependencies]
@@ -1,5 +1,7 @@
"""Tests for hanzo-tools-browser."""
import struct
import pytest
@@ -111,3 +113,54 @@ class TestCdpTool:
)
assert sent["method"] == "Page.navigate"
assert sent["params"]["url"] == "https://example.com"
class TestZapdWire:
"""The consumer delegates the router envelope to canonical ``zap.frame`` and
keeps only the untagged browser-command codec the extension peer decodes."""
def test_uses_canonical_frame(self):
# No hand-rolled envelope: socket_path and the client come from zap.
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)"
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.
"""
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)
# Mirror extension/.../shared/native-zap.ts decodeCmd (little-endian).
o = 0
ml = struct.unpack_from("<H", buf, o)[0]; o += 2
assert buf[o:o + ml].decode() == method; o += ml
n = struct.unpack_from("<H", buf, o)[0]; o += 2
assert n == len(params)
out: dict[str, str] = {}
for _ in range(n):
kl = struct.unpack_from("<H", buf, o)[0]; o += 2
k = buf[o:o + kl].decode(); o += kl
vl = struct.unpack_from("<I", buf, o)[0]; o += 4 # u32 value len, no tag
out[k] = buf[o:o + vl].decode(); o += vl
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)
+73 -119
View File
@@ -1,27 +1,36 @@
"""
luxfi/zap binary wire protocol — Python implementation.
"""ZAP binary wire protocol — the hanzo-zap service layer over canonical ``zap``.
Compatible with the Rust `hanzo-zap` crate and Go `github.com/luxfi/zap` v0.2.0.
The zero-copy codec is NOT reimplemented here. The object encode/decode, the
deferred out-of-line layout, 8-byte alignment, and the 16-byte header all come
from the canonical ``zap.wire`` package (the byte-faithful port of
``zap-proto/go``), the one Python copy of the wire. This module is a thin layer
that keeps hanzo-zap's small API surface (``set_u32``/``obj_uint32`` spellings,
the ``Message`` header view) and adds the hanzo *cloud service* schema
(``MsgType 100`` request/response, the handshake, and length-prefixed frame I/O)
on top of those canonical primitives.
Wire format:
Wire format (defined and owned by ``zap.wire``):
Frame: [4-byte LE length][message bytes]
Message header (16 bytes): magic(4) + version(2) + flags(2) + root_offset(4) + size(4)
Object fields: inline primitives, (relOffset:i32 + length:u32) for text/bytes
relOffset is relative to the field's absolute position in the buffer.
Object fields: inline primitives; (relOffset:u32 + length:u32) for text/bytes,
the offset being relative to the field's absolute position in the buffer.
"""
from __future__ import annotations
import struct
# ── Constants ────────────────────────────────────────────────────────────
from zap import wire as _zw
ZAP_MAGIC = b"ZAP\x00"
HEADER_SIZE = 16
VERSION = 1
ALIGNMENT = 8
# ── Constants ────────────────────────────────────────────────────────────
# Codec constants are sourced from the canonical wire so they can never drift.
ZAP_MAGIC = _zw.MAGIC # b"ZAP\x00"
HEADER_SIZE = _zw.HEADER_SIZE # 16
VERSION = _zw.VERSION # 1
ALIGNMENT = _zw.ALIGNMENT # 8
MAX_MESSAGE_SIZE = 10 * 1024 * 1024 # 10 MB
# hanzo cloud-service schema (layered on the wire; not part of the codec).
MSG_TYPE_CLOUD = 100
# Cloud request field byte offsets (each Text/Bytes = 8 bytes: relOffset + length)
@@ -32,8 +41,8 @@ CLOUD_REQ_BODY = 16
# Cloud response field byte offsets
# Layout: status(0:u32, 4 bytes) + body(4:Bytes, 8 bytes) + error(12:Text, 8 bytes)
CLOUD_RESP_STATUS = 0 # u32 inline (4 bytes)
CLOUD_RESP_BODY = 4 # (relOffset:i32 + length:u32)
CLOUD_RESP_ERROR = 12 # (relOffset:i32 + length:u32)
CLOUD_RESP_BODY = 4 # (relOffset:u32 + length:u32)
CLOUD_RESP_ERROR = 12 # (relOffset:u32 + length:u32)
# Call correlation flags
REQ_FLAG_REQ = 1
@@ -48,7 +57,12 @@ HANDSHAKE_ID_LEN_OFFSET = 60
# ── Message ──────────────────────────────────────────────────────────────
class Message:
"""Parsed ZAP binary message (owns the full buffer including header)."""
"""A parsed ZAP message — a header view over the full buffer.
The object body is read with the canonical :class:`zap.wire.Object` (via the
``obj_*`` helpers below). This wrapper only exposes the 16-byte header fields
in hanzo-zap's vocabulary (``msg_type``, ``root_offset``, ``total_size``).
"""
__slots__ = ("_data",)
@@ -92,130 +106,70 @@ class Message:
return struct.unpack_from("<I", self._data, 12)[0]
# ── Object reader (works on full message buffer with absolute offsets) ──
# ── Object reader (delegates field decode to canonical zap.wire.Object) ──
def obj_uint32(data: bytes, obj_offset: int, field_offset: int) -> int:
"""Read a u32 inline field from object at obj_offset."""
pos = obj_offset + field_offset
if pos + 4 > len(data):
return 0
return struct.unpack_from("<I", data, pos)[0]
"""Read a u32 inline field from the object at ``obj_offset``."""
return _zw.Object(memoryview(data), obj_offset).uint32(field_offset)
def obj_bytes(data: bytes, obj_offset: int, field_offset: int) -> bytes:
"""Read a Bytes field (relOffset:i32 + length:u32) from object."""
pos = obj_offset + field_offset
if pos + 8 > len(data):
return b""
rel_off = struct.unpack_from("<i", data, pos)[0] # signed i32
length = struct.unpack_from("<I", data, pos + 4)[0]
if length == 0 or rel_off == 0:
return b""
abs_off = pos + rel_off
if abs_off < 0 or abs_off + length > len(data):
return b""
return data[abs_off:abs_off + length]
"""Read a Bytes field (relOffset + length) from the object."""
return _zw.Object(memoryview(data), obj_offset).bytes(field_offset)
def obj_text(data: bytes, obj_offset: int, field_offset: int) -> str:
"""Read a Text field from object."""
b = obj_bytes(data, obj_offset, field_offset)
return b.decode("utf-8", errors="replace") if b else ""
"""Read a Text field from the object."""
return _zw.Object(memoryview(data), obj_offset).text(field_offset)
# ── Builder (matches Rust Builder exactly) ───────────────────────────────
# ── Builder (delegates the codec to canonical zap.wire.Builder) ──────────
def _align(pos: int) -> int:
return (pos + ALIGNMENT - 1) & ~(ALIGNMENT - 1)
class ObjectBuilder:
"""hanzo-zap object builder — wraps :class:`zap.wire.ObjectBuilder`.
Keeps hanzo-zap's ``set_u32``/``set_u8`` spellings over the canonical
``set_uint32``/``set_uint8`` so existing schema code is unchanged.
"""
__slots__ = ("_ob",)
def __init__(self, ob: _zw.ObjectBuilder) -> None:
self._ob = ob
def set_u32(self, field_offset: int, v: int) -> None:
self._ob.set_uint32(field_offset, v)
def set_u8(self, field_offset: int, v: int) -> None:
self._ob.set_uint8(field_offset, v)
def set_bytes(self, field_offset: int, data: bytes) -> None:
self._ob.set_bytes(field_offset, data)
def set_text(self, field_offset: int, text: str) -> None:
self._ob.set_text(field_offset, text)
def finish_as_root(self) -> None:
self._ob.finish_as_root()
class Builder:
"""Builds a ZAP message in a single buffer, matching Rust Builder."""
"""hanzo-zap message builder — wraps :class:`zap.wire.Builder`.
Adds the hanzo ``finish(flags)`` convenience (canonical splits this into
``finish`` / ``finish_with_flags``).
"""
__slots__ = ("_b",)
def __init__(self, capacity: int = 256) -> None:
cap = max(capacity, 256)
self._buf = bytearray(cap)
self._buf[:4] = ZAP_MAGIC
struct.pack_into("<H", self._buf, 4, VERSION)
self._pos = HEADER_SIZE
self._root_offset = 0
self._b = _zw.Builder(max(capacity, 256))
def _grow(self, n: int) -> None:
needed = self._pos + n
if needed <= len(self._buf):
return
new_cap = max(len(self._buf) * 2, needed)
self._buf.extend(b"\x00" * (new_cap - len(self._buf)))
def _align_pos(self) -> None:
padding = (ALIGNMENT - (self._pos % ALIGNMENT)) % ALIGNMENT
self._grow(padding)
for _ in range(padding):
self._buf[self._pos] = 0
self._pos += 1
def start_object(self, data_size: int) -> "ObjectBuilder":
self._align_pos()
return ObjectBuilder(self, self._pos, data_size)
def start_object(self, data_size: int) -> ObjectBuilder:
return ObjectBuilder(self._b.start_object(data_size))
def finish(self, flags: int = 0) -> bytes:
struct.pack_into("<H", self._buf, 6, flags)
struct.pack_into("<I", self._buf, 8, self._root_offset)
struct.pack_into("<I", self._buf, 12, self._pos)
return bytes(self._buf[:self._pos])
class ObjectBuilder:
"""Builds a single object within a Builder, matching Rust ObjectBuilder."""
def __init__(self, builder: Builder, start_pos: int, data_size: int) -> None:
self._builder = builder
self._start = start_pos
self._data_size = data_size
self._deferred: list[tuple[int, bytes]] = [] # (field_offset, data)
def _ensure_field(self, end_offset: int) -> None:
needed = self._start + end_offset
if needed > self._builder._pos:
self._builder._grow(needed - self._builder._pos)
for i in range(self._builder._pos, needed):
self._builder._buf[i] = 0
self._builder._pos = needed
def set_u32(self, field_offset: int, v: int) -> None:
self._ensure_field(field_offset + 4)
struct.pack_into("<I", self._builder._buf, self._start + field_offset, v)
def set_u8(self, field_offset: int, v: int) -> None:
self._ensure_field(field_offset + 1)
self._builder._buf[self._start + field_offset] = v & 0xFF
def set_bytes(self, field_offset: int, data: bytes) -> None:
self._ensure_field(field_offset + 8)
pos = self._start + field_offset
if not data:
struct.pack_into("<I", self._builder._buf, pos, 0)
struct.pack_into("<I", self._builder._buf, pos + 4, 0)
return
struct.pack_into("<I", self._builder._buf, pos + 4, len(data))
self._deferred.append((field_offset, data))
def set_text(self, field_offset: int, text: str) -> None:
self.set_bytes(field_offset, text.encode("utf-8"))
def finish_as_root(self) -> None:
"""Finalize and set as root object."""
self._ensure_field(self._data_size)
for field_offset, data in self._deferred:
data_pos = self._builder._pos
self._builder._grow(len(data))
start = self._builder._pos
self._builder._buf[start:start + len(data)] = data
self._builder._pos += len(data)
field_abs = self._start + field_offset
rel_offset = data_pos - field_abs
struct.pack_into("<i", self._builder._buf, field_abs, rel_offset) # signed i32
self._builder._root_offset = self._start
return self._b.finish_with_flags(flags) if flags else self._b.finish()
# ── Cloud service helpers ────────────────────────────────────────────────
+3
View File
@@ -24,6 +24,9 @@ classifiers = [
keywords = ["agent", "ai", "hanzo", "mcp", "protocol", "zap", "zero-copy"]
dependencies = [
"httpx>=0.23.0",
# Canonical zero-copy wire codec (zap.wire). hanzo-zap layers its cloud
# service schema on top of this; the codec is never reimplemented here.
"zap-proto>=1.2.0",
]
[project.urls]