Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
52076f2c25 | ||
|
|
03e081e83a | ||
|
|
3bb7d57a9f |
@@ -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
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@hanzo/extension",
|
||||
"version": "1.9.14",
|
||||
"version": "1.9.20",
|
||||
"private": true,
|
||||
"description": "Hanzo AI Extension",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@hanzo/browser-extension",
|
||||
"version": "1.9.19",
|
||||
"version": "1.9.21",
|
||||
"description": "Hanzo AI Browser Extension",
|
||||
"main": "dist/background.js",
|
||||
"scripts": {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 = false;
|
||||
|
||||
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.
|
||||
*
|
||||
@@ -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');
|
||||
@@ -131,23 +121,18 @@ async function build() {
|
||||
// There is a tsconfig in src.
|
||||
// execSync('cd src && npx tsc --noEmit', { stdio: 'inherit' });
|
||||
|
||||
// Copy manifests for different browsers
|
||||
fs.copyFileSync(
|
||||
'src/manifest.json',
|
||||
'dist/browser-extension/manifest.json'
|
||||
);
|
||||
|
||||
// Chrome version
|
||||
fs.copyFileSync(
|
||||
'src/manifest.json',
|
||||
'dist/browser-extension/chrome/manifest.json'
|
||||
);
|
||||
|
||||
// Firefox version
|
||||
fs.copyFileSync(
|
||||
'src/manifest-firefox.json',
|
||||
'dist/browser-extension/firefox/manifest.json'
|
||||
);
|
||||
// Copy manifests for different browsers. package.json is the SINGLE source of
|
||||
// truth for the version — stamp it into every manifest so the three can never
|
||||
// drift apart (the "must agree" rule, enforced by the build instead of by hand).
|
||||
const pkgVersion = JSON.parse(fs.readFileSync('package.json', 'utf8')).version;
|
||||
const writeManifest = (srcPath, destPath) => {
|
||||
const m = JSON.parse(fs.readFileSync(srcPath, 'utf8'));
|
||||
m.version = pkgVersion;
|
||||
fs.writeFileSync(destPath, JSON.stringify(m, null, 2));
|
||||
};
|
||||
writeManifest('src/manifest.json', 'dist/browser-extension/manifest.json');
|
||||
writeManifest('src/manifest.json', 'dist/browser-extension/chrome/manifest.json');
|
||||
writeManifest('src/manifest-firefox.json', 'dist/browser-extension/firefox/manifest.json');
|
||||
|
||||
// Safari needs special handling — write Info.plist for Xcode project
|
||||
fs.writeFileSync('dist/browser-extension/safari/Info.plist', `<?xml version="1.0" encoding="UTF-8"?>
|
||||
@@ -159,9 +144,9 @@ async function build() {
|
||||
<key>CFBundleIdentifier</key>
|
||||
<string>ai.hanzo.browser-extension</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>1.0.0</string>
|
||||
<string>${pkgVersion}</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>1.0.0</string>
|
||||
<string>${pkgVersion}</string>
|
||||
<key>NSExtension</key>
|
||||
<dict>
|
||||
<key>NSExtensionPointIdentifier</key>
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"manifest_version": 3,
|
||||
"name": "Hanzo AI",
|
||||
"version": "1.9.19",
|
||||
"version": "1.9.21",
|
||||
"description": "AI-powered dev assistant — click-to-code navigation, source maps, WebGPU AI, and MCP integration for Claude Code.",
|
||||
"permissions": [
|
||||
"activeTab",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"manifest_version": 3,
|
||||
"name": "Hanzo AI",
|
||||
"version": "1.9.19",
|
||||
"version": "1.9.21",
|
||||
"description": "AI-powered dev assistant — click-to-code navigation, source maps, WebGPU AI, and MCP integration for Claude Code.",
|
||||
"key": "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAxfXGh0lPUT5m04GKfjUwrLsV6pLaK3VcZuFRPogqAir2tzyLYnQPRtHynue9yvDyguIVnlkwvcwfDOYZrvH76zbw4s6onPBG8HqTKO6LQ9K3kdO1qBBkMMjdOgULQ1MrWThEbpU7NSTiwLYpEta/jAvrKRCAeKIlQE8p6htZmPy9aRUZuae66JgLcAlzD2vviX9sVB1asFABJVswL1RgZ55/8IzZaUrFjzOo9OHK4hmEOtudzkML+5silsAYdC+1BZugph2x94ai17YmZTCL1XyUa5Ke4q80cj+i9rOTgzhZs+mruyhL/AvNVOXilsgqCdNqSz77naWzC3pVGbxOewIDAQAB",
|
||||
"permissions": [
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -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': {
|
||||
|
||||
Reference in New Issue
Block a user