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 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. - 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. - 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. - Auto-update checks, downloads, and restart/apply flow.
- The browser panel's own session (`persist:openchamber-browser`): its storage is - The browser panel's own session (`persist:openchamber-browser`): its storage is
cleared only through the scoped clear-data command, and camera, microphone, 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 contextMenu from 'electron-context-menu';
import log from 'electron-log/main.js'; import log from 'electron-log/main.js';
import dgram from 'node:dgram'; import dgram from 'node:dgram';
@@ -33,6 +33,7 @@ import {
} from './linux-autostart.mjs'; } from './linux-autostart.mjs';
import { unsupportedAppSpecificOpenError, validateLocalPath } from './path-open-utils.mjs'; import { unsupportedAppSpecificOpenError, validateLocalPath } from './path-open-utils.mjs';
import { shouldAllowBrowserPanelCertificateError } from './browser-panel-security.mjs'; import { shouldAllowBrowserPanelCertificateError } from './browser-panel-security.mjs';
import { createRelayDevTunnelBridge } from './relay-dev-tunnel.mjs';
import { attachRendererRecovery } from './renderer-recovery.mjs'; import { attachRendererRecovery } from './renderer-recovery.mjs';
import { mintOutsideFileGrant } from '@openchamber/web/server/lib/fs/routes.js'; 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 // 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. // client) and is loaded only if the user actually previews a remote dev server.
let devTunnelClientPromise = null; let devTunnelClientPromise = null;
const relayDevTunnelBridge = createRelayDevTunnelBridge({ createMessageChannel: () => new MessageChannelMain(), logger: log });
const getDevTunnelClient = async () => { const getDevTunnelClient = async () => {
if (!devTunnelClientPromise) { if (!devTunnelClientPromise) {
devTunnelClientPromise = import('@openchamber/web/server/lib/dev-tunnel/client.js') devTunnelClientPromise = import('@openchamber/web/server/lib/dev-tunnel/client.js')
@@ -3852,6 +3854,7 @@ const getDevTunnelClient = async () => {
}; };
const closeAllDevTunnels = () => { const closeAllDevTunnels = () => {
relayDevTunnelBridge.closeAll();
if (!devTunnelClientPromise) return; if (!devTunnelClientPromise) return;
const pending = devTunnelClientPromise; const pending = devTunnelClientPromise;
devTunnelClientPromise = null; devTunnelClientPromise = null;
@@ -3959,6 +3962,11 @@ const handleInvoke = async (browserWindow, command, args = {}) => {
if (!baseUrl) throw new Error('baseUrl is required'); if (!baseUrl) throw new Error('baseUrl is required');
if (!(port > 0 && port <= 65535)) throw new Error('A valid port 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 headers = {};
const requestHeaders = args.requestHeaders && typeof args.requestHeaders === 'object' ? args.requestHeaders : {}; const requestHeaders = args.requestHeaders && typeof args.requestHeaders === 'object' ? args.requestHeaders : {};
for (const [name, value] of Object.entries(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 }) }; 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. * 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); 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 // The desktop bridge is exposed on all pages; the main-process gate in
// ipcMain.handle('openchamber:invoke') decides per-command what is safe // ipcMain.handle('openchamber:invoke') decides per-command what is safe
// for non-local callers (window/host-switcher ops yes, file/shell ops // for non-local callers (window/host-switcher ops yes, file/shell ops
// no). See COMMANDS_SAFE_FOR_REMOTE in main.mjs. // no). See COMMANDS_SAFE_FOR_REMOTE in main.mjs.
contextBridge.exposeInMainWorld('__OPENCHAMBER_DESKTOP__', { const desktopBridge = {
invoke: (cmd, args) => ipcRenderer.invoke('openchamber:invoke', cmd, args || {}), invoke: (cmd, args) => ipcRenderer.invoke('openchamber:invoke', cmd, args || {}),
openDialog: (options) => ipcRenderer.invoke('openchamber:dialog:open', options || {}), openDialog: (options) => ipcRenderer.invoke('openchamber:dialog:open', options || {}),
grantFileAccess: (filePath) => ipcRenderer.invoke('openchamber:file:grant-existing', filePath), grantFileAccess: (filePath) => ipcRenderer.invoke('openchamber:file:grant-existing', filePath),
openExternal: (url) => ipcRenderer.invoke('openchamber:invoke', 'desktop_open_external_url', { url }), openExternal: (url) => ipcRenderer.invoke('openchamber:invoke', 'desktop_open_external_url', { url }),
listen: async (event, handler) => addListener(event, handler), 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 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', () => ({ mock.module('@/lib/desktopNative', () => ({
invokeDesktopCommand: mock(async () => { invokeDesktopCommand: mock(async (_command: string, args?: DesktopTunnelArgs) => {
desktopArgs = args;
if (tunnelResult instanceof Error) throw tunnelResult; if (tunnelResult instanceof Error) throw tunnelResult;
return 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', () => ({ mock.module('@/lib/runtime-auth', () => ({
getRuntimeBearerTokenSync: () => 'token', getRuntimeBearerTokenSync: () => 'token',
getRuntimeExtraHeadersSync: () => ({}), 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', () => ({ mock.module('@/lib/runtime-switch', () => ({
getRuntimeApiBaseUrl: () => apiBaseUrl, getRuntimeApiBaseUrl: () => apiBaseUrl,
getRuntimeKey: () => relayActive ? 'host:exe' : `url:${apiBaseUrl}`,
subscribeRuntimeEndpointChanged: () => () => {}, subscribeRuntimeEndpointChanged: () => () => {},
})); }));
@@ -25,23 +58,30 @@ const {
toDisplayUrl, toDisplayUrl,
} = await import('./devTunnel'); } = await import('./devTunnel');
const globalScope = globalThis as unknown as { window?: unknown };
const asDesktop = (value: boolean) => { const asDesktop = (value: boolean) => {
globalScope.window = value Object.defineProperty(globalThis, 'window', {
? { __OPENCHAMBER_ELECTRON__: true, location: { href: 'http://127.0.0.1:3901/' } } configurable: true,
: { location: { href: 'http://127.0.0.1:3901/' } }; 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', () => { describe('loopback navigations against a remote instance', () => {
beforeEach(() => { beforeEach(() => {
apiBaseUrl = 'https://remote.example.test'; apiBaseUrl = 'https://remote.example.test';
tunnelResult = { localPort: 52418, reused: false }; 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); asDesktop(true);
}); });
afterEach(() => { afterEach(() => {
delete globalScope.window; Reflect.deleteProperty(globalThis, 'window');
}); });
test('a page reached through a tunnel keeps its other ports on the host', () => { 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); 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', () => { test('a local instance resolves its own loopback correctly', () => {
apiBaseUrl = 'http://127.0.0.1:3901'; apiBaseUrl = 'http://127.0.0.1:3901';
expect(shouldTunnelLoopbackUrl('http://localhost:4322/docs/')).toBe(false); 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 * Everywhere else — local runtime, web, mobile — the URL is already correct and
* is returned untouched. * is returned untouched.
*/ */
import { invokeDesktopCommand } from '@/lib/desktopNative'; import { invokeDesktopCommand, listenForDesktopRelayDevTunnels, postDesktopRelayDevTunnelMessage } from '@/lib/desktopNative';
import { getRuntimeBearerTokenSync, getRuntimeExtraHeadersSync } from '@/lib/runtime-auth'; import { getRuntimeBearerTokenSync, getRuntimeExtraHeadersSync, refreshRuntimeUrlAuthToken } from '@/lib/runtime-auth';
import { getRuntimeApiBaseUrl, subscribeRuntimeEndpointChanged } from '@/lib/runtime-switch'; 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'; 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. */ /** Keyed by `${baseUrl}|${port}`; the shell owns the real lifetime. */
const localPortByTarget = new Map<string, number>(); const localPortByTarget = new Map<string, number>();
/** Reverse map, so a tunnel port never leaks into the address bar or storage. */ /** Reverse map, so a tunnel port never leaks into the address bar or storage. */
const originByLocalPort = new Map<number, string>(); 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 => ( const isDesktopRuntime = (): boolean => (
typeof window !== 'undefined' && Boolean(window.__OPENCHAMBER_ELECTRON__) 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. */ /** Thrown when a remote dev server exists but could not be reached from here. */
export class DevTunnelUnavailableError extends Error { export class DevTunnelUnavailableError extends Error {
constructor(message: string) { constructor(message: string) {
@@ -100,11 +157,7 @@ export const resolveBrowsableUrl = async (url: string): Promise<string> => {
const key = `${baseUrl}|${port}`; const key = `${baseUrl}|${port}`;
const cached = localPortByTarget.get(key); const cached = localPortByTarget.get(key);
if (cached) { if (cached) {
try { rememberOriginalOrigin(url, cached);
originByLocalPort.set(cached, new URL(url).origin);
} catch {
// Unparseable input never reaches here; nothing to record.
}
return rewriteToLocalPort(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', { const result = await invokeDesktopCommand<TunnelResult>('desktop_dev_tunnel_open', {
baseUrl, baseUrl,
port, port,
relay: isRelayModeActive(),
targetKey: getRuntimeKey(),
clientToken: getRuntimeBearerTokenSync(), clientToken: getRuntimeBearerTokenSync(),
requestHeaders: getRuntimeExtraHeadersSync(), requestHeaders: getRuntimeExtraHeadersSync(),
}); });
@@ -119,11 +174,7 @@ export const resolveBrowsableUrl = async (url: string): Promise<string> => {
throw new DevTunnelUnavailableError(url); throw new DevTunnelUnavailableError(url);
} }
localPortByTarget.set(key, result.localPort); localPortByTarget.set(key, result.localPort);
try { rememberOriginalOrigin(url, result.localPort);
originByLocalPort.set(result.localPort, new URL(url).origin);
} catch {
// Unparseable input never reaches here; nothing to record.
}
return rewriteToLocalPort(url, result.localPort); return rewriteToLocalPort(url, result.localPort);
} catch (error) { } catch (error) {
if (error instanceof DevTunnelUnavailableError) throw error; if (error instanceof DevTunnelUnavailableError) throw error;
@@ -180,6 +231,10 @@ export const toDisplayUrl = (url: string): string => {
const resetDevTunnelCache = (): void => { const resetDevTunnelCache = (): void => {
localPortByTarget.clear(); localPortByTarget.clear();
originByLocalPort.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') { if (typeof window !== 'undefined') {
+28
View File
@@ -1,6 +1,34 @@
import { hasDesktopInvoke, invokeDesktop, isDesktopShell } from '@/lib/desktop'; import { hasDesktopInvoke, invokeDesktop, isDesktopShell } from '@/lib/desktop';
type InvokeArgs = Record<string, unknown>; 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>( export const invokeDesktopCommand = async <TValue = unknown>(
command: string, 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 - `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 machine and pipes each accepted connection through one WebSocket. It lives in
this package because it needs a WebSocket client the package already depends 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 - 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 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. - 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 usual origin allowlist applies unchanged. That check is a CSRF defence: a
hostile page can make a browser open a WebSocket carrying ambient cookies, hostile page can make a browser open a WebSocket carrying ambient cookies,
and the origin is what exposes it. and the origin is what exposes it.
- With no `Origin` the request must carry client-token auth. A browser cannot - With no `Origin` the request must carry client-token auth or a short-lived
reach this path — the WebSocket API always sends an origin and never lets a URL token. The bearer case is the desktop main process. The URL-token case
page set an `Authorization` header — so this case is the desktop shell. 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 - Concurrency is capped per host, not per page, because one page load opens
many sockets. many sockets.
- A connection that cannot be established fails the socket rather than holding - 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 * - With an `Origin` header, the request came from a browser context and the
* usual origin check applies unchanged. * usual origin check applies unchanged.
* - With no `Origin`, the request must carry client-token auth. A browser * - With no `Origin`, the request must carry client-token auth or a short-lived
* cannot reach this path: the WebSocket API always sends an origin and never * URL token. The URL-token case is used only by the trusted renderer through
* lets a page set an `Authorization` header. * the E2EE relay; the UI-auth allowlist limits it to this exact path.
*/ */
import net from 'node:net'; import net from 'node:net';
import { WebSocketServer } from 'ws'; import { WebSocketServer } from 'ws';
@@ -133,7 +133,7 @@ export function createDevTunnelRuntime({
void (async () => { void (async () => {
try { try {
if (uiAuthController?.enabled) { if (uiAuthController?.enabled) {
const auth = await uiAuthController.resolveAuthContext(req, null, { allowUrlToken: false }); const auth = await uiAuthController.resolveAuthContext(req, null, { allowUrlToken: true });
if (!auth) { if (!auth) {
rejectWebSocketUpgrade(socket, 401, 'UI authentication required'); rejectWebSocketUpgrade(socket, 401, 'UI authentication required');
return; return;
@@ -1,6 +1,7 @@
import { afterEach, describe, expect, test } from 'bun:test'; import { afterEach, describe, expect, test } from 'bun:test';
import http from 'node:http'; import http from 'node:http';
import net from 'node:net'; import net from 'node:net';
import WebSocket from 'ws';
import { createDevTunnelClient } from './client.js'; import { createDevTunnelClient } from './client.js';
import { createDevTunnelRuntime, isDevTunnelPath } from './runtime.js'; import { createDevTunnelRuntime, isDevTunnelPath } from './runtime.js';
@@ -228,6 +229,12 @@ describe('dev tunnel authentication', () => {
enabled: true, enabled: true,
resolveAuthContext: async () => ({ type: 'session' }), 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 () => { test('accepts a bearer-authenticated client that sends no origin', async () => {
const devPort = await startDevServer((_req, res) => res.end('ok')); const devPort = await startDevServer((_req, res) => res.end('ok'));
@@ -243,6 +250,19 @@ describe('dev tunnel authentication', () => {
expect((await httpGet(localPort, '/')).body).toBe('ok'); 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 () => { test('rejects a client with no credentials', async () => {
const devPort = await startDevServer((_req, res) => res.end('ok')); const devPort = await startDevServer((_req, res) => res.end('ok'));
const host = await startHost({ allowedPorts: [devPort], auth: clientAuth }); 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: Everything a client normally sends to the single OpenChamber origin:
- **HTTP** — REST endpoints and proxied OpenCode SDK calls under `/api/*`, plus `/auth/*` and `/health`. - **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. - **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). 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/event/ws',
'/api/terminal/ws', '/api/terminal/ws',
'/api/dictation/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 // 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 // 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'); void sendAbort(streamId, error?.message ?? 'malformed ws open');
return; return;
} }
if (!ALLOWED_WS_PATHS.has(open.path)) { if (!isAllowedRelayWebSocketPath(open.path)) {
void sendAbort(streamId, 'Path is not allowed through the relay'); void sendAbort(streamId, 'Path is not allowed through the relay');
return; return;
} }
@@ -1,7 +1,7 @@
import { describe, test, expect } from 'bun:test'; import { describe, test, expect } from 'bun:test';
import http from 'node:http'; 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'; import { decodeTunnelFrame, encodeTunnelFrame, encodeJsonPayload, TunnelFrameType } from './tunnel-codec.js';
const startLoopback = () => const startLoopback = () =>
@@ -145,3 +145,11 @@ describe('tunnel-host HTTP body forwarding', () => {
await loopback.stop(); 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/openchamber/realtime-proxy/ws'
|| pathname === '/api/terminal/ws' || pathname === '/api/terminal/ws'
|| pathname === '/api/dictation/ws' || pathname === '/api/dictation/ws'
|| pathname === '/api/dev-tunnel'
|| pathname.startsWith('/api/preview/proxy/'); || 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'); 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 = { const dictationHttpReq = {
method: 'GET', method: 'GET',
path: '/api/dictation/ws', path: '/api/dictation/ws',