From a87f068256cb52eeda1b87609a69ce8e0537c29d Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Tue, 1 Sep 2026 21:12:28 +0300 Subject: [PATCH] fix: support dev server previews over relay --- packages/electron/README.md | 1 + packages/electron/main.mjs | 13 +- packages/electron/preload.mjs | 31 ++++- packages/electron/relay-dev-tunnel.mjs | 121 ++++++++++++++++++ packages/electron/relay-dev-tunnel.test.mjs | 96 ++++++++++++++ packages/ui/src/lib/browser/devTunnel.test.ts | 89 +++++++++++-- packages/ui/src/lib/browser/devTunnel.ts | 83 ++++++++++-- packages/ui/src/lib/desktopNative.ts | 28 ++++ .../server/lib/dev-tunnel/DOCUMENTATION.md | 17 ++- packages/web/server/lib/dev-tunnel/runtime.js | 8 +- .../web/server/lib/dev-tunnel/tunnel.test.js | 20 +++ .../web/server/lib/relay/DOCUMENTATION.md | 2 +- packages/web/server/lib/relay/tunnel-host.js | 4 +- .../web/server/lib/relay/tunnel-host.test.js | 10 +- packages/web/server/lib/ui-auth/ui-auth.js | 1 + .../web/server/lib/ui-auth/ui-auth.test.js | 16 +++ 16 files changed, 504 insertions(+), 36 deletions(-) create mode 100644 packages/electron/relay-dev-tunnel.mjs create mode 100644 packages/electron/relay-dev-tunnel.test.mjs diff --git a/packages/electron/README.md b/packages/electron/README.md index c5c7df08..41490145 100644 --- a/packages/electron/README.md +++ b/packages/electron/README.md @@ -155,6 +155,7 @@ Use an explicit override when testing a different OpenCode CLI build or when a u - SSH host import, connections, logs, and port forwarding. - SSH uses OpenSSH ControlMaster on macOS/Linux. Windows uses independent hidden OpenSSH processes for setup commands and each long-lived forward because Win32 OpenSSH does not support ControlMaster reliably. - Tunnel lifecycle integration through the web server runtime. +- Remote dev-server previews use a direct WebSocket tunnel when the instance has an HTTP address. Relay-only instances keep the encrypted relay transport in the renderer and bridge its raw bytes to the browser panel through a local Electron listener. - Auto-update checks, downloads, and restart/apply flow. - The browser panel's own session (`persist:openchamber-browser`): its storage is cleared only through the scoped clear-data command, and camera, microphone, diff --git a/packages/electron/main.mjs b/packages/electron/main.mjs index 2a0de81b..64a08849 100644 --- a/packages/electron/main.mjs +++ b/packages/electron/main.mjs @@ -1,4 +1,4 @@ -import { app, BrowserWindow, dialog, ipcMain, Menu, nativeTheme, net as electronNet, Notification, powerMonitor, powerSaveBlocker, protocol, screen, session, shell, webContents } from 'electron'; +import { app, BrowserWindow, dialog, ipcMain, Menu, MessageChannelMain, nativeTheme, net as electronNet, Notification, powerMonitor, powerSaveBlocker, protocol, screen, session, shell, webContents } from 'electron'; import contextMenu from 'electron-context-menu'; import log from 'electron-log/main.js'; import dgram from 'node:dgram'; @@ -33,6 +33,7 @@ import { } from './linux-autostart.mjs'; import { unsupportedAppSpecificOpenError, validateLocalPath } from './path-open-utils.mjs'; import { shouldAllowBrowserPanelCertificateError } from './browser-panel-security.mjs'; +import { createRelayDevTunnelBridge } from './relay-dev-tunnel.mjs'; import { attachRendererRecovery } from './renderer-recovery.mjs'; import { mintOutsideFileGrant } from '@openchamber/web/server/lib/fs/routes.js'; @@ -3839,6 +3840,7 @@ const runSpecChain = (specs, appName) => { // The tunnel client lives in the web package (it already has a WebSocket // client) and is loaded only if the user actually previews a remote dev server. let devTunnelClientPromise = null; +const relayDevTunnelBridge = createRelayDevTunnelBridge({ createMessageChannel: () => new MessageChannelMain(), logger: log }); const getDevTunnelClient = async () => { if (!devTunnelClientPromise) { devTunnelClientPromise = import('@openchamber/web/server/lib/dev-tunnel/client.js') @@ -3852,6 +3854,7 @@ const getDevTunnelClient = async () => { }; const closeAllDevTunnels = () => { + relayDevTunnelBridge.closeAll(); if (!devTunnelClientPromise) return; const pending = devTunnelClientPromise; devTunnelClientPromise = null; @@ -3959,6 +3962,11 @@ const handleInvoke = async (browserWindow, command, args = {}) => { if (!baseUrl) throw new Error('baseUrl is required'); if (!(port > 0 && port <= 65535)) throw new Error('A valid port is required'); + if (args.relay === true) { + const targetKey = typeof args.targetKey === 'string' ? args.targetKey.trim() : ''; + return relayDevTunnelBridge.open({ targetKey, remotePort: port, webContents: browserWindow?.webContents }); + } + const headers = {}; const requestHeaders = args.requestHeaders && typeof args.requestHeaders === 'object' ? args.requestHeaders : {}; for (const [name, value] of Object.entries(requestHeaders)) { @@ -3981,6 +3989,9 @@ const handleInvoke = async (browserWindow, command, args = {}) => { return { closed: client.close({ baseUrl, port }) }; } + case 'desktop_relay_dev_tunnel_close_all': + return { closed: relayDevTunnelBridge.closeForWebContents(browserWindow?.webContents.id) }; + /** * Forces prefers-color-scheme for one previewed page. * diff --git a/packages/electron/preload.mjs b/packages/electron/preload.mjs index f3f96667..7cecec8c 100644 --- a/packages/electron/preload.mjs +++ b/packages/electron/preload.mjs @@ -158,14 +158,41 @@ ipcRenderer.on('openchamber:emit', (_evt, payload) => { dispatchNativeEvent(event, payload.detail); }); +const relayDevTunnelPorts = new Map(); +let relayDevTunnelHandler = null; +ipcRenderer.on('openchamber:relay-dev-tunnel-connect', (event, payload) => { + if (!isLocalPage || !payload || typeof payload.connectionId !== 'string' || !event.ports?.[0]) return; + const port = event.ports[0]; + relayDevTunnelPorts.set(payload.connectionId, port); + port.onmessage = (messageEvent) => relayDevTunnelHandler?.({ + connectionId: payload.connectionId, + remotePort: payload.remotePort, + message: messageEvent.data, + }); + port.start(); + relayDevTunnelHandler?.({ connectionId: payload.connectionId, remotePort: payload.remotePort, message: { type: 'connect' } }); +}); + // The desktop bridge is exposed on all pages; the main-process gate in // ipcMain.handle('openchamber:invoke') decides per-command what is safe // for non-local callers (window/host-switcher ops yes, file/shell ops // no). See COMMANDS_SAFE_FOR_REMOTE in main.mjs. -contextBridge.exposeInMainWorld('__OPENCHAMBER_DESKTOP__', { +const desktopBridge = { invoke: (cmd, args) => ipcRenderer.invoke('openchamber:invoke', cmd, args || {}), openDialog: (options) => ipcRenderer.invoke('openchamber:dialog:open', options || {}), grantFileAccess: (filePath) => ipcRenderer.invoke('openchamber:file:grant-existing', filePath), openExternal: (url) => ipcRenderer.invoke('openchamber:invoke', 'desktop_open_external_url', { url }), listen: async (event, handler) => addListener(event, handler), -}); +}; + +if (isLocalPage) { + desktopBridge.relayDevTunnelListen = (handler) => { + relayDevTunnelHandler = typeof handler === 'function' ? handler : null; + }; + desktopBridge.relayDevTunnelPost = (connectionId, message) => { + relayDevTunnelPorts.get(connectionId)?.postMessage(message); + if (message?.type === 'close') relayDevTunnelPorts.delete(connectionId); + }; +} + +contextBridge.exposeInMainWorld('__OPENCHAMBER_DESKTOP__', desktopBridge); diff --git a/packages/electron/relay-dev-tunnel.mjs b/packages/electron/relay-dev-tunnel.mjs new file mode 100644 index 00000000..de9bd6c1 --- /dev/null +++ b/packages/electron/relay-dev-tunnel.mjs @@ -0,0 +1,121 @@ +import net from 'node:net'; +import { randomUUID } from 'node:crypto'; + +const CONNECTION_READY_TIMEOUT_MS = 15_000; + +const listen = (server) => new Promise((resolve, reject) => { + server.once('error', reject); + server.listen(0, '127.0.0.1', () => { + server.off('error', reject); + const address = server.address(); + const port = Number(address?.port); + if (!Number.isInteger(port) || port <= 0) { + reject(new Error('Failed to bind a local relay tunnel port')); + return; + } + resolve(port); + }); +}); + +const messageData = (event) => { + if (event?.type === 'ready' || event?.type === 'data' || event?.type === 'close') return event; + return event?.data ?? null; +}; + +export const createRelayDevTunnelBridge = ({ createMessageChannel, logger = console } = {}) => { + const tunnels = new Map(); + + const closeTunnel = (key) => { + const tunnel = tunnels.get(key); + if (!tunnel) return false; + tunnels.delete(key); + for (const connection of tunnel.connections.values()) connection.close(); + try { tunnel.server.close(); } catch { /* already closing */ } + return true; + }; + + return { + async open({ targetKey, remotePort, webContents }) { + const port = Number.parseInt(String(remotePort), 10); + if (!Number.isInteger(port) || port <= 0 || port > 65535) throw new Error('A valid remote port is required'); + if (!targetKey) throw new Error('A relay target key is required'); + if (!webContents || webContents.isDestroyed?.()) throw new Error('The desktop window is unavailable'); + + const key = `${webContents.id}|${targetKey}|${port}`; + const existing = tunnels.get(key); + if (existing) return { localPort: existing.localPort, reused: true }; + + const connections = new Map(); + const server = net.createServer((socket) => { + socket.setNoDelay(true); + socket.pause(); + const connectionId = randomUUID(); + const { port1, port2 } = createMessageChannel(); + let closed = false; + const readyTimer = setTimeout(() => close(), CONNECTION_READY_TIMEOUT_MS); + + const close = () => { + if (closed) return; + closed = true; + clearTimeout(readyTimer); + connections.delete(connectionId); + try { port1.postMessage({ type: 'close' }); } catch { /* already closed */ } + try { socket.destroy(); } catch { /* already closed */ } + try { port1.close(); } catch { /* already closed */ } + }; + connections.set(connectionId, { close }); + + port1.on('message', (event) => { + const message = messageData(event); + if (!message) return; + if (message.type === 'ready') { + clearTimeout(readyTimer); + socket.resume(); + return; + } + if (message.type === 'data' && message.data) { + socket.write(Buffer.from(message.data)); + return; + } + if (message.type === 'close') close(); + }); + port1.on('close', close); + port1.start?.(); + + socket.on('data', (chunk) => { + if (closed) return; + port1.postMessage({ type: 'data', data: Uint8Array.from(chunk) }); + }); + socket.on('error', close); + socket.on('close', close); + + try { + webContents.postMessage('openchamber:relay-dev-tunnel-connect', { connectionId, remotePort: port }, [port2]); + } catch (error) { + logger.warn?.(`[dev-tunnel] failed to hand relay connection to renderer: ${error?.message || error}`); + close(); + } + }); + + const localPort = await listen(server); + server.on('error', (error) => logger.warn?.(`[dev-tunnel] relay listener failed: ${error?.message || error}`)); + tunnels.set(key, { server, connections, localPort }); + webContents.once?.('destroyed', () => closeTunnel(key)); + return { localPort, reused: false }; + }, + + closeAll() { + for (const key of [...tunnels.keys()]) closeTunnel(key); + }, + + closeForWebContents(webContentsId) { + let closed = 0; + const prefix = `${webContentsId}|`; + for (const key of [...tunnels.keys()]) { + if (!key.startsWith(prefix)) continue; + if (closeTunnel(key)) closed += 1; + } + return closed; + }, + }; +}; diff --git a/packages/electron/relay-dev-tunnel.test.mjs b/packages/electron/relay-dev-tunnel.test.mjs new file mode 100644 index 00000000..a370db82 --- /dev/null +++ b/packages/electron/relay-dev-tunnel.test.mjs @@ -0,0 +1,96 @@ +import { afterEach, describe, expect, test } from 'bun:test'; +import net from 'node:net'; +import { MessageChannel } from 'node:worker_threads'; +import { createRelayDevTunnelBridge } from './relay-dev-tunnel.mjs'; + +const bridges = []; + +afterEach(() => { + while (bridges.length) bridges.pop().closeAll(); +}); + +describe('relay dev tunnel bridge', () => { + test('pipes a local browser connection through a renderer-owned message port', async () => { + let nextPort; + const webContents = { + id: 7, + isDestroyed: () => false, + once: () => {}, + postMessage: (_channel, payload, ports) => { + nextPort = ports[0]; + nextPort.on('message', (message) => { + if (message.type !== 'data') return; + expect(Buffer.from(message.data).toString()).toContain('GET /docs HTTP/1.1'); + nextPort.postMessage({ type: 'data', data: Buffer.from('HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\nok') }); + nextPort.postMessage({ type: 'close' }); + }); + nextPort.start(); + expect(payload.remotePort).toBe(4322); + nextPort.postMessage({ type: 'ready' }); + }, + }; + const bridge = createRelayDevTunnelBridge({ createMessageChannel: () => new MessageChannel(), logger: { warn: () => {} } }); + bridges.push(bridge); + const { localPort } = await bridge.open({ targetKey: 'host:exe', remotePort: 4322, webContents }); + + const response = await new Promise((resolve, reject) => { + const socket = net.connect({ host: '127.0.0.1', port: localPort }, () => socket.write('GET /docs HTTP/1.1\r\nHost: localhost\r\n\r\n')); + let data = ''; + socket.on('data', (chunk) => { data += chunk; }); + socket.on('close', () => resolve(data)); + socket.on('error', reject); + }); + expect(response).toContain('\r\n\r\nok'); + }); + + test('reuses one local listener for the same window, runtime, and port', async () => { + const webContents = { id: 9, isDestroyed: () => false, once: () => {}, postMessage: () => {} }; + const bridge = createRelayDevTunnelBridge({ createMessageChannel: () => new MessageChannel() }); + bridges.push(bridge); + const first = await bridge.open({ targetKey: 'host:exe', remotePort: 4322, webContents }); + const second = await bridge.open({ targetKey: 'host:exe', remotePort: 4322, webContents }); + expect(second).toEqual({ localPort: first.localPort, reused: true }); + }); + + test('tells the renderer when the local browser connection closes', async () => { + const rendererClosed = new Promise((resolve) => { + const webContents = { + id: 11, + isDestroyed: () => false, + once: () => {}, + postMessage: (_channel, _payload, ports) => { + const rendererPort = ports[0]; + rendererPort.on('message', (message) => { + if (message.type === 'close') resolve(); + }); + rendererPort.start(); + rendererPort.postMessage({ type: 'ready' }); + }, + }; + const bridge = createRelayDevTunnelBridge({ createMessageChannel: () => new MessageChannel() }); + bridges.push(bridge); + void bridge.open({ targetKey: 'host:exe', remotePort: 4322, webContents }).then(({ localPort }) => { + const socket = net.connect({ host: '127.0.0.1', port: localPort }, () => socket.destroy()); + }); + }); + + await rendererClosed; + }); + + test('closes only listeners owned by the requested desktop window', async () => { + const bridge = createRelayDevTunnelBridge({ createMessageChannel: () => new MessageChannel() }); + bridges.push(bridge); + const windowOne = { id: 21, isDestroyed: () => false, once: () => {}, postMessage: () => {} }; + const windowTwo = { id: 22, isDestroyed: () => false, once: () => {}, postMessage: () => {} }; + const first = await bridge.open({ targetKey: 'host:one', remotePort: 4322, webContents: windowOne }); + const second = await bridge.open({ targetKey: 'host:two', remotePort: 4322, webContents: windowTwo }); + + expect(bridge.closeForWebContents(windowOne.id)).toBe(1); + await expect(new Promise((resolve, reject) => { + const socket = net.connect({ host: '127.0.0.1', port: first.localPort }, resolve); + socket.on('error', reject); + })).rejects.toThrow(); + const remaining = await bridge.open({ targetKey: 'host:two', remotePort: 4322, webContents: windowTwo }); + expect(remaining).toEqual({ localPort: second.localPort, reused: true }); + }); +}); diff --git a/packages/ui/src/lib/browser/devTunnel.test.ts b/packages/ui/src/lib/browser/devTunnel.test.ts index ff8093a2..02bb1615 100644 --- a/packages/ui/src/lib/browser/devTunnel.test.ts +++ b/packages/ui/src/lib/browser/devTunnel.test.ts @@ -2,19 +2,52 @@ import { afterEach, beforeEach, describe, expect, mock, test } from 'bun:test'; let apiBaseUrl = 'https://remote.example.test'; -let tunnelResult: unknown = { localPort: 52418, reused: false }; +type TunnelResult = { localPort: number; reused: boolean } | Error; +type DesktopTunnelArgs = { baseUrl?: string; port?: number; relay?: boolean; targetKey?: string }; +type RelayEvent = { connectionId: string; remotePort: number; message: { type: string; data?: ArrayBuffer } }; +type RelaySocketFixture = { + binaryType: string; + onopen: (() => void) | null; + onmessage: ((event: { data: ArrayBuffer | string }) => void) | null; + onerror: (() => void) | null; + onclose: (() => void) | null; + send: ReturnType; + close: ReturnType; + readyState: number; +}; + +let tunnelResult: TunnelResult = { localPort: 52418, reused: false }; +let desktopArgs: DesktopTunnelArgs | undefined; +let relayActive = false; +let openedRelayUrl = ''; +let refreshedBaseUrl = ''; +let refreshUrlAuth = async (baseUrl: string) => { refreshedBaseUrl = baseUrl; return 'url-token'; }; +let relayHandler: ((event: RelayEvent) => void) | null = null; +const relayPosts: Array<{ connectionId: string; message: { type: string; data?: ArrayBuffer } }> = []; +const relaySocket: RelaySocketFixture = { binaryType: 'arraybuffer', onopen: null, onmessage: null, onerror: null, onclose: null, send: mock(() => {}), close: mock(() => {}), readyState: 0 }; mock.module('@/lib/desktopNative', () => ({ - invokeDesktopCommand: mock(async () => { + invokeDesktopCommand: mock(async (_command: string, args?: DesktopTunnelArgs) => { + desktopArgs = args; if (tunnelResult instanceof Error) throw tunnelResult; return tunnelResult; }), + listenForDesktopRelayDevTunnels: (handler: typeof relayHandler) => { relayHandler = handler; return true; }, + postDesktopRelayDevTunnelMessage: (connectionId: string, message: { type: string; data?: ArrayBuffer }) => relayPosts.push({ connectionId, message }), })); +mock.module('@/lib/relay/runtime-tunnel', () => ({ + isRelayModeActive: () => relayActive, + getActiveRelayTunnel: () => relayActive ? {} : null, +})); +mock.module('@/lib/relay/runtime-socket', () => ({ openRuntimeWebSocket: (url: string) => { openedRelayUrl = url; return relaySocket; } })); mock.module('@/lib/runtime-auth', () => ({ getRuntimeBearerTokenSync: () => 'token', getRuntimeExtraHeadersSync: () => ({}), + refreshRuntimeUrlAuthToken: (baseUrl: string) => refreshUrlAuth(baseUrl), })); +mock.module('@/lib/runtime-url', () => ({ getRuntimeUrlResolver: () => ({ websocket: (path: string) => `openchamber-ui://app${path}&oc_url_token=test` }) })); mock.module('@/lib/runtime-switch', () => ({ getRuntimeApiBaseUrl: () => apiBaseUrl, + getRuntimeKey: () => relayActive ? 'host:exe' : `url:${apiBaseUrl}`, subscribeRuntimeEndpointChanged: () => () => {}, })); @@ -25,23 +58,30 @@ const { toDisplayUrl, } = await import('./devTunnel'); -const globalScope = globalThis as unknown as { window?: unknown }; - const asDesktop = (value: boolean) => { - globalScope.window = value - ? { __OPENCHAMBER_ELECTRON__: true, location: { href: 'http://127.0.0.1:3901/' } } - : { location: { href: 'http://127.0.0.1:3901/' } }; + Object.defineProperty(globalThis, 'window', { + configurable: true, + value: value + ? { __OPENCHAMBER_ELECTRON__: true, location: { href: 'http://127.0.0.1:3901/' } } + : { location: { href: 'http://127.0.0.1:3901/' } }, + }); }; describe('loopback navigations against a remote instance', () => { beforeEach(() => { apiBaseUrl = 'https://remote.example.test'; tunnelResult = { localPort: 52418, reused: false }; + desktopArgs = undefined; + relayActive = false; + relayPosts.length = 0; + openedRelayUrl = ''; + refreshedBaseUrl = ''; + refreshUrlAuth = async (baseUrl: string) => { refreshedBaseUrl = baseUrl; return 'url-token'; }; asDesktop(true); }); afterEach(() => { - delete globalScope.window; + Reflect.deleteProperty(globalThis, 'window'); }); test('a page reached through a tunnel keeps its other ports on the host', () => { @@ -82,6 +122,39 @@ describe('loopback navigations against a remote instance', () => { expect(failed).toBe(true); }); + test('a relay-only runtime asks Electron for a local relay bridge', async () => { + relayActive = true; + apiBaseUrl = 'openchamber-ui://app'; + const resolved = await resolveBrowsableUrl('http://localhost:4322/docs/'); + expect(resolved).toBe('http://127.0.0.1:52418/docs/'); + expect(desktopArgs?.relay).toBe(true); + expect(desktopArgs?.targetKey).toBe('host:exe'); + expect(desktopArgs?.port).toBe(4322); + + relayHandler?.({ connectionId: 'connection-1', remotePort: 4322, message: { type: 'connect' } }); + await Promise.resolve(); + await Promise.resolve(); + relaySocket.onopen?.(); + expect(refreshedBaseUrl).toBe('openchamber-ui://app'); + expect(openedRelayUrl).toContain('/api/dev-tunnel?port=4322&oc_url_token=test'); + expect(relayPosts.some((entry) => entry.connectionId === 'connection-1' && entry.message.type === 'ready')).toBe(true); + }); + + test('a local disconnect during auth does not leave an orphan relay socket', async () => { + relayActive = true; + apiBaseUrl = 'openchamber-ui://app'; + let finishAuth = () => {}; + refreshUrlAuth = () => new Promise((resolve) => { finishAuth = () => resolve('url-token'); }); + + relayHandler?.({ connectionId: 'connection-cancelled', remotePort: 4322, message: { type: 'connect' } }); + relayHandler?.({ connectionId: 'connection-cancelled', remotePort: 4322, message: { type: 'close' } }); + finishAuth(); + await Promise.resolve(); + await Promise.resolve(); + + expect(openedRelayUrl).toBe(''); + }); + test('a local instance resolves its own loopback correctly', () => { apiBaseUrl = 'http://127.0.0.1:3901'; expect(shouldTunnelLoopbackUrl('http://localhost:4322/docs/')).toBe(false); diff --git a/packages/ui/src/lib/browser/devTunnel.ts b/packages/ui/src/lib/browser/devTunnel.ts index 439f99e2..1f9322b2 100644 --- a/packages/ui/src/lib/browser/devTunnel.ts +++ b/packages/ui/src/lib/browser/devTunnel.ts @@ -10,17 +10,66 @@ * Everywhere else — local runtime, web, mobile — the URL is already correct and * is returned untouched. */ -import { invokeDesktopCommand } from '@/lib/desktopNative'; -import { getRuntimeBearerTokenSync, getRuntimeExtraHeadersSync } from '@/lib/runtime-auth'; -import { getRuntimeApiBaseUrl, subscribeRuntimeEndpointChanged } from '@/lib/runtime-switch'; +import { invokeDesktopCommand, listenForDesktopRelayDevTunnels, postDesktopRelayDevTunnelMessage } from '@/lib/desktopNative'; +import { getRuntimeBearerTokenSync, getRuntimeExtraHeadersSync, refreshRuntimeUrlAuthToken } from '@/lib/runtime-auth'; +import { getActiveRelayTunnel, isRelayModeActive } from '@/lib/relay/runtime-tunnel'; +import { openRuntimeWebSocket } from '@/lib/relay/runtime-socket'; +import type { RelayTunnelWebSocket } from '@/lib/relay/tunnel-client'; +import { getRuntimeApiBaseUrl, getRuntimeKey, subscribeRuntimeEndpointChanged } from '@/lib/runtime-switch'; +import { getRuntimeUrlResolver } from '@/lib/runtime-url'; import { isLoopbackUrl } from './url'; -type TunnelResult = { localPort: number; reused: boolean; url: string }; +type TunnelResult = { localPort: number }; /** Keyed by `${baseUrl}|${port}`; the shell owns the real lifetime. */ const localPortByTarget = new Map(); /** Reverse map, so a tunnel port never leaks into the address bar or storage. */ const originByLocalPort = new Map(); +const relaySockets = new Map(); +const pendingRelayConnections = new Set(); + +const openRelayConnection = async (connectionId: string, remotePort: number): Promise => { + if (!getActiveRelayTunnel()) { + pendingRelayConnections.delete(connectionId); + postDesktopRelayDevTunnelMessage(connectionId, { type: 'close' }); + return; + } + await refreshRuntimeUrlAuthToken(getRuntimeApiBaseUrl()); + if (!pendingRelayConnections.has(connectionId) || !getActiveRelayTunnel()) return; + const url = getRuntimeUrlResolver().websocket(`/api/dev-tunnel?port=${remotePort}`); + const socket = openRuntimeWebSocket(url); + relaySockets.set(connectionId, socket); + socket.binaryType = 'arraybuffer'; + socket.onopen = () => postDesktopRelayDevTunnelMessage(connectionId, { type: 'ready' }); + socket.onmessage = (event) => postDesktopRelayDevTunnelMessage(connectionId, { type: 'data', data: event.data instanceof ArrayBuffer ? event.data : new TextEncoder().encode(event.data) }); + socket.onerror = () => postDesktopRelayDevTunnelMessage(connectionId, { type: 'close' }); + socket.onclose = () => { + pendingRelayConnections.delete(connectionId); + relaySockets.delete(connectionId); + postDesktopRelayDevTunnelMessage(connectionId, { type: 'close' }); + }; +}; + +listenForDesktopRelayDevTunnels(({ connectionId, remotePort, message }) => { + switch (message.type) { + case 'data': { + const socket = relaySockets.get(connectionId); + if (socket && message.data) socket.send(message.data); + return; + } + case 'close': + pendingRelayConnections.delete(connectionId); + relaySockets.get(connectionId)?.close(); + relaySockets.delete(connectionId); + return; + case 'connect': + pendingRelayConnections.add(connectionId); + void openRelayConnection(connectionId, remotePort).catch(() => { + pendingRelayConnections.delete(connectionId); + postDesktopRelayDevTunnelMessage(connectionId, { type: 'close' }); + }); + } +}); const isDesktopRuntime = (): boolean => ( typeof window !== 'undefined' && Boolean(window.__OPENCHAMBER_ELECTRON__) @@ -69,6 +118,14 @@ const rewriteToLocalPort = (url: string, localPort: number): string => { } }; +const rememberOriginalOrigin = (url: string, localPort: number): void => { + try { + originByLocalPort.set(localPort, new URL(url).origin); + } catch { + // Unparseable input never reaches here; nothing to record. + } +}; + /** Thrown when a remote dev server exists but could not be reached from here. */ export class DevTunnelUnavailableError extends Error { constructor(message: string) { @@ -100,11 +157,7 @@ export const resolveBrowsableUrl = async (url: string): Promise => { const key = `${baseUrl}|${port}`; const cached = localPortByTarget.get(key); if (cached) { - try { - originByLocalPort.set(cached, new URL(url).origin); - } catch { - // Unparseable input never reaches here; nothing to record. - } + rememberOriginalOrigin(url, cached); return rewriteToLocalPort(url, cached); } @@ -112,6 +165,8 @@ export const resolveBrowsableUrl = async (url: string): Promise => { const result = await invokeDesktopCommand('desktop_dev_tunnel_open', { baseUrl, port, + relay: isRelayModeActive(), + targetKey: getRuntimeKey(), clientToken: getRuntimeBearerTokenSync(), requestHeaders: getRuntimeExtraHeadersSync(), }); @@ -119,11 +174,7 @@ export const resolveBrowsableUrl = async (url: string): Promise => { throw new DevTunnelUnavailableError(url); } localPortByTarget.set(key, result.localPort); - try { - originByLocalPort.set(result.localPort, new URL(url).origin); - } catch { - // Unparseable input never reaches here; nothing to record. - } + rememberOriginalOrigin(url, result.localPort); return rewriteToLocalPort(url, result.localPort); } catch (error) { if (error instanceof DevTunnelUnavailableError) throw error; @@ -180,6 +231,10 @@ export const toDisplayUrl = (url: string): string => { const resetDevTunnelCache = (): void => { localPortByTarget.clear(); originByLocalPort.clear(); + pendingRelayConnections.clear(); + for (const socket of relaySockets.values()) socket.close(); + relaySockets.clear(); + void invokeDesktopCommand('desktop_relay_dev_tunnel_close_all').catch(() => {}); }; if (typeof window !== 'undefined') { diff --git a/packages/ui/src/lib/desktopNative.ts b/packages/ui/src/lib/desktopNative.ts index 9f1a7d29..945f64b0 100644 --- a/packages/ui/src/lib/desktopNative.ts +++ b/packages/ui/src/lib/desktopNative.ts @@ -1,6 +1,34 @@ import { hasDesktopInvoke, invokeDesktop, isDesktopShell } from '@/lib/desktop'; type InvokeArgs = Record; +type RelayDevTunnelData = ArrayBuffer | Uint8Array; +type RelayDevTunnelMessage = { type: 'connect' | 'ready' | 'data' | 'close'; data?: RelayDevTunnelData }; +type RelayDevTunnelEvent = { connectionId: string; remotePort: number; message: RelayDevTunnelMessage }; +type RelayDevTunnelBridge = { + relayDevTunnelListen?: (handler: (event: RelayDevTunnelEvent) => void) => void; + relayDevTunnelPost?: (connectionId: string, message: RelayDevTunnelMessage) => void; +}; + +declare global { + interface Window { + __OPENCHAMBER_DESKTOP__?: RelayDevTunnelBridge; + } +} + +const getRelayDevTunnelBridge = (): RelayDevTunnelBridge | null => { + return globalThis.window?.__OPENCHAMBER_DESKTOP__ ?? null; +}; + +export const listenForDesktopRelayDevTunnels = (handler: (event: RelayDevTunnelEvent) => void): boolean => { + const bridge = getRelayDevTunnelBridge(); + if (!bridge?.relayDevTunnelListen) return false; + bridge.relayDevTunnelListen(handler); + return true; +}; + +export const postDesktopRelayDevTunnelMessage = (connectionId: string, message: RelayDevTunnelMessage): void => { + getRelayDevTunnelBridge()?.relayDevTunnelPost?.(connectionId, message); +}; export const invokeDesktopCommand = async ( command: string, diff --git a/packages/web/server/lib/dev-tunnel/DOCUMENTATION.md b/packages/web/server/lib/dev-tunnel/DOCUMENTATION.md index b341090c..c8d264df 100644 --- a/packages/web/server/lib/dev-tunnel/DOCUMENTATION.md +++ b/packages/web/server/lib/dev-tunnel/DOCUMENTATION.md @@ -20,7 +20,13 @@ and what made it fragile per framework. - `client.js` is the local end: it binds a loopback listener on the user's machine and pipes each accepted connection through one WebSocket. It lives in this package because it needs a WebSocket client the package already depends - on; the desktop shell drives it over IPC. + on; the desktop shell drives it over IPC for directly reachable HTTP(S) + runtimes. +- Relay-only runtimes use `packages/electron/relay-dev-tunnel.mjs` for the local + listener. Each accepted connection gets an Electron `MessagePort`; the trusted + renderer carries its bytes through the active E2EE relay. This keeps relay + credentials and encryption in their existing renderer owner instead of + duplicating them in Electron main. - Port discovery is not owned here. `runtime.js` is given the reachable set by the same dev-server discovery the user's own list is built from. - The browser panel decides when to tunnel; this module never chooses a target. @@ -40,9 +46,12 @@ and what made it fragile per framework. usual origin allowlist applies unchanged. That check is a CSRF defence: a hostile page can make a browser open a WebSocket carrying ambient cookies, and the origin is what exposes it. - - With no `Origin` the request must carry client-token auth. A browser cannot - reach this path — the WebSocket API always sends an origin and never lets a - page set an `Authorization` header — so this case is the desktop shell. + - With no `Origin` the request must carry client-token auth or a short-lived + URL token. The bearer case is the desktop main process. The URL-token case + is the trusted renderer carrying the socket through the E2EE relay. + - Through the E2EE relay, the trusted renderer mints a short-lived URL token + and includes it in the virtual WebSocket URL. The relay host and URL-token + allowlists accept exactly `/api/dev-tunnel`, not subpaths. - Concurrency is capped per host, not per page, because one page load opens many sockets. - A connection that cannot be established fails the socket rather than holding diff --git a/packages/web/server/lib/dev-tunnel/runtime.js b/packages/web/server/lib/dev-tunnel/runtime.js index efe265a2..46792f18 100644 --- a/packages/web/server/lib/dev-tunnel/runtime.js +++ b/packages/web/server/lib/dev-tunnel/runtime.js @@ -20,9 +20,9 @@ * * - With an `Origin` header, the request came from a browser context and the * usual origin check applies unchanged. - * - With no `Origin`, the request must carry client-token auth. A browser - * cannot reach this path: the WebSocket API always sends an origin and never - * lets a page set an `Authorization` header. + * - With no `Origin`, the request must carry client-token auth or a short-lived + * URL token. The URL-token case is used only by the trusted renderer through + * the E2EE relay; the UI-auth allowlist limits it to this exact path. */ import net from 'node:net'; import { WebSocketServer } from 'ws'; @@ -133,7 +133,7 @@ export function createDevTunnelRuntime({ void (async () => { try { if (uiAuthController?.enabled) { - const auth = await uiAuthController.resolveAuthContext(req, null, { allowUrlToken: false }); + const auth = await uiAuthController.resolveAuthContext(req, null, { allowUrlToken: true }); if (!auth) { rejectWebSocketUpgrade(socket, 401, 'UI authentication required'); return; diff --git a/packages/web/server/lib/dev-tunnel/tunnel.test.js b/packages/web/server/lib/dev-tunnel/tunnel.test.js index 5e593891..e3ab1ba3 100644 --- a/packages/web/server/lib/dev-tunnel/tunnel.test.js +++ b/packages/web/server/lib/dev-tunnel/tunnel.test.js @@ -1,6 +1,7 @@ import { afterEach, describe, expect, test } from 'bun:test'; import http from 'node:http'; import net from 'node:net'; +import WebSocket from 'ws'; import { createDevTunnelClient } from './client.js'; import { createDevTunnelRuntime, isDevTunnelPath } from './runtime.js'; @@ -228,6 +229,12 @@ describe('dev tunnel authentication', () => { enabled: true, resolveAuthContext: async () => ({ type: 'session' }), }; + const urlTokenAuth = { + enabled: true, + resolveAuthContext: async (req, _res, options) => ( + options?.allowUrlToken === true && req.url.includes('oc_url_token=good') ? { type: 'client', token: 'url:authenticated' } : null + ), + }; test('accepts a bearer-authenticated client that sends no origin', async () => { const devPort = await startDevServer((_req, res) => res.end('ok')); @@ -243,6 +250,19 @@ describe('dev tunnel authentication', () => { expect((await httpGet(localPort, '/')).body).toBe('ok'); }); + test('accepts a URL-token client carried by the E2EE relay', async () => { + const devPort = await startDevServer((_req, res) => res.end('relay-ok')); + const host = await startHost({ allowedPorts: [devPort], auth: urlTokenAuth }); + + const body = await new Promise((resolve, reject) => { + const socket = new WebSocket(`ws://127.0.0.1:${host.port}/api/dev-tunnel?port=${devPort}&oc_url_token=good`); + socket.on('open', () => socket.send('GET / HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n')); + socket.on('message', (data) => resolve(Buffer.from(data).toString())); + socket.on('error', reject); + }); + expect(body).toContain('relay-ok'); + }); + test('rejects a client with no credentials', async () => { const devPort = await startDevServer((_req, res) => res.end('ok')); const host = await startHost({ allowedPorts: [devPort], auth: clientAuth }); diff --git a/packages/web/server/lib/relay/DOCUMENTATION.md b/packages/web/server/lib/relay/DOCUMENTATION.md index d6df917c..2ce5f53a 100644 --- a/packages/web/server/lib/relay/DOCUMENTATION.md +++ b/packages/web/server/lib/relay/DOCUMENTATION.md @@ -41,7 +41,7 @@ Relay is not a separate link format: it is one transport candidate inside the un Everything a client normally sends to the single OpenChamber origin: - **HTTP** — REST endpoints and proxied OpenCode SDK calls under `/api/*`, plus `/auth/*` and `/health`. - **SSE** — long-lived streamed responses (the event stream and notifications). These are just HTTP responses whose body streams; the tunnel needs no special SSE handling. -- **WebSocket** — the endpoints that use a real socket (the global event stream on platforms that support WS, terminal I/O, dictation). +- **WebSocket** — the endpoints that use a real socket (the global event stream on platforms that support WS, terminal I/O, dictation, and desktop dev-server previews). The host dispatcher restricts tunneled traffic to explicit path allowlists (one for HTTP, one for WS). diff --git a/packages/web/server/lib/relay/tunnel-host.js b/packages/web/server/lib/relay/tunnel-host.js index b052b385..91c31b68 100644 --- a/packages/web/server/lib/relay/tunnel-host.js +++ b/packages/web/server/lib/relay/tunnel-host.js @@ -33,7 +33,9 @@ const ALLOWED_WS_PATHS = new Set([ '/api/event/ws', '/api/terminal/ws', '/api/dictation/ws', + '/api/dev-tunnel', ]); +export const isAllowedRelayWebSocketPath = (pathname) => ALLOWED_WS_PATHS.has(pathname); // Hop-by-hop headers stripped from tunneled requests; `host` is set by fetch // to the loopback origin. content-length is dropped too because the body is @@ -425,7 +427,7 @@ export const createTunnelHost = ({ connectionId, getLocalPort, sendFrame, getBuf void sendAbort(streamId, error?.message ?? 'malformed ws open'); return; } - if (!ALLOWED_WS_PATHS.has(open.path)) { + if (!isAllowedRelayWebSocketPath(open.path)) { void sendAbort(streamId, 'Path is not allowed through the relay'); return; } diff --git a/packages/web/server/lib/relay/tunnel-host.test.js b/packages/web/server/lib/relay/tunnel-host.test.js index 382e2c55..8ef521bd 100644 --- a/packages/web/server/lib/relay/tunnel-host.test.js +++ b/packages/web/server/lib/relay/tunnel-host.test.js @@ -1,7 +1,7 @@ import { describe, test, expect } from 'bun:test'; import http from 'node:http'; -import { createTunnelHost } from './tunnel-host.js'; +import { createTunnelHost, isAllowedRelayWebSocketPath } from './tunnel-host.js'; import { decodeTunnelFrame, encodeTunnelFrame, encodeJsonPayload, TunnelFrameType } from './tunnel-codec.js'; const startLoopback = () => @@ -145,3 +145,11 @@ describe('tunnel-host HTTP body forwarding', () => { await loopback.stop(); }); }); + +describe('relay host WebSocket allowlist', () => { + test('allows only the exact dev-server tunnel path', () => { + expect(isAllowedRelayWebSocketPath('/api/dev-tunnel')).toBe(true); + expect(isAllowedRelayWebSocketPath('/api/dev-tunnel/')).toBe(false); + expect(isAllowedRelayWebSocketPath('/api/database/ws')).toBe(false); + }); +}); diff --git a/packages/web/server/lib/ui-auth/ui-auth.js b/packages/web/server/lib/ui-auth/ui-auth.js index aa61edde..71232d6e 100644 --- a/packages/web/server/lib/ui-auth/ui-auth.js +++ b/packages/web/server/lib/ui-auth/ui-auth.js @@ -310,6 +310,7 @@ const isUrlAuthWebSocketPath = (pathname) => { || pathname === '/api/openchamber/realtime-proxy/ws' || pathname === '/api/terminal/ws' || pathname === '/api/dictation/ws' + || pathname === '/api/dev-tunnel' || pathname.startsWith('/api/preview/proxy/'); }; diff --git a/packages/web/server/lib/ui-auth/ui-auth.test.js b/packages/web/server/lib/ui-auth/ui-auth.test.js index 185b0af1..f0d5f019 100644 --- a/packages/web/server/lib/ui-auth/ui-auth.test.js +++ b/packages/web/server/lib/ui-auth/ui-auth.test.js @@ -221,6 +221,22 @@ describe('ui auth client credential seam', () => { }; expect(await auth.ensureSessionToken(dictationWsReq, null)).toBe('client:device-1'); + const devTunnelWsReq = { + method: 'GET', + path: '/api/dev-tunnel', + url: `/api/dev-tunnel?port=4322&oc_url_token=${encodeURIComponent(urlToken)}`, + headers: { upgrade: 'websocket' }, + }; + expect(await auth.ensureSessionToken(devTunnelWsReq, null)).toBe('client:device-1'); + + const devTunnelSubpathWsReq = { + method: 'GET', + path: '/api/dev-tunnel/private', + url: `/api/dev-tunnel/private?port=4322&oc_url_token=${encodeURIComponent(urlToken)}`, + headers: { upgrade: 'websocket' }, + }; + expect(await auth.ensureSessionToken(devTunnelSubpathWsReq, null)).toBe(null); + const dictationHttpReq = { method: 'GET', path: '/api/dictation/ws',