fix: support dev server previews over relay

This commit is contained in:
Bohdan Triapitsyn
2026-09-03 11:49:34 +03:00
parent da6ab52a65
commit a87f068256
16 changed files with 504 additions and 36 deletions
+1
View File
@@ -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,
+12 -1
View File
@@ -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.
*
+29 -2
View File
@@ -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);
+121
View File
@@ -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;
},
};
};
@@ -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 });
});
});
+81 -8
View File
@@ -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<typeof mock>;
close: ReturnType<typeof mock>;
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<string>((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);
+69 -14
View File
@@ -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<string, number>();
/** Reverse map, so a tunnel port never leaks into the address bar or storage. */
const originByLocalPort = new Map<number, string>();
const relaySockets = new Map<string, RelayTunnelWebSocket>();
const pendingRelayConnections = new Set<string>();
const openRelayConnection = async (connectionId: string, remotePort: number): Promise<void> => {
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<string> => {
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<string> => {
const result = await invokeDesktopCommand<TunnelResult>('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<string> => {
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') {
+28
View File
@@ -1,6 +1,34 @@
import { hasDesktopInvoke, invokeDesktop, isDesktopShell } from '@/lib/desktop';
type InvokeArgs = Record<string, unknown>;
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 <TValue = unknown>(
command: string,
@@ -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
@@ -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;
@@ -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 });
@@ -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).
+3 -1
View File
@@ -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;
}
@@ -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);
});
});
@@ -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/');
};
@@ -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',