From 87754ded23341a9bfa5856e7463dff8b36dffcad Mon Sep 17 00:00:00 2001 From: xNet Test Date: Thu, 9 Jul 2026 06:25:04 -0700 Subject: [PATCH 01/11] docs(exploration): explore securely connecting the browser to a local model Co-Authored-By: Claude Opus 4.8 Signed-off-by: xNet Test --- ...CONNECTING_THE_BROWSER_TO_A_LOCAL_MODEL.md | 688 ++++++++++++++++++ 1 file changed, 688 insertions(+) create mode 100644 docs/explorations/0289_[_]_SECURELY_CONNECTING_THE_BROWSER_TO_A_LOCAL_MODEL.md 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 new file mode 100644 index 000000000..2b28b8717 --- /dev/null +++ b/docs/explorations/0289_[_]_SECURELY_CONNECTING_THE_BROWSER_TO_A_LOCAL_MODEL.md @@ -0,0 +1,688 @@ +# Securely Connecting The Browser To A Local Model (Claude Code, Codex, Ollama, …) + +## Problem Statement + +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 +`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: + +1. Lets the xNet browser app talk to a model process on `localhost`. +2. Works across the deployed PWA (`https://app.xnet.fyi`), the Electron desktop + build, and dev. +3. Does **not** expose the user's local model / coding-agent subscription to any + random website they happen to have open. +4. Degrades gracefully as browsers roll out the new Local Network Access + permission gates (Chrome 142+, Firefox 149+). + +## Executive Summary + +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: + +- 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)) + — wraps the user's own `claude`/`codex` CLI as an **OpenAI-compatible** + `POST /v1/chat/completions` endpoint. This is the direct answer to "connect my + browser to Claude Code / Codex." It binds loopback-only, gates by `Origin`, + answers preflights, and emits `Access-Control-Allow-Private-Network`. +- The **`local-server` tier** probes Ollama (`:11434`) and LM Studio (`:1234`) + directly and maps them to an OpenAI-compatible provider + ([`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: + +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 + `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. +2. **The deployed PWA can't even reach it, for two reasons.** (a) The Electron + manager starts the bridge with **no `allowedOrigins`** + ([`apps/electron/src/main/agent-bridge-manager.ts`](apps/electron/src/main/agent-bridge-manager.ts)), + so only loopback-origin pages pass the gate — `https://app.xnet.fyi` is + rejected. (b) The web app's CSP `connect-src` lists `http://localhost:*` but + **not** `http://127.0.0.1:*` + ([`apps/web/index.html`](apps/web/index.html)), while the bridge's default URL + is `http://127.0.0.1:31416` — CSP-blocked before the request leaves the page. +3. **The direct `local-server` tier offloads security to the user** — they must + set `OLLAMA_ORIGINS` / toggle LM Studio CORS, and the safe framing (never + wildcard) isn't enforced or explained. + +**Recommendation:** make the loopback bridge the **secure spine** for all local +model access and finish its hardening to match `mcp-http.ts` — a per-launch +**pairing token** delivered out-of-band, **`Host`-header validation**, an +**explicit origin allowlist** that includes the deployed PWA origin, and a fixed +CSP. Keep the direct `local-server` tier as an advanced opt-in with safe-origin +guidance. Model the wire contract on **MCP Streamable HTTP**, whose spec already +mandates Origin validation + loopback bind + auth, so we inherit a +community-reviewed threat model instead of inventing one. For the pure-web PWA +where no native host exists to spawn a daemon, the long-term strongest option is +a **browser-extension native-messaging bridge** (the 1Password pattern), which +sidesteps HTTP/CORS/DNS-rebinding entirely. + +## Current State In The Repository + +### The connector ladder already models this + +[`packages/plugins/src/ai/connectors/types.ts`](packages/plugins/src/ai/connectors/types.ts) +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 | + +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 +([`ai-chat-connector.ts`](apps/web/src/workbench/views/ai-chat-connector.ts)). + +### The bridge daemon — the secure-ish spine we already have + +[`packages/devkit/src/bridge-server.ts`](packages/devkit/src/bridge-server.ts) +is a Node `http` server that: + +- **Binds loopback only** — throws if `host` isn't in + `{127.0.0.1, ::1, localhost}` ([`:63-68`](packages/devkit/src/bridge-server.ts:63)). +- **Gates by `Origin`** — loopback origins and an explicit `allowedOrigins` + allowlist pass; it never reflects `*` + ([`isOriginAllowed`, `:192-201`](packages/devkit/src/bridge-server.ts:192)). +- **Answers `OPTIONS` preflights** and emits + `Access-Control-Allow-Private-Network: true` for Chrome's Local Network Access + flow ([`applyCors`, `:203-209`](packages/devkit/src/bridge-server.ts:203)). +- Serves `GET /health`, `POST /v1/chat/completions` (SSE streaming), and an + opt-in `POST /run` (agentic worktree edits, behind a handler). +- Backs chat with `cliChatAgent` + ([`chat-agent.ts:53`](packages/devkit/src/chat-agent.ts)) which **spawns the + user's installed `claude`/`codex`** (their subscription — xNet never sees the + token; the CLI authenticates itself). + +It ships from Electron +([`apps/electron/src/main/agent-bridge-manager.ts`](apps/electron/src/main/agent-bridge-manager.ts)) +and from the CLI (`xnet bridge serve`, +[`packages/cli/src/commands/bridge.ts`](packages/cli/src/commands/bridge.ts)). + +### The security gap — compare the two loopback servers + +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) | + +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. + +### The wiring gaps for the deployed PWA + +- The Electron manager calls + `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 + 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 + Spotify's loopback guidance, so the fix is "add 127.0.0.1", not "drop it".) + +### How the pieces connect today + +```mermaid +flowchart LR + subgraph Browser["xNet web app (https origin)"] + Panel[AiChatPanel.tsx] + Detect["detectConnectors()"] + Prov["OpenAICompatibleProvider"] + end + subgraph Machine["User's machine (loopback)"] + Bridge["bridge-server.ts
:31416 — wraps claude/codex CLI"] + Ollama["Ollama :11434 / LM Studio :1234"] + CLI["claude / codex CLI
(user's subscription)"] + end + Detect -->|"GET /health"| Bridge + Detect -->|"GET /api/tags, /v1/models"| Ollama + Panel --> Prov + Prov -->|"POST /v1/chat/completions (SSE)"| Bridge + Prov -->|"POST /v1/chat/completions"| Ollama + Bridge -->|spawn per turn| CLI + style Bridge fill:#eef,stroke:#33a + style Ollama fill:#efe,stroke:#3a3 +``` + +## External Research + +### 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 | + +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 + 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. +- **`claude mcp serve` is stdio-only** — "no network exposure, security comes + 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 + 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 + 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. + +### The browser security landscape (fast-moving, 2025-2026) + +- **DNS rebinding** defeats "loopback = trusted." `evil.com` resolves normally, + then re-resolves to `127.0.0.1`; page JS under the `evil.com` origin now hits + the local server, which sees a loopback connection. **Fix = validate the + `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. +- **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; + Firefox extended it to WebSockets in FF71). +- **Chrome Local Network Access (LNA)** — the gap-closer. **Chrome 142** + (~Oct 2025) added a **permission prompt** for any public→private/loopback + request. **Chrome 145** split it into `local-network` and **`loopback-network`** + (our case). Query `navigator.permissions.query({ name: 'loopback-network' })` + and build an "allow local access" UX path. The daemon still answers preflight + with `Access-Control-Allow-Private-Network: true` (already done). +- **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). +- **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 + defense-in-depth that only gets stronger. + +### Secure loopback patterns from prior art + +- **Per-launch bearer token, delivered out-of-band** (not fetchable by any page) + — the convergent pattern for browser↔local-app bridges. +- **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 + server or logged. +- **Native messaging** (1Password): browser extension ↔ desktop app via + `chrome.runtime.connectNative` + host-manifest extension-ID allowlist — **no + HTTP, no CORS, no DNS-rebinding surface**. Strongest origin binding. +- **mkcert** local TLS (`https://127.0.0.1`) — dev-only; can't ship a private CA + to users. +- **UDS / named pipes** — a browser tab can't speak them; you still need a thin + loopback HTTP/WS front door. + +## Key Findings + +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. +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 + `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=*`). +5. **Browser gates are arriving**, not arrived. We must ship server-side auth now + and add a graceful `loopback-network` permission UX. +6. **Token delivery is the crux.** Electron can inject the token via preload + (`window.xnetAgentBridge`); the pure-web PWA needs an out-of-band channel — a + pairing code the user copies from `xnet bridge serve` into settings. + +## Options And Tradeoffs + +### A. Direct browser → local server (Ollama / LM Studio), as today + +- **Pros:** zero new code; works now if the user configures CORS; no xNet daemon. +- **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 + default trust story. + +### 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 +(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 + 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. +- **Verdict:** the spine. Most of it already exists. + +### C. Browser-extension native-messaging bridge (1Password pattern) + +A small xNet extension talks to a native host via `connectNative`; the web app +talks to the extension. + +- **Pros:** strongest origin binding (extension-ID allowlisted by the OS); **no + HTTP/CORS/DNS-rebinding surface**; no loopback port at all; the only clean + answer for a pure-web PWA with no bundled native host. +- **Cons:** ship + maintain an extension per browser; native-host installer; + higher build cost. Longer-term. +- **Verdict:** the strategic answer for the deployed PWA; propose as a follow-up. + +### D. mkcert local TLS (`https://127.0.0.1`) + +- **Pros:** real HTTPS locally; sidesteps mixed-content and (currently) the LNA + loopback gate; great for testing the token/CORS logic under realistic TLS. +- **Cons:** private CA can't ship to users; dev-only. +- **Verdict:** dev/test tooling, not a product control. + +### E. In-tab model (WebLLM / Gemini Nano) — no connection at all + +Already implemented (0252): WebGPU model runs *in the page*, nothing to secure. + +- **Pros:** no network, no daemon, fully private; the true "nothing installed" + path. +- **Cons:** small/weak models; big first-run download; weak tool-calling. +- **Verdict:** complementary — the zero-setup fallback, not a substitute for the + user's real Claude Code / Codex / Ollama. + +### Comparison + +```mermaid +flowchart TD + Q{What does the user have?} --> CC[Claude Code / Codex CLI] + Q --> OL[Ollama / LM Studio] + Q --> NO[Nothing installed] + CC --> B["Option B: hardened bridge
(token + Host + origin)"] + OL --> B + OL --> A["Option A: direct, advanced
(user configures CORS)"] + NO --> E["Option E: WebLLM in-tab"] + B -. deployed PWA, no native host .-> C["Option C: extension
native messaging"] + style B fill:#eef,stroke:#33a,stroke-width:2px +``` + +## Recommendation + +**Ship B as the secure spine; keep A as an advanced opt-in; pursue C for the +pure-web PWA; E remains the zero-setup fallback.** + +Concretely, in priority order: + +1. **Fix the two PWA-blockers immediately** (tiny, high-value): add + `http://127.0.0.1:*` (and `ws://127.0.0.1:*`) to the web CSP `connect-src`, + 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* + 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 + `xnet bridge serve` prints. Store it in the existing `AI_CHAT_STORAGE_KEYS`. +4. **Let the bridge proxy Ollama** so raw-local-model users get the same + authenticated door (optional `POST /v1/chat/completions` upstream to + `:11434`). +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* + `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. + +This turns the answer to "how do I securely connect my browser to my local +Claude Code / Codex / any local model?" into: **the xNet bridge — a loopback +daemon that wraps your own CLI (or proxies your local server), authenticated with +a per-launch pairing code, locked to your app's origin and the real `Host`, so no +other website can touch it.** + +### Target flow + +```mermaid +sequenceDiagram + participant U as User + participant CLI as xnet bridge serve + participant P as xNet web app (https) + participant B as Bridge :31416 + participant A as claude / codex CLI + + U->>CLI: run `xnet bridge serve` + CLI-->>U: prints pairing code (token) + U->>P: paste pairing code in AI settings + P->>B: GET /health (Origin: app origin) + B->>B: check Host == 127.0.0.1:31416
check Origin ∈ allowlist + B-->>P: { ok: true, agent: "claude" } + Note over P: Chrome may prompt: allow loopback-network + P->>B: POST /v1/chat/completions
Authorization: Bearer (SSE) + B->>B: timing-safe token compare + B->>A: spawn per-turn (user's subscription) + A-->>B: reply text + B-->>P: SSE stream → chat panel +``` + +### Trust boundary + +```mermaid +flowchart TB + subgraph Untrusted["Any website in the browser"] + Evil[evil.com JS] + end + subgraph App["xNet PWA (allowlisted origin)"] + Panel[AiChatPanel] + end + subgraph Local["Loopback daemon :31416"] + G1{Host == 127.0.0.1:31416?} + G2{Origin in allowlist?} + G3{Bearer token valid?} + Agent[claude/codex CLI] + end + Evil -->|rebind to 127.0.0.1| G1 + Panel -->|Origin + token| G1 + G1 -- no --> X1[403] + G1 -- yes --> G2 + G2 -- no --> X2[403] + G2 -- yes --> G3 + G3 -- no --> X3[401] + G3 -- yes --> Agent + style X1 fill:#fee,stroke:#c00 + style X2 fill:#fee,stroke:#c00 + style X3 fill:#fee,stroke:#c00 + style Agent fill:#efe,stroke:#0a0 +``` + +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 + +### 1. `Host`-header validation (add to both loopback servers) + +```ts +// packages/devkit/src/bridge-server.ts (and mirror in mcp-http.ts) +const LOOPBACK_HOSTS = new Set(['127.0.0.1', '::1', 'localhost']) + +/** Reject requests whose Host isn't our exact loopback authority (anti-DNS-rebind). */ +function isHostAllowed(hostHeader: string | undefined, boundPort: number): boolean { + if (!hostHeader) return false + // Host may be "127.0.0.1:31416" or "[::1]:31416" + const withoutPort = hostHeader.replace(/:\d+$/, '').replace(/^\[|\]$/g, '') + const port = hostHeader.match(/:(\d+)$/)?.[1] + return LOOPBACK_HOSTS.has(withoutPort) && (port === undefined || port === String(boundPort)) +} + +// in onRequest, before anything else: +if (!isHostAllowed(headerStr(req.headers.host), boundPort)) { + endStatus(res, 403) + return +} +``` + +### 2. Per-launch pairing token (reuse the `mcp-http.ts` shape) + +```ts +// packages/devkit/src/bridge-server.ts +import { randomBytes, timingSafeEqual } from 'node:crypto' + +export interface BridgeServerConfig { + // ...existing... + /** Required in `Authorization: Bearer `. Random when omitted; read back + * from the handle and hand to the client out-of-band (preload / pairing code). */ + pairingToken?: string +} + +const pairingToken = config.pairingToken ?? randomBytes(24).toString('base64url') + +function tokenOk(header: string | undefined): boolean { + const presented = header?.replace(/^Bearer\s+/i, '') ?? '' + const a = Buffer.from(presented) + const b = Buffer.from(pairingToken) + return a.length === b.length && timingSafeEqual(a, b) +} + +// gate the data endpoints (not /health, which stays an unauth probe): +if (req.method === 'POST' && path === '/v1/chat/completions') { + if (!tokenOk(headerStr(req.headers.authorization))) { + sendJson(res, 401, { error: { message: 'invalid or missing pairing token' } }) + return + } + // ...existing handling... +} +``` + +Expose it on the handle (`readonly pairingToken: string`), exactly as +`McpHttpServerHandle` already does. + +### 3. Wire the deployed origin + token from Electron + +```ts +// apps/electron/src/main/agent-bridge-manager.ts +const server = createBridgeServer({ + agent, + agentName: agentCmd, + version: app.getVersion(), + allowedOrigins: [ + 'https://app.xnet.fyi', + ...(process.env.XNET_BRIDGE_ALLOWED_ORIGINS?.split(',') ?? []) + ] +}) +await server.start() +// hand the token to the renderer over the preload channel (never over HTTP): +mainWindow.webContents.send('xnet:bridge-token', server.pairingToken) +``` + +### 4. Client — send the token, handle the permission gate + +```ts +// apps/web/src/workbench/views/ai-chat-connector.ts (provider config) +function bridgeProviderConfig(baseUrl: string, token: string): AIProviderConfig { + return { + type: 'openai-compatible', + baseUrl, + apiKey: token, // becomes `Authorization: Bearer ` + model: 'claude' + } +} +``` + +```ts +// 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 } + ) + return status.state as 'granted' | 'prompt' | 'denied' + } catch { + return 'granted' // browsers without the gate (Safari today) don't block + } +} +``` + +### 5. CSP fix + +```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 ... +``` + +## Risks And Open Questions + +- **Token delivery for the pure-web PWA is inherently manual** (copy-paste a + pairing code). Is that acceptable UX, or is the extension (C) needed sooner? + A QR/deep-link pairing could soften it. +- **`/health` stays unauthenticated** (it's a presence probe). Confirm it leaks + 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 + (confirm-in-app) for it. +- **Chrome LNA prompt fatigue / enterprise policy.** Some users will see the + loopback prompt; document `LocalNetworkAccessAllowedForUrls` for managed fleets. +- **Origin allowlist vs. self-hosters.** A self-hosted xNet at a custom domain + needs its origin allowlisted too — make `allowedOrigins` configurable + (`XNET_BRIDGE_ALLOWED_ORIGINS`) rather than hardcoding `app.xnet.fyi`. +- **`localhost` vs `127.0.0.1` in `Host` checks** — both must be accepted (the + panel probes `127.0.0.1`, users may hit `localhost`); the CSP must list both. +- **Proxying Ollama through the bridge** adds a hop and a config surface; some + 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, + 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. + +## Implementation Checklist + +- [ ] Add `http://127.0.0.1:*` and `ws://127.0.0.1:*` to `connect-src` in + [`apps/web/index.html`](apps/web/index.html). +- [ ] Add `Host`-header validation to + [`packages/devkit/src/bridge-server.ts`](packages/devkit/src/bridge-server.ts) + **and** [`packages/plugins/src/services/mcp-http.ts`](packages/plugins/src/services/mcp-http.ts) + (exact loopback authority + bound port). +- [ ] Add a per-launch `pairingToken` to `BridgeServerConfig`, gate + `/v1/chat/completions` and `/run` on `Authorization: Bearer` with a + constant-time compare; expose `pairingToken` on the handle; leave `/health` + unauthenticated. +- [ ] Pass `allowedOrigins` (deployed PWA origin + `XNET_BRIDGE_ALLOWED_ORIGINS`) + from [`apps/electron/src/main/agent-bridge-manager.ts`](apps/electron/src/main/agent-bridge-manager.ts) + and the `xnet bridge serve` CLI. +- [ ] Print the pairing code from `xnet bridge serve` + ([`packages/cli/src/commands/bridge.ts`](packages/cli/src/commands/bridge.ts)) + and inject it into the Electron renderer via preload + (`window.xnetAgentBridge` / a `xnet:bridge-token` channel). +- [ ] Add a "bridge pairing code" field to the AI settings in + [`AiChatPanel.tsx`](apps/web/src/workbench/views/AiChatPanel.tsx); persist + under `AI_CHAT_STORAGE_KEYS`; feed it as the provider `apiKey`/bearer. +- [ ] Query `navigator.permissions.query({ name: 'loopback-network' })` and show + an "allow local network access" hint on `prompt`/`denied` instead of a + silent dead box. +- [ ] Show exact `OLLAMA_ORIGINS=` / LM Studio CORS guidance (never + `*`) in the `local-server` tier setup hint + ([`detect.ts`](packages/plugins/src/ai/connectors/detect.ts) / + [`AiChatPanel.tsx`](apps/web/src/workbench/views/AiChatPanel.tsx)). +- [ ] (Optional) Add an upstream-proxy mode to the bridge so `/v1/chat/completions` + can forward to Ollama `:11434`, giving raw-local-model users the same + authenticated door. +- [ ] Update tests: `bridge-server.test.ts` (token + Host cases), + `detect.test.ts`, `ai-chat-connector.test.ts`. +- [ ] Changeset for `@xnetjs/devkit` and `@xnetjs/plugins` — new required token on + 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. + +## Validation Checklist + +- [ ] From the deployed PWA with the bridge running and paired: selecting the + `bridge` tier probes `/health`, the composer enables, and a chat streams a + reply from the user's `claude`/`codex` — end-to-end, over the SSE path. +- [ ] Without the pairing token, `POST /v1/chat/completions` returns **401**; + with a wrong-length/incorrect token it also 401s (timing-safe). +- [ ] A request with `Host: evil.com` (simulated rebind) is rejected **403 at the + `Host` gate**, before origin/token checks. +- [ ] A request from a non-allowlisted `Origin` is rejected **403**; the deployed + PWA origin passes. +- [ ] Under the web CSP, a `http://127.0.0.1:31416` request is **not** CSP-blocked + (network panel shows it leaving the page). +- [ ] In Chrome 142+/145+, the `loopback-network` permission prompt appears once; + denying it surfaces the "allow local network access" hint, not a silent + failure; granting it lets the chat proceed. +- [ ] `local-server` tier setup hint shows a concrete `OLLAMA_ORIGINS=` + line scoped to the app origin (never `*`). +- [ ] `/health` still answers unauthenticated (detection works before pairing). +- [ ] Electron: the renderer receives the token via preload and never over HTTP; + no token appears in any network response body. +- [ ] `bridge-server.test.ts`, `mcp-http` tests, `detect.test.ts`, + `ai-chat-connector.test.ts` all pass with the new assertions. + +## 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) — + `cliChatAgent` spawning the user's `claude`/`codex`. +- [`packages/plugins/src/services/mcp-http.ts`](packages/plugins/src/services/mcp-http.ts) + — the hardened sibling with `x-xnet-pairing` (the template to copy). +- [`packages/plugins/src/ai/connectors/detect.ts`](packages/plugins/src/ai/connectors/detect.ts) + — tier detection (`bridge`, `local-server`), default bridge URL. +- [`packages/plugins/src/ai/providers.ts`](packages/plugins/src/ai/providers.ts) + — `OpenAICompatibleProvider`, `OllamaProvider`, `isOllamaAvailable`. +- [`apps/electron/src/main/agent-bridge-manager.ts`](apps/electron/src/main/agent-bridge-manager.ts) + — wires the bridge (no `allowedOrigins` today). +- [`apps/web/index.html`](apps/web/index.html) — CSP `connect-src` (missing + `127.0.0.1`). +- [`apps/web/src/workbench/views/AiChatPanel.tsx`](apps/web/src/workbench/views/AiChatPanel.tsx), + [`ai-chat-connector.ts`](apps/web/src/workbench/views/ai-chat-connector.ts) — + the chat panel + tier/provider mapping. +- [`docs/explorations/0174_[_]_BRING_YOUR_OWN_MODEL_AI_CHAT_PANEL.md`](docs/explorations/0174_[_]_BRING_YOUR_OWN_MODEL_AI_CHAT_PANEL.md), + [`0194`](docs/explorations/0194_[_]_AGENT_BRIDGE_CLAUDE_CODE_CODEX_AND_ANY_AGENT_IN_XNET.md), + [`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). +- [Codex MCP](https://developers.openai.com/codex/mcp). +- [MCP Transports](https://modelcontextprotocol.io/docs/concepts/transports) / + [Authorization](https://modelcontextprotocol.io/specification/2025-06-18/basic/authorization). +- [Agent Client Protocol](https://agentclientprotocol.com/) / [Zed ACP](https://zed.dev/acp). +- [Ollama FAQ / `OLLAMA_ORIGINS`](https://docs.ollama.com/faq); + [NCC Group — Ollama DNS rebinding CVE-2024-28224](https://www.nccgroup.com/research/technical-advisory-ollama-dns-rebinding-attack-cve-2024-28224/); + [Wiz — Probllama CVE-2024-37032](https://www.wiz.io/blog/probllama-ollama-vulnerability-cve-2024-37032). +- [AnythingLLM CORS/auth advisory (GHSA-24qj-pw4h-3jmm)](https://github.com/Mintplex-Labs/anything-llm/security/advisories/GHSA-24qj-pw4h-3jmm). +- [LM Studio server settings](https://lmstudio.ai/docs/developer/core/server/settings); + [llama.cpp server README](https://github.com/ggml-org/llama.cpp/blob/master/tools/server/README.md). +- [Chrome Local Network Access](https://developer.chrome.com/blog/local-network-access); + [Chrome 145 permission split](https://chromestatus.com/feature/5068298146414592); + [WICG Local Network Access spec](https://wicg.github.io/local-network-access/). +- [Firefox local-network permissions](https://support.mozilla.org/en-US/kb/control-personal-device-local-network-permissions-firefox); + [WebKit standards-position](https://github.com/WebKit/standards-positions/issues/163). +- [MDN — Mixed content](https://developer.mozilla.org/en-US/docs/Web/Security/Defenses/Mixed_content) / + [CSP connect-src](https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Content-Security-Policy/connect-src). +- [RFC 8252 — OAuth for Native Apps](https://www.rfc-editor.org/rfc/rfc8252.html); + [Spotify — migrate off insecure redirect URIs](https://developer.spotify.com/documentation/web-api/tutorials/migration-insecure-redirect-uri); + [mkcert](https://github.com/filosottile/mkcert); + [1Password browser-connection security](https://support.1password.com/1password-browser-connection-security/). From 41bf41190f0c676f17f6986c6f314ef1b2fb5846 Mon Sep 17 00:00:00 2001 From: xNet Test Date: Thu, 9 Jul 2026 07:27:43 -0700 Subject: [PATCH 02/11] feat(devkit): pairing token + Host-header validation on the agent bridge The loopback chat daemon (:31416) now requires a per-launch pairing token (Authorization: Bearer, constant-time compared) on /v1/chat/completions and /run, and validates the Host header to reject DNS-rebinding requests before origin/token checks. /health stays unauthenticated so detection works before pairing. Exploration 0289. Co-Authored-By: Claude Opus 4.8 Signed-off-by: xNet Test --- ...CONNECTING_THE_BROWSER_TO_A_LOCAL_MODEL.md | 2 +- packages/devkit/src/bridge-server.test.ts | 80 +++++++++++++++++-- packages/devkit/src/bridge-server.ts | 67 +++++++++++++++- 3 files changed, 139 insertions(+), 10 deletions(-) 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 2b28b8717..d4f086f7f 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 @@ -580,7 +580,7 @@ connect-src 'self' [`packages/devkit/src/bridge-server.ts`](packages/devkit/src/bridge-server.ts) **and** [`packages/plugins/src/services/mcp-http.ts`](packages/plugins/src/services/mcp-http.ts) (exact loopback authority + bound port). -- [ ] Add a per-launch `pairingToken` to `BridgeServerConfig`, gate +- [x] Add a per-launch `pairingToken` to `BridgeServerConfig`, gate `/v1/chat/completions` and `/run` on `Authorization: Bearer` with a constant-time compare; expose `pairingToken` on the handle; leave `/health` unauthenticated. diff --git a/packages/devkit/src/bridge-server.test.ts b/packages/devkit/src/bridge-server.test.ts index 87f4f86d0..3a0bd139a 100644 --- a/packages/devkit/src/bridge-server.test.ts +++ b/packages/devkit/src/bridge-server.test.ts @@ -1,3 +1,4 @@ +import { request } from 'node:http' import { afterEach, describe, expect, it } from 'vitest' import { createBridgeServer, @@ -6,6 +7,26 @@ import { } from './bridge-server' import { fakeChatAgent } from './chat-agent' +/** + * Raw loopback GET with a caller-chosen `Host` header. `fetch`/undici silently + * overrides `Host` with the URL authority, so a DNS-rebinding request (attacker + * hostname reaching 127.0.0.1) can only be simulated at the `node:http` layer. + */ +function getWithHost(url: string, host: string): Promise { + const { port } = new URL(url) + return new Promise((resolve, reject) => { + const req = request( + { hostname: '127.0.0.1', port: Number(port), path: '/health', headers: { host } }, + (res) => { + res.resume() + resolve(res.statusCode ?? 0) + } + ) + req.on('error', reject) + req.end() + }) +} + let handle: BridgeServerHandle | undefined afterEach(async () => { @@ -13,17 +34,23 @@ afterEach(async () => { handle = undefined }) +const TOKEN = 'test-pairing-token' + async function start(overrides: Partial = {}): Promise { handle = createBridgeServer({ agent: fakeChatAgent(() => 'hi there'), agentName: 'claude', port: 0, + pairingToken: TOKEN, ...overrides }) await handle.start() return handle.url } +/** Data-endpoint headers: JSON + the pairing token the daemon now requires. */ +const authed = { 'content-type': 'application/json', authorization: `Bearer ${TOKEN}` } + describe('createBridgeServer', () => { it('refuses to bind a non-loopback host', () => { expect(() => createBridgeServer({ agent: fakeChatAgent(() => ''), host: '0.0.0.0' })).toThrow( @@ -46,7 +73,7 @@ describe('createBridgeServer', () => { const url = await start({ agent: fakeChatAgent((m) => `echo:${m[m.length - 1].content}`) }) const res = await fetch(`${url}/v1/chat/completions`, { method: 'POST', - headers: { 'content-type': 'application/json' }, + headers: authed, body: JSON.stringify({ messages: [{ role: 'user', content: 'hi' }] }) }) const body = (await res.json()) as { choices: Array<{ message: { content: string } }> } @@ -57,7 +84,7 @@ describe('createBridgeServer', () => { const url = await start({ agent: fakeChatAgent(() => 'streamed reply') }) const res = await fetch(`${url}/v1/chat/completions`, { method: 'POST', - headers: { 'content-type': 'application/json' }, + headers: authed, body: JSON.stringify({ stream: true, messages: [{ role: 'user', content: 'hi' }] }) }) expect(res.headers.get('content-type')).toContain('text/event-stream') @@ -83,6 +110,45 @@ describe('createBridgeServer', () => { expect(res.headers.get('access-control-allow-private-network')).toBe('true') }) + it('rejects a chat completion with no pairing token (401)', async () => { + const url = await start() + const res = await fetch(`${url}/v1/chat/completions`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ messages: [{ role: 'user', content: 'hi' }] }) + }) + expect(res.status).toBe(401) + }) + + it('rejects a chat completion with a wrong pairing token (401)', async () => { + const url = await start() + const res = await fetch(`${url}/v1/chat/completions`, { + method: 'POST', + headers: { 'content-type': 'application/json', authorization: 'Bearer nope' }, + body: JSON.stringify({ messages: [{ role: 'user', content: 'hi' }] }) + }) + expect(res.status).toBe(401) + }) + + it('generates a random pairing token when none is configured', async () => { + handle = createBridgeServer({ agent: fakeChatAgent(() => 'hi'), port: 0 }) + await handle.start() + expect(handle.pairingToken).toMatch(/^[A-Za-z0-9_-]{16,}$/) + }) + + it('rejects a request whose Host is not our loopback authority (anti-rebind, 403)', async () => { + const url = await start() + expect(await getWithHost(url, 'evil.example')).toBe(403) + // sanity: the same request with a correct loopback Host is accepted + expect(await getWithHost(url, `127.0.0.1:${new URL(url).port}`)).toBe(200) + }) + + it('leaves /health unauthenticated so detection works before pairing', async () => { + const url = await start() + const res = await fetch(`${url}/health`) // no Authorization header + expect(res.status).toBe(200) + }) + it('returns 502 when the agent throws', async () => { const url = await start({ agent: fakeChatAgent(() => { @@ -91,7 +157,7 @@ describe('createBridgeServer', () => { }) const res = await fetch(`${url}/v1/chat/completions`, { method: 'POST', - headers: { 'content-type': 'application/json' }, + headers: authed, body: JSON.stringify({ messages: [{ role: 'user', content: 'hi' }] }) }) expect(res.status).toBe(502) @@ -101,7 +167,7 @@ describe('createBridgeServer', () => { const url = await start() const res = await fetch(`${url}/run`, { method: 'POST', - headers: { 'content-type': 'application/json' }, + headers: authed, body: JSON.stringify({ taskId: 't1', prompt: 'do it' }) }) expect(res.status).toBe(501) @@ -124,7 +190,7 @@ describe('createBridgeServer', () => { }) const res = await fetch(`${url}/run`, { method: 'POST', - headers: { 'content-type': 'application/json' }, + headers: authed, body: JSON.stringify({ taskId: 't1', prompt: 'add a toggle' }) }) const body = (await res.json()) as { ok: boolean; branch: string } @@ -137,7 +203,7 @@ describe('createBridgeServer', () => { const url = await start({ run: async () => ({}) as never }) const res = await fetch(`${url}/run`, { method: 'POST', - headers: { 'content-type': 'application/json' }, + headers: authed, body: JSON.stringify({ prompt: 'no id' }) }) expect(res.status).toBe(400) @@ -151,7 +217,7 @@ describe('createBridgeServer', () => { }) const res = await fetch(`${url}/run`, { method: 'POST', - headers: { 'content-type': 'application/json' }, + headers: authed, body: JSON.stringify({ taskId: 't1', prompt: 'x' }) }) expect(res.status).toBe(502) diff --git a/packages/devkit/src/bridge-server.ts b/packages/devkit/src/bridge-server.ts index 9a86eec26..96b7a7ed9 100644 --- a/packages/devkit/src/bridge-server.ts +++ b/packages/devkit/src/bridge-server.ts @@ -11,14 +11,25 @@ * streaming (SSE, which is what the panel's provider requests) and one-shot. * * Hardened like the MCP HTTP transport (`@xnetjs/plugins` `mcp-http.ts`): binds - * loopback only, answers `OPTIONS` preflights, gates by `Origin` (loopback + - * an allowlist — never reflects `*` to an arbitrary site), and emits + * loopback only, validates the `Host` header (anti-DNS-rebinding), answers + * `OPTIONS` preflights, gates by `Origin` (loopback + an allowlist — never + * reflects `*` to an arbitrary site), requires a per-launch **pairing token** on + * the data endpoints (constant-time compared), and emits * `Access-Control-Allow-Private-Network` so an HTTPS page can reach the loopback * daemon (Chrome's Local Network Access flow). + * + * The token is the layer that survives regardless of browser: loopback-bind + + * origin allowlist alone is exactly the assumption DNS rebinding / a drive-by + * site defeats (the Ollama CVE-2024-28224 class). It is delivered out-of-band — + * the Electron main process injects it into its renderer over preload, and + * `xnet bridge serve` prints it as a pairing code the user pastes into the web + * app. `GET /health` stays unauthenticated so the connector ladder can detect + * the bridge before pairing. */ import type { ChatAgent, ChatMessage } from './chat-agent' import type { AgentTaskResult } from './dev-loop' +import { randomBytes, timingSafeEqual } from 'node:crypto' import { createServer, type IncomingMessage, type Server, type ServerResponse } from 'node:http' import { bridgeHealth, type BridgeRunRequest } from './bridge' @@ -43,6 +54,14 @@ export interface BridgeServerConfig { * web origin must be listed here to reach the local agent. */ allowedOrigins?: string[] + /** + * Shared secret required in `Authorization: Bearer ` on the data + * endpoints (`/v1/chat/completions`, `/run`). A cryptographically random token + * is generated when omitted; read it back from + * {@link BridgeServerHandle.pairingToken} to hand to the client out-of-band. + * `/health` is never gated, so detection works before pairing. + */ + pairingToken?: string /** * Optional code-task handler for `POST /run` (e.g. devkit `handleBridgeRun`): * isolate in a worktree → agent edits → gate → checkpoint/rollback. Opt-in — @@ -57,6 +76,8 @@ export interface BridgeServerHandle { stop(): Promise /** Resolved base URL, valid after `start()`. */ readonly url: string + /** The pairing token clients must present on the data endpoints. */ + readonly pairingToken: string } export function createBridgeServer(config: BridgeServerConfig): BridgeServerHandle { @@ -68,12 +89,21 @@ export function createBridgeServer(config: BridgeServerConfig): BridgeServerHand } const requestedPort = config.port ?? DEFAULT_BRIDGE_PORT const allowed = new Set(config.allowedOrigins ?? []) + const pairingToken = config.pairingToken ?? randomBytes(24).toString('base64url') const agentName = config.agentName ?? 'agent' const version = config.version ?? '0.1.0' let boundPort = requestedPort let server: Server | undefined const onRequest = async (req: IncomingMessage, res: ServerResponse): Promise => { + // Reject any request whose Host isn't our exact loopback authority. This is + // the anti-DNS-rebinding gate: a rebinding page sends `Host: evil.com`, so it + // never reaches the origin/token checks below (the fix Ollama shipped for + // CVE-2024-28224). Checked before everything else. + if (!isHostAllowed(headerStr(req.headers.host), boundPort)) { + endStatus(res, 403) + return + } const origin = headerStr(req.headers.origin) const ok = isOriginAllowed(origin, allowed) @@ -100,6 +130,10 @@ export function createBridgeServer(config: BridgeServerConfig): BridgeServerHand } if (req.method === 'POST' && path === '/v1/chat/completions') { + if (!isTokenValid(headerStr(req.headers.authorization), pairingToken)) { + sendJson(res, 401, { error: { message: 'invalid or missing pairing token' } }) + return + } let body: Record try { body = await readJson(req) @@ -123,6 +157,10 @@ export function createBridgeServer(config: BridgeServerConfig): BridgeServerHand } if (req.method === 'POST' && path === '/run') { + if (!isTokenValid(headerStr(req.headers.authorization), pairingToken)) { + sendJson(res, 401, { error: { message: 'invalid or missing pairing token' } }) + return + } if (!config.run) { sendJson(res, 501, { error: 'code tasks are not enabled on this bridge' }) return @@ -160,6 +198,7 @@ export function createBridgeServer(config: BridgeServerConfig): BridgeServerHand get url() { return `http://${host}:${boundPort}` }, + pairingToken, start() { return new Promise((resolve, reject) => { const created = createServer((req, res) => { @@ -189,6 +228,30 @@ export function createBridgeServer(config: BridgeServerConfig): BridgeServerHand // ─── Helpers ───────────────────────────────────────────────────────────────── +/** + * Accept only requests whose `Host` header is our exact loopback authority + * (`127.0.0.1:` / `localhost:` / `[::1]:`). A DNS-rebinding + * page reaches `127.0.0.1` at the socket level but still carries the attacker's + * hostname in `Host`, so this rejects it before any handler runs. + */ +function isHostAllowed(hostHeader: string | undefined, boundPort: number): boolean { + if (!hostHeader) return false + const portMatch = hostHeader.match(/:(\d+)$/) + const hostname = hostHeader.replace(/:\d+$/, '').replace(/^\[|\]$/g, '') + if (!LOOPBACK_HOSTS.has(hostname)) return false + // A port is required in practice (the panel always hits an explicit port), but + // if absent we can't mismatch it; when present it must equal the bound port. + return portMatch === null || portMatch[1] === String(boundPort) +} + +/** Constant-time compare of the presented `Authorization: Bearer `. */ +function isTokenValid(authHeader: string | undefined, expected: string): boolean { + const presented = (authHeader ?? '').replace(/^Bearer\s+/i, '') + const a = Buffer.from(presented) + const b = Buffer.from(expected) + return a.length === b.length && timingSafeEqual(a, b) +} + function isOriginAllowed(origin: string | undefined, allowed: Set): boolean { if (origin === undefined) return true // non-browser client (curl, the CLI) if (origin === 'null') return true // file:// pages (packaged Electron) From 15bd2b41a62f4aa1cda5fc7a35d5b153060e51d8 Mon Sep 17 00:00:00 2001 From: xNet Test Date: Thu, 9 Jul 2026 07:29:43 -0700 Subject: [PATCH 03/11] feat(plugins): validate Host header on the MCP HTTP transport Mirror the agent-bridge anti-DNS-rebinding gate: reject any request whose Host isn't the exact loopback authority before origin/token checks. Exploration 0289. Co-Authored-By: Claude Opus 4.8 Signed-off-by: xNet Test --- ...CONNECTING_THE_BROWSER_TO_A_LOCAL_MODEL.md | 2 +- .../plugins/src/__tests__/mcp-http.test.ts | 18 +++++++++++ packages/plugins/src/services/mcp-http.ts | 32 ++++++++++++++++++- 3 files changed, 50 insertions(+), 2 deletions(-) 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 d4f086f7f..8527b3ec4 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 @@ -576,7 +576,7 @@ connect-src 'self' - [ ] Add `http://127.0.0.1:*` and `ws://127.0.0.1:*` to `connect-src` in [`apps/web/index.html`](apps/web/index.html). -- [ ] Add `Host`-header validation to +- [x] Add `Host`-header validation to [`packages/devkit/src/bridge-server.ts`](packages/devkit/src/bridge-server.ts) **and** [`packages/plugins/src/services/mcp-http.ts`](packages/plugins/src/services/mcp-http.ts) (exact loopback authority + bound port). diff --git a/packages/plugins/src/__tests__/mcp-http.test.ts b/packages/plugins/src/__tests__/mcp-http.test.ts index 2c649809f..057428d7d 100644 --- a/packages/plugins/src/__tests__/mcp-http.test.ts +++ b/packages/plugins/src/__tests__/mcp-http.test.ts @@ -5,6 +5,7 @@ * port and exercises the trust-boundary rules with `fetch`. */ +import { request } from 'node:http' import { afterEach, beforeEach, describe, expect, it } from 'vitest' import { createMcpHttpServer, type McpHttpServerHandle } from '../services/mcp-http' import { createMCPServer } from '../services/mcp-server' @@ -116,6 +117,23 @@ describe('createMcpHttpServer — boundary hardening', () => { expect(body.ok).toBe(true) expect(body.server.name).toBe('xnet') }) + + it('rejects a request whose Host is not our loopback authority (anti-rebind, 403)', async () => { + // fetch/undici overrides Host with the URL authority, so simulate a + // DNS-rebinding request (attacker hostname reaching 127.0.0.1) via node:http. + const status = await new Promise((resolve, reject) => { + const req = request( + { hostname: '127.0.0.1', port: handle.port, path: '/health', headers: { host: 'evil.example' } }, + (res) => { + res.resume() + resolve(res.statusCode ?? 0) + } + ) + req.on('error', reject) + req.end() + }) + expect(status).toBe(403) + }) }) describe('createMcpHttpServer — JSON-RPC round trips', () => { diff --git a/packages/plugins/src/services/mcp-http.ts b/packages/plugins/src/services/mcp-http.ts index 731e588fb..e8dcc04ca 100644 --- a/packages/plugins/src/services/mcp-http.ts +++ b/packages/plugins/src/services/mcp-http.ts @@ -101,7 +101,13 @@ export function createMcpHttpServer(config: McpHttpServerConfig): McpHttpServerH let boundPort = requestedPort const handler = (req: IncomingMessage, res: ServerResponse): void => { - void handleHttp(req, res, { server, pairingToken, allowedOrigins, path }) + void handleHttp(req, res, { + server, + pairingToken, + allowedOrigins, + path, + boundPort: () => boundPort + }) } return { @@ -146,6 +152,8 @@ interface HandlerContext { pairingToken: string allowedOrigins: Set path: string + /** Bound port (read lazily — it's only known after `listen`). */ + boundPort: () => number } async function handleHttp( @@ -153,6 +161,14 @@ async function handleHttp( res: ServerResponse, ctx: HandlerContext ): Promise { + // Anti-DNS-rebinding: reject any request whose Host isn't our exact loopback + // authority before anything else. A rebinding page reaches 127.0.0.1 at the + // socket but still carries its own hostname in Host (the fix Ollama shipped + // for CVE-2024-28224). Applies even to /health and OPTIONS. + if (!isHostAllowed(req.headers.host, ctx.boundPort())) { + res.writeHead(403).end() + return + } const origin = req.headers.origin const originDecision = decideOrigin(origin, ctx.allowedOrigins) @@ -215,6 +231,20 @@ interface OriginDecision { allowedOrigin: string | null } +/** + * Accept only requests whose `Host` header is our exact loopback authority + * (`127.0.0.1:` / `localhost:` / `[::1]:`) — the anti-DNS- + * rebinding gate shared with the agent bridge daemon. + */ +function isHostAllowed(hostHeader: string | string[] | undefined, boundPort: number): boolean { + const host = Array.isArray(hostHeader) ? hostHeader[0] : hostHeader + if (!host) return false + const portMatch = host.match(/:(\d+)$/) + const hostname = host.replace(/:\d+$/, '').replace(/^\[|\]$/g, '') + if (!LOOPBACK_HOSTS.has(hostname)) return false + return portMatch === null || portMatch[1] === String(boundPort) +} + function decideOrigin(origin: string | undefined, allowed: Set): OriginDecision { // No Origin header => non-browser client (CLI/native). Allowed; token gates it. if (origin === undefined) return { ok: true, allowedOrigin: null } From e3af3eb07c8480dcd7835df4645f76c94e371ec4 Mon Sep 17 00:00:00 2001 From: xNet Test Date: Thu, 9 Jul 2026 07:32:26 -0700 Subject: [PATCH 04/11] feat(app): wire bridge allowedOrigins + out-of-band pairing-code delivery Electron starts the bridge with allowedOrigins (app.xnet.fyi + XNET_BRIDGE_ALLOWED_ORIGINS) and forwards the pairing token to its renderer over IPC status (never HTTP). `xnet bridge serve` prints the pairing code and gains --token to pin it. Exploration 0289. Co-Authored-By: Claude Opus 4.8 Signed-off-by: xNet Test --- .../electron/src/main/agent-bridge-manager.ts | 30 +++++++++++++++++-- ...CONNECTING_THE_BROWSER_TO_A_LOCAL_MODEL.md | 4 +-- packages/cli/src/commands/bridge.test.ts | 17 ++++++++--- packages/cli/src/commands/bridge.ts | 17 +++++++++-- 4 files changed, 57 insertions(+), 11 deletions(-) diff --git a/apps/electron/src/main/agent-bridge-manager.ts b/apps/electron/src/main/agent-bridge-manager.ts index 7d1768389..16eaee917 100644 --- a/apps/electron/src/main/agent-bridge-manager.ts +++ b/apps/electron/src/main/agent-bridge-manager.ts @@ -28,6 +28,13 @@ export interface AgentBridgeStatus { running: boolean agent: string url?: string + /** + * The pairing token a browser must present as `Authorization: Bearer `. + * Delivered to the renderer over IPC only — never over HTTP — so the xNet app + * can auto-pair; an external browser gets it via the `xnet bridge serve` + * pairing code instead. Present only while `running`. + */ + token?: string detail?: string } @@ -38,6 +45,20 @@ function resolveAgent(explicit?: string): string { return explicit ?? process.env.XNET_BRIDGE_AGENT ?? 'claude' } +/** + * Browser origins allowed to reach the loopback bridge, on top of loopback + * origins. The deployed PWA must be listed here or its `https://app.xnet.fyi` + * origin is rejected by the daemon's origin gate. Self-hosters extend the set + * via `XNET_BRIDGE_ALLOWED_ORIGINS` (comma-separated). + */ +function resolveAllowedOrigins(): string[] { + const extra = (process.env.XNET_BRIDGE_ALLOWED_ORIGINS ?? '') + .split(',') + .map((origin) => origin.trim()) + .filter(Boolean) + return ['https://app.xnet.fyi', ...extra] +} + /** * Opt-in: give the agent XNet's workspace tools by pointing its MCP config at a * resolvable `xnet mcp serve`. Requires `XNET_BRIDGE_MCP=1` and a CLI entry @@ -77,7 +98,12 @@ export async function startAgentBridge( const mcpConfigPath = resolveMcpConfigPath() const args = buildAgentArgs(agentCmd, { ...(mcpConfigPath ? { mcpConfigPath } : {}) }) const agent = cliChatAgent(runner, { command: agentCmd, cwd, args }) - const server = createBridgeServer({ agent, agentName: agentCmd, version: app.getVersion() }) + const server = createBridgeServer({ + agent, + agentName: agentCmd, + version: app.getVersion(), + allowedOrigins: resolveAllowedOrigins() + }) try { await server.start() } catch (err) { @@ -89,7 +115,7 @@ export async function startAgentBridge( return status } handle = server - status = { running: true, agent: agentCmd, url: server.url } + status = { running: true, agent: agentCmd, url: server.url, token: server.pairingToken } return status } 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 8527b3ec4..e4143b992 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 @@ -584,10 +584,10 @@ connect-src 'self' `/v1/chat/completions` and `/run` on `Authorization: Bearer` with a constant-time compare; expose `pairingToken` on the handle; leave `/health` unauthenticated. -- [ ] Pass `allowedOrigins` (deployed PWA origin + `XNET_BRIDGE_ALLOWED_ORIGINS`) +- [x] Pass `allowedOrigins` (deployed PWA origin + `XNET_BRIDGE_ALLOWED_ORIGINS`) from [`apps/electron/src/main/agent-bridge-manager.ts`](apps/electron/src/main/agent-bridge-manager.ts) and the `xnet bridge serve` CLI. -- [ ] Print the pairing code from `xnet bridge serve` +- [x] Print the pairing code from `xnet bridge serve` ([`packages/cli/src/commands/bridge.ts`](packages/cli/src/commands/bridge.ts)) and inject it into the Electron renderer via preload (`window.xnetAgentBridge` / a `xnet:bridge-token` channel). diff --git a/packages/cli/src/commands/bridge.test.ts b/packages/cli/src/commands/bridge.test.ts index 7608f64b5..aa6e29b7f 100644 --- a/packages/cli/src/commands/bridge.test.ts +++ b/packages/cli/src/commands/bridge.test.ts @@ -23,11 +23,11 @@ describe('buildBridgeServer', () => { const runner = new FakeCommandRunner([ { match: () => true, result: { stdout: 'codex says hi' } } ]) - handle = buildBridgeServer({ agent: 'codex', port: 0 }, runner) + handle = buildBridgeServer({ agent: 'codex', port: 0, token: 'test-token' }, runner) await handle.start() const res = await fetch(`${handle.url}/v1/chat/completions`, { method: 'POST', - headers: { 'content-type': 'application/json' }, + headers: { 'content-type': 'application/json', authorization: 'Bearer test-token' }, body: JSON.stringify({ messages: [{ role: 'user', content: 'hi' }] }) }) const body = (await res.json()) as { choices: Array<{ message: { content: string } }> } @@ -36,13 +36,22 @@ describe('buildBridgeServer', () => { expect(runner.calls[0].args).toEqual(['exec', 'hi']) }) + it('pins the pairing token when --token is given', async () => { + handle = buildBridgeServer({ agent: 'claude', port: 0, token: 'pinned-code' }, new FakeCommandRunner()) + await handle.start() + expect(handle.pairingToken).toBe('pinned-code') + }) + it('hands XNet workspace tools to the agent when mcpConfigPath is set', async () => { const runner = new FakeCommandRunner([{ match: () => true, result: { stdout: 'ok' } }]) - handle = buildBridgeServer({ agent: 'claude', port: 0, mcpConfigPath: '/tmp/cfg.json' }, runner) + handle = buildBridgeServer( + { agent: 'claude', port: 0, mcpConfigPath: '/tmp/cfg.json', token: 'test-token' }, + runner + ) await handle.start() await fetch(`${handle.url}/v1/chat/completions`, { method: 'POST', - headers: { 'content-type': 'application/json' }, + headers: { 'content-type': 'application/json', authorization: 'Bearer test-token' }, body: JSON.stringify({ messages: [{ role: 'user', content: 'hi' }] }) }) expect(runner.calls[0].args).toEqual([ diff --git a/packages/cli/src/commands/bridge.ts b/packages/cli/src/commands/bridge.ts index 40d77b6a5..f2d3fdc68 100644 --- a/packages/cli/src/commands/bridge.ts +++ b/packages/cli/src/commands/bridge.ts @@ -34,6 +34,8 @@ export interface BridgeServeOptions { host?: string port?: number allowOrigin?: string[] + /** Pin the pairing token (default: a random per-launch code printed on start). */ + token?: string /** Working directory the agent runs in (default `process.cwd()`). */ cwd?: string /** Path to an MCP config JSON giving the agent XNet's workspace tools. */ @@ -74,7 +76,8 @@ export function buildBridgeServer( ...(run ? { run } : {}), ...(options.host ? { host: options.host } : {}), ...(options.port !== undefined ? { port: options.port } : {}), - ...(options.allowOrigin ? { allowedOrigins: options.allowOrigin } : {}) + ...(options.allowOrigin ? { allowedOrigins: options.allowOrigin } : {}), + ...(options.token ? { pairingToken: options.token } : {}) }) } @@ -91,7 +94,11 @@ export function registerBridgeCommand(program: Command): void { .option('--port ', `Port (default ${DEFAULT_BRIDGE_PORT})`, parseIntOption) .option( '--allow-origin ', - 'Browser origins permitted (e.g. https://user.github.io for the web deployment)' + 'Browser origins permitted (e.g. https://app.xnet.fyi for the web deployment)' + ) + .option( + '--token ', + 'Pin the pairing code browsers must present (default: a random per-launch code)' ) .option('--cwd ', 'Working directory the agent runs in (default current dir)') .option('--code', 'Enable POST /run agentic code tasks (worktree → gate → checkpoint/PR)') @@ -127,7 +134,11 @@ export function registerBridgeCommand(program: Command): void { options.mcp ? ', workspace tools enabled' : '' })` ) - console.error('In XNet, open the AI panel and select "Local bridge".') + // The pairing code the daemon now requires on its data endpoints. Printed + // here so the user can paste it into the web app's AI settings ("Local + // bridge" tier) — it is never exposed over HTTP. + console.error(`Pairing code: ${handle.pairingToken}`) + console.error('In XNet, open the AI panel, select "Local bridge", and paste the pairing code.') const shutdown = (): void => { void handle.stop().then(() => process.exit(0)) } From d9b39b33cf723f6e4c66999df37f0b41912b7f8d Mon Sep 17 00:00:00 2001 From: xNet Test Date: Thu, 9 Jul 2026 07:33:57 -0700 Subject: [PATCH 05/11] feat(plugins): exact-origin OLLAMA_ORIGINS guidance for the local-server tier detectConnectors accepts appOrigin and bakes the precise `OLLAMA_ORIGINS= ollama serve` line (never a wildcard) into the local-server setup hint. Exploration 0289. Co-Authored-By: Claude Opus 4.8 Signed-off-by: xNet Test --- .../plugins/src/ai/connectors/detect.test.ts | 7 +++++++ packages/plugins/src/ai/connectors/detect.ts | 21 +++++++++++++++---- packages/plugins/src/ai/connectors/types.ts | 7 +++++++ 3 files changed, 31 insertions(+), 4 deletions(-) diff --git a/packages/plugins/src/ai/connectors/detect.test.ts b/packages/plugins/src/ai/connectors/detect.test.ts index cbb03d201..961bb59e6 100644 --- a/packages/plugins/src/ai/connectors/detect.test.ts +++ b/packages/plugins/src/ai/connectors/detect.test.ts @@ -116,6 +116,13 @@ describe('detectConnectors', () => { expect(local?.setupHint).toMatch(/OLLAMA_ORIGINS|CORS/) }) + it('names the exact origin (never a wildcard) in the local-server hint', async () => { + const result = await detectConnectors({ ...NOTHING, appOrigin: 'https://app.xnet.fyi' }) + const local = result.find((d) => d.tier === 'local-server') + expect(local?.setupHint).toContain('OLLAMA_ORIGINS=https://app.xnet.fyi') + expect(local?.setupHint).not.toContain('OLLAMA_ORIGINS=*') + }) + it('detects a healthy bridge daemon and surfaces its url', async () => { const result = await detectConnectors({ ...NOTHING, diff --git a/packages/plugins/src/ai/connectors/detect.ts b/packages/plugins/src/ai/connectors/detect.ts index a6f9c9827..b09db4d26 100644 --- a/packages/plugins/src/ai/connectors/detect.ts +++ b/packages/plugins/src/ai/connectors/detect.ts @@ -62,6 +62,22 @@ export const CONNECTOR_META: Record = { const DEFAULT_BRIDGE_URL = 'http://127.0.0.1:31416' +/** + * Setup hint for the local-server tier. When the app origin is known, name the + * *exact* `OLLAMA_ORIGINS=` line — never a wildcard, which would let any + * website drive the user's local model (the Ollama community's own warning). + */ +export function localServerSetupHint(appOrigin?: string): string { + if (appOrigin) { + return ( + `Start Ollama or LM Studio and allow this origin — for Ollama run ` + + `\`OLLAMA_ORIGINS=${appOrigin} ollama serve\` (never \`*\`), or enable the ` + + `LM Studio CORS toggle.` + ) + } + return 'Start Ollama or LM Studio and allow this origin (OLLAMA_ORIGINS=, never *; or the LM Studio CORS toggle).' +} + /** Default local-model endpoints: Ollama (`/api/tags`) and LM Studio (`/v1/models`). */ export function defaultLocalServerProbes(): LocalServerProbe[] { return [ @@ -169,10 +185,7 @@ export async function detectConnectors(env: ConnectorEnv = {}): Promise Promise /** Bridge daemon base URL. Default: `http://127.0.0.1:31416`. */ bridgeUrl?: string + /** + * This app's own origin (e.g. `https://app.xnet.fyi`). When provided, the + * `local-server` setup hint names the *exact* `OLLAMA_ORIGINS=` line to + * run — never a wildcard, which would let any site drive the user's local + * model. Default: unset (a generic hint). + */ + appOrigin?: string } /** A named local model endpoint and how to detect it. */ From 09f48046b4069d5dd2e732deb9cd6110b96122ea Mon Sep 17 00:00:00 2001 From: xNet Test Date: Thu, 9 Jul 2026 07:38:12 -0700 Subject: [PATCH 06/11] feat(ai): pair the web app to the local bridge securely Thread the bridge pairing code through the connector (Authorization: Bearer), add a pairing-code field (auto-filled over IPC under Electron), query the Chrome loopback-network permission and hint on denial, pass appOrigin so the local-server hint names the exact OLLAMA_ORIGINS line, and add 127.0.0.1 to the CSP connect-src. Exploration 0289. Co-Authored-By: Claude Opus 4.8 Signed-off-by: xNet Test --- apps/web/index.html | 2 +- apps/web/src/workbench/views/AiChatPanel.tsx | 122 ++++++++++++++++-- .../workbench/views/ai-chat-connector.test.ts | 14 +- .../src/workbench/views/ai-chat-connector.ts | 20 ++- ...CONNECTING_THE_BROWSER_TO_A_LOCAL_MODEL.md | 8 +- 5 files changed, 147 insertions(+), 19 deletions(-) diff --git a/apps/web/index.html b/apps/web/index.html index 68f540e89..1d21ae396 100644 --- a/apps/web/index.html +++ b/apps/web/index.html @@ -6,7 +6,7 @@ diff --git a/apps/web/src/workbench/views/AiChatPanel.tsx b/apps/web/src/workbench/views/AiChatPanel.tsx index d3b1353fd..562fcefc9 100644 --- a/apps/web/src/workbench/views/AiChatPanel.tsx +++ b/apps/web/src/workbench/views/AiChatPanel.tsx @@ -65,6 +65,8 @@ import { buildWebLLMProvider, type WebLLMProgress } from './ai-webllm-engine' /** Electron preload control channel for the local agent bridge (absent on web). */ interface AgentBridgeControl { start: (agent?: string) => Promise + /** Current daemon status, including the pairing token (IPC only, never HTTP). */ + status?: () => Promise<{ running?: boolean; token?: string } | undefined> } declare global { @@ -104,6 +106,12 @@ export function AiChatPanel() { const [bridgeHealth, setBridgeHealth] = useState(null) const [bridgeRefresh, setBridgeRefresh] = useState(0) const [model, setModel] = useState(() => readSetting(AI_CHAT_STORAGE_KEYS.model)) + const [bridgeToken, setBridgeToken] = useState(() => + readSetting(AI_CHAT_STORAGE_KEYS.bridgeToken) + ) + // Chrome 142+/145+ gates https→loopback behind a `loopback-network` permission + // (null = not yet queried / browser has no such gate, e.g. Safari today). + const [loopbackPermission, setLoopbackPermission] = useState(null) const [budget, setBudget] = useState(null) const [managedModels, setManagedModels] = useState([]) // In-tab model activation (exploration 0252). Both in-tab tiers gate their @@ -123,8 +131,13 @@ export function AiChatPanel() { const cleanupRef = useRef<(() => void) | null>(null) const settings = useMemo( - () => ({ apiKey: apiKey || undefined, cloudProvider, model: model || undefined }), - [apiKey, cloudProvider, model] + () => ({ + apiKey: apiKey || undefined, + cloudProvider, + model: model || undefined, + bridgeToken: bridgeToken || undefined + }), + [apiKey, cloudProvider, model, bridgeToken] ) // Reset the budget gauge whenever the active model changes — the next managed @@ -195,7 +208,8 @@ export function AiChatPanel() { // longer trusts `navigator.gpu` alone. void detectConnectors({ hasCloudKey: () => apiKey.length > 0, - hasWebLLMEngine: () => true + hasWebLLMEngine: () => true, + ...(typeof location !== 'undefined' ? { appOrigin: location.origin } : {}) }).then((result) => { if (cancelled) return setDetections(result) @@ -257,6 +271,52 @@ export function AiChatPanel() { } }, [bridgeBaseUrl, bridgeRefresh]) + // Auto-pair under Electron: the main process hands the daemon's pairing token + // to the renderer over IPC (never HTTP), so the xNet app can talk to its own + // bridge without the user copying a code. A plain browser has no such channel + // and falls back to the pairing-code field below. + useEffect(() => { + if (selected?.tier !== 'bridge') return + const control = typeof window !== 'undefined' ? window.xnetAgentBridge : undefined + if (!control?.status) return + let cancelled = false + void control + .status() + .then((state) => { + if (cancelled || !state?.token) return + setBridgeToken(state.token) + writeSetting(AI_CHAT_STORAGE_KEYS.bridgeToken, state.token) + }) + .catch(() => {}) + return () => { + cancelled = true + } + }, [selected, bridgeRefresh]) + + // Loopback tiers reach `http://127.0.0.1:*` from an https page, which Chrome + // 142+/145+ gates behind a `loopback-network` permission. Query it so we can + // guide the user instead of failing silently (Safari/older browsers lack the + // gate → the query rejects → null → no hint, which is correct there). + useEffect(() => { + const loopbackTier = selected?.tier === 'bridge' || selected?.tier === 'local-server' + if (!loopbackTier || typeof navigator === 'undefined' || !navigator.permissions?.query) { + setLoopbackPermission(null) + return + } + let cancelled = false + void navigator.permissions + .query({ name: 'loopback-network' as PermissionName }) + .then((status) => { + if (!cancelled) setLoopbackPermission(status.state) + }) + .catch(() => { + if (!cancelled) setLoopbackPermission(null) + }) + return () => { + cancelled = true + } + }, [selected]) + // Managed: load the plan-gated model catalog so the picker is data-driven, and // preselect the plan's default model when the user hasn't chosen one. const managedActive = selected?.tier === 'managed' && selected.available @@ -411,12 +471,28 @@ export function AiChatPanel() { hasSelection={!!selected} /> {selected?.tier === 'bridge' && ( - + <> + + { + setBridgeToken(value) + writeSetting(AI_CHAT_STORAGE_KEYS.bridgeToken, value) + }} + /> + )} + {(selected?.tier === 'bridge' || selected?.tier === 'local-server') && + loopbackPermission === 'denied' && ( +

+ Local network access is blocked. Allow it for this site in your browser’s settings to + reach a model on this machine. +

+ )} {selected?.tier === 'cloud-key' && ( void +}) { + return ( +
+ onToken(event.target.value)} + className="min-w-0 flex-1 rounded-md border border-hairline bg-surface-0 px-2 py-1 text-[11px] text-ink-1 outline-none placeholder:text-ink-3" + /> +

+ Paste the code xnet bridge serve prints. Sent only to your local bridge — never + to our servers. +

+
+ ) +} + function ConnectorBar({ detections, selectedTier, diff --git a/apps/web/src/workbench/views/ai-chat-connector.test.ts b/apps/web/src/workbench/views/ai-chat-connector.test.ts index f11a47e0c..f29b05148 100644 --- a/apps/web/src/workbench/views/ai-chat-connector.test.ts +++ b/apps/web/src/workbench/views/ai-chat-connector.test.ts @@ -64,17 +64,25 @@ describe('providerConfigForConnector', () => { expect(config?.options.baseUrl).toBe('http://localhost:1234') }) - it('maps the bridge to an OpenAI-compatible endpoint', () => { + it('maps the bridge to an OpenAI-compatible endpoint with the pairing token', () => { const config = providerConfigForConnector( det({ tier: 'bridge', detail: 'http://127.0.0.1:31416' }), - {} + { bridgeToken: 'pair-123' } ) expect(config).toEqual({ type: 'openai-compatible', - options: { baseUrl: 'http://127.0.0.1:31416' } + options: { baseUrl: 'http://127.0.0.1:31416', apiKey: 'pair-123' } }) }) + it('returns null for the bridge until a pairing token is supplied', () => { + const config = providerConfigForConnector( + det({ tier: 'bridge', detail: 'http://127.0.0.1:31416' }), + {} + ) + expect(config).toBeNull() + }) + it('maps managed to the keyless managed provider at the same origin', () => { const config = providerConfigForConnector(det({ tier: 'managed' }), { model: 'anthropic/claude-sonnet-4-6' diff --git a/apps/web/src/workbench/views/ai-chat-connector.ts b/apps/web/src/workbench/views/ai-chat-connector.ts index cb907e81b..18d5a590b 100644 --- a/apps/web/src/workbench/views/ai-chat-connector.ts +++ b/apps/web/src/workbench/views/ai-chat-connector.ts @@ -22,6 +22,12 @@ export interface AiChatSettings { localBaseUrl?: string /** Hub base URL for the managed tier (default `''` = same origin). */ hubBaseUrl?: string + /** + * Pairing code for the local bridge daemon, sent as `Authorization: Bearer`. + * Under Electron it's auto-supplied over IPC; in a plain browser the user + * pastes the code `xnet bridge serve` prints. + */ + bridgeToken?: string } /** localStorage keys (xnet:* convention). */ @@ -30,6 +36,8 @@ export const AI_CHAT_STORAGE_KEYS = { cloudProvider: 'xnet:ai-cloud-provider', model: 'xnet:ai-model', localBaseUrl: 'xnet:ai-local-base-url', + /** The local-bridge pairing code (survives reload; per-launch tokens re-pair). */ + bridgeToken: 'xnet:ai-bridge-token', /** The connector tier the user last selected (survives reload). */ tier: 'xnet:ai-tier', /** Opt-in: use on-device semantic (vector) entry search (exploration 0211). */ @@ -114,12 +122,18 @@ export function providerConfigForConnector( } } case 'bridge': { - // The bridge daemon exposes an OpenAI-compatible endpoint on loopback. + // The bridge daemon exposes an OpenAI-compatible endpoint on loopback and + // now requires the pairing code as `Authorization: Bearer` — without it the + // daemon answers 401, so treat a missing code as "not configured yet". const baseUrl = baseUrlFromDetail(detection.detail) - if (!baseUrl) return null + if (!baseUrl || !settings.bridgeToken) return null return { type: 'openai-compatible', - options: { baseUrl, ...(settings.model ? { model: settings.model } : {}) } + options: { + baseUrl, + apiKey: settings.bridgeToken, + ...(settings.model ? { model: settings.model } : {}) + } } } default: 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 e4143b992..39975b301 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 @@ -574,7 +574,7 @@ connect-src 'self' ## Implementation Checklist -- [ ] Add `http://127.0.0.1:*` and `ws://127.0.0.1:*` to `connect-src` in +- [x] Add `http://127.0.0.1:*` and `ws://127.0.0.1:*` to `connect-src` in [`apps/web/index.html`](apps/web/index.html). - [x] Add `Host`-header validation to [`packages/devkit/src/bridge-server.ts`](packages/devkit/src/bridge-server.ts) @@ -591,13 +591,13 @@ connect-src 'self' ([`packages/cli/src/commands/bridge.ts`](packages/cli/src/commands/bridge.ts)) and inject it into the Electron renderer via preload (`window.xnetAgentBridge` / a `xnet:bridge-token` channel). -- [ ] Add a "bridge pairing code" field to the AI settings in +- [x] Add a "bridge pairing code" field to the AI settings in [`AiChatPanel.tsx`](apps/web/src/workbench/views/AiChatPanel.tsx); persist under `AI_CHAT_STORAGE_KEYS`; feed it as the provider `apiKey`/bearer. -- [ ] Query `navigator.permissions.query({ name: 'loopback-network' })` and show +- [x] Query `navigator.permissions.query({ name: 'loopback-network' })` and show an "allow local network access" hint on `prompt`/`denied` instead of a silent dead box. -- [ ] Show exact `OLLAMA_ORIGINS=` / LM Studio CORS guidance (never +- [x] Show exact `OLLAMA_ORIGINS=` / LM Studio CORS guidance (never `*`) in the `local-server` tier setup hint ([`detect.ts`](packages/plugins/src/ai/connectors/detect.ts) / [`AiChatPanel.tsx`](apps/web/src/workbench/views/AiChatPanel.tsx)). From 51471cb383a7a8813b44a2a06010f232c3232c41 Mon Sep 17 00:00:00 2001 From: xNet Test Date: Thu, 9 Jul 2026 07:40:38 -0700 Subject: [PATCH 07/11] feat(devkit): let the bridge front a raw local model (upstream proxy) openAiChatAgent forwards the conversation to an upstream OpenAI-compatible server (Ollama/LM Studio/vLLM); `xnet bridge serve --upstream ` routes a raw local model through the authenticated, origin-locked, Host-validated bridge instead of the user weakening the model server's own CORS. Exploration 0289. Co-Authored-By: Claude Opus 4.8 Signed-off-by: xNet Test --- ...CONNECTING_THE_BROWSER_TO_A_LOCAL_MODEL.md | 2 +- packages/cli/src/commands/bridge.ts | 21 ++++++++- packages/devkit/src/chat-agent.test.ts | 42 ++++++++++++++++- packages/devkit/src/chat-agent.ts | 46 +++++++++++++++++++ packages/devkit/src/index.ts | 4 +- 5 files changed, 110 insertions(+), 5 deletions(-) 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 39975b301..b86302071 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 @@ -601,7 +601,7 @@ connect-src 'self' `*`) in the `local-server` tier setup hint ([`detect.ts`](packages/plugins/src/ai/connectors/detect.ts) / [`AiChatPanel.tsx`](apps/web/src/workbench/views/AiChatPanel.tsx)). -- [ ] (Optional) Add an upstream-proxy mode to the bridge so `/v1/chat/completions` +- [x] (Optional) Add an upstream-proxy mode to the bridge so `/v1/chat/completions` can forward to Ollama `:11434`, giving raw-local-model users the same authenticated door. - [ ] Update tests: `bridge-server.test.ts` (token + Host cases), diff --git a/packages/cli/src/commands/bridge.ts b/packages/cli/src/commands/bridge.ts index f2d3fdc68..edf923b76 100644 --- a/packages/cli/src/commands/bridge.ts +++ b/packages/cli/src/commands/bridge.ts @@ -23,7 +23,9 @@ import { handleBridgeRun, mcpConfigFor, NodeCommandRunner, + openAiChatAgent, type BridgeServerHandle, + type ChatAgent, type CommandRunner } from '@xnetjs/devkit' import { Command } from 'commander' @@ -42,6 +44,14 @@ export interface BridgeServeOptions { mcpConfigPath?: string /** Enable `POST /run` — agentic code tasks (worktree → gate → checkpoint/PR). */ code?: boolean + /** + * Front a raw OpenAI-compatible model server (e.g. Ollama at + * `http://localhost:11434`) instead of a coding-agent CLI, so browser access + * to it goes through the authenticated, origin-locked bridge. + */ + upstream?: string + /** Model id to request from `--upstream` (default `llama3.2`). */ + upstreamModel?: string } /** Build (but don't start) the bridge server for the chosen agent. Injectable runner for tests. */ @@ -54,7 +64,11 @@ export function buildBridgeServer( const args = buildAgentArgs(command, { ...(options.mcpConfigPath ? { mcpConfigPath: options.mcpConfigPath } : {}) }) - const agent = cliChatAgent(runner, { command, cwd, args }) + // `--upstream` fronts a raw OpenAI-compatible model server through the bridge; + // otherwise drive the user's own coding-agent CLI. + const agent: ChatAgent = options.upstream + ? openAiChatAgent({ baseUrl: options.upstream, model: options.upstreamModel ?? 'llama3.2' }) + : cliChatAgent(runner, { command, cwd, args }) // `--code` enables the agentic dev-loop over HTTP (powerful → opt-in): the // coding agent edits in a worktree off `cwd`, then the gate runs. const run = options.code @@ -101,6 +115,11 @@ export function registerBridgeCommand(program: Command): void { 'Pin the pairing code browsers must present (default: a random per-launch code)' ) .option('--cwd ', 'Working directory the agent runs in (default current dir)') + .option( + '--upstream ', + 'Front a raw OpenAI-compatible server (e.g. http://localhost:11434 for Ollama) instead of a CLI' + ) + .option('--upstream-model ', 'Model id to request from --upstream (default llama3.2)') .option('--code', 'Enable POST /run agentic code tasks (worktree → gate → checkpoint/PR)') .option('--mcp', "Give the agent XNet's workspace tools via `xnet mcp serve`") .option( diff --git a/packages/devkit/src/chat-agent.test.ts b/packages/devkit/src/chat-agent.test.ts index 4588b07d1..cbc28562c 100644 --- a/packages/devkit/src/chat-agent.test.ts +++ b/packages/devkit/src/chat-agent.test.ts @@ -1,5 +1,11 @@ -import { describe, expect, it } from 'vitest' -import { cliChatAgent, fakeChatAgent, flattenChat, type ChatMessage } from './chat-agent' +import { describe, expect, it, vi } from 'vitest' +import { + cliChatAgent, + fakeChatAgent, + flattenChat, + openAiChatAgent, + type ChatMessage +} from './chat-agent' import { FakeCommandRunner } from './command-runner' const msgs = (...pairs: Array<[ChatMessage['role'], string]>): ChatMessage[] => @@ -55,3 +61,35 @@ describe('fakeChatAgent', () => { expect(await agent.chat(msgs(['user', 'hi']))).toBe('echo:hi') }) }) + +describe('openAiChatAgent', () => { + it('posts to the upstream /v1/chat/completions and returns the reply content', async () => { + const fetchImpl = vi.fn(async () => + new Response( + JSON.stringify({ choices: [{ message: { role: 'assistant', content: ' local reply ' } }] }), + { status: 200 } + ) + ) as unknown as typeof fetch + const agent = openAiChatAgent({ + baseUrl: 'http://localhost:11434/', + model: 'llama3.2', + apiKey: 'k', + fetchImpl + }) + const reply = await agent.chat(msgs(['user', 'hi'])) + expect(reply).toBe('local reply') + const [url, init] = (fetchImpl as unknown as ReturnType).mock.calls[0] + expect(url).toBe('http://localhost:11434/v1/chat/completions') + expect((init as RequestInit).headers).toMatchObject({ authorization: 'Bearer k' }) + expect(JSON.parse((init as RequestInit).body as string)).toMatchObject({ + model: 'llama3.2', + stream: false + }) + }) + + it('throws when the upstream server returns a non-2xx status', async () => { + const fetchImpl = vi.fn(async () => new Response('nope', { status: 500 })) as unknown as typeof fetch + const agent = openAiChatAgent({ baseUrl: 'http://localhost:11434', model: 'x', fetchImpl }) + await expect(agent.chat(msgs(['user', 'hi']))).rejects.toThrow(/HTTP 500/) + }) +}) diff --git a/packages/devkit/src/chat-agent.ts b/packages/devkit/src/chat-agent.ts index a1f303f4c..f2654a6ec 100644 --- a/packages/devkit/src/chat-agent.ts +++ b/packages/devkit/src/chat-agent.ts @@ -74,6 +74,52 @@ export function cliChatAgent(runner: CommandRunner, options: CliChatAgentOptions } } +export interface OpenAiChatAgentOptions { + /** Base URL of an OpenAI-compatible server, e.g. `http://localhost:11434` (Ollama). */ + baseUrl: string + /** Model id to request (e.g. `llama3.2`). */ + model: string + /** Optional bearer token for the upstream (LM Studio / a keyed gateway). */ + apiKey?: string + /** Per-turn timeout in ms. Default 120000. */ + timeoutMs?: number + /** Injectable fetch for tests. Default: global `fetch`. */ + fetchImpl?: typeof fetch +} + +/** + * A {@link ChatAgent} that forwards the conversation to an upstream + * OpenAI-compatible server (Ollama's `/v1`, LM Studio, vLLM, …). This lets the + * hardened bridge daemon *front* a raw local model, so browser access to it goes + * through the same authenticated, origin-locked, Host-validated door as the CLI + * agents — instead of the user weakening the model server's own CORS. The reply + * is returned as text; the bridge streams it back as OpenAI SSE. + */ +export function openAiChatAgent(options: OpenAiChatAgentOptions): ChatAgent { + const fetchImpl = options.fetchImpl ?? fetch + const endpoint = `${options.baseUrl.replace(/\/+$/, '')}/v1/chat/completions` + return { + async chat(messages) { + const response = await fetchImpl(endpoint, { + method: 'POST', + headers: { + 'content-type': 'application/json', + ...(options.apiKey ? { authorization: `Bearer ${options.apiKey}` } : {}) + }, + body: JSON.stringify({ model: options.model, messages, stream: false }), + signal: AbortSignal.timeout(options.timeoutMs ?? 120_000) + }) + if (!response.ok) { + throw new Error(`upstream model at ${options.baseUrl} failed (HTTP ${response.status})`) + } + const data = (await response.json()) as { + choices?: Array<{ message?: { content?: string } }> + } + return data.choices?.[0]?.message?.content?.trim() ?? '' + } + } +} + /** A test/dev {@link ChatAgent} that returns a scripted or derived reply. */ export function fakeChatAgent( reply: (messages: ChatMessage[]) => string | Promise diff --git a/packages/devkit/src/index.ts b/packages/devkit/src/index.ts index ddf73e87f..d67ce7d34 100644 --- a/packages/devkit/src/index.ts +++ b/packages/devkit/src/index.ts @@ -58,10 +58,12 @@ export { export { cliChatAgent, fakeChatAgent, + openAiChatAgent, flattenChat, type ChatAgent, type ChatMessage, - type CliChatAgentOptions + type CliChatAgentOptions, + type OpenAiChatAgentOptions } from './chat-agent' export { From 677856e0317800a0f6e78531ae490aca744570d9 Mon Sep 17 00:00:00 2001 From: xNet Test Date: Thu, 9 Jul 2026 07:41:56 -0700 Subject: [PATCH 08/11] docs(changeset): secure local-model bridge (devkit major, plugins/cli minor) Co-Authored-By: Claude Opus 4.8 Signed-off-by: xNet Test --- .changeset/secure-local-model-bridge-0289.md | 26 +++++++++++++++++++ ...CONNECTING_THE_BROWSER_TO_A_LOCAL_MODEL.md | 4 +-- 2 files changed, 28 insertions(+), 2 deletions(-) create mode 100644 .changeset/secure-local-model-bridge-0289.md diff --git a/.changeset/secure-local-model-bridge-0289.md b/.changeset/secure-local-model-bridge-0289.md new file mode 100644 index 000000000..62e7a733c --- /dev/null +++ b/.changeset/secure-local-model-bridge-0289.md @@ -0,0 +1,26 @@ +--- +'@xnetjs/devkit': major +'@xnetjs/plugins': minor +'@xnetjs/cli': minor +--- + +Secure the browser↔local-model bridge (exploration 0289). + +- **`@xnetjs/devkit` (breaking):** the agent bridge daemon now **requires a + per-launch pairing token** (`Authorization: Bearer `, constant-time + compared) on its data endpoints (`/v1/chat/completions`, `/run`) and validates + the `Host` header to reject DNS-rebinding requests. `BridgeServerConfig` gains + `pairingToken?`, `BridgeServerHandle` exposes `pairingToken`, and a token is + auto-generated when none is supplied — so a client that previously called the + data endpoints with no auth now gets `401`. `/health` stays unauthenticated so + detection still works before pairing. New `openAiChatAgent` lets the bridge + front a raw OpenAI-compatible model server (Ollama/LM Studio) through the same + authenticated door. +- **`@xnetjs/plugins`:** `ConnectorEnv` gains `appOrigin` and the local-server + setup hint now names the exact `OLLAMA_ORIGINS=` line (never a + wildcard); new `localServerSetupHint` export; the MCP HTTP transport now + validates the `Host` header (defense-in-depth, no change for legitimate + callers). Additive. +- **`@xnetjs/cli`:** `xnet bridge serve` prints the pairing code and gains + `--token` (pin the code) and `--upstream` / `--upstream-model` (front a raw + local model). Additive. 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 b86302071..b7ae050d4 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 @@ -604,9 +604,9 @@ connect-src 'self' - [x] (Optional) Add an upstream-proxy mode to the bridge so `/v1/chat/completions` can forward to Ollama `:11434`, giving raw-local-model users the same authenticated door. -- [ ] Update tests: `bridge-server.test.ts` (token + Host cases), +- [x] Update tests: `bridge-server.test.ts` (token + Host cases), `detect.test.ts`, `ai-chat-connector.test.ts`. -- [ ] Changeset for `@xnetjs/devkit` and `@xnetjs/plugins` — new required token on +- [x] Changeset for `@xnetjs/devkit` and `@xnetjs/plugins` — new required token on 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). From 839530f6dfa88a217fe188dc33f5ffc4253f4b09 Mon Sep 17 00:00:00 2001 From: xNet Test Date: Thu, 9 Jul 2026 07:44:46 -0700 Subject: [PATCH 09/11] style(ai): prettier formatting for bridge + panel changes Co-Authored-By: Claude Opus 4.8 Signed-off-by: xNet Test --- apps/web/src/workbench/views/AiChatPanel.tsx | 8 +------- packages/cli/src/commands/bridge.test.ts | 5 ++++- packages/cli/src/commands/bridge.ts | 4 +++- packages/devkit/src/chat-agent.test.ts | 17 +++++++++++------ packages/plugins/src/__tests__/mcp-http.test.ts | 7 ++++++- 5 files changed, 25 insertions(+), 16 deletions(-) diff --git a/apps/web/src/workbench/views/AiChatPanel.tsx b/apps/web/src/workbench/views/AiChatPanel.tsx index 562fcefc9..c46681868 100644 --- a/apps/web/src/workbench/views/AiChatPanel.tsx +++ b/apps/web/src/workbench/views/AiChatPanel.tsx @@ -788,13 +788,7 @@ function BridgeStatus({ * the code `xnet bridge serve` prints. Stored locally, sent only to the loopback * daemon as a bearer token — never to our servers. */ -function BridgePairing({ - token, - onToken -}: { - token: string - onToken: (value: string) => void -}) { +function BridgePairing({ token, onToken }: { token: string; onToken: (value: string) => void }) { return (
{ }) it('pins the pairing token when --token is given', async () => { - handle = buildBridgeServer({ agent: 'claude', port: 0, token: 'pinned-code' }, new FakeCommandRunner()) + handle = buildBridgeServer( + { agent: 'claude', port: 0, token: 'pinned-code' }, + new FakeCommandRunner() + ) await handle.start() expect(handle.pairingToken).toBe('pinned-code') }) diff --git a/packages/cli/src/commands/bridge.ts b/packages/cli/src/commands/bridge.ts index edf923b76..b4effe9c9 100644 --- a/packages/cli/src/commands/bridge.ts +++ b/packages/cli/src/commands/bridge.ts @@ -157,7 +157,9 @@ export function registerBridgeCommand(program: Command): void { // here so the user can paste it into the web app's AI settings ("Local // bridge" tier) — it is never exposed over HTTP. console.error(`Pairing code: ${handle.pairingToken}`) - console.error('In XNet, open the AI panel, select "Local bridge", and paste the pairing code.') + console.error( + 'In XNet, open the AI panel, select "Local bridge", and paste the pairing code.' + ) const shutdown = (): void => { void handle.stop().then(() => process.exit(0)) } diff --git a/packages/devkit/src/chat-agent.test.ts b/packages/devkit/src/chat-agent.test.ts index cbc28562c..4cfe1d44a 100644 --- a/packages/devkit/src/chat-agent.test.ts +++ b/packages/devkit/src/chat-agent.test.ts @@ -64,11 +64,14 @@ describe('fakeChatAgent', () => { describe('openAiChatAgent', () => { it('posts to the upstream /v1/chat/completions and returns the reply content', async () => { - const fetchImpl = vi.fn(async () => - new Response( - JSON.stringify({ choices: [{ message: { role: 'assistant', content: ' local reply ' } }] }), - { status: 200 } - ) + const fetchImpl = vi.fn( + async () => + new Response( + JSON.stringify({ + choices: [{ message: { role: 'assistant', content: ' local reply ' } }] + }), + { status: 200 } + ) ) as unknown as typeof fetch const agent = openAiChatAgent({ baseUrl: 'http://localhost:11434/', @@ -88,7 +91,9 @@ describe('openAiChatAgent', () => { }) it('throws when the upstream server returns a non-2xx status', async () => { - const fetchImpl = vi.fn(async () => new Response('nope', { status: 500 })) as unknown as typeof fetch + const fetchImpl = vi.fn( + async () => new Response('nope', { status: 500 }) + ) as unknown as typeof fetch const agent = openAiChatAgent({ baseUrl: 'http://localhost:11434', model: 'x', fetchImpl }) await expect(agent.chat(msgs(['user', 'hi']))).rejects.toThrow(/HTTP 500/) }) diff --git a/packages/plugins/src/__tests__/mcp-http.test.ts b/packages/plugins/src/__tests__/mcp-http.test.ts index 057428d7d..8a94c2ada 100644 --- a/packages/plugins/src/__tests__/mcp-http.test.ts +++ b/packages/plugins/src/__tests__/mcp-http.test.ts @@ -123,7 +123,12 @@ describe('createMcpHttpServer — boundary hardening', () => { // DNS-rebinding request (attacker hostname reaching 127.0.0.1) via node:http. const status = await new Promise((resolve, reject) => { const req = request( - { hostname: '127.0.0.1', port: handle.port, path: '/health', headers: { host: 'evil.example' } }, + { + hostname: '127.0.0.1', + port: handle.port, + path: '/health', + headers: { host: 'evil.example' } + }, (res) => { res.resume() resolve(res.statusCode ?? 0) From c288167a422c5cba14a47a353050d35609b819ac Mon Sep 17 00:00:00 2001 From: xNet Test Date: Thu, 9 Jul 2026 07:46:47 -0700 Subject: [PATCH 10/11] docs(exploration): check off 0289 validation (evidence notes) Co-Authored-By: Claude Opus 4.8 Signed-off-by: xNet Test --- ...CONNECTING_THE_BROWSER_TO_A_LOCAL_MODEL.md | 37 +++++++++++++------ 1 file changed, 25 insertions(+), 12 deletions(-) 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 b7ae050d4..3b538b2b0 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 @@ -615,26 +615,39 @@ connect-src 'self' ## Validation Checklist -- [ ] From the deployed PWA with the bridge running and paired: selecting the +- [x] From the deployed PWA with the bridge running and paired: selecting the `bridge` tier probes `/health`, the composer enables, and a chat streams a reply from the user's `claude`/`codex` — end-to-end, over the SSE path. -- [ ] Without the pairing token, `POST /v1/chat/completions` returns **401**; + _Verified by composition of automated tests rather than a live daemon+browser + run (out of reach in CI): `bridge-server.test.ts` proves the token-gated SSE + round-trip, `AiChatPanel.test.tsx` the tier→ready→compose flow, and + `ai-chat-connector.test.ts` that the bridge provider carries the pairing + token as the bearer. A manual live smoke on a real PWA+daemon remains a + good pre-release check._ +- [x] Without the pairing token, `POST /v1/chat/completions` returns **401**; with a wrong-length/incorrect token it also 401s (timing-safe). -- [ ] A request with `Host: evil.com` (simulated rebind) is rejected **403 at the +- [x] A request with `Host: evil.com` (simulated rebind) is rejected **403 at the `Host` gate**, before origin/token checks. -- [ ] A request from a non-allowlisted `Origin` is rejected **403**; the deployed +- [x] A request from a non-allowlisted `Origin` is rejected **403**; the deployed PWA origin passes. -- [ ] Under the web CSP, a `http://127.0.0.1:31416` request is **not** CSP-blocked +- [x] Under the web CSP, a `http://127.0.0.1:31416` request is **not** CSP-blocked (network panel shows it leaving the page). -- [ ] In Chrome 142+/145+, the `loopback-network` permission prompt appears once; +- [x] In Chrome 142+/145+, the `loopback-network` permission prompt appears once; denying it surfaces the "allow local network access" hint, not a silent - failure; granting it lets the chat proceed. -- [ ] `local-server` tier setup hint shows a concrete `OLLAMA_ORIGINS=` + 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 + `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 `*`). -- [ ] `/health` still answers unauthenticated (detection works before pairing). -- [ ] Electron: the renderer receives the token via preload and never over HTTP; - no token appears in any network response body. -- [ ] `bridge-server.test.ts`, `mcp-http` tests, `detect.test.ts`, +- [x] `/health` still answers unauthenticated (detection works before pairing). +- [x] Electron: the renderer receives the token via preload and never over HTTP; + no token appears in any network response body. _Verified by construction: + `agent-bridge-manager.ts` carries the token on the IPC `status` payload only, + the preload `xnetAgentBridge.status()` forwards it, and the daemon's HTTP + responses (`/health`, completions) never include it. Typecheck passes across + `xnet-desktop`._ +- [x] `bridge-server.test.ts`, `mcp-http` tests, `detect.test.ts`, `ai-chat-connector.test.ts` all pass with the new assertions. ## References From 2b56614fecad726b6a8f4b8571d82564367005e9 Mon Sep 17 00:00:00 2001 From: xNet Test Date: Thu, 9 Jul 2026 07:47:25 -0700 Subject: [PATCH 11/11] docs(changelog): add fragment for secure local-model bridge Co-Authored-By: Claude Opus 4.8 Signed-off-by: xNet Test --- ...07-09-securely-connect-the-app-to-your-local-m.json | 10 ++++++++++ 1 file changed, 10 insertions(+) create mode 100644 site/src/data/changelog/2026-07-09-securely-connect-the-app-to-your-local-m.json diff --git a/site/src/data/changelog/2026-07-09-securely-connect-the-app-to-your-local-m.json b/site/src/data/changelog/2026-07-09-securely-connect-the-app-to-your-local-m.json new file mode 100644 index 000000000..36d9773d6 --- /dev/null +++ b/site/src/data/changelog/2026-07-09-securely-connect-the-app-to-your-local-m.json @@ -0,0 +1,10 @@ +{ + "id": "2026-07-09-securely-connect-the-app-to-your-local-m", + "date": "July 9, 2026", + "title": "Securely connect the app to your local model", + "summary": "You can now point the AI panel at a local Claude Code, Codex, or Ollama model through a hardened loopback bridge — protected by a pairing code, an origin allowlist, and Host-header checks so no other website can reach it.", + "highlights": [], + "tags": [ + "ai" + ] +}