From 8c610c07493d55d212f987a83232ac6f6d2de9af Mon Sep 17 00:00:00 2001 From: "Anthony Fu (via agent)" Date: Thu, 3 Sep 2026 13:09:17 +0000 Subject: [PATCH 1/2] feat(vite,next): forward allowedOrigins through bridge and Next handler The Vite bridge and Next handler inherited devframe's loopback-only WS origin check with no way to widen it, so a devframe served same-origin by a non-loopback dev server (vite --host, containers, Codespaces, tunnels) rejected the RPC upgrade with 403 and the client sat stuck on 'connecting'. Add allowedOrigins to DevframeViteBridgeOptions (inherited by DevframeViteOptions) and CreateDevframeNextHandlerOptions, forwarded verbatim to initDevframe. Closes #348 --- docs/content/3.frameworks/1.vite.md | 1 + docs/content/3.frameworks/3.next.md | 1 + packages/next/src/handler.ts | 10 ++++++ packages/vite/package.json | 4 ++- packages/vite/src/single.ts | 12 +++++++ packages/vite/test/single.test.ts | 50 +++++++++++++++++++++++++++++ pnpm-lock.yaml | 6 ++++ 7 files changed, 83 insertions(+), 1 deletion(-) diff --git a/docs/content/3.frameworks/1.vite.md b/docs/content/3.frameworks/1.vite.md index 4f770e5a8..a4b967127 100644 --- a/docs/content/3.frameworks/1.vite.md +++ b/docs/content/3.frameworks/1.vite.md @@ -43,6 +43,7 @@ Devframe spawns a separate RPC + WS server and registers Vite middleware at `__mcp`. `'auto'` mounts once agent tools exist; `true` forces the origin-only route on (trusts same-machine callers); `McpRouteOptions` can add an `authorization` identity check. | +| `allowedOrigins` | loopback-only | Widen the WS origin check for a `vite --host` / container / tunnel origin: extra origins, a `WsOriginRegistry`, or `false` to disable (the auth gate stays the trust boundary). | ## `devframeVite`: convenience wrapper diff --git a/docs/content/3.frameworks/3.next.md b/docs/content/3.frameworks/3.next.md index fe64b9f4e..f7ded548b 100644 --- a/docs/content/3.frameworks/3.next.md +++ b/docs/content/3.frameworks/3.next.md @@ -49,6 +49,7 @@ export const GET = handler.fetch | `flags` | none | Passed to `def.setup(ctx, { flags })`. | | `auth` | `false` | `true` for the OTP gate, or a handler. | | `mcp` | `'auto'` | Expose the MCP route. `'auto'` mounts once agent tools exist; `true` forces the origin-only route on (trusts same-machine callers); `McpRouteOptions` can add an `authorization` identity check. | +| `allowedOrigins` | loopback-only | Widen the side-car WS origin check for a remotely-accessed dev server (container / Codespace / tunnel): extra origins, a `WsOriginRegistry`, or `false` to disable (the auth gate stays the trust boundary). | | `key` | `@devframes/next::` | `globalThis` memoization key. | ## Hosting a hub diff --git a/packages/next/src/handler.ts b/packages/next/src/handler.ts index 42a0206f2..831aed1e4 100644 --- a/packages/next/src/handler.ts +++ b/packages/next/src/handler.ts @@ -27,6 +27,15 @@ export interface CreateDevframeNextHandlerOptions { * or a handler for a custom scheme. */ auth?: InitDevframeOptions['auth'] + /** + * Widen the side-car WebSocket origin check beyond devframe's + * loopback-only default. Reaching a remotely-accessed Next dev server + * (containers, Codespaces, tunnels) needs the app's own origin allowed. + * Pass extra origins, a `WsOriginRegistry`, or `false` to disable the + * check (safe when the auth gate owns the trust boundary). Forwarded + * verbatim to `initDevframe`. + */ + allowedOrigins?: InitDevframeOptions['allowedOrigins'] /** Origin the Next app is reachable at, for docks needing an absolute URL. */ resolveOrigin?: () => string /** Override where persisted devframe state lives (defaults under the cwd / home). */ @@ -139,6 +148,7 @@ export function createDevframeNextHandler( */ auth: options.auth, mcp: options.mcp, + allowedOrigins: options.allowedOrigins, /** * Next's route handlers never see WebSocket upgrades, so the RPC socket * lives on a side-car server (on `options.port` when pinned, otherwise diff --git a/packages/vite/package.json b/packages/vite/package.json index 82ef9b6b1..1aa9a6da5 100644 --- a/packages/vite/package.json +++ b/packages/vite/package.json @@ -61,10 +61,12 @@ "@devframes/hub-ui": "workspace:*", "@modelcontextprotocol/client": "catalog:deps", "@types/node": "catalog:types", + "@types/ws": "catalog:types", "devframe": "workspace:*", "get-port-please": "catalog:deps", "tsdown": "catalog:build", "vite": "catalog:build", - "vitest": "catalog:testing" + "vitest": "catalog:testing", + "ws": "catalog:deps" } } diff --git a/packages/vite/src/single.ts b/packages/vite/src/single.ts index 3e1c3fc5d..6c289d235 100644 --- a/packages/vite/src/single.ts +++ b/packages/vite/src/single.ts @@ -1,6 +1,7 @@ import type { DevframeDefinition, McpSetting } from 'devframe' import type { DevframeInstance } from 'devframe/initiate' import type { DevframeAuthHandler } from 'devframe/node/auth' +import type { WsOriginRegistry } from 'devframe/rpc/transports/ws-server' import type { IncomingMessage, Server as NodeHttpServer, ServerResponse } from 'node:http' import type { Plugin } from 'vite' import process from 'node:process' @@ -119,6 +120,16 @@ export interface DevframeViteBridgeOptions { * surface is non-empty); `false` disables the route regardless. */ mcp?: McpSetting + /** + * Widen the WebSocket origin check beyond devframe's loopback-only + * default. A bridge serves the tool same-origin with the host Vite app, + * so reaching it from a non-loopback origin (`vite --host`, containers, + * Codespaces, tunnels) needs the dev server's own origin allowed. Pass + * extra origins, a {@link WsOriginRegistry}, or `false` to disable the + * check (safe when the bridge's auth gate owns the trust boundary). + * Forwarded verbatim to `initDevframe`. + */ + allowedOrigins?: readonly string[] | WsOriginRegistry | false } /** @@ -185,6 +196,7 @@ export function devframeViteBridge(d: DevframeDefinition, options: DevframeViteB */ auth: options.auth, mcp: options.mcp, + allowedOrigins: options.allowedOrigins, }) server.middlewares.use(created.nodeMiddleware) await created.ready diff --git a/packages/vite/test/single.test.ts b/packages/vite/test/single.test.ts index f991349c7..816bebd65 100644 --- a/packages/vite/test/single.test.ts +++ b/packages/vite/test/single.test.ts @@ -10,6 +10,7 @@ import { createRpcClient } from 'devframe/rpc/client' import { createWsRpcChannel } from 'devframe/rpc/transports/ws-client' import { getPort } from 'get-port-please' import { afterEach, describe, expect, it } from 'vitest' +import { WebSocket } from 'ws' import { devframeVite, devframeViteBridge, devframeVitePlugin } from '../src/single' function defineTestDef(overrides: Partial = {}): DevframeDefinition { @@ -243,6 +244,55 @@ describe('devframeViteBridge (auth default)', () => { }) }) +describe('devframeViteBridge (allowedOrigins)', () => { + let bridge: ReturnType | undefined + let vite: FakeViteServer | undefined + + afterEach(async () => { + await bridge?.closeBundle?.() + bridge = undefined + vite?.close() + vite = undefined + }) + + /** Attempt a raw WS upgrade carrying a spoofed browser Origin header. */ + async function upgradeWithOrigin(port: number, origin: string): Promise<'open' | 'closed'> { + return await new Promise((resolve) => { + const ws = new WebSocket(`ws://127.0.0.1:${port}/__ws`, { headers: { origin } }) + ws.on('open', () => { + ws.close() + resolve('open') + }) + ws.on('error', () => resolve('closed')) + ws.on('unexpected-response', () => resolve('closed')) + ws.on('close', () => resolve('closed')) + }) + } + + it('rejects a non-loopback origin by default (loopback-only)', async () => { + const port = await getPort({ port: 19760, host: '127.0.0.1' }) + bridge = devframeViteBridge(defineTestDef(), { port, host: '127.0.0.1', auth: false }) + vite = fakeViteServer() + await bridge.configureServer(vite) + + expect(await upgradeWithOrigin(port, 'https://tunnel.example.dev')).toBe('closed') + }) + + it('accepts a non-loopback origin when forwarded through allowedOrigins', async () => { + const port = await getPort({ port: 19770, host: '127.0.0.1' }) + bridge = devframeViteBridge(defineTestDef(), { + port, + host: '127.0.0.1', + auth: false, + allowedOrigins: ['https://tunnel.example.dev'], + }) + vite = fakeViteServer() + await bridge.configureServer(vite) + + expect(await upgradeWithOrigin(port, 'https://tunnel.example.dev')).toBe('open') + }) +}) + describe('devframeVite (dispatcher)', () => { let vite: FakeViteServer | undefined let plugin: DevframeVitePlugin | undefined diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a9b7653ee..62f69105b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1759,6 +1759,9 @@ importers: '@types/node': specifier: catalog:types version: 26.4.0 + '@types/ws': + specifier: catalog:types + version: 8.18.1 devframe: specifier: workspace:* version: link:../devframe @@ -1774,6 +1777,9 @@ importers: vitest: specifier: catalog:testing version: 4.1.11(@opentelemetry/api@1.9.1)(@types/node@26.4.0)(vite@8.2.2(@types/node@26.4.0)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.47.1)(tsx@4.23.13)(yaml@2.9.0)) + ws: + specifier: catalog:deps + version: 8.21.3 plugins/a11y: dependencies: From d5dbddc99228869d1c54297dd7b79ec6669cdda2 Mon Sep 17 00:00:00 2001 From: "Anthony Fu (via agent)" Date: Thu, 3 Sep 2026 13:15:11 +0000 Subject: [PATCH 2/2] test: update tsnapi api snapshots for allowedOrigins --- tests/__snapshots__/tsnapi/@devframes/next/single.snapshot.d.ts | 1 + tests/__snapshots__/tsnapi/@devframes/vite/single.snapshot.d.ts | 1 + 2 files changed, 2 insertions(+) diff --git a/tests/__snapshots__/tsnapi/@devframes/next/single.snapshot.d.ts b/tests/__snapshots__/tsnapi/@devframes/next/single.snapshot.d.ts index d2d44c65b..ff14a757a 100644 --- a/tests/__snapshots__/tsnapi/@devframes/next/single.snapshot.d.ts +++ b/tests/__snapshots__/tsnapi/@devframes/next/single.snapshot.d.ts @@ -8,6 +8,7 @@ export interface CreateDevframeNextHandlerOptions { port?: number; flags?: Record; auth?: InitDevframeOptions['auth']; + allowedOrigins?: InitDevframeOptions['allowedOrigins']; resolveOrigin?: () => string; getStorageDir?: (_: DevframeStorageScope) => string; mcp?: InitDevframeOptions['mcp']; diff --git a/tests/__snapshots__/tsnapi/@devframes/vite/single.snapshot.d.ts b/tests/__snapshots__/tsnapi/@devframes/vite/single.snapshot.d.ts index d6de453b0..3963e8738 100644 --- a/tests/__snapshots__/tsnapi/@devframes/vite/single.snapshot.d.ts +++ b/tests/__snapshots__/tsnapi/@devframes/vite/single.snapshot.d.ts @@ -9,6 +9,7 @@ export interface DevframeViteBridgeOptions { flags?: Record; auth?: boolean | DevframeAuthHandler; mcp?: McpSetting; + allowedOrigins?: readonly string[] | WsOriginRegistry | false; } export interface DevframeViteDevServerLike { middlewares: {