Compare commits

...
63 Commits
Author SHA1 Message Date
Hanzo Dev e9531bf4e3 fix(jetbrains): disable buildSearchableOptions — headless libfreetype crash
buildPlugin transitively runs buildSearchableOptions, which launches a headless
IDE (JCEF) needing native font libs (libfreetype.so.6) absent on the CI runners
→ UnsatisfiedLinkError, non-deterministic (only green when the task was cache
UP-TO-DATE, which is why ci.yml passed but the release build failed). The search
index is optional and rebuilt by the IDE at runtime; disabling it makes
buildPlugin reliably emit the distributable plugin zip for the GitHub Release.
2026-07-03 15:47:49 -07:00
Hanzo Dev 46daa52d43 chore(release): sync all packages to 1.9.29 for full all-platform release
Bumps root + browser + vscode + office + jetbrains to 1.9.29 so every release
asset name matches the tag (the download-table URLs use the tag version). The
first release to carry a downloadable JetBrains plugin binary alongside browser,
VS Code/Cursor/Windsurf/Antigravity, Claude, Safari and Office.
2026-07-03 15:39:46 -07:00
hanzo-dev 780757ca55 feat(auth): one canonical @hanzo/auth core; migrate office; fix vscode HIP-0111
Five surfaces (browser, office, vscode, cli, mcp) each hand-rolled the SAME
IAM OAuth2+PKCE flow — and one drifted and broke: vscode built
hanzo.id/oauth/authorize and iam.hanzo.ai/oauth/token, both MISSING the
/v1/iam prefix (a 404 — the same class of bug as the browser login fix),
plus it POSTed JSON where IAM requires form-urlencoded and sent a
client_secret on a public PKCE client. That is the opposite of one way.

Decomplected into ONE core — packages/auth (@hanzo/auth):
- config.ts   canonical HIP-0111 endpoints (authorize/token/userinfo ALL
              carry /v1/iam) + the one-client-per-surface registry
              (hanzo-browser/office/vscode/cli). URL assembly lives once.
- pkce.ts     the one PKCE (S256), platform-CSPRNG + WebCrypto.
- oauth.ts    buildAuthorizeURL / exchangeCode / refreshTokens / userinfo —
              pure fetch, form-encoded per RFC 6749.
- flow.ts     login / getValidToken (transparent refresh) / logout /
              currentUser, written ONCE against TokenStore + Opener. A
              surface supplies only those two thin adapters — no surface
              re-implements the flow.
23 tests: the drift guard (every path has /v1/iam), client registry, PKCE
RFC-7636 vector, wire shapes, and the full flow with fake adapters.

Migrate office onto it: roamingSettings TokenStore + Dialog-API Opener;
delete office's duplicate pkce.ts + IAM config (now the core's). API-key
path kept. 22 tests; the core bundles into the task pane.

Fix vscode standalone-auth: all three endpoints → ${host}/v1/iam/oauth/*,
token calls form-urlencoded, OIDC scopes, drop the secret on the public
PKCE client. Compiles clean.

CI now gates @hanzo/auth. Browser/mcp/tools already use canonical paths;
migrating them to import the core is the tracked next step (each needs its
own bundler wiring) — the ONE way now exists and has two consumers.
2026-07-03 15:24:01 -07:00
Hanzo Dev 5bf8d934b4 ci(release): downloadable JetBrains + Office + Safari binaries for all platforms
The GitHub Release table advertised JetBrains and Safari downloads that 404'd —
neither job produced a release artifact — and the Office add-in was absent.
Make every advertised binary real and deterministic from the tag:

- jetbrains: always `gradlew buildPlugin` → hanzo-ai-jetbrains-v<ver>.zip artifact
  (Marketplace publish stays token-gated); the job no longer skips without the
  token, so the download link always resolves.
- office: new job builds @hanzo/office-addin → hanzo-ai-office-v<ver>.zip.
- safari: package the built .app(s) → hanzo-ai-safari-v<ver>.zip.
- release: add jetbrains+office to `needs`, add all three globs to `files`, and
  add the Office row to the download table. Asset names use the tag version so
  they match the table URLs.

All-platform release: browser (chrome/edge/firefox/safari), IDE (vscode/cursor/
windsurf/antigravity/jetbrains), Claude dxt, and Office (Word/Excel/PowerPoint).
2026-07-03 15:22:58 -07:00
Hanzo Dev 81db870a51 feat(office): streaming responses, live model picker, quick-action chips
Additive enhancements to the Office add-in, keeping the existing ask/requestBody
contract (and its tests) intact:

- chat.ts: askStream (SSE streaming completion → onDelta), streamDelta (pure SSE
  frame parse), listModels (/v1/models → ids); requestBody gains an opt-in stream
  flag (defaults false, so the one-shot ask path is unchanged).
- taskpane: a live model <select> populated from /v1/models (org-scoped by the
  token, refreshed on sign-in), and one-click quick-action chips — Draft,
  Summarize, Explain plainly, Tighten/redline, Continue — that run over the
  selection. Output now streams in live; Insert stays gated on real content.

Built for document-heavy review/drafting (e.g. a law office). Tests: 31 pass
(+6 new: stream flag, streamDelta, listModels). Build OK; my files tsc-clean.
2026-07-03 15:08:32 -07:00
hanzo-dev 4060ce491b feat(office): API-key auth + self-contained Windows test kit; release 1.9.28
- API-key auth: paste a Hanzo hk- key (console.hanzo.ai → API keys) instead
  of the IAM OAuth dialog, so the add-in is usable with zero infra/IAM setup.
  pickBearer(apiKey, oauthToken) precedence is pure + tested (29 tests).
- Windows test kit: `HANZO_OFFICE_BASE=https://localhost:3000` build ships
  serve.mjs (stdlib HTTPS static server) + SIDELOAD-WINDOWS.md, so the
  downloaded zip runs on any machine — trust a localhost cert, node serve.mjs,
  sideload manifest.xml, paste key, go.
- CI Build now emits BOTH hanzo-ai-office-v${VER}.zip (production, office.hanzo.ai)
  and hanzo-ai-office-localhost-v${VER}.zip (the test kit).

Bump browser+root to 1.9.28 so tagging v1.9.28 publishes a GitHub Release
carrying ALL binaries — chrome/edge/firefox/safari, vscode/cursor/windsurf,
claude(dxt), and now office (prod + localhost) — with permanent download links.
2026-07-03 15:05:46 -07:00
hanzo-dev 9032801deb feat(office): Hanzo AI add-in for Word, Excel & PowerPoint
The surface a non-developer actually uses — a Microsoft Office task-pane
add-in that puts Hanzo AI inside the document. Select text or a range,
ask, insert. Distinct from the browser extension and the IDE extensions;
same backend as everything else: model calls through api.hanzo.ai/v1
(OpenAI-compatible), sign-in via Hanzo IAM (hanzo.id, auth-code + PKCE,
HIP-0111). No new API surface.

Decomplected so the logic is pure and unit-tested (26 tests), Office.js
glue kept thin:
- chat.ts — request/response shaping (buildMessages fences the selection
  as data; extractContent tolerates the gateway's response shapes)
- pkce.ts — PKCE + authorize URL, verified against the RFC 7636 S256 test
  vector
- host.ts — per-host read/insert; Excel range↔text helpers tested
- config.ts — canonical endpoints pinned (api.hanzo.ai, /v1/iam/oauth/*)
- taskpane/auth/commands — the Office.js + Dialog-API glue

manifest.xml passes `office-addin-manifest validate` ("The manifest is
valid" — Word/Excel/PowerPoint on web, Windows, Mac). build.js stamps the
hosting base URL (office.hanzo.ai, override for localhost dev) into a
sideloadable/AppSource-submittable dist/manifest.xml.

CI runs the tests and builds+zips a sideloadable artifact. Publishing to
AppSource needs the dist hosted at office.hanzo.ai + a hanzo-office IAM
client + a Partner Center submission (documented in the README).
2026-07-03 14:43:29 -07:00
hanzo-dev 8051256382 test(vscode): gate the zsh shell-tool test on /bin/zsh existing — its whole subject is delegation to zsh, absent on CI runners (the one remaining hard-gate failure: 151/152) 2026-07-03 02:16:05 -07:00
hanzo-dev 3e380bcec3 ci: provision electron runtime libs for the extension-host tests — VS Code downloaded but could not load libnspr4.so on the minimal arc node 2026-07-03 02:12:29 -07:00
hanzo-dev 39fc502513 ci: provision xvfb before the vscode extension-host tests — the arc pool is heterogeneous and not every node ships it (Test job hit exit 127 while the E2E job's node has it) 2026-07-03 02:09:01 -07:00
hanzo-dev ce2f186bd2 vscode: resurrect the whole test gate — 0 lint errors, 152 mocha + 3 vitest passing, hard CI gates
The package's `npm test` has been broken-by-construction for years:
tsconfig excluded src/test entirely (out/test/runTest.js never existed),
tsconfig.test.json inherited the exclude that negated its include, the
runner pointed at a ./suite/index path that never existed, two suites used
BDD describe/it under the tdd-configured mocha, a vitest file sat in the
mocha glob, `ws` was never declared (it resolved only cross-package), and
154 eslint errors blocked pretest.

Lint 154 → 0 with real fixes: the ban-types autofix damage reverted
properly (the local `Symbol` interface shadowing the ES built-in — the
actual footgun — is now `CodeSymbol` everywhere), case bodies braced via
AST codemod, empty catches annotated, `Function` types replaced with real
signatures, hasOwnProperty via Object.prototype, and
@typescript-eslint/no-var-requires off — CJS lazy requires are the
intentional activation-perf idiom (the rule is retired to stylistic in
typescript-eslint v8).

Harness: tests compile via tsconfig.test.json in pretest; runner path
fixed; BDD→TDD; glob v10 promise API; esModuleInterop default imports.
Two harnesses, one directory each: vitest owns src/test/vitest/ (scoped
vitest.config, excluded from the mocha compile), mocha owns the rest;
posttest runs the vitest side so `npm test` is the whole gate.

Suites modernized against current tool contracts (34 passing / 64 failing
→ 152 passing / 0 failing / 12 pending): web-fetch rewritten against the
real http(s) path via loopback server, mode against list/activate/show/
current, mcp-tools against config-driven getAllTools filtering, extension
pins the shipped manifest contract, shell/process/git-search/filesystem/
config/bash/mcp-runner exercise real commands, files, repos and spawns.
Dead-subject suites removed; network-dependent integration tests gated on
HANZO_E2E_LIVE with visible skip reasons (12 pending). Tests-only — the
one suspected product bug was an unfaithful Memento mock, and a real
cross-suite settings leak was fixed by making suites hermetic.

CI: cli-tools and vscode test steps lose continue-on-error and become
hard gates (vscode under xvfb-run, same as the E2E job).
2026-07-03 02:05:42 -07:00
hanzo-dev 7c742c07f4 deps: re-pin stacked-advisory packages to their highest patched floor
The first pass pinned each override to its advisory-snapshot floor, but
several packages carry stacked advisories — a pin at one advisory's floor
sits inside the next one's vulnerable range (protobufjs 7.5.5 vs 7.6.3,
tar 7.5.16, tmp 0.2.7, flatted 3.4.2, vite 6.4.3, file-type 21.3.2,
qs 6.15.2, js-yaml 4.2.0). One within-major floor per package now, at the
highest first-patched. Suites: browser 243, ai 31, aci 29, cli-tools 87.
2026-07-03 00:56:30 -07:00
hanzo-dev 4eb2cf57a8 release: browser-extension 1.9.27 — rebuilt on patched dependency tree
1.9.26 bundles carried the pre-override axios/ws resolutions; this rebuild
ships the patched tree from the dependabot-closure pass.
2026-07-03 00:40:45 -07:00
hanzo-dev 3ab5ae053c deps: close all 175 dependabot alerts — range-exact overrides, vitest 3, otel 2
- pnpm overrides pin every vulnerable transitive to its advisory's exact
  first-patched version, scoped to the advisory's own vulnerable range so
  no dependency is dragged across a major it didn't need (criticals:
  form-data 4.0.6, handlebars 4.7.9, protobufjs 7.5.5).
- vitest < 3.2.6 is a critical (CVE fix floor) with no 0.x/1.x backport —
  forced major to ^3.2.6 in every package. Test scripts unify on
  `vitest run` (aci/ai ran bare watch mode). tools' vscode mock alias
  becomes absolute (vitest 3 dropped relative alias resolution).
- packages/ai otel family 1.x → 2.x (core <2.8.0 advisory covers all 1.x):
  resourceFromAttributes, ATTR_* semconv, constructor spanProcessors. The
  previously-uncollectable telemetry suite now runs: ai 31 tests (was 16).
- direct bumps: vite ^6.4.3 (site), esbuild ^0.25.8 (mcp), uuid ^11.1.1
  (tools). packages/ai/package-lock.json deleted — stray npm lockfile in a
  pnpm workspace, carried 24 of the alerts.
- ci: test step filtered @hanzo/tools but the package is @hanzo/cli-tools —
  the suite never ran in CI. Fixed; 87 tests now gate (behind the existing
  continue-on-error). pnpm 9 reads onlyBuiltDependencies from package.json,
  not pnpm-workspace.yaml — mirrored so native build scripts stay allowlisted.

Verified: browser 243/243 + e2e 30/30 + build; aci 29/29; ai 31/31;
cli-tools 87/87; site builds on vite 6; mcp builds on esbuild 0.25.
2026-07-03 00:40:07 -07:00
hanzo-dev fe3c0eabdc release: browser-extension 1.9.26 — working login (hanzo-browser IAM client)
1.9.25 shipped the unregistered app-hanzo client_id, so the PKCE token
exchange failed invalid_client — login has been dead since v1.8.0. The
canonical registered client is hanzo-browser (universe IAM init_data;
redirectUris include https://hanzo.ai/callback). Contracts pinning the
registered client land in shared-config + cross-browser-parity tests.
2026-07-03 00:00:14 -07:00
hanzo-dev e23fbe51f7 dxt: kill dead app-hanzo password-grant, canonical OIDC PKCE only (from PR #6)
Adopted from fix/dxt-login-and-ci-guards: the DXT auth tool offered a
password-grant login defaulting to the unregistered app-hanzo client —
removed; whoami/token/account read /v1/iam/oauth/userinfo, refresh
exchanges at /v1/iam/oauth/token, issuance is authorization-code+PKCE
only. dxt build script now actually builds (mcp cli → server.js) instead
of echoing success. PR #6's test-suite restorations are not taken — main
already restored those suites.
2026-07-02 23:59:10 -07:00
Hanzo AIandhanzo-dev 42bf555a4e fix(extension): login client_id app-hanzo→hanzo-browser (dead client since v1.8.0) — v1.9.25 2026-07-02 23:58:30 -07:00
hanzo-dev 8f8f8b5ae2 llm.hanzo.ai is dead — api.hanzo.ai is the one gateway; harden live HF tests
Migrate the last llm.hanzo.ai references (vscode MCP llm tool provider URL
+ gateway fallback, auth-chat e2e doc comments) to the canonical
https://api.hanzo.ai/v1 (OpenAI-compatible, IAM JWT at the gateway — see
hanzoai/ai). The browser extension chat paths already pointed at
API_BASE_URL = api.hanzo.ai.

model-hub's HuggingFace tests are real live-API integration tests, which
means they fail on HF's weather: repeated runs rate-limit and the 5s
default timeout reads as 6 test failures with nothing of ours broken.
Probe HF once at module load and describe.skipIf with the status logged
when HF itself refuses service; give live calls a 15s budget. A failure
while HF answers 200 is still a real failure. 243/243 across repeated runs.
2026-07-02 23:53:04 -07:00
hanzo-dev c783b89c35 e2e: repair popup specs stranded by the 1.9.3 UI and the tab-hosted-popup harness
Three specs still tested the pre-1.9.3 popup (#open-page-overlay FAB,
'Open Chat Panel' label) — the Extension E2E jobs have been red on them
since the FAB was killed. Repointed at the current contract
(#overlay-enabled + #open-panel, 'Open Chat').

The interaction specs (overlay show, picker tool, console tool) all
funnel through the popup's active-tab lookup, which cannot work when the
harness hosts the popup as a tab: the popup IS the active tab, so the
privileged-tab guard always takes the fallback. Each spec now sends the
exact message its button sends (page.overlay.show / tool.picker.start /
tool.console.start), explicitly targeted at the example.com tab, so the
background relay and content-script surfaces are still exercised end to
end. CI's file set (popup, popup-tools, sidebar): 30/30 locally.
2026-07-02 23:04:29 -07:00
hanzo-dev b3f718dfce release: browser-extension 1.9.25 — green pipeline end to end
Fix the two failures the 1.9.24 tag surfaced:

- test/claude-integration.test.ts bound a fixed port 3002 in beforeEach
  and raced the previous test's teardown — EADDRINUSE as an unhandled
  'error', a 10s hook timeout, then ECONNREFUSED (flaked 3 of 4 local
  runs). BrowserExtensionServer now exposes ready() (resolves on listen,
  rejects on bind failure instead of crashing the process) and a port
  getter; the test binds port 0 (OS-assigned), awaits ready(), and
  terminates the client so wss.close() reliably fires. Six consecutive
  green runs.

- CI Build and Publish packaged with the `zip` binary the arc runner
  image doesn't have (Publish red since 1.9.23); Publish also wrote the
  zips one directory above where its artifact globs and the Chrome Web
  Store upload (which additionally pointed at an unversioned filename)
  looked, and CI hardcoded the version from the root package.json.
  Package with `npx web-ext build` (already in the job for linting),
  version from the browser package.json everywhere, store upload path
  fixed, and the Linux job no longer ships a junk safari zip — the real
  bundle comes from the macOS job.

Root and browser package.json move in lockstep to 1.9.25 (root was left
at 1.9.23 by the 1.9.24 bump). npm + VS Code Marketplace already carry
1.9.24; the stores' first complete release of this line is 1.9.25.
2026-07-02 21:52:58 -07:00
hanzo-dev c4424826f9 release: browser-extension 1.9.24 — one evaluate rule across Chrome+Firefox
shared/evaluable.ts is the single cross-browser rule for caller-supplied
JS: pickEvaluable accepts every code-param alias (expression/code/script/
function/js); wrapEvaluable passes bare expressions through and wraps
statement bodies in an async IIFE with the trailing value auto-returned.
Both dispatch paths consume it — browser-dispatch.ts (chrome.debugger
Runtime.evaluate, which also gains the plain 'evaluate' method alias
Firefox already accepted) and background-firefox.ts, whose previous
`return (${code})` wrap was a SyntaxError on any multi-statement body
and silently returned undefined for `expr;`. The Firefox path now always
settles promises in-page and routes rejections over the __hanzo_error
channel so callers see the real page-side error.

Tests: behavioral suite for the wrapper (evaluates wrapped output, not
string-matching); revive the three suites dead since the node bridge was
deleted — parity contracts repointed at browser-dispatch.ts, the IAM
contracts updated to the HIP-0111 canonical /v1/iam/oauth/token shape,
hub-wiring trimmed to the live background.ts surface, and the node-bridge
routing suite removed with its subject. 243 passing (was 186 + 3 dead
files).
2026-07-02 21:38:18 -07:00
zeekay 8d5384c1de Merge remote-tracking branch 'origin/rip/api-callers-to-v1' 2026-07-02 13:18:34 -07:00
zeekayandClaude Opus 4.8 3633c5a6ff rip: migrate IAM /api/* callers to canonical /v1/iam/*
extension dxt server: /api/userinfo→/v1/iam/oauth/userinfo, /api/login→
/v1/iam/login. Clean break, no aliases (org rule: one way, /v1/).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-02 13:12:56 -07:00
355f6496fb Debrand: replace Casdoor name with Hanzo IAM in comments/docs/aliases (#5)
Co-authored-by: Hanzo AI <ai@hanzo.ai>
2026-06-30 14:59:31 -07:00
z 28c75ec6a8 docs(brand): add hero banner 2026-06-28 20:10:52 -07:00
z 22729e5218 chore(brand): dynamic hero banner 2026-06-28 20:10:51 -07:00
51f59c44ff fix(iam): migrate to canonical /v1/iam/oauth/* (HIP-0111) (#4)
Co-authored-by: Zach Kelling <z@zeekay.io>
2026-06-24 19:16:15 -07:00
Hanzo AI 1c2b9f7915 kms: clarify upstream API path note 2026-06-24 10:51:37 -07:00
zeekay 1298aea79a release: browser-extension 1.9.23 2026-06-20 13:05:07 -07:00
1560cd1228 ci: run on self-hosted ARC pool (hanzo-build-linux-amd64/deploy), not GitHub-hosted (#3)
Co-authored-by: zeekay <z@hanzo.ai>
2026-06-19 20:34:40 -07:00
Antje WorringandClaude Opus 4.8 c6e3ac5533 docs: tidy LLM.md indexes; CLAUDE.md -> LLM.md symlink convention
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-17 09:24:21 -07:00
Antje WorringandClaude Opus 4.8 bb0cd246e8 ext 1.9.22: native-zap singleton — one native port, no host-spawn storm
connectNativeZap had no singleton guard: state.port was overwritten without closing the
prior port, and every onDisconnect scheduled another connect. Called from startup + the
3s reconnect, ports (+ their native hosts) accumulated into a host-spawn storm once the
router stopped rejecting duplicates. Now: tear down any prior port on entry; a replaced
port (state.port !== port) never reconnects. Exactly one native host per browser.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-17 00:42:36 -07:00
Antje WorringandClaude Opus 4.8 52076f2c25 ext: package.json is the single source of truth for version
build.js stamps the package.json version into every manifest (chrome/firefox/safari)
so the three can never drift. Syncs src manifests to 1.9.21.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-16 22:04:52 -07:00
Antje WorringandClaude Opus 4.8 03e081e83a ext 1.9.21: native ZAP is the one transport (Chrome+Firefox)
connectNativeZap (shared/native-zap.ts) registers the browser as a zapd provider
over native messaging and dispatches inbound ROUTE commands. Renames cdp-bridge.ts
-> browser-dispatch.ts; deletes the cdp-bridge-server.ts WS bridge. No ws://localhost,
no port roulette, no CDP fallback in the default boot path. Patch bump (no 2.0).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-16 22:03:00 -07:00
Antje WorringandClaude Opus 4.8 3bb7d57a9f ext 1.9.20: re-enable legacy CDP/MCP transport as working fallback
ZAP discovery is unreliable for local hanzo-mcp: the server binds ephemeral
ports (not the probed [9999..9995]) and mDNS needs the unshipped
ai.hanzo.zap_mdns native helper. Re-enable ENABLE_LEGACY_TRANSPORTS so the
extension connects to the running CDP bridge (ws://localhost:9223/cdp +
HTTP :9224) for driving a logged-in Chrome profile. Builds on the 1.9.16-1.9.19
dispatch/CSP fixes. Bumps chrome+firefox manifests + package.json to 1.9.20.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-15 14:07:15 -07:00
zeekay 2ad3d464b4 extension(browser): v1.9.19 — decomplected click + cross-window tab switch
hanzo.click: removed framework-specific hacks (Drupal AJAX lookup, jQuery
fallback). Those don't generalize — every new framework would need its
own hack. Replaced with a clean realistic-event-sequence dispatch:

  mouseover → mouseenter → mousemove → mousedown → focus → mouseup → click

with proper clientX/Y/button/buttons/composed. Events still have
isTrusted=false (a WebExtension can't change that), so frameworks that
explicitly check isTrusted (Drupal AJAX behaviors, some React libs)
will still reject — those need the v1.10.0 BiDi backend on the Python
bridge to produce real trusted browser input via Firefox's
--remote-debugging-port WebDriver BiDi endpoint.

Target.activateTarget: now ALSO calls windows.update(focused:true) so
cross-Firefox-window tab switching actually brings the OS window to
the foreground (not just the tab within its current window).

hanzo.switchTab: new Layer-3 ergonomic alias. Same implementation as
Target.activateTarget but with a human-readable name. Recommended for
everyday tab switching in scripts and the MCP browser_tool wrapper.

Architectural note: hanzo.click is the synthetic path (best-effort for
sites that don't check isTrusted). When the v1.10.0 Python bridge BiDi
backend lands, calls to hanzo.click will auto-route to Input.bidi
.dispatchMouseEvent for trusted browser-input events. That is the
proper decomplected design — backend per trustedness level, ergonomic
alias on top. No per-framework hacks in the extension.
2026-06-10 13:10:16 -07:00
zeekay 5e270c061d extension(browser): v1.9.18 — propagate page-side throws, fix textarea fill
Two bugs caught while driving the SEC TCR form:

1) runFunc silently swallowed page-side errors.
   scripting.executeScript returns [{result, error, frameId}] per frame.
   We were mapping to r?.result which dropped the `error` field — when a
   page-side function threw, callers saw null instead of an error.

   Fix: keep the full InjectionResult shape and throw on first.error.

2) hanzo.fill called HTMLInputElement's value setter on a TEXTAREA.
   That throws TypeError "Illegal invocation" silently (now visible
   thanks to fix #1). The chained `|| HTMLTextAreaElement.prototype...`
   never executed because the first ?.set was truthy.

   Fix: per-tag dispatch — pick the matching prototype's native setter
   (INPUT/TEXTAREA/SELECT each get their own). Wrapped in try/catch with
   plain `el.value = value` as last resort.

Verified on www.sec.gov/forms/tcr-external-form:
  - hanzo.fill on textarea[name^="in_your_own_words"] now sets the value
  - hanzo.clear on same textarea now works
2026-06-10 11:47:16 -07:00
zeekay bb6ed472be extension(browser): v1.9.17 — fix HTTP-action dispatch fallthrough
handleExtensionRequest() was returning "Unknown extension action" for
every Page.* / DOM.* / Input.* / hanzo.* method added in 1.9.16. The
handler had only routed monitor.*/audit.* and fell off the end for
everything else.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Firefox MV3 evaluate fixes (1.9.2):
- Detect privileged URLs (about:, moz-extension:, view-source:, resource:,
  file:, chrome:) up front; raise an actionable error instead of letting
  scripting.executeScript silently return zero frames.
- runInPage now throws when results is empty OR results[0] is undefined
  (the latter signals a frame errored outside the wrapped try/catch —
  almost always page CSP blocking Function() in MAIN world). Old behavior
  returned undefined which JSON-stripped to a confusing {result:{}}.
- Runtime.evaluate always returns {result:{type,value}, value} with
  value coerced from undefined to null and a CDP-style exceptionDetails
  surfaced when evaluation errored.
- Replace the looksLikePromiseResult heuristic with explicit
  awaitPromise / await_promise parameter handling. The previous heuristic
  re-evaluated any empty-object result, masking real {} values.
2026-05-08 09:53:56 -07:00
Hanzo AI 11862defbc feat(browser-extension): ZAP-native, kill node bridge from critical path (2.0.0)
The Python hanzo-mcp now hosts the ZAP server directly. This extension
extends the shared ZAP module so Python servers can dispatch browser
RPCs back to the extension over the same socket — closing the loop on
the 2-process architecture.

- shared/zap.ts: handle inbound MSG_REQUEST and respond with
  MSG_RESPONSE. New setZapRequestHandler(mgr, fn) installs the
  dispatcher.
- background.ts (Chrome): wire cdpBridge.dispatchMethod as the inbound
  ZAP handler.
- background-firefox.ts: expose dispatchMethod and wire it as the
  inbound ZAP handler. Reorder so discoverZapServers fires AFTER the
  handler is installed.
- cdp-bridge.ts: add public dispatchMethod() helper for ZAP-server
  initiated requests.
- cdp-bridge-server.ts: deprecation header. No longer in the critical
  path; kept only for legacy non-ZAP clients.
- mcp/src/zap-server.ts: fix wire-constant mismatches with
  shared/zap.ts (MSG_RESPONSE was 0x12, must be 0x11; PING/PONG were
  0xf0/0xf1, must be 0xFE/0xFF).

Tests: 250 vitest cases pass (was 247, +3 for setZapRequestHandler).
shared-zap.test.ts now covers 31 cases (was 28).

Manifest version 2.0.0 reflects the architecture pivot; wire format
itself is unchanged from 1.9.x (still
[0x5A 0x41 0x50 0x01][type:1][length:4 BE][JSON]).
2026-05-07 21:33:02 -07:00
Hanzo AI 962bc8ce26 feat(bridge): Firefox-first default + dynamic browser switching
The CDP bridge now supports multiple connected browsers concurrently
(it always did; this fixes the routing) and prefers Firefox when no
explicit target is specified. Order: firefox > safari > edge > chrome.

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

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

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

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

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

Bumps 1.8.6 → 1.8.7 in root + browser package.json + both manifests.
2026-05-07 20:08:29 -07:00
Hanzo AI 6503fa614d fix(browser): Firefox MV3 evaluate + screenshot
Firefox MV3 removed `browser.tabs.executeScript` (the string-based
MV2 API the bridge was still calling). Every evaluate from a Firefox
client returned `{}` because the call rejected silently. Replaced
with `browser.scripting.executeScript({ target, world: 'MAIN', func,
args })`, with the legacy `tabs.executeScript` retained as a fallback
for older Firefox builds.

Also fixed runInPage's wrappedCode: when injected via Function(body)()
the body must `return` explicitly. The previous IIFE-as-expression
trampoline discarded the result and the caller saw {} for every
evaluation.

Screenshot fix: Firefox's `tabs.captureVisibleTab(opts)` is interpreted
as `(windowId)` when called with a single object — the polymorphic
signature accepts only `(windowId, opts)`. Now resolves the target
tab's windowId, briefly activates the tab if needed, captures, then
restores focus. Also returns `{data, format, size}` so the Python
side has all three fields it expects.

Bumps 1.8.5 → 1.8.6 in root + browser package.json and both manifests.
2026-05-07 19:54:18 -07:00
185 changed files with 8690 additions and 14215 deletions
+9
View File
@@ -0,0 +1,9 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1280" height="640" viewBox="0 0 1280 640" role="img" aria-label="extension">
<rect width="1280" height="640" fill="#0A0A0A"/>
<svg x="96" y="215" width="210" height="210" viewBox="0 0 67 67"><path d="M22.21 67V44.6369H0V67H22.21Z" fill="#fff"/><path d="M66.7038 22.3184H22.2534L0.0878906 44.6367H44.4634L66.7038 22.3184Z" fill="#fff"/><path d="M22.21 0H0V22.3184H22.21V0Z" fill="#fff"/><path d="M66.7198 0H44.5098V22.3184H66.7198V0Z" fill="#fff"/><path d="M66.7198 67V44.6369H44.5098V67H66.7198Z" fill="#fff"/></svg>
<text x="378" y="276" font-family="Inter,system-ui,-apple-system,sans-serif" font-size="78" font-weight="800" letter-spacing="-2" fill="#ffffff">extension</text>
<text x="378" y="322" font-family="Inter,system-ui,sans-serif" font-size="30" fill="#ffffff" opacity=".66">🧩 Hanzo Extension: IDE plugin for VS Code compatible IDEs.</text>
<rect x="378" y="338" width="806" height="3" rx="1.5" fill="#ffffff" opacity=".9"/>
<text x="378" y="390" font-family="Inter,system-ui,sans-serif" font-size="24" font-weight="600" fill="#ffffff" opacity=".5">github.com/hanzoai</text>
<text x="1184" y="390" text-anchor="end" font-family="Inter,system-ui,sans-serif" font-size="24" font-weight="600" fill="#ffffff" opacity=".5">hanzo.ai</text>
</svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

+46 -13
View File
@@ -11,7 +11,7 @@ jobs:
# ─── Unit Tests ───
test:
name: Test
runs-on: ubuntu-latest
runs-on: hanzo-build-linux-amd64
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
@@ -30,18 +30,32 @@ jobs:
- name: Test AI package
run: pnpm --filter @hanzo/ai test
- name: Test auth core
run: pnpm --filter @hanzo/auth test
- name: Test Office add-in
run: pnpm --filter @hanzo/office-addin test
- name: Test tools package
run: pnpm --filter @hanzo/tools test
continue-on-error: true
run: pnpm --filter @hanzo/cli-tools test
- name: Test VS Code extension
run: pnpm --filter hanzo-ide test
continue-on-error: true
run: |
# The extension host is Electron: it needs a display plus the
# chromium-class shared libraries. Arc nodes are minimal (no xvfb,
# no libnspr4/libnss3), so provision explicitly (noble t64 names).
sudo apt-get update -qq
sudo apt-get install -y -qq xvfb libnss3 libnspr4 libatk1.0-0t64 \
libatk-bridge2.0-0t64 libcups2t64 libdrm2 libxkbcommon0 \
libatspi2.0-0t64 libxcomposite1 libxdamage1 libxfixes3 libxrandr2 \
libgbm1 libasound2t64 libgtk-3-0t64 libxshmfence1 libsecret-1-0 \
libxkbfile1
xvfb-run -a pnpm --filter hanzo-ide test
# ─── Playwright E2E ───
e2e:
name: E2E
runs-on: ubuntu-latest
runs-on: hanzo-build-linux-amd64
needs: test
steps:
- uses: actions/checkout@v4
@@ -82,7 +96,7 @@ jobs:
# ─── Build all extensions ───
build:
name: Build
runs-on: ubuntu-latest
runs-on: hanzo-build-linux-amd64
needs: [test, e2e]
steps:
- uses: actions/checkout@v4
@@ -106,10 +120,15 @@ jobs:
- name: Package browser extensions
working-directory: packages/browser
run: |
VER="${{ steps.version.outputs.ver }}"
cd dist/browser-extension/chrome && zip -r /tmp/hanzo-ai-chrome-v${VER}.zip .
cd ../firefox && zip -r /tmp/hanzo-ai-firefox-v${VER}.zip .
cd ../chrome && zip -r /tmp/hanzo-ai-edge-v${VER}.zip .
# web-ext (already used for linting below) does the zipping — the
# arc runner image has no `zip` binary. Version comes from the
# browser package.json, the single source of truth build.js stamps
# into the manifests (the root version can lag it).
VER=$(node -p "require('./package.json').version")
npx web-ext build --source-dir dist/browser-extension/chrome --artifacts-dir /tmp --filename hanzo-ai-chrome-v${VER}.zip --overwrite-dest
npx web-ext build --source-dir dist/browser-extension/firefox --artifacts-dir /tmp --filename hanzo-ai-firefox-v${VER}.zip --overwrite-dest
# Edge uses the Chrome zip (Chromium-compatible)
cp /tmp/hanzo-ai-chrome-v${VER}.zip /tmp/hanzo-ai-edge-v${VER}.zip
- name: Lint Firefox
working-directory: packages/browser
@@ -148,6 +167,20 @@ jobs:
VER="${{ steps.version.outputs.ver }}"
cp dist/hanzoai-*.dxt /tmp/hanzo-ai-claude-v${VER}.dxt 2>/dev/null || true
# ── Microsoft Office add-in (Word/Excel/PowerPoint) ──
- name: Build Office add-in
run: |
VER="${{ steps.version.outputs.ver }}"
# Two builds, two zips (node-based bestzip — the arc image has no `zip`):
# - production: manifest points at office.hanzo.ai (AppSource / admin deploy)
# - localhost: manifest points at https://localhost:3000 + serve.mjs +
# SIDELOAD-WINDOWS.md — the self-contained "test on my machine" kit
pnpm --filter @hanzo/office-addin build
(cd packages/office/dist && npx --yes bestzip /tmp/hanzo-ai-office-v${VER}.zip .)
HANZO_OFFICE_BASE=https://localhost:3000 pnpm --filter @hanzo/office-addin build
(cd packages/office/dist && npx --yes bestzip /tmp/hanzo-ai-office-localhost-v${VER}.zip .)
continue-on-error: true
# ── MCP npm package ──
- name: Build MCP server
run: pnpm --filter @hanzo/mcp run build
@@ -211,7 +244,7 @@ jobs:
# ─── JetBrains ───
build-jetbrains:
name: JetBrains
runs-on: ubuntu-latest
runs-on: hanzo-build-linux-amd64
needs: [test, e2e]
steps:
- uses: actions/checkout@v4
@@ -243,7 +276,7 @@ jobs:
# ─── GitHub Release (on tag push) ───
release:
name: Release
runs-on: ubuntu-latest
runs-on: hanzo-build-linux-amd64
needs: [build, build-safari, build-jetbrains]
if: >-
always() &&
+2 -2
View File
@@ -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:
+1 -1
View File
@@ -10,7 +10,7 @@ on:
jobs:
deploy:
runs-on: ubuntu-latest
runs-on: hanzo-deploy-linux-amd64
steps:
- uses: actions/checkout@v4
+100 -16
View File
@@ -20,7 +20,7 @@ jobs:
# ─── Check available secrets ───
secrets:
name: Load KMS Secrets
runs-on: ubuntu-latest
runs-on: hanzo-build-linux-amd64
outputs:
has-chrome: ${{ steps.check.outputs.has-chrome }}
has-firefox: ${{ steps.check.outputs.has-firefox }}
@@ -40,7 +40,7 @@ jobs:
# ─── Chrome + Firefox ───
browser:
name: Chrome + Firefox
runs-on: ubuntu-latest
runs-on: hanzo-build-linux-amd64
needs: secrets
steps:
- uses: actions/checkout@v4
@@ -58,12 +58,17 @@ jobs:
- name: Package
working-directory: packages/browser
run: |
# web-ext (already used for linting below) does the zipping — the
# arc runner image has no `zip` binary. The old `zip -r ../../../…`
# also escaped dist/, so the artifact globs and the store upload
# never saw the files. The Safari bundle comes from the dedicated
# macOS job — the Linux build has no Xcode and emits no safari
# manifest, so packaging it here would ship junk.
VERSION=$(node -p "require('./package.json').version")
cd dist/browser-extension/chrome && zip -r ../../../hanzo-ai-chrome-v${VERSION}.zip .
cd ../../browser-extension/firefox && zip -r ../../../hanzo-ai-firefox-v${VERSION}.zip .
cd ../../browser-extension/safari && zip -r ../../../hanzo-ai-safari-v${VERSION}.zip .
# Edge uses Chrome zip (Chromium-compatible)
cd ../../.. && cp hanzo-ai-chrome-v${VERSION}.zip hanzo-ai-edge-v${VERSION}.zip
npx web-ext build --source-dir dist/browser-extension/chrome --artifacts-dir dist --filename hanzo-ai-chrome-v${VERSION}.zip --overwrite-dest
npx web-ext build --source-dir dist/browser-extension/firefox --artifacts-dir dist --filename hanzo-ai-firefox-v${VERSION}.zip --overwrite-dest
# Edge uses the Chrome zip (Chromium-compatible)
cp dist/hanzo-ai-chrome-v${VERSION}.zip dist/hanzo-ai-edge-v${VERSION}.zip
- name: Upload browser zips
uses: actions/upload-artifact@v4
@@ -73,7 +78,6 @@ jobs:
packages/browser/dist/hanzo-ai-chrome-v*.zip
packages/browser/dist/hanzo-ai-edge-v*.zip
packages/browser/dist/hanzo-ai-firefox-v*.zip
packages/browser/dist/hanzo-ai-safari-v*.zip
- name: Lint Firefox
working-directory: packages/browser
@@ -89,11 +93,12 @@ jobs:
-d "client_secret=${{ secrets.CHROME_CLIENT_SECRET }}" \
-d "refresh_token=${{ secrets.CHROME_REFRESH_TOKEN }}" \
-d "grant_type=refresh_token" | jq -r '.access_token')
VERSION=$(node -p "require('./package.json').version")
curl -sf -X PUT \
"https://www.googleapis.com/upload/chromewebstore/v1.1/items/${{ secrets.CHROME_EXTENSION_ID }}" \
-H "Authorization: Bearer $ACCESS_TOKEN" \
-H "x-goog-api-version: 2" \
-T dist/hanzo-ai-chrome.zip
-T dist/hanzo-ai-chrome-v${VERSION}.zip
curl -sf -X POST \
"https://www.googleapis.com/chromewebstore/v1.1/items/${{ secrets.CHROME_EXTENSION_ID }}/publish" \
-H "Authorization: Bearer $ACCESS_TOKEN" \
@@ -149,10 +154,33 @@ jobs:
-derivedDataPath dist/safari-build-ios
continue-on-error: true
# Package the built .app(s) so the GitHub Release Safari link resolves — the
# unsigned bundle a developer sideloads (source + built products).
- name: Package Safari for release
working-directory: packages/browser
run: |
VERSION=${GITHUB_REF_NAME#v}
cd dist
# Prefer the built .app products; fall back to the Xcode source project.
APPS=$(find safari-build-macos safari-build-ios -name '*.app' -type d 2>/dev/null || true)
if [ -n "$APPS" ]; then
zip -r -y "/tmp/hanzo-ai-safari-v${VERSION}.zip" $APPS
else
zip -r "/tmp/hanzo-ai-safari-v${VERSION}.zip" "safari/Hanzo AI"
fi
continue-on-error: true
- name: Upload Safari bundle
uses: actions/upload-artifact@v4
with:
name: hanzo-ai-safari
path: /tmp/hanzo-ai-safari-v*.zip
continue-on-error: true
# ─── VS Code + Cursor + Windsurf + Antigravity + Open VSX ───
vscode:
name: VS Code Marketplace
runs-on: ubuntu-latest
runs-on: hanzo-build-linux-amd64
needs: secrets
steps:
- uses: actions/checkout@v4
@@ -213,7 +241,7 @@ jobs:
# ─── npm (@hanzo/mcp) ───
npm:
name: npm
runs-on: ubuntu-latest
runs-on: hanzo-build-linux-amd64
needs: secrets
if: needs.secrets.outputs.has-npm == 'true'
steps:
@@ -236,9 +264,8 @@ jobs:
# ─── JetBrains Marketplace ───
jetbrains:
name: JetBrains
runs-on: ubuntu-latest
runs-on: hanzo-build-linux-amd64
needs: secrets
if: needs.secrets.outputs.has-jetbrains == 'true'
steps:
- uses: actions/checkout@v4
- uses: actions/setup-java@v4
@@ -246,18 +273,64 @@ jobs:
java-version: '17'
distribution: 'temurin'
- name: Build and publish
# Always build the downloadable plugin zip so the GitHub Release link works
# even when the Marketplace token is absent (publish below is token-gated).
- name: Build plugin
working-directory: packages/jetbrains
run: ./gradlew buildPlugin
- name: Package for release
working-directory: packages/jetbrains
run: |
VERSION=${GITHUB_REF_NAME#v}
ZIP=$(ls build/distributions/*.zip | head -1)
cp "$ZIP" "/tmp/hanzo-ai-jetbrains-v${VERSION}.zip"
- name: Upload JetBrains plugin
uses: actions/upload-artifact@v4
with:
name: hanzo-ai-jetbrains
path: /tmp/hanzo-ai-jetbrains-v*.zip
- name: Publish to JetBrains Marketplace
if: needs.secrets.outputs.has-jetbrains == 'true'
working-directory: packages/jetbrains
run: ./gradlew publishPlugin
env:
PUBLISH_TOKEN: ${{ secrets.JETBRAINS_TOKEN }}
continue-on-error: true
# ─── Microsoft Office add-in (Word / Excel / PowerPoint) ───
office:
name: Office Add-in
runs-on: hanzo-build-linux-amd64
needs: secrets
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version: '22'
cache: 'pnpm'
- run: pnpm install --frozen-lockfile
- name: Build + package
run: |
VERSION=${GITHUB_REF_NAME#v}
pnpm --filter @hanzo/office-addin build
cd packages/office/dist && npx --yes bestzip "/tmp/hanzo-ai-office-v${VERSION}.zip" .
- name: Upload Office add-in
uses: actions/upload-artifact@v4
with:
name: hanzo-ai-office
path: /tmp/hanzo-ai-office-v*.zip
# ─── GitHub Release with downloadable artifacts ───
release:
name: GitHub Release
runs-on: ubuntu-latest
needs: [browser, safari, vscode]
runs-on: hanzo-build-linux-amd64
needs: [browser, safari, vscode, jetbrains, office]
if: startsWith(github.ref, 'refs/tags/v')
steps:
- uses: actions/checkout@v4
@@ -286,6 +359,14 @@ jobs:
| **Claude Desktop/Code** | Windows / macOS / Linux | [`hanzo-ai-claude-v${{ steps.version.outputs.version }}.dxt`](https://github.com/hanzoai/extension/releases/download/${{ github.ref_name }}/hanzo-ai-claude-v${{ steps.version.outputs.version }}.dxt) |
| **JetBrains** | Windows / macOS / Linux | [`hanzo-ai-jetbrains-v${{ steps.version.outputs.version }}.zip`](https://github.com/hanzoai/extension/releases/download/${{ github.ref_name }}/hanzo-ai-jetbrains-v${{ steps.version.outputs.version }}.zip) |
## Microsoft Office
| App | Platform | Download |
|-----|----------|----------|
| **Word · Excel · PowerPoint** | Windows / macOS / Web / iPad | [`hanzo-ai-office-v${{ steps.version.outputs.version }}.zip`](https://github.com/hanzoai/extension/releases/download/${{ github.ref_name }}/hanzo-ai-office-v${{ steps.version.outputs.version }}.zip) |
> Office: unzip and sideload `manifest.xml` via the M365 admin center, or Insert → Add-ins → Upload My Add-in.
## Browser Extensions
| Browser | Platform | Download |
@@ -303,3 +384,6 @@ jobs:
files: |
artifacts/hanzo-ai-browser/*
artifacts/hanzo-ai-ide/*
artifacts/hanzo-ai-jetbrains/*
artifacts/hanzo-ai-office/*
artifacts/hanzo-ai-safari/*
+2
View File
@@ -2,6 +2,8 @@
out/
dist/
*.vsix
*.xpi
*.crx
# Dependencies
node_modules/
+47 -33
View File
@@ -1,38 +1,52 @@
# LLM.md - Hanzo Extension
# Hanzo Extension
## Overview
Hanzo AI Development Platform Monorepo
Browser extension (`packages/browser`) that joins the shared local **zapd** fabric.
One native primitive — no WebSocket, no localhost port, no mDNS, no CDP bridge.
Ships Chrome / Firefox / Safari. Build: esbuild (`src/build.js`, `pnpm build`);
tests: vitest.
## Tech Stack
- **Language**: TypeScript/JavaScript
## Architecture (native ZAP)
## Build & Run
```bash
pnpm install && pnpm build
pnpm test
```
extension ─connectNative("ai.hanzo.zap")─► native host ─UDS─► zapd ◄─ hanzo-mcp
## Structure
```
extension/
LICENSE
LLM.md
Makefile
PUBLISHING.md
README.md
apps/
benchmark/
docs/
examples/
hanzo-ai-chrome-1.7.12.zip
hanzo-ai-firefox-1.7.12.zip
images/
package.json
packages/
pnpm-lock.yaml
```
- `shared/native-zap.ts` — the ONE transport: `connectNativeZap()` opens the native
port (**singleton** — one port; the router does evict-and-replace), registers as
provider `browser:<engine>/<host>/default`, dispatches inbound ROUTE commands.
Cross-browser (`browser ∥ chrome`).
- `background.ts` (Chrome) — dispatch via `chrome.debugger` (CDP→tab; shows the
"debugging this browser" banner; WebKit-incompatible). **To be removed** — see WXT plan.
- `background-firefox.ts` — native `browser.*` dispatch (no banner). The correct model.
- `browser-dispatch.ts``chrome.debugger` actuation (Chrome-only).
- `shared/evaluable.ts` — the ONE evaluate rule: `pickEvaluable` (code-param
aliases) + `wrapEvaluable` (bare expression passes through; statement bodies
→ async IIFE with the trailing value auto-returned). Both dispatch paths
consume it, so Chrome and Firefox accept identical caller JS.
## Key Files
- `README.md` -- Project documentation
- `package.json` -- Dependencies and scripts
- `Makefile` -- Build automation
Wire to zapd: the binary ZAP router envelope (`zap-proto/zapd/src/frame.rs`); the
browser↔host hop is native-messaging JSON, base64'd, quarantined in the host. Host +
per-browser manifests come from `zapd install-host` (`zap-proto/zapd`).
## Versioning
Patch only (X.Y.Z+1), never major. `package.json` is the source of truth; `build.js`
stamps it into every manifest (chrome/firefox/safari).
## Cross-platform — WXT migration (canonical plan, not yet executed)
One WebExtension codebase, every engine. **WXT** = build/SDK layer (build matrix,
manifest gen, targets, MV3, Vite/TS); `webextension-polyfill` / `@wxt-dev/browser` =
`browser.*` normalization. **Custom (keep):** `shared/native-zap.ts`, and the Safari
`SafariWebExtensionHandler.swift``~/.zap/run/zapd.sock` bridge. WXT builds Safari
but does NOT give WebKit `chrome.debugger` — so dispatch must become portable
`browser.*` (the Firefox model is already correct).
1. Adopt WXT build matrix (chrome/firefox/safari) — replaces `build.js`.
2. Collapse forks → one `background.ts`; delete `background-firefox.ts`; runtime
`browser` adapter.
3. Collapse dispatch → delete `chrome.debugger` / `browser-dispatch.ts`; actuate over
`tabs`/`scripting`/`webNavigation`/`browser.*` (portable, no banner, Safari-capable).
4. Transport stays per-platform: Chrome/Firefox `connectNative` → host → `zapd.sock`;
Safari `SafariWebExtensionHandler.swift``zapd.sock`.
5. CI guards (last; go red until 14 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.
+2
View File
@@ -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 🚀
[![VS Code Extension CI/CD](https://github.com/hanzoai/dev/workflows/VS%20Code%20Extension%20CI%2FCD/badge.svg)](https://github.com/hanzoai/dev/actions/workflows/vscode-extension.yml)
+1 -1
View File
@@ -18,6 +18,6 @@
"devDependencies": {
"@playwright/test": "^1.54.1",
"terser": "^5.43.1",
"vite": "^5.4.19"
"vite": "^6.4.3"
}
}
+1 -1
View File
@@ -144,7 +144,7 @@ VS Code extensions are signed automatically when published to the marketplace.
- `VSCODE_ENV`: Set to 'development' or 'production'
- `HANZO_WORKSPACE`: Default workspace for MCP operations
- `HANZO_ANONYMOUS`: Set to 'true' for anonymous mode
- `HANZO_IAM_ENDPOINT`: Custom IAM endpoint (default: https://iam.hanzo.ai)
- `IAM_ENDPOINT`: Custom IAM endpoint (default: https://iam.hanzo.ai)
## Testing Builds
+1 -1
View File
@@ -184,7 +184,7 @@ The extension will generate `.windsurfrules` file.
- `HANZO_ANONYMOUS` - Set to 'true' for anonymous mode
- `MCP_TRANSPORT` - Transport method (stdio or tcp)
- `MCP_PORT` - Port for TCP transport (default: 3000)
- `HANZO_IAM_ENDPOINT` - Custom IAM endpoint (default: https://iam.hanzo.ai)
- `IAM_ENDPOINT` - Custom IAM endpoint (default: https://iam.hanzo.ai)
### Tool Configuration
-195
View File
@@ -1,195 +0,0 @@
# Comprehensive Tool Testing Report
## Overview
All core tools have been implemented, tested, and benchmarked. The extension now supports 56 tools total, with 27 enabled by default.
## Tool Implementation Status
### ✅ Fully Implemented and Tested Tools
#### File System Tools (6/6)
-**read** - Read file contents with line numbers
-**write** - Write content to files
-**edit** - Edit files by replacing patterns
-**multi_edit** - Multiple edits in one operation
-**directory_tree** - Display directory structure
-**find_files** - Find files matching patterns
#### Search Tools (4/4)
-**grep** - Pattern search using ripgrep
-**search** - Unified search across files/symbols/git
-**symbols** - Search code symbols
-**unified_search** - Parallel search across all types
#### Shell Tools (3/3)
-**run_command** - Execute shell commands
-**open** - Open files/URLs
-**process** - Background process management with logging
#### Development Tools (5/5)
-**todo_read** - Read todo list
-**todo_write** - Write todo items
-**todo_unified** - Unified todo management
-**think** - Structured thinking space
-**critic** - Code review and analysis
#### Configuration Tools (3/3)
-**config** - Git-style configuration
-**rules** - Project conventions (.cursorrules, .clauderc)
-**palette** - Tool personality switching
#### Database & AI Tools (6/6)
-**graph_db** - Graph database with AST integration
-**vector_index** - Index documents for vector search
-**vector_search** - Semantic search with embeddings
-**vector_similar** - Find similar documents
-**document_store** - Chat document management
-**zen** - Hanzo Zen1 AI model (local/cloud)
#### Utility Tools (2/2)
-**batch** - Batch operations
-**web_fetch** - Fetch and analyze web content
## Test Results
### Functionality Tests
All key tools passed functionality tests:
- ✅ think - Thought recording works
- ✅ critic - Code analysis works
- ✅ unified_search - Parallel search works
- ✅ graph_db - Node/edge operations work
- ✅ vector_index - Document indexing works
- ✅ document_store - Document management works
- ✅ web_fetch - Web content fetching works
- ✅ palette - Tool switching works
- ✅ config - Configuration management works
- ✅ process - Background process management works
### Performance Benchmarks
#### Graph Database
- **Add nodes**: 6,768 nodes/ms (excellent)
- **Query performance**: < 0.1ms for most operations
- **Path finding**: < 0.01ms average
- **Connected components**: 0.42ms for full analysis
#### Vector Store
- **Index documents**: 211 documents/ms
- **Search performance**: 0.49ms average (🟢 Fast)
- **Filtered search**: 0.24ms average
- **Metadata search**: 0.11ms average
#### AST Index
- **File indexing**: ~5,610 files/second
- **Symbol search**: < 0.02ms
- **Reference finding**: < 0.01ms
- **Call hierarchy**: 0.02ms average
#### Document Store
- **Add documents**: 13ms per batch
- **Search documents**: < 0.01ms
- **Session save**: 1.64ms average
## Backend Abstraction
### Local vs Cloud Support
**Local Backend**
- In-memory graph database
- Local vector store with mock embeddings
- File-based document persistence
- Support for Ollama and LM Studio
- Hanzo local model support
**Cloud Backend**
- API-based operations
- Real embeddings from cloud
- Persistent storage
- Authentication via API key
- Unified interface with local
### AI Model Support
**Local AI Providers**
- Ollama (auto-detected at localhost:11434)
- LM Studio (auto-detected at localhost:1234)
- Hanzo Local Models (zen1, zen1-mini, zen1-code)
**Cloud AI Providers**
- Hanzo Cloud API
- Fallback to OpenAI/Anthropic
- Unified LLM interface
## Critic Tool Capabilities
The critic tool provides comprehensive code analysis:
1. **Security Analysis**
- SQL injection detection
- XSS vulnerability checks
- Authentication/authorization issues
- Sensitive data exposure
2. **Performance Analysis**
- Algorithm complexity
- Database query optimization
- Memory leak detection
- Unnecessary computations
3. **Code Quality**
- Naming conventions
- Code organization
- Documentation coverage
- Error handling
4. **Correctness**
- Logic errors
- Edge case handling
- Type safety
- Test coverage
## Missing/Partial Implementations
### Tools Not Yet Implemented
- ❌ AST analyzer (compilation issues with TypeScript parser)
- ❌ Tree-sitter analyzer (module resolution issues)
- ❌ Jupyter notebook tools (notebook_read, notebook_edit)
- ❌ SQL database tools (sql_query, sql_search)
- ❌ LLM consensus tool
- ❌ Agent dispatch tool
- ❌ Some system tools (memory, date, copy, move, delete)
### Limitations
- Vector store uses mock embeddings (real embeddings need API integration)
- Graph database is in-memory only for local mode
- Document store requires file system access
## Recommendations
1. **Enable More Tools by Default**
- Consider enabling graph_db, vector tools, and zen by default
- These are now fully tested and performant
2. **Real Embeddings**
- Integrate with OpenAI/Cohere for real embeddings
- Or use local sentence-transformers
3. **Persistent Storage**
- Add SQLite backend option for local persistence
- Implement proper backup/restore
4. **Complete AST Integration**
- Fix TypeScript compilation issues
- Add tree-sitter support for more languages
## Summary
**27 tools** fully implemented and tested
**Excellent performance** across all subsystems
**Local and cloud** backend abstraction working
**AI model integration** for Ollama, LM Studio, and Hanzo
**Comprehensive testing** with benchmarks
The extension is production-ready with all core functionality working as expected.
+87 -3
View File
@@ -1,8 +1,8 @@
{
"name": "hanzo-ai-monorepo",
"version": "1.8.5",
"name": "@hanzo/extension",
"version": "1.9.29",
"private": true,
"description": "Hanzo AI Development Platform Monorepo",
"description": "Hanzo AI Extension",
"license": "MIT",
"repository": {
"type": "git",
@@ -29,5 +29,89 @@
"build:jetbrains": "cd packages/jetbrains && ./gradlew build",
"package:vscode": "pnpm --filter @hanzo/extension run package",
"package:dxt": "pnpm --filter @hanzo/dxt run package"
},
"pnpm": {
"overrides": {
"@babel/core@<=7.29.0": "7.29.6",
"@grpc/grpc-js@<1.9.16": "1.9.16",
"@isaacs/brace-expansion@<=5.0.0": "5.0.1",
"@modelcontextprotocol/sdk@<1.24.0": "1.24.0",
"@modelcontextprotocol/sdk@<1.25.2": "1.25.2",
"@modelcontextprotocol/sdk@>=1.10.0 <=1.25.3": "1.26.0",
"@opentelemetry/core@<2.8.0": "2.8.0",
"@protobufjs/utf8@<=1.1.0": "1.1.1",
"ajv@<6.14.0": "6.14.0",
"ajv@>=7.0.0-alpha.0 <8.18.0": "8.18.0",
"axios@>=1.0.0 <=1.13.4": "1.13.5",
"axios@>=1.0.0 <1.12.0": "1.12.0",
"axios@>=1.0.0 <1.15.0": "1.15.0",
"axios@>=1.0.0 <1.15.1": "1.15.1",
"axios@>=1.0.0 <1.15.2": "1.15.2",
"axios@>=1.0.0 <1.16.0": "1.16.0",
"axios@>=1.7.0 <1.16.0": "1.16.0",
"body-parser@>=2.2.0 <2.2.1": "2.2.1",
"brace-expansion@<1.1.13": "1.1.13",
"brace-expansion@>=2.0.0 <2.0.3": "2.0.3",
"diff@>=6.0.0 <8.0.3": "8.0.3",
"esbuild@<=0.24.2": "0.25.0",
"fast-uri@<=3.1.0": "3.1.1",
"fast-uri@<=3.1.1": "3.1.2",
"file-type@<21.3.2": "21.3.2",
"flatted@<3.4.2": "3.4.2",
"follow-redirects@<=1.15.11": "1.16.0",
"form-data@>=4.0.0 <4.0.4": "4.0.4",
"form-data@>=4.0.0 <4.0.6": "4.0.6",
"glob@>=10.2.0 <10.5.0": "10.5.0",
"handlebars@>=4.0.0 <=4.7.8": "4.7.9",
"handlebars@>=4.0.0 <4.7.9": "4.7.9",
"handlebars@>=4.6.0 <=4.7.8": "4.7.9",
"ip-address@<=10.1.0": "10.1.1",
"js-yaml@<3.15.0": "3.15.0",
"js-yaml@>=4.0.0 <4.2.0": "4.2.0",
"jws@<3.2.3": "3.2.3",
"linkify-it@<=5.0.0": "5.0.1",
"lodash@<=4.17.23": "4.18.0",
"lodash@>=4.0.0 <=4.17.22": "4.17.23",
"lodash@>=4.0.0 <=4.17.23": "4.18.0",
"markdown-it@<=14.1.1": "14.2.0",
"min-document@<=2.19.0": "2.19.1",
"minimatch@<3.1.3": "3.1.3",
"minimatch@<3.1.4": "3.1.4",
"minimatch@>=10.0.0 <10.2.1": "10.2.1",
"minimatch@>=10.0.0 <10.2.3": "10.2.3",
"minimatch@>=5.0.0 <5.1.7": "5.1.7",
"minimatch@>=5.0.0 <5.1.8": "5.1.8",
"minimatch@>=9.0.0 <9.0.6": "9.0.6",
"minimatch@>=9.0.0 <9.0.7": "9.0.7",
"path-to-regexp@>=8.0.0 <8.4.0": "8.4.0",
"picomatch@<2.3.2": "2.3.2",
"picomatch@>=4.0.0 <4.0.4": "4.0.4",
"playwright@<1.55.1": "1.55.1",
"postcss@<8.5.10": "8.5.10",
"protobufjs@<7.6.3": "7.6.3",
"qs@<6.15.2": "6.15.2",
"rollup@>=4.0.0 <4.59.0": "4.59.0",
"serialize-javascript@<=7.0.2": "7.0.3",
"serialize-javascript@>=5.0.0 <7.0.5": "7.0.5",
"tar-fs@>=2.0.0 <2.1.4": "2.1.4",
"tar-fs@>=3.0.0 <3.1.1": "3.1.1",
"tar@<7.5.16": "7.5.16",
"tmp@<0.2.7": "0.2.7",
"underscore@<=1.13.7": "1.13.8",
"undici@>=7.0.0 <7.18.2": "7.18.2",
"undici@>=7.0.0 <7.24.0": "7.24.0",
"undici@>=7.0.0 <7.28.0": "7.28.0",
"uuid@<11.1.1": "11.1.1",
"validator@<13.15.20": "13.15.20",
"validator@<13.15.22": "13.15.22",
"vite@<6.4.3": "6.4.3",
"vitest@<3.2.6": "3.2.6",
"ws@>=8.0.0 <8.20.1": "8.20.1",
"ws@>=8.0.0 <8.21.0": "8.21.0",
"yaml@>=2.0.0 <2.8.3": "2.8.3"
},
"onlyBuiltDependencies": [
"better-sqlite3"
]
}
}
+3 -3
View File
@@ -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"
}
}
}
-5062
View File
File diff suppressed because it is too large Load Diff
+10 -10
View File
@@ -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"
}
}
}
+20 -20
View File
@@ -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();
+20
View File
@@ -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"
}
+53
View File
@@ -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(/\/+$/, '');
}
+103
View File
@@ -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;
}
}
+21
View File
@@ -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 };
}
+133
View File
@@ -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 '';
}
}
+36
View File
@@ -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 };
}
+32
View File
@@ -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>;
}
+39
View File
@@ -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',
);
});
});
+106
View File
@@ -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');
});
});
+88
View File
@@ -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',
);
});
});
+14
View File
@@ -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"]
}
+8
View File
@@ -0,0 +1,8 @@
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
include: ['tests/**/*.test.ts'],
environment: 'node',
},
});
+9 -9
View File
@@ -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!!!',
+15 -9
View File
@@ -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();
});
});
+23 -7
View File
@@ -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');
+5 -4
View File
@@ -1,6 +1,6 @@
{
"name": "@hanzo/browser-extension",
"version": "1.8.5",
"version": "1.9.29",
"description": "Hanzo AI Browser Extension",
"main": "dist/background.js",
"scripts": {
@@ -16,20 +16,21 @@
"axios": "^1.10.0",
"chalk": "^4.1.2",
"commander": "^11.1.0",
"webextension-polyfill": "0.12.0",
"ws": "^8.18.3"
},
"devDependencies": {
"@playwright/test": "^1.52.0",
"@types/chrome": "^0.0.270",
"@types/firefox-webext-browser": "^120.0.0",
"@types/jsdom": "^21.1.7",
"@types/ws": "^8.18.1",
"@playwright/test": "^1.52.0",
"esbuild": "^0.25.6",
"esbuild": "^0.25.8",
"jsdom": "^26.1.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"typescript": "^5.8.3",
"vitest": "^0.34.6",
"vitest": "^3.2.6",
"ws": "^8.18.3"
},
"engines": {
File diff suppressed because it is too large Load Diff
+98 -75
View File
@@ -1,14 +1,21 @@
// Background Service Worker for Browser Extension
//
// First import: webextension-polyfill. On Chrome/Edge this is a side-effect
// import that defines `globalThis.browser` as the unified Promise-returning
// WebExtension API. On Firefox/Safari `browser.*` is already native and the
// polyfill is a no-op. The shared modules (auth.ts, tab-id.ts, etc.) read
// this global so importing it once at the entrypoint is enough.
import 'webextension-polyfill';
import type { ZapState, ControlSession, RagSnippet, RagQueryParams } from './shared/types.js';
import {
createZapManager,
encodeZapMessage,
discoverZapServers,
handleZapMessage,
MSG_REQUEST,
DEFAULT_ZAP_PORTS as SHARED_ZAP_PORTS,
type ZapManager,
} from './shared/zap.js';
import { connectNativeZap, type NativeZapState } from './shared/native-zap.js';
import {
getRagConfig as getSharedRagConfig,
normalizeRagSnippets,
@@ -29,7 +36,7 @@ import {
const IAM_V1 = `${IAM_API_URL}${IAM_API_PATH}`;
import { BrowserControl } from './browser-control';
import { WebGPUAI } from './webgpu-ai';
import { getCDPBridge, CDPBridge } from './cdp-bridge';
import { getCDPBridge, CDPBridge } from './browser-dispatch';
import * as auth from './auth';
import { listModels, chatCompletion, ChatMessage } from './chat-client';
import { PageMonitor } from './page-monitor';
@@ -115,8 +122,11 @@ const controlSession: ControlSession = {
startedAt: null,
};
// Default ports (overridable via chrome.storage.local settings)
const DEFAULT_ZAP_PORTS = SHARED_ZAP_PORTS;
// Default ports (overridable via chrome.storage.local settings).
// NOTE: ZAP discovery is mDNS-only (HIP-0069); the legacy zapPorts setting
// is no longer consulted for discovery — kept here only because other
// surfaces (status display, settings UI) still expose it during the
// removal window.
const DEFAULT_MCP_PORT = CFG_MCP_PORT;
const DEFAULT_CDP_PORT = CFG_CDP_PORT;
const DEFAULT_RAG_TOP_K = CFG_RAG_TOP_K;
@@ -133,11 +143,10 @@ const controlMessageLimiter = new ActionRateLimiter();
const controlMessageSignatures = new Map<string, string>();
/** Load port configuration from storage */
async function getPortConfig(): Promise<{ zapPorts: number[]; mcpPort: number; cdpPort: number }> {
async function getPortConfig(): Promise<{ mcpPort: number; cdpPort: number }> {
return new Promise((resolve) => {
chrome.storage.local.get(['zapPorts', 'mcpPort', 'cdpPort'], (result) => {
chrome.storage.local.get(['mcpPort', 'cdpPort'], (result) => {
resolve({
zapPorts: result.zapPorts || DEFAULT_ZAP_PORTS,
mcpPort: result.mcpPort || DEFAULT_MCP_PORT,
cdpPort: result.cdpPort || DEFAULT_CDP_PORT,
});
@@ -281,15 +290,46 @@ chrome.runtime.onInstalled.addListener(async () => {
if (gpuAvailable) {
debugLog('[Hanzo] WebGPU available');
try {
await webgpuAI.loadModel({
name: 'hanzo-browser-control',
url: chrome.runtime.getURL('models/browser-control-4bit.bin'),
quantization: '4bit',
maxTokens: 512
});
// Model source resolution order (first hit wins):
// 1. chrome.storage.local.hanzo_model_url — explicit override
// 2. chrome.storage.local.hanzo_model_hf — { repo, file, revision? }
// 3. The hanzo-browser-control HF default — cached after first load
// 4. The bundled-with-extension fallback at models/browser-control-4bit.bin
const stored = await chrome.storage.local.get([
'hanzo_model_url', 'hanzo_model_hf', 'hanzo_model_quant', 'hanzo_model_max_tokens', 'hanzo_model_vocab_url',
]);
let modelUrl: string;
if (stored.hanzo_model_url) {
modelUrl = stored.hanzo_model_url;
} else if (stored.hanzo_model_hf && stored.hanzo_model_hf.repo && stored.hanzo_model_hf.file) {
const { repo, file, revision } = stored.hanzo_model_hf;
modelUrl = WebGPUAI.huggingFaceURL(repo, file, revision || 'main');
} else {
// Default to the HF-published browser-control artifact. If it 404s, the
// catch below falls through to the bundled file, then to remote providers.
modelUrl = WebGPUAI.huggingFaceURL('zenlm/hanzo-browser-control', 'browser-control-4bit.bin');
}
try {
await webgpuAI.loadModel({
name: 'hanzo-browser-control',
url: modelUrl,
quantization: stored.hanzo_model_quant || '4bit',
maxTokens: stored.hanzo_model_max_tokens || 512,
vocabUrl: stored.hanzo_model_vocab_url,
});
debugLog(`[Hanzo] WebGPU model loaded from ${modelUrl}`);
} catch (hfErr) {
debugLog(`[Hanzo] WebGPU model fetch failed for ${modelUrl}: ${hfErr}; trying bundled fallback…`);
await webgpuAI.loadModel({
name: 'hanzo-browser-control',
url: chrome.runtime.getURL('models/browser-control-4bit.bin'),
quantization: '4bit',
maxTokens: 512,
});
}
} catch (error) {
// Model file not bundled yet — will use Ollama/LM Studio/cloud instead
debugLog('[Hanzo] Local model not bundled, using remote providers');
// Neither HF nor bundled artifact available — fall back to remote providers.
debugLog(`[Hanzo] No local WebGPU model available (${error}); using Ollama/LM Studio/cloud`);
}
}
@@ -1468,7 +1508,7 @@ async function handleMessage(request: any, sender: chrome.runtime.MessageSender,
const data = await resp.json();
const user = data.data || data;
// Casdoor stores org memberships in user.groups or via org API
// Hanzo IAM stores org memberships in user.groups or via org API
const orgs: { id: string; name: string; displayName?: string }[] = [];
// Primary org from user's owner field
@@ -1750,54 +1790,16 @@ async function handleMessage(request: any, sender: chrome.runtime.MessageSender,
}
}
// =============================================================================
// Legacy MCP WebSocket (fallback when no ZAP gateway running)
// =============================================================================
let ws: WebSocket | null = null;
async function connectToMCP() {
const { mcpPort } = await getPortConfig();
ws = new WebSocket(`ws://localhost:${mcpPort}/browser-extension`);
ws.onopen = () => {
debugLog('[Hanzo] Connected to legacy MCP server');
};
ws.onmessage = (event) => {
const data = JSON.parse(event.data);
handleMCPMessage(data);
};
ws.onerror = () => {
// Silent — ZAP is primary, this is fallback
};
ws.onclose = () => {
setTimeout(connectToMCP, 5000);
};
}
function handleMCPMessage(data: any) {
switch (data.type) {
case 'browserControl':
chrome.tabs.query({ active: true, currentWindow: true }, (tabs) => {
if (tabs[0]?.id) {
browserControl.launchAIWorker(tabs[0].id, data.model);
}
});
break;
}
}
// =============================================================================
// Startup
// =============================================================================
// 0. Register side panel
if (chrome.sidePanel) {
chrome.sidePanel.setOptions({ path: 'sidebar.html', enabled: true });
}
// 0. Side panel intentionally NOT registered. The user requirement is a
// right-anchored, in-page chat overlay across every browser; the native
// chrome.sidePanel surface gives an OS-managed panel which doesn't match
// that design and behaves differently from the Firefox path. Routing
// through the content-script overlay (page.overlay.* messages) is the
// single source of truth — see popup.ts.
// End active session when the controlled tab closes.
chrome.tabs.onRemoved.addListener((tabId) => {
@@ -1806,23 +1808,44 @@ chrome.tabs.onRemoved.addListener((tabId) => {
}
});
// 1. Primary: Discover ZAP servers (high-performance binary protocol)
getPortConfig().then(({ zapPorts }) => {
discoverZapServers(zapMgr, BROWSER_NAME, VERSION, zapPorts, debugLog);
// 1. Primary: connect to the local zapd router via the ai.hanzo.zap native
// host and register as a browser provider. Inbound ROUTE commands are
// dispatched to the browser. Exactly one transport — no WebSocket, no
// localhost ports, no mDNS probing; the browser can never get a refused
// connection (connectNative attaches the host or fails once, cleanly).
const BROWSER_CAPS = ['browser.tabs', 'browser.navigate', 'browser.dom', 'browser.screenshot', 'browser.input'];
const nativeZap: NativeZapState = {
connected: false,
port: null,
providerId: `browser:${BROWSER_NAME}/default`,
};
const browserDispatch = (method: string, params: any) => cdpBridge.dispatchMethod(method, params);
connectNativeZap(nativeZap, 'hanzo', BROWSER_CAPS, browserDispatch, debugLog);
// Keep-alive — Layer 1: Port. Content scripts in every loaded http(s) tab
// hold a `zap-keepalive` port. As long as ANY tab has one, Chrome keeps
// the service worker alive without alarms.
chrome.runtime.onConnect.addListener((port) => {
if (port.name !== 'zap-keepalive') return;
port.onMessage.addListener(() => { /* receipt is the keep-alive */ });
});
// 2. Fallback: Connect to legacy MCP WebSocket
connectToMCP();
// Keep-alive — Layer 2: Alarms. Wake the worker periodically; if the native
// host link dropped (or the worker was idled out), reconnect.
const KEEP_ALIVE_ALARM = 'hanzo-zap-keepalive';
try {
chrome.alarms.create(KEEP_ALIVE_ALARM, { periodInMinutes: 0.25 });
chrome.alarms.onAlarm.addListener((alarm) => {
if (alarm.name !== KEEP_ALIVE_ALARM) return;
if (!nativeZap.connected) {
debugLog('[Hanzo/ZAP] keep-alive: reconnecting native host');
connectNativeZap(nativeZap, 'hanzo', BROWSER_CAPS, browserDispatch, debugLog);
}
});
} catch (e) {
console.warn('[Hanzo] alarms unavailable:', e);
}
// 3. CDP bridge for browser control (configurable port)
getPortConfig().then(({ cdpPort }) => {
try {
cdpBridge.startWebSocketServer(cdpPort);
debugLog(`[Hanzo] CDP bridge connecting to ws://localhost:${cdpPort}/cdp`);
} catch (e) {
console.error('[Hanzo] Failed to start CDP bridge:', e);
}
});
// Export for testing
export { browserControl, webgpuAI, zapMgr, zapState };
+85 -21
View File
@@ -1377,30 +1377,94 @@ export class BrowserControl {
// Script Execution Helper
// ===========================================================================
private executeScript<T = any>(tabId: number, code: string): Promise<T> {
return new Promise((resolve, reject) => {
if (chrome.scripting) {
// MV3: use chrome.scripting.executeScript
chrome.scripting.executeScript({
/**
* Run JS in a tab and return its result.
*
* Why MV3 + MAIN world + the Function trampoline?
*
* - `chrome.scripting.executeScript` only accepts a function (or files);
* it does NOT take a code string the way MV2's `tabs.executeScript`
* did. To support our existing call sites that pass dynamic code as
* a string, we serialise the user's code through `args` and run it
* via `Function(codeStr)()` inside the page.
* - MAIN world is required so the page's own globals (window.fetch,
* CustomElements, framework instances) are visible. ISOLATED world
* can't see them.
* - Async unwrap: if the caller's code yields a Promise, executeScript
* serialises it as `{}` (the value-loss bug). We detect that shape
* and re-run wrapping in `Promise.resolve(...)` so the resolved
* value comes back instead of the placeholder.
*
* Falls back to the MV2 string API on browsers / runtimes that still
* have it (older Firefox; no Chrome MV2 ships now).
*/
private async executeScript<T = any>(tabId: number, code: string): Promise<T> {
const exec = async (wrapped: string): Promise<T> => {
// MV3 path — preferred on every modern build.
if (chrome.scripting?.executeScript) {
const results = await chrome.scripting.executeScript({
target: { tabId },
func: new Function(`return (${code})`) as () => T,
}, (results) => {
if (chrome.runtime.lastError) {
reject(new Error(chrome.runtime.lastError.message));
} else {
resolve(results?.[0]?.result as T);
}
world: 'MAIN',
args: [wrapped],
func: (codeStr: string) => {
try {
// eslint-disable-next-line @typescript-eslint/no-implied-eval, no-new-func
return Function(codeStr)();
} catch (e: any) {
return { __hanzo_error: e?.message || String(e) };
}
},
});
} else {
// MV2 fallback: use chrome.tabs.executeScript
(chrome.tabs as any).executeScript(tabId, { code }, (results: any[]) => {
if (chrome.runtime.lastError) {
reject(new Error(chrome.runtime.lastError.message));
} else {
resolve(results?.[0] as T);
}
return (results?.[0] as any)?.result as T;
}
// MV2 fallback — only Chrome-MV2 / older Firefox would reach this.
const legacy = (chrome.tabs as any).executeScript;
if (legacy) {
return new Promise<T>((resolve, reject) => {
legacy(tabId, { code: wrapped }, (results: any[]) => {
if (chrome.runtime.lastError) {
reject(new Error(chrome.runtime.lastError.message));
} else {
resolve(results?.[0] as T);
}
});
});
}
});
throw new Error('No executeScript API available (need MV3 scripting permission)');
};
// Wrap the user's code so it ALWAYS has a top-level `return`. The
// Function trampoline discards the value of an IIFE — without an
// explicit `return` the result is undefined → JSON-encodes to `{}`.
const trimmed = code.trim();
const wrappedCode = /^\s*return\b/.test(trimmed)
? trimmed
: `try { return (${trimmed}); } catch(__e) { return { __hanzo_error: __e.message || String(__e) }; }`;
let result: any = await exec(wrappedCode);
// Unwrap our error sentinel so callers see a real Error.
if (result && typeof result === 'object' && '__hanzo_error' in result) {
throw new Error(result.__hanzo_error);
}
// Detect the empty-Promise serialization. If the caller's code yielded
// a Promise (e.g. (async () => 'x')()) executeScript drops it as `{}`.
// Re-run wrapped in Promise.resolve so the resolved value comes back.
const looksEmpty = result !== null
&& typeof result === 'object'
&& Object.keys(result).length === 0
&& !Array.isArray(result);
if (looksEmpty) {
const promiseWrapped = `return Promise.resolve((function(){ ${wrappedCode} })()).then(function(__v){
try { return JSON.parse(JSON.stringify(__v === undefined ? null : __v)); } catch(e) { return String(__v); }
});`;
result = await exec(promiseWrapped);
if (result && typeof result === 'object' && '__hanzo_error' in result) {
throw new Error(result.__hanzo_error);
}
}
return result as T;
}
}
@@ -1,6 +1,8 @@
// CDP Bridge for hanzo-mcp Browser Tool Integration
// Enables Playwright to control browser via Chrome DevTools Protocol
import { ActionRateLimiter, debugLog, loadDebugFlagFromStorage } from './runtime-guard';
import { parseTabId, unwrapEvaluateResult } from './shared/tab-id.js';
import { pickEvaluable, wrapEvaluable } from './shared/evaluable.js';
interface CDPSession {
tabId: number;
@@ -164,27 +166,40 @@ export class CDPBridge {
debugLog(`[CDP] Page.captureScreenshot failed, using captureVisibleTab fallback: ${e}`);
}
// Fallback: use chrome.tabs.captureVisibleTab (no debugger needed)
// Fallback: chrome.tabs.captureVisibleTab. This API only works on a
// *foreground* tab in the target window — Firefox enforces this strictly,
// Chrome will sometimes succeed with a backgrounded tab but is
// inconsistent on Mac. Brief-activate the target tab if needed and
// restore focus afterwards. Always pass an explicit windowId resolved
// from the tab so multi-window automation captures the right window.
const tab = await chrome.tabs.get(tabId);
const dataUrl = await chrome.tabs.captureVisibleTab(tab.windowId, {
format: options?.format === 'jpeg' ? 'jpeg' : 'png',
quality: options?.quality,
});
let activatedFor: number | null = null;
let prevActiveId: number | undefined;
if (!tab.active) {
const prev = await new Promise<chrome.tabs.Tab | undefined>((resolve) => {
chrome.tabs.query({ active: true, windowId: tab.windowId }, (tabs) => resolve(tabs[0]));
});
prevActiveId = prev?.id;
await chrome.tabs.update(tabId, { active: true });
activatedFor = tabId;
// One animation tick so the tab actually composes.
await new Promise((r) => setTimeout(r, 50));
}
let dataUrl: string;
try {
dataUrl = await chrome.tabs.captureVisibleTab(tab.windowId, {
format: options?.format === 'jpeg' ? 'jpeg' : 'png',
quality: options?.quality,
});
} finally {
if (activatedFor !== null && prevActiveId) {
chrome.tabs.update(prevActiveId, { active: true }).catch(() => {});
}
}
// Strip data:image/png;base64, prefix
return dataUrl.replace(/^data:image\/\w+;base64,/, '');
}
/** Accept tabId in any of: number | "tab-NNN" | "NNN" | undefined.
* Fully anchored so "https://x.com/?tab-123" can't be coerced. */
private parseTabId(raw: unknown): number | null {
if (typeof raw === 'number' && Number.isFinite(raw)) return raw;
if (typeof raw !== 'string' || raw === '') return null;
const m = raw.match(/^(?:tab-)?(\d+)$/);
if (!m) return null;
const n = Number(m[1]);
return Number.isFinite(n) ? n : null;
}
async click(tabId: number, x: number, y: number): Promise<void> {
await this.send(tabId, 'Input.dispatchMouseEvent', {
type: 'mousePressed',
@@ -218,11 +233,18 @@ export class CDPBridge {
async evaluate(tabId: number, expression: string): Promise<any> {
await this.send(tabId, 'Runtime.enable');
// awaitPromise: true makes CDP wait for any async IIFE the caller passed
// to resolve before returning. Without this, expressions like
// (async () => fetch('/x').then(r => r.json()))()
// serialize to {} (a Promise placeholder) and the caller can't tell
// success from failure. unwrapEvaluateResult further normalises every
// shape (Chrome / CDP / hanzo-tools) to the bare value.
const result = await this.send(tabId, 'Runtime.evaluate', {
expression,
returnByValue: true
expression: wrapEvaluable(expression),
returnByValue: true,
awaitPromise: true,
});
return result.result?.value;
return unwrapEvaluateResult(result);
}
async getDocument(tabId: number): Promise<any> {
@@ -293,78 +315,28 @@ export class CDPBridge {
return true;
}
// WebSocket server for external connections (hanzo-mcp)
startWebSocketServer(port: number = 9223): void {
// Note: Chrome extensions can't create native WebSocket servers
// Instead, we connect to an external bridge server
this.connectToBridge(`ws://localhost:${port}/cdp`);
}
private connectToBridge(url: string): void {
try {
this.wsServer = new WebSocket(url);
this.wsServer.onopen = () => {
debugLog('[CDP] Connected to bridge server');
// Register as CDP provider with browser identification
const browser = typeof navigator !== 'undefined'
? (navigator.userAgent.includes('Firefox') ? 'firefox'
: navigator.userAgent.includes('Edg/') ? 'edge'
: navigator.userAgent.includes('Chrome') ? 'chrome'
: navigator.userAgent.includes('Safari') ? 'safari'
: 'unknown')
: 'unknown';
this.wsServer?.send(JSON.stringify({
type: 'register',
role: 'cdp-provider',
browser,
capabilities: ['navigate', 'screenshot', 'click', 'type', 'evaluate', 'takeover']
}));
};
this.wsServer.onmessage = async (event) => {
try {
const message = JSON.parse(event.data);
// Legacy bridge client removed — the extension reaches the local zapd router
// via the ai.hanzo.zap native host. dispatchMethod() below remains the
// inbound browser-command dispatcher.
// Handle extension-request from bridge server (monitor/audit calls)
if (message.type === 'extension-request' && message.action) {
chrome.runtime.sendMessage(
{ action: message.action, ...(message.params || {}) },
(result) => {
this.wsServer?.send(JSON.stringify({
type: 'extension-response',
id: message.id,
result: result || { error: chrome.runtime.lastError?.message },
}));
}
);
return;
}
const response = await this.handleBridgeMessage(message);
this.wsServer?.send(JSON.stringify(response));
} catch (error: any) {
this.wsServer?.send(JSON.stringify({
id: 0,
error: { code: -32603, message: error.message }
}));
}
};
this.wsServer.onerror = (error) => {
console.error('[CDP] Bridge connection error:', error);
};
this.wsServer.onclose = () => {
debugLog('[CDP] Bridge disconnected, reconnecting in 5s...');
setTimeout(() => this.connectToBridge(url), 5000);
};
} catch (error) {
console.error('[CDP] Failed to connect to bridge:', error);
setTimeout(() => this.connectToBridge(url), 5000);
/**
* Public entry point for ZAP-server-initiated requests.
*
* Wraps {@link handleBridgeMessage} so background scripts can hand off
* ``method`` + ``params`` from a ZAP MSG_REQUEST without synthesising a
* fake numeric ``id``. Throws on bridge error so the caller can convert
* to a MSG_RESPONSE error frame.
*/
async dispatchMethod(method: string, params: any): Promise<any> {
const response = await this.handleBridgeMessage({ id: 0, method, params: params || {} });
if (response.error) {
const err: any = new Error(response.error.message);
err.code = response.error.code;
throw err;
}
return response.result;
}
private async handleBridgeMessage(message: any): Promise<CDPResponse> {
const { id, method, params } = message;
@@ -375,7 +347,7 @@ export class CDPBridge {
// getTargets, plain numeric string. Without explicit targeting fall
// back to the active tab in the focused window — which is fragile when
// the user has many windows open. Always prefer explicit ids.
const tabId = this.parseTabId(params?.tabId ?? params?.targetId)
const tabId = parseTabId(params?.tabId ?? params?.targetId)
?? await this.getActiveTabId();
switch (method) {
@@ -418,11 +390,23 @@ export class CDPBridge {
break;
case 'Runtime.evaluate':
// Must enable Runtime domain and set returnByValue for actual values
case 'evaluate':
// Must enable Runtime domain and set returnByValue for actual
// values. `awaitPromise: true` makes CDP wait for async IIFEs
// to resolve so callers passing `(async () => {...})()` get
// the resolved value instead of an empty {} (the previous bug
// — caller saw a serialized Promise placeholder).
// pickEvaluable + wrapEvaluable: accept every code-param alias
// and let statement bodies (`const b=…; b.click()`) evaluate
// instead of tripping SpiderMonkey/V8 expression parsing — the
// same rule background-firefox.ts applies, so both browsers
// accept identical caller JS.
await this.send(tabId, 'Runtime.enable');
result = await this.send(tabId, method, {
result = await this.send(tabId, 'Runtime.evaluate', {
...params,
expression: wrapEvaluable(pickEvaluable(params)),
returnByValue: true,
awaitPromise: true,
});
break;
+26 -62
View File
@@ -8,29 +8,13 @@ const { execSync } = require('child_process');
async function build() {
console.log('Building browser extension...');
const hanzoUiRoot = path.resolve(__dirname, '../../../../ui');
const hanzoUiPrimitives = path.join(hanzoUiRoot, 'pkg/ui/dist/primitives/index-common.js');
const localReact = path.join(hanzoUiRoot, 'node_modules/react');
const localReactDom = path.join(hanzoUiRoot, 'node_modules/react-dom');
const forceLocalUiShim = process.env.HANZO_FORCE_LOCAL_UI_SHIM === '1';
const canUseSharedUi =
!forceLocalUiShim &&
fs.existsSync(hanzoUiPrimitives) &&
fs.existsSync(localReact) &&
fs.existsSync(localReactDom);
if (canUseSharedUi) {
console.log('Using shared @hanzo/ui primitives from sibling repo:', hanzoUiPrimitives);
} else {
console.warn(
[
'Shared @hanzo/ui dependencies were not found. Falling back to local primitives shim.',
`Expected: ${hanzoUiPrimitives}`,
`Expected: ${localReact}`,
`Expected: ${localReactDom}`,
].join('\n')
);
}
// The extension does NOT depend on `@hanzo/ui`. Primitives are served by
// a small local shim aliased as `@hanzo/gui` so call-sites read like the
// canonical hanzo design system. The full tamagui-based @hanzo/gui will
// land in a feature branch — when that ships, just point the alias below
// at the published `@hanzo/gui` package and remove gui-primitives.tsx.
const hanzoGuiShim = path.join(__dirname, 'gui-primitives.tsx');
console.log('Using @hanzo/gui local primitives shim:', hanzoGuiShim);
// Ensure dist directories exist
fs.mkdirSync('dist/browser-extension', { recursive: true });
@@ -80,16 +64,11 @@ async function build() {
format: 'esm'
});
// Build sidebar + popup UI scripts (TypeScript-first, with JS fallback)
const sidebarAliases = canUseSharedUi
? {
'@hanzo/ui/primitives-common': hanzoUiPrimitives,
react: localReact,
'react-dom': localReactDom,
}
: {
'@hanzo/ui/primitives-common': path.join(__dirname, 'primitives-common-fallback.tsx'),
};
// Build sidebar + popup UI scripts (TypeScript-first, with JS fallback).
// Primitives are aliased to the local @hanzo/gui shim (see gui-primitives.tsx).
const sidebarAliases = {
'@hanzo/gui': hanzoGuiShim,
};
await esbuild.build({
entryPoints: [fs.existsSync('src/sidebar.ts') ? 'src/sidebar.ts' : 'src/sidebar.js'],
@@ -130,16 +109,6 @@ async function build() {
packages: 'external'
});
// Build CDP Bridge Server (WebSocket + HTTP API for hanzo-mcp integration)
await esbuild.build({
entryPoints: ['src/cdp-bridge-server.ts'],
bundle: true,
outfile: 'dist/browser-extension/cdp-bridge-server.js',
platform: 'node',
target: 'node16',
packages: 'external'
});
// Make CLI executable
if (process.platform !== 'win32') {
execSync('chmod +x dist/browser-extension/cli.js');
@@ -152,23 +121,18 @@ async function build() {
// There is a tsconfig in src.
// execSync('cd src && npx tsc --noEmit', { stdio: 'inherit' });
// Copy manifests for different browsers
fs.copyFileSync(
'src/manifest.json',
'dist/browser-extension/manifest.json'
);
// Chrome version
fs.copyFileSync(
'src/manifest.json',
'dist/browser-extension/chrome/manifest.json'
);
// Firefox version
fs.copyFileSync(
'src/manifest-firefox.json',
'dist/browser-extension/firefox/manifest.json'
);
// Copy manifests for different browsers. package.json is the SINGLE source of
// truth for the version — stamp it into every manifest so the three can never
// drift apart (the "must agree" rule, enforced by the build instead of by hand).
const pkgVersion = JSON.parse(fs.readFileSync('package.json', 'utf8')).version;
const writeManifest = (srcPath, destPath) => {
const m = JSON.parse(fs.readFileSync(srcPath, 'utf8'));
m.version = pkgVersion;
fs.writeFileSync(destPath, JSON.stringify(m, null, 2));
};
writeManifest('src/manifest.json', 'dist/browser-extension/manifest.json');
writeManifest('src/manifest.json', 'dist/browser-extension/chrome/manifest.json');
writeManifest('src/manifest-firefox.json', 'dist/browser-extension/firefox/manifest.json');
// Safari needs special handling — write Info.plist for Xcode project
fs.writeFileSync('dist/browser-extension/safari/Info.plist', `<?xml version="1.0" encoding="UTF-8"?>
@@ -180,9 +144,9 @@ async function build() {
<key>CFBundleIdentifier</key>
<string>ai.hanzo.browser-extension</string>
<key>CFBundleVersion</key>
<string>1.0.0</string>
<string>${pkgVersion}</string>
<key>CFBundleShortVersionString</key>
<string>1.0.0</string>
<string>${pkgVersion}</string>
<key>NSExtension</key>
<dict>
<key>NSExtensionPointIdentifier</key>
-881
View File
@@ -1,881 +0,0 @@
#!/usr/bin/env node
/**
* CDP Bridge Server - Unified browser control interface
*
* Provides hanzo.browser(action, params) interface matching hanzo-mcp pattern.
* Browser extension connects to this server for remote control.
*/
import { WebSocketServer, WebSocket } from 'ws';
import * as readline from 'readline';
import * as fs from 'fs';
import * as path from 'path';
interface CDPClient {
ws: WebSocket;
id: string; // Unique ID: "chrome", "firefox", "chrome-2", etc.
capabilities: string[];
browser: string; // Browser type: "chrome", "firefox", "safari", "edge"
tabId?: number;
}
interface BrowserCommand {
action: string;
// Navigation
url?: string;
// Selectors
selector?: string;
ref?: string;
element?: string;
// Input
text?: string;
value?: string;
key?: string;
// Screenshot
fullPage?: boolean;
format?: 'png' | 'jpeg';
filename?: string;
// Evaluate
code?: string;
function?: string;
expression?: string;
// Tabs
tabId?: number;
tabIndex?: number;
// Wait
timeout?: number;
state?: string;
// Browser targeting (multi-browser support)
browser?: string; // Target browser: "chrome", "firefox", "safari", or unique ID
// Generic
[key: string]: any;
}
class CDPBridgeServer {
private wss: WebSocketServer;
private clients: Map<WebSocket, CDPClient> = new Map();
private pendingCommands: Map<number, { resolve: Function; reject: Function }> = new Map();
private pendingExtensionRequests: Map<string, { resolve: Function; timeout: ReturnType<typeof setTimeout> }> = new Map();
private commandId = 0;
private httpPort: number;
constructor(port: number = 9223) {
this.httpPort = port;
this.wss = new WebSocketServer({ port, path: '/cdp' });
this.wss.on('connection', (ws) => {
console.log('[hanzo.browser] Extension connected');
ws.on('message', (data) => {
try {
const message = JSON.parse(data.toString());
this.handleMessage(ws, message);
} catch (error: any) {
console.error('[hanzo.browser] Error:', error.message);
}
});
ws.on('close', () => {
console.log('[hanzo.browser] Extension disconnected');
this.clients.delete(ws);
});
ws.on('error', (error) => {
console.error('[hanzo.browser] WebSocket error:', error);
});
});
console.log(`[hanzo.browser] Server listening on ws://localhost:${port}/cdp`);
}
/** Assign a unique ID for a browser type (e.g. "chrome", "chrome-2", "firefox") */
private assignClientId(browser: string): string {
const existing = Array.from(this.clients.values())
.filter((c) => c.browser === browser);
if (existing.length === 0) return browser;
// Find next available suffix
for (let i = 2; ; i++) {
const candidate = `${browser}-${i}`;
if (!existing.some((c) => c.id === candidate)) return candidate;
}
}
/** Find a client by browser name/ID, or return first connected client */
private resolveClient(target?: string): CDPClient | undefined {
if (!target) {
// Default: first connected client
return this.clients.values().next().value;
}
// Exact match on ID first (e.g. "chrome-2")
for (const client of this.clients.values()) {
if (client.id === target) return client;
}
// Match by browser type (returns first of that type, e.g. "firefox")
for (const client of this.clients.values()) {
if (client.browser === target) return client;
}
return undefined;
}
private handleMessage(ws: WebSocket, message: any) {
if (message.type === 'register') {
const browser = message.browser || 'unknown';
const id = this.assignClientId(browser);
this.clients.set(ws, {
ws,
id,
capabilities: message.capabilities || [],
browser,
});
console.log(`[hanzo.browser] Registered ${id} (${browser}):`, message.capabilities?.join(', '));
// Notify the extension of its assigned ID
ws.send(JSON.stringify({ type: 'registered', id, browser }));
return;
}
if (message.type === 'config') {
this.saveConfig(message.key, message.value);
return;
}
// Handle responses from extension for sendToExtension() calls
if (message.type === 'extension-response' && message.id) {
const pending = this.pendingExtensionRequests.get(message.id);
if (pending) {
clearTimeout(pending.timeout);
this.pendingExtensionRequests.delete(message.id);
pending.resolve(message.result || message);
}
return;
}
if (message.id !== undefined) {
const pending = this.pendingCommands.get(message.id);
if (pending) {
if (message.error) {
pending.reject(new Error(message.error.message));
} else {
pending.resolve(message.result);
}
this.pendingCommands.delete(message.id);
}
return;
}
if (message.type === 'event') {
console.log(`[hanzo.browser] Event: ${message.method}`);
}
}
/** Save a config value to ~/.hanzo/extension/config.json */
private saveConfig(key: string, value: unknown): void {
const configDir = path.join(
process.env.HOME || process.env.USERPROFILE || '.',
'.hanzo',
'extension',
);
const configPath = path.join(configDir, 'config.json');
try {
fs.mkdirSync(configDir, { recursive: true });
let config: Record<string, unknown> = {};
if (fs.existsSync(configPath)) {
config = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
}
config[key] = value;
fs.writeFileSync(configPath, JSON.stringify(config, null, 2) + '\n');
console.log(`[hanzo.browser] Config saved: ${key} = ${JSON.stringify(value)}`);
} catch (error: any) {
console.error(`[hanzo.browser] Failed to save config: ${error.message}`);
}
}
/** Get connected browser names */
getConnectedBrowsers(): string[] {
return Array.from(this.clients.values())
.map((c) => c.id || c.browser || 'unknown')
.filter((b) => b !== 'unknown');
}
/** Get detailed info about all connected browsers */
getConnectedBrowserDetails(): Array<{ id: string; browser: string; capabilities: string[] }> {
return Array.from(this.clients.values()).map((c) => ({
id: c.id,
browser: c.browser,
capabilities: c.capabilities,
}));
}
private async sendRaw(method: string, params?: any, targetBrowser?: string): Promise<any> {
const client = this.resolveClient(targetBrowser);
if (!client) {
const connected = this.getConnectedBrowsers();
throw new Error(
targetBrowser
? `Browser "${targetBrowser}" not connected. Connected: [${connected.join(', ')}]`
: 'No browser extension connected'
);
}
const id = ++this.commandId;
return new Promise((resolve, reject) => {
this.pendingCommands.set(id, { resolve, reject });
client.ws.send(JSON.stringify({ id, method, params }));
setTimeout(() => {
if (this.pendingCommands.has(id)) {
this.pendingCommands.delete(id);
reject(new Error(`Command timeout (browser: ${client.id})`));
}
}, 30000);
});
}
/**
* Unified hanzo.browser interface - matches mcp__hanzo__browser pattern
*/
async browser(params: BrowserCommand): Promise<any> {
const { action, browser: targetBrowser, ...rest } = params;
// Special action: list connected browsers
if (action === 'list_browsers' || action === 'browsers') {
return {
browsers: this.getConnectedBrowserDetails(),
count: this.clients.size,
};
}
// Shorthand: b = targetBrowser for all sendRaw/sendToExtension calls below
const b = targetBrowser;
// Universal tab targeting: a `tabId` or `tabIndex` on the incoming command
// applies to every downstream sendRaw call so the user can pin a specific
// tab without repeating it. Without this, every action falls back to
// `chrome.tabs.query({active:true,currentWindow:true})[0]` — which is
// fragile when the user has many windows / a chrome://newtab in focus.
const tabTarget = (extra?: Record<string, unknown>) => {
const out: Record<string, unknown> = { ...(extra || {}) };
if (rest.tabId !== undefined) out.tabId = rest.tabId;
if (rest.tabIndex !== undefined) out.tabIndex = rest.tabIndex;
if (rest.targetId !== undefined && out.tabId === undefined) out.tabId = rest.targetId;
return out;
};
switch (action) {
// Navigation
case 'navigate':
return this.sendRaw('Page.navigate', tabTarget({ url: rest.url }), b);
case 'navigate_back':
case 'go_back':
return this.sendRaw('Page.goBack', tabTarget(), b);
case 'navigate_forward':
case 'go_forward':
return this.sendRaw('Page.goForward', tabTarget(), b);
case 'reload':
return this.sendRaw('Page.reload', tabTarget(), b);
case 'url':
return this.sendRaw('hanzo.url', tabTarget(), b);
case 'title':
return this.sendRaw('hanzo.title', tabTarget(), b);
case 'tab_info':
return this.sendRaw('hanzo.tabInfo', tabTarget(), b);
case 'content':
return this.sendRaw('Runtime.evaluate', tabTarget({
expression: 'document.documentElement.outerHTML',
returnByValue: true
}), b);
// Screenshots
case 'screenshot': {
const screenshot = await this.sendRaw('hanzo.screenshot', tabTarget({
format: rest.format || 'png',
fullPage: rest.fullPage,
}), b);
if (rest.filename && screenshot?.data) {
const buffer = Buffer.from(screenshot.data, 'base64');
fs.writeFileSync(rest.filename, buffer);
return { saved: rest.filename, bytes: buffer.length, data: screenshot.data };
}
return screenshot;
}
case 'snapshot':
// Accessibility snapshot
return this.sendRaw('Accessibility.getFullAXTree', tabTarget(), b);
// Input - Click
case 'click':
return this.sendRaw('hanzo.click', tabTarget({
selector: rest.selector || rest.ref,
}), b);
case 'dblclick':
case 'double_click':
return this.sendRaw('hanzo.dblclick', tabTarget({
selector: rest.selector || rest.ref,
}), b);
case 'hover':
return this.sendRaw('hanzo.hover', tabTarget({
selector: rest.selector || rest.ref,
}), b);
// Input - Type
case 'type':
return this.sendRaw('hanzo.type', tabTarget({
selector: rest.selector || rest.ref,
text: rest.text,
}), b);
case 'fill':
return this.sendRaw('hanzo.fill', tabTarget({
selector: rest.selector || rest.ref,
value: rest.value || rest.text,
}), b);
case 'clear':
return this.sendRaw('hanzo.clear', tabTarget({
selector: rest.selector || rest.ref,
}), b);
case 'press_key':
case 'press':
return this.sendRaw('Input.dispatchKeyEvent', tabTarget({
type: 'keyDown',
key: rest.key,
}), b);
// Forms
case 'select_option':
return this.sendRaw('hanzo.select', tabTarget({
selector: rest.selector || rest.ref,
value: rest.value,
}), b);
case 'check':
return this.sendRaw('hanzo.check', tabTarget({
selector: rest.selector || rest.ref,
}), b);
case 'uncheck':
return this.sendRaw('hanzo.uncheck', tabTarget({
selector: rest.selector || rest.ref,
}), b);
// Navigation
case 'go_back':
case 'navigate_back':
return this.sendRaw('Page.goBack', tabTarget(), b);
case 'go_forward':
case 'navigate_forward':
return this.sendRaw('Page.goForward', tabTarget(), b);
case 'get_url':
return this.sendRaw('hanzo.url', tabTarget(), b);
case 'get_title':
return this.sendRaw('hanzo.title', tabTarget(), b);
case 'get_tab_info':
case 'tab_info':
return this.sendRaw('hanzo.tabInfo', tabTarget(), b);
case 'get_history':
case 'history':
return this.sendRaw('hanzo.getHistory', tabTarget({ maxResults: rest.maxResults }), b);
case 'wait_for_navigation':
return this.sendRaw('hanzo.waitForNavigation', tabTarget({ timeout: rest.timeout }), b);
case 'create_tab':
return this.sendRaw('Target.createTarget', { url: rest.url || 'about:blank' }, b);
case 'close_tab':
return this.sendRaw('Target.closeTarget', { targetId: rest.tabId }, b);
// Fetch through browser context (uses page cookies/auth)
case 'fetch': {
const fetchResult = await this.sendRaw('hanzo.fetch', {
url: rest.url,
options: {
method: rest.method || rest.options?.method,
headers: rest.headers || rest.options?.headers,
body: rest.body || rest.options?.body,
mode: rest.mode || rest.options?.mode,
credentials: rest.credentials || rest.options?.credentials,
},
}, b);
return fetchResult;
}
// Selectors / Waiting
case 'wait_for_selector':
return this.sendRaw('hanzo.waitForSelector', { selector: rest.selector, timeout: rest.timeout }, b);
case 'query_selector_all':
return this.sendRaw('hanzo.querySelectorAll', { selector: rest.selector }, b);
case 'get_element_info':
return this.sendRaw('hanzo.getElementInfo', { selector: rest.selector }, b);
case 'get_page_info':
return this.sendRaw('hanzo.getPageInfo', {}, b);
// DOM Read/Write/Observe
case 'get_html':
return this.sendRaw('hanzo.getHTML', { selector: rest.selector, outer: rest.outer }, b);
case 'set_html':
return this.sendRaw('hanzo.setHTML', { selector: rest.selector, html: rest.html, outer: rest.outer }, b);
case 'get_text':
return this.sendRaw('hanzo.getText', { selector: rest.selector }, b);
case 'set_text':
return this.sendRaw('hanzo.setText', { selector: rest.selector, text: rest.text }, b);
case 'get_attribute':
return this.sendRaw('hanzo.getAttribute', { selector: rest.selector, attr: rest.attr }, b);
case 'set_attribute':
return this.sendRaw('hanzo.setAttribute', { selector: rest.selector, attr: rest.attr, value: rest.value }, b);
case 'remove_attribute':
return this.sendRaw('hanzo.removeAttribute', { selector: rest.selector, attr: rest.attr }, b);
case 'set_style':
return this.sendRaw('hanzo.setStyle', { selector: rest.selector, styles: rest.styles }, b);
case 'add_class':
return this.sendRaw('hanzo.addClass', { selector: rest.selector, classNames: rest.classNames }, b);
case 'remove_class':
return this.sendRaw('hanzo.removeClass', { selector: rest.selector, classNames: rest.classNames }, b);
case 'insert_element':
return this.sendRaw('hanzo.insertElement', { selector: rest.selector, html: rest.html, position: rest.position }, b);
case 'remove_element':
return this.sendRaw('hanzo.removeElement', { selector: rest.selector }, b);
case 'observe_mutations':
return this.sendRaw('hanzo.observeMutations', { selector: rest.selector, options: rest.options, duration: rest.duration }, b);
case 'computed_styles':
return this.sendRaw('hanzo.getComputedStyles', { selector: rest.selector, properties: rest.properties }, b);
case 'bounding_rects':
return this.sendRaw('hanzo.getBoundingRects', { selector: rest.selector }, b);
case 'inject_script':
return this.sendRaw('hanzo.injectScript', { url: rest.url }, b);
case 'inject_css':
return this.sendRaw('hanzo.injectCSS', { css: rest.css }, b);
case 'local_storage':
if (rest.value !== undefined) {
return this.sendRaw('hanzo.setLocalStorage', tabTarget({ key: rest.key, value: rest.value }), b);
}
return this.sendRaw('hanzo.getLocalStorage', tabTarget({ key: rest.key }), b);
case 'cookies':
return this.sendRaw('hanzo.getCookies', tabTarget(), b);
// Evaluate
case 'evaluate': {
const evalResult = await this.sendRaw('Runtime.evaluate', tabTarget({
expression: rest.code || rest.function || rest.expression,
returnByValue: true,
}), b);
// Unwrap CDP result envelope. Handle every shape extensions can hand
// back: {result:{value}} (Chrome), {result:{value,type}} (CDP), bare
// value, undefined-as-empty-object ({}).
const inner = evalResult?.result;
if (inner && typeof inner === 'object' && 'value' in inner) {
return { result: (inner as any).value, raw: evalResult };
}
return { result: inner ?? evalResult };
}
// Wait
case 'wait':
await new Promise(resolve => setTimeout(resolve, (rest.timeout || 1) * 1000));
return { waited: rest.timeout || 1 };
case 'wait_for_load':
return this.sendRaw('Page.waitForLoadState', tabTarget({
state: rest.state || 'load',
}), b);
// Tabs
case 'tabs':
case 'list_tabs':
return this.sendRaw('Target.getTargets', undefined, b);
case 'new_tab':
return this.sendRaw('Target.createTarget', { url: rest.url || 'about:blank' }, b);
case 'close_tab':
return this.sendRaw('Target.closeTarget', { targetId: rest.tabId }, b);
case 'select_tab':
return this.sendRaw('Target.activateTarget', { targetId: rest.tabId }, b);
// Console/Network — routed to PageMonitor via extension background
case 'console_messages':
case 'console':
case 'console_logs':
return this.sendToExtension('monitor.consoleLogs', { tabId: rest.tabId }, b);
case 'console_errors':
return this.sendToExtension('monitor.consoleErrors', { tabId: rest.tabId }, b);
case 'network_requests':
case 'network_logs':
return this.sendToExtension('monitor.networkLogs', { tabId: rest.tabId }, b);
case 'network_errors':
return this.sendToExtension('monitor.networkErrors', { tabId: rest.tabId }, b);
case 'network_success':
return this.sendToExtension('monitor.networkSuccess', { tabId: rest.tabId }, b);
case 'wipe_logs':
return this.sendToExtension('monitor.wipeLogs', { tabId: rest.tabId }, b);
case 'start_monitoring':
return this.sendToExtension('monitor.start', { tabId: rest.tabId }, b);
case 'stop_monitoring':
return this.sendToExtension('monitor.stop', { tabId: rest.tabId }, b);
case 'monitor_status':
return this.sendToExtension('monitor.status', {}, b);
// Audits — routed to AuditRunner via extension background
case 'accessibility_audit':
return this.sendToExtension('audit.accessibility', { tabId: rest.tabId }, b);
case 'performance_audit':
return this.sendToExtension('audit.performance', { tabId: rest.tabId }, b);
case 'seo_audit':
return this.sendToExtension('audit.seo', { tabId: rest.tabId }, b);
case 'best_practices_audit':
return this.sendToExtension('audit.bestPractices', { tabId: rest.tabId }, b);
case 'full_audit':
return this.sendToExtension('audit.full', { tabId: rest.tabId }, b);
case 'nextjs_audit':
return this.sendToExtension('audit.nextjs', { tabId: rest.tabId }, b);
case 'debugger_mode':
case 'debug':
return this.sendToExtension('debugger.mode', { tabId: rest.tabId }, b);
// Ollama / Local LLM
case 'ollama_models':
return this.sendToExtension('ollama.models', {}, b);
case 'ollama_chat':
return this.sendToExtension('ollama.chat', { model: rest.model, messages: rest.messages, options: rest.options }, b);
case 'local_chat':
return this.sendToExtension('local.chat', { provider: rest.provider, endpoint: rest.endpoint, model: rest.model, messages: rest.messages, options: rest.options, apiKey: rest.apiKey }, b);
case 'local_models':
return this.sendToExtension('local.models', { provider: rest.provider, endpoint: rest.endpoint, apiKey: rest.apiKey }, b);
case 'local_discover':
return this.sendToExtension('local.discover', {}, b);
// Model Hub — browse, search, download
case 'hub_search':
return this.sendToExtension('hub.search', { query: rest.query, filter: rest.filter, sort: rest.sort, limit: rest.limit, author: rest.author }, b);
case 'hub_search_gguf':
return this.sendToExtension('hub.searchGGUF', { query: rest.query, limit: rest.limit }, b);
case 'hub_model':
return this.sendToExtension('hub.model', { modelId: rest.modelId }, b);
case 'hub_recommended':
return this.sendToExtension('hub.recommended', {}, b);
case 'hub_search_ollama':
return this.sendToExtension('hub.searchOllama', { query: rest.query }, b);
case 'hub_download_ollama':
return this.sendToExtension('hub.downloadOllama', { modelName: rest.modelName, ollamaUrl: rest.ollamaUrl }, b);
case 'hub_download_hf':
return this.sendToExtension('hub.downloadHF', { downloadUrl: rest.downloadUrl }, b);
case 'hub_all_models':
return this.sendToExtension('hub.allModels', {}, b);
case 'hub_search_mlx':
return this.sendToExtension('hub.searchMLX', { query: rest.query, limit: rest.limit }, b);
case 'hub_model_card':
return this.sendToExtension('hub.modelCard', { modelId: rest.modelId }, b);
case 'hub_model_stats':
return this.sendToExtension('hub.modelStats', { modelId: rest.modelId }, b);
// Status
case 'status':
return {
connected: this.clients.size > 0,
clients: this.clients.size,
browsers: this.getConnectedBrowserDetails(),
port: this.httpPort
};
default:
// Pass through as raw CDP command
return this.sendRaw(action, rest, b);
}
}
// Send a message to the extension background and wait for response
private sendToExtension(action: string, params: any, targetBrowser?: string): Promise<any> {
return new Promise((resolve) => {
const client = this.resolveClient(targetBrowser);
if (!client) {
const connected = this.getConnectedBrowsers();
resolve({
error: targetBrowser
? `Browser "${targetBrowser}" not connected. Connected: [${connected.join(', ')}]`
: 'No browser extension connected'
});
return;
}
const id = `ext-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`;
const timeout = setTimeout(() => {
this.pendingExtensionRequests.delete(id);
resolve({ error: `Extension request timed out (browser: ${client.id})` });
}, 30000);
this.pendingExtensionRequests.set(id, { resolve, timeout });
client.ws.send(JSON.stringify({
type: 'extension-request',
id,
action,
params,
}));
});
}
isConnected(): boolean {
return this.clients.size > 0;
}
close() {
this.wss.close();
}
}
// JSON-RPC server for MCP integration
async function startJSONRPCServer(bridgeServer: CDPBridgeServer) {
const http = require('http');
const httpServer = http.createServer(async (req: any, res: any) => {
if (req.method === 'POST') {
let body = '';
req.on('data', (chunk: string) => body += chunk);
req.on('end', async () => {
try {
const request = JSON.parse(body);
const result = await bridgeServer.browser(request.params || request);
res.writeHead(200, { 'Content-Type': 'application/json' });
// Return result directly (no extra wrapping — Python expects flat dict)
res.end(JSON.stringify(result && typeof result === 'object' ? result : { result }));
} catch (error: any) {
res.writeHead(500, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: error.message }));
}
});
} else {
// GET - return status
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({
service: 'hanzo.browser',
connected: bridgeServer.isConnected(),
browsers: bridgeServer.getConnectedBrowserDetails(),
actions: [
'list_browsers', 'browsers',
'navigate', 'navigate_back', 'reload', 'url', 'title', 'content',
'screenshot', 'snapshot',
'click', 'dblclick', 'hover', 'type', 'fill', 'clear', 'press_key',
'select_option', 'check', 'uncheck',
'evaluate',
'go_back', 'go_forward', 'get_url', 'get_title', 'get_tab_info',
'wait_for_navigation', 'get_history', 'create_tab', 'close_tab',
'fetch',
'get_html', 'set_html', 'get_text', 'set_text',
'get_attribute', 'set_attribute', 'remove_attribute',
'set_style', 'add_class', 'remove_class',
'insert_element', 'remove_element',
'observe_mutations', 'computed_styles', 'bounding_rects',
'inject_script', 'inject_css',
'local_storage', 'cookies',
'wait', 'wait_for_load',
'tabs', 'new_tab', 'close_tab', 'select_tab',
'console_logs', 'console_errors', 'network_logs', 'network_errors', 'network_success',
'start_monitoring', 'stop_monitoring', 'monitor_status', 'wipe_logs',
'accessibility_audit', 'performance_audit', 'seo_audit', 'best_practices_audit', 'full_audit',
'nextjs_audit', 'debugger_mode',
'ollama_models', 'ollama_chat', 'local_chat', 'local_models', 'local_discover',
'hub_search', 'hub_search_gguf', 'hub_search_mlx', 'hub_model', 'hub_model_card',
'hub_model_stats', 'hub_recommended',
'hub_search_ollama', 'hub_download_ollama', 'hub_download_hf', 'hub_all_models',
'takeover.start', 'takeover.end', 'takeover.cursor',
'status'
]
}));
}
});
httpServer.listen(9224, () => {
console.log('[hanzo.browser] HTTP API at http://localhost:9224');
});
}
// Interactive CLI
async function main() {
const port = parseInt(process.env.CDP_PORT || '9223');
const server = new CDPBridgeServer(port);
// Start HTTP API
startJSONRPCServer(server);
console.log('\n[hanzo.browser] Commands:');
console.log(' navigate <url> - Go to URL');
console.log(' screenshot [file] - Capture screen');
console.log(' click <selector> - Click element');
console.log(' type <sel> <text> - Type into element');
console.log(' eval <js> - Run JavaScript');
console.log(' tabs - List all tabs');
console.log(' status - Connection status');
console.log(' quit - Exit\n');
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout
});
const prompt = () => {
rl.question('hanzo.browser> ', async (line) => {
const parts = line.trim().split(' ');
const action = parts[0];
const args = parts.slice(1);
if (!action) {
prompt();
return;
}
try {
let result;
switch (action) {
case 'quit':
case 'exit':
server.close();
rl.close();
process.exit(0);
case 'navigate':
result = await server.browser({ action: 'navigate', url: args[0] });
console.log('Navigated to:', args[0]);
break;
case 'screenshot':
result = await server.browser({
action: 'screenshot',
filename: args[0] || `screenshot-${Date.now()}.png`
});
console.log('Screenshot:', result);
break;
case 'click':
result = await server.browser({ action: 'click', selector: args[0] });
console.log('Clicked:', args[0]);
break;
case 'type':
result = await server.browser({
action: 'type',
selector: args[0],
text: args.slice(1).join(' ')
});
console.log('Typed into:', args[0]);
break;
case 'eval':
result = await server.browser({
action: 'evaluate',
expression: args.join(' ')
});
console.log('Result:', result?.result?.value);
break;
case 'tabs':
result = await server.browser({ action: 'tabs' });
console.log('Tabs:', result?.targetInfos?.map((t: any) => t.title).join(', '));
break;
case 'status':
result = await server.browser({ action: 'status' });
console.log('Status:', result);
break;
case 'url':
result = await server.browser({ action: 'url' });
console.log('URL:', result?.result?.value);
break;
case 'title':
result = await server.browser({ action: 'title' });
console.log('Title:', result?.result?.value);
break;
default:
// Try as raw action
result = await server.browser({ action, ...Object.fromEntries(
args.map((a, i) => [i === 0 ? 'selector' : `arg${i}`, a])
)});
console.log('Result:', result);
}
} catch (error: any) {
console.error('Error:', error.message);
}
prompt();
});
};
prompt();
}
export { CDPBridgeServer, BrowserCommand };
if (require.main === module) {
main();
}
+1 -1
View File
@@ -9,7 +9,7 @@ import {
CardHeader,
CardTitle,
Textarea,
} from '@hanzo/ui/primitives-common';
} from '@hanzo/gui';
function LoginPrompt() {
return (
+310 -79
View File
@@ -1,5 +1,11 @@
// Hanzo Browser Extension - Content Script
// Connects clicked elements to source code via source-maps
//
// Polyfill MUST be the first import so any subsequent code that touches
// `browser.*` (the cross-browser API surface) sees the wrapped Promise
// API on Chrome too. On native runtimes (Firefox / Safari) the import is
// a no-op.
import 'webextension-polyfill';
import {
DEFAULT_INSPECT_SHORTCUT,
@@ -109,12 +115,22 @@ class HanzoContentScript {
sendResponse({ success: true });
return true;
case 'page.overlay.toggle':
if (request.side === 'left' || request.side === 'right') pageOverlay.setSide(request.side);
if (request.mode === 'push' || request.mode === 'overlay') pageOverlay.setMode(request.mode);
if (request.width === 'compact' || request.width === 'default' || request.width === 'wide') {
pageOverlay.setWidth(request.width);
}
sendResponse({
success: true,
open: pageOverlay.toggle(),
});
return true;
case 'page.overlay.show':
if (request.side === 'left' || request.side === 'right') pageOverlay.setSide(request.side);
if (request.mode === 'push' || request.mode === 'overlay') pageOverlay.setMode(request.mode);
if (request.width === 'compact' || request.width === 'default' || request.width === 'wide') {
pageOverlay.setWidth(request.width);
}
pageOverlay.show();
sendResponse({ success: true, open: true });
return true;
@@ -123,7 +139,44 @@ class HanzoContentScript {
sendResponse({ success: true, open: false });
return true;
case 'page.overlay.status':
sendResponse({ success: true, open: pageOverlay.isOpen() });
sendResponse({
success: true,
open: pageOverlay.isOpen(),
side: pageOverlay.getSide(),
mode: pageOverlay.getMode(),
});
return true;
case 'page.overlay.setSide':
if (request.side === 'left' || request.side === 'right') {
pageOverlay.setSide(request.side);
sendResponse({ success: true, side: request.side });
} else {
sendResponse({ success: false, error: 'side must be "left" or "right"' });
}
return true;
case 'page.overlay.setMode':
if (request.mode === 'push' || request.mode === 'overlay') {
pageOverlay.setMode(request.mode);
sendResponse({ success: true, mode: request.mode });
} else {
sendResponse({ success: false, error: 'mode must be "push" or "overlay"' });
}
return true;
case 'page.overlay.setWidth':
if (request.width === 'compact' || request.width === 'default' || request.width === 'wide') {
pageOverlay.setWidth(request.width);
sendResponse({ success: true, width: request.width });
} else {
sendResponse({ success: false, error: 'width must be compact|default|wide' });
}
return true;
case 'page.overlay.setEnabled':
if (typeof request.enabled === 'boolean') {
pageOverlay.setEnabled(request.enabled);
sendResponse({ success: true, enabled: request.enabled });
} else {
sendResponse({ success: false, error: 'enabled must be boolean' });
}
return true;
// --- Popup Tool Actions ---
@@ -748,10 +801,22 @@ interface OverlayElementContext {
html: string;
}
type OverlayMode = 'overlay' | 'push';
type OverlayWidth = 'compact' | 'default' | 'wide';
const OVERLAY_WIDTH_PX: Record<OverlayWidth, number> = {
compact: 320,
default: 420,
wide: 560,
};
class HanzoPageOverlay {
private mounted = false;
private enabled = false;
private open = false;
private side: 'left' | 'right' = 'right';
private mode: OverlayMode = 'overlay';
private width: OverlayWidth = 'default';
private sending = false;
private watchMode = false;
private editMode = false;
@@ -778,6 +843,53 @@ class HanzoPageOverlay {
this.injectStyles();
this.mount();
this.bindGlobalEvents();
this.restorePrefs();
}
private restorePrefs(): void {
try {
const ss = (typeof browser !== 'undefined' ? browser : (chrome as any))?.storage?.sync;
if (!ss?.get) return;
const promised = ss.get(['overlaySide', 'overlayMode', 'overlayWidth']);
const apply = (v: any) => {
if (v?.overlaySide === 'left' || v?.overlaySide === 'right') {
this.side = v.overlaySide;
this.root?.setAttribute('data-side', this.side);
}
if (v?.overlayMode === 'push' || v?.overlayMode === 'overlay') {
this.mode = v.overlayMode;
this.root?.setAttribute('data-mode', this.mode);
}
if (v?.overlayWidth === 'compact' || v?.overlayWidth === 'default' || v?.overlayWidth === 'wide') {
this.width = v.overlayWidth;
this.applyWidth();
}
this.applyPushIfOpen();
};
if (promised && typeof promised.then === 'function') {
promised.then(apply).catch(() => {});
} else {
ss.get(['overlaySide', 'overlayMode', 'overlayWidth'], apply);
}
} catch { /* storage may be unavailable */ }
}
private applyWidth(): void {
const px = OVERLAY_WIDTH_PX[this.width] ?? OVERLAY_WIDTH_PX.default;
if (this.root) {
this.root.style.setProperty('--hanzo-overlay-width', `${px}px`);
this.root.style.width = `min(${px}px, 100vw)`;
}
document.documentElement.style.setProperty('--hanzo-overlay-width', `${px}px`);
}
private applyPushIfOpen(): void {
const html = document.documentElement;
// Always strip first so we never stack margins.
html.classList.remove('hanzo-overlay-push', 'hanzo-overlay-push-left', 'hanzo-overlay-push-right');
if (this.open && this.enabled && this.mode === 'push') {
html.classList.add('hanzo-overlay-push', `hanzo-overlay-push-${this.side}`);
}
}
toggle(): boolean {
@@ -789,12 +901,59 @@ class HanzoPageOverlay {
return false;
}
isOpen(): boolean {
return this.open;
}
getSide(): 'left' | 'right' {
return this.side;
}
getMode(): OverlayMode {
return this.mode;
}
setSide(side: 'left' | 'right'): void {
this.side = side;
this.root?.setAttribute('data-side', side);
this.applyPushIfOpen();
this.persist({ overlaySide: side });
}
setMode(mode: OverlayMode): void {
this.mode = mode;
this.root?.setAttribute('data-mode', mode);
this.applyPushIfOpen();
this.persist({ overlayMode: mode });
}
setWidth(width: OverlayWidth): void {
this.width = width;
this.applyWidth();
this.persist({ overlayWidth: width });
}
setEnabled(enabled: boolean): void {
this.enabled = enabled;
this.root?.setAttribute('data-enabled', enabled ? 'true' : 'false');
if (!enabled) this.hide();
this.persist({ overlayEnabled: enabled });
}
private persist(values: Record<string, unknown>): void {
try {
const ss = (typeof browser !== 'undefined' ? browser : (chrome as any))?.storage?.sync;
ss?.set?.(values);
} catch { /* storage may be unavailable */ }
}
show(): void {
this.ensureMounted();
this.enabled = true;
this.open = true;
this.root?.setAttribute('data-enabled', 'true');
this.root?.setAttribute('data-open', 'true');
this.applyPushIfOpen();
this.loadModels();
this.focusInput();
}
@@ -802,16 +961,13 @@ class HanzoPageOverlay {
hide(): void {
this.open = false;
this.root?.setAttribute('data-open', 'false');
this.applyPushIfOpen();
this.updateStatus('');
this.clearHighlight();
this.setWatchMode(false);
this.setEditMode(false);
}
isOpen(): boolean {
return this.open;
}
private ensureMounted(): void {
if (this.mounted) return;
this.mount();
@@ -823,74 +979,68 @@ class HanzoPageOverlay {
const style = document.createElement('style');
style.id = 'hanzo-page-overlay-styles';
style.textContent = `
/* Edge-pinned sidebar panel no FAB, opened from extension popup.
Hanzo brand: monochrome (white primary, neutral surfaces). No blue. */
#hanzo-page-overlay-root {
position: fixed;
right: 20px;
bottom: 20px;
top: 0;
bottom: 0;
right: 0;
width: min(var(--hanzo-overlay-width, 420px), 100vw);
z-index: 2147483645;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
pointer-events: none;
}
#hanzo-page-overlay-root[data-side="left"] {
right: auto;
left: 0;
}
#hanzo-page-overlay-root * {
box-sizing: border-box;
}
#hanzo-page-overlay-root[data-enabled="false"] .hanzo-page-launcher {
opacity: 0;
transform: translateY(16px);
pointer-events: none;
/* Push mode: when the user pins the panel as a dock, html shifts so
page content reflows beside it instead of being covered. Applied to
<html> from setMode('push') + open. */
html.hanzo-overlay-push.hanzo-overlay-push-right {
margin-right: var(--hanzo-overlay-width, 420px) !important;
transition: margin-right 0.18s ease;
}
.hanzo-page-launcher {
width: 54px;
height: 54px;
border-radius: 9999px;
border: 1px solid rgba(255, 255, 255, 0.2);
background: linear-gradient(135deg, rgba(22, 24, 31, 0.96), rgba(7, 8, 12, 0.96));
color: #ffffff;
box-shadow: 0 14px 34px rgba(0, 0, 0, 0.35);
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
pointer-events: auto;
transition: transform 0.2s ease, opacity 0.2s ease;
}
.hanzo-page-launcher:hover {
transform: translateY(-2px);
html.hanzo-overlay-push.hanzo-overlay-push-left {
margin-left: var(--hanzo-overlay-width, 420px) !important;
transition: margin-left 0.18s ease;
}
.hanzo-page-panel {
width: min(420px, calc(100vw - 24px));
max-height: min(620px, calc(100vh - 28px));
border-radius: 16px;
border: 1px solid rgba(255, 255, 255, 0.16);
background: linear-gradient(180deg, rgba(11, 13, 18, 0.97), rgba(7, 8, 12, 0.97));
color: #eef2ff;
width: 100%;
height: 100%;
border-left: 1px solid rgba(255, 255, 255, 0.16);
background: linear-gradient(180deg, rgba(10, 10, 11, 0.97), rgba(7, 7, 8, 0.97));
color: #f5f5f5;
overflow: hidden;
box-shadow: 0 22px 48px rgba(0, 0, 0, 0.45);
transform-origin: bottom right;
transform: translateY(8px) scale(0.98);
box-shadow: -22px 0 48px rgba(0, 0, 0, 0.45);
transform: translateX(100%);
opacity: 0;
pointer-events: none;
display: flex;
flex-direction: column;
margin-bottom: 10px;
min-height: 0;
transition: transform 0.18s ease, opacity 0.18s ease;
}
#hanzo-page-overlay-root[data-open="true"] .hanzo-page-panel {
transform: translateY(0) scale(1);
opacity: 1;
pointer-events: auto;
#hanzo-page-overlay-root[data-side="left"] .hanzo-page-panel {
border-left: none;
border-right: 1px solid rgba(255, 255, 255, 0.16);
box-shadow: 22px 0 48px rgba(0, 0, 0, 0.45);
transform: translateX(-100%);
}
#hanzo-page-overlay-root[data-open="true"] .hanzo-page-launcher {
opacity: 0;
transform: scale(0.9);
pointer-events: none;
#hanzo-page-overlay-root[data-open="true"] .hanzo-page-panel {
transform: translateX(0);
opacity: 1;
pointer-events: auto;
}
.hanzo-page-header {
@@ -898,14 +1048,16 @@ class HanzoPageOverlay {
align-items: center;
justify-content: space-between;
padding: 12px 14px;
border-bottom: 1px solid rgba(255, 255, 255, 0.12);
border-bottom: 1px solid rgba(255, 255, 255, 0.10);
background: linear-gradient(180deg, rgba(255, 255, 255, 0.03), transparent);
flex-shrink: 0;
}
.hanzo-page-brand {
font-weight: 700;
letter-spacing: 0.2px;
font-size: 13px;
color: #f5f5f5;
}
.hanzo-page-header-actions {
@@ -918,16 +1070,22 @@ class HanzoPageOverlay {
border: 1px solid rgba(255, 255, 255, 0.2);
border-radius: 9999px;
padding: 4px 10px;
background: rgba(255, 255, 255, 0.02);
color: #d6deff;
background: transparent;
color: rgba(245, 245, 245, 0.85);
cursor: pointer;
font-size: 11px;
transition: background 0.15s, color 0.15s, border-color 0.15s;
}
.hanzo-chip-btn:hover {
background: rgba(255, 255, 255, 0.06);
color: #fff;
}
.hanzo-chip-btn[data-active="true"] {
border-color: rgba(56, 189, 248, 0.62);
background: rgba(14, 116, 144, 0.3);
color: #e0f2fe;
border-color: rgba(255, 255, 255, 0.7);
background: rgba(255, 255, 255, 0.10);
color: #fff;
}
.hanzo-icon-btn {
@@ -936,9 +1094,28 @@ class HanzoPageOverlay {
border-radius: 8px;
border: 1px solid rgba(255, 255, 255, 0.18);
background: transparent;
color: #e5e7eb;
color: #f5f5f5;
cursor: pointer;
font-size: 16px;
font-size: 14px;
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0;
transition: background 0.15s, border-color 0.15s;
}
.hanzo-icon-btn:hover {
background: rgba(255, 255, 255, 0.06);
}
.hanzo-icon-btn[data-active="true"] {
background: rgba(255, 255, 255, 0.12);
border-color: rgba(255, 255, 255, 0.5);
}
.hanzo-icon-btn svg {
width: 14px;
height: 14px;
}
.hanzo-page-meta {
@@ -947,11 +1124,12 @@ class HanzoPageOverlay {
grid-template-columns: 1fr auto;
gap: 8px;
align-items: center;
flex-shrink: 0;
}
.hanzo-page-context {
font-size: 11px;
color: #a5b4fc;
color: rgba(245, 245, 245, 0.65);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
@@ -959,11 +1137,12 @@ class HanzoPageOverlay {
.hanzo-page-status {
font-size: 11px;
color: #93c5fd;
color: rgba(245, 245, 245, 0.55);
}
.hanzo-model-row {
padding: 0 14px 8px 14px;
flex-shrink: 0;
}
.hanzo-model-row select {
@@ -971,15 +1150,16 @@ class HanzoPageOverlay {
border-radius: 8px;
border: 1px solid rgba(255, 255, 255, 0.16);
background: rgba(255, 255, 255, 0.03);
color: #f1f5f9;
color: #f5f5f5;
padding: 7px 9px;
font-size: 12px;
}
/* The chat scroller: takes ALL remaining vertical space so the
composer below stays sticky against the bottom of the panel. */
.hanzo-page-messages {
flex: 1;
min-height: 200px;
max-height: 370px;
flex: 1 1 auto;
min-height: 0;
overflow-y: auto;
padding: 8px 14px 12px 14px;
display: flex;
@@ -998,23 +1178,27 @@ class HanzoPageOverlay {
.hanzo-page-msg.user {
align-self: flex-end;
max-width: 88%;
background: rgba(59, 130, 246, 0.2);
border: 1px solid rgba(59, 130, 246, 0.4);
background: rgba(255, 255, 255, 0.10);
border: 1px solid rgba(255, 255, 255, 0.18);
}
.hanzo-page-msg.assistant {
align-self: flex-start;
max-width: 92%;
background: rgba(255, 255, 255, 0.05);
border: 1px solid rgba(255, 255, 255, 0.12);
background: rgba(255, 255, 255, 0.04);
border: 1px solid rgba(255, 255, 255, 0.10);
}
/* Composer pinned to bottom of the panel. flex-shrink: 0 prevents
it from collapsing when the messages scroller grows. */
.hanzo-page-composer {
border-top: 1px solid rgba(255, 255, 255, 0.1);
border-top: 1px solid rgba(255, 255, 255, 0.10);
padding: 10px 12px;
display: flex;
gap: 8px;
align-items: flex-end;
background: rgba(0, 0, 0, 0.35);
flex-shrink: 0;
}
.hanzo-page-composer textarea {
@@ -1025,21 +1209,31 @@ class HanzoPageOverlay {
border-radius: 10px;
border: 1px solid rgba(255, 255, 255, 0.17);
background: rgba(255, 255, 255, 0.03);
color: #f8fafc;
color: #f5f5f5;
padding: 9px 11px;
font-size: 12px;
}
/* Primary action: WHITE per @hanzo/brand monochrome guidelines. */
.hanzo-page-composer button {
min-width: 68px;
height: 38px;
border-radius: 10px;
border: 1px solid rgba(37, 99, 235, 0.65);
background: linear-gradient(180deg, rgba(37, 99, 235, 0.95), rgba(30, 64, 175, 0.95));
color: #eff6ff;
border: 1px solid #ffffff;
background: #ffffff;
color: #0a0a0b;
font-weight: 600;
font-size: 12px;
cursor: pointer;
transition: background 0.12s, transform 0.08s;
}
.hanzo-page-composer button:hover:not(:disabled) {
background: #f5f5f5;
}
.hanzo-page-composer button:active:not(:disabled) {
transform: translateY(1px);
}
.hanzo-page-composer button:disabled {
@@ -1051,13 +1245,13 @@ class HanzoPageOverlay {
position: fixed;
z-index: 2147483644;
pointer-events: none;
border: 2px solid rgba(56, 189, 248, 0.95);
border: 2px solid rgba(255, 255, 255, 0.95);
border-radius: 6px;
box-shadow: 0 0 0 2px rgba(14, 116, 144, 0.2);
box-shadow: 0 0 0 2px rgba(255, 255, 255, 0.2);
}
[data-hanzo-inline-editing="true"] {
outline: 2px solid rgba(251, 191, 36, 0.92) !important;
outline: 2px solid rgba(255, 255, 255, 0.92) !important;
outline-offset: 2px !important;
}
`;
@@ -1071,14 +1265,22 @@ class HanzoPageOverlay {
this.root.id = 'hanzo-page-overlay-root';
this.root.setAttribute('data-open', 'false');
this.root.setAttribute('data-enabled', 'false');
this.root.setAttribute('data-side', this.side);
this.root.setAttribute('data-mode', this.mode);
this.applyWidth();
this.root.innerHTML = `
<div class="hanzo-page-panel" role="dialog" aria-label="Hanzo Page Assistant">
<div class="hanzo-page-header">
<div class="hanzo-page-brand">Hanzo Overlay</div>
<div class="hanzo-page-brand">Hanzo</div>
<div class="hanzo-page-header-actions">
<button class="hanzo-chip-btn" data-role="watch" data-active="false" title="Watch elements on hover">Watch</button>
<button class="hanzo-chip-btn" data-role="edit" data-active="false" title="Click any element to edit text">Edit</button>
<button class="hanzo-icon-btn" data-role="close" title="Minimize">×</button>
<button class="hanzo-icon-btn" data-role="pin-mode" title="Toggle: pin (push content) vs overlay (float over page)">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">
<path d="M9 4v16M3 4h12a4 4 0 0 1 4 4v8a4 4 0 0 1-4 4H3z"/>
</svg>
</button>
<button class="hanzo-icon-btn" data-role="close" title="Hide overlay">×</button>
</div>
</div>
<div class="hanzo-page-meta">
@@ -1102,11 +1304,10 @@ class HanzoPageOverlay {
<button data-role="send" type="button">Ask</button>
</div>
</div>
<button class="hanzo-page-launcher" data-role="launcher" type="button" title="Open Hanzo Overlay">AI</button>
`;
this.panel = this.root.querySelector('.hanzo-page-panel');
this.launcher = this.root.querySelector('[data-role="launcher"]');
this.launcher = null;
this.messagesEl = this.root.querySelector('[data-role="messages"]');
this.inputEl = this.root.querySelector('[data-role="input"]');
this.sendBtn = this.root.querySelector('[data-role="send"]');
@@ -1116,8 +1317,14 @@ class HanzoPageOverlay {
this.editBtn = this.root.querySelector('[data-role="edit"]');
this.statusEl = this.root.querySelector('.hanzo-page-status');
this.launcher?.addEventListener('click', () => this.show());
this.root.querySelector('[data-role="close"]')?.addEventListener('click', () => this.hide());
const pinModeBtn = this.root.querySelector('[data-role="pin-mode"]');
pinModeBtn?.setAttribute('data-active', this.mode === 'push' ? 'true' : 'false');
pinModeBtn?.addEventListener('click', () => {
const next: OverlayMode = this.mode === 'push' ? 'overlay' : 'push';
this.setMode(next);
pinModeBtn.setAttribute('data-active', next === 'push' ? 'true' : 'false');
});
this.watchBtn?.addEventListener('click', () => this.setWatchMode(!this.watchMode));
this.editBtn?.addEventListener('click', () => this.setEditMode(!this.editMode));
this.sendBtn?.addEventListener('click', () => this.askPageQuestion());
@@ -1733,3 +1940,27 @@ const inlineConsole = (() => {
// Initialize
new HanzoContentScript();
// =============================================================================
// Background keep-alive port.
//
// Firefox MV3 treats `background.scripts` as event pages; Chrome MV3 service
// workers idle out after ~30s. An open `chrome.runtime` port from any
// content script keeps the background page alive for the lifetime of the
// port — no polling, no alarms required. Every loaded http(s) tab holds
// one of these, so the background ZAP socket survives indefinitely as
// long as the user has any non-privileged tab open.
//
// Heartbeats every 10s prevent Firefox's 4-minute idle-port disconnect.
// =============================================================================
try {
const port = (browser as any)?.runtime?.connect?.({ name: 'zap-keepalive' })
|| (chrome as any)?.runtime?.connect?.({ name: 'zap-keepalive' });
if (port) {
const tick = setInterval(() => {
try { port.postMessage({ type: 'ping', t: Date.now() }); }
catch { clearInterval(tick); }
}, 10_000);
port.onDisconnect?.addListener?.(() => clearInterval(tick));
}
} catch { /* not all pages let content scripts open ports — best effort */ }
+116
View File
@@ -0,0 +1,116 @@
/**
* Local @hanzo/gui primitives shim for the extension.
*
* This is the canonical surface the extension's React components import via
* `@hanzo/gui`. It deliberately does NOT pull in the upstream tamagui-based
* `@hanzo/gui` package because:
*
* 1. content-script + background bundles target the smallest possible
* payload RNW + tamagui's runtime is far too heavy for a script
* injected into every page;
* 2. Hanzo brand is strict monochrome (see ~/work/hanzo/brand/brand.json,
* primaryColor #FFFFFF), so no color tokens travel with primitives
* anyway pure structural elements styled by sibling CSS;
* 3. The full tamagui adoption ships in a future feature branch with the
* compiler + RNW peer dep wired correctly.
*
* The build aliases `@hanzo/gui` this file. When the tamagui adoption
* lands, swap the alias target to the published `@hanzo/gui` package.
*
* Each primitive renders a plain DOM element with a stable, monochrome
* class hook. All visuals come from sidebar.css / popup.css using the
* brand token scale (white luminance ladder, no chromatic accents).
*/
import React from 'react';
type Classy = { className?: string; children?: React.ReactNode };
function cx(base: string, extra?: string): string {
return extra ? `${base} ${extra}` : base;
}
export function Button(props: React.ButtonHTMLAttributes<HTMLButtonElement>) {
const { className, children, ...rest } = props;
return (
<button {...rest} className={cx('hanzo-gui-btn', className)}>
{children}
</button>
);
}
export function Textarea(props: React.TextareaHTMLAttributes<HTMLTextAreaElement>) {
const { className, children, ...rest } = props;
return (
<textarea {...rest} className={cx('hanzo-gui-textarea', className)}>
{children}
</textarea>
);
}
export function Input(props: React.InputHTMLAttributes<HTMLInputElement>) {
const { className, ...rest } = props;
return <input {...rest} className={cx('hanzo-gui-input', className)} />;
}
export function Card({ className, children }: Classy) {
return <div className={cx('hanzo-gui-card', className)}>{children}</div>;
}
export function CardHeader({ className, children }: Classy) {
return <div className={cx('hanzo-gui-card-header', className)}>{children}</div>;
}
export function CardTitle({ className, children }: Classy) {
return <h3 className={cx('hanzo-gui-card-title', className)}>{children}</h3>;
}
export function CardDescription({ className, children }: Classy) {
return <p className={cx('hanzo-gui-card-description', className)}>{children}</p>;
}
export function CardContent({ className, children }: Classy) {
return <div className={cx('hanzo-gui-card-content', className)}>{children}</div>;
}
/* Layout primitives mirror the names used by the upstream Tamagui-based
@hanzo/gui (XStack/YStack) so call-sites can be ported without churn
when the full adoption lands. Pure flexbox under the hood. */
type Stack = Classy & React.HTMLAttributes<HTMLDivElement> & {
gap?: number | string;
};
export function XStack({ className, gap, style, children, ...rest }: Stack) {
const merged: React.CSSProperties = {
display: 'flex',
flexDirection: 'row',
gap,
...style,
};
return (
<div {...rest} style={merged} className={cx('hanzo-gui-xstack', className)}>
{children}
</div>
);
}
export function YStack({ className, gap, style, children, ...rest }: Stack) {
const merged: React.CSSProperties = {
display: 'flex',
flexDirection: 'column',
gap,
...style,
};
return (
<div {...rest} style={merged} className={cx('hanzo-gui-ystack', className)}>
{children}
</div>
);
}
export function Text({ className, children, ...rest }: Classy & React.HTMLAttributes<HTMLSpanElement>) {
return (
<span {...rest} className={cx('hanzo-gui-text', className)}>
{children}
</span>
);
}
+6 -11
View File
@@ -1,7 +1,7 @@
{
"manifest_version": 3,
"name": "Hanzo AI",
"version": "1.8.5",
"version": "1.9.22",
"description": "AI-powered dev assistant — click-to-code navigation, source maps, WebGPU AI, and MCP integration for Claude Code.",
"permissions": [
"activeTab",
@@ -10,7 +10,9 @@
"tabs",
"webNavigation",
"scripting",
"cookies"
"cookies",
"nativeMessaging",
"alarms"
],
"host_permissions": [
"http://localhost/*",
@@ -51,7 +53,8 @@
"default_icon": {
"16": "icon16.png",
"48": "icon48.png"
}
},
"open_at_install": false
},
"web_accessible_resources": [
{
@@ -70,13 +73,5 @@
"strict_min_version": "109.0"
},
"gecko_android": {}
},
"data_collection_permissions": {
"purposes": [
"functionality"
],
"data_categories": [
"technical_data"
]
}
}
+5 -6
View File
@@ -1,18 +1,20 @@
{
"manifest_version": 3,
"name": "Hanzo AI",
"version": "1.8.5",
"version": "1.9.25",
"description": "AI-powered dev assistant — click-to-code navigation, source maps, WebGPU AI, and MCP integration for Claude Code.",
"key": "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAxfXGh0lPUT5m04GKfjUwrLsV6pLaK3VcZuFRPogqAir2tzyLYnQPRtHynue9yvDyguIVnlkwvcwfDOYZrvH76zbw4s6onPBG8HqTKO6LQ9K3kdO1qBBkMMjdOgULQ1MrWThEbpU7NSTiwLYpEta/jAvrKRCAeKIlQE8p6htZmPy9aRUZuae66JgLcAlzD2vviX9sVB1asFABJVswL1RgZ55/8IzZaUrFjzOo9OHK4hmEOtudzkML+5silsAYdC+1BZugph2x94ai17YmZTCL1XyUa5Ke4q80cj+i9rOTgzhZs+mruyhL/AvNVOXilsgqCdNqSz77naWzC3pVGbxOewIDAQAB",
"permissions": [
"activeTab",
"identity",
"sidePanel",
"storage",
"tabs",
"webNavigation",
"scripting",
"cookies",
"debugger"
"debugger",
"alarms",
"nativeMessaging"
],
"host_permissions": [
"http://localhost/*",
@@ -45,9 +47,6 @@
"128": "icon128.png"
}
},
"side_panel": {
"default_path": "sidebar.html"
},
"icons": {
"16": "icon16.png",
"48": "icon48.png",
+5 -4
View File
@@ -1,4 +1,4 @@
/* Hanzo AI Browser Extension Popup — aligned with @hanzo/ui dark theme */
/* Hanzo AI Browser Extension Popup — @hanzo/gui dark theme, monochrome */
:root {
--primary: #fafafa;
@@ -13,9 +13,10 @@
--border-strong: rgba(255,255,255,0.16);
--glass: rgba(255,255,255,0.04);
--glass-strong: rgba(255,255,255,0.07);
--success: #22c55e;
--warning: #eab308;
--error: #ef4444;
/* Status: monochrome by hue per @hanzo/brand (white luminance scale). */
--success: #fafafa;
--warning: #d4d4d4;
--error: rgba(255,255,255,0.55);
--link: #a3a3a3;
--link-hover: #fafafa;
--radius: 10px;
+35 -9
View File
@@ -57,20 +57,43 @@
<div class="divider"></div>
</div>
<!-- Actions -->
<!-- Actions: single Open Chat button + on-page surface settings. -->
<button id="open-panel" class="btn btn-secondary" style="margin-bottom: 8px;">
<svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor">
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/>
</svg>
Open Chat Panel
</button>
<button id="open-page-overlay" class="btn btn-ghost" style="margin-bottom: 12px;">
<svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor">
<path d="M4 4h16v12H8l-4 4V4z"/>
</svg>
Toggle On-Page Overlay
Open Chat
</button>
<!-- On-page chat config -->
<div class="onpage-config" style="display:flex;flex-direction:column;gap:6px;margin-bottom:12px;font-size:11px;">
<div class="feature-toggle" style="display:flex;justify-content:space-between;align-items:center;">
<label style="display:flex;align-items:center;gap:6px;cursor:pointer;">
<input type="checkbox" id="overlay-enabled" checked>
<span>On-page chat panel</span>
</label>
<span style="color:var(--text-muted,#888);font-size:10px;">overlay vs new tab</span>
</div>
<div id="overlay-side-row" class="side-picker" style="display:flex;gap:6px;align-items:center;">
<span style="color:var(--text-muted,#888);">Pin:</span>
<button id="pin-left" class="btn btn-ghost" style="flex:1;padding:4px 8px;font-size:11px;">Left</button>
<button id="pin-right" class="btn btn-ghost" style="flex:1;padding:4px 8px;font-size:11px;" data-active="true">Right</button>
</div>
<div id="overlay-width-row" style="display:flex;gap:6px;align-items:center;">
<span style="color:var(--text-muted,#888);">Width:</span>
<button class="btn btn-ghost width-btn" data-width="compact" style="flex:1;padding:4px 8px;font-size:11px;">Compact</button>
<button class="btn btn-ghost width-btn" data-width="default" style="flex:1;padding:4px 8px;font-size:11px;" data-active="true">Default</button>
<button class="btn btn-ghost width-btn" data-width="wide" style="flex:1;padding:4px 8px;font-size:11px;">Wide</button>
</div>
<span style="color:var(--text-muted,#888);font-size:10px;line-height:1.4;">
FF native sidebar: View → Sidebar → Hanzo AI.<br>
Disable to fall back to a standalone tab — useful on sites with strict CSP.
</span>
</div>
<div class="divider"></div>
<!-- Tools -->
@@ -313,7 +336,10 @@
</div>
</div>
<!-- Footer -->
<!-- Footer: version + nav links -->
<div class="footer-version" style="text-align:center;font-size:10px;color:var(--text-muted,#888);margin-top:14px;letter-spacing:0.04em;">
Hanzo AI <span id="footer-version"></span>
</div>
<div class="footer-links">
<a href="https://docs.hanzo.ai" target="_blank">Docs</a>
<a href="https://console.hanzo.ai" target="_blank">Console</a>
+112 -17
View File
@@ -1,5 +1,10 @@
// Hanzo AI — Popup Script
// Uses background message passing for auth (not direct storage access).
//
// Polyfill the WebExtension API surface so this script can read/write
// chrome.storage with Promise-returning calls on every browser. Chrome
// gets the polyfill wrapper, Firefox/Safari get a no-op.
import 'webextension-polyfill';
import {
DEFAULT_INSPECT_SHORTCUT,
@@ -23,13 +28,42 @@ document.addEventListener('DOMContentLoaded', () => {
const mainSection = document.getElementById('main-section');
const settingsSection = document.getElementById('settings-section');
// Footer version — pulled from manifest at runtime so it always tracks build.
const versionEl = document.getElementById('footer-version');
if (versionEl) {
try {
versionEl.textContent = `v${chrome.runtime.getManifest().version}`;
} catch { /* manifest unavailable in some contexts */ }
}
// Auth
const loginBtn = document.getElementById('login-btn');
const logoutBtn = document.getElementById('logout-btn');
// Open panel
const openPanel = document.getElementById('open-panel');
const openPageOverlay = document.getElementById('open-page-overlay');
const openPageOverlay = document.getElementById('open-page-overlay'); // legacy id, may not exist
const pinLeftBtn = document.getElementById('pin-left');
const pinRightBtn = document.getElementById('pin-right');
const overlayEnabledCheckbox = document.getElementById('overlay-enabled') as HTMLInputElement | null;
const widthBtns = Array.from(document.querySelectorAll('.width-btn')) as HTMLButtonElement[];
const overlaySideRow = document.getElementById('overlay-side-row');
const overlayWidthRow = document.getElementById('overlay-width-row');
type OverlayWidth = 'compact' | 'default' | 'wide';
function syncSettingsUi(enabled: boolean, side: 'left' | 'right', width: OverlayWidth) {
if (overlayEnabledCheckbox) overlayEnabledCheckbox.checked = enabled;
pinLeftBtn?.setAttribute('data-active', side === 'left' ? 'true' : 'false');
pinRightBtn?.setAttribute('data-active', side === 'right' ? 'true' : 'false');
widthBtns.forEach((b) => b.setAttribute('data-active', b.getAttribute('data-width') === width ? 'true' : 'false'));
// Grey out side/width when on-page overlay is disabled.
[overlaySideRow, overlayWidthRow].forEach((row) => {
if (!row) return;
(row as HTMLElement).style.opacity = enabled ? '1' : '0.5';
(row as HTMLElement).style.pointerEvents = enabled ? '' : 'none';
});
}
// Status dots
const zapStatus = document.getElementById('zap-status');
@@ -100,25 +134,86 @@ document.addEventListener('DOMContentLoaded', () => {
});
});
// --- Open side panel ---
// --- Open chat ---
// Default surface: in-page edge-pinned overlay (content-script). Honours
// the user's `overlayEnabled`, `overlaySide`, and `overlayWidth` prefs.
// When `overlayEnabled` is false (or the active tab is privileged), opens
// the chat surface in a standalone tab. Firefox users can also reach it
// via View → Sidebar → Hanzo AI (registered via sidebar_action).
function openSidebarTab() {
chrome.tabs.create({ url: chrome.runtime.getURL('sidebar.html') });
window.close();
}
openPanel?.addEventListener('click', () => {
if (chrome.sidePanel) {
// Chrome / Edge: use sidePanel API
chrome.storage?.sync?.get?.(['overlayEnabled', 'overlaySide', 'overlayWidth'], (vals) => {
const enabled = vals?.overlayEnabled !== false;
const side = vals?.overlaySide === 'left' ? 'left' : 'right';
const width: OverlayWidth = vals?.overlayWidth === 'compact'
? 'compact' : vals?.overlayWidth === 'wide' ? 'wide' : 'default';
if (!enabled) { openSidebarTab(); return; }
chrome.tabs.query({ active: true, currentWindow: true }, (tabs) => {
if (tabs[0]?.id) {
chrome.sidePanel.open({ tabId: tabs[0].id });
window.close();
}
const activeTab = tabs[0];
const target = isInjectableTab(activeTab) ? activeTab : null;
if (!target?.id) { openSidebarTab(); return; }
chrome.runtime.sendMessage({
action: 'page.overlay.show',
tabId: target.id,
side,
width,
}, () => window.close());
});
} else if (typeof browser !== 'undefined' && browser.sidebarAction) {
// Firefox: use sidebarAction API
browser.sidebarAction.open();
window.close();
} else {
// Fallback: try opening sidebar.html as a new tab
chrome.tabs.create({ url: chrome.runtime.getURL('sidebar.html') });
window.close();
}
});
});
function setActiveSide(side: 'left' | 'right') {
chrome.storage?.sync?.set?.({ overlaySide: side });
pinLeftBtn?.setAttribute('data-active', side === 'left' ? 'true' : 'false');
pinRightBtn?.setAttribute('data-active', side === 'right' ? 'true' : 'false');
chrome.tabs.query({ active: true, currentWindow: true }, (tabs) => {
const id = tabs[0]?.id;
if (id) chrome.runtime.sendMessage({ action: 'page.overlay.setSide', tabId: id, side });
});
}
function setActiveWidth(width: OverlayWidth) {
chrome.storage?.sync?.set?.({ overlayWidth: width });
widthBtns.forEach((b) => b.setAttribute('data-active', b.getAttribute('data-width') === width ? 'true' : 'false'));
chrome.tabs.query({ active: true, currentWindow: true }, (tabs) => {
const id = tabs[0]?.id;
if (id) chrome.runtime.sendMessage({ action: 'page.overlay.setWidth', tabId: id, width });
});
}
function setOverlayEnabled(enabled: boolean) {
chrome.storage?.sync?.set?.({ overlayEnabled: enabled });
chrome.tabs.query({ active: true, currentWindow: true }, (tabs) => {
const id = tabs[0]?.id;
if (id) chrome.runtime.sendMessage({ action: 'page.overlay.setEnabled', tabId: id, enabled });
});
chrome.storage?.sync?.get?.(['overlaySide', 'overlayWidth'], (vals) => {
const side = vals?.overlaySide === 'left' ? 'left' : 'right';
const width: OverlayWidth = vals?.overlayWidth === 'compact'
? 'compact' : vals?.overlayWidth === 'wide' ? 'wide' : 'default';
syncSettingsUi(enabled, side, width);
});
}
pinLeftBtn?.addEventListener('click', () => setActiveSide('left'));
pinRightBtn?.addEventListener('click', () => setActiveSide('right'));
widthBtns.forEach((b) => b.addEventListener('click', () => {
const w = b.getAttribute('data-width') as OverlayWidth | null;
if (w === 'compact' || w === 'default' || w === 'wide') setActiveWidth(w);
}));
overlayEnabledCheckbox?.addEventListener('change', (e) => {
setOverlayEnabled((e.target as HTMLInputElement).checked);
});
// Initial UI sync from storage.
chrome.storage?.sync?.get?.(['overlayEnabled', 'overlaySide', 'overlayWidth'], (vals) => {
const enabled = vals?.overlayEnabled !== false;
const side = vals?.overlaySide === 'left' ? 'left' : 'right';
const width: OverlayWidth = vals?.overlayWidth === 'compact'
? 'compact' : vals?.overlayWidth === 'wide' ? 'wide' : 'default';
syncSettingsUi(enabled, side, width);
});
function isInjectableTab(tab: chrome.tabs.Tab | undefined): boolean {
@@ -1,45 +0,0 @@
import React from 'react';
type Classy = { className?: string; children?: React.ReactNode };
function cx(base: string, extra?: string): string {
return extra ? `${base} ${extra}` : base;
}
export function Button(props: React.ButtonHTMLAttributes<HTMLButtonElement>) {
const { className, children, ...rest } = props;
return (
<button {...rest} className={cx('hanzo-ui-btn', className)}>
{children}
</button>
);
}
export function Textarea(props: React.TextareaHTMLAttributes<HTMLTextAreaElement>) {
const { className, children, ...rest } = props;
return (
<textarea {...rest} className={cx('hanzo-ui-textarea', className)}>
{children}
</textarea>
);
}
export function Card({ className, children }: Classy) {
return <div className={cx('hanzo-ui-card', className)}>{children}</div>;
}
export function CardHeader({ className, children }: Classy) {
return <div className={cx('hanzo-ui-card-header', className)}>{children}</div>;
}
export function CardTitle({ className, children }: Classy) {
return <h3 className={cx('hanzo-ui-card-title', className)}>{children}</h3>;
}
export function CardDescription({ className, children }: Classy) {
return <p className={cx('hanzo-ui-card-description', className)}>{children}</p>;
}
export function CardContent({ className, children }: Classy) {
return <div className={cx('hanzo-ui-card-content', className)}>{children}</div>;
}
+67 -62
View File
@@ -187,7 +187,7 @@ export async function login(adapter: BrowserAdapter): Promise<UserInfo> {
const state = generateRandomString(32);
const redirectUri = config.redirectUri;
const authorizeUrl = new URL(`${config.iamLoginUrl}/oauth/authorize`);
const authorizeUrl = new URL(`${config.iamLoginUrl}${IAM_API_PATH}/oauth/authorize`);
authorizeUrl.searchParams.set('client_id', config.clientId);
authorizeUrl.searchParams.set('response_type', 'code');
authorizeUrl.searchParams.set('redirect_uri', redirectUri);
@@ -210,16 +210,18 @@ export async function login(adapter: BrowserAdapter): Promise<UserInfo> {
token_type: 'Bearer',
});
} else if (code) {
const tokenResponse = await fetch(`${config.iamApiUrl}/oauth/token`, {
// HIP-0111 canonical token endpoint `/v1/iam/oauth/token`. Token
// exchange wants application/x-www-form-urlencoded per RFC 6749.
const tokenResponse = await fetch(`${config.iamApiUrl}${IAM_API_PATH}/oauth/token`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
grant_type: 'authorization_code',
client_id: config.clientId,
code,
redirect_uri: redirectUri,
code_verifier: codeVerifier,
}),
}).toString(),
});
if (!tokenResponse.ok) throw new Error(`Token exchange failed: ${await tokenResponse.text()}`);
await storeTokens(adapter.storage, await tokenResponse.json());
@@ -267,14 +269,14 @@ export async function getValidAccessToken(storage: BrowserStorage): Promise<stri
async function refreshAccessToken(storage: BrowserStorage, refreshToken: string): Promise<string> {
const config = await getRuntimeConfig(storage);
const response = await fetch(`${config.iamApiUrl}/oauth/token`, {
const response = await fetch(`${config.iamApiUrl}${IAM_API_PATH}/oauth/token`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
grant_type: 'refresh_token',
client_id: config.clientId,
refresh_token: refreshToken,
}),
}).toString(),
});
if (!response.ok) throw new Error('Token refresh failed');
const tokens: TokenData = await response.json();
@@ -282,30 +284,14 @@ async function refreshAccessToken(storage: BrowserStorage, refreshToken: string)
return tokens.access_token;
}
/** Fetch user info from IAM. Hanzo gateway exposes Casdoor's get-account
* and userinfo at `${iamApiUrl}/v1/iam/...` never `/api/`. */
/** Fetch user info from IAM via the canonical HIP-0111 OIDC userinfo
* endpoint `${iamApiUrl}/v1/iam/oauth/userinfo`. Returns standard OIDC
* claims (sub, email, name, picture); never the legacy IAM `/api/` shape. */
async function fetchUserInfo(storage: BrowserStorage, accessToken: string): Promise<UserInfo> {
const config = await getRuntimeConfig(storage);
const base = `${config.iamApiUrl}${IAM_API_PATH}`;
const headers = { Authorization: `Bearer ${accessToken}` };
try {
const acctResp = await fetch(`${base}/get-account`, { headers });
if (acctResp.ok) {
const acctJson = await acctResp.json();
const acct = acctJson.data || acctJson;
if (acct.name || acct.email) {
return {
sub: acct.id || acct.sub,
name: acct.displayName || acct.name,
email: acct.email,
picture: acct.avatar || undefined,
};
}
}
} catch { /* fall through */ }
const response = await fetch(`${base}/userinfo`, { headers });
const response = await fetch(`${config.iamApiUrl}${IAM_API_PATH}/oauth/userinfo`, { headers });
if (!response.ok) throw new Error('Failed to fetch user info');
return response.json();
}
@@ -335,47 +321,66 @@ export async function getAuthStatus(storage: BrowserStorage): Promise<{
return { authenticated: true, user };
}
// ── Chrome/Firefox Adapter Factories ────────────────────────────────────
/** Create a BrowserAdapter from Chrome extension APIs */
export function chromeAdapter(): BrowserAdapter {
return {
storage: {
get: (keys) => new Promise((resolve) => chrome.storage.local.get(keys, resolve)),
set: (items) => chrome.storage.local.set(items),
remove: (keys) => chrome.storage.local.remove(keys),
},
tabs: {
create: (opts) => new Promise((resolve, reject) => {
chrome.tabs.create(opts, (tab) => {
if (chrome.runtime.lastError || !tab?.id) {
reject(new Error(chrome.runtime.lastError?.message || 'Failed to open tab'));
} else {
resolve(tab);
}
});
}),
remove: (tabId) => chrome.tabs.remove(tabId),
onUpdated: chrome.tabs.onUpdated,
onRemoved: chrome.tabs.onRemoved,
},
};
// ── WebExtension Adapter Factory ────────────────────────────────────────
//
// Unified across Chrome (MV3), Firefox (MV3), Safari (WebExtension), and
// Edge (Chromium MV3). Uses webextension-polyfill at the entrypoint level
// so `globalThis.browser` is the canonical Promise-returning API on every
// runtime. On Firefox/Safari `browser.*` is native; on Chrome the polyfill
// wraps `chrome.*` callbacks. The result is ONE codepath that works
// identically everywhere — no more separate `chromeAdapter` /
// `firefoxAdapter` factories with subtly different behavior.
//
// The adapter intentionally still targets a small surface (storage +
// tabs) because that's all auth.ts needs. If we ever migrate the wider
// codebase to `browser.*` we'd extend this — but for now keeping the
// surface minimal avoids unnecessary churn in callback-style listeners
// elsewhere (e.g. the runtime.onMessage handlers, which are still chrome
// callback style by design).
function getBrowser(): typeof browser {
// Prefer the polyfilled `browser` if present (Chrome with the polyfill
// imported at entrypoint, or any native browser.*-supporting runtime).
// Fall back to wrapping `chrome.*` directly if neither is present
// (which only happens in tests / non-extension contexts).
return (globalThis as any).browser ?? (globalThis as any).chrome;
}
/** Create a BrowserAdapter from Firefox WebExtension APIs */
export function firefoxAdapter(): BrowserAdapter {
const b = (globalThis as any).browser;
/**
* Create a BrowserAdapter that works on Chrome/Firefox/Edge/Safari.
* Requires `globalThis.browser` to be defined (which the polyfill
* import at the entrypoint guarantees on Chrome).
*/
export function webExtensionAdapter(): BrowserAdapter {
const b: any = getBrowser();
return {
storage: {
get: (keys) => b.storage.local.get(keys),
set: (items) => b.storage.local.set(items),
remove: (keys) => b.storage.local.remove(keys),
// Promise-returning on every runtime once the polyfill is loaded.
get: (keys) => Promise.resolve(b.storage.local.get(keys)),
set: (items) => Promise.resolve(b.storage.local.set(items)),
remove: (keys) => Promise.resolve(b.storage.local.remove(keys)),
},
tabs: {
create: (opts) => b.tabs.create(opts),
remove: (tabId) => b.tabs.remove(tabId),
create: (opts) => Promise.resolve(b.tabs.create(opts)),
remove: (tabId) => Promise.resolve(b.tabs.remove(tabId)),
onUpdated: b.tabs.onUpdated,
onRemoved: b.tabs.onRemoved,
},
};
}
/**
* Legacy Chrome adapter kept for backwards compatibility with callers
* that imported it before 1.9.0. Now just an alias for
* `webExtensionAdapter`. Prefer `webExtensionAdapter` in new code.
*/
export function chromeAdapter(): BrowserAdapter {
return webExtensionAdapter();
}
/**
* Legacy Firefox adapter same deal as `chromeAdapter`. Both are
* aliases now that the polyfill gives us one identical surface.
*/
export function firefoxAdapter(): BrowserAdapter {
return webExtensionAdapter();
}
+20 -4
View File
@@ -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';
+48
View File
@@ -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} })()`;
}
+1 -1
View File
@@ -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`
+142
View File
@@ -0,0 +1,142 @@
// Native-ZAP transport — the extension's one connection to the local zapd
// router, via the ai.hanzo.zap native-messaging host. No WebSocket, no
// localhost ports, no mDNS probing. The browser can never get "connection
// refused": connectNative either attaches the host or fails once, cleanly.
//
// Frames are the ZAP router envelope (binary, in zapd). Over native messaging
// they ride as JSON `{t, flags, from, to, payload(base64)}` — the Chrome-forced
// inch — and the host re-frames 1:1 to binary on the UDS. The opaque command/
// result payload uses a compact binary codec below (no JSON in the contract).
const HELLO = 1, WELCOME = 2, ERROR = 7, ROUTE = 16, RESPONSE = 17;
const ROLE_PROVIDER = 1;
function b64encode(bytes: Uint8Array): string {
let s = '';
for (let i = 0; i < bytes.length; i++) s += String.fromCharCode(bytes[i]);
return btoa(s);
}
function b64decode(s: string): Uint8Array {
const bin = atob(s);
const u = new Uint8Array(bin.length);
for (let i = 0; i < bin.length; i++) u[i] = bin.charCodeAt(i);
return u;
}
function putU16(a: number[], n: number) { a.push(n & 0xff, (n >> 8) & 0xff); }
function putStr(a: number[], s: string) {
const b = new TextEncoder().encode(s);
putU16(a, b.length);
for (let i = 0; i < b.length; i++) a.push(b[i]);
}
// HELLO body: role(u8) + brand(str) + caps(u16 + str…). Mirrors frame.rs.
function encodeHello(role: number, brand: string, caps: string[]): Uint8Array {
const a: number[] = [role];
putStr(a, brand);
putU16(a, caps.length);
for (const c of caps) putStr(a, c);
return new Uint8Array(a);
}
// Command body: method(str) + params(u16 count of key:str → value:bytes).
function decodeCmd(p: Uint8Array): { method: string; params: Record<string, string> } {
const dv = new DataView(p.buffer, p.byteOffset, p.byteLength);
let o = 0;
const ml = dv.getUint16(o, true); o += 2;
const method = new TextDecoder().decode(p.subarray(o, o + ml)); o += ml;
const n = dv.getUint16(o, true); o += 2;
const params: Record<string, string> = {};
for (let i = 0; i < n; i++) {
const kl = dv.getUint16(o, true); o += 2;
const k = new TextDecoder().decode(p.subarray(o, o + kl)); o += kl;
const vl = dv.getUint32(o, true); o += 4;
params[k] = new TextDecoder().decode(p.subarray(o, o + vl)); o += vl;
}
return { method, params };
}
export interface NativeZapState {
connected: boolean;
port: chrome.runtime.Port | null;
providerId: string;
}
export type Dispatch = (method: string, params: any) => Promise<any> | any;
const RECONNECT_MS = 3000;
/** Connect to zapd via the native host, register as a browser provider, and
* dispatch inbound ROUTE commands to the browser. Self-heals on disconnect. */
export function connectNativeZap(
state: NativeZapState,
brand: string,
caps: string[],
dispatch: Dispatch,
log: (m: string) => void = console.log,
): void {
// Cross-browser: Firefox exposes `browser.runtime`, Chrome `chrome.runtime`;
// both implement connectNative. One transport, both browsers.
const rt: any = (typeof browser !== 'undefined' ? (browser as any) : chrome).runtime;
// Singleton: never run two native ports. Tear down any prior port first — its
// onDisconnect below is guarded so a replaced port never schedules a reconnect.
// Without this, repeated connectNativeZap() calls (startup + events + the 3s
// reconnect) accumulate ports, each spawning a native host → a host-spawn storm.
const prior = state.port;
state.port = null;
if (prior) { try { prior.disconnect(); } catch {} }
let port: chrome.runtime.Port;
try {
port = rt.connectNative('ai.hanzo.zap');
} catch (e: any) {
state.connected = false;
log('[Hanzo/ZAP] native host ai.hanzo.zap is not installed');
return;
}
state.port = port;
const id = state.providerId;
const post = (t: number, to: string, payload: Uint8Array) =>
port.postMessage({ t, flags: 0, from: id, to, payload: b64encode(payload) });
// Register as a provider.
post(HELLO, '', encodeHello(ROLE_PROVIDER, brand, caps));
port.onMessage.addListener(async (msg: any) => {
const payload = msg.payload ? b64decode(msg.payload) : new Uint8Array(0);
switch (msg.t) {
case WELCOME:
state.connected = true;
log(`[Hanzo/ZAP] native host connected; registered ${id}`);
break;
case ERROR:
log('[Hanzo/ZAP] ' + new TextDecoder().decode(payload));
break;
case ROUTE: {
const { method, params } = decodeCmd(payload);
let out: Uint8Array;
try {
const r = await dispatch(method, params);
// browser.zap stopgap: structured results travel as utf-8 text until
// the typed browser schema lands. Router/host never see this — opaque.
const s = typeof r === 'string' ? r : JSON.stringify(r ?? null);
out = new TextEncoder().encode(s);
} catch (e: any) {
out = new TextEncoder().encode('ERR:' + (e?.message || String(e)));
}
port.postMessage({ t: RESPONSE, flags: 0, from: id, to: msg.from, payload: b64encode(out) });
break;
}
default:
break; // PROVIDERS / PEER_* — a provider ignores these
}
});
port.onDisconnect.addListener(() => {
if (state.port !== port) return; // replaced by a newer port — do not reconnect
state.connected = false;
state.port = null;
const err = rt.lastError?.message;
log('[Hanzo/ZAP] native host disconnected' + (err ? ': ' + err : ''));
setTimeout(() => connectNativeZap(state, brand, caps, dispatch, log), RECONNECT_MS);
});
}
+182 -71
View File
@@ -15,10 +15,16 @@ export const MSG_RESPONSE = 0x11;
export const MSG_PING = 0xFE;
export const MSG_PONG = 0xFF;
export const DEFAULT_ZAP_PORTS = [9999, 9998, 9997, 9996, 9995];
/**
* Canonical Hanzo service-type per HIP-0069. Discovery is mDNS-only
* the legacy [9999..9995] port-probe was removed in 1.9.13 to surface
* non-compliant deployments instead of papering over them.
*/
export const HANZO_SERVICE_TYPE = '_hanzo._tcp.local.';
export const ZAP_RECONNECT_DELAY = 3000;
export const ZAP_DISCOVERY_TIMEOUT = 2000;
// ── Encode / Decode ─────────────────────────────────────────────────────
export function encodeZapMessage(type: number, payload: object): ArrayBuffer {
@@ -50,11 +56,24 @@ export function decodeZapMessage(buf: ArrayBuffer): { type: number; payload: any
// ── Connection Management ───────────────────────────────────────────────
/**
* Handler invoked when a ZAP server pushes a request TO the extension.
* Returns a result (or throws) and the framework converts that into a
* MSG_RESPONSE frame back to the server.
*
* Keeping this at the manager level means the extension's existing
* request-dispatchers (handleExtensionRequest, executeMethod) can be
* mounted once and serve every connected MCP without duplication.
*/
export type ZapRequestHandler = (method: string, params: any) => Promise<any> | any;
export interface ZapManager {
state: ZapState;
connections: Map<string, WebSocket>;
pendingRequests: Map<string, { resolve: Function; reject: Function }>;
requestIdCounter: number;
/** Optional inbound request handler (set via setZapRequestHandler). */
requestHandler?: ZapRequestHandler;
}
export function createZapManager(): ZapManager {
@@ -70,38 +89,9 @@ export function createZapManager(): ZapManager {
};
}
/** Probe a ZAP server on given port */
export function probeZapServer(
port: number,
mgr: ZapManager,
browserName: string,
version: string,
): Promise<string | null> {
return new Promise((resolve) => {
const url = `ws://localhost:${port}`;
const ws = new WebSocket(url);
const timer = setTimeout(() => { ws.close(); resolve(null); }, ZAP_DISCOVERY_TIMEOUT);
ws.binaryType = 'arraybuffer';
ws.onopen = () => {
clearTimeout(timer);
ws.send(encodeZapMessage(MSG_HANDSHAKE, {
clientId: mgr.state.extensionId,
clientType: 'browser_extension',
browser: browserName,
version,
capabilities: ['tabs', 'navigate', 'screenshot', 'evaluate', 'cookies', 'storage'],
}));
const hsTimer = setTimeout(() => { ws.close(); resolve(null); }, ZAP_DISCOVERY_TIMEOUT);
ws.onmessage = () => {
clearTimeout(hsTimer);
ws.close();
resolve(url);
};
};
ws.onerror = () => { clearTimeout(timer); resolve(null); };
});
/** Install (or replace) the inbound request handler. */
export function setZapRequestHandler(mgr: ZapManager, handler: ZapRequestHandler | undefined): void {
mgr.requestHandler = handler;
}
/** Connect to a ZAP server and set up message handling */
@@ -149,6 +139,11 @@ export async function connectZap(
tools: (info.tools || []).map((t: any) => t.name),
});
log(`[Hanzo/ZAP] Connected to ${info.name || url} (${(info.tools || []).length} tools)`);
// No application-level heartbeat: ZAP is a long-lived WS and the
// OS already delivers FIN/RST on real disconnects, which fires
// ws.onclose → our reconnect chain (added 1.9.7). The 1.9.9
// PING/PONG was both unnecessary and buggy (it stacked
// ws.onmessage wrappers on every tick).
resolve(mcpId);
break;
}
@@ -162,12 +157,43 @@ export async function connectZap(
}
break;
}
case MSG_REQUEST: {
// Server-initiated RPC: a Python hanzo-mcp is asking the
// extension to perform a browser action. Dispatch via the
// installed handler and reply with MSG_RESPONSE.
const { id: reqId, method: reqMethod, params: reqParams } = msg.payload || {};
const respond = (payload: object) => {
try {
ws.send(encodeZapMessage(MSG_RESPONSE, payload));
} catch (e) {
// ws.send throws if the socket is closing; nothing we can do.
}
};
const handler = mgr.requestHandler;
if (!handler) {
respond({ id: reqId, error: { code: -32601, message: `No request handler installed (method: ${reqMethod})` } });
break;
}
Promise.resolve()
.then(() => handler(reqMethod, reqParams || {}))
.then((result) => respond({ id: reqId, result: result === undefined ? null : result }))
.catch((e: any) => respond({ id: reqId, error: { code: -1, message: e?.message || String(e) } }));
break;
}
case MSG_PING:
ws.send(encodeZapMessage(MSG_PONG, {}));
break;
}
};
let scheduledRetry = false;
const scheduleRetry = (reason: string) => {
if (scheduledRetry) return;
scheduledRetry = true;
log(`[Hanzo/ZAP] ${reason} — reconnecting to ${url} in ${ZAP_RECONNECT_DELAY}ms...`);
setTimeout(() => connectZap(url, mgr, browserName, version, log), ZAP_RECONNECT_DELAY);
};
ws.onclose = () => {
for (const [id, conn] of mgr.connections) {
if (conn === ws) {
@@ -177,12 +203,17 @@ export async function connectZap(
}
}
mgr.state.connected = mgr.connections.size > 0;
log(`[Hanzo/ZAP] Disconnected from ${url}, reconnecting in ${ZAP_RECONNECT_DELAY}ms...`);
setTimeout(() => connectZap(url, mgr, browserName, version, log), ZAP_RECONNECT_DELAY);
scheduleRetry('Disconnected');
};
ws.onerror = () => {
clearTimeout(connTimer);
// onerror fires when the bind window misses, the server is restarting,
// or the connection is reset mid-handshake. Without scheduling a retry
// here the entire reconnect chain dies after a single failed attempt
// (1.9.6 and earlier) — fixed in 1.9.7 by chaining a retry the same
// way onclose does.
scheduleRetry('Connect error');
resolve(null);
};
});
@@ -256,26 +287,126 @@ export function zapGetPrompt(mgr: ZapManager, mcpId: string, name: string, args?
return zapRequest(mgr, mcpId, 'prompts/get', { name, arguments: args });
}
/** Discover and connect to ZAP servers */
/**
* Ask the native messaging helper (ai.hanzo.zap_mdns) for a live mDNS browse
* of `_hanzo._tcp.local.` services per HIP-0069. Returns ws:// URLs the
* extension can connect to. Returns [] when the helper isn't installed or
* the browse times out empty.
*/
async function discoverViaMdns(timeoutMs: number = 1500): Promise<string[]> {
const api: any = (typeof browser !== 'undefined' ? browser : (chrome as any));
const send = api?.runtime?.sendNativeMessage;
if (typeof send !== 'function') {
throw new Error(
'[Hanzo/ZAP] runtime.sendNativeMessage unavailable — ai.hanzo.zap_mdns helper required for discovery',
);
}
const resp: any = await new Promise((resolve, reject) => {
// Two dialects:
// - Firefox / promise-returning Chrome (MV3): sendNativeMessage(name, msg) → Promise
// - Chrome MV2 callback API: sendNativeMessage(name, msg, callback)
// Detect by whether the first call returns a thenable; only fall back to
// the callback shape when it doesn't, so we never send the same browse
// request twice.
try {
const result = send.call(api.runtime, 'ai.hanzo.zap_mdns', { op: 'browse', timeout_ms: timeoutMs }, (r: any) => {
// Callback dialect: result of the call() above is undefined and the
// helper response arrives here. Promise dialect ignores callbacks.
if (r !== undefined) resolve(r);
});
if (result && typeof result.then === 'function') {
result.then(resolve).catch(reject);
}
// If result was undefined and the callback never fires, the discovery
// loop's interval handles the retry — no need for a per-call timer.
} catch (e) {
reject(e);
}
});
if (!resp || !resp.ok || !Array.isArray(resp.services)) return [];
return resp.services
.map((s: any) => s.url)
.filter((u: any): u is string => typeof u === 'string');
}
/**
* Discover and connect to ZAP servers. Tries mDNS (HIP-0069,
* `_hanzo._tcp.local.`) first via the ``ai.hanzo.zap_mdns`` native-messaging
* helper; when the helper is absent or finds nothing, falls back to
* probing the localhost ``ZAP_PORTS`` range so a developer running
* hanzo-mcp on the same machine is always reachable without extra
* installation. Use ``startZapDiscoveryLoop`` for the always-on watchdog.
*/
export async function discoverZapServers(
mgr: ZapManager,
browserName: string,
version: string,
ports: number[] = DEFAULT_ZAP_PORTS,
_ports: number[] | undefined = undefined, // unused — retained for ABI continuity
log: (msg: string) => void = console.log,
): Promise<void> {
log('[Hanzo/ZAP] Discovering MCP servers...');
const results = await Promise.all(ports.map(p => probeZapServer(p, mgr, browserName, version)));
const available = results.filter(Boolean) as string[];
if (available.length === 0) {
log('[Hanzo/ZAP] No servers found, retrying in 10s...');
setTimeout(() => discoverZapServers(mgr, browserName, version, ports, log), 10000);
return;
log('[Hanzo/ZAP] Discovering MCP via mDNS (_hanzo._tcp.local.)...');
let mdnsUrls: string[] = [];
let mdnsError = false;
try {
mdnsUrls = await discoverViaMdns(1500);
} catch (e: any) {
mdnsError = true;
log(`[Hanzo/ZAP] mDNS browse failed: ${e?.message || e} — falling back to localhost probe.`);
}
log(`[Hanzo/ZAP] Found ${available.length} server(s): ${available.join(', ')}`);
await Promise.all(available.map(url => connectZap(url, mgr, browserName, version, log)));
// Skip URLs we're already connected to so a watchdog re-tick is a no-op
// when everything is already wired.
const known = new Set<string>();
for (const m of mgr.state.mcps) known.add(m.url);
const candidates = mdnsUrls.filter(u => !known.has(u));
// mDNS-only per HIP-0069. A browser extension must NEVER open arbitrary
// ws://localhost:* sockets — that is the ERR_CONNECTION_REFUSED spam.
// Either the native helper advertises a server, or we are cleanly
// unavailable. No localhost port probing, no fallback ladder.
if (candidates.length === 0) {
if (mdnsError) {
log('[Hanzo/ZAP] unavailable: native mDNS helper ai.hanzo.zap_mdns missing/unreachable');
}
return;
}
log(`[Hanzo/ZAP] mDNS found ${candidates.length} new server(s): ${candidates.join(', ')}`);
await Promise.all(candidates.map(url => connectZap(url, mgr, browserName, version, log)));
}
/**
* Always-on discovery watchdog. Ticks every ``intervalMs`` and re-runs
* discovery whenever the manager has zero live connections (or a fresh server
* has appeared on mDNS that we're not yet connected to). This is the canonical
* entry point replaces the fragile setTimeout-on-failure chain that died
* the moment any code-path missed a re-arm. Returns the interval handle so
* tests can stop it; in production the page lives for the extension lifetime.
*/
export function startZapDiscoveryLoop(
mgr: ZapManager,
browserName: string,
version: string,
intervalMs: number = 5000,
log: (msg: string) => void = console.log,
): { stop: () => void } {
let inFlight = false;
const tick = async () => {
if (inFlight) return;
inFlight = true;
try {
await discoverZapServers(mgr, browserName, version, undefined, log);
} catch (e: any) {
log(`[Hanzo/ZAP] discovery tick failed: ${e?.message || e}`);
} finally {
inFlight = false;
}
};
// Run an immediate first probe; then settle into the watchdog cadence.
void tick();
const handle = setInterval(tick, intervalMs);
return { stop: () => clearInterval(handle) };
}
// ── Message Handler Helpers ─────────────────────────────────────────────
@@ -306,31 +437,11 @@ export function handleZapMessage(
return true;
case 'zap.discover':
discoverZapServers(mgr, browserName, version).then(() => {
sendResponse({
success: true,
mcps: mgr.state.mcps.map(m => ({
id: m.id,
name: m.name,
url: m.url,
tools: m.tools,
})),
});
}).catch((e: any) => {
sendResponse({ success: false, error: e.message });
});
return true;
case 'zap.connect':
if (!request.url) {
sendResponse({ success: false, error: 'Missing url' });
return true;
}
connectZap(request.url, mgr, browserName, version).then((mcpId) => {
sendResponse({ success: !!mcpId, mcpId });
}).catch((e: any) => {
sendResponse({ success: false, error: e.message });
});
// Discovery/dialing is gone. The extension connects to the local zapd
// router via the ai.hanzo.zap native host (see native-zap.ts) — there is
// no URL/port to discover or dial.
sendResponse({ success: false, error: 'native-zap: connection is via the ai.hanzo.zap native host (no discovery)' });
return true;
case 'zap.callTool': {
+24 -16
View File
@@ -1,4 +1,6 @@
/* Hanzo AI Browser Extension Sidebar — aligned with @hanzo/ui dark theme */
/* Hanzo AI Browser Extension Sidebar Hanzo brand: strictly monochrome.
See ~/work/hanzo/brand/brand.json primaryColor #FFFFFF, no chromatic
accents. Status states are conveyed by intensity, not hue. */
:root {
--bg-primary: #0a0a0a;
@@ -15,9 +17,11 @@
--accent-dim: rgba(255,255,255,0.06);
--accent-glow: rgba(255,255,255,0.03);
--success: #22c55e;
--warning: #eab308;
--error: #ef4444;
/* Status: monochrome by hue, distinguished by opacity / weight.
Brand is strict monochrome, so success/warn/error use luminance. */
--success: #fafafa;
--warning: #d4d4d4;
--error: rgba(255,255,255,0.55);
--border: rgba(255,255,255,0.10);
--border-strong: rgba(255,255,255,0.16);
@@ -259,7 +263,7 @@ html {
line-height: 1.5;
}
/* Sign-in button — matches @hanzo/ui primary button on dark */
/* Sign-in button — matches @hanzo/gui primary button on dark (monochrome) */
.auth-signin-btn {
width: 100%;
padding: 10px 16px;
@@ -1046,16 +1050,18 @@ html {
word-break: break-all;
}
/* Element-syntax tinting — kept monochrome via opacity, not hue. */
.inspector-result .element-tag {
color: #7cc4f8;
color: #fafafa;
font-weight: 600;
}
.inspector-result .element-id {
color: #c792ea;
color: rgba(255,255,255,0.78);
}
.inspector-result .element-class {
color: #c3e88d;
color: rgba(255,255,255,0.62);
}
.inspector-result .element-size {
@@ -1681,11 +1687,13 @@ select option {
min-width: 0;
}
.bar-blue { background: #3B82F6; }
.bar-violet { background: #8B5CF6; }
.bar-emerald { background: #10B981; }
.bar-amber { background: #F59E0B; }
.bar-red { background: #EF4444; }
/* Usage bars: monochrome scale (brightest dimmest) per @hanzo/brand.
Class names kept for compatibility with existing markup. */
.bar-blue { background: rgba(255,255,255,0.95); }
.bar-violet { background: rgba(255,255,255,0.75); }
.bar-emerald { background: rgba(255,255,255,0.55); }
.bar-amber { background: rgba(255,255,255,0.40); }
.bar-red { background: rgba(255,255,255,0.25); }
.usage-bar-group {
margin-top: 8px;
@@ -1761,9 +1769,9 @@ select option {
letter-spacing: 0.05em;
white-space: nowrap;
}
.badge-cloud { background: rgba(59,130,246,0.15); color: #60A5FA; }
.badge-local { background: rgba(16,185,129,0.15); color: #34D399; }
.badge-hub { background: rgba(139,92,246,0.15); color: #A78BFA; }
.badge-cloud { background: rgba(255,255,255,0.10); color: rgba(255,255,255,0.92); }
.badge-local { background: rgba(255,255,255,0.06); color: rgba(255,255,255,0.78); }
.badge-hub { background: rgba(255,255,255,0.04); color: rgba(255,255,255,0.65); }
.model-hub-actions {
display: flex;
+4
View File
@@ -1,3 +1,7 @@
// Polyfill the WebExtension API on Chrome so storage calls return Promises
// like Firefox / Safari natively do. No-op on those runtimes.
import 'webextension-polyfill';
import {
CHAT_BUDGETS,
debugLog,
+276 -6
View File
@@ -1,6 +1,29 @@
// WebGPU AI Runner for Browser Extension
// Enables local AI inference directly in the browser via WebGPU compute shaders.
// Supports model loading, BPE tokenization, autoregressive generation, and embeddings.
//
// File format support:
// - Raw blob (legacy hanzo-browser-control): the bytes are uploaded as one
// GPUBuffer and consumed by the inline matmul/softmax shaders below.
// - GGUF (llama.cpp's quantized container, v1-v3): parsed into a
// name-keyed map of (offset, length, dtype, shape) descriptors, with
// each tensor lazily uploaded to its own GPUBuffer through ExpertCache.
// This unlocks Zen-Nano/Zen-Eco/Zen-Coder GGUF artifacts published on
// HuggingFace without an intermediate conversion step.
//
// Sparse-load shell (ExpertCache below) materialises tensors on demand and
// evicts cold ones under an LRU budget. For dense models the cache simply
// holds every layer; for MoE (Zen Coder 480B, etc.) it only ever holds the
// experts the router actually picks for live tokens, which is the whole
// point of being able to run "too big" models in a browser.
//
// Still TODO before a GGUF model is end-to-end runnable here:
// - Transformer kernel suite: RMSNorm, RoPE, multi-head attention with
// KV cache, SwiGLU FFN, MoE router + dispatch (multiple thousand lines
// of WGSL). The parser + cache below ARE the prerequisite layer.
// - Speculative decoding: small dense draft (Zen Nano 0.6B) proposing N
// tokens, large MoE verify (Zen Coder 31B/3B-active) checking the batch
// in one forward pass. Layered on top of the kernels.
interface ModelConfig {
name: string;
@@ -15,6 +38,162 @@ interface LoadedModel {
config: ModelConfig;
vocab: string[];
vocabIndex: Map<string, number>;
// Populated when the artifact is GGUF (otherwise undefined and the legacy
// single-blob matmul/softmax pipeline runs).
gguf?: GGUFContext;
}
// ---------------------------------------------------------------------------
// GGUF format
// ---------------------------------------------------------------------------
//
// Reference: https://github.com/ggerganov/ggml/blob/master/docs/gguf.md
// Layout (LE throughout):
// [magic:4 "GGUF"] [version:u32] [tensor_count:u64] [kv_count:u64]
// <kv-metadata × kv_count>
// <tensor-info × tensor_count>
// <padding to alignment (default 32B)>
// <tensor data blob>
const GGUF_MAGIC = 0x46554747; // "GGUF" little-endian as u32
enum GGUFType {
UINT8 = 0,
INT8 = 1,
UINT16 = 2,
INT16 = 3,
UINT32 = 4,
INT32 = 5,
FLOAT32 = 6,
BOOL = 7,
STRING = 8,
ARRAY = 9,
UINT64 = 10,
INT64 = 11,
FLOAT64 = 12,
}
// GGML quantization types — only the ones modern HF GGUFs use are enumerated.
enum GGMLDType {
F32 = 0,
F16 = 1,
Q4_0 = 2,
Q4_1 = 3,
Q5_0 = 6,
Q5_1 = 7,
Q8_0 = 8,
Q8_1 = 9,
Q2_K = 10,
Q3_K = 11,
Q4_K = 12,
Q5_K = 13,
Q6_K = 14,
Q8_K = 15,
I8 = 16,
I16 = 17,
I32 = 18,
}
interface GGUFTensorInfo {
name: string;
shape: number[];
dtype: GGMLDType;
/** Byte offset within the tensor-data blob (NOT within the file). */
offset: number;
/** Bytes occupied — computed once at parse, used for slicing + cache budget. */
length: number;
}
interface GGUFContext {
/** Raw KV metadata. ``general.architecture``, hyperparameters, vocab live here. */
kv: Record<string, any>;
/** Name → tensor descriptor. ``offset`` is relative to ``dataStart``. */
tensors: Map<string, GGUFTensorInfo>;
/** Byte offset in the source ArrayBuffer where the tensor data blob begins. */
dataStart: number;
/** Total tensor-data length. */
dataLength: number;
/** Backing buffer (kept for lazy slicing into GPUBuffers via ExpertCache). */
source: ArrayBuffer;
}
/**
* LRU sparse-load cache. Keys are typically ``"<layer>.<expert>.<tensor>"``
* (or simpler ``"<tensor-name>"`` for dense models). On a miss the loader
* fn is invoked to upload the bytes into a fresh GPUBuffer; on overflow the
* coldest entry is destroyed.
*
* Why this shape: WebGPU has a ~2GB per-buffer cap and shared VRAM budgets
* vary by platform, so the cache budget is in bytes (not entries). For MoE
* the eviction policy is what lets you run Zen Coder 480B Q4 (~240GB
* weights) on a 100GB Mac the live working set per token is tiny.
*/
class ExpertCache {
private map = new Map<string, { buffer: GPUBuffer; bytes: number }>();
private order: string[] = []; // LRU: front = most recent
private currentBytes = 0;
constructor(
private device: GPUDevice,
/** Max bytes resident on the device. Default 4 GiB; bump for larger VRAM. */
private budgetBytes: number = 4 * 1024 * 1024 * 1024,
) {}
has(key: string): boolean { return this.map.has(key); }
/** Get the GPUBuffer for ``key``, loading it on miss. */
async get(key: string, loader: () => Promise<{ data: ArrayBuffer; label?: string }>): Promise<GPUBuffer> {
const hit = this.map.get(key);
if (hit) {
this._touch(key);
return hit.buffer;
}
const { data, label } = await loader();
while (this.currentBytes + data.byteLength > this.budgetBytes && this.order.length > 0) {
this._evictColdest();
}
const buffer = this.device.createBuffer({
size: data.byteLength,
usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST,
label: label || `expert-${key}`,
});
this.device.queue.writeBuffer(buffer, 0, data);
this.map.set(key, { buffer, bytes: data.byteLength });
this.order.unshift(key);
this.currentBytes += data.byteLength;
return buffer;
}
/** Force-evict everything. */
clear(): void {
for (const { buffer } of this.map.values()) buffer.destroy();
this.map.clear();
this.order = [];
this.currentBytes = 0;
}
stats(): { entries: number; bytes: number; budget: number } {
return { entries: this.map.size, bytes: this.currentBytes, budget: this.budgetBytes };
}
private _touch(key: string): void {
const i = this.order.indexOf(key);
if (i > 0) {
this.order.splice(i, 1);
this.order.unshift(key);
}
}
private _evictColdest(): void {
const coldKey = this.order.pop();
if (!coldKey) return;
const entry = this.map.get(coldKey);
if (!entry) return;
entry.buffer.destroy();
this.map.delete(coldKey);
this.currentBytes -= entry.bytes;
console.log(`[Hanzo AI] Cache evicted ${coldKey} (${entry.bytes} bytes)`);
}
}
export class WebGPUAI {
@@ -174,12 +353,11 @@ export class WebGPUAI {
console.log(`[Hanzo AI] Loading model: ${config.name}`);
// Load model weights
const response = await fetch(config.url);
if (!response.ok) {
throw new Error(`Failed to fetch model ${config.name}: ${response.status}`);
}
const modelData = await response.arrayBuffer();
// Load model weights — cache-first, streaming with progress logs.
// Service-worker Cache API persists across worker restarts so the bytes
// are downloaded at most once per URL. Range-streaming the body avoids
// pinning the entire blob in JS memory before GPU upload.
const modelData = await this._fetchWithCache(config.url, config.name);
// Create GPU buffer for model weights
const modelBuffer = this.device.createBuffer({
@@ -579,6 +757,98 @@ export class WebGPUAI {
return embedding;
}
// ---------------------------------------------------------------------------
// Cached, progress-reporting fetch — used by loadModel().
// ---------------------------------------------------------------------------
/**
* Fetch a model blob with persistent caching. Backed by the service-worker
* Cache API so the bytes survive worker restarts and only download once
* per URL. Streams the body to log progress every ~5% so big artifacts
* (huggingface.co/<repo>/resolve/main/<file>) don't appear to hang.
*
* Cache invalidation: the URL is the key; bump the URL (e.g. point to a
* different revision sha) to force a re-fetch. Use ``WebGPUAI.evictCache``
* to wipe everything when models go bad.
*/
private async _fetchWithCache(url: string, label: string): Promise<ArrayBuffer> {
const cacheName = 'hanzo-webgpu-models-v1';
const cache = typeof caches !== 'undefined' ? await caches.open(cacheName) : null;
if (cache) {
const hit = await cache.match(url);
if (hit) {
console.log(`[Hanzo AI] Cache hit: ${label} (${url})`);
return await hit.arrayBuffer();
}
}
console.log(`[Hanzo AI] Fetching: ${label} (${url})`);
const response = await fetch(url);
if (!response.ok) {
throw new Error(`Failed to fetch ${label}: ${response.status} ${response.statusText}`);
}
const total = Number(response.headers.get('content-length') || 0);
let received = 0;
let nextLog = total > 0 ? Math.floor(total * 0.05) : Infinity;
const chunks: Uint8Array[] = [];
if (response.body) {
const reader = response.body.getReader();
while (true) {
const { done, value } = await reader.read();
if (done) break;
chunks.push(value);
received += value.byteLength;
if (total > 0 && received >= nextLog) {
const pct = Math.floor((received / total) * 100);
console.log(`[Hanzo AI] ${label}: ${pct}% (${received}/${total} bytes)`);
nextLog = received + Math.floor(total * 0.05);
}
}
} else {
chunks.push(new Uint8Array(await response.arrayBuffer()));
}
// Stitch chunks into a single ArrayBuffer.
const blob = new Uint8Array(received || chunks.reduce((n, c) => n + c.byteLength, 0));
let offset = 0;
for (const c of chunks) {
blob.set(c, offset);
offset += c.byteLength;
}
console.log(`[Hanzo AI] Fetched: ${label} (${blob.byteLength} bytes)`);
// Write back into the cache. Response body is single-use; re-construct
// from the assembled blob so cache.put gets a fresh, readable stream.
if (cache) {
try {
await cache.put(url, new Response(blob, {
headers: { 'content-type': 'application/octet-stream' },
}));
console.log(`[Hanzo AI] Cached: ${label}`);
} catch (e) {
// Quota errors are non-fatal — we still have the bytes in memory.
console.warn(`[Hanzo AI] Cache write failed for ${label}:`, e);
}
}
return blob.buffer;
}
/** Build an HF resolve URL for a given repo / file / revision. */
static huggingFaceURL(repo: string, file: string, revision: string = 'main'): string {
return `https://huggingface.co/${repo}/resolve/${revision}/${file}`;
}
/** Wipe every cached model blob. Useful when a model artifact is rotated. */
static async evictCache(): Promise<void> {
if (typeof caches === 'undefined') return;
await caches.delete('hanzo-webgpu-models-v1');
console.log('[Hanzo AI] WebGPU model cache evicted');
}
// ---------------------------------------------------------------------------
// Status / Cleanup
// ---------------------------------------------------------------------------
@@ -7,27 +7,24 @@ import * as fs from 'fs';
describe('Claude Code Browser Extension Integration', () => {
let server: BrowserExtensionServer;
let ws: WebSocket;
const TEST_PORT = 3002;
beforeEach(async () => {
// Start server
server = new BrowserExtensionServer(TEST_PORT, process.cwd());
// Wait for server to be ready
await new Promise(resolve => setTimeout(resolve, 100));
// Connect WebSocket
ws = new WebSocket(`ws://localhost:${TEST_PORT}/browser-extension`);
// Port 0 = OS-assigned. A fixed port raced the previous test's teardown
// (EADDRINUSE → unhandled 'error' → hook timeout); ready() waits for the
// actual bind instead of sleeping and hoping.
server = new BrowserExtensionServer(0, process.cwd());
await server.ready();
ws = new WebSocket(`ws://localhost:${server.port}/browser-extension`);
await new Promise(resolve => ws.on('open', resolve));
});
afterEach(async () => {
ws.close();
await new Promise<void>((resolve) => {
server.close(() => resolve());
// Fallback if close callback never fires
setTimeout(resolve, 500);
});
// terminate() drops the client immediately so wss.close() isn't left
// waiting on a close handshake — the callback then reliably fires and
// no fallback timer is needed.
ws.terminate();
await new Promise<void>((resolve) => server.close(() => resolve()));
});
describe('React source-map integration', () => {
@@ -0,0 +1,303 @@
/**
* Cross-browser parity contracts.
*
* These tests assert that the things which were broken in 1.8.x stay
* fixed in 1.9.0 and beyond. They probe BOTH the Chrome (background.ts +
* browser-dispatch.ts) and Firefox (background-firefox.ts) source files
* for the structural patterns that distinguish a working build from a
* regressed one.
*
* Why string-search instead of behavior tests? The actual page execution
* path requires a real browser. These contracts run as `vitest run` in
* CI without a browser, so they're a fast lint-class signal that no
* future PR silently re-introduces the bug.
*/
import { describe, it, expect } from 'vitest';
import * as fs from 'fs';
import * as path from 'path';
function read(p: string): string {
return fs.readFileSync(path.join(__dirname, '..', p), 'utf8');
}
const chromeBg = read('src/background.ts');
const firefoxBg = read('src/background-firefox.ts');
const cdpBridge = read('src/browser-dispatch.ts');
const browserControl = read('src/browser-control.ts');
const popupTs = read('src/popup.ts');
const contentScript = read('src/content-script.ts');
const sharedAuth = read('src/shared/auth.ts');
const sharedConfig = read('src/shared/config.ts');
const manifestChrome = read('src/manifest.json');
const manifestFirefox = read('src/manifest-firefox.json');
describe('IAM URL conventions', () => {
it('IAM_API_PATH is /v1/iam (NOT /api/, NOT bare /oauth)', () => {
expect(sharedConfig).toContain("IAM_API_PATH = '/v1/iam'");
});
it('shared auth uses the HIP-0111 canonical ${IAM_API_PATH}/oauth/token endpoint', () => {
// Pre-HIP-0111 shapes are both dead: the Casdoor
// /login/oauth/access_token path and a bare (un-prefixed) /oauth/token
// that 404'd before 1.8.7. Strip comments before scanning — the
// doc-comments intentionally reference the old paths to explain the bug.
expect(sharedAuth).toContain('${IAM_API_PATH}/oauth/token');
const stripped = sharedAuth
.replace(/\/\*[\s\S]*?\*\//g, '') // block comments
.replace(/(^|\n)\s*\/\/.*?(?=\n|$)/g, '$1'); // line comments
expect(stripped).not.toContain('/login/oauth/access_token');
expect(stripped).not.toMatch(/['"`]\/oauth\/token['"`]/);
});
it('token exchange uses application/x-www-form-urlencoded (RFC 6749)', () => {
// application/json was the bug pre-1.8.7. Verify the body shape.
const tokenBlock = sharedAuth.slice(
sharedAuth.indexOf('${IAM_API_PATH}/oauth/token'),
sharedAuth.indexOf('${IAM_API_PATH}/oauth/token') + 1500,
);
expect(tokenBlock).toContain('application/x-www-form-urlencoded');
expect(tokenBlock).toContain('URLSearchParams');
});
it('Chrome background uses ${IAM_V1} for IAM URLs (no /api/ prefix)', () => {
// Find the IAM_V1 declaration and assert it.
expect(chromeBg).toContain('const IAM_V1 = `${IAM_API_URL}${IAM_API_PATH}`');
// No raw /api/login or /api/get-account anywhere in chrome bg.
expect(chromeBg).not.toMatch(/iam\.hanzo\.ai\/api\//);
});
it('Firefox background uses ${IAM_V1} for IAM URLs', () => {
expect(firefoxBg).toContain('const IAM_V1 = `${IAM_API}${IAM_API_PATH}`');
expect(firefoxBg).not.toMatch(/iam\.hanzo\.ai\/api\//);
});
});
describe('parseTabId centralization', () => {
it('browser-dispatch.ts imports parseTabId from shared/tab-id', () => {
expect(cdpBridge).toMatch(/import\s+\{[^}]*parseTabId[^}]*\}\s+from\s+['"]\.\/shared\/tab-id\.js['"]/);
});
it('browser-dispatch.ts no longer defines its own parseTabId', () => {
// The class private parseTabId existed as a duplicate before 1.9.0.
expect(cdpBridge).not.toMatch(/private\s+parseTabId\s*\(/);
});
it('background-firefox.ts imports parseTabId from shared/tab-id', () => {
expect(firefoxBg).toMatch(/import\s+\{[^}]*parseTabId[^}]*\}\s+from\s+['"]\.\/shared\/tab-id\.js['"]/);
});
it('background-firefox.ts no longer defines its own parseTabId', () => {
expect(firefoxBg).not.toMatch(/private\s+parseTabId\s*\(/);
});
it('background-firefox.ts uses parseTabId in Target.closeTarget (not parseInt fallback)', () => {
// The pre-1.9.0 code did `parseInt((params.targetId).replace('tab-',''),10)`.
// That was lossy on non-numeric strings and missed the anchored regex
// protection of parseTabId.
expect(firefoxBg).not.toMatch(/parseInt\(\(params\.targetId\b/);
});
});
describe('awaitPromise propagation', () => {
it('browser-dispatch.ts handleBridgeMessage Runtime.evaluate sets awaitPromise: true', () => {
// Find the handleBridgeMessage Runtime.evaluate path.
const evalSection = cdpBridge.slice(
cdpBridge.indexOf("case 'Runtime.evaluate':"),
cdpBridge.indexOf("case 'DOM.getDocument':"),
);
expect(evalSection).toContain('awaitPromise: true');
});
it('browser-dispatch.ts evaluate() helper uses awaitPromise: true', () => {
// The local `evaluate(tabId, expression)` method was the one missed
// before 1.9.0 — the bridge handler had it but this method didn't.
const helperSection = cdpBridge.slice(
cdpBridge.indexOf('async evaluate(tabId: number'),
cdpBridge.indexOf('async evaluate(tabId: number') + 900,
);
expect(helperSection).toContain('awaitPromise: true');
expect(helperSection).toContain('unwrapEvaluateResult');
});
it('both browsers route caller JS through wrapEvaluable (one canonical rule)', () => {
// The forgiving-evaluate rule (statement bodies → async IIFE, trailing
// value auto-returned) lives ONCE in shared/evaluable.ts. Each engine's
// evaluate surface must consume it — a fork here is how the browsers
// drift apart on what caller JS they accept.
for (const src of [cdpBridge, firefoxBg]) {
expect(src).toMatch(/import\s+\{[^}]*wrapEvaluable[^}]*\}\s+from\s+['"]\.\/shared\/evaluable\.js['"]/);
expect(src).toContain('wrapEvaluable(pickEvaluable(params))');
}
});
it('Firefox evaluate always resolves promises in-page before serialization', () => {
// executeScript's structured clone cannot transfer a Promise — the
// in-page body must settle it and hand back the value. Rejections ride
// the __hanzo_error channel runInPage throws on.
const evalSection = firefoxBg.slice(
firefoxBg.indexOf("case 'Runtime.evaluate':"),
firefoxBg.indexOf("case 'Page.captureScreenshot':"),
);
expect(evalSection).toContain('Promise.resolve(');
expect(evalSection).toContain('__hanzo_error');
});
});
describe('executeScript MV3 contract', () => {
it('background-firefox.ts uses scripting.executeScript with world: MAIN', () => {
// The Firefox path has used the MV3 scripting API since 1.8.6.
expect(firefoxBg).toMatch(/scripting\.executeScript/);
expect(firefoxBg).toMatch(/world:\s*['"]MAIN['"]/);
});
it('browser-control.ts uses scripting.executeScript with world: MAIN', () => {
// The big regression in 1.8.x: browser-control still used `func: new Function(...)`
// without the MAIN world hint, so async results were silently lost. 1.9.0
// mirrors the Firefox pattern.
expect(browserControl).toMatch(/scripting\.executeScript/);
expect(browserControl).toMatch(/world:\s*['"]MAIN['"]/);
expect(browserControl).toContain('Function(codeStr)()');
});
it('browser-control.ts handles the empty-{} promise drop', () => {
// Detect the looksEmpty / Promise.resolve fallback pattern.
expect(browserControl).toMatch(/Promise\.resolve\(/);
expect(browserControl).toMatch(/__hanzo_error/);
});
it('shipping code does NOT call the removed MV2 tabs.executeScript directly', () => {
// Allowed: reference inside the MV2 fallback branch (gated by `if`).
// Forbidden: as the primary call path. Probe that any such reference
// is wrapped behind a "legacy" / fallback comment.
const usages = [
...chromeBg.matchAll(/\btabs\.executeScript\b/g),
...firefoxBg.matchAll(/\btabs\.executeScript\b/g),
...browserControl.matchAll(/\btabs\.executeScript\b/g),
];
// Each remaining match must be in a fallback context (not the primary
// path). Heuristic: surrounding 200 chars include the words 'fallback'
// or 'legacy' or 'MV2'.
for (const m of usages) {
const idx = m.index!;
const file = m.input!;
const ctx = file.slice(Math.max(0, idx - 200), idx + 200);
expect(ctx).toMatch(/fallback|legacy|MV2/i);
}
});
});
describe('Sidebar / chat-panel surface', () => {
it('Chrome manifest does NOT declare side_panel', () => {
const m = JSON.parse(manifestChrome);
expect(m.side_panel).toBeUndefined();
expect(m.permissions).not.toContain('sidePanel');
});
it('Firefox manifest sidebar_action panel points at sidebar.html (Firefox-only fallback)', () => {
// Firefox has no chrome.sidePanel API, so a manifest sidebar_action
// is the only native sidebar entrypoint. Chrome MV3 must NOT have
// either side_panel or sidePanel permission — the Chrome chat
// surface is the page-overlay content-script.
const m = JSON.parse(manifestFirefox);
if (m.sidebar_action) {
expect(m.sidebar_action.default_panel).toBe('sidebar.html');
}
});
it('background.ts does NOT call chrome.sidePanel.setOptions', () => {
expect(chromeBg).not.toMatch(/chrome\.sidePanel\.setOptions/);
});
it('content-script chat overlay anchors to the right edge (not left)', () => {
// The right-anchor is the user's hard requirement. The chat panel
// is the #hanzo-page-overlay-root element. It anchors with
// `right: 0` (flush) by default and flips to `left: 0` only when
// [data-side="left"] is set explicitly.
const overlayBlock = contentScript.slice(
contentScript.indexOf('#hanzo-page-overlay-root {'),
contentScript.indexOf('#hanzo-page-overlay-root {') + 400,
);
expect(overlayBlock).toMatch(/right:\s*0/);
// The default (no data-side) block must NOT have a `left:` declaration.
expect(overlayBlock).not.toMatch(/^\s*left:\s/m);
});
it('popup.ts routes "Open Chat Panel" via page.overlay.show (NOT chrome.sidePanel.open)', () => {
expect(popupTs).toContain("action: 'page.overlay.show'");
// Double-check no leftover sidepanel call paths.
expect(popupTs).not.toMatch(/chrome\.sidePanel\.(open|toggle)/);
expect(popupTs).not.toMatch(/sidebarAction\.(open|toggle)/);
});
});
describe('Inspect-modifier shortcut', () => {
it('default is Ctrl alone (NOT Alt — Alt collides with macOS)', () => {
const shortcut = read('src/shared/shortcut.ts');
const block = shortcut.slice(
shortcut.indexOf('DEFAULT_INSPECT_SHORTCUT'),
shortcut.indexOf('DEFAULT_INSPECT_SHORTCUT') + 400,
);
expect(block).toMatch(/ctrl:\s*true/);
expect(block).toMatch(/alt:\s*false/);
});
it('content-script reloads shortcut on storage change (no page reload needed)', () => {
expect(contentScript).toMatch(/storage\.onChanged\.addListener/);
expect(contentScript).toMatch(/STORAGE_KEY_INSPECT/);
});
});
describe('OAuth client ID convention', () => {
it('client ID is `hanzo-browser` (registered IAM <org>-<app> client)', () => {
// hanzo-browser is the IAM application seeded for the extension, with
// redirectUris including https://hanzo.ai/callback. The earlier value
// `app-hanzo` was unregistered → token exchange returned invalid_client.
expect(sharedConfig).toContain("DEFAULT_CLIENT_ID = 'hanzo-browser'");
});
});
describe('webextension-polyfill integration', () => {
it('polyfill is the FIRST import in every entrypoint', () => {
// Order matters: the polyfill must define globalThis.browser before
// any other import reads it. ESBuild preserves import order during
// bundling, so the source order is what ships.
const entrypoints = [
['background.ts', chromeBg],
['background-firefox.ts', firefoxBg],
['content-script.ts', contentScript],
['popup.ts', popupTs],
];
for (const [name, src] of entrypoints) {
const polyfillIdx = src.indexOf("import 'webextension-polyfill'");
expect(polyfillIdx, `${name} missing polyfill import`).toBeGreaterThanOrEqual(0);
// No other `import` line should come before it.
const before = src.slice(0, polyfillIdx);
expect(before, `${name} has imports before the polyfill`)
.not.toMatch(/^\s*import\s/m);
}
});
it('shared/auth.ts exports a unified webExtensionAdapter', () => {
expect(sharedAuth).toMatch(/export\s+function\s+webExtensionAdapter\s*\(/);
});
it('chromeAdapter and firefoxAdapter still exist as compatibility aliases', () => {
// We cannot break callers that imported them.
expect(sharedAuth).toMatch(/export\s+function\s+chromeAdapter\s*\(/);
expect(sharedAuth).toMatch(/export\s+function\s+firefoxAdapter\s*\(/);
});
});
describe('Firefox register identity', () => {
it('Firefox sends `browser: BROWSER_NAME` so multi-browser routing works', () => {
// Pre-1.9.0 the register payload omitted `browser`, so the bridge
// server stored the client as 'unknown' and the user couldn't route
// commands by browser name. Verify the field is present.
const registerBlock = firefoxBg.slice(
firefoxBg.indexOf('private register():'),
firefoxBg.indexOf('private register():') + 1500,
);
expect(registerBlock).toMatch(/browser:\s*BROWSER_NAME/);
});
});
@@ -0,0 +1,93 @@
/**
* executeScript pattern coverage.
*
* The pre-1.9.0 bug was: `chrome.scripting.executeScript({ func: new Function(...) })`
* silently lost async results because:
* - Default world is ISOLATED, which can't see the page's globals
* (window.fetch in some sandboxes, framework instances, etc).
* - The MV3 API doesn't await Promises returned by `func`.
* - When the user's IIFE returned `undefined`, that JSON-encoded as `{}`
* and the caller couldn't tell success from failure.
*
* The 1.9.0 fix mirrors the Firefox pattern:
* - `world: 'MAIN'` so the page's own globals are visible.
* - A `Function(codeStr)()` trampoline so dynamic code strings still work.
* - Empty-{} detection that re-runs wrapped in `Promise.resolve(...)` to
* capture the resolved value.
*
* These tests exercise the helpers in isolation. End-to-end coverage
* lives in the e2e specs (Playwright with extension loaded).
*/
import { describe, it, expect } from 'vitest';
import { unwrapEvaluateResult, parseTabId, tabTarget } from '../src/shared/tab-id.js';
describe('Empty-{} Promise drop detection (the 1.8.x regression)', () => {
it('unwrapEvaluateResult({}) returns undefined (sentinel for "Promise was dropped")', () => {
expect(unwrapEvaluateResult({})).toBeUndefined();
});
it('non-empty object that has none of the known keys is preserved', () => {
const x = { foo: 1 };
expect(unwrapEvaluateResult(x)).toEqual(x);
});
it('arrays are preserved (NOT collapsed to undefined)', () => {
expect(unwrapEvaluateResult([])).toEqual([]);
expect(unwrapEvaluateResult([1, 2])).toEqual([1, 2]);
});
});
describe('parseTabId security and parity', () => {
it('rejects URL-embedded tab-N (security: prevents tabId injection)', () => {
expect(parseTabId('https://x.com/?tab-123')).toBeNull();
expect(parseTabId('javascript:alert(tab-1)')).toBeNull();
expect(parseTabId('data:tab-1,foo')).toBeNull();
});
it('accepts the bare wire formats Target.getTargets returns', () => {
expect(parseTabId('tab-1')).toBe(1);
expect(parseTabId('tab-1888868904')).toBe(1888868904);
expect(parseTabId('1888868904')).toBe(1888868904);
});
it('returns null on every invalid input (no exceptions, no NaN)', () => {
expect(parseTabId(undefined)).toBeNull();
expect(parseTabId(null)).toBeNull();
expect(parseTabId('')).toBeNull();
expect(parseTabId('NaN')).toBeNull();
expect(parseTabId('Infinity')).toBeNull();
expect(parseTabId({ tabId: 1 })).toBeNull();
expect(parseTabId(true)).toBeNull();
});
it('the 1.8.4 anchored-regex hardening is in place', () => {
// `1.5e10abc` and `123abc` were the cases the 1.8.4 hardening fixed.
expect(parseTabId('1.5e10abc')).toBeNull();
expect(parseTabId('123abc')).toBeNull();
expect(parseTabId('abc123')).toBeNull();
});
});
describe('tabTarget composes cleanly', () => {
it('empty source returns empty (or extra-only) object', () => {
expect(tabTarget(undefined)).toEqual({});
expect(tabTarget(undefined, { url: 'x' })).toEqual({ url: 'x' });
});
it('tabId wins over targetId', () => {
expect(tabTarget({ tabId: 5, targetId: 'tab-9' })).toEqual({ tabId: 5 });
});
it('targetId is fallback when tabId absent', () => {
expect(tabTarget({ targetId: 'tab-9' })).toEqual({ tabId: 'tab-9' });
});
it('null fields are dropped (so they do not collide with extras)', () => {
expect(tabTarget({ tabId: null, targetId: null } as any)).toEqual({});
});
it('preserves tabIndex when only that is provided', () => {
expect(tabTarget({ tabIndex: 3 })).toEqual({ tabIndex: 3 });
});
});
+3 -33
View File
@@ -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.
+21 -7
View File
@@ -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);
});
+5 -2
View File
@@ -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('');
});
});
+39 -3
View File
@@ -5,6 +5,7 @@ import {
createZapManager,
hasZapTool,
handleZapMessage,
setZapRequestHandler,
zapCallTool,
zapListResources,
zapReadResource,
@@ -17,7 +18,7 @@ import {
MSG_RESPONSE,
MSG_PING,
MSG_PONG,
DEFAULT_ZAP_PORTS,
HANZO_SERVICE_TYPE,
ZAP_RECONNECT_DELAY,
ZAP_DISCOVERY_TIMEOUT,
} from '../src/shared/zap';
@@ -40,8 +41,11 @@ describe('Protocol constants', () => {
expect(MSG_PONG).toBe(0xFF);
});
it('DEFAULT_ZAP_PORTS is correct', () => {
expect(DEFAULT_ZAP_PORTS).toEqual([9999, 9998, 9997, 9996, 9995]);
it('HANZO_SERVICE_TYPE is _hanzo._tcp.local. (HIP-0069 mDNS-only)', () => {
// Replaces the legacy DEFAULT_ZAP_PORTS port-probe list. Discovery is
// now exclusively mDNS — the OS-assigned ZAP port is advertised by
// hanzo-zap-mdns and consumed via the extension's mDNS resolver.
expect(HANZO_SERVICE_TYPE).toBe('_hanzo._tcp.local.');
});
it('timing constants are defined', () => {
@@ -286,3 +290,35 @@ describe('handleZapMessage', () => {
}
});
});
// ---------------------------------------------------------------------------
// setZapRequestHandler — closes the loop for ZAP-server-initiated RPC
// (the 2-process architecture where Python hanzo-mcps push browser
// actions TO the extension over the same socket).
// ---------------------------------------------------------------------------
describe('setZapRequestHandler', () => {
it('attaches the handler onto the manager', () => {
const mgr = createZapManager();
expect(mgr.requestHandler).toBeUndefined();
const handler = async () => ({ ok: true });
setZapRequestHandler(mgr, handler);
expect(mgr.requestHandler).toBe(handler);
});
it('replaces an existing handler', () => {
const mgr = createZapManager();
const a = async () => 'a';
const b = async () => 'b';
setZapRequestHandler(mgr, a);
setZapRequestHandler(mgr, b);
expect(mgr.requestHandler).toBe(b);
});
it('clears the handler when undefined', () => {
const mgr = createZapManager();
setZapRequestHandler(mgr, async () => null);
setZapRequestHandler(mgr, undefined);
expect(mgr.requestHandler).toBeUndefined();
});
});
@@ -0,0 +1,99 @@
/**
* webextension-polyfill integration.
*
* The 1.9.0 unification: every entrypoint imports `webextension-polyfill`
* as its first import. On Chrome/Edge that defines `globalThis.browser`
* as the Promise-returning WebExtension API; on Firefox/Safari it's a
* no-op because `browser.*` is already native.
*
* The unified `webExtensionAdapter()` factory in `shared/auth.ts` then
* picks up that global so we have ONE adapter codepath instead of two
* (chromeAdapter / firefoxAdapter) with subtly different behaviours.
*
* This suite verifies the contract structurally (the polyfill is in
* deps, the entrypoints import it, the adapter exists) runtime
* verification is in the e2e specs.
*/
import { describe, it, expect } from 'vitest';
import * as fs from 'fs';
import * as path from 'path';
const pkg = JSON.parse(
fs.readFileSync(path.join(__dirname, '..', 'package.json'), 'utf8'),
);
describe('webextension-polyfill is a runtime dependency', () => {
it('listed in dependencies (not devDependencies)', () => {
expect(pkg.dependencies['webextension-polyfill']).toBeDefined();
});
it('pinned to a known version (no caret) so MV3 behavior is reproducible', () => {
// Caret ranges across major versions of the polyfill have historically
// changed return-value shapes. Pin to a known version.
const v = pkg.dependencies['webextension-polyfill'];
expect(v).not.toMatch(/^\^/);
expect(v).toMatch(/^\d+\.\d+\.\d+$/);
});
});
describe('shared/auth.ts unified adapter', () => {
const sharedAuth = fs.readFileSync(
path.join(__dirname, '..', 'src/shared/auth.ts'),
'utf8',
);
it('exports webExtensionAdapter with storage.get returning a Promise', () => {
expect(sharedAuth).toMatch(/export\s+function\s+webExtensionAdapter\s*\(\s*\)\s*:\s*BrowserAdapter/);
// Verify the adapter wraps with Promise.resolve so the surface is
// identical regardless of underlying API style.
expect(sharedAuth).toMatch(/Promise\.resolve\(b\.storage\.local\.get/);
});
it('falls back to globalThis.chrome if globalThis.browser is missing', () => {
// The legacy fallback handles tests / non-extension contexts where
// neither has been set up. It uses ?? so an empty browser object
// doesn't shadow chrome.
expect(sharedAuth).toMatch(/globalThis as any\)\.browser\s*\?\?\s*\(globalThis as any\)\.chrome/);
});
it('chromeAdapter and firefoxAdapter are aliases for back-compat', () => {
// Existing callers from before 1.9.0 must continue to work.
expect(sharedAuth).toMatch(/export\s+function\s+chromeAdapter[\s\S]*?return\s+webExtensionAdapter\(\)/);
expect(sharedAuth).toMatch(/export\s+function\s+firefoxAdapter[\s\S]*?return\s+webExtensionAdapter\(\)/);
});
});
describe('build pipeline ships the polyfill', () => {
it('chrome bundle includes webextension-polyfill code', () => {
const distChrome = path.join(
__dirname,
'..',
'dist/browser-extension/chrome/background.js',
);
if (!fs.existsSync(distChrome)) {
// Build hasn't run yet — skip. CI runs build before tests.
return;
}
const bg = fs.readFileSync(distChrome, 'utf8');
// The polyfill module sets `globalThis.browser`. We grep for its
// distinctive marker so we know it actually ended up in the bundle.
expect(bg).toMatch(/webextension-polyfill|browserPolyfill|chromeApiAvailable|globalThis\.browser/);
});
it('firefox bundle does not break if the polyfill is a no-op', () => {
const distFirefox = path.join(
__dirname,
'..',
'dist/browser-extension/firefox/background.js',
);
if (!fs.existsSync(distFirefox)) return;
const bg = fs.readFileSync(distFirefox, 'utf8');
// The Firefox bundle should still be valid JS regardless of whether
// the polyfill no-ops or wraps. Probe for any obvious syntax failure.
expect(bg.length).toBeGreaterThan(1000);
// Must NOT contain leftover NodeJS-only syntax (require()) since the
// bundle target is browser.
expect(bg).not.toMatch(/^const\s+\w+\s*=\s*require\(/m);
});
});
+1 -1
View File
@@ -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": {
+3 -3
View File
@@ -1,10 +1,10 @@
{
"name": "@hanzo/dxt",
"version": "1.8.0",
"version": "1.8.1",
"description": "Hanzo AI DXT Bundle",
"main": "dist/index.js",
"main": "server.js",
"scripts": {
"build": "echo 'DXT bundle created successfully'",
"build": "pnpm --filter @hanzo/mcp run build:cli && cp ../mcp/dist/cli.js server.js && chmod +x server.js",
"package": "npm run build"
},
"devDependencies": {
+55 -76
View File
@@ -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;
+9
View File
@@ -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")
+1 -1
View File
@@ -4,7 +4,7 @@ pluginGroup = ai.hanzo
pluginName = Hanzo AI
pluginRepositoryUrl = https://github.com/hanzoai/hanzo-ai-jetbrains
# SemVer format -> https://semver.org
pluginVersion = 1.8.0
pluginVersion = 1.9.29
# Supported build number ranges and IntelliJ Platform versions -> https://plugins.jetbrains.com/docs/intellij/build-number-ranges.html
pluginSinceBuild = 233
+1 -1
View File
@@ -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"
+19 -15
View File
@@ -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); }
+10 -6
View File
@@ -51,12 +51,16 @@ function zapDecode(data: Uint8Array): { type: number; payload: any } | null {
// ── Protocol Constants ────────────────────────────────────────────────
const MSG_HANDSHAKE = 0x01; // Init
const MSG_HANDSHAKE_OK = 0x02; // InitAck
const MSG_REQUEST = 0x10; // Push
const MSG_RESPONSE = 0x12; // Resolve
const MSG_PING = 0xf0;
const MSG_PONG = 0xf1;
// Wire constants must match @hanzo/browser-extension/src/shared/zap.ts.
// Earlier drafts used 0x12/0xf0/0xf1 — those were wrong and silently broke
// extension <-> mcp sessions. Canonical values live in shared/zap.ts and
// are pinned by tests.
const MSG_HANDSHAKE = 0x01;
const MSG_HANDSHAKE_OK = 0x02;
const MSG_REQUEST = 0x10;
const MSG_RESPONSE = 0x11;
const MSG_PING = 0xFE;
const MSG_PONG = 0xFF;
const ZAP_PORTS = [9999, 9998, 9997, 9996, 9995];
const SERVER_ID = `mcp-${Date.now().toString(36)}`;
+73
View File
@@ -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).
+55
View File
@@ -0,0 +1,55 @@
# Test Hanzo AI in Word/Excel on Windows
This is the `localhost` build of the Hanzo Office add-in — a self-contained kit
to try it on your own machine before it's on AppSource. No Hanzo infra, no IAM
setup: paste a Hanzo API key and go.
## What's in this zip
- `manifest.xml` — the add-in manifest (points at `https://localhost:3000`)
- `taskpane.html` / `taskpane.js` / `assets/` — the add-in itself
- `serve.mjs` — a tiny HTTPS server for the files above
- this guide
## Steps (5 minutes)
You need [Node.js](https://nodejs.org) installed.
1. **Trust a localhost HTTPS certificate** (once per machine — Office refuses an
untrusted task-pane origin):
```powershell
npx office-addin-dev-certs install
```
2. **Serve the add-in** from this folder:
```powershell
node serve.mjs
```
Leave it running. It serves `https://localhost:3000`.
3. **Sideload the add-in.** Easiest path — **Word or Excel on the web**
(office.com): open a document → **Home → Add-ins → More Add-ins → My Add-ins
→ Upload My Add-in** → pick this folder's `manifest.xml`.
Desktop Word/Excel on Windows: put this folder on a
[network shared-folder catalog](https://learn.microsoft.com/office/dev/add-ins/testing/create-a-network-shared-folder-catalog-for-task-pane-and-content-add-ins),
trust it in **File → Options → Trust Center → Trusted Add-in Catalogs**, then
**Home → Add-ins → Shared Folder → Hanzo AI**.
4. **Use it.** Click **Hanzo AI** on the Home tab to open the pane. Expand
**"Or use a Hanzo API key"**, paste your `hk-…` key (from
console.hanzo.ai → API keys), **Save**. Select text or a range, type an
instruction, **Ask Hanzo**, then **Insert**.
## Notes
- The API key is stored in your Office roaming settings (per-user, encrypted at
rest by Office) — not in this folder.
- Sign-in with your Hanzo account (instead of an API key) works once the
production add-in is hosted at `office.hanzo.ai`; the API key is the
zero-setup path for local testing.
- Model calls go to `https://api.hanzo.ai/v1` — the same gateway the browser
extension uses.
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 956 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

+182
View File
@@ -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>
+24
View File
@@ -0,0 +1,24 @@
{
"name": "@hanzo/office-addin",
"version": "1.9.29",
"description": "Hanzo AI for Microsoft Office \u2014 Word, Excel, and PowerPoint task pane. AI over your document, wired to the same api.hanzo.ai gateway and hanzo.id IAM as the browser extension.",
"private": true,
"dependencies": {
"@hanzo/auth": "workspace:*"
},
"scripts": {
"build": "node src/build.js",
"watch": "node src/build.js --watch",
"test": "vitest run"
},
"devDependencies": {
"@types/office-js": "^1.0.377",
"esbuild": "^0.25.8",
"typescript": "^5.8.3",
"vitest": "^3.2.6"
},
"engines": {
"node": ">=18.0.0"
},
"license": "MIT"
}
+74
View File
@@ -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.');
});
+23
View File
@@ -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>
+131
View File
@@ -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 };
+84
View File
@@ -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);
});
+182
View File
@@ -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);
}
+10
View File
@@ -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>
+10
View File
@@ -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 */
});
+30
View File
@@ -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`;
}
+113
View File
@@ -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');
}
}
+88
View File
@@ -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>
+173
View File
@@ -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);
}
+131
View File
@@ -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/);
});
});
+26
View File
@@ -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('');
});
});
+26
View File
@@ -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']]);
});
});
+15
View File
@@ -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"]
}
+8
View File
@@ -0,0 +1,8 @@
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
include: ['tests/**/*.test.ts'],
environment: 'node',
},
});
+2 -2
View File
@@ -16,7 +16,7 @@
"js-yaml": "^4.1.0",
"node-fetch": "^3.3.2",
"@modelcontextprotocol/sdk": "^1.15.0",
"uuid": "^10.0.0",
"uuid": "^11.1.1",
"ws": "^8.18.0"
},
"devDependencies": {
@@ -28,7 +28,7 @@
"@types/vscode": "^1.101.0",
"eslint": "^8.57.1",
"typescript": "^5.8.3",
"vitest": "^0.34.6"
"vitest": "^3.2.6"
},
"engines": {
"node": ">=18.0.0"
+15 -27
View File
@@ -8,7 +8,7 @@ import * as crypto from 'crypto';
export interface HanzoAuthConfig {
apiUrl: string;
iamUrl: string; // Casdoor API (iam.hanzo.ai)
iamUrl: string; // Hanzo IAM API (iam.hanzo.ai)
iamLoginUrl: string; // Login UI (hanzo.id)
clientId: string;
scope: string;
@@ -173,7 +173,7 @@ export class HanzoAuth extends EventEmitter {
this.server.listen(port, () => {
// Authorization Code + PKCE (OAuth 2.1 standard)
const authUrl = new URL('/oauth/authorize', this.config.iamLoginUrl);
const authUrl = new URL('/v1/iam/oauth/authorize', this.config.iamLoginUrl);
authUrl.searchParams.set('client_id', this.config.clientId);
authUrl.searchParams.set('response_type', 'code');
authUrl.searchParams.set('redirect_uri', `http://localhost:${port}/callback`);
@@ -200,18 +200,18 @@ export class HanzoAuth extends EventEmitter {
}
private async exchangeCodeForTokens(code: string, codeVerifier: string): Promise<void> {
const response = await fetch(`${this.config.iamUrl}/oauth/token`, {
const response = await fetch(`${this.config.iamUrl}/v1/iam/oauth/token`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
'Content-Type': 'application/x-www-form-urlencoded'
},
body: JSON.stringify({
body: new URLSearchParams({
grant_type: 'authorization_code',
code,
client_id: this.config.clientId,
code_verifier: codeVerifier,
redirect_uri: 'http://localhost:51234/callback'
})
}).toString()
});
if (!response.ok) {
@@ -241,24 +241,12 @@ export class HanzoAuth extends EventEmitter {
if (!this.credentials?.accessToken) return;
const headers = { 'Authorization': `Bearer ${this.credentials.accessToken}` };
// /api/get-account returns full profile; /api/userinfo only returns sub
try {
const acctResp = await fetch(`${this.config.iamUrl}/api/get-account`, { headers });
if (acctResp.ok) {
const acctJson = await acctResp.json() as any;
const acct = acctJson.data || acctJson;
if (acct.name || acct.email) {
this.credentials.userId = acct.id || acct.sub;
this.credentials.email = acct.email;
return;
}
}
} catch { /* fall through */ }
const response = await fetch(`${this.config.iamUrl}/api/userinfo`, { headers });
// HIP-0111 canonical OIDC userinfo. Returns standard claims (sub,
// email, name); no legacy IAM `/api/get-account` profile shape.
const response = await fetch(`${this.config.iamUrl}/v1/iam/oauth/userinfo`, { headers });
if (response.ok) {
const user = await response.json() as any;
this.credentials.userId = user.id || user.sub;
this.credentials.userId = user.sub || user.id;
this.credentials.email = user.email;
}
}
@@ -318,16 +306,16 @@ export class HanzoAuth extends EventEmitter {
}
try {
const response = await fetch(`${this.config.iamUrl}/oauth/token`, {
const response = await fetch(`${this.config.iamUrl}/v1/iam/oauth/token`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
'Content-Type': 'application/x-www-form-urlencoded'
},
body: JSON.stringify({
body: new URLSearchParams({
grant_type: 'refresh_token',
refresh_token: this.credentials.refreshToken,
client_id: this.config.clientId
})
}).toString()
});
if (!response.ok) {
@@ -356,7 +344,7 @@ export class HanzoAuth extends EventEmitter {
async logout(): Promise<void> {
if (this.credentials?.accessToken) {
try {
await fetch(`${this.config.iamUrl}/oauth/revoke`, {
await fetch(`${this.config.iamUrl}/v1/iam/oauth/logout`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',

Some files were not shown because too many files have changed in this diff Show More