A browser joins the ZAP router through native messaging

hanzo-zap-host is the Chrome native messaging host ai.hanzo.zap: it relays
router envelopes between the extension's stdio and the router's unix socket
and stands for the router itself. hanzo-mcp registers it with every installed
Chromium-family browser on start. hanzo-tools-browser 0.5.13.
This commit is contained in:
z
2026-09-30 10:17:05 -07:00
parent ea4d7b06b4
commit e629d5e539
3 changed files with 179 additions and 5 deletions
@@ -0,0 +1,165 @@
"""The browser's way onto this machine's ZAP router: Chrome native messaging.
A browser extension cannot open a unix socket, so Chrome starts this helper for
it (host ``ai.hanzo.zap``, allowed for the Hanzo extension's id only) and talks
to it over stdio. The helper relays ZAP router envelopes both ways between that
stdio and the router's unix socket. No port and no pairing: Chrome's origin
check and the socket's 0600 owner are the trust.
The helper stands for the router like every other ZAP process
(``zapd.embed()``), so a browser with no other Hanzo process running still
has one.
Native messaging frames are a u32 native-endian length and UTF-8 JSON; each
envelope rides base64 in ``{"z": ...}``. Envelopes on the socket carry their
own little-endian u32 length.
"""
from __future__ import annotations
import base64
import json
import os
import socket
import struct
import sys
import threading
import time
from pathlib import Path
NAME = "ai.hanzo.zap"
EXTENSIONS = ("biingenefmanpecedoafkfajbnlgdmbl",)
# Where each Chromium-family browser looks for a user-level host manifest.
_LINUX = (
"~/.config/google-chrome",
"~/.config/google-chrome-beta",
"~/.config/google-chrome-unstable",
"~/.config/chromium",
"~/.config/BraveSoftware/Brave-Browser",
"~/.config/microsoft-edge",
"~/.config/vivaldi",
)
_DARWIN = (
"~/Library/Application Support/Google/Chrome",
"~/Library/Application Support/Google/Chrome Beta",
"~/Library/Application Support/Chromium",
"~/Library/Application Support/BraveSoftware/Brave-Browser",
"~/Library/Application Support/Microsoft Edge",
"~/Library/Application Support/Vivaldi",
)
def _executable() -> str | None:
"""The installed ``hanzo-zap-host`` beside this interpreter's scripts."""
for d in (Path(sys.executable).parent, Path.home() / ".local" / "bin"):
p = d / "hanzo-zap-host"
if p.exists():
return str(p)
return None
def install() -> list[str]:
"""Register the host with every browser installed for this user.
Idempotent and silent: a browser that is not installed is skipped, and a
manifest already naming this executable is left as it is.
"""
exe = _executable()
if not exe:
return []
manifest = {
"name": NAME,
"description": "Hanzo ZAP: the browser's seat on this machine's router",
"path": exe,
"type": "stdio",
"allowed_origins": [f"chrome-extension://{e}/" for e in EXTENSIONS],
}
body = json.dumps(manifest, indent=2) + "\n"
roots = _DARWIN if sys.platform == "darwin" else _LINUX
written = []
for root in roots:
base = Path(root).expanduser()
if not base.is_dir():
continue
target = base / "NativeMessagingHosts" / f"{NAME}.json"
try:
if target.exists() and target.read_text() == body:
continue
target.parent.mkdir(parents=True, exist_ok=True)
target.write_text(body)
written.append(str(target))
except OSError:
continue
return written
def _read(stream) -> dict | None:
head = stream.read(4)
if len(head) < 4:
return None
(n,) = struct.unpack("=I", head)
data = stream.read(n)
if len(data) < n:
return None
return json.loads(data)
def _dial(path: str, deadline: float) -> socket.socket:
"""The router's socket, waiting out an election in progress."""
while True:
s = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
try:
s.connect(path)
return s
except OSError:
s.close()
if time.monotonic() > deadline:
raise
time.sleep(0.05)
def main() -> None:
# stdout is the native messaging channel: keep a private handle on it and
# point fd 1 at stderr, so nothing else this process prints can corrupt it.
out = os.fdopen(os.dup(1), "wb", buffering=0)
os.dup2(2, 1)
sys.stdout = sys.stderr
import zapd
zapd.embed()
sock = _dial(zapd.socket_path(), time.monotonic() + 10)
lock = threading.Lock()
def up() -> None:
"""Router → browser: split the socket into envelopes, one message each."""
buf = b""
while True:
chunk = sock.recv(1 << 16)
if not chunk:
os._exit(0)
buf += chunk
while len(buf) >= 4:
(n,) = struct.unpack("<I", buf[:4])
if len(buf) < 4 + n:
break
frame, buf = buf[: 4 + n], buf[4 + n :]
msg = json.dumps({"z": base64.b64encode(frame).decode()}, separators=(",", ":")).encode()
with lock:
out.write(struct.pack("=I", len(msg)) + msg)
threading.Thread(target=up, daemon=True).start()
stdin = sys.stdin.buffer
while True:
m = _read(stdin)
if m is None:
os._exit(0)
z = m.get("z")
if isinstance(z, str):
sock.sendall(base64.b64decode(z))
if __name__ == "__main__":
main()
@@ -3,8 +3,10 @@
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``.
``browser/<host>/<name>`` node that joins through Chrome native messaging
(:mod:`.native_host`, registered here on every start), and a command reaches
it with ``Node.call``. The paired door (``hanzo-mcp pair``) is only for
browsers that cannot start a native host.
The one codec here is the browser command payload (:func:`_encode_cmd`). The
router forwards it opaquely; the extension's ``decodeCmd`` reads it.
@@ -22,8 +24,9 @@ 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"
"no browser on the ZAP router: open Chrome with the Hanzo extension (1.9.59+); "
"it joins on its own. A sandboxed browser (snap, Flatpak) pairs instead: "
"`hanzo-mcp pair`, then paste the code into the extension's popup"
)
@@ -54,6 +57,9 @@ class ZapdConsumer:
"""This process's node on the router, and the browsers it can reach."""
def __init__(self, name: Optional[str] = None):
from .native_host import install
install()
zapd.embed()
self.node = zapd.Node(name or f"mcp/hanzo-{os.getpid()}")
+4 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "hanzo-tools-browser"
version = "0.5.12"
version = "0.5.13"
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"
@@ -27,6 +27,9 @@ playwright = [
"playwright>=1.49.0",
]
[project.scripts]
hanzo-zap-host = "hanzo_tools.browser.native_host:main"
[project.entry-points."hanzo.tools"]
browser = "hanzo_tools.browser:TOOLS"