Compare commits

...
25 Commits
Author SHA1 Message Date
zeekay bb6ed472be extension(browser): v1.9.17 — fix HTTP-action dispatch fallthrough
handleExtensionRequest() was returning "Unknown extension action" for
every Page.* / DOM.* / Input.* / hanzo.* method added in 1.9.16. The
handler had only routed monitor.*/audit.* and fell off the end for
everything else.

Fix: fall back to executeMethod (the canonical CDP-shaped dispatcher)
before returning Unknown. Strictly additive — all previously-working
actions continue through their existing branches.

Verified locally on www.sec.gov/forms/tcr-external-form: hanzo.listForm
returns 183 elements with full label association; hanzo.scroll +
hanzo.clickByText + hanzo.fillByLabel are now routable.

Next: a v1.10.x multi-backend Python bridge that adds WebDriver BiDi
(Firefox 129+), CDP (Chrome 124+), and safaridriver (Safari 17+) as
secondary backends for network interception, log streams, and other
capabilities the WebExtension scripting API cannot provide. Same wire
surface, prefix-based routing (BiDi.* / CDP.* / WD.*).
2026-06-10 11:29:59 -07:00
zeekay 610367e6fd extension(browser): v1.9.16 — CSP-safe DOM ops + CDP-shaped surface
Decomplected three orthogonal layers:
  L3: hanzo.* ergonomic aliases
  L2: CDP-shape canonical primitives (Page.* DOM.* Input.* ...)
  L1: runFunc(tabId, fn, args) → scripting.executeScript({world: MAIN, func: ref})

Every DOM op now passes a real function reference to scripting.executeScript
instead of constructing Function(codeStr)(). The Function() path is blocked
by strict page CSP on sec.gov / github.com / any default-src 'self' SPA.
Direct function refs are exempt from page CSP per the WebExtension spec, so
the new path works everywhere.

Added (all CSP-safe):
  • hanzo.clickByText  fillByLabel  findByText  listForm  submitForm
  • hanzo.press        scroll        waitForText  waitForMutation
  • hanzo.uploadFile   dialogAccept  observe/observeRead/observeStop
  • Input.dispatchMouseEvent  Input.scrollWheel
  • DOM.querySelector  DOM.scrollIntoView  DOM.focus
  • Page.printToPDF

Rewrote with runFunc (CSP-safe):
  • hanzo.click  fill  check  uncheck  clear  getText  getHTML
  • hanzo.querySelectorAll

Capabilities handshake bumped to advertise the new method names.
Driver-side mapping in hanzo-tools-browser/browser_tool.py updated with
~100 snake_case action → wire-method entries (Python MCP surface).

API reference: dist/browser-extension/firefox/API.md
2026-06-10 11:21:29 -07:00
Hanzo AI d268fa9298 cleanup: remove AI-slop summary / status / plan / report files
Reverts violations of the durable rule that current-state docs belong
in LLM.md and history belongs in git log. Removed files were session
handoffs, agent-style "complete success" / "1000%" reports, dated
audit dumps, and stub NOTES.
2026-06-07 13:34:07 -07:00
Hanzo AI 44fccae452 chore: drop stale hanzo-auth.ts.bak
Fossilized copy left over from an earlier rewrite of packages/tools/src/auth.
The live hanzo-auth.ts is the source of truth.
2026-06-03 11:26:39 -07:00
Antje WorringandClaude Opus 4.7 d002cc136c ZAP wire fixes + WebGPU on-demand HF model loader
Fixes the browser ↔ hanzo-mcp ZAP path end to end:

- shared/zap.ts: vendor ZAP_PORTS constant and restore localhost
  port-probe fallback in discoverZapServers when the mDNS native
  helper isn't available. Sequential probe (stops on first hit) so
  the Chrome console stays quiet on the happy path.
- src/manifest.json: add deterministic ``key`` (extension ID
  ``biingenefmanpecedoafkfajbnlgdmbl``) + ``nativeMessaging``
  permission so the ``ai.hanzo.zap_mdns`` helper can be invoked.
- background.ts: configurable HF model source for the WebGPU
  runtime via chrome.storage (``hanzo_model_url`` or
  ``hanzo_model_hf={repo,file,revision}``); falls back through HF →
  bundled artifact → remote providers.
- webgpu-ai.ts: cache-first fetch backed by the service-worker
  Cache API + progress logs, ``WebGPUAI.huggingFaceURL`` helper, and
  ``WebGPUAI.evictCache`` for rotating model artefacts. Drop-in for
  the existing single-blob loader; lays groundwork for sparse-load
  MoE work.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 22:24:31 -07:00
Hanzo AI 4aebce7014 extension(vscode): drop HANZO_IAM_ env-var prefix, use canonical IAM_
Aligns the standalone MCP server's auth path with the canonical IAM_
env contract enforced by hanzo-iam 1.30.0 (python-sdk 3cb6a34) — there
is exactly one prefix, IAM_, with no upstream-brand alias chain.

- HANZO_IAM_ENDPOINT       → IAM_ENDPOINT
- HANZO_IAM_LOGIN_URL      → IAM_LOGIN_URL
- HANZO_IAM_CLIENT_ID      → IAM_CLIENT_ID
- HANZO_IAM_CLIENT_SECRET  → IAM_CLIENT_SECRET

Updates docs/BUILD.md and docs/MCP_INSTALLATION.md to match.
2026-05-14 23:40:22 -07:00
Antje Worring 2287b6a707 extension(browser): gate legacy MCP+CDP WebSockets behind feature flag
Module-load WS connections to ws://localhost:3001/browser-extension
(legacy MCP) and ws://localhost:9223/cdp (legacy CDP bridge) spam
ERR_CONNECTION_REFUSED on every reload when no local hanzo-mcp / CDP
server is running.

ZAP mDNS discovery (startZapDiscoveryLoop / discoverZapServers, added
in 1.9.13–1.9.15) is now the canonical transport, so the legacy paths
are dead-on-load by default.

Adds ENABLE_LEGACY_TRANSPORTS = false (top of background.ts) and gates:
- connectToMCP() body (early-return when off)
- The module-load call site for connectToMCP()
- The module-load call site for cdpBridge.startWebSocketServer(cdpPort)

Flip the flag to true when developing against a local WS server.
ZAP path is untouched.
2026-05-14 15:16:53 -07:00
Hanzo AI 8caa647106 ext 1.9.15: cdp-bridge routing tests + mDNS-only ZAP discovery contract
Browser extension 1.9.14 -> 1.9.15.

- Add cdp-bridge-routing.test.ts: result-unwrapping, awaitPromise, multi-tab fan-out
- Add cross-browser-parity.test.ts: IAM URL, ZAP wire, sidebar/overlay surface, inspect shortcut
- Add execute-script.test.ts: page-world injection contract
- Add webextension-polyfill.test.ts: Chrome vs Firefox parity for chrome.* / browser.*
- Update shared-zap.test.ts to the new mDNS-only contract (DEFAULT_ZAP_PORTS removed;
  HANZO_SERVICE_TYPE = _hanzo._tcp.local. is the canonical discovery entry)
- background.ts / background-firefox.ts: tab_id routing path for ZAP tools/call
- shared/zap.ts: HANZO_SERVICE_TYPE constant, drop legacy port-probe list
- manifest version bump to 1.9.15 (Chrome + Firefox)
- .gitignore: *.xpi / *.crx build artifacts

Driving Porkbun + CF dashboards end-to-end this session surfaced the tab_id
routing path; the new tests pin the contract so it doesn't silently regress.

Tests: 250 pass / 0 fail (vitest run).
Patch-bump.
2026-05-10 17:17:45 -07:00
Hanzo Dev 07b9bf6239 fix(extension): 1.9.14 — drop data_collection_permissions (Firefox manifest warning)
Firefox 151 warns on `data_collection_permissions` — that key is not
yet in Firefox's accepted MV3 manifest schema. The warning was
sufficient to block clean loading of native-messaging permissions in
some contexts (visible in about:debugging as "Reading manifest:
Warning processing data_collection_permissions"), which broke our
mDNS discovery via runtime.sendNativeMessage with an opaque
"unexpected error".

Removed the field. No data-collection semantics depend on it on the
Firefox side; Chrome MV3 still ignores unknown keys.
2026-05-09 14:09:10 -07:00
Hanzo Dev 5932768548 fix(extension): 1.9.13 — mDNS-only discovery via _hanzo._tcp.local.
Per HIP-0069 — extension discovers MCPs via mDNS only, no port-probe
fallback. The legacy [9999..9995] DEFAULT_ZAP_PORTS array is gone;
non-compliant deployments (no mDNS responder, no native-messaging
helper installed) fail loudly so the operator can fix the install
rather than the extension silently picking the wrong server.

shared/zap.ts:
- Replaced DEFAULT_ZAP_PORTS export with HANZO_SERVICE_TYPE constant
  ('_hanzo._tcp.local.')
- discoverZapServers: mDNS-only, retries every 10s when the helper
  is unavailable or the browse comes back empty
- discoverViaMdns: throws on missing native-messaging API instead of
  swallowing — surfaces the install gap

background.ts:
- Removed DEFAULT_ZAP_PORTS / SHARED_ZAP_PORTS imports + zapPorts
  storage key from getPortConfig
- Both discovery callsites pass undefined for ports (mDNS routes them)
2026-05-08 13:24:13 -07:00
Hanzo AI aabc15a382 fix(extension): 1.9.12 — content-script port keep-alive + correct package metadata
The 1.9.11 alarms-based wake-up worked but felt fragile — the background
was still idling out between alarms. Switching primary keep-alive to
the canonical MV3 Port pattern: every content script in every loaded
http(s) tab opens a `zap-keepalive` chrome.runtime port back to the
background and pings it every 10s. As long as ANY tab holds a port,
the background event-page (Firefox) / service worker (Chrome) stays
loaded indefinitely. The WebSocket survives without polling.

Alarms remain as belt-and-suspenders for the no-tab edge case (fresh
browser window, only privileged pages open).

Package metadata corrected: monorepo root is now `@hanzo/extension`
with description "Hanzo AI Extension" — preparing the surface for
publishing the bits people actually want (zap shared module,
gui-primitives shim, mDNS package).
2026-05-08 12:25:19 -07:00
Hanzo AI 6ca23de63a fix(browser-extension): 1.9.11 — alarms-driven keep-alive across MV3 idle
Root cause of the "connect once, never reconnect" pattern: Firefox MV3
treats `"background.scripts"` as event pages and Chrome MV3 service
workers idle out after ~30s. Once the background unloads, every
setTimeout (including our connectZap retry) silently dies. The first
connect happens on initial load; once that WS closes the script
suspends and never comes back.

Fix: register a `chrome.alarms` / `browser.alarms` 30s alarm. Alarm
events wake the worker/page even when suspended. The handler is a
no-op when the ZAP connection is healthy and triggers
discoverZapServers() when it isn't — closing the gap that the
WebSocket reconnect chain alone could not.

Adds `alarms` permission to both Firefox and Chrome manifests.
2026-05-08 12:22:22 -07:00
Hanzo AI 179a5bc718 fix(browser-extension): 1.9.10 — drop application-level heartbeat
ZAP is a long-lived WebSocket. The OS delivers FIN/RST on real
disconnects, which fires ws.onclose → the reconnect chain shipped in
1.9.7. The 20s/8s PING/PONG dance added in 1.9.9 was both unnecessary
and broken: it overwrote ws.onmessage on every tick, stacking closures
indefinitely until the socket closed.

Reverted. The transport stays simple: connect, stream, on close
reconnect. No polling, no wrapper chain.
2026-05-08 12:13:36 -07:00
Hanzo AI 8677e55f30 fix(browser-extension): 1.9.9 — heartbeat keepalive + connection robustness
shared/zap.ts:
  Every successful connectZap now arms a 20-second PING heartbeat. If no
  PONG arrives within 8s the socket is force-closed so the existing
  reconnect chain (1.9.7) takes over. This catches the "half-open"
  failure mode where neither side gets ws.onclose — typical when the
  agent process is killed -9, a laptop sleeps mid-WS, or NAT keep-alive
  drops a stale connection between LAN hops.

  ws.onclose now also clears the heartbeat interval, so we never PING a
  socket whose owner is in the middle of being torn down.

Combined with 1.9.7's onerror retry and 1.9.8's MAIN→ISOLATED CSP
fallback + 30s evaluate timeout, the 1.9.x line is rock-solid for
low-latency tab driving across MCP restarts, page CSP, and any
hand-off between agents.
2026-05-08 12:10:26 -07:00
Hanzo AI 719cd51919 fix(browser-extension): 1.9.8 — Firefox driving, hardened evaluate path
executeScript now tries world: 'MAIN' first (so callers reading
page-internal state still work) and silently falls back to ISOLATED on
the recognisable CSP-blocked-Function() error string. This rescues every
strict 'script-src self' page (banking sites, Porkbun account pages,
GitHub, Google Docs) without forcing the caller to know which world it
needs.

Default Runtime.evaluate timeout bumped from 10s → 30s. Page-walks
against jQuery-rendered domain-management UIs with 1.5k+ DOM nodes
routinely need more than 10s for the first selector pass; caller can
still override via params.timeout for short queries.

Together with 1.9.7's reconnect-chain fix the extension can now drive
any Firefox tab end-to-end: stays glued to its ZAP server through
restarts, evaluates JS regardless of page CSP, and exposes the result
on a clean shape (1.9.2's null-coercion + exception-details wiring).
2026-05-08 11:51:47 -07:00
Hanzo AI ea747cff45 fix(browser-extension): 1.9.7 — connectZap retry chain survives bind-miss
connectZap previously chained a 3s reconnect on ws.onclose only. When the
WebSocket failed BEFORE a successful handshake (typical case: agent
process restarting and rebinding port 9999), Firefox hit ws.onerror →
resolve(null) → the reconnect chain died after a single attempt.

Now both onclose AND onerror schedule the same idempotent retry (guarded
by a one-shot flag so we never double-fire). Firefox stays glued to its
ZAP server across MCP restarts, server upgrades, and any transient bind
window — exactly what we need for low-latency tab driving.
2026-05-08 11:38:10 -07:00
Hanzo AI 3200927f8c fix(browser-extension): 1.9.6 — native mDNS discovery + version footer
Discovery (shared/zap.ts):
  discoverZapServers now tries mDNS via the native-messaging helper
  `ai.hanzo.zap_mdns` first (op=browse, 1.5s timeout), falling back to
  the legacy `[9999..9995]` port-probe only when the helper isn't
  installed or returns no services. With the helper in place we get
  collision-free LAN-wide discovery — the same agent can be found
  whether it's on this host or another machine on the network.

  See ~/work/zap/mdns/ for the helper + manifest:
    python/zap_mdns.py    publish() / browse() (zeroconf-backed, no-op
                          fallback when zeroconf isn't installed)
    python/omni.py        roles: mcp / browser / host / gateway over
                          shared `_hanzo._tcp.local.` service-type
    ext/helper.py         stdio native-messaging host
    ext/native-messaging-host.json   manifest for Firefox NMH path

Manifest:
  Adds `nativeMessaging` permission so the helper can be reached.

Popup:
  Footer now displays "Hanzo AI v1.9.6" (read from manifest at runtime
  so the chip always tracks the actual loaded build).
2026-05-08 11:34:37 -07:00
Hanzo AI 39cf5950a5 fix(browser-extension): 1.9.5 — drop @hanzo/ui, switch to @hanzo/gui
The extension no longer depends on @hanzo/ui. The single React consumer
(chat-widget.tsx) now imports from `@hanzo/gui`, which the build aliases
to a small local primitives shim at `src/gui-primitives.tsx`. The shim is
strictly monochrome plain DOM (Button, Textarea, Input, Card*, XStack,
YStack, Text) — sized for content-script bundles. When the full tamagui-
based @hanzo/gui adoption ships in a feature branch, swap the alias
target in build.js and remove the shim.

Why the local shim and not the upstream @hanzo/gui (npm) right now:
- the upstream is tamagui-flavoured: react-native-web + @hanzogui/*
  packages + the static compiler — far too heavy for the per-page
  content-script + background bundles we ship today;
- @hanzo/brand is strict monochrome anyway, so primitives carry no
  colour tokens — sibling CSS (popup.css/sidebar.css) does the styling
  with the white-luminance scale committed in 1.9.4.

Surface change: every `@hanzo/ui/primitives-common` import is now
`@hanzo/gui`, build.js no longer probes a sibling @hanzo/ui repo, and
`primitives-common-fallback.tsx` is replaced by the canonical
`gui-primitives.tsx`. Bundle drops ~200KB across firefox + chrome from
not pulling the @hanzo/ui dist.

Also includes the in-flight ZAP-bridge / Firefox auth / sidebar
auto-mount tweaks that were already in working-tree.
2026-05-08 11:05:21 -07:00
Hanzo AI 1781862fb1 fix(browser-extension): 1.9.4 — monochrome brand pass + on-page chat config
@hanzo/brand is strictly monochrome (primaryColor #FFFFFF, no chromatic
accents). Stripped every blue/violet/emerald/amber/red literal across
the in-page overlay, sidebar, and popup; replaced with a luminance scale
keyed off white. Status states (success/warn/error) now distinguished
by opacity, not hue.

On-page overlay (content-script):
- Replaces the brittle `looksLikePromiseResult` blue button with a white
  primary action (#fff bg / #0a0a0b text) per brand guidelines.
- Composer is now sticky-bottom: messages flex/scroll, composer
  flex-shrink: 0, panel min-height: 0 lets the layout collapse cleanly.
- Adds a pin-mode toggle in the header. `mode=push` reflows page content
  (sets html.hanzo-overlay-push-{side} → margin-{side}: var(width));
  `mode=overlay` floats over the page (default).
- Adds setMode / setWidth / setEnabled message handlers and persists all
  three in chrome.storage.sync alongside the existing overlaySide.
- Picks up overlayWidth (compact 320 / default 420 / wide 560) via
  --hanzo-overlay-width CSS var, used by both the panel itself and the
  push-mode html margin.

Popup:
- "Open Chat" honours overlayEnabled — falls back to standalone tab
  when disabled (or when active tab is privileged).
- Adds On-page chat panel checkbox + Width picker (Compact/Default/Wide)
  alongside the existing Pin Left/Right.

CSS / brand cleanup:
- sidebar.css usage bars (.bar-blue/.bar-violet/...) → white opacity scale.
- model-hub badges (.badge-cloud/.badge-local/.badge-hub) → mono-luminance.
- inspector-result element-{tag,id,class} → mono-luminance instead of
  blue/violet/green syntax tints.
- --success / --warning / --error in both stylesheets → white shades.
2026-05-08 10:50:20 -07:00
Hanzo AI cfd53c8142 fix(browser-extension): 1.9.3 — kill on-page FAB, edge-pinned sidebar, native FF sidebar
- Remove the round "AI" FAB launcher from the in-page overlay. It rendered
  as a 54px circle bottom-right and looked out of place; once toggled on
  it was sticky because hide() never reset data-enabled. Removed the
  button + CSS + listener entirely.
- Reposition the overlay as a full-height edge-pinned sidebar panel
  (top:0; bottom:0; right:0; width:min(420px,100vw)) instead of the old
  floating bottom-right card. Slides in via translateX rather than
  translateY+scale so it reads as a sidebar.
- Add data-side="left|right" with persisted overlaySide setting in
  chrome.storage.sync. Popup gains a Pin Left | Pin Right picker that
  flips the side on the fly and stores the preference for next page load.
- Unify the popup's two old buttons (Open Chat Panel + Toggle On-Page
  Overlay — both did the same thing in different words) into a single
  Open Chat button.
- Register sidebar_action in manifest-firefox.json so Firefox users who
  prefer the OS-native sidebar can reach the same chat surface via
  View → Sidebar → Hanzo AI.
2026-05-08 10:04:38 -07:00
Hanzo AI 4c5a0bb064 fix(browser-extension): patch-bump to 1.9.2 + perfect FF evaluate path
Revert 2.0.0 to 1.9.2 — internal refactor doesn't warrant a major bump.

Firefox MV3 evaluate fixes (1.9.2):
- Detect privileged URLs (about:, moz-extension:, view-source:, resource:,
  file:, chrome:) up front; raise an actionable error instead of letting
  scripting.executeScript silently return zero frames.
- runInPage now throws when results is empty OR results[0] is undefined
  (the latter signals a frame errored outside the wrapped try/catch —
  almost always page CSP blocking Function() in MAIN world). Old behavior
  returned undefined which JSON-stripped to a confusing {result:{}}.
- Runtime.evaluate always returns {result:{type,value}, value} with
  value coerced from undefined to null and a CDP-style exceptionDetails
  surfaced when evaluation errored.
- Replace the looksLikePromiseResult heuristic with explicit
  awaitPromise / await_promise parameter handling. The previous heuristic
  re-evaluated any empty-object result, masking real {} values.
2026-05-08 09:53:56 -07:00
Hanzo AI 11862defbc 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]).
2026-05-07 21:33:02 -07:00
Hanzo AI 962bc8ce26 feat(bridge): Firefox-first default + dynamic browser switching
The CDP bridge now supports multiple connected browsers concurrently
(it always did; this fixes the routing) and prefers Firefox when no
explicit target is specified. Order: firefox > safari > edge > chrome.

User explicitly said personal browser is Firefox — Chrome was
silently winning whenever both extensions were active because the
bridge picked the first-registered client. Now Chrome only wins
when it's the only one connected.

- resolveClient(): walks the DEFAULT_BROWSER_PREFERENCE list to find
  the first connected match. Last-resort fallback is the
  first-registered client.
- defaultBrowser config: persisted to ~/.hanzo/extension/config.json
  so the preference survives bridge restarts. Read on construct.
- New 'set_default_browser' / 'use_browser' actions: agent can flip
  the default at runtime without restarting the bridge.
- list_browsers now returns defaultBrowser + preferenceOrder so
  callers can see what would be picked.

The agent can now talk to multiple browsers simultaneously by passing
'browser' as a per-call target ('firefox', 'chrome', 'safari', or a
specific instance ID like 'firefox-2'). Without a target, the
configured default (or Firefox by preference) wins.
2026-05-07 21:04:08 -07:00
Hanzo AI 0cc7d4972f feat(browser): right-anchored chat overlay everywhere + Chrome awaitPromise
- popup.ts: 'Open Chat Panel' now opens the in-page right-anchored
  overlay instead of the browser-native sidebar. Firefox's sidebar_action
  always rendered on the user's preferred side (default left) wrapped in
  the native sidebar-icon strip — both unwanted. Chrome's sidePanel was
  inconsistent with the overlay's behaviour. Single right-anchored
  surface across every browser.
- manifest-firefox.json: drop sidebar_action entirely. No more native
  Firefox sidebar; the floating right-side overlay (already at
  right:20px;bottom:20px in content-script.ts) is the only chat surface.
- cdp-bridge.ts: add awaitPromise:true to Runtime.evaluate so async
  IIFEs (the standard pattern for fetch+credentials:include in MCP
  evaluate calls) resolve to the actual value instead of returning {}.

Bumps 1.8.7 → 1.8.8.
2026-05-07 20:30:14 -07:00
Hanzo AI 43a81de0d0 fix(auth): /v1/iam/login/oauth/access_token instead of /oauth/token
The Hanzo gateway exposes Casdoor's
at  (verified via probe — POST
returns 401 JSON with proper error semantics). The bare /oauth/token
listed in iam.hanzo.ai/.well-known/openid-configuration returns 404
in prod — the gateway has no rewrite for it.

Token exchange + refresh now hit the working path and use proper
application/x-www-form-urlencoded body per RFC 6749. JSON bodies were
silently rejected by Casdoor's token endpoint (it only accepts form-
encoded for the OAuth path).

Bumps 1.8.6 → 1.8.7 in root + browser package.json + both manifests.
2026-05-07 20:08:29 -07:00
37 changed files with 3295 additions and 1239 deletions
+2
View File
@@ -2,6 +2,8 @@
out/
dist/
*.vsix
*.xpi
*.crx
# Dependencies
node_modules/
+36 -22
View File
@@ -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 -1
View File
@@ -144,7 +144,7 @@ VS Code extensions are signed automatically when published to the marketplace.
- `VSCODE_ENV`: Set to 'development' or 'production'
- `HANZO_WORKSPACE`: Default workspace for MCP operations
- `HANZO_ANONYMOUS`: Set to 'true' for anonymous mode
- `HANZO_IAM_ENDPOINT`: Custom IAM endpoint (default: https://iam.hanzo.ai)
- `IAM_ENDPOINT`: Custom IAM endpoint (default: https://iam.hanzo.ai)
## Testing Builds
+1 -1
View File
@@ -184,7 +184,7 @@ The extension will generate `.windsurfrules` file.
- `HANZO_ANONYMOUS` - Set to 'true' for anonymous mode
- `MCP_TRANSPORT` - Transport method (stdio or tcp)
- `MCP_PORT` - Port for TCP transport (default: 3000)
- `HANZO_IAM_ENDPOINT` - Custom IAM endpoint (default: https://iam.hanzo.ai)
- `IAM_ENDPOINT` - Custom IAM endpoint (default: https://iam.hanzo.ai)
### Tool Configuration
-195
View File
@@ -1,195 +0,0 @@
# Comprehensive Tool Testing Report
## Overview
All core tools have been implemented, tested, and benchmarked. The extension now supports 56 tools total, with 27 enabled by default.
## Tool Implementation Status
### ✅ Fully Implemented and Tested Tools
#### File System Tools (6/6)
-**read** - Read file contents with line numbers
-**write** - Write content to files
-**edit** - Edit files by replacing patterns
-**multi_edit** - Multiple edits in one operation
-**directory_tree** - Display directory structure
-**find_files** - Find files matching patterns
#### Search Tools (4/4)
-**grep** - Pattern search using ripgrep
-**search** - Unified search across files/symbols/git
-**symbols** - Search code symbols
-**unified_search** - Parallel search across all types
#### Shell Tools (3/3)
-**run_command** - Execute shell commands
-**open** - Open files/URLs
-**process** - Background process management with logging
#### Development Tools (5/5)
-**todo_read** - Read todo list
-**todo_write** - Write todo items
-**todo_unified** - Unified todo management
-**think** - Structured thinking space
-**critic** - Code review and analysis
#### Configuration Tools (3/3)
-**config** - Git-style configuration
-**rules** - Project conventions (.cursorrules, .clauderc)
-**palette** - Tool personality switching
#### Database & AI Tools (6/6)
-**graph_db** - Graph database with AST integration
-**vector_index** - Index documents for vector search
-**vector_search** - Semantic search with embeddings
-**vector_similar** - Find similar documents
-**document_store** - Chat document management
-**zen** - Hanzo Zen1 AI model (local/cloud)
#### Utility Tools (2/2)
-**batch** - Batch operations
-**web_fetch** - Fetch and analyze web content
## Test Results
### Functionality Tests
All key tools passed functionality tests:
- ✅ think - Thought recording works
- ✅ critic - Code analysis works
- ✅ unified_search - Parallel search works
- ✅ graph_db - Node/edge operations work
- ✅ vector_index - Document indexing works
- ✅ document_store - Document management works
- ✅ web_fetch - Web content fetching works
- ✅ palette - Tool switching works
- ✅ config - Configuration management works
- ✅ process - Background process management works
### Performance Benchmarks
#### Graph Database
- **Add nodes**: 6,768 nodes/ms (excellent)
- **Query performance**: < 0.1ms for most operations
- **Path finding**: < 0.01ms average
- **Connected components**: 0.42ms for full analysis
#### Vector Store
- **Index documents**: 211 documents/ms
- **Search performance**: 0.49ms average (🟢 Fast)
- **Filtered search**: 0.24ms average
- **Metadata search**: 0.11ms average
#### AST Index
- **File indexing**: ~5,610 files/second
- **Symbol search**: < 0.02ms
- **Reference finding**: < 0.01ms
- **Call hierarchy**: 0.02ms average
#### Document Store
- **Add documents**: 13ms per batch
- **Search documents**: < 0.01ms
- **Session save**: 1.64ms average
## Backend Abstraction
### Local vs Cloud Support
**Local Backend**
- In-memory graph database
- Local vector store with mock embeddings
- File-based document persistence
- Support for Ollama and LM Studio
- Hanzo local model support
**Cloud Backend**
- API-based operations
- Real embeddings from cloud
- Persistent storage
- Authentication via API key
- Unified interface with local
### AI Model Support
**Local AI Providers**
- Ollama (auto-detected at localhost:11434)
- LM Studio (auto-detected at localhost:1234)
- Hanzo Local Models (zen1, zen1-mini, zen1-code)
**Cloud AI Providers**
- Hanzo Cloud API
- Fallback to OpenAI/Anthropic
- Unified LLM interface
## Critic Tool Capabilities
The critic tool provides comprehensive code analysis:
1. **Security Analysis**
- SQL injection detection
- XSS vulnerability checks
- Authentication/authorization issues
- Sensitive data exposure
2. **Performance Analysis**
- Algorithm complexity
- Database query optimization
- Memory leak detection
- Unnecessary computations
3. **Code Quality**
- Naming conventions
- Code organization
- Documentation coverage
- Error handling
4. **Correctness**
- Logic errors
- Edge case handling
- Type safety
- Test coverage
## Missing/Partial Implementations
### Tools Not Yet Implemented
- ❌ AST analyzer (compilation issues with TypeScript parser)
- ❌ Tree-sitter analyzer (module resolution issues)
- ❌ Jupyter notebook tools (notebook_read, notebook_edit)
- ❌ SQL database tools (sql_query, sql_search)
- ❌ LLM consensus tool
- ❌ Agent dispatch tool
- ❌ Some system tools (memory, date, copy, move, delete)
### Limitations
- Vector store uses mock embeddings (real embeddings need API integration)
- Graph database is in-memory only for local mode
- Document store requires file system access
## Recommendations
1. **Enable More Tools by Default**
- Consider enabling graph_db, vector tools, and zen by default
- These are now fully tested and performant
2. **Real Embeddings**
- Integrate with OpenAI/Cohere for real embeddings
- Or use local sentence-transformers
3. **Persistent Storage**
- Add SQLite backend option for local persistence
- Implement proper backup/restore
4. **Complete AST Integration**
- Fix TypeScript compilation issues
- Add tree-sitter support for more languages
## Summary
**27 tools** fully implemented and tested
**Excellent performance** across all subsystems
**Local and cloud** backend abstraction working
**AI model integration** for Ollama, LM Studio, and Hanzo
**Comprehensive testing** with benchmarks
The extension is production-ready with all core functionality working as expected.
+3 -3
View File
@@ -1,8 +1,8 @@
{
"name": "hanzo-ai-monorepo",
"version": "1.8.6",
"name": "@hanzo/extension",
"version": "1.9.14",
"private": true,
"description": "Hanzo AI Development Platform Monorepo",
"description": "Hanzo AI Extension",
"license": "MIT",
"repository": {
"type": "git",
+4 -3
View File
@@ -1,6 +1,6 @@
{
"name": "@hanzo/browser-extension",
"version": "1.8.6",
"version": "1.9.17",
"description": "Hanzo AI Browser Extension",
"main": "dist/background.js",
"scripts": {
@@ -16,15 +16,16 @@
"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",
"esbuild": "^0.25.8",
"jsdom": "^26.1.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
File diff suppressed because it is too large Load Diff
+117 -31
View File
@@ -1,12 +1,21 @@
// 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,
startZapDiscoveryLoop,
handleZapMessage,
setZapRequestHandler,
MSG_REQUEST,
DEFAULT_ZAP_PORTS as SHARED_ZAP_PORTS,
type ZapManager,
} from './shared/zap.js';
import {
@@ -115,8 +124,11 @@ const controlSession: ControlSession = {
startedAt: null,
};
// Default ports (overridable via chrome.storage.local settings)
const DEFAULT_ZAP_PORTS = SHARED_ZAP_PORTS;
// Default ports (overridable via chrome.storage.local settings).
// NOTE: ZAP discovery is mDNS-only (HIP-0069); the legacy zapPorts setting
// is no longer consulted for discovery — kept here only because other
// surfaces (status display, settings UI) still expose it during the
// removal window.
const DEFAULT_MCP_PORT = CFG_MCP_PORT;
const DEFAULT_CDP_PORT = CFG_CDP_PORT;
const DEFAULT_RAG_TOP_K = CFG_RAG_TOP_K;
@@ -133,11 +145,10 @@ const controlMessageLimiter = new ActionRateLimiter();
const controlMessageSignatures = new Map<string, string>();
/** Load port configuration from storage */
async function getPortConfig(): Promise<{ zapPorts: number[]; mcpPort: number; cdpPort: number }> {
async function getPortConfig(): Promise<{ mcpPort: number; cdpPort: number }> {
return new Promise((resolve) => {
chrome.storage.local.get(['zapPorts', 'mcpPort', 'cdpPort'], (result) => {
chrome.storage.local.get(['mcpPort', 'cdpPort'], (result) => {
resolve({
zapPorts: result.zapPorts || DEFAULT_ZAP_PORTS,
mcpPort: result.mcpPort || DEFAULT_MCP_PORT,
cdpPort: result.cdpPort || DEFAULT_CDP_PORT,
});
@@ -281,15 +292,46 @@ chrome.runtime.onInstalled.addListener(async () => {
if (gpuAvailable) {
debugLog('[Hanzo] WebGPU available');
try {
await webgpuAI.loadModel({
name: 'hanzo-browser-control',
url: chrome.runtime.getURL('models/browser-control-4bit.bin'),
quantization: '4bit',
maxTokens: 512
});
// Model source resolution order (first hit wins):
// 1. chrome.storage.local.hanzo_model_url — explicit override
// 2. chrome.storage.local.hanzo_model_hf — { repo, file, revision? }
// 3. The hanzo-browser-control HF default — cached after first load
// 4. The bundled-with-extension fallback at models/browser-control-4bit.bin
const stored = await chrome.storage.local.get([
'hanzo_model_url', 'hanzo_model_hf', 'hanzo_model_quant', 'hanzo_model_max_tokens', 'hanzo_model_vocab_url',
]);
let modelUrl: string;
if (stored.hanzo_model_url) {
modelUrl = stored.hanzo_model_url;
} else if (stored.hanzo_model_hf && stored.hanzo_model_hf.repo && stored.hanzo_model_hf.file) {
const { repo, file, revision } = stored.hanzo_model_hf;
modelUrl = WebGPUAI.huggingFaceURL(repo, file, revision || 'main');
} else {
// Default to the HF-published browser-control artifact. If it 404s, the
// catch below falls through to the bundled file, then to remote providers.
modelUrl = WebGPUAI.huggingFaceURL('zenlm/hanzo-browser-control', 'browser-control-4bit.bin');
}
try {
await webgpuAI.loadModel({
name: 'hanzo-browser-control',
url: modelUrl,
quantization: stored.hanzo_model_quant || '4bit',
maxTokens: stored.hanzo_model_max_tokens || 512,
vocabUrl: stored.hanzo_model_vocab_url,
});
debugLog(`[Hanzo] WebGPU model loaded from ${modelUrl}`);
} catch (hfErr) {
debugLog(`[Hanzo] WebGPU model fetch failed for ${modelUrl}: ${hfErr}; trying bundled fallback…`);
await webgpuAI.loadModel({
name: 'hanzo-browser-control',
url: chrome.runtime.getURL('models/browser-control-4bit.bin'),
quantization: '4bit',
maxTokens: 512,
});
}
} catch (error) {
// Model file not bundled yet — will use Ollama/LM Studio/cloud instead
debugLog('[Hanzo] Local model not bundled, using remote providers');
// Neither HF nor bundled artifact available — fall back to remote providers.
debugLog(`[Hanzo] No local WebGPU model available (${error}); using Ollama/LM Studio/cloud`);
}
}
@@ -1754,9 +1796,14 @@ 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`);
@@ -1794,10 +1841,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,22 +1856,59 @@ chrome.tabs.onRemoved.addListener((tabId) => {
});
// 1. Primary: Discover ZAP servers (high-performance binary protocol)
getPortConfig().then(({ zapPorts }) => {
discoverZapServers(zapMgr, BROWSER_NAME, VERSION, zapPorts, debugLog);
// 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);
// 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
// the service worker alive without alarms.
chrome.runtime.onConnect.addListener((port) => {
if (port.name !== 'zap-keepalive') return;
port.onMessage.addListener(() => { /* receipt is the keep-alive */ });
});
// 2. Fallback: Connect to legacy MCP WebSocket
connectToMCP();
// 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.
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);
}
});
} catch (e) {
console.warn('[Hanzo] alarms unavailable:', e);
}
// 3. CDP bridge for browser control (configurable port)
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);
}
});
// 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 };
+85 -21
View File
@@ -1377,30 +1377,94 @@ export class BrowserControl {
// Script Execution Helper
// ===========================================================================
private executeScript<T = any>(tabId: number, code: string): Promise<T> {
return new Promise((resolve, reject) => {
if (chrome.scripting) {
// MV3: use chrome.scripting.executeScript
chrome.scripting.executeScript({
/**
* Run JS in a tab and return its result.
*
* Why MV3 + MAIN world + the Function trampoline?
*
* - `chrome.scripting.executeScript` only accepts a function (or files);
* it does NOT take a code string the way MV2's `tabs.executeScript`
* did. To support our existing call sites that pass dynamic code as
* a string, we serialise the user's code through `args` and run it
* via `Function(codeStr)()` inside the page.
* - MAIN world is required so the page's own globals (window.fetch,
* CustomElements, framework instances) are visible. ISOLATED world
* can't see them.
* - Async unwrap: if the caller's code yields a Promise, executeScript
* serialises it as `{}` (the value-loss bug). We detect that shape
* and re-run wrapping in `Promise.resolve(...)` so the resolved
* value comes back instead of the placeholder.
*
* Falls back to the MV2 string API on browsers / runtimes that still
* have it (older Firefox; no Chrome MV2 ships now).
*/
private async executeScript<T = any>(tabId: number, code: string): Promise<T> {
const exec = async (wrapped: string): Promise<T> => {
// MV3 path — preferred on every modern build.
if (chrome.scripting?.executeScript) {
const results = await chrome.scripting.executeScript({
target: { tabId },
func: new Function(`return (${code})`) as () => T,
}, (results) => {
if (chrome.runtime.lastError) {
reject(new Error(chrome.runtime.lastError.message));
} else {
resolve(results?.[0]?.result as T);
}
world: 'MAIN',
args: [wrapped],
func: (codeStr: string) => {
try {
// eslint-disable-next-line @typescript-eslint/no-implied-eval, no-new-func
return Function(codeStr)();
} catch (e: any) {
return { __hanzo_error: e?.message || String(e) };
}
},
});
} else {
// MV2 fallback: use chrome.tabs.executeScript
(chrome.tabs as any).executeScript(tabId, { code }, (results: any[]) => {
if (chrome.runtime.lastError) {
reject(new Error(chrome.runtime.lastError.message));
} else {
resolve(results?.[0] as T);
}
return (results?.[0] as any)?.result as T;
}
// MV2 fallback — only Chrome-MV2 / older Firefox would reach this.
const legacy = (chrome.tabs as any).executeScript;
if (legacy) {
return new Promise<T>((resolve, reject) => {
legacy(tabId, { code: wrapped }, (results: any[]) => {
if (chrome.runtime.lastError) {
reject(new Error(chrome.runtime.lastError.message));
} else {
resolve(results?.[0] as T);
}
});
});
}
});
throw new Error('No executeScript API available (need MV3 scripting permission)');
};
// Wrap the user's code so it ALWAYS has a top-level `return`. The
// Function trampoline discards the value of an IIFE — without an
// explicit `return` the result is undefined → JSON-encodes to `{}`.
const trimmed = code.trim();
const wrappedCode = /^\s*return\b/.test(trimmed)
? trimmed
: `try { return (${trimmed}); } catch(__e) { return { __hanzo_error: __e.message || String(__e) }; }`;
let result: any = await exec(wrappedCode);
// Unwrap our error sentinel so callers see a real Error.
if (result && typeof result === 'object' && '__hanzo_error' in result) {
throw new Error(result.__hanzo_error);
}
// Detect the empty-Promise serialization. If the caller's code yielded
// a Promise (e.g. (async () => 'x')()) executeScript drops it as `{}`.
// Re-run wrapped in Promise.resolve so the resolved value comes back.
const looksEmpty = result !== null
&& typeof result === 'object'
&& Object.keys(result).length === 0
&& !Array.isArray(result);
if (looksEmpty) {
const promiseWrapped = `return Promise.resolve((function(){ ${wrappedCode} })()).then(function(__v){
try { return JSON.parse(JSON.stringify(__v === undefined ? null : __v)); } catch(e) { return String(__v); }
});`;
result = await exec(promiseWrapped);
if (result && typeof result === 'object' && '__hanzo_error' in result) {
throw new Error(result.__hanzo_error);
}
}
return result as T;
}
}
+12 -33
View File
@@ -8,29 +8,13 @@ const { execSync } = require('child_process');
async function build() {
console.log('Building browser extension...');
const hanzoUiRoot = path.resolve(__dirname, '../../../../ui');
const hanzoUiPrimitives = path.join(hanzoUiRoot, 'pkg/ui/dist/primitives/index-common.js');
const localReact = path.join(hanzoUiRoot, 'node_modules/react');
const localReactDom = path.join(hanzoUiRoot, 'node_modules/react-dom');
const forceLocalUiShim = process.env.HANZO_FORCE_LOCAL_UI_SHIM === '1';
const canUseSharedUi =
!forceLocalUiShim &&
fs.existsSync(hanzoUiPrimitives) &&
fs.existsSync(localReact) &&
fs.existsSync(localReactDom);
if (canUseSharedUi) {
console.log('Using shared @hanzo/ui primitives from sibling repo:', hanzoUiPrimitives);
} else {
console.warn(
[
'Shared @hanzo/ui dependencies were not found. Falling back to local primitives shim.',
`Expected: ${hanzoUiPrimitives}`,
`Expected: ${localReact}`,
`Expected: ${localReactDom}`,
].join('\n')
);
}
// The extension does NOT depend on `@hanzo/ui`. Primitives are served by
// a small local shim aliased as `@hanzo/gui` so call-sites read like the
// canonical hanzo design system. The full tamagui-based @hanzo/gui will
// land in a feature branch — when that ships, just point the alias below
// at the published `@hanzo/gui` package and remove gui-primitives.tsx.
const hanzoGuiShim = path.join(__dirname, 'gui-primitives.tsx');
console.log('Using @hanzo/gui local primitives shim:', hanzoGuiShim);
// Ensure dist directories exist
fs.mkdirSync('dist/browser-extension', { recursive: true });
@@ -80,16 +64,11 @@ async function build() {
format: 'esm'
});
// Build sidebar + popup UI scripts (TypeScript-first, with JS fallback)
const sidebarAliases = canUseSharedUi
? {
'@hanzo/ui/primitives-common': hanzoUiPrimitives,
react: localReact,
'react-dom': localReactDom,
}
: {
'@hanzo/ui/primitives-common': path.join(__dirname, 'primitives-common-fallback.tsx'),
};
// Build sidebar + popup UI scripts (TypeScript-first, with JS fallback).
// Primitives are aliased to the local @hanzo/gui shim (see gui-primitives.tsx).
const sidebarAliases = {
'@hanzo/gui': hanzoGuiShim,
};
await esbuild.build({
entryPoints: [fs.existsSync('src/sidebar.ts') ? 'src/sidebar.ts' : 'src/sidebar.js'],
+115 -35
View File
@@ -1,6 +1,18 @@
#!/usr/bin/env node
/**
* CDP Bridge Server - Unified browser control interface
* 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.
@@ -10,6 +22,7 @@ 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;
@@ -61,6 +74,21 @@ class CDPBridgeServer {
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) => {
@@ -100,21 +128,53 @@ class CDPBridgeServer {
}
}
/** Find a client by browser name/ID, or return first connected client */
/**
* 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) {
// Default: first connected client
return this.clients.values().next().value;
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;
}
// Exact match on ID first (e.g. "chrome-2")
for (const client of this.clients.values()) {
if (client.id === target) return client;
// 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;
}
}
}
// Match by browser type (returns first of that type, e.g. "firefox")
for (const client of this.clients.values()) {
if (client.browser === target) 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;
}
}
return undefined;
// Last resort: any connected client.
return this.clients.values().next().value;
}
private handleMessage(ws: WebSocket, message: any) {
@@ -134,6 +194,12 @@ class CDPBridgeServer {
}
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;
}
@@ -244,6 +310,25 @@ class CDPBridgeServer {
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(),
};
}
@@ -371,15 +456,12 @@ class CDPBridgeServer {
selector: rest.selector || rest.ref,
}), b);
// Navigation
case 'go_back':
case 'navigate_back':
return this.sendRaw('Page.goBack', tabTarget(), b);
case 'go_forward':
case 'navigate_forward':
return this.sendRaw('Page.goForward', tabTarget(), 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);
@@ -387,11 +469,9 @@ class CDPBridgeServer {
return this.sendRaw('hanzo.title', tabTarget(), b);
case 'get_tab_info':
case 'tab_info':
return this.sendRaw('hanzo.tabInfo', tabTarget(), b);
case 'get_history':
case 'history':
return this.sendRaw('hanzo.getHistory', tabTarget({ maxResults: rest.maxResults }), b);
case 'wait_for_navigation':
@@ -400,9 +480,6 @@ class CDPBridgeServer {
case 'create_tab':
return this.sendRaw('Target.createTarget', { url: rest.url || 'about:blank' }, b);
case 'close_tab':
return this.sendRaw('Target.closeTarget', { targetId: rest.tabId }, b);
// Fetch through browser context (uses page cookies/auth)
case 'fetch': {
const fetchResult = await this.sendRaw('hanzo.fetch', {
@@ -492,20 +569,23 @@ class CDPBridgeServer {
case 'cookies':
return this.sendRaw('hanzo.getCookies', tabTarget(), b);
// Evaluate
// 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);
// Unwrap CDP result envelope. Handle every shape extensions can hand
// back: {result:{value}} (Chrome), {result:{value,type}} (CDP), bare
// value, undefined-as-empty-object ({}).
const inner = evalResult?.result;
if (inner && typeof inner === 'object' && 'value' in inner) {
return { result: (inner as any).value, raw: evalResult };
}
return { result: inner ?? evalResult };
return { result: unwrapEvaluateResult(evalResult), raw: evalResult };
}
// Wait
+64 -20
View File
@@ -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) {
@@ -418,11 +457,16 @@ export class CDPBridge {
break;
case 'Runtime.evaluate':
// Must enable Runtime domain and set returnByValue for actual values
// Must enable Runtime domain and set returnByValue for actual
// values. `awaitPromise: true` makes CDP wait for async IIFEs
// to resolve so callers passing `(async () => {...})()` get
// the resolved value instead of an empty {} (the previous bug
// — caller saw a serialized Promise placeholder).
await this.send(tabId, 'Runtime.enable');
result = await this.send(tabId, method, {
...params,
returnByValue: true,
awaitPromise: true,
});
break;
+1 -1
View File
@@ -9,7 +9,7 @@ import {
CardHeader,
CardTitle,
Textarea,
} from '@hanzo/ui/primitives-common';
} from '@hanzo/gui';
function LoginPrompt() {
return (
+310 -79
View File
@@ -1,5 +1,11 @@
// Hanzo Browser Extension - Content Script
// Connects clicked elements to source code via source-maps
//
// Polyfill MUST be the first import so any subsequent code that touches
// `browser.*` (the cross-browser API surface) sees the wrapped Promise
// API on Chrome too. On native runtimes (Firefox / Safari) the import is
// a no-op.
import 'webextension-polyfill';
import {
DEFAULT_INSPECT_SHORTCUT,
@@ -109,12 +115,22 @@ class HanzoContentScript {
sendResponse({ success: true });
return true;
case 'page.overlay.toggle':
if (request.side === 'left' || request.side === 'right') pageOverlay.setSide(request.side);
if (request.mode === 'push' || request.mode === 'overlay') pageOverlay.setMode(request.mode);
if (request.width === 'compact' || request.width === 'default' || request.width === 'wide') {
pageOverlay.setWidth(request.width);
}
sendResponse({
success: true,
open: pageOverlay.toggle(),
});
return true;
case 'page.overlay.show':
if (request.side === 'left' || request.side === 'right') pageOverlay.setSide(request.side);
if (request.mode === 'push' || request.mode === 'overlay') pageOverlay.setMode(request.mode);
if (request.width === 'compact' || request.width === 'default' || request.width === 'wide') {
pageOverlay.setWidth(request.width);
}
pageOverlay.show();
sendResponse({ success: true, open: true });
return true;
@@ -123,7 +139,44 @@ class HanzoContentScript {
sendResponse({ success: true, open: false });
return true;
case 'page.overlay.status':
sendResponse({ success: true, open: pageOverlay.isOpen() });
sendResponse({
success: true,
open: pageOverlay.isOpen(),
side: pageOverlay.getSide(),
mode: pageOverlay.getMode(),
});
return true;
case 'page.overlay.setSide':
if (request.side === 'left' || request.side === 'right') {
pageOverlay.setSide(request.side);
sendResponse({ success: true, side: request.side });
} else {
sendResponse({ success: false, error: 'side must be "left" or "right"' });
}
return true;
case 'page.overlay.setMode':
if (request.mode === 'push' || request.mode === 'overlay') {
pageOverlay.setMode(request.mode);
sendResponse({ success: true, mode: request.mode });
} else {
sendResponse({ success: false, error: 'mode must be "push" or "overlay"' });
}
return true;
case 'page.overlay.setWidth':
if (request.width === 'compact' || request.width === 'default' || request.width === 'wide') {
pageOverlay.setWidth(request.width);
sendResponse({ success: true, width: request.width });
} else {
sendResponse({ success: false, error: 'width must be compact|default|wide' });
}
return true;
case 'page.overlay.setEnabled':
if (typeof request.enabled === 'boolean') {
pageOverlay.setEnabled(request.enabled);
sendResponse({ success: true, enabled: request.enabled });
} else {
sendResponse({ success: false, error: 'enabled must be boolean' });
}
return true;
// --- Popup Tool Actions ---
@@ -748,10 +801,22 @@ interface OverlayElementContext {
html: string;
}
type OverlayMode = 'overlay' | 'push';
type OverlayWidth = 'compact' | 'default' | 'wide';
const OVERLAY_WIDTH_PX: Record<OverlayWidth, number> = {
compact: 320,
default: 420,
wide: 560,
};
class HanzoPageOverlay {
private mounted = false;
private enabled = false;
private open = false;
private side: 'left' | 'right' = 'right';
private mode: OverlayMode = 'overlay';
private width: OverlayWidth = 'default';
private sending = false;
private watchMode = false;
private editMode = false;
@@ -778,6 +843,53 @@ class HanzoPageOverlay {
this.injectStyles();
this.mount();
this.bindGlobalEvents();
this.restorePrefs();
}
private restorePrefs(): void {
try {
const ss = (typeof browser !== 'undefined' ? browser : (chrome as any))?.storage?.sync;
if (!ss?.get) return;
const promised = ss.get(['overlaySide', 'overlayMode', 'overlayWidth']);
const apply = (v: any) => {
if (v?.overlaySide === 'left' || v?.overlaySide === 'right') {
this.side = v.overlaySide;
this.root?.setAttribute('data-side', this.side);
}
if (v?.overlayMode === 'push' || v?.overlayMode === 'overlay') {
this.mode = v.overlayMode;
this.root?.setAttribute('data-mode', this.mode);
}
if (v?.overlayWidth === 'compact' || v?.overlayWidth === 'default' || v?.overlayWidth === 'wide') {
this.width = v.overlayWidth;
this.applyWidth();
}
this.applyPushIfOpen();
};
if (promised && typeof promised.then === 'function') {
promised.then(apply).catch(() => {});
} else {
ss.get(['overlaySide', 'overlayMode', 'overlayWidth'], apply);
}
} catch { /* storage may be unavailable */ }
}
private applyWidth(): void {
const px = OVERLAY_WIDTH_PX[this.width] ?? OVERLAY_WIDTH_PX.default;
if (this.root) {
this.root.style.setProperty('--hanzo-overlay-width', `${px}px`);
this.root.style.width = `min(${px}px, 100vw)`;
}
document.documentElement.style.setProperty('--hanzo-overlay-width', `${px}px`);
}
private applyPushIfOpen(): void {
const html = document.documentElement;
// Always strip first so we never stack margins.
html.classList.remove('hanzo-overlay-push', 'hanzo-overlay-push-left', 'hanzo-overlay-push-right');
if (this.open && this.enabled && this.mode === 'push') {
html.classList.add('hanzo-overlay-push', `hanzo-overlay-push-${this.side}`);
}
}
toggle(): boolean {
@@ -789,12 +901,59 @@ class HanzoPageOverlay {
return false;
}
isOpen(): boolean {
return this.open;
}
getSide(): 'left' | 'right' {
return this.side;
}
getMode(): OverlayMode {
return this.mode;
}
setSide(side: 'left' | 'right'): void {
this.side = side;
this.root?.setAttribute('data-side', side);
this.applyPushIfOpen();
this.persist({ overlaySide: side });
}
setMode(mode: OverlayMode): void {
this.mode = mode;
this.root?.setAttribute('data-mode', mode);
this.applyPushIfOpen();
this.persist({ overlayMode: mode });
}
setWidth(width: OverlayWidth): void {
this.width = width;
this.applyWidth();
this.persist({ overlayWidth: width });
}
setEnabled(enabled: boolean): void {
this.enabled = enabled;
this.root?.setAttribute('data-enabled', enabled ? 'true' : 'false');
if (!enabled) this.hide();
this.persist({ overlayEnabled: enabled });
}
private persist(values: Record<string, unknown>): void {
try {
const ss = (typeof browser !== 'undefined' ? browser : (chrome as any))?.storage?.sync;
ss?.set?.(values);
} catch { /* storage may be unavailable */ }
}
show(): void {
this.ensureMounted();
this.enabled = true;
this.open = true;
this.root?.setAttribute('data-enabled', 'true');
this.root?.setAttribute('data-open', 'true');
this.applyPushIfOpen();
this.loadModels();
this.focusInput();
}
@@ -802,16 +961,13 @@ class HanzoPageOverlay {
hide(): void {
this.open = false;
this.root?.setAttribute('data-open', 'false');
this.applyPushIfOpen();
this.updateStatus('');
this.clearHighlight();
this.setWatchMode(false);
this.setEditMode(false);
}
isOpen(): boolean {
return this.open;
}
private ensureMounted(): void {
if (this.mounted) return;
this.mount();
@@ -823,74 +979,68 @@ class HanzoPageOverlay {
const style = document.createElement('style');
style.id = 'hanzo-page-overlay-styles';
style.textContent = `
/* Edge-pinned sidebar panel — no FAB, opened from extension popup.
Hanzo brand: monochrome (white primary, neutral surfaces). No blue. */
#hanzo-page-overlay-root {
position: fixed;
right: 20px;
bottom: 20px;
top: 0;
bottom: 0;
right: 0;
width: min(var(--hanzo-overlay-width, 420px), 100vw);
z-index: 2147483645;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
pointer-events: none;
}
#hanzo-page-overlay-root[data-side="left"] {
right: auto;
left: 0;
}
#hanzo-page-overlay-root * {
box-sizing: border-box;
}
#hanzo-page-overlay-root[data-enabled="false"] .hanzo-page-launcher {
opacity: 0;
transform: translateY(16px);
pointer-events: none;
/* Push mode: when the user pins the panel as a dock, html shifts so
page content reflows beside it instead of being covered. Applied to
<html> from setMode('push') + open. */
html.hanzo-overlay-push.hanzo-overlay-push-right {
margin-right: var(--hanzo-overlay-width, 420px) !important;
transition: margin-right 0.18s ease;
}
.hanzo-page-launcher {
width: 54px;
height: 54px;
border-radius: 9999px;
border: 1px solid rgba(255, 255, 255, 0.2);
background: linear-gradient(135deg, rgba(22, 24, 31, 0.96), rgba(7, 8, 12, 0.96));
color: #ffffff;
box-shadow: 0 14px 34px rgba(0, 0, 0, 0.35);
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
pointer-events: auto;
transition: transform 0.2s ease, opacity 0.2s ease;
}
.hanzo-page-launcher:hover {
transform: translateY(-2px);
html.hanzo-overlay-push.hanzo-overlay-push-left {
margin-left: var(--hanzo-overlay-width, 420px) !important;
transition: margin-left 0.18s ease;
}
.hanzo-page-panel {
width: min(420px, calc(100vw - 24px));
max-height: min(620px, calc(100vh - 28px));
border-radius: 16px;
border: 1px solid rgba(255, 255, 255, 0.16);
background: linear-gradient(180deg, rgba(11, 13, 18, 0.97), rgba(7, 8, 12, 0.97));
color: #eef2ff;
width: 100%;
height: 100%;
border-left: 1px solid rgba(255, 255, 255, 0.16);
background: linear-gradient(180deg, rgba(10, 10, 11, 0.97), rgba(7, 7, 8, 0.97));
color: #f5f5f5;
overflow: hidden;
box-shadow: 0 22px 48px rgba(0, 0, 0, 0.45);
transform-origin: bottom right;
transform: translateY(8px) scale(0.98);
box-shadow: -22px 0 48px rgba(0, 0, 0, 0.45);
transform: translateX(100%);
opacity: 0;
pointer-events: none;
display: flex;
flex-direction: column;
margin-bottom: 10px;
min-height: 0;
transition: transform 0.18s ease, opacity 0.18s ease;
}
#hanzo-page-overlay-root[data-open="true"] .hanzo-page-panel {
transform: translateY(0) scale(1);
opacity: 1;
pointer-events: auto;
#hanzo-page-overlay-root[data-side="left"] .hanzo-page-panel {
border-left: none;
border-right: 1px solid rgba(255, 255, 255, 0.16);
box-shadow: 22px 0 48px rgba(0, 0, 0, 0.45);
transform: translateX(-100%);
}
#hanzo-page-overlay-root[data-open="true"] .hanzo-page-launcher {
opacity: 0;
transform: scale(0.9);
pointer-events: none;
#hanzo-page-overlay-root[data-open="true"] .hanzo-page-panel {
transform: translateX(0);
opacity: 1;
pointer-events: auto;
}
.hanzo-page-header {
@@ -898,14 +1048,16 @@ class HanzoPageOverlay {
align-items: center;
justify-content: space-between;
padding: 12px 14px;
border-bottom: 1px solid rgba(255, 255, 255, 0.12);
border-bottom: 1px solid rgba(255, 255, 255, 0.10);
background: linear-gradient(180deg, rgba(255, 255, 255, 0.03), transparent);
flex-shrink: 0;
}
.hanzo-page-brand {
font-weight: 700;
letter-spacing: 0.2px;
font-size: 13px;
color: #f5f5f5;
}
.hanzo-page-header-actions {
@@ -918,16 +1070,22 @@ class HanzoPageOverlay {
border: 1px solid rgba(255, 255, 255, 0.2);
border-radius: 9999px;
padding: 4px 10px;
background: rgba(255, 255, 255, 0.02);
color: #d6deff;
background: transparent;
color: rgba(245, 245, 245, 0.85);
cursor: pointer;
font-size: 11px;
transition: background 0.15s, color 0.15s, border-color 0.15s;
}
.hanzo-chip-btn:hover {
background: rgba(255, 255, 255, 0.06);
color: #fff;
}
.hanzo-chip-btn[data-active="true"] {
border-color: rgba(56, 189, 248, 0.62);
background: rgba(14, 116, 144, 0.3);
color: #e0f2fe;
border-color: rgba(255, 255, 255, 0.7);
background: rgba(255, 255, 255, 0.10);
color: #fff;
}
.hanzo-icon-btn {
@@ -936,9 +1094,28 @@ class HanzoPageOverlay {
border-radius: 8px;
border: 1px solid rgba(255, 255, 255, 0.18);
background: transparent;
color: #e5e7eb;
color: #f5f5f5;
cursor: pointer;
font-size: 16px;
font-size: 14px;
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0;
transition: background 0.15s, border-color 0.15s;
}
.hanzo-icon-btn:hover {
background: rgba(255, 255, 255, 0.06);
}
.hanzo-icon-btn[data-active="true"] {
background: rgba(255, 255, 255, 0.12);
border-color: rgba(255, 255, 255, 0.5);
}
.hanzo-icon-btn svg {
width: 14px;
height: 14px;
}
.hanzo-page-meta {
@@ -947,11 +1124,12 @@ class HanzoPageOverlay {
grid-template-columns: 1fr auto;
gap: 8px;
align-items: center;
flex-shrink: 0;
}
.hanzo-page-context {
font-size: 11px;
color: #a5b4fc;
color: rgba(245, 245, 245, 0.65);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
@@ -959,11 +1137,12 @@ class HanzoPageOverlay {
.hanzo-page-status {
font-size: 11px;
color: #93c5fd;
color: rgba(245, 245, 245, 0.55);
}
.hanzo-model-row {
padding: 0 14px 8px 14px;
flex-shrink: 0;
}
.hanzo-model-row select {
@@ -971,15 +1150,16 @@ class HanzoPageOverlay {
border-radius: 8px;
border: 1px solid rgba(255, 255, 255, 0.16);
background: rgba(255, 255, 255, 0.03);
color: #f1f5f9;
color: #f5f5f5;
padding: 7px 9px;
font-size: 12px;
}
/* The chat scroller: takes ALL remaining vertical space so the
composer below stays sticky against the bottom of the panel. */
.hanzo-page-messages {
flex: 1;
min-height: 200px;
max-height: 370px;
flex: 1 1 auto;
min-height: 0;
overflow-y: auto;
padding: 8px 14px 12px 14px;
display: flex;
@@ -998,23 +1178,27 @@ class HanzoPageOverlay {
.hanzo-page-msg.user {
align-self: flex-end;
max-width: 88%;
background: rgba(59, 130, 246, 0.2);
border: 1px solid rgba(59, 130, 246, 0.4);
background: rgba(255, 255, 255, 0.10);
border: 1px solid rgba(255, 255, 255, 0.18);
}
.hanzo-page-msg.assistant {
align-self: flex-start;
max-width: 92%;
background: rgba(255, 255, 255, 0.05);
border: 1px solid rgba(255, 255, 255, 0.12);
background: rgba(255, 255, 255, 0.04);
border: 1px solid rgba(255, 255, 255, 0.10);
}
/* Composer pinned to bottom of the panel. flex-shrink: 0 prevents
it from collapsing when the messages scroller grows. */
.hanzo-page-composer {
border-top: 1px solid rgba(255, 255, 255, 0.1);
border-top: 1px solid rgba(255, 255, 255, 0.10);
padding: 10px 12px;
display: flex;
gap: 8px;
align-items: flex-end;
background: rgba(0, 0, 0, 0.35);
flex-shrink: 0;
}
.hanzo-page-composer textarea {
@@ -1025,21 +1209,31 @@ class HanzoPageOverlay {
border-radius: 10px;
border: 1px solid rgba(255, 255, 255, 0.17);
background: rgba(255, 255, 255, 0.03);
color: #f8fafc;
color: #f5f5f5;
padding: 9px 11px;
font-size: 12px;
}
/* Primary action: WHITE per @hanzo/brand monochrome guidelines. */
.hanzo-page-composer button {
min-width: 68px;
height: 38px;
border-radius: 10px;
border: 1px solid rgba(37, 99, 235, 0.65);
background: linear-gradient(180deg, rgba(37, 99, 235, 0.95), rgba(30, 64, 175, 0.95));
color: #eff6ff;
border: 1px solid #ffffff;
background: #ffffff;
color: #0a0a0b;
font-weight: 600;
font-size: 12px;
cursor: pointer;
transition: background 0.12s, transform 0.08s;
}
.hanzo-page-composer button:hover:not(:disabled) {
background: #f5f5f5;
}
.hanzo-page-composer button:active:not(:disabled) {
transform: translateY(1px);
}
.hanzo-page-composer button:disabled {
@@ -1051,13 +1245,13 @@ class HanzoPageOverlay {
position: fixed;
z-index: 2147483644;
pointer-events: none;
border: 2px solid rgba(56, 189, 248, 0.95);
border: 2px solid rgba(255, 255, 255, 0.95);
border-radius: 6px;
box-shadow: 0 0 0 2px rgba(14, 116, 144, 0.2);
box-shadow: 0 0 0 2px rgba(255, 255, 255, 0.2);
}
[data-hanzo-inline-editing="true"] {
outline: 2px solid rgba(251, 191, 36, 0.92) !important;
outline: 2px solid rgba(255, 255, 255, 0.92) !important;
outline-offset: 2px !important;
}
`;
@@ -1071,14 +1265,22 @@ class HanzoPageOverlay {
this.root.id = 'hanzo-page-overlay-root';
this.root.setAttribute('data-open', 'false');
this.root.setAttribute('data-enabled', 'false');
this.root.setAttribute('data-side', this.side);
this.root.setAttribute('data-mode', this.mode);
this.applyWidth();
this.root.innerHTML = `
<div class="hanzo-page-panel" role="dialog" aria-label="Hanzo Page Assistant">
<div class="hanzo-page-header">
<div class="hanzo-page-brand">Hanzo Overlay</div>
<div class="hanzo-page-brand">Hanzo</div>
<div class="hanzo-page-header-actions">
<button class="hanzo-chip-btn" data-role="watch" data-active="false" title="Watch elements on hover">Watch</button>
<button class="hanzo-chip-btn" data-role="edit" data-active="false" title="Click any element to edit text">Edit</button>
<button class="hanzo-icon-btn" data-role="close" title="Minimize">×</button>
<button class="hanzo-icon-btn" data-role="pin-mode" title="Toggle: pin (push content) vs overlay (float over page)">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">
<path d="M9 4v16M3 4h12a4 4 0 0 1 4 4v8a4 4 0 0 1-4 4H3z"/>
</svg>
</button>
<button class="hanzo-icon-btn" data-role="close" title="Hide overlay">×</button>
</div>
</div>
<div class="hanzo-page-meta">
@@ -1102,11 +1304,10 @@ class HanzoPageOverlay {
<button data-role="send" type="button">Ask</button>
</div>
</div>
<button class="hanzo-page-launcher" data-role="launcher" type="button" title="Open Hanzo Overlay">AI</button>
`;
this.panel = this.root.querySelector('.hanzo-page-panel');
this.launcher = this.root.querySelector('[data-role="launcher"]');
this.launcher = null;
this.messagesEl = this.root.querySelector('[data-role="messages"]');
this.inputEl = this.root.querySelector('[data-role="input"]');
this.sendBtn = this.root.querySelector('[data-role="send"]');
@@ -1116,8 +1317,14 @@ class HanzoPageOverlay {
this.editBtn = this.root.querySelector('[data-role="edit"]');
this.statusEl = this.root.querySelector('.hanzo-page-status');
this.launcher?.addEventListener('click', () => this.show());
this.root.querySelector('[data-role="close"]')?.addEventListener('click', () => this.hide());
const pinModeBtn = this.root.querySelector('[data-role="pin-mode"]');
pinModeBtn?.setAttribute('data-active', this.mode === 'push' ? 'true' : 'false');
pinModeBtn?.addEventListener('click', () => {
const next: OverlayMode = this.mode === 'push' ? 'overlay' : 'push';
this.setMode(next);
pinModeBtn.setAttribute('data-active', next === 'push' ? 'true' : 'false');
});
this.watchBtn?.addEventListener('click', () => this.setWatchMode(!this.watchMode));
this.editBtn?.addEventListener('click', () => this.setEditMode(!this.editMode));
this.sendBtn?.addEventListener('click', () => this.askPageQuestion());
@@ -1733,3 +1940,27 @@ const inlineConsole = (() => {
// Initialize
new HanzoContentScript();
// =============================================================================
// Background keep-alive port.
//
// Firefox MV3 treats `background.scripts` as event pages; Chrome MV3 service
// workers idle out after ~30s. An open `chrome.runtime` port from any
// content script keeps the background page alive for the lifetime of the
// port — no polling, no alarms required. Every loaded http(s) tab holds
// one of these, so the background ZAP socket survives indefinitely as
// long as the user has any non-privileged tab open.
//
// Heartbeats every 10s prevent Firefox's 4-minute idle-port disconnect.
// =============================================================================
try {
const port = (browser as any)?.runtime?.connect?.({ name: 'zap-keepalive' })
|| (chrome as any)?.runtime?.connect?.({ name: 'zap-keepalive' });
if (port) {
const tick = setInterval(() => {
try { port.postMessage({ type: 'ping', t: Date.now() }); }
catch { clearInterval(tick); }
}, 10_000);
port.onDisconnect?.addListener?.(() => clearInterval(tick));
}
} catch { /* not all pages let content scripts open ports — best effort */ }
+116
View File
@@ -0,0 +1,116 @@
/**
* Local @hanzo/gui primitives shim for the extension.
*
* This is the canonical surface the extension's React components import via
* `@hanzo/gui`. It deliberately does NOT pull in the upstream tamagui-based
* `@hanzo/gui` package because:
*
* 1. content-script + background bundles target the smallest possible
* payload RNW + tamagui's runtime is far too heavy for a script
* injected into every page;
* 2. Hanzo brand is strict monochrome (see ~/work/hanzo/brand/brand.json,
* primaryColor #FFFFFF), so no color tokens travel with primitives
* anyway pure structural elements styled by sibling CSS;
* 3. The full tamagui adoption ships in a future feature branch with the
* compiler + RNW peer dep wired correctly.
*
* The build aliases `@hanzo/gui` this file. When the tamagui adoption
* lands, swap the alias target to the published `@hanzo/gui` package.
*
* Each primitive renders a plain DOM element with a stable, monochrome
* class hook. All visuals come from sidebar.css / popup.css using the
* brand token scale (white luminance ladder, no chromatic accents).
*/
import React from 'react';
type Classy = { className?: string; children?: React.ReactNode };
function cx(base: string, extra?: string): string {
return extra ? `${base} ${extra}` : base;
}
export function Button(props: React.ButtonHTMLAttributes<HTMLButtonElement>) {
const { className, children, ...rest } = props;
return (
<button {...rest} className={cx('hanzo-gui-btn', className)}>
{children}
</button>
);
}
export function Textarea(props: React.TextareaHTMLAttributes<HTMLTextAreaElement>) {
const { className, children, ...rest } = props;
return (
<textarea {...rest} className={cx('hanzo-gui-textarea', className)}>
{children}
</textarea>
);
}
export function Input(props: React.InputHTMLAttributes<HTMLInputElement>) {
const { className, ...rest } = props;
return <input {...rest} className={cx('hanzo-gui-input', className)} />;
}
export function Card({ className, children }: Classy) {
return <div className={cx('hanzo-gui-card', className)}>{children}</div>;
}
export function CardHeader({ className, children }: Classy) {
return <div className={cx('hanzo-gui-card-header', className)}>{children}</div>;
}
export function CardTitle({ className, children }: Classy) {
return <h3 className={cx('hanzo-gui-card-title', className)}>{children}</h3>;
}
export function CardDescription({ className, children }: Classy) {
return <p className={cx('hanzo-gui-card-description', className)}>{children}</p>;
}
export function CardContent({ className, children }: Classy) {
return <div className={cx('hanzo-gui-card-content', className)}>{children}</div>;
}
/* Layout primitives mirror the names used by the upstream Tamagui-based
@hanzo/gui (XStack/YStack) so call-sites can be ported without churn
when the full adoption lands. Pure flexbox under the hood. */
type Stack = Classy & React.HTMLAttributes<HTMLDivElement> & {
gap?: number | string;
};
export function XStack({ className, gap, style, children, ...rest }: Stack) {
const merged: React.CSSProperties = {
display: 'flex',
flexDirection: 'row',
gap,
...style,
};
return (
<div {...rest} style={merged} className={cx('hanzo-gui-xstack', className)}>
{children}
</div>
);
}
export function YStack({ className, gap, style, children, ...rest }: Stack) {
const merged: React.CSSProperties = {
display: 'flex',
flexDirection: 'column',
gap,
...style,
};
return (
<div {...rest} style={merged} className={cx('hanzo-gui-ystack', className)}>
{children}
</div>
);
}
export function Text({ className, children, ...rest }: Classy & React.HTMLAttributes<HTMLSpanElement>) {
return (
<span {...rest} className={cx('hanzo-gui-text', className)}>
{children}
</span>
);
}
+6 -11
View File
@@ -1,7 +1,7 @@
{
"manifest_version": 3,
"name": "Hanzo AI",
"version": "1.8.6",
"version": "1.9.17",
"description": "AI-powered dev assistant — click-to-code navigation, source maps, WebGPU AI, and MCP integration for Claude Code.",
"permissions": [
"activeTab",
@@ -10,7 +10,9 @@
"tabs",
"webNavigation",
"scripting",
"cookies"
"cookies",
"nativeMessaging",
"alarms"
],
"host_permissions": [
"http://localhost/*",
@@ -51,7 +53,8 @@
"default_icon": {
"16": "icon16.png",
"48": "icon48.png"
}
},
"open_at_install": false
},
"web_accessible_resources": [
{
@@ -70,13 +73,5 @@
"strict_min_version": "109.0"
},
"gecko_android": {}
},
"data_collection_permissions": {
"purposes": [
"functionality"
],
"data_categories": [
"technical_data"
]
}
}
+5 -6
View File
@@ -1,18 +1,20 @@
{
"manifest_version": 3,
"name": "Hanzo AI",
"version": "1.8.6",
"version": "1.9.17",
"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": [
"activeTab",
"identity",
"sidePanel",
"storage",
"tabs",
"webNavigation",
"scripting",
"cookies",
"debugger"
"debugger",
"alarms",
"nativeMessaging"
],
"host_permissions": [
"http://localhost/*",
@@ -45,9 +47,6 @@
"128": "icon128.png"
}
},
"side_panel": {
"default_path": "sidebar.html"
},
"icons": {
"16": "icon16.png",
"48": "icon48.png",
+5 -4
View File
@@ -1,4 +1,4 @@
/* Hanzo AI Browser Extension Popup — aligned with @hanzo/ui dark theme */
/* Hanzo AI Browser Extension Popup — @hanzo/gui dark theme, monochrome */
:root {
--primary: #fafafa;
@@ -13,9 +13,10 @@
--border-strong: rgba(255,255,255,0.16);
--glass: rgba(255,255,255,0.04);
--glass-strong: rgba(255,255,255,0.07);
--success: #22c55e;
--warning: #eab308;
--error: #ef4444;
/* Status: monochrome by hue per @hanzo/brand (white luminance scale). */
--success: #fafafa;
--warning: #d4d4d4;
--error: rgba(255,255,255,0.55);
--link: #a3a3a3;
--link-hover: #fafafa;
--radius: 10px;
+35 -9
View File
@@ -57,20 +57,43 @@
<div class="divider"></div>
</div>
<!-- Actions -->
<!-- Actions: single Open Chat button + on-page surface settings. -->
<button id="open-panel" class="btn btn-secondary" style="margin-bottom: 8px;">
<svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor">
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/>
</svg>
Open Chat Panel
</button>
<button id="open-page-overlay" class="btn btn-ghost" style="margin-bottom: 12px;">
<svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor">
<path d="M4 4h16v12H8l-4 4V4z"/>
</svg>
Toggle On-Page Overlay
Open Chat
</button>
<!-- On-page chat config -->
<div class="onpage-config" style="display:flex;flex-direction:column;gap:6px;margin-bottom:12px;font-size:11px;">
<div class="feature-toggle" style="display:flex;justify-content:space-between;align-items:center;">
<label style="display:flex;align-items:center;gap:6px;cursor:pointer;">
<input type="checkbox" id="overlay-enabled" checked>
<span>On-page chat panel</span>
</label>
<span style="color:var(--text-muted,#888);font-size:10px;">overlay vs new tab</span>
</div>
<div id="overlay-side-row" class="side-picker" style="display:flex;gap:6px;align-items:center;">
<span style="color:var(--text-muted,#888);">Pin:</span>
<button id="pin-left" class="btn btn-ghost" style="flex:1;padding:4px 8px;font-size:11px;">Left</button>
<button id="pin-right" class="btn btn-ghost" style="flex:1;padding:4px 8px;font-size:11px;" data-active="true">Right</button>
</div>
<div id="overlay-width-row" style="display:flex;gap:6px;align-items:center;">
<span style="color:var(--text-muted,#888);">Width:</span>
<button class="btn btn-ghost width-btn" data-width="compact" style="flex:1;padding:4px 8px;font-size:11px;">Compact</button>
<button class="btn btn-ghost width-btn" data-width="default" style="flex:1;padding:4px 8px;font-size:11px;" data-active="true">Default</button>
<button class="btn btn-ghost width-btn" data-width="wide" style="flex:1;padding:4px 8px;font-size:11px;">Wide</button>
</div>
<span style="color:var(--text-muted,#888);font-size:10px;line-height:1.4;">
FF native sidebar: View → Sidebar → Hanzo AI.<br>
Disable to fall back to a standalone tab — useful on sites with strict CSP.
</span>
</div>
<div class="divider"></div>
<!-- Tools -->
@@ -313,7 +336,10 @@
</div>
</div>
<!-- Footer -->
<!-- Footer: version + nav links -->
<div class="footer-version" style="text-align:center;font-size:10px;color:var(--text-muted,#888);margin-top:14px;letter-spacing:0.04em;">
Hanzo AI <span id="footer-version"></span>
</div>
<div class="footer-links">
<a href="https://docs.hanzo.ai" target="_blank">Docs</a>
<a href="https://console.hanzo.ai" target="_blank">Console</a>
+112 -17
View File
@@ -1,5 +1,10 @@
// Hanzo AI — Popup Script
// Uses background message passing for auth (not direct storage access).
//
// Polyfill the WebExtension API surface so this script can read/write
// chrome.storage with Promise-returning calls on every browser. Chrome
// gets the polyfill wrapper, Firefox/Safari get a no-op.
import 'webextension-polyfill';
import {
DEFAULT_INSPECT_SHORTCUT,
@@ -23,13 +28,42 @@ document.addEventListener('DOMContentLoaded', () => {
const mainSection = document.getElementById('main-section');
const settingsSection = document.getElementById('settings-section');
// Footer version — pulled from manifest at runtime so it always tracks build.
const versionEl = document.getElementById('footer-version');
if (versionEl) {
try {
versionEl.textContent = `v${chrome.runtime.getManifest().version}`;
} catch { /* manifest unavailable in some contexts */ }
}
// Auth
const loginBtn = document.getElementById('login-btn');
const logoutBtn = document.getElementById('logout-btn');
// Open panel
const openPanel = document.getElementById('open-panel');
const openPageOverlay = document.getElementById('open-page-overlay');
const openPageOverlay = document.getElementById('open-page-overlay'); // legacy id, may not exist
const pinLeftBtn = document.getElementById('pin-left');
const pinRightBtn = document.getElementById('pin-right');
const overlayEnabledCheckbox = document.getElementById('overlay-enabled') as HTMLInputElement | null;
const widthBtns = Array.from(document.querySelectorAll('.width-btn')) as HTMLButtonElement[];
const overlaySideRow = document.getElementById('overlay-side-row');
const overlayWidthRow = document.getElementById('overlay-width-row');
type OverlayWidth = 'compact' | 'default' | 'wide';
function syncSettingsUi(enabled: boolean, side: 'left' | 'right', width: OverlayWidth) {
if (overlayEnabledCheckbox) overlayEnabledCheckbox.checked = enabled;
pinLeftBtn?.setAttribute('data-active', side === 'left' ? 'true' : 'false');
pinRightBtn?.setAttribute('data-active', side === 'right' ? 'true' : 'false');
widthBtns.forEach((b) => b.setAttribute('data-active', b.getAttribute('data-width') === width ? 'true' : 'false'));
// Grey out side/width when on-page overlay is disabled.
[overlaySideRow, overlayWidthRow].forEach((row) => {
if (!row) return;
(row as HTMLElement).style.opacity = enabled ? '1' : '0.5';
(row as HTMLElement).style.pointerEvents = enabled ? '' : 'none';
});
}
// Status dots
const zapStatus = document.getElementById('zap-status');
@@ -100,25 +134,86 @@ document.addEventListener('DOMContentLoaded', () => {
});
});
// --- Open side panel ---
// --- Open chat ---
// Default surface: in-page edge-pinned overlay (content-script). Honours
// the user's `overlayEnabled`, `overlaySide`, and `overlayWidth` prefs.
// When `overlayEnabled` is false (or the active tab is privileged), opens
// the chat surface in a standalone tab. Firefox users can also reach it
// via View → Sidebar → Hanzo AI (registered via sidebar_action).
function openSidebarTab() {
chrome.tabs.create({ url: chrome.runtime.getURL('sidebar.html') });
window.close();
}
openPanel?.addEventListener('click', () => {
if (chrome.sidePanel) {
// Chrome / Edge: use sidePanel API
chrome.storage?.sync?.get?.(['overlayEnabled', 'overlaySide', 'overlayWidth'], (vals) => {
const enabled = vals?.overlayEnabled !== false;
const side = vals?.overlaySide === 'left' ? 'left' : 'right';
const width: OverlayWidth = vals?.overlayWidth === 'compact'
? 'compact' : vals?.overlayWidth === 'wide' ? 'wide' : 'default';
if (!enabled) { openSidebarTab(); return; }
chrome.tabs.query({ active: true, currentWindow: true }, (tabs) => {
if (tabs[0]?.id) {
chrome.sidePanel.open({ tabId: tabs[0].id });
window.close();
}
const activeTab = tabs[0];
const target = isInjectableTab(activeTab) ? activeTab : null;
if (!target?.id) { openSidebarTab(); return; }
chrome.runtime.sendMessage({
action: 'page.overlay.show',
tabId: target.id,
side,
width,
}, () => window.close());
});
} else if (typeof browser !== 'undefined' && browser.sidebarAction) {
// Firefox: use sidebarAction API
browser.sidebarAction.open();
window.close();
} else {
// Fallback: try opening sidebar.html as a new tab
chrome.tabs.create({ url: chrome.runtime.getURL('sidebar.html') });
window.close();
}
});
});
function setActiveSide(side: 'left' | 'right') {
chrome.storage?.sync?.set?.({ overlaySide: side });
pinLeftBtn?.setAttribute('data-active', side === 'left' ? 'true' : 'false');
pinRightBtn?.setAttribute('data-active', side === 'right' ? 'true' : 'false');
chrome.tabs.query({ active: true, currentWindow: true }, (tabs) => {
const id = tabs[0]?.id;
if (id) chrome.runtime.sendMessage({ action: 'page.overlay.setSide', tabId: id, side });
});
}
function setActiveWidth(width: OverlayWidth) {
chrome.storage?.sync?.set?.({ overlayWidth: width });
widthBtns.forEach((b) => b.setAttribute('data-active', b.getAttribute('data-width') === width ? 'true' : 'false'));
chrome.tabs.query({ active: true, currentWindow: true }, (tabs) => {
const id = tabs[0]?.id;
if (id) chrome.runtime.sendMessage({ action: 'page.overlay.setWidth', tabId: id, width });
});
}
function setOverlayEnabled(enabled: boolean) {
chrome.storage?.sync?.set?.({ overlayEnabled: enabled });
chrome.tabs.query({ active: true, currentWindow: true }, (tabs) => {
const id = tabs[0]?.id;
if (id) chrome.runtime.sendMessage({ action: 'page.overlay.setEnabled', tabId: id, enabled });
});
chrome.storage?.sync?.get?.(['overlaySide', 'overlayWidth'], (vals) => {
const side = vals?.overlaySide === 'left' ? 'left' : 'right';
const width: OverlayWidth = vals?.overlayWidth === 'compact'
? 'compact' : vals?.overlayWidth === 'wide' ? 'wide' : 'default';
syncSettingsUi(enabled, side, width);
});
}
pinLeftBtn?.addEventListener('click', () => setActiveSide('left'));
pinRightBtn?.addEventListener('click', () => setActiveSide('right'));
widthBtns.forEach((b) => b.addEventListener('click', () => {
const w = b.getAttribute('data-width') as OverlayWidth | null;
if (w === 'compact' || w === 'default' || w === 'wide') setActiveWidth(w);
}));
overlayEnabledCheckbox?.addEventListener('change', (e) => {
setOverlayEnabled((e.target as HTMLInputElement).checked);
});
// Initial UI sync from storage.
chrome.storage?.sync?.get?.(['overlayEnabled', 'overlaySide', 'overlayWidth'], (vals) => {
const enabled = vals?.overlayEnabled !== false;
const side = vals?.overlaySide === 'left' ? 'left' : 'right';
const width: OverlayWidth = vals?.overlayWidth === 'compact'
? 'compact' : vals?.overlayWidth === 'wide' ? 'wide' : 'default';
syncSettingsUi(enabled, side, width);
});
function isInjectableTab(tab: chrome.tabs.Tab | undefined): boolean {
@@ -1,45 +0,0 @@
import React from 'react';
type Classy = { className?: string; children?: React.ReactNode };
function cx(base: string, extra?: string): string {
return extra ? `${base} ${extra}` : base;
}
export function Button(props: React.ButtonHTMLAttributes<HTMLButtonElement>) {
const { className, children, ...rest } = props;
return (
<button {...rest} className={cx('hanzo-ui-btn', className)}>
{children}
</button>
);
}
export function Textarea(props: React.TextareaHTMLAttributes<HTMLTextAreaElement>) {
const { className, children, ...rest } = props;
return (
<textarea {...rest} className={cx('hanzo-ui-textarea', className)}>
{children}
</textarea>
);
}
export function Card({ className, children }: Classy) {
return <div className={cx('hanzo-ui-card', className)}>{children}</div>;
}
export function CardHeader({ className, children }: Classy) {
return <div className={cx('hanzo-ui-card-header', className)}>{children}</div>;
}
export function CardTitle({ className, children }: Classy) {
return <h3 className={cx('hanzo-ui-card-title', className)}>{children}</h3>;
}
export function CardDescription({ className, children }: Classy) {
return <p className={cx('hanzo-ui-card-description', className)}>{children}</p>;
}
export function CardContent({ className, children }: Classy) {
return <div className={cx('hanzo-ui-card-content', className)}>{children}</div>;
}
+64 -41
View File
@@ -210,16 +210,20 @@ export async function login(adapter: BrowserAdapter): Promise<UserInfo> {
token_type: 'Bearer',
});
} else if (code) {
const tokenResponse = await fetch(`${config.iamApiUrl}/oauth/token`, {
// Hanzo gateway exposes Casdoor's `/api/login/oauth/access_token` at
// `/v1/iam/login/oauth/access_token`. The bare `/oauth/token` from
// the OIDC well-known doc 404s in prod. Token exchange wants
// application/x-www-form-urlencoded per RFC 6749, not JSON.
const tokenResponse = await fetch(`${config.iamApiUrl}${IAM_API_PATH}/login/oauth/access_token`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
grant_type: 'authorization_code',
client_id: config.clientId,
code,
redirect_uri: redirectUri,
code_verifier: codeVerifier,
}),
}).toString(),
});
if (!tokenResponse.ok) throw new Error(`Token exchange failed: ${await tokenResponse.text()}`);
await storeTokens(adapter.storage, await tokenResponse.json());
@@ -267,14 +271,14 @@ export async function getValidAccessToken(storage: BrowserStorage): Promise<stri
async function refreshAccessToken(storage: BrowserStorage, refreshToken: string): Promise<string> {
const config = await getRuntimeConfig(storage);
const response = await fetch(`${config.iamApiUrl}/oauth/token`, {
const response = await fetch(`${config.iamApiUrl}${IAM_API_PATH}/login/oauth/access_token`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
grant_type: 'refresh_token',
client_id: config.clientId,
refresh_token: refreshToken,
}),
}).toString(),
});
if (!response.ok) throw new Error('Token refresh failed');
const tokens: TokenData = await response.json();
@@ -335,47 +339,66 @@ export async function getAuthStatus(storage: BrowserStorage): Promise<{
return { authenticated: true, user };
}
// ── Chrome/Firefox Adapter Factories ────────────────────────────────────
/** Create a BrowserAdapter from Chrome extension APIs */
export function chromeAdapter(): BrowserAdapter {
return {
storage: {
get: (keys) => new Promise((resolve) => chrome.storage.local.get(keys, resolve)),
set: (items) => chrome.storage.local.set(items),
remove: (keys) => chrome.storage.local.remove(keys),
},
tabs: {
create: (opts) => new Promise((resolve, reject) => {
chrome.tabs.create(opts, (tab) => {
if (chrome.runtime.lastError || !tab?.id) {
reject(new Error(chrome.runtime.lastError?.message || 'Failed to open tab'));
} else {
resolve(tab);
}
});
}),
remove: (tabId) => chrome.tabs.remove(tabId),
onUpdated: chrome.tabs.onUpdated,
onRemoved: chrome.tabs.onRemoved,
},
};
// ── WebExtension Adapter Factory ────────────────────────────────────────
//
// Unified across Chrome (MV3), Firefox (MV3), Safari (WebExtension), and
// Edge (Chromium MV3). Uses webextension-polyfill at the entrypoint level
// so `globalThis.browser` is the canonical Promise-returning API on every
// runtime. On Firefox/Safari `browser.*` is native; on Chrome the polyfill
// wraps `chrome.*` callbacks. The result is ONE codepath that works
// identically everywhere — no more separate `chromeAdapter` /
// `firefoxAdapter` factories with subtly different behavior.
//
// The adapter intentionally still targets a small surface (storage +
// tabs) because that's all auth.ts needs. If we ever migrate the wider
// codebase to `browser.*` we'd extend this — but for now keeping the
// surface minimal avoids unnecessary churn in callback-style listeners
// elsewhere (e.g. the runtime.onMessage handlers, which are still chrome
// callback style by design).
function getBrowser(): typeof browser {
// Prefer the polyfilled `browser` if present (Chrome with the polyfill
// imported at entrypoint, or any native browser.*-supporting runtime).
// Fall back to wrapping `chrome.*` directly if neither is present
// (which only happens in tests / non-extension contexts).
return (globalThis as any).browser ?? (globalThis as any).chrome;
}
/** Create a BrowserAdapter from Firefox WebExtension APIs */
export function firefoxAdapter(): BrowserAdapter {
const b = (globalThis as any).browser;
/**
* Create a BrowserAdapter that works on Chrome/Firefox/Edge/Safari.
* Requires `globalThis.browser` to be defined (which the polyfill
* import at the entrypoint guarantees on Chrome).
*/
export function webExtensionAdapter(): BrowserAdapter {
const b: any = getBrowser();
return {
storage: {
get: (keys) => b.storage.local.get(keys),
set: (items) => b.storage.local.set(items),
remove: (keys) => b.storage.local.remove(keys),
// Promise-returning on every runtime once the polyfill is loaded.
get: (keys) => Promise.resolve(b.storage.local.get(keys)),
set: (items) => Promise.resolve(b.storage.local.set(items)),
remove: (keys) => Promise.resolve(b.storage.local.remove(keys)),
},
tabs: {
create: (opts) => b.tabs.create(opts),
remove: (tabId) => b.tabs.remove(tabId),
create: (opts) => Promise.resolve(b.tabs.create(opts)),
remove: (tabId) => Promise.resolve(b.tabs.remove(tabId)),
onUpdated: b.tabs.onUpdated,
onRemoved: b.tabs.onRemoved,
},
};
}
/**
* Legacy Chrome adapter kept for backwards compatibility with callers
* that imported it before 1.9.0. Now just an alias for
* `webExtensionAdapter`. Prefer `webExtensionAdapter` in new code.
*/
export function chromeAdapter(): BrowserAdapter {
return webExtensionAdapter();
}
/**
* Legacy Firefox adapter same deal as `chromeAdapter`. Both are
* aliases now that the polyfill gives us one identical surface.
*/
export function firefoxAdapter(): BrowserAdapter {
return webExtensionAdapter();
}
+202 -15
View File
@@ -15,10 +15,24 @@ export const MSG_RESPONSE = 0x11;
export const MSG_PING = 0xFE;
export const MSG_PONG = 0xFF;
export const DEFAULT_ZAP_PORTS = [9999, 9998, 9997, 9996, 9995];
/**
* Canonical Hanzo service-type per HIP-0069. Discovery is mDNS-only
* the legacy [9999..9995] port-probe was removed in 1.9.13 to surface
* non-compliant deployments instead of papering over them.
*/
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 ─────────────────────────────────────────────────────
export function encodeZapMessage(type: number, payload: object): ArrayBuffer {
@@ -50,11 +64,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 +97,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,
@@ -149,6 +181,11 @@ export async function connectZap(
tools: (info.tools || []).map((t: any) => t.name),
});
log(`[Hanzo/ZAP] Connected to ${info.name || url} (${(info.tools || []).length} tools)`);
// No application-level heartbeat: ZAP is a long-lived WS and the
// OS already delivers FIN/RST on real disconnects, which fires
// ws.onclose → our reconnect chain (added 1.9.7). The 1.9.9
// PING/PONG was both unnecessary and buggy (it stacked
// ws.onmessage wrappers on every tick).
resolve(mcpId);
break;
}
@@ -162,12 +199,43 @@ 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;
}
};
let scheduledRetry = false;
const scheduleRetry = (reason: string) => {
if (scheduledRetry) return;
scheduledRetry = true;
log(`[Hanzo/ZAP] ${reason} — reconnecting to ${url} in ${ZAP_RECONNECT_DELAY}ms...`);
setTimeout(() => connectZap(url, mgr, browserName, version, log), ZAP_RECONNECT_DELAY);
};
ws.onclose = () => {
for (const [id, conn] of mgr.connections) {
if (conn === ws) {
@@ -177,12 +245,17 @@ export async function connectZap(
}
}
mgr.state.connected = mgr.connections.size > 0;
log(`[Hanzo/ZAP] Disconnected from ${url}, reconnecting in ${ZAP_RECONNECT_DELAY}ms...`);
setTimeout(() => connectZap(url, mgr, browserName, version, log), ZAP_RECONNECT_DELAY);
scheduleRetry('Disconnected');
};
ws.onerror = () => {
clearTimeout(connTimer);
// onerror fires when the bind window misses, the server is restarting,
// or the connection is reset mid-handshake. Without scheduling a retry
// here the entire reconnect chain dies after a single failed attempt
// (1.9.6 and earlier) — fixed in 1.9.7 by chaining a retry the same
// way onclose does.
scheduleRetry('Connect error');
resolve(null);
};
});
@@ -256,26 +329,140 @@ export function zapGetPrompt(mgr: ZapManager, mcpId: string, name: string, args?
return zapRequest(mgr, mcpId, 'prompts/get', { name, arguments: args });
}
/** Discover and connect to ZAP servers */
/**
* Ask the native messaging helper (ai.hanzo.zap_mdns) for a live mDNS browse
* of `_hanzo._tcp.local.` services per HIP-0069. Returns ws:// URLs the
* extension can connect to. Returns [] when the helper isn't installed or
* the browse times out empty.
*/
async function discoverViaMdns(timeoutMs: number = 1500): Promise<string[]> {
const api: any = (typeof browser !== 'undefined' ? browser : (chrome as any));
const send = api?.runtime?.sendNativeMessage;
if (typeof send !== 'function') {
throw new Error(
'[Hanzo/ZAP] runtime.sendNativeMessage unavailable — ai.hanzo.zap_mdns helper required for discovery',
);
}
const resp: any = await new Promise((resolve, reject) => {
// Two dialects:
// - Firefox / promise-returning Chrome (MV3): sendNativeMessage(name, msg) → Promise
// - Chrome MV2 callback API: sendNativeMessage(name, msg, callback)
// Detect by whether the first call returns a thenable; only fall back to
// the callback shape when it doesn't, so we never send the same browse
// request twice.
try {
const result = send.call(api.runtime, 'ai.hanzo.zap_mdns', { op: 'browse', timeout_ms: timeoutMs }, (r: any) => {
// Callback dialect: result of the call() above is undefined and the
// helper response arrives here. Promise dialect ignores callbacks.
if (r !== undefined) resolve(r);
});
if (result && typeof result.then === 'function') {
result.then(resolve).catch(reject);
}
// If result was undefined and the callback never fires, the discovery
// loop's interval handles the retry — no need for a per-call timer.
} catch (e) {
reject(e);
}
});
if (!resp || !resp.ok || !Array.isArray(resp.services)) return [];
return resp.services
.map((s: any) => s.url)
.filter((u: any): u is string => typeof u === 'string');
}
/**
* Discover and connect to ZAP servers. Tries mDNS (HIP-0069,
* `_hanzo._tcp.local.`) first via the ``ai.hanzo.zap_mdns`` native-messaging
* helper; when the helper is absent or finds nothing, falls back to
* probing the localhost ``ZAP_PORTS`` range so a developer running
* hanzo-mcp on the same machine is always reachable without extra
* installation. Use ``startZapDiscoveryLoop`` for the always-on watchdog.
*/
export async function discoverZapServers(
mgr: ZapManager,
browserName: string,
version: string,
ports: number[] = DEFAULT_ZAP_PORTS,
_ports: number[] | undefined = undefined, // unused — retained for ABI continuity
log: (msg: string) => void = console.log,
): Promise<void> {
log('[Hanzo/ZAP] Discovering MCP servers...');
const results = await Promise.all(ports.map(p => probeZapServer(p, mgr, browserName, version)));
const available = results.filter(Boolean) as string[];
if (available.length === 0) {
log('[Hanzo/ZAP] No servers found, retrying in 10s...');
setTimeout(() => discoverZapServers(mgr, browserName, version, ports, log), 10000);
return;
log('[Hanzo/ZAP] Discovering MCP via mDNS (_hanzo._tcp.local.)...');
let mdnsUrls: string[] = [];
let mdnsError = false;
try {
mdnsUrls = await discoverViaMdns(1500);
} catch (e: any) {
mdnsError = true;
log(`[Hanzo/ZAP] mDNS browse failed: ${e?.message || e} — falling back to localhost probe.`);
}
log(`[Hanzo/ZAP] Found ${available.length} server(s): ${available.join(', ')}`);
await Promise.all(available.map(url => connectZap(url, mgr, browserName, version, log)));
// Skip URLs we're already connected to so a watchdog re-tick is a no-op
// when everything is already wired.
const known = new Set<string>();
for (const m of mgr.state.mcps) known.add(m.url);
let 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.
if (candidates.length === 0) {
if (!mdnsError && mdnsUrls.length === 0) {
log('[Hanzo/ZAP] No services advertising _hanzo._tcp.local. — probing localhost ports.');
}
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(', ')}`);
}
await Promise.all(candidates.map(url => connectZap(url, mgr, browserName, version, log)));
}
/**
* Always-on discovery watchdog. Ticks every ``intervalMs`` and re-runs
* discovery whenever the manager has zero live connections (or a fresh server
* has appeared on mDNS that we're not yet connected to). This is the canonical
* entry point replaces the fragile setTimeout-on-failure chain that died
* the moment any code-path missed a re-arm. Returns the interval handle so
* tests can stop it; in production the page lives for the extension lifetime.
*/
export function startZapDiscoveryLoop(
mgr: ZapManager,
browserName: string,
version: string,
intervalMs: number = 5000,
log: (msg: string) => void = console.log,
): { stop: () => void } {
let inFlight = false;
const tick = async () => {
if (inFlight) return;
inFlight = true;
try {
await discoverZapServers(mgr, browserName, version, undefined, log);
} catch (e: any) {
log(`[Hanzo/ZAP] discovery tick failed: ${e?.message || e}`);
} finally {
inFlight = false;
}
};
// Run an immediate first probe; then settle into the watchdog cadence.
void tick();
const handle = setInterval(tick, intervalMs);
return { stop: () => clearInterval(handle) };
}
// ── Message Handler Helpers ─────────────────────────────────────────────
+24 -16
View File
@@ -1,4 +1,6 @@
/* Hanzo AI Browser Extension Sidebar — aligned with @hanzo/ui dark theme */
/* Hanzo AI Browser Extension Sidebar Hanzo brand: strictly monochrome.
See ~/work/hanzo/brand/brand.json primaryColor #FFFFFF, no chromatic
accents. Status states are conveyed by intensity, not hue. */
:root {
--bg-primary: #0a0a0a;
@@ -15,9 +17,11 @@
--accent-dim: rgba(255,255,255,0.06);
--accent-glow: rgba(255,255,255,0.03);
--success: #22c55e;
--warning: #eab308;
--error: #ef4444;
/* Status: monochrome by hue, distinguished by opacity / weight.
Brand is strict monochrome, so success/warn/error use luminance. */
--success: #fafafa;
--warning: #d4d4d4;
--error: rgba(255,255,255,0.55);
--border: rgba(255,255,255,0.10);
--border-strong: rgba(255,255,255,0.16);
@@ -259,7 +263,7 @@ html {
line-height: 1.5;
}
/* Sign-in button — matches @hanzo/ui primary button on dark */
/* Sign-in button — matches @hanzo/gui primary button on dark (monochrome) */
.auth-signin-btn {
width: 100%;
padding: 10px 16px;
@@ -1046,16 +1050,18 @@ html {
word-break: break-all;
}
/* Element-syntax tinting — kept monochrome via opacity, not hue. */
.inspector-result .element-tag {
color: #7cc4f8;
color: #fafafa;
font-weight: 600;
}
.inspector-result .element-id {
color: #c792ea;
color: rgba(255,255,255,0.78);
}
.inspector-result .element-class {
color: #c3e88d;
color: rgba(255,255,255,0.62);
}
.inspector-result .element-size {
@@ -1681,11 +1687,13 @@ select option {
min-width: 0;
}
.bar-blue { background: #3B82F6; }
.bar-violet { background: #8B5CF6; }
.bar-emerald { background: #10B981; }
.bar-amber { background: #F59E0B; }
.bar-red { background: #EF4444; }
/* Usage bars: monochrome scale (brightest dimmest) per @hanzo/brand.
Class names kept for compatibility with existing markup. */
.bar-blue { background: rgba(255,255,255,0.95); }
.bar-violet { background: rgba(255,255,255,0.75); }
.bar-emerald { background: rgba(255,255,255,0.55); }
.bar-amber { background: rgba(255,255,255,0.40); }
.bar-red { background: rgba(255,255,255,0.25); }
.usage-bar-group {
margin-top: 8px;
@@ -1761,9 +1769,9 @@ select option {
letter-spacing: 0.05em;
white-space: nowrap;
}
.badge-cloud { background: rgba(59,130,246,0.15); color: #60A5FA; }
.badge-local { background: rgba(16,185,129,0.15); color: #34D399; }
.badge-hub { background: rgba(139,92,246,0.15); color: #A78BFA; }
.badge-cloud { background: rgba(255,255,255,0.10); color: rgba(255,255,255,0.92); }
.badge-local { background: rgba(255,255,255,0.06); color: rgba(255,255,255,0.78); }
.badge-hub { background: rgba(255,255,255,0.04); color: rgba(255,255,255,0.65); }
.model-hub-actions {
display: flex;
+4
View File
@@ -1,3 +1,7 @@
// Polyfill the WebExtension API on Chrome so storage calls return Promises
// like Firefox / Safari natively do. No-op on those runtimes.
import 'webextension-polyfill';
import {
CHAT_BUDGETS,
debugLog,
+276 -6
View File
@@ -1,6 +1,29 @@
// WebGPU AI Runner for Browser Extension
// Enables local AI inference directly in the browser via WebGPU compute shaders.
// Supports model loading, BPE tokenization, autoregressive generation, and embeddings.
//
// File format support:
// - Raw blob (legacy hanzo-browser-control): the bytes are uploaded as one
// GPUBuffer and consumed by the inline matmul/softmax shaders below.
// - GGUF (llama.cpp's quantized container, v1-v3): parsed into a
// name-keyed map of (offset, length, dtype, shape) descriptors, with
// each tensor lazily uploaded to its own GPUBuffer through ExpertCache.
// This unlocks Zen-Nano/Zen-Eco/Zen-Coder GGUF artifacts published on
// HuggingFace without an intermediate conversion step.
//
// Sparse-load shell (ExpertCache below) materialises tensors on demand and
// evicts cold ones under an LRU budget. For dense models the cache simply
// holds every layer; for MoE (Zen Coder 480B, etc.) it only ever holds the
// experts the router actually picks for live tokens, which is the whole
// point of being able to run "too big" models in a browser.
//
// Still TODO before a GGUF model is end-to-end runnable here:
// - Transformer kernel suite: RMSNorm, RoPE, multi-head attention with
// KV cache, SwiGLU FFN, MoE router + dispatch (multiple thousand lines
// of WGSL). The parser + cache below ARE the prerequisite layer.
// - Speculative decoding: small dense draft (Zen Nano 0.6B) proposing N
// tokens, large MoE verify (Zen Coder 31B/3B-active) checking the batch
// in one forward pass. Layered on top of the kernels.
interface ModelConfig {
name: string;
@@ -15,6 +38,162 @@ interface LoadedModel {
config: ModelConfig;
vocab: string[];
vocabIndex: Map<string, number>;
// Populated when the artifact is GGUF (otherwise undefined and the legacy
// single-blob matmul/softmax pipeline runs).
gguf?: GGUFContext;
}
// ---------------------------------------------------------------------------
// GGUF format
// ---------------------------------------------------------------------------
//
// Reference: https://github.com/ggerganov/ggml/blob/master/docs/gguf.md
// Layout (LE throughout):
// [magic:4 "GGUF"] [version:u32] [tensor_count:u64] [kv_count:u64]
// <kv-metadata × kv_count>
// <tensor-info × tensor_count>
// <padding to alignment (default 32B)>
// <tensor data blob>
const GGUF_MAGIC = 0x46554747; // "GGUF" little-endian as u32
enum GGUFType {
UINT8 = 0,
INT8 = 1,
UINT16 = 2,
INT16 = 3,
UINT32 = 4,
INT32 = 5,
FLOAT32 = 6,
BOOL = 7,
STRING = 8,
ARRAY = 9,
UINT64 = 10,
INT64 = 11,
FLOAT64 = 12,
}
// GGML quantization types — only the ones modern HF GGUFs use are enumerated.
enum GGMLDType {
F32 = 0,
F16 = 1,
Q4_0 = 2,
Q4_1 = 3,
Q5_0 = 6,
Q5_1 = 7,
Q8_0 = 8,
Q8_1 = 9,
Q2_K = 10,
Q3_K = 11,
Q4_K = 12,
Q5_K = 13,
Q6_K = 14,
Q8_K = 15,
I8 = 16,
I16 = 17,
I32 = 18,
}
interface GGUFTensorInfo {
name: string;
shape: number[];
dtype: GGMLDType;
/** Byte offset within the tensor-data blob (NOT within the file). */
offset: number;
/** Bytes occupied — computed once at parse, used for slicing + cache budget. */
length: number;
}
interface GGUFContext {
/** Raw KV metadata. ``general.architecture``, hyperparameters, vocab live here. */
kv: Record<string, any>;
/** Name → tensor descriptor. ``offset`` is relative to ``dataStart``. */
tensors: Map<string, GGUFTensorInfo>;
/** Byte offset in the source ArrayBuffer where the tensor data blob begins. */
dataStart: number;
/** Total tensor-data length. */
dataLength: number;
/** Backing buffer (kept for lazy slicing into GPUBuffers via ExpertCache). */
source: ArrayBuffer;
}
/**
* LRU sparse-load cache. Keys are typically ``"<layer>.<expert>.<tensor>"``
* (or simpler ``"<tensor-name>"`` for dense models). On a miss the loader
* fn is invoked to upload the bytes into a fresh GPUBuffer; on overflow the
* coldest entry is destroyed.
*
* Why this shape: WebGPU has a ~2GB per-buffer cap and shared VRAM budgets
* vary by platform, so the cache budget is in bytes (not entries). For MoE
* the eviction policy is what lets you run Zen Coder 480B Q4 (~240GB
* weights) on a 100GB Mac the live working set per token is tiny.
*/
class ExpertCache {
private map = new Map<string, { buffer: GPUBuffer; bytes: number }>();
private order: string[] = []; // LRU: front = most recent
private currentBytes = 0;
constructor(
private device: GPUDevice,
/** Max bytes resident on the device. Default 4 GiB; bump for larger VRAM. */
private budgetBytes: number = 4 * 1024 * 1024 * 1024,
) {}
has(key: string): boolean { return this.map.has(key); }
/** Get the GPUBuffer for ``key``, loading it on miss. */
async get(key: string, loader: () => Promise<{ data: ArrayBuffer; label?: string }>): Promise<GPUBuffer> {
const hit = this.map.get(key);
if (hit) {
this._touch(key);
return hit.buffer;
}
const { data, label } = await loader();
while (this.currentBytes + data.byteLength > this.budgetBytes && this.order.length > 0) {
this._evictColdest();
}
const buffer = this.device.createBuffer({
size: data.byteLength,
usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST,
label: label || `expert-${key}`,
});
this.device.queue.writeBuffer(buffer, 0, data);
this.map.set(key, { buffer, bytes: data.byteLength });
this.order.unshift(key);
this.currentBytes += data.byteLength;
return buffer;
}
/** Force-evict everything. */
clear(): void {
for (const { buffer } of this.map.values()) buffer.destroy();
this.map.clear();
this.order = [];
this.currentBytes = 0;
}
stats(): { entries: number; bytes: number; budget: number } {
return { entries: this.map.size, bytes: this.currentBytes, budget: this.budgetBytes };
}
private _touch(key: string): void {
const i = this.order.indexOf(key);
if (i > 0) {
this.order.splice(i, 1);
this.order.unshift(key);
}
}
private _evictColdest(): void {
const coldKey = this.order.pop();
if (!coldKey) return;
const entry = this.map.get(coldKey);
if (!entry) return;
entry.buffer.destroy();
this.map.delete(coldKey);
this.currentBytes -= entry.bytes;
console.log(`[Hanzo AI] Cache evicted ${coldKey} (${entry.bytes} bytes)`);
}
}
export class WebGPUAI {
@@ -174,12 +353,11 @@ export class WebGPUAI {
console.log(`[Hanzo AI] Loading model: ${config.name}`);
// Load model weights
const response = await fetch(config.url);
if (!response.ok) {
throw new Error(`Failed to fetch model ${config.name}: ${response.status}`);
}
const modelData = await response.arrayBuffer();
// Load model weights — cache-first, streaming with progress logs.
// Service-worker Cache API persists across worker restarts so the bytes
// are downloaded at most once per URL. Range-streaming the body avoids
// pinning the entire blob in JS memory before GPU upload.
const modelData = await this._fetchWithCache(config.url, config.name);
// Create GPU buffer for model weights
const modelBuffer = this.device.createBuffer({
@@ -579,6 +757,98 @@ export class WebGPUAI {
return embedding;
}
// ---------------------------------------------------------------------------
// Cached, progress-reporting fetch — used by loadModel().
// ---------------------------------------------------------------------------
/**
* Fetch a model blob with persistent caching. Backed by the service-worker
* Cache API so the bytes survive worker restarts and only download once
* per URL. Streams the body to log progress every ~5% so big artifacts
* (huggingface.co/<repo>/resolve/main/<file>) don't appear to hang.
*
* Cache invalidation: the URL is the key; bump the URL (e.g. point to a
* different revision sha) to force a re-fetch. Use ``WebGPUAI.evictCache``
* to wipe everything when models go bad.
*/
private async _fetchWithCache(url: string, label: string): Promise<ArrayBuffer> {
const cacheName = 'hanzo-webgpu-models-v1';
const cache = typeof caches !== 'undefined' ? await caches.open(cacheName) : null;
if (cache) {
const hit = await cache.match(url);
if (hit) {
console.log(`[Hanzo AI] Cache hit: ${label} (${url})`);
return await hit.arrayBuffer();
}
}
console.log(`[Hanzo AI] Fetching: ${label} (${url})`);
const response = await fetch(url);
if (!response.ok) {
throw new Error(`Failed to fetch ${label}: ${response.status} ${response.statusText}`);
}
const total = Number(response.headers.get('content-length') || 0);
let received = 0;
let nextLog = total > 0 ? Math.floor(total * 0.05) : Infinity;
const chunks: Uint8Array[] = [];
if (response.body) {
const reader = response.body.getReader();
while (true) {
const { done, value } = await reader.read();
if (done) break;
chunks.push(value);
received += value.byteLength;
if (total > 0 && received >= nextLog) {
const pct = Math.floor((received / total) * 100);
console.log(`[Hanzo AI] ${label}: ${pct}% (${received}/${total} bytes)`);
nextLog = received + Math.floor(total * 0.05);
}
}
} else {
chunks.push(new Uint8Array(await response.arrayBuffer()));
}
// Stitch chunks into a single ArrayBuffer.
const blob = new Uint8Array(received || chunks.reduce((n, c) => n + c.byteLength, 0));
let offset = 0;
for (const c of chunks) {
blob.set(c, offset);
offset += c.byteLength;
}
console.log(`[Hanzo AI] Fetched: ${label} (${blob.byteLength} bytes)`);
// Write back into the cache. Response body is single-use; re-construct
// from the assembled blob so cache.put gets a fresh, readable stream.
if (cache) {
try {
await cache.put(url, new Response(blob, {
headers: { 'content-type': 'application/octet-stream' },
}));
console.log(`[Hanzo AI] Cached: ${label}`);
} catch (e) {
// Quota errors are non-fatal — we still have the bytes in memory.
console.warn(`[Hanzo AI] Cache write failed for ${label}:`, e);
}
}
return blob.buffer;
}
/** Build an HF resolve URL for a given repo / file / revision. */
static huggingFaceURL(repo: string, file: string, revision: string = 'main'): string {
return `https://huggingface.co/${repo}/resolve/${revision}/${file}`;
}
/** Wipe every cached model blob. Useful when a model artifact is rotated. */
static async evictCache(): Promise<void> {
if (typeof caches === 'undefined') return;
await caches.delete('hanzo-webgpu-models-v1');
console.log('[Hanzo AI] WebGPU model cache evicted');
}
// ---------------------------------------------------------------------------
// Status / Cleanup
// ---------------------------------------------------------------------------
@@ -0,0 +1,289 @@
/**
* Multi-browser routing through the CDP bridge server.
*
* The bridge is the SINGLE entrypoint for hanzo-mcp / Playwright clients.
* When two extensions register against the same bridge (e.g. Chrome AND
* Firefox both running locally), the user must be able to route a command
* to a specific browser via `{ ...command, browser: 'firefox' }`. This
* suite asserts that resolution works deterministically.
*
* The bridge exposes `assignClientId`, `resolveClient`, and the public
* `browser({ action, browser })` action namespace. We exercise those
* directly with mocked WebSocket clients so we never hit a real port.
*/
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { CDPBridgeServer } from '../src/cdp-bridge-server';
// jsdom's `WebSocket` doesn't behave like the `ws` server expects, so we
// use the real ws lib but on a unique port per test to avoid contention.
import { WebSocket } from 'ws';
let server: CDPBridgeServer;
const PORTS_BASE = 39220; // unique band so CI doesn't collide with prod 9223
let portCursor = PORTS_BASE;
function nextPort(): number {
return portCursor++;
}
beforeEach(() => {
// Server created per-test with fresh port.
});
afterEach(() => {
if (server) {
server.close();
server = undefined as any;
}
});
/**
* Spin up a real CDPBridgeServer and connect a fake "extension" client
* over WebSocket. The fake client immediately registers and then echoes
* any command back as the result. Returns the connected client so the
* test can drive responses.
*/
async function setupBridgeWithClient(
browser: string,
): Promise<{ port: number; client: WebSocket; opened: Promise<void> }> {
const port = nextPort();
server = new CDPBridgeServer(port);
// Give the WS server one tick to start listening.
await new Promise((r) => setTimeout(r, 50));
const client = new WebSocket(`ws://localhost:${port}/cdp`);
const opened = new Promise<void>((resolve) => client.on('open', () => resolve()));
await opened;
client.send(JSON.stringify({
type: 'register',
role: 'cdp-provider',
browser,
capabilities: ['navigate'],
}));
// One tick for register to land.
await new Promise((r) => setTimeout(r, 30));
// Echo back any command as a successful result so the test doesn't time out.
client.on('message', (raw) => {
const msg = JSON.parse(raw.toString());
if (msg.id !== undefined && msg.method) {
client.send(JSON.stringify({
id: msg.id,
result: { __echoed: { method: msg.method, params: msg.params } },
}));
}
});
return { port, client, opened };
}
describe('CDPBridgeServer.assignClientId', () => {
it('first client of a browser type gets its bare name', async () => {
const { client } = await setupBridgeWithClient('chrome');
// Internal: the server stores by browser name. Verify via list_browsers.
const result = await server.browser({ action: 'list_browsers' });
expect(result.browsers).toHaveLength(1);
expect(result.browsers[0].id).toBe('chrome');
expect(result.browsers[0].browser).toBe('chrome');
client.close();
});
it('second client of same browser type gets a -2 suffix', async () => {
const { client: c1 } = await setupBridgeWithClient('chrome');
// Open a second connection registered as `chrome`.
const port = (server as any).httpPort;
const c2 = new WebSocket(`ws://localhost:${port}/cdp`);
await new Promise<void>((resolve) => c2.on('open', () => resolve()));
c2.send(JSON.stringify({
type: 'register',
role: 'cdp-provider',
browser: 'chrome',
capabilities: ['navigate'],
}));
await new Promise((r) => setTimeout(r, 30));
const result = await server.browser({ action: 'list_browsers' });
expect(result.browsers).toHaveLength(2);
const ids = result.browsers.map((b: any) => b.id).sort();
expect(ids).toEqual(['chrome', 'chrome-2']);
c1.close();
c2.close();
});
it('different browser types each get their bare names', async () => {
const { client: chromeClient } = await setupBridgeWithClient('chrome');
const port = (server as any).httpPort;
const ffClient = new WebSocket(`ws://localhost:${port}/cdp`);
await new Promise<void>((resolve) => ffClient.on('open', () => resolve()));
ffClient.send(JSON.stringify({
type: 'register',
role: 'cdp-provider',
browser: 'firefox',
capabilities: ['navigate'],
}));
await new Promise((r) => setTimeout(r, 30));
ffClient.on('message', (raw) => {
const msg = JSON.parse(raw.toString());
if (msg.id !== undefined && msg.method) {
ffClient.send(JSON.stringify({
id: msg.id,
result: { __echoed: { method: msg.method, browser: 'firefox' } },
}));
}
});
const result = await server.browser({ action: 'list_browsers' });
const ids = result.browsers.map((b: any) => b.id).sort();
expect(ids).toEqual(['chrome', 'firefox']);
chromeClient.close();
ffClient.close();
});
});
describe('CDPBridgeServer.browser routing', () => {
it('routes by browser=firefox to the firefox client', async () => {
const { client: chromeClient } = await setupBridgeWithClient('chrome');
const port = (server as any).httpPort;
const ffClient = new WebSocket(`ws://localhost:${port}/cdp`);
await new Promise<void>((resolve) => ffClient.on('open', () => resolve()));
ffClient.send(JSON.stringify({
type: 'register',
role: 'cdp-provider',
browser: 'firefox',
capabilities: ['navigate'],
}));
await new Promise((r) => setTimeout(r, 30));
ffClient.on('message', (raw) => {
const msg = JSON.parse(raw.toString());
if (msg.id !== undefined && msg.method) {
ffClient.send(JSON.stringify({
id: msg.id,
result: { __echoed: { method: msg.method, browser: 'firefox' } },
}));
}
});
const result = await server.browser({
action: 'navigate',
browser: 'firefox',
url: 'https://example.com',
});
// Result should come from firefox echoer, not chrome's.
expect(result.__echoed.browser).toBe('firefox');
expect(result.__echoed.method).toBe('Page.navigate');
chromeClient.close();
ffClient.close();
});
it('routes by chrome-2 unique ID to the second chrome client', async () => {
const { client: c1 } = await setupBridgeWithClient('chrome');
const port = (server as any).httpPort;
// c1 already echoes generically; install a label-specific echoer on c2.
const c2 = new WebSocket(`ws://localhost:${port}/cdp`);
await new Promise<void>((resolve) => c2.on('open', () => resolve()));
c2.send(JSON.stringify({
type: 'register',
role: 'cdp-provider',
browser: 'chrome',
capabilities: ['navigate'],
}));
await new Promise((r) => setTimeout(r, 30));
c2.on('message', (raw) => {
const msg = JSON.parse(raw.toString());
if (msg.id !== undefined && msg.method) {
c2.send(JSON.stringify({
id: msg.id,
result: { __echoed: { method: msg.method, label: 'chrome-2' } },
}));
}
});
const result = await server.browser({
action: 'navigate',
browser: 'chrome-2',
url: 'https://example.com',
});
expect(result.__echoed.label).toBe('chrome-2');
c1.close();
c2.close();
});
it('throws on unknown browser target', async () => {
const { client } = await setupBridgeWithClient('chrome');
await expect(
server.browser({ action: 'navigate', browser: 'safari', url: 'x' }),
).rejects.toThrow(/Browser "safari" not connected/);
client.close();
});
it('list_browsers returns count and details', async () => {
const { client } = await setupBridgeWithClient('chrome');
const result = await server.browser({ action: 'list_browsers' });
expect(result.count).toBe(1);
expect(result.browsers).toEqual([
{ id: 'chrome', browser: 'chrome', capabilities: ['navigate'] },
]);
client.close();
});
it('default routing (no browser specified) picks the first registered client', async () => {
const { client } = await setupBridgeWithClient('firefox');
const result = await server.browser({
action: 'navigate',
url: 'https://example.com',
});
expect(result.__echoed.method).toBe('Page.navigate');
client.close();
});
});
describe('CDPBridgeServer.evaluate result unwrapping', () => {
it('passes awaitPromise: true to Runtime.evaluate', async () => {
const { client } = await setupBridgeWithClient('chrome');
let receivedParams: any = null;
// Replace generic echoer with a custom one that captures params.
client.removeAllListeners('message');
client.on('message', (raw) => {
const msg = JSON.parse(raw.toString());
if (msg.id !== undefined && msg.method === 'Runtime.evaluate') {
receivedParams = msg.params;
client.send(JSON.stringify({
id: msg.id,
result: { result: { value: 42, type: 'number' } },
}));
}
});
const result = await server.browser({
action: 'evaluate',
code: '1 + 1',
});
expect(receivedParams.awaitPromise).toBe(true);
expect(receivedParams.returnByValue).toBe(true);
// unwrapEvaluateResult unwraps {result: {value: 42}} to 42.
expect(result.result).toBe(42);
client.close();
});
it('unwraps an empty {} (Promise drop) to undefined', async () => {
const { client } = await setupBridgeWithClient('chrome');
client.removeAllListeners('message');
client.on('message', (raw) => {
const msg = JSON.parse(raw.toString());
if (msg.id !== undefined && msg.method === 'Runtime.evaluate') {
client.send(JSON.stringify({ id: msg.id, result: {} }));
}
});
const result = await server.browser({ action: 'evaluate', code: 'foo()' });
expect(result.result).toBeUndefined();
client.close();
});
});
@@ -0,0 +1,305 @@
/**
* Cross-browser parity contracts.
*
* These tests assert that the things which were broken in 1.8.x stay
* fixed in 1.9.0 and beyond. They probe BOTH the Chrome (background.ts +
* cdp-bridge.ts) and Firefox (background-firefox.ts) source files for
* the structural patterns that distinguish a working build from a
* regressed one.
*
* Why string-search instead of behavior tests? The actual page execution
* path requires a real browser. These contracts run as `vitest run` in
* CI without a browser, so they're a fast lint-class signal that no
* future PR silently re-introduces the bug.
*/
import { describe, it, expect } from 'vitest';
import * as fs from 'fs';
import * as path from 'path';
function read(p: string): string {
return fs.readFileSync(path.join(__dirname, '..', p), 'utf8');
}
const chromeBg = read('src/background.ts');
const firefoxBg = read('src/background-firefox.ts');
const cdpBridge = read('src/cdp-bridge.ts');
const cdpBridgeServer = read('src/cdp-bridge-server.ts');
const browserControl = read('src/browser-control.ts');
const popupTs = read('src/popup.ts');
const contentScript = read('src/content-script.ts');
const sharedAuth = read('src/shared/auth.ts');
const sharedConfig = read('src/shared/config.ts');
const manifestChrome = read('src/manifest.json');
const manifestFirefox = read('src/manifest-firefox.json');
describe('IAM URL conventions', () => {
it('IAM_API_PATH is /v1/iam (NOT /api/, NOT bare /oauth)', () => {
expect(sharedConfig).toContain("IAM_API_PATH = '/v1/iam'");
});
it('shared auth uses /login/oauth/access_token (not bare /oauth/token)', () => {
expect(sharedAuth).toContain('/login/oauth/access_token');
// The bare /oauth/token path 404'd before 1.8.7. We only care about
// the URL appearing in non-comment code lines (the doc-comment
// intentionally references it to explain the bug). Strip comments
// and re-scan.
const stripped = sharedAuth
.replace(/\/\*[\s\S]*?\*\//g, '') // block comments
.replace(/(^|\n)\s*\/\/.*?(?=\n|$)/g, '$1'); // line comments
expect(stripped).not.toMatch(/['"`]\/oauth\/token['"`]/);
});
it('token exchange uses application/x-www-form-urlencoded (RFC 6749)', () => {
// application/json was the bug pre-1.8.7. Verify the body shape.
const tokenBlock = sharedAuth.slice(
sharedAuth.indexOf('login/oauth/access_token'),
sharedAuth.indexOf('login/oauth/access_token') + 1500,
);
expect(tokenBlock).toContain('application/x-www-form-urlencoded');
expect(tokenBlock).toContain('URLSearchParams');
});
it('Chrome background uses ${IAM_V1} for IAM URLs (no /api/ prefix)', () => {
// Find the IAM_V1 declaration and assert it.
expect(chromeBg).toContain('const IAM_V1 = `${IAM_API_URL}${IAM_API_PATH}`');
// No raw /api/login or /api/get-account anywhere in chrome bg.
expect(chromeBg).not.toMatch(/iam\.hanzo\.ai\/api\//);
});
it('Firefox background uses ${IAM_V1} for IAM URLs', () => {
expect(firefoxBg).toContain('const IAM_V1 = `${IAM_API}${IAM_API_PATH}`');
expect(firefoxBg).not.toMatch(/iam\.hanzo\.ai\/api\//);
});
});
describe('parseTabId centralization', () => {
it('cdp-bridge.ts imports parseTabId from shared/tab-id', () => {
expect(cdpBridge).toMatch(/import\s+\{[^}]*parseTabId[^}]*\}\s+from\s+['"]\.\/shared\/tab-id\.js['"]/);
});
it('cdp-bridge.ts no longer defines its own parseTabId', () => {
// The class private parseTabId existed as a duplicate before 1.9.0.
expect(cdpBridge).not.toMatch(/private\s+parseTabId\s*\(/);
});
it('background-firefox.ts imports parseTabId from shared/tab-id', () => {
expect(firefoxBg).toMatch(/import\s+\{[^}]*parseTabId[^}]*\}\s+from\s+['"]\.\/shared\/tab-id\.js['"]/);
});
it('background-firefox.ts no longer defines its own parseTabId', () => {
expect(firefoxBg).not.toMatch(/private\s+parseTabId\s*\(/);
});
it('background-firefox.ts uses parseTabId in Target.closeTarget (not parseInt fallback)', () => {
// The pre-1.9.0 code did `parseInt((params.targetId).replace('tab-',''),10)`.
// That was lossy on non-numeric strings and missed the anchored regex
// protection of parseTabId.
expect(firefoxBg).not.toMatch(/parseInt\(\(params\.targetId\b/);
});
});
describe('awaitPromise propagation', () => {
it('cdp-bridge.ts handleBridgeMessage Runtime.evaluate sets awaitPromise: true', () => {
// Find the handleBridgeMessage Runtime.evaluate path.
const evalSection = cdpBridge.slice(
cdpBridge.indexOf("case 'Runtime.evaluate':"),
cdpBridge.indexOf("case 'DOM.getDocument':"),
);
expect(evalSection).toContain('awaitPromise: true');
});
it('cdp-bridge.ts evaluate() helper uses awaitPromise: true', () => {
// The local `evaluate(tabId, expression)` method was the one missed
// before 1.9.0 — the bridge handler had it but this method didn't.
const helperSection = cdpBridge.slice(
cdpBridge.indexOf('async evaluate(tabId: number'),
cdpBridge.indexOf('async evaluate(tabId: number') + 600,
);
expect(helperSection).toContain('awaitPromise: true');
expect(helperSection).toContain('unwrapEvaluateResult');
});
it('cdp-bridge-server.ts evaluate action sets awaitPromise: true', () => {
const evalSection = cdpBridgeServer.slice(
cdpBridgeServer.indexOf("case 'evaluate':"),
cdpBridgeServer.indexOf("case 'evaluate':") + 800,
);
expect(evalSection).toContain('awaitPromise: true');
expect(evalSection).toContain('unwrapEvaluateResult');
});
});
describe('executeScript MV3 contract', () => {
it('background-firefox.ts uses scripting.executeScript with world: MAIN', () => {
// The Firefox path has used the MV3 scripting API since 1.8.6.
expect(firefoxBg).toMatch(/scripting\.executeScript/);
expect(firefoxBg).toMatch(/world:\s*['"]MAIN['"]/);
});
it('browser-control.ts uses scripting.executeScript with world: MAIN', () => {
// The big regression in 1.8.x: browser-control still used `func: new Function(...)`
// without the MAIN world hint, so async results were silently lost. 1.9.0
// mirrors the Firefox pattern.
expect(browserControl).toMatch(/scripting\.executeScript/);
expect(browserControl).toMatch(/world:\s*['"]MAIN['"]/);
expect(browserControl).toContain('Function(codeStr)()');
});
it('browser-control.ts handles the empty-{} promise drop', () => {
// Detect the looksEmpty / Promise.resolve fallback pattern.
expect(browserControl).toMatch(/Promise\.resolve\(/);
expect(browserControl).toMatch(/__hanzo_error/);
});
it('shipping code does NOT call the removed MV2 tabs.executeScript directly', () => {
// Allowed: reference inside the MV2 fallback branch (gated by `if`).
// Forbidden: as the primary call path. Probe that any such reference
// is wrapped behind a "legacy" / fallback comment.
const usages = [
...chromeBg.matchAll(/\btabs\.executeScript\b/g),
...firefoxBg.matchAll(/\btabs\.executeScript\b/g),
...browserControl.matchAll(/\btabs\.executeScript\b/g),
];
// Each remaining match must be in a fallback context (not the primary
// path). Heuristic: surrounding 200 chars include the words 'fallback'
// or 'legacy' or 'MV2'.
for (const m of usages) {
const idx = m.index!;
const file = m.input!;
const ctx = file.slice(Math.max(0, idx - 200), idx + 200);
expect(ctx).toMatch(/fallback|legacy|MV2/i);
}
});
});
describe('Sidebar / chat-panel surface', () => {
it('Chrome manifest does NOT declare side_panel', () => {
const m = JSON.parse(manifestChrome);
expect(m.side_panel).toBeUndefined();
expect(m.permissions).not.toContain('sidePanel');
});
it('Firefox manifest sidebar_action panel points at sidebar.html (Firefox-only fallback)', () => {
// Firefox has no chrome.sidePanel API, so a manifest sidebar_action
// is the only native sidebar entrypoint. Chrome MV3 must NOT have
// either side_panel or sidePanel permission — the Chrome chat
// surface is the page-overlay content-script.
const m = JSON.parse(manifestFirefox);
if (m.sidebar_action) {
expect(m.sidebar_action.default_panel).toBe('sidebar.html');
}
});
it('background.ts does NOT call chrome.sidePanel.setOptions', () => {
expect(chromeBg).not.toMatch(/chrome\.sidePanel\.setOptions/);
});
it('content-script chat overlay anchors to the right edge (not left)', () => {
// The right-anchor is the user's hard requirement. The chat panel
// is the #hanzo-page-overlay-root element. It anchors with
// `right: 0` (flush) by default and flips to `left: 0` only when
// [data-side="left"] is set explicitly.
const overlayBlock = contentScript.slice(
contentScript.indexOf('#hanzo-page-overlay-root {'),
contentScript.indexOf('#hanzo-page-overlay-root {') + 400,
);
expect(overlayBlock).toMatch(/right:\s*0/);
// The default (no data-side) block must NOT have a `left:` declaration.
expect(overlayBlock).not.toMatch(/^\s*left:\s/m);
});
it('popup.ts routes "Open Chat Panel" via page.overlay.show (NOT chrome.sidePanel.open)', () => {
expect(popupTs).toContain("action: 'page.overlay.show'");
// Double-check no leftover sidepanel call paths.
expect(popupTs).not.toMatch(/chrome\.sidePanel\.(open|toggle)/);
expect(popupTs).not.toMatch(/sidebarAction\.(open|toggle)/);
});
});
describe('Inspect-modifier shortcut', () => {
it('default is Ctrl alone (NOT Alt — Alt collides with macOS)', () => {
const shortcut = read('src/shared/shortcut.ts');
const block = shortcut.slice(
shortcut.indexOf('DEFAULT_INSPECT_SHORTCUT'),
shortcut.indexOf('DEFAULT_INSPECT_SHORTCUT') + 400,
);
expect(block).toMatch(/ctrl:\s*true/);
expect(block).toMatch(/alt:\s*false/);
});
it('content-script reloads shortcut on storage change (no page reload needed)', () => {
expect(contentScript).toMatch(/storage\.onChanged\.addListener/);
expect(contentScript).toMatch(/STORAGE_KEY_INSPECT/);
});
});
describe('OAuth client ID convention', () => {
it('client ID is `app-hanzo` (Hanzo IAM <org>-<app> convention)', () => {
expect(sharedConfig).toContain("DEFAULT_CLIENT_ID = 'app-hanzo'");
});
});
describe('webextension-polyfill integration', () => {
it('polyfill is the FIRST import in every entrypoint', () => {
// Order matters: the polyfill must define globalThis.browser before
// any other import reads it. ESBuild preserves import order during
// bundling, so the source order is what ships.
const entrypoints = [
['background.ts', chromeBg],
['background-firefox.ts', firefoxBg],
['content-script.ts', contentScript],
['popup.ts', popupTs],
];
for (const [name, src] of entrypoints) {
const polyfillIdx = src.indexOf("import 'webextension-polyfill'");
expect(polyfillIdx, `${name} missing polyfill import`).toBeGreaterThanOrEqual(0);
// No other `import` line should come before it.
const before = src.slice(0, polyfillIdx);
expect(before, `${name} has imports before the polyfill`)
.not.toMatch(/^\s*import\s/m);
}
});
it('shared/auth.ts exports a unified webExtensionAdapter', () => {
expect(sharedAuth).toMatch(/export\s+function\s+webExtensionAdapter\s*\(/);
});
it('chromeAdapter and firefoxAdapter still exist as compatibility aliases', () => {
// We cannot break callers that imported them.
expect(sharedAuth).toMatch(/export\s+function\s+chromeAdapter\s*\(/);
expect(sharedAuth).toMatch(/export\s+function\s+firefoxAdapter\s*\(/);
});
});
describe('Bridge server hygiene', () => {
it('no duplicate switch case for go_back / go_forward / etc.', () => {
// Count occurrences of the dead-duplicate cases. Each name MUST appear
// at most once as `case 'X':`.
const tokens = [
'go_back', 'go_forward', 'navigate_back', 'navigate_forward',
'get_url', 'get_title', 'get_tab_info', 'get_history',
'wait_for_navigation', 'create_tab',
// close_tab is allowed twice because the mid-section uses it as an
// alias on the navigation block AND the tabs block. Same for tab_info.
];
for (const t of tokens) {
const re = new RegExp(`case ['"]${t}['"]:`, 'g');
const count = (cdpBridgeServer.match(re) || []).length;
expect(count, `case '${t}' appears ${count} times in cdp-bridge-server.ts`).toBeLessThanOrEqual(1);
}
});
});
describe('Firefox register identity', () => {
it('Firefox sends `browser: BROWSER_NAME` so multi-browser routing works', () => {
// Pre-1.9.0 the register payload omitted `browser`, so the bridge
// server stored the client as 'unknown' and the user couldn't route
// commands by browser name. Verify the field is present.
const registerBlock = firefoxBg.slice(
firefoxBg.indexOf('private register():'),
firefoxBg.indexOf('private register():') + 1500,
);
expect(registerBlock).toMatch(/browser:\s*BROWSER_NAME/);
});
});
@@ -0,0 +1,93 @@
/**
* executeScript pattern coverage.
*
* The pre-1.9.0 bug was: `chrome.scripting.executeScript({ func: new Function(...) })`
* silently lost async results because:
* - Default world is ISOLATED, which can't see the page's globals
* (window.fetch in some sandboxes, framework instances, etc).
* - The MV3 API doesn't await Promises returned by `func`.
* - When the user's IIFE returned `undefined`, that JSON-encoded as `{}`
* and the caller couldn't tell success from failure.
*
* The 1.9.0 fix mirrors the Firefox pattern:
* - `world: 'MAIN'` so the page's own globals are visible.
* - A `Function(codeStr)()` trampoline so dynamic code strings still work.
* - Empty-{} detection that re-runs wrapped in `Promise.resolve(...)` to
* capture the resolved value.
*
* These tests exercise the helpers in isolation. End-to-end coverage
* lives in the e2e specs (Playwright with extension loaded).
*/
import { describe, it, expect } from 'vitest';
import { unwrapEvaluateResult, parseTabId, tabTarget } from '../src/shared/tab-id.js';
describe('Empty-{} Promise drop detection (the 1.8.x regression)', () => {
it('unwrapEvaluateResult({}) returns undefined (sentinel for "Promise was dropped")', () => {
expect(unwrapEvaluateResult({})).toBeUndefined();
});
it('non-empty object that has none of the known keys is preserved', () => {
const x = { foo: 1 };
expect(unwrapEvaluateResult(x)).toEqual(x);
});
it('arrays are preserved (NOT collapsed to undefined)', () => {
expect(unwrapEvaluateResult([])).toEqual([]);
expect(unwrapEvaluateResult([1, 2])).toEqual([1, 2]);
});
});
describe('parseTabId security and parity', () => {
it('rejects URL-embedded tab-N (security: prevents tabId injection)', () => {
expect(parseTabId('https://x.com/?tab-123')).toBeNull();
expect(parseTabId('javascript:alert(tab-1)')).toBeNull();
expect(parseTabId('data:tab-1,foo')).toBeNull();
});
it('accepts the bare wire formats Target.getTargets returns', () => {
expect(parseTabId('tab-1')).toBe(1);
expect(parseTabId('tab-1888868904')).toBe(1888868904);
expect(parseTabId('1888868904')).toBe(1888868904);
});
it('returns null on every invalid input (no exceptions, no NaN)', () => {
expect(parseTabId(undefined)).toBeNull();
expect(parseTabId(null)).toBeNull();
expect(parseTabId('')).toBeNull();
expect(parseTabId('NaN')).toBeNull();
expect(parseTabId('Infinity')).toBeNull();
expect(parseTabId({ tabId: 1 })).toBeNull();
expect(parseTabId(true)).toBeNull();
});
it('the 1.8.4 anchored-regex hardening is in place', () => {
// `1.5e10abc` and `123abc` were the cases the 1.8.4 hardening fixed.
expect(parseTabId('1.5e10abc')).toBeNull();
expect(parseTabId('123abc')).toBeNull();
expect(parseTabId('abc123')).toBeNull();
});
});
describe('tabTarget composes cleanly', () => {
it('empty source returns empty (or extra-only) object', () => {
expect(tabTarget(undefined)).toEqual({});
expect(tabTarget(undefined, { url: 'x' })).toEqual({ url: 'x' });
});
it('tabId wins over targetId', () => {
expect(tabTarget({ tabId: 5, targetId: 'tab-9' })).toEqual({ tabId: 5 });
});
it('targetId is fallback when tabId absent', () => {
expect(tabTarget({ targetId: 'tab-9' })).toEqual({ tabId: 'tab-9' });
});
it('null fields are dropped (so they do not collide with extras)', () => {
expect(tabTarget({ tabId: null, targetId: null } as any)).toEqual({});
});
it('preserves tabIndex when only that is provided', () => {
expect(tabTarget({ tabIndex: 3 })).toEqual({ tabIndex: 3 });
});
});
+39 -3
View File
@@ -5,6 +5,7 @@ import {
createZapManager,
hasZapTool,
handleZapMessage,
setZapRequestHandler,
zapCallTool,
zapListResources,
zapReadResource,
@@ -17,7 +18,7 @@ import {
MSG_RESPONSE,
MSG_PING,
MSG_PONG,
DEFAULT_ZAP_PORTS,
HANZO_SERVICE_TYPE,
ZAP_RECONNECT_DELAY,
ZAP_DISCOVERY_TIMEOUT,
} from '../src/shared/zap';
@@ -40,8 +41,11 @@ describe('Protocol constants', () => {
expect(MSG_PONG).toBe(0xFF);
});
it('DEFAULT_ZAP_PORTS is correct', () => {
expect(DEFAULT_ZAP_PORTS).toEqual([9999, 9998, 9997, 9996, 9995]);
it('HANZO_SERVICE_TYPE is _hanzo._tcp.local. (HIP-0069 mDNS-only)', () => {
// Replaces the legacy DEFAULT_ZAP_PORTS port-probe list. Discovery is
// now exclusively mDNS — the OS-assigned ZAP port is advertised by
// hanzo-zap-mdns and consumed via the extension's mDNS resolver.
expect(HANZO_SERVICE_TYPE).toBe('_hanzo._tcp.local.');
});
it('timing constants are defined', () => {
@@ -286,3 +290,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();
});
});
@@ -0,0 +1,99 @@
/**
* webextension-polyfill integration.
*
* The 1.9.0 unification: every entrypoint imports `webextension-polyfill`
* as its first import. On Chrome/Edge that defines `globalThis.browser`
* as the Promise-returning WebExtension API; on Firefox/Safari it's a
* no-op because `browser.*` is already native.
*
* The unified `webExtensionAdapter()` factory in `shared/auth.ts` then
* picks up that global so we have ONE adapter codepath instead of two
* (chromeAdapter / firefoxAdapter) with subtly different behaviours.
*
* This suite verifies the contract structurally (the polyfill is in
* deps, the entrypoints import it, the adapter exists) runtime
* verification is in the e2e specs.
*/
import { describe, it, expect } from 'vitest';
import * as fs from 'fs';
import * as path from 'path';
const pkg = JSON.parse(
fs.readFileSync(path.join(__dirname, '..', 'package.json'), 'utf8'),
);
describe('webextension-polyfill is a runtime dependency', () => {
it('listed in dependencies (not devDependencies)', () => {
expect(pkg.dependencies['webextension-polyfill']).toBeDefined();
});
it('pinned to a known version (no caret) so MV3 behavior is reproducible', () => {
// Caret ranges across major versions of the polyfill have historically
// changed return-value shapes. Pin to a known version.
const v = pkg.dependencies['webextension-polyfill'];
expect(v).not.toMatch(/^\^/);
expect(v).toMatch(/^\d+\.\d+\.\d+$/);
});
});
describe('shared/auth.ts unified adapter', () => {
const sharedAuth = fs.readFileSync(
path.join(__dirname, '..', 'src/shared/auth.ts'),
'utf8',
);
it('exports webExtensionAdapter with storage.get returning a Promise', () => {
expect(sharedAuth).toMatch(/export\s+function\s+webExtensionAdapter\s*\(\s*\)\s*:\s*BrowserAdapter/);
// Verify the adapter wraps with Promise.resolve so the surface is
// identical regardless of underlying API style.
expect(sharedAuth).toMatch(/Promise\.resolve\(b\.storage\.local\.get/);
});
it('falls back to globalThis.chrome if globalThis.browser is missing', () => {
// The legacy fallback handles tests / non-extension contexts where
// neither has been set up. It uses ?? so an empty browser object
// doesn't shadow chrome.
expect(sharedAuth).toMatch(/globalThis as any\)\.browser\s*\?\?\s*\(globalThis as any\)\.chrome/);
});
it('chromeAdapter and firefoxAdapter are aliases for back-compat', () => {
// Existing callers from before 1.9.0 must continue to work.
expect(sharedAuth).toMatch(/export\s+function\s+chromeAdapter[\s\S]*?return\s+webExtensionAdapter\(\)/);
expect(sharedAuth).toMatch(/export\s+function\s+firefoxAdapter[\s\S]*?return\s+webExtensionAdapter\(\)/);
});
});
describe('build pipeline ships the polyfill', () => {
it('chrome bundle includes webextension-polyfill code', () => {
const distChrome = path.join(
__dirname,
'..',
'dist/browser-extension/chrome/background.js',
);
if (!fs.existsSync(distChrome)) {
// Build hasn't run yet — skip. CI runs build before tests.
return;
}
const bg = fs.readFileSync(distChrome, 'utf8');
// The polyfill module sets `globalThis.browser`. We grep for its
// distinctive marker so we know it actually ended up in the bundle.
expect(bg).toMatch(/webextension-polyfill|browserPolyfill|chromeApiAvailable|globalThis\.browser/);
});
it('firefox bundle does not break if the polyfill is a no-op', () => {
const distFirefox = path.join(
__dirname,
'..',
'dist/browser-extension/firefox/background.js',
);
if (!fs.existsSync(distFirefox)) return;
const bg = fs.readFileSync(distFirefox, 'utf8');
// The Firefox bundle should still be valid JS regardless of whether
// the polyfill no-ops or wraps. Probe for any obvious syntax failure.
expect(bg.length).toBeGreaterThan(1000);
// Must NOT contain leftover NodeJS-only syntax (require()) since the
// bundle target is browser.
expect(bg).not.toMatch(/^const\s+\w+\s*=\s*require\(/m);
});
});
+10 -6
View File
@@ -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)}`;
-478
View File
@@ -1,478 +0,0 @@
import * as fs from 'fs';
import * as path from 'path';
import * as os from 'os';
import { execSync, spawn } from 'child_process';
import { EventEmitter } from 'events';
import * as http from 'http';
import * as crypto from 'crypto';
export interface HanzoAuthConfig {
apiUrl: string;
iamUrl: string;
clientId: string;
scope: string;
configPath?: string;
}
export interface HanzoCredentials {
apiKey?: string;
accessToken?: string;
refreshToken?: string;
expiresAt?: number;
userId?: string;
email?: string;
settings?: Record<string, any>;
}
export interface APIKeyInfo {
name: string;
key: string;
provider: string;
enabled: boolean;
masked?: string;
}
export class HanzoAuth extends EventEmitter {
private config: HanzoAuthConfig;
private credentialsPath: string;
private settingsPath: string;
private credentials: HanzoCredentials | null = null;
private server?: http.Server;
constructor(config: Partial<HanzoAuthConfig> = {}) {
super();
this.config = {
apiUrl: config.apiUrl || 'https://api.hanzo.ai',
iamUrl: config.iamUrl || 'https://iam.hanzo.ai',
clientId: config.clientId || 'hanzo-dev-cli',
scope: config.scope || 'api:access tools:manage',
configPath: config.configPath || path.join(os.homedir(), '.hanzo')
};
// Ensure config directory exists
fs.mkdirSync(this.config.configPath!, { recursive: true });
this.credentialsPath = path.join(this.config.configPath!, 'credentials.json');
this.settingsPath = path.join(this.config.configPath!, 'settings.json');
// Load existing credentials
this.loadCredentials();
}
private loadCredentials(): void {
try {
if (fs.existsSync(this.credentialsPath)) {
const data = fs.readFileSync(this.credentialsPath, 'utf-8');
this.credentials = JSON.parse(data);
// Check if token is expired
if (this.credentials?.expiresAt && this.credentials.expiresAt < Date.now()) {
this.emit('token:expired');
}
}
} catch (error) {
console.error('Failed to load credentials:', error);
}
}
private saveCredentials(): void {
try {
fs.writeFileSync(
this.credentialsPath,
JSON.stringify(this.credentials, null, 2),
{ mode: 0o600 } // Secure file permissions
);
} catch (error) {
console.error('Failed to save credentials:', error);
}
}
async login(): Promise<boolean> {
return new Promise((resolve, reject) => {
// Generate PKCE challenge
const codeVerifier = this.generateCodeVerifier();
const codeChallenge = this.generateCodeChallenge(codeVerifier);
const state = crypto.randomBytes(16).toString('hex');
// Start local server for OAuth callback
const port = 51234;
this.server = http.createServer(async (req, res) => {
const url = new URL(req.url!, `http://localhost:${port}`);
if (url.pathname === '/callback') {
const code = url.searchParams.get('code');
const returnedState = url.searchParams.get('state');
if (returnedState !== state) {
res.writeHead(400);
res.end('Invalid state parameter');
this.server?.close();
reject(new Error('Invalid state parameter'));
return;
}
if (code) {
// Exchange code for tokens
try {
await this.exchangeCodeForTokens(code, codeVerifier);
// Success response
res.writeHead(200, { 'Content-Type': 'text/html' });
res.end(`
<html>
<head>
<title>Hanzo Dev - Login Successful</title>
<style>
body { font-family: system-ui; display: flex; align-items: center; justify-content: center; height: 100vh; margin: 0; background: #1a1a1a; color: white; }
.container { text-align: center; }
.success { color: #4ade80; font-size: 48px; margin-bottom: 20px; }
</style>
</head>
<body>
<div class="container">
<div class="success">✓</div>
<h1>Login Successful!</h1>
<p>You can now close this window and return to your terminal.</p>
</div>
<script>setTimeout(() => window.close(), 3000);</script>
</body>
</html>
`);
this.server?.close();
resolve(true);
} catch (error) {
res.writeHead(500);
res.end('Failed to exchange code for tokens');
this.server?.close();
reject(error);
}
} else {
res.writeHead(400);
res.end('No authorization code received');
this.server?.close();
reject(new Error('No authorization code received'));
}
}
});
this.server.listen(port, () => {
// Build authorization URL
const authUrl = new URL('/oauth/authorize', this.config.iamUrl);
authUrl.searchParams.set('client_id', this.config.clientId);
authUrl.searchParams.set('response_type', 'code');
authUrl.searchParams.set('redirect_uri', `http://localhost:${port}/callback`);
authUrl.searchParams.set('scope', this.config.scope);
authUrl.searchParams.set('state', state);
authUrl.searchParams.set('code_challenge', codeChallenge);
authUrl.searchParams.set('code_challenge_method', 'S256');
// Open browser
this.openBrowser(authUrl.toString());
console.log('Opening browser for authentication...');
console.log('If browser doesn\'t open, visit:', authUrl.toString());
});
// Timeout after 5 minutes
setTimeout(() => {
if (this.server) {
this.server.close();
reject(new Error('Login timeout'));
}
}, 5 * 60 * 1000);
});
}
private async exchangeCodeForTokens(code: string, codeVerifier: string): Promise<void> {
const response = await fetch(`${this.config.iamUrl}/oauth/token`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
grant_type: 'authorization_code',
code,
client_id: this.config.clientId,
code_verifier: codeVerifier,
redirect_uri: 'http://localhost:51234/callback'
})
});
if (!response.ok) {
throw new Error(`Failed to exchange code: ${response.statusText}`);
}
const data = await response.json();
this.credentials = {
accessToken: data.access_token,
refreshToken: data.refresh_token,
expiresAt: Date.now() + (data.expires_in * 1000),
...this.credentials // Preserve existing settings
};
// Fetch user info
await this.fetchUserInfo();
// Fetch and sync API keys
await this.syncAPIKeys();
this.saveCredentials();
this.emit('login:success', this.credentials);
}
private async fetchUserInfo(): Promise<void> {
if (!this.credentials?.accessToken) return;
const response = await fetch(`${this.config.iamUrl}/api/user`, {
headers: {
'Authorization': `Bearer ${this.credentials.accessToken}`
}
});
if (response.ok) {
const user = await response.json();
this.credentials.userId = user.id;
this.credentials.email = user.email;
}
}
async syncAPIKeys(): Promise<APIKeyInfo[]> {
if (!this.credentials?.accessToken) {
throw new Error('Not authenticated');
}
const response = await fetch(`${this.config.apiUrl}/v1/api-keys`, {
headers: {
'Authorization': `Bearer ${this.credentials.accessToken}`
}
});
if (!response.ok) {
throw new Error(`Failed to fetch API keys: ${response.statusText}`);
}
const apiKeys = await response.json();
// Store API keys in settings (encrypted)
const settings = this.loadSettings();
settings.apiKeys = apiKeys.map((key: any) => ({
name: key.name,
provider: key.provider,
enabled: key.enabled,
// Store encrypted version locally
encryptedKey: this.encryptData(key.key),
masked: key.key.substring(0, 8) + '...' + key.key.substring(key.key.length - 4)
}));
this.saveSettings(settings);
this.emit('apikeys:synced', apiKeys.length);
return apiKeys;
}
getAPIKey(provider: string): string | null {
const settings = this.loadSettings();
const keyInfo = settings.apiKeys?.find((k: any) =>
k.provider === provider && k.enabled
);
if (!keyInfo?.encryptedKey) {
// Try environment variable as fallback
const envKey = `${provider.toUpperCase()}_API_KEY`;
return process.env[envKey] || null;
}
return this.decryptData(keyInfo.encryptedKey);
}
async refreshToken(): Promise<boolean> {
if (!this.credentials?.refreshToken) {
return false;
}
try {
const response = await fetch(`${this.config.iamUrl}/oauth/token`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
grant_type: 'refresh_token',
refresh_token: this.credentials.refreshToken,
client_id: this.config.clientId
})
});
if (!response.ok) {
throw new Error(`Failed to refresh token: ${response.statusText}`);
}
const data = await response.json();
this.credentials = {
...this.credentials,
accessToken: data.access_token,
refreshToken: data.refresh_token || this.credentials.refreshToken,
expiresAt: Date.now() + (data.expires_in * 1000)
};
this.saveCredentials();
this.emit('token:refreshed');
return true;
} catch (error) {
this.emit('token:refresh:failed', error);
return false;
}
}
async logout(): Promise<void> {
if (this.credentials?.accessToken) {
try {
await fetch(`${this.config.iamUrl}/oauth/revoke`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${this.credentials.accessToken}`
},
body: JSON.stringify({
token: this.credentials.refreshToken || this.credentials.accessToken
})
});
} catch (error) {
console.error('Failed to revoke token:', error);
}
}
// Clear credentials but keep settings
this.credentials = null;
if (fs.existsSync(this.credentialsPath)) {
fs.unlinkSync(this.credentialsPath);
}
this.emit('logout');
}
isAuthenticated(): boolean {
if (!this.credentials?.accessToken) {
return false;
}
// Check if token is expired
if (this.credentials.expiresAt && this.credentials.expiresAt < Date.now()) {
return false;
}
return true;
}
getCredentials(): HanzoCredentials | null {
return this.credentials;
}
async makeAuthenticatedRequest(url: string, options: RequestInit = {}): Promise<Response> {
if (!this.isAuthenticated()) {
// Try to refresh token
if (this.credentials?.refreshToken) {
const refreshed = await this.refreshToken();
if (!refreshed) {
throw new Error('Not authenticated');
}
} else {
throw new Error('Not authenticated');
}
}
const headers = {
...options.headers,
'Authorization': `Bearer ${this.credentials!.accessToken}`
};
return fetch(url, { ...options, headers });
}
private loadSettings(): Record<string, any> {
try {
if (fs.existsSync(this.settingsPath)) {
return JSON.parse(fs.readFileSync(this.settingsPath, 'utf-8'));
}
} catch (error) {
console.error('Failed to load settings:', error);
}
return {};
}
private saveSettings(settings: Record<string, any>): void {
try {
fs.writeFileSync(
this.settingsPath,
JSON.stringify(settings, null, 2),
{ mode: 0o600 }
);
} catch (error) {
console.error('Failed to save settings:', error);
}
}
private encryptData(data: string): string {
// Use machine ID as encryption key
const key = crypto.scryptSync(this.getMachineId(), 'salt', 32);
const iv = crypto.randomBytes(16);
const cipher = crypto.createCipheriv('aes-256-cbc', key, iv);
let encrypted = cipher.update(data, 'utf8', 'hex');
encrypted += cipher.final('hex');
return iv.toString('hex') + ':' + encrypted;
}
private decryptData(encryptedData: string): string {
const [ivHex, encrypted] = encryptedData.split(':');
const key = crypto.scryptSync(this.getMachineId(), 'salt', 32);
const iv = Buffer.from(ivHex, 'hex');
const decipher = crypto.createDecipheriv('aes-256-cbc', key, iv);
let decrypted = decipher.update(encrypted, 'hex', 'utf8');
decrypted += decipher.final('utf8');
return decrypted;
}
private getMachineId(): string {
// Simple machine ID based on hostname and platform
return crypto.createHash('sha256')
.update(os.hostname())
.update(os.platform())
.update(os.homedir())
.digest('hex')
.substring(0, 32);
}
private generateCodeVerifier(): string {
return crypto.randomBytes(32).toString('base64url');
}
private generateCodeChallenge(verifier: string): string {
return crypto.createHash('sha256')
.update(verifier)
.digest('base64url');
}
private openBrowser(url: string): void {
const platform = os.platform();
try {
if (platform === 'darwin') {
spawn('open', [url], { detached: true });
} else if (platform === 'win32') {
spawn('start', ['', url], { shell: true, detached: true });
} else {
spawn('xdg-open', [url], { detached: true });
}
} catch (error) {
console.error('Failed to open browser:', error);
}
}
}
+5 -5
View File
@@ -39,12 +39,12 @@ export class StandaloneAuthManager {
this.tokenFile = path.join(this.configDir, 'auth.json');
this.deviceIdFile = path.join(this.configDir, 'device.json');
// Casdoor configuration: login UI on hanzo.id, API on iam.hanzo.ai
// IAM configuration: login UI on hanzo.id, API on iam.hanzo.ai
this.casdoorConfig = {
endpoint: process.env.HANZO_IAM_ENDPOINT || 'https://iam.hanzo.ai',
loginUrl: process.env.HANZO_IAM_LOGIN_URL || 'https://hanzo.id',
clientId: process.env.HANZO_IAM_CLIENT_ID || 'hanzo-mcp',
clientSecret: process.env.HANZO_IAM_CLIENT_SECRET,
endpoint: process.env.IAM_ENDPOINT || 'https://iam.hanzo.ai',
loginUrl: process.env.IAM_LOGIN_URL || 'https://hanzo.id',
clientId: process.env.IAM_CLIENT_ID || 'hanzo-mcp',
clientSecret: process.env.IAM_CLIENT_SECRET,
applicationName: 'hanzo-mcp',
organizationName: 'hanzo'
};
@@ -37,7 +37,7 @@ Environment Variables:
HANZO_WORKSPACE Default workspace directory
MCP_TRANSPORT Transport method (stdio or tcp, default: stdio)
MCP_PORT Port for TCP transport (default: 3000)
HANZO_IAM_ENDPOINT IAM endpoint (default: https://iam.hanzo.ai)
IAM_ENDPOINT IAM endpoint (default: https://iam.hanzo.ai)
Authentication:
By default, the server requires authentication with your Hanzo account.
+18 -2
View File
@@ -129,6 +129,9 @@ importers:
commander:
specifier: ^11.1.0
version: 11.1.0
webextension-polyfill:
specifier: 0.12.0
version: 0.12.0
ws:
specifier: ^8.18.3
version: 8.18.3
@@ -149,7 +152,7 @@ importers:
specifier: ^8.18.1
version: 8.18.1
esbuild:
specifier: ^0.25.6
specifier: ^0.25.8
version: 0.25.8
jsdom:
specifier: ^26.1.0
@@ -2602,6 +2605,7 @@ packages:
'@ungap/structured-clone@1.3.0':
resolution: {integrity: sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==}
deprecated: Potential CWE-502 - Update to 1.3.1 or higher
'@vitest/coverage-v8@0.34.6':
resolution: {integrity: sha512-fivy/OK2d/EsJFoEoxHFEnNGTg+MmdZBAVK9Ka4qhXR2K3J0DS08vcGVwzDtXSuUMabLv4KtPcpSKkcMXFDViw==}
@@ -3967,11 +3971,12 @@ packages:
glob@10.4.5:
resolution: {integrity: sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==}
deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me
hasBin: true
glob@7.2.3:
resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==}
deprecated: Glob versions prior to v9 are no longer supported
deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me
global@4.4.0:
resolution: {integrity: sha512-wv/LAoHdRE3BeTGz53FAamhGlPLhlssK45usmGFThIi4XqnBmjKQ16u+RNbP7WvigRZDxUsM0J3gcQ5yicaL0w==}
@@ -4993,6 +4998,7 @@ packages:
nats@2.29.3:
resolution: {integrity: sha512-tOQCRCwC74DgBTk4pWZ9V45sk4d7peoE2njVprMRCBXrhJ5q5cYM7i6W+Uvw2qUrcfOSnuisrX7bEx3b3Wx4QA==}
engines: {node: '>= 14.0.0'}
deprecated: Package moved. Use @nats-io/transport-node from https://github.com/nats-io/nats.js
natural-compare@1.4.0:
resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==}
@@ -5401,11 +5407,13 @@ packages:
prebuild-install@5.3.6:
resolution: {integrity: sha512-s8Aai8++QQGi4sSbs/M1Qku62PFK49Jm1CbgXklGz4nmHveDq0wzJkg7Na5QbnO1uNH8K7iqx2EQ/mV0MZEmOg==}
engines: {node: '>=6'}
deprecated: No longer maintained. Please contact the author of the relevant native addon; alternatives are available.
hasBin: true
prebuild-install@7.1.3:
resolution: {integrity: sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==}
engines: {node: '>=10'}
deprecated: No longer maintained. Please contact the author of the relevant native addon; alternatives are available.
hasBin: true
prelude-ls@1.2.1:
@@ -5980,6 +5988,7 @@ packages:
tar@7.4.3:
resolution: {integrity: sha512-5S7Va8hKfV7W5U6g3aYxXmlPoZVAwUMy9AOKyF2fVuZa2UD3qZjg578OrLRt8PcNN1PleVaL/5/yYATNL0ICUw==}
engines: {node: '>=18'}
deprecated: Old versions of tar are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me
terser@5.43.1:
resolution: {integrity: sha512-+6erLbBm0+LROX2sPXlUYx/ux5PyE9K/a92Wrt6oA+WDAoFTdpHE5tCYCI5PNzq2y8df4rA+QgHLJuR4jNymsg==}
@@ -6300,10 +6309,12 @@ packages:
uuid@10.0.0:
resolution: {integrity: sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ==}
deprecated: uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028).
hasBin: true
uuid@8.3.2:
resolution: {integrity: sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==}
deprecated: uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028).
hasBin: true
v8-to-istanbul@9.3.0:
@@ -6435,6 +6446,9 @@ packages:
web-vitals@4.2.4:
resolution: {integrity: sha512-r4DIlprAGwJ7YM11VZp4R884m0Vmgr6EAKe3P+kO0PPj3Unqyvv59rczf6UiGcb9Z8QxZVcqKNwv/g0WNdWwsw==}
webextension-polyfill@0.12.0:
resolution: {integrity: sha512-97TBmpoWJEE+3nFBQ4VocyCdLKfw54rFaJ6EVQYLBCXqCIpLSZkwGgASpv4oPt9gdKCJ80RJlcmNzNn008Ag6Q==}
webidl-conversions@3.0.1:
resolution: {integrity: sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==}
@@ -13524,6 +13538,8 @@ snapshots:
web-vitals@4.2.4: {}
webextension-polyfill@0.12.0: {}
webidl-conversions@3.0.1: {}
webidl-conversions@4.0.2: {}