Files
zeekay ec17c308ce chore: initial — hanzo-zap-mdns package
Unified mDNS auto-discovery for ZAP services across hanzo / lux / zoo /
osage.

  mdns/
    pyproject.toml                  hanzo-zap-mdns 0.1.0 (zeroconf dep)
    src/hanzo_zap_mdns/
      __init__.py                   publish() / browse() / ZapService
      omni.py                       roles: mcp / browser / host / gateway
      __main__.py                   `hanzo-zap-mdns browse` CLI
    ext/                            native-messaging helper for browser exts
      helper.py                     stdio host doing publish/browse for
                                    chrome/firefox/safari extensions
      native-messaging-host.json    manifest fragment

Service: `_hanzo-zap._tcp.local.` with TXT keys server_id, agent_label,
version, capabilities, proto. Replaces the brittle [9999..9995] port
probe + lockfile registry the extension was using through 1.9.x.

`pip install -e ~/work/zap/mdns` and the python ZAP server publishes
itself automatically (zap_server.py wired in @hanzo/python-sdk).
2026-05-15 12:18:03 -07:00

114 lines
3.5 KiB
Python
Executable File

#!/usr/bin/env python3
"""Native-messaging host that bridges the browser extension to mDNS.
Browsers (Chrome, Firefox, Edge) don't expose mDNS APIs to extensions. This
small stdio helper does the browse on the host and pipes results back as
length-prefixed JSON over stdin/stdout per the WebExtensions native-messaging
protocol.
Install:
1. Build helper: pyinstaller --onefile helper.py -n hanzo-zap-mdns-helper
2. Copy binary: /usr/local/bin/hanzo-zap-mdns-helper
3. Drop manifest: ~/Library/Application Support/Mozilla/NativeMessagingHosts/
(macOS Firefox) or platform equivalent
4. Extension calls browser.runtime.sendNativeMessage('ai.hanzo.zap_mdns', {})
Protocol (per WebExtensions native-messaging spec):
in: 4-byte LE length | UTF-8 JSON request
out: 4-byte LE length | UTF-8 JSON response
Request:
{ "op": "browse", "timeout_ms"?: 2000 }
{ "op": "publish", "port": N, "server_id": "...", ... } (host-side advertise)
Response:
{ "ok": true, "services": [ {role, server_id, host, port, ... }, ... ] }
"""
import json
import struct
import sys
import os
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', 'python'))
try:
from zap_mdns import browse, publish
except ImportError:
# Allow the helper to ship as a single bundle; the python module path
# may differ when packaged with pyinstaller.
from python.zap_mdns import browse, publish # type: ignore
def read_message():
raw = sys.stdin.buffer.read(4)
if len(raw) < 4:
return None
length = struct.unpack('<I', raw)[0]
body = sys.stdin.buffer.read(length)
return json.loads(body.decode('utf-8'))
def send_message(obj):
body = json.dumps(obj).encode('utf-8')
sys.stdout.buffer.write(struct.pack('<I', len(body)))
sys.stdout.buffer.write(body)
sys.stdout.buffer.flush()
def handle(req: dict) -> dict:
op = req.get('op')
if op == 'browse':
timeout = (req.get('timeout_ms') or 2000) / 1000.0
services = browse(timeout=timeout)
return {
'ok': True,
'services': [
{
'server_id': s.server_id,
'host': s.host,
'port': s.port,
'url': s.url,
'agent_label': s.agent_label,
'version': s.version,
'capabilities': s.capabilities,
'proto': s.proto,
}
for s in services
],
}
if op == 'publish':
# The extension can publish itself as a browser-role service so
# other agents on the LAN can discover this Firefox/Chrome instance.
port = int(req['port'])
server_id = str(req['server_id'])
publish(
port=port,
server_id=server_id,
agent_label=req.get('agent_label', ''),
version=req.get('version', ''),
capabilities=req.get('capabilities', []),
)
return {'ok': True}
return {'ok': False, 'error': f'unknown op: {op}'}
def main():
while True:
try:
req = read_message()
except Exception as e:
send_message({'ok': False, 'error': f'read: {e}'})
return
if req is None:
return # browser closed channel
try:
resp = handle(req)
except Exception as e:
resp = {'ok': False, 'error': str(e)}
send_message(resp)
if __name__ == '__main__':
main()