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
26 changes: 26 additions & 0 deletions .changeset/secure-local-model-bridge-0289.md
Original file line number Diff line number Diff line change
@@ -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 <token>`, 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=<origin>` 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.
30 changes: 28 additions & 2 deletions apps/electron/src/main/agent-bridge-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,13 @@ export interface AgentBridgeStatus {
running: boolean
agent: string
url?: string
/**
* The pairing token a browser must present as `Authorization: Bearer <token>`.
* 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
}

Expand All @@ -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
Expand Down Expand Up @@ -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) {
Expand All @@ -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
}

Expand Down
2 changes: 1 addition & 1 deletion apps/web/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
<meta name="theme-color" content="#000000" />
<meta
http-equiv="Content-Security-Policy"
content="default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; connect-src 'self' ws://localhost:* http://localhost:* wss://* https://hub.xnet.fyi https://*.xnet.fyi https://demo-bucket.protomaps.com https://www.youtube.com https://publish.twitter.com https://huggingface.co https://*.huggingface.co https://*.hf.co https://raw.githubusercontent.com; font-src 'self' data:; worker-src 'self' blob:; frame-src https://www.youtube.com https://www.youtube-nocookie.com https://player.vimeo.com https://open.spotify.com https://w.soundcloud.com https://platform.twitter.com https://www.instagram.com https://instagram.com https://www.tiktok.com https://codepen.io https://codesandbox.io https://stackblitz.com https://figma.com https://www.figma.com"
content="default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; 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://demo-bucket.protomaps.com https://www.youtube.com https://publish.twitter.com https://huggingface.co https://*.huggingface.co https://*.hf.co https://raw.githubusercontent.com; font-src 'self' data:; worker-src 'self' blob:; frame-src https://www.youtube.com https://www.youtube-nocookie.com https://player.vimeo.com https://open.spotify.com https://w.soundcloud.com https://platform.twitter.com https://www.instagram.com https://instagram.com https://www.tiktok.com https://codepen.io https://codesandbox.io https://stackblitz.com https://figma.com https://www.figma.com"
/>
<link rel="manifest" href="/manifest.json" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
Expand Down
116 changes: 108 additions & 8 deletions apps/web/src/workbench/views/AiChatPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<unknown>
/** Current daemon status, including the pairing token (IPC only, never HTTP). */
status?: () => Promise<{ running?: boolean; token?: string } | undefined>
}

declare global {
Expand Down Expand Up @@ -104,6 +106,12 @@ export function AiChatPanel() {
const [bridgeHealth, setBridgeHealth] = useState<BridgeHealth | null>(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<PermissionState | null>(null)
const [budget, setBudget] = useState<ManagedBudgetSnapshot | null>(null)
const [managedModels, setManagedModels] = useState<ManagedModel[]>([])
// In-tab model activation (exploration 0252). Both in-tab tiers gate their
Expand All @@ -123,8 +131,13 @@ export function AiChatPanel() {
const cleanupRef = useRef<(() => void) | null>(null)

const settings = useMemo<AiChatSettings>(
() => ({ 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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -411,12 +471,28 @@ export function AiChatPanel() {
hasSelection={!!selected}
/>
{selected?.tier === 'bridge' && (
<BridgeStatus
health={bridgeHealth}
canSwitch={typeof window !== 'undefined' && !!window.xnetAgentBridge}
onSwitchAgent={switchBridgeAgent}
/>
<>
<BridgeStatus
health={bridgeHealth}
canSwitch={typeof window !== 'undefined' && !!window.xnetAgentBridge}
onSwitchAgent={switchBridgeAgent}
/>
<BridgePairing
token={bridgeToken}
onToken={(value) => {
setBridgeToken(value)
writeSetting(AI_CHAT_STORAGE_KEYS.bridgeToken, value)
}}
/>
</>
)}
{(selected?.tier === 'bridge' || selected?.tier === 'local-server') &&
loopbackPermission === 'denied' && (
<p className="border-b border-hairline px-3 py-2 text-[11px] text-amber-600">
Local network access is blocked. Allow it for this site in your browser’s settings to
reach a model on this machine.
</p>
)}
{selected?.tier === 'cloud-key' && (
<CloudKeyFields
apiKey={apiKey}
Expand Down Expand Up @@ -706,6 +782,30 @@ function BridgeStatus({
)
}

/**
* The pairing code the bridge daemon requires. Under Electron it's auto-filled
* over IPC (the field then just confirms it); in a plain browser the user pastes
* 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 }) {
return (
<div className="flex flex-col gap-1 border-b border-hairline px-3 py-2">
<input
type="password"
value={token}
placeholder="Bridge pairing code"
onChange={(event) => 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"
/>
<p className="text-[10px] text-ink-3">
Paste the code <code>xnet bridge serve</code> prints. Sent only to your local bridge — never
to our servers.
</p>
</div>
)
}

function ConnectorBar({
detections,
selectedTier,
Expand Down
14 changes: 11 additions & 3 deletions apps/web/src/workbench/views/ai-chat-connector.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
20 changes: 17 additions & 3 deletions apps/web/src/workbench/views/ai-chat-connector.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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). */
Expand All @@ -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). */
Expand Down Expand Up @@ -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:
Expand Down
Loading
Loading