From 3b00c918932046d35ed48842a65265aa7d7734fe Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Thu, 30 Jul 2026 12:20:54 +0300 Subject: [PATCH] fix(desktop): isolate remote runtime auth and embeds Fix remote Desktop runtime bootstrapping across context-panel session chats, additional windows, and host switches.\n\n- Bootstrap embedded session-chat frames through a same-origin parent handshake that supplies the active endpoint, bearer token, runtime headers, local origin, and a credential-free relay descriptor.\n- Keep relay pairing grants out of iframe state and explicitly rebind the SDK after embedded bootstrap or relay restoration.\n- Preserve each additional and Mini Chat window's own init script instead of overwriting it when the main window's host configuration changes.\n- Replace direct iframe global calls with same-origin postMessage synchronization for theme, chat settings, and visibility.\n\nHarden Desktop host authentication and probing.\n\n- Bind password, passkey, session-status, and token-persistence completions to the runtime identity that started them, so a late result cannot alter a newly selected host.\n- Cancel active passkey operations and reset transient auth UI state on endpoint changes.\n- Verify stored client authentication via /auth/session for direct and relay host probes, distinguishing reachable hosts from hosts that require re-authentication.\n- Bound every relay probe request with an aborting timeout so a stalled auth request cannot hang refresh or host switching.\n\nAdd regression coverage for the embedded bootstrap handshake, credential-free relay descriptor exposure, runtime configuration, stale password completion after an A-to-B switch, and SDK errors that carry a zero response status.\n\nAlso preserve SDK response status on session-message loader errors so callers can distinguish transport and server failures. --- packages/electron/README.md | 2 + packages/electron/main.mjs | 53 ++++--- .../auth/SessionAuthGate.behavior.test.tsx | 114 +++++++++++++- .../components/auth/SessionAuthGate.test.ts | 16 +- .../src/components/auth/SessionAuthGate.tsx | 129 +++++++++++----- .../components/auth/sessionAuthGateState.ts | 9 ++ .../desktop/DesktopHostSwitcher.tsx | 6 +- .../ui/src/components/layout/ContextPanel.tsx | 73 ++++----- .../layout/contextPanelEmbeddedChat.test.ts | 137 +++++++++++++++++ .../layout/contextPanelEmbeddedChat.ts | 90 ++++++++++- .../remote-instances/RemoteInstancesPage.tsx | 2 +- packages/ui/src/lib/desktopHosts.ts | 50 ++++-- packages/ui/src/lib/relay/runtime-tunnel.ts | 8 + packages/ui/src/lib/runtime-switch.test.ts | 28 ++++ .../src/sync/session-message-loader.test.ts | 15 ++ .../ui/src/sync/session-message-loader.ts | 5 +- packages/web/src/main.tsx | 36 +++-- packages/web/src/runtimeConfig.test.ts | 145 ++++++++++++++++++ packages/web/src/runtimeConfig.ts | 60 +++++--- 19 files changed, 819 insertions(+), 159 deletions(-) create mode 100644 packages/web/src/runtimeConfig.test.ts diff --git a/packages/electron/README.md b/packages/electron/README.md index 4eaa3dc5..b218565e 100644 --- a/packages/electron/README.md +++ b/packages/electron/README.md @@ -10,6 +10,8 @@ Desktop starts the OpenChamber web server in the same Electron main process. The `main.mjs` imports `@openchamber/web/server/index.js` and calls `startWebUiServer()`. The Electron window then loads the UI from the local server in development, or from packaged `resources/web-dist` assets in packaged builds. +Same-origin session-chat iframes complete an authenticated parent-frame handshake before creating their SDK client. The parent supplies its active in-memory endpoint and credentials; when relay is active it also supplies the public relay descriptor without any pairing grant, because Electron preload and IPC are unavailable inside the iframe. The iframe establishes its own transport and rebinds its SDK before rendering. Additional windows retain their own per-window runtime bootstrap instead of being overwritten by the main window. Credentials are never placed in iframe URLs, and other child pages do not receive this runtime state. + The preload bridge exposes desktop-only APIs to the web UI through `window.__OPENCHAMBER_DESKTOP__`. Privileged commands are checked in `main.mjs`, not only in the UI. ## Main Files diff --git a/packages/electron/main.mjs b/packages/electron/main.mjs index ccf988fd..90f8106d 100644 --- a/packages/electron/main.mjs +++ b/packages/electron/main.mjs @@ -861,6 +861,16 @@ const buildVersionUrl = (url) => { } }; +const buildSessionStatusUrl = (url) => { + try { + const parsed = new URL(url); + parsed.pathname = `${parsed.pathname.replace(/\/$/, '') || ''}/auth/session`; + return parsed.toString(); + } catch { + return null; + } +}; + const classifyVersionPayload = (payload) => { const compatibility = payload?.compatibility; if (!payload || payload.status !== 'ok' || !compatibility || typeof compatibility !== 'object') { @@ -895,7 +905,8 @@ const fetchVersionPayload = async (versionUrl, { headers, timeoutMs }) => { const probeHostWithTimeout = async (url, timeoutMs, clientToken = '', requestHeaders = {}, expectedServerId = '') => { const versionUrl = buildVersionUrl(url); - if (!versionUrl) { + const sessionStatusUrl = buildSessionStatusUrl(url); + if (!versionUrl || !sessionStatusUrl) { throw new Error('Invalid URL'); } @@ -939,8 +950,19 @@ const probeHostWithTimeout = async (url, timeoutMs, clientToken = '', requestHea return { status: 'unreachable', latencyMs: Date.now() - started }; } const payload = await response.json().catch(() => null); + const versionStatus = classifyVersionPayload(payload); + if (versionStatus !== 'ok') { + return { status: versionStatus, latencyMs: Date.now() - started }; + } + const sessionResponse = await fetchVersionPayload(sessionStatusUrl, { headers, timeoutMs }); + if (sessionResponse.status === 401 || sessionResponse.status === 403) { + return { status: 'auth', latencyMs: Date.now() - started }; + } + if (!sessionResponse.ok) { + return { status: 'unreachable', latencyMs: Date.now() - started }; + } return { - status: classifyVersionPayload(payload), + status: versionStatus, latencyMs: Date.now() - started, }; } catch { @@ -1556,15 +1578,13 @@ const buildInitScript = (localOrigin, bootOutcome, apiBaseUrl = '', clientToken ].join(''); }; -// Keep per-window init scripts aligned with state. Chooser/onboarding reloads after -// desktop_hosts_set; if only state.initScript is updated, dom-ready reinjects a stale -// not-configured outcome and the UI flickers on "Waiting for OpenCode". -const syncInitScriptToWindows = (initScript = state.initScript) => { +// Keep the main window aligned with global host configuration without overwriting +// the runtime-specific bootstrap retained by additional and Mini Chat windows. +const syncMainWindowInitScript = (initScript = state.initScript) => { if (!initScript) return; - for (const win of BrowserWindow.getAllWindows()) { - if (!win.isDestroyed()) { - win.__ocInitScript = initScript; - } + const mainWindow = state.mainWindow; + if (mainWindow && !mainWindow.isDestroyed()) { + mainWindow.__ocInitScript = initScript; } }; @@ -2476,11 +2496,8 @@ const createBrowserWindow = ({ label, restoreGeometry, url, runtimeConfig = {} } }); browserWindow.webContents.on('dom-ready', () => { - // Prefer authoritative state script so hosts_set updates survive reloads even if a - // window still holds a pre-activation / not-configured __ocInitScript. - const initScript = state.initScript || browserWindow.__ocInitScript; + const initScript = browserWindow.__ocInitScript; if (initScript) { - browserWindow.__ocInitScript = initScript; void browserWindow.webContents.executeJavaScript(initScript).catch(() => {}); } }); @@ -2531,7 +2548,7 @@ const activateMainWindow = async (url, localOrigin, bootOutcome, runtimeConfig = rendererRuntimeConfig.clientToken, rendererRuntimeConfig.requestHeaders, ); - syncInitScriptToWindows(state.initScript); + syncMainWindowInitScript(state.initScript); const mainWindow = state.mainWindow; if (mainWindow && !mainWindow.isDestroyed()) { @@ -2755,9 +2772,8 @@ const createMiniChatWindow = async ({ mode, sessionId = '', directory = '', proj void shell.openExternal(url).catch(() => {}); }); browserWindow.webContents.on('dom-ready', () => { - const initScript = state.initScript || browserWindow.__ocInitScript; + const initScript = browserWindow.__ocInitScript; if (initScript) { - browserWindow.__ocInitScript = initScript; void browserWindow.webContents.executeJavaScript(initScript).catch(() => {}); } }); @@ -4007,8 +4023,7 @@ const handleInvoke = async (browserWindow, command, args = {}) => { localAvailable: Boolean(state.sidecarUrl || state.localOrigin), }); state.initScript = buildInitScript(state.localOrigin, state.bootOutcome, state.apiBaseUrl, state.clientToken, state.requestHeaders || {}); - syncInitScriptToWindows(state.initScript); - log.info('[electron] hosts config updated, recomputed bootOutcome', state.bootOutcome); + syncMainWindowInitScript(state.initScript); return null; } diff --git a/packages/ui/src/components/auth/SessionAuthGate.behavior.test.tsx b/packages/ui/src/components/auth/SessionAuthGate.behavior.test.tsx index 871bc74c..8f8cd7ee 100644 --- a/packages/ui/src/components/auth/SessionAuthGate.behavior.test.tsx +++ b/packages/ui/src/components/auth/SessionAuthGate.behavior.test.tsx @@ -1,4 +1,4 @@ -import { describe, expect, mock, test } from 'bun:test'; +import { afterEach, describe, expect, mock, test } from 'bun:test'; type ComponentFn

= Record> = (props: P) => unknown; @@ -16,12 +16,43 @@ const hookRecords = new Map(); let currentRecord: HookRecord | null = null; let hookIndex = 0; let pendingEffects: Array<() => void> = []; +const originalWindow = Object.getOwnPropertyDescriptor(globalThis, 'window'); + +afterEach(() => { + if (originalWindow) { + Object.defineProperty(globalThis, 'window', originalWindow); + } else { + Reflect.deleteProperty(globalThis, 'window'); + } +}); const resetHarness = () => { hookRecords.clear(); currentRecord = null; hookIndex = 0; pendingEffects = []; + runtimeApiBaseUrl = ''; + runtimeKey = 'local'; + runtimeEndpointChangedListener = null; + desktopInvoke = async () => null; + desktopHostsGetCalls = 0; + desktopHostsSetCalls = 0; + runtimeSwitchCalls = 0; + Object.defineProperty(globalThis, 'window', { + configurable: true, + value: { + isSecureContext: false, + localStorage: { + getItem: () => null, + setItem: () => undefined, + }, + setTimeout: (callback: () => void) => { + queueMicrotask(callback); + return 0; + }, + clearTimeout: () => undefined, + }, + }); }; const shallowEqualDeps = (left?: unknown[], right?: unknown[]): boolean => { @@ -149,6 +180,13 @@ const reactJsxRuntime = { let desktopShell = false; let runtimeFetchRejects = true; +let runtimeApiBaseUrl = ''; +let runtimeKey = 'local'; +let runtimeEndpointChangedListener: (() => void) | null = null; +let desktopInvoke: () => Promise = async () => null; +let desktopHostsGetCalls = 0; +let desktopHostsSetCalls = 0; +let runtimeSwitchCalls = 0; mock.module('react/jsx-runtime', () => reactJsxRuntime); mock.module('react/jsx-dev-runtime', () => reactJsxRuntime); @@ -172,7 +210,7 @@ mock.module('@/components/ui/checkbox', () => ({ })); mock.module('@/components/ui/input', () => ({ - Input: () => null, + Input: (props: JSXProps) => ({ type: 'input', props }), })); mock.module('@/components/ui', () => ({ @@ -200,7 +238,7 @@ mock.module('@/lib/i18n', () => ({ })); mock.module('@/lib/desktop', () => ({ - invokeDesktop: mock(() => Promise.resolve(null)), + invokeDesktop: () => desktopInvoke(), isDesktopShell: mock(() => desktopShell), isVSCodeRuntime: mock(() => false), })); @@ -232,14 +270,26 @@ mock.module('@/lib/runtime-auth', () => ({ })); mock.module('@/lib/runtime-switch', () => ({ - getRuntimeApiBaseUrl: mock(() => ''), - subscribeRuntimeEndpointChanged: mock(() => () => {}), - switchRuntimeEndpoint: mock(() => undefined), + getRuntimeApiBaseUrl: () => runtimeApiBaseUrl, + getRuntimeKey: () => runtimeKey, + subscribeRuntimeEndpointChanged: (listener: () => void) => { + runtimeEndpointChangedListener = listener; + return () => { + if (runtimeEndpointChangedListener === listener) runtimeEndpointChangedListener = null; + }; + }, + switchRuntimeEndpoint: () => { runtimeSwitchCalls += 1; }, })); mock.module('@/lib/desktopHosts', () => ({ - desktopHostsGet: mock(() => Promise.resolve(null)), - desktopHostsSet: mock(() => Promise.resolve()), + desktopHostsGet: () => { + desktopHostsGetCalls += 1; + return Promise.resolve(null); + }, + desktopHostsSet: () => { + desktopHostsSetCalls += 1; + return Promise.resolve(); + }, getDesktopHostApiUrl: mock(() => ''), normalizeHostUrl: mock(() => ''), })); @@ -288,6 +338,21 @@ const collectText = (node: unknown): string => { return ''; }; +const findElement = (node: unknown, type: string): { type: string; props: JSXProps } | null => { + if (!node || typeof node !== 'object') return null; + const element = node as { type?: unknown; props?: JSXProps }; + if (element.type === type && element.props) return { type, props: element.props }; + const children = element.props?.children; + if (Array.isArray(children)) { + for (const child of children) { + const match = findElement(child, type); + if (match) return match; + } + return null; + } + return findElement(children, type); +}; + describe('SessionAuthGate status-check failure behavior', () => { test('keeps non-desktop status-check rejection on the error screen', async () => { resetHarness(); @@ -312,4 +377,37 @@ describe('SessionAuthGate status-check failure behavior', () => { expect(text).toContain('sessionAuth.locked.unlockTitle'); expect(text).not.toContain('sessionAuth.error.networkTitle'); }); + + test('discards a password completion after switching to another host', async () => { + resetHarness(); + desktopShell = true; + runtimeFetchRejects = false; + runtimeApiBaseUrl = 'https://host-a.example'; + runtimeKey = 'host:a'; + let resolveLogin: (value: unknown) => void = () => { + throw new Error('Password login did not start'); + }; + desktopInvoke = () => new Promise((resolve) => { resolveLogin = resolve; }); + + const lockedTree = await renderGate(); + const input = findElement(lockedTree, 'input'); + expect(input).not.toBeNull(); + (input?.props.onChange as (event: { target: { value: string } }) => void)({ target: { value: 'password-a' } }); + + const passwordTree = await renderGate(); + const form = findElement(passwordTree, 'form'); + expect(form).not.toBeNull(); + const pending = (form?.props.onSubmit as (event: { preventDefault: () => void }) => Promise)({ preventDefault: () => undefined }); + await Promise.resolve(); + + runtimeApiBaseUrl = 'https://host-b.example'; + runtimeKey = 'host:b'; + runtimeEndpointChangedListener?.(); + resolveLogin({ token: 'token-a' }); + await pending; + + expect(desktopHostsGetCalls).toBe(0); + expect(desktopHostsSetCalls).toBe(0); + expect(runtimeSwitchCalls).toBe(0); + }); }); diff --git a/packages/ui/src/components/auth/SessionAuthGate.test.ts b/packages/ui/src/components/auth/SessionAuthGate.test.ts index 543a6fbe..9e80797b 100644 --- a/packages/ui/src/components/auth/SessionAuthGate.test.ts +++ b/packages/ui/src/components/auth/SessionAuthGate.test.ts @@ -1,6 +1,6 @@ import { describe, expect, test } from 'bun:test'; -import { resolveStatusCheckFailureState } from './sessionAuthGateState'; +import { resolveStatusCheckFailureState, runtimeIdentityMatches } from './sessionAuthGateState'; describe('resolveStatusCheckFailureState', () => { test('keeps the desktop-shell password login fallback intact', () => { @@ -10,4 +10,18 @@ describe('resolveStatusCheckFailureState', () => { test('uses the network error screen for non-desktop status-check failures', () => { expect(resolveStatusCheckFailureState({})).toBe('error'); }); + + test('rejects async auth results after switching hosts', () => { + expect(runtimeIdentityMatches( + { apiBaseUrl: 'https://host-a.example', runtimeKey: 'host:a' }, + { apiBaseUrl: 'https://host-b.example', runtimeKey: 'host:b' }, + )).toBe(false); + }); + + test('accepts a credential refresh for the same host', () => { + expect(runtimeIdentityMatches( + { apiBaseUrl: 'https://host-a.example', runtimeKey: 'host:a' }, + { apiBaseUrl: 'https://host-a.example', runtimeKey: 'host:a' }, + )).toBe(true); + }); }); diff --git a/packages/ui/src/components/auth/SessionAuthGate.tsx b/packages/ui/src/components/auth/SessionAuthGate.tsx index b867fad2..553804cc 100644 --- a/packages/ui/src/components/auth/SessionAuthGate.tsx +++ b/packages/ui/src/components/auth/SessionAuthGate.tsx @@ -13,9 +13,9 @@ import { Icon } from "@/components/icon/Icon"; import { useI18n } from '@/lib/i18n'; import { runtimeFetch } from '@/lib/runtime-fetch'; import { getRuntimeExtraHeadersSync } from '@/lib/runtime-auth'; -import { getRuntimeApiBaseUrl, subscribeRuntimeEndpointChanged, switchRuntimeEndpoint } from '@/lib/runtime-switch'; +import { getRuntimeApiBaseUrl, getRuntimeKey, subscribeRuntimeEndpointChanged, switchRuntimeEndpoint } from '@/lib/runtime-switch'; import { desktopHostsGet, desktopHostsSet, getDesktopHostApiUrl, normalizeHostUrl } from '@/lib/desktopHosts'; -import { resolveStatusCheckFailureState, type GateState } from './sessionAuthGateState'; +import { resolveStatusCheckFailureState, runtimeIdentityMatches, type GateState, type RuntimeIdentity } from './sessionAuthGateState'; import { authenticateWithPasskey, cancelPasskeyCeremony, @@ -160,20 +160,34 @@ const shouldUseDesktopShellPasswordLogin = (): boolean => { return isDesktopShell() && !isLocalDesktopRuntime(); }; +const captureRuntimeIdentity = (): RuntimeIdentity => ({ + apiBaseUrl: getRuntimeApiBaseUrl(), + runtimeKey: getRuntimeKey(), +}); + +const isRuntimeIdentityActive = (identity: RuntimeIdentity): boolean => { + return runtimeIdentityMatches(identity, captureRuntimeIdentity()); +}; + type DesktopPasswordLoginResult = { token: string; status?: number; }; -const issueDesktopClientTokenViaShell = async (password: string, trustDevice: boolean): Promise => { +const issueDesktopClientTokenViaShell = async ( + password: string, + trustDevice: boolean, + runtime: RuntimeIdentity, + requestHeaders: Record, +): Promise => { if (!isDesktopShell() || typeof window === 'undefined') { return null; } const response = await invokeDesktop('desktop_remote_password_login', { - url: getRuntimeApiBaseUrl(), + url: runtime.apiBaseUrl, password, trustDevice, - requestHeaders: getRuntimeExtraHeadersSync(), + requestHeaders, }).catch(() => null); if (!response || typeof response !== 'object') { return null; @@ -186,22 +200,22 @@ const issueDesktopClientTokenViaShell = async (password: string, trustDevice: bo }; }; -const persistDesktopClientToken = async (apiBaseUrl: string, clientToken: string): Promise => { - if (!isDesktopShell() || !clientToken) return; +const persistDesktopClientToken = async (runtime: RuntimeIdentity, clientToken: string): Promise => { + if (!isDesktopShell() || !clientToken || !isRuntimeIdentityActive(runtime)) return false; const cfg = await desktopHostsGet().catch(() => null); - if (!cfg) return; - if (cfg.localOrigin && sameOrigin(cfg.localOrigin, apiBaseUrl)) { + if (!cfg || !isRuntimeIdentityActive(runtime)) return false; + if (cfg.localOrigin && sameOrigin(cfg.localOrigin, runtime.apiBaseUrl)) { await desktopHostsSet({ hosts: cfg.hosts, defaultHostId: cfg.defaultHostId, initialHostChoiceCompleted: cfg.initialHostChoiceCompleted, localClientToken: clientToken, }).catch(() => undefined); - return; + return isRuntimeIdentityActive(runtime); } let changed = false; const hosts = cfg.hosts.map((host) => { - if (!sameOrigin(getDesktopHostApiUrl(host), apiBaseUrl)) { + if (!sameOrigin(getDesktopHostApiUrl(host), runtime.apiBaseUrl)) { return host; } if (host.clientToken === clientToken) { @@ -210,24 +224,31 @@ const persistDesktopClientToken = async (apiBaseUrl: string, clientToken: string changed = true; return { ...host, clientToken }; }); - if (!changed) return; + if (!changed) return true; + if (!isRuntimeIdentityActive(runtime)) return false; await desktopHostsSet({ hosts, defaultHostId: cfg.defaultHostId, initialHostChoiceCompleted: cfg.initialHostChoiceCompleted, }).catch(() => undefined); + return isRuntimeIdentityActive(runtime); }; -const applyDesktopClientToken = async (clientToken: string): Promise => { - if (!clientToken) return; - const apiBaseUrl = getRuntimeApiBaseUrl(); - const requestHeaders = getRuntimeExtraHeadersSync(); - await persistDesktopClientToken(apiBaseUrl, clientToken); +const applyDesktopClientToken = async ( + clientToken: string, + runtime: RuntimeIdentity, + requestHeaders: Record, +): Promise => { + if (!clientToken || !isRuntimeIdentityActive(runtime)) return false; + if (!await persistDesktopClientToken(runtime, clientToken)) return false; + if (!isRuntimeIdentityActive(runtime)) return false; switchRuntimeEndpoint({ - apiBaseUrl, + apiBaseUrl: runtime.apiBaseUrl, clientToken, requestHeaders: Object.keys(requestHeaders).length > 0 ? requestHeaders : null, + runtimeKey: runtime.runtimeKey, }); + return true; }; const AuthShell: React.FC<{ children: React.ReactNode }> = ({ children }) => { @@ -338,17 +359,21 @@ export const SessionAuthGate: React.FC = ({ window.localStorage.setItem(TRUST_DEVICE_STORAGE_KEY, trustDevice ? 'true' : 'false'); }, [trustDevice]); - const refreshPasskeyStatus = React.useCallback(async () => { + const refreshPasskeyStatus = React.useCallback(async (runtime = captureRuntimeIdentity()) => { if (skipAuth) { return defaultPasskeyStatus; } try { const nextStatus = await fetchPasskeyStatus(); - setPasskeyStatus(nextStatus); + if (isRuntimeIdentityActive(runtime)) { + setPasskeyStatus(nextStatus); + } return nextStatus; } catch { - setPasskeyStatus(defaultPasskeyStatus); + if (isRuntimeIdentityActive(runtime)) { + setPasskeyStatus(defaultPasskeyStatus); + } return defaultPasskeyStatus; } }, [skipAuth]); @@ -423,14 +448,19 @@ export const SessionAuthGate: React.FC = ({ return; } + const runtime = captureRuntimeIdentity(); setState((prev) => (prev === 'authenticated' ? prev : 'pending')); try { const [response, latestPasskeyStatus] = await Promise.all([ fetchSessionStatus(), - refreshPasskeyStatus(), + refreshPasskeyStatus(runtime), ]); const responseText = await response.text(); + if (!isRuntimeIdentityActive(runtime)) { + return; + } + if (response.ok) { resetTransientRetry(); setState('authenticated'); @@ -472,6 +502,9 @@ export const SessionAuthGate: React.FC = ({ setState('error'); setIsTunnelLocked(false); } catch (error) { + if (!isRuntimeIdentityActive(runtime)) { + return; + } console.warn('Failed to check session status:', error); if (resolveStatusCheckFailureState({ shouldUseDesktopShellPasswordLogin: shouldUseDesktopShellPasswordLogin() }) === 'locked') { setState('locked'); @@ -504,10 +537,14 @@ export const SessionAuthGate: React.FC = ({ } return subscribeRuntimeEndpointChanged(() => { + cancelPasskeyCeremony(); setPassword(''); setErrorMessage(''); setRetryAfter(undefined); setIsTunnelLocked(false); + setIsSubmitting(false); + setActivePasskeyAction(null); + setIsPasskeyBusy(false); resetTransientRetry(); setState('pending'); void checkStatus(); @@ -547,15 +584,19 @@ export const SessionAuthGate: React.FC = ({ }; const registerPasskeyForCurrentSession = React.useCallback(async () => { + const runtime = captureRuntimeIdentity(); setActivePasskeyAction('register'); setIsPasskeyBusy(true); try { await registerCurrentDevicePasskey(); } finally { - setActivePasskeyAction(null); - setIsPasskeyBusy(false); + if (isRuntimeIdentityActive(runtime)) { + setActivePasskeyAction(null); + setIsPasskeyBusy(false); + } } - await refreshPasskeyStatus(); + if (!isRuntimeIdentityActive(runtime)) return; + await refreshPasskeyStatus(runtime); }, [refreshPasskeyStatus]); const cancelActivePasskey = React.useCallback(() => { @@ -576,16 +617,19 @@ export const SessionAuthGate: React.FC = ({ cancelActivePasskey(); } + const runtime = captureRuntimeIdentity(); + const requestHeaders = getRuntimeExtraHeadersSync(); setIsSubmitting(true); setErrorMessage(''); try { if (shouldUseDesktopShellPasswordLogin()) { - const shellLogin = await issueDesktopClientTokenViaShell(password, trustDevice); + const shellLogin = await issueDesktopClientTokenViaShell(password, trustDevice, runtime, requestHeaders); + if (!isRuntimeIdentityActive(runtime)) return; if (shellLogin?.token) { setPassword(''); setIsTunnelLocked(false); - await applyDesktopClientToken(shellLogin.token); + if (!await applyDesktopClientToken(shellLogin.token, runtime, requestHeaders)) return; setState('authenticated'); return; } @@ -604,8 +648,10 @@ export const SessionAuthGate: React.FC = ({ } const response = await submitPassword(password, trustDevice); + if (!isRuntimeIdentityActive(runtime)) return; if (response.ok) { const payload = await response.json().catch(() => null) as { clientToken?: unknown } | null; + if (!isRuntimeIdentityActive(runtime)) return; const shouldUseClientToken = shouldIssueDesktopClientToken(); let clientToken = ''; if (shouldUseClientToken) { @@ -613,18 +659,21 @@ export const SessionAuthGate: React.FC = ({ ? payload.clientToken.trim() : ''; if (!clientToken) { - const shellLogin = await issueDesktopClientTokenViaShell(password, trustDevice); + const shellLogin = await issueDesktopClientTokenViaShell(password, trustDevice, runtime, requestHeaders); + if (!isRuntimeIdentityActive(runtime)) return; clientToken = shellLogin?.token || await issueDesktopClientToken(); + if (!isRuntimeIdentityActive(runtime)) return; } } setPassword(''); setIsTunnelLocked(false); if (clientToken) { - await applyDesktopClientToken(clientToken); + if (!await applyDesktopClientToken(clientToken, runtime, requestHeaders)) return; } if (enrollPasskey && supportsPasskeys) { try { await registerPasskeyForCurrentSession(); + if (!isRuntimeIdentityActive(runtime)) return; toast.success(t('sessionAuth.toast.passkeyAdded')); setState('authenticated'); return; @@ -662,14 +711,16 @@ export const SessionAuthGate: React.FC = ({ setIsTunnelLocked(false); setState('error'); } catch (error) { + if (!isRuntimeIdentityActive(runtime)) return; console.warn('Failed to submit UI password:', error); const shellLogin = shouldUseDesktopShellPasswordLogin() - ? await issueDesktopClientTokenViaShell(password, trustDevice) + ? await issueDesktopClientTokenViaShell(password, trustDevice, runtime, requestHeaders) : null; + if (!isRuntimeIdentityActive(runtime)) return; if (shellLogin?.token) { setPassword(''); setIsTunnelLocked(false); - await applyDesktopClientToken(shellLogin.token); + if (!await applyDesktopClientToken(shellLogin.token, runtime, requestHeaders)) return; setState('authenticated'); return; } @@ -689,7 +740,9 @@ export const SessionAuthGate: React.FC = ({ setIsTunnelLocked(false); setState('error'); } finally { - setIsSubmitting(false); + if (isRuntimeIdentityActive(runtime)) { + setIsSubmitting(false); + } } }, [cancelActivePasskey, isPasskeyBusy, isSubmitting, isTunnelLocked, password, registerPasskeyForCurrentSession, supportsPasskeys, t, trustDevice]); @@ -706,6 +759,8 @@ export const SessionAuthGate: React.FC = ({ setIsPasskeyBusy(true); setActivePasskeyAction('auth'); setErrorMessage(''); + const runtime = captureRuntimeIdentity(); + const requestHeaders = getRuntimeExtraHeadersSync(); try { const payload = await authenticateWithPasskey(trustDevice, { @@ -716,13 +771,15 @@ export const SessionAuthGate: React.FC = ({ const clientToken = shouldIssueDesktopClientToken() && typeof payload?.clientToken === 'string' && payload.clientToken.trim() ? payload.clientToken.trim() : ''; + if (!isRuntimeIdentityActive(runtime)) return; if (clientToken) { - await applyDesktopClientToken(clientToken); + if (!await applyDesktopClientToken(clientToken, runtime, requestHeaders)) return; } setPassword(''); setState('authenticated'); } catch (error) { + if (!isRuntimeIdentityActive(runtime)) return; if (isPasskeyCeremonyAbort(error)) { setErrorMessage(''); } else { @@ -730,8 +787,10 @@ export const SessionAuthGate: React.FC = ({ setErrorMessage(message); } } finally { - setActivePasskeyAction(null); - setIsPasskeyBusy(false); + if (isRuntimeIdentityActive(runtime)) { + setActivePasskeyAction(null); + setIsPasskeyBusy(false); + } } }, [cancelActivePasskey, isPasskeyBusy, isSubmitting, supportsPasskeys, t, trustDevice]); diff --git a/packages/ui/src/components/auth/sessionAuthGateState.ts b/packages/ui/src/components/auth/sessionAuthGateState.ts index 258d4acc..bcf3c6ac 100644 --- a/packages/ui/src/components/auth/sessionAuthGateState.ts +++ b/packages/ui/src/components/auth/sessionAuthGateState.ts @@ -1,5 +1,14 @@ export type GateState = 'pending' | 'authenticated' | 'locked' | 'error' | 'rate-limited'; +export type RuntimeIdentity = { + apiBaseUrl: string; + runtimeKey: string; +}; + +export const runtimeIdentityMatches = (left: RuntimeIdentity, right: RuntimeIdentity): boolean => { + return left.apiBaseUrl === right.apiBaseUrl && left.runtimeKey === right.runtimeKey; +}; + export const resolveStatusCheckFailureState = (options: { shouldUseDesktopShellPasswordLogin?: boolean; }): Exclude => { diff --git a/packages/ui/src/components/desktop/DesktopHostSwitcher.tsx b/packages/ui/src/components/desktop/DesktopHostSwitcher.tsx index cf953a8d..76e53af9 100644 --- a/packages/ui/src/components/desktop/DesktopHostSwitcher.tsx +++ b/packages/ui/src/components/desktop/DesktopHostSwitcher.tsx @@ -434,8 +434,9 @@ export function DesktopHostSwitcherDialog({ const localClientToken = await getLocalClientToken(); const results = await Promise.all( hosts.map(async (h) => { + const clientToken = h.id === LOCAL_HOST_ID ? localClientToken : (h.clientToken || ''); const probeRelayLeg = async (): Promise => { - const res = await probeRelayDesktopHost(h.relay!).catch((): HostProbeResult => ({ status: 'unreachable', latencyMs: 0 })); + const res = await probeRelayDesktopHost(h.relay!, { clientToken, requestHeaders: h.requestHeaders || null }).catch((): HostProbeResult => ({ status: 'unreachable', latencyMs: 0 })); return { status: res.status, latencyMs: res.latencyMs, ...(res.status === 'ok' ? { via: 'relay' as const } : {}) }; }; // Relay-only host: no HTTP address — probe through the E2EE tunnel. @@ -446,7 +447,6 @@ export function DesktopHostSwitcherDialog({ if (!url) { return [h.id, { status: 'unreachable' as const, latencyMs: 0 } satisfies HostStatus] as const; } - const clientToken = h.id === LOCAL_HOST_ID ? localClientToken : (h.clientToken || ''); const res = await desktopHostProbe(url, { clientToken: clientToken || null, requestHeaders: h.requestHeaders || null }).catch((): HostProbeResult => ({ status: 'unreachable', latencyMs: 0 })); // Multi-transport host away from its network: the direct leg fails // but the relay may still reach it. @@ -572,7 +572,7 @@ export function DesktopHostSwitcherDialog({ } let relayProbeTunnel: ReturnType | undefined; if (!transport && host.relay) { - const probe = await probeRelayDesktopHost(host.relay, { keepTunnel: true }) + const probe = await probeRelayDesktopHost(host.relay, { keepTunnel: true, clientToken: clientToken || null, requestHeaders: host.requestHeaders || null }) .catch((): HostProbeResult => ({ status: 'unreachable', latencyMs: 0 })); if (probe.status === 'ok') { finalStatus = { status: probe.status, latencyMs: probe.latencyMs, via: 'relay' }; diff --git a/packages/ui/src/components/layout/ContextPanel.tsx b/packages/ui/src/components/layout/ContextPanel.tsx index 2fa239ef..7af914ff 100644 --- a/packages/ui/src/components/layout/ContextPanel.tsx +++ b/packages/ui/src/components/layout/ContextPanel.tsx @@ -27,14 +27,21 @@ import { setExternallyViewedSession, useDirectoryStore } from '@/sync/sync-conte import { ContextPanelContent } from './ContextSidebarTab'; import { toast } from '@/components/ui'; import { runtimeFetch } from '@/lib/runtime-fetch'; -import { refreshRuntimeUrlAuthToken } from '@/lib/runtime-auth'; +import { getRuntimeBearerTokenSync, getRuntimeExtraHeadersSync, refreshRuntimeUrlAuthToken } from '@/lib/runtime-auth'; import { getRuntimeUrlResolver } from '@/lib/runtime-url'; -import { getRuntimeApiBaseUrl } from '@/lib/runtime-switch'; +import { getRuntimeApiBaseUrl, getRuntimeKey } from '@/lib/runtime-switch'; +import { getActiveRelayDescriptor } from '@/lib/relay/runtime-tunnel'; import { getPreviewTargetRecoveryAction } from '@/lib/preview/proxy-response'; import { Icon } from "@/components/icon/Icon"; import { OpenChamberLogo } from "@/components/ui/OpenChamberLogo"; import { invokeDesktopCommand } from '@/lib/desktopNative'; -import { getOrCreateEmbeddedSessionChatURL, type EmbeddedSessionChatURLCacheEntry } from './contextPanelEmbeddedChat'; +import { + EMBEDDED_RUNTIME_BOOTSTRAP_REQUEST, + EMBEDDED_RUNTIME_BOOTSTRAP_RESPONSE, + getOrCreateEmbeddedSessionChatURL, + type EmbeddedSessionChatURLCacheEntry, + type EmbeddedSessionRuntimeBootstrap, +} from './contextPanelEmbeddedChat'; import { getContextSurfaceWidthFraction } from '@/lib/surfaces/registry'; import { type PreviewElementMetadata, @@ -2538,19 +2545,6 @@ export const ContextPanel: React.FC = () => { continue; } - const directThemeSync = (frameWindow as unknown as { - __openchamberApplyThemeSync?: (themePayload: typeof payload) => void; - }).__openchamberApplyThemeSync; - - if (typeof directThemeSync === 'function') { - try { - directThemeSync(payload); - continue; - } catch { - // fallback to postMessage below - } - } - frameWindow.postMessage( { type: 'openchamber:theme-sync', @@ -2569,18 +2563,6 @@ export const ContextPanel: React.FC = () => { const frameWindow = frame.contentWindow; if (!frameWindow) continue; - const directSync = (frameWindow as unknown as { - __openchamberApplyChatSettingsSync?: (settings: typeof payload) => void; - }).__openchamberApplyChatSettingsSync; - if (typeof directSync === 'function') { - try { - directSync(payload); - continue; - } catch { - // fallback to postMessage below - } - } - frameWindow.postMessage({ type: 'openchamber:chat-settings-sync', payload }, window.location.origin); } }, [allowPromptingSubagentSessions]); @@ -2597,19 +2579,6 @@ export const ContextPanel: React.FC = () => { } const payload = { visible: activeChatTabID === tabID }; - const directVisibilitySync = (frameWindow as unknown as { - __openchamberSetEmbeddedVisibility?: (visibilityPayload: typeof payload) => void; - }).__openchamberSetEmbeddedVisibility; - - if (typeof directVisibilitySync === 'function') { - try { - directVisibilitySync(payload); - continue; - } catch { - // fallback to postMessage below - } - } - frameWindow.postMessage( { type: 'openchamber:embedded-visibility', @@ -2636,7 +2605,27 @@ export const ContextPanel: React.FC = () => { return; } - const data = event.data as { type?: unknown }; + const data = event.data as { type?: unknown; requestId?: unknown }; + if (data?.type === EMBEDDED_RUNTIME_BOOTSTRAP_REQUEST) { + if (typeof data.requestId !== 'string' || !data.requestId) return; + const runtimeKey = getRuntimeKey(); + const payload: EmbeddedSessionRuntimeBootstrap = { + apiBaseUrl: getRuntimeApiBaseUrl(), + clientToken: getRuntimeBearerTokenSync(), + localOrigin: typeof window.__OPENCHAMBER_LOCAL_ORIGIN__ === 'string' + ? window.__OPENCHAMBER_LOCAL_ORIGIN__ + : '', + runtimeHeaders: getRuntimeExtraHeadersSync(), + relayHostId: runtimeKey.startsWith('host:') ? runtimeKey.slice('host:'.length) : '', + relay: getActiveRelayDescriptor() ?? undefined, + }; + (event.source as WindowProxy | null)?.postMessage({ + type: EMBEDDED_RUNTIME_BOOTSTRAP_RESPONSE, + requestId: data.requestId, + payload, + }, event.origin); + return; + } if (data?.type === 'openchamber:theme-sync-request') { postThemeSyncToEmbeddedChat(); return; diff --git a/packages/ui/src/components/layout/contextPanelEmbeddedChat.test.ts b/packages/ui/src/components/layout/contextPanelEmbeddedChat.test.ts index fdbbd0da..33cf7ef6 100644 --- a/packages/ui/src/components/layout/contextPanelEmbeddedChat.test.ts +++ b/packages/ui/src/components/layout/contextPanelEmbeddedChat.test.ts @@ -3,9 +3,12 @@ import { getDefaultTheme } from '@/lib/theme/themes'; import type { Theme } from '@/types/theme'; import { buildEmbeddedSessionChatURL, + EMBEDDED_RUNTIME_BOOTSTRAP_REQUEST, + EMBEDDED_RUNTIME_BOOTSTRAP_RESPONSE, getOrCreateEmbeddedSessionChatURL, getEmbeddedSessionChatOriginSessionId, isEmbeddedSessionChat, + requestEmbeddedSessionRuntimeBootstrap, resetEmbeddedSessionChatCache, type EmbeddedSessionChatURLCacheEntry, } from './contextPanelEmbeddedChat'; @@ -207,3 +210,137 @@ describe('getEmbeddedSessionChatOriginSessionId', () => { expect(getEmbeddedSessionChatOriginSessionId()).toBe('ses_child'); }); }); + +describe('embedded runtime bootstrap handshake', () => { + test('accepts only the matching response from the same-origin parent', async () => { + let messageListener: ((event: MessageEvent) => void) | null = null; + let requestCount = 0; + let retryCleared = false; + const parent = { + postMessage(message: { type?: string; requestId?: string }, targetOrigin: string) { + requestCount += 1; + expect(message.type).toBe(EMBEDDED_RUNTIME_BOOTSTRAP_REQUEST); + queueMicrotask(() => { + messageListener?.({ + origin: 'https://wrong.example.com', + source: parent, + data: { + type: EMBEDDED_RUNTIME_BOOTSTRAP_RESPONSE, + requestId: message.requestId, + payload: null, + }, + } as unknown as MessageEvent); + if (requestCount === 1) return; + messageListener?.({ + origin: targetOrigin, + source: parent, + data: { + type: EMBEDDED_RUNTIME_BOOTSTRAP_RESPONSE, + requestId: 'different-request', + payload: null, + }, + } as unknown as MessageEvent); + messageListener?.({ + origin: targetOrigin, + source: parent, + data: { + type: EMBEDDED_RUNTIME_BOOTSTRAP_RESPONSE, + requestId: message.requestId, + payload: { + apiBaseUrl: 'https://remote.example.com', + clientToken: 'client-token', + localOrigin: 'openchamber-ui://app', + runtimeHeaders: { 'x-runtime': 'value' }, + relayHostId: 'host-1', + relay: { + relayUrl: 'wss://relay.example.com', + serverId: 'server-1', + hostEncPubJwk: { kty: 'EC', crv: 'P-256', x: 'public-x', y: 'public-y' }, + }, + }, + }, + } as unknown as MessageEvent); + }); + }, + }; + const url = new URL('openchamber-ui://app/index.html?ocPanel=session-chat&sessionId=ses_1'); + Object.defineProperty(globalThis, 'window', { + configurable: true, + value: { + location: { origin: url.origin, search: url.search }, + parent, + addEventListener: (type: string, listener: (event: MessageEvent) => void) => { + if (type === 'message') messageListener = listener; + }, + removeEventListener: (type: string, listener: (event: MessageEvent) => void) => { + if (type === 'message' && messageListener === listener) messageListener = null; + }, + setTimeout: globalThis.setTimeout.bind(globalThis), + clearTimeout: globalThis.clearTimeout.bind(globalThis), + setInterval: globalThis.setInterval.bind(globalThis), + clearInterval: (interval: ReturnType) => { + retryCleared = true; + globalThis.clearInterval(interval); + }, + }, + }); + resetEmbeddedSessionChatCache(); + + const result = await requestEmbeddedSessionRuntimeBootstrap(); + expect(result).toEqual({ + apiBaseUrl: 'https://remote.example.com', + clientToken: 'client-token', + localOrigin: 'openchamber-ui://app', + runtimeHeaders: { 'x-runtime': 'value' }, + relayHostId: 'host-1', + relay: { + relayUrl: 'wss://relay.example.com', + serverId: 'server-1', + hostEncPubJwk: { kty: 'EC', crv: 'P-256', x: 'public-x', y: 'public-y' }, + }, + }); + expect(requestCount).toBe(2); + expect(retryCleared).toBe(true); + expect(messageListener).toBeNull(); + }); + + test('cleans up its listener and retry when the bootstrap times out', async () => { + let messageListener: ((event: MessageEvent) => void) | null = null; + let timeoutCallback: () => void = () => { + throw new Error('Timeout was not scheduled'); + }; + let timeoutCleared = false; + let retryCleared = false; + const parent = { postMessage() {} }; + const url = new URL('openchamber-ui://app/index.html?ocPanel=session-chat&sessionId=ses_1'); + Object.defineProperty(globalThis, 'window', { + configurable: true, + value: { + location: { origin: url.origin, search: url.search }, + parent, + addEventListener: (type: string, listener: (event: MessageEvent) => void) => { + if (type === 'message') messageListener = listener; + }, + removeEventListener: (type: string, listener: (event: MessageEvent) => void) => { + if (type === 'message' && messageListener === listener) messageListener = null; + }, + setTimeout: (callback: () => void) => { + timeoutCallback = callback; + return 1; + }, + clearTimeout: () => { timeoutCleared = true; }, + setInterval: () => 2, + clearInterval: () => { retryCleared = true; }, + }, + }); + resetEmbeddedSessionChatCache(); + + const resultPromise = requestEmbeddedSessionRuntimeBootstrap(); + timeoutCallback(); + + expect(await resultPromise).toBeNull(); + expect(timeoutCleared).toBe(true); + expect(retryCleared).toBe(true); + expect(messageListener).toBeNull(); + }); +}); diff --git a/packages/ui/src/components/layout/contextPanelEmbeddedChat.ts b/packages/ui/src/components/layout/contextPanelEmbeddedChat.ts index 85f53573..14890478 100644 --- a/packages/ui/src/components/layout/contextPanelEmbeddedChat.ts +++ b/packages/ui/src/components/layout/contextPanelEmbeddedChat.ts @@ -1,4 +1,5 @@ import type { Theme } from '@/types/theme'; +import type { RelayRuntimeDescriptor } from '@/lib/relay/runtime-tunnel'; export type EmbeddedSessionChatThemeBootstrap = { mode: 'light' | 'dark' | 'system'; @@ -12,6 +13,93 @@ export type EmbeddedSessionChatURLCacheEntry = { src: string; }; +export type EmbeddedSessionRuntimeBootstrap = { + apiBaseUrl: string; + clientToken: string; + localOrigin: string; + runtimeHeaders?: Record; + relayHostId: string; + relay?: Omit; +}; + +export const EMBEDDED_RUNTIME_BOOTSTRAP_REQUEST = 'openchamber:embedded-runtime-bootstrap-request'; +export const EMBEDDED_RUNTIME_BOOTSTRAP_RESPONSE = 'openchamber:embedded-runtime-bootstrap-response'; +const EMBEDDED_RUNTIME_BOOTSTRAP_TIMEOUT_MS = 5_000; +const EMBEDDED_RUNTIME_BOOTSTRAP_RETRY_MS = 100; + +const isStringRecord = (value: unknown): value is Record => ( + value !== null + && typeof value === 'object' + && !Array.isArray(value) + && Object.values(value).every((entry) => typeof entry === 'string') +); + +const isRuntimeBootstrap = (value: unknown): value is EmbeddedSessionRuntimeBootstrap => { + if (!value || typeof value !== 'object') return false; + + const candidate = value as Partial; + if ( + typeof candidate.apiBaseUrl !== 'string' + || typeof candidate.clientToken !== 'string' + || typeof candidate.localOrigin !== 'string' + || typeof candidate.relayHostId !== 'string' + ) { + return false; + } + if (candidate.runtimeHeaders !== undefined && !isStringRecord(candidate.runtimeHeaders)) { + return false; + } + + const relay = candidate.relay; + if (relay === undefined) return true; + + return relay !== null + && typeof relay === 'object' + && !('grant' in relay) + && typeof relay.relayUrl === 'string' + && typeof relay.serverId === 'string' + && relay.hostEncPubJwk !== null + && typeof relay.hostEncPubJwk === 'object' + && !Array.isArray(relay.hostEncPubJwk); +}; + +export const requestEmbeddedSessionRuntimeBootstrap = (): Promise => { + if (!isEmbeddedSessionChat() || typeof window === 'undefined' || window.parent === window) { + return Promise.resolve(null); + } + + const requestId = typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function' + ? crypto.randomUUID() + : `${Date.now()}-${Math.random()}`; + + return new Promise((resolve) => { + let settled = false; + let retry = 0; + let timeout = 0; + const finish = (value: EmbeddedSessionRuntimeBootstrap | null) => { + if (settled) return; + settled = true; + window.clearTimeout(timeout); + window.clearInterval(retry); + window.removeEventListener('message', handleMessage); + resolve(value); + }; + const handleMessage = (event: MessageEvent) => { + if (event.origin !== window.location.origin || event.source !== window.parent) return; + const data = event.data as { type?: unknown; requestId?: unknown; payload?: unknown }; + if (data?.type !== EMBEDDED_RUNTIME_BOOTSTRAP_RESPONSE || data.requestId !== requestId) return; + finish(isRuntimeBootstrap(data.payload) ? data.payload : null); + }; + timeout = window.setTimeout(() => finish(null), EMBEDDED_RUNTIME_BOOTSTRAP_TIMEOUT_MS); + const sendRequest = () => { + window.parent.postMessage({ type: EMBEDDED_RUNTIME_BOOTSTRAP_REQUEST, requestId }, window.location.origin); + }; + retry = window.setInterval(sendRequest, EMBEDDED_RUNTIME_BOOTSTRAP_RETRY_MS); + window.addEventListener('message', handleMessage); + sendRequest(); + }); +}; + const buildEmbeddedSessionChatURLSignature = ( sessionID: string, directory: string | null, @@ -125,4 +213,4 @@ export const getEmbeddedSessionChatOriginSessionId = (): string | null => { } catch { return null; } -}; \ No newline at end of file +}; diff --git a/packages/ui/src/components/sections/remote-instances/RemoteInstancesPage.tsx b/packages/ui/src/components/sections/remote-instances/RemoteInstancesPage.tsx index 22f8c8db..b921e586 100644 --- a/packages/ui/src/components/sections/remote-instances/RemoteInstancesPage.tsx +++ b/packages/ui/src/components/sections/remote-instances/RemoteInstancesPage.tsx @@ -751,7 +751,7 @@ export const RemoteInstancesPage: React.FC = () => { if (!showInstanceManagement || directHosts.length === 0) return; let cancelled = false; void Promise.all(directHosts.map(async (host) => { - const relayProbe = () => probeRelayDesktopHost(host.relay!).catch((): HostProbeResult => ({ status: 'unreachable', latencyMs: 0 })); + const relayProbe = () => probeRelayDesktopHost(host.relay!, { clientToken: host.clientToken || null, requestHeaders: host.requestHeaders || null }).catch((): HostProbeResult => ({ status: 'unreachable', latencyMs: 0 })); // Relay-only host: tunnel probe. Multi-transport host: direct first, // relay as the away-from-home fallback. if (host.relay && !host.apiUrl) { diff --git a/packages/ui/src/lib/desktopHosts.ts b/packages/ui/src/lib/desktopHosts.ts index 0e5af343..ddcc4787 100644 --- a/packages/ui/src/lib/desktopHosts.ts +++ b/packages/ui/src/lib/desktopHosts.ts @@ -409,19 +409,34 @@ export const desktopInstallIdGet = async (): Promise => { const RELAY_PROBE_TIMEOUT_MS = 8_000; +const fetchRelayProbe = async ( + tunnel: ReturnType, + path: string, + init?: RequestInit, +): Promise => { + const controller = new AbortController(); + const timer = window.setTimeout(() => controller.abort(), RELAY_PROBE_TIMEOUT_MS); + try { + return await tunnel.fetch(path, { ...init, signal: controller.signal }); + } finally { + window.clearTimeout(timer); + } +}; + /** - * Reachability check for a relay host: open a throwaway E2EE tunnel and hit - * /health. Relay hosts have no HTTP address for `desktopHostProbe`. Hard - * timeout: a ghost relay registration (relay lost the host, host doesn't know) - * leaves the tunnel in `connecting` forever — the probe must report - * unreachable instead of hanging every status/switch flow with it. + * Reachability and client-auth check for a relay host: open a throwaway E2EE + * tunnel, verify `/health`, then verify `/auth/session` with the saved bearer. + * Relay hosts have no HTTP address for `desktopHostProbe`. Hard timeout: a + * ghost relay registration (relay lost the host, host doesn't know) leaves the + * tunnel in `connecting` forever — the probe must report unreachable instead + * of hanging every status/switch flow with it. */ export const probeRelayDesktopHost = async ( relay: DesktopHostRelay, // With `keepTunnel`, an 'ok' probe RETURNS its live tunnel (the caller owns // it — typically adopting it as the runtime tunnel, skipping a second // WebSocket connect + E2EE handshake); every other outcome closes it. - options?: { keepTunnel?: boolean }, + options?: { keepTunnel?: boolean; clientToken?: string | null; requestHeaders?: Record | null }, ): Promise }> => { const tunnel = createRelayTunnelClient({ relayUrl: relay.relayUrl, @@ -431,16 +446,19 @@ export const probeRelayDesktopHost = async ( const startedAt = Date.now(); let keep = false; try { - const response = await Promise.race([ - tunnel.fetch('/health'), - new Promise((resolve) => { - const timer = window.setTimeout(() => resolve(null), RELAY_PROBE_TIMEOUT_MS); - if (typeof timer !== 'number' && typeof (timer as { unref?: () => void }).unref === 'function') { - (timer as unknown as { unref: () => void }).unref(); - } - }), - ]); - if (!response?.ok) return { status: 'unreachable', latencyMs: 0 }; + const response = await fetchRelayProbe(tunnel, '/health'); + if (!response.ok) return { status: 'unreachable', latencyMs: 0 }; + const headers = new Headers({ Accept: 'application/json' }); + for (const [name, value] of Object.entries(options?.requestHeaders || {})) { + if (name.toLowerCase() !== 'authorization') headers.set(name, value); + } + const clientToken = options?.clientToken?.trim(); + if (clientToken) headers.set('Authorization', `Bearer ${clientToken}`); + const sessionResponse = await fetchRelayProbe(tunnel, '/auth/session', { headers }); + if (sessionResponse.status === 401 || sessionResponse.status === 403) { + return { status: 'auth', latencyMs: Math.max(0, Date.now() - startedAt) }; + } + if (!sessionResponse.ok) return { status: 'unreachable', latencyMs: 0 }; keep = options?.keepTunnel === true; return { status: 'ok', latencyMs: Math.max(0, Date.now() - startedAt), ...(keep ? { tunnel } : {}) }; } catch { diff --git a/packages/ui/src/lib/relay/runtime-tunnel.ts b/packages/ui/src/lib/relay/runtime-tunnel.ts index dc3ce7ed..016cca38 100644 --- a/packages/ui/src/lib/relay/runtime-tunnel.ts +++ b/packages/ui/src/lib/relay/runtime-tunnel.ts @@ -22,6 +22,14 @@ const descriptorsEqual = (a: RelayRuntimeDescriptor, b: RelayRuntimeDescriptor): JSON.stringify(a.hostEncPubJwk) === JSON.stringify(b.hostEncPubJwk); export const getActiveRelayTunnel = (): RelayTunnelClient | null => activeTunnel; +export const getActiveRelayDescriptor = (): Omit | null => { + if (!activeTunnel || !activeDescriptor) return null; + return { + relayUrl: activeDescriptor.relayUrl, + serverId: activeDescriptor.serverId, + hostEncPubJwk: { ...activeDescriptor.hostEncPubJwk }, + }; +}; export const isRelayModeActive = (): boolean => activeTunnel !== null; diff --git a/packages/ui/src/lib/runtime-switch.test.ts b/packages/ui/src/lib/runtime-switch.test.ts index 18fee373..7208c1bd 100644 --- a/packages/ui/src/lib/runtime-switch.test.ts +++ b/packages/ui/src/lib/runtime-switch.test.ts @@ -7,8 +7,36 @@ import { switchRuntimeEndpoint, } from './runtime-switch'; import { clearRuntimeUrlAuthToken, setRuntimeExtraHeaders } from './runtime-auth'; +import { + activateRelayTunnel, + deactivateRelayTunnel, + getActiveRelayDescriptor, +} from './relay/runtime-tunnel'; describe('runtime endpoint switching', () => { + test('exposes a credential-free copy of the active relay descriptor', () => { + const descriptor = { + relayUrl: 'wss://relay.example.com', + serverId: 'server-1', + hostEncPubJwk: { kty: 'EC', crv: 'P-256', x: 'public-x', y: 'public-y' }, + grant: 'one-time-secret', + }; + + try { + activateRelayTunnel(descriptor); + const exposed = getActiveRelayDescriptor(); + expect(exposed).toEqual({ + relayUrl: descriptor.relayUrl, + serverId: descriptor.serverId, + hostEncPubJwk: descriptor.hostEncPubJwk, + }); + expect(exposed).not.toBe(descriptor); + expect(exposed?.hostEncPubJwk).not.toBe(descriptor.hostEncPubJwk); + } finally { + deactivateRelayTunnel(); + } + }); + test('notifies listeners before and after mutating the active endpoint', () => { const previousWindow = Object.getOwnPropertyDescriptor(globalThis, 'window'); const previousFetch = globalThis.fetch; diff --git a/packages/ui/src/sync/session-message-loader.test.ts b/packages/ui/src/sync/session-message-loader.test.ts index f7fcd275..62325e10 100644 --- a/packages/ui/src/sync/session-message-loader.test.ts +++ b/packages/ui/src/sync/session-message-loader.test.ts @@ -140,6 +140,7 @@ describe("SessionMessageLoader", () => { await loader.ensure(target, { force: true }) expect(loader.getSnapshot(target).status).toBe("error") + expect((loader.getSnapshot(target).error as Error & { status?: number }).status).toBe(400) expect(store.getState().message[target.sessionID]?.[0]?.id).toBe("cached") fail = false @@ -149,6 +150,20 @@ describe("SessionMessageLoader", () => { childStores.disposeAll() }) + test("propagates a zero response status on SDK errors", async () => { + const { childStores, loader } = createLoader(async () => ({ + error: { message: "network rejected" }, + response: { status: 0 }, + })) + const target = { directory: "/repo", sessionID: "session-a" } + + await loader.ensure(target, { force: true }) + + expect((loader.getSnapshot(target).error as Error & { status?: number }).status).toBe(0) + loader.dispose() + childStores.disposeAll() + }) + test("prevents an evicted in-flight request from repopulating the store", async () => { const pending = deferred>() const { childStores, loader } = createLoader(async () => pending.promise) diff --git a/packages/ui/src/sync/session-message-loader.ts b/packages/ui/src/sync/session-message-loader.ts index 540c6232..1b423c4a 100644 --- a/packages/ui/src/sync/session-message-loader.ts +++ b/packages/ui/src/sync/session-message-loader.ts @@ -98,7 +98,10 @@ const assertSdkSuccess = (result: { }, operation: string): void => { if (!result.error) return const status = result.response?.status - throw new Error(`${operation} failed${status ? ` (${status})` : ""}: ${formatSdkError(result.error)}`) + const message = `${operation} failed${status ? ` (${status})` : ""}: ${formatSdkError(result.error)}` + const error = new Error(message) as Error & { status?: number } + if (status !== undefined) error.status = status + throw error } const sortParts = (parts: Part[]): Part[] => parts diff --git a/packages/web/src/main.tsx b/packages/web/src/main.tsx index fd8f1b0b..6d0a41a3 100644 --- a/packages/web/src/main.tsx +++ b/packages/web/src/main.tsx @@ -4,6 +4,10 @@ import { registerSW } from 'virtual:pwa-register'; import type { RuntimeAPIs } from '@openchamber/ui/lib/api/types'; import { getStoredMobileLayoutPreference } from '@openchamber/ui/lib/mobileLayoutPreference'; import type { HostedSurface } from '@openchamber/ui/lib/runtimeSurface'; +import { + isEmbeddedSessionChat, + requestEmbeddedSessionRuntimeBootstrap, +} from '@openchamber/ui/components/layout/contextPanelEmbeddedChat'; import '@openchamber/ui/index.css'; import '@openchamber/ui/styles/fonts'; @@ -14,8 +18,6 @@ declare global { } } -window.__OPENCHAMBER_RUNTIME_APIS__ = createConfiguredWebAPIs(); - const isCoarsePointer = (): boolean => { if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') { return false; @@ -104,18 +106,24 @@ const unregisterDevelopmentServiceWorkers = (): void => { }); }; -if (hostedSurface === 'mobile') { - void import('@openchamber/ui/apps/renderMobileApp') - .then(({ renderMobileApp }) => { - renderMobileApp(window.__OPENCHAMBER_RUNTIME_APIS__ ?? createConfiguredWebAPIs()); - }); -} else { - // Hold the render (HTML splash stays up) until a desktop relay-host restore - // has picked its transport — otherwise the app boots against a not-yet-chosen - // endpoint and flashes the auth screen before the tunnel connects. Resolves - // immediately when no relay host is involved. - void getDesktopRelayRestoreReady().then(() => import('@openchamber/ui/main')); -} +const start = async (): Promise => { + const embeddedBootstrap = isEmbeddedSessionChat() + ? await requestEmbeddedSessionRuntimeBootstrap() + : null; + window.__OPENCHAMBER_RUNTIME_APIS__ = createConfiguredWebAPIs(embeddedBootstrap); + + if (hostedSurface === 'mobile') { + const { renderMobileApp } = await import('@openchamber/ui/apps/renderMobileApp'); + renderMobileApp(window.__OPENCHAMBER_RUNTIME_APIS__); + return; + } + + // Hold the render until a desktop relay-host restore has picked its transport. + await getDesktopRelayRestoreReady(); + await import('@openchamber/ui/main'); +}; + +void start(); if (import.meta.env.PROD) { registerPwaServiceWorker(); diff --git a/packages/web/src/runtimeConfig.test.ts b/packages/web/src/runtimeConfig.test.ts new file mode 100644 index 00000000..2f1d4948 --- /dev/null +++ b/packages/web/src/runtimeConfig.test.ts @@ -0,0 +1,145 @@ +import { afterAll, beforeEach, describe, expect, test, vi } from 'vitest'; + +vi.mock('@openchamber/ui/lib/runtime-auth', () => ({ + getRuntimeBearerTokenSync: vi.fn(() => ''), + getRuntimeExtraHeadersSync: vi.fn(() => ({})), + refreshLocalRuntimeUrlAuthToken: vi.fn(() => Promise.resolve()), + refreshRuntimeUrlAuthToken: vi.fn(() => Promise.resolve()), + setRuntimeBearerToken: vi.fn(), + setRuntimeExtraHeaders: vi.fn(), +})); +vi.mock('@openchamber/ui/lib/runtime-fetch', () => ({ installRuntimeFetchBridge: vi.fn() })); +vi.mock('@openchamber/ui/lib/runtime-switch', () => ({ + getRuntimeApiBaseUrl: vi.fn(() => ''), + getRuntimeKey: vi.fn(() => 'local'), + initializeRuntimeEndpoint: vi.fn(), + switchRuntimeEndpoint: vi.fn(), +})); +vi.mock('@openchamber/ui/lib/desktopRelayRestore', () => ({ restoreDesktopRelayRuntime: vi.fn(() => Promise.resolve()) })); +vi.mock('@openchamber/ui/lib/runtime-url', () => ({ configureRuntimeUrlResolver: vi.fn(() => ({})) })); +vi.mock('@openchamber/ui/lib/opencode/client', () => ({ opencodeClient: { reconnectToRuntimeBaseUrl: vi.fn() } })); +vi.mock('./api', () => ({ createWebAPIs: vi.fn() })); + +import { setRuntimeBearerToken, setRuntimeExtraHeaders } from '@openchamber/ui/lib/runtime-auth'; +import { initializeRuntimeEndpoint, switchRuntimeEndpoint } from '@openchamber/ui/lib/runtime-switch'; +import { restoreDesktopRelayRuntime } from '@openchamber/ui/lib/desktopRelayRestore'; +import { opencodeClient } from '@openchamber/ui/lib/opencode/client'; +import { createConfiguredWebAPIs, readRuntimeBootstrapConfig } from './runtimeConfig'; + +const originalWindow = globalThis.window; + +const installWindow = (value: Record) => { + Object.defineProperty(globalThis, 'window', { + configurable: true, + value, + }); +}; + +const makeWindow = (search = ''): Record => { + const value: Record = { + location: { origin: 'openchamber-ui://app', search }, + setTimeout: vi.fn(() => 1), + }; + value.parent = value; + return value; +}; + +beforeEach(() => { + vi.clearAllMocks(); + installWindow(makeWindow()); +}); + +afterAll(() => { + Object.defineProperty(globalThis, 'window', { + configurable: true, + value: originalWindow, + }); +}); + +describe('readRuntimeBootstrapConfig', () => { + test('reads the runtime injected into the current window', () => { + const current = makeWindow(); + current.__OPENCHAMBER_API_BASE_URL__ = ' https://remote.example.com '; + current.__OPENCHAMBER_CLIENT_TOKEN__ = ' remote-token '; + current.__OPENCHAMBER_LOCAL_ORIGIN__ = ' http://127.0.0.1:3000 '; + current.__OPENCHAMBER_RUNTIME_HEADERS__ = { 'x-openchamber-relay': 'relay-value' }; + current.__OPENCHAMBER_RELAY_HOST_ID__ = ' remote-host '; + installWindow(current); + + expect(readRuntimeBootstrapConfig()).toEqual({ + apiBaseUrl: 'https://remote.example.com', + clientToken: 'remote-token', + localOrigin: 'http://127.0.0.1:3000', + runtimeHeaders: { 'x-openchamber-relay': 'relay-value' }, + relayHostId: 'remote-host', + }); + }); + + test('does not read runtime credentials directly from a parent window', () => { + const parent = makeWindow(); + parent.__OPENCHAMBER_API_BASE_URL__ = 'https://remote.example.com'; + parent.__OPENCHAMBER_CLIENT_TOKEN__ = 'remote-token'; + const child = makeWindow('?ocPanel=session-chat&sessionId=ses_child'); + child.parent = parent; + installWindow(child); + + expect(readRuntimeBootstrapConfig()).toEqual({ + apiBaseUrl: '', + clientToken: '', + localOrigin: '', + runtimeHeaders: undefined, + relayHostId: '', + }); + }); + +}); + +describe('createConfiguredWebAPIs', () => { + test('applies an embedded handshake before restoring its relay host', () => { + const bootstrap = { + apiBaseUrl: 'https://remote.example.com', + clientToken: 'client-token', + localOrigin: 'openchamber-ui://app', + runtimeHeaders: { 'x-runtime': 'value' }, + relayHostId: 'host-1', + }; + + createConfiguredWebAPIs(bootstrap); + + expect(initializeRuntimeEndpoint).toHaveBeenCalledWith({ + apiBaseUrl: bootstrap.apiBaseUrl, + runtimeKey: null, + }); + expect(setRuntimeBearerToken).toHaveBeenCalledWith(bootstrap.clientToken); + expect(setRuntimeExtraHeaders).toHaveBeenCalledWith(bootstrap.runtimeHeaders); + expect(restoreDesktopRelayRuntime).toHaveBeenCalledWith(bootstrap.relayHostId); + expect(opencodeClient.reconnectToRuntimeBaseUrl).toHaveBeenCalled(); + }); + + test('activates an embedded relay without relying on Electron preload IPC', () => { + const relay = { + relayUrl: 'wss://relay.example.com', + serverId: 'server-1', + hostEncPubJwk: { kty: 'EC', crv: 'P-256', x: 'public-x', y: 'public-y' }, + }; + const bootstrap = { + apiBaseUrl: 'openchamber-ui://app', + clientToken: 'client-token', + localOrigin: 'http://127.0.0.1:3000', + relayHostId: 'host-1', + relay, + }; + + createConfiguredWebAPIs(bootstrap); + + expect(switchRuntimeEndpoint).toHaveBeenCalledWith({ + apiBaseUrl: bootstrap.apiBaseUrl, + clientToken: bootstrap.clientToken, + requestHeaders: null, + runtimeKey: 'host:host-1', + relay, + }); + expect(restoreDesktopRelayRuntime).not.toHaveBeenCalled(); + expect(opencodeClient.reconnectToRuntimeBaseUrl).toHaveBeenCalled(); + }); +}); diff --git a/packages/web/src/runtimeConfig.ts b/packages/web/src/runtimeConfig.ts index 5b687ccb..ba34fc9d 100644 --- a/packages/web/src/runtimeConfig.ts +++ b/packages/web/src/runtimeConfig.ts @@ -1,8 +1,10 @@ import { getRuntimeExtraHeadersSync, refreshLocalRuntimeUrlAuthToken, refreshRuntimeUrlAuthToken, setRuntimeBearerToken, setRuntimeExtraHeaders } from '@openchamber/ui/lib/runtime-auth'; import { installRuntimeFetchBridge } from '@openchamber/ui/lib/runtime-fetch'; -import { initializeRuntimeEndpoint } from '@openchamber/ui/lib/runtime-switch'; +import { initializeRuntimeEndpoint, switchRuntimeEndpoint } from '@openchamber/ui/lib/runtime-switch'; import { restoreDesktopRelayRuntime } from '@openchamber/ui/lib/desktopRelayRestore'; import { configureRuntimeUrlResolver } from '@openchamber/ui/lib/runtime-url'; +import type { EmbeddedSessionRuntimeBootstrap } from '@openchamber/ui/components/layout/contextPanelEmbeddedChat'; +import { opencodeClient } from '@openchamber/ui/lib/opencode/client'; import { createWebAPIs } from './api'; const sameOrigin = (left: string, right: string): boolean => { @@ -20,24 +22,29 @@ declare global { __OPENCHAMBER_CLIENT_TOKEN__?: string; __OPENCHAMBER_RUNTIME_HEADERS__?: Record; __OPENCHAMBER_LOCAL_ORIGIN__?: string; + __OPENCHAMBER_RELAY_HOST_ID__?: string; } } +export const readRuntimeBootstrapConfig = (): EmbeddedSessionRuntimeBootstrap => { + const readString = (value: unknown): string => typeof value === 'string' ? value.trim() : ''; + + return { + apiBaseUrl: readString(window.__OPENCHAMBER_API_BASE_URL__), + clientToken: readString(window.__OPENCHAMBER_CLIENT_TOKEN__), + localOrigin: readString(window.__OPENCHAMBER_LOCAL_ORIGIN__), + runtimeHeaders: window.__OPENCHAMBER_RUNTIME_HEADERS__, + relayHostId: readString(window.__OPENCHAMBER_RELAY_HOST_ID__), + }; +}; + // Resolved once the desktop relay-host restore (if any) has picked a transport. // Immediately-resolved everywhere else. See createConfiguredWebAPIs. let desktopRelayRestoreReady: Promise = Promise.resolve(); export const getDesktopRelayRestoreReady = (): Promise => desktopRelayRestoreReady; -export const createConfiguredWebAPIs = () => { - const apiBaseUrl = typeof window.__OPENCHAMBER_API_BASE_URL__ === 'string' - ? window.__OPENCHAMBER_API_BASE_URL__.trim() - : ''; - const clientToken = typeof window.__OPENCHAMBER_CLIENT_TOKEN__ === 'string' - ? window.__OPENCHAMBER_CLIENT_TOKEN__.trim() - : ''; - const localOrigin = typeof window.__OPENCHAMBER_LOCAL_ORIGIN__ === 'string' - ? window.__OPENCHAMBER_LOCAL_ORIGIN__.trim() - : ''; +export const createConfiguredWebAPIs = (bootstrap?: EmbeddedSessionRuntimeBootstrap | null) => { + const { apiBaseUrl, clientToken, localOrigin, runtimeHeaders, relayHostId, relay } = bootstrap ?? readRuntimeBootstrapConfig(); const urls = configureRuntimeUrlResolver({ apiBaseUrl: apiBaseUrl || undefined, @@ -48,7 +55,19 @@ export const createConfiguredWebAPIs = () => { runtimeKey: sameOrigin(apiBaseUrl, localOrigin) ? 'local' : null, }); setRuntimeBearerToken(clientToken || null); - setRuntimeExtraHeaders(window.__OPENCHAMBER_RUNTIME_HEADERS__ || null); + setRuntimeExtraHeaders(runtimeHeaders || null); + if (relay) { + switchRuntimeEndpoint({ + apiBaseUrl, + clientToken: clientToken || null, + requestHeaders: runtimeHeaders || null, + runtimeKey: relayHostId ? `host:${relayHostId}` : null, + relay, + }); + } + // createWebAPIs imports UI stores, which instantiate the SDK singleton before + // an embedded frame's asynchronous parent bootstrap is available. + opencodeClient.reconnectToRuntimeBaseUrl(); void refreshRuntimeUrlAuthToken(apiBaseUrl || undefined).catch(() => {}); if (localOrigin && !sameOrigin(apiBaseUrl, localOrigin) && Object.keys(getRuntimeExtraHeadersSync()).length > 0) { void refreshLocalRuntimeUrlAuthToken(localOrigin).catch(() => {}); @@ -60,11 +79,16 @@ export const createConfiguredWebAPIs = () => { // relay host is involved. main.tsx holds the app render on this promise so // the user sees the splash instead of a transient auth screen against an // endpoint that is still being selected. - const relayHostId = (window as typeof window & { __OPENCHAMBER_RELAY_HOST_ID__?: string }).__OPENCHAMBER_RELAY_HOST_ID__; - desktopRelayRestoreReady = Promise.race([ - restoreDesktopRelayRuntime(typeof relayHostId === 'string' && relayHostId ? relayHostId : undefined).catch(() => {}), - // Never hold the app hostage: a stuck probe/tunnel gives up to the UI. - new Promise((resolve) => { window.setTimeout(resolve, 10_000); }), - ]); + desktopRelayRestoreReady = relay + ? Promise.resolve() + : Promise.race([ + restoreDesktopRelayRuntime(relayHostId || undefined).catch(() => {}), + // Never hold the app hostage: a stuck probe/tunnel gives up to the UI. + new Promise((resolve) => { window.setTimeout(resolve, 10_000); }), + ]).then(() => { + // Relay-capable windows may select a reachable direct leg before React + // subscribes to runtime-change events, so bind the SDK explicitly. + opencodeClient.reconnectToRuntimeBaseUrl(); + }); return createWebAPIs({ urls }); };