Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view

Large diffs are not rendered by default.

4 changes: 4 additions & 0 deletions packages/native-bridge-extension/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
# Extension signing private key — minted by `pnpm gen`, never committed.
# Only needed to pack a .crx for distribution; unpacked dev uses the public
# `key` already baked into extension/manifest.json.
extension.pem
133 changes: 133 additions & 0 deletions packages/native-bridge-extension/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
# @xnetjs/native-bridge-extension

**Spike — exploration [0289](../../docs/explorations/0289_%5B_%5D_SECURELY_CONNECTING_THE_BROWSER_TO_A_LOCAL_MODEL.md), Option C.** A browser
extension + native-messaging host that lets the **pure-web PWA**
(`https://app.xnet.fyi`, no bundled Electron host) reach a **local model** with
the strongest origin binding available in a browser — **no loopback HTTP port,
no CORS, no DNS-rebinding surface** (the 1Password pattern).

This is a proof-of-concept, not a shipped product: `private: true`, no build
step, no changeset. It exists to (a) prove the transport works end-to-end and
(b) compare its UX against the copy-paste **pairing code** shipped for 0289
Option B (the hardened loopback bridge).

## Why this exists

0289 Option B hardened the loopback daemon (`packages/devkit/src/bridge-server.ts`)
into a secure spine: `Host`-header validation, an origin allowlist, and a
**per-launch pairing token** the user copies from `xnet bridge serve` into the AI
settings. That token is the layer that survives DNS-rebinding and drive-by sites
_because loopback-bind + origin allowlist alone cannot_ — any site can be made to
resolve to `127.0.0.1`.

Option C removes the attack surface instead of defending it. The page never makes
a network request to `localhost`; it talks to an **extension** that the OS has
bound, by ID, to a **native host**. There is no port to rebind to and no CORS to
misconfigure.

## Architecture

```
https://app.xnet.fyi (page)
│ chrome.runtime.sendMessage(<extId>, {v:1, kind, …}) ← gated by
▼ externally_connectable
background.js (extension service worker) (origin allowlist)
│ chrome.runtime.connectNative('fyi.xnet.bridge') ← gated by the native
▼ host manifest's
xnet-bridge-host.mjs (native messaging host) allowed_origins = <extId>
│ stdio, 4-byte-LE-length-prefixed JSON frames (OS-enforced)
backend:
• cli → spawn the user's `claude` / `codex` (NO port anywhere) [default]
• daemon → forward to the hardened bridge daemon over loopback,
carrying its pairing token (reuse MCP tools / /run / Ollama proxy)
```

Two OS-/browser-enforced allowlists, and that's the whole trust story:

1. **`externally_connectable.matches`** (`extension/manifest.json`) — only the
deployed PWA origin (+ loopback for dev) may deliver a message to the
extension. `background.js` re-checks `sender.origin` as defence in depth.
2. **`allowed_origins`** in the native host manifest — pinned to _this
extension's ID_, so the OS refuses to launch the host for any other
extension. The ID is derived deterministically from the extension's public
`key` (see `scripts/crx-id.mjs`), so it's stable across machines.

## Layout

| Path | What it is |
| ------------------------------- | ----------------------------------------------------------------------------------------- |
| `extension/manifest.json` | MV3 manifest — `externally_connectable`, `nativeMessaging`, a fixed `key` (→ stable ID) |
| `extension/background.js` | Service-worker relay: page ↔ native host, origin-checked |
| `host/native-messaging.mjs` | The 4-byte-length framing codec (one source of truth) |
| `host/relay.mjs` | `handleMessage(msg, backend)` — transport-free, never throws |
| `host/backends.mjs` | `cliBackend` (spawn CLI) and `daemonBackend` (forward to `:31416`) |
| `host/xnet-bridge-host.mjs` | The runnable native host — stdin → relay → stdout |
| `host/manifest.template.json` | Native host manifest template (`__HOST_PATH__`, `__EXTENSION_ORIGIN__`) |
| `web/extension-connector.mjs` | Page-side client — mirrors the `ChatAgent` contract so it slots into the connector ladder |
| `scripts/gen-extension-key.mjs` | Mint the signing key; bake the public `key` (→ stable ID) into the manifest |
| `scripts/install-host.mjs` | Write the native host manifest to the browser's `NativeMessagingHosts` dir |
| `scripts/crx-id.mjs` | Derive the Chromium extension ID from the packed public key |

## Try it (macOS / Linux, Chromium-family)

```bash
cd packages/native-bridge-extension
node scripts/gen-extension-key.mjs # once — prints the stable extension ID
node scripts/install-host.mjs # writes ~/…/NativeMessagingHosts/fyi.xnet.bridge.json
# --browser chrome|chromium|brave|edge (default: chrome)
```

Then load the unpacked extension: `chrome://extensions` → Developer mode → **Load
unpacked** → select `./extension`. Confirm the ID matches what `gen` printed.
Reload the xNet tab; the page can now call the connector in `web/`.

Backend selection is by environment (set in your shell or the host manifest):

```bash
# default — spawn the user's coding-agent CLI, no port anywhere:
XNET_BRIDGE_MODE=cli XNET_BRIDGE_AGENT=claude

# or reuse the already-hardened loopback daemon (MCP tools, /run, Ollama proxy):
XNET_BRIDGE_MODE=daemon XNET_BRIDGE_URL=http://127.0.0.1:31416 XNET_BRIDGE_TOKEN=<pairing code>
```

**Windows** uses a registry key instead of a manifest file
(`HKCU\Software\Google\Chrome\NativeMessagingHosts\fyi.xnet.bridge` → the manifest
path), and the host needs a `.bat`/`.cmd` launcher since Windows can't exec a
`.mjs` directly. Out of scope for this spike; noted for a productionization pass.

## UX comparison: pairing code (0289 B) vs. this extension (0289 C)

| | **Pairing code** (shipped, Option B) | **Extension + native messaging** (this spike, Option C) |
| ------------------------- | ---------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------- |
| **First-run steps** | Install a CLI (or Electron) · run `xnet bridge serve` · **copy a code** · paste it into AI settings | Install the extension (one click from a store) · run the host installer once |
| **Recurring friction** | Token is **per-launch** — the code changes every time the daemon restarts, so the user re-pastes (or pins `--token`, weakening it) | **None** — the OS↔extension binding is durable; nothing to re-enter |
| **What the user handles** | A secret they must not paste into the wrong site | Nothing secret ever crosses the page boundary |
| **Failure mode** | Wrong/expired code → silent 401; "why is it broken?" | Extension missing → detectable, actionable "install the extension" prompt |
| **Security ceiling** | Defends the surface: token + `Host` + origin on an open loopback port | Removes the surface: no port, no CORS, no rebinding; OS-pinned extension ID |
| **Browser gates (LNA)** | Subject to Chrome 142+/Firefox `loopback-network` permission prompts | Unaffected — no local-network request is ever made |
| **Ship cost** | Already merged; zero new artifacts | Ship + maintain an extension per browser store + a native-host installer |
| **Cross-browser** | Any browser (server-side auth is the floor) | Chromium family + Firefox (WebKit/Safari has no extension native-messaging parity) |

**Takeaway.** The pairing code is the right **now**: no artifacts to distribute,
works on every browser, already shipped. Its recurring cost is the per-launch
re-paste and the "silent 401" failure mode. The extension is the right
**strategic** answer for the deployed PWA: after a one-time install it's
zero-friction and zero-secret forever, and it's the only option that _eliminates_
the loopback attack surface rather than guarding it — at the cost of building and
maintaining a per-browser extension + native-host installer. Recommend keeping B
as the default and pursuing C as an opt-in "install the xNet bridge extension"
upgrade once there's demand for a browser-only install with no bundled daemon.

## Tests

```bash
pnpm vitest run --project integration packages/native-bridge-extension
```

Covers the framing codec (partial/multi/oversize frames, multibyte UTF-8), the
relay + both backends (including a round-trip through the **real hardened bridge
daemon** with its pairing token), the page-side protocol against a fake `chrome`,
the extension-ID derivation, and a **real-process end-to-end** run that spawns
`xnet-bridge-host.mjs` and drives it with native-messaging frames.
79 changes: 79 additions & 0 deletions packages/native-bridge-extension/extension/background.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
/**
* xNet Local Bridge — background service worker (exploration 0289, Option C).
*
* The only job: relay a message from the xNet web page to the native-messaging
* host and hand the reply back. Two allowlists guard the door, and they are the
* whole security story of this approach:
*
* 1. `externally_connectable.matches` in the manifest — only pages whose origin
* matches (the deployed PWA + loopback for dev) can even deliver a message to
* `onMessageExternal`. We re-check `sender.origin` below as defence in depth.
* 2. The native host's own manifest pins `allowed_origins` to THIS extension's
* ID, so the OS refuses to launch the host for any other extension.
*
* There is no `fetch`, no port, no CORS here — the page never touches the network
* to reach the model. That is what removes the DNS-rebinding / drive-by-site
* surface that the loopback-HTTP bridge has to defend against with a token.
*/

const NATIVE_HOST = 'fyi.xnet.bridge'

// Belt-and-braces alongside `externally_connectable`: the exact origins allowed
// to drive the local model. Keep in lockstep with the manifest matches.
const ALLOWED_ORIGINS = new Set(['https://app.xnet.fyi', 'http://localhost', 'http://127.0.0.1'])

function originOf(sender) {
if (sender.origin) return new URL(sender.origin).origin
if (sender.url) return new URL(sender.url).origin
return null
}

function isAllowed(sender) {
const origin = originOf(sender)
return origin !== null && ALLOWED_ORIGINS.has(origin)
}

/**
* Send one framed request to the native host and resolve with its one reply.
* A fresh short-lived port per request keeps the POC simple; a streaming build
* would keep the port open and forward `port.onMessage` deltas to the page.
*/
function callNativeHost(message) {
return new Promise((resolve) => {
let settled = false
const done = (value) => {
if (settled) return
settled = true
try {
port.disconnect()
} catch {
/* already gone */
}
resolve(value)
}
const port = chrome.runtime.connectNative(NATIVE_HOST)
port.onMessage.addListener((reply) => done(reply))
port.onDisconnect.addListener(() => {
const err = chrome.runtime.lastError
done({ ok: false, error: err ? err.message : 'native host disconnected' })
})
try {
port.postMessage(message)
} catch (err) {
done({ ok: false, error: err instanceof Error ? err.message : String(err) })
}
})
}

chrome.runtime.onMessageExternal.addListener((message, sender, sendResponse) => {
if (!isAllowed(sender)) {
sendResponse({ ok: false, error: 'origin not allowed' })
return false
}
if (!message || (message.kind !== 'health' && message.kind !== 'chat')) {
sendResponse({ ok: false, error: 'unknown request' })
return false
}
callNativeHost(message).then(sendResponse)
return true // keep the channel open for the async reply
})
15 changes: 15 additions & 0 deletions packages/native-bridge-extension/extension/manifest.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
{
"manifest_version": 3,
"name": "xNet Local Bridge",
"version": "0.1.0",
"description": "Relays xNet's web app to a local model (Claude Code / Codex / a bridge daemon) over native messaging — no loopback port, no CORS.",
"background": {
"service_worker": "background.js",
"type": "module"
},
"permissions": ["nativeMessaging"],
"externally_connectable": {
"matches": ["https://app.xnet.fyi/*", "http://localhost/*", "http://127.0.0.1/*"]
},
"key": "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAitgqlga3G24EnV+qF1p+GKmyE2LdEJqjeqm5WcnQH4U1N93s5wUCV7t7wiJy9bgH3+bb0CDs84ht29PUEwKMIDO6SZQet9kMcI5QdD1YOyFmbyRoo2QN53fgAW1nsx9Bav2x14NMkgvtHEtCCXNlY8J2lkDnTr3rU3dsheeQuAEVLa7Na6zDr6X/MSQ7TgqTsGnY4Y3n6YkxkU3XCBHuycrqVPffOL12fcMTQBb5j2hQVEV06G8NK44I8I0m9mGRsA4JlVqbc/83YtpIciSmrLvATRttYR/enEP9LNnhnjAcnbkIDgHzsAC7qoa/k2T20aBn6Thw7D+Im48plB957QIDAQAB"
}
110 changes: 110 additions & 0 deletions packages/native-bridge-extension/host/backends.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
/**
* The two ways the native host answers a chat turn (exploration 0289, Option C).
*
* A `Backend` is `{ health(), chat(messages, model?) }` — the same tiny contract
* the HTTP bridge daemon exposes, minus the transport. The whole point of the
* native-messaging path is that this contract is reached with NO loopback HTTP
* port exposed to the browser: the browser talks to the extension, the extension
* talks to this host over the OS's stdio pipe, and only THEN — if at all — does a
* local socket get involved.
*
* - `cliBackend` spawns the user's own `claude` / `codex` CLI directly. This is
* the purest form of Option C: no port anywhere, the strongest origin binding
* (the OS gates which extension may even launch this host).
* - `daemonBackend` forwards to an already-running hardened bridge daemon
* (`packages/devkit/src/bridge-server.ts`) over loopback, carrying its pairing
* token. That loopback hop is process-to-process on the same machine, initiated
* by a trusted native binary — not by a web origin — so it carries none of the
* CORS / DNS-rebinding surface the daemon's HTTP front door has to defend
* against. Use this to reuse the daemon's richer capabilities (MCP tools,
* `POST /run`, the Ollama upstream proxy) behind the same stdio door.
*/

import { execFile } from 'node:child_process'

/** Flatten a chat into a single prompt for a headless CLI (mirrors devkit `flattenChat`). */
export function flattenChat(messages) {
return messages
.map((m) => (m.role === 'user' ? m.content : `${m.role}: ${m.content}`))
.join('\n\n')
}

/**
* Drive the user's installed coding-agent CLI as the model. Spawning the CLI
* (rather than reusing its token) is the ToS-safe way to use their subscription —
* xNet never sees the credential; the CLI authenticates itself.
*/
export function cliBackend(options = {}) {
const command = options.command ?? 'claude'
const argsTemplate = options.args ?? ['-p', '{prompt}']
const cwd = options.cwd ?? process.cwd()
const timeoutMs = options.timeoutMs ?? 120_000
// Injectable for tests; defaults to the real child_process spawn.
const run = options.run ?? defaultRun

return {
async health() {
return { ok: true, agent: command, version: options.version ?? '0.1.0', transport: 'cli' }
},
async chat(messages, _model) {
const prompt = flattenChat(messages)
// split/join (not replace): the prompt is arbitrary text and String.replace
// would treat `$&` etc. as special and only swap the first token.
const args = argsTemplate.map((a) => a.split('{prompt}').join(prompt))
const { code, stdout, stderr } = await run(command, args, { cwd, timeoutMs })
if (code !== 0) {
throw new Error(`agent "${command}" failed (code ${code}): ${(stderr || stdout).trim()}`)
}
return stdout.trim()
}
}
}

function defaultRun(command, args, { cwd, timeoutMs }) {
return new Promise((resolve) => {
execFile(
command,
args,
{ cwd, timeout: timeoutMs, maxBuffer: 8 * 1024 * 1024 },
(err, stdout, stderr) => {
resolve({ code: err && typeof err.code === 'number' ? err.code : err ? 1 : 0, stdout, stderr })
}
)
})
}

/**
* Forward to a running hardened bridge daemon over loopback, presenting its
* per-launch pairing token as the bearer. This is how the native host reuses the
* daemon we already ship instead of respawning the CLI per turn.
*/
export function daemonBackend(options = {}) {
const url = (options.url ?? 'http://127.0.0.1:31416').replace(/\/+$/, '')
const token = options.token ?? ''
const timeoutMs = options.timeoutMs ?? 120_000
const fetchImpl = options.fetchImpl ?? fetch

return {
async health() {
const res = await fetchImpl(`${url}/health`, { signal: AbortSignal.timeout(2000) })
if (!res.ok) throw new Error(`bridge daemon health ${res.status}`)
return { ...(await res.json()), transport: 'daemon' }
},
async chat(messages, model) {
const res = await fetchImpl(`${url}/v1/chat/completions`, {
method: 'POST',
headers: {
'content-type': 'application/json',
...(token ? { authorization: `Bearer ${token}` } : {})
},
body: JSON.stringify({ messages, ...(model ? { model } : {}), stream: false }),
signal: AbortSignal.timeout(timeoutMs)
})
if (!res.ok) {
throw new Error(`bridge daemon /v1/chat/completions ${res.status}`)
}
const data = await res.json()
return data?.choices?.[0]?.message?.content?.trim() ?? ''
}
}
}
7 changes: 7 additions & 0 deletions packages/native-bridge-extension/host/manifest.template.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"name": "fyi.xnet.bridge",
"description": "xNet local model bridge — native messaging host",
"path": "__HOST_PATH__",
"type": "stdio",
"allowed_origins": ["__EXTENSION_ORIGIN__"]
}
Loading
Loading