ext 1.9.21: native ZAP is the one transport (Chrome+Firefox)

connectNativeZap (shared/native-zap.ts) registers the browser as a zapd provider
over native messaging and dispatches inbound ROUTE commands. Renames cdp-bridge.ts
-> browser-dispatch.ts; deletes the cdp-bridge-server.ts WS bridge. No ws://localhost,
no port roulette, no CDP fallback in the default boot path. Patch bump (no 2.0).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Antje Worring
2026-06-16 22:03:00 -07:00
co-authored by Claude Opus 4.8
parent 3bb7d57a9f
commit 03e081e83a
9 changed files with 214 additions and 1260 deletions
+28 -26
View File
@@ -15,38 +15,40 @@ pnpm install && pnpm build
pnpm test
```
## Architecture (since browser-extension 2.0.0)
## Architecture (browser-extension 1.9.x — native ZAP)
Two-process: each Python `hanzo-mcp` hosts a ZAP server directly. The
extension discovers it on the lowest free port from
`[9999, 9998, 9997, 9996, 9995]` and dispatches commands over a binary
WebSocket frame:
The extension connects to the **one shared local `zapd` router** (see
`~/work/zap`) through exactly one browser-native primitive — there is **no**
WebSocket, no localhost port, no mDNS probing, no CDP bridge:
```
[0x5A 0x41 0x50 0x01][type:1][length:4 BE][JSON payload]
extension ─connectNative("ai.hanzo.zap")─► native host ─UDS─► zapd ◄─ hanzo-mcp
```
- **shared/zap.ts** — canonical wire constants (MSG_REQUEST=0x10,
MSG_RESPONSE=0x11, MSG_PING=0xFE, MSG_PONG=0xFF). Both Chrome and
Firefox extensions use this. Includes `setZapRequestHandler(mgr, fn)`
so the Python server can dispatch RPCs back to the extension.
- **background.ts** (Chrome) — wires `cdpBridge.dispatchMethod` as the
ZAP inbound handler.
- **background-firefox.ts** — wires `HanzoFirefoxExtension.dispatchMethod`
as the ZAP inbound handler.
- **cdp-bridge-server.ts** — DEPRECATED. Legacy node bridge on
`:9223/9224`. Fallback only; not in the critical path.
- **shared/native-zap.ts** — the ONE transport: `connectNativeZap()` opens the
native-messaging port, registers as a provider (`browser:<chrome|firefox>/default`,
the host stamps the device hostname), and dispatches inbound ROUTE commands.
Cross-browser (`browser ∥ chrome`).
- **background.ts** (Chrome) — `connectNativeZap(..., cdpBridge.dispatchMethod)`.
Dispatch still uses `chrome.debugger` (CDP-to-tab) → Chrome's "debugging this
browser" banner. **TODO:** native `browser.*` dispatch for banner-light parity.
- **background-firefox.ts** — `connectNativeZap(..., hanzoExtension.dispatchMethod)`.
Already native `browser.*` (no banner). Same transport as Chrome.
- **browser-dispatch.ts** (was `cdp-bridge.ts`) — `chrome.debugger` actuation
(`dispatchMethod`). The legacy `cdp-bridge-server.ts` WS bridge is **deleted**.
- **shared/zap.ts** — legacy WebSocket ZAP wire; retained only for unrelated
helpers (RAG/event broadcast no-op when disconnected). NOT the transport.
Wire constants must stay locked across implementations:
- `python-sdk/pkg/hanzo-tools-browser/hanzo_tools/browser/zap_server.py`
- `extension/packages/browser/src/shared/zap.ts`
- `extension/packages/mcp/src/zap-server.ts`
Native-host manifests: Chrome `…/Google/Chrome/NativeMessagingHosts/ai.hanzo.zap.json`
(`allowed_origins`), Firefox `…/Mozilla/NativeMessagingHosts/ai.hanzo.zap.json`
(`allowed_extensions: ["hanzo-ai@hanzo.ai"]`). Both `path``~/work/zap/host/zap_host.sh`.
If you change them, change all three and update `tests/shared-zap.test.ts`
+ `tests/test_zap_server.py::TestWireFormat::test_constants_match_extension`.
The wire to zapd is the **binary ZAP router envelope** (`len|type|flags|from|to|payload`,
defined in `zap-proto/zapd/src/frame.rs`); the opaque command payload is a compact
binary codec. The browser↔host hop is native-messaging JSON (Chrome-forced), base64'd,
quarantined in the host.
## Versioning rule
Bump patch (X.Y.Z+1) for protocol-compatible changes. Bump major (e.g.
1.x → 2.0) only when the wire format changes or the architecture pivots
(2.0.0 = ZAP becomes canonical, dropping the node bridge from default
boot). `package.json` and both `manifest*.json` must agree.
Always bump **patch** (X.Y.Z → X.Y.Z+1). Never bump major — the ZAP
transport pivot ships as ordinary patches, not a 2.0. `package.json` and
both `manifest*.json` must agree.
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@hanzo/browser-extension",
"version": "1.9.20",
"version": "1.9.21",
"description": "Hanzo AI Browser Extension",
"main": "dist/background.js",
"scripts": {
+16 -19
View File
@@ -20,16 +20,13 @@ import 'webextension-polyfill';
import type { ControlSession, RagSnippet, RagQueryParams } from './shared/types.js';
import {
createZapManager,
discoverZapServers,
startZapDiscoveryLoop,
connectZap,
zapRequest,
zapCallTool,
hasZapTool,
handleZapMessage,
setZapRequestHandler,
type ZapManager,
} from './shared/zap.js';
import { connectNativeZap, type NativeZapState } from './shared/native-zap.js';
import {
getRagConfig,
normalizeRagSnippets,
@@ -3024,18 +3021,18 @@ browser.runtime.onMessage.addListener((request: any, sender: any, sendResponse:
const hanzoExtension = new HanzoFirefoxExtension();
// Install the ZAP inbound-request handler so Python hanzo-mcps can dispatch
// browser actions BACK to Firefox over the same socket. This is what
// closes the loop: the extension is no longer ZAP-client-only — it accepts
// inbound RPC and routes via the canonical executeMethod dispatcher.
setZapRequestHandler(zapMgr, async (method, params) => {
return hanzoExtension.dispatchMethod(method, (params || {}) as Record<string, unknown>);
});
// Start the always-on ZAP discovery loop. Ticks every 5s; each tick is a
// no-op when we're already connected to every advertised server. Replaces
// the earlier setTimeout chain that died if any path missed a re-arm.
startZapDiscoveryLoop(zapMgr, BROWSER_NAME, VERSION, 5000);
// Connect to the local zapd router via the ai.hanzo.zap native host and
// register Firefox as a browser provider the SAME transport Chrome uses.
// Inbound ROUTE commands dispatch to Firefox's native browser.* dispatcher.
const BROWSER_CAPS = ['browser.tabs', 'browser.navigate', 'browser.dom', 'browser.screenshot', 'browser.input'];
const nativeZap: NativeZapState = {
connected: false,
port: null,
providerId: `browser:${BROWSER_NAME}/default`,
};
const browserDispatch = (method: string, params: any) =>
hanzoExtension.dispatchMethod(method, (params || {}) as Record<string, unknown>);
connectNativeZap(nativeZap, 'hanzo', BROWSER_CAPS, browserDispatch, console.log);
// =============================================================================
// Keep-alive — two-layer:
@@ -3066,9 +3063,9 @@ try {
browser.alarms.create(KEEP_ALIVE_ALARM, { periodInMinutes: 0.25 });
browser.alarms.onAlarm.addListener((alarm) => {
if (alarm.name !== KEEP_ALIVE_ALARM) return;
if (!zapMgr.state.connected) {
console.log('[Hanzo/ZAP] keep-alive alarm: no connection, rediscovering');
void discoverZapServers(zapMgr, BROWSER_NAME, VERSION);
if (!nativeZap.connected) {
console.log('[Hanzo/ZAP] keep-alive: reconnecting native host');
connectNativeZap(nativeZap, 'hanzo', BROWSER_CAPS, browserDispatch, console.log);
}
});
} catch (e) {
+20 -83
View File
@@ -11,13 +11,11 @@ import type { ZapState, ControlSession, RagSnippet, RagQueryParams } from './sha
import {
createZapManager,
encodeZapMessage,
discoverZapServers,
startZapDiscoveryLoop,
handleZapMessage,
setZapRequestHandler,
MSG_REQUEST,
type ZapManager,
} from './shared/zap.js';
import { connectNativeZap, type NativeZapState } from './shared/native-zap.js';
import {
getRagConfig as getSharedRagConfig,
normalizeRagSnippets,
@@ -38,7 +36,7 @@ import {
const IAM_V1 = `${IAM_API_URL}${IAM_API_PATH}`;
import { BrowserControl } from './browser-control';
import { WebGPUAI } from './webgpu-ai';
import { getCDPBridge, CDPBridge } from './cdp-bridge';
import { getCDPBridge, CDPBridge } from './browser-dispatch';
import * as auth from './auth';
import { listModels, chatCompletion, ChatMessage } from './chat-client';
import { PageMonitor } from './page-monitor';
@@ -1792,51 +1790,6 @@ async function handleMessage(request: any, sender: chrome.runtime.MessageSender,
}
}
// =============================================================================
// Legacy MCP WebSocket (fallback when no ZAP gateway running)
// =============================================================================
// Off by default — ZAP mDNS is the canonical transport. Flip on for local
// dev against a hanzo-mcp WS server or a CDP bridge running on the host.
const ENABLE_LEGACY_TRANSPORTS = true;
let ws: WebSocket | null = null;
async function connectToMCP() {
if (!ENABLE_LEGACY_TRANSPORTS) return;
const { mcpPort } = await getPortConfig();
ws = new WebSocket(`ws://localhost:${mcpPort}/browser-extension`);
ws.onopen = () => {
debugLog('[Hanzo] Connected to legacy MCP server');
};
ws.onmessage = (event) => {
const data = JSON.parse(event.data);
handleMCPMessage(data);
};
ws.onerror = () => {
// Silent — ZAP is primary, this is fallback
};
ws.onclose = () => {
setTimeout(connectToMCP, 5000);
};
}
function handleMCPMessage(data: any) {
switch (data.type) {
case 'browserControl':
chrome.tabs.query({ active: true, currentWindow: true }, (tabs) => {
if (tabs[0]?.id) {
browserControl.launchAIWorker(tabs[0].id, data.model);
}
});
break;
}
}
// =============================================================================
// Startup
// =============================================================================
@@ -1855,17 +1808,19 @@ chrome.tabs.onRemoved.addListener((tabId) => {
}
});
// 1. Primary: Discover ZAP servers (high-performance binary protocol)
// Install a request handler so Python hanzo-mcps can dispatch browser
// actions BACK to this extension over the same socket. This is what
// makes the 2-process architecture work — the extension is no longer
// a one-way client; it accepts inbound RPC and routes via the existing
// cdp-bridge dispatcher.
setZapRequestHandler(zapMgr, async (method, params) => {
return cdpBridge.dispatchMethod(method, params);
});
// mDNS-only discovery per HIP-0069 — always-on watchdog, no port hint.
startZapDiscoveryLoop(zapMgr, BROWSER_NAME, VERSION, 5000, debugLog);
// 1. Primary: connect to the local zapd router via the ai.hanzo.zap native
// host and register as a browser provider. Inbound ROUTE commands are
// dispatched to the browser. Exactly one transport — no WebSocket, no
// localhost ports, no mDNS probing; the browser can never get a refused
// connection (connectNative attaches the host or fails once, cleanly).
const BROWSER_CAPS = ['browser.tabs', 'browser.navigate', 'browser.dom', 'browser.screenshot', 'browser.input'];
const nativeZap: NativeZapState = {
connected: false,
port: null,
providerId: `browser:${BROWSER_NAME}/default`,
};
const browserDispatch = (method: string, params: any) => cdpBridge.dispatchMethod(method, params);
connectNativeZap(nativeZap, 'hanzo', BROWSER_CAPS, browserDispatch, debugLog);
// Keep-alive — Layer 1: Port. Content scripts in every loaded http(s) tab
// hold a `zap-keepalive` port. As long as ANY tab has one, Chrome keeps
@@ -1875,40 +1830,22 @@ chrome.runtime.onConnect.addListener((port) => {
port.onMessage.addListener(() => { /* receipt is the keep-alive */ });
});
// Keep-alive — Layer 2: Alarms. Belt-and-suspenders for the case where
// no http(s) tab is open AND the worker idles out. Alarm fires every 30s,
// wakes the worker, re-discovers if the ZAP socket is gone.
// Keep-alive — Layer 2: Alarms. Wake the worker periodically; if the native
// host link dropped (or the worker was idled out), reconnect.
const KEEP_ALIVE_ALARM = 'hanzo-zap-keepalive';
try {
// Wakes the worker every 15s; one-shot discovery probe if disconnected.
chrome.alarms.create(KEEP_ALIVE_ALARM, { periodInMinutes: 0.25 });
chrome.alarms.onAlarm.addListener((alarm) => {
if (alarm.name !== KEEP_ALIVE_ALARM) return;
if (!zapMgr.state.connected) {
debugLog('[Hanzo/ZAP] keep-alive alarm: no connection, rediscovering via mDNS');
void discoverZapServers(zapMgr, BROWSER_NAME, VERSION, undefined, debugLog);
if (!nativeZap.connected) {
debugLog('[Hanzo/ZAP] keep-alive: reconnecting native host');
connectNativeZap(nativeZap, 'hanzo', BROWSER_CAPS, browserDispatch, debugLog);
}
});
} catch (e) {
console.warn('[Hanzo] alarms unavailable:', e);
}
// 2. Fallback: Connect to legacy MCP WebSocket (gated; ZAP is primary)
if (ENABLE_LEGACY_TRANSPORTS) {
connectToMCP();
}
// 3. CDP bridge for browser control (configurable port) — legacy; ZAP is primary
if (ENABLE_LEGACY_TRANSPORTS) {
getPortConfig().then(({ cdpPort }) => {
try {
cdpBridge.startWebSocketServer(cdpPort);
debugLog(`[Hanzo] CDP bridge connecting to ws://localhost:${cdpPort}/cdp`);
} catch (e) {
console.error('[Hanzo] Failed to start CDP bridge:', e);
}
});
}
// Export for testing
export { browserControl, webgpuAI, zapMgr, zapState };
@@ -314,78 +314,10 @@ export class CDPBridge {
return true;
}
// WebSocket server for external connections (hanzo-mcp)
startWebSocketServer(port: number = 9223): void {
// Note: Chrome extensions can't create native WebSocket servers
// Instead, we connect to an external bridge server
this.connectToBridge(`ws://localhost:${port}/cdp`);
}
private connectToBridge(url: string): void {
try {
this.wsServer = new WebSocket(url);
this.wsServer.onopen = () => {
debugLog('[CDP] Connected to bridge server');
// Register as CDP provider with browser identification
const browser = typeof navigator !== 'undefined'
? (navigator.userAgent.includes('Firefox') ? 'firefox'
: navigator.userAgent.includes('Edg/') ? 'edge'
: navigator.userAgent.includes('Chrome') ? 'chrome'
: navigator.userAgent.includes('Safari') ? 'safari'
: 'unknown')
: 'unknown';
this.wsServer?.send(JSON.stringify({
type: 'register',
role: 'cdp-provider',
browser,
capabilities: ['navigate', 'screenshot', 'click', 'type', 'evaluate', 'takeover']
}));
};
this.wsServer.onmessage = async (event) => {
try {
const message = JSON.parse(event.data);
// Legacy bridge client removed — the extension reaches the local zapd router
// via the ai.hanzo.zap native host. dispatchMethod() below remains the
// inbound browser-command dispatcher.
// Handle extension-request from bridge server (monitor/audit calls)
if (message.type === 'extension-request' && message.action) {
chrome.runtime.sendMessage(
{ action: message.action, ...(message.params || {}) },
(result) => {
this.wsServer?.send(JSON.stringify({
type: 'extension-response',
id: message.id,
result: result || { error: chrome.runtime.lastError?.message },
}));
}
);
return;
}
const response = await this.handleBridgeMessage(message);
this.wsServer?.send(JSON.stringify(response));
} catch (error: any) {
this.wsServer?.send(JSON.stringify({
id: 0,
error: { code: -32603, message: error.message }
}));
}
};
this.wsServer.onerror = (error) => {
console.error('[CDP] Bridge connection error:', error);
};
this.wsServer.onclose = () => {
debugLog('[CDP] Bridge disconnected, reconnecting in 5s...');
setTimeout(() => this.connectToBridge(url), 5000);
};
} catch (error) {
console.error('[CDP] Failed to connect to bridge:', error);
setTimeout(() => this.connectToBridge(url), 5000);
}
}
/**
* Public entry point for ZAP-server-initiated requests.
*
-10
View File
@@ -109,16 +109,6 @@ async function build() {
packages: 'external'
});
// Build CDP Bridge Server (WebSocket + HTTP API for hanzo-mcp integration)
await esbuild.build({
entryPoints: ['src/cdp-bridge-server.ts'],
bundle: true,
outfile: 'dist/browser-extension/cdp-bridge-server.js',
platform: 'node',
target: 'node16',
packages: 'external'
});
// Make CLI executable
if (process.platform !== 'win32') {
execSync('chmod +x dist/browser-extension/cli.js');
-961
View File
@@ -1,961 +0,0 @@
#!/usr/bin/env node
/**
* CDP Bridge Server DEPRECATED in @hanzo/browser-extension 1.9.1.
*
* The canonical 2-process architecture removes this node bridge from the
* critical path. Each Python `hanzo-mcp` now hosts a ZAP server directly
* (one of ports 9999..9995); browser extensions discover and connect to
* those MCPs without any intermediary. See
* `python-sdk/pkg/hanzo-tools-browser/hanzo_tools/browser/zap_server.py`.
*
* This file remains only as a fallback for non-ZAP MCP clients (legacy
* stdio-only consumers that still want to talk to the extension via the
* old WS+HTTP API on 9223/9224). It is no longer auto-launched. Do not
* extend it for new functionality extend the Python ZAP server and the
* extension's `shared/zap.ts` instead.
*
* Provides hanzo.browser(action, params) interface matching hanzo-mcp pattern.
* Browser extension connects to this server for remote control.
*/
import { WebSocketServer, WebSocket } from 'ws';
import * as readline from 'readline';
import * as fs from 'fs';
import * as path from 'path';
import { unwrapEvaluateResult } from './shared/tab-id.js';
interface CDPClient {
ws: WebSocket;
id: string; // Unique ID: "chrome", "firefox", "chrome-2", etc.
capabilities: string[];
browser: string; // Browser type: "chrome", "firefox", "safari", "edge"
tabId?: number;
}
interface BrowserCommand {
action: string;
// Navigation
url?: string;
// Selectors
selector?: string;
ref?: string;
element?: string;
// Input
text?: string;
value?: string;
key?: string;
// Screenshot
fullPage?: boolean;
format?: 'png' | 'jpeg';
filename?: string;
// Evaluate
code?: string;
function?: string;
expression?: string;
// Tabs
tabId?: number;
tabIndex?: number;
// Wait
timeout?: number;
state?: string;
// Browser targeting (multi-browser support)
browser?: string; // Target browser: "chrome", "firefox", "safari", or unique ID
// Generic
[key: string]: any;
}
class CDPBridgeServer {
private wss: WebSocketServer;
private clients: Map<WebSocket, CDPClient> = new Map();
private pendingCommands: Map<number, { resolve: Function; reject: Function }> = new Map();
private pendingExtensionRequests: Map<string, { resolve: Function; timeout: ReturnType<typeof setTimeout> }> = new Map();
private commandId = 0;
private httpPort: number;
constructor(port: number = 9223) {
this.httpPort = port;
// Restore persisted defaultBrowser so user's "always Firefox" preference
// survives bridge restarts. The config file is written by the
// `config` message handler.
try {
const cfgPath = path.join(
process.env.HOME || process.env.USERPROFILE || '.',
'.hanzo', 'extension', 'config.json',
);
if (fs.existsSync(cfgPath)) {
const cfg = JSON.parse(fs.readFileSync(cfgPath, 'utf-8'));
if (cfg && typeof cfg.defaultBrowser === 'string') {
this.defaultBrowser = cfg.defaultBrowser;
}
}
} catch { /* ignore — startup config is best-effort */ }
this.wss = new WebSocketServer({ port, path: '/cdp' });
this.wss.on('connection', (ws) => {
console.log('[hanzo.browser] Extension connected');
ws.on('message', (data) => {
try {
const message = JSON.parse(data.toString());
this.handleMessage(ws, message);
} catch (error: any) {
console.error('[hanzo.browser] Error:', error.message);
}
});
ws.on('close', () => {
console.log('[hanzo.browser] Extension disconnected');
this.clients.delete(ws);
});
ws.on('error', (error) => {
console.error('[hanzo.browser] WebSocket error:', error);
});
});
console.log(`[hanzo.browser] Server listening on ws://localhost:${port}/cdp`);
}
/** Assign a unique ID for a browser type (e.g. "chrome", "chrome-2", "firefox") */
private assignClientId(browser: string): string {
const existing = Array.from(this.clients.values())
.filter((c) => c.browser === browser);
if (existing.length === 0) return browser;
// Find next available suffix
for (let i = 2; ; i++) {
const candidate = `${browser}-${i}`;
if (!existing.some((c) => c.id === candidate)) return candidate;
}
}
/**
* Default browser preference order when caller doesn't specify a target.
* Firefox first because (a) it's the user's primary browser, (b) it's
* where authenticated sessions typically live (Cloudflare, Porkbun,
* hanzo.id), (c) Chrome is often a secondary/clean profile that the
* agent should never silently land on.
*
* Override at runtime by writing the `defaultBrowser` config via the
* `config` action, e.g. `{ action: 'config', key: 'defaultBrowser',
* value: 'chrome' }`. The override is also persisted to
* `~/.hanzo/extension/config.json` so it survives bridge restarts.
*/
private static DEFAULT_BROWSER_PREFERENCE = ['firefox', 'safari', 'edge', 'chrome'] as const;
private defaultBrowser: string | null = null;
/** Find a client by browser name/ID. Without a target, picks the
* configured default browser (defaults to Firefox) or the first
* preferred-order match among connected clients. */
private resolveClient(target?: string): CDPClient | undefined {
if (target) {
// Exact match on ID first (e.g. "chrome-2")
for (const client of this.clients.values()) {
if (client.id === target) return client;
}
// Then match by browser type (returns first of that type)
for (const client of this.clients.values()) {
if (client.browser === target) return client;
}
return undefined;
}
// No target — prefer the user-configured default browser if connected.
if (this.defaultBrowser) {
for (const client of this.clients.values()) {
if (client.id === this.defaultBrowser || client.browser === this.defaultBrowser) {
return client;
}
}
}
// Fall back to the standard preference order. Firefox first because
// the user's authenticated sessions live there.
for (const browser of CDPBridgeServer.DEFAULT_BROWSER_PREFERENCE) {
for (const client of this.clients.values()) {
if (client.browser === browser) return client;
}
}
// Last resort: any connected client.
return this.clients.values().next().value;
}
private handleMessage(ws: WebSocket, message: any) {
if (message.type === 'register') {
const browser = message.browser || 'unknown';
const id = this.assignClientId(browser);
this.clients.set(ws, {
ws,
id,
capabilities: message.capabilities || [],
browser,
});
console.log(`[hanzo.browser] Registered ${id} (${browser}):`, message.capabilities?.join(', '));
// Notify the extension of its assigned ID
ws.send(JSON.stringify({ type: 'registered', id, browser }));
return;
}
if (message.type === 'config') {
// Special-case: defaultBrowser is read by resolveClient() so apply it
// immediately in addition to persisting it. Without this every config
// change would require a bridge restart.
if (message.key === 'defaultBrowser') {
this.defaultBrowser = (message.value as string) || null;
}
this.saveConfig(message.key, message.value);
return;
}
// Handle responses from extension for sendToExtension() calls
if (message.type === 'extension-response' && message.id) {
const pending = this.pendingExtensionRequests.get(message.id);
if (pending) {
clearTimeout(pending.timeout);
this.pendingExtensionRequests.delete(message.id);
pending.resolve(message.result || message);
}
return;
}
if (message.id !== undefined) {
const pending = this.pendingCommands.get(message.id);
if (pending) {
if (message.error) {
pending.reject(new Error(message.error.message));
} else {
pending.resolve(message.result);
}
this.pendingCommands.delete(message.id);
}
return;
}
if (message.type === 'event') {
console.log(`[hanzo.browser] Event: ${message.method}`);
}
}
/** Save a config value to ~/.hanzo/extension/config.json */
private saveConfig(key: string, value: unknown): void {
const configDir = path.join(
process.env.HOME || process.env.USERPROFILE || '.',
'.hanzo',
'extension',
);
const configPath = path.join(configDir, 'config.json');
try {
fs.mkdirSync(configDir, { recursive: true });
let config: Record<string, unknown> = {};
if (fs.existsSync(configPath)) {
config = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
}
config[key] = value;
fs.writeFileSync(configPath, JSON.stringify(config, null, 2) + '\n');
console.log(`[hanzo.browser] Config saved: ${key} = ${JSON.stringify(value)}`);
} catch (error: any) {
console.error(`[hanzo.browser] Failed to save config: ${error.message}`);
}
}
/** Get connected browser names */
getConnectedBrowsers(): string[] {
return Array.from(this.clients.values())
.map((c) => c.id || c.browser || 'unknown')
.filter((b) => b !== 'unknown');
}
/** Get detailed info about all connected browsers */
getConnectedBrowserDetails(): Array<{ id: string; browser: string; capabilities: string[] }> {
return Array.from(this.clients.values()).map((c) => ({
id: c.id,
browser: c.browser,
capabilities: c.capabilities,
}));
}
private async sendRaw(method: string, params?: any, targetBrowser?: string): Promise<any> {
const client = this.resolveClient(targetBrowser);
if (!client) {
const connected = this.getConnectedBrowsers();
throw new Error(
targetBrowser
? `Browser "${targetBrowser}" not connected. Connected: [${connected.join(', ')}]`
: 'No browser extension connected'
);
}
const id = ++this.commandId;
return new Promise((resolve, reject) => {
this.pendingCommands.set(id, { resolve, reject });
client.ws.send(JSON.stringify({ id, method, params }));
setTimeout(() => {
if (this.pendingCommands.has(id)) {
this.pendingCommands.delete(id);
reject(new Error(`Command timeout (browser: ${client.id})`));
}
}, 30000);
});
}
/**
* Unified hanzo.browser interface - matches mcp__hanzo__browser pattern
*/
async browser(params: BrowserCommand): Promise<any> {
const { action, browser: targetBrowser, ...rest } = params;
// Special action: list connected browsers
if (action === 'list_browsers' || action === 'browsers') {
return {
browsers: this.getConnectedBrowserDetails(),
count: this.clients.size,
defaultBrowser: this.defaultBrowser,
preferenceOrder: CDPBridgeServer.DEFAULT_BROWSER_PREFERENCE,
};
}
// Special action: set the default browser the bridge auto-routes to
// when no per-call `browser` target is specified. Persisted to
// ~/.hanzo/extension/config.json so it survives restarts.
if (action === 'set_default_browser' || action === 'use_browser') {
const target = (params.browser || params.value || params.target) as string | undefined;
if (!target) {
return { ok: false, error: 'browser/value/target required (e.g. "firefox")' };
}
this.defaultBrowser = target;
this.saveConfig('defaultBrowser', target);
return {
ok: true,
defaultBrowser: this.defaultBrowser,
connected: this.getConnectedBrowserDetails(),
};
}
// Shorthand: b = targetBrowser for all sendRaw/sendToExtension calls below
const b = targetBrowser;
// Universal tab targeting: a `tabId` or `tabIndex` on the incoming command
// applies to every downstream sendRaw call so the user can pin a specific
// tab without repeating it. Without this, every action falls back to
// `chrome.tabs.query({active:true,currentWindow:true})[0]` — which is
// fragile when the user has many windows / a chrome://newtab in focus.
const tabTarget = (extra?: Record<string, unknown>) => {
const out: Record<string, unknown> = { ...(extra || {}) };
if (rest.tabId !== undefined) out.tabId = rest.tabId;
if (rest.tabIndex !== undefined) out.tabIndex = rest.tabIndex;
if (rest.targetId !== undefined && out.tabId === undefined) out.tabId = rest.targetId;
return out;
};
switch (action) {
// Navigation
case 'navigate':
return this.sendRaw('Page.navigate', tabTarget({ url: rest.url }), b);
case 'navigate_back':
case 'go_back':
return this.sendRaw('Page.goBack', tabTarget(), b);
case 'navigate_forward':
case 'go_forward':
return this.sendRaw('Page.goForward', tabTarget(), b);
case 'reload':
return this.sendRaw('Page.reload', tabTarget(), b);
case 'url':
return this.sendRaw('hanzo.url', tabTarget(), b);
case 'title':
return this.sendRaw('hanzo.title', tabTarget(), b);
case 'tab_info':
return this.sendRaw('hanzo.tabInfo', tabTarget(), b);
case 'content':
return this.sendRaw('Runtime.evaluate', tabTarget({
expression: 'document.documentElement.outerHTML',
returnByValue: true
}), b);
// Screenshots
case 'screenshot': {
const screenshot = await this.sendRaw('hanzo.screenshot', tabTarget({
format: rest.format || 'png',
fullPage: rest.fullPage,
}), b);
if (rest.filename && screenshot?.data) {
const buffer = Buffer.from(screenshot.data, 'base64');
fs.writeFileSync(rest.filename, buffer);
return { saved: rest.filename, bytes: buffer.length, data: screenshot.data };
}
return screenshot;
}
case 'snapshot':
// Accessibility snapshot
return this.sendRaw('Accessibility.getFullAXTree', tabTarget(), b);
// Input - Click
case 'click':
return this.sendRaw('hanzo.click', tabTarget({
selector: rest.selector || rest.ref,
}), b);
case 'dblclick':
case 'double_click':
return this.sendRaw('hanzo.dblclick', tabTarget({
selector: rest.selector || rest.ref,
}), b);
case 'hover':
return this.sendRaw('hanzo.hover', tabTarget({
selector: rest.selector || rest.ref,
}), b);
// Input - Type
case 'type':
return this.sendRaw('hanzo.type', tabTarget({
selector: rest.selector || rest.ref,
text: rest.text,
}), b);
case 'fill':
return this.sendRaw('hanzo.fill', tabTarget({
selector: rest.selector || rest.ref,
value: rest.value || rest.text,
}), b);
case 'clear':
return this.sendRaw('hanzo.clear', tabTarget({
selector: rest.selector || rest.ref,
}), b);
case 'press_key':
case 'press':
return this.sendRaw('Input.dispatchKeyEvent', tabTarget({
type: 'keyDown',
key: rest.key,
}), b);
// Forms
case 'select_option':
return this.sendRaw('hanzo.select', tabTarget({
selector: rest.selector || rest.ref,
value: rest.value,
}), b);
case 'check':
return this.sendRaw('hanzo.check', tabTarget({
selector: rest.selector || rest.ref,
}), b);
case 'uncheck':
return this.sendRaw('hanzo.uncheck', tabTarget({
selector: rest.selector || rest.ref,
}), b);
// Aliases for the navigation cases above. We attach `get_url`,
// `get_title`, `get_tab_info`, `get_history`, `wait_for_navigation`,
// `create_tab` here. Note that `go_back`, `go_forward`, `tab_info`,
// and `history` are already aliased in the upper block — TS picks
// the first matching case-label so duplicates here would be dead
// code. Consolidating once and for all to avoid that confusion.
case 'get_url':
return this.sendRaw('hanzo.url', tabTarget(), b);
case 'get_title':
return this.sendRaw('hanzo.title', tabTarget(), b);
case 'get_tab_info':
return this.sendRaw('hanzo.tabInfo', tabTarget(), b);
case 'get_history':
return this.sendRaw('hanzo.getHistory', tabTarget({ maxResults: rest.maxResults }), b);
case 'wait_for_navigation':
return this.sendRaw('hanzo.waitForNavigation', tabTarget({ timeout: rest.timeout }), b);
case 'create_tab':
return this.sendRaw('Target.createTarget', { url: rest.url || 'about:blank' }, b);
// Fetch through browser context (uses page cookies/auth)
case 'fetch': {
const fetchResult = await this.sendRaw('hanzo.fetch', {
url: rest.url,
options: {
method: rest.method || rest.options?.method,
headers: rest.headers || rest.options?.headers,
body: rest.body || rest.options?.body,
mode: rest.mode || rest.options?.mode,
credentials: rest.credentials || rest.options?.credentials,
},
}, b);
return fetchResult;
}
// Selectors / Waiting
case 'wait_for_selector':
return this.sendRaw('hanzo.waitForSelector', { selector: rest.selector, timeout: rest.timeout }, b);
case 'query_selector_all':
return this.sendRaw('hanzo.querySelectorAll', { selector: rest.selector }, b);
case 'get_element_info':
return this.sendRaw('hanzo.getElementInfo', { selector: rest.selector }, b);
case 'get_page_info':
return this.sendRaw('hanzo.getPageInfo', {}, b);
// DOM Read/Write/Observe
case 'get_html':
return this.sendRaw('hanzo.getHTML', { selector: rest.selector, outer: rest.outer }, b);
case 'set_html':
return this.sendRaw('hanzo.setHTML', { selector: rest.selector, html: rest.html, outer: rest.outer }, b);
case 'get_text':
return this.sendRaw('hanzo.getText', { selector: rest.selector }, b);
case 'set_text':
return this.sendRaw('hanzo.setText', { selector: rest.selector, text: rest.text }, b);
case 'get_attribute':
return this.sendRaw('hanzo.getAttribute', { selector: rest.selector, attr: rest.attr }, b);
case 'set_attribute':
return this.sendRaw('hanzo.setAttribute', { selector: rest.selector, attr: rest.attr, value: rest.value }, b);
case 'remove_attribute':
return this.sendRaw('hanzo.removeAttribute', { selector: rest.selector, attr: rest.attr }, b);
case 'set_style':
return this.sendRaw('hanzo.setStyle', { selector: rest.selector, styles: rest.styles }, b);
case 'add_class':
return this.sendRaw('hanzo.addClass', { selector: rest.selector, classNames: rest.classNames }, b);
case 'remove_class':
return this.sendRaw('hanzo.removeClass', { selector: rest.selector, classNames: rest.classNames }, b);
case 'insert_element':
return this.sendRaw('hanzo.insertElement', { selector: rest.selector, html: rest.html, position: rest.position }, b);
case 'remove_element':
return this.sendRaw('hanzo.removeElement', { selector: rest.selector }, b);
case 'observe_mutations':
return this.sendRaw('hanzo.observeMutations', { selector: rest.selector, options: rest.options, duration: rest.duration }, b);
case 'computed_styles':
return this.sendRaw('hanzo.getComputedStyles', { selector: rest.selector, properties: rest.properties }, b);
case 'bounding_rects':
return this.sendRaw('hanzo.getBoundingRects', { selector: rest.selector }, b);
case 'inject_script':
return this.sendRaw('hanzo.injectScript', { url: rest.url }, b);
case 'inject_css':
return this.sendRaw('hanzo.injectCSS', { css: rest.css }, b);
case 'local_storage':
if (rest.value !== undefined) {
return this.sendRaw('hanzo.setLocalStorage', tabTarget({ key: rest.key, value: rest.value }), b);
}
return this.sendRaw('hanzo.getLocalStorage', tabTarget({ key: rest.key }), b);
case 'cookies':
return this.sendRaw('hanzo.getCookies', tabTarget(), b);
// Evaluate.
//
// awaitPromise: true tells CDP to wait for any async IIFE to resolve
// before returning. Without it, expressions like
// (async () => fetch('/x').then(r => r.json()))()
// serialise as `{}` (a Promise placeholder) and the caller can't
// distinguish "the page evaluated to {}" from "the bridge swallowed
// the result". We then route through `unwrapEvaluateResult` (the
// shared helper from shared/tab-id.ts) which handles every shape
// Chrome / Firefox / Safari extensions can hand back.
case 'evaluate': {
const evalResult = await this.sendRaw('Runtime.evaluate', tabTarget({
expression: rest.code || rest.function || rest.expression,
returnByValue: true,
awaitPromise: true,
}), b);
return { result: unwrapEvaluateResult(evalResult), raw: evalResult };
}
// Wait
case 'wait':
await new Promise(resolve => setTimeout(resolve, (rest.timeout || 1) * 1000));
return { waited: rest.timeout || 1 };
case 'wait_for_load':
return this.sendRaw('Page.waitForLoadState', tabTarget({
state: rest.state || 'load',
}), b);
// Tabs
case 'tabs':
case 'list_tabs':
return this.sendRaw('Target.getTargets', undefined, b);
case 'new_tab':
return this.sendRaw('Target.createTarget', { url: rest.url || 'about:blank' }, b);
case 'close_tab':
return this.sendRaw('Target.closeTarget', { targetId: rest.tabId }, b);
case 'select_tab':
return this.sendRaw('Target.activateTarget', { targetId: rest.tabId }, b);
// Console/Network — routed to PageMonitor via extension background
case 'console_messages':
case 'console':
case 'console_logs':
return this.sendToExtension('monitor.consoleLogs', { tabId: rest.tabId }, b);
case 'console_errors':
return this.sendToExtension('monitor.consoleErrors', { tabId: rest.tabId }, b);
case 'network_requests':
case 'network_logs':
return this.sendToExtension('monitor.networkLogs', { tabId: rest.tabId }, b);
case 'network_errors':
return this.sendToExtension('monitor.networkErrors', { tabId: rest.tabId }, b);
case 'network_success':
return this.sendToExtension('monitor.networkSuccess', { tabId: rest.tabId }, b);
case 'wipe_logs':
return this.sendToExtension('monitor.wipeLogs', { tabId: rest.tabId }, b);
case 'start_monitoring':
return this.sendToExtension('monitor.start', { tabId: rest.tabId }, b);
case 'stop_monitoring':
return this.sendToExtension('monitor.stop', { tabId: rest.tabId }, b);
case 'monitor_status':
return this.sendToExtension('monitor.status', {}, b);
// Audits — routed to AuditRunner via extension background
case 'accessibility_audit':
return this.sendToExtension('audit.accessibility', { tabId: rest.tabId }, b);
case 'performance_audit':
return this.sendToExtension('audit.performance', { tabId: rest.tabId }, b);
case 'seo_audit':
return this.sendToExtension('audit.seo', { tabId: rest.tabId }, b);
case 'best_practices_audit':
return this.sendToExtension('audit.bestPractices', { tabId: rest.tabId }, b);
case 'full_audit':
return this.sendToExtension('audit.full', { tabId: rest.tabId }, b);
case 'nextjs_audit':
return this.sendToExtension('audit.nextjs', { tabId: rest.tabId }, b);
case 'debugger_mode':
case 'debug':
return this.sendToExtension('debugger.mode', { tabId: rest.tabId }, b);
// Ollama / Local LLM
case 'ollama_models':
return this.sendToExtension('ollama.models', {}, b);
case 'ollama_chat':
return this.sendToExtension('ollama.chat', { model: rest.model, messages: rest.messages, options: rest.options }, b);
case 'local_chat':
return this.sendToExtension('local.chat', { provider: rest.provider, endpoint: rest.endpoint, model: rest.model, messages: rest.messages, options: rest.options, apiKey: rest.apiKey }, b);
case 'local_models':
return this.sendToExtension('local.models', { provider: rest.provider, endpoint: rest.endpoint, apiKey: rest.apiKey }, b);
case 'local_discover':
return this.sendToExtension('local.discover', {}, b);
// Model Hub — browse, search, download
case 'hub_search':
return this.sendToExtension('hub.search', { query: rest.query, filter: rest.filter, sort: rest.sort, limit: rest.limit, author: rest.author }, b);
case 'hub_search_gguf':
return this.sendToExtension('hub.searchGGUF', { query: rest.query, limit: rest.limit }, b);
case 'hub_model':
return this.sendToExtension('hub.model', { modelId: rest.modelId }, b);
case 'hub_recommended':
return this.sendToExtension('hub.recommended', {}, b);
case 'hub_search_ollama':
return this.sendToExtension('hub.searchOllama', { query: rest.query }, b);
case 'hub_download_ollama':
return this.sendToExtension('hub.downloadOllama', { modelName: rest.modelName, ollamaUrl: rest.ollamaUrl }, b);
case 'hub_download_hf':
return this.sendToExtension('hub.downloadHF', { downloadUrl: rest.downloadUrl }, b);
case 'hub_all_models':
return this.sendToExtension('hub.allModels', {}, b);
case 'hub_search_mlx':
return this.sendToExtension('hub.searchMLX', { query: rest.query, limit: rest.limit }, b);
case 'hub_model_card':
return this.sendToExtension('hub.modelCard', { modelId: rest.modelId }, b);
case 'hub_model_stats':
return this.sendToExtension('hub.modelStats', { modelId: rest.modelId }, b);
// Status
case 'status':
return {
connected: this.clients.size > 0,
clients: this.clients.size,
browsers: this.getConnectedBrowserDetails(),
port: this.httpPort
};
default:
// Pass through as raw CDP command
return this.sendRaw(action, rest, b);
}
}
// Send a message to the extension background and wait for response
private sendToExtension(action: string, params: any, targetBrowser?: string): Promise<any> {
return new Promise((resolve) => {
const client = this.resolveClient(targetBrowser);
if (!client) {
const connected = this.getConnectedBrowsers();
resolve({
error: targetBrowser
? `Browser "${targetBrowser}" not connected. Connected: [${connected.join(', ')}]`
: 'No browser extension connected'
});
return;
}
const id = `ext-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`;
const timeout = setTimeout(() => {
this.pendingExtensionRequests.delete(id);
resolve({ error: `Extension request timed out (browser: ${client.id})` });
}, 30000);
this.pendingExtensionRequests.set(id, { resolve, timeout });
client.ws.send(JSON.stringify({
type: 'extension-request',
id,
action,
params,
}));
});
}
isConnected(): boolean {
return this.clients.size > 0;
}
close() {
this.wss.close();
}
}
// JSON-RPC server for MCP integration
async function startJSONRPCServer(bridgeServer: CDPBridgeServer) {
const http = require('http');
const httpServer = http.createServer(async (req: any, res: any) => {
if (req.method === 'POST') {
let body = '';
req.on('data', (chunk: string) => body += chunk);
req.on('end', async () => {
try {
const request = JSON.parse(body);
const result = await bridgeServer.browser(request.params || request);
res.writeHead(200, { 'Content-Type': 'application/json' });
// Return result directly (no extra wrapping — Python expects flat dict)
res.end(JSON.stringify(result && typeof result === 'object' ? result : { result }));
} catch (error: any) {
res.writeHead(500, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: error.message }));
}
});
} else {
// GET - return status
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({
service: 'hanzo.browser',
connected: bridgeServer.isConnected(),
browsers: bridgeServer.getConnectedBrowserDetails(),
actions: [
'list_browsers', 'browsers',
'navigate', 'navigate_back', 'reload', 'url', 'title', 'content',
'screenshot', 'snapshot',
'click', 'dblclick', 'hover', 'type', 'fill', 'clear', 'press_key',
'select_option', 'check', 'uncheck',
'evaluate',
'go_back', 'go_forward', 'get_url', 'get_title', 'get_tab_info',
'wait_for_navigation', 'get_history', 'create_tab', 'close_tab',
'fetch',
'get_html', 'set_html', 'get_text', 'set_text',
'get_attribute', 'set_attribute', 'remove_attribute',
'set_style', 'add_class', 'remove_class',
'insert_element', 'remove_element',
'observe_mutations', 'computed_styles', 'bounding_rects',
'inject_script', 'inject_css',
'local_storage', 'cookies',
'wait', 'wait_for_load',
'tabs', 'new_tab', 'close_tab', 'select_tab',
'console_logs', 'console_errors', 'network_logs', 'network_errors', 'network_success',
'start_monitoring', 'stop_monitoring', 'monitor_status', 'wipe_logs',
'accessibility_audit', 'performance_audit', 'seo_audit', 'best_practices_audit', 'full_audit',
'nextjs_audit', 'debugger_mode',
'ollama_models', 'ollama_chat', 'local_chat', 'local_models', 'local_discover',
'hub_search', 'hub_search_gguf', 'hub_search_mlx', 'hub_model', 'hub_model_card',
'hub_model_stats', 'hub_recommended',
'hub_search_ollama', 'hub_download_ollama', 'hub_download_hf', 'hub_all_models',
'takeover.start', 'takeover.end', 'takeover.cursor',
'status'
]
}));
}
});
httpServer.listen(9224, () => {
console.log('[hanzo.browser] HTTP API at http://localhost:9224');
});
}
// Interactive CLI
async function main() {
const port = parseInt(process.env.CDP_PORT || '9223');
const server = new CDPBridgeServer(port);
// Start HTTP API
startJSONRPCServer(server);
console.log('\n[hanzo.browser] Commands:');
console.log(' navigate <url> - Go to URL');
console.log(' screenshot [file] - Capture screen');
console.log(' click <selector> - Click element');
console.log(' type <sel> <text> - Type into element');
console.log(' eval <js> - Run JavaScript');
console.log(' tabs - List all tabs');
console.log(' status - Connection status');
console.log(' quit - Exit\n');
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout
});
const prompt = () => {
rl.question('hanzo.browser> ', async (line) => {
const parts = line.trim().split(' ');
const action = parts[0];
const args = parts.slice(1);
if (!action) {
prompt();
return;
}
try {
let result;
switch (action) {
case 'quit':
case 'exit':
server.close();
rl.close();
process.exit(0);
case 'navigate':
result = await server.browser({ action: 'navigate', url: args[0] });
console.log('Navigated to:', args[0]);
break;
case 'screenshot':
result = await server.browser({
action: 'screenshot',
filename: args[0] || `screenshot-${Date.now()}.png`
});
console.log('Screenshot:', result);
break;
case 'click':
result = await server.browser({ action: 'click', selector: args[0] });
console.log('Clicked:', args[0]);
break;
case 'type':
result = await server.browser({
action: 'type',
selector: args[0],
text: args.slice(1).join(' ')
});
console.log('Typed into:', args[0]);
break;
case 'eval':
result = await server.browser({
action: 'evaluate',
expression: args.join(' ')
});
console.log('Result:', result?.result?.value);
break;
case 'tabs':
result = await server.browser({ action: 'tabs' });
console.log('Tabs:', result?.targetInfos?.map((t: any) => t.title).join(', '));
break;
case 'status':
result = await server.browser({ action: 'status' });
console.log('Status:', result);
break;
case 'url':
result = await server.browser({ action: 'url' });
console.log('URL:', result?.result?.value);
break;
case 'title':
result = await server.browser({ action: 'title' });
console.log('Title:', result?.result?.value);
break;
default:
// Try as raw action
result = await server.browser({ action, ...Object.fromEntries(
args.map((a, i) => [i === 0 ? 'selector' : `arg${i}`, a])
)});
console.log('Result:', result);
}
} catch (error: any) {
console.error('Error:', error.message);
}
prompt();
});
};
prompt();
}
export { CDPBridgeServer, BrowserCommand };
if (require.main === module) {
main();
}
+133
View File
@@ -0,0 +1,133 @@
// Native-ZAP transport — the extension's one connection to the local zapd
// router, via the ai.hanzo.zap native-messaging host. No WebSocket, no
// localhost ports, no mDNS probing. The browser can never get "connection
// refused": connectNative either attaches the host or fails once, cleanly.
//
// Frames are the ZAP router envelope (binary, in zapd). Over native messaging
// they ride as JSON `{t, flags, from, to, payload(base64)}` — the Chrome-forced
// inch — and the host re-frames 1:1 to binary on the UDS. The opaque command/
// result payload uses a compact binary codec below (no JSON in the contract).
const HELLO = 1, WELCOME = 2, ERROR = 7, ROUTE = 16, RESPONSE = 17;
const ROLE_PROVIDER = 1;
function b64encode(bytes: Uint8Array): string {
let s = '';
for (let i = 0; i < bytes.length; i++) s += String.fromCharCode(bytes[i]);
return btoa(s);
}
function b64decode(s: string): Uint8Array {
const bin = atob(s);
const u = new Uint8Array(bin.length);
for (let i = 0; i < bin.length; i++) u[i] = bin.charCodeAt(i);
return u;
}
function putU16(a: number[], n: number) { a.push(n & 0xff, (n >> 8) & 0xff); }
function putStr(a: number[], s: string) {
const b = new TextEncoder().encode(s);
putU16(a, b.length);
for (let i = 0; i < b.length; i++) a.push(b[i]);
}
// HELLO body: role(u8) + brand(str) + caps(u16 + str…). Mirrors frame.rs.
function encodeHello(role: number, brand: string, caps: string[]): Uint8Array {
const a: number[] = [role];
putStr(a, brand);
putU16(a, caps.length);
for (const c of caps) putStr(a, c);
return new Uint8Array(a);
}
// Command body: method(str) + params(u16 count of key:str → value:bytes).
function decodeCmd(p: Uint8Array): { method: string; params: Record<string, string> } {
const dv = new DataView(p.buffer, p.byteOffset, p.byteLength);
let o = 0;
const ml = dv.getUint16(o, true); o += 2;
const method = new TextDecoder().decode(p.subarray(o, o + ml)); o += ml;
const n = dv.getUint16(o, true); o += 2;
const params: Record<string, string> = {};
for (let i = 0; i < n; i++) {
const kl = dv.getUint16(o, true); o += 2;
const k = new TextDecoder().decode(p.subarray(o, o + kl)); o += kl;
const vl = dv.getUint32(o, true); o += 4;
params[k] = new TextDecoder().decode(p.subarray(o, o + vl)); o += vl;
}
return { method, params };
}
export interface NativeZapState {
connected: boolean;
port: chrome.runtime.Port | null;
providerId: string;
}
export type Dispatch = (method: string, params: any) => Promise<any> | any;
const RECONNECT_MS = 3000;
/** Connect to zapd via the native host, register as a browser provider, and
* dispatch inbound ROUTE commands to the browser. Self-heals on disconnect. */
export function connectNativeZap(
state: NativeZapState,
brand: string,
caps: string[],
dispatch: Dispatch,
log: (m: string) => void = console.log,
): void {
// Cross-browser: Firefox exposes `browser.runtime`, Chrome `chrome.runtime`;
// both implement connectNative. One transport, both browsers.
const rt: any = (typeof browser !== 'undefined' ? (browser as any) : chrome).runtime;
let port: chrome.runtime.Port;
try {
port = rt.connectNative('ai.hanzo.zap');
} catch (e: any) {
state.connected = false;
log('[Hanzo/ZAP] native host ai.hanzo.zap is not installed');
return;
}
state.port = port;
const id = state.providerId;
const post = (t: number, to: string, payload: Uint8Array) =>
port.postMessage({ t, flags: 0, from: id, to, payload: b64encode(payload) });
// Register as a provider.
post(HELLO, '', encodeHello(ROLE_PROVIDER, brand, caps));
port.onMessage.addListener(async (msg: any) => {
const payload = msg.payload ? b64decode(msg.payload) : new Uint8Array(0);
switch (msg.t) {
case WELCOME:
state.connected = true;
log(`[Hanzo/ZAP] native host connected; registered ${id}`);
break;
case ERROR:
log('[Hanzo/ZAP] ' + new TextDecoder().decode(payload));
break;
case ROUTE: {
const { method, params } = decodeCmd(payload);
let out: Uint8Array;
try {
const r = await dispatch(method, params);
// browser.zap stopgap: structured results travel as utf-8 text until
// the typed browser schema lands. Router/host never see this — opaque.
const s = typeof r === 'string' ? r : JSON.stringify(r ?? null);
out = new TextEncoder().encode(s);
} catch (e: any) {
out = new TextEncoder().encode('ERR:' + (e?.message || String(e)));
}
port.postMessage({ t: RESPONSE, flags: 0, from: id, to: msg.from, payload: b64encode(out) });
break;
}
default:
break; // PROVIDERS / PEER_* — a provider ignores these
}
});
port.onDisconnect.addListener(() => {
state.connected = false;
const err = rt.lastError?.message;
log('[Hanzo/ZAP] native host disconnected' + (err ? ': ' + err : ''));
setTimeout(() => connectNativeZap(state, brand, caps, dispatch, log), RECONNECT_MS);
});
}
+13 -89
View File
@@ -24,14 +24,6 @@ export const HANZO_SERVICE_TYPE = '_hanzo._tcp.local.';
export const ZAP_RECONNECT_DELAY = 3000;
export const ZAP_DISCOVERY_TIMEOUT = 2000;
/**
* Localhost ports the extension probes when the mDNS native-messaging
* helper (``ai.hanzo.zap_mdns``) is unavailable. Same range hanzo-mcp's
* ``ZapServer`` binds to. Kept identical to ``zap.protocol.ZAP_PORTS``
* on the Python side so a local hanzo-mcp is always discoverable in dev
* without requiring the mDNS host to be installed.
*/
export const ZAP_PORTS: readonly number[] = [9999, 9998, 9997, 9996, 9995];
// ── Encode / Decode ─────────────────────────────────────────────────────
@@ -102,40 +94,6 @@ export function setZapRequestHandler(mgr: ZapManager, handler: ZapRequestHandler
mgr.requestHandler = handler;
}
/** Probe a ZAP server on given port */
export function probeZapServer(
port: number,
mgr: ZapManager,
browserName: string,
version: string,
): Promise<string | null> {
return new Promise((resolve) => {
const url = `ws://localhost:${port}`;
const ws = new WebSocket(url);
const timer = setTimeout(() => { ws.close(); resolve(null); }, ZAP_DISCOVERY_TIMEOUT);
ws.binaryType = 'arraybuffer';
ws.onopen = () => {
clearTimeout(timer);
ws.send(encodeZapMessage(MSG_HANDSHAKE, {
clientId: mgr.state.extensionId,
clientType: 'browser_extension',
browser: browserName,
version,
capabilities: ['tabs', 'navigate', 'screenshot', 'evaluate', 'cookies', 'storage'],
}));
const hsTimer = setTimeout(() => { ws.close(); resolve(null); }, ZAP_DISCOVERY_TIMEOUT);
ws.onmessage = () => {
clearTimeout(hsTimer);
ws.close();
resolve(url);
};
};
ws.onerror = () => { clearTimeout(timer); resolve(null); };
});
}
/** Connect to a ZAP server and set up message handling */
export async function connectZap(
url: string,
@@ -401,33 +359,19 @@ export async function discoverZapServers(
const known = new Set<string>();
for (const m of mgr.state.mcps) known.add(m.url);
let candidates = mdnsUrls.filter(u => !known.has(u));
const candidates = mdnsUrls.filter(u => !known.has(u));
// Fallback: when mDNS yielded nothing (helper missing OR no advertisers
// on the LAN), probe the localhost ZAP_PORTS range *sequentially* —
// stop on the first hit. Parallel probing surfaces ERR_CONNECTION_REFUSED
// in Chrome's console for every empty port, even though those probes
// are expected to fail. Sequential keeps the console clean: at most one
// refused log per discovery tick, and zero on the happy path.
// mDNS-only per HIP-0069. A browser extension must NEVER open arbitrary
// ws://localhost:* sockets — that is the ERR_CONNECTION_REFUSED spam.
// Either the native helper advertises a server, or we are cleanly
// unavailable. No localhost port probing, no fallback ladder.
if (candidates.length === 0) {
if (!mdnsError && mdnsUrls.length === 0) {
log('[Hanzo/ZAP] No services advertising _hanzo._tcp.local. — probing localhost ports.');
if (mdnsError) {
log('[Hanzo/ZAP] unavailable: native mDNS helper ai.hanzo.zap_mdns missing/unreachable');
}
for (const port of ZAP_PORTS) {
const url = await probeZapServer(port, mgr, browserName, version);
if (url && !known.has(url)) {
candidates = [url];
break;
}
}
if (candidates.length === 0) {
log('[Hanzo/ZAP] No hanzo-mcp ZAP server found on mDNS or localhost.');
return;
}
log(`[Hanzo/ZAP] Localhost probe found ${candidates.length} server(s): ${candidates.join(', ')}`);
} else {
log(`[Hanzo/ZAP] mDNS found ${candidates.length} new server(s): ${candidates.join(', ')}`);
return;
}
log(`[Hanzo/ZAP] mDNS found ${candidates.length} new server(s): ${candidates.join(', ')}`);
await Promise.all(candidates.map(url => connectZap(url, mgr, browserName, version, log)));
}
@@ -493,31 +437,11 @@ export function handleZapMessage(
return true;
case 'zap.discover':
discoverZapServers(mgr, browserName, version).then(() => {
sendResponse({
success: true,
mcps: mgr.state.mcps.map(m => ({
id: m.id,
name: m.name,
url: m.url,
tools: m.tools,
})),
});
}).catch((e: any) => {
sendResponse({ success: false, error: e.message });
});
return true;
case 'zap.connect':
if (!request.url) {
sendResponse({ success: false, error: 'Missing url' });
return true;
}
connectZap(request.url, mgr, browserName, version).then((mcpId) => {
sendResponse({ success: !!mcpId, mcpId });
}).catch((e: any) => {
sendResponse({ success: false, error: e.message });
});
// Discovery/dialing is gone. The extension connects to the local zapd
// router via the ai.hanzo.zap native host (see native-zap.ts) — there is
// no URL/port to discover or dial.
sendResponse({ success: false, error: 'native-zap: connection is via the ai.hanzo.zap native host (no discovery)' });
return true;
case 'zap.callTool': {