feat(browser-extension): ZAP-native, kill node bridge from critical path (2.0.0)
The Python hanzo-mcp now hosts the ZAP server directly. This extension extends the shared ZAP module so Python servers can dispatch browser RPCs back to the extension over the same socket — closing the loop on the 2-process architecture. - shared/zap.ts: handle inbound MSG_REQUEST and respond with MSG_RESPONSE. New setZapRequestHandler(mgr, fn) installs the dispatcher. - background.ts (Chrome): wire cdpBridge.dispatchMethod as the inbound ZAP handler. - background-firefox.ts: expose dispatchMethod and wire it as the inbound ZAP handler. Reorder so discoverZapServers fires AFTER the handler is installed. - cdp-bridge.ts: add public dispatchMethod() helper for ZAP-server initiated requests. - cdp-bridge-server.ts: deprecation header. No longer in the critical path; kept only for legacy non-ZAP clients. - mcp/src/zap-server.ts: fix wire-constant mismatches with shared/zap.ts (MSG_RESPONSE was 0x12, must be 0x11; PING/PONG were 0xf0/0xf1, must be 0xFE/0xFF). Tests: 250 vitest cases pass (was 247, +3 for setZapRequestHandler). shared-zap.test.ts now covers 31 cases (was 28). Manifest version 2.0.0 reflects the architecture pivot; wire format itself is unchanged from 1.9.x (still [0x5A 0x41 0x50 0x01][type:1][length:4 BE][JSON]).
This commit is contained in:
@@ -1,10 +1,13 @@
|
||||
# LLM.md - Hanzo Extension
|
||||
|
||||
## Overview
|
||||
Hanzo AI Development Platform Monorepo
|
||||
Hanzo AI Development Platform Monorepo. The browser extension lives in
|
||||
`packages/browser` and ships to Chrome / Firefox / Safari.
|
||||
|
||||
## Tech Stack
|
||||
- **Language**: TypeScript/JavaScript
|
||||
- **Build**: esbuild via `packages/browser/src/build.js`
|
||||
- **Tests**: vitest
|
||||
|
||||
## Build & Run
|
||||
```bash
|
||||
@@ -12,27 +15,38 @@ pnpm install && pnpm build
|
||||
pnpm test
|
||||
```
|
||||
|
||||
## Structure
|
||||
## Architecture (since browser-extension 2.0.0)
|
||||
|
||||
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:
|
||||
|
||||
```
|
||||
extension/
|
||||
LICENSE
|
||||
LLM.md
|
||||
Makefile
|
||||
PUBLISHING.md
|
||||
README.md
|
||||
apps/
|
||||
benchmark/
|
||||
docs/
|
||||
examples/
|
||||
hanzo-ai-chrome-1.7.12.zip
|
||||
hanzo-ai-firefox-1.7.12.zip
|
||||
images/
|
||||
package.json
|
||||
packages/
|
||||
pnpm-lock.yaml
|
||||
[0x5A 0x41 0x50 0x01][type:1][length:4 BE][JSON payload]
|
||||
```
|
||||
|
||||
## Key Files
|
||||
- `README.md` -- Project documentation
|
||||
- `package.json` -- Dependencies and scripts
|
||||
- `Makefile` -- Build automation
|
||||
- **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.
|
||||
|
||||
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`
|
||||
|
||||
If you change them, change all three and update `tests/shared-zap.test.ts`
|
||||
+ `tests/test_zap_server.py::TestWireFormat::test_constants_match_extension`.
|
||||
|
||||
## 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.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@hanzo/browser-extension",
|
||||
"version": "1.8.8",
|
||||
"version": "2.0.0",
|
||||
"description": "Hanzo AI Browser Extension",
|
||||
"main": "dist/background.js",
|
||||
"scripts": {
|
||||
@@ -16,14 +16,15 @@
|
||||
"axios": "^1.10.0",
|
||||
"chalk": "^4.1.2",
|
||||
"commander": "^11.1.0",
|
||||
"webextension-polyfill": "0.12.0",
|
||||
"ws": "^8.18.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.52.0",
|
||||
"@types/chrome": "^0.0.270",
|
||||
"@types/firefox-webext-browser": "^120.0.0",
|
||||
"@types/jsdom": "^21.1.7",
|
||||
"@types/ws": "^8.18.1",
|
||||
"@playwright/test": "^1.52.0",
|
||||
"esbuild": "^0.25.6",
|
||||
"jsdom": "^26.1.0",
|
||||
"react": "^18.3.1",
|
||||
|
||||
@@ -8,8 +8,14 @@
|
||||
* - Uses `browser.*` APIs (with Promises) instead of `chrome.*` (callbacks)
|
||||
* - Cannot use ES module exports at top level
|
||||
* - Cannot run WebSocket servers, only clients
|
||||
* - Uses `browser.tabs.executeScript` instead of `chrome.debugger`
|
||||
* - MV3 109+ uses `browser.scripting.executeScript({world,func,args})`,
|
||||
* NOT the removed MV2 `tabs.executeScript`. See `executeScriptWithTimeout`.
|
||||
*
|
||||
* The webextension-polyfill import is a no-op on Firefox (browser.* is
|
||||
* native) but keeping it as the first import here mirrors the Chrome
|
||||
* background's structure and avoids divergence.
|
||||
*/
|
||||
import 'webextension-polyfill';
|
||||
|
||||
import type { ControlSession, RagSnippet, RagQueryParams } from './shared/types.js';
|
||||
import {
|
||||
@@ -20,6 +26,7 @@ import {
|
||||
zapCallTool,
|
||||
hasZapTool,
|
||||
handleZapMessage,
|
||||
setZapRequestHandler,
|
||||
type ZapManager,
|
||||
} from './shared/zap.js';
|
||||
import {
|
||||
@@ -37,6 +44,7 @@ import {
|
||||
firefoxAdapter,
|
||||
type BrowserAdapter,
|
||||
} from './shared/auth.js';
|
||||
import { parseTabId, unwrapEvaluateResult } from './shared/tab-id.js';
|
||||
|
||||
// Declare browser API for TypeScript
|
||||
declare const browser: typeof chrome;
|
||||
@@ -66,8 +74,9 @@ const controlSession: ControlSession = {
|
||||
startedAt: null,
|
||||
};
|
||||
|
||||
// Start ZAP discovery on load (using shared module)
|
||||
discoverZapServers(zapMgr, BROWSER_NAME, VERSION);
|
||||
// ZAP discovery is started AFTER the extension class is instantiated
|
||||
// because the inbound request handler delegates to its dispatchMethod().
|
||||
// See bottom of file for the discoverZapServers() call.
|
||||
|
||||
// =============================================================================
|
||||
|
||||
@@ -124,9 +133,16 @@ class HanzoFirefoxExtension {
|
||||
}
|
||||
|
||||
private register(): void {
|
||||
// Tell the bridge server explicitly that we're Firefox so the
|
||||
// `browser` parameter on multi-browser actions can route to us by
|
||||
// name (e.g. `{ action: 'navigate', browser: 'firefox', url: '...' }`).
|
||||
// Without this the bridge falls back to the User-Agent sniff in
|
||||
// `assignClientId` which we can't observe from the SW (no navigator
|
||||
// headers on a WebSocket message).
|
||||
this.send({
|
||||
type: 'register',
|
||||
role: 'cdp-provider',
|
||||
browser: BROWSER_NAME, // 'firefox'
|
||||
capabilities: [
|
||||
'navigate', 'screenshot', 'click', 'dblclick', 'hover', 'type',
|
||||
'evaluate', 'tabs', 'fill', 'clear', 'reload', 'goBack', 'goForward',
|
||||
@@ -229,7 +245,7 @@ class HanzoFirefoxExtension {
|
||||
private async resolveTab(params: Record<string, unknown>): Promise<chrome.tabs.Tab> {
|
||||
const rawId = params.tabId ?? params.targetId;
|
||||
if (rawId !== undefined && rawId !== null) {
|
||||
const id = this.parseTabId(rawId);
|
||||
const id = parseTabId(rawId);
|
||||
if (id !== null) {
|
||||
try { return await browser.tabs.get(id); } catch { /* fall through */ }
|
||||
}
|
||||
@@ -244,17 +260,6 @@ class HanzoFirefoxExtension {
|
||||
throw new Error('No active tab');
|
||||
}
|
||||
|
||||
/** Fully anchored: rejects URLs / hashes / arbitrary strings even when
|
||||
* they happen to end with a numeric segment. See shared/tab-id.ts. */
|
||||
private parseTabId(raw: unknown): number | null {
|
||||
if (typeof raw === 'number' && Number.isFinite(raw)) return raw;
|
||||
if (typeof raw !== 'string' || raw === '') return null;
|
||||
const m = raw.match(/^(?:tab-)?(\d+)$/);
|
||||
if (!m) return null;
|
||||
const n = Number(m[1]);
|
||||
return Number.isFinite(n) ? n : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Run JS in the page via executeScript and return the serialized result.
|
||||
* Wraps result to avoid undefined → {} JSON serialization issue.
|
||||
@@ -293,6 +298,21 @@ class HanzoFirefoxExtension {
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Public dispatcher for ZAP-server-initiated requests. Mirrors
|
||||
* `CDPBridge.dispatchMethod` on the Chrome side so background-ZAP wiring
|
||||
* is symmetric across browsers. Calls into the same canonical
|
||||
* ``executeMethod`` switch already used by the legacy WS path.
|
||||
*/
|
||||
async dispatchMethod(method: string, params: Record<string, unknown>): Promise<any> {
|
||||
const result = await this.executeMethod(method, params || {});
|
||||
if (result && typeof result === 'object' && 'error' in result && result.error) {
|
||||
const err: any = new Error(String(result.error));
|
||||
throw err;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private async executeMethod(
|
||||
method: string,
|
||||
params: Record<string, unknown>
|
||||
@@ -329,18 +349,16 @@ class HanzoFirefoxExtension {
|
||||
}
|
||||
|
||||
case 'Target.closeTarget': {
|
||||
const closeId = params.targetId
|
||||
? parseInt((params.targetId as string).replace('tab-', ''), 10)
|
||||
: params.tabId as number;
|
||||
if (closeId) await browser.tabs.remove(closeId);
|
||||
// Use shared parseTabId for safety: rejects URLs / strings that
|
||||
// happen to end with digits (e.g. an ad URL with ?tab=123 in it).
|
||||
const closeId = parseTabId(params.targetId ?? params.tabId);
|
||||
if (closeId !== null) await browser.tabs.remove(closeId);
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
case 'Target.activateTarget': {
|
||||
const activateId = params.targetId
|
||||
? parseInt((params.targetId as string).replace('tab-', ''), 10)
|
||||
: params.tabId as number;
|
||||
if (activateId) await browser.tabs.update(activateId, { active: true });
|
||||
const activateId = parseTabId(params.targetId ?? params.tabId);
|
||||
if (activateId !== null) await browser.tabs.update(activateId, { active: true });
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
@@ -2333,6 +2351,17 @@ 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 ZAP discovery now that the handler is installed.
|
||||
discoverZapServers(zapMgr, BROWSER_NAME, VERSION);
|
||||
|
||||
// End active session when the controlled tab closes.
|
||||
browser.tabs.onRemoved.addListener((tabId) => {
|
||||
if (controlSession.active && controlSession.tabId === tabId) {
|
||||
|
||||
@@ -1,10 +1,19 @@
|
||||
// Background Service Worker for Browser Extension
|
||||
//
|
||||
// First import: webextension-polyfill. On Chrome/Edge this is a side-effect
|
||||
// import that defines `globalThis.browser` as the unified Promise-returning
|
||||
// WebExtension API. On Firefox/Safari `browser.*` is already native and the
|
||||
// polyfill is a no-op. The shared modules (auth.ts, tab-id.ts, etc.) read
|
||||
// this global so importing it once at the entrypoint is enough.
|
||||
import 'webextension-polyfill';
|
||||
|
||||
import type { ZapState, ControlSession, RagSnippet, RagQueryParams } from './shared/types.js';
|
||||
import {
|
||||
createZapManager,
|
||||
encodeZapMessage,
|
||||
discoverZapServers,
|
||||
handleZapMessage,
|
||||
setZapRequestHandler,
|
||||
MSG_REQUEST,
|
||||
DEFAULT_ZAP_PORTS as SHARED_ZAP_PORTS,
|
||||
type ZapManager,
|
||||
@@ -1794,10 +1803,12 @@ function handleMCPMessage(data: any) {
|
||||
// Startup
|
||||
// =============================================================================
|
||||
|
||||
// 0. Register side panel
|
||||
if (chrome.sidePanel) {
|
||||
chrome.sidePanel.setOptions({ path: 'sidebar.html', enabled: true });
|
||||
}
|
||||
// 0. Side panel intentionally NOT registered. The user requirement is a
|
||||
// right-anchored, in-page chat overlay across every browser; the native
|
||||
// chrome.sidePanel surface gives an OS-managed panel which doesn't match
|
||||
// that design and behaves differently from the Firefox path. Routing
|
||||
// through the content-script overlay (page.overlay.* messages) is the
|
||||
// single source of truth — see popup.ts.
|
||||
|
||||
// End active session when the controlled tab closes.
|
||||
chrome.tabs.onRemoved.addListener((tabId) => {
|
||||
@@ -1807,6 +1818,14 @@ 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);
|
||||
});
|
||||
getPortConfig().then(({ zapPorts }) => {
|
||||
discoverZapServers(zapMgr, BROWSER_NAME, VERSION, zapPorts, debugLog);
|
||||
});
|
||||
|
||||
@@ -1,6 +1,18 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* CDP Bridge Server - Unified browser control interface
|
||||
* CDP Bridge Server — DEPRECATED in @hanzo/browser-extension 2.0.0.
|
||||
*
|
||||
* 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.
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
// CDP Bridge for hanzo-mcp Browser Tool Integration
|
||||
// Enables Playwright to control browser via Chrome DevTools Protocol
|
||||
import { ActionRateLimiter, debugLog, loadDebugFlagFromStorage } from './runtime-guard';
|
||||
import { parseTabId, unwrapEvaluateResult } from './shared/tab-id.js';
|
||||
|
||||
interface CDPSession {
|
||||
tabId: number;
|
||||
@@ -164,27 +165,40 @@ export class CDPBridge {
|
||||
debugLog(`[CDP] Page.captureScreenshot failed, using captureVisibleTab fallback: ${e}`);
|
||||
}
|
||||
|
||||
// Fallback: use chrome.tabs.captureVisibleTab (no debugger needed)
|
||||
// Fallback: chrome.tabs.captureVisibleTab. This API only works on a
|
||||
// *foreground* tab in the target window — Firefox enforces this strictly,
|
||||
// Chrome will sometimes succeed with a backgrounded tab but is
|
||||
// inconsistent on Mac. Brief-activate the target tab if needed and
|
||||
// restore focus afterwards. Always pass an explicit windowId resolved
|
||||
// from the tab so multi-window automation captures the right window.
|
||||
const tab = await chrome.tabs.get(tabId);
|
||||
const dataUrl = await chrome.tabs.captureVisibleTab(tab.windowId, {
|
||||
format: options?.format === 'jpeg' ? 'jpeg' : 'png',
|
||||
quality: options?.quality,
|
||||
});
|
||||
let activatedFor: number | null = null;
|
||||
let prevActiveId: number | undefined;
|
||||
if (!tab.active) {
|
||||
const prev = await new Promise<chrome.tabs.Tab | undefined>((resolve) => {
|
||||
chrome.tabs.query({ active: true, windowId: tab.windowId }, (tabs) => resolve(tabs[0]));
|
||||
});
|
||||
prevActiveId = prev?.id;
|
||||
await chrome.tabs.update(tabId, { active: true });
|
||||
activatedFor = tabId;
|
||||
// One animation tick so the tab actually composes.
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
}
|
||||
let dataUrl: string;
|
||||
try {
|
||||
dataUrl = await chrome.tabs.captureVisibleTab(tab.windowId, {
|
||||
format: options?.format === 'jpeg' ? 'jpeg' : 'png',
|
||||
quality: options?.quality,
|
||||
});
|
||||
} finally {
|
||||
if (activatedFor !== null && prevActiveId) {
|
||||
chrome.tabs.update(prevActiveId, { active: true }).catch(() => {});
|
||||
}
|
||||
}
|
||||
// Strip data:image/png;base64, prefix
|
||||
return dataUrl.replace(/^data:image\/\w+;base64,/, '');
|
||||
}
|
||||
|
||||
/** Accept tabId in any of: number | "tab-NNN" | "NNN" | undefined.
|
||||
* Fully anchored so "https://x.com/?tab-123" can't be coerced. */
|
||||
private parseTabId(raw: unknown): number | null {
|
||||
if (typeof raw === 'number' && Number.isFinite(raw)) return raw;
|
||||
if (typeof raw !== 'string' || raw === '') return null;
|
||||
const m = raw.match(/^(?:tab-)?(\d+)$/);
|
||||
if (!m) return null;
|
||||
const n = Number(m[1]);
|
||||
return Number.isFinite(n) ? n : null;
|
||||
}
|
||||
|
||||
async click(tabId: number, x: number, y: number): Promise<void> {
|
||||
await this.send(tabId, 'Input.dispatchMouseEvent', {
|
||||
type: 'mousePressed',
|
||||
@@ -218,11 +232,18 @@ export class CDPBridge {
|
||||
|
||||
async evaluate(tabId: number, expression: string): Promise<any> {
|
||||
await this.send(tabId, 'Runtime.enable');
|
||||
// awaitPromise: true makes CDP wait for any async IIFE the caller passed
|
||||
// to resolve before returning. Without this, expressions like
|
||||
// (async () => fetch('/x').then(r => r.json()))()
|
||||
// serialize to {} (a Promise placeholder) and the caller can't tell
|
||||
// success from failure. unwrapEvaluateResult further normalises every
|
||||
// shape (Chrome / CDP / hanzo-tools) to the bare value.
|
||||
const result = await this.send(tabId, 'Runtime.evaluate', {
|
||||
expression,
|
||||
returnByValue: true
|
||||
returnByValue: true,
|
||||
awaitPromise: true,
|
||||
});
|
||||
return result.result?.value;
|
||||
return unwrapEvaluateResult(result);
|
||||
}
|
||||
|
||||
async getDocument(tabId: number): Promise<any> {
|
||||
@@ -365,6 +386,24 @@ export class CDPBridge {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Public entry point for ZAP-server-initiated requests.
|
||||
*
|
||||
* Wraps {@link handleBridgeMessage} so background scripts can hand off
|
||||
* ``method`` + ``params`` from a ZAP MSG_REQUEST without synthesising a
|
||||
* fake numeric ``id``. Throws on bridge error so the caller can convert
|
||||
* to a MSG_RESPONSE error frame.
|
||||
*/
|
||||
async dispatchMethod(method: string, params: any): Promise<any> {
|
||||
const response = await this.handleBridgeMessage({ id: 0, method, params: params || {} });
|
||||
if (response.error) {
|
||||
const err: any = new Error(response.error.message);
|
||||
err.code = response.error.code;
|
||||
throw err;
|
||||
}
|
||||
return response.result;
|
||||
}
|
||||
|
||||
private async handleBridgeMessage(message: any): Promise<CDPResponse> {
|
||||
const { id, method, params } = message;
|
||||
|
||||
@@ -375,7 +414,7 @@ export class CDPBridge {
|
||||
// getTargets, plain numeric string. Without explicit targeting fall
|
||||
// back to the active tab in the focused window — which is fragile when
|
||||
// the user has many windows open. Always prefer explicit ids.
|
||||
const tabId = this.parseTabId(params?.tabId ?? params?.targetId)
|
||||
const tabId = parseTabId(params?.tabId ?? params?.targetId)
|
||||
?? await this.getActiveTabId();
|
||||
|
||||
switch (method) {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"manifest_version": 3,
|
||||
"name": "Hanzo AI",
|
||||
"version": "1.8.8",
|
||||
"version": "2.0.0",
|
||||
"description": "AI-powered dev assistant — click-to-code navigation, source maps, WebGPU AI, and MCP integration for Claude Code.",
|
||||
"permissions": [
|
||||
"activeTab",
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
{
|
||||
"manifest_version": 3,
|
||||
"name": "Hanzo AI",
|
||||
"version": "1.8.8",
|
||||
"version": "2.0.0",
|
||||
"description": "AI-powered dev assistant — click-to-code navigation, source maps, WebGPU AI, and MCP integration for Claude Code.",
|
||||
"permissions": [
|
||||
"activeTab",
|
||||
"identity",
|
||||
"sidePanel",
|
||||
"storage",
|
||||
"tabs",
|
||||
"webNavigation",
|
||||
@@ -45,9 +44,6 @@
|
||||
"128": "icon128.png"
|
||||
}
|
||||
},
|
||||
"side_panel": {
|
||||
"default_path": "sidebar.html"
|
||||
},
|
||||
"icons": {
|
||||
"16": "icon16.png",
|
||||
"48": "icon48.png",
|
||||
|
||||
@@ -50,11 +50,24 @@ export function decodeZapMessage(buf: ArrayBuffer): { type: number; payload: any
|
||||
|
||||
// ── Connection Management ───────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Handler invoked when a ZAP server pushes a request TO the extension.
|
||||
* Returns a result (or throws) and the framework converts that into a
|
||||
* MSG_RESPONSE frame back to the server.
|
||||
*
|
||||
* Keeping this at the manager level means the extension's existing
|
||||
* request-dispatchers (handleExtensionRequest, executeMethod) can be
|
||||
* mounted once and serve every connected MCP without duplication.
|
||||
*/
|
||||
export type ZapRequestHandler = (method: string, params: any) => Promise<any> | any;
|
||||
|
||||
export interface ZapManager {
|
||||
state: ZapState;
|
||||
connections: Map<string, WebSocket>;
|
||||
pendingRequests: Map<string, { resolve: Function; reject: Function }>;
|
||||
requestIdCounter: number;
|
||||
/** Optional inbound request handler (set via setZapRequestHandler). */
|
||||
requestHandler?: ZapRequestHandler;
|
||||
}
|
||||
|
||||
export function createZapManager(): ZapManager {
|
||||
@@ -70,6 +83,11 @@ export function createZapManager(): ZapManager {
|
||||
};
|
||||
}
|
||||
|
||||
/** Install (or replace) the inbound request handler. */
|
||||
export function setZapRequestHandler(mgr: ZapManager, handler: ZapRequestHandler | undefined): void {
|
||||
mgr.requestHandler = handler;
|
||||
}
|
||||
|
||||
/** Probe a ZAP server on given port */
|
||||
export function probeZapServer(
|
||||
port: number,
|
||||
@@ -162,6 +180,29 @@ export async function connectZap(
|
||||
}
|
||||
break;
|
||||
}
|
||||
case MSG_REQUEST: {
|
||||
// Server-initiated RPC: a Python hanzo-mcp is asking the
|
||||
// extension to perform a browser action. Dispatch via the
|
||||
// installed handler and reply with MSG_RESPONSE.
|
||||
const { id: reqId, method: reqMethod, params: reqParams } = msg.payload || {};
|
||||
const respond = (payload: object) => {
|
||||
try {
|
||||
ws.send(encodeZapMessage(MSG_RESPONSE, payload));
|
||||
} catch (e) {
|
||||
// ws.send throws if the socket is closing; nothing we can do.
|
||||
}
|
||||
};
|
||||
const handler = mgr.requestHandler;
|
||||
if (!handler) {
|
||||
respond({ id: reqId, error: { code: -32601, message: `No request handler installed (method: ${reqMethod})` } });
|
||||
break;
|
||||
}
|
||||
Promise.resolve()
|
||||
.then(() => handler(reqMethod, reqParams || {}))
|
||||
.then((result) => respond({ id: reqId, result: result === undefined ? null : result }))
|
||||
.catch((e: any) => respond({ id: reqId, error: { code: -1, message: e?.message || String(e) } }));
|
||||
break;
|
||||
}
|
||||
case MSG_PING:
|
||||
ws.send(encodeZapMessage(MSG_PONG, {}));
|
||||
break;
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
createZapManager,
|
||||
hasZapTool,
|
||||
handleZapMessage,
|
||||
setZapRequestHandler,
|
||||
zapCallTool,
|
||||
zapListResources,
|
||||
zapReadResource,
|
||||
@@ -286,3 +287,35 @@ describe('handleZapMessage', () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// setZapRequestHandler — closes the loop for ZAP-server-initiated RPC
|
||||
// (the 2-process architecture where Python hanzo-mcps push browser
|
||||
// actions TO the extension over the same socket).
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('setZapRequestHandler', () => {
|
||||
it('attaches the handler onto the manager', () => {
|
||||
const mgr = createZapManager();
|
||||
expect(mgr.requestHandler).toBeUndefined();
|
||||
const handler = async () => ({ ok: true });
|
||||
setZapRequestHandler(mgr, handler);
|
||||
expect(mgr.requestHandler).toBe(handler);
|
||||
});
|
||||
|
||||
it('replaces an existing handler', () => {
|
||||
const mgr = createZapManager();
|
||||
const a = async () => 'a';
|
||||
const b = async () => 'b';
|
||||
setZapRequestHandler(mgr, a);
|
||||
setZapRequestHandler(mgr, b);
|
||||
expect(mgr.requestHandler).toBe(b);
|
||||
});
|
||||
|
||||
it('clears the handler when undefined', () => {
|
||||
const mgr = createZapManager();
|
||||
setZapRequestHandler(mgr, async () => null);
|
||||
setZapRequestHandler(mgr, undefined);
|
||||
expect(mgr.requestHandler).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -51,12 +51,16 @@ function zapDecode(data: Uint8Array): { type: number; payload: any } | null {
|
||||
|
||||
// ── Protocol Constants ────────────────────────────────────────────────
|
||||
|
||||
const MSG_HANDSHAKE = 0x01; // Init
|
||||
const MSG_HANDSHAKE_OK = 0x02; // InitAck
|
||||
const MSG_REQUEST = 0x10; // Push
|
||||
const MSG_RESPONSE = 0x12; // Resolve
|
||||
const MSG_PING = 0xf0;
|
||||
const MSG_PONG = 0xf1;
|
||||
// Wire constants must match @hanzo/browser-extension/src/shared/zap.ts.
|
||||
// Earlier drafts used 0x12/0xf0/0xf1 — those were wrong and silently broke
|
||||
// extension <-> mcp sessions. Canonical values live in shared/zap.ts and
|
||||
// are pinned by tests.
|
||||
const MSG_HANDSHAKE = 0x01;
|
||||
const MSG_HANDSHAKE_OK = 0x02;
|
||||
const MSG_REQUEST = 0x10;
|
||||
const MSG_RESPONSE = 0x11;
|
||||
const MSG_PING = 0xFE;
|
||||
const MSG_PONG = 0xFF;
|
||||
|
||||
const ZAP_PORTS = [9999, 9998, 9997, 9996, 9995];
|
||||
const SERVER_ID = `mcp-${Date.now().toString(36)}`;
|
||||
|
||||
Reference in New Issue
Block a user