Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1c7679c41e | ||
|
|
3105d4a099 | ||
|
|
5f14961fbb | ||
|
|
36de7695af | ||
|
|
0ae9186728 | ||
|
|
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 |
@@ -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 |
@@ -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,38 @@ 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 Outlook add-in
|
||||
run: pnpm --filter @hanzo/outlook-addin test
|
||||
|
||||
- name: Test Google Workspace add-on
|
||||
run: pnpm --filter @hanzo/gworkspace-addon 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 +102,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 +126,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 +173,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 +250,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 +282,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
|
||||
|
||||
|
||||
@@ -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,90 @@ 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
|
||||
|
||||
# ─── Microsoft Outlook mail add-in ───
|
||||
outlook:
|
||||
name: Outlook 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/outlook-addin build
|
||||
cd packages/outlook/dist && npx --yes bestzip "/tmp/hanzo-ai-outlook-v${VERSION}.zip" .
|
||||
|
||||
- name: Upload Outlook add-in
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: hanzo-ai-outlook
|
||||
path: /tmp/hanzo-ai-outlook-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, outlook]
|
||||
if: startsWith(github.ref, 'refs/tags/v')
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
@@ -286,6 +385,15 @@ 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) |
|
||||
| **Outlook** | Windows / macOS / Web | [`hanzo-ai-outlook-v${{ steps.version.outputs.version }}.zip`](https://github.com/hanzoai/extension/releases/download/${{ github.ref_name }}/hanzo-ai-outlook-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 +411,7 @@ jobs:
|
||||
files: |
|
||||
artifacts/hanzo-ai-browser/*
|
||||
artifacts/hanzo-ai-ide/*
|
||||
artifacts/hanzo-ai-jetbrains/*
|
||||
artifacts/hanzo-ai-office/*
|
||||
artifacts/hanzo-ai-outlook/*
|
||||
artifacts/hanzo-ai-safari/*
|
||||
|
||||
@@ -1,54 +1,52 @@
|
||||
# LLM.md - Hanzo Extension
|
||||
# Hanzo Extension
|
||||
|
||||
## Overview
|
||||
Hanzo AI Development Platform Monorepo. The browser extension lives in
|
||||
`packages/browser` and ships to Chrome / Firefox / Safari.
|
||||
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
|
||||
- **Build**: esbuild via `packages/browser/src/build.js`
|
||||
- **Tests**: vitest
|
||||
## Architecture (native ZAP)
|
||||
|
||||
## Build & Run
|
||||
```bash
|
||||
pnpm install && pnpm build
|
||||
pnpm test
|
||||
```
|
||||
extension ─connectNative("ai.hanzo.zap")─► native host ─UDS─► zapd ◄─ hanzo-mcp
|
||||
|
||||
## Architecture (browser-extension 1.9.x — native ZAP)
|
||||
|
||||
The extension connects to the **one shared local `zapd` router** (see
|
||||
`~/work/zap`) through exactly one browser-native primitive — there is **no**
|
||||
WebSocket, no localhost port, no mDNS probing, no CDP bridge:
|
||||
|
||||
```
|
||||
extension ─connectNative("ai.hanzo.zap")─► native host ─UDS─► zapd ◄─ hanzo-mcp
|
||||
```
|
||||
|
||||
- **shared/native-zap.ts** — the ONE transport: `connectNativeZap()` opens the
|
||||
native-messaging port, registers as a provider (`browser:<chrome|firefox>/default`,
|
||||
the host stamps the device hostname), and dispatches inbound ROUTE commands.
|
||||
- `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) — `connectNativeZap(..., cdpBridge.dispatchMethod)`.
|
||||
Dispatch still uses `chrome.debugger` (CDP-to-tab) → Chrome's "debugging this
|
||||
browser" banner. **TODO:** native `browser.*` dispatch for banner-light parity.
|
||||
- **background-firefox.ts** — `connectNativeZap(..., hanzoExtension.dispatchMethod)`.
|
||||
Already native `browser.*` (no banner). Same transport as Chrome.
|
||||
- **browser-dispatch.ts** (was `cdp-bridge.ts`) — `chrome.debugger` actuation
|
||||
(`dispatchMethod`). The legacy `cdp-bridge-server.ts` WS bridge is **deleted**.
|
||||
- **shared/zap.ts** — legacy WebSocket ZAP wire; retained only for unrelated
|
||||
helpers (RAG/event broadcast no-op when disconnected). NOT the transport.
|
||||
- `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.
|
||||
|
||||
Native-host manifests: Chrome `…/Google/Chrome/NativeMessagingHosts/ai.hanzo.zap.json`
|
||||
(`allowed_origins`), Firefox `…/Mozilla/NativeMessagingHosts/ai.hanzo.zap.json`
|
||||
(`allowed_extensions: ["hanzo-ai@hanzo.ai"]`). Both `path` → `~/work/zap/host/zap_host.sh`.
|
||||
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`).
|
||||
|
||||
The wire to zapd is the **binary ZAP router envelope** (`len|type|flags|from|to|payload`,
|
||||
defined in `zap-proto/zapd/src/frame.rs`); the opaque command payload is a compact
|
||||
binary codec. The browser↔host hop is native-messaging JSON (Chrome-forced), base64'd,
|
||||
quarantined in the host.
|
||||
## Versioning
|
||||
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).
|
||||
|
||||
## Versioning rule
|
||||
Always bump **patch** (X.Y.Z → X.Y.Z+1). Never bump major — the ZAP
|
||||
transport pivot ships as ordinary patches, not a 2.0. `package.json` and
|
||||
both `manifest*.json` must agree.
|
||||
## 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,6 +1,6 @@
|
||||
{
|
||||
"name": "@hanzo/extension",
|
||||
"version": "1.9.20",
|
||||
"version": "1.9.30",
|
||||
"private": true,
|
||||
"description": "Hanzo AI Extension",
|
||||
"license": "MIT",
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.9.21",
|
||||
"version": "1.9.30",
|
||||
"description": "Hanzo AI Browser Extension",
|
||||
"main": "dist/background.js",
|
||||
"scripts": {
|
||||
@@ -30,7 +30,7 @@
|
||||
"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": {
|
||||
|
||||
@@ -27,6 +27,7 @@ import {
|
||||
type ZapManager,
|
||||
} from './shared/zap.js';
|
||||
import { connectNativeZap, type NativeZapState } from './shared/native-zap.js';
|
||||
import { pickEvaluable, wrapEvaluable } from './shared/evaluable.js';
|
||||
import {
|
||||
getRagConfig,
|
||||
normalizeRagSnippets,
|
||||
@@ -607,34 +608,26 @@ class HanzoFirefoxExtension {
|
||||
case 'Runtime.evaluate':
|
||||
case 'evaluate': {
|
||||
if (!tabId) throw new Error('No active tab');
|
||||
// Accept both `expression` (CDP) and `code` (MCP/SDK convention).
|
||||
const expression = (params.expression as string) ?? (params.code as string) ?? '';
|
||||
// Default 30s — Porkbun-class pages with 1500+ DOM nodes routinely
|
||||
// need more than 10s for first-paint queries. Caller can override.
|
||||
const timeout = (params.timeout as number) || 30000;
|
||||
// Caller can opt into Promise-await semantics. CDP names this flag
|
||||
// `awaitPromise`; the MCP/SDK callers pass `await_promise`.
|
||||
const awaitPromise = Boolean(params.awaitPromise ?? params.await_promise);
|
||||
|
||||
// If the caller already wrote a `return` or wrote a function/IIFE,
|
||||
// wrap differently so we don't double-wrap and break syntax.
|
||||
const trimmed = expression.trim();
|
||||
const code = /^\s*return\b/.test(trimmed)
|
||||
? trimmed
|
||||
: trimmed.endsWith(';')
|
||||
? `${trimmed} return undefined;`
|
||||
: `return (${trimmed});`;
|
||||
|
||||
// When awaitPromise is requested, resolve in-page so executeScript's
|
||||
// structured-clone serialization sees the resolved value rather than
|
||||
// a Promise (which it can't transfer).
|
||||
const evalCode = awaitPromise
|
||||
? `
|
||||
return Promise.resolve((function(){ ${code} })()).then(function(__v){
|
||||
try { return JSON.parse(JSON.stringify(__v)); } catch(e) { return String(__v); }
|
||||
});
|
||||
`
|
||||
: code;
|
||||
// pickEvaluable accepts every code-param alias (expression / code /
|
||||
// script / function / js); wrapEvaluable is the ONE cross-browser
|
||||
// rule that turns statement bodies (`const b=…; b.click()`) into an
|
||||
// async IIFE with the trailing value auto-returned — the previous
|
||||
// `return (${code})` wrap was a SyntaxError on any multi-statement
|
||||
// body. Always resolve in-page so executeScript's structured-clone
|
||||
// serialization sees the settled value, never a Promise; rejections
|
||||
// ride the __hanzo_error channel runInPage already throws on.
|
||||
const evalCode = `
|
||||
return Promise.resolve(${wrapEvaluable(pickEvaluable(params))}).then(function(__v){
|
||||
if (__v === undefined || __v === null) return null;
|
||||
try { return JSON.parse(JSON.stringify(__v)); } catch(e) { return String(__v); }
|
||||
}, function(__e){
|
||||
return { __hanzo_error: (__e && __e.message) || String(__e) };
|
||||
});
|
||||
`;
|
||||
|
||||
let value: unknown;
|
||||
let evalError: string | null = null;
|
||||
|
||||
@@ -1508,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
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
// 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;
|
||||
@@ -239,7 +240,7 @@ export class CDPBridge {
|
||||
// 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,
|
||||
expression: wrapEvaluable(expression),
|
||||
returnByValue: true,
|
||||
awaitPromise: true,
|
||||
});
|
||||
@@ -389,14 +390,21 @@ export class CDPBridge {
|
||||
break;
|
||||
|
||||
case 'Runtime.evaluate':
|
||||
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,
|
||||
});
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"manifest_version": 3,
|
||||
"name": "Hanzo AI",
|
||||
"version": "1.9.21",
|
||||
"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",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"manifest_version": 3,
|
||||
"name": "Hanzo AI",
|
||||
"version": "1.9.21",
|
||||
"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": [
|
||||
|
||||
@@ -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,11 +210,9 @@ export async function login(adapter: BrowserAdapter): Promise<UserInfo> {
|
||||
token_type: 'Bearer',
|
||||
});
|
||||
} else if (code) {
|
||||
// Hanzo gateway exposes Casdoor's `/api/login/oauth/access_token` at
|
||||
// `/v1/iam/login/oauth/access_token`. The bare `/oauth/token` from
|
||||
// the OIDC well-known doc 404s in prod. Token exchange wants
|
||||
// application/x-www-form-urlencoded per RFC 6749, not JSON.
|
||||
const tokenResponse = await fetch(`${config.iamApiUrl}${IAM_API_PATH}/login/oauth/access_token`, {
|
||||
// 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/x-www-form-urlencoded' },
|
||||
body: new URLSearchParams({
|
||||
@@ -271,7 +269,7 @@ 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}${IAM_API_PATH}/login/oauth/access_token`, {
|
||||
const response = await fetch(`${config.iamApiUrl}${IAM_API_PATH}/oauth/token`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: new URLSearchParams({
|
||||
@@ -286,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();
|
||||
}
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -77,6 +77,13 @@ export function connectNativeZap(
|
||||
// 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');
|
||||
@@ -125,7 +132,9 @@ export function connectNativeZap(
|
||||
});
|
||||
|
||||
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);
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -1,289 +0,0 @@
|
||||
/**
|
||||
* Multi-browser routing through the CDP bridge server.
|
||||
*
|
||||
* The bridge is the SINGLE entrypoint for hanzo-mcp / Playwright clients.
|
||||
* When two extensions register against the same bridge (e.g. Chrome AND
|
||||
* Firefox both running locally), the user must be able to route a command
|
||||
* to a specific browser via `{ ...command, browser: 'firefox' }`. This
|
||||
* suite asserts that resolution works deterministically.
|
||||
*
|
||||
* The bridge exposes `assignClientId`, `resolveClient`, and the public
|
||||
* `browser({ action, browser })` action namespace. We exercise those
|
||||
* directly with mocked WebSocket clients so we never hit a real port.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import { CDPBridgeServer } from '../src/cdp-bridge-server';
|
||||
|
||||
// jsdom's `WebSocket` doesn't behave like the `ws` server expects, so we
|
||||
// use the real ws lib but on a unique port per test to avoid contention.
|
||||
import { WebSocket } from 'ws';
|
||||
|
||||
let server: CDPBridgeServer;
|
||||
const PORTS_BASE = 39220; // unique band so CI doesn't collide with prod 9223
|
||||
let portCursor = PORTS_BASE;
|
||||
function nextPort(): number {
|
||||
return portCursor++;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
// Server created per-test with fresh port.
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (server) {
|
||||
server.close();
|
||||
server = undefined as any;
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Spin up a real CDPBridgeServer and connect a fake "extension" client
|
||||
* over WebSocket. The fake client immediately registers and then echoes
|
||||
* any command back as the result. Returns the connected client so the
|
||||
* test can drive responses.
|
||||
*/
|
||||
async function setupBridgeWithClient(
|
||||
browser: string,
|
||||
): Promise<{ port: number; client: WebSocket; opened: Promise<void> }> {
|
||||
const port = nextPort();
|
||||
server = new CDPBridgeServer(port);
|
||||
// Give the WS server one tick to start listening.
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
|
||||
const client = new WebSocket(`ws://localhost:${port}/cdp`);
|
||||
const opened = new Promise<void>((resolve) => client.on('open', () => resolve()));
|
||||
await opened;
|
||||
|
||||
client.send(JSON.stringify({
|
||||
type: 'register',
|
||||
role: 'cdp-provider',
|
||||
browser,
|
||||
capabilities: ['navigate'],
|
||||
}));
|
||||
// One tick for register to land.
|
||||
await new Promise((r) => setTimeout(r, 30));
|
||||
|
||||
// Echo back any command as a successful result so the test doesn't time out.
|
||||
client.on('message', (raw) => {
|
||||
const msg = JSON.parse(raw.toString());
|
||||
if (msg.id !== undefined && msg.method) {
|
||||
client.send(JSON.stringify({
|
||||
id: msg.id,
|
||||
result: { __echoed: { method: msg.method, params: msg.params } },
|
||||
}));
|
||||
}
|
||||
});
|
||||
|
||||
return { port, client, opened };
|
||||
}
|
||||
|
||||
describe('CDPBridgeServer.assignClientId', () => {
|
||||
it('first client of a browser type gets its bare name', async () => {
|
||||
const { client } = await setupBridgeWithClient('chrome');
|
||||
// Internal: the server stores by browser name. Verify via list_browsers.
|
||||
const result = await server.browser({ action: 'list_browsers' });
|
||||
expect(result.browsers).toHaveLength(1);
|
||||
expect(result.browsers[0].id).toBe('chrome');
|
||||
expect(result.browsers[0].browser).toBe('chrome');
|
||||
client.close();
|
||||
});
|
||||
|
||||
it('second client of same browser type gets a -2 suffix', async () => {
|
||||
const { client: c1 } = await setupBridgeWithClient('chrome');
|
||||
// Open a second connection registered as `chrome`.
|
||||
const port = (server as any).httpPort;
|
||||
const c2 = new WebSocket(`ws://localhost:${port}/cdp`);
|
||||
await new Promise<void>((resolve) => c2.on('open', () => resolve()));
|
||||
c2.send(JSON.stringify({
|
||||
type: 'register',
|
||||
role: 'cdp-provider',
|
||||
browser: 'chrome',
|
||||
capabilities: ['navigate'],
|
||||
}));
|
||||
await new Promise((r) => setTimeout(r, 30));
|
||||
|
||||
const result = await server.browser({ action: 'list_browsers' });
|
||||
expect(result.browsers).toHaveLength(2);
|
||||
const ids = result.browsers.map((b: any) => b.id).sort();
|
||||
expect(ids).toEqual(['chrome', 'chrome-2']);
|
||||
|
||||
c1.close();
|
||||
c2.close();
|
||||
});
|
||||
|
||||
it('different browser types each get their bare names', async () => {
|
||||
const { client: chromeClient } = await setupBridgeWithClient('chrome');
|
||||
const port = (server as any).httpPort;
|
||||
const ffClient = new WebSocket(`ws://localhost:${port}/cdp`);
|
||||
await new Promise<void>((resolve) => ffClient.on('open', () => resolve()));
|
||||
ffClient.send(JSON.stringify({
|
||||
type: 'register',
|
||||
role: 'cdp-provider',
|
||||
browser: 'firefox',
|
||||
capabilities: ['navigate'],
|
||||
}));
|
||||
await new Promise((r) => setTimeout(r, 30));
|
||||
ffClient.on('message', (raw) => {
|
||||
const msg = JSON.parse(raw.toString());
|
||||
if (msg.id !== undefined && msg.method) {
|
||||
ffClient.send(JSON.stringify({
|
||||
id: msg.id,
|
||||
result: { __echoed: { method: msg.method, browser: 'firefox' } },
|
||||
}));
|
||||
}
|
||||
});
|
||||
|
||||
const result = await server.browser({ action: 'list_browsers' });
|
||||
const ids = result.browsers.map((b: any) => b.id).sort();
|
||||
expect(ids).toEqual(['chrome', 'firefox']);
|
||||
|
||||
chromeClient.close();
|
||||
ffClient.close();
|
||||
});
|
||||
});
|
||||
|
||||
describe('CDPBridgeServer.browser routing', () => {
|
||||
it('routes by browser=firefox to the firefox client', async () => {
|
||||
const { client: chromeClient } = await setupBridgeWithClient('chrome');
|
||||
const port = (server as any).httpPort;
|
||||
const ffClient = new WebSocket(`ws://localhost:${port}/cdp`);
|
||||
await new Promise<void>((resolve) => ffClient.on('open', () => resolve()));
|
||||
ffClient.send(JSON.stringify({
|
||||
type: 'register',
|
||||
role: 'cdp-provider',
|
||||
browser: 'firefox',
|
||||
capabilities: ['navigate'],
|
||||
}));
|
||||
await new Promise((r) => setTimeout(r, 30));
|
||||
ffClient.on('message', (raw) => {
|
||||
const msg = JSON.parse(raw.toString());
|
||||
if (msg.id !== undefined && msg.method) {
|
||||
ffClient.send(JSON.stringify({
|
||||
id: msg.id,
|
||||
result: { __echoed: { method: msg.method, browser: 'firefox' } },
|
||||
}));
|
||||
}
|
||||
});
|
||||
|
||||
const result = await server.browser({
|
||||
action: 'navigate',
|
||||
browser: 'firefox',
|
||||
url: 'https://example.com',
|
||||
});
|
||||
|
||||
// Result should come from firefox echoer, not chrome's.
|
||||
expect(result.__echoed.browser).toBe('firefox');
|
||||
expect(result.__echoed.method).toBe('Page.navigate');
|
||||
|
||||
chromeClient.close();
|
||||
ffClient.close();
|
||||
});
|
||||
|
||||
it('routes by chrome-2 unique ID to the second chrome client', async () => {
|
||||
const { client: c1 } = await setupBridgeWithClient('chrome');
|
||||
const port = (server as any).httpPort;
|
||||
// c1 already echoes generically; install a label-specific echoer on c2.
|
||||
const c2 = new WebSocket(`ws://localhost:${port}/cdp`);
|
||||
await new Promise<void>((resolve) => c2.on('open', () => resolve()));
|
||||
c2.send(JSON.stringify({
|
||||
type: 'register',
|
||||
role: 'cdp-provider',
|
||||
browser: 'chrome',
|
||||
capabilities: ['navigate'],
|
||||
}));
|
||||
await new Promise((r) => setTimeout(r, 30));
|
||||
c2.on('message', (raw) => {
|
||||
const msg = JSON.parse(raw.toString());
|
||||
if (msg.id !== undefined && msg.method) {
|
||||
c2.send(JSON.stringify({
|
||||
id: msg.id,
|
||||
result: { __echoed: { method: msg.method, label: 'chrome-2' } },
|
||||
}));
|
||||
}
|
||||
});
|
||||
|
||||
const result = await server.browser({
|
||||
action: 'navigate',
|
||||
browser: 'chrome-2',
|
||||
url: 'https://example.com',
|
||||
});
|
||||
expect(result.__echoed.label).toBe('chrome-2');
|
||||
|
||||
c1.close();
|
||||
c2.close();
|
||||
});
|
||||
|
||||
it('throws on unknown browser target', async () => {
|
||||
const { client } = await setupBridgeWithClient('chrome');
|
||||
await expect(
|
||||
server.browser({ action: 'navigate', browser: 'safari', url: 'x' }),
|
||||
).rejects.toThrow(/Browser "safari" not connected/);
|
||||
client.close();
|
||||
});
|
||||
|
||||
it('list_browsers returns count and details', async () => {
|
||||
const { client } = await setupBridgeWithClient('chrome');
|
||||
const result = await server.browser({ action: 'list_browsers' });
|
||||
expect(result.count).toBe(1);
|
||||
expect(result.browsers).toEqual([
|
||||
{ id: 'chrome', browser: 'chrome', capabilities: ['navigate'] },
|
||||
]);
|
||||
client.close();
|
||||
});
|
||||
|
||||
it('default routing (no browser specified) picks the first registered client', async () => {
|
||||
const { client } = await setupBridgeWithClient('firefox');
|
||||
const result = await server.browser({
|
||||
action: 'navigate',
|
||||
url: 'https://example.com',
|
||||
});
|
||||
expect(result.__echoed.method).toBe('Page.navigate');
|
||||
client.close();
|
||||
});
|
||||
});
|
||||
|
||||
describe('CDPBridgeServer.evaluate result unwrapping', () => {
|
||||
it('passes awaitPromise: true to Runtime.evaluate', async () => {
|
||||
const { client } = await setupBridgeWithClient('chrome');
|
||||
let receivedParams: any = null;
|
||||
// Replace generic echoer with a custom one that captures params.
|
||||
client.removeAllListeners('message');
|
||||
client.on('message', (raw) => {
|
||||
const msg = JSON.parse(raw.toString());
|
||||
if (msg.id !== undefined && msg.method === 'Runtime.evaluate') {
|
||||
receivedParams = msg.params;
|
||||
client.send(JSON.stringify({
|
||||
id: msg.id,
|
||||
result: { result: { value: 42, type: 'number' } },
|
||||
}));
|
||||
}
|
||||
});
|
||||
|
||||
const result = await server.browser({
|
||||
action: 'evaluate',
|
||||
code: '1 + 1',
|
||||
});
|
||||
|
||||
expect(receivedParams.awaitPromise).toBe(true);
|
||||
expect(receivedParams.returnByValue).toBe(true);
|
||||
// unwrapEvaluateResult unwraps {result: {value: 42}} to 42.
|
||||
expect(result.result).toBe(42);
|
||||
client.close();
|
||||
});
|
||||
|
||||
it('unwraps an empty {} (Promise drop) to undefined', async () => {
|
||||
const { client } = await setupBridgeWithClient('chrome');
|
||||
client.removeAllListeners('message');
|
||||
client.on('message', (raw) => {
|
||||
const msg = JSON.parse(raw.toString());
|
||||
if (msg.id !== undefined && msg.method === 'Runtime.evaluate') {
|
||||
client.send(JSON.stringify({ id: msg.id, result: {} }));
|
||||
}
|
||||
});
|
||||
|
||||
const result = await server.browser({ action: 'evaluate', code: 'foo()' });
|
||||
expect(result.result).toBeUndefined();
|
||||
client.close();
|
||||
});
|
||||
});
|
||||
@@ -3,8 +3,8 @@
|
||||
*
|
||||
* These tests assert that the things which were broken in 1.8.x stay
|
||||
* fixed in 1.9.0 and beyond. They probe BOTH the Chrome (background.ts +
|
||||
* cdp-bridge.ts) and Firefox (background-firefox.ts) source files for
|
||||
* the structural patterns that distinguish a working build from a
|
||||
* 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
|
||||
@@ -23,8 +23,7 @@ function read(p: string): string {
|
||||
|
||||
const chromeBg = read('src/background.ts');
|
||||
const firefoxBg = read('src/background-firefox.ts');
|
||||
const cdpBridge = read('src/cdp-bridge.ts');
|
||||
const cdpBridgeServer = read('src/cdp-bridge-server.ts');
|
||||
const 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');
|
||||
@@ -38,23 +37,24 @@ describe('IAM URL conventions', () => {
|
||||
expect(sharedConfig).toContain("IAM_API_PATH = '/v1/iam'");
|
||||
});
|
||||
|
||||
it('shared auth uses /login/oauth/access_token (not bare /oauth/token)', () => {
|
||||
expect(sharedAuth).toContain('/login/oauth/access_token');
|
||||
// The bare /oauth/token path 404'd before 1.8.7. We only care about
|
||||
// the URL appearing in non-comment code lines (the doc-comment
|
||||
// intentionally references it to explain the bug). Strip comments
|
||||
// and re-scan.
|
||||
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('login/oauth/access_token'),
|
||||
sharedAuth.indexOf('login/oauth/access_token') + 1500,
|
||||
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');
|
||||
@@ -74,11 +74,11 @@ describe('IAM URL conventions', () => {
|
||||
});
|
||||
|
||||
describe('parseTabId centralization', () => {
|
||||
it('cdp-bridge.ts imports parseTabId from shared/tab-id', () => {
|
||||
it('browser-dispatch.ts imports parseTabId from shared/tab-id', () => {
|
||||
expect(cdpBridge).toMatch(/import\s+\{[^}]*parseTabId[^}]*\}\s+from\s+['"]\.\/shared\/tab-id\.js['"]/);
|
||||
});
|
||||
|
||||
it('cdp-bridge.ts no longer defines its own parseTabId', () => {
|
||||
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*\(/);
|
||||
});
|
||||
@@ -100,7 +100,7 @@ describe('parseTabId centralization', () => {
|
||||
});
|
||||
|
||||
describe('awaitPromise propagation', () => {
|
||||
it('cdp-bridge.ts handleBridgeMessage Runtime.evaluate sets awaitPromise: true', () => {
|
||||
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':"),
|
||||
@@ -109,24 +109,38 @@ describe('awaitPromise propagation', () => {
|
||||
expect(evalSection).toContain('awaitPromise: true');
|
||||
});
|
||||
|
||||
it('cdp-bridge.ts evaluate() helper uses 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') + 600,
|
||||
cdpBridge.indexOf('async evaluate(tabId: number') + 900,
|
||||
);
|
||||
expect(helperSection).toContain('awaitPromise: true');
|
||||
expect(helperSection).toContain('unwrapEvaluateResult');
|
||||
});
|
||||
|
||||
it('cdp-bridge-server.ts evaluate action sets awaitPromise: true', () => {
|
||||
const evalSection = cdpBridgeServer.slice(
|
||||
cdpBridgeServer.indexOf("case 'evaluate':"),
|
||||
cdpBridgeServer.indexOf("case 'evaluate':") + 800,
|
||||
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('awaitPromise: true');
|
||||
expect(evalSection).toContain('unwrapEvaluateResult');
|
||||
expect(evalSection).toContain('Promise.resolve(');
|
||||
expect(evalSection).toContain('__hanzo_error');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -235,8 +249,11 @@ describe('Inspect-modifier shortcut', () => {
|
||||
});
|
||||
|
||||
describe('OAuth client ID convention', () => {
|
||||
it('client ID is `app-hanzo` (Hanzo IAM <org>-<app> convention)', () => {
|
||||
expect(sharedConfig).toContain("DEFAULT_CLIENT_ID = 'app-hanzo'");
|
||||
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'");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -272,25 +289,6 @@ describe('webextension-polyfill integration', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('Bridge server hygiene', () => {
|
||||
it('no duplicate switch case for go_back / go_forward / etc.', () => {
|
||||
// Count occurrences of the dead-duplicate cases. Each name MUST appear
|
||||
// at most once as `case 'X':`.
|
||||
const tokens = [
|
||||
'go_back', 'go_forward', 'navigate_back', 'navigate_forward',
|
||||
'get_url', 'get_title', 'get_tab_info', 'get_history',
|
||||
'wait_for_navigation', 'create_tab',
|
||||
// close_tab is allowed twice because the mid-section uses it as an
|
||||
// alias on the navigation block AND the tabs block. Same for tab_info.
|
||||
];
|
||||
for (const t of tokens) {
|
||||
const re = new RegExp(`case ['"]${t}['"]:`, 'g');
|
||||
const count = (cdpBridgeServer.match(re) || []).length;
|
||||
expect(count, `case '${t}' appears ${count} times in cdp-bridge-server.ts`).toBeLessThanOrEqual(1);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('Firefox register identity', () => {
|
||||
it('Firefox sends `browser: BROWSER_NAME` so multi-browser routing works', () => {
|
||||
// Pre-1.9.0 the register payload omitted `browser`, so the bridge
|
||||
|
||||
@@ -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('');
|
||||
});
|
||||
});
|
||||
@@ -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": {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"scriptId": "<APPS_SCRIPT_PROJECT_ID>",
|
||||
"rootDir": "src"
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
# clasp pushes rootDir (src/) — everything outside it is dev-only tooling.
|
||||
**/**
|
||||
!appsscript.json
|
||||
!*.gs
|
||||
@@ -0,0 +1,5 @@
|
||||
# Per-developer clasp state — scriptId + auth are not shared. Commit only
|
||||
# .clasp.json.example.
|
||||
.clasp.json
|
||||
.clasprc.json
|
||||
node_modules/
|
||||
@@ -0,0 +1,168 @@
|
||||
# Hanzo AI for Google Workspace
|
||||
|
||||
A Google Workspace **add-on** that puts Hanzo AI inside **Google Docs, Sheets,
|
||||
Slides, and Gmail** — the Google-ecosystem counterpart to
|
||||
[`@hanzo/office-addin`](../office) (Word/Excel/PowerPoint). Open the Hanzo panel
|
||||
from the add-on sidebar, pick a model, ask (or tap a quick action), and insert
|
||||
the result into the document.
|
||||
|
||||
It reuses the **same backend as everything else Hanzo**: model calls go through
|
||||
`api.hanzo.ai/v1` (OpenAI-compatible) via `UrlFetchApp`. No new API surface.
|
||||
|
||||
## Architecture
|
||||
|
||||
Google Workspace Add-ons run on **Apps Script** (server-side JS in Google's V8
|
||||
runtime) with a **CardService** UI — not HTML/JS in a task pane. Apps Script has
|
||||
no `fetch`/SSE, so every model call is **one non-streaming completion**
|
||||
(`UrlFetchApp.fetch` → insert), which keeps the insert atomic.
|
||||
|
||||
```
|
||||
src/
|
||||
appsscript.json add-on manifest: scopes, urlFetchWhitelist, per-host triggers
|
||||
hanzo.gs API client — buildMessages / requestBody / extractContent /
|
||||
parseModels (pure, tested) + ask / listModels (UrlFetchApp) +
|
||||
the per-user API-key & model store (PropertiesService)
|
||||
ui.gs CardService UI + every entry point the manifest names:
|
||||
homepage triggers, Gmail contextual/compose triggers,
|
||||
settings + universal actions, button handlers
|
||||
docs.gs Docs: read selection/body, insert/replace at cursor
|
||||
sheets.gs Sheets: read range as TSV, write result down a column
|
||||
slides.gs Slides: read selected text/shapes, insert
|
||||
gmail.gs Gmail: read the open message/thread, draft a reply
|
||||
tests/ vitest over the pure logic (loads the .gs in a Node sandbox)
|
||||
scripts/ validate-manifest.mjs — preflight before `clasp push`
|
||||
```
|
||||
|
||||
The pure functions live **once** in the `.gs` files and end with a guarded
|
||||
`module.exports` (inert in Apps Script — it has no `module` — but live under
|
||||
Node), so the tests exercise the exact shipped code with zero duplication.
|
||||
|
||||
## Auth
|
||||
|
||||
Paste a Hanzo **API key** (`hk-…`, created at
|
||||
[console.hanzo.ai/api-keys](https://console.hanzo.ai/api-keys)) into the add-on
|
||||
**Settings** card. It is stored in `PropertiesService.getUserProperties()`
|
||||
(per-user, never leaves the account) and sent as `Authorization: Bearer <key>`.
|
||||
No key means the add-on shows an onboarding card first.
|
||||
|
||||
## OAuth scopes (in `appsscript.json`)
|
||||
|
||||
| Scope | Why |
|
||||
| --- | --- |
|
||||
| `documents.currentonly` | read/insert in the open Doc |
|
||||
| `spreadsheets.currentonly` | read range / write cell in the open Sheet |
|
||||
| `presentations.currentonly` | read/insert in the open Slides deck |
|
||||
| `gmail.addons.current.message.readonly` | read the open message/thread |
|
||||
| `gmail.addons.current.message.metadata` | message metadata for the contextual trigger |
|
||||
| `gmail.addons.current.action.compose` | open a draft reply |
|
||||
| `gmail.addons.execute` | run the Gmail add-on |
|
||||
| `script.external_request` | `UrlFetchApp` to `api.hanzo.ai` |
|
||||
| `script.locale` | user locale |
|
||||
|
||||
`urlFetchWhitelist` is pinned to `https://api.hanzo.ai/` — the add-on can call
|
||||
**nothing else**.
|
||||
|
||||
## Develop & test
|
||||
|
||||
```bash
|
||||
pnpm --filter @hanzo/gworkspace-addon test # vitest over the pure logic
|
||||
pnpm --filter @hanzo/gworkspace-addon validate # manifest + .gs preflight
|
||||
```
|
||||
|
||||
`validate` checks that `appsscript.json` is valid JSON with the required scopes
|
||||
and the `api.hanzo.ai` whitelist, that every `runFunction`/`onTriggerFunction`
|
||||
the manifest names is defined in a `.gs`, and that every `.gs` parses.
|
||||
|
||||
## Deploy (clasp → Apps Script → Workspace Add-on)
|
||||
|
||||
[`clasp`](https://github.com/google/clasp) is the Apps Script CLI. It is a
|
||||
devDependency of this package.
|
||||
|
||||
### 1. One-time: log in and create the Apps Script project
|
||||
|
||||
```bash
|
||||
cd packages/gworkspace
|
||||
pnpm install # installs @google/clasp
|
||||
pnpm exec clasp login # opens a browser; authorizes clasp
|
||||
|
||||
# Create a NEW standalone Apps Script project bound to nothing (add-ons are
|
||||
# standalone, not container-bound). This writes .clasp.json for you:
|
||||
pnpm exec clasp create --type standalone --title "Hanzo AI" --rootDir src
|
||||
```
|
||||
|
||||
If the project already exists (a teammate created it), skip `create` and copy
|
||||
`.clasp.json.example` → `.clasp.json`, filling in the `scriptId` from the Apps
|
||||
Script editor (**Project Settings → IDs → Script ID**). `.clasp.json` is
|
||||
git-ignored; only the `.example` is committed.
|
||||
|
||||
### 2. Push the code
|
||||
|
||||
```bash
|
||||
pnpm exec clasp push # uploads src/*.gs + src/appsscript.json
|
||||
```
|
||||
|
||||
`rootDir: src` and `.claspignore` mean only the `.gs` files and the manifest are
|
||||
pushed — tests, scripts, and `node_modules` stay local.
|
||||
|
||||
> The **Google Cloud project** backing the Apps Script must have the
|
||||
> **Google Workspace Add-ons API** and (for Gmail) the **Gmail API** enabled,
|
||||
> and an **OAuth consent screen** configured. In the Apps Script editor:
|
||||
> **Project Settings → Google Cloud Platform (GCP) Project** → switch to a
|
||||
> standard GCP project you own, then enable those APIs in that project.
|
||||
|
||||
### 3. Test-deploy (install into your own account)
|
||||
|
||||
In the [Apps Script editor](https://script.google.com) for the project:
|
||||
|
||||
1. **Deploy → Test deployments**.
|
||||
2. Under **Application(s): Google Workspace Add-on**, click **Install**.
|
||||
3. Open Docs / Sheets / Slides / Gmail — the **Hanzo AI** add-on appears in the
|
||||
right-hand add-on rail. Open it, go to **Settings**, paste your `hk-` key.
|
||||
|
||||
A test deployment installs the add-on **only for you**, from the current HEAD of
|
||||
what you pushed — the fast inner loop.
|
||||
|
||||
### 4. Versioned deployment (for sharing / Marketplace)
|
||||
|
||||
```bash
|
||||
pnpm exec clasp deploy --description "v1" # cuts a numbered version
|
||||
pnpm exec clasp deployments # list deployment ids
|
||||
```
|
||||
|
||||
A versioned deployment is what the Google Workspace Marketplace listing points
|
||||
at.
|
||||
|
||||
## Publish to the Google Workspace Marketplace (staged)
|
||||
|
||||
Add-ons are distributed through the **Google Workspace Marketplace**, configured
|
||||
via the **Google Cloud Console** on the same GCP project that backs the Apps
|
||||
Script.
|
||||
|
||||
1. **Enable** the *Google Workspace Marketplace SDK* on the GCP project
|
||||
(**APIs & Services → Library**).
|
||||
2. **Marketplace SDK → App Configuration**:
|
||||
- **Visibility: `Private`** (only your Workspace org) first, or **`Unlisted`**
|
||||
(anyone with the link) for a wider staged test — **not `Public`** yet.
|
||||
- **App integration: Google Workspace Add-on**, and paste the **deployment
|
||||
ID** from `clasp deployments` (step 4 above).
|
||||
- Declare the same OAuth scopes as `appsscript.json`.
|
||||
3. **Marketplace SDK → Store Listing**: name (Hanzo AI), icons, screenshots,
|
||||
description, support/privacy URLs (`hanzo.ai/support`,
|
||||
`hanzo.ai/privacy`), then **Publish** to the chosen visibility.
|
||||
4. **OAuth verification**: because the add-on requests Gmail scopes, a
|
||||
**`Public`** listing requires Google's **OAuth app verification** (and, for
|
||||
the restricted Gmail scopes, a **CASA security assessment**). Stay
|
||||
`Private`/`Unlisted` until that clears — internal-org (`Private`) and
|
||||
unlisted installs work without full verification.
|
||||
|
||||
**Staging path:** `Private` (your org) → `Unlisted` (link-shared beta) →
|
||||
`Public` (after OAuth verification + CASA). Ship internal first.
|
||||
|
||||
## Not here yet
|
||||
|
||||
- **Streaming** — Apps Script has no SSE reader, so responses are single
|
||||
non-streaming completions by design.
|
||||
- **Hanzo OAuth (hanzo.id) sign-in** — the Office add-in offers an IAM OAuth
|
||||
path in addition to the API key; here the API key is the one path (an OAuth
|
||||
path would need a registered redirect on the Apps Script web-app URL). API key
|
||||
is complete and zero-setup.
|
||||
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"name": "@hanzo/gworkspace-addon",
|
||||
"version": "1.9.30",
|
||||
"description": "Hanzo AI for Google Workspace \u2014 a Google Docs, Sheets, Slides, and Gmail add-on (Apps Script + CardService). AI over your document, wired to the same api.hanzo.ai gateway as the browser extension and the Office add-in.",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"test": "vitest run",
|
||||
"validate": "node scripts/validate-manifest.mjs",
|
||||
"login": "clasp login",
|
||||
"push": "clasp push",
|
||||
"pull": "clasp pull",
|
||||
"deploy": "clasp deploy",
|
||||
"open": "clasp open"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@google/clasp": "^2.5.0",
|
||||
"vitest": "^3.2.6"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
},
|
||||
"license": "MIT"
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
// validate-manifest — a standalone preflight for `clasp push`:
|
||||
// 1. appsscript.json is valid JSON with the required scopes + whitelist.
|
||||
// 2. every runFunction / onTriggerFunction named in the manifest is defined
|
||||
// in a src/*.gs file (no trigger pointing at a missing handler).
|
||||
// 3. every src/*.gs parses as JavaScript.
|
||||
// Exits non-zero on any failure so it can gate a push. `npm run validate`.
|
||||
|
||||
import { readFileSync, readdirSync } from 'node:fs';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { dirname, resolve } from 'node:path';
|
||||
import vm from 'node:vm';
|
||||
|
||||
const here = dirname(fileURLToPath(import.meta.url));
|
||||
const srcDir = resolve(here, '..', 'src');
|
||||
const fail = (msg) => {
|
||||
console.error('✗ ' + msg);
|
||||
process.exitCode = 1;
|
||||
};
|
||||
|
||||
// 1. manifest
|
||||
const manifest = JSON.parse(readFileSync(resolve(srcDir, 'appsscript.json'), 'utf8'));
|
||||
|
||||
const requiredScopes = [
|
||||
'https://www.googleapis.com/auth/documents.currentonly',
|
||||
'https://www.googleapis.com/auth/spreadsheets.currentonly',
|
||||
'https://www.googleapis.com/auth/presentations.currentonly',
|
||||
'https://www.googleapis.com/auth/gmail.addons.current.message.readonly',
|
||||
'https://www.googleapis.com/auth/gmail.addons.current.action.compose',
|
||||
'https://www.googleapis.com/auth/script.external_request',
|
||||
];
|
||||
for (const s of requiredScopes) {
|
||||
if (!manifest.oauthScopes.includes(s)) fail('missing oauthScope: ' + s);
|
||||
}
|
||||
if (JSON.stringify(manifest.urlFetchWhitelist) !== JSON.stringify(['https://api.hanzo.ai/'])) {
|
||||
fail('urlFetchWhitelist must be exactly ["https://api.hanzo.ai/"]');
|
||||
}
|
||||
|
||||
// 2. collect every function the manifest references
|
||||
const referenced = new Set();
|
||||
const walk = (node) => {
|
||||
if (!node || typeof node !== 'object') return;
|
||||
for (const [k, v] of Object.entries(node)) {
|
||||
if ((k === 'runFunction' || k === 'onTriggerFunction') && typeof v === 'string') {
|
||||
referenced.add(v);
|
||||
} else {
|
||||
walk(v);
|
||||
}
|
||||
}
|
||||
};
|
||||
walk(manifest.addOns);
|
||||
|
||||
// 3. load every .gs, collect defined function names, and parse-check
|
||||
const defined = new Set();
|
||||
for (const file of readdirSync(srcDir).filter((f) => f.endsWith('.gs'))) {
|
||||
const src = readFileSync(resolve(srcDir, file), 'utf8');
|
||||
try {
|
||||
new vm.Script(src, { filename: file });
|
||||
} catch (e) {
|
||||
fail(file + ' does not parse: ' + e.message);
|
||||
continue;
|
||||
}
|
||||
for (const m of src.matchAll(/^function\s+([A-Za-z0-9_]+)\s*\(/gm)) defined.add(m[1]);
|
||||
}
|
||||
|
||||
for (const fn of referenced) {
|
||||
if (!defined.has(fn)) fail('manifest references undefined function: ' + fn + '()');
|
||||
}
|
||||
|
||||
if (process.exitCode) {
|
||||
console.error('\nvalidate-manifest FAILED');
|
||||
} else {
|
||||
console.log(
|
||||
'✓ manifest scopes + whitelist OK; ' +
|
||||
referenced.size +
|
||||
' referenced functions all defined across ' +
|
||||
readdirSync(srcDir).filter((f) => f.endsWith('.gs')).length +
|
||||
' .gs files (all parse).'
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
{
|
||||
"timeZone": "Etc/UTC",
|
||||
"exceptionLogging": "STACKDRIVER",
|
||||
"runtimeVersion": "V8",
|
||||
"oauthScopes": [
|
||||
"https://www.googleapis.com/auth/documents.currentonly",
|
||||
"https://www.googleapis.com/auth/spreadsheets.currentonly",
|
||||
"https://www.googleapis.com/auth/presentations.currentonly",
|
||||
"https://www.googleapis.com/auth/gmail.addons.current.message.readonly",
|
||||
"https://www.googleapis.com/auth/gmail.addons.current.message.metadata",
|
||||
"https://www.googleapis.com/auth/gmail.addons.current.action.compose",
|
||||
"https://www.googleapis.com/auth/gmail.addons.execute",
|
||||
"https://www.googleapis.com/auth/script.external_request",
|
||||
"https://www.googleapis.com/auth/script.locale"
|
||||
],
|
||||
"urlFetchWhitelist": [
|
||||
"https://api.hanzo.ai/"
|
||||
],
|
||||
"addOns": {
|
||||
"common": {
|
||||
"name": "Hanzo AI",
|
||||
"logoUrl": "https://hanzo.ai/assets/icon-128.png",
|
||||
"layoutProperties": {
|
||||
"primaryColor": "#111111",
|
||||
"secondaryColor": "#4f46e5"
|
||||
},
|
||||
"homepageTrigger": {
|
||||
"runFunction": "onHomepage"
|
||||
},
|
||||
"universalActions": [
|
||||
{
|
||||
"label": "Settings — Hanzo API key",
|
||||
"runFunction": "onSettings"
|
||||
},
|
||||
{
|
||||
"label": "Get an API key",
|
||||
"openLink": "https://console.hanzo.ai/api-keys"
|
||||
}
|
||||
]
|
||||
},
|
||||
"docs": {
|
||||
"homepageTrigger": {
|
||||
"runFunction": "onDocsHomepage"
|
||||
}
|
||||
},
|
||||
"sheets": {
|
||||
"homepageTrigger": {
|
||||
"runFunction": "onSheetsHomepage"
|
||||
}
|
||||
},
|
||||
"slides": {
|
||||
"homepageTrigger": {
|
||||
"runFunction": "onSlidesHomepage"
|
||||
}
|
||||
},
|
||||
"gmail": {
|
||||
"homepageTrigger": {
|
||||
"runFunction": "onGmailHomepage"
|
||||
},
|
||||
"contextualTriggers": [
|
||||
{
|
||||
"unconditional": {},
|
||||
"onTriggerFunction": "onGmailMessage"
|
||||
}
|
||||
],
|
||||
"composeTrigger": {
|
||||
"selectActions": [
|
||||
{
|
||||
"text": "Draft with Hanzo AI",
|
||||
"runFunction": "onGmailCompose"
|
||||
}
|
||||
],
|
||||
"draftAccess": "METADATA"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
/**
|
||||
* docs.gs — Google Docs host glue.
|
||||
*
|
||||
* Read the selection (or the whole body when nothing is selected), run the
|
||||
* task, and insert/replace at the cursor. Mirrors the Word path in
|
||||
* packages/office/src/host.ts.
|
||||
*/
|
||||
|
||||
/** docsSelectionText returns the selected text, or '' when nothing is selected. */
|
||||
function docsSelectionText() {
|
||||
var doc = DocumentApp.getActiveDocument();
|
||||
var sel = doc.getSelection();
|
||||
if (!sel) return '';
|
||||
var parts = [];
|
||||
var elements = sel.getRangeElements();
|
||||
for (var i = 0; i < elements.length; i++) {
|
||||
var el = elements[i];
|
||||
var e = el.getElement();
|
||||
if (!e.editAsText) continue;
|
||||
var t = e.asText().getText();
|
||||
if (el.isPartial()) t = t.substring(el.getStartOffset(), el.getEndOffsetInclusive() + 1);
|
||||
if (t) parts.push(t);
|
||||
}
|
||||
return parts.join('\n');
|
||||
}
|
||||
|
||||
/** docsContextText is the selection if present, else the full body — the model's input. */
|
||||
function docsContextText() {
|
||||
var sel = docsSelectionText();
|
||||
if (sel) return sel;
|
||||
return DocumentApp.getActiveDocument().getBody().getText();
|
||||
}
|
||||
|
||||
/**
|
||||
* docsInsert writes the result into the doc. When there is a selection we
|
||||
* replace it; otherwise we insert at the cursor (or append to the body when
|
||||
* there is no cursor, e.g. right after a programmatic selection).
|
||||
*/
|
||||
function docsInsert(text) {
|
||||
var doc = DocumentApp.getActiveDocument();
|
||||
var sel = doc.getSelection();
|
||||
if (sel) {
|
||||
replaceSelection_(sel, text);
|
||||
return;
|
||||
}
|
||||
var cursor = doc.getCursor();
|
||||
if (cursor) {
|
||||
var inserted = cursor.insertText(text);
|
||||
if (inserted) doc.setCursor(doc.newPosition(inserted, text.length));
|
||||
return;
|
||||
}
|
||||
doc.getBody().appendParagraph(text);
|
||||
}
|
||||
|
||||
// replaceSelection_ deletes the selected content and inserts the result in its
|
||||
// place, at the first partial/whole element of the selection.
|
||||
function replaceSelection_(sel, text) {
|
||||
var elements = sel.getRangeElements();
|
||||
var anchor = null;
|
||||
var anchorOffset = 0;
|
||||
for (var i = 0; i < elements.length; i++) {
|
||||
var el = elements[i];
|
||||
var e = el.getElement();
|
||||
if (!e.editAsText) continue;
|
||||
var t = e.asText();
|
||||
if (el.isPartial()) {
|
||||
if (anchor === null) {
|
||||
anchor = t;
|
||||
anchorOffset = el.getStartOffset();
|
||||
}
|
||||
t.deleteText(el.getStartOffset(), el.getEndOffsetInclusive());
|
||||
} else if (i === 0) {
|
||||
anchor = t;
|
||||
anchorOffset = 0;
|
||||
t.setText('');
|
||||
} else if (e.getParent() && e.getParent().getChildIndex) {
|
||||
var parent = e.getParent();
|
||||
if (parent.getNumChildren && parent.getNumChildren() > 1) e.removeFromParent();
|
||||
else t.setText('');
|
||||
}
|
||||
}
|
||||
if (anchor) anchor.insertText(anchorOffset, text);
|
||||
else DocumentApp.getActiveDocument().getBody().appendParagraph(text);
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
/**
|
||||
* gmail.gs — Gmail host glue.
|
||||
*
|
||||
* Read the open message / thread from the contextual-trigger event and run a
|
||||
* task. The gmail.addons.current.* scopes give per-message access; the
|
||||
* current-message token must be set before reading via GmailApp. The UI action
|
||||
* handlers (ui.gs) call these, then render or draft with the result.
|
||||
*/
|
||||
|
||||
/** gmailCurrentMessage_ resolves the GmailMessage for the current event, after
|
||||
* authorizing the per-message access token. Returns null when there is none. */
|
||||
function gmailCurrentMessage_(e) {
|
||||
if (!e || !e.gmail || !e.gmail.messageId) return null;
|
||||
if (e.gmail.accessToken) GmailApp.setCurrentMessageAccessToken(e.gmail.accessToken);
|
||||
return GmailApp.getMessageById(e.gmail.messageId);
|
||||
}
|
||||
|
||||
/** gmailMessageText returns the plain-text body of the message in the event e. */
|
||||
function gmailMessageText(e) {
|
||||
var message = gmailCurrentMessage_(e);
|
||||
if (!message) return '';
|
||||
return message.getPlainBody() || message.getBody() || '';
|
||||
}
|
||||
|
||||
/** gmailThreadText joins the plain-text bodies of every message in the thread,
|
||||
* each tagged with its sender — the model's input for "summarize the thread". */
|
||||
function gmailThreadText(e) {
|
||||
var message = gmailCurrentMessage_(e);
|
||||
if (!message) return '';
|
||||
var messages = message.getThread().getMessages();
|
||||
var parts = [];
|
||||
for (var i = 0; i < messages.length; i++) {
|
||||
var m = messages[i];
|
||||
parts.push('From: ' + m.getFrom() + '\n' + (m.getPlainBody() || m.getBody() || ''));
|
||||
}
|
||||
return parts.join('\n\n---\n\n');
|
||||
}
|
||||
|
||||
/** gmailDraftReply builds a compose ActionResponse that opens a reply draft on
|
||||
* the current message, prefilled with `text`. */
|
||||
function gmailDraftReply(e, text) {
|
||||
var message = gmailCurrentMessage_(e);
|
||||
var draft = message.createDraftReply(text);
|
||||
return CardService.newComposeActionResponseBuilder().setGmailDraft(draft).build();
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
/**
|
||||
* hanzo.gs — the Hanzo API client for the Google Workspace add-on.
|
||||
*
|
||||
* Mirrors packages/office/src/{config,chat}.ts, adapted to Apps Script:
|
||||
* requests go through UrlFetchApp (no fetch/SSE, so one non-streaming
|
||||
* completion per call) and the api.hanzo.ai/v1 gateway. The API key lives in
|
||||
* PropertiesService.getUserProperties() (per-user, roams with the account).
|
||||
*
|
||||
* The pure functions (buildMessages, requestBody, extractContent) have no Apps
|
||||
* Script dependency and are the tested core — they are re-exported for Node
|
||||
* under the guarded module.exports at the bottom (inert in Apps Script).
|
||||
*/
|
||||
|
||||
var API_BASE_URL = 'https://api.hanzo.ai';
|
||||
|
||||
// Default model. Overridable per-request; the gateway routes it.
|
||||
var DEFAULT_MODEL = 'zen-1';
|
||||
|
||||
// PropertiesService key for the pasted Hanzo `hk-` API key.
|
||||
var APIKEY_PROP = 'hanzo.apiKey';
|
||||
// PropertiesService key for the preferred model id.
|
||||
var MODEL_PROP = 'hanzo.model';
|
||||
|
||||
// chatCompletionsURL / modelsURL — the model gateway endpoints. api.hanzo.ai/v1
|
||||
// only; never an /api/ prefix.
|
||||
function chatCompletionsURL() {
|
||||
return API_BASE_URL + '/v1/chat/completions';
|
||||
}
|
||||
function modelsURL() {
|
||||
return API_BASE_URL + '/v1/models';
|
||||
}
|
||||
|
||||
/**
|
||||
* buildMessages turns a task (the user's instruction) plus a selection into the
|
||||
* OpenAI-compatible message list. Empty selection = pure generation; non-empty =
|
||||
* an operation ON the text, fenced so the model treats it as data.
|
||||
*/
|
||||
function buildMessages(task, selection) {
|
||||
var system = {
|
||||
role: 'system',
|
||||
content:
|
||||
'You are Hanzo AI inside a Google Workspace document. Return ONLY the text ' +
|
||||
'to insert — no preamble, no markdown fences, no "Here is". Match the ' +
|
||||
"document's tone. When given a selection, operate on it directly.",
|
||||
};
|
||||
var trimmed = (selection || '').trim();
|
||||
var user = {
|
||||
role: 'user',
|
||||
content: trimmed ? task + '\n\n---- selected text ----\n' + selection : task,
|
||||
};
|
||||
return [system, user];
|
||||
}
|
||||
|
||||
/**
|
||||
* requestBody is the exact JSON the gateway receives. stream is always false —
|
||||
* Apps Script has no streaming reader, so every call is one-shot.
|
||||
*/
|
||||
function requestBody(messages, opts) {
|
||||
opts = opts || {};
|
||||
var body = {
|
||||
model: opts.model || DEFAULT_MODEL,
|
||||
messages: messages,
|
||||
stream: false,
|
||||
};
|
||||
if (opts.temperature !== undefined) body.temperature = opts.temperature;
|
||||
return body;
|
||||
}
|
||||
|
||||
/**
|
||||
* extractContent pulls the assistant text out of an OpenAI-compatible response,
|
||||
* tolerating the shapes the gateway returns. Throws on an error payload so the
|
||||
* card surfaces the real gateway message.
|
||||
*/
|
||||
function extractContent(data) {
|
||||
if (data && data.error) {
|
||||
var msg = typeof data.error === 'string' ? data.error : data.error.message || 'unknown error';
|
||||
throw new Error('Hanzo API error: ' + msg);
|
||||
}
|
||||
var choice = data && data.choices && data.choices[0];
|
||||
var content =
|
||||
(choice && choice.message && choice.message.content) ||
|
||||
(choice && choice.text) ||
|
||||
(data && data.content) ||
|
||||
'';
|
||||
if (typeof content !== 'string' || content === '') {
|
||||
throw new Error('Hanzo API returned no content');
|
||||
}
|
||||
return content;
|
||||
}
|
||||
|
||||
/**
|
||||
* parseModels pulls the routable model ids out of a /v1/models response,
|
||||
* tolerating {data:[...]}, {models:[...]}, a bare array, and string-or-object
|
||||
* entries. Pure — the network read is in listModels.
|
||||
*/
|
||||
function parseModels(data) {
|
||||
var items =
|
||||
(data && data.data) || (data && data.models) || (Array.isArray(data) ? data : []);
|
||||
var ids = [];
|
||||
for (var i = 0; i < items.length; i++) {
|
||||
var m = items[i];
|
||||
var id = typeof m === 'string' ? m : m && m.id;
|
||||
if (typeof id === 'string' && id.length > 0) ids.push(id);
|
||||
}
|
||||
return ids;
|
||||
}
|
||||
|
||||
// ── UrlFetchApp calls (Apps Script only) ──────────────────────────────────
|
||||
|
||||
/**
|
||||
* 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 Hanzo `hk-` key.
|
||||
*/
|
||||
function ask(task, selection, token, opts) {
|
||||
var headers = {};
|
||||
if (token) headers.Authorization = 'Bearer ' + token;
|
||||
var resp = UrlFetchApp.fetch(chatCompletionsURL(), {
|
||||
method: 'post',
|
||||
contentType: 'application/json',
|
||||
headers: headers,
|
||||
payload: JSON.stringify(requestBody(buildMessages(task, selection), opts)),
|
||||
muteHttpExceptions: true,
|
||||
});
|
||||
var code = resp.getResponseCode();
|
||||
var text = resp.getContentText();
|
||||
var data;
|
||||
try {
|
||||
data = JSON.parse(text);
|
||||
} catch (e) {
|
||||
throw new Error('Hanzo API ' + code + ': ' + text.slice(0, 200));
|
||||
}
|
||||
if (code < 200 || code >= 300) {
|
||||
try {
|
||||
return extractContent(data);
|
||||
} catch (e2) {
|
||||
var m = (data && data.error && (data.error.message || data.error)) || 'HTTP ' + code;
|
||||
throw new Error('Hanzo API ' + code + ': ' + m);
|
||||
}
|
||||
}
|
||||
return extractContent(data);
|
||||
}
|
||||
|
||||
/**
|
||||
* listModels returns the routable model ids from /v1/models. Org-scoped by the
|
||||
* bearer; an empty token lists public models. Non-fatal for callers — returns []
|
||||
* on any failure so the picker falls back to the default.
|
||||
*/
|
||||
function listModels(token) {
|
||||
try {
|
||||
var headers = {};
|
||||
if (token) headers.Authorization = 'Bearer ' + token;
|
||||
var resp = UrlFetchApp.fetch(modelsURL(), {
|
||||
method: 'get',
|
||||
headers: headers,
|
||||
muteHttpExceptions: true,
|
||||
});
|
||||
if (resp.getResponseCode() !== 200) return [];
|
||||
return parseModels(JSON.parse(resp.getContentText()));
|
||||
} catch (e) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
// ── PropertiesService: per-user API key + model preference ─────────────────
|
||||
|
||||
function getApiKey() {
|
||||
return PropertiesService.getUserProperties().getProperty(APIKEY_PROP) || '';
|
||||
}
|
||||
function setApiKey(key) {
|
||||
var props = PropertiesService.getUserProperties();
|
||||
var trimmed = (key || '').trim();
|
||||
if (trimmed) props.setProperty(APIKEY_PROP, trimmed);
|
||||
else props.deleteProperty(APIKEY_PROP);
|
||||
}
|
||||
function getModel() {
|
||||
return PropertiesService.getUserProperties().getProperty(MODEL_PROP) || DEFAULT_MODEL;
|
||||
}
|
||||
function setModel(model) {
|
||||
var props = PropertiesService.getUserProperties();
|
||||
if (model && model !== DEFAULT_MODEL) props.setProperty(MODEL_PROP, model);
|
||||
else props.deleteProperty(MODEL_PROP);
|
||||
}
|
||||
|
||||
/**
|
||||
* runTask is the single entry every host calls: read the stored key + model,
|
||||
* ask the gateway, return the text. One code path for Docs/Sheets/Slides/Gmail.
|
||||
*/
|
||||
function runTask(task, selection) {
|
||||
return ask(task, selection, getApiKey(), { model: getModel() });
|
||||
}
|
||||
|
||||
// Node-only export for unit tests (inert in Apps Script — no `module` there).
|
||||
if (typeof module !== 'undefined' && module.exports) {
|
||||
module.exports = {
|
||||
API_BASE_URL: API_BASE_URL,
|
||||
DEFAULT_MODEL: DEFAULT_MODEL,
|
||||
chatCompletionsURL: chatCompletionsURL,
|
||||
modelsURL: modelsURL,
|
||||
buildMessages: buildMessages,
|
||||
requestBody: requestBody,
|
||||
extractContent: extractContent,
|
||||
parseModels: parseModels,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
/**
|
||||
* sheets.gs — Google Sheets host glue.
|
||||
*
|
||||
* Read the selected range as TSV, run the task, and write the result down a
|
||||
* column from the selection's top-left. Mirrors the Excel path in
|
||||
* packages/office/src/host.ts (rangeToText / linesToRows are pure and tested).
|
||||
*/
|
||||
|
||||
/**
|
||||
* rangeToText flattens a selected range's 2-D values into TSV: rows joined by
|
||||
* newline, cells by tab, null/undefined as empty. Pure.
|
||||
*/
|
||||
function rangeToText(values) {
|
||||
if (!values || values.length === 0) return '';
|
||||
var rows = [];
|
||||
for (var r = 0; r < values.length; r++) {
|
||||
var cells = [];
|
||||
for (var c = 0; c < values[r].length; c++) {
|
||||
var v = values[r][c];
|
||||
cells.push(v === null || v === undefined ? '' : String(v));
|
||||
}
|
||||
rows.push(cells.join('\t'));
|
||||
}
|
||||
return rows.join('\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* linesToRows turns model output into a 1-column, N-row 2-D array for writing
|
||||
* down a column. Trailing blank lines are dropped so a stray newline does not
|
||||
* clear a cell. Pure.
|
||||
*/
|
||||
function linesToRows(text) {
|
||||
var trimmed = String(text).replace(/\n+$/, '');
|
||||
var lines = trimmed.split('\n');
|
||||
var rows = [];
|
||||
for (var i = 0; i < lines.length; i++) rows.push([lines[i]]);
|
||||
return rows;
|
||||
}
|
||||
|
||||
/** sheetsContextText returns the active range as TSV — the model's input. */
|
||||
function sheetsContextText() {
|
||||
var range = SpreadsheetApp.getActiveRange();
|
||||
if (!range) return '';
|
||||
return rangeToText(range.getValues());
|
||||
}
|
||||
|
||||
/**
|
||||
* sheetsInsert writes the result down a column from the active cell (a
|
||||
* spreadsheet has no "after cursor"): the top-left of the current selection.
|
||||
*/
|
||||
function sheetsInsert(text) {
|
||||
var sheet = SpreadsheetApp.getActiveSheet();
|
||||
var range = SpreadsheetApp.getActiveRange();
|
||||
var startRow = range ? range.getRow() : 1;
|
||||
var startCol = range ? range.getColumn() : 1;
|
||||
var rows = linesToRows(text);
|
||||
sheet.getRange(startRow, startCol, rows.length, 1).setValues(rows);
|
||||
}
|
||||
|
||||
// Node-only export for unit tests (inert in Apps Script — no `module` there).
|
||||
if (typeof module !== 'undefined' && module.exports) {
|
||||
module.exports = { rangeToText: rangeToText, linesToRows: linesToRows };
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
/**
|
||||
* slides.gs — Google Slides host glue.
|
||||
*
|
||||
* Read the selected text (a text range inside a shape, or the shapes of the
|
||||
* selected page), run the task, and insert. Mirrors the PowerPoint path in
|
||||
* packages/office/src/host.ts.
|
||||
*/
|
||||
|
||||
/** slidesContextText returns the selected text — a text-range selection wins,
|
||||
* else the concatenated text of the selected page's shapes. */
|
||||
function slidesContextText() {
|
||||
var sel = SlidesApp.getActivePresentation().getSelection();
|
||||
if (!sel) return '';
|
||||
var textRange = sel.getTextRange();
|
||||
if (textRange && textRange.asString()) return textRange.asString();
|
||||
return pageShapesText_(sel);
|
||||
}
|
||||
|
||||
// pageShapesText_ joins the text of every text-bearing shape on the selected
|
||||
// page — the model's input when the user selected a slide, not a text range.
|
||||
function pageShapesText_(sel) {
|
||||
var page = sel.getCurrentPage();
|
||||
if (!page) return '';
|
||||
var parts = [];
|
||||
var shapes = page.getShapes();
|
||||
for (var i = 0; i < shapes.length; i++) {
|
||||
var shape = shapes[i];
|
||||
if (!shape.getText) continue;
|
||||
var t = shape.getText().asString();
|
||||
if (t && t.trim()) parts.push(t.trim());
|
||||
}
|
||||
return parts.join('\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* slidesInsert writes the result into the selected text range (replacing it), or
|
||||
* into the first text-bearing shape on the selected page, or a new text box.
|
||||
*/
|
||||
function slidesInsert(text) {
|
||||
var presentation = SlidesApp.getActivePresentation();
|
||||
var sel = presentation.getSelection();
|
||||
var textRange = sel && sel.getTextRange();
|
||||
if (textRange) {
|
||||
textRange.setText(text);
|
||||
return;
|
||||
}
|
||||
var page = sel && sel.getCurrentPage();
|
||||
if (!page) page = presentation.getSlides()[0];
|
||||
var shapes = page.getShapes();
|
||||
for (var i = 0; i < shapes.length; i++) {
|
||||
if (shapes[i].getText) {
|
||||
shapes[i].getText().setText(text);
|
||||
return;
|
||||
}
|
||||
}
|
||||
var box = page.insertTextBox(text);
|
||||
box.setLeft(30).setTop(30).setWidth(400).setHeight(200);
|
||||
}
|
||||
@@ -0,0 +1,468 @@
|
||||
/**
|
||||
* ui.gs — CardService UI + every add-on entry point.
|
||||
*
|
||||
* The manifest's runFunction names all resolve here: homepage triggers per host,
|
||||
* the Gmail contextual + compose triggers, the settings/universal actions, and
|
||||
* the button callbacks. The cards read/write through hanzo.gs (the API client)
|
||||
* and the per-host glue (docs/sheets/slides/gmail.gs).
|
||||
*
|
||||
* The quick actions mirror the office task pane's chips: Draft, Summarize,
|
||||
* Explain, Tighten (redline), Continue — plus Gmail-specific reply/actions.
|
||||
*/
|
||||
|
||||
// QUICK_ACTIONS: the preset instructions, one source of truth for every host's
|
||||
// action chips. { key, label, task } — task is empty for Draft (free prompt).
|
||||
var QUICK_ACTIONS = [
|
||||
{ key: 'draft', label: 'Draft', task: '' },
|
||||
{ key: 'summarize', label: 'Summarize', task: 'Summarize the selection concisely.' },
|
||||
{ key: 'explain', label: 'Explain', task: 'Explain the selection in plain language.' },
|
||||
{
|
||||
key: 'tighten',
|
||||
label: 'Tighten (redline)',
|
||||
task: 'Tighten and clarify the selection as a redline edit. Keep the meaning; cut the fluff.',
|
||||
},
|
||||
{ key: 'continue', label: 'Continue', task: 'Continue writing from where the selection ends.' },
|
||||
];
|
||||
|
||||
// GMAIL_ACTIONS: Gmail-specific presets over the open message/thread.
|
||||
var GMAIL_ACTIONS = [
|
||||
{
|
||||
key: 'summarize_thread',
|
||||
label: 'Summarize thread',
|
||||
task: 'Summarize this email thread: the ask, the decisions, and the open questions.',
|
||||
},
|
||||
{
|
||||
key: 'action_items',
|
||||
label: 'Extract action items',
|
||||
task: 'Extract the concrete action items from this email as a short bullet list. Who owes what.',
|
||||
},
|
||||
{
|
||||
key: 'draft_reply',
|
||||
label: 'Draft a reply',
|
||||
task: 'Draft a professional reply to this email. Return only the reply body.',
|
||||
},
|
||||
];
|
||||
|
||||
// ── Colors / branding ──────────────────────────────────────────────────────
|
||||
|
||||
var BRAND = 'Hanzo AI';
|
||||
var ICON_URL = 'https://hanzo.ai/assets/icon-128.png';
|
||||
|
||||
// ── Homepage triggers (one per host + the common fallback) ─────────────────
|
||||
|
||||
function onHomepage(e) {
|
||||
return buildHomeCard_(hostFromEvent_(e), null);
|
||||
}
|
||||
function onDocsHomepage(e) {
|
||||
return buildHomeCard_('docs', null);
|
||||
}
|
||||
function onSheetsHomepage(e) {
|
||||
return buildHomeCard_('sheets', null);
|
||||
}
|
||||
function onSlidesHomepage(e) {
|
||||
return buildHomeCard_('slides', null);
|
||||
}
|
||||
function onGmailHomepage(e) {
|
||||
return buildHomeCard_('gmail', null);
|
||||
}
|
||||
|
||||
// onGmailMessage — Gmail contextual trigger: an open message. Same card, with a
|
||||
// note that the actions read the open message.
|
||||
function onGmailMessage(e) {
|
||||
return buildHomeCard_('gmail', null);
|
||||
}
|
||||
|
||||
// hostFromEvent_ reads the host from the common event; defaults to a generic card.
|
||||
function hostFromEvent_(e) {
|
||||
var host = e && e.commonEventObject && e.commonEventObject.hostApp;
|
||||
if (host === 'DOCS') return 'docs';
|
||||
if (host === 'SHEETS') return 'sheets';
|
||||
if (host === 'SLIDES') return 'slides';
|
||||
if (host === 'GMAIL') return 'gmail';
|
||||
return 'docs';
|
||||
}
|
||||
|
||||
// ── Home card ──────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* buildHomeCard_ renders the sidebar: header, an onboarding hint when no key is
|
||||
* set, the model picker, a free-prompt field, the quick-action buttons, and (on
|
||||
* a prior run) the result with Insert. `state` carries prompt/output between
|
||||
* button clicks (CardService is stateless — we thread it through form inputs).
|
||||
*/
|
||||
function buildHomeCard_(host, state) {
|
||||
var hasKey = !!getApiKey();
|
||||
var card = CardService.newCardBuilder().setHeader(
|
||||
CardService.newCardHeader().setTitle(BRAND).setImageUrl(ICON_URL).setSubtitle(hostLabel_(host))
|
||||
);
|
||||
|
||||
if (!hasKey) {
|
||||
card.addSection(onboardingSection_());
|
||||
return card.build();
|
||||
}
|
||||
|
||||
card.addSection(promptSection_(host, state));
|
||||
card.addSection(actionsSection_(host));
|
||||
if (state && state.output) card.addSection(outputSection_(host, state));
|
||||
card.setFixedFooter(settingsFooter_());
|
||||
return card.build();
|
||||
}
|
||||
|
||||
// onboardingSection_ — shown until an API key is saved: a one-tap path to keys +
|
||||
// the paste field.
|
||||
function onboardingSection_() {
|
||||
var section = CardService.newCardSection().setHeader('Get started');
|
||||
section.addWidget(
|
||||
CardService.newTextParagraph().setText(
|
||||
'Paste a Hanzo API key to use your models. Create one at ' +
|
||||
'<b>console.hanzo.ai/api-keys</b>.'
|
||||
)
|
||||
);
|
||||
section.addWidget(
|
||||
CardService.newTextButton()
|
||||
.setText('Get an API key')
|
||||
.setOpenLink(CardService.newOpenLink().setUrl('https://console.hanzo.ai/api-keys'))
|
||||
);
|
||||
section.addWidget(apiKeyInput_(''));
|
||||
section.addWidget(
|
||||
CardService.newTextButton()
|
||||
.setText('Save key')
|
||||
.setOnClickAction(CardService.newAction().setFunctionName('onSaveKey'))
|
||||
);
|
||||
return section;
|
||||
}
|
||||
|
||||
// promptSection_ — the model picker + the free-prompt textarea.
|
||||
function promptSection_(host, state) {
|
||||
var section = CardService.newCardSection();
|
||||
section.addWidget(modelPicker_());
|
||||
section.addWidget(
|
||||
CardService.newTextInput()
|
||||
.setFieldName('prompt')
|
||||
.setTitle('Ask Hanzo')
|
||||
.setHint('e.g. "draft an NDA mutual-confidentiality clause"')
|
||||
.setMultiline(true)
|
||||
.setValue(state && state.prompt ? state.prompt : '')
|
||||
);
|
||||
section.addWidget(
|
||||
CardService.newTextButton()
|
||||
.setText('Run')
|
||||
.setTextButtonStyle(CardService.TextButtonStyle.FILLED)
|
||||
.setOnClickAction(actionFor_('onRun', host, ''))
|
||||
);
|
||||
return section;
|
||||
}
|
||||
|
||||
// actionsSection_ — the quick-action chips (per host). Each button runs its
|
||||
// preset task through the current host's read → ask → (output card) path.
|
||||
function actionsSection_(host) {
|
||||
var section = CardService.newCardSection().setHeader('Quick actions');
|
||||
var actions = host === 'gmail' ? GMAIL_ACTIONS : QUICK_ACTIONS;
|
||||
for (var i = 0; i < actions.length; i++) {
|
||||
var a = actions[i];
|
||||
if (a.key === 'draft') continue; // Draft = the free prompt above
|
||||
var fn = a.key === 'draft_reply' ? 'onDraftReply' : 'onRun';
|
||||
section.addWidget(
|
||||
CardService.newTextButton().setText(a.label).setOnClickAction(actionFor_(fn, host, a.task))
|
||||
);
|
||||
}
|
||||
return section;
|
||||
}
|
||||
|
||||
// outputSection_ — the last result with Insert (host-specific) + Copy hint.
|
||||
function outputSection_(host, state) {
|
||||
var section = CardService.newCardSection().setHeader('Result');
|
||||
section.addWidget(CardService.newTextParagraph().setText(escapeHtml_(state.output)));
|
||||
if (host !== 'gmail') {
|
||||
section.addWidget(
|
||||
CardService.newTextButton()
|
||||
.setText('Insert into document')
|
||||
.setTextButtonStyle(CardService.TextButtonStyle.FILLED)
|
||||
.setOnClickAction(actionFor_('onInsert', host, '').setParameters({ output: state.output }))
|
||||
);
|
||||
}
|
||||
return section;
|
||||
}
|
||||
|
||||
// settingsFooter_ — persistent footer button to the settings card. Uses the
|
||||
// card-action handler (pushCard), NOT the universal action (which returns a
|
||||
// different response type).
|
||||
function settingsFooter_() {
|
||||
return CardService.newFixedFooter().setPrimaryButton(
|
||||
CardService.newTextButton()
|
||||
.setText('Settings')
|
||||
.setOnClickAction(CardService.newAction().setFunctionName('onOpenSettings'))
|
||||
);
|
||||
}
|
||||
|
||||
// ── Model picker + inputs ──────────────────────────────────────────────────
|
||||
|
||||
function modelPicker_() {
|
||||
var current = getModel();
|
||||
var picker = CardService.newSelectionInput()
|
||||
.setType(CardService.SelectionInputType.DROPDOWN)
|
||||
.setTitle('Model')
|
||||
.setFieldName('model')
|
||||
.setOnChangeAction(CardService.newAction().setFunctionName('onSelectModel'));
|
||||
var ids = listModels(getApiKey());
|
||||
if (ids.indexOf(current) === -1) ids.unshift(current);
|
||||
if (ids.length === 0) ids = [DEFAULT_MODEL];
|
||||
for (var i = 0; i < ids.length; i++) {
|
||||
picker.addItem(ids[i], ids[i], ids[i] === current);
|
||||
}
|
||||
return picker;
|
||||
}
|
||||
|
||||
function apiKeyInput_(value) {
|
||||
return CardService.newTextInput()
|
||||
.setFieldName('apikey')
|
||||
.setTitle('Hanzo API key (hk-…)')
|
||||
.setHint('Stored per-user, never leaves your account.')
|
||||
.setValue(value || '');
|
||||
}
|
||||
|
||||
// ── Settings card ──────────────────────────────────────────────────────────
|
||||
|
||||
// settingsCard_ builds the settings card — one definition, two entry points
|
||||
// (the universal action and the in-card footer button) that wrap it in their
|
||||
// respective response types.
|
||||
function settingsCard_() {
|
||||
var card = CardService.newCardBuilder().setHeader(
|
||||
CardService.newCardHeader().setTitle('Hanzo AI — Settings')
|
||||
);
|
||||
var section = CardService.newCardSection().setHeader('API key');
|
||||
section.addWidget(
|
||||
CardService.newTextParagraph().setText(
|
||||
'Create a key at <b>console.hanzo.ai/api-keys</b> and paste it below. ' +
|
||||
'It is stored in your per-user properties and sent only to api.hanzo.ai.'
|
||||
)
|
||||
);
|
||||
section.addWidget(apiKeyInput_(getApiKey()));
|
||||
section.addWidget(
|
||||
CardService.newTextButton()
|
||||
.setText('Save key')
|
||||
.setTextButtonStyle(CardService.TextButtonStyle.FILLED)
|
||||
.setOnClickAction(CardService.newAction().setFunctionName('onSaveKey'))
|
||||
);
|
||||
section.addWidget(
|
||||
CardService.newTextButton()
|
||||
.setText('Clear key')
|
||||
.setOnClickAction(CardService.newAction().setFunctionName('onClearKey'))
|
||||
);
|
||||
card.addSection(section);
|
||||
return card.build();
|
||||
}
|
||||
|
||||
// onSettings — the manifest universal action. Returns a UniversalActionResponse.
|
||||
function onSettings(e) {
|
||||
return CardService.newUniversalActionResponseBuilder()
|
||||
.displayAddOnCards([settingsCard_()])
|
||||
.build();
|
||||
}
|
||||
|
||||
// onOpenSettings — the in-card footer button. Returns an ActionResponse that
|
||||
// pushes the settings card onto the navigation stack.
|
||||
function onOpenSettings(e) {
|
||||
return CardService.newActionResponseBuilder()
|
||||
.setNavigation(CardService.newNavigation().pushCard(settingsCard_()))
|
||||
.build();
|
||||
}
|
||||
|
||||
// ── Action handlers ────────────────────────────────────────────────────────
|
||||
|
||||
// onSaveKey persists the pasted key. From the settings sub-card we pop back to
|
||||
// the home card underneath (already the right host); from the onboarding card
|
||||
// (no card beneath) we render a fresh home card so the prompt UI appears.
|
||||
function onSaveKey(e) {
|
||||
var key = formValue_(e, 'apikey');
|
||||
setApiKey(key);
|
||||
return notifyAndReturn_(e, key ? 'API key saved.' : 'API key cleared.');
|
||||
}
|
||||
|
||||
function onClearKey(e) {
|
||||
setApiKey('');
|
||||
return notifyAndReturn_(e, 'API key cleared.');
|
||||
}
|
||||
|
||||
// onSelectModel persists the picked model (no reload needed — the choice sticks
|
||||
// via PropertiesService and the next Run/action reads it).
|
||||
function onSelectModel(e) {
|
||||
setModel(formValue_(e, 'model'));
|
||||
return CardService.newActionResponseBuilder()
|
||||
.setNotification(CardService.newNotification().setText('Model set.'))
|
||||
.build();
|
||||
}
|
||||
|
||||
/**
|
||||
* onRun is the single button handler for the free prompt AND every quick action
|
||||
* across Docs/Sheets/Slides/Gmail. It reads the host's context, asks the
|
||||
* gateway, and re-renders the home card with the result.
|
||||
*/
|
||||
function onRun(e) {
|
||||
var host = paramHost_(e);
|
||||
var presetTask = paramTask_(e);
|
||||
var prompt = presetTask || formValue_(e, 'prompt');
|
||||
if (!prompt) return notify_('Type an instruction or pick an action first.');
|
||||
try {
|
||||
var context = readContext_(host, e);
|
||||
var output = runTask(prompt, context);
|
||||
return CardService.newActionResponseBuilder()
|
||||
.setNavigation(
|
||||
CardService.newNavigation().updateCard(
|
||||
buildHomeCard_(host, { prompt: prompt, output: output })
|
||||
)
|
||||
)
|
||||
.build();
|
||||
} catch (err) {
|
||||
return notify_(errMessage_(err));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* onInsert writes the last result into the host document (Docs/Sheets/Slides).
|
||||
* The output was threaded through the button's parameters.
|
||||
*/
|
||||
function onInsert(e) {
|
||||
var host = paramHost_(e);
|
||||
var output = e && e.parameters && e.parameters.output;
|
||||
if (!output) return notify_('Nothing to insert.');
|
||||
try {
|
||||
insertResult_(host, output);
|
||||
return notify_('Inserted into the ' + hostLabel_(host) + '.');
|
||||
} catch (err) {
|
||||
return notify_(errMessage_(err));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* onDraftReply (Gmail) generates a reply and opens it as a Gmail draft — the
|
||||
* compose ActionResponse.
|
||||
*/
|
||||
function onDraftReply(e) {
|
||||
try {
|
||||
var body = gmailThreadText(e) || gmailMessageText(e);
|
||||
var task = 'Draft a professional reply to this email. Return only the reply body.';
|
||||
var reply = runTask(task, body);
|
||||
return gmailDraftReply(e, reply);
|
||||
} catch (err) {
|
||||
return notify_(errMessage_(err));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* onGmailCompose (compose trigger, from the manifest composeTrigger) drafts body
|
||||
* text for the message the user is composing and inserts it into the open draft.
|
||||
* The compose context has no "current message"; it drafts from the recipients /
|
||||
* subject the user already typed, or a brief professional email otherwise.
|
||||
*/
|
||||
function onGmailCompose(e) {
|
||||
try {
|
||||
var draft = e && e.draftMetadata;
|
||||
var hint = draft
|
||||
? 'To: ' + (draft.toRecipients || []).join(', ') + '\nSubject: ' + (draft.subject || '')
|
||||
: '';
|
||||
var task =
|
||||
'Draft a professional email body. Return only the body text — no subject, no signature.';
|
||||
var body = runTask(task, hint);
|
||||
return CardService.newUpdateDraftActionResponseBuilder()
|
||||
.setUpdateDraftBodyAction(
|
||||
CardService.newUpdateDraftBodyAction()
|
||||
.addUpdateContent(body, CardService.ContentType.PLAIN_TEXT)
|
||||
.setUpdateType(CardService.UpdateDraftBodyType.IN_PLACE_INSERT)
|
||||
)
|
||||
.build();
|
||||
} catch (err) {
|
||||
return notify_(errMessage_(err));
|
||||
}
|
||||
}
|
||||
|
||||
// ── Host read/insert dispatch ──────────────────────────────────────────────
|
||||
|
||||
function readContext_(host, e) {
|
||||
switch (host) {
|
||||
case 'docs':
|
||||
return docsContextText();
|
||||
case 'sheets':
|
||||
return sheetsContextText();
|
||||
case 'slides':
|
||||
return slidesContextText();
|
||||
case 'gmail':
|
||||
return gmailThreadText(e) || gmailMessageText(e);
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
function insertResult_(host, text) {
|
||||
switch (host) {
|
||||
case 'docs':
|
||||
docsInsert(text);
|
||||
return;
|
||||
case 'sheets':
|
||||
sheetsInsert(text);
|
||||
return;
|
||||
case 'slides':
|
||||
slidesInsert(text);
|
||||
return;
|
||||
default:
|
||||
throw new Error('This host does not support insert.');
|
||||
}
|
||||
}
|
||||
|
||||
// ── Small helpers ──────────────────────────────────────────────────────────
|
||||
|
||||
// actionFor_ builds an Action for `fn` carrying the host + task as parameters
|
||||
// (CardService actions can only pass string params, so we thread state here).
|
||||
function actionFor_(fn, host, task) {
|
||||
return CardService.newAction()
|
||||
.setFunctionName(fn)
|
||||
.setParameters({ host: host, task: task || '' });
|
||||
}
|
||||
|
||||
function paramHost_(e) {
|
||||
return (e && e.parameters && e.parameters.host) || hostFromEvent_(e);
|
||||
}
|
||||
function paramTask_(e) {
|
||||
return (e && e.parameters && e.parameters.task) || '';
|
||||
}
|
||||
|
||||
// formValue_ reads a form input value from the CardService event.
|
||||
function formValue_(e, field) {
|
||||
if (!e || !e.commonEventObject || !e.commonEventObject.formInputs) return '';
|
||||
var input = e.commonEventObject.formInputs[field];
|
||||
if (!input || !input.stringInputs || !input.stringInputs.value) return '';
|
||||
return input.stringInputs.value[0] || '';
|
||||
}
|
||||
|
||||
function notify_(text) {
|
||||
return CardService.newActionResponseBuilder()
|
||||
.setNotification(CardService.newNotification().setText(text))
|
||||
.build();
|
||||
}
|
||||
|
||||
// notifyAndReturn_ shows a toast and rebuilds the host's home card in place, so
|
||||
// saving a key from onboarding reveals the prompt UI and saving from settings
|
||||
// returns to a current home card (root card — replaces the whole stack).
|
||||
function notifyAndReturn_(e, text) {
|
||||
var host = hostFromEvent_(e);
|
||||
return CardService.newActionResponseBuilder()
|
||||
.setNotification(CardService.newNotification().setText(text))
|
||||
.setNavigation(CardService.newNavigation().updateCard(buildHomeCard_(host, null)))
|
||||
.setStateChanged(true)
|
||||
.build();
|
||||
}
|
||||
|
||||
function hostLabel_(host) {
|
||||
return { docs: 'Docs', sheets: 'Sheets', slides: 'Slides', gmail: 'Gmail' }[host] || 'Workspace';
|
||||
}
|
||||
|
||||
function escapeHtml_(s) {
|
||||
return String(s)
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>');
|
||||
}
|
||||
|
||||
function errMessage_(err) {
|
||||
return err && err.message ? String(err.message) : String(err);
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { loadGs } from './loadGs.mjs';
|
||||
|
||||
const {
|
||||
API_BASE_URL,
|
||||
DEFAULT_MODEL,
|
||||
chatCompletionsURL,
|
||||
modelsURL,
|
||||
buildMessages,
|
||||
requestBody,
|
||||
extractContent,
|
||||
parseModels,
|
||||
} = loadGs('src/hanzo.gs');
|
||||
|
||||
describe('gateway endpoints', () => {
|
||||
it('chat + models go through api.hanzo.ai/v1 — no /api/ prefix, no v2', () => {
|
||||
expect(API_BASE_URL).toBe('https://api.hanzo.ai');
|
||||
expect(chatCompletionsURL()).toBe('https://api.hanzo.ai/v1/chat/completions');
|
||||
expect(modelsURL()).toBe('https://api.hanzo.ai/v1/models');
|
||||
expect(chatCompletionsURL()).not.toContain('/api/');
|
||||
expect(chatCompletionsURL()).not.toContain('/v2/');
|
||||
});
|
||||
});
|
||||
|
||||
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('tolerates undefined/whitespace selection as pure generation', () => {
|
||||
expect(buildMessages('x', undefined)[1].content).toBe('x');
|
||||
expect(buildMessages('x', ' ')[1].content).toBe('x');
|
||||
});
|
||||
|
||||
it('system prompt forbids markdown fences / preamble', () => {
|
||||
expect(buildMessages('x', '')[0].content.toLowerCase()).toContain('only the text');
|
||||
});
|
||||
});
|
||||
|
||||
describe('requestBody', () => {
|
||||
it('defaults the model and forces stream:false (Apps Script has no SSE)', () => {
|
||||
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('never streams even when asked (contract: one-shot only)', () => {
|
||||
expect(requestBody(buildMessages('x', ''), { stream: true }).stream).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
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 with the gateway message', () => {
|
||||
expect(() => extractContent({ error: { message: 'rate limited' } })).toThrow(/rate limited/);
|
||||
expect(() => extractContent({ error: 'bad key' })).toThrow(/bad key/);
|
||||
});
|
||||
it('throws when there is no content', () => {
|
||||
expect(() => extractContent({ choices: [{}] })).toThrow(/no content/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseModels', () => {
|
||||
it('reads ids from a {data:[…]} response', () => {
|
||||
expect(parseModels({ data: [{ id: 'zen-1' }, { id: 'zen-pro' }] })).toEqual(['zen-1', 'zen-pro']);
|
||||
});
|
||||
it('tolerates {models:[…]} and a bare string array', () => {
|
||||
expect(parseModels({ models: [{ id: 'a' }] })).toEqual(['a']);
|
||||
expect(parseModels(['x', 'y'])).toEqual(['x', 'y']);
|
||||
});
|
||||
it('drops empty / non-string ids', () => {
|
||||
expect(parseModels({ data: [{ id: '' }, {}, { id: 'ok' }] })).toEqual(['ok']);
|
||||
});
|
||||
it('empty on a shapeless response', () => {
|
||||
expect(parseModels({})).toEqual([]);
|
||||
expect(parseModels(null)).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,22 @@
|
||||
// loadGs — evaluate a `.gs` file in a Node sandbox and return its
|
||||
// module.exports. Apps Script `.gs` is plain JS in one global scope; hanzo.gs
|
||||
// ends with a guarded `module.exports` (inert in Apps Script, live under Node),
|
||||
// so the pure functions are testable with zero duplication. The Apps Script
|
||||
// globals (UrlFetchApp, PropertiesService, …) are left undefined — only the
|
||||
// pure functions are exercised here.
|
||||
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { dirname, resolve } from 'node:path';
|
||||
import vm from 'node:vm';
|
||||
|
||||
const here = dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
export function loadGs(relPath) {
|
||||
const src = readFileSync(resolve(here, '..', relPath), 'utf8');
|
||||
const module = { exports: {} };
|
||||
const sandbox = { module, exports: module.exports, console };
|
||||
vm.createContext(sandbox);
|
||||
vm.runInContext(src, sandbox, { filename: relPath });
|
||||
return module.exports;
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { dirname, resolve } from 'node:path';
|
||||
|
||||
const here = dirname(fileURLToPath(import.meta.url));
|
||||
const manifest = JSON.parse(
|
||||
readFileSync(resolve(here, '..', 'src', 'appsscript.json'), 'utf8')
|
||||
);
|
||||
|
||||
describe('appsscript.json', () => {
|
||||
it('is V8 with Stackdriver logging', () => {
|
||||
expect(manifest.runtimeVersion).toBe('V8');
|
||||
expect(manifest.exceptionLogging).toBe('STACKDRIVER');
|
||||
});
|
||||
|
||||
it('declares the per-host currentonly scopes + external_request', () => {
|
||||
const scopes = manifest.oauthScopes;
|
||||
expect(scopes).toContain('https://www.googleapis.com/auth/documents.currentonly');
|
||||
expect(scopes).toContain('https://www.googleapis.com/auth/spreadsheets.currentonly');
|
||||
expect(scopes).toContain('https://www.googleapis.com/auth/presentations.currentonly');
|
||||
expect(scopes).toContain('https://www.googleapis.com/auth/script.external_request');
|
||||
});
|
||||
|
||||
it('declares the Gmail add-on scopes (read message + compose action)', () => {
|
||||
const scopes = manifest.oauthScopes;
|
||||
expect(scopes).toContain(
|
||||
'https://www.googleapis.com/auth/gmail.addons.current.message.readonly'
|
||||
);
|
||||
expect(scopes).toContain(
|
||||
'https://www.googleapis.com/auth/gmail.addons.current.action.compose'
|
||||
);
|
||||
});
|
||||
|
||||
it('whitelists ONLY api.hanzo.ai for UrlFetch', () => {
|
||||
expect(manifest.urlFetchWhitelist).toEqual(['https://api.hanzo.ai/']);
|
||||
});
|
||||
|
||||
it('registers a homepage trigger for every host', () => {
|
||||
const addOns = manifest.addOns;
|
||||
expect(addOns.common.homepageTrigger.runFunction).toBe('onHomepage');
|
||||
expect(addOns.docs.homepageTrigger.runFunction).toBe('onDocsHomepage');
|
||||
expect(addOns.sheets.homepageTrigger.runFunction).toBe('onSheetsHomepage');
|
||||
expect(addOns.slides.homepageTrigger.runFunction).toBe('onSlidesHomepage');
|
||||
expect(addOns.gmail.homepageTrigger.runFunction).toBe('onGmailHomepage');
|
||||
});
|
||||
|
||||
it('wires the Gmail contextual + compose triggers', () => {
|
||||
const gmail = manifest.addOns.gmail;
|
||||
expect(gmail.contextualTriggers[0].onTriggerFunction).toBe('onGmailMessage');
|
||||
expect(gmail.composeTrigger.selectActions[0].runFunction).toBe('onGmailCompose');
|
||||
});
|
||||
|
||||
it('exposes the settings + get-key universal actions', () => {
|
||||
const actions = manifest.addOns.common.universalActions;
|
||||
const settings = actions.find((a) => a.runFunction === 'onSettings');
|
||||
const getKey = actions.find((a) => a.openLink === 'https://console.hanzo.ai/api-keys');
|
||||
expect(settings).toBeTruthy();
|
||||
expect(getKey).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,28 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { loadGs } from './loadGs.mjs';
|
||||
|
||||
const { rangeToText, linesToRows } = loadGs('src/sheets.gs');
|
||||
|
||||
describe('rangeToText', () => {
|
||||
it('flattens a 2-D range to TSV rows', () => {
|
||||
expect(rangeToText([['a', 'b'], ['c', 'd']])).toBe('a\tb\nc\td');
|
||||
});
|
||||
it('renders null/undefined cells as empty', () => {
|
||||
expect(rangeToText([[1, null], [undefined, 4]])).toBe('1\t\n\t4');
|
||||
});
|
||||
it('empty range → empty string', () => {
|
||||
expect(rangeToText([])).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('linesToRows', () => {
|
||||
it('one line per cell down a column', () => {
|
||||
expect(linesToRows('a\nb\nc')).toEqual([['a'], ['b'], ['c']]);
|
||||
});
|
||||
it('drops trailing blank lines so a stray newline does not clear a cell', () => {
|
||||
expect(linesToRows('a\nb\n\n')).toEqual([['a'], ['b']]);
|
||||
});
|
||||
it('single line stays a 1x1', () => {
|
||||
expect(linesToRows('solo')).toEqual([['solo']]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,8 @@
|
||||
import { defineConfig } from 'vitest/config';
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
include: ['tests/**/*.test.mjs'],
|
||||
environment: 'node',
|
||||
},
|
||||
});
|
||||
@@ -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.30
|
||||
|
||||
# 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); }
|
||||
|
||||
@@ -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.
|
||||
|
After Width: | Height: | Size: 1.9 KiB |
|
After Width: | Height: | Size: 956 B |
|
After Width: | Height: | Size: 1.3 KiB |
|
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.30",
|
||||
"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',
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,112 @@
|
||||
# Hanzo AI for Microsoft Outlook
|
||||
|
||||
A **mail add-in** that puts Hanzo AI inside **Outlook** — desktop (Windows/Mac),
|
||||
new Outlook, and Outlook on the web. Open a message, click the Hanzo button on
|
||||
the ribbon, and:
|
||||
|
||||
- **Summarize thread** — parties, dates, defined terms, obligations, open questions.
|
||||
- **Draft reply** — a professional reply in the sender's tone; open it in a reply
|
||||
window or insert it into the compose body.
|
||||
- **Extract deadlines & action items** — every due date and task, with owner and date.
|
||||
- **Explain plainly** — plain-language read-out plus the risks/obligations/deadlines.
|
||||
|
||||
This is the highest-value surface for a law office after Word: the inbox is where
|
||||
deadlines and commitments actually arrive.
|
||||
|
||||
It reuses the **same backend as everything else Hanzo**: model calls go through
|
||||
`api.hanzo.ai/v1` (OpenAI-compatible, streamed), sign-in is Hanzo IAM (`hanzo.id`,
|
||||
authorization-code + PKCE, HIP-0111) — the mail add-in shares the `hanzo-office`
|
||||
IAM client. No new API surface.
|
||||
|
||||
## What it reads / writes
|
||||
|
||||
- **Read mode** (a received message is open): reads the body (`item.body.getAsync`
|
||||
as plain text), subject, and `from` — the input to summarize / extract / draft.
|
||||
The write path is **Draft reply**, which opens a reply form
|
||||
(`item.displayReplyForm`) pre-filled with the AI text.
|
||||
- **Compose mode** (a reply / new message is open): **Insert into message** writes
|
||||
the draft into the compose body — prepended above the quoted thread
|
||||
(`item.body.prependAsync`) or replacing the body (`item.body.setSelectedDataAsync`).
|
||||
|
||||
## Architecture (DRY with the Office task pane)
|
||||
|
||||
The pure, host-agnostic core is **identical** to `@hanzo/office-addin` and
|
||||
unit-tested here:
|
||||
|
||||
- `config.ts` — the `api.hanzo.ai/v1` gateway endpoints + roaming-settings keys.
|
||||
- `chat.ts` — `ask` / `askStream` (SSE) / `listModels` and the request/response shaping.
|
||||
- `auth.ts` — the `@hanzo/auth` OAuth2+PKCE core wired to Office `roamingSettings`,
|
||||
plus the pasted `hk-` API-key path.
|
||||
|
||||
The only Outlook-specific module is **`outlook-host.ts`**: the mailbox glue
|
||||
(`Office.context.mailbox.item`) and its pure shaping helpers — `stripHtml`,
|
||||
`collapseWhitespace`, `shapeMessage`, `formatFrom`, `mailMode` — which are what
|
||||
the tests cover. The Office.js calls themselves are thin.
|
||||
|
||||
## Build
|
||||
|
||||
```bash
|
||||
pnpm --filter @hanzo/outlook-addin build # production (base https://outlook.hanzo.ai)
|
||||
pnpm --filter @hanzo/outlook-addin test # vitest
|
||||
pnpm --filter @hanzo/outlook-addin typecheck # tsc --noEmit
|
||||
```
|
||||
|
||||
The build stamps the **hosting base URL** into `dist/manifest.xml`. Outlook loads
|
||||
the task pane from an HTTPS origin declared in the manifest; the same origin
|
||||
serves `auth-callback.html` (the IAM redirect, registered on the `hanzo-office`
|
||||
IAM client). Override the base for local dev:
|
||||
|
||||
```bash
|
||||
HANZO_OUTLOOK_BASE=https://localhost:3000 pnpm --filter @hanzo/outlook-addin build
|
||||
```
|
||||
|
||||
Then serve `dist/` over HTTPS at that base:
|
||||
|
||||
```bash
|
||||
npx office-addin-dev-certs install # trust a localhost cert (once)
|
||||
cd packages/outlook/dist && node serve.mjs # https://localhost:3000
|
||||
```
|
||||
|
||||
## Sideload (no store needed)
|
||||
|
||||
A **mail add-in** installs differently from a Word/Excel task pane — you add it
|
||||
through the mailbox, not "Insert → Add-ins".
|
||||
|
||||
- **Outlook on the web / new Outlook**: **Settings ⚙ → Mail → Customize actions**
|
||||
is *not* the path — use **Get Add-ins** (the "…" / Apps menu on an open message)
|
||||
**→ My add-ins → Custom Addins → Add a custom add-in → Add from file**, pick
|
||||
`dist/manifest.xml`. (`dist/` must be served at the base URL over HTTPS.)
|
||||
- **Outlook desktop (Windows/Mac, classic)**: **Home → Get Add-ins → My add-ins →
|
||||
Custom add-ins → Add a custom add-in → Add from file** → `dist/manifest.xml`.
|
||||
Requires a Microsoft 365 / Exchange Online mailbox (sideloading a custom mail
|
||||
add-in from file needs the mailbox to allow it).
|
||||
- Open any message: the **Hanzo AI** button appears on the message ribbon (read
|
||||
view); open a reply/compose and it appears there too.
|
||||
|
||||
### Whole firm (recommended for a law office)
|
||||
|
||||
An M365 admin deploys the add-in org-wide — every user gets the Hanzo button with
|
||||
no per-person sideload:
|
||||
|
||||
**Microsoft 365 admin center → Settings → Integrated apps → Upload custom apps →
|
||||
Provide link to manifest file** (host `manifest.xml` at the base URL) **or upload
|
||||
the manifest**, choose the users/groups, and deploy. Centralized Deployment
|
||||
covers Outlook desktop, web, and Mac.
|
||||
|
||||
## Publish (AppSource — public listing)
|
||||
|
||||
`dist/manifest.xml` is a valid, submittable Mail add-in manifest (validate with
|
||||
`npx office-addin-manifest validate dist/manifest.xml`). To list publicly:
|
||||
|
||||
1. Host `dist/` at the production base (`outlook.hanzo.ai`) over HTTPS.
|
||||
2. Ensure the `hanzo-office` IAM client allows the redirect
|
||||
`https://outlook.hanzo.ai/auth-callback.html`.
|
||||
3. Submit through **Microsoft Partner Center → Office Store** (needs a Partner
|
||||
Center account + Microsoft's validation pass).
|
||||
|
||||
## Requirements
|
||||
|
||||
- Mailbox requirement set **1.5+** (declared in the manifest) — covers current
|
||||
Outlook desktop, Mac, and Outlook on the web / new Outlook.
|
||||
- `Permissions: ReadWriteItem` — reads the open item and writes the reply draft;
|
||||
it never touches other items or makes EWS calls (so *not* ReadWriteMailbox).
|
||||
|
After Width: | Height: | Size: 1.9 KiB |
|
After Width: | Height: | Size: 956 B |
|
After Width: | Height: | Size: 1.3 KiB |
|
After Width: | Height: | Size: 1.9 KiB |