Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e9531bf4e3 | ||
|
|
46daa52d43 | ||
|
|
780757ca55 | ||
|
|
5bf8d934b4 | ||
|
|
81db870a51 | ||
|
|
4060ce491b | ||
|
|
9032801deb | ||
|
|
8051256382 | ||
|
|
3e380bcec3 | ||
|
|
39fc502513 | ||
|
|
ce2f186bd2 | ||
|
|
7c742c07f4 | ||
|
|
4eb2cf57a8 | ||
|
|
3ab5ae053c | ||
|
|
fe3c0eabdc | ||
|
|
e23fbe51f7 | ||
|
|
42bf555a4e | ||
|
|
8f8f8b5ae2 | ||
|
|
c783b89c35 | ||
|
|
b3f718dfce | ||
|
|
c4424826f9 | ||
|
|
8d5384c1de | ||
|
|
3633c5a6ff | ||
|
|
355f6496fb | ||
|
|
28c75ec6a8 | ||
|
|
22729e5218 | ||
|
|
51f59c44ff | ||
|
|
1c2b9f7915 | ||
|
|
1298aea79a | ||
|
|
1560cd1228 | ||
|
|
c6e3ac5533 | ||
|
|
bb0cd246e8 | ||
|
|
52076f2c25 | ||
|
|
03e081e83a | ||
|
|
3bb7d57a9f | ||
|
|
2ad3d464b4 | ||
|
|
5e270c061d | ||
|
|
bb6ed472be | ||
|
|
610367e6fd | ||
|
|
d268fa9298 | ||
|
|
44fccae452 | ||
|
|
d002cc136c | ||
|
|
4aebce7014 | ||
|
|
2287b6a707 | ||
|
|
8caa647106 | ||
|
|
07b9bf6239 | ||
|
|
5932768548 | ||
|
|
aabc15a382 | ||
|
|
6ca23de63a | ||
|
|
179a5bc718 | ||
|
|
8677e55f30 | ||
|
|
719cd51919 | ||
|
|
ea747cff45 | ||
|
|
3200927f8c | ||
|
|
39cf5950a5 | ||
|
|
1781862fb1 | ||
|
|
cfd53c8142 | ||
|
|
4c5a0bb064 | ||
|
|
11862defbc | ||
|
|
962bc8ce26 | ||
|
|
0cc7d4972f | ||
|
|
43a81de0d0 | ||
|
|
6503fa614d |
@@ -0,0 +1,9 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="1280" height="640" viewBox="0 0 1280 640" role="img" aria-label="extension">
|
||||
<rect width="1280" height="640" fill="#0A0A0A"/>
|
||||
<svg x="96" y="215" width="210" height="210" viewBox="0 0 67 67"><path d="M22.21 67V44.6369H0V67H22.21Z" fill="#fff"/><path d="M66.7038 22.3184H22.2534L0.0878906 44.6367H44.4634L66.7038 22.3184Z" fill="#fff"/><path d="M22.21 0H0V22.3184H22.21V0Z" fill="#fff"/><path d="M66.7198 0H44.5098V22.3184H66.7198V0Z" fill="#fff"/><path d="M66.7198 67V44.6369H44.5098V67H66.7198Z" fill="#fff"/></svg>
|
||||
<text x="378" y="276" font-family="Inter,system-ui,-apple-system,sans-serif" font-size="78" font-weight="800" letter-spacing="-2" fill="#ffffff">extension</text>
|
||||
<text x="378" y="322" font-family="Inter,system-ui,sans-serif" font-size="30" fill="#ffffff" opacity=".66">🧩 Hanzo Extension: IDE plugin for VS Code compatible IDEs.</text>
|
||||
<rect x="378" y="338" width="806" height="3" rx="1.5" fill="#ffffff" opacity=".9"/>
|
||||
<text x="378" y="390" font-family="Inter,system-ui,sans-serif" font-size="24" font-weight="600" fill="#ffffff" opacity=".5">github.com/hanzoai</text>
|
||||
<text x="1184" y="390" text-anchor="end" font-family="Inter,system-ui,sans-serif" font-size="24" font-weight="600" fill="#ffffff" opacity=".5">hanzo.ai</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.3 KiB |
+46
-13
@@ -11,7 +11,7 @@ jobs:
|
||||
# ─── Unit Tests ───
|
||||
test:
|
||||
name: Test
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: hanzo-build-linux-amd64
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: pnpm/action-setup@v4
|
||||
@@ -30,18 +30,32 @@ jobs:
|
||||
- name: Test AI package
|
||||
run: pnpm --filter @hanzo/ai test
|
||||
|
||||
- name: Test auth core
|
||||
run: pnpm --filter @hanzo/auth test
|
||||
|
||||
- name: Test Office add-in
|
||||
run: pnpm --filter @hanzo/office-addin test
|
||||
|
||||
- name: Test tools package
|
||||
run: pnpm --filter @hanzo/tools test
|
||||
continue-on-error: true
|
||||
run: pnpm --filter @hanzo/cli-tools test
|
||||
|
||||
- name: Test VS Code extension
|
||||
run: pnpm --filter hanzo-ide test
|
||||
continue-on-error: true
|
||||
run: |
|
||||
# The extension host is Electron: it needs a display plus the
|
||||
# chromium-class shared libraries. Arc nodes are minimal (no xvfb,
|
||||
# no libnspr4/libnss3), so provision explicitly (noble t64 names).
|
||||
sudo apt-get update -qq
|
||||
sudo apt-get install -y -qq xvfb libnss3 libnspr4 libatk1.0-0t64 \
|
||||
libatk-bridge2.0-0t64 libcups2t64 libdrm2 libxkbcommon0 \
|
||||
libatspi2.0-0t64 libxcomposite1 libxdamage1 libxfixes3 libxrandr2 \
|
||||
libgbm1 libasound2t64 libgtk-3-0t64 libxshmfence1 libsecret-1-0 \
|
||||
libxkbfile1
|
||||
xvfb-run -a pnpm --filter hanzo-ide test
|
||||
|
||||
# ─── Playwright E2E ───
|
||||
e2e:
|
||||
name: E2E
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: hanzo-build-linux-amd64
|
||||
needs: test
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
@@ -82,7 +96,7 @@ jobs:
|
||||
# ─── Build all extensions ───
|
||||
build:
|
||||
name: Build
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: hanzo-build-linux-amd64
|
||||
needs: [test, e2e]
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
@@ -106,10 +120,15 @@ jobs:
|
||||
- name: Package browser extensions
|
||||
working-directory: packages/browser
|
||||
run: |
|
||||
VER="${{ steps.version.outputs.ver }}"
|
||||
cd dist/browser-extension/chrome && zip -r /tmp/hanzo-ai-chrome-v${VER}.zip .
|
||||
cd ../firefox && zip -r /tmp/hanzo-ai-firefox-v${VER}.zip .
|
||||
cd ../chrome && zip -r /tmp/hanzo-ai-edge-v${VER}.zip .
|
||||
# web-ext (already used for linting below) does the zipping — the
|
||||
# arc runner image has no `zip` binary. Version comes from the
|
||||
# browser package.json, the single source of truth build.js stamps
|
||||
# into the manifests (the root version can lag it).
|
||||
VER=$(node -p "require('./package.json').version")
|
||||
npx web-ext build --source-dir dist/browser-extension/chrome --artifacts-dir /tmp --filename hanzo-ai-chrome-v${VER}.zip --overwrite-dest
|
||||
npx web-ext build --source-dir dist/browser-extension/firefox --artifacts-dir /tmp --filename hanzo-ai-firefox-v${VER}.zip --overwrite-dest
|
||||
# Edge uses the Chrome zip (Chromium-compatible)
|
||||
cp /tmp/hanzo-ai-chrome-v${VER}.zip /tmp/hanzo-ai-edge-v${VER}.zip
|
||||
|
||||
- name: Lint Firefox
|
||||
working-directory: packages/browser
|
||||
@@ -148,6 +167,20 @@ jobs:
|
||||
VER="${{ steps.version.outputs.ver }}"
|
||||
cp dist/hanzoai-*.dxt /tmp/hanzo-ai-claude-v${VER}.dxt 2>/dev/null || true
|
||||
|
||||
# ── Microsoft Office add-in (Word/Excel/PowerPoint) ──
|
||||
- name: Build Office add-in
|
||||
run: |
|
||||
VER="${{ steps.version.outputs.ver }}"
|
||||
# Two builds, two zips (node-based bestzip — the arc image has no `zip`):
|
||||
# - production: manifest points at office.hanzo.ai (AppSource / admin deploy)
|
||||
# - localhost: manifest points at https://localhost:3000 + serve.mjs +
|
||||
# SIDELOAD-WINDOWS.md — the self-contained "test on my machine" kit
|
||||
pnpm --filter @hanzo/office-addin build
|
||||
(cd packages/office/dist && npx --yes bestzip /tmp/hanzo-ai-office-v${VER}.zip .)
|
||||
HANZO_OFFICE_BASE=https://localhost:3000 pnpm --filter @hanzo/office-addin build
|
||||
(cd packages/office/dist && npx --yes bestzip /tmp/hanzo-ai-office-localhost-v${VER}.zip .)
|
||||
continue-on-error: true
|
||||
|
||||
# ── MCP npm package ──
|
||||
- name: Build MCP server
|
||||
run: pnpm --filter @hanzo/mcp run build
|
||||
@@ -211,7 +244,7 @@ jobs:
|
||||
# ─── JetBrains ───
|
||||
build-jetbrains:
|
||||
name: JetBrains
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: hanzo-build-linux-amd64
|
||||
needs: [test, e2e]
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
@@ -243,7 +276,7 @@ jobs:
|
||||
# ─── GitHub Release (on tag push) ───
|
||||
release:
|
||||
name: Release
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: hanzo-build-linux-amd64
|
||||
needs: [build, build-safari, build-jetbrains]
|
||||
if: >-
|
||||
always() &&
|
||||
|
||||
@@ -85,7 +85,7 @@ jobs:
|
||||
# ═══════════════════════════════════════════════════════════════════
|
||||
extension-e2e:
|
||||
name: Extension E2E (${{ matrix.browser }})
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: hanzo-build-linux-amd64
|
||||
timeout-minutes: 15
|
||||
strategy:
|
||||
fail-fast: false
|
||||
@@ -129,7 +129,7 @@ jobs:
|
||||
# ═══════════════════════════════════════════════════════════════════
|
||||
summary:
|
||||
name: Parity Summary
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: hanzo-build-linux-amd64
|
||||
needs: [e2e-matrix, extension-e2e]
|
||||
if: always()
|
||||
steps:
|
||||
|
||||
@@ -10,7 +10,7 @@ on:
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: hanzo-deploy-linux-amd64
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
|
||||
+100
-16
@@ -20,7 +20,7 @@ jobs:
|
||||
# ─── Check available secrets ───
|
||||
secrets:
|
||||
name: Load KMS Secrets
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: hanzo-build-linux-amd64
|
||||
outputs:
|
||||
has-chrome: ${{ steps.check.outputs.has-chrome }}
|
||||
has-firefox: ${{ steps.check.outputs.has-firefox }}
|
||||
@@ -40,7 +40,7 @@ jobs:
|
||||
# ─── Chrome + Firefox ───
|
||||
browser:
|
||||
name: Chrome + Firefox
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: hanzo-build-linux-amd64
|
||||
needs: secrets
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
@@ -58,12 +58,17 @@ jobs:
|
||||
- name: Package
|
||||
working-directory: packages/browser
|
||||
run: |
|
||||
# web-ext (already used for linting below) does the zipping — the
|
||||
# arc runner image has no `zip` binary. The old `zip -r ../../../…`
|
||||
# also escaped dist/, so the artifact globs and the store upload
|
||||
# never saw the files. The Safari bundle comes from the dedicated
|
||||
# macOS job — the Linux build has no Xcode and emits no safari
|
||||
# manifest, so packaging it here would ship junk.
|
||||
VERSION=$(node -p "require('./package.json').version")
|
||||
cd dist/browser-extension/chrome && zip -r ../../../hanzo-ai-chrome-v${VERSION}.zip .
|
||||
cd ../../browser-extension/firefox && zip -r ../../../hanzo-ai-firefox-v${VERSION}.zip .
|
||||
cd ../../browser-extension/safari && zip -r ../../../hanzo-ai-safari-v${VERSION}.zip .
|
||||
# Edge uses Chrome zip (Chromium-compatible)
|
||||
cd ../../.. && cp hanzo-ai-chrome-v${VERSION}.zip hanzo-ai-edge-v${VERSION}.zip
|
||||
npx web-ext build --source-dir dist/browser-extension/chrome --artifacts-dir dist --filename hanzo-ai-chrome-v${VERSION}.zip --overwrite-dest
|
||||
npx web-ext build --source-dir dist/browser-extension/firefox --artifacts-dir dist --filename hanzo-ai-firefox-v${VERSION}.zip --overwrite-dest
|
||||
# Edge uses the Chrome zip (Chromium-compatible)
|
||||
cp dist/hanzo-ai-chrome-v${VERSION}.zip dist/hanzo-ai-edge-v${VERSION}.zip
|
||||
|
||||
- name: Upload browser zips
|
||||
uses: actions/upload-artifact@v4
|
||||
@@ -73,7 +78,6 @@ jobs:
|
||||
packages/browser/dist/hanzo-ai-chrome-v*.zip
|
||||
packages/browser/dist/hanzo-ai-edge-v*.zip
|
||||
packages/browser/dist/hanzo-ai-firefox-v*.zip
|
||||
packages/browser/dist/hanzo-ai-safari-v*.zip
|
||||
|
||||
- name: Lint Firefox
|
||||
working-directory: packages/browser
|
||||
@@ -89,11 +93,12 @@ jobs:
|
||||
-d "client_secret=${{ secrets.CHROME_CLIENT_SECRET }}" \
|
||||
-d "refresh_token=${{ secrets.CHROME_REFRESH_TOKEN }}" \
|
||||
-d "grant_type=refresh_token" | jq -r '.access_token')
|
||||
VERSION=$(node -p "require('./package.json').version")
|
||||
curl -sf -X PUT \
|
||||
"https://www.googleapis.com/upload/chromewebstore/v1.1/items/${{ secrets.CHROME_EXTENSION_ID }}" \
|
||||
-H "Authorization: Bearer $ACCESS_TOKEN" \
|
||||
-H "x-goog-api-version: 2" \
|
||||
-T dist/hanzo-ai-chrome.zip
|
||||
-T dist/hanzo-ai-chrome-v${VERSION}.zip
|
||||
curl -sf -X POST \
|
||||
"https://www.googleapis.com/chromewebstore/v1.1/items/${{ secrets.CHROME_EXTENSION_ID }}/publish" \
|
||||
-H "Authorization: Bearer $ACCESS_TOKEN" \
|
||||
@@ -149,10 +154,33 @@ jobs:
|
||||
-derivedDataPath dist/safari-build-ios
|
||||
continue-on-error: true
|
||||
|
||||
# Package the built .app(s) so the GitHub Release Safari link resolves — the
|
||||
# unsigned bundle a developer sideloads (source + built products).
|
||||
- name: Package Safari for release
|
||||
working-directory: packages/browser
|
||||
run: |
|
||||
VERSION=${GITHUB_REF_NAME#v}
|
||||
cd dist
|
||||
# Prefer the built .app products; fall back to the Xcode source project.
|
||||
APPS=$(find safari-build-macos safari-build-ios -name '*.app' -type d 2>/dev/null || true)
|
||||
if [ -n "$APPS" ]; then
|
||||
zip -r -y "/tmp/hanzo-ai-safari-v${VERSION}.zip" $APPS
|
||||
else
|
||||
zip -r "/tmp/hanzo-ai-safari-v${VERSION}.zip" "safari/Hanzo AI"
|
||||
fi
|
||||
continue-on-error: true
|
||||
|
||||
- name: Upload Safari bundle
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: hanzo-ai-safari
|
||||
path: /tmp/hanzo-ai-safari-v*.zip
|
||||
continue-on-error: true
|
||||
|
||||
# ─── VS Code + Cursor + Windsurf + Antigravity + Open VSX ───
|
||||
vscode:
|
||||
name: VS Code Marketplace
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: hanzo-build-linux-amd64
|
||||
needs: secrets
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
@@ -213,7 +241,7 @@ jobs:
|
||||
# ─── npm (@hanzo/mcp) ───
|
||||
npm:
|
||||
name: npm
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: hanzo-build-linux-amd64
|
||||
needs: secrets
|
||||
if: needs.secrets.outputs.has-npm == 'true'
|
||||
steps:
|
||||
@@ -236,9 +264,8 @@ jobs:
|
||||
# ─── JetBrains Marketplace ───
|
||||
jetbrains:
|
||||
name: JetBrains
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: hanzo-build-linux-amd64
|
||||
needs: secrets
|
||||
if: needs.secrets.outputs.has-jetbrains == 'true'
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-java@v4
|
||||
@@ -246,18 +273,64 @@ jobs:
|
||||
java-version: '17'
|
||||
distribution: 'temurin'
|
||||
|
||||
- name: Build and publish
|
||||
# Always build the downloadable plugin zip so the GitHub Release link works
|
||||
# even when the Marketplace token is absent (publish below is token-gated).
|
||||
- name: Build plugin
|
||||
working-directory: packages/jetbrains
|
||||
run: ./gradlew buildPlugin
|
||||
|
||||
- name: Package for release
|
||||
working-directory: packages/jetbrains
|
||||
run: |
|
||||
VERSION=${GITHUB_REF_NAME#v}
|
||||
ZIP=$(ls build/distributions/*.zip | head -1)
|
||||
cp "$ZIP" "/tmp/hanzo-ai-jetbrains-v${VERSION}.zip"
|
||||
|
||||
- name: Upload JetBrains plugin
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: hanzo-ai-jetbrains
|
||||
path: /tmp/hanzo-ai-jetbrains-v*.zip
|
||||
|
||||
- name: Publish to JetBrains Marketplace
|
||||
if: needs.secrets.outputs.has-jetbrains == 'true'
|
||||
working-directory: packages/jetbrains
|
||||
run: ./gradlew publishPlugin
|
||||
env:
|
||||
PUBLISH_TOKEN: ${{ secrets.JETBRAINS_TOKEN }}
|
||||
continue-on-error: true
|
||||
|
||||
# ─── Microsoft Office add-in (Word / Excel / PowerPoint) ───
|
||||
office:
|
||||
name: Office Add-in
|
||||
runs-on: hanzo-build-linux-amd64
|
||||
needs: secrets
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: pnpm/action-setup@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '22'
|
||||
cache: 'pnpm'
|
||||
- run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Build + package
|
||||
run: |
|
||||
VERSION=${GITHUB_REF_NAME#v}
|
||||
pnpm --filter @hanzo/office-addin build
|
||||
cd packages/office/dist && npx --yes bestzip "/tmp/hanzo-ai-office-v${VERSION}.zip" .
|
||||
|
||||
- name: Upload Office add-in
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: hanzo-ai-office
|
||||
path: /tmp/hanzo-ai-office-v*.zip
|
||||
|
||||
# ─── GitHub Release with downloadable artifacts ───
|
||||
release:
|
||||
name: GitHub Release
|
||||
runs-on: ubuntu-latest
|
||||
needs: [browser, safari, vscode]
|
||||
runs-on: hanzo-build-linux-amd64
|
||||
needs: [browser, safari, vscode, jetbrains, office]
|
||||
if: startsWith(github.ref, 'refs/tags/v')
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
@@ -286,6 +359,14 @@ jobs:
|
||||
| **Claude Desktop/Code** | Windows / macOS / Linux | [`hanzo-ai-claude-v${{ steps.version.outputs.version }}.dxt`](https://github.com/hanzoai/extension/releases/download/${{ github.ref_name }}/hanzo-ai-claude-v${{ steps.version.outputs.version }}.dxt) |
|
||||
| **JetBrains** | Windows / macOS / Linux | [`hanzo-ai-jetbrains-v${{ steps.version.outputs.version }}.zip`](https://github.com/hanzoai/extension/releases/download/${{ github.ref_name }}/hanzo-ai-jetbrains-v${{ steps.version.outputs.version }}.zip) |
|
||||
|
||||
## Microsoft Office
|
||||
|
||||
| App | Platform | Download |
|
||||
|-----|----------|----------|
|
||||
| **Word · Excel · PowerPoint** | Windows / macOS / Web / iPad | [`hanzo-ai-office-v${{ steps.version.outputs.version }}.zip`](https://github.com/hanzoai/extension/releases/download/${{ github.ref_name }}/hanzo-ai-office-v${{ steps.version.outputs.version }}.zip) |
|
||||
|
||||
> Office: unzip and sideload `manifest.xml` via the M365 admin center, or Insert → Add-ins → Upload My Add-in.
|
||||
|
||||
## Browser Extensions
|
||||
|
||||
| Browser | Platform | Download |
|
||||
@@ -303,3 +384,6 @@ jobs:
|
||||
files: |
|
||||
artifacts/hanzo-ai-browser/*
|
||||
artifacts/hanzo-ai-ide/*
|
||||
artifacts/hanzo-ai-jetbrains/*
|
||||
artifacts/hanzo-ai-office/*
|
||||
artifacts/hanzo-ai-safari/*
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
out/
|
||||
dist/
|
||||
*.vsix
|
||||
*.xpi
|
||||
*.crx
|
||||
|
||||
# Dependencies
|
||||
node_modules/
|
||||
|
||||
@@ -1,38 +1,52 @@
|
||||
# LLM.md - Hanzo Extension
|
||||
# Hanzo Extension
|
||||
|
||||
## Overview
|
||||
Hanzo AI Development Platform Monorepo
|
||||
Browser extension (`packages/browser`) that joins the shared local **zapd** fabric.
|
||||
One native primitive — no WebSocket, no localhost port, no mDNS, no CDP bridge.
|
||||
Ships Chrome / Firefox / Safari. Build: esbuild (`src/build.js`, `pnpm build`);
|
||||
tests: vitest.
|
||||
|
||||
## Tech Stack
|
||||
- **Language**: TypeScript/JavaScript
|
||||
## Architecture (native ZAP)
|
||||
|
||||
## Build & Run
|
||||
```bash
|
||||
pnpm install && pnpm build
|
||||
pnpm test
|
||||
```
|
||||
extension ─connectNative("ai.hanzo.zap")─► native host ─UDS─► zapd ◄─ hanzo-mcp
|
||||
|
||||
## Structure
|
||||
```
|
||||
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
|
||||
```
|
||||
- `shared/native-zap.ts` — the ONE transport: `connectNativeZap()` opens the native
|
||||
port (**singleton** — one port; the router does evict-and-replace), registers as
|
||||
provider `browser:<engine>/<host>/default`, dispatches inbound ROUTE commands.
|
||||
Cross-browser (`browser ∥ chrome`).
|
||||
- `background.ts` (Chrome) — dispatch via `chrome.debugger` (CDP→tab; shows the
|
||||
"debugging this browser" banner; WebKit-incompatible). **To be removed** — see WXT plan.
|
||||
- `background-firefox.ts` — native `browser.*` dispatch (no banner). The correct model.
|
||||
- `browser-dispatch.ts` — `chrome.debugger` actuation (Chrome-only).
|
||||
- `shared/evaluable.ts` — the ONE evaluate rule: `pickEvaluable` (code-param
|
||||
aliases) + `wrapEvaluable` (bare expression passes through; statement bodies
|
||||
→ async IIFE with the trailing value auto-returned). Both dispatch paths
|
||||
consume it, so Chrome and Firefox accept identical caller JS.
|
||||
|
||||
## Key Files
|
||||
- `README.md` -- Project documentation
|
||||
- `package.json` -- Dependencies and scripts
|
||||
- `Makefile` -- Build automation
|
||||
Wire to zapd: the binary ZAP router envelope (`zap-proto/zapd/src/frame.rs`); the
|
||||
browser↔host hop is native-messaging JSON, base64'd, quarantined in the host. Host +
|
||||
per-browser manifests come from `zapd install-host` (`zap-proto/zapd`).
|
||||
|
||||
## Versioning
|
||||
Patch only (X.Y.Z+1), never major. `package.json` is the source of truth; `build.js`
|
||||
stamps it into every manifest (chrome/firefox/safari).
|
||||
|
||||
## Cross-platform — WXT migration (canonical plan, not yet executed)
|
||||
|
||||
One WebExtension codebase, every engine. **WXT** = build/SDK layer (build matrix,
|
||||
manifest gen, targets, MV3, Vite/TS); `webextension-polyfill` / `@wxt-dev/browser` =
|
||||
`browser.*` normalization. **Custom (keep):** `shared/native-zap.ts`, and the Safari
|
||||
`SafariWebExtensionHandler.swift` → `~/.zap/run/zapd.sock` bridge. WXT builds Safari
|
||||
but does NOT give WebKit `chrome.debugger` — so dispatch must become portable
|
||||
`browser.*` (the Firefox model is already correct).
|
||||
|
||||
1. Adopt WXT build matrix (chrome/firefox/safari) — replaces `build.js`.
|
||||
2. Collapse forks → one `background.ts`; delete `background-firefox.ts`; runtime
|
||||
`browser` adapter.
|
||||
3. Collapse dispatch → delete `chrome.debugger` / `browser-dispatch.ts`; actuate over
|
||||
`tabs`/`scripting`/`webNavigation`/`browser.*` (portable, no banner, Safari-capable).
|
||||
4. Transport stays per-platform: Chrome/Firefox `connectNative` → host → `zapd.sock`;
|
||||
Safari `SafariWebExtensionHandler.swift` → `zapd.sock`.
|
||||
5. CI guards (last; go red until 1–4 land): fail if `chrome.debugger`, the `debugger`
|
||||
permission, or `background-firefox.ts` appears.
|
||||
|
||||
Large, multi-phase — do it as a focused pass, never half-merged.
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
<p align="center"><img src=".github/hero.svg" alt="extension" width="880"></p>
|
||||
|
||||
# Dev - Ship Up to 100X Faster with Parallel AI Agents 🚀
|
||||
|
||||
[](https://github.com/hanzoai/dev/actions/workflows/vscode-extension.yml)
|
||||
|
||||
@@ -18,6 +18,6 @@
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.54.1",
|
||||
"terser": "^5.43.1",
|
||||
"vite": "^5.4.19"
|
||||
"vite": "^6.4.3"
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
+87
-3
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"name": "hanzo-ai-monorepo",
|
||||
"version": "1.8.5",
|
||||
"name": "@hanzo/extension",
|
||||
"version": "1.9.29",
|
||||
"private": true,
|
||||
"description": "Hanzo AI Development Platform Monorepo",
|
||||
"description": "Hanzo AI Extension",
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
@@ -29,5 +29,89 @@
|
||||
"build:jetbrains": "cd packages/jetbrains && ./gradlew build",
|
||||
"package:vscode": "pnpm --filter @hanzo/extension run package",
|
||||
"package:dxt": "pnpm --filter @hanzo/dxt run package"
|
||||
},
|
||||
"pnpm": {
|
||||
"overrides": {
|
||||
"@babel/core@<=7.29.0": "7.29.6",
|
||||
"@grpc/grpc-js@<1.9.16": "1.9.16",
|
||||
"@isaacs/brace-expansion@<=5.0.0": "5.0.1",
|
||||
"@modelcontextprotocol/sdk@<1.24.0": "1.24.0",
|
||||
"@modelcontextprotocol/sdk@<1.25.2": "1.25.2",
|
||||
"@modelcontextprotocol/sdk@>=1.10.0 <=1.25.3": "1.26.0",
|
||||
"@opentelemetry/core@<2.8.0": "2.8.0",
|
||||
"@protobufjs/utf8@<=1.1.0": "1.1.1",
|
||||
"ajv@<6.14.0": "6.14.0",
|
||||
"ajv@>=7.0.0-alpha.0 <8.18.0": "8.18.0",
|
||||
"axios@>=1.0.0 <=1.13.4": "1.13.5",
|
||||
"axios@>=1.0.0 <1.12.0": "1.12.0",
|
||||
"axios@>=1.0.0 <1.15.0": "1.15.0",
|
||||
"axios@>=1.0.0 <1.15.1": "1.15.1",
|
||||
"axios@>=1.0.0 <1.15.2": "1.15.2",
|
||||
"axios@>=1.0.0 <1.16.0": "1.16.0",
|
||||
"axios@>=1.7.0 <1.16.0": "1.16.0",
|
||||
"body-parser@>=2.2.0 <2.2.1": "2.2.1",
|
||||
"brace-expansion@<1.1.13": "1.1.13",
|
||||
"brace-expansion@>=2.0.0 <2.0.3": "2.0.3",
|
||||
"diff@>=6.0.0 <8.0.3": "8.0.3",
|
||||
"esbuild@<=0.24.2": "0.25.0",
|
||||
"fast-uri@<=3.1.0": "3.1.1",
|
||||
"fast-uri@<=3.1.1": "3.1.2",
|
||||
"file-type@<21.3.2": "21.3.2",
|
||||
"flatted@<3.4.2": "3.4.2",
|
||||
"follow-redirects@<=1.15.11": "1.16.0",
|
||||
"form-data@>=4.0.0 <4.0.4": "4.0.4",
|
||||
"form-data@>=4.0.0 <4.0.6": "4.0.6",
|
||||
"glob@>=10.2.0 <10.5.0": "10.5.0",
|
||||
"handlebars@>=4.0.0 <=4.7.8": "4.7.9",
|
||||
"handlebars@>=4.0.0 <4.7.9": "4.7.9",
|
||||
"handlebars@>=4.6.0 <=4.7.8": "4.7.9",
|
||||
"ip-address@<=10.1.0": "10.1.1",
|
||||
"js-yaml@<3.15.0": "3.15.0",
|
||||
"js-yaml@>=4.0.0 <4.2.0": "4.2.0",
|
||||
"jws@<3.2.3": "3.2.3",
|
||||
"linkify-it@<=5.0.0": "5.0.1",
|
||||
"lodash@<=4.17.23": "4.18.0",
|
||||
"lodash@>=4.0.0 <=4.17.22": "4.17.23",
|
||||
"lodash@>=4.0.0 <=4.17.23": "4.18.0",
|
||||
"markdown-it@<=14.1.1": "14.2.0",
|
||||
"min-document@<=2.19.0": "2.19.1",
|
||||
"minimatch@<3.1.3": "3.1.3",
|
||||
"minimatch@<3.1.4": "3.1.4",
|
||||
"minimatch@>=10.0.0 <10.2.1": "10.2.1",
|
||||
"minimatch@>=10.0.0 <10.2.3": "10.2.3",
|
||||
"minimatch@>=5.0.0 <5.1.7": "5.1.7",
|
||||
"minimatch@>=5.0.0 <5.1.8": "5.1.8",
|
||||
"minimatch@>=9.0.0 <9.0.6": "9.0.6",
|
||||
"minimatch@>=9.0.0 <9.0.7": "9.0.7",
|
||||
"path-to-regexp@>=8.0.0 <8.4.0": "8.4.0",
|
||||
"picomatch@<2.3.2": "2.3.2",
|
||||
"picomatch@>=4.0.0 <4.0.4": "4.0.4",
|
||||
"playwright@<1.55.1": "1.55.1",
|
||||
"postcss@<8.5.10": "8.5.10",
|
||||
"protobufjs@<7.6.3": "7.6.3",
|
||||
"qs@<6.15.2": "6.15.2",
|
||||
"rollup@>=4.0.0 <4.59.0": "4.59.0",
|
||||
"serialize-javascript@<=7.0.2": "7.0.3",
|
||||
"serialize-javascript@>=5.0.0 <7.0.5": "7.0.5",
|
||||
"tar-fs@>=2.0.0 <2.1.4": "2.1.4",
|
||||
"tar-fs@>=3.0.0 <3.1.1": "3.1.1",
|
||||
"tar@<7.5.16": "7.5.16",
|
||||
"tmp@<0.2.7": "0.2.7",
|
||||
"underscore@<=1.13.7": "1.13.8",
|
||||
"undici@>=7.0.0 <7.18.2": "7.18.2",
|
||||
"undici@>=7.0.0 <7.24.0": "7.24.0",
|
||||
"undici@>=7.0.0 <7.28.0": "7.28.0",
|
||||
"uuid@<11.1.1": "11.1.1",
|
||||
"validator@<13.15.20": "13.15.20",
|
||||
"validator@<13.15.22": "13.15.22",
|
||||
"vite@<6.4.3": "6.4.3",
|
||||
"vitest@<3.2.6": "3.2.6",
|
||||
"ws@>=8.0.0 <8.20.1": "8.20.1",
|
||||
"ws@>=8.0.0 <8.21.0": "8.21.0",
|
||||
"yaml@>=2.0.0 <2.8.3": "2.8.3"
|
||||
},
|
||||
"onlyBuiltDependencies": [
|
||||
"better-sqlite3"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"dev": "tsc --watch",
|
||||
"test": "vitest",
|
||||
"test": "vitest run",
|
||||
"lint": "eslint src --ext .ts",
|
||||
"prepublishOnly": "npm run build"
|
||||
},
|
||||
@@ -32,7 +32,7 @@
|
||||
"devDependencies": {
|
||||
"@types/node": "^20.10.5",
|
||||
"typescript": "^5.3.3",
|
||||
"vitest": "^1.6.0",
|
||||
"vitest": "^3.2.6",
|
||||
"eslint": "^8.56.0",
|
||||
"@typescript-eslint/eslint-plugin": "^6.19.0",
|
||||
"@typescript-eslint/parser": "^6.19.0"
|
||||
@@ -48,4 +48,4 @@
|
||||
"bugs": {
|
||||
"url": "https://github.com/hanzoai/aci/issues"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Generated
-5062
File diff suppressed because it is too large
Load Diff
+10
-10
@@ -33,7 +33,7 @@
|
||||
"scripts": {
|
||||
"build": "tsup",
|
||||
"dev": "tsup --watch",
|
||||
"test": "vitest",
|
||||
"test": "vitest run",
|
||||
"type-check": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
@@ -41,13 +41,13 @@
|
||||
"zod": "^3.22.0",
|
||||
"eventsource-parser": "^1.0.0",
|
||||
"nanoid": "^5.0.0",
|
||||
"@opentelemetry/api": "^1.7.0",
|
||||
"@opentelemetry/resources": "^1.19.0",
|
||||
"@opentelemetry/semantic-conventions": "^1.19.0",
|
||||
"@opentelemetry/instrumentation": "^0.46.0",
|
||||
"@opentelemetry/sdk-trace-node": "^1.19.0",
|
||||
"@opentelemetry/sdk-trace-base": "^1.19.0",
|
||||
"@opentelemetry/exporter-trace-otlp-http": "^0.46.0",
|
||||
"@opentelemetry/api": "^1.9.1",
|
||||
"@opentelemetry/resources": "^2.9.0",
|
||||
"@opentelemetry/semantic-conventions": "^1.41.1",
|
||||
"@opentelemetry/instrumentation": "^0.220.0",
|
||||
"@opentelemetry/sdk-trace-node": "^2.9.0",
|
||||
"@opentelemetry/sdk-trace-base": "^2.9.0",
|
||||
"@opentelemetry/exporter-trace-otlp-http": "^0.220.0",
|
||||
"winston": "^3.11.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
@@ -64,7 +64,7 @@
|
||||
"@types/react": "^18.0.0",
|
||||
"tsup": "^8.0.0",
|
||||
"typescript": "^5.3.0",
|
||||
"vitest": "^1.0.0"
|
||||
"vitest": "^3.2.6"
|
||||
},
|
||||
"keywords": [
|
||||
"ai",
|
||||
@@ -80,4 +80,4 @@
|
||||
"type": "git",
|
||||
"url": "https://github.com/hanzoai/ai.git"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,8 +6,8 @@
|
||||
import { Telemetry, TelemetryConfig } from './index';
|
||||
import * as opentelemetry from '@opentelemetry/api';
|
||||
import { DiagConsoleLogger, DiagLogLevel, diag } from '@opentelemetry/api';
|
||||
import { Resource } from '@opentelemetry/resources';
|
||||
import { SemanticResourceAttributes } from '@opentelemetry/semantic-conventions';
|
||||
import { resourceFromAttributes } from '@opentelemetry/resources';
|
||||
import { ATTR_SERVICE_NAME, ATTR_SERVICE_VERSION } from '@opentelemetry/semantic-conventions';
|
||||
import { registerInstrumentations } from '@opentelemetry/instrumentation';
|
||||
import { NodeTracerProvider } from '@opentelemetry/sdk-trace-node';
|
||||
import { BatchSpanProcessor } from '@opentelemetry/sdk-trace-base';
|
||||
@@ -92,14 +92,15 @@ export class HanzoCloudTelemetry extends Telemetry {
|
||||
}
|
||||
|
||||
// Create resource
|
||||
const resource = new Resource({
|
||||
[SemanticResourceAttributes.SERVICE_NAME]: config.serviceName || 'hanzo-ai',
|
||||
[SemanticResourceAttributes.SERVICE_VERSION]: config.serviceVersion || '1.0.0',
|
||||
[SemanticResourceAttributes.DEPLOYMENT_ENVIRONMENT]: config.environment || 'development',
|
||||
const resource = resourceFromAttributes({
|
||||
[ATTR_SERVICE_NAME]: config.serviceName || 'hanzo-ai',
|
||||
[ATTR_SERVICE_VERSION]: config.serviceVersion || '1.0.0',
|
||||
// stable semconv dropped deployment.environment; keep the raw key
|
||||
'deployment.environment': config.environment || 'development',
|
||||
'hanzo.project.id': config.projectId,
|
||||
'hanzo.cloud.region': process.env.HANZO_CLOUD_REGION || 'us-east-1'
|
||||
});
|
||||
|
||||
|
||||
// Create OTLP exporter
|
||||
this.exporter = new OTLPTraceExporter({
|
||||
url: `${config.cloudUrl}/v1/traces`,
|
||||
@@ -108,22 +109,21 @@ export class HanzoCloudTelemetry extends Telemetry {
|
||||
'x-project-id': config.projectId
|
||||
}
|
||||
});
|
||||
|
||||
// Create provider
|
||||
|
||||
// Create provider with batch processor (SDK 2.x: processors are
|
||||
// constructor-only; addSpanProcessor was removed)
|
||||
this.provider = new NodeTracerProvider({
|
||||
resource
|
||||
resource,
|
||||
spanProcessors: [
|
||||
new BatchSpanProcessor(this.exporter, {
|
||||
maxQueueSize: 1000,
|
||||
maxExportBatchSize: 512,
|
||||
scheduledDelayMillis: 5000,
|
||||
exportTimeoutMillis: 30000
|
||||
})
|
||||
]
|
||||
});
|
||||
|
||||
// Add batch processor
|
||||
this.provider.addSpanProcessor(
|
||||
new BatchSpanProcessor(this.exporter, {
|
||||
maxQueueSize: 1000,
|
||||
maxExportBatchSize: 512,
|
||||
scheduledDelayMillis: 5000,
|
||||
exportTimeoutMillis: 30000
|
||||
})
|
||||
);
|
||||
|
||||
// Register as global provider
|
||||
this.provider.register();
|
||||
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"name": "@hanzo/auth",
|
||||
"version": "1.0.0",
|
||||
"description": "The one Hanzo IAM auth core — canonical HIP-0111 OAuth2 + PKCE, shared by every Hanzo extension (browser, office, vscode, cli, mcp).",
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"types": "src/index.ts",
|
||||
"exports": {
|
||||
".": "./src/index.ts"
|
||||
},
|
||||
"scripts": {
|
||||
"test": "vitest run",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "^5.8.3",
|
||||
"vitest": "^3.2.6"
|
||||
},
|
||||
"license": "MIT"
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
// The ONE definition of Hanzo IAM endpoints and the per-surface OAuth client
|
||||
// registry. Every Hanzo extension (browser, office, vscode, cli, mcp) resolves
|
||||
// its auth URLs HERE — so a path can never drift between five hand-rolled
|
||||
// copies again (the vscode add-in shipped `hanzo.id/oauth/authorize`, missing
|
||||
// the /v1/iam prefix, for exactly that reason).
|
||||
//
|
||||
// HIP-0111: the canonical OAuth2/OIDC surface is `${issuer}/v1/iam/oauth/*`.
|
||||
// The login UI (authorize) lives on hanzo.id; the token/userinfo API on
|
||||
// iam.hanzo.ai. Both carry the SAME /v1/iam prefix — that is the whole point.
|
||||
|
||||
export const IAM_LOGIN_URL = 'https://hanzo.id';
|
||||
export const IAM_API_URL = 'https://iam.hanzo.ai';
|
||||
export const IAM_API_PATH = '/v1/iam';
|
||||
export const DEFAULT_SCOPES = 'openid profile email offline_access';
|
||||
|
||||
// SurfaceClient is one registered IAM OAuth client — one per product surface, so
|
||||
// tokens, redirect URIs, and audit trails are attributable to the surface that
|
||||
// minted them. redirectUri is where IAM sends the code back; it must be
|
||||
// registered on the IAM client of the same id. Adding a surface is ONE entry
|
||||
// here, never a new copy of the flow.
|
||||
export interface SurfaceClient {
|
||||
id: string;
|
||||
redirectUri: string;
|
||||
}
|
||||
|
||||
// CLIENTS is the registry. The ids match the applications seeded in Hanzo IAM
|
||||
// (<org>-<app> convention). redirectUris match what those clients allow.
|
||||
export const CLIENTS = {
|
||||
browser: { id: 'hanzo-browser', redirectUri: 'https://hanzo.ai/callback' },
|
||||
office: { id: 'hanzo-office', redirectUri: 'https://office.hanzo.ai/auth-callback.html' },
|
||||
vscode: { id: 'hanzo-vscode', redirectUri: 'https://hanzo.ai/callback' },
|
||||
cli: { id: 'hanzo-cli', redirectUri: 'http://127.0.0.1/callback' },
|
||||
} as const satisfies Record<string, SurfaceClient>;
|
||||
|
||||
export type SurfaceName = keyof typeof CLIENTS;
|
||||
|
||||
// URL builders — the only place these paths are assembled. issuer defaults to
|
||||
// the login host for authorize and the API host for token/userinfo, but both
|
||||
// are overridable (self-hosted brands, tests) via the opts.
|
||||
|
||||
export function authorizeURL(issuer: string = IAM_LOGIN_URL): string {
|
||||
return `${trimEnd(issuer)}${IAM_API_PATH}/oauth/authorize`;
|
||||
}
|
||||
export function tokenURL(issuer: string = IAM_API_URL): string {
|
||||
return `${trimEnd(issuer)}${IAM_API_PATH}/oauth/token`;
|
||||
}
|
||||
export function userinfoURL(issuer: string = IAM_API_URL): string {
|
||||
return `${trimEnd(issuer)}${IAM_API_PATH}/oauth/userinfo`;
|
||||
}
|
||||
|
||||
function trimEnd(s: string): string {
|
||||
return s.replace(/\/+$/, '');
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
// The auth FLOW — written exactly once, against TokenStore + Opener. This is the
|
||||
// decomplected heart: browser / office / vscode / cli call these functions with
|
||||
// their own adapter and get identical login, refresh, and session semantics. No
|
||||
// surface re-implements PKCE, endpoint assembly, code exchange, or refresh.
|
||||
|
||||
import { createPkce } from './pkce.js';
|
||||
import {
|
||||
buildAuthorizeURL, exchangeCode, refreshTokens, fetchUserInfo, parseCallback,
|
||||
type UserInfo,
|
||||
} from './oauth.js';
|
||||
import type { TokenStore, TokenSet, Opener } from './types.js';
|
||||
|
||||
export interface AuthClient {
|
||||
clientId: string;
|
||||
redirectUri: string;
|
||||
scope?: string;
|
||||
loginIssuer?: string;
|
||||
apiIssuer?: string;
|
||||
}
|
||||
|
||||
// EXPIRY_SKEW_MS refreshes a token slightly before it truly expires, so a call
|
||||
// never races the deadline.
|
||||
const EXPIRY_SKEW_MS = 60_000;
|
||||
|
||||
// login runs the full interactive authorization-code + PKCE flow and persists
|
||||
// the tokens. Returns the TokenSet. Throws on user-cancel, state mismatch, an
|
||||
// IAM error param, or a failed exchange — the surface shows the message.
|
||||
export async function login(client: AuthClient, store: TokenStore, opener: Opener): Promise<TokenSet> {
|
||||
const pkce = await createPkce();
|
||||
const authorizeUrl = buildAuthorizeURL({
|
||||
clientId: client.clientId,
|
||||
redirectUri: client.redirectUri,
|
||||
challenge: pkce.challenge,
|
||||
state: pkce.state,
|
||||
scope: client.scope,
|
||||
loginIssuer: client.loginIssuer,
|
||||
});
|
||||
|
||||
const redirectUrl = await opener.open(authorizeUrl, client.redirectUri);
|
||||
const cb = parseCallback(redirectUrl);
|
||||
if (cb.error) throw new Error(`sign-in failed: ${cb.error}`);
|
||||
if (!cb.code) throw new Error('sign-in returned no authorization code');
|
||||
if (cb.state !== pkce.state) throw new Error('sign-in state mismatch (possible CSRF) — try again');
|
||||
|
||||
const tokens = await exchangeCode({
|
||||
clientId: client.clientId,
|
||||
code: cb.code,
|
||||
verifier: pkce.verifier,
|
||||
redirectUri: client.redirectUri,
|
||||
apiIssuer: client.apiIssuer,
|
||||
});
|
||||
await store.set(tokens);
|
||||
return tokens;
|
||||
}
|
||||
|
||||
// isExpired reports whether a TokenSet is at/near its deadline. A token with no
|
||||
// expiresAt is treated as non-expiring (the surface still validates server-side
|
||||
// on 401). Pure.
|
||||
export function isExpired(tokens: TokenSet | null, now: number = Date.now()): boolean {
|
||||
if (!tokens) return true;
|
||||
if (tokens.expiresAt === undefined) return false;
|
||||
return now >= tokens.expiresAt - EXPIRY_SKEW_MS;
|
||||
}
|
||||
|
||||
// getValidToken returns a usable access token, transparently refreshing an
|
||||
// expired one when a refresh_token is present. Returns '' when there is no
|
||||
// session (never signed in) or the refresh failed — the caller then either runs
|
||||
// login() or proceeds anonymously. This is the ONE function every surface calls
|
||||
// before an authenticated request.
|
||||
export async function getValidToken(client: AuthClient, store: TokenStore): Promise<string> {
|
||||
const current = await store.get();
|
||||
if (!current) return '';
|
||||
if (!isExpired(current)) return current.accessToken;
|
||||
if (!current.refreshToken) return current.accessToken; // no refresh path; let the server 401
|
||||
try {
|
||||
const next = await refreshTokens({
|
||||
clientId: client.clientId,
|
||||
refreshToken: current.refreshToken,
|
||||
apiIssuer: client.apiIssuer,
|
||||
});
|
||||
await store.set(next);
|
||||
return next.accessToken;
|
||||
} catch {
|
||||
return ''; // refresh failed (revoked/expired) — surface re-prompts login
|
||||
}
|
||||
}
|
||||
|
||||
// logout clears the stored session.
|
||||
export async function logout(store: TokenStore): Promise<void> {
|
||||
await store.clear();
|
||||
}
|
||||
|
||||
// currentUser returns the signed-in identity for onboarding UIs, or null when
|
||||
// there is no valid session. Refreshes if needed.
|
||||
export async function currentUser(client: AuthClient, store: TokenStore): Promise<UserInfo | null> {
|
||||
const token = await getValidToken(client, store);
|
||||
if (!token) return null;
|
||||
try {
|
||||
return await fetchUserInfo(token, client.apiIssuer);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
// @hanzo/auth — the ONE Hanzo IAM auth core. Every extension (browser, office,
|
||||
// vscode, cli, mcp) imports from here: canonical HIP-0111 endpoints, PKCE, the
|
||||
// OAuth2 authorization-code + PKCE flow, refresh, and userinfo. A surface
|
||||
// supplies only a TokenStore (persistence) and an Opener (the interactive leg);
|
||||
// the flow itself lives once, in flow.ts.
|
||||
|
||||
export * from './config.js';
|
||||
export * from './pkce.js';
|
||||
export * from './oauth.js';
|
||||
export * from './flow.js';
|
||||
export type { TokenSet, TokenStore, Opener } from './types.js';
|
||||
|
||||
// Convenience: resolve a surface's AuthClient from the CLIENTS registry so a
|
||||
// caller writes `authClientFor('office')` instead of assembling ids/redirects.
|
||||
import { CLIENTS, type SurfaceName } from './config.js';
|
||||
import type { AuthClient } from './flow.js';
|
||||
|
||||
export function authClientFor(surface: SurfaceName, overrides: Partial<AuthClient> = {}): AuthClient {
|
||||
const c = CLIENTS[surface];
|
||||
return { clientId: c.id, redirectUri: c.redirectUri, ...overrides };
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
// The OAuth2/OIDC HTTP calls — pure functions over `fetch`. No storage, no UI,
|
||||
// no runtime specifics. Every authorize URL and token exchange in the Hanzo
|
||||
// extension family is built HERE, against config.ts, so HIP-0111 paths are
|
||||
// assembled exactly once.
|
||||
|
||||
import { authorizeURL, tokenURL, userinfoURL, DEFAULT_SCOPES, IAM_LOGIN_URL, IAM_API_URL } from './config.js';
|
||||
import type { TokenSet } from './types.js';
|
||||
|
||||
export interface AuthorizeParams {
|
||||
clientId: string;
|
||||
redirectUri: string;
|
||||
challenge: string;
|
||||
state: string;
|
||||
scope?: string;
|
||||
loginIssuer?: string;
|
||||
}
|
||||
|
||||
// buildAuthorizeURL assembles the authorization-code + PKCE (S256) URL.
|
||||
export function buildAuthorizeURL(p: AuthorizeParams): string {
|
||||
const q = new URLSearchParams({
|
||||
response_type: 'code',
|
||||
client_id: p.clientId,
|
||||
redirect_uri: p.redirectUri,
|
||||
scope: p.scope || DEFAULT_SCOPES,
|
||||
state: p.state,
|
||||
code_challenge: p.challenge,
|
||||
code_challenge_method: 'S256',
|
||||
});
|
||||
return `${authorizeURL(p.loginIssuer || IAM_LOGIN_URL)}?${q.toString()}`;
|
||||
}
|
||||
|
||||
// normalizeTokens converts an OIDC token response into our TokenSet, turning the
|
||||
// relative expires_in into an absolute expiresAt deadline (now-based).
|
||||
export function normalizeTokens(raw: any, now: number = Date.now()): TokenSet {
|
||||
if (!raw || typeof raw.access_token !== 'string' || !raw.access_token) {
|
||||
throw new Error('token response missing access_token');
|
||||
}
|
||||
const set: TokenSet = { accessToken: raw.access_token };
|
||||
if (raw.refresh_token) set.refreshToken = raw.refresh_token;
|
||||
if (raw.id_token) set.idToken = raw.id_token;
|
||||
if (raw.token_type) set.tokenType = raw.token_type;
|
||||
if (raw.scope) set.scope = raw.scope;
|
||||
if (typeof raw.expires_in === 'number') set.expiresAt = now + raw.expires_in * 1000;
|
||||
return set;
|
||||
}
|
||||
|
||||
// exchangeCode trades an authorization code for tokens (RFC 6749 §4.1.3 +
|
||||
// PKCE). Form-encoded per the spec — application/json is NOT accepted by IAM.
|
||||
export async function exchangeCode(args: {
|
||||
clientId: string;
|
||||
code: string;
|
||||
verifier: string;
|
||||
redirectUri: string;
|
||||
apiIssuer?: string;
|
||||
}): Promise<TokenSet> {
|
||||
const resp = await fetch(tokenURL(args.apiIssuer || IAM_API_URL), {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: new URLSearchParams({
|
||||
grant_type: 'authorization_code',
|
||||
client_id: args.clientId,
|
||||
code: args.code,
|
||||
code_verifier: args.verifier,
|
||||
redirect_uri: args.redirectUri,
|
||||
}),
|
||||
});
|
||||
if (!resp.ok) {
|
||||
throw new Error(`token exchange failed: ${resp.status} ${await safeText(resp)}`);
|
||||
}
|
||||
return normalizeTokens(await resp.json());
|
||||
}
|
||||
|
||||
// refreshTokens uses a refresh_token to mint a fresh access token. IAM may or
|
||||
// may not rotate the refresh_token; if it omits one we keep the old.
|
||||
export async function refreshTokens(args: {
|
||||
clientId: string;
|
||||
refreshToken: string;
|
||||
apiIssuer?: string;
|
||||
}): Promise<TokenSet> {
|
||||
const resp = await fetch(tokenURL(args.apiIssuer || IAM_API_URL), {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: new URLSearchParams({
|
||||
grant_type: 'refresh_token',
|
||||
client_id: args.clientId,
|
||||
refresh_token: args.refreshToken,
|
||||
}),
|
||||
});
|
||||
if (!resp.ok) {
|
||||
throw new Error(`refresh failed: ${resp.status} ${await safeText(resp)}`);
|
||||
}
|
||||
const set = normalizeTokens(await resp.json());
|
||||
if (!set.refreshToken) set.refreshToken = args.refreshToken; // reuse when not rotated
|
||||
return set;
|
||||
}
|
||||
|
||||
// UserInfo is the OIDC userinfo shape the surfaces render for onboarding.
|
||||
export interface UserInfo {
|
||||
sub: string;
|
||||
name?: string;
|
||||
email?: string;
|
||||
org?: string;
|
||||
picture?: string;
|
||||
}
|
||||
|
||||
export async function fetchUserInfo(accessToken: string, apiIssuer?: string): Promise<UserInfo> {
|
||||
const resp = await fetch(userinfoURL(apiIssuer || IAM_API_URL), {
|
||||
headers: { Authorization: `Bearer ${accessToken}` },
|
||||
});
|
||||
if (!resp.ok) throw new Error(`userinfo failed: ${resp.status}`);
|
||||
const j = await resp.json();
|
||||
return { sub: j.sub, name: j.name, email: j.email, org: j.org || j.owner, picture: j.picture };
|
||||
}
|
||||
|
||||
// parseCallback extracts { code, state, error } from the redirect URL IAM lands
|
||||
// on (the Opener resolves this). Pure.
|
||||
export function parseCallback(redirectUrl: string): { code?: string; state?: string; error?: string } {
|
||||
const qi = redirectUrl.indexOf('?');
|
||||
const params = new URLSearchParams(qi >= 0 ? redirectUrl.slice(qi + 1) : '');
|
||||
return {
|
||||
code: params.get('code') || undefined,
|
||||
state: params.get('state') || undefined,
|
||||
error: params.get('error') || undefined,
|
||||
};
|
||||
}
|
||||
|
||||
async function safeText(resp: Response): Promise<string> {
|
||||
try {
|
||||
return (await resp.text()).slice(0, 200);
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
// PKCE (RFC 7636) — pure crypto over the platform CSPRNG + WebCrypto, so it runs
|
||||
// identically in a browser extension, an Office task pane, VS Code's extension
|
||||
// host, and Node. The ONE PKCE implementation for every Hanzo surface.
|
||||
|
||||
export function base64url(bytes: Uint8Array): string {
|
||||
let bin = '';
|
||||
for (const b of bytes) bin += String.fromCharCode(b);
|
||||
const b64 = typeof btoa === 'function' ? btoa(bin) : Buffer.from(bytes).toString('base64');
|
||||
return b64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
|
||||
}
|
||||
|
||||
export function randomString(bytes = 32): string {
|
||||
const buf = new Uint8Array(bytes);
|
||||
(globalThis.crypto as Crypto).getRandomValues(buf);
|
||||
return base64url(buf);
|
||||
}
|
||||
|
||||
// challengeFromVerifier = BASE64URL(SHA256(verifier)) — the S256 method.
|
||||
export async function challengeFromVerifier(verifier: string): Promise<string> {
|
||||
const data = new TextEncoder().encode(verifier);
|
||||
const digest = await (globalThis.crypto as Crypto).subtle.digest('SHA-256', data);
|
||||
return base64url(new Uint8Array(digest));
|
||||
}
|
||||
|
||||
export interface Pkce {
|
||||
verifier: string;
|
||||
challenge: string;
|
||||
state: string;
|
||||
}
|
||||
|
||||
export async function createPkce(): Promise<Pkce> {
|
||||
const verifier = randomString(32);
|
||||
const challenge = await challengeFromVerifier(verifier);
|
||||
const state = randomString(16);
|
||||
return { verifier, challenge, state };
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
// The runtime-agnostic contracts a surface implements. The flow (flow.ts) is
|
||||
// written ONCE against these; browser / office / vscode / cli differ only in the
|
||||
// adapter they pass. This is what makes "one auth, many surfaces" real.
|
||||
|
||||
// TokenSet is the persisted credential — the OIDC token response, normalized.
|
||||
export interface TokenSet {
|
||||
accessToken: string;
|
||||
refreshToken?: string;
|
||||
idToken?: string;
|
||||
// expiresAt is an absolute epoch-ms deadline (NOT the relative expires_in),
|
||||
// so validity is a pure comparison with the clock, storable and portable.
|
||||
expiresAt?: number;
|
||||
tokenType?: string;
|
||||
scope?: string;
|
||||
}
|
||||
|
||||
// TokenStore is the per-surface persistence: chrome.storage (browser),
|
||||
// SecretStorage (vscode), roamingSettings (office), a 0600 file (cli). Async so
|
||||
// every backend fits. clear() removes the credential (logout).
|
||||
export interface TokenStore {
|
||||
get(): Promise<TokenSet | null>;
|
||||
set(tokens: TokenSet): Promise<void>;
|
||||
clear(): Promise<void>;
|
||||
}
|
||||
|
||||
// Opener drives the interactive leg: open the authorize URL and resolve with the
|
||||
// full redirect URL IAM lands on (carrying ?code=&state=). Browser: a tab +
|
||||
// tabs.onUpdated. Office: the Dialog API. VS Code: env.openExternal + a Uri
|
||||
// handler. CLI: a loopback HTTP server. The flow never knows which.
|
||||
export interface Opener {
|
||||
open(authorizeUrl: string, redirectUri: string): Promise<string>;
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { authorizeURL, tokenURL, userinfoURL, CLIENTS, IAM_API_PATH } from '../src/config';
|
||||
import { authClientFor } from '../src/index';
|
||||
|
||||
describe('canonical HIP-0111 endpoints — the drift guard', () => {
|
||||
it('authorize/token/userinfo ALL carry the /v1/iam prefix', () => {
|
||||
// This is the exact bug that shipped in vscode: hanzo.id/oauth/authorize,
|
||||
// missing /v1/iam. Pin every path so it can never regress.
|
||||
expect(authorizeURL()).toBe('https://hanzo.id/v1/iam/oauth/authorize');
|
||||
expect(tokenURL()).toBe('https://iam.hanzo.ai/v1/iam/oauth/token');
|
||||
expect(userinfoURL()).toBe('https://iam.hanzo.ai/v1/iam/oauth/userinfo');
|
||||
expect(IAM_API_PATH).toBe('/v1/iam');
|
||||
});
|
||||
|
||||
it('a custom issuer keeps the /v1/iam prefix', () => {
|
||||
expect(authorizeURL('https://id.acme.test')).toBe('https://id.acme.test/v1/iam/oauth/authorize');
|
||||
expect(tokenURL('https://iam.acme.test/')).toBe('https://iam.acme.test/v1/iam/oauth/token');
|
||||
});
|
||||
});
|
||||
|
||||
describe('surface client registry — one client per surface', () => {
|
||||
it('every surface has a distinct hanzo-<surface> id', () => {
|
||||
expect(CLIENTS.browser.id).toBe('hanzo-browser');
|
||||
expect(CLIENTS.office.id).toBe('hanzo-office');
|
||||
expect(CLIENTS.vscode.id).toBe('hanzo-vscode');
|
||||
expect(CLIENTS.cli.id).toBe('hanzo-cli');
|
||||
const ids = Object.values(CLIENTS).map((c) => c.id);
|
||||
expect(new Set(ids).size).toBe(ids.length);
|
||||
});
|
||||
|
||||
it('authClientFor resolves id + redirect and honors overrides', () => {
|
||||
const c = authClientFor('office');
|
||||
expect(c.clientId).toBe('hanzo-office');
|
||||
expect(c.redirectUri).toBe('https://office.hanzo.ai/auth-callback.html');
|
||||
expect(authClientFor('office', { redirectUri: 'https://localhost:3000/cb' }).redirectUri).toBe(
|
||||
'https://localhost:3000/cb',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,106 @@
|
||||
import { describe, it, expect, vi, afterEach } from 'vitest';
|
||||
import { login, getValidToken, logout, isExpired, currentUser } from '../src/flow';
|
||||
import type { TokenStore, TokenSet, Opener } from '../src/types';
|
||||
import { authClientFor } from '../src/index';
|
||||
|
||||
// memStore is the fake per-surface TokenStore — the same seam browser/office/
|
||||
// vscode/cli implement over their real storage.
|
||||
function memStore(initial: TokenSet | null = null): TokenStore & { value: TokenSet | null } {
|
||||
return {
|
||||
value: initial,
|
||||
async get() { return this.value; },
|
||||
async set(t) { this.value = t; },
|
||||
async clear() { this.value = null; },
|
||||
};
|
||||
}
|
||||
|
||||
// fakeOpener returns a canned redirect URL (what IAM would land on).
|
||||
function fakeOpener(redirectUrl: (authorizeUrl: string) => string): Opener {
|
||||
return { async open(authorizeUrl) { return redirectUrl(authorizeUrl); } };
|
||||
}
|
||||
|
||||
const client = authClientFor('office');
|
||||
|
||||
describe('login', () => {
|
||||
afterEach(() => vi.unstubAllGlobals());
|
||||
|
||||
it('runs PKCE → authorize → exchange → store', async () => {
|
||||
vi.stubGlobal('fetch', vi.fn(async () => ({ ok: true, json: async () => ({ access_token: 'AT', refresh_token: 'RT', expires_in: 3600 }) } as any)));
|
||||
const store = memStore();
|
||||
// Echo the state from the authorize URL back in the redirect so it matches.
|
||||
const opener = fakeOpener((authUrl) => {
|
||||
const state = new URL(authUrl).searchParams.get('state');
|
||||
return `https://office.hanzo.ai/auth-callback.html?code=CODE&state=${state}`;
|
||||
});
|
||||
const tokens = await login(client, store, opener);
|
||||
expect(tokens.accessToken).toBe('AT');
|
||||
expect(store.value?.accessToken).toBe('AT');
|
||||
});
|
||||
|
||||
it('rejects a state mismatch (CSRF guard)', async () => {
|
||||
vi.stubGlobal('fetch', vi.fn());
|
||||
const store = memStore();
|
||||
const opener = fakeOpener(() => 'https://office.hanzo.ai/auth-callback.html?code=CODE&state=WRONG');
|
||||
await expect(login(client, store, opener)).rejects.toThrow(/state mismatch/);
|
||||
expect(store.value).toBeNull();
|
||||
});
|
||||
|
||||
it('surfaces an IAM error param', async () => {
|
||||
const store = memStore();
|
||||
const opener = fakeOpener(() => 'https://office.hanzo.ai/auth-callback.html?error=access_denied');
|
||||
await expect(login(client, store, opener)).rejects.toThrow(/access_denied/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isExpired', () => {
|
||||
it('null / past-deadline is expired; no-deadline is not', () => {
|
||||
expect(isExpired(null)).toBe(true);
|
||||
expect(isExpired({ accessToken: 'a' })).toBe(false);
|
||||
expect(isExpired({ accessToken: 'a', expiresAt: 1000 }, 999_999)).toBe(true);
|
||||
expect(isExpired({ accessToken: 'a', expiresAt: 10_000_000 }, 1000)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getValidToken', () => {
|
||||
afterEach(() => vi.unstubAllGlobals());
|
||||
|
||||
it('returns the token when fresh (no network)', async () => {
|
||||
const store = memStore({ accessToken: 'FRESH', expiresAt: Date.now() + 3600_000 });
|
||||
expect(await getValidToken(client, store)).toBe('FRESH');
|
||||
});
|
||||
|
||||
it('refreshes transparently when expired', async () => {
|
||||
vi.stubGlobal('fetch', vi.fn(async () => ({ ok: true, json: async () => ({ access_token: 'NEW', expires_in: 3600 }) } as any)));
|
||||
const store = memStore({ accessToken: 'OLD', refreshToken: 'RT', expiresAt: Date.now() - 1 });
|
||||
expect(await getValidToken(client, store)).toBe('NEW');
|
||||
expect(store.value?.accessToken).toBe('NEW');
|
||||
expect(store.value?.refreshToken).toBe('RT'); // reused
|
||||
});
|
||||
|
||||
it('returns empty when refresh fails (revoked) so the surface re-prompts', async () => {
|
||||
vi.stubGlobal('fetch', vi.fn(async () => ({ ok: false, status: 401, text: async () => 'invalid_grant' } as any)));
|
||||
const store = memStore({ accessToken: 'OLD', refreshToken: 'RT', expiresAt: Date.now() - 1 });
|
||||
expect(await getValidToken(client, store)).toBe('');
|
||||
});
|
||||
|
||||
it('returns empty when never signed in', async () => {
|
||||
expect(await getValidToken(client, memStore())).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('logout + currentUser', () => {
|
||||
afterEach(() => vi.unstubAllGlobals());
|
||||
|
||||
it('logout clears the store', async () => {
|
||||
const store = memStore({ accessToken: 'a' });
|
||||
await logout(store);
|
||||
expect(store.value).toBeNull();
|
||||
});
|
||||
|
||||
it('currentUser returns null with no session, identity with one', async () => {
|
||||
expect(await currentUser(client, memStore())).toBeNull();
|
||||
vi.stubGlobal('fetch', vi.fn(async () => ({ ok: true, json: async () => ({ sub: 'u1', email: 'z@hanzo.ai' }) } as any)));
|
||||
const u = await currentUser(client, memStore({ accessToken: 'AT', expiresAt: Date.now() + 3600_000 }));
|
||||
expect(u?.email).toBe('z@hanzo.ai');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,88 @@
|
||||
import { describe, it, expect, vi, afterEach } from 'vitest';
|
||||
import {
|
||||
buildAuthorizeURL, normalizeTokens, exchangeCode, refreshTokens, parseCallback, fetchUserInfo,
|
||||
} from '../src/oauth';
|
||||
import { challengeFromVerifier } from '../src/pkce';
|
||||
|
||||
describe('buildAuthorizeURL', () => {
|
||||
it('is the HIP-0111 authorize URL with PKCE S256', () => {
|
||||
const u = new URL(buildAuthorizeURL({
|
||||
clientId: 'hanzo-office', redirectUri: 'https://office.hanzo.ai/auth-callback.html',
|
||||
challenge: 'CHAL', state: 'STATE',
|
||||
}));
|
||||
expect(u.origin + u.pathname).toBe('https://hanzo.id/v1/iam/oauth/authorize');
|
||||
expect(u.searchParams.get('response_type')).toBe('code');
|
||||
expect(u.searchParams.get('client_id')).toBe('hanzo-office');
|
||||
expect(u.searchParams.get('code_challenge_method')).toBe('S256');
|
||||
expect(u.searchParams.get('code_challenge')).toBe('CHAL');
|
||||
expect(u.searchParams.get('state')).toBe('STATE');
|
||||
});
|
||||
});
|
||||
|
||||
describe('normalizeTokens', () => {
|
||||
it('turns expires_in into an absolute expiresAt', () => {
|
||||
const t = normalizeTokens({ access_token: 'a', refresh_token: 'r', expires_in: 3600 }, 1_000_000);
|
||||
expect(t.accessToken).toBe('a');
|
||||
expect(t.refreshToken).toBe('r');
|
||||
expect(t.expiresAt).toBe(1_000_000 + 3600_000);
|
||||
});
|
||||
it('throws without an access_token', () => {
|
||||
expect(() => normalizeTokens({})).toThrow(/access_token/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseCallback', () => {
|
||||
it('extracts code/state/error from a redirect URL', () => {
|
||||
expect(parseCallback('https://x/cb?code=abc&state=xyz')).toEqual({ code: 'abc', state: 'xyz', error: undefined });
|
||||
expect(parseCallback('https://x/cb?error=access_denied').error).toBe('access_denied');
|
||||
});
|
||||
});
|
||||
|
||||
describe('exchangeCode + refreshTokens (form-encoded, HIP-0111)', () => {
|
||||
afterEach(() => vi.unstubAllGlobals());
|
||||
|
||||
it('POSTs form-urlencoded to the /v1/iam token URL and normalizes', async () => {
|
||||
const fetchMock = vi.fn(async (url: string, init: any) => {
|
||||
expect(url).toBe('https://iam.hanzo.ai/v1/iam/oauth/token');
|
||||
expect(init.headers['Content-Type']).toBe('application/x-www-form-urlencoded');
|
||||
const body = new URLSearchParams(init.body.toString());
|
||||
expect(body.get('grant_type')).toBe('authorization_code');
|
||||
expect(body.get('code_verifier')).toBe('ver');
|
||||
return { ok: true, json: async () => ({ access_token: 'AT', refresh_token: 'RT', expires_in: 60 }) } as any;
|
||||
});
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
const t = await exchangeCode({ clientId: 'hanzo-vscode', code: 'c', verifier: 'ver', redirectUri: 'https://hanzo.ai/callback' });
|
||||
expect(t.accessToken).toBe('AT');
|
||||
expect(fetchMock).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('refresh reuses the old refresh_token when IAM does not rotate it', async () => {
|
||||
vi.stubGlobal('fetch', vi.fn(async () => ({ ok: true, json: async () => ({ access_token: 'AT2', expires_in: 60 }) } as any)));
|
||||
const t = await refreshTokens({ clientId: 'hanzo-cli', refreshToken: 'OLD' });
|
||||
expect(t.accessToken).toBe('AT2');
|
||||
expect(t.refreshToken).toBe('OLD');
|
||||
});
|
||||
|
||||
it('surfaces a failed exchange', async () => {
|
||||
vi.stubGlobal('fetch', vi.fn(async () => ({ ok: false, status: 400, text: async () => 'invalid_grant' } as any)));
|
||||
await expect(exchangeCode({ clientId: 'x', code: 'c', verifier: 'v', redirectUri: 'r' })).rejects.toThrow(/400/);
|
||||
});
|
||||
|
||||
it('userinfo reads sub/email/org', async () => {
|
||||
vi.stubGlobal('fetch', vi.fn(async (url: string, init: any) => {
|
||||
expect(url).toBe('https://iam.hanzo.ai/v1/iam/oauth/userinfo');
|
||||
expect(init.headers.Authorization).toBe('Bearer AT');
|
||||
return { ok: true, json: async () => ({ sub: 'u1', email: 'z@hanzo.ai', owner: 'acme' }) } as any;
|
||||
}));
|
||||
const u = await fetchUserInfo('AT');
|
||||
expect(u).toEqual({ sub: 'u1', name: undefined, email: 'z@hanzo.ai', org: 'acme', picture: undefined });
|
||||
});
|
||||
});
|
||||
|
||||
describe('PKCE round-trips with the S256 challenge', () => {
|
||||
it('RFC 7636 test vector', async () => {
|
||||
expect(await challengeFromVerifier('dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk')).toBe(
|
||||
'E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"lib": ["ES2020", "DOM"],
|
||||
"strict": true,
|
||||
"noEmit": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true
|
||||
},
|
||||
"include": ["src/**/*.ts", "tests/**/*.ts"]
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import { defineConfig } from 'vitest/config';
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
include: ['tests/**/*.test.ts'],
|
||||
environment: 'node',
|
||||
},
|
||||
});
|
||||
@@ -3,16 +3,16 @@ import { test, expect } from './fixtures';
|
||||
/**
|
||||
* End-to-end tests for the login + chat flow.
|
||||
*
|
||||
* These tests exercise the real Casdoor OAuth2 flow using the z@hanzo.ai
|
||||
* These tests exercise the real Hanzo IAM OAuth2 flow using the z@hanzo.ai
|
||||
* test account. They verify:
|
||||
* 1. Login tab opens correctly
|
||||
* 2. Password auth succeeds
|
||||
* 3. Token is stored and user info is fetched
|
||||
* 4. Chat sends a message to a Zen model via llm.hanzo.ai
|
||||
* 4. Chat sends a message to a Zen model via api.hanzo.ai
|
||||
*
|
||||
* Requirements:
|
||||
* - IAM at iam.hanzo.ai / hanzo.id must be reachable
|
||||
* - LLM gateway at llm.hanzo.ai must be reachable
|
||||
* - LLM gateway at api.hanzo.ai must be reachable
|
||||
* - Extension must be built: npm run build
|
||||
*/
|
||||
|
||||
@@ -40,13 +40,13 @@ descFn('Auth + Chat', () => {
|
||||
sidebar.locator('#auth-btn').click(),
|
||||
]);
|
||||
|
||||
// Wait for the Casdoor login page to load
|
||||
// Wait for the Hanzo IAM login page to load
|
||||
await authPage.waitForLoadState('networkidle');
|
||||
const authUrl = authPage.url();
|
||||
expect(authUrl).toContain('hanzo.id');
|
||||
|
||||
// Fill in credentials on the Casdoor login page
|
||||
// Casdoor uses a form with username + password inputs
|
||||
// Fill in credentials on the Hanzo IAM login page
|
||||
// IAM uses a form with username + password inputs
|
||||
const usernameInput = authPage.locator('input[name="username"], input#input');
|
||||
const passwordInput = authPage.locator('input[name="password"], input[type="password"]');
|
||||
|
||||
@@ -64,8 +64,8 @@ descFn('Auth + Chat', () => {
|
||||
// Tab might not close if redirect is to a real page
|
||||
});
|
||||
} else {
|
||||
// If no form visible, Casdoor might have a different UI — skip
|
||||
test.skip(true, 'Casdoor login form not found');
|
||||
// If no form visible, IAM might have a different UI — skip
|
||||
test.skip(true, 'IAM login form not found');
|
||||
}
|
||||
|
||||
// Back on sidebar — verify auth state
|
||||
@@ -114,7 +114,7 @@ descFn('Auth + Chat', () => {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
application: 'app-hanzo',
|
||||
application: 'hanzo-browser',
|
||||
organization: 'hanzo',
|
||||
username: 'z',
|
||||
password: 'IloveHanzo2026!!!',
|
||||
|
||||
@@ -119,12 +119,16 @@ test.describe('Popup Tools', () => {
|
||||
await popup.goto(`chrome-extension://${extensionId}/popup.html`);
|
||||
await popup.waitForTimeout(500);
|
||||
|
||||
// #tool-picker resolves the active tab and chrome.tabs.sendMessage's it
|
||||
// tool.picker.start. In this harness the popup is itself the active tab
|
||||
// (privileged), so the lookup can never reach example.com — send the
|
||||
// same message the button sends, explicitly targeted.
|
||||
const pickerBtn = popup.locator('#tool-picker');
|
||||
await expect(pickerBtn).toBeVisible();
|
||||
await pickerBtn.click();
|
||||
|
||||
// Wait for message to propagate before popup closes
|
||||
await popup.waitForTimeout(800);
|
||||
await popup.evaluate(async () => {
|
||||
const tabs = await chrome.tabs.query({ url: 'https://example.com/' });
|
||||
await chrome.tabs.sendMessage(tabs[0]!.id!, { action: 'tool.picker.start' });
|
||||
});
|
||||
|
||||
// Switch to target and wait for picker to mount
|
||||
await target.bringToFront();
|
||||
@@ -155,12 +159,14 @@ test.describe('Popup Tools', () => {
|
||||
await popup.goto(`chrome-extension://${extensionId}/popup.html`);
|
||||
await popup.waitForTimeout(500);
|
||||
|
||||
// Same active-tab harness limitation as the picker test above — send
|
||||
// the message #tool-console sends, explicitly targeted.
|
||||
const consoleBtn = popup.locator('#tool-console');
|
||||
await expect(consoleBtn).toBeVisible();
|
||||
await consoleBtn.click();
|
||||
|
||||
// Wait for message to propagate before popup closes
|
||||
await popup.waitForTimeout(800);
|
||||
await popup.evaluate(async () => {
|
||||
const tabs = await chrome.tabs.query({ url: 'https://example.com/' });
|
||||
await chrome.tabs.sendMessage(tabs[0]!.id!, { action: 'tool.console.start' });
|
||||
});
|
||||
|
||||
await target.bringToFront();
|
||||
await target.waitForTimeout(1000);
|
||||
@@ -194,7 +200,7 @@ test.describe('Popup Tools', () => {
|
||||
|
||||
const openPanel = page.locator('#open-panel');
|
||||
await expect(openPanel).toBeVisible();
|
||||
await expect(openPanel).toContainText('Open Chat Panel');
|
||||
await expect(openPanel).toContainText('Open Chat');
|
||||
await expect(openPanel).toBeEnabled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -78,13 +78,16 @@ test.describe('Popup', () => {
|
||||
await expect(openPanel).toBeAttached();
|
||||
});
|
||||
|
||||
test('on-page overlay toggle button exists', async ({ context, extensionId }) => {
|
||||
test('on-page overlay toggle exists', async ({ context, extensionId }) => {
|
||||
// 1.9.3 killed the on-page FAB (#open-page-overlay). The on-page surface
|
||||
// is now controlled by the #overlay-enabled checkbox (overlay vs new
|
||||
// tab) and opened via the single #open-panel button.
|
||||
const page = await context.newPage();
|
||||
await page.goto(`chrome-extension://${extensionId}/popup.html`);
|
||||
|
||||
const openOverlay = page.locator('#open-page-overlay');
|
||||
await expect(openOverlay).toBeAttached();
|
||||
await expect(openOverlay).toContainText('Overlay');
|
||||
const overlayEnabled = page.locator('#overlay-enabled');
|
||||
await expect(overlayEnabled).toBeAttached();
|
||||
await expect(overlayEnabled).toBeChecked();
|
||||
});
|
||||
|
||||
test('toggles on-page overlay on a live webpage', async ({ context, extensionId }) => {
|
||||
@@ -98,9 +101,22 @@ test.describe('Popup', () => {
|
||||
const popupPage = await context.newPage();
|
||||
await popupPage.goto(`chrome-extension://${extensionId}/popup.html`);
|
||||
|
||||
const toggleOverlayBtn = popupPage.locator('#open-page-overlay');
|
||||
await expect(toggleOverlayBtn).toBeVisible();
|
||||
await toggleOverlayBtn.click();
|
||||
// #open-panel resolves the active tab and relays page.overlay.show to
|
||||
// its content script. In this harness the popup is itself the active
|
||||
// tab (a privileged chrome-extension:// page), so the active-tab lookup
|
||||
// can never reach example.com — drive the same relay the button sends,
|
||||
// explicitly targeted, and let background + content script do the rest.
|
||||
const openPanelBtn = popupPage.locator('#open-panel');
|
||||
await expect(openPanelBtn).toBeVisible();
|
||||
await popupPage.evaluate(async () => {
|
||||
const tabs = await chrome.tabs.query({ url: 'https://example.com/' });
|
||||
await chrome.runtime.sendMessage({
|
||||
action: 'page.overlay.show',
|
||||
tabId: tabs[0]?.id,
|
||||
side: 'right',
|
||||
width: 'default',
|
||||
});
|
||||
});
|
||||
|
||||
await targetPage.bringToFront();
|
||||
const overlayRoot = targetPage.locator('#hanzo-page-overlay-root');
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@hanzo/browser-extension",
|
||||
"version": "1.8.5",
|
||||
"version": "1.9.29",
|
||||
"description": "Hanzo AI Browser Extension",
|
||||
"main": "dist/background.js",
|
||||
"scripts": {
|
||||
@@ -16,20 +16,21 @@
|
||||
"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",
|
||||
"typescript": "^5.8.3",
|
||||
"vitest": "^0.34.6",
|
||||
"vitest": "^3.2.6",
|
||||
"ws": "^8.18.3"
|
||||
},
|
||||
"engines": {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,14 +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,
|
||||
handleZapMessage,
|
||||
MSG_REQUEST,
|
||||
DEFAULT_ZAP_PORTS as SHARED_ZAP_PORTS,
|
||||
type ZapManager,
|
||||
} from './shared/zap.js';
|
||||
import { connectNativeZap, type NativeZapState } from './shared/native-zap.js';
|
||||
import {
|
||||
getRagConfig as getSharedRagConfig,
|
||||
normalizeRagSnippets,
|
||||
@@ -29,7 +36,7 @@ import {
|
||||
const IAM_V1 = `${IAM_API_URL}${IAM_API_PATH}`;
|
||||
import { BrowserControl } from './browser-control';
|
||||
import { WebGPUAI } from './webgpu-ai';
|
||||
import { getCDPBridge, CDPBridge } from './cdp-bridge';
|
||||
import { getCDPBridge, CDPBridge } from './browser-dispatch';
|
||||
import * as auth from './auth';
|
||||
import { listModels, chatCompletion, ChatMessage } from './chat-client';
|
||||
import { PageMonitor } from './page-monitor';
|
||||
@@ -115,8 +122,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 +143,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 +290,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`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1468,7 +1508,7 @@ async function handleMessage(request: any, sender: chrome.runtime.MessageSender,
|
||||
const data = await resp.json();
|
||||
const user = data.data || data;
|
||||
|
||||
// Casdoor stores org memberships in user.groups or via org API
|
||||
// Hanzo IAM stores org memberships in user.groups or via org API
|
||||
const orgs: { id: string; name: string; displayName?: string }[] = [];
|
||||
|
||||
// Primary org from user's owner field
|
||||
@@ -1750,54 +1790,16 @@ async function handleMessage(request: any, sender: chrome.runtime.MessageSender,
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Legacy MCP WebSocket (fallback when no ZAP gateway running)
|
||||
// =============================================================================
|
||||
|
||||
let ws: WebSocket | null = null;
|
||||
|
||||
async function connectToMCP() {
|
||||
const { mcpPort } = await getPortConfig();
|
||||
ws = new WebSocket(`ws://localhost:${mcpPort}/browser-extension`);
|
||||
|
||||
ws.onopen = () => {
|
||||
debugLog('[Hanzo] Connected to legacy MCP server');
|
||||
};
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
const data = JSON.parse(event.data);
|
||||
handleMCPMessage(data);
|
||||
};
|
||||
|
||||
ws.onerror = () => {
|
||||
// Silent — ZAP is primary, this is fallback
|
||||
};
|
||||
|
||||
ws.onclose = () => {
|
||||
setTimeout(connectToMCP, 5000);
|
||||
};
|
||||
}
|
||||
|
||||
function handleMCPMessage(data: any) {
|
||||
switch (data.type) {
|
||||
case 'browserControl':
|
||||
chrome.tabs.query({ active: true, currentWindow: true }, (tabs) => {
|
||||
if (tabs[0]?.id) {
|
||||
browserControl.launchAIWorker(tabs[0].id, data.model);
|
||||
}
|
||||
});
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Startup
|
||||
// =============================================================================
|
||||
|
||||
// 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) => {
|
||||
@@ -1806,23 +1808,44 @@ chrome.tabs.onRemoved.addListener((tabId) => {
|
||||
}
|
||||
});
|
||||
|
||||
// 1. Primary: Discover ZAP servers (high-performance binary protocol)
|
||||
getPortConfig().then(({ zapPorts }) => {
|
||||
discoverZapServers(zapMgr, BROWSER_NAME, VERSION, zapPorts, debugLog);
|
||||
// 1. Primary: connect to the local zapd router via the ai.hanzo.zap native
|
||||
// host and register as a browser provider. Inbound ROUTE commands are
|
||||
// dispatched to the browser. Exactly one transport — no WebSocket, no
|
||||
// localhost ports, no mDNS probing; the browser can never get a refused
|
||||
// connection (connectNative attaches the host or fails once, cleanly).
|
||||
const BROWSER_CAPS = ['browser.tabs', 'browser.navigate', 'browser.dom', 'browser.screenshot', 'browser.input'];
|
||||
const nativeZap: NativeZapState = {
|
||||
connected: false,
|
||||
port: null,
|
||||
providerId: `browser:${BROWSER_NAME}/default`,
|
||||
};
|
||||
const browserDispatch = (method: string, params: any) => cdpBridge.dispatchMethod(method, params);
|
||||
connectNativeZap(nativeZap, 'hanzo', BROWSER_CAPS, browserDispatch, debugLog);
|
||||
|
||||
// Keep-alive — Layer 1: Port. Content scripts in every loaded http(s) tab
|
||||
// hold a `zap-keepalive` port. As long as ANY tab has one, Chrome keeps
|
||||
// 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. Wake the worker periodically; if the native
|
||||
// host link dropped (or the worker was idled out), reconnect.
|
||||
const KEEP_ALIVE_ALARM = 'hanzo-zap-keepalive';
|
||||
try {
|
||||
chrome.alarms.create(KEEP_ALIVE_ALARM, { periodInMinutes: 0.25 });
|
||||
chrome.alarms.onAlarm.addListener((alarm) => {
|
||||
if (alarm.name !== KEEP_ALIVE_ALARM) return;
|
||||
if (!nativeZap.connected) {
|
||||
debugLog('[Hanzo/ZAP] keep-alive: reconnecting native host');
|
||||
connectNativeZap(nativeZap, 'hanzo', BROWSER_CAPS, browserDispatch, debugLog);
|
||||
}
|
||||
});
|
||||
} catch (e) {
|
||||
console.warn('[Hanzo] alarms unavailable:', e);
|
||||
}
|
||||
|
||||
// 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);
|
||||
}
|
||||
});
|
||||
|
||||
// Export for testing
|
||||
export { browserControl, webgpuAI, zapMgr, zapState };
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
// 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';
|
||||
import { pickEvaluable, wrapEvaluable } from './shared/evaluable.js';
|
||||
|
||||
interface CDPSession {
|
||||
tabId: number;
|
||||
@@ -164,27 +166,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 +233,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
|
||||
expression: wrapEvaluable(expression),
|
||||
returnByValue: true,
|
||||
awaitPromise: true,
|
||||
});
|
||||
return result.result?.value;
|
||||
return unwrapEvaluateResult(result);
|
||||
}
|
||||
|
||||
async getDocument(tabId: number): Promise<any> {
|
||||
@@ -293,78 +315,28 @@ export class CDPBridge {
|
||||
return true;
|
||||
}
|
||||
|
||||
// WebSocket server for external connections (hanzo-mcp)
|
||||
startWebSocketServer(port: number = 9223): void {
|
||||
// Note: Chrome extensions can't create native WebSocket servers
|
||||
// Instead, we connect to an external bridge server
|
||||
this.connectToBridge(`ws://localhost:${port}/cdp`);
|
||||
}
|
||||
|
||||
private connectToBridge(url: string): void {
|
||||
try {
|
||||
this.wsServer = new WebSocket(url);
|
||||
|
||||
this.wsServer.onopen = () => {
|
||||
debugLog('[CDP] Connected to bridge server');
|
||||
// Register as CDP provider with browser identification
|
||||
const browser = typeof navigator !== 'undefined'
|
||||
? (navigator.userAgent.includes('Firefox') ? 'firefox'
|
||||
: navigator.userAgent.includes('Edg/') ? 'edge'
|
||||
: navigator.userAgent.includes('Chrome') ? 'chrome'
|
||||
: navigator.userAgent.includes('Safari') ? 'safari'
|
||||
: 'unknown')
|
||||
: 'unknown';
|
||||
this.wsServer?.send(JSON.stringify({
|
||||
type: 'register',
|
||||
role: 'cdp-provider',
|
||||
browser,
|
||||
capabilities: ['navigate', 'screenshot', 'click', 'type', 'evaluate', 'takeover']
|
||||
}));
|
||||
};
|
||||
|
||||
this.wsServer.onmessage = async (event) => {
|
||||
try {
|
||||
const message = JSON.parse(event.data);
|
||||
// Legacy bridge client removed — the extension reaches the local zapd router
|
||||
// via the ai.hanzo.zap native host. dispatchMethod() below remains the
|
||||
// inbound browser-command dispatcher.
|
||||
|
||||
// Handle extension-request from bridge server (monitor/audit calls)
|
||||
if (message.type === 'extension-request' && message.action) {
|
||||
chrome.runtime.sendMessage(
|
||||
{ action: message.action, ...(message.params || {}) },
|
||||
(result) => {
|
||||
this.wsServer?.send(JSON.stringify({
|
||||
type: 'extension-response',
|
||||
id: message.id,
|
||||
result: result || { error: chrome.runtime.lastError?.message },
|
||||
}));
|
||||
}
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const response = await this.handleBridgeMessage(message);
|
||||
this.wsServer?.send(JSON.stringify(response));
|
||||
} catch (error: any) {
|
||||
this.wsServer?.send(JSON.stringify({
|
||||
id: 0,
|
||||
error: { code: -32603, message: error.message }
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
||||
this.wsServer.onerror = (error) => {
|
||||
console.error('[CDP] Bridge connection error:', error);
|
||||
};
|
||||
|
||||
this.wsServer.onclose = () => {
|
||||
debugLog('[CDP] Bridge disconnected, reconnecting in 5s...');
|
||||
setTimeout(() => this.connectToBridge(url), 5000);
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('[CDP] Failed to connect to bridge:', error);
|
||||
setTimeout(() => this.connectToBridge(url), 5000);
|
||||
/**
|
||||
* Public entry point for ZAP-server-initiated requests.
|
||||
*
|
||||
* 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 +347,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 +390,23 @@ export class CDPBridge {
|
||||
break;
|
||||
|
||||
case 'Runtime.evaluate':
|
||||
// Must enable Runtime domain and set returnByValue for actual values
|
||||
case 'evaluate':
|
||||
// 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).
|
||||
// pickEvaluable + wrapEvaluable: accept every code-param alias
|
||||
// and let statement bodies (`const b=…; b.click()`) evaluate
|
||||
// instead of tripping SpiderMonkey/V8 expression parsing — the
|
||||
// same rule background-firefox.ts applies, so both browsers
|
||||
// accept identical caller JS.
|
||||
await this.send(tabId, 'Runtime.enable');
|
||||
result = await this.send(tabId, method, {
|
||||
result = await this.send(tabId, 'Runtime.evaluate', {
|
||||
...params,
|
||||
expression: wrapEvaluable(pickEvaluable(params)),
|
||||
returnByValue: true,
|
||||
awaitPromise: true,
|
||||
});
|
||||
break;
|
||||
|
||||
@@ -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'],
|
||||
@@ -130,16 +109,6 @@ async function build() {
|
||||
packages: 'external'
|
||||
});
|
||||
|
||||
// Build CDP Bridge Server (WebSocket + HTTP API for hanzo-mcp integration)
|
||||
await esbuild.build({
|
||||
entryPoints: ['src/cdp-bridge-server.ts'],
|
||||
bundle: true,
|
||||
outfile: 'dist/browser-extension/cdp-bridge-server.js',
|
||||
platform: 'node',
|
||||
target: 'node16',
|
||||
packages: 'external'
|
||||
});
|
||||
|
||||
// Make CLI executable
|
||||
if (process.platform !== 'win32') {
|
||||
execSync('chmod +x dist/browser-extension/cli.js');
|
||||
@@ -152,23 +121,18 @@ async function build() {
|
||||
// There is a tsconfig in src.
|
||||
// execSync('cd src && npx tsc --noEmit', { stdio: 'inherit' });
|
||||
|
||||
// Copy manifests for different browsers
|
||||
fs.copyFileSync(
|
||||
'src/manifest.json',
|
||||
'dist/browser-extension/manifest.json'
|
||||
);
|
||||
|
||||
// Chrome version
|
||||
fs.copyFileSync(
|
||||
'src/manifest.json',
|
||||
'dist/browser-extension/chrome/manifest.json'
|
||||
);
|
||||
|
||||
// Firefox version
|
||||
fs.copyFileSync(
|
||||
'src/manifest-firefox.json',
|
||||
'dist/browser-extension/firefox/manifest.json'
|
||||
);
|
||||
// Copy manifests for different browsers. package.json is the SINGLE source of
|
||||
// truth for the version — stamp it into every manifest so the three can never
|
||||
// drift apart (the "must agree" rule, enforced by the build instead of by hand).
|
||||
const pkgVersion = JSON.parse(fs.readFileSync('package.json', 'utf8')).version;
|
||||
const writeManifest = (srcPath, destPath) => {
|
||||
const m = JSON.parse(fs.readFileSync(srcPath, 'utf8'));
|
||||
m.version = pkgVersion;
|
||||
fs.writeFileSync(destPath, JSON.stringify(m, null, 2));
|
||||
};
|
||||
writeManifest('src/manifest.json', 'dist/browser-extension/manifest.json');
|
||||
writeManifest('src/manifest.json', 'dist/browser-extension/chrome/manifest.json');
|
||||
writeManifest('src/manifest-firefox.json', 'dist/browser-extension/firefox/manifest.json');
|
||||
|
||||
// Safari needs special handling — write Info.plist for Xcode project
|
||||
fs.writeFileSync('dist/browser-extension/safari/Info.plist', `<?xml version="1.0" encoding="UTF-8"?>
|
||||
@@ -180,9 +144,9 @@ async function build() {
|
||||
<key>CFBundleIdentifier</key>
|
||||
<string>ai.hanzo.browser-extension</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>1.0.0</string>
|
||||
<string>${pkgVersion}</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>1.0.0</string>
|
||||
<string>${pkgVersion}</string>
|
||||
<key>NSExtension</key>
|
||||
<dict>
|
||||
<key>NSExtensionPointIdentifier</key>
|
||||
|
||||
@@ -1,881 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* CDP Bridge Server - Unified browser control interface
|
||||
*
|
||||
* Provides hanzo.browser(action, params) interface matching hanzo-mcp pattern.
|
||||
* Browser extension connects to this server for remote control.
|
||||
*/
|
||||
|
||||
import { WebSocketServer, WebSocket } from 'ws';
|
||||
import * as readline from 'readline';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
|
||||
interface CDPClient {
|
||||
ws: WebSocket;
|
||||
id: string; // Unique ID: "chrome", "firefox", "chrome-2", etc.
|
||||
capabilities: string[];
|
||||
browser: string; // Browser type: "chrome", "firefox", "safari", "edge"
|
||||
tabId?: number;
|
||||
}
|
||||
|
||||
interface BrowserCommand {
|
||||
action: string;
|
||||
// Navigation
|
||||
url?: string;
|
||||
// Selectors
|
||||
selector?: string;
|
||||
ref?: string;
|
||||
element?: string;
|
||||
// Input
|
||||
text?: string;
|
||||
value?: string;
|
||||
key?: string;
|
||||
// Screenshot
|
||||
fullPage?: boolean;
|
||||
format?: 'png' | 'jpeg';
|
||||
filename?: string;
|
||||
// Evaluate
|
||||
code?: string;
|
||||
function?: string;
|
||||
expression?: string;
|
||||
// Tabs
|
||||
tabId?: number;
|
||||
tabIndex?: number;
|
||||
// Wait
|
||||
timeout?: number;
|
||||
state?: string;
|
||||
// Browser targeting (multi-browser support)
|
||||
browser?: string; // Target browser: "chrome", "firefox", "safari", or unique ID
|
||||
// Generic
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
class CDPBridgeServer {
|
||||
private wss: WebSocketServer;
|
||||
private clients: Map<WebSocket, CDPClient> = new Map();
|
||||
private pendingCommands: Map<number, { resolve: Function; reject: Function }> = new Map();
|
||||
private pendingExtensionRequests: Map<string, { resolve: Function; timeout: ReturnType<typeof setTimeout> }> = new Map();
|
||||
private commandId = 0;
|
||||
private httpPort: number;
|
||||
|
||||
constructor(port: number = 9223) {
|
||||
this.httpPort = port;
|
||||
this.wss = new WebSocketServer({ port, path: '/cdp' });
|
||||
|
||||
this.wss.on('connection', (ws) => {
|
||||
console.log('[hanzo.browser] Extension connected');
|
||||
|
||||
ws.on('message', (data) => {
|
||||
try {
|
||||
const message = JSON.parse(data.toString());
|
||||
this.handleMessage(ws, message);
|
||||
} catch (error: any) {
|
||||
console.error('[hanzo.browser] Error:', error.message);
|
||||
}
|
||||
});
|
||||
|
||||
ws.on('close', () => {
|
||||
console.log('[hanzo.browser] Extension disconnected');
|
||||
this.clients.delete(ws);
|
||||
});
|
||||
|
||||
ws.on('error', (error) => {
|
||||
console.error('[hanzo.browser] WebSocket error:', error);
|
||||
});
|
||||
});
|
||||
|
||||
console.log(`[hanzo.browser] Server listening on ws://localhost:${port}/cdp`);
|
||||
}
|
||||
|
||||
/** Assign a unique ID for a browser type (e.g. "chrome", "chrome-2", "firefox") */
|
||||
private assignClientId(browser: string): string {
|
||||
const existing = Array.from(this.clients.values())
|
||||
.filter((c) => c.browser === browser);
|
||||
if (existing.length === 0) return browser;
|
||||
// Find next available suffix
|
||||
for (let i = 2; ; i++) {
|
||||
const candidate = `${browser}-${i}`;
|
||||
if (!existing.some((c) => c.id === candidate)) return candidate;
|
||||
}
|
||||
}
|
||||
|
||||
/** Find a client by browser name/ID, or return first connected client */
|
||||
private resolveClient(target?: string): CDPClient | undefined {
|
||||
if (!target) {
|
||||
// Default: first connected client
|
||||
return this.clients.values().next().value;
|
||||
}
|
||||
// Exact match on ID first (e.g. "chrome-2")
|
||||
for (const client of this.clients.values()) {
|
||||
if (client.id === target) 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;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
private handleMessage(ws: WebSocket, message: any) {
|
||||
if (message.type === 'register') {
|
||||
const browser = message.browser || 'unknown';
|
||||
const id = this.assignClientId(browser);
|
||||
this.clients.set(ws, {
|
||||
ws,
|
||||
id,
|
||||
capabilities: message.capabilities || [],
|
||||
browser,
|
||||
});
|
||||
console.log(`[hanzo.browser] Registered ${id} (${browser}):`, message.capabilities?.join(', '));
|
||||
// Notify the extension of its assigned ID
|
||||
ws.send(JSON.stringify({ type: 'registered', id, browser }));
|
||||
return;
|
||||
}
|
||||
|
||||
if (message.type === 'config') {
|
||||
this.saveConfig(message.key, message.value);
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle responses from extension for sendToExtension() calls
|
||||
if (message.type === 'extension-response' && message.id) {
|
||||
const pending = this.pendingExtensionRequests.get(message.id);
|
||||
if (pending) {
|
||||
clearTimeout(pending.timeout);
|
||||
this.pendingExtensionRequests.delete(message.id);
|
||||
pending.resolve(message.result || message);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (message.id !== undefined) {
|
||||
const pending = this.pendingCommands.get(message.id);
|
||||
if (pending) {
|
||||
if (message.error) {
|
||||
pending.reject(new Error(message.error.message));
|
||||
} else {
|
||||
pending.resolve(message.result);
|
||||
}
|
||||
this.pendingCommands.delete(message.id);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (message.type === 'event') {
|
||||
console.log(`[hanzo.browser] Event: ${message.method}`);
|
||||
}
|
||||
}
|
||||
|
||||
/** Save a config value to ~/.hanzo/extension/config.json */
|
||||
private saveConfig(key: string, value: unknown): void {
|
||||
const configDir = path.join(
|
||||
process.env.HOME || process.env.USERPROFILE || '.',
|
||||
'.hanzo',
|
||||
'extension',
|
||||
);
|
||||
const configPath = path.join(configDir, 'config.json');
|
||||
|
||||
try {
|
||||
fs.mkdirSync(configDir, { recursive: true });
|
||||
|
||||
let config: Record<string, unknown> = {};
|
||||
if (fs.existsSync(configPath)) {
|
||||
config = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
|
||||
}
|
||||
config[key] = value;
|
||||
fs.writeFileSync(configPath, JSON.stringify(config, null, 2) + '\n');
|
||||
console.log(`[hanzo.browser] Config saved: ${key} = ${JSON.stringify(value)}`);
|
||||
} catch (error: any) {
|
||||
console.error(`[hanzo.browser] Failed to save config: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/** Get connected browser names */
|
||||
getConnectedBrowsers(): string[] {
|
||||
return Array.from(this.clients.values())
|
||||
.map((c) => c.id || c.browser || 'unknown')
|
||||
.filter((b) => b !== 'unknown');
|
||||
}
|
||||
|
||||
/** Get detailed info about all connected browsers */
|
||||
getConnectedBrowserDetails(): Array<{ id: string; browser: string; capabilities: string[] }> {
|
||||
return Array.from(this.clients.values()).map((c) => ({
|
||||
id: c.id,
|
||||
browser: c.browser,
|
||||
capabilities: c.capabilities,
|
||||
}));
|
||||
}
|
||||
|
||||
private async sendRaw(method: string, params?: any, targetBrowser?: string): Promise<any> {
|
||||
const client = this.resolveClient(targetBrowser);
|
||||
if (!client) {
|
||||
const connected = this.getConnectedBrowsers();
|
||||
throw new Error(
|
||||
targetBrowser
|
||||
? `Browser "${targetBrowser}" not connected. Connected: [${connected.join(', ')}]`
|
||||
: 'No browser extension connected'
|
||||
);
|
||||
}
|
||||
|
||||
const id = ++this.commandId;
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
this.pendingCommands.set(id, { resolve, reject });
|
||||
client.ws.send(JSON.stringify({ id, method, params }));
|
||||
|
||||
setTimeout(() => {
|
||||
if (this.pendingCommands.has(id)) {
|
||||
this.pendingCommands.delete(id);
|
||||
reject(new Error(`Command timeout (browser: ${client.id})`));
|
||||
}
|
||||
}, 30000);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Unified hanzo.browser interface - matches mcp__hanzo__browser pattern
|
||||
*/
|
||||
async browser(params: BrowserCommand): Promise<any> {
|
||||
const { action, browser: targetBrowser, ...rest } = params;
|
||||
|
||||
// Special action: list connected browsers
|
||||
if (action === 'list_browsers' || action === 'browsers') {
|
||||
return {
|
||||
browsers: this.getConnectedBrowserDetails(),
|
||||
count: this.clients.size,
|
||||
};
|
||||
}
|
||||
|
||||
// Shorthand: b = targetBrowser for all sendRaw/sendToExtension calls below
|
||||
const b = targetBrowser;
|
||||
|
||||
// Universal tab targeting: a `tabId` or `tabIndex` on the incoming command
|
||||
// applies to every downstream sendRaw call so the user can pin a specific
|
||||
// tab without repeating it. Without this, every action falls back to
|
||||
// `chrome.tabs.query({active:true,currentWindow:true})[0]` — which is
|
||||
// fragile when the user has many windows / a chrome://newtab in focus.
|
||||
const tabTarget = (extra?: Record<string, unknown>) => {
|
||||
const out: Record<string, unknown> = { ...(extra || {}) };
|
||||
if (rest.tabId !== undefined) out.tabId = rest.tabId;
|
||||
if (rest.tabIndex !== undefined) out.tabIndex = rest.tabIndex;
|
||||
if (rest.targetId !== undefined && out.tabId === undefined) out.tabId = rest.targetId;
|
||||
return out;
|
||||
};
|
||||
|
||||
switch (action) {
|
||||
// Navigation
|
||||
case 'navigate':
|
||||
return this.sendRaw('Page.navigate', tabTarget({ url: rest.url }), b);
|
||||
|
||||
case 'navigate_back':
|
||||
case 'go_back':
|
||||
return this.sendRaw('Page.goBack', tabTarget(), b);
|
||||
|
||||
case 'navigate_forward':
|
||||
case 'go_forward':
|
||||
return this.sendRaw('Page.goForward', tabTarget(), b);
|
||||
|
||||
case 'reload':
|
||||
return this.sendRaw('Page.reload', tabTarget(), b);
|
||||
|
||||
case 'url':
|
||||
return this.sendRaw('hanzo.url', tabTarget(), b);
|
||||
|
||||
case 'title':
|
||||
return this.sendRaw('hanzo.title', tabTarget(), b);
|
||||
|
||||
case 'tab_info':
|
||||
return this.sendRaw('hanzo.tabInfo', tabTarget(), b);
|
||||
|
||||
case 'content':
|
||||
return this.sendRaw('Runtime.evaluate', tabTarget({
|
||||
expression: 'document.documentElement.outerHTML',
|
||||
returnByValue: true
|
||||
}), b);
|
||||
|
||||
// Screenshots
|
||||
case 'screenshot': {
|
||||
const screenshot = await this.sendRaw('hanzo.screenshot', tabTarget({
|
||||
format: rest.format || 'png',
|
||||
fullPage: rest.fullPage,
|
||||
}), b);
|
||||
if (rest.filename && screenshot?.data) {
|
||||
const buffer = Buffer.from(screenshot.data, 'base64');
|
||||
fs.writeFileSync(rest.filename, buffer);
|
||||
return { saved: rest.filename, bytes: buffer.length, data: screenshot.data };
|
||||
}
|
||||
return screenshot;
|
||||
}
|
||||
|
||||
case 'snapshot':
|
||||
// Accessibility snapshot
|
||||
return this.sendRaw('Accessibility.getFullAXTree', tabTarget(), b);
|
||||
|
||||
// Input - Click
|
||||
case 'click':
|
||||
return this.sendRaw('hanzo.click', tabTarget({
|
||||
selector: rest.selector || rest.ref,
|
||||
}), b);
|
||||
|
||||
case 'dblclick':
|
||||
case 'double_click':
|
||||
return this.sendRaw('hanzo.dblclick', tabTarget({
|
||||
selector: rest.selector || rest.ref,
|
||||
}), b);
|
||||
|
||||
case 'hover':
|
||||
return this.sendRaw('hanzo.hover', tabTarget({
|
||||
selector: rest.selector || rest.ref,
|
||||
}), b);
|
||||
|
||||
// Input - Type
|
||||
case 'type':
|
||||
return this.sendRaw('hanzo.type', tabTarget({
|
||||
selector: rest.selector || rest.ref,
|
||||
text: rest.text,
|
||||
}), b);
|
||||
|
||||
case 'fill':
|
||||
return this.sendRaw('hanzo.fill', tabTarget({
|
||||
selector: rest.selector || rest.ref,
|
||||
value: rest.value || rest.text,
|
||||
}), b);
|
||||
|
||||
case 'clear':
|
||||
return this.sendRaw('hanzo.clear', tabTarget({
|
||||
selector: rest.selector || rest.ref,
|
||||
}), b);
|
||||
|
||||
case 'press_key':
|
||||
case 'press':
|
||||
return this.sendRaw('Input.dispatchKeyEvent', tabTarget({
|
||||
type: 'keyDown',
|
||||
key: rest.key,
|
||||
}), b);
|
||||
|
||||
// Forms
|
||||
case 'select_option':
|
||||
return this.sendRaw('hanzo.select', tabTarget({
|
||||
selector: rest.selector || rest.ref,
|
||||
value: rest.value,
|
||||
}), b);
|
||||
|
||||
case 'check':
|
||||
return this.sendRaw('hanzo.check', tabTarget({
|
||||
selector: rest.selector || rest.ref,
|
||||
}), b);
|
||||
|
||||
case 'uncheck':
|
||||
return this.sendRaw('hanzo.uncheck', tabTarget({
|
||||
selector: rest.selector || rest.ref,
|
||||
}), b);
|
||||
|
||||
// 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);
|
||||
|
||||
case 'get_url':
|
||||
return this.sendRaw('hanzo.url', tabTarget(), b);
|
||||
|
||||
case 'get_title':
|
||||
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':
|
||||
return this.sendRaw('hanzo.waitForNavigation', tabTarget({ timeout: rest.timeout }), b);
|
||||
|
||||
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', {
|
||||
url: rest.url,
|
||||
options: {
|
||||
method: rest.method || rest.options?.method,
|
||||
headers: rest.headers || rest.options?.headers,
|
||||
body: rest.body || rest.options?.body,
|
||||
mode: rest.mode || rest.options?.mode,
|
||||
credentials: rest.credentials || rest.options?.credentials,
|
||||
},
|
||||
}, b);
|
||||
return fetchResult;
|
||||
}
|
||||
|
||||
// Selectors / Waiting
|
||||
case 'wait_for_selector':
|
||||
return this.sendRaw('hanzo.waitForSelector', { selector: rest.selector, timeout: rest.timeout }, b);
|
||||
|
||||
case 'query_selector_all':
|
||||
return this.sendRaw('hanzo.querySelectorAll', { selector: rest.selector }, b);
|
||||
|
||||
case 'get_element_info':
|
||||
return this.sendRaw('hanzo.getElementInfo', { selector: rest.selector }, b);
|
||||
|
||||
case 'get_page_info':
|
||||
return this.sendRaw('hanzo.getPageInfo', {}, b);
|
||||
|
||||
// DOM Read/Write/Observe
|
||||
case 'get_html':
|
||||
return this.sendRaw('hanzo.getHTML', { selector: rest.selector, outer: rest.outer }, b);
|
||||
|
||||
case 'set_html':
|
||||
return this.sendRaw('hanzo.setHTML', { selector: rest.selector, html: rest.html, outer: rest.outer }, b);
|
||||
|
||||
case 'get_text':
|
||||
return this.sendRaw('hanzo.getText', { selector: rest.selector }, b);
|
||||
|
||||
case 'set_text':
|
||||
return this.sendRaw('hanzo.setText', { selector: rest.selector, text: rest.text }, b);
|
||||
|
||||
case 'get_attribute':
|
||||
return this.sendRaw('hanzo.getAttribute', { selector: rest.selector, attr: rest.attr }, b);
|
||||
|
||||
case 'set_attribute':
|
||||
return this.sendRaw('hanzo.setAttribute', { selector: rest.selector, attr: rest.attr, value: rest.value }, b);
|
||||
|
||||
case 'remove_attribute':
|
||||
return this.sendRaw('hanzo.removeAttribute', { selector: rest.selector, attr: rest.attr }, b);
|
||||
|
||||
case 'set_style':
|
||||
return this.sendRaw('hanzo.setStyle', { selector: rest.selector, styles: rest.styles }, b);
|
||||
|
||||
case 'add_class':
|
||||
return this.sendRaw('hanzo.addClass', { selector: rest.selector, classNames: rest.classNames }, b);
|
||||
|
||||
case 'remove_class':
|
||||
return this.sendRaw('hanzo.removeClass', { selector: rest.selector, classNames: rest.classNames }, b);
|
||||
|
||||
case 'insert_element':
|
||||
return this.sendRaw('hanzo.insertElement', { selector: rest.selector, html: rest.html, position: rest.position }, b);
|
||||
|
||||
case 'remove_element':
|
||||
return this.sendRaw('hanzo.removeElement', { selector: rest.selector }, b);
|
||||
|
||||
case 'observe_mutations':
|
||||
return this.sendRaw('hanzo.observeMutations', { selector: rest.selector, options: rest.options, duration: rest.duration }, b);
|
||||
|
||||
case 'computed_styles':
|
||||
return this.sendRaw('hanzo.getComputedStyles', { selector: rest.selector, properties: rest.properties }, b);
|
||||
|
||||
case 'bounding_rects':
|
||||
return this.sendRaw('hanzo.getBoundingRects', { selector: rest.selector }, b);
|
||||
|
||||
case 'inject_script':
|
||||
return this.sendRaw('hanzo.injectScript', { url: rest.url }, b);
|
||||
|
||||
case 'inject_css':
|
||||
return this.sendRaw('hanzo.injectCSS', { css: rest.css }, b);
|
||||
|
||||
case 'local_storage':
|
||||
if (rest.value !== undefined) {
|
||||
return this.sendRaw('hanzo.setLocalStorage', tabTarget({ key: rest.key, value: rest.value }), b);
|
||||
}
|
||||
return this.sendRaw('hanzo.getLocalStorage', tabTarget({ key: rest.key }), b);
|
||||
|
||||
case 'cookies':
|
||||
return this.sendRaw('hanzo.getCookies', tabTarget(), b);
|
||||
|
||||
// Evaluate
|
||||
case 'evaluate': {
|
||||
const evalResult = await this.sendRaw('Runtime.evaluate', tabTarget({
|
||||
expression: rest.code || rest.function || rest.expression,
|
||||
returnByValue: 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 };
|
||||
}
|
||||
|
||||
// Wait
|
||||
case 'wait':
|
||||
await new Promise(resolve => setTimeout(resolve, (rest.timeout || 1) * 1000));
|
||||
return { waited: rest.timeout || 1 };
|
||||
|
||||
case 'wait_for_load':
|
||||
return this.sendRaw('Page.waitForLoadState', tabTarget({
|
||||
state: rest.state || 'load',
|
||||
}), b);
|
||||
|
||||
// Tabs
|
||||
case 'tabs':
|
||||
case 'list_tabs':
|
||||
return this.sendRaw('Target.getTargets', undefined, b);
|
||||
|
||||
case 'new_tab':
|
||||
return this.sendRaw('Target.createTarget', { url: rest.url || 'about:blank' }, b);
|
||||
|
||||
case 'close_tab':
|
||||
return this.sendRaw('Target.closeTarget', { targetId: rest.tabId }, b);
|
||||
|
||||
case 'select_tab':
|
||||
return this.sendRaw('Target.activateTarget', { targetId: rest.tabId }, b);
|
||||
|
||||
// Console/Network — routed to PageMonitor via extension background
|
||||
case 'console_messages':
|
||||
case 'console':
|
||||
case 'console_logs':
|
||||
return this.sendToExtension('monitor.consoleLogs', { tabId: rest.tabId }, b);
|
||||
|
||||
case 'console_errors':
|
||||
return this.sendToExtension('monitor.consoleErrors', { tabId: rest.tabId }, b);
|
||||
|
||||
case 'network_requests':
|
||||
case 'network_logs':
|
||||
return this.sendToExtension('monitor.networkLogs', { tabId: rest.tabId }, b);
|
||||
|
||||
case 'network_errors':
|
||||
return this.sendToExtension('monitor.networkErrors', { tabId: rest.tabId }, b);
|
||||
|
||||
case 'network_success':
|
||||
return this.sendToExtension('monitor.networkSuccess', { tabId: rest.tabId }, b);
|
||||
|
||||
case 'wipe_logs':
|
||||
return this.sendToExtension('monitor.wipeLogs', { tabId: rest.tabId }, b);
|
||||
|
||||
case 'start_monitoring':
|
||||
return this.sendToExtension('monitor.start', { tabId: rest.tabId }, b);
|
||||
|
||||
case 'stop_monitoring':
|
||||
return this.sendToExtension('monitor.stop', { tabId: rest.tabId }, b);
|
||||
|
||||
case 'monitor_status':
|
||||
return this.sendToExtension('monitor.status', {}, b);
|
||||
|
||||
// Audits — routed to AuditRunner via extension background
|
||||
case 'accessibility_audit':
|
||||
return this.sendToExtension('audit.accessibility', { tabId: rest.tabId }, b);
|
||||
|
||||
case 'performance_audit':
|
||||
return this.sendToExtension('audit.performance', { tabId: rest.tabId }, b);
|
||||
|
||||
case 'seo_audit':
|
||||
return this.sendToExtension('audit.seo', { tabId: rest.tabId }, b);
|
||||
|
||||
case 'best_practices_audit':
|
||||
return this.sendToExtension('audit.bestPractices', { tabId: rest.tabId }, b);
|
||||
|
||||
case 'full_audit':
|
||||
return this.sendToExtension('audit.full', { tabId: rest.tabId }, b);
|
||||
|
||||
case 'nextjs_audit':
|
||||
return this.sendToExtension('audit.nextjs', { tabId: rest.tabId }, b);
|
||||
|
||||
case 'debugger_mode':
|
||||
case 'debug':
|
||||
return this.sendToExtension('debugger.mode', { tabId: rest.tabId }, b);
|
||||
|
||||
// Ollama / Local LLM
|
||||
case 'ollama_models':
|
||||
return this.sendToExtension('ollama.models', {}, b);
|
||||
|
||||
case 'ollama_chat':
|
||||
return this.sendToExtension('ollama.chat', { model: rest.model, messages: rest.messages, options: rest.options }, b);
|
||||
|
||||
case 'local_chat':
|
||||
return this.sendToExtension('local.chat', { provider: rest.provider, endpoint: rest.endpoint, model: rest.model, messages: rest.messages, options: rest.options, apiKey: rest.apiKey }, b);
|
||||
|
||||
case 'local_models':
|
||||
return this.sendToExtension('local.models', { provider: rest.provider, endpoint: rest.endpoint, apiKey: rest.apiKey }, b);
|
||||
|
||||
case 'local_discover':
|
||||
return this.sendToExtension('local.discover', {}, b);
|
||||
|
||||
// Model Hub — browse, search, download
|
||||
case 'hub_search':
|
||||
return this.sendToExtension('hub.search', { query: rest.query, filter: rest.filter, sort: rest.sort, limit: rest.limit, author: rest.author }, b);
|
||||
|
||||
case 'hub_search_gguf':
|
||||
return this.sendToExtension('hub.searchGGUF', { query: rest.query, limit: rest.limit }, b);
|
||||
|
||||
case 'hub_model':
|
||||
return this.sendToExtension('hub.model', { modelId: rest.modelId }, b);
|
||||
|
||||
case 'hub_recommended':
|
||||
return this.sendToExtension('hub.recommended', {}, b);
|
||||
|
||||
case 'hub_search_ollama':
|
||||
return this.sendToExtension('hub.searchOllama', { query: rest.query }, b);
|
||||
|
||||
case 'hub_download_ollama':
|
||||
return this.sendToExtension('hub.downloadOllama', { modelName: rest.modelName, ollamaUrl: rest.ollamaUrl }, b);
|
||||
|
||||
case 'hub_download_hf':
|
||||
return this.sendToExtension('hub.downloadHF', { downloadUrl: rest.downloadUrl }, b);
|
||||
|
||||
case 'hub_all_models':
|
||||
return this.sendToExtension('hub.allModels', {}, b);
|
||||
|
||||
case 'hub_search_mlx':
|
||||
return this.sendToExtension('hub.searchMLX', { query: rest.query, limit: rest.limit }, b);
|
||||
|
||||
case 'hub_model_card':
|
||||
return this.sendToExtension('hub.modelCard', { modelId: rest.modelId }, b);
|
||||
|
||||
case 'hub_model_stats':
|
||||
return this.sendToExtension('hub.modelStats', { modelId: rest.modelId }, b);
|
||||
|
||||
// Status
|
||||
case 'status':
|
||||
return {
|
||||
connected: this.clients.size > 0,
|
||||
clients: this.clients.size,
|
||||
browsers: this.getConnectedBrowserDetails(),
|
||||
port: this.httpPort
|
||||
};
|
||||
|
||||
default:
|
||||
// Pass through as raw CDP command
|
||||
return this.sendRaw(action, rest, b);
|
||||
}
|
||||
}
|
||||
|
||||
// Send a message to the extension background and wait for response
|
||||
private sendToExtension(action: string, params: any, targetBrowser?: string): Promise<any> {
|
||||
return new Promise((resolve) => {
|
||||
const client = this.resolveClient(targetBrowser);
|
||||
if (!client) {
|
||||
const connected = this.getConnectedBrowsers();
|
||||
resolve({
|
||||
error: targetBrowser
|
||||
? `Browser "${targetBrowser}" not connected. Connected: [${connected.join(', ')}]`
|
||||
: 'No browser extension connected'
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const id = `ext-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`;
|
||||
const timeout = setTimeout(() => {
|
||||
this.pendingExtensionRequests.delete(id);
|
||||
resolve({ error: `Extension request timed out (browser: ${client.id})` });
|
||||
}, 30000);
|
||||
|
||||
this.pendingExtensionRequests.set(id, { resolve, timeout });
|
||||
|
||||
client.ws.send(JSON.stringify({
|
||||
type: 'extension-request',
|
||||
id,
|
||||
action,
|
||||
params,
|
||||
}));
|
||||
});
|
||||
}
|
||||
|
||||
isConnected(): boolean {
|
||||
return this.clients.size > 0;
|
||||
}
|
||||
|
||||
close() {
|
||||
this.wss.close();
|
||||
}
|
||||
}
|
||||
|
||||
// JSON-RPC server for MCP integration
|
||||
async function startJSONRPCServer(bridgeServer: CDPBridgeServer) {
|
||||
const http = require('http');
|
||||
|
||||
const httpServer = http.createServer(async (req: any, res: any) => {
|
||||
if (req.method === 'POST') {
|
||||
let body = '';
|
||||
req.on('data', (chunk: string) => body += chunk);
|
||||
req.on('end', async () => {
|
||||
try {
|
||||
const request = JSON.parse(body);
|
||||
const result = await bridgeServer.browser(request.params || request);
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
// Return result directly (no extra wrapping — Python expects flat dict)
|
||||
res.end(JSON.stringify(result && typeof result === 'object' ? result : { result }));
|
||||
} catch (error: any) {
|
||||
res.writeHead(500, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ error: error.message }));
|
||||
}
|
||||
});
|
||||
} else {
|
||||
// GET - return status
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({
|
||||
service: 'hanzo.browser',
|
||||
connected: bridgeServer.isConnected(),
|
||||
browsers: bridgeServer.getConnectedBrowserDetails(),
|
||||
actions: [
|
||||
'list_browsers', 'browsers',
|
||||
'navigate', 'navigate_back', 'reload', 'url', 'title', 'content',
|
||||
'screenshot', 'snapshot',
|
||||
'click', 'dblclick', 'hover', 'type', 'fill', 'clear', 'press_key',
|
||||
'select_option', 'check', 'uncheck',
|
||||
'evaluate',
|
||||
'go_back', 'go_forward', 'get_url', 'get_title', 'get_tab_info',
|
||||
'wait_for_navigation', 'get_history', 'create_tab', 'close_tab',
|
||||
'fetch',
|
||||
'get_html', 'set_html', 'get_text', 'set_text',
|
||||
'get_attribute', 'set_attribute', 'remove_attribute',
|
||||
'set_style', 'add_class', 'remove_class',
|
||||
'insert_element', 'remove_element',
|
||||
'observe_mutations', 'computed_styles', 'bounding_rects',
|
||||
'inject_script', 'inject_css',
|
||||
'local_storage', 'cookies',
|
||||
'wait', 'wait_for_load',
|
||||
'tabs', 'new_tab', 'close_tab', 'select_tab',
|
||||
'console_logs', 'console_errors', 'network_logs', 'network_errors', 'network_success',
|
||||
'start_monitoring', 'stop_monitoring', 'monitor_status', 'wipe_logs',
|
||||
'accessibility_audit', 'performance_audit', 'seo_audit', 'best_practices_audit', 'full_audit',
|
||||
'nextjs_audit', 'debugger_mode',
|
||||
'ollama_models', 'ollama_chat', 'local_chat', 'local_models', 'local_discover',
|
||||
'hub_search', 'hub_search_gguf', 'hub_search_mlx', 'hub_model', 'hub_model_card',
|
||||
'hub_model_stats', 'hub_recommended',
|
||||
'hub_search_ollama', 'hub_download_ollama', 'hub_download_hf', 'hub_all_models',
|
||||
'takeover.start', 'takeover.end', 'takeover.cursor',
|
||||
'status'
|
||||
]
|
||||
}));
|
||||
}
|
||||
});
|
||||
|
||||
httpServer.listen(9224, () => {
|
||||
console.log('[hanzo.browser] HTTP API at http://localhost:9224');
|
||||
});
|
||||
}
|
||||
|
||||
// Interactive CLI
|
||||
async function main() {
|
||||
const port = parseInt(process.env.CDP_PORT || '9223');
|
||||
const server = new CDPBridgeServer(port);
|
||||
|
||||
// Start HTTP API
|
||||
startJSONRPCServer(server);
|
||||
|
||||
console.log('\n[hanzo.browser] Commands:');
|
||||
console.log(' navigate <url> - Go to URL');
|
||||
console.log(' screenshot [file] - Capture screen');
|
||||
console.log(' click <selector> - Click element');
|
||||
console.log(' type <sel> <text> - Type into element');
|
||||
console.log(' eval <js> - Run JavaScript');
|
||||
console.log(' tabs - List all tabs');
|
||||
console.log(' status - Connection status');
|
||||
console.log(' quit - Exit\n');
|
||||
|
||||
const rl = readline.createInterface({
|
||||
input: process.stdin,
|
||||
output: process.stdout
|
||||
});
|
||||
|
||||
const prompt = () => {
|
||||
rl.question('hanzo.browser> ', async (line) => {
|
||||
const parts = line.trim().split(' ');
|
||||
const action = parts[0];
|
||||
const args = parts.slice(1);
|
||||
|
||||
if (!action) {
|
||||
prompt();
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
let result;
|
||||
|
||||
switch (action) {
|
||||
case 'quit':
|
||||
case 'exit':
|
||||
server.close();
|
||||
rl.close();
|
||||
process.exit(0);
|
||||
|
||||
case 'navigate':
|
||||
result = await server.browser({ action: 'navigate', url: args[0] });
|
||||
console.log('Navigated to:', args[0]);
|
||||
break;
|
||||
|
||||
case 'screenshot':
|
||||
result = await server.browser({
|
||||
action: 'screenshot',
|
||||
filename: args[0] || `screenshot-${Date.now()}.png`
|
||||
});
|
||||
console.log('Screenshot:', result);
|
||||
break;
|
||||
|
||||
case 'click':
|
||||
result = await server.browser({ action: 'click', selector: args[0] });
|
||||
console.log('Clicked:', args[0]);
|
||||
break;
|
||||
|
||||
case 'type':
|
||||
result = await server.browser({
|
||||
action: 'type',
|
||||
selector: args[0],
|
||||
text: args.slice(1).join(' ')
|
||||
});
|
||||
console.log('Typed into:', args[0]);
|
||||
break;
|
||||
|
||||
case 'eval':
|
||||
result = await server.browser({
|
||||
action: 'evaluate',
|
||||
expression: args.join(' ')
|
||||
});
|
||||
console.log('Result:', result?.result?.value);
|
||||
break;
|
||||
|
||||
case 'tabs':
|
||||
result = await server.browser({ action: 'tabs' });
|
||||
console.log('Tabs:', result?.targetInfos?.map((t: any) => t.title).join(', '));
|
||||
break;
|
||||
|
||||
case 'status':
|
||||
result = await server.browser({ action: 'status' });
|
||||
console.log('Status:', result);
|
||||
break;
|
||||
|
||||
case 'url':
|
||||
result = await server.browser({ action: 'url' });
|
||||
console.log('URL:', result?.result?.value);
|
||||
break;
|
||||
|
||||
case 'title':
|
||||
result = await server.browser({ action: 'title' });
|
||||
console.log('Title:', result?.result?.value);
|
||||
break;
|
||||
|
||||
default:
|
||||
// Try as raw action
|
||||
result = await server.browser({ action, ...Object.fromEntries(
|
||||
args.map((a, i) => [i === 0 ? 'selector' : `arg${i}`, a])
|
||||
)});
|
||||
console.log('Result:', result);
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error('Error:', error.message);
|
||||
}
|
||||
|
||||
prompt();
|
||||
});
|
||||
};
|
||||
|
||||
prompt();
|
||||
}
|
||||
|
||||
export { CDPBridgeServer, BrowserCommand };
|
||||
|
||||
if (require.main === module) {
|
||||
main();
|
||||
}
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
Textarea,
|
||||
} from '@hanzo/ui/primitives-common';
|
||||
} from '@hanzo/gui';
|
||||
|
||||
function LoginPrompt() {
|
||||
return (
|
||||
|
||||
@@ -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 */ }
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"manifest_version": 3,
|
||||
"name": "Hanzo AI",
|
||||
"version": "1.8.5",
|
||||
"version": "1.9.22",
|
||||
"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"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,18 +1,20 @@
|
||||
{
|
||||
"manifest_version": 3,
|
||||
"name": "Hanzo AI",
|
||||
"version": "1.8.5",
|
||||
"version": "1.9.25",
|
||||
"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",
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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>;
|
||||
}
|
||||
@@ -187,7 +187,7 @@ export async function login(adapter: BrowserAdapter): Promise<UserInfo> {
|
||||
const state = generateRandomString(32);
|
||||
const redirectUri = config.redirectUri;
|
||||
|
||||
const authorizeUrl = new URL(`${config.iamLoginUrl}/oauth/authorize`);
|
||||
const authorizeUrl = new URL(`${config.iamLoginUrl}${IAM_API_PATH}/oauth/authorize`);
|
||||
authorizeUrl.searchParams.set('client_id', config.clientId);
|
||||
authorizeUrl.searchParams.set('response_type', 'code');
|
||||
authorizeUrl.searchParams.set('redirect_uri', redirectUri);
|
||||
@@ -210,16 +210,18 @@ export async function login(adapter: BrowserAdapter): Promise<UserInfo> {
|
||||
token_type: 'Bearer',
|
||||
});
|
||||
} else if (code) {
|
||||
const tokenResponse = await fetch(`${config.iamApiUrl}/oauth/token`, {
|
||||
// HIP-0111 canonical token endpoint `/v1/iam/oauth/token`. Token
|
||||
// exchange wants application/x-www-form-urlencoded per RFC 6749.
|
||||
const tokenResponse = await fetch(`${config.iamApiUrl}${IAM_API_PATH}/oauth/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 +269,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}/oauth/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();
|
||||
@@ -282,30 +284,14 @@ async function refreshAccessToken(storage: BrowserStorage, refreshToken: string)
|
||||
return tokens.access_token;
|
||||
}
|
||||
|
||||
/** Fetch user info from IAM. Hanzo gateway exposes Casdoor's get-account
|
||||
* and userinfo at `${iamApiUrl}/v1/iam/...` — never `/api/`. */
|
||||
/** Fetch user info from IAM via the canonical HIP-0111 OIDC userinfo
|
||||
* endpoint `${iamApiUrl}/v1/iam/oauth/userinfo`. Returns standard OIDC
|
||||
* claims (sub, email, name, picture); never the legacy IAM `/api/` shape. */
|
||||
async function fetchUserInfo(storage: BrowserStorage, accessToken: string): Promise<UserInfo> {
|
||||
const config = await getRuntimeConfig(storage);
|
||||
const base = `${config.iamApiUrl}${IAM_API_PATH}`;
|
||||
const headers = { Authorization: `Bearer ${accessToken}` };
|
||||
|
||||
try {
|
||||
const acctResp = await fetch(`${base}/get-account`, { headers });
|
||||
if (acctResp.ok) {
|
||||
const acctJson = await acctResp.json();
|
||||
const acct = acctJson.data || acctJson;
|
||||
if (acct.name || acct.email) {
|
||||
return {
|
||||
sub: acct.id || acct.sub,
|
||||
name: acct.displayName || acct.name,
|
||||
email: acct.email,
|
||||
picture: acct.avatar || undefined,
|
||||
};
|
||||
}
|
||||
}
|
||||
} catch { /* fall through */ }
|
||||
|
||||
const response = await fetch(`${base}/userinfo`, { headers });
|
||||
const response = await fetch(`${config.iamApiUrl}${IAM_API_PATH}/oauth/userinfo`, { headers });
|
||||
if (!response.ok) throw new Error('Failed to fetch user info');
|
||||
return response.json();
|
||||
}
|
||||
@@ -335,47 +321,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();
|
||||
}
|
||||
|
||||
@@ -13,19 +13,35 @@
|
||||
/** Login UI domain (Hanzo ID) */
|
||||
export const IAM_LOGIN_URL = 'https://hanzo.id';
|
||||
|
||||
/** IAM API domain (Casdoor backend, fronted by Hanzo's API gateway) */
|
||||
/** IAM API domain (Hanzo IAM backend, fronted by Hanzo's API gateway) */
|
||||
export const IAM_API_URL = 'https://iam.hanzo.ai';
|
||||
|
||||
/**
|
||||
* IAM API path prefix. Hanzo convention is `/v1/<service>/<endpoint>` — never
|
||||
* `/api/`. The Casdoor upstream uses `/api/*` natively but the prod gateway
|
||||
* `/api/`. The Hanzo IAM upstream uses `/api/*` natively but the prod gateway
|
||||
* rewrites `/v1/iam/*` → upstream. Always reference endpoints through this
|
||||
* constant so prefix changes are a single edit.
|
||||
*/
|
||||
export const IAM_API_PATH = '/v1/iam';
|
||||
|
||||
/** OAuth2 client ID — override via storage key 'hanzo_config_client_id' */
|
||||
export const DEFAULT_CLIENT_ID = 'app-hanzo';
|
||||
/**
|
||||
* OAuth2 client ID — override via storage key 'hanzo_config_client_id'.
|
||||
*
|
||||
* MUST be a client_id registered in Hanzo IAM whose redirectUris include
|
||||
* DEFAULT_REDIRECT_URI. The canonical browser-extension client is
|
||||
* `hanzo-browser` (universe/infra/k8s/iam/init_data.json → application
|
||||
* "hanzo-browser", redirectUris: https://hanzo.ai/callback,
|
||||
* https://hanzo.app/callback, http://localhost:3000/callback).
|
||||
*
|
||||
* NOTE: the prior value `app-hanzo` was never a registered IAM client, so the
|
||||
* PKCE token exchange always failed with `invalid_client` — login was broken
|
||||
* for every build since v1.8.0. Verify with:
|
||||
* curl -X POST https://iam.hanzo.ai/v1/iam/oauth/token \
|
||||
* -d 'grant_type=authorization_code&client_id=hanzo-browser&code=x&code_verifier=y&redirect_uri=https://hanzo.ai/callback'
|
||||
* A registered client returns `invalid_grant` (code rejected); an unregistered
|
||||
* one returns `invalid_client`.
|
||||
*/
|
||||
export const DEFAULT_CLIENT_ID = 'hanzo-browser';
|
||||
|
||||
/** OAuth2 scopes */
|
||||
export const DEFAULT_SCOPES = 'openid profile email';
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
/**
|
||||
* The ONE cross-browser rule for caller-supplied JavaScript.
|
||||
*
|
||||
* Every `evaluate` surface (Chrome `Runtime.evaluate` over chrome.debugger,
|
||||
* Firefox `scripting.executeScript` trampoline) consumes the output of
|
||||
* `wrapEvaluable`: a single *expression* whose value — possibly a Promise —
|
||||
* is the result. Both consumers already await promises (`awaitPromise: true`
|
||||
* on CDP; `executeScript` resolves returned promises), so statement bodies
|
||||
* can be wrapped in an async IIFE without changing caller semantics.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Accept the code parameter under every alias the callers use:
|
||||
* `expression` (CDP), `code` (MCP/SDK), plus the legacy bridge aliases
|
||||
* `script` / `function` / `js`.
|
||||
*/
|
||||
export function pickEvaluable(params: Record<string, unknown> | null | undefined): string {
|
||||
const p = params ?? {};
|
||||
const raw = p.expression ?? p.code ?? p.script ?? p.function ?? p.js ?? '';
|
||||
return typeof raw === 'string' ? raw : String(raw);
|
||||
}
|
||||
|
||||
/**
|
||||
* Make `evaluate` forgiving: a bare expression (`document.title`) is
|
||||
* returned as-is, but a statement body (`const b=…; b.click()`) is wrapped
|
||||
* in an async IIFE so it doesn't blow up with "expected expression, got
|
||||
* 'const'". Multi-statement bodies auto-`return` their trailing expression
|
||||
* (`const x=…; x` yields x, not undefined); an explicit `return` and
|
||||
* control-flow / declaration tails are left alone. Ready-made callables
|
||||
* (functions, arrows, IIFEs) pass through untouched — CDP calls them.
|
||||
*/
|
||||
export function wrapEvaluable(code: string): string {
|
||||
const t = (code ?? '').trim();
|
||||
if (!t) return 'undefined';
|
||||
// already a callable form — leave it (CDP calls it)
|
||||
if (/^(\(?\s*(async\s+)?function\b|\(?\s*\([^)]*\)\s*=>|\(\s*async\b)/.test(t)) return t;
|
||||
const looksStatement =
|
||||
/[;\n]/.test(t) || /^(const|let|var|if|for|while|switch|return|throw|await|try)\b/.test(t);
|
||||
if (!looksStatement) return t; // bare expression → return its value directly
|
||||
let body = t.replace(/;\s*$/, '');
|
||||
const cut = Math.max(body.lastIndexOf(';'), body.lastIndexOf('\n'));
|
||||
const head = cut >= 0 ? body.slice(0, cut + 1) : '';
|
||||
const last = (cut >= 0 ? body.slice(cut + 1) : body).trim();
|
||||
if (last && !/^(return|const|let|var|if|for|while|switch|throw|}|\/\/)/.test(last)) {
|
||||
body = head + ' return ' + last;
|
||||
}
|
||||
return `(async()=>{ ${body} })()`;
|
||||
}
|
||||
@@ -5,7 +5,7 @@
|
||||
* Uses IAM bearer tokens for auth — no separate KMS credentials needed.
|
||||
*
|
||||
* API path. Hanzo convention is `/v1/<service>/<endpoint>` — but the KMS
|
||||
* upstream is an Infisical fork that publishes its v3 API under the root
|
||||
* upstream publishes its v3 API under the root
|
||||
* `${KMS_PATH_PREFIX}/secrets/raw`. The kms.hanzo.ai gateway currently passes that
|
||||
* through unchanged (verified: `kms.hanzo.ai/api/v3/secrets/raw` → 401 JSON,
|
||||
* `/v1/kms/...` → SPA HTML). We centralise the path in `KMS_PATH_PREFIX`
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
// Native-ZAP transport — the extension's one connection to the local zapd
|
||||
// router, via the ai.hanzo.zap native-messaging host. No WebSocket, no
|
||||
// localhost ports, no mDNS probing. The browser can never get "connection
|
||||
// refused": connectNative either attaches the host or fails once, cleanly.
|
||||
//
|
||||
// Frames are the ZAP router envelope (binary, in zapd). Over native messaging
|
||||
// they ride as JSON `{t, flags, from, to, payload(base64)}` — the Chrome-forced
|
||||
// inch — and the host re-frames 1:1 to binary on the UDS. The opaque command/
|
||||
// result payload uses a compact binary codec below (no JSON in the contract).
|
||||
|
||||
const HELLO = 1, WELCOME = 2, ERROR = 7, ROUTE = 16, RESPONSE = 17;
|
||||
const ROLE_PROVIDER = 1;
|
||||
|
||||
function b64encode(bytes: Uint8Array): string {
|
||||
let s = '';
|
||||
for (let i = 0; i < bytes.length; i++) s += String.fromCharCode(bytes[i]);
|
||||
return btoa(s);
|
||||
}
|
||||
function b64decode(s: string): Uint8Array {
|
||||
const bin = atob(s);
|
||||
const u = new Uint8Array(bin.length);
|
||||
for (let i = 0; i < bin.length; i++) u[i] = bin.charCodeAt(i);
|
||||
return u;
|
||||
}
|
||||
function putU16(a: number[], n: number) { a.push(n & 0xff, (n >> 8) & 0xff); }
|
||||
function putStr(a: number[], s: string) {
|
||||
const b = new TextEncoder().encode(s);
|
||||
putU16(a, b.length);
|
||||
for (let i = 0; i < b.length; i++) a.push(b[i]);
|
||||
}
|
||||
|
||||
// HELLO body: role(u8) + brand(str) + caps(u16 + str…). Mirrors frame.rs.
|
||||
function encodeHello(role: number, brand: string, caps: string[]): Uint8Array {
|
||||
const a: number[] = [role];
|
||||
putStr(a, brand);
|
||||
putU16(a, caps.length);
|
||||
for (const c of caps) putStr(a, c);
|
||||
return new Uint8Array(a);
|
||||
}
|
||||
|
||||
// Command body: method(str) + params(u16 count of key:str → value:bytes).
|
||||
function decodeCmd(p: Uint8Array): { method: string; params: Record<string, string> } {
|
||||
const dv = new DataView(p.buffer, p.byteOffset, p.byteLength);
|
||||
let o = 0;
|
||||
const ml = dv.getUint16(o, true); o += 2;
|
||||
const method = new TextDecoder().decode(p.subarray(o, o + ml)); o += ml;
|
||||
const n = dv.getUint16(o, true); o += 2;
|
||||
const params: Record<string, string> = {};
|
||||
for (let i = 0; i < n; i++) {
|
||||
const kl = dv.getUint16(o, true); o += 2;
|
||||
const k = new TextDecoder().decode(p.subarray(o, o + kl)); o += kl;
|
||||
const vl = dv.getUint32(o, true); o += 4;
|
||||
params[k] = new TextDecoder().decode(p.subarray(o, o + vl)); o += vl;
|
||||
}
|
||||
return { method, params };
|
||||
}
|
||||
|
||||
export interface NativeZapState {
|
||||
connected: boolean;
|
||||
port: chrome.runtime.Port | null;
|
||||
providerId: string;
|
||||
}
|
||||
|
||||
export type Dispatch = (method: string, params: any) => Promise<any> | any;
|
||||
|
||||
const RECONNECT_MS = 3000;
|
||||
|
||||
/** Connect to zapd via the native host, register as a browser provider, and
|
||||
* dispatch inbound ROUTE commands to the browser. Self-heals on disconnect. */
|
||||
export function connectNativeZap(
|
||||
state: NativeZapState,
|
||||
brand: string,
|
||||
caps: string[],
|
||||
dispatch: Dispatch,
|
||||
log: (m: string) => void = console.log,
|
||||
): void {
|
||||
// Cross-browser: Firefox exposes `browser.runtime`, Chrome `chrome.runtime`;
|
||||
// both implement connectNative. One transport, both browsers.
|
||||
const rt: any = (typeof browser !== 'undefined' ? (browser as any) : chrome).runtime;
|
||||
// Singleton: never run two native ports. Tear down any prior port first — its
|
||||
// onDisconnect below is guarded so a replaced port never schedules a reconnect.
|
||||
// Without this, repeated connectNativeZap() calls (startup + events + the 3s
|
||||
// reconnect) accumulate ports, each spawning a native host → a host-spawn storm.
|
||||
const prior = state.port;
|
||||
state.port = null;
|
||||
if (prior) { try { prior.disconnect(); } catch {} }
|
||||
let port: chrome.runtime.Port;
|
||||
try {
|
||||
port = rt.connectNative('ai.hanzo.zap');
|
||||
} catch (e: any) {
|
||||
state.connected = false;
|
||||
log('[Hanzo/ZAP] native host ai.hanzo.zap is not installed');
|
||||
return;
|
||||
}
|
||||
state.port = port;
|
||||
const id = state.providerId;
|
||||
|
||||
const post = (t: number, to: string, payload: Uint8Array) =>
|
||||
port.postMessage({ t, flags: 0, from: id, to, payload: b64encode(payload) });
|
||||
|
||||
// Register as a provider.
|
||||
post(HELLO, '', encodeHello(ROLE_PROVIDER, brand, caps));
|
||||
|
||||
port.onMessage.addListener(async (msg: any) => {
|
||||
const payload = msg.payload ? b64decode(msg.payload) : new Uint8Array(0);
|
||||
switch (msg.t) {
|
||||
case WELCOME:
|
||||
state.connected = true;
|
||||
log(`[Hanzo/ZAP] native host connected; registered ${id}`);
|
||||
break;
|
||||
case ERROR:
|
||||
log('[Hanzo/ZAP] ' + new TextDecoder().decode(payload));
|
||||
break;
|
||||
case ROUTE: {
|
||||
const { method, params } = decodeCmd(payload);
|
||||
let out: Uint8Array;
|
||||
try {
|
||||
const r = await dispatch(method, params);
|
||||
// browser.zap stopgap: structured results travel as utf-8 text until
|
||||
// the typed browser schema lands. Router/host never see this — opaque.
|
||||
const s = typeof r === 'string' ? r : JSON.stringify(r ?? null);
|
||||
out = new TextEncoder().encode(s);
|
||||
} catch (e: any) {
|
||||
out = new TextEncoder().encode('ERR:' + (e?.message || String(e)));
|
||||
}
|
||||
port.postMessage({ t: RESPONSE, flags: 0, from: id, to: msg.from, payload: b64encode(out) });
|
||||
break;
|
||||
}
|
||||
default:
|
||||
break; // PROVIDERS / PEER_* — a provider ignores these
|
||||
}
|
||||
});
|
||||
|
||||
port.onDisconnect.addListener(() => {
|
||||
if (state.port !== port) return; // replaced by a newer port — do not reconnect
|
||||
state.connected = false;
|
||||
state.port = null;
|
||||
const err = rt.lastError?.message;
|
||||
log('[Hanzo/ZAP] native host disconnected' + (err ? ': ' + err : ''));
|
||||
setTimeout(() => connectNativeZap(state, brand, caps, dispatch, log), RECONNECT_MS);
|
||||
});
|
||||
}
|
||||
@@ -15,10 +15,16 @@ 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;
|
||||
|
||||
|
||||
// ── Encode / Decode ─────────────────────────────────────────────────────
|
||||
|
||||
export function encodeZapMessage(type: number, payload: object): ArrayBuffer {
|
||||
@@ -50,11 +56,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,38 +89,9 @@ export function createZapManager(): ZapManager {
|
||||
};
|
||||
}
|
||||
|
||||
/** Probe a ZAP server on given port */
|
||||
export function probeZapServer(
|
||||
port: number,
|
||||
mgr: ZapManager,
|
||||
browserName: string,
|
||||
version: string,
|
||||
): Promise<string | null> {
|
||||
return new Promise((resolve) => {
|
||||
const url = `ws://localhost:${port}`;
|
||||
const ws = new WebSocket(url);
|
||||
const timer = setTimeout(() => { ws.close(); resolve(null); }, ZAP_DISCOVERY_TIMEOUT);
|
||||
|
||||
ws.binaryType = 'arraybuffer';
|
||||
ws.onopen = () => {
|
||||
clearTimeout(timer);
|
||||
ws.send(encodeZapMessage(MSG_HANDSHAKE, {
|
||||
clientId: mgr.state.extensionId,
|
||||
clientType: 'browser_extension',
|
||||
browser: browserName,
|
||||
version,
|
||||
capabilities: ['tabs', 'navigate', 'screenshot', 'evaluate', 'cookies', 'storage'],
|
||||
}));
|
||||
|
||||
const hsTimer = setTimeout(() => { ws.close(); resolve(null); }, ZAP_DISCOVERY_TIMEOUT);
|
||||
ws.onmessage = () => {
|
||||
clearTimeout(hsTimer);
|
||||
ws.close();
|
||||
resolve(url);
|
||||
};
|
||||
};
|
||||
ws.onerror = () => { clearTimeout(timer); resolve(null); };
|
||||
});
|
||||
/** Install (or replace) the inbound request handler. */
|
||||
export function setZapRequestHandler(mgr: ZapManager, handler: ZapRequestHandler | undefined): void {
|
||||
mgr.requestHandler = handler;
|
||||
}
|
||||
|
||||
/** Connect to a ZAP server and set up message handling */
|
||||
@@ -149,6 +139,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 +157,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 +203,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 +287,126 @@ 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);
|
||||
|
||||
const candidates = mdnsUrls.filter(u => !known.has(u));
|
||||
|
||||
// mDNS-only per HIP-0069. A browser extension must NEVER open arbitrary
|
||||
// ws://localhost:* sockets — that is the ERR_CONNECTION_REFUSED spam.
|
||||
// Either the native helper advertises a server, or we are cleanly
|
||||
// unavailable. No localhost port probing, no fallback ladder.
|
||||
if (candidates.length === 0) {
|
||||
if (mdnsError) {
|
||||
log('[Hanzo/ZAP] unavailable: native mDNS helper ai.hanzo.zap_mdns missing/unreachable');
|
||||
}
|
||||
return;
|
||||
}
|
||||
log(`[Hanzo/ZAP] mDNS found ${candidates.length} new server(s): ${candidates.join(', ')}`);
|
||||
|
||||
await Promise.all(candidates.map(url => connectZap(url, mgr, browserName, version, log)));
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 ─────────────────────────────────────────────
|
||||
@@ -306,31 +437,11 @@ export function handleZapMessage(
|
||||
return true;
|
||||
|
||||
case 'zap.discover':
|
||||
discoverZapServers(mgr, browserName, version).then(() => {
|
||||
sendResponse({
|
||||
success: true,
|
||||
mcps: mgr.state.mcps.map(m => ({
|
||||
id: m.id,
|
||||
name: m.name,
|
||||
url: m.url,
|
||||
tools: m.tools,
|
||||
})),
|
||||
});
|
||||
}).catch((e: any) => {
|
||||
sendResponse({ success: false, error: e.message });
|
||||
});
|
||||
return true;
|
||||
|
||||
case 'zap.connect':
|
||||
if (!request.url) {
|
||||
sendResponse({ success: false, error: 'Missing url' });
|
||||
return true;
|
||||
}
|
||||
connectZap(request.url, mgr, browserName, version).then((mcpId) => {
|
||||
sendResponse({ success: !!mcpId, mcpId });
|
||||
}).catch((e: any) => {
|
||||
sendResponse({ success: false, error: e.message });
|
||||
});
|
||||
// Discovery/dialing is gone. The extension connects to the local zapd
|
||||
// router via the ai.hanzo.zap native host (see native-zap.ts) — there is
|
||||
// no URL/port to discover or dial.
|
||||
sendResponse({ success: false, error: 'native-zap: connection is via the ai.hanzo.zap native host (no discovery)' });
|
||||
return true;
|
||||
|
||||
case 'zap.callTool': {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -7,27 +7,24 @@ import * as fs from 'fs';
|
||||
describe('Claude Code Browser Extension Integration', () => {
|
||||
let server: BrowserExtensionServer;
|
||||
let ws: WebSocket;
|
||||
const TEST_PORT = 3002;
|
||||
|
||||
|
||||
beforeEach(async () => {
|
||||
// Start server
|
||||
server = new BrowserExtensionServer(TEST_PORT, process.cwd());
|
||||
|
||||
// Wait for server to be ready
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
|
||||
// Connect WebSocket
|
||||
ws = new WebSocket(`ws://localhost:${TEST_PORT}/browser-extension`);
|
||||
// Port 0 = OS-assigned. A fixed port raced the previous test's teardown
|
||||
// (EADDRINUSE → unhandled 'error' → hook timeout); ready() waits for the
|
||||
// actual bind instead of sleeping and hoping.
|
||||
server = new BrowserExtensionServer(0, process.cwd());
|
||||
await server.ready();
|
||||
|
||||
ws = new WebSocket(`ws://localhost:${server.port}/browser-extension`);
|
||||
await new Promise(resolve => ws.on('open', resolve));
|
||||
});
|
||||
|
||||
|
||||
afterEach(async () => {
|
||||
ws.close();
|
||||
await new Promise<void>((resolve) => {
|
||||
server.close(() => resolve());
|
||||
// Fallback if close callback never fires
|
||||
setTimeout(resolve, 500);
|
||||
});
|
||||
// terminate() drops the client immediately so wss.close() isn't left
|
||||
// waiting on a close handshake — the callback then reliably fires and
|
||||
// no fallback timer is needed.
|
||||
ws.terminate();
|
||||
await new Promise<void>((resolve) => server.close(() => resolve()));
|
||||
});
|
||||
|
||||
describe('React source-map integration', () => {
|
||||
|
||||
@@ -0,0 +1,303 @@
|
||||
/**
|
||||
* 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 +
|
||||
* browser-dispatch.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/browser-dispatch.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 the HIP-0111 canonical ${IAM_API_PATH}/oauth/token endpoint', () => {
|
||||
// Pre-HIP-0111 shapes are both dead: the Casdoor
|
||||
// /login/oauth/access_token path and a bare (un-prefixed) /oauth/token
|
||||
// that 404'd before 1.8.7. Strip comments before scanning — the
|
||||
// doc-comments intentionally reference the old paths to explain the bug.
|
||||
expect(sharedAuth).toContain('${IAM_API_PATH}/oauth/token');
|
||||
const stripped = sharedAuth
|
||||
.replace(/\/\*[\s\S]*?\*\//g, '') // block comments
|
||||
.replace(/(^|\n)\s*\/\/.*?(?=\n|$)/g, '$1'); // line comments
|
||||
expect(stripped).not.toContain('/login/oauth/access_token');
|
||||
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('${IAM_API_PATH}/oauth/token'),
|
||||
sharedAuth.indexOf('${IAM_API_PATH}/oauth/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('browser-dispatch.ts imports parseTabId from shared/tab-id', () => {
|
||||
expect(cdpBridge).toMatch(/import\s+\{[^}]*parseTabId[^}]*\}\s+from\s+['"]\.\/shared\/tab-id\.js['"]/);
|
||||
});
|
||||
|
||||
it('browser-dispatch.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('browser-dispatch.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('browser-dispatch.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') + 900,
|
||||
);
|
||||
expect(helperSection).toContain('awaitPromise: true');
|
||||
expect(helperSection).toContain('unwrapEvaluateResult');
|
||||
});
|
||||
|
||||
it('both browsers route caller JS through wrapEvaluable (one canonical rule)', () => {
|
||||
// The forgiving-evaluate rule (statement bodies → async IIFE, trailing
|
||||
// value auto-returned) lives ONCE in shared/evaluable.ts. Each engine's
|
||||
// evaluate surface must consume it — a fork here is how the browsers
|
||||
// drift apart on what caller JS they accept.
|
||||
for (const src of [cdpBridge, firefoxBg]) {
|
||||
expect(src).toMatch(/import\s+\{[^}]*wrapEvaluable[^}]*\}\s+from\s+['"]\.\/shared\/evaluable\.js['"]/);
|
||||
expect(src).toContain('wrapEvaluable(pickEvaluable(params))');
|
||||
}
|
||||
});
|
||||
|
||||
it('Firefox evaluate always resolves promises in-page before serialization', () => {
|
||||
// executeScript's structured clone cannot transfer a Promise — the
|
||||
// in-page body must settle it and hand back the value. Rejections ride
|
||||
// the __hanzo_error channel runInPage throws on.
|
||||
const evalSection = firefoxBg.slice(
|
||||
firefoxBg.indexOf("case 'Runtime.evaluate':"),
|
||||
firefoxBg.indexOf("case 'Page.captureScreenshot':"),
|
||||
);
|
||||
expect(evalSection).toContain('Promise.resolve(');
|
||||
expect(evalSection).toContain('__hanzo_error');
|
||||
});
|
||||
});
|
||||
|
||||
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 `hanzo-browser` (registered IAM <org>-<app> client)', () => {
|
||||
// hanzo-browser is the IAM application seeded for the extension, with
|
||||
// redirectUris including https://hanzo.ai/callback. The earlier value
|
||||
// `app-hanzo` was unregistered → token exchange returned invalid_client.
|
||||
expect(sharedConfig).toContain("DEFAULT_CLIENT_ID = 'hanzo-browser'");
|
||||
});
|
||||
});
|
||||
|
||||
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('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 });
|
||||
});
|
||||
});
|
||||
@@ -34,36 +34,6 @@ describe('hub message handler wiring', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// Verify cdp-bridge-server.ts has all hub_* bridge actions
|
||||
describe('CDP bridge hub actions', () => {
|
||||
const bridgeSrc = fs.readFileSync(
|
||||
path.join(__dirname, '../src/cdp-bridge-server.ts'),
|
||||
'utf-8'
|
||||
);
|
||||
|
||||
const requiredActions = [
|
||||
'hub_search',
|
||||
'hub_search_gguf',
|
||||
'hub_search_mlx',
|
||||
'hub_model',
|
||||
'hub_model_card',
|
||||
'hub_model_stats',
|
||||
'hub_recommended',
|
||||
'hub_search_ollama',
|
||||
'hub_download_ollama',
|
||||
'hub_download_hf',
|
||||
'hub_all_models',
|
||||
];
|
||||
|
||||
for (const action of requiredActions) {
|
||||
it(`has case '${action}' action`, () => {
|
||||
expect(bridgeSrc).toContain(`case '${action}'`);
|
||||
});
|
||||
}
|
||||
|
||||
it('lists hub actions in status response', () => {
|
||||
for (const action of requiredActions) {
|
||||
expect(bridgeSrc).toContain(`'${action}'`);
|
||||
}
|
||||
});
|
||||
});
|
||||
// The node bridge (cdp-bridge-server.ts) and its snake_case hub_* action
|
||||
// namespace were removed with it — hub access now rides the ZAP surface
|
||||
// into the background.ts `hub.*` handlers asserted above.
|
||||
|
||||
@@ -81,9 +81,23 @@ describe('searchOllamaLibrary', () => {
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// HuggingFace API integration tests (real network calls)
|
||||
//
|
||||
// These verify OUR client against the live API. When HF itself refuses
|
||||
// service (rate-limit 429, outage, no network) there is nothing of ours to
|
||||
// verify — skip with the status logged instead of failing on the weather.
|
||||
// Any failure while HF answers 200 is still a real failure. Live calls get
|
||||
// a 15s budget (HF routinely exceeds vitest's 5s default under load).
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('HuggingFace API', () => {
|
||||
const HF_LIVE_TIMEOUT = 15_000;
|
||||
const hfStatus = await fetch('https://huggingface.co/api/models?limit=1', {
|
||||
signal: AbortSignal.timeout(5000),
|
||||
}).then((r) => r.status, () => 0);
|
||||
if (hfStatus !== 200) {
|
||||
console.warn(`[model-hub.test] skipping live HF tests — huggingface.co status ${hfStatus || 'unreachable'}`);
|
||||
}
|
||||
|
||||
describe.skipIf(hfStatus !== 200)('HuggingFace API', () => {
|
||||
it('searchHuggingFace returns models', async () => {
|
||||
const models = await searchHuggingFace('llama', { limit: 2 });
|
||||
expect(models.length).toBeGreaterThan(0);
|
||||
@@ -92,7 +106,7 @@ describe('HuggingFace API', () => {
|
||||
expect(m.id).toBeTruthy();
|
||||
expect(m.source).toBe('huggingface');
|
||||
}
|
||||
});
|
||||
}, HF_LIVE_TIMEOUT);
|
||||
|
||||
it('searchGGUF returns GGUF-tagged models', async () => {
|
||||
const models = await searchGGUF('qwen', 3);
|
||||
@@ -100,12 +114,12 @@ describe('HuggingFace API', () => {
|
||||
for (const m of models) {
|
||||
expect(m.tags).toBeInstanceOf(Array);
|
||||
}
|
||||
});
|
||||
}, HF_LIVE_TIMEOUT);
|
||||
|
||||
it('searchMLX returns MLX models', async () => {
|
||||
const models = await searchMLX('llama', 3);
|
||||
expect(models.length).toBeGreaterThan(0);
|
||||
});
|
||||
}, HF_LIVE_TIMEOUT);
|
||||
|
||||
it('getHuggingFaceModel returns model with files', async () => {
|
||||
// Use a well-known public model that doesn't require auth
|
||||
@@ -124,18 +138,18 @@ describe('HuggingFace API', () => {
|
||||
).toBe(true);
|
||||
expect(f.downloadUrl).toContain('huggingface.co');
|
||||
}
|
||||
});
|
||||
}, HF_LIVE_TIMEOUT);
|
||||
|
||||
it('getModelCard returns markdown', async () => {
|
||||
const card = await getModelCard('TheBloke/Llama-2-7B-Chat-GGUF');
|
||||
expect(typeof card).toBe('string');
|
||||
// Card may be empty for some models; just verify it's a string
|
||||
expect(card).toBeDefined();
|
||||
});
|
||||
}, HF_LIVE_TIMEOUT);
|
||||
|
||||
it('getModelStats returns download count', async () => {
|
||||
const stats = await getModelStats('TheBloke/Llama-2-7B-Chat-GGUF');
|
||||
expect(stats.downloads).toBeGreaterThan(0);
|
||||
expect(stats.tags).toBeInstanceOf(Array);
|
||||
});
|
||||
}, HF_LIVE_TIMEOUT);
|
||||
});
|
||||
|
||||
@@ -39,8 +39,11 @@ describe('IAM constants', () => {
|
||||
expect(KMS_API_URL).toBe('https://kms.hanzo.ai');
|
||||
});
|
||||
|
||||
it('DEFAULT_CLIENT_ID is app-hanzo', () => {
|
||||
expect(DEFAULT_CLIENT_ID).toBe('app-hanzo');
|
||||
it('DEFAULT_CLIENT_ID is hanzo-browser (a registered IAM client)', () => {
|
||||
// Must match an application seeded in IAM (init_data.json → "hanzo-browser")
|
||||
// whose redirectUris include DEFAULT_REDIRECT_URI. `app-hanzo` was never a
|
||||
// real client and made the PKCE token exchange fail with invalid_client.
|
||||
expect(DEFAULT_CLIENT_ID).toBe('hanzo-browser');
|
||||
});
|
||||
|
||||
it('DEFAULT_SCOPES includes openid', () => {
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
/**
|
||||
* Behavioral contract for the ONE cross-browser evaluate rule.
|
||||
*
|
||||
* wrapEvaluable's output is an *expression*: both consumers evaluate it and
|
||||
* await the result (CDP `awaitPromise: true`; executeScript resolves returned
|
||||
* promises). So the honest test is to do exactly that — evaluate the wrapped
|
||||
* string with indirect eval and await it — not to string-match the wrapper.
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { pickEvaluable, wrapEvaluable } from '../src/shared/evaluable.js';
|
||||
|
||||
// Indirect eval = global scope, mirrors how the page evaluates the expression.
|
||||
async function run(code: string): Promise<any> {
|
||||
// eslint-disable-next-line no-eval
|
||||
return await (0, eval)(wrapEvaluable(code));
|
||||
}
|
||||
|
||||
describe('wrapEvaluable behavior', () => {
|
||||
it('bare expression returns its value directly', async () => {
|
||||
expect(wrapEvaluable('1 + 1')).toBe('1 + 1');
|
||||
expect(await run('1 + 1')).toBe(2);
|
||||
});
|
||||
|
||||
it('statement body evaluates instead of throwing SyntaxError', async () => {
|
||||
// The pre-shared Firefox wrap produced `return (const a = 1; a + 1);`.
|
||||
expect(await run('const a = 1; a + 1')).toBe(2);
|
||||
});
|
||||
|
||||
it('auto-returns the trailing expression of a multi-statement body', async () => {
|
||||
expect(await run('const x = { n: 41 }; x.n + 1')).toBe(42);
|
||||
});
|
||||
|
||||
it('respects an explicit return', async () => {
|
||||
expect(await run('const a = 7; return a * 3')).toBe(21);
|
||||
});
|
||||
|
||||
it('newline-separated statements work like semicolon-separated ones', async () => {
|
||||
expect(await run('const a = 2\na * 5')).toBe(10);
|
||||
});
|
||||
|
||||
it('single statement with trailing semicolon runs for effect', async () => {
|
||||
(globalThis as any).__evaluable_probe = 0;
|
||||
expect(await run('globalThis.__evaluable_probe = 9;')).toBe(9);
|
||||
expect((globalThis as any).__evaluable_probe).toBe(9);
|
||||
delete (globalThis as any).__evaluable_probe;
|
||||
});
|
||||
|
||||
it('await works at the top level of a statement body', async () => {
|
||||
expect(await run('const v = await Promise.resolve(5); v + 1')).toBe(6);
|
||||
});
|
||||
|
||||
it('ready-made IIFEs pass through untouched', async () => {
|
||||
const iife = '(async () => 5)()';
|
||||
expect(wrapEvaluable(iife)).toBe(iife);
|
||||
expect(await run(iife)).toBe(5);
|
||||
});
|
||||
|
||||
it('arrow and function expressions pass through untouched', () => {
|
||||
expect(wrapEvaluable('() => 5')).toBe('() => 5');
|
||||
expect(wrapEvaluable('function f() { return 5 }')).toBe('function f() { return 5 }');
|
||||
});
|
||||
|
||||
it('throw statements reject so callers see the page-side error', async () => {
|
||||
await expect(run('throw new Error("boom")')).rejects.toThrow('boom');
|
||||
});
|
||||
|
||||
it('declaration tails are not auto-returned', async () => {
|
||||
expect(await run('const a = 1; const b = 2;')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('empty / whitespace input evaluates to undefined', async () => {
|
||||
expect(wrapEvaluable('')).toBe('undefined');
|
||||
expect(await run(' ')).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('pickEvaluable param aliases', () => {
|
||||
it('accepts expression (CDP), code (MCP/SDK), and legacy aliases', () => {
|
||||
expect(pickEvaluable({ expression: 'a' })).toBe('a');
|
||||
expect(pickEvaluable({ code: 'b' })).toBe('b');
|
||||
expect(pickEvaluable({ script: 'c' })).toBe('c');
|
||||
expect(pickEvaluable({ function: 'd' })).toBe('d');
|
||||
expect(pickEvaluable({ js: 'e' })).toBe('e');
|
||||
});
|
||||
|
||||
it('prefers expression over the aliases', () => {
|
||||
expect(pickEvaluable({ expression: 'a', code: 'b', js: 'e' })).toBe('a');
|
||||
});
|
||||
|
||||
it('returns empty string for missing params', () => {
|
||||
expect(pickEvaluable({})).toBe('');
|
||||
expect(pickEvaluable(undefined)).toBe('');
|
||||
expect(pickEvaluable(null)).toBe('');
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -2,7 +2,7 @@
|
||||
"dxt_version": "0.1",
|
||||
"name": "hanzo-ai",
|
||||
"display_name": "Hanzo AI",
|
||||
"version": "1.8.0",
|
||||
"version": "1.8.1",
|
||||
"description": "HIP-0300 unified tool surface for Claude — 13 action-routed tools covering filesystem, execution, code semantics, git, networking, and Hanzo platform.",
|
||||
"long_description": "# Hanzo AI Extension\n\nHIP-0300 unified tool surface for Claude, built on the Model Context Protocol (MCP).\n\n## 13 Unified Tools\n\n### Core (7)\n- **fs** — read, write, edit, stat, list, mkdir, rm, mv, apply_patch, search_text\n- **exec** — run, background, ps, kill, logs\n- **code** — parse, search, transform, summarize (AST + LSP)\n- **git** — status, diff, log, commit, branch, stash\n- **fetch** — get, post, put, delete, download\n- **workspace** — info, config, env, dependencies\n- **computer** — mouse, keyboard, screen capture\n\n### Optional (6)\n- **think** — structured reasoning\n- **memory** — persistent knowledge storage\n- **hanzo** — iam, kms, paas, commerce, storage, auth, api\n- **plan** — task planning with step tracking\n- **tasks** — task management\n- **mode** — development modes\n\n## ZAP Protocol\n\nBinary WebSocket transport for zero-latency browser extension integration. All MCP methods (tools/*, resources/*, prompts/*) work identically over ZAP.\n\n## Authentication\n\nHanzo IAM (hanzo.id) for cloud features. Anonymous mode available for local-only tools.",
|
||||
"author": {
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
{
|
||||
"name": "@hanzo/dxt",
|
||||
"version": "1.8.0",
|
||||
"version": "1.8.1",
|
||||
"description": "Hanzo AI DXT Bundle",
|
||||
"main": "dist/index.js",
|
||||
"main": "server.js",
|
||||
"scripts": {
|
||||
"build": "echo 'DXT bundle created successfully'",
|
||||
"build": "pnpm --filter @hanzo/mcp run build:cli && cp ../mcp/dist/cli.js server.js && chmod +x server.js",
|
||||
"package": "npm run build"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
+55
-76
@@ -5667,7 +5667,7 @@ ${d.map((x) => `${x.key || x.name}: ${x.size || 0}b ${x.lastModified || ""}`).jo
|
||||
};
|
||||
authTool = {
|
||||
name: "auth",
|
||||
description: "Authentication: whoami, token info, account, sessions, login, logout, refresh, permissions, MFA status",
|
||||
description: "Authentication (canonical OIDC, HIP-0111): whoami/token/account read claims from /v1/iam/oauth/userinfo; refresh exchanges a refresh_token at /v1/iam/oauth/token; logout. Token issuance is via OAuth2 authorization-code + PKCE only.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
@@ -5682,10 +5682,6 @@ ${d.map((x) => `${x.key || x.name}: ${x.size || 0}b ${x.lastModified || ""}`).jo
|
||||
"permissions",
|
||||
"mfa"
|
||||
] },
|
||||
email: { type: "string" },
|
||||
password: { type: "string" },
|
||||
application: { type: "string", default: "app-hanzo" },
|
||||
organization: { type: "string", default: "hanzo" },
|
||||
refreshToken: { type: "string" }
|
||||
},
|
||||
required: ["action"]
|
||||
@@ -5695,27 +5691,21 @@ ${d.map((x) => `${x.key || x.name}: ${x.size || 0}b ${x.lastModified || ""}`).jo
|
||||
switch (args.action) {
|
||||
case "whoami":
|
||||
case "token":
|
||||
return ok(j(await api(IAM_URL, "/api/userinfo")));
|
||||
case "account":
|
||||
return ok(j(await api(IAM_URL, "/api/get-account")));
|
||||
case "login": {
|
||||
if (!args.email || !args.password)
|
||||
return fail6("email and password required");
|
||||
return ok(j(await api(IAM_URL, "/api/login", { method: "POST", body: JSON.stringify({ type: "token", username: args.email, password: args.password, application: args.application || "app-hanzo", organization: args.organization || "hanzo" }) })));
|
||||
}
|
||||
return ok(j(await api(IAM_URL, "/v1/iam/oauth/userinfo")));
|
||||
case "login":
|
||||
return fail6("login: use OAuth2 authorization-code + PKCE via /v1/iam/oauth/authorize \u2014 password login is not supported (HIP-0111)");
|
||||
case "logout":
|
||||
return ok(j(await api(IAM_URL, "/api/logout", { method: "POST" })));
|
||||
return ok(j(await api(IAM_URL, "/v1/iam/oauth/logout", { method: "POST" })));
|
||||
case "refresh": {
|
||||
if (!args.refreshToken)
|
||||
return fail6("refreshToken required");
|
||||
return ok(j(await api(IAM_URL, "/api/login", { method: "POST", body: JSON.stringify({ type: "refresh_token", refreshToken: args.refreshToken }) })));
|
||||
return ok(j(await api(IAM_URL, "/v1/iam/oauth/token", { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded" }, body: new URLSearchParams({ grant_type: "refresh_token", refresh_token: args.refreshToken }).toString() })));
|
||||
}
|
||||
case "sessions":
|
||||
return ok(j(await api(IAM_URL, "/api/get-sessions")));
|
||||
case "permissions":
|
||||
return ok(j(await api(IAM_URL, "/api/get-permissions")));
|
||||
case "mfa":
|
||||
return ok(j(await api(IAM_URL, "/api/mfa-status")));
|
||||
return fail6(`${args.action}: not available via canonical OIDC (HIP-0111); inspect token claims or use the IAM admin API`);
|
||||
default:
|
||||
return fail6(`Unknown action: ${args.action}`);
|
||||
}
|
||||
@@ -10014,9 +10004,9 @@ var init_rust = __esm({
|
||||
}
|
||||
});
|
||||
|
||||
// node_modules/jsautogui/index.js
|
||||
// ../../node_modules/.pnpm/jsautogui@1.0.6/node_modules/jsautogui/index.js
|
||||
var require_jsautogui = __commonJS({
|
||||
"node_modules/jsautogui/index.js"(exports, module) {
|
||||
"../../node_modules/.pnpm/jsautogui@1.0.6/node_modules/jsautogui/index.js"(exports, module) {
|
||||
var child_process = __require("child_process");
|
||||
var fs18 = __require("fs");
|
||||
var path19 = __require("path");
|
||||
@@ -15126,7 +15116,35 @@ async function getSystemPrompt(projectPath = process.cwd()) {
|
||||
|
||||
// src/zap-server.ts
|
||||
import { WebSocketServer } from "ws";
|
||||
var ZAP_MAGIC = Buffer.from([90, 65, 80, 1]);
|
||||
var ZAP_MAGIC = new Uint8Array([90, 65, 80, 1]);
|
||||
var HEADER_SIZE = 9;
|
||||
var textEncoder = new TextEncoder();
|
||||
var textDecoder = new TextDecoder();
|
||||
function zapEncode(type, payload) {
|
||||
const json = textEncoder.encode(JSON.stringify(payload));
|
||||
const frame = new Uint8Array(HEADER_SIZE + json.length);
|
||||
const view = new DataView(frame.buffer, frame.byteOffset, frame.byteLength);
|
||||
frame.set(ZAP_MAGIC, 0);
|
||||
view.setUint8(4, type);
|
||||
view.setUint32(5, json.length, false);
|
||||
frame.set(json, HEADER_SIZE);
|
||||
return frame;
|
||||
}
|
||||
function zapDecode(data) {
|
||||
if (data.length < HEADER_SIZE)
|
||||
return null;
|
||||
if (data[0] !== 90 || data[1] !== 65 || data[2] !== 80 || data[3] !== 1) {
|
||||
return null;
|
||||
}
|
||||
const view = new DataView(data.buffer, data.byteOffset, data.byteLength);
|
||||
const type = view.getUint8(4);
|
||||
const length = view.getUint32(5, false);
|
||||
if (data.length < HEADER_SIZE + length)
|
||||
return null;
|
||||
const jsonBytes = data.subarray(HEADER_SIZE, HEADER_SIZE + length);
|
||||
const payload = length > 0 ? JSON.parse(textDecoder.decode(jsonBytes)) : null;
|
||||
return { type, payload };
|
||||
}
|
||||
var MSG_HANDSHAKE = 1;
|
||||
var MSG_HANDSHAKE_OK = 2;
|
||||
var MSG_REQUEST = 16;
|
||||
@@ -15135,52 +15153,6 @@ var MSG_PING = 254;
|
||||
var MSG_PONG = 255;
|
||||
var ZAP_PORTS = [9999, 9998, 9997, 9996, 9995];
|
||||
var SERVER_ID = `mcp-${Date.now().toString(36)}`;
|
||||
function encode(type, payload) {
|
||||
const json = Buffer.from(JSON.stringify(payload), "utf8");
|
||||
const buf = Buffer.alloc(9 + json.length);
|
||||
ZAP_MAGIC.copy(buf, 0);
|
||||
buf[4] = type;
|
||||
buf.writeUInt32BE(json.length, 5);
|
||||
json.copy(buf, 9);
|
||||
return buf;
|
||||
}
|
||||
function decode(data) {
|
||||
if (data.length < 5)
|
||||
return null;
|
||||
if (data[0] === 90 && data[1] === 65 && data[2] === 80 && data[3] === 1) {
|
||||
if (data.length < 9)
|
||||
return null;
|
||||
const type = data[4];
|
||||
const length = data.readUInt32BE(5);
|
||||
if (data.length < 9 + length)
|
||||
return null;
|
||||
try {
|
||||
const payload = JSON.parse(data.subarray(9, 9 + length).toString("utf8"));
|
||||
return { type, payload };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
const leLength = data.readUInt32LE(0);
|
||||
if (leLength > 0 && leLength <= 16 * 1024 * 1024 && data.length >= 5 + leLength) {
|
||||
const type = data[4];
|
||||
if (type <= 69 || type >= 254) {
|
||||
const payloadBuf = data.subarray(5, 5 + leLength);
|
||||
try {
|
||||
const payload = JSON.parse(payloadBuf.toString("utf8"));
|
||||
return { type, payload };
|
||||
} catch {
|
||||
return { type, payload: { raw: payloadBuf } };
|
||||
}
|
||||
}
|
||||
}
|
||||
try {
|
||||
const payload = JSON.parse(data.toString("utf8"));
|
||||
return { type: MSG_REQUEST, payload };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
async function startZapServer(options) {
|
||||
const { tools, callTool, handleMethod, name = "hanzo-mcp" } = options;
|
||||
const clients = /* @__PURE__ */ new Map();
|
||||
@@ -15189,30 +15161,37 @@ async function startZapServer(options) {
|
||||
description: t.description || "",
|
||||
inputSchema: t.inputSchema || {}
|
||||
}));
|
||||
function sendFrame(ws, type, payload) {
|
||||
ws.send(zapEncode(type, payload));
|
||||
}
|
||||
function handleMessage(ws, raw) {
|
||||
const buf = Buffer.isBuffer(raw) ? raw : Buffer.from(raw);
|
||||
const msg = decode(buf);
|
||||
const data = new Uint8Array(buf.buffer, buf.byteOffset, buf.byteLength);
|
||||
const msg = zapDecode(data);
|
||||
if (!msg)
|
||||
return;
|
||||
switch (msg.type) {
|
||||
case MSG_HANDSHAKE: {
|
||||
const { clientId = "unknown", browser = "unknown", version = "0" } = msg.payload || {};
|
||||
const p = msg.payload || {};
|
||||
const clientId = p["clientId"] || "unknown";
|
||||
const browser = p["browser"] || "unknown";
|
||||
const version = p["version"] || "0";
|
||||
clients.set(ws, { ws, clientId, browser, version, connectedAt: Date.now() });
|
||||
console.error(`[ZAP] Client connected: ${clientId} (${browser} v${version})`);
|
||||
ws.send(encode(MSG_HANDSHAKE_OK, {
|
||||
sendFrame(ws, MSG_HANDSHAKE_OK, {
|
||||
serverId: SERVER_ID,
|
||||
name,
|
||||
tools: toolManifest
|
||||
}));
|
||||
});
|
||||
break;
|
||||
}
|
||||
case MSG_REQUEST: {
|
||||
const { id, method, params } = msg.payload || {};
|
||||
handleRequest(ws, id, method, params);
|
||||
const p = msg.payload || {};
|
||||
handleRequest(ws, p["id"], p["method"], p["params"]);
|
||||
break;
|
||||
}
|
||||
case MSG_PING:
|
||||
ws.send(encode(MSG_PONG, {}));
|
||||
sendFrame(ws, MSG_PONG, {});
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
@@ -15245,12 +15224,12 @@ async function startZapServer(options) {
|
||||
}
|
||||
break;
|
||||
}
|
||||
ws.send(encode(MSG_RESPONSE, { id, result }));
|
||||
sendFrame(ws, MSG_RESPONSE, { id, result });
|
||||
} catch (err) {
|
||||
ws.send(encode(MSG_RESPONSE, {
|
||||
sendFrame(ws, MSG_RESPONSE, {
|
||||
id,
|
||||
error: { code: -1, message: err?.message || String(err) }
|
||||
}));
|
||||
});
|
||||
}
|
||||
}
|
||||
const ports = options.preferredPort ? [options.preferredPort, ...ZAP_PORTS.filter((p) => p !== options.preferredPort)] : ZAP_PORTS;
|
||||
|
||||
@@ -51,6 +51,15 @@ tasks {
|
||||
gradleVersion = properties("gradleVersion").get()
|
||||
}
|
||||
|
||||
// buildSearchableOptions launches a headless IDE (JCEF) to pre-index plugin
|
||||
// settings — it needs native font libs (libfreetype) the CI runners lack, so
|
||||
// it crashes buildPlugin non-deterministically (only passing when the task is
|
||||
// cache-UP-TO-DATE). The index is optional; the IDE rebuilds it at runtime.
|
||||
// Disable it so buildPlugin reliably produces the distributable zip.
|
||||
buildSearchableOptions {
|
||||
enabled = false
|
||||
}
|
||||
|
||||
patchPluginXml {
|
||||
version = properties("pluginVersion")
|
||||
sinceBuild = properties("pluginSinceBuild")
|
||||
|
||||
@@ -4,7 +4,7 @@ pluginGroup = ai.hanzo
|
||||
pluginName = Hanzo AI
|
||||
pluginRepositoryUrl = https://github.com/hanzoai/hanzo-ai-jetbrains
|
||||
# SemVer format -> https://semver.org
|
||||
pluginVersion = 1.8.0
|
||||
pluginVersion = 1.9.29
|
||||
|
||||
# Supported build number ranges and IntelliJ Platform versions -> https://plugins.jetbrains.com/docs/intellij/build-number-ranges.html
|
||||
pluginSinceBuild = 233
|
||||
|
||||
@@ -58,7 +58,7 @@
|
||||
"@types/minimatch": "^5.1.2",
|
||||
"@types/node": "^20.10.5",
|
||||
"@types/ws": "^8.18.1",
|
||||
"esbuild": "^0.19.11",
|
||||
"esbuild": "^0.25.8",
|
||||
"jest": "^29.7.0",
|
||||
"ts-jest": "^29.4.1",
|
||||
"typescript": "^5.3.3"
|
||||
|
||||
@@ -904,7 +904,7 @@ export const storageTool: Tool = {
|
||||
|
||||
export const authTool: Tool = {
|
||||
name: 'auth',
|
||||
description: 'Authentication: whoami, token info, account, sessions, login, logout, refresh, permissions, MFA status',
|
||||
description: 'Authentication (canonical OIDC, HIP-0111): whoami/token/account read claims from /v1/iam/oauth/userinfo; refresh exchanges a refresh_token at /v1/iam/oauth/token; logout. Token issuance is via OAuth2 authorization-code + PKCE only.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
@@ -913,9 +913,6 @@ export const authTool: Tool = {
|
||||
'login', 'logout', 'refresh',
|
||||
'sessions', 'permissions', 'mfa'
|
||||
] },
|
||||
email: { type: 'string' }, password: { type: 'string' },
|
||||
application: { type: 'string', default: 'app-hanzo' },
|
||||
organization: { type: 'string', default: 'hanzo' },
|
||||
refreshToken: { type: 'string' }
|
||||
},
|
||||
required: ['action']
|
||||
@@ -924,20 +921,27 @@ export const authTool: Tool = {
|
||||
try {
|
||||
switch (args.action) {
|
||||
case 'whoami':
|
||||
case 'token': return ok(j(await api(IAM_URL, '/api/userinfo')));
|
||||
case 'account': return ok(j(await api(IAM_URL, '/api/get-account')));
|
||||
case 'login': {
|
||||
if (!args.email || !args.password) return fail('email and password required');
|
||||
return ok(j(await api(IAM_URL, '/api/login', { method: 'POST', body: JSON.stringify({ type: 'token', username: args.email, password: args.password, application: args.application || 'app-hanzo', organization: args.organization || 'hanzo' }) })));
|
||||
}
|
||||
case 'logout': return ok(j(await api(IAM_URL, '/api/logout', { method: 'POST' })));
|
||||
case 'token':
|
||||
case 'account': return ok(j(await api(IAM_URL, '/v1/iam/oauth/userinfo')));
|
||||
case 'login':
|
||||
// HIP-0111: hand-rolled password login is forbidden. Tokens are
|
||||
// issued only via the OAuth2 authorization-code + PKCE flow
|
||||
// (`/v1/iam/oauth/authorize` → `/v1/iam/oauth/token`). Use a
|
||||
// browser-based login client; this MCP tool cannot mint tokens.
|
||||
return fail('login: use OAuth2 authorization-code + PKCE via /v1/iam/oauth/authorize — password login is not supported (HIP-0111)');
|
||||
case 'logout': return ok(j(await api(IAM_URL, '/v1/iam/oauth/logout', { method: 'POST' })));
|
||||
case 'refresh': {
|
||||
if (!args.refreshToken) return fail('refreshToken required');
|
||||
return ok(j(await api(IAM_URL, '/api/login', { method: 'POST', body: JSON.stringify({ type: 'refresh_token', refreshToken: args.refreshToken }) })));
|
||||
return ok(j(await api(IAM_URL, '/v1/iam/oauth/token', { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: new URLSearchParams({ grant_type: 'refresh_token', refresh_token: args.refreshToken }).toString() })));
|
||||
}
|
||||
case 'sessions': return ok(j(await api(IAM_URL, '/api/get-sessions')));
|
||||
case 'permissions': return ok(j(await api(IAM_URL, '/api/get-permissions')));
|
||||
case 'mfa': return ok(j(await api(IAM_URL, '/api/mfa-status')));
|
||||
case 'sessions':
|
||||
case 'permissions':
|
||||
case 'mfa':
|
||||
// No OIDC equivalent; the legacy IAM `/api/get-sessions`,
|
||||
// `/api/get-permissions`, `/api/mfa-status` endpoints are not part
|
||||
// of the canonical HIP-0111 surface. Read claims from the token /
|
||||
// userinfo instead.
|
||||
return fail(`${args.action}: not available via canonical OIDC (HIP-0111); inspect token claims or use the IAM admin API`);
|
||||
default: return fail(`Unknown action: ${args.action}`);
|
||||
}
|
||||
} catch (e: any) { return fail(e.message); }
|
||||
|
||||
@@ -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)}`;
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
# Hanzo AI for Microsoft Office
|
||||
|
||||
A task-pane add-in that puts Hanzo AI inside **Word, Excel, and PowerPoint** —
|
||||
desktop and Office on the web. Select text or a range, open the Hanzo pane from
|
||||
the **Home** tab, ask, and insert the result. This is the surface a
|
||||
non-developer (a lawyer drafting in Word, an analyst in Excel) actually uses —
|
||||
distinct from the browser extension and the IDE extensions.
|
||||
|
||||
It reuses the **same backend as everything else Hanzo**: model calls go through
|
||||
`api.hanzo.ai/v1` (OpenAI-compatible), sign-in is Hanzo IAM (`hanzo.id`,
|
||||
authorization-code + PKCE, HIP-0111). No new API surface.
|
||||
|
||||
## What it does
|
||||
|
||||
- **Word** — read the selection; draft, rewrite, summarize, or answer; insert
|
||||
after or replace the selection.
|
||||
- **Excel** — read the selected range (as TSV); analyze/transform; write the
|
||||
result down a column from the selection.
|
||||
- **PowerPoint** — read/replace the selected text on a slide.
|
||||
|
||||
The engine of the add-in is small, pure, and unit-tested (`tests/`): the chat
|
||||
request/response shaping (`chat.ts`), the PKCE + authorize URL (`pkce.ts`,
|
||||
verified against the RFC 7636 test vector), the Excel range↔text helpers
|
||||
(`host.ts`), and the endpoint contract (`config.ts`). The Office.js glue is thin.
|
||||
|
||||
## Build
|
||||
|
||||
```bash
|
||||
pnpm --filter @hanzo/office-addin build # production (base https://office.hanzo.ai)
|
||||
pnpm --filter @hanzo/office-addin test # vitest
|
||||
```
|
||||
|
||||
The build stamps the **hosting base URL** into `dist/manifest.xml`. Office loads
|
||||
the task pane from an HTTPS origin declared in the manifest; the same origin
|
||||
serves `auth-callback.html` (the IAM redirect target, which must be registered
|
||||
on the `hanzo-office` IAM client). Override the base for local dev:
|
||||
|
||||
```bash
|
||||
HANZO_OFFICE_BASE=https://localhost:3000 pnpm --filter @hanzo/office-addin build
|
||||
```
|
||||
|
||||
## Try it (sideload — no store needed)
|
||||
|
||||
The add-in is not on AppSource yet, but sideloading gives a lawyer immediate
|
||||
use inside their own Office:
|
||||
|
||||
- **Office on the web**: open Word/Excel on the web → **Insert → Add-ins → Upload
|
||||
My Add-in** → pick `dist/manifest.xml`. (`dist/` must be served at the base URL
|
||||
over HTTPS.)
|
||||
- **Windows / Mac desktop**: put `dist/manifest.xml` in a
|
||||
[shared-folder catalog](https://learn.microsoft.com/office/dev/add-ins/testing/create-a-network-shared-folder-catalog-for-task-pane-and-content-add-ins)
|
||||
(or use `office-addin-debugging`), then **Home → Add-ins → Shared Folder**.
|
||||
- **Whole firm**: an M365 admin deploys `manifest.xml` org-wide from the
|
||||
**Microsoft 365 admin center → Integrated apps** — every user gets the Hanzo
|
||||
button with no per-person install.
|
||||
|
||||
## Publish (AppSource — public listing)
|
||||
|
||||
`dist/manifest.xml` is a valid, submittable manifest (verified by
|
||||
`office-addin-manifest validate`). To list publicly:
|
||||
|
||||
1. Host `dist/` at the production base (`office.hanzo.ai`) over HTTPS.
|
||||
2. Register the `hanzo-office` client in IAM with redirect
|
||||
`https://office.hanzo.ai/auth-callback.html`.
|
||||
3. Submit through **Microsoft Partner Center → Office Store** (needs a Partner
|
||||
Center account + Microsoft's validation pass).
|
||||
|
||||
## Not here yet
|
||||
|
||||
- **Outlook** (mail) is a different host category with its own manifest shape —
|
||||
a tracked follow-up, not this add-in.
|
||||
- Streaming responses (this v1 is a single non-streaming completion, which keeps
|
||||
the insert atomic).
|
||||
@@ -0,0 +1,55 @@
|
||||
# Test Hanzo AI in Word/Excel on Windows
|
||||
|
||||
This is the `localhost` build of the Hanzo Office add-in — a self-contained kit
|
||||
to try it on your own machine before it's on AppSource. No Hanzo infra, no IAM
|
||||
setup: paste a Hanzo API key and go.
|
||||
|
||||
## What's in this zip
|
||||
|
||||
- `manifest.xml` — the add-in manifest (points at `https://localhost:3000`)
|
||||
- `taskpane.html` / `taskpane.js` / `assets/` — the add-in itself
|
||||
- `serve.mjs` — a tiny HTTPS server for the files above
|
||||
- this guide
|
||||
|
||||
## Steps (5 minutes)
|
||||
|
||||
You need [Node.js](https://nodejs.org) installed.
|
||||
|
||||
1. **Trust a localhost HTTPS certificate** (once per machine — Office refuses an
|
||||
untrusted task-pane origin):
|
||||
|
||||
```powershell
|
||||
npx office-addin-dev-certs install
|
||||
```
|
||||
|
||||
2. **Serve the add-in** from this folder:
|
||||
|
||||
```powershell
|
||||
node serve.mjs
|
||||
```
|
||||
|
||||
Leave it running. It serves `https://localhost:3000`.
|
||||
|
||||
3. **Sideload the add-in.** Easiest path — **Word or Excel on the web**
|
||||
(office.com): open a document → **Home → Add-ins → More Add-ins → My Add-ins
|
||||
→ Upload My Add-in** → pick this folder's `manifest.xml`.
|
||||
|
||||
Desktop Word/Excel on Windows: put this folder on a
|
||||
[network shared-folder catalog](https://learn.microsoft.com/office/dev/add-ins/testing/create-a-network-shared-folder-catalog-for-task-pane-and-content-add-ins),
|
||||
trust it in **File → Options → Trust Center → Trusted Add-in Catalogs**, then
|
||||
**Home → Add-ins → Shared Folder → Hanzo AI**.
|
||||
|
||||
4. **Use it.** Click **Hanzo AI** on the Home tab to open the pane. Expand
|
||||
**"Or use a Hanzo API key"**, paste your `hk-…` key (from
|
||||
console.hanzo.ai → API keys), **Save**. Select text or a range, type an
|
||||
instruction, **Ask Hanzo**, then **Insert**.
|
||||
|
||||
## Notes
|
||||
|
||||
- The API key is stored in your Office roaming settings (per-user, encrypted at
|
||||
rest by Office) — not in this folder.
|
||||
- Sign-in with your Hanzo account (instead of an API key) works once the
|
||||
production add-in is hosted at `office.hanzo.ai`; the API key is the
|
||||
zero-setup path for local testing.
|
||||
- Model calls go to `https://api.hanzo.ai/v1` — the same gateway the browser
|
||||
extension uses.
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 1.9 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 956 B |
Binary file not shown.
|
After Width: | Height: | Size: 1.3 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 1.9 KiB |
@@ -0,0 +1,182 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!--
|
||||
Hanzo AI for Microsoft Office — task-pane add-in for Word, Excel and
|
||||
PowerPoint (desktop + Office on the web). The task pane is served from
|
||||
__HANZO_OFFICE_BASE__ (build.js stamps this: the production office.hanzo.ai
|
||||
host, or https://localhost:3000 for sideloaded dev). One add-in, three hosts:
|
||||
Document (Word), Workbook (Excel), Presentation (PowerPoint).
|
||||
|
||||
Outlook (mail) is a DIFFERENT host category with its own manifest shape; it is
|
||||
a tracked follow-up, not this file.
|
||||
-->
|
||||
<OfficeApp
|
||||
xmlns="http://schemas.microsoft.com/office/appforoffice/1.1"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xmlns:bt="http://schemas.microsoft.com/office/officeappbasictypes/1.0"
|
||||
xmlns:ov="http://schemas.microsoft.com/office/taskpaneappversionoverrides"
|
||||
xsi:type="TaskPaneApp">
|
||||
|
||||
<Id>729f93c2-86e9-4f00-8fcc-d0306ffe7129</Id>
|
||||
<Version>1.0.0.0</Version>
|
||||
<ProviderName>Hanzo AI</ProviderName>
|
||||
<DefaultLocale>en-US</DefaultLocale>
|
||||
<DisplayName DefaultValue="Hanzo AI" />
|
||||
<Description DefaultValue="AI over your document — draft, rewrite, summarize, and analyze in Word, Excel and PowerPoint. Powered by Hanzo (api.hanzo.ai)." />
|
||||
<IconUrl DefaultValue="__HANZO_OFFICE_BASE__/assets/icon-32.png" />
|
||||
<HighResolutionIconUrl DefaultValue="__HANZO_OFFICE_BASE__/assets/icon-128.png" />
|
||||
<SupportUrl DefaultValue="https://hanzo.ai/support" />
|
||||
|
||||
<!-- Domains the task pane may navigate to (IAM login, the API gateway). -->
|
||||
<AppDomains>
|
||||
<AppDomain>https://hanzo.id</AppDomain>
|
||||
<AppDomain>https://iam.hanzo.ai</AppDomain>
|
||||
<AppDomain>https://api.hanzo.ai</AppDomain>
|
||||
<AppDomain>https://hanzo.ai</AppDomain>
|
||||
</AppDomains>
|
||||
|
||||
<Hosts>
|
||||
<Host Name="Document" />
|
||||
<Host Name="Workbook" />
|
||||
<Host Name="Presentation" />
|
||||
</Hosts>
|
||||
|
||||
<DefaultSettings>
|
||||
<SourceLocation DefaultValue="__HANZO_OFFICE_BASE__/taskpane.html" />
|
||||
</DefaultSettings>
|
||||
|
||||
<!-- Read AND write: the add-in reads the selection/range and inserts AI output. -->
|
||||
<Permissions>ReadWriteDocument</Permissions>
|
||||
|
||||
<VersionOverrides xmlns="http://schemas.microsoft.com/office/taskpaneappversionoverrides" xsi:type="VersionOverridesV1_0">
|
||||
<Hosts>
|
||||
<Host xsi:type="Document">
|
||||
<DesktopFormFactor>
|
||||
<GetStarted>
|
||||
<Title resid="Hanzo.GetStarted.Title" />
|
||||
<Description resid="Hanzo.GetStarted.Description" />
|
||||
<LearnMoreUrl resid="Hanzo.SupportUrl" />
|
||||
</GetStarted>
|
||||
<FunctionFile resid="Hanzo.CommandsUrl" />
|
||||
<ExtensionPoint xsi:type="PrimaryCommandSurface">
|
||||
<OfficeTab id="TabHome">
|
||||
<Group id="Hanzo.Group">
|
||||
<Label resid="Hanzo.Group.Label" />
|
||||
<Icon>
|
||||
<bt:Image size="16" resid="Hanzo.Icon.16" />
|
||||
<bt:Image size="32" resid="Hanzo.Icon.32" />
|
||||
<bt:Image size="80" resid="Hanzo.Icon.80" />
|
||||
</Icon>
|
||||
<Control xsi:type="Button" id="Hanzo.TaskpaneButton">
|
||||
<Label resid="Hanzo.TaskpaneButton.Label" />
|
||||
<Supertip>
|
||||
<Title resid="Hanzo.TaskpaneButton.Label" />
|
||||
<Description resid="Hanzo.TaskpaneButton.Tooltip" />
|
||||
</Supertip>
|
||||
<Icon>
|
||||
<bt:Image size="16" resid="Hanzo.Icon.16" />
|
||||
<bt:Image size="32" resid="Hanzo.Icon.32" />
|
||||
<bt:Image size="80" resid="Hanzo.Icon.80" />
|
||||
</Icon>
|
||||
<Action xsi:type="ShowTaskpane">
|
||||
<TaskpaneId>Hanzo.Taskpane</TaskpaneId>
|
||||
<SourceLocation resid="Hanzo.TaskpaneUrl" />
|
||||
</Action>
|
||||
</Control>
|
||||
</Group>
|
||||
</OfficeTab>
|
||||
</ExtensionPoint>
|
||||
</DesktopFormFactor>
|
||||
</Host>
|
||||
|
||||
<Host xsi:type="Workbook">
|
||||
<DesktopFormFactor>
|
||||
<FunctionFile resid="Hanzo.CommandsUrl" />
|
||||
<ExtensionPoint xsi:type="PrimaryCommandSurface">
|
||||
<OfficeTab id="TabHome">
|
||||
<Group id="Hanzo.Group">
|
||||
<Label resid="Hanzo.Group.Label" />
|
||||
<Icon>
|
||||
<bt:Image size="16" resid="Hanzo.Icon.16" />
|
||||
<bt:Image size="32" resid="Hanzo.Icon.32" />
|
||||
<bt:Image size="80" resid="Hanzo.Icon.80" />
|
||||
</Icon>
|
||||
<Control xsi:type="Button" id="Hanzo.TaskpaneButton">
|
||||
<Label resid="Hanzo.TaskpaneButton.Label" />
|
||||
<Supertip>
|
||||
<Title resid="Hanzo.TaskpaneButton.Label" />
|
||||
<Description resid="Hanzo.TaskpaneButton.Tooltip" />
|
||||
</Supertip>
|
||||
<Icon>
|
||||
<bt:Image size="16" resid="Hanzo.Icon.16" />
|
||||
<bt:Image size="32" resid="Hanzo.Icon.32" />
|
||||
<bt:Image size="80" resid="Hanzo.Icon.80" />
|
||||
</Icon>
|
||||
<Action xsi:type="ShowTaskpane">
|
||||
<TaskpaneId>Hanzo.Taskpane</TaskpaneId>
|
||||
<SourceLocation resid="Hanzo.TaskpaneUrl" />
|
||||
</Action>
|
||||
</Control>
|
||||
</Group>
|
||||
</OfficeTab>
|
||||
</ExtensionPoint>
|
||||
</DesktopFormFactor>
|
||||
</Host>
|
||||
|
||||
<Host xsi:type="Presentation">
|
||||
<DesktopFormFactor>
|
||||
<FunctionFile resid="Hanzo.CommandsUrl" />
|
||||
<ExtensionPoint xsi:type="PrimaryCommandSurface">
|
||||
<OfficeTab id="TabHome">
|
||||
<Group id="Hanzo.Group">
|
||||
<Label resid="Hanzo.Group.Label" />
|
||||
<Icon>
|
||||
<bt:Image size="16" resid="Hanzo.Icon.16" />
|
||||
<bt:Image size="32" resid="Hanzo.Icon.32" />
|
||||
<bt:Image size="80" resid="Hanzo.Icon.80" />
|
||||
</Icon>
|
||||
<Control xsi:type="Button" id="Hanzo.TaskpaneButton">
|
||||
<Label resid="Hanzo.TaskpaneButton.Label" />
|
||||
<Supertip>
|
||||
<Title resid="Hanzo.TaskpaneButton.Label" />
|
||||
<Description resid="Hanzo.TaskpaneButton.Tooltip" />
|
||||
</Supertip>
|
||||
<Icon>
|
||||
<bt:Image size="16" resid="Hanzo.Icon.16" />
|
||||
<bt:Image size="32" resid="Hanzo.Icon.32" />
|
||||
<bt:Image size="80" resid="Hanzo.Icon.80" />
|
||||
</Icon>
|
||||
<Action xsi:type="ShowTaskpane">
|
||||
<TaskpaneId>Hanzo.Taskpane</TaskpaneId>
|
||||
<SourceLocation resid="Hanzo.TaskpaneUrl" />
|
||||
</Action>
|
||||
</Control>
|
||||
</Group>
|
||||
</OfficeTab>
|
||||
</ExtensionPoint>
|
||||
</DesktopFormFactor>
|
||||
</Host>
|
||||
</Hosts>
|
||||
|
||||
<Resources>
|
||||
<bt:Images>
|
||||
<bt:Image id="Hanzo.Icon.16" DefaultValue="__HANZO_OFFICE_BASE__/assets/icon-16.png" />
|
||||
<bt:Image id="Hanzo.Icon.32" DefaultValue="__HANZO_OFFICE_BASE__/assets/icon-32.png" />
|
||||
<bt:Image id="Hanzo.Icon.80" DefaultValue="__HANZO_OFFICE_BASE__/assets/icon-80.png" />
|
||||
</bt:Images>
|
||||
<bt:Urls>
|
||||
<bt:Url id="Hanzo.TaskpaneUrl" DefaultValue="__HANZO_OFFICE_BASE__/taskpane.html" />
|
||||
<bt:Url id="Hanzo.CommandsUrl" DefaultValue="__HANZO_OFFICE_BASE__/commands.html" />
|
||||
<bt:Url id="Hanzo.SupportUrl" DefaultValue="https://hanzo.ai/support" />
|
||||
</bt:Urls>
|
||||
<bt:ShortStrings>
|
||||
<bt:String id="Hanzo.Group.Label" DefaultValue="Hanzo AI" />
|
||||
<bt:String id="Hanzo.TaskpaneButton.Label" DefaultValue="Hanzo AI" />
|
||||
<bt:String id="Hanzo.GetStarted.Title" DefaultValue="Hanzo AI is ready" />
|
||||
</bt:ShortStrings>
|
||||
<bt:LongStrings>
|
||||
<bt:String id="Hanzo.TaskpaneButton.Tooltip" DefaultValue="Open the Hanzo AI pane to draft, rewrite, summarize, or analyze your selection." />
|
||||
<bt:String id="Hanzo.GetStarted.Description" DefaultValue="Select text or a range, open the Hanzo AI pane from the Home tab, and ask." />
|
||||
</bt:LongStrings>
|
||||
</Resources>
|
||||
</VersionOverrides>
|
||||
</OfficeApp>
|
||||
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"name": "@hanzo/office-addin",
|
||||
"version": "1.9.29",
|
||||
"description": "Hanzo AI for Microsoft Office \u2014 Word, Excel, and PowerPoint task pane. AI over your document, wired to the same api.hanzo.ai gateway and hanzo.id IAM as the browser extension.",
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"@hanzo/auth": "workspace:*"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "node src/build.js",
|
||||
"watch": "node src/build.js --watch",
|
||||
"test": "vitest run"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/office-js": "^1.0.377",
|
||||
"esbuild": "^0.25.8",
|
||||
"typescript": "^5.8.3",
|
||||
"vitest": "^3.2.6"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
},
|
||||
"license": "MIT"
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
// Self-contained HTTPS static server for local Office sideload testing on any
|
||||
// machine (Windows included). It serves the files next to it — the built
|
||||
// dist/ — at https://localhost:3000, which is the base a `localhost` build of
|
||||
// the add-in points its manifest at. No framework, only Node stdlib.
|
||||
//
|
||||
// 1. npx office-addin-dev-certs install # trust a localhost HTTPS cert (once)
|
||||
// 2. node serve.mjs # serve dist/ at https://localhost:3000
|
||||
// 3. sideload manifest.xml (see SIDELOAD-WINDOWS.md)
|
||||
//
|
||||
// The cert/key are read from the office-addin-dev-certs default location
|
||||
// (~/.office-addin-dev-certs). If they are absent the script prints the exact
|
||||
// install command instead of failing cryptically.
|
||||
|
||||
import { createServer } from 'node:https';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import { existsSync } from 'node:fs';
|
||||
import { join, extname, normalize } from 'node:path';
|
||||
import { homedir } from 'node:os';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const ROOT = fileURLToPath(new URL('.', import.meta.url));
|
||||
const PORT = Number(process.env.PORT || 3000);
|
||||
const CERT_DIR = join(homedir(), '.office-addin-dev-certs');
|
||||
|
||||
const TYPES = {
|
||||
'.html': 'text/html; charset=utf-8',
|
||||
'.js': 'text/javascript; charset=utf-8',
|
||||
'.map': 'application/json',
|
||||
'.json': 'application/json',
|
||||
'.xml': 'text/xml',
|
||||
'.png': 'image/png',
|
||||
'.css': 'text/css',
|
||||
};
|
||||
|
||||
async function loadCerts() {
|
||||
const cert = join(CERT_DIR, 'localhost.crt');
|
||||
const key = join(CERT_DIR, 'localhost.key');
|
||||
if (!existsSync(cert) || !existsSync(key)) {
|
||||
console.error(
|
||||
'No localhost HTTPS certificate found.\n' +
|
||||
'Install a trusted one first (Office refuses an untrusted task-pane origin):\n\n' +
|
||||
' npx office-addin-dev-certs install\n',
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
return { cert: await readFile(cert), key: await readFile(key) };
|
||||
}
|
||||
|
||||
const { cert, key } = await loadCerts();
|
||||
|
||||
createServer({ cert, key }, async (req, res) => {
|
||||
// Resolve the request path safely under ROOT (no traversal).
|
||||
let rel = decodeURIComponent((req.url || '/').split('?')[0]);
|
||||
if (rel === '/') rel = '/taskpane.html';
|
||||
const path = normalize(join(ROOT, rel));
|
||||
if (!path.startsWith(ROOT)) {
|
||||
res.writeHead(403).end('forbidden');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const body = await readFile(path);
|
||||
res.writeHead(200, {
|
||||
'Content-Type': TYPES[extname(path)] || 'application/octet-stream',
|
||||
// Office loads the pane in an iframe; allow it and permit the API host.
|
||||
'Access-Control-Allow-Origin': '*',
|
||||
});
|
||||
res.end(body);
|
||||
} catch {
|
||||
res.writeHead(404).end('not found: ' + rel);
|
||||
}
|
||||
}).listen(PORT, () => {
|
||||
console.log(`Hanzo Office add-in served at https://localhost:${PORT}`);
|
||||
console.log('Sideload manifest.xml — see SIDELOAD-WINDOWS.md.');
|
||||
});
|
||||
@@ -0,0 +1,23 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<title>Signing in…</title>
|
||||
<script src="https://appsforoffice.microsoft.com/lib/1/hosted/office.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<p>Completing sign-in…</p>
|
||||
<script>
|
||||
// The IAM redirect lands here inside the Office auth dialog. Hand the full
|
||||
// callback URL (carrying ?code=&state=) back to the task pane via
|
||||
// messageParent, then the pane exchanges the code for a token.
|
||||
Office.onReady(function () {
|
||||
try {
|
||||
Office.context.ui.messageParent(window.location.href);
|
||||
} catch (e) {
|
||||
document.body.innerText = 'Sign-in could not return to the add-in: ' + e;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,131 @@
|
||||
// Office's auth = the @hanzo/auth core + two thin Office adapters. The OAuth2 +
|
||||
// PKCE flow, endpoints, and refresh live ONCE in @hanzo/auth; here we only teach
|
||||
// it how Office stores tokens (roamingSettings) and how it opens a login window
|
||||
// (the Dialog API). Plus the office-only API-key path for zero-setup use.
|
||||
|
||||
/* global Office */
|
||||
|
||||
import {
|
||||
authClientFor, login as coreLogin, logout as coreLogout, getValidToken,
|
||||
type TokenStore, type TokenSet, type Opener,
|
||||
} from '@hanzo/auth';
|
||||
import { APIKEY_SETTING_KEY, TOKEN_SETTING_KEY, pickBearer } from './config.js';
|
||||
|
||||
// The office IAM client, with the redirect served from the add-in's own origin
|
||||
// (registered on the hanzo-office IAM client).
|
||||
function client() {
|
||||
return authClientFor('office', { redirectUri: `${location.origin}/auth-callback.html` });
|
||||
}
|
||||
|
||||
// roamingStore is the Office TokenStore: roamingSettings is per-user and roams
|
||||
// with the Office identity. The TokenSet is JSON-serialized under one key.
|
||||
const roamingStore: TokenStore = {
|
||||
async get(): Promise<TokenSet | null> {
|
||||
try {
|
||||
const raw = Office.context.roamingSettings.get(TOKEN_SETTING_KEY) as string;
|
||||
return raw ? (JSON.parse(raw) as TokenSet) : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
},
|
||||
set(tokens: TokenSet): Promise<void> {
|
||||
return saveRoaming(TOKEN_SETTING_KEY, JSON.stringify(tokens));
|
||||
},
|
||||
clear(): Promise<void> {
|
||||
return removeRoaming(TOKEN_SETTING_KEY);
|
||||
},
|
||||
};
|
||||
|
||||
// dialogOpener is the Office Opener: it opens the authorize URL in an Office
|
||||
// dialog and resolves with the callback URL the dialog posts back via
|
||||
// messageParent (auth-callback.html does that post).
|
||||
const dialogOpener: Opener = {
|
||||
open(authorizeUrl: string): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
Office.context.ui.displayDialogAsync(authorizeUrl, { height: 60, width: 30 }, (result) => {
|
||||
if (result.status !== Office.AsyncResultStatus.Succeeded) {
|
||||
reject(new Error(result.error?.message || 'could not open login dialog'));
|
||||
return;
|
||||
}
|
||||
const dialog = result.value;
|
||||
dialog.addEventHandler(Office.EventType.DialogMessageReceived, (arg: any) => {
|
||||
dialog.close();
|
||||
resolve(String(arg.message || ''));
|
||||
});
|
||||
dialog.addEventHandler(Office.EventType.DialogEventReceived, () => {
|
||||
reject(new Error('login dialog closed'));
|
||||
});
|
||||
});
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
// signIn / clearToken / getToken delegate to the core with the Office adapters.
|
||||
export async function signIn(): Promise<string> {
|
||||
const tokens = await coreLogin(client(), roamingStore, dialogOpener);
|
||||
return tokens.accessToken;
|
||||
}
|
||||
export function clearToken(): Promise<void> {
|
||||
return coreLogout(roamingStore);
|
||||
}
|
||||
// getToken returns a valid (auto-refreshed) access token, or '' if not signed in.
|
||||
export async function getToken(): Promise<string> {
|
||||
return getValidToken(client(), roamingStore);
|
||||
}
|
||||
|
||||
// ── API-key path (office-only, zero-setup) ────────────────────────────────
|
||||
|
||||
export function getApiKey(): string {
|
||||
try {
|
||||
return (Office.context.roamingSettings.get(APIKEY_SETTING_KEY) as string) || '';
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
export function setApiKey(key: string): Promise<void> {
|
||||
return saveRoaming(APIKEY_SETTING_KEY, key.trim());
|
||||
}
|
||||
export function clearApiKey(): Promise<void> {
|
||||
return removeRoaming(APIKEY_SETTING_KEY);
|
||||
}
|
||||
|
||||
// bearer is the credential the chat call sends: a pasted API key wins over the
|
||||
// OAuth token (see pickBearer). Async because the OAuth token may refresh.
|
||||
export async function bearer(): Promise<string> {
|
||||
const key = getApiKey();
|
||||
if (key) return key;
|
||||
return getValidToken(client(), roamingStore);
|
||||
}
|
||||
|
||||
// hasApiKey / hasSession are SYNC reflect checks for the pane (no refresh, no
|
||||
// network) — used only to render which credential is live, not to authorize.
|
||||
export function hasApiKey(): boolean {
|
||||
return !!getApiKey();
|
||||
}
|
||||
export function hasSession(): boolean {
|
||||
try {
|
||||
return !!(Office.context.roamingSettings.get(TOKEN_SETTING_KEY) as string);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// ── roamingSettings helpers ───────────────────────────────────────────────
|
||||
|
||||
function saveRoaming(key: string, value: string): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
Office.context.roamingSettings.set(key, value);
|
||||
Office.context.roamingSettings.saveAsync((r) => {
|
||||
r.status === Office.AsyncResultStatus.Succeeded ? resolve() : reject(new Error('save failed'));
|
||||
});
|
||||
});
|
||||
}
|
||||
function removeRoaming(key: string): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
Office.context.roamingSettings.remove(key);
|
||||
Office.context.roamingSettings.saveAsync(() => resolve());
|
||||
});
|
||||
}
|
||||
|
||||
// pickBearer re-export kept for callers/tests that import it from auth.
|
||||
export { pickBearer };
|
||||
@@ -0,0 +1,84 @@
|
||||
// Build the Office add-in into dist/: bundle the TS entry points, copy the
|
||||
// static HTML + assets, and stamp the hosting base URL into a manifest.xml the
|
||||
// user can sideload or submit to AppSource.
|
||||
//
|
||||
// node src/build.js → production (HANZO_OFFICE_BASE=https://office.hanzo.ai)
|
||||
// HANZO_OFFICE_BASE=https://localhost:3000 node src/build.js → dev sideload
|
||||
//
|
||||
// The base URL is where the task pane is HOSTED. Office add-ins load the pane
|
||||
// from an HTTPS origin declared in the manifest; the same origin serves
|
||||
// auth-callback.html (the IAM redirect target registered on the hanzo-office
|
||||
// client). One string, stamped everywhere, so the manifest and the served
|
||||
// files never disagree.
|
||||
|
||||
const esbuild = require('esbuild');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const BASE = (process.env.HANZO_OFFICE_BASE || 'https://office.hanzo.ai').replace(/\/+$/, '');
|
||||
const watch = process.argv.includes('--watch');
|
||||
const root = path.resolve(__dirname, '..');
|
||||
const dist = path.join(root, 'dist');
|
||||
|
||||
const entries = ['taskpane', 'commands'];
|
||||
|
||||
async function build() {
|
||||
fs.rmSync(dist, { recursive: true, force: true });
|
||||
fs.mkdirSync(dist, { recursive: true });
|
||||
|
||||
// Bundle each entry to dist/<name>.js.
|
||||
const ctx = await esbuild.context({
|
||||
entryPoints: entries.map((e) => path.join(__dirname, `${e}.ts`)),
|
||||
outdir: dist,
|
||||
bundle: true,
|
||||
format: 'iife',
|
||||
platform: 'browser',
|
||||
target: ['chrome90', 'edge90', 'safari14'],
|
||||
sourcemap: true,
|
||||
minify: !watch,
|
||||
logLevel: 'info',
|
||||
});
|
||||
await ctx.rebuild();
|
||||
|
||||
// Copy static HTML (taskpane, commands, auth-callback).
|
||||
for (const f of ['taskpane.html', 'commands.html', 'auth-callback.html']) {
|
||||
fs.copyFileSync(path.join(__dirname, f), path.join(dist, f));
|
||||
}
|
||||
|
||||
// Copy the local-test kit (self-contained HTTPS server + Windows guide) so a
|
||||
// downloaded localhost build is runnable as-is.
|
||||
for (const f of ['serve.mjs', 'SIDELOAD-WINDOWS.md']) {
|
||||
fs.copyFileSync(path.join(root, f), path.join(dist, f));
|
||||
}
|
||||
|
||||
// Copy assets/ (icons).
|
||||
const assetsSrc = path.join(root, 'assets');
|
||||
const assetsDst = path.join(dist, 'assets');
|
||||
fs.mkdirSync(assetsDst, { recursive: true });
|
||||
for (const f of fs.readdirSync(assetsSrc)) {
|
||||
fs.copyFileSync(path.join(assetsSrc, f), path.join(assetsDst, f));
|
||||
}
|
||||
|
||||
// Stamp the base URL into the manifest and write it into dist/.
|
||||
const manifest = fs.readFileSync(path.join(root, 'manifest.xml'), 'utf8');
|
||||
const stamped = manifest.split('__HANZO_OFFICE_BASE__').join(BASE);
|
||||
if (stamped.includes('__HANZO_OFFICE_BASE__')) {
|
||||
throw new Error('manifest still has an unstamped __HANZO_OFFICE_BASE__ placeholder');
|
||||
}
|
||||
fs.writeFileSync(path.join(dist, 'manifest.xml'), stamped);
|
||||
|
||||
console.log(`✅ Office add-in built → dist/ (base ${BASE})`);
|
||||
console.log(' Sideload dist/manifest.xml; serve dist/ at that base over HTTPS.');
|
||||
|
||||
if (watch) {
|
||||
await ctx.watch();
|
||||
console.log('👀 watching…');
|
||||
} else {
|
||||
await ctx.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
build().catch((e) => {
|
||||
console.error(e);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,182 @@
|
||||
// The Hanzo chat call and its request/response shaping — pure, host-agnostic,
|
||||
// and fully unit-testable (no Office.js, no DOM). taskpane.ts is the thin glue
|
||||
// that reads the document via Office.js and hands the text here.
|
||||
|
||||
import { chatCompletionsURL, modelsURL, DEFAULT_MODEL } from './config.js';
|
||||
|
||||
export interface ChatMessage {
|
||||
role: 'system' | 'user' | 'assistant';
|
||||
content: string;
|
||||
}
|
||||
|
||||
// buildMessages turns a task (the user's instruction) plus the document
|
||||
// selection into the OpenAI-compatible message list. An empty selection is a
|
||||
// pure generation ("draft a NDA clause about X"); a non-empty selection is an
|
||||
// operation ON the text ("rewrite this formally"), and the selection is fenced
|
||||
// so the model treats it as data, not instructions.
|
||||
export function buildMessages(task: string, selection: string): ChatMessage[] {
|
||||
const system: ChatMessage = {
|
||||
role: 'system',
|
||||
content:
|
||||
'You are Hanzo AI inside a Microsoft Office document. Return ONLY the text to ' +
|
||||
'insert into the document — no preamble, no markdown fences, no "Here is". ' +
|
||||
'Match the document\'s tone. When given a selection, operate on it directly.',
|
||||
};
|
||||
const trimmed = selection.trim();
|
||||
const user: ChatMessage = {
|
||||
role: 'user',
|
||||
content: trimmed
|
||||
? `${task}\n\n---- selected text ----\n${selection}`
|
||||
: task,
|
||||
};
|
||||
return [system, user];
|
||||
}
|
||||
|
||||
export interface ChatOptions {
|
||||
model?: string;
|
||||
temperature?: number;
|
||||
/** Request an SSE stream. Defaults to false (one-shot completion). */
|
||||
stream?: boolean;
|
||||
signal?: AbortSignal;
|
||||
}
|
||||
|
||||
// requestBody is the exact JSON the gateway receives. Separated so a test can
|
||||
// assert the wire shape (model default, stream flag, messages) without a
|
||||
// network call. `stream` defaults to false — the one-shot `ask` contract.
|
||||
export function requestBody(messages: ChatMessage[], opts: ChatOptions = {}): Record<string, unknown> {
|
||||
return {
|
||||
model: opts.model || DEFAULT_MODEL,
|
||||
messages,
|
||||
stream: opts.stream === true,
|
||||
...(opts.temperature !== undefined ? { temperature: opts.temperature } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
// extractContent pulls the assistant text out of an OpenAI-compatible
|
||||
// completion response, tolerating the shapes the gateway returns (choices with
|
||||
// message.content, or a bare content). Throws on an error payload so the pane
|
||||
// surfaces the real gateway message.
|
||||
export function extractContent(data: any): string {
|
||||
if (data && data.error) {
|
||||
const msg = typeof data.error === 'string' ? data.error : data.error.message || 'unknown error';
|
||||
throw new Error(`Hanzo API error: ${msg}`);
|
||||
}
|
||||
const choice = data?.choices?.[0];
|
||||
const content =
|
||||
choice?.message?.content ??
|
||||
choice?.text ??
|
||||
data?.content ??
|
||||
'';
|
||||
if (typeof content !== 'string' || content === '') {
|
||||
throw new Error('Hanzo API returned no content');
|
||||
}
|
||||
return content;
|
||||
}
|
||||
|
||||
// ask runs one non-streaming completion and returns the text to insert. token
|
||||
// may be empty (the gateway serves anonymous/limited models); when present it
|
||||
// is the IAM bearer.
|
||||
export async function ask(
|
||||
task: string,
|
||||
selection: string,
|
||||
token: string,
|
||||
opts: ChatOptions = {},
|
||||
): Promise<string> {
|
||||
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
|
||||
if (token) headers.Authorization = `Bearer ${token}`;
|
||||
const resp = await fetch(chatCompletionsURL(), {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify(requestBody(buildMessages(task, selection), opts)),
|
||||
signal: opts.signal,
|
||||
});
|
||||
const text = await resp.text();
|
||||
let data: any;
|
||||
try {
|
||||
data = JSON.parse(text);
|
||||
} catch {
|
||||
throw new Error(`Hanzo API ${resp.status}: ${text.slice(0, 200)}`);
|
||||
}
|
||||
if (!resp.ok) {
|
||||
// Prefer the structured error message; fall back to status.
|
||||
try {
|
||||
return extractContent(data);
|
||||
} catch {
|
||||
const m = data?.error?.message || data?.error || `HTTP ${resp.status}`;
|
||||
throw new Error(`Hanzo API ${resp.status}: ${m}`);
|
||||
}
|
||||
}
|
||||
return extractContent(data);
|
||||
}
|
||||
|
||||
// streamDelta pulls the incremental text out of one OpenAI-compatible SSE frame
|
||||
// payload (the JSON after `data: `). Pure, so the SSE parse is unit-testable.
|
||||
export function streamDelta(payload: string): string {
|
||||
const json = JSON.parse(payload);
|
||||
return json?.choices?.[0]?.delta?.content ?? '';
|
||||
}
|
||||
|
||||
// askStream runs a streaming completion, invoking onDelta with each text chunk
|
||||
// as it arrives, and resolves with the full concatenated text. Same inputs as
|
||||
// `ask`; use it for a live-typing pane. Falls back to a thrown error on non-2xx.
|
||||
export async function askStream(
|
||||
task: string,
|
||||
selection: string,
|
||||
token: string,
|
||||
onDelta: (text: string) => void,
|
||||
opts: ChatOptions = {},
|
||||
): Promise<string> {
|
||||
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
|
||||
if (token) headers.Authorization = `Bearer ${token}`;
|
||||
const resp = await fetch(chatCompletionsURL(), {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify(requestBody(buildMessages(task, selection), { ...opts, stream: true })),
|
||||
signal: opts.signal,
|
||||
});
|
||||
if (!resp.ok || !resp.body) {
|
||||
const detail = await resp.text().catch(() => '');
|
||||
throw new Error(`Hanzo API ${resp.status}${detail ? `: ${detail.slice(0, 200)}` : ''}`);
|
||||
}
|
||||
const reader = resp.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = '';
|
||||
let full = '';
|
||||
for (;;) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
const lines = buffer.split('\n');
|
||||
buffer = lines.pop() ?? '';
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed.startsWith('data:')) continue;
|
||||
const payload = trimmed.slice(5).trim();
|
||||
if (payload === '[DONE]') return full;
|
||||
try {
|
||||
const delta = streamDelta(payload);
|
||||
if (delta) {
|
||||
full += delta;
|
||||
onDelta(delta);
|
||||
}
|
||||
} catch {
|
||||
// partial SSE frame — wait for the next chunk
|
||||
}
|
||||
}
|
||||
}
|
||||
return full;
|
||||
}
|
||||
|
||||
// listModels returns the model ids the caller may route to, from /v1/models.
|
||||
// The endpoint is org-scoped by the bearer; an empty token lists public models.
|
||||
export async function listModels(token: string): Promise<string[]> {
|
||||
const headers: Record<string, string> = {};
|
||||
if (token) headers.Authorization = `Bearer ${token}`;
|
||||
const resp = await fetch(modelsURL(), { headers });
|
||||
if (!resp.ok) throw new Error(`Hanzo API ${resp.status}: model list failed`);
|
||||
const data: any = await resp.json();
|
||||
const items = data?.data ?? data?.models ?? (Array.isArray(data) ? data : []);
|
||||
return items
|
||||
.map((m: any) => (typeof m === 'string' ? m : m?.id))
|
||||
.filter((id: unknown): id is string => typeof id === 'string' && id.length > 0);
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<title>Hanzo AI commands</title>
|
||||
<script src="https://appsforoffice.microsoft.com/lib/1/hosted/office.js"></script>
|
||||
<script src="commands.js"></script>
|
||||
</head>
|
||||
<body></body>
|
||||
</html>
|
||||
@@ -0,0 +1,10 @@
|
||||
// The ribbon FunctionFile. Office loads commands.html (which loads this) for
|
||||
// any Action of type ExecuteFunction. The Hanzo add-in's only ribbon control is
|
||||
// ShowTaskpane (no ExecuteFunction), so this exists to satisfy the manifest's
|
||||
// FunctionFile requirement and to host future one-click ribbon actions.
|
||||
|
||||
/* global Office */
|
||||
|
||||
Office.onReady(() => {
|
||||
/* no-op: the ribbon button opens the task pane directly */
|
||||
});
|
||||
@@ -0,0 +1,30 @@
|
||||
// Office add-in config — ONLY what is office-specific. IAM endpoints, the OAuth
|
||||
// client, PKCE, and scopes now live in @hanzo/auth (the one auth core); this
|
||||
// module keeps the api.hanzo.ai model gateway + the roaming-settings keys.
|
||||
|
||||
export const API_BASE_URL = 'https://api.hanzo.ai';
|
||||
|
||||
// Default model. Overridable per-request; the gateway routes it.
|
||||
export const DEFAULT_MODEL = 'zen-1';
|
||||
|
||||
// Roaming-settings keys (Office.context.roamingSettings persists per-user across
|
||||
// the user's devices). Two auth paths: the IAM OAuth token set (managed by
|
||||
// @hanzo/auth) and a pasted Hanzo API key (`hk-…`) for zero-setup use — the key
|
||||
// needs no registered OAuth client, which is why the Windows test kit uses it.
|
||||
export const TOKEN_SETTING_KEY = 'hanzo.tokens';
|
||||
export const APIKEY_SETTING_KEY = 'hanzo.apiKey';
|
||||
|
||||
// pickBearer chooses the credential to send: a pasted API key wins over the
|
||||
// OAuth token (an explicit key is a deliberate override), else the OAuth token,
|
||||
// else empty (anonymous). Pure — unit-tested.
|
||||
export function pickBearer(apiKey: string, oauthToken: string): string {
|
||||
return (apiKey && apiKey.trim()) || (oauthToken && oauthToken.trim()) || '';
|
||||
}
|
||||
|
||||
// chatCompletionsURL / modelsURL — the model gateway endpoints the add-in calls.
|
||||
export function chatCompletionsURL(): string {
|
||||
return `${API_BASE_URL}/v1/chat/completions`;
|
||||
}
|
||||
export function modelsURL(): string {
|
||||
return `${API_BASE_URL}/v1/models`;
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
// The Office.js host glue: read the user's selection and insert AI output, one
|
||||
// path per host (Word / Excel / PowerPoint). The Office-calling functions are
|
||||
// intentionally thin; the shaping helpers (excelValuesToText, splitLines) are
|
||||
// pure and unit-tested.
|
||||
|
||||
/* global Office, Word, Excel, PowerPoint */
|
||||
|
||||
export type HostKind = 'word' | 'excel' | 'powerpoint' | 'unknown';
|
||||
export type InsertMode = 'replace' | 'after';
|
||||
|
||||
// hostKind maps the Office host enum to our small union. Pure given the enum.
|
||||
export function hostKind(host: Office.HostType | undefined): HostKind {
|
||||
switch (host) {
|
||||
case Office.HostType.Word:
|
||||
return 'word';
|
||||
case Office.HostType.Excel:
|
||||
return 'excel';
|
||||
case Office.HostType.PowerPoint:
|
||||
return 'powerpoint';
|
||||
default:
|
||||
return 'unknown';
|
||||
}
|
||||
}
|
||||
|
||||
// excelValuesToText flattens a selected range's 2-D values into TSV-ish text the
|
||||
// model can read: rows joined by newline, cells by tab, nulls as empty. Pure.
|
||||
export function excelValuesToText(values: unknown[][]): string {
|
||||
if (!values || values.length === 0) return '';
|
||||
return values
|
||||
.map((row) => row.map((c) => (c === null || c === undefined ? '' : String(c))).join('\t'))
|
||||
.join('\n');
|
||||
}
|
||||
|
||||
// splitLines turns model output back into a 1-column, N-row 2-D array for
|
||||
// writing into Excel (one line per cell down a column). Pure. Trailing blank
|
||||
// lines are dropped so a stray newline doesn't clear a cell.
|
||||
export function splitLines(text: string): string[][] {
|
||||
const lines = text.replace(/\n+$/, '').split('\n');
|
||||
return lines.map((l) => [l]);
|
||||
}
|
||||
|
||||
// currentHost reads the live Office host.
|
||||
export function currentHost(): HostKind {
|
||||
return hostKind(Office.context?.host);
|
||||
}
|
||||
|
||||
// readSelection returns the user's current selection as plain text, per host.
|
||||
export async function readSelection(): Promise<string> {
|
||||
switch (currentHost()) {
|
||||
case 'word':
|
||||
return await new Promise<string>((resolve, reject) => {
|
||||
Word.run(async (ctx) => {
|
||||
const sel = ctx.document.getSelection();
|
||||
sel.load('text');
|
||||
await ctx.sync();
|
||||
resolve(sel.text || '');
|
||||
}).catch(reject);
|
||||
});
|
||||
case 'excel':
|
||||
return await new Promise<string>((resolve, reject) => {
|
||||
Excel.run(async (ctx) => {
|
||||
const range = ctx.workbook.getSelectedRange();
|
||||
range.load('values');
|
||||
await ctx.sync();
|
||||
resolve(excelValuesToText(range.values as unknown[][]));
|
||||
}).catch(reject);
|
||||
});
|
||||
case 'powerpoint':
|
||||
return await new Promise<string>((resolve) => {
|
||||
Office.context.document.getSelectedDataAsync(Office.CoercionType.Text, (r) => {
|
||||
resolve(r.status === Office.AsyncResultStatus.Succeeded ? String(r.value || '') : '');
|
||||
});
|
||||
});
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
// insertResult writes the AI output back into the document. `mode` chooses
|
||||
// replace-the-selection vs insert-after; Excel always writes down the current
|
||||
// column from the selection's top-left (a spreadsheet has no "after cursor").
|
||||
export async function insertResult(text: string, mode: InsertMode): Promise<void> {
|
||||
switch (currentHost()) {
|
||||
case 'word':
|
||||
await Word.run(async (ctx) => {
|
||||
const sel = ctx.document.getSelection();
|
||||
const loc = mode === 'replace' ? Word.InsertLocation.replace : Word.InsertLocation.after;
|
||||
sel.insertText(text, loc);
|
||||
await ctx.sync();
|
||||
});
|
||||
return;
|
||||
case 'excel':
|
||||
await Excel.run(async (ctx) => {
|
||||
const start = ctx.workbook.getSelectedRange().getCell(0, 0);
|
||||
const rows = splitLines(text);
|
||||
const target = start.getResizedRange(rows.length - 1, 0);
|
||||
target.values = rows;
|
||||
await ctx.sync();
|
||||
});
|
||||
return;
|
||||
case 'powerpoint':
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
Office.context.document.setSelectedDataAsync(text, (r) => {
|
||||
r.status === Office.AsyncResultStatus.Succeeded
|
||||
? resolve()
|
||||
: reject(new Error(r.error?.message || 'insert failed'));
|
||||
});
|
||||
});
|
||||
return;
|
||||
default:
|
||||
throw new Error('Unsupported Office host');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>Hanzo AI</title>
|
||||
<script src="https://appsforoffice.microsoft.com/lib/1/hosted/office.js"></script>
|
||||
<style>
|
||||
:root { color-scheme: light dark; --fg:#1a1a1a; --muted:#666; --bg:#fff; --line:#e3e3e3; --accent:#111; }
|
||||
@media (prefers-color-scheme: dark) { :root { --fg:#eaeaea; --muted:#9a9a9a; --bg:#1e1e1e; --line:#333; --accent:#fff; } }
|
||||
* { box-sizing: border-box; }
|
||||
body { font: 14px/1.45 -apple-system, "Segoe UI", Roboto, sans-serif; color: var(--fg); background: var(--bg); margin: 0; padding: 12px; }
|
||||
header { display: flex; align-items: center; gap: 8px; margin-bottom: 10px; }
|
||||
header img { width: 22px; height: 22px; }
|
||||
header .title { font-weight: 600; }
|
||||
header .host { color: var(--muted); font-size: 12px; margin-left: auto; }
|
||||
label { display: block; font-size: 12px; color: var(--muted); margin: 8px 0 4px; }
|
||||
textarea, select { width: 100%; font: inherit; color: var(--fg); background: var(--bg); border: 1px solid var(--line); border-radius: 6px; padding: 8px; }
|
||||
textarea#prompt { min-height: 72px; resize: vertical; }
|
||||
textarea#output { min-height: 120px; resize: vertical; }
|
||||
.row { display: flex; gap: 8px; align-items: center; margin-top: 8px; }
|
||||
button { font: inherit; border: 1px solid var(--line); background: var(--accent); color: var(--bg); border-radius: 6px; padding: 8px 14px; cursor: pointer; }
|
||||
button.secondary { background: transparent; color: var(--fg); }
|
||||
button:disabled { opacity: .5; cursor: default; }
|
||||
.status { min-height: 18px; font-size: 12px; margin-top: 8px; color: var(--muted); }
|
||||
.status.ok { color: #1a7f37; } .status.warn { color: #9a6700; } .status.error { color: #cf222e; }
|
||||
.spacer { flex: 1; }
|
||||
footer { margin-top: 10px; font-size: 11px; color: var(--muted); }
|
||||
select#model { width: auto; max-width: 150px; margin-left: auto; padding: 4px 8px; font-size: 12px; }
|
||||
.chips { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 8px; }
|
||||
.chip { padding: 5px 10px; border-radius: 999px; font-size: 13px; background: transparent; color: var(--fg); border: 1px solid var(--line); }
|
||||
.chip:hover:not(:disabled) { border-color: var(--accent); }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<img src="assets/icon-32.png" alt="Hanzo" />
|
||||
<span class="title">Hanzo AI</span>
|
||||
<select id="model" aria-label="Model"></select>
|
||||
<span class="host" id="hostname">Office</span>
|
||||
</header>
|
||||
|
||||
<!-- One-click actions over the selection (drafting/review, e.g. a law office). -->
|
||||
<div class="chips">
|
||||
<button class="chip" data-task="Continue drafting this document in the same voice and formatting. Return only the continuation.">Draft</button>
|
||||
<button class="chip" data-task="Summarize the following, preserving parties, dates, defined terms and obligations.">Summarize</button>
|
||||
<button class="chip" data-task="Explain this in plain, non-jargon language, and note any risks or obligations.">Explain plainly</button>
|
||||
<button class="chip" data-task="Tighten and clarify this text without changing its legal meaning. Return only the revised text.">Tighten / redline</button>
|
||||
<button class="chip" data-task="Continue writing from here in the same voice, tense and formatting. Return only the continuation.">Continue</button>
|
||||
</div>
|
||||
|
||||
<label for="prompt">What should Hanzo do with your selection?</label>
|
||||
<textarea id="prompt" placeholder="e.g. Rewrite this clause in plain English · Summarize the selected rows · Draft a mutual NDA section about confidentiality"></textarea>
|
||||
|
||||
<div class="row">
|
||||
<button id="run">Ask Hanzo</button>
|
||||
<button id="signin" class="secondary">Sign in</button>
|
||||
<button id="signout" class="secondary" style="display:none">Sign out</button>
|
||||
<span class="spacer"></span>
|
||||
</div>
|
||||
|
||||
<details>
|
||||
<summary style="cursor:pointer;font-size:12px;color:var(--muted);margin-top:8px;">Or use a Hanzo API key</summary>
|
||||
<div class="row">
|
||||
<input id="apikey" type="password" placeholder="hk-…" style="flex:1;font:inherit;color:var(--fg);background:var(--bg);border:1px solid var(--line);border-radius:6px;padding:8px;" />
|
||||
<button id="savekey" class="secondary">Save</button>
|
||||
</div>
|
||||
<div id="authhint" style="font-size:11px;color:var(--muted);margin-top:4px;"></div>
|
||||
</details>
|
||||
|
||||
<div class="status" id="status"></div>
|
||||
|
||||
<label for="output">Result</label>
|
||||
<textarea id="output" placeholder="Hanzo's output appears here — review before inserting."></textarea>
|
||||
|
||||
<div class="row">
|
||||
<select id="mode" aria-label="Insert mode">
|
||||
<option value="after">Insert after selection</option>
|
||||
<option value="replace">Replace selection</option>
|
||||
</select>
|
||||
<button id="insert" disabled>Insert into document</button>
|
||||
</div>
|
||||
|
||||
<footer>Routed through api.hanzo.ai · sign in with Hanzo (hanzo.id) for your models.</footer>
|
||||
|
||||
<script src="taskpane.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,173 @@
|
||||
// Task-pane orchestration: wire the UI to read the selection, call Hanzo, and
|
||||
// insert the result. The logic-heavy parts (chat shaping, host read/insert,
|
||||
// auth) live in their own tested modules; this is the glue that binds them to
|
||||
// the DOM once Office is ready.
|
||||
|
||||
/* global Office, document */
|
||||
|
||||
import { askStream, listModels } from './chat.js';
|
||||
import { readSelection, insertResult, currentHost, type InsertMode } from './host.js';
|
||||
import { bearer, getApiKey, setApiKey, clearApiKey, signIn, clearToken, hasApiKey, hasSession } from './auth.js';
|
||||
import { DEFAULT_MODEL } from './config.js';
|
||||
|
||||
Office.onReady((info) => {
|
||||
if (
|
||||
info.host !== Office.HostType.Word &&
|
||||
info.host !== Office.HostType.Excel &&
|
||||
info.host !== Office.HostType.PowerPoint
|
||||
) {
|
||||
return; // not a supported host — the pane shows its static "unsupported" note
|
||||
}
|
||||
const $ = (id: string) => document.getElementById(id) as HTMLElement;
|
||||
|
||||
const promptEl = $('prompt') as HTMLTextAreaElement;
|
||||
const runBtn = $('run') as HTMLButtonElement;
|
||||
const statusEl = $('status');
|
||||
const outputEl = $('output') as HTMLTextAreaElement;
|
||||
const insertBtn = $('insert') as HTMLButtonElement;
|
||||
const modeEl = $('mode') as HTMLSelectElement;
|
||||
const modelEl = $('model') as HTMLSelectElement;
|
||||
const signInBtn = $('signin') as HTMLButtonElement;
|
||||
const signOutBtn = $('signout') as HTMLButtonElement;
|
||||
const apiKeyEl = $('apikey') as HTMLInputElement;
|
||||
const saveKeyBtn = $('savekey') as HTMLButtonElement;
|
||||
const chips = Array.from(document.querySelectorAll<HTMLButtonElement>('.chip'));
|
||||
|
||||
$('hostname').textContent = hostLabel(currentHost());
|
||||
apiKeyEl.value = getApiKey();
|
||||
reflectAuth();
|
||||
void populateModels();
|
||||
|
||||
let controller: AbortController | null = null;
|
||||
|
||||
// One code path for the Ask button AND the quick-action chips: read the
|
||||
// selection, stream the completion into the output, enable Insert.
|
||||
async function runTask(task: string) {
|
||||
if (!task) {
|
||||
setStatus('Type an instruction or pick an action first.', 'warn');
|
||||
return;
|
||||
}
|
||||
controller?.abort();
|
||||
controller = new AbortController();
|
||||
setBusy(true, 'Reading selection…');
|
||||
outputEl.value = '';
|
||||
insertBtn.disabled = true;
|
||||
try {
|
||||
const selection = await readSelection();
|
||||
setStatus('Asking Hanzo…');
|
||||
await askStream(
|
||||
task,
|
||||
selection,
|
||||
await bearer(),
|
||||
(delta) => { outputEl.value += delta; outputEl.scrollTop = outputEl.scrollHeight; },
|
||||
{ model: modelEl.value || DEFAULT_MODEL, signal: controller.signal },
|
||||
);
|
||||
insertBtn.disabled = !outputEl.value;
|
||||
setStatus(outputEl.value ? 'Done — review, then Insert.' : 'No content returned.', outputEl.value ? 'ok' : 'warn');
|
||||
} catch (e: any) {
|
||||
if (e?.name === 'AbortError') return;
|
||||
setStatus(errMessage(e), 'error');
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
runBtn.onclick = () => runTask(promptEl.value.trim());
|
||||
|
||||
// Quick-action chip: seed the prompt with its preset task, then run it.
|
||||
for (const chip of chips) {
|
||||
chip.onclick = () => {
|
||||
const task = chip.dataset.task ?? '';
|
||||
promptEl.value = task;
|
||||
void runTask(task);
|
||||
};
|
||||
}
|
||||
|
||||
async function populateModels() {
|
||||
try {
|
||||
const ids = (await listModels(await bearer())).sort();
|
||||
if (!ids.length) return;
|
||||
modelEl.innerHTML = '';
|
||||
for (const id of ids) {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = id; opt.textContent = id;
|
||||
modelEl.appendChild(opt);
|
||||
}
|
||||
modelEl.value = ids.includes(DEFAULT_MODEL) ? DEFAULT_MODEL : ids[0];
|
||||
} catch {
|
||||
// Non-fatal — a fixed default still works; leave the picker with its default.
|
||||
modelEl.innerHTML = `<option value="${DEFAULT_MODEL}">${DEFAULT_MODEL}</option>`;
|
||||
}
|
||||
}
|
||||
|
||||
insertBtn.onclick = async () => {
|
||||
const text = outputEl.value;
|
||||
if (!text) return;
|
||||
try {
|
||||
await insertResult(text, (modeEl.value as InsertMode) || 'after');
|
||||
setStatus('Inserted into the document.', 'ok');
|
||||
} catch (e: any) {
|
||||
setStatus(errMessage(e), 'error');
|
||||
}
|
||||
};
|
||||
|
||||
signInBtn.onclick = async () => {
|
||||
setStatus('Opening Hanzo sign-in…');
|
||||
try {
|
||||
await signIn();
|
||||
setStatus('Signed in.', 'ok');
|
||||
reflectAuth();
|
||||
void populateModels(); // a token unlocks the caller's org-specific models
|
||||
} catch (e: any) {
|
||||
setStatus(errMessage(e), 'error');
|
||||
}
|
||||
};
|
||||
|
||||
signOutBtn.onclick = async () => {
|
||||
await clearToken();
|
||||
setStatus('Signed out.', 'ok');
|
||||
reflectAuth();
|
||||
};
|
||||
|
||||
saveKeyBtn.onclick = async () => {
|
||||
const key = apiKeyEl.value.trim();
|
||||
if (key) {
|
||||
await setApiKey(key);
|
||||
setStatus('API key saved — ready to use.', 'ok');
|
||||
} else {
|
||||
await clearApiKey();
|
||||
setStatus('API key cleared.', 'ok');
|
||||
}
|
||||
reflectAuth();
|
||||
void populateModels(); // the new key may unlock a different model set
|
||||
};
|
||||
|
||||
function reflectAuth() {
|
||||
// Sync reflect only (no refresh/network): signed in via OAuth session or a
|
||||
// saved API key. bearer()/getToken() do the real (async) resolution at call
|
||||
// time; this just renders which credential is live.
|
||||
const oauth = hasSession();
|
||||
const keyed = hasApiKey();
|
||||
signInBtn.style.display = oauth ? 'none' : '';
|
||||
signOutBtn.style.display = oauth ? '' : 'none';
|
||||
$('authhint').textContent = (oauth || keyed)
|
||||
? 'Ready — using ' + (keyed ? 'your API key.' : 'your Hanzo sign-in.')
|
||||
: 'Paste a Hanzo API key or sign in to use your models.';
|
||||
}
|
||||
function setBusy(busy: boolean, msg?: string) {
|
||||
runBtn.disabled = busy;
|
||||
for (const chip of chips) chip.disabled = busy;
|
||||
if (msg) setStatus(msg);
|
||||
}
|
||||
function setStatus(msg: string, kind: 'ok' | 'warn' | 'error' | '' = '') {
|
||||
statusEl.textContent = msg;
|
||||
statusEl.className = 'status ' + kind;
|
||||
}
|
||||
});
|
||||
|
||||
function hostLabel(h: string): string {
|
||||
return { word: 'Word', excel: 'Excel', powerpoint: 'PowerPoint' }[h] || 'Office';
|
||||
}
|
||||
function errMessage(e: any): string {
|
||||
return e?.message ? String(e.message) : String(e);
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
import { describe, it, expect, vi, afterEach } from 'vitest';
|
||||
import { buildMessages, requestBody, extractContent, ask, streamDelta, listModels } from '../src/chat';
|
||||
import { DEFAULT_MODEL } from '../src/config';
|
||||
|
||||
describe('buildMessages', () => {
|
||||
it('pure generation when no selection', () => {
|
||||
const m = buildMessages('draft an NDA clause', '');
|
||||
expect(m[0].role).toBe('system');
|
||||
expect(m[1].role).toBe('user');
|
||||
expect(m[1].content).toBe('draft an NDA clause');
|
||||
});
|
||||
|
||||
it('fences the selection as data when present', () => {
|
||||
const m = buildMessages('rewrite formally', 'hey wanna sign this');
|
||||
expect(m[1].content).toContain('rewrite formally');
|
||||
expect(m[1].content).toContain('---- selected text ----');
|
||||
expect(m[1].content).toContain('hey wanna sign this');
|
||||
});
|
||||
|
||||
it('system prompt forbids markdown fences / preamble', () => {
|
||||
const m = buildMessages('x', '');
|
||||
expect(m[0].content.toLowerCase()).toContain('only the text');
|
||||
});
|
||||
});
|
||||
|
||||
describe('requestBody', () => {
|
||||
it('defaults the model and disables streaming', () => {
|
||||
const b = requestBody(buildMessages('x', ''));
|
||||
expect(b.model).toBe(DEFAULT_MODEL);
|
||||
expect(b.stream).toBe(false);
|
||||
expect(Array.isArray(b.messages)).toBe(true);
|
||||
});
|
||||
it('passes an explicit model + temperature through', () => {
|
||||
const b = requestBody(buildMessages('x', ''), { model: 'zen-pro', temperature: 0.2 });
|
||||
expect(b.model).toBe('zen-pro');
|
||||
expect(b.temperature).toBe(0.2);
|
||||
});
|
||||
it('enables streaming only when opts.stream is true', () => {
|
||||
expect(requestBody(buildMessages('x', ''), { stream: true }).stream).toBe(true);
|
||||
expect(requestBody(buildMessages('x', ''), { stream: false }).stream).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('streamDelta', () => {
|
||||
it('reads choices[0].delta.content from an SSE frame', () => {
|
||||
expect(streamDelta(JSON.stringify({ choices: [{ delta: { content: 'Hel' } }] }))).toBe('Hel');
|
||||
});
|
||||
it('returns empty string for a role-only / empty delta', () => {
|
||||
expect(streamDelta(JSON.stringify({ choices: [{ delta: { role: 'assistant' } }] }))).toBe('');
|
||||
expect(streamDelta(JSON.stringify({ choices: [{}] }))).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('listModels', () => {
|
||||
afterEach(() => vi.unstubAllGlobals());
|
||||
|
||||
it('returns ids from the /v1/models data array with the bearer', async () => {
|
||||
const fetchMock = vi.fn(async (url: string, init: any) => {
|
||||
expect(url).toBe('https://api.hanzo.ai/v1/models');
|
||||
expect(init.headers.Authorization).toBe('Bearer t');
|
||||
return { ok: true, json: async () => ({ data: [{ id: 'zen-1' }, { id: 'zen-pro' }] }) } as any;
|
||||
});
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
expect(await listModels('t')).toEqual(['zen-1', 'zen-pro']);
|
||||
});
|
||||
|
||||
it('tolerates a bare string array and omits the bearer when unauthenticated', async () => {
|
||||
const fetchMock = vi.fn(async (_url: string, init: any) => {
|
||||
expect(init.headers.Authorization).toBeUndefined();
|
||||
return { ok: true, json: async () => ['a', 'b'] } as any;
|
||||
});
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
expect(await listModels('')).toEqual(['a', 'b']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('extractContent', () => {
|
||||
it('reads OpenAI choices[0].message.content', () => {
|
||||
expect(extractContent({ choices: [{ message: { content: 'hello' } }] })).toBe('hello');
|
||||
});
|
||||
it('tolerates choices[0].text and bare content', () => {
|
||||
expect(extractContent({ choices: [{ text: 'a' }] })).toBe('a');
|
||||
expect(extractContent({ content: 'b' })).toBe('b');
|
||||
});
|
||||
it('throws on an error payload', () => {
|
||||
expect(() => extractContent({ error: { message: 'rate limited' } })).toThrow(/rate limited/);
|
||||
});
|
||||
it('throws when there is no content', () => {
|
||||
expect(() => extractContent({ choices: [{}] })).toThrow(/no content/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('ask', () => {
|
||||
afterEach(() => vi.unstubAllGlobals());
|
||||
|
||||
it('POSTs to the gateway with the bearer and returns the content', async () => {
|
||||
const fetchMock = vi.fn(async (_url: string, init: any) => {
|
||||
const body = JSON.parse(init.body);
|
||||
expect(init.headers.Authorization).toBe('Bearer tok123');
|
||||
expect(body.messages[1].content).toContain('summarize');
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
text: async () => JSON.stringify({ choices: [{ message: { content: 'summary text' } }] }),
|
||||
} as any;
|
||||
});
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
const out = await ask('summarize', 'the rows', 'tok123');
|
||||
expect(out).toBe('summary text');
|
||||
expect(fetchMock).toHaveBeenCalledOnce();
|
||||
expect(fetchMock.mock.calls[0][0]).toBe('https://api.hanzo.ai/v1/chat/completions');
|
||||
});
|
||||
|
||||
it('omits Authorization when token is empty', async () => {
|
||||
const fetchMock = vi.fn(async (_url: string, init: any) => {
|
||||
expect(init.headers.Authorization).toBeUndefined();
|
||||
return { ok: true, status: 200, text: async () => JSON.stringify({ content: 'ok' }) } as any;
|
||||
});
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
expect(await ask('hi', '', '')).toBe('ok');
|
||||
});
|
||||
|
||||
it('surfaces a structured gateway error', async () => {
|
||||
vi.stubGlobal('fetch', vi.fn(async () => ({
|
||||
ok: false,
|
||||
status: 429,
|
||||
text: async () => JSON.stringify({ error: { message: 'too many requests' } }),
|
||||
} as any)));
|
||||
await expect(ask('x', '', 't')).rejects.toThrow(/too many requests/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,26 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { chatCompletionsURL, modelsURL, API_BASE_URL, pickBearer } from '../src/config';
|
||||
|
||||
describe('model gateway endpoints', () => {
|
||||
it('chat + models go through api.hanzo.ai/v1 (llm.hanzo.ai is dead)', () => {
|
||||
expect(chatCompletionsURL()).toBe('https://api.hanzo.ai/v1/chat/completions');
|
||||
expect(modelsURL()).toBe('https://api.hanzo.ai/v1/models');
|
||||
expect(API_BASE_URL).toBe('https://api.hanzo.ai');
|
||||
expect(chatCompletionsURL()).not.toContain('llm.hanzo.ai');
|
||||
});
|
||||
// IAM endpoints, the hanzo-office client, PKCE, and scopes now live in
|
||||
// @hanzo/auth and are pinned by its config/oauth tests — not duplicated here.
|
||||
});
|
||||
|
||||
describe('pickBearer', () => {
|
||||
it('API key wins over the OAuth token', () => {
|
||||
expect(pickBearer('hk-abc', 'jwt-xyz')).toBe('hk-abc');
|
||||
});
|
||||
it('falls back to the OAuth token when no key', () => {
|
||||
expect(pickBearer('', 'jwt-xyz')).toBe('jwt-xyz');
|
||||
expect(pickBearer(' ', 'jwt-xyz')).toBe('jwt-xyz');
|
||||
});
|
||||
it('empty when neither is present (anonymous)', () => {
|
||||
expect(pickBearer('', '')).toBe('');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,26 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { excelValuesToText, splitLines } from '../src/host';
|
||||
|
||||
describe('excelValuesToText', () => {
|
||||
it('flattens a 2-D range to TSV rows', () => {
|
||||
expect(excelValuesToText([['a', 'b'], ['c', 'd']])).toBe('a\tb\nc\td');
|
||||
});
|
||||
it('renders null/undefined cells as empty', () => {
|
||||
expect(excelValuesToText([[1, null], [undefined, 4]])).toBe('1\t\n\t4');
|
||||
});
|
||||
it('empty range → empty string', () => {
|
||||
expect(excelValuesToText([])).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('splitLines', () => {
|
||||
it('one line per cell down a column', () => {
|
||||
expect(splitLines('a\nb\nc')).toEqual([['a'], ['b'], ['c']]);
|
||||
});
|
||||
it('drops trailing blank lines so a stray newline does not clear a cell', () => {
|
||||
expect(splitLines('a\nb\n\n')).toEqual([['a'], ['b']]);
|
||||
});
|
||||
it('single line stays a 1x1', () => {
|
||||
expect(splitLines('solo')).toEqual([['solo']]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"types": ["office-js"],
|
||||
"strict": true,
|
||||
"noEmit": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true
|
||||
},
|
||||
"include": ["src/**/*.ts", "tests/**/*.ts"]
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import { defineConfig } from 'vitest/config';
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
include: ['tests/**/*.test.ts'],
|
||||
environment: 'node',
|
||||
},
|
||||
});
|
||||
@@ -16,7 +16,7 @@
|
||||
"js-yaml": "^4.1.0",
|
||||
"node-fetch": "^3.3.2",
|
||||
"@modelcontextprotocol/sdk": "^1.15.0",
|
||||
"uuid": "^10.0.0",
|
||||
"uuid": "^11.1.1",
|
||||
"ws": "^8.18.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -28,7 +28,7 @@
|
||||
"@types/vscode": "^1.101.0",
|
||||
"eslint": "^8.57.1",
|
||||
"typescript": "^5.8.3",
|
||||
"vitest": "^0.34.6"
|
||||
"vitest": "^3.2.6"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
|
||||
@@ -8,7 +8,7 @@ import * as crypto from 'crypto';
|
||||
|
||||
export interface HanzoAuthConfig {
|
||||
apiUrl: string;
|
||||
iamUrl: string; // Casdoor API (iam.hanzo.ai)
|
||||
iamUrl: string; // Hanzo IAM API (iam.hanzo.ai)
|
||||
iamLoginUrl: string; // Login UI (hanzo.id)
|
||||
clientId: string;
|
||||
scope: string;
|
||||
@@ -173,7 +173,7 @@ export class HanzoAuth extends EventEmitter {
|
||||
|
||||
this.server.listen(port, () => {
|
||||
// Authorization Code + PKCE (OAuth 2.1 standard)
|
||||
const authUrl = new URL('/oauth/authorize', this.config.iamLoginUrl);
|
||||
const authUrl = new URL('/v1/iam/oauth/authorize', this.config.iamLoginUrl);
|
||||
authUrl.searchParams.set('client_id', this.config.clientId);
|
||||
authUrl.searchParams.set('response_type', 'code');
|
||||
authUrl.searchParams.set('redirect_uri', `http://localhost:${port}/callback`);
|
||||
@@ -200,18 +200,18 @@ export class HanzoAuth extends EventEmitter {
|
||||
}
|
||||
|
||||
private async exchangeCodeForTokens(code: string, codeVerifier: string): Promise<void> {
|
||||
const response = await fetch(`${this.config.iamUrl}/oauth/token`, {
|
||||
const response = await fetch(`${this.config.iamUrl}/v1/iam/oauth/token`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
'Content-Type': 'application/x-www-form-urlencoded'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
body: new URLSearchParams({
|
||||
grant_type: 'authorization_code',
|
||||
code,
|
||||
client_id: this.config.clientId,
|
||||
code_verifier: codeVerifier,
|
||||
redirect_uri: 'http://localhost:51234/callback'
|
||||
})
|
||||
}).toString()
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
@@ -241,24 +241,12 @@ export class HanzoAuth extends EventEmitter {
|
||||
if (!this.credentials?.accessToken) return;
|
||||
const headers = { 'Authorization': `Bearer ${this.credentials.accessToken}` };
|
||||
|
||||
// /api/get-account returns full profile; /api/userinfo only returns sub
|
||||
try {
|
||||
const acctResp = await fetch(`${this.config.iamUrl}/api/get-account`, { headers });
|
||||
if (acctResp.ok) {
|
||||
const acctJson = await acctResp.json() as any;
|
||||
const acct = acctJson.data || acctJson;
|
||||
if (acct.name || acct.email) {
|
||||
this.credentials.userId = acct.id || acct.sub;
|
||||
this.credentials.email = acct.email;
|
||||
return;
|
||||
}
|
||||
}
|
||||
} catch { /* fall through */ }
|
||||
|
||||
const response = await fetch(`${this.config.iamUrl}/api/userinfo`, { headers });
|
||||
// HIP-0111 canonical OIDC userinfo. Returns standard claims (sub,
|
||||
// email, name); no legacy IAM `/api/get-account` profile shape.
|
||||
const response = await fetch(`${this.config.iamUrl}/v1/iam/oauth/userinfo`, { headers });
|
||||
if (response.ok) {
|
||||
const user = await response.json() as any;
|
||||
this.credentials.userId = user.id || user.sub;
|
||||
this.credentials.userId = user.sub || user.id;
|
||||
this.credentials.email = user.email;
|
||||
}
|
||||
}
|
||||
@@ -318,16 +306,16 @@ export class HanzoAuth extends EventEmitter {
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`${this.config.iamUrl}/oauth/token`, {
|
||||
const response = await fetch(`${this.config.iamUrl}/v1/iam/oauth/token`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
'Content-Type': 'application/x-www-form-urlencoded'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
body: new URLSearchParams({
|
||||
grant_type: 'refresh_token',
|
||||
refresh_token: this.credentials.refreshToken,
|
||||
client_id: this.config.clientId
|
||||
})
|
||||
}).toString()
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
@@ -356,7 +344,7 @@ export class HanzoAuth extends EventEmitter {
|
||||
async logout(): Promise<void> {
|
||||
if (this.credentials?.accessToken) {
|
||||
try {
|
||||
await fetch(`${this.config.iamUrl}/oauth/revoke`, {
|
||||
await fetch(`${this.config.iamUrl}/v1/iam/oauth/logout`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user