From 439cec32639314c0a3361872f3c8c93eb1cdf1bc Mon Sep 17 00:00:00 2001 From: xNet Test Date: Thu, 9 Jul 2026 10:50:03 -0700 Subject: [PATCH] feat(native-bridge): native-messaging bridge spike (0289 C) The pure-web PWA (no bundled Electron host) can reach a local model with no loopback HTTP port, no CORS, and no DNS-rebinding surface (the 1Password pattern). Follow-up to exploration 0289; keeps the shipped pairing-code bridge (Option B) as the default. Private, tested POC in packages/native-bridge-extension: - MV3 extension: externally_connectable allowlist + fixed key -> stable ID - background.js relay: page <-> native host, sender.origin re-checked - native host (xnet-bridge-host.mjs): 4-byte-LE stdio framing -> backend, drains in-flight replies before exit; cli backend spawns claude/codex with NO port, daemon backend forwards to the hardened bridge + token - install-host.mjs pins native-host allowed_origins to the derived extension ID (crx-id.mjs); page connector mirrors the ChatAgent contract - 28 tests (framing, relay + both backends incl. a round-trip through the real hardened daemon, page protocol, ext-ID derivation, real-process e2e) Registered under the vitest integration project; checks off the last 0289 item with a UX comparison (B stays default, C is an opt-in upgrade). Co-Authored-By: Claude Opus 4.8 Signed-off-by: xNet Test --- ...CONNECTING_THE_BROWSER_TO_A_LOCAL_MODEL.md | 168 +++++++++++------- packages/native-bridge-extension/.gitignore | 4 + packages/native-bridge-extension/README.md | 133 ++++++++++++++ .../extension/background.js | 79 ++++++++ .../extension/manifest.json | 15 ++ .../native-bridge-extension/host/backends.mjs | 110 ++++++++++++ .../host/manifest.template.json | 7 + .../host/native-messaging.mjs | 74 ++++++++ .../native-bridge-extension/host/relay.mjs | 44 +++++ .../host/xnet-bridge-host.mjs | 93 ++++++++++ packages/native-bridge-extension/package.json | 16 ++ .../scripts/crx-id.mjs | 30 ++++ .../scripts/gen-extension-key.mjs | 44 +++++ .../scripts/install-host.mjs | 85 +++++++++ .../test/crx-id.test.mjs | 29 +++ .../test/extension-connector.test.mjs | 69 +++++++ .../test/host-e2e.test.mjs | 54 ++++++ .../test/native-messaging.test.mjs | 74 ++++++++ .../test/relay.test.mjs | 108 +++++++++++ .../web/extension-connector.mjs | 70 ++++++++ pnpm-lock.yaml | 9 + vitest.config.ts | 6 +- 22 files changed, 1260 insertions(+), 61 deletions(-) create mode 100644 packages/native-bridge-extension/.gitignore create mode 100644 packages/native-bridge-extension/README.md create mode 100644 packages/native-bridge-extension/extension/background.js create mode 100644 packages/native-bridge-extension/extension/manifest.json create mode 100644 packages/native-bridge-extension/host/backends.mjs create mode 100644 packages/native-bridge-extension/host/manifest.template.json create mode 100644 packages/native-bridge-extension/host/native-messaging.mjs create mode 100644 packages/native-bridge-extension/host/relay.mjs create mode 100755 packages/native-bridge-extension/host/xnet-bridge-host.mjs create mode 100644 packages/native-bridge-extension/package.json create mode 100644 packages/native-bridge-extension/scripts/crx-id.mjs create mode 100755 packages/native-bridge-extension/scripts/gen-extension-key.mjs create mode 100755 packages/native-bridge-extension/scripts/install-host.mjs create mode 100644 packages/native-bridge-extension/test/crx-id.test.mjs create mode 100644 packages/native-bridge-extension/test/extension-connector.test.mjs create mode 100644 packages/native-bridge-extension/test/host-e2e.test.mjs create mode 100644 packages/native-bridge-extension/test/native-messaging.test.mjs create mode 100644 packages/native-bridge-extension/test/relay.test.mjs create mode 100644 packages/native-bridge-extension/web/extension-connector.mjs diff --git a/docs/explorations/0289_[_]_SECURELY_CONNECTING_THE_BROWSER_TO_A_LOCAL_MODEL.md b/docs/explorations/0289_[_]_SECURELY_CONNECTING_THE_BROWSER_TO_A_LOCAL_MODEL.md index 3b538b2b0..798caff33 100644 --- a/docs/explorations/0289_[_]_SECURELY_CONNECTING_THE_BROWSER_TO_A_LOCAL_MODEL.md +++ b/docs/explorations/0289_[_]_SECURELY_CONNECTING_THE_BROWSER_TO_A_LOCAL_MODEL.md @@ -5,7 +5,7 @@ The user wants to drive a model from **inside the xNet web app** while the model runs **on their own machine** — their `claude` (Claude Code) or `codex` CLI subscription, or a raw local server like Ollama / LM Studio / llama.cpp. The -requirement is not just "make it work" but **make it work *securely***: an +requirement is not just "make it work" but **make it work _securely_**: an `https://` page reaching a plaintext `http://127.0.0.1` daemon is exactly the shape of attack that has produced a string of real CVEs against local AI tools (Ollama DNS-rebinding, AnythingLLM CORS bypass). We need a path that: @@ -22,7 +22,7 @@ shape of attack that has produced a string of real CVEs against local AI tools xNet **already has both halves of this** from explorations 0174 (connector ladder) and 0194 (agent bridge), but they are wired for the Electron build and -carry security gaps that make the *deployed browser* case both broken and unsafe: +carry security gaps that make the _deployed browser_ case both broken and unsafe: - The **`bridge` tier** — a loopback daemon at `http://127.0.0.1:31416` ([`packages/devkit/src/bridge-server.ts`](packages/devkit/src/bridge-server.ts)) @@ -35,13 +35,13 @@ carry security gaps that make the *deployed browser* case both broken and unsafe ([`packages/plugins/src/ai/connectors/detect.ts`](packages/plugins/src/ai/connectors/detect.ts), [`providers.ts`](packages/plugins/src/ai/providers.ts)). -Three concrete problems block the *secure browser* story: +Three concrete problems block the _secure browser_ story: 1. **The bridge has no authentication token.** Its sibling — the MCP HTTP transport ([`packages/plugins/src/services/mcp-http.ts`](packages/plugins/src/services/mcp-http.ts)) — requires a constant-time-compared `x-xnet-pairing` secret. The chat bridge - does **not**: it trusts *origin + loopback bind alone*, and it does **no + does **not**: it trusts _origin + loopback bind alone_, and it does **no `Host`-header validation**. That is precisely the assumption DNS rebinding breaks (the Ollama CVE-2024-28224 class). Any allowlisted origin — or, via rebinding, any site — could drive the user's paid coding agent. @@ -77,15 +77,15 @@ sidesteps HTTP/CORS/DNS-rebinding entirely. defines `ConnectorTier = managed | bridge | cloud-key | local-server | webllm | prompt-api`. Two of these are "local model on your machine": -| Tier | What it is | Probe | Provider mapping | -| --- | --- | --- | --- | -| `bridge` | User's own `claude`/`codex` CLI, wrapped as an HTTP daemon | `GET http://127.0.0.1:31416/health` → `{ ok: true }` | OpenAI-compatible → the daemon | -| `local-server` | Ollama / LM Studio running directly | `GET :11434/api/tags`, `GET :1234/v1/models` | OpenAI-compatible / Ollama provider | +| Tier | What it is | Probe | Provider mapping | +| -------------- | ---------------------------------------------------------- | ---------------------------------------------------- | ----------------------------------- | +| `bridge` | User's own `claude`/`codex` CLI, wrapped as an HTTP daemon | `GET http://127.0.0.1:31416/health` → `{ ok: true }` | OpenAI-compatible → the daemon | +| `local-server` | Ollama / LM Studio running directly | `GET :11434/api/tags`, `GET :1234/v1/models` | OpenAI-compatible / Ollama provider | Ranking + probes live in [`detect.ts:63-208`](packages/plugins/src/ai/connectors/detect.ts): `bridge` is `preference: 1` (just under managed cloud), `local-server` is `preference: 3`. -The panel auto-selects the most-preferred *available* tier +The panel auto-selects the most-preferred _available_ tier ([`ai-chat-connector.ts`](apps/web/src/workbench/views/ai-chat-connector.ts)). ### The bridge daemon — the secure-ish spine we already have @@ -115,19 +115,19 @@ and from the CLI (`xnet bridge serve`, ### The security gap — compare the two loopback servers -The MCP HTTP transport is the *same shape* daemon but properly hardened. Diffing +The MCP HTTP transport is the _same shape_ daemon but properly hardened. Diffing the two is the crux of this exploration: -| Control | `mcp-http.ts` (MCP) | `bridge-server.ts` (chat) | -| --- | --- | --- | -| Loopback-only bind | ✅ | ✅ | -| Origin allowlist, never `*` | ✅ | ✅ | -| PNA / preflight | ✅ | ✅ | -| **Pairing token** (`x-xnet-pairing`, constant-time) | ✅ [`:36,94`](packages/plugins/src/services/mcp-http.ts:36) | ❌ **none** | -| **`Host`-header validation** (anti-DNS-rebind) | ❌ (also missing) | ❌ **none** | -| Body-size cap | ✅ | ✅ [`:28`](packages/devkit/src/bridge-server.ts:28) | +| Control | `mcp-http.ts` (MCP) | `bridge-server.ts` (chat) | +| --------------------------------------------------- | ----------------------------------------------------------- | --------------------------------------------------- | +| Loopback-only bind | ✅ | ✅ | +| Origin allowlist, never `*` | ✅ | ✅ | +| PNA / preflight | ✅ | ✅ | +| **Pairing token** (`x-xnet-pairing`, constant-time) | ✅ [`:36,94`](packages/plugins/src/services/mcp-http.ts:36) | ❌ **none** | +| **`Host`-header validation** (anti-DNS-rebind) | ❌ (also missing) | ❌ **none** | +| Body-size cap | ✅ | ✅ [`:28`](packages/devkit/src/bridge-server.ts:28) | -So the daemon that drives the user's **paid coding agent** is the *less* guarded +So the daemon that drives the user's **paid coding agent** is the _less_ guarded of the two. And neither validates `Host`, which is the specific fix Ollama shipped for its DNS-rebinding CVE. @@ -137,13 +137,13 @@ shipped for its DNS-rebinding CVE. `createBridgeServer({ agent, agentName, version })` ([`agent-bridge-manager.ts:80`](apps/electron/src/main/agent-bridge-manager.ts:80)) — **no `allowedOrigins`**, so `https://app.xnet.fyi` (a non-loopback origin) - is rejected by `isOriginAllowed`. Only a page *also* served from loopback can + is rejected by `isOriginAllowed`. Only a page _also_ served from loopback can talk to it. - CSP in [`apps/web/index.html`](apps/web/index.html) `connect-src` includes `http://localhost:*` and `ws://localhost:*` but **not** `http://127.0.0.1:*`. The bridge's default URL (`DEFAULT_BRIDGE_URL = 'http://127.0.0.1:31416'`, [`detect.ts:63`](packages/plugins/src/ai/connectors/detect.ts:63)) is a - `127.0.0.1` literal → blocked. (This is *also* the safer literal to prefer per + `127.0.0.1` literal → blocked. (This is _also_ the safer literal to prefer per Spotify's loopback guidance, so the fix is "add 127.0.0.1", not "drop it".) ### How the pieces connect today @@ -174,20 +174,20 @@ flowchart LR ### Local model servers — can a browser talk to them, and how safely? -| System | Port | Transport | Browser-direct? | Auth | CORS default | -| --- | --- | --- | --- | --- | --- | -| **Claude Code / Agent SDK** | none | stdio subprocess | ❌ needs your own HTTP/WS relay | build it yourself | n/a | -| **Codex CLI** (`codex mcp-server`) | stdio (or HTTP) | stdio / Streamable HTTP | ⚠️ only if HTTP transport configured | Bearer / OAuth (HTTP mode) | you add it | -| **Ollama** | 11434 | HTTP (OpenAI `/v1` + `/api`) | ⚠️ from allowlisted origins only | **none** | conservative allowlist (`localhost`, app schemes); **not** arbitrary `https://` — extend via `OLLAMA_ORIGINS` | -| **LM Studio** | 1234 | HTTP (OpenAI + Anthropic) | ✅ if CORS toggled on | optional token (off) | off by default | -| **llama.cpp / llamafile** | 8080 | HTTP + SSE | ✅ **unconditionally** — reflects any `Origin`, allows credentials | optional `--api-key` | **wide open** (no flag to disable) | -| **MCP Streamable HTTP** | any | HTTP POST + SSE | ✅ | OAuth 2.1 resource-server (RFC 9728/8707) | spec **mandates** Origin validation + loopback bind | -| **ACP (Zed)** | n/a | JSON-RPC over stdio | ❌ remote/HTTP transport still WIP | `authenticate` RPC | n/a | +| System | Port | Transport | Browser-direct? | Auth | CORS default | +| ---------------------------------- | --------------- | ---------------------------- | ------------------------------------------------------------------ | ----------------------------------------- | ------------------------------------------------------------------------------------------------------------- | +| **Claude Code / Agent SDK** | none | stdio subprocess | ❌ needs your own HTTP/WS relay | build it yourself | n/a | +| **Codex CLI** (`codex mcp-server`) | stdio (or HTTP) | stdio / Streamable HTTP | ⚠️ only if HTTP transport configured | Bearer / OAuth (HTTP mode) | you add it | +| **Ollama** | 11434 | HTTP (OpenAI `/v1` + `/api`) | ⚠️ from allowlisted origins only | **none** | conservative allowlist (`localhost`, app schemes); **not** arbitrary `https://` — extend via `OLLAMA_ORIGINS` | +| **LM Studio** | 1234 | HTTP (OpenAI + Anthropic) | ✅ if CORS toggled on | optional token (off) | off by default | +| **llama.cpp / llamafile** | 8080 | HTTP + SSE | ✅ **unconditionally** — reflects any `Origin`, allows credentials | optional `--api-key` | **wide open** (no flag to disable) | +| **MCP Streamable HTTP** | any | HTTP POST + SSE | ✅ | OAuth 2.1 resource-server (RFC 9728/8707) | spec **mandates** Origin validation + loopback bind | +| **ACP (Zed)** | n/a | JSON-RPC over stdio | ❌ remote/HTTP transport still WIP | `authenticate` RPC | n/a | Key takeaways: - **There is no zero-config "point a browser at Claude Code."** The Agent SDK - explicitly documents that *you* build the HTTP/WS layer in front of the stdio + explicitly documents that _you_ build the HTTP/WS layer in front of the stdio subprocess ([Hosting the Agent SDK](https://code.claude.com/docs/en/agent-sdk/hosting)). xNet's `cliChatAgent` + `bridge-server.ts` **is** exactly that relay — so we're already on the officially-sanctioned pattern, not fighting it. @@ -195,10 +195,10 @@ Key takeaways: through process isolation" ([Claude Code MCP docs](https://code.claude.com/docs/en/mcp)). A browser can't speak stdio, so a relay is unavoidable. - **llama.cpp is the cautionary tale**: its server reflects any `Origin` and - allows credentials *unconditionally* — any open web page can already drive a + allows credentials _unconditionally_ — any open web page can already drive a user's local `llama-server`. Our bridge must not become this. - **MCP Streamable HTTP is the only native protocol here designed with - DNS-rebinding/CORS in mind from the spec level** — it *mandates* servers + DNS-rebinding/CORS in mind from the spec level** — it _mandates_ servers validate `Origin`, bind `127.0.0.1`, and authenticate, and (2025-06 update) forbids passing client tokens upstream. Aligning our wire contract with it inherits a reviewed threat model and future MCP interop. @@ -211,7 +211,7 @@ Key takeaways: `Host` header** (exact `localhost:`/`127.0.0.1:`) **+ require a token.** This is the Ollama **CVE-2024-28224** fix (NCC Group; exploitable in ~3s). See also **AnythingLLM GHSA-24qj-pw4h-3jmm** — `cors({ origin: true })` - + an auth bypass let any site exfiltrate workspaces/keys. + - an auth bypass let any site exfiltrate workspaces/keys. - **Mixed content**: loopback (`127.0.0.1`, `::1`, `localhost`, `*.localhost`) is a "potentially trustworthy" origin, so `https://` → `http://localhost` fetch/WebSocket is **not** mixed-content-blocked (has been true for years; @@ -225,7 +225,7 @@ Key takeaways: - **Firefox**: rolling its own local-network permission (Strict-ETP users in **FF149**, general in **FF151**). **Safari/WebKit**: standards-position "support" but **not shipped** — today the most permissive (server-side auth is - the *only* protection there). + the _only_ protection there). - **Consequence**: **don't rely on the browser to protect the user.** The one layer under our control on every browser/version is **server-side auth + `Host`/`Origin` validation on the daemon.** Browser prompts are @@ -238,7 +238,7 @@ Key takeaways: - **RFC 8252** (OAuth for native apps): `http://127.0.0.1:` redirect is fine because the request never leaves the device; layer **PKCE**. Spotify now steers redirect URIs to the `127.0.0.1` literal over `localhost`. -- **Capability token in the URL *fragment*** (`#token=…`) — never sent to a +- **Capability token in the URL _fragment_** (`#token=…`) — never sent to a server or logged. - **Native messaging** (1Password): browser extension ↔ desktop app via `chrome.runtime.connectNative` + host-manifest extension-ID allowlist — **no @@ -252,12 +252,12 @@ Key takeaways: 1. **We already have the right architecture** (loopback relay wrapping the user's own CLI) — it matches Anthropic's official hosting guidance and MCP's threat - model. The work is *hardening + wiring*, not a rebuild. + model. The work is _hardening + wiring_, not a rebuild. 2. **The chat bridge is under-secured relative to its sibling.** It lacks the pairing token that `mcp-http.ts` already implements, and neither validates `Host`. The daemon driving a paid subscription is the weakest of the two. 3. **The deployed PWA is doubly blocked**: no `allowedOrigins` on the daemon, and - a CSP that omits `http://127.0.0.1:*` while the bridge default *is* a + a CSP that omits `http://127.0.0.1:*` while the bridge default _is_ a `127.0.0.1` URL. 4. **Direct `local-server` access pushes security onto the user** and doesn't teach the safe framing (never `OLLAMA_ORIGINS=*`). @@ -275,23 +275,23 @@ Key takeaways: - **Cons:** no auth on Ollama at all; relies on the user editing `OLLAMA_ORIGINS` correctly (wildcard = any site can drive their model); no `Host` validation on their side; llama.cpp is wide open. Security is entirely the user's problem. -- **Verdict:** keep as an *advanced* tier with safe-origin guidance; never the +- **Verdict:** keep as an _advanced_ tier with safe-origin guidance; never the default trust story. -### B. Harden the loopback bridge into the secure spine *(recommended)* +### B. Harden the loopback bridge into the secure spine _(recommended)_ Add a **pairing token** (reuse `mcp-http.ts`'s constant-time check), **`Host` validation**, an **explicit origin allowlist** including the deployed PWA origin, -out-of-band token delivery, and fix the CSP. The bridge can *also* front Ollama +out-of-band token delivery, and fix the CSP. The bridge can _also_ front Ollama (proxy `/v1/chat/completions` to `:11434`) so even "raw local model" access flows through one audited, authenticated door. -- **Pros:** one hardened trust boundary for *all* local model access; works for +- **Pros:** one hardened trust boundary for _all_ local model access; works for Claude Code / Codex (the user's actual ask) and for Ollama; MCP-aligned; survives every browser because auth is server-side; the token defeats DNS-rebinding + drive-by sites. - **Cons:** needs a native host process (Electron or `xnet bridge serve`) — the - pure-web PWA can't *spawn* it, only *connect* to it; token-pairing UX to build. + pure-web PWA can't _spawn_ it, only _connect_ to it; token-pairing UX to build. - **Verdict:** the spine. Most of it already exists. ### C. Browser-extension native-messaging bridge (1Password pattern) @@ -315,7 +315,7 @@ talks to the extension. ### E. In-tab model (WebLLM / Gemini Nano) — no connection at all -Already implemented (0252): WebGPU model runs *in the page*, nothing to secure. +Already implemented (0252): WebGPU model runs _in the page_, nothing to secure. - **Pros:** no network, no daemon, fully private; the true "nothing installed" path. @@ -350,7 +350,7 @@ Concretely, in priority order: and pass `allowedOrigins: [deployedWebOrigin, …]` when the Electron/CLI host starts the bridge. 2. **Harden `bridge-server.ts` to match `mcp-http.ts`**: per-launch pairing - token (constant-time compare), **`Host`-header validation** (add to *both* + token (constant-time compare), **`Host`-header validation** (add to _both_ servers), keep the origin allowlist + PNA header. 3. **Deliver the token out-of-band**: Electron injects it via preload; the web PWA shows a "paste your bridge pairing code" field fed by what @@ -361,7 +361,7 @@ Concretely, in priority order: 5. **Add a `loopback-network` permission UX**: query the permission, and on denial show "Allow local network access to use your local model" instead of a silent failure. -6. **Guide the direct tier**: when `local-server` is selected, show the *exact* +6. **Guide the direct tier**: when `local-server` is selected, show the _exact_ `OLLAMA_ORIGINS=https://app.xnet.fyi` line (never `*`) and a one-line "why". 7. **Follow-up: the native-messaging extension (C)** for a browser-only install with no bundled daemon. @@ -426,7 +426,7 @@ flowchart TB style Agent fill:#efe,stroke:#0a0 ``` -Note how `Host` validation (G1) stops the DNS-rebind path *before* origin/token +Note how `Host` validation (G1) stops the DNS-rebind path _before_ origin/token even matter — a rebinding page sends `Host: evil.com`, so it never reaches G2. ## Example Code @@ -524,9 +524,7 @@ function bridgeProviderConfig(baseUrl: string, token: string): AIProviderConfig // graceful Local Network Access UX before probing the bridge async function ensureLoopbackAllowed(): Promise<'granted' | 'prompt' | 'denied'> { try { - const status = await navigator.permissions.query( - { name: 'loopback-network' as PermissionName } - ) + const status = await navigator.permissions.query({ name: 'loopback-network' as PermissionName }) return status.state as 'granted' | 'prompt' | 'denied' } catch { return 'granted' // browsers without the gate (Safari today) don't block @@ -538,11 +536,9 @@ async function ensureLoopbackAllowed(): Promise<'granted' | 'prompt' | 'denied'> ```html -connect-src 'self' - ws://localhost:* http://localhost:* - ws://127.0.0.1:* http://127.0.0.1:* - wss://* https://hub.xnet.fyi https://*.xnet.fyi - https://huggingface.co https://*.huggingface.co https://*.hf.co ... +connect-src 'self' ws://localhost:* http://localhost:* ws://127.0.0.1:* http://127.0.0.1:* wss://* +https://hub.xnet.fyi https://*.xnet.fyi https://huggingface.co https://*.huggingface.co +https://*.hf.co ... ``` ## Risks And Open Questions @@ -554,7 +550,7 @@ connect-src 'self' nothing sensitive — currently just `{ ok, service, agent, version }`. The `agent` name is arguably minor fingerprinting; acceptable. - **`/run`** (agentic worktree edits) is far more dangerous than chat — it must - require the token *and* stay opt-in; consider a *separate*, stronger gate + require the token _and_ stay opt-in; consider a _separate_, stronger gate (confirm-in-app) for it. - **Chrome LNA prompt fatigue / enterprise policy.** Some users will see the loopback prompt; document `LocalNetworkAccessAllowedForUrls` for managed fleets. @@ -567,7 +563,7 @@ connect-src 'self' users will still want the direct tier. Keep both, default to the bridge. - **Does `codex mcp-server` support an HTTP-listen mode** we could target directly (skipping our relay for Codex)? Needs a doc-diff — research suggests - Codex's HTTP transport is for Codex-as-*client*, not inbound. Until confirmed, + Codex's HTTP transport is for Codex-as-_client_, not inbound. Until confirmed, the `cliChatAgent` relay covers Codex uniformly. - **0174 and 0194 are still `[_]`.** This doc is their security last-mile; decide whether to check them off or track hardening here. @@ -610,8 +606,58 @@ connect-src 'self' the bridge data endpoints and the changed `Host` behavior are a **breaking** wire-contract change → **major** for any published surface (bump from the diff, per CLAUDE.md). -- [ ] (Follow-up) Spike the native-messaging extension (Option C) for the pure-web - PWA install. +- [x] (Follow-up) Spike the native-messaging extension (Option C) for the pure-web + PWA install → [`packages/native-bridge-extension/`](packages/native-bridge-extension/README.md). + A minimal, tested POC: an MV3 extension (`externally_connectable` origin + allowlist + a fixed `key` → stable ID), a `background.js` relay, a native + host (`xnet-bridge-host.mjs`) that either spawns the user's `claude`/`codex` + **with no loopback port at all** or forwards to the hardened bridge daemon + over loopback carrying its pairing token, and a page-side connector that + mirrors the `ChatAgent` contract so it slots into the ladder as an + `extension` tier. 28 tests (framing codec, relay + both backends incl. a + round-trip through the real hardened daemon, page protocol, extension-ID + derivation, and a real-process end-to-end). **UX finding (full table in the + package README):** the shipped pairing code (B) is the right default — + zero artifacts, every browser, already merged — but carries a recurring + per-launch re-paste and a silent-401 failure mode; the extension (C) is + zero-friction and zero-secret after a one-time install and is the _only_ + option that eliminates the loopback attack surface rather than guarding it, + at the cost of a per-browser extension + native-host installer. Recommend B + as default, C as an opt-in upgrade for a browser-only install with no + bundled daemon. Not wired into the deployed app (would touch the churny + `detect.ts`); left as a documented drop-in. + +## Spike Outcome — Option C (native-messaging extension) + +The follow-up POC lives in +[`packages/native-bridge-extension/`](packages/native-bridge-extension/README.md) +(`private`, no build, no changeset). It confirms the 1Password pattern is a +clean fit for xNet: + +```mermaid +sequenceDiagram + participant P as PWA page (https://app.xnet.fyi) + participant X as Extension (background.js) + participant H as Native host (xnet-bridge-host.mjs) + participant A as claude / codex (or the loopback daemon) + + P->>X: sendMessage(extId, {v:1, kind:'chat', messages}) + Note over X: externally_connectable + sender.origin allowlist + X->>H: connectNative('fyi.xnet.bridge') · 4-byte-LE framed JSON + Note over H: OS gates: manifest allowed_origins == this extId + H->>A: spawn CLI (no port) · or POST loopback + pairing token + A-->>H: reply text + H-->>X: framed {ok:true, content} + X-->>P: reply → chat panel +``` + +Two allowlists — the extension's `externally_connectable` and the native host +manifest's `allowed_origins` (pinned to the extension's derived ID) — are the +entire trust story. No loopback port faces the browser in `cli` mode, so the +DNS-rebinding / drive-by-site class the token defends against in Option B simply +does not exist here. The trade is distribution cost: a per-browser extension and +a native-host installer to ship and maintain. See the README's UX table for the +default-vs-upgrade recommendation. ## Validation Checklist @@ -636,7 +682,7 @@ connect-src 'self' denying it surfaces the "allow local network access" hint, not a silent failure; granting it lets the chat proceed. _The native permission prompt is browser behaviour; the panel wires `navigator.permissions.query({ name: - 'loopback-network' })` and renders the denial hint (verified in + 'loopback-network' })` and renders the denial hint (verified in `AiChatPanel.tsx`). The prompt itself needs a real Chrome ≥142 to observe._ - [x] `local-server` tier setup hint shows a concrete `OLLAMA_ORIGINS=` line scoped to the app origin (never `*`). @@ -653,6 +699,7 @@ connect-src 'self' ## References ### Repo + - [`packages/devkit/src/bridge-server.ts`](packages/devkit/src/bridge-server.ts) — the loopback chat daemon (`:31416`), missing token + `Host` checks. - [`packages/devkit/src/chat-agent.ts`](packages/devkit/src/chat-agent.ts) — @@ -675,6 +722,7 @@ connect-src 'self' [`0252`](docs/explorations/0252_[_]_WHY_THE_AI_CHAT_BOX_IS_DISABLED_LOCAL_MODEL_CONNECTOR_GAPS.md). ### External + - [Claude Agent SDK — Hosting](https://code.claude.com/docs/en/agent-sdk/hosting) and [Secure Deployment](https://code.claude.com/docs/en/agent-sdk/secure-deployment). - [Claude Code as an MCP server](https://code.claude.com/docs/en/mcp) (stdio-only). diff --git a/packages/native-bridge-extension/.gitignore b/packages/native-bridge-extension/.gitignore new file mode 100644 index 000000000..64920d7fb --- /dev/null +++ b/packages/native-bridge-extension/.gitignore @@ -0,0 +1,4 @@ +# Extension signing private key — minted by `pnpm gen`, never committed. +# Only needed to pack a .crx for distribution; unpacked dev uses the public +# `key` already baked into extension/manifest.json. +extension.pem diff --git a/packages/native-bridge-extension/README.md b/packages/native-bridge-extension/README.md new file mode 100644 index 000000000..8ba3c2520 --- /dev/null +++ b/packages/native-bridge-extension/README.md @@ -0,0 +1,133 @@ +# @xnetjs/native-bridge-extension + +**Spike — exploration [0289](../../docs/explorations/0289_%5B_%5D_SECURELY_CONNECTING_THE_BROWSER_TO_A_LOCAL_MODEL.md), Option C.** A browser +extension + native-messaging host that lets the **pure-web PWA** +(`https://app.xnet.fyi`, no bundled Electron host) reach a **local model** with +the strongest origin binding available in a browser — **no loopback HTTP port, +no CORS, no DNS-rebinding surface** (the 1Password pattern). + +This is a proof-of-concept, not a shipped product: `private: true`, no build +step, no changeset. It exists to (a) prove the transport works end-to-end and +(b) compare its UX against the copy-paste **pairing code** shipped for 0289 +Option B (the hardened loopback bridge). + +## Why this exists + +0289 Option B hardened the loopback daemon (`packages/devkit/src/bridge-server.ts`) +into a secure spine: `Host`-header validation, an origin allowlist, and a +**per-launch pairing token** the user copies from `xnet bridge serve` into the AI +settings. That token is the layer that survives DNS-rebinding and drive-by sites +_because loopback-bind + origin allowlist alone cannot_ — any site can be made to +resolve to `127.0.0.1`. + +Option C removes the attack surface instead of defending it. The page never makes +a network request to `localhost`; it talks to an **extension** that the OS has +bound, by ID, to a **native host**. There is no port to rebind to and no CORS to +misconfigure. + +## Architecture + +``` + https://app.xnet.fyi (page) + │ chrome.runtime.sendMessage(, {v:1, kind, …}) ← gated by + ▼ externally_connectable + background.js (extension service worker) (origin allowlist) + │ chrome.runtime.connectNative('fyi.xnet.bridge') ← gated by the native + ▼ host manifest's + xnet-bridge-host.mjs (native messaging host) allowed_origins = + │ stdio, 4-byte-LE-length-prefixed JSON frames (OS-enforced) + ▼ + backend: + • cli → spawn the user's `claude` / `codex` (NO port anywhere) [default] + • daemon → forward to the hardened bridge daemon over loopback, + carrying its pairing token (reuse MCP tools / /run / Ollama proxy) +``` + +Two OS-/browser-enforced allowlists, and that's the whole trust story: + +1. **`externally_connectable.matches`** (`extension/manifest.json`) — only the + deployed PWA origin (+ loopback for dev) may deliver a message to the + extension. `background.js` re-checks `sender.origin` as defence in depth. +2. **`allowed_origins`** in the native host manifest — pinned to _this + extension's ID_, so the OS refuses to launch the host for any other + extension. The ID is derived deterministically from the extension's public + `key` (see `scripts/crx-id.mjs`), so it's stable across machines. + +## Layout + +| Path | What it is | +| ------------------------------- | ----------------------------------------------------------------------------------------- | +| `extension/manifest.json` | MV3 manifest — `externally_connectable`, `nativeMessaging`, a fixed `key` (→ stable ID) | +| `extension/background.js` | Service-worker relay: page ↔ native host, origin-checked | +| `host/native-messaging.mjs` | The 4-byte-length framing codec (one source of truth) | +| `host/relay.mjs` | `handleMessage(msg, backend)` — transport-free, never throws | +| `host/backends.mjs` | `cliBackend` (spawn CLI) and `daemonBackend` (forward to `:31416`) | +| `host/xnet-bridge-host.mjs` | The runnable native host — stdin → relay → stdout | +| `host/manifest.template.json` | Native host manifest template (`__HOST_PATH__`, `__EXTENSION_ORIGIN__`) | +| `web/extension-connector.mjs` | Page-side client — mirrors the `ChatAgent` contract so it slots into the connector ladder | +| `scripts/gen-extension-key.mjs` | Mint the signing key; bake the public `key` (→ stable ID) into the manifest | +| `scripts/install-host.mjs` | Write the native host manifest to the browser's `NativeMessagingHosts` dir | +| `scripts/crx-id.mjs` | Derive the Chromium extension ID from the packed public key | + +## Try it (macOS / Linux, Chromium-family) + +```bash +cd packages/native-bridge-extension +node scripts/gen-extension-key.mjs # once — prints the stable extension ID +node scripts/install-host.mjs # writes ~/…/NativeMessagingHosts/fyi.xnet.bridge.json +# --browser chrome|chromium|brave|edge (default: chrome) +``` + +Then load the unpacked extension: `chrome://extensions` → Developer mode → **Load +unpacked** → select `./extension`. Confirm the ID matches what `gen` printed. +Reload the xNet tab; the page can now call the connector in `web/`. + +Backend selection is by environment (set in your shell or the host manifest): + +```bash +# default — spawn the user's coding-agent CLI, no port anywhere: +XNET_BRIDGE_MODE=cli XNET_BRIDGE_AGENT=claude + +# or reuse the already-hardened loopback daemon (MCP tools, /run, Ollama proxy): +XNET_BRIDGE_MODE=daemon XNET_BRIDGE_URL=http://127.0.0.1:31416 XNET_BRIDGE_TOKEN= +``` + +**Windows** uses a registry key instead of a manifest file +(`HKCU\Software\Google\Chrome\NativeMessagingHosts\fyi.xnet.bridge` → the manifest +path), and the host needs a `.bat`/`.cmd` launcher since Windows can't exec a +`.mjs` directly. Out of scope for this spike; noted for a productionization pass. + +## UX comparison: pairing code (0289 B) vs. this extension (0289 C) + +| | **Pairing code** (shipped, Option B) | **Extension + native messaging** (this spike, Option C) | +| ------------------------- | ---------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------- | +| **First-run steps** | Install a CLI (or Electron) · run `xnet bridge serve` · **copy a code** · paste it into AI settings | Install the extension (one click from a store) · run the host installer once | +| **Recurring friction** | Token is **per-launch** — the code changes every time the daemon restarts, so the user re-pastes (or pins `--token`, weakening it) | **None** — the OS↔extension binding is durable; nothing to re-enter | +| **What the user handles** | A secret they must not paste into the wrong site | Nothing secret ever crosses the page boundary | +| **Failure mode** | Wrong/expired code → silent 401; "why is it broken?" | Extension missing → detectable, actionable "install the extension" prompt | +| **Security ceiling** | Defends the surface: token + `Host` + origin on an open loopback port | Removes the surface: no port, no CORS, no rebinding; OS-pinned extension ID | +| **Browser gates (LNA)** | Subject to Chrome 142+/Firefox `loopback-network` permission prompts | Unaffected — no local-network request is ever made | +| **Ship cost** | Already merged; zero new artifacts | Ship + maintain an extension per browser store + a native-host installer | +| **Cross-browser** | Any browser (server-side auth is the floor) | Chromium family + Firefox (WebKit/Safari has no extension native-messaging parity) | + +**Takeaway.** The pairing code is the right **now**: no artifacts to distribute, +works on every browser, already shipped. Its recurring cost is the per-launch +re-paste and the "silent 401" failure mode. The extension is the right +**strategic** answer for the deployed PWA: after a one-time install it's +zero-friction and zero-secret forever, and it's the only option that _eliminates_ +the loopback attack surface rather than guarding it — at the cost of building and +maintaining a per-browser extension + native-host installer. Recommend keeping B +as the default and pursuing C as an opt-in "install the xNet bridge extension" +upgrade once there's demand for a browser-only install with no bundled daemon. + +## Tests + +```bash +pnpm vitest run --project integration packages/native-bridge-extension +``` + +Covers the framing codec (partial/multi/oversize frames, multibyte UTF-8), the +relay + both backends (including a round-trip through the **real hardened bridge +daemon** with its pairing token), the page-side protocol against a fake `chrome`, +the extension-ID derivation, and a **real-process end-to-end** run that spawns +`xnet-bridge-host.mjs` and drives it with native-messaging frames. diff --git a/packages/native-bridge-extension/extension/background.js b/packages/native-bridge-extension/extension/background.js new file mode 100644 index 000000000..87982c6dc --- /dev/null +++ b/packages/native-bridge-extension/extension/background.js @@ -0,0 +1,79 @@ +/** + * xNet Local Bridge — background service worker (exploration 0289, Option C). + * + * The only job: relay a message from the xNet web page to the native-messaging + * host and hand the reply back. Two allowlists guard the door, and they are the + * whole security story of this approach: + * + * 1. `externally_connectable.matches` in the manifest — only pages whose origin + * matches (the deployed PWA + loopback for dev) can even deliver a message to + * `onMessageExternal`. We re-check `sender.origin` below as defence in depth. + * 2. The native host's own manifest pins `allowed_origins` to THIS extension's + * ID, so the OS refuses to launch the host for any other extension. + * + * There is no `fetch`, no port, no CORS here — the page never touches the network + * to reach the model. That is what removes the DNS-rebinding / drive-by-site + * surface that the loopback-HTTP bridge has to defend against with a token. + */ + +const NATIVE_HOST = 'fyi.xnet.bridge' + +// Belt-and-braces alongside `externally_connectable`: the exact origins allowed +// to drive the local model. Keep in lockstep with the manifest matches. +const ALLOWED_ORIGINS = new Set(['https://app.xnet.fyi', 'http://localhost', 'http://127.0.0.1']) + +function originOf(sender) { + if (sender.origin) return new URL(sender.origin).origin + if (sender.url) return new URL(sender.url).origin + return null +} + +function isAllowed(sender) { + const origin = originOf(sender) + return origin !== null && ALLOWED_ORIGINS.has(origin) +} + +/** + * Send one framed request to the native host and resolve with its one reply. + * A fresh short-lived port per request keeps the POC simple; a streaming build + * would keep the port open and forward `port.onMessage` deltas to the page. + */ +function callNativeHost(message) { + return new Promise((resolve) => { + let settled = false + const done = (value) => { + if (settled) return + settled = true + try { + port.disconnect() + } catch { + /* already gone */ + } + resolve(value) + } + const port = chrome.runtime.connectNative(NATIVE_HOST) + port.onMessage.addListener((reply) => done(reply)) + port.onDisconnect.addListener(() => { + const err = chrome.runtime.lastError + done({ ok: false, error: err ? err.message : 'native host disconnected' }) + }) + try { + port.postMessage(message) + } catch (err) { + done({ ok: false, error: err instanceof Error ? err.message : String(err) }) + } + }) +} + +chrome.runtime.onMessageExternal.addListener((message, sender, sendResponse) => { + if (!isAllowed(sender)) { + sendResponse({ ok: false, error: 'origin not allowed' }) + return false + } + if (!message || (message.kind !== 'health' && message.kind !== 'chat')) { + sendResponse({ ok: false, error: 'unknown request' }) + return false + } + callNativeHost(message).then(sendResponse) + return true // keep the channel open for the async reply +}) diff --git a/packages/native-bridge-extension/extension/manifest.json b/packages/native-bridge-extension/extension/manifest.json new file mode 100644 index 000000000..62a063d96 --- /dev/null +++ b/packages/native-bridge-extension/extension/manifest.json @@ -0,0 +1,15 @@ +{ + "manifest_version": 3, + "name": "xNet Local Bridge", + "version": "0.1.0", + "description": "Relays xNet's web app to a local model (Claude Code / Codex / a bridge daemon) over native messaging — no loopback port, no CORS.", + "background": { + "service_worker": "background.js", + "type": "module" + }, + "permissions": ["nativeMessaging"], + "externally_connectable": { + "matches": ["https://app.xnet.fyi/*", "http://localhost/*", "http://127.0.0.1/*"] + }, + "key": "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAitgqlga3G24EnV+qF1p+GKmyE2LdEJqjeqm5WcnQH4U1N93s5wUCV7t7wiJy9bgH3+bb0CDs84ht29PUEwKMIDO6SZQet9kMcI5QdD1YOyFmbyRoo2QN53fgAW1nsx9Bav2x14NMkgvtHEtCCXNlY8J2lkDnTr3rU3dsheeQuAEVLa7Na6zDr6X/MSQ7TgqTsGnY4Y3n6YkxkU3XCBHuycrqVPffOL12fcMTQBb5j2hQVEV06G8NK44I8I0m9mGRsA4JlVqbc/83YtpIciSmrLvATRttYR/enEP9LNnhnjAcnbkIDgHzsAC7qoa/k2T20aBn6Thw7D+Im48plB957QIDAQAB" +} diff --git a/packages/native-bridge-extension/host/backends.mjs b/packages/native-bridge-extension/host/backends.mjs new file mode 100644 index 000000000..36ba82816 --- /dev/null +++ b/packages/native-bridge-extension/host/backends.mjs @@ -0,0 +1,110 @@ +/** + * The two ways the native host answers a chat turn (exploration 0289, Option C). + * + * A `Backend` is `{ health(), chat(messages, model?) }` — the same tiny contract + * the HTTP bridge daemon exposes, minus the transport. The whole point of the + * native-messaging path is that this contract is reached with NO loopback HTTP + * port exposed to the browser: the browser talks to the extension, the extension + * talks to this host over the OS's stdio pipe, and only THEN — if at all — does a + * local socket get involved. + * + * - `cliBackend` spawns the user's own `claude` / `codex` CLI directly. This is + * the purest form of Option C: no port anywhere, the strongest origin binding + * (the OS gates which extension may even launch this host). + * - `daemonBackend` forwards to an already-running hardened bridge daemon + * (`packages/devkit/src/bridge-server.ts`) over loopback, carrying its pairing + * token. That loopback hop is process-to-process on the same machine, initiated + * by a trusted native binary — not by a web origin — so it carries none of the + * CORS / DNS-rebinding surface the daemon's HTTP front door has to defend + * against. Use this to reuse the daemon's richer capabilities (MCP tools, + * `POST /run`, the Ollama upstream proxy) behind the same stdio door. + */ + +import { execFile } from 'node:child_process' + +/** Flatten a chat into a single prompt for a headless CLI (mirrors devkit `flattenChat`). */ +export function flattenChat(messages) { + return messages + .map((m) => (m.role === 'user' ? m.content : `${m.role}: ${m.content}`)) + .join('\n\n') +} + +/** + * Drive the user's installed coding-agent CLI as the model. Spawning the CLI + * (rather than reusing its token) is the ToS-safe way to use their subscription — + * xNet never sees the credential; the CLI authenticates itself. + */ +export function cliBackend(options = {}) { + const command = options.command ?? 'claude' + const argsTemplate = options.args ?? ['-p', '{prompt}'] + const cwd = options.cwd ?? process.cwd() + const timeoutMs = options.timeoutMs ?? 120_000 + // Injectable for tests; defaults to the real child_process spawn. + const run = options.run ?? defaultRun + + return { + async health() { + return { ok: true, agent: command, version: options.version ?? '0.1.0', transport: 'cli' } + }, + async chat(messages, _model) { + const prompt = flattenChat(messages) + // split/join (not replace): the prompt is arbitrary text and String.replace + // would treat `$&` etc. as special and only swap the first token. + const args = argsTemplate.map((a) => a.split('{prompt}').join(prompt)) + const { code, stdout, stderr } = await run(command, args, { cwd, timeoutMs }) + if (code !== 0) { + throw new Error(`agent "${command}" failed (code ${code}): ${(stderr || stdout).trim()}`) + } + return stdout.trim() + } + } +} + +function defaultRun(command, args, { cwd, timeoutMs }) { + return new Promise((resolve) => { + execFile( + command, + args, + { cwd, timeout: timeoutMs, maxBuffer: 8 * 1024 * 1024 }, + (err, stdout, stderr) => { + resolve({ code: err && typeof err.code === 'number' ? err.code : err ? 1 : 0, stdout, stderr }) + } + ) + }) +} + +/** + * Forward to a running hardened bridge daemon over loopback, presenting its + * per-launch pairing token as the bearer. This is how the native host reuses the + * daemon we already ship instead of respawning the CLI per turn. + */ +export function daemonBackend(options = {}) { + const url = (options.url ?? 'http://127.0.0.1:31416').replace(/\/+$/, '') + const token = options.token ?? '' + const timeoutMs = options.timeoutMs ?? 120_000 + const fetchImpl = options.fetchImpl ?? fetch + + return { + async health() { + const res = await fetchImpl(`${url}/health`, { signal: AbortSignal.timeout(2000) }) + if (!res.ok) throw new Error(`bridge daemon health ${res.status}`) + return { ...(await res.json()), transport: 'daemon' } + }, + async chat(messages, model) { + const res = await fetchImpl(`${url}/v1/chat/completions`, { + method: 'POST', + headers: { + 'content-type': 'application/json', + ...(token ? { authorization: `Bearer ${token}` } : {}) + }, + body: JSON.stringify({ messages, ...(model ? { model } : {}), stream: false }), + signal: AbortSignal.timeout(timeoutMs) + }) + if (!res.ok) { + throw new Error(`bridge daemon /v1/chat/completions ${res.status}`) + } + const data = await res.json() + return data?.choices?.[0]?.message?.content?.trim() ?? '' + } + } +} diff --git a/packages/native-bridge-extension/host/manifest.template.json b/packages/native-bridge-extension/host/manifest.template.json new file mode 100644 index 000000000..4ef272505 --- /dev/null +++ b/packages/native-bridge-extension/host/manifest.template.json @@ -0,0 +1,7 @@ +{ + "name": "fyi.xnet.bridge", + "description": "xNet local model bridge — native messaging host", + "path": "__HOST_PATH__", + "type": "stdio", + "allowed_origins": ["__EXTENSION_ORIGIN__"] +} diff --git a/packages/native-bridge-extension/host/native-messaging.mjs b/packages/native-bridge-extension/host/native-messaging.mjs new file mode 100644 index 000000000..37aa3a340 --- /dev/null +++ b/packages/native-bridge-extension/host/native-messaging.mjs @@ -0,0 +1,74 @@ +/** + * Chrome/Firefox native-messaging wire framing (exploration 0289, Option C). + * + * A native messaging host speaks a trivial length-prefixed protocol over + * stdin/stdout: each message is a 4-byte unsigned length (the browser writes it + * in the machine's *native* byte order — little-endian on every platform Chrome + * ships) followed by that many bytes of UTF-8 JSON. The browser also caps a + * single message at 1 MB in each direction. + * + * This module is the one source of truth for that framing: the runnable host + * (`xnet-bridge-host.mjs`) and the tests both import it, so there is no second + * copy to drift. Pure Node built-ins, zero dependencies — a native host is + * spawned by the browser as `node `, so it must run without a build step. + */ + +/** Browser-enforced ceiling on a single native message (1 MB each way). */ +export const MAX_MESSAGE_BYTES = 1024 * 1024 + +/** + * Frame a JS value as a native message: 4-byte little-endian length + UTF-8 + * JSON. Throws if the encoded body exceeds {@link MAX_MESSAGE_BYTES} (the + * browser would silently drop an over-long frame otherwise). + */ +export function encodeMessage(value) { + const json = Buffer.from(JSON.stringify(value), 'utf8') + if (json.length > MAX_MESSAGE_BYTES) { + throw new Error(`native message too large: ${json.length} > ${MAX_MESSAGE_BYTES} bytes`) + } + const header = Buffer.allocUnsafe(4) + header.writeUInt32LE(json.length, 0) + return Buffer.concat([header, json]) +} + +/** + * A stateful decoder for the inbound stdin stream. Feed it raw chunks with + * {@link push}; it invokes `onMessage(value)` once per complete frame and + * buffers partial frames across chunk boundaries. `onError` is called (and the + * decoder stops emitting) if a frame declares a length past the 1 MB cap — + * treated as a framing desync / hostile peer rather than silently growing an + * unbounded buffer. + */ +export function createMessageDecoder(onMessage, onError = () => {}) { + let buffer = Buffer.alloc(0) + let poisoned = false + + return { + push(chunk) { + if (poisoned) return + buffer = buffer.length === 0 ? chunk : Buffer.concat([buffer, chunk]) + // Drain every complete frame currently in the buffer. + for (;;) { + if (buffer.length < 4) return + const length = buffer.readUInt32LE(0) + if (length > MAX_MESSAGE_BYTES) { + poisoned = true + onError(new Error(`native message length ${length} exceeds ${MAX_MESSAGE_BYTES}`)) + return + } + if (buffer.length < 4 + length) return // frame not fully arrived yet + const body = buffer.subarray(4, 4 + length) + buffer = buffer.subarray(4 + length) + let value + try { + value = JSON.parse(body.toString('utf8')) + } catch (err) { + poisoned = true + onError(err instanceof Error ? err : new Error(String(err))) + return + } + onMessage(value) + } + } + } +} diff --git a/packages/native-bridge-extension/host/relay.mjs b/packages/native-bridge-extension/host/relay.mjs new file mode 100644 index 000000000..2a3a04e58 --- /dev/null +++ b/packages/native-bridge-extension/host/relay.mjs @@ -0,0 +1,44 @@ +/** + * The relay: turn one decoded native message into one response (0289, Option C). + * + * Kept transport-free and backend-injected so it can be unit-tested without a + * browser, a subprocess, or a socket. `xnet-bridge-host.mjs` wires stdin → + * decoder → {@link handleMessage} → encoder → stdout around it; the extension's + * service worker speaks the same `{ v, kind }` envelope to the page. + */ + +export const PROTOCOL_VERSION = 1 + +/** + * Handle one request envelope against a backend, returning a response envelope. + * Never throws — a backend failure becomes `{ ok: false, error }` so the host + * can always frame a reply and the page composer surfaces the reason instead of + * hanging. Each reply echoes the request `id` (if any) so the page can correlate + * concurrent turns over a single long-lived port. + */ +export async function handleMessage(msg, backend) { + const id = msg && typeof msg === 'object' ? msg.id : undefined + const reply = (body) => (id === undefined ? body : { ...body, id }) + try { + if (!msg || typeof msg !== 'object') { + return reply({ ok: false, error: 'malformed message' }) + } + if (msg.v !== undefined && msg.v !== PROTOCOL_VERSION) { + return reply({ ok: false, error: `unsupported protocol version ${msg.v}` }) + } + switch (msg.kind) { + case 'health': + return reply({ ok: true, ...(await backend.health()) }) + case 'chat': { + const messages = Array.isArray(msg.messages) ? msg.messages : [] + if (messages.length === 0) return reply({ ok: false, error: 'no messages' }) + const content = await backend.chat(messages, typeof msg.model === 'string' ? msg.model : undefined) + return reply({ ok: true, content }) + } + default: + return reply({ ok: false, error: `unknown kind: ${String(msg.kind)}` }) + } + } catch (err) { + return reply({ ok: false, error: err instanceof Error ? err.message : String(err) }) + } +} diff --git a/packages/native-bridge-extension/host/xnet-bridge-host.mjs b/packages/native-bridge-extension/host/xnet-bridge-host.mjs new file mode 100755 index 000000000..d5aaa9ea3 --- /dev/null +++ b/packages/native-bridge-extension/host/xnet-bridge-host.mjs @@ -0,0 +1,93 @@ +#!/usr/bin/env node +/** + * xNet native-messaging host (exploration 0289, Option C). + * + * The browser spawns this file as `node xnet-bridge-host.mjs` when the xNet + * extension calls `chrome.runtime.connectNative('fyi.xnet.bridge')`, and speaks + * to it over stdin/stdout using length-prefixed JSON frames. There is NO network + * listener here: the page reaches the local model through the extension → this + * host → the user's CLI (or a loopback daemon the host itself dials out to). No + * port is opened to the browser, so there is no CORS surface and no + * DNS-rebinding surface to defend — the OS gates, by the native-host manifest's + * `allowed_origins`, which extension is even permitted to launch this process. + * + * Backend selection is by environment (set in the manifest or the user's shell): + * XNET_BRIDGE_MODE=cli|daemon (default: cli — no port anywhere) + * cli: XNET_BRIDGE_AGENT=claude|codex|… XNET_BRIDGE_CWD= + * daemon: XNET_BRIDGE_URL=http://127.0.0.1:31416 + * XNET_BRIDGE_TOKEN= | XNET_BRIDGE_TOKEN_FILE= + */ + +import { readFileSync } from 'node:fs' +import { createMessageDecoder, encodeMessage } from './native-messaging.mjs' +import { handleMessage } from './relay.mjs' +import { cliBackend, daemonBackend } from './backends.mjs' + +function resolveBackend(env) { + const mode = env.XNET_BRIDGE_MODE ?? 'cli' + if (mode === 'daemon') { + let token = env.XNET_BRIDGE_TOKEN ?? '' + if (!token && env.XNET_BRIDGE_TOKEN_FILE) { + try { + token = readFileSync(env.XNET_BRIDGE_TOKEN_FILE, 'utf8').trim() + } catch { + token = '' + } + } + return daemonBackend({ + ...(env.XNET_BRIDGE_URL ? { url: env.XNET_BRIDGE_URL } : {}), + token + }) + } + return cliBackend({ + ...(env.XNET_BRIDGE_AGENT ? { command: env.XNET_BRIDGE_AGENT } : {}), + ...(env.XNET_BRIDGE_CWD ? { cwd: env.XNET_BRIDGE_CWD } : {}) + }) +} + +function write(value) { + process.stdout.write(encodeMessage(value)) +} + +function main() { + const backend = resolveBackend(process.env) + const inFlight = new Set() + let stdinEnded = false + + const maybeExit = () => { + // Exit only once the browser has closed the port AND every reply has + // flushed — exiting on `end` alone would drop a chat still awaiting the CLI. + if (stdinEnded && inFlight.size === 0) process.exit(0) + } + + const decoder = createMessageDecoder( + (msg) => { + // One request → one reply. handleMessage never rejects. + const task = handleMessage(msg, backend).then((reply) => { + write(reply) + inFlight.delete(task) + maybeExit() + }) + inFlight.add(task) + }, + (err) => { + // Framing desync / oversize frame: report once and exit so the browser + // tears down the port rather than us buffering forever. + try { + write({ ok: false, error: `framing error: ${err.message}` }) + } finally { + process.exit(1) + } + } + ) + + process.stdin.on('data', (chunk) => decoder.push(chunk)) + // The browser closes stdin to disconnect the port; drain in-flight work first. + process.stdin.on('end', () => { + stdinEnded = true + maybeExit() + }) + process.stdin.on('error', () => process.exit(1)) +} + +main() diff --git a/packages/native-bridge-extension/package.json b/packages/native-bridge-extension/package.json new file mode 100644 index 000000000..18cd8207d --- /dev/null +++ b/packages/native-bridge-extension/package.json @@ -0,0 +1,16 @@ +{ + "name": "@xnetjs/native-bridge-extension", + "version": "0.0.0", + "private": true, + "type": "module", + "description": "Spike (exploration 0289, Option C): a browser-extension + native-messaging bridge that lets the pure-web PWA reach a local model with no loopback port, no CORS, and no DNS-rebinding surface.", + "scripts": { + "gen": "node scripts/gen-extension-key.mjs", + "install-host": "node scripts/install-host.mjs", + "test": "vitest run" + }, + "devDependencies": { + "@xnetjs/devkit": "workspace:*", + "vitest": "^4.0.0" + } +} diff --git a/packages/native-bridge-extension/scripts/crx-id.mjs b/packages/native-bridge-extension/scripts/crx-id.mjs new file mode 100644 index 000000000..b64c3e754 --- /dev/null +++ b/packages/native-bridge-extension/scripts/crx-id.mjs @@ -0,0 +1,30 @@ +/** + * Compute a Chromium extension ID from its packed public key (0289, Option C). + * + * Chrome derives an extension's ID deterministically from the `key` field in + * `manifest.json` (the base64 DER-encoded SubjectPublicKeyInfo). The ID is the + * first 128 bits of `sha256(DER)`, hex-encoded, with each nibble `0–9a–f` + * remapped to `a–p` ("mpdecimal"). Because it's derived from the key rather than + * the install path, baking a fixed `key` into the manifest gives the *same* ID on + * every machine — which is what lets the native-messaging host manifest pin + * `allowed_origins: ["chrome-extension:///"]` and have the OS enforce that + * only THIS extension may launch the host. + */ + +import { createHash } from 'node:crypto' + +/** @param {string} base64Spki The manifest `key` value (base64 DER SPKI). */ +export function crxIdFromKey(base64Spki) { + const der = Buffer.from(base64Spki, 'base64') + const digest = createHash('sha256').update(der).digest('hex').slice(0, 32) + let id = '' + for (const ch of digest) { + id += String.fromCharCode('a'.charCodeAt(0) + parseInt(ch, 16)) + } + return id +} + +/** `chrome-extension:///` — the exact string a native-host manifest allowlists. */ +export function crxOriginFromKey(base64Spki) { + return `chrome-extension://${crxIdFromKey(base64Spki)}/` +} diff --git a/packages/native-bridge-extension/scripts/gen-extension-key.mjs b/packages/native-bridge-extension/scripts/gen-extension-key.mjs new file mode 100755 index 000000000..e6f4bca9b --- /dev/null +++ b/packages/native-bridge-extension/scripts/gen-extension-key.mjs @@ -0,0 +1,44 @@ +#!/usr/bin/env node +/** + * Generate the extension signing keypair and print the derived ID (0289, C). + * + * Run once to mint a stable identity for the spike: + * node scripts/gen-extension-key.mjs + * + * It writes the base64 public key into `extension/manifest.json` (`key`) so the + * unpacked extension loads with a fixed ID, prints that ID, and writes the + * PKCS#8 private key to `extension.pem` (git-ignored) — that private key is only + * needed to pack a `.crx` for distribution; day-to-day "Load unpacked" dev needs + * only the public `key` already in the manifest. Idempotent report if a key + * already exists (pass --force to rotate). + */ + +import { generateKeyPairSync } from 'node:crypto' +import { readFileSync, writeFileSync, existsSync } from 'node:fs' +import { fileURLToPath } from 'node:url' +import { dirname, join } from 'node:path' +import { crxIdFromKey } from './crx-id.mjs' + +const here = dirname(fileURLToPath(import.meta.url)) +const manifestPath = join(here, '..', 'extension', 'manifest.json') +const pemPath = join(here, '..', 'extension.pem') + +const manifest = JSON.parse(readFileSync(manifestPath, 'utf8')) +const force = process.argv.includes('--force') + +if (manifest.key && !force) { + console.error(`manifest already has a key → id ${crxIdFromKey(manifest.key)} (pass --force to rotate)`) + process.exit(0) +} + +const { publicKey, privateKey } = generateKeyPairSync('rsa', { modulusLength: 2048 }) +const spkiDer = publicKey.export({ type: 'spki', format: 'der' }) +const key = spkiDer.toString('base64') + +manifest.key = key +writeFileSync(manifestPath, JSON.stringify(manifest, null, 2) + '\n') +writeFileSync(pemPath, privateKey.export({ type: 'pkcs8', format: 'pem' })) + +console.error(`extension id: ${crxIdFromKey(key)}`) +console.error(`wrote key → ${manifestPath}`) +console.error(`wrote private key → ${pemPath} (git-ignored; needed only to pack a .crx)`) diff --git a/packages/native-bridge-extension/scripts/install-host.mjs b/packages/native-bridge-extension/scripts/install-host.mjs new file mode 100755 index 000000000..9e63dc637 --- /dev/null +++ b/packages/native-bridge-extension/scripts/install-host.mjs @@ -0,0 +1,85 @@ +#!/usr/bin/env node +/** + * Register the native-messaging host with the browser (0289, Option C). + * + * node scripts/install-host.mjs [--browser chrome|chromium|brave|edge] + * + * Writes `fyi.xnet.bridge.json` into the browser's NativeMessagingHosts + * directory, filling in the absolute path to `xnet-bridge-host.mjs` and the + * `allowed_origins` entry derived from the extension's own `key` — so the OS + * enforces that ONLY our extension may launch the host. Re-run after `pnpm gen` + * rotates the key, or for each browser you want to enable. + * + * This spike targets macOS/Linux (the manifest-file mechanism). Windows uses a + * registry key instead; see README.md. + */ + +import { chmodSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs' +import { homedir, platform } from 'node:os' +import { fileURLToPath } from 'node:url' +import { dirname, join } from 'node:path' +import { crxOriginFromKey } from './crx-id.mjs' + +const here = dirname(fileURLToPath(import.meta.url)) +const pkgRoot = join(here, '..') +const hostPath = join(pkgRoot, 'host', 'xnet-bridge-host.mjs') +const template = JSON.parse(readFileSync(join(pkgRoot, 'host', 'manifest.template.json'), 'utf8')) +const extManifest = JSON.parse(readFileSync(join(pkgRoot, 'extension', 'manifest.json'), 'utf8')) + +if (!extManifest.key) { + console.error('extension/manifest.json has no `key`; run `node scripts/gen-extension-key.mjs` first.') + process.exit(1) +} + +const browser = argValue('--browser') ?? 'chrome' +const dir = hostDir(browser) +if (!dir) { + console.error(`unsupported platform/browser: ${platform()} / ${browser}`) + process.exit(1) +} + +// The host file must be executable — the browser execs `path` directly. +chmodSync(hostPath, 0o755) + +const manifest = { + ...template, + path: hostPath, + allowed_origins: [crxOriginFromKey(extManifest.key)] +} +mkdirSync(dir, { recursive: true }) +const out = join(dir, 'fyi.xnet.bridge.json') +writeFileSync(out, JSON.stringify(manifest, null, 2) + '\n') + +console.error(`installed native host manifest → ${out}`) +console.error(` path = ${manifest.path}`) +console.error(` allowed_origins = ${manifest.allowed_origins[0]}`) +console.error('Load the unpacked extension from ./extension, then reload the xNet tab.') + +/** NativeMessagingHosts directory per browser, macOS + Linux. */ +function hostDir(browserName) { + const home = homedir() + const mac = { + chrome: 'Google/Chrome', + chromium: 'Chromium', + brave: 'BraveSoftware/Brave-Browser', + edge: 'Microsoft Edge' + } + const linux = { + chrome: 'google-chrome', + chromium: 'chromium', + brave: 'BraveSoftware/Brave-Browser', + edge: 'microsoft-edge' + } + if (platform() === 'darwin' && mac[browserName]) { + return join(home, 'Library', 'Application Support', mac[browserName], 'NativeMessagingHosts') + } + if (platform() === 'linux' && linux[browserName]) { + return join(home, '.config', linux[browserName], 'NativeMessagingHosts') + } + return null +} + +function argValue(flag) { + const i = process.argv.indexOf(flag) + return i >= 0 ? process.argv[i + 1] : undefined +} diff --git a/packages/native-bridge-extension/test/crx-id.test.mjs b/packages/native-bridge-extension/test/crx-id.test.mjs new file mode 100644 index 000000000..9bb4a0443 --- /dev/null +++ b/packages/native-bridge-extension/test/crx-id.test.mjs @@ -0,0 +1,29 @@ +import { describe, it, expect } from 'vitest' +import { crxIdFromKey, crxOriginFromKey } from '../scripts/crx-id.mjs' + +describe('crxIdFromKey', () => { + it('produces a 32-char id in the a–p alphabet', () => { + // A throwaway SPKI DER (base64) — value is arbitrary; we assert shape + determinism. + const key = + 'MFwwDQYJKoZIhvcNAQEBBQADSwAwSAJBAKt2m1r0Xh0Z3wZ0J0mI0m0m0m0m0m0m0m0m0m0m0m0m0m0m0m0m0m0m0m0m0m0m0m0m0m0m0m0m0m0CAwEAAQ==' + const id = crxIdFromKey(key) + expect(id).toHaveLength(32) + expect(id).toMatch(/^[a-p]{32}$/) + }) + + it('is deterministic for a given key', () => { + const key = Buffer.from('a stable public key blob').toString('base64') + expect(crxIdFromKey(key)).toBe(crxIdFromKey(key)) + }) + + it('changes when the key changes', () => { + const a = crxIdFromKey(Buffer.from('key-a').toString('base64')) + const b = crxIdFromKey(Buffer.from('key-b').toString('base64')) + expect(a).not.toBe(b) + }) + + it('formats the chrome-extension origin the native host allowlists', () => { + const key = Buffer.from('origin key').toString('base64') + expect(crxOriginFromKey(key)).toBe(`chrome-extension://${crxIdFromKey(key)}/`) + }) +}) diff --git a/packages/native-bridge-extension/test/extension-connector.test.mjs b/packages/native-bridge-extension/test/extension-connector.test.mjs new file mode 100644 index 000000000..c3de52466 --- /dev/null +++ b/packages/native-bridge-extension/test/extension-connector.test.mjs @@ -0,0 +1,69 @@ +import { describe, it, expect } from 'vitest' +import { createExtensionConnector } from '../web/extension-connector.mjs' + +/** + * A fake `chrome.runtime` that routes `sendMessage(extId, msg, cb)` through a + * handler, mimicking the extension's `onMessageExternal` relay — so we exercise + * the page-side protocol with no browser. + */ +function fakeChrome(handler) { + return { + runtime: { + lastError: null, + sendMessage(extensionId, message, cb) { + Promise.resolve(handler(message, extensionId)).then((reply) => cb(reply)) + } + } + } +} + +describe('createExtensionConnector', () => { + it('probeHealth is true when the extension answers ok', async () => { + const chrome = fakeChrome((msg) => + msg.kind === 'health' ? { ok: true, agent: 'claude' } : { ok: false } + ) + const c = createExtensionConnector({ chrome, extensionId: 'abc' }) + expect(await c.probeHealth()).toBe(true) + }) + + it('probeHealth is false when the extension is absent', async () => { + const c = createExtensionConnector({ chrome: undefined, extensionId: 'abc' }) + expect(await c.probeHealth()).toBe(false) + }) + + it('chat returns the assistant content', async () => { + const chrome = fakeChrome((msg) => ({ ok: true, content: `reply:${msg.messages[0].content}` })) + const c = createExtensionConnector({ chrome, extensionId: 'abc' }) + expect(await c.chat([{ role: 'user', content: 'hi' }])).toBe('reply:hi') + }) + + it('chat rejects with the backend error message', async () => { + const chrome = fakeChrome(() => ({ ok: false, error: 'origin not allowed' })) + const c = createExtensionConnector({ chrome, extensionId: 'abc' }) + await expect(c.chat([{ role: 'user', content: 'hi' }])).rejects.toThrow(/origin not allowed/) + }) + + it('surfaces chrome.runtime.lastError as a rejection', async () => { + const chrome = { + runtime: { + lastError: { message: 'Could not establish connection' }, + sendMessage(_id, _msg, cb) { + cb(undefined) + } + } + } + const c = createExtensionConnector({ chrome, extensionId: 'abc' }) + await expect(c.chat([{ role: 'user', content: 'hi' }])).rejects.toThrow(/establish connection/) + }) + + it('sends the v:1 envelope the host expects', async () => { + let seen + const chrome = fakeChrome((msg) => { + seen = msg + return { ok: true, content: 'x' } + }) + const c = createExtensionConnector({ chrome, extensionId: 'abc' }) + await c.chat([{ role: 'user', content: 'hi' }], 'claude') + expect(seen).toEqual({ v: 1, kind: 'chat', messages: [{ role: 'user', content: 'hi' }], model: 'claude' }) + }) +}) diff --git a/packages/native-bridge-extension/test/host-e2e.test.mjs b/packages/native-bridge-extension/test/host-e2e.test.mjs new file mode 100644 index 000000000..882140a89 --- /dev/null +++ b/packages/native-bridge-extension/test/host-e2e.test.mjs @@ -0,0 +1,54 @@ +import { describe, it, expect } from 'vitest' +import { spawn } from 'node:child_process' +import { mkdtempSync, writeFileSync, chmodSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { fileURLToPath } from 'node:url' +import { dirname, join } from 'node:path' +import { encodeMessage, createMessageDecoder } from '../host/native-messaging.mjs' + +const here = dirname(fileURLToPath(import.meta.url)) +const HOST = join(here, '..', 'host', 'xnet-bridge-host.mjs') + +/** Spawn the real host, drive it with framed requests, collect framed replies. */ +function driveHost(env, requests) { + return new Promise((resolve, reject) => { + const replies = [] + const child = spawn(process.execPath, [HOST], { + env: { ...process.env, ...env }, + stdio: ['pipe', 'pipe', 'inherit'] + }) + const decoder = createMessageDecoder((m) => replies.push(m), reject) + child.stdout.on('data', (chunk) => decoder.push(chunk)) + child.on('error', reject) + child.on('exit', (code) => resolve({ code, replies })) + for (const req of requests) child.stdin.write(encodeMessage(req)) + child.stdin.end() // browser closing the port → host exits 0 + }) +} + +describe('xnet-bridge-host.mjs end-to-end (cli backend, real process)', () => { + it('answers health and a chat turn over stdio framing, then exits cleanly', async () => { + // A stand-in coding-agent CLI: prints a reply built from the -p prompt. + const dir = mkdtempSync(join(tmpdir(), 'xnet-fake-agent-')) + const fakeAgent = join(dir, 'fake-agent.mjs') + writeFileSync( + fakeAgent, + '#!/usr/bin/env node\nprocess.stdout.write("cli reply: " + process.argv[3])\n' + ) + chmodSync(fakeAgent, 0o755) + + const { code, replies } = await driveHost( + { XNET_BRIDGE_MODE: 'cli', XNET_BRIDGE_AGENT: fakeAgent }, + [ + { v: 1, id: 'h', kind: 'health' }, + { v: 1, id: 'c', kind: 'chat', messages: [{ role: 'user', content: 'hello world' }] } + ] + ) + + expect(code).toBe(0) + const health = replies.find((r) => r.id === 'h') + expect(health).toMatchObject({ ok: true, transport: 'cli', agent: fakeAgent }) + const chat = replies.find((r) => r.id === 'c') + expect(chat).toEqual({ ok: true, content: 'cli reply: hello world', id: 'c' }) + }) +}) diff --git a/packages/native-bridge-extension/test/native-messaging.test.mjs b/packages/native-bridge-extension/test/native-messaging.test.mjs new file mode 100644 index 000000000..8ffa0dfdb --- /dev/null +++ b/packages/native-bridge-extension/test/native-messaging.test.mjs @@ -0,0 +1,74 @@ +import { describe, it, expect } from 'vitest' +import { + encodeMessage, + createMessageDecoder, + MAX_MESSAGE_BYTES +} from '../host/native-messaging.mjs' + +/** Collect every message a decoder emits for the given chunks. */ +function drain(chunks) { + const out = [] + const errors = [] + const decoder = createMessageDecoder((m) => out.push(m), (e) => errors.push(e)) + for (const chunk of chunks) decoder.push(chunk) + return { out, errors } +} + +describe('native-messaging framing', () => { + it('encodes a 4-byte little-endian length prefix + UTF-8 JSON', () => { + const frame = encodeMessage({ kind: 'health' }) + const bodyLen = frame.readUInt32LE(0) + expect(bodyLen).toBe(frame.length - 4) + expect(JSON.parse(frame.subarray(4).toString('utf8'))).toEqual({ kind: 'health' }) + }) + + it('round-trips a single message', () => { + const msg = { v: 1, kind: 'chat', messages: [{ role: 'user', content: 'hi' }] } + const { out } = drain([encodeMessage(msg)]) + expect(out).toEqual([msg]) + }) + + it('decodes several messages delivered in one chunk', () => { + const a = encodeMessage({ id: 1 }) + const b = encodeMessage({ id: 2 }) + const { out } = drain([Buffer.concat([a, b])]) + expect(out).toEqual([{ id: 1 }, { id: 2 }]) + }) + + it('reassembles a message split across chunk boundaries (incl. mid-header)', () => { + const frame = encodeMessage({ id: 'split', content: 'x'.repeat(500) }) + // Split inside the 4-byte header and again inside the body. + const chunks = [frame.subarray(0, 2), frame.subarray(2, 40), frame.subarray(40)] + const { out } = drain(chunks) + expect(out).toEqual([{ id: 'split', content: 'x'.repeat(500) }]) + }) + + it('handles multibyte UTF-8 without mislengthing', () => { + const msg = { content: 'café — 日本語 — 🌉' } + const { out } = drain([encodeMessage(msg)]) + expect(out).toEqual([msg]) + }) + + it('refuses to encode a frame larger than the 1 MB cap', () => { + const huge = { content: 'x'.repeat(MAX_MESSAGE_BYTES + 1) } + expect(() => encodeMessage(huge)).toThrow(/too large/) + }) + + it('poisons the stream on an oversize declared length (framing desync)', () => { + const header = Buffer.allocUnsafe(4) + header.writeUInt32LE(MAX_MESSAGE_BYTES + 1, 0) + const { out, errors } = drain([header]) + expect(out).toEqual([]) + expect(errors).toHaveLength(1) + expect(errors[0].message).toMatch(/exceeds/) + }) + + it('reports invalid JSON once and stops emitting', () => { + const bad = Buffer.from('not json') + const header = Buffer.allocUnsafe(4) + header.writeUInt32LE(bad.length, 0) + const { out, errors } = drain([Buffer.concat([header, bad])]) + expect(out).toEqual([]) + expect(errors).toHaveLength(1) + }) +}) diff --git a/packages/native-bridge-extension/test/relay.test.mjs b/packages/native-bridge-extension/test/relay.test.mjs new file mode 100644 index 000000000..513e3f751 --- /dev/null +++ b/packages/native-bridge-extension/test/relay.test.mjs @@ -0,0 +1,108 @@ +import { describe, it, expect } from 'vitest' +import { createBridgeServer, fakeChatAgent } from '@xnetjs/devkit' +import { handleMessage } from '../host/relay.mjs' +import { cliBackend, daemonBackend, flattenChat } from '../host/backends.mjs' + +const USER = [{ role: 'user', content: 'ping' }] + +describe('relay.handleMessage', () => { + const echo = { + async health() { + return { agent: 'fake', version: '9.9.9' } + }, + async chat(messages) { + return `echo:${flattenChat(messages)}` + } + } + + it('answers health', async () => { + expect(await handleMessage({ v: 1, kind: 'health' }, echo)).toEqual({ + ok: true, + agent: 'fake', + version: '9.9.9' + }) + }) + + it('answers chat and echoes the request id for correlation', async () => { + const reply = await handleMessage({ v: 1, id: 't1', kind: 'chat', messages: USER }, echo) + expect(reply).toEqual({ ok: true, content: 'echo:ping', id: 't1' }) + }) + + it('rejects an unsupported protocol version', async () => { + const reply = await handleMessage({ v: 99, kind: 'health' }, echo) + expect(reply.ok).toBe(false) + expect(reply.error).toMatch(/protocol version/) + }) + + it('rejects unknown kinds and empty chats without throwing', async () => { + expect((await handleMessage({ kind: 'nope' }, echo)).ok).toBe(false) + expect((await handleMessage({ kind: 'chat', messages: [] }, echo)).ok).toBe(false) + }) + + it('turns a backend failure into ok:false rather than rejecting', async () => { + const boom = { async health() { throw new Error('backend down') }, async chat() {} } + const reply = await handleMessage({ kind: 'health' }, boom) + expect(reply).toEqual({ ok: false, error: 'backend down' }) + }) +}) + +describe('cliBackend (injected runner)', () => { + it('flattens the conversation into the {prompt} slot and returns stdout', async () => { + const calls = [] + const backend = cliBackend({ + command: 'claude', + run: async (command, args, opts) => { + calls.push({ command, args, opts }) + return { code: 0, stdout: 'hello from cli\n', stderr: '' } + } + }) + const content = await backend.chat([ + { role: 'system', content: 'be terse' }, + { role: 'user', content: 'hi' } + ]) + expect(content).toBe('hello from cli') + expect(calls[0].command).toBe('claude') + expect(calls[0].args).toEqual(['-p', 'system: be terse\n\nhi']) + }) + + it('surfaces a non-zero exit as an error', async () => { + const backend = cliBackend({ + run: async () => ({ code: 2, stdout: '', stderr: 'not logged in' }) + }) + await expect(backend.chat(USER)).rejects.toThrow(/not logged in/) + }) +}) + +describe('daemonBackend → real hardened bridge daemon', () => { + it('forwards a chat through the token-gated daemon and gets the reply', async () => { + const server = createBridgeServer({ + agent: fakeChatAgent((messages) => `daemon saw: ${messages.at(-1).content}`), + agentName: 'claude', + port: 0, // ephemeral + pairingToken: 'spike-token' + }) + await server.start() + try { + const backend = daemonBackend({ url: server.url, token: server.pairingToken }) + expect((await backend.health()).ok).toBe(true) + expect(await backend.chat([{ role: 'user', content: 'hello' }])).toBe('daemon saw: hello') + } finally { + await server.stop() + } + }) + + it('fails when the pairing token is wrong (daemon returns 401)', async () => { + const server = createBridgeServer({ + agent: fakeChatAgent(() => 'secret'), + port: 0, + pairingToken: 'right-token' + }) + await server.start() + try { + const backend = daemonBackend({ url: server.url, token: 'wrong-token' }) + await expect(backend.chat(USER)).rejects.toThrow(/401/) + } finally { + await server.stop() + } + }) +}) diff --git a/packages/native-bridge-extension/web/extension-connector.mjs b/packages/native-bridge-extension/web/extension-connector.mjs new file mode 100644 index 000000000..61a865db7 --- /dev/null +++ b/packages/native-bridge-extension/web/extension-connector.mjs @@ -0,0 +1,70 @@ +/** + * Page-side client for the native-messaging bridge (0289, Option C). + * + * This is what the xNet web app would import to add an `extension` connector + * tier alongside `bridge` in `packages/plugins/src/ai/connectors/detect.ts`. It + * speaks to the extension with `chrome.runtime.sendMessage(, …)`, + * which only works because the extension allowlisted this origin in + * `externally_connectable`. `chrome` is injectable so the protocol can be tested + * without a browser. + * + * The contract deliberately mirrors the bridge tier: `probeHealth()` for + * detection (cheap, unauthenticated), then `chat(messages, model?)` — and the + * returned object satisfies the same `{ chat }` `ChatAgent` shape the panel's + * providers consume, so it drops into the ladder as one more available tier. + */ + +export const DEFAULT_TIMEOUT_MS = 120_000 + +/** + * @param {object} [opts] + * @param {string} opts.extensionId The published/derived extension ID. + * @param {object} [opts.chrome] Injectable `chrome` (defaults to global). + */ +export function createExtensionConnector(opts = {}) { + const chrome = opts.chrome ?? (typeof globalThis !== 'undefined' ? globalThis.chrome : undefined) + const extensionId = opts.extensionId + const timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS + + function send(message) { + return new Promise((resolve, reject) => { + if (!chrome?.runtime?.sendMessage) { + reject(new Error('xNet bridge extension not installed')) + return + } + const timer = setTimeout(() => reject(new Error('extension timed out')), timeoutMs) + try { + chrome.runtime.sendMessage(extensionId, message, (reply) => { + clearTimeout(timer) + const lastError = chrome.runtime.lastError + if (lastError) { + reject(new Error(lastError.message)) + return + } + resolve(reply) + }) + } catch (err) { + clearTimeout(timer) + reject(err instanceof Error ? err : new Error(String(err))) + } + }) + } + + return { + /** True when the extension + a working backend answer a health ping. */ + async probeHealth() { + try { + const reply = await send({ v: 1, kind: 'health' }) + return reply?.ok === true + } catch { + return false + } + }, + /** ChatAgent-shaped: return the assistant's reply text, or throw with the reason. */ + async chat(messages, model) { + const reply = await send({ v: 1, kind: 'chat', messages, ...(model ? { model } : {}) }) + if (!reply?.ok) throw new Error(reply?.error ?? 'bridge extension error') + return reply.content + } + } +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1435630e1..41562c87a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1541,6 +1541,15 @@ importers: specifier: ^4.0.0 version: 4.0.18(@opentelemetry/api@1.9.1)(@types/node@20.19.30)(@vitest/browser-playwright@4.0.18)(jiti@2.6.1)(jsdom@26.1.0)(lightningcss@1.27.0)(msw@2.12.7(@types/node@20.19.30)(typescript@5.9.3))(terser@5.48.0)(tsx@4.21.0)(yaml@2.9.0) + packages/native-bridge-extension: + devDependencies: + '@xnetjs/devkit': + specifier: workspace:* + version: link:../devkit + vitest: + specifier: ^4.0.0 + version: 4.0.18(@opentelemetry/api@1.9.1)(@types/node@20.19.30)(@vitest/browser-playwright@4.0.18)(jiti@2.6.1)(jsdom@26.1.0)(lightningcss@1.27.0)(msw@2.12.7(@types/node@20.19.30)(typescript@5.9.3))(terser@5.48.0)(tsx@4.21.0)(yaml@2.9.0) + packages/network: dependencies: '@chainsafe/libp2p-noise': diff --git a/vitest.config.ts b/vitest.config.ts index ca552ba4d..31c20f966 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -160,7 +160,11 @@ export default defineConfig({ testTimeout: 15000, include: [ 'packages/{hub,plugins,sdk,devkit,unreal,server}/src/**/*.test.ts', - 'packages/{hub,plugins,sdk,devkit,unreal,server}/test/**/*.test.ts' + 'packages/{hub,plugins,sdk,devkit,unreal,server}/test/**/*.test.ts', + // Native-messaging bridge spike (0289 Option C): plain-ESM host + + // relay, tested as .mjs (a native host must run without a build). + // Process/IO heavy (spawns the host, dials the daemon) → forks pool. + 'packages/native-bridge-extension/test/**/*.test.mjs' ], server: { deps: {