fix: support dev server previews over relay
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
*
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
@@ -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') {
|
||||
|
||||
@@ -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).
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user