diff --git a/packages/electron/main.mjs b/packages/electron/main.mjs index 27312ef4..2a07debd 100644 --- a/packages/electron/main.mjs +++ b/packages/electron/main.mjs @@ -13,6 +13,7 @@ import updaterPkg from 'electron-updater'; import { ElectronSshManager } from './ssh-manager.mjs'; import { createTrayController } from './tray.mjs'; import { resolveManagedOpenCodeCwd } from './opencode-cwd.mjs'; +import { sanitizeRuntimeRequestHeaders } from './runtime-request-headers.mjs'; import { mintOutsideFileGrant } from '@openchamber/web/server/lib/fs/routes.js'; const execFileAsync = promisify(execFile); @@ -170,6 +171,7 @@ const state = { localOrigin: null, apiBaseUrl: null, clientToken: null, + requestHeaders: {}, bootOutcome: null, initScript: null, mainWindow: null, @@ -495,10 +497,11 @@ const shouldUseSameOriginDevProxy = (uiUrl, apiBaseUrl) => ( const buildRendererRuntimeConfig = (uiUrl, runtimeConfig = {}) => { const apiBaseUrl = typeof runtimeConfig.apiBaseUrl === 'string' ? runtimeConfig.apiBaseUrl : (state.apiBaseUrl || ''); const clientToken = typeof runtimeConfig.clientToken === 'string' ? runtimeConfig.clientToken : (state.clientToken || ''); + const requestHeaders = sanitizeRuntimeRequestHeaders(runtimeConfig.requestHeaders || state.requestHeaders || {}); if (shouldUseSameOriginDevProxy(uiUrl, apiBaseUrl)) { - return { apiBaseUrl: '', clientToken: '' }; + return { apiBaseUrl: '', clientToken: '', requestHeaders: {} }; } - return { apiBaseUrl, clientToken }; + return { apiBaseUrl, clientToken, requestHeaders }; }; const readDesktopLocalClientToken = () => { @@ -520,8 +523,9 @@ const readDesktopHostsConfig = () => { if (!id || id === LOCAL_HOST_ID || !url) return null; const apiUrl = sanitizeHostUrlForStorage(entry?.apiUrl) || url; const clientToken = sanitizeClientTokenForStorage(entry?.clientToken); + const requestHeaders = sanitizeRuntimeRequestHeaders(entry?.requestHeaders); const label = typeof entry?.label === 'string' && entry.label.trim() ? entry.label.trim() : url; - return { id, label, url, apiUrl, ...(clientToken ? { clientToken } : {}) }; + return { id, label, url, apiUrl, ...(clientToken ? { clientToken } : {}), ...(Object.keys(requestHeaders).length > 0 ? { requestHeaders } : {}) }; }) .filter(Boolean); @@ -544,12 +548,14 @@ const writeDesktopHostsConfig = async (config) => { if (!id || id === LOCAL_HOST_ID || !url) return null; const apiUrl = sanitizeHostUrlForStorage(entry?.apiUrl) || url; const clientToken = sanitizeClientTokenForStorage(entry?.clientToken); + const requestHeaders = sanitizeRuntimeRequestHeaders(entry?.requestHeaders); return { id, label: typeof entry?.label === 'string' && entry.label.trim() ? entry.label.trim() : url, url, apiUrl, ...(clientToken ? { clientToken } : {}), + ...(Object.keys(requestHeaders).length > 0 ? { requestHeaders } : {}), }; }) .filter(Boolean) @@ -704,7 +710,7 @@ const fetchVersionPayload = async (versionUrl, { headers, timeoutMs }) => { } }; -const probeHostWithTimeout = async (url, timeoutMs, clientToken = '') => { +const probeHostWithTimeout = async (url, timeoutMs, clientToken = '', requestHeaders = {}) => { const versionUrl = buildVersionUrl(url); if (!versionUrl) { throw new Error('Invalid URL'); @@ -712,7 +718,7 @@ const probeHostWithTimeout = async (url, timeoutMs, clientToken = '') => { const started = Date.now(); try { - const headers = { Accept: 'application/json' }; + const headers = { ...sanitizeRuntimeRequestHeaders(requestHeaders), Accept: 'application/json' }; const token = typeof clientToken === 'string' ? clientToken.trim() : ''; if (token) { headers.Authorization = `Bearer ${token}`; @@ -1317,17 +1323,18 @@ const macosMajorVersion = () => { return major === 10 ? minor : major; }; -const buildInitScript = (localOrigin, bootOutcome, apiBaseUrl = '', clientToken = '') => { +const buildInitScript = (localOrigin, bootOutcome, apiBaseUrl = '', clientToken = '', requestHeaders = {}) => { const home = JSON.stringify(os.homedir() || ''); const local = JSON.stringify(localOrigin || ''); const apiBase = JSON.stringify(apiBaseUrl || ''); const token = JSON.stringify(clientToken || ''); + const headers = JSON.stringify(sanitizeRuntimeRequestHeaders(requestHeaders)); const packagedOrigin = JSON.stringify(packagedUiOrigin()); const macVersion = macosMajorVersion(); const outcome = JSON.stringify(bootOutcome ?? null); return [ '(function(){', - `try{var __oc_local=${local};var __oc_api=${apiBase};var __oc_packaged=${packagedOrigin};var __oc_origin=window.location&&window.location.origin||'';var __oc_is_packaged=__oc_origin===__oc_packaged;var __oc_is_local=__oc_local&&__oc_origin===new URL(__oc_local).origin;window.__OPENCHAMBER_MACOS_MAJOR__=${macVersion};window.__OPENCHAMBER_LOCAL_ORIGIN__=__oc_local;window.__OPENCHAMBER_API_BASE_URL__=__oc_api;if(__oc_is_local||__oc_is_packaged){window.__OPENCHAMBER_HOME__=${home};}if((__oc_is_local||__oc_is_packaged)&&${token}){window.__OPENCHAMBER_CLIENT_TOKEN__=${token};}var __oc_bo=${outcome};if(__oc_bo){window.__OPENCHAMBER_DESKTOP_BOOT_OUTCOME__=__oc_bo;}}catch(_e){}`, + `try{var __oc_local=${local};var __oc_api=${apiBase};var __oc_headers=${headers};var __oc_packaged=${packagedOrigin};var __oc_origin=window.location&&window.location.origin||'';var __oc_is_packaged=__oc_origin===__oc_packaged;var __oc_is_local=__oc_local&&__oc_origin===new URL(__oc_local).origin;window.__OPENCHAMBER_MACOS_MAJOR__=${macVersion};window.__OPENCHAMBER_LOCAL_ORIGIN__=__oc_local;window.__OPENCHAMBER_API_BASE_URL__=__oc_api;if(__oc_is_local||__oc_is_packaged){window.__OPENCHAMBER_HOME__=${home};window.__OPENCHAMBER_RUNTIME_HEADERS__=__oc_headers;}if((__oc_is_local||__oc_is_packaged)&&${token}){window.__OPENCHAMBER_CLIENT_TOKEN__=${token};}var __oc_bo=${outcome};if(__oc_bo){window.__OPENCHAMBER_DESKTOP_BOOT_OUTCOME__=__oc_bo;}}catch(_e){}`, '}())', ].join(''); }; @@ -1694,10 +1701,12 @@ const switchToHostById = async (rawId) => { let targetUrl = null; let apiBaseUrl = null; let clientToken = ''; + let requestHeaders = {}; if (id === LOCAL_HOST_ID) { targetUrl = shouldUsePackagedUi() ? buildPackagedUiUrl('/index.html') : (state.sidecarUrl || state.localOrigin); apiBaseUrl = state.sidecarUrl; clientToken = readDesktopLocalClientToken(); + requestHeaders = {}; } else { const host = config.hosts.find((entry) => entry.id === id); if (!host) { @@ -1707,6 +1716,7 @@ const switchToHostById = async (rawId) => { targetUrl = shouldUsePackagedUi() ? buildPackagedUiUrl('/index.html') : host.url; apiBaseUrl = host.apiUrl || host.url; clientToken = host.clientToken || ''; + requestHeaders = sanitizeRuntimeRequestHeaders(host.requestHeaders || {}); } if (!targetUrl || !apiBaseUrl) { log.warn('[electron] deep-link host has no target URL:', id); @@ -1716,7 +1726,7 @@ const switchToHostById = async (rawId) => { ? { target: 'local', status: 'ok' } : { target: 'remote', status: 'ok', hostId: id, url: apiBaseUrl }; log.info('[electron] switching to host', { id, bootOutcome }); - await activateMainWindow(targetUrl, state.localOrigin, bootOutcome, { apiBaseUrl, clientToken }); + await activateMainWindow(targetUrl, state.localOrigin, bootOutcome, { apiBaseUrl, clientToken, requestHeaders }); }; const confirmConnectDeepLink = async (payload) => { @@ -1913,6 +1923,7 @@ const createBrowserWindow = ({ label, restoreGeometry, url, runtimeConfig = {} } const rendererRuntimeConfig = buildRendererRuntimeConfig(url, runtimeConfig); const desktopApiBaseUrl = rendererRuntimeConfig.apiBaseUrl; const desktopClientToken = rendererRuntimeConfig.clientToken; + const desktopRequestHeaders = rendererRuntimeConfig.requestHeaders || {}; const desktopHome = os.homedir() || ''; const desktopMacosMajor = String(macosMajorVersion()); const usesCustomTitleBar = process.platform === 'darwin' || process.platform === 'win32'; @@ -1949,6 +1960,7 @@ const createBrowserWindow = ({ label, restoreGeometry, url, runtimeConfig = {} } `--openchamber-local-origin=${desktopLocalOrigin}`, `--openchamber-api-base-url=${desktopApiBaseUrl}`, `--openchamber-client-token=${desktopClientToken}`, + `--openchamber-runtime-headers=${JSON.stringify(desktopRequestHeaders)}`, `--openchamber-home=${desktopHome}`, `--openchamber-macos-major=${desktopMacosMajor}`, `--openchamber-mac-vibrancy=${useVibrancy ? '1' : '0'}`, @@ -1969,8 +1981,8 @@ const createBrowserWindow = ({ label, restoreGeometry, url, runtimeConfig = {} } const browserWindow = new BrowserWindow(options); browserWindow.__ocLabel = label || nextWindowLabel(); - browserWindow.__ocRuntimeConfig = { apiBaseUrl: desktopApiBaseUrl, clientToken: desktopClientToken }; - browserWindow.__ocInitScript = buildInitScript(desktopLocalOrigin, state.bootOutcome, desktopApiBaseUrl, desktopClientToken); + browserWindow.__ocRuntimeConfig = { apiBaseUrl: desktopApiBaseUrl, clientToken: desktopClientToken, requestHeaders: desktopRequestHeaders }; + browserWindow.__ocInitScript = buildInitScript(desktopLocalOrigin, state.bootOutcome, desktopApiBaseUrl, desktopClientToken, desktopRequestHeaders); browserWindow.__ocTitleBarOverlayEnabled = titleBarOverlayEnabled; if (useSaved && saved.maximized) { @@ -2156,16 +2168,19 @@ const activateMainWindow = async (url, localOrigin, bootOutcome, runtimeConfig = state.localOrigin = localOrigin; state.apiBaseUrl = typeof runtimeConfig.apiBaseUrl === 'string' ? runtimeConfig.apiBaseUrl : state.apiBaseUrl; state.clientToken = typeof runtimeConfig.clientToken === 'string' ? runtimeConfig.clientToken : ''; + state.requestHeaders = sanitizeRuntimeRequestHeaders(runtimeConfig.requestHeaders || {}); state.bootOutcome = bootOutcome ?? null; const rendererRuntimeConfig = buildRendererRuntimeConfig(url, { apiBaseUrl: state.apiBaseUrl || '', clientToken: state.clientToken || '', + requestHeaders: state.requestHeaders || {}, }); state.initScript = buildInitScript( localOrigin, state.bootOutcome, rendererRuntimeConfig.apiBaseUrl, rendererRuntimeConfig.clientToken, + rendererRuntimeConfig.requestHeaders, ); const mainWindow = state.mainWindow; @@ -2189,8 +2204,8 @@ const activateMainWindow = async (url, localOrigin, bootOutcome, runtimeConfig = const openMainWindow = async () => { if (!state.localOrigin) { - const { initialUrl, localOrigin, bootOutcome, apiBaseUrl, clientToken } = await resolveInitialUrl(); - return activateMainWindow(initialUrl, localOrigin, bootOutcome, { apiBaseUrl, clientToken }); + const { initialUrl, localOrigin, bootOutcome, apiBaseUrl, clientToken, requestHeaders } = await resolveInitialUrl(); + return activateMainWindow(initialUrl, localOrigin, bootOutcome, { apiBaseUrl, clientToken, requestHeaders }); } const config = readDesktopHostsConfig(); @@ -2200,10 +2215,11 @@ const openMainWindow = async () => { : null; const apiBaseUrl = host?.apiUrl || host?.url || state.sidecarUrl || state.apiBaseUrl || ''; const clientToken = host?.clientToken || resolveStoredClientTokenForUrl(apiBaseUrl, config) || state.clientToken || ''; + const requestHeaders = sanitizeRuntimeRequestHeaders(host?.requestHeaders || {}); const targetUrl = host?.url && apiBaseUrl && !state.unreachableHosts.has(apiBaseUrl) ? (shouldUsePackagedUi() ? buildPackagedUiUrl('/index.html') : host.url) : localUiUrl; - return activateMainWindow(targetUrl, state.localOrigin, state.bootOutcome, { apiBaseUrl, clientToken }); + return activateMainWindow(targetUrl, state.localOrigin, state.bootOutcome, { apiBaseUrl, clientToken, requestHeaders }); }; const createAdditionalWindow = async (url, runtimeConfig = {}) => { @@ -2242,12 +2258,14 @@ const getWindowRuntimeConfig = (browserWindow) => { const fallback = { apiBaseUrl: state.apiBaseUrl || state.localOrigin || state.sidecarUrl || '', clientToken: state.clientToken || '', + requestHeaders: state.requestHeaders || {}, }; if (!browserWindow || browserWindow.isDestroyed()) return fallback; const config = browserWindow.__ocRuntimeConfig; return { apiBaseUrl: typeof config?.apiBaseUrl === 'string' ? config.apiBaseUrl : fallback.apiBaseUrl, clientToken: typeof config?.clientToken === 'string' ? config.clientToken : fallback.clientToken, + requestHeaders: sanitizeRuntimeRequestHeaders(config?.requestHeaders || fallback.requestHeaders), }; }; @@ -2255,6 +2273,7 @@ const createMiniChatWindow = async ({ mode, sessionId = '', directory = '', proj const effectiveRuntimeConfig = { apiBaseUrl: normalizeHostUrl(runtimeConfig.apiBaseUrl || state.apiBaseUrl || state.localOrigin || state.sidecarUrl || ''), clientToken: sanitizeClientTokenForStorage(runtimeConfig.clientToken || state.clientToken || ''), + requestHeaders: sanitizeRuntimeRequestHeaders(runtimeConfig.requestHeaders || state.requestHeaders || {}), }; const sessionWindowKey = mode === 'session' && sessionId ? miniChatSessionWindowKey(effectiveRuntimeConfig, sessionId) : ''; if (mode === 'session' && sessionId) { @@ -2271,6 +2290,7 @@ const createMiniChatWindow = async ({ mode, sessionId = '', directory = '', proj const desktopLocalOrigin = state.localOrigin || ''; const desktopApiBaseUrl = effectiveRuntimeConfig.apiBaseUrl || ''; const desktopClientToken = effectiveRuntimeConfig.clientToken || ''; + const desktopRequestHeaders = effectiveRuntimeConfig.requestHeaders || {}; const desktopHome = os.homedir() || ''; const desktopMacosMajor = String(macosMajorVersion()); // macOS vibrancy, on by default; users can disable it (Appearance settings). @@ -2297,6 +2317,7 @@ const createMiniChatWindow = async ({ mode, sessionId = '', directory = '', proj `--openchamber-local-origin=${desktopLocalOrigin}`, `--openchamber-api-base-url=${desktopApiBaseUrl}`, `--openchamber-client-token=${desktopClientToken}`, + `--openchamber-runtime-headers=${JSON.stringify(desktopRequestHeaders)}`, `--openchamber-home=${desktopHome}`, `--openchamber-macos-major=${desktopMacosMajor}`, ], @@ -2311,7 +2332,7 @@ const createMiniChatWindow = async ({ mode, sessionId = '', directory = '', proj }); browserWindow.__ocLabel = nextWindowLabel(); browserWindow.__ocRuntimeConfig = effectiveRuntimeConfig; - browserWindow.__ocInitScript = buildInitScript(desktopLocalOrigin, state.bootOutcome, desktopApiBaseUrl, desktopClientToken); + browserWindow.__ocInitScript = buildInitScript(desktopLocalOrigin, state.bootOutcome, desktopApiBaseUrl, desktopClientToken, desktopRequestHeaders); browserWindow.__ocMiniChat = true; browserWindow.__ocMiniChatSessionId = sessionWindowKey; browserWindow.__ocPinned = false; @@ -2402,9 +2423,11 @@ const resolveMiniChatRuntimeConfig = (browserWindow, args = {}) => { const providedToken = sanitizeClientTokenForStorage(args.clientToken); const storedToken = targetUrl ? resolveStoredClientTokenForUrl(targetUrl) : ''; const windowToken = targetUrl && sameOrigin(windowConfig.apiBaseUrl, targetUrl) ? windowConfig.clientToken : ''; + const windowHeaders = targetUrl && sameOrigin(windowConfig.apiBaseUrl, targetUrl) ? windowConfig.requestHeaders : {}; return { apiBaseUrl: targetUrl, clientToken: providedToken || windowToken || storedToken || '', + requestHeaders: sanitizeRuntimeRequestHeaders(args.requestHeaders || windowHeaders || {}), }; }; @@ -2430,6 +2453,7 @@ const resolveInitialUrl = async () => { let initialUrl = localUiUrl; let apiBaseUrl = localUrl; let clientToken = readDesktopLocalClientToken(); + let requestHeaders = {}; let remoteProbe = null; const envTarget = normalizeHostUrl(process.env.OPENCHAMBER_SERVER_URL || ''); @@ -2437,25 +2461,28 @@ const resolveInitialUrl = async () => { if (envTarget) { apiBaseUrl = envTarget; clientToken = ''; + requestHeaders = {}; initialUrl = shouldUsePackagedUi() ? localUiUrl : envTarget; } else if (config.defaultHostId && config.defaultHostId !== LOCAL_HOST_ID) { const host = config.hosts.find((entry) => entry.id === config.defaultHostId); if (host?.url) { apiBaseUrl = host.apiUrl || host.url; clientToken = host.clientToken || ''; + requestHeaders = sanitizeRuntimeRequestHeaders(host.requestHeaders || {}); initialUrl = shouldUsePackagedUi() ? localUiUrl : host.url; } } if (apiBaseUrl && apiBaseUrl !== localUrl) { - remoteProbe = await probeHostWithTimeout(apiBaseUrl, 2_000); + remoteProbe = await probeHostWithTimeout(apiBaseUrl, 2_000, clientToken, requestHeaders); if (remoteProbe.status === 'unreachable') { - remoteProbe = await probeHostWithTimeout(apiBaseUrl, 10_000); + remoteProbe = await probeHostWithTimeout(apiBaseUrl, 10_000, clientToken, requestHeaders); } if (remoteProbe.status === 'unreachable') { state.unreachableHosts.add(apiBaseUrl); apiBaseUrl = localUrl; clientToken = readDesktopLocalClientToken(); + requestHeaders = {}; initialUrl = localUiUrl; } } @@ -2467,7 +2494,7 @@ const resolveInitialUrl = async () => { localAvailable, }); - return { initialUrl, localOrigin, localUiUrl, bootOutcome, apiBaseUrl, clientToken }; + return { initialUrl, localOrigin, localUiUrl, bootOutcome, apiBaseUrl, clientToken, requestHeaders }; }; const compareSemver = (left, right) => { @@ -3459,7 +3486,7 @@ const handleInvoke = async (browserWindow, command, args = {}) => { config: updatedConfig, localAvailable: Boolean(state.sidecarUrl || state.localOrigin), }); - state.initScript = buildInitScript(state.localOrigin, state.bootOutcome, state.apiBaseUrl, state.clientToken); + state.initScript = buildInitScript(state.localOrigin, state.bootOutcome, state.apiBaseUrl, state.clientToken, state.requestHeaders || {}); log.info('[electron] hosts config updated, recomputed bootOutcome', state.bootOutcome); return null; } @@ -3468,7 +3495,7 @@ const handleInvoke = async (browserWindow, command, args = {}) => { return readDesktopLocalClientToken(); case 'desktop_host_probe': - return probeHostWithTimeout(String(args.url || ''), 2_000, String(args.clientToken || '')); + return probeHostWithTimeout(String(args.url || ''), 2_000, String(args.clientToken || ''), args.requestHeaders || {}); case 'desktop_remote_password_login': return loginRemoteAndIssueClientToken({ @@ -3658,6 +3685,7 @@ const handleInvoke = async (browserWindow, command, args = {}) => { let runtimeConfig = { apiBaseUrl: state.sidecarUrl || state.localOrigin || '', clientToken: readDesktopLocalClientToken(), + requestHeaders: {}, }; if (config.defaultHostId && config.defaultHostId !== LOCAL_HOST_ID) { const host = config.hosts.find((entry) => entry.id === config.defaultHostId); @@ -3667,6 +3695,7 @@ const handleInvoke = async (browserWindow, command, args = {}) => { runtimeConfig = { apiBaseUrl: normalizeHostUrl(apiUrl), clientToken: sanitizeClientTokenForStorage(host.clientToken), + requestHeaders: sanitizeRuntimeRequestHeaders(host.requestHeaders), }; } } @@ -3682,8 +3711,9 @@ const handleInvoke = async (browserWindow, command, args = {}) => { const config = readDesktopHostsConfig(); const providedToken = typeof args.clientToken === 'string' ? args.clientToken : ''; const clientToken = sanitizeClientTokenForStorage(providedToken) || resolveStoredClientTokenForUrl(targetUrl, config); + const requestHeaders = sanitizeRuntimeRequestHeaders(args.requestHeaders || config.hosts.find((host) => normalizeHostUrl(host.apiUrl || host.url) === targetUrl)?.requestHeaders || {}); let windowUrl = targetUrl; - const runtimeConfig = { apiBaseUrl: targetUrl, clientToken }; + const runtimeConfig = { apiBaseUrl: targetUrl, clientToken, requestHeaders }; if (shouldUsePackagedUi()) { windowUrl = buildPackagedUiUrl('/index.html'); } @@ -4468,10 +4498,11 @@ app.whenReady().then(async () => { } if (isBackgroundStart) { - const { localOrigin, bootOutcome } = await resolveInitialUrl(); + const { localOrigin, bootOutcome, requestHeaders } = await resolveInitialUrl(); state.localOrigin = localOrigin; state.bootOutcome = bootOutcome ?? null; - state.initScript = buildInitScript(localOrigin, state.bootOutcome); + state.requestHeaders = sanitizeRuntimeRequestHeaders(requestHeaders || {}); + state.initScript = buildInitScript(localOrigin, state.bootOutcome, '', '', state.requestHeaders); log.info('[electron] started in background without window'); return; } @@ -4485,8 +4516,8 @@ app.whenReady().then(async () => { const initial = extractInitialDeepLinks(); if (initial.length > 0) handleDeepLinks(initial); - const { initialUrl, localOrigin, bootOutcome, apiBaseUrl, clientToken } = await resolveInitialUrl(); - await activateMainWindow(initialUrl, localOrigin, bootOutcome, { apiBaseUrl, clientToken }); + const { initialUrl, localOrigin, bootOutcome, apiBaseUrl, clientToken, requestHeaders } = await resolveInitialUrl(); + await activateMainWindow(initialUrl, localOrigin, bootOutcome, { apiBaseUrl, clientToken, requestHeaders }); // Notify renderer on OS wake-from-sleep so the SSE event pipeline can // reconnect immediately instead of waiting for the heartbeat watchdog. diff --git a/packages/electron/preload.mjs b/packages/electron/preload.mjs index bddbe344..6f7c7923 100644 --- a/packages/electron/preload.mjs +++ b/packages/electron/preload.mjs @@ -14,6 +14,7 @@ const readArgValue = (name) => { const localOrigin = readArgValue('--openchamber-local-origin'); const apiBaseUrl = readArgValue('--openchamber-api-base-url'); const clientToken = readArgValue('--openchamber-client-token'); +const runtimeHeadersRaw = readArgValue('--openchamber-runtime-headers'); const homeDirectory = readArgValue('--openchamber-home'); const macosMajorRaw = readArgValue('--openchamber-macos-major'); const macosMajor = Number.parseInt(macosMajorRaw, 10); @@ -61,6 +62,16 @@ if (clientToken && isLocalPage) { contextBridge.exposeInMainWorld('__OPENCHAMBER_CLIENT_TOKEN__', clientToken); } +if (runtimeHeadersRaw && isLocalPage) { + try { + const runtimeHeaders = JSON.parse(runtimeHeadersRaw); + if (runtimeHeaders && typeof runtimeHeaders === 'object') { + contextBridge.exposeInMainWorld('__OPENCHAMBER_RUNTIME_HEADERS__', runtimeHeaders); + } + } catch { + } +} + // Home directory leaks the OS username — keep local-only. Remote pages // operate on the REMOTE server's filesystem, local home is irrelevant // (and would be misleading if consumed as a workspace hint). diff --git a/packages/electron/runtime-request-headers.mjs b/packages/electron/runtime-request-headers.mjs new file mode 100644 index 00000000..4fb143bb --- /dev/null +++ b/packages/electron/runtime-request-headers.mjs @@ -0,0 +1,16 @@ +const isReservedRuntimeRequestHeaderName = (name) => { + return String(name || '').trim().toLowerCase() === 'authorization'; +}; + +export const sanitizeRuntimeRequestHeaders = (headers) => { + if (!headers || typeof headers !== 'object') return {}; + const next = {}; + for (const [rawName, rawValue] of Object.entries(headers)) { + const name = typeof rawName === 'string' ? rawName.trim() : ''; + const value = typeof rawValue === 'string' ? rawValue.trim() : ''; + if (!name || !value || /[\r\n:]/.test(name) || /[\r\n]/.test(value)) continue; + if (isReservedRuntimeRequestHeaderName(name)) continue; + next[name] = value; + } + return next; +}; diff --git a/packages/electron/runtime-request-headers.test.mjs b/packages/electron/runtime-request-headers.test.mjs new file mode 100644 index 00000000..9945a6b4 --- /dev/null +++ b/packages/electron/runtime-request-headers.test.mjs @@ -0,0 +1,26 @@ +import { describe, expect, test } from 'bun:test'; +import { sanitizeRuntimeRequestHeaders } from './runtime-request-headers.mjs'; + +describe('sanitizeRuntimeRequestHeaders', () => { + test('preserves safe custom headers', () => { + expect(sanitizeRuntimeRequestHeaders({ + ' CF-Access-Client-Id ': ' client-id ', + 'X-Custom-Header': 'value', + })).toEqual({ + 'CF-Access-Client-Id': 'client-id', + 'X-Custom-Header': 'value', + }); + }); + + test('drops invalid and reserved headers', () => { + expect(sanitizeRuntimeRequestHeaders({ + Authorization: 'Bearer proxy-token', + 'authorization': 'Bearer lower-token', + 'Bad:Name': 'value', + 'Bad\nName': 'value', + 'Bad-Value': 'line\nbreak', + Empty: '', + Good: 'ok', + })).toEqual({ Good: 'ok' }); + }); +}); diff --git a/packages/ui/src/components/desktop/DesktopHostSwitcher.tsx b/packages/ui/src/components/desktop/DesktopHostSwitcher.tsx index 4ba3ccad..269d4b90 100644 --- a/packages/ui/src/components/desktop/DesktopHostSwitcher.tsx +++ b/packages/ui/src/components/desktop/DesktopHostSwitcher.tsx @@ -424,7 +424,7 @@ export function DesktopHostSwitcherDialog({ 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 }).catch((): HostProbeResult => ({ status: 'unreachable', latencyMs: 0 })); + const res = await desktopHostProbe(url, { clientToken: clientToken || null, requestHeaders: h.requestHeaders || null }).catch((): HostProbeResult => ({ status: 'unreachable', latencyMs: 0 })); return [h.id, { status: res.status, latencyMs: res.latencyMs } satisfies HostStatus] as const; }) ); @@ -492,7 +492,7 @@ export function DesktopHostSwitcherDialog({ if (!apiOrigin) return; setSwitchingHostId(host.id); const clientToken = host.id === LOCAL_HOST_ID ? await getLocalClientToken() : (host.clientToken || ''); - const probe = await desktopHostProbe(apiOrigin, { clientToken: clientToken || null }).catch((): HostProbeResult => ({ status: 'unreachable', latencyMs: 0 })); + const probe = await desktopHostProbe(apiOrigin, { clientToken: clientToken || null, requestHeaders: host.requestHeaders || null }).catch((): HostProbeResult => ({ status: 'unreachable', latencyMs: 0 })); setStatusById((prev) => ({ ...prev, [host.id]: { status: probe.status, latencyMs: probe.latencyMs }, @@ -504,7 +504,7 @@ export function DesktopHostSwitcherDialog({ return; } - switchRuntimeEndpoint({ apiBaseUrl: apiOrigin, clientToken: clientToken || null, runtimeKey: runtimeKeyForHost(host) }); + switchRuntimeEndpoint({ apiBaseUrl: apiOrigin, clientToken: clientToken || null, requestHeaders: host.requestHeaders || null, runtimeKey: runtimeKeyForHost(host) }); onHostSwitched?.(); setSwitchingHostId(null); return; @@ -598,7 +598,7 @@ export function DesktopHostSwitcherDialog({ if (host.id !== LOCAL_HOST_ID && isDesktopShell()) { setSwitchingHostId(host.id); - const probe = await desktopHostProbe(origin, { clientToken: host.clientToken || null }).catch((): HostProbeResult => ({ status: 'unreachable', latencyMs: 0 })); + const probe = await desktopHostProbe(origin, { clientToken: host.clientToken || null, requestHeaders: host.requestHeaders || null }).catch((): HostProbeResult => ({ status: 'unreachable', latencyMs: 0 })); setStatusById((prev) => ({ ...prev, [host.id]: { status: probe.status, latencyMs: probe.latencyMs }, @@ -646,7 +646,7 @@ export function DesktopHostSwitcherDialog({ const url = resolved.persistedUrl; const label = (editLabel || redactSensitiveUrl(url)).trim(); - const nextHosts = configHosts.map((h) => (h.id === editingId ? { ...h, label, url } : h)); + const nextHosts = configHosts.map((h) => (h.id === editingId ? { ...h, label, url, apiUrl: url } : h)); await persist(nextHosts, defaultHostId); cancelEdit(); if (resolved.redeemUrl) { @@ -663,7 +663,7 @@ export function DesktopHostSwitcherDialog({ const origin = host.id === LOCAL_HOST_ID ? localOrigin : getDesktopHostApiUrl(host); if (!origin) return; const target = toNavigationUrl(origin); - desktopOpenNewWindowAtUrl(target, { clientToken: host.clientToken || null }).catch((err: unknown) => { + desktopOpenNewWindowAtUrl(target, { clientToken: host.clientToken || null, requestHeaders: host.requestHeaders || null }).catch((err: unknown) => { toast.error(t('desktopHostSwitcher.error.failedToOpenNewWindow'), { description: err instanceof Error ? err.message : String(err), }); diff --git a/packages/ui/src/components/sections/remote-instances/RemoteInstancesPage.tsx b/packages/ui/src/components/sections/remote-instances/RemoteInstancesPage.tsx index 638bfac3..5bbeea9a 100644 --- a/packages/ui/src/components/sections/remote-instances/RemoteInstancesPage.tsx +++ b/packages/ui/src/components/sections/remote-instances/RemoteInstancesPage.tsx @@ -199,6 +199,36 @@ const formatLogLine = (line: string): string => { return `[${iso}] [${level}] ${message}`; }; +type HeaderDraft = { + id: string; + name: string; + value: string; +}; + +const createHeaderDraft = (name = '', value = ''): HeaderDraft => ({ + id: typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function' + ? crypto.randomUUID() + : `header-${Date.now()}-${Math.random().toString(16).slice(2)}`, + name, + value, +}); + +const isReservedRequestHeaderName = (name: string): boolean => name.trim().toLowerCase() === 'authorization'; + +const buildRequestHeaders = (headers: HeaderDraft[]): Record | undefined => { + const next: Record = {}; + for (const header of headers) { + const name = header.name.trim(); + const value = header.value.trim(); + if (name && value && !isReservedRequestHeaderName(name)) next[name] = value; + } + return Object.keys(next).length > 0 ? next : undefined; +}; + +const readRequestHeaderDrafts = (headers: Record | undefined): HeaderDraft[] => { + return Object.entries(headers || {}).map(([name, value]) => createHeaderDraft(name, value)); +}; + const navigateToUrl = (rawUrl: string): void => { const target = rawUrl.trim(); if (!target) { @@ -301,6 +331,7 @@ export const RemoteInstancesPage: React.FC = () => { const [directLabel, setDirectLabel] = React.useState(''); const [directUrl, setDirectUrl] = React.useState(''); const [directToken, setDirectToken] = React.useState(''); + const [directHeaders, setDirectHeaders] = React.useState([]); const [directConnectLink, setDirectConnectLink] = React.useState(''); const [directError, setDirectError] = React.useState(null); const [directAddDialogOpen, setDirectAddDialogOpen] = React.useState(false); @@ -309,6 +340,7 @@ export const RemoteInstancesPage: React.FC = () => { const [directEditLabel, setDirectEditLabel] = React.useState(''); const [directEditUrl, setDirectEditUrl] = React.useState(''); const [directEditToken, setDirectEditToken] = React.useState(''); + const [directEditHeaders, setDirectEditHeaders] = React.useState([]); const [remoteClients, setRemoteClients] = React.useState([]); const [remoteClientsLoading, setRemoteClientsLoading] = React.useState(false); const [remoteClientLabel, setRemoteClientLabel] = React.useState(''); @@ -374,16 +406,18 @@ export const RemoteInstancesPage: React.FC = () => { url, apiUrl: url, ...(directToken.trim() ? { clientToken: directToken.trim() } : {}), + ...(buildRequestHeaders(directHeaders) ? { requestHeaders: buildRequestHeaders(directHeaders) } : {}), }; await persistDirectHosts([host, ...directHosts], directDefaultHostId); setDirectLabel(''); setDirectUrl(''); setDirectToken(''); + setDirectHeaders([]); setDirectAddDialogOpen(false); if (resolved.redeemUrl) { navigateToUrl(resolved.redeemUrl); } - }, [directDefaultHostId, directHosts, directLabel, directToken, directUrl, persistDirectHosts, t]); + }, [directDefaultHostId, directHeaders, directHosts, directLabel, directToken, directUrl, persistDirectHosts, t]); const importDirectConnectLink = React.useCallback(async () => { const payload = parseClientConnectionPayload(directConnectLink); @@ -427,6 +461,7 @@ export const RemoteInstancesPage: React.FC = () => { setDirectEditLabel(host.label); setDirectEditUrl(host.apiUrl || host.url); setDirectEditToken(host.clientToken || ''); + setDirectEditHeaders(readRequestHeaderDrafts(host.requestHeaders)); setDirectError(null); }, []); @@ -445,6 +480,7 @@ export const RemoteInstancesPage: React.FC = () => { url, apiUrl: url, clientToken: directEditToken.trim() || undefined, + requestHeaders: buildRequestHeaders(directEditHeaders), } : host); await persistDirectHosts(nextHosts, directDefaultHostId); @@ -452,7 +488,7 @@ export const RemoteInstancesPage: React.FC = () => { if (resolved.redeemUrl) { navigateToUrl(resolved.redeemUrl); } - }, [directDefaultHostId, directEditLabel, directEditToken, directEditUrl, directEditingId, directHosts, persistDirectHosts, t]); + }, [directDefaultHostId, directEditHeaders, directEditLabel, directEditToken, directEditUrl, directEditingId, directHosts, persistDirectHosts, t]); const createSshInstanceFromDialog = React.useCallback(async () => { const command = sshCommandDraft.trim(); @@ -1095,6 +1131,25 @@ export const RemoteInstancesPage: React.FC = () => { setDirectLabel(event.target.value)} placeholder={t('settings.remoteInstances.direct.field.labelPlaceholder')} disabled={directSaving} /> setDirectUrl(event.target.value)} placeholder={t('settings.remoteInstances.direct.field.urlPlaceholder')} disabled={directSaving} autoFocus /> setDirectToken(event.target.value)} placeholder={t('settings.remoteInstances.direct.field.tokenPlaceholder')} type="password" disabled={directSaving} /> +
+
+

{t('settings.remoteInstances.direct.headers.title')}

+

{t('settings.remoteInstances.direct.headers.description')}

+
+ {directHeaders.map((header) => ( +
+ setDirectHeaders((headers) => headers.map((item) => item.id === header.id ? { ...item, name: event.target.value } : item))} placeholder={t('settings.remoteInstances.direct.headers.field.namePlaceholder')} disabled={directSaving} /> + setDirectHeaders((headers) => headers.map((item) => item.id === header.id ? { ...item, value: event.target.value } : item))} placeholder={t('settings.remoteInstances.direct.headers.field.valuePlaceholder')} type="password" disabled={directSaving} /> + +
+ ))} + +
@@ -1113,6 +1168,25 @@ export const RemoteInstancesPage: React.FC = () => { setDirectEditLabel(event.target.value)} placeholder={t('settings.remoteInstances.direct.field.labelPlaceholder')} disabled={directSaving} /> setDirectEditUrl(event.target.value)} placeholder={t('settings.remoteInstances.direct.field.urlPlaceholder')} disabled={directSaving} autoFocus /> setDirectEditToken(event.target.value)} placeholder={t('settings.remoteInstances.direct.field.tokenPlaceholder')} type="password" disabled={directSaving} /> +
+
+

{t('settings.remoteInstances.direct.headers.title')}

+

{t('settings.remoteInstances.direct.headers.description')}

+
+ {directEditHeaders.map((header) => ( +
+ setDirectEditHeaders((headers) => headers.map((item) => item.id === header.id ? { ...item, name: event.target.value } : item))} placeholder={t('settings.remoteInstances.direct.headers.field.namePlaceholder')} disabled={directSaving} /> + setDirectEditHeaders((headers) => headers.map((item) => item.id === header.id ? { ...item, value: event.target.value } : item))} placeholder={t('settings.remoteInstances.direct.headers.field.valuePlaceholder')} type="password" disabled={directSaving} /> + +
+ ))} + +
diff --git a/packages/ui/src/lib/desktopHosts.test.ts b/packages/ui/src/lib/desktopHosts.test.ts index d5f4cc75..1a24853a 100644 --- a/packages/ui/src/lib/desktopHosts.test.ts +++ b/packages/ui/src/lib/desktopHosts.test.ts @@ -1,5 +1,26 @@ import { describe, expect, test } from 'bun:test'; -import { redactSensitiveUrl, resolveDesktopHostUrl } from './desktopHosts'; +import { desktopHostProbe, desktopHostsGet, desktopHostsSet, redactSensitiveUrl, resolveDesktopHostUrl } from './desktopHosts'; + +const withDesktopBridge = async (handler: (cmd: string, args: Record) => unknown | Promise, run: () => Promise): Promise => { + const previousWindow = Object.getOwnPropertyDescriptor(globalThis, 'window'); + Object.defineProperty(globalThis, 'window', { + configurable: true, + value: { + __OPENCHAMBER_DESKTOP__: { + invoke: handler, + }, + }, + }); + try { + return await run(); + } finally { + if (previousWindow) { + Object.defineProperty(globalThis, 'window', previousWindow); + } else { + Reflect.deleteProperty(globalThis, 'window'); + } + } +}; describe('resolveDesktopHostUrl', () => { test('keeps regular host URLs unchanged', () => { @@ -32,3 +53,65 @@ describe('resolveDesktopHostUrl', () => { ); }); }); + +describe('desktop host runtime headers', () => { + test('parses persisted request headers from desktop config', async () => { + await withDesktopBridge(async (cmd) => { + expect(cmd).toBe('desktop_hosts_get'); + return { + hosts: [{ + id: 'remote-1', + label: 'Remote', + url: 'https://remote.example', + requestHeaders: { + ' CF-Access-Client-Id ': ' client-id ', + Authorization: 'Bearer should-not-be-read', + 'Bad:Name': 'bad', + }, + }], + defaultHostId: 'remote-1', + initialHostChoiceCompleted: true, + }; + }, async () => { + const config = await desktopHostsGet(); + expect(config.hosts[0]?.requestHeaders).toEqual({ + 'CF-Access-Client-Id': 'client-id', + }); + }); + }); + + test('passes request headers through host save and probe IPC calls', async () => { + const calls: Array<{ cmd: string; args: Record }> = []; + await withDesktopBridge(async (cmd, args) => { + calls.push({ cmd, args }); + if (cmd === 'desktop_host_probe') return { status: 'ok', latencyMs: 7 }; + return null; + }, async () => { + const requestHeaders = { 'CF-Access-Client-Id': 'client-id' }; + await desktopHostsSet({ + hosts: [{ id: 'remote-1', label: 'Remote', url: 'https://remote.example', requestHeaders }], + defaultHostId: 'remote-1', + }); + const probe = await desktopHostProbe('https://remote.example', { requestHeaders }); + expect(probe).toEqual({ status: 'ok', latencyMs: 7 }); + }); + + expect(calls[0]).toEqual({ + cmd: 'desktop_hosts_set', + args: { + input: { + hosts: [{ id: 'remote-1', label: 'Remote', url: 'https://remote.example', requestHeaders: { 'CF-Access-Client-Id': 'client-id' } }], + defaultHostId: 'remote-1', + initialHostChoiceCompleted: undefined, + }, + }, + }); + expect(calls[1]).toEqual({ + cmd: 'desktop_host_probe', + args: { + url: 'https://remote.example', + requestHeaders: { 'CF-Access-Client-Id': 'client-id' }, + }, + }); + }); +}); diff --git a/packages/ui/src/lib/desktopHosts.ts b/packages/ui/src/lib/desktopHosts.ts index 5dc39e96..a566bddb 100644 --- a/packages/ui/src/lib/desktopHosts.ts +++ b/packages/ui/src/lib/desktopHosts.ts @@ -2,6 +2,25 @@ import { hasDesktopInvoke, invokeDesktop } from '@/lib/desktop'; type DesktopInvoke = (cmd: string, args?: Record) => Promise; +const isRecord = (value: unknown): value is Record => { + return typeof value === 'object' && value !== null; +}; + +const isReservedRequestHeaderName = (name: string): boolean => name.trim().toLowerCase() === 'authorization'; + +const sanitizeRequestHeaders = (headers: unknown): Record | undefined => { + if (!isRecord(headers)) return undefined; + const next: Record = {}; + for (const [key, value] of Object.entries(headers)) { + const name = key.trim(); + const headerValue = typeof value === 'string' ? value.trim() : ''; + if (!name || !headerValue || /[\r\n:]/.test(name) || /[\r\n]/.test(headerValue)) continue; + if (isReservedRequestHeaderName(name)) continue; + next[name] = headerValue; + } + return Object.keys(next).length > 0 ? next : undefined; +}; + export type DesktopHost = { id: string; label: string; @@ -11,6 +30,8 @@ export type DesktopHost = { apiUrl?: string; /** Remote client bearer token for packaged-client API access. */ clientToken?: string; + /** Extra headers for desktop runtime API requests. */ + requestHeaders?: Record; }; export type DesktopHostsConfig = { @@ -135,10 +156,6 @@ export const locationMatchesHost = (locationHref: string, hostUrl: string): bool } }; -const isRecord = (value: unknown): value is Record => { - return typeof value === 'object' && value !== null; -}; - const readString = (obj: Record, key: string): string | null => { const val = obj[key]; return typeof val === 'string' ? val : null; @@ -156,6 +173,7 @@ const parseHost = (value: unknown): DesktopHost | null => { const url = readString(value, 'url'); const apiUrl = readString(value, 'apiUrl') || readString(value, 'api_url'); const clientToken = readString(value, 'clientToken') || readString(value, 'client_token'); + const requestHeaders = sanitizeRequestHeaders(value.requestHeaders); if (!id || !label || !url) return null; return { id, @@ -163,6 +181,7 @@ const parseHost = (value: unknown): DesktopHost | null => { url, ...(apiUrl ? { apiUrl } : {}), ...(clientToken ? { clientToken } : {}), + ...(requestHeaders ? { requestHeaders } : {}), }; }; @@ -226,13 +245,13 @@ export const desktopLocalClientTokenGet = async (): Promise => { return typeof raw === 'string' ? raw.trim() : ''; }; -export const desktopHostProbe = async (url: string, options?: { clientToken?: string | null }): Promise => { +export const desktopHostProbe = async (url: string, options?: { clientToken?: string | null; requestHeaders?: Record | null }): Promise => { const invoke = getInvoke(); if (!invoke) { return { status: 'unreachable', latencyMs: 0 }; } - const raw = await invoke('desktop_host_probe', { url, clientToken: options?.clientToken || undefined }); + const raw = await invoke('desktop_host_probe', { url, clientToken: options?.clientToken || undefined, requestHeaders: options?.requestHeaders || undefined }); if (!isRecord(raw)) { return { status: 'unreachable', latencyMs: 0 }; } @@ -247,8 +266,8 @@ export const desktopHostProbe = async (url: string, options?: { clientToken?: st return { status, latencyMs }; }; -export const desktopOpenNewWindowAtUrl = async (url: string, options?: { clientToken?: string | null }): Promise => { +export const desktopOpenNewWindowAtUrl = async (url: string, options?: { clientToken?: string | null; requestHeaders?: Record | null }): Promise => { const invoke = getInvoke(); if (!invoke) return; - await invoke('desktop_new_window_at_url', { url, clientToken: options?.clientToken || undefined }); + await invoke('desktop_new_window_at_url', { url, clientToken: options?.clientToken || undefined, requestHeaders: options?.requestHeaders || undefined }); }; diff --git a/packages/ui/src/lib/i18n/messages/en.settings.ts b/packages/ui/src/lib/i18n/messages/en.settings.ts index 2233aa65..0ae24ab1 100644 --- a/packages/ui/src/lib/i18n/messages/en.settings.ts +++ b/packages/ui/src/lib/i18n/messages/en.settings.ts @@ -258,6 +258,12 @@ export const settingsDict = { 'settings.remoteInstances.direct.field.urlPlaceholder': 'https://host:port', 'settings.remoteInstances.direct.field.tokenPlaceholder': 'Connection token (optional for trusted local servers)', 'settings.remoteInstances.direct.note': 'Connection tokens are saved on this device and used only when this app connects to that server.', + 'settings.remoteInstances.direct.headers.title': 'Additional headers', + 'settings.remoteInstances.direct.headers.description': 'Optional HTTP headers for desktop API requests. Authorization is reserved for the connection token.', + 'settings.remoteInstances.direct.headers.field.namePlaceholder': 'Header name', + 'settings.remoteInstances.direct.headers.field.valuePlaceholder': 'Header value', + 'settings.remoteInstances.direct.headers.actions.add': 'Add header', + 'settings.remoteInstances.direct.headers.removeAria': 'Remove header', 'settings.remoteInstances.direct.actions.add': 'Add Server', 'settings.remoteInstances.direct.import.description': 'Paste a connection link from another OpenChamber server.', 'settings.remoteInstances.direct.import.placeholder': 'openchamber://connect?...', diff --git a/packages/ui/src/lib/i18n/messages/es.settings.ts b/packages/ui/src/lib/i18n/messages/es.settings.ts index 9d5bd6e4..7318c404 100644 --- a/packages/ui/src/lib/i18n/messages/es.settings.ts +++ b/packages/ui/src/lib/i18n/messages/es.settings.ts @@ -225,6 +225,12 @@ export const settingsDict = { "settings.remoteInstances.direct.field.urlPlaceholder": "https://host:port", "settings.remoteInstances.direct.field.tokenPlaceholder": "Token de conexión (opcional para servidores locales de confianza)", "settings.remoteInstances.direct.note": "Los tokens de conexión se guardan en este dispositivo y solo se usan cuando esta app se conecta a ese servidor.", + "settings.remoteInstances.direct.headers.title": "Additional headers", + "settings.remoteInstances.direct.headers.description": "Optional HTTP headers for desktop API requests. Authorization is reserved for the connection token.", + "settings.remoteInstances.direct.headers.field.namePlaceholder": "Header name", + "settings.remoteInstances.direct.headers.field.valuePlaceholder": "Header value", + "settings.remoteInstances.direct.headers.actions.add": "Add header", + "settings.remoteInstances.direct.headers.removeAria": "Remove header", "settings.remoteInstances.direct.actions.add": "Añadir servidor", "settings.remoteInstances.direct.import.description": "Pega un enlace de conexión de otro servidor de OpenChamber.", "settings.remoteInstances.direct.import.placeholder": "openchamber://connect?...", diff --git a/packages/ui/src/lib/i18n/messages/fr.settings.ts b/packages/ui/src/lib/i18n/messages/fr.settings.ts index 010e89dc..d41c9aaf 100644 --- a/packages/ui/src/lib/i18n/messages/fr.settings.ts +++ b/packages/ui/src/lib/i18n/messages/fr.settings.ts @@ -1729,6 +1729,12 @@ export const settingsDict = { 'settings.remoteInstances.direct.field.urlPlaceholder': 'https://hôte:port', 'settings.remoteInstances.direct.field.tokenPlaceholder': 'Token de connexion (facultatif pour les serveurs locaux de confiance)', 'settings.remoteInstances.direct.note': 'Les tokens de connexion sont enregistrés sur cet appareil et utilisés uniquement lorsque cette application se connecte à ce serveur.', + 'settings.remoteInstances.direct.headers.title': 'Additional headers', + 'settings.remoteInstances.direct.headers.description': 'Optional HTTP headers for desktop API requests. Authorization is reserved for the connection token.', + 'settings.remoteInstances.direct.headers.field.namePlaceholder': 'Header name', + 'settings.remoteInstances.direct.headers.field.valuePlaceholder': 'Header value', + 'settings.remoteInstances.direct.headers.actions.add': 'Add header', + 'settings.remoteInstances.direct.headers.removeAria': 'Remove header', 'settings.remoteInstances.direct.actions.add': 'Ajouter le serveur', 'settings.remoteInstances.direct.import.description': 'Collez un lien de connexion provenant d’un autre serveur OpenChamber.', 'settings.remoteInstances.direct.import.placeholder': 'openchamber://connect?...', diff --git a/packages/ui/src/lib/i18n/messages/ja.settings.ts b/packages/ui/src/lib/i18n/messages/ja.settings.ts index f9b73858..6c329cb9 100644 --- a/packages/ui/src/lib/i18n/messages/ja.settings.ts +++ b/packages/ui/src/lib/i18n/messages/ja.settings.ts @@ -258,6 +258,12 @@ export const settingsDict = { 'settings.remoteInstances.direct.field.urlPlaceholder': 'https://host:port', 'settings.remoteInstances.direct.field.tokenPlaceholder': '接続 Token(信頼できるローカルサーバーでは任意)', 'settings.remoteInstances.direct.note': '接続 Token はこのデバイスに保存され、このアプリがそのサーバーに接続するときにのみ使用されます。', + 'settings.remoteInstances.direct.headers.title': 'Additional headers', + 'settings.remoteInstances.direct.headers.description': 'Optional HTTP headers for desktop API requests. Authorization is reserved for the connection token.', + 'settings.remoteInstances.direct.headers.field.namePlaceholder': 'Header name', + 'settings.remoteInstances.direct.headers.field.valuePlaceholder': 'Header value', + 'settings.remoteInstances.direct.headers.actions.add': 'Add header', + 'settings.remoteInstances.direct.headers.removeAria': 'Remove header', 'settings.remoteInstances.direct.actions.add': 'サーバーを追加', 'settings.remoteInstances.direct.import.description': '別の OpenChamber サーバーからの接続リンクを貼り付けてください。', 'settings.remoteInstances.direct.import.placeholder': 'openchamber://connect?...', diff --git a/packages/ui/src/lib/i18n/messages/ko.settings.ts b/packages/ui/src/lib/i18n/messages/ko.settings.ts index 152f0205..270ca640 100644 --- a/packages/ui/src/lib/i18n/messages/ko.settings.ts +++ b/packages/ui/src/lib/i18n/messages/ko.settings.ts @@ -225,6 +225,12 @@ export const settingsDict = { 'settings.remoteInstances.direct.field.urlPlaceholder': 'https://host:port', 'settings.remoteInstances.direct.field.tokenPlaceholder': '연결 토큰(신뢰할 수 있는 로컬 서버는 선택 사항)', 'settings.remoteInstances.direct.note': '연결 토큰은 이 기기에 저장되며 이 앱이 해당 서버에 연결할 때만 사용됩니다.', + 'settings.remoteInstances.direct.headers.title': 'Additional headers', + 'settings.remoteInstances.direct.headers.description': 'Optional HTTP headers for desktop API requests. Authorization is reserved for the connection token.', + 'settings.remoteInstances.direct.headers.field.namePlaceholder': 'Header name', + 'settings.remoteInstances.direct.headers.field.valuePlaceholder': 'Header value', + 'settings.remoteInstances.direct.headers.actions.add': 'Add header', + 'settings.remoteInstances.direct.headers.removeAria': 'Remove header', 'settings.remoteInstances.direct.actions.add': '서버 추가', 'settings.remoteInstances.direct.import.description': '다른 OpenChamber 서버에서 만든 연결 링크를 붙여넣으세요.', 'settings.remoteInstances.direct.import.placeholder': 'openchamber://connect?...', diff --git a/packages/ui/src/lib/i18n/messages/pl.settings.ts b/packages/ui/src/lib/i18n/messages/pl.settings.ts index 3daa0224..5734196a 100644 --- a/packages/ui/src/lib/i18n/messages/pl.settings.ts +++ b/packages/ui/src/lib/i18n/messages/pl.settings.ts @@ -1434,6 +1434,12 @@ export const settingsDict = { 'settings.remoteInstances.direct.field.urlPlaceholder': 'https://host:port', 'settings.remoteInstances.direct.field.tokenPlaceholder': 'Token połączenia (opcjonalny dla zaufanych serwerów lokalnych)', 'settings.remoteInstances.direct.note': 'Tokeny połączenia są zapisywane na tym urządzeniu i używane tylko wtedy, gdy ta aplikacja łączy się z danym serwerem.', + 'settings.remoteInstances.direct.headers.title': 'Additional headers', + 'settings.remoteInstances.direct.headers.description': 'Optional HTTP headers for desktop API requests. Authorization is reserved for the connection token.', + 'settings.remoteInstances.direct.headers.field.namePlaceholder': 'Header name', + 'settings.remoteInstances.direct.headers.field.valuePlaceholder': 'Header value', + 'settings.remoteInstances.direct.headers.actions.add': 'Add header', + 'settings.remoteInstances.direct.headers.removeAria': 'Remove header', 'settings.remoteInstances.direct.actions.add': 'Dodaj serwer', 'settings.remoteInstances.direct.import.description': 'Wklej link połączenia z innego serwera OpenChamber.', 'settings.remoteInstances.direct.import.placeholder': 'openchamber://connect?...', diff --git a/packages/ui/src/lib/i18n/messages/pt-BR.settings.ts b/packages/ui/src/lib/i18n/messages/pt-BR.settings.ts index f9eb5f03..13ff5d0e 100644 --- a/packages/ui/src/lib/i18n/messages/pt-BR.settings.ts +++ b/packages/ui/src/lib/i18n/messages/pt-BR.settings.ts @@ -225,6 +225,12 @@ export const settingsDict = { "settings.remoteInstances.direct.field.urlPlaceholder": "https://host:port", "settings.remoteInstances.direct.field.tokenPlaceholder": "Token de conexão (opcional para servidores locais confiáveis)", "settings.remoteInstances.direct.note": "Os tokens de conexão ficam salvos neste dispositivo e são usados apenas quando este app se conecta a esse servidor.", + "settings.remoteInstances.direct.headers.title": "Additional headers", + "settings.remoteInstances.direct.headers.description": "Optional HTTP headers for desktop API requests. Authorization is reserved for the connection token.", + "settings.remoteInstances.direct.headers.field.namePlaceholder": "Header name", + "settings.remoteInstances.direct.headers.field.valuePlaceholder": "Header value", + "settings.remoteInstances.direct.headers.actions.add": "Add header", + "settings.remoteInstances.direct.headers.removeAria": "Remove header", "settings.remoteInstances.direct.actions.add": "Adicionar servidor", "settings.remoteInstances.direct.import.description": "Cole um link de conexão de outro servidor OpenChamber.", "settings.remoteInstances.direct.import.placeholder": "openchamber://connect?...", diff --git a/packages/ui/src/lib/i18n/messages/uk.settings.ts b/packages/ui/src/lib/i18n/messages/uk.settings.ts index 8b351bfc..6464e347 100644 --- a/packages/ui/src/lib/i18n/messages/uk.settings.ts +++ b/packages/ui/src/lib/i18n/messages/uk.settings.ts @@ -225,6 +225,12 @@ export const settingsDict = { "settings.remoteInstances.direct.field.urlPlaceholder": "https://host:port", "settings.remoteInstances.direct.field.tokenPlaceholder": "Токен підключення (необов’язково для довірених локальних серверів)", "settings.remoteInstances.direct.note": "Токени підключення зберігаються на цьому пристрої й використовуються лише коли цей застосунок підключається до відповідного сервера.", + "settings.remoteInstances.direct.headers.title": "Additional headers", + "settings.remoteInstances.direct.headers.description": "Optional HTTP headers for desktop API requests. Authorization is reserved for the connection token.", + "settings.remoteInstances.direct.headers.field.namePlaceholder": "Header name", + "settings.remoteInstances.direct.headers.field.valuePlaceholder": "Header value", + "settings.remoteInstances.direct.headers.actions.add": "Add header", + "settings.remoteInstances.direct.headers.removeAria": "Remove header", "settings.remoteInstances.direct.actions.add": "Додати сервер", "settings.remoteInstances.direct.import.description": "Вставте посилання для підключення з іншого сервера OpenChamber.", "settings.remoteInstances.direct.import.placeholder": "openchamber://connect?...", diff --git a/packages/ui/src/lib/i18n/messages/zh-CN.settings.ts b/packages/ui/src/lib/i18n/messages/zh-CN.settings.ts index e6a959d5..ccd1b42a 100644 --- a/packages/ui/src/lib/i18n/messages/zh-CN.settings.ts +++ b/packages/ui/src/lib/i18n/messages/zh-CN.settings.ts @@ -225,6 +225,12 @@ export const settingsDict = { 'settings.remoteInstances.direct.field.urlPlaceholder': 'https://host:port', 'settings.remoteInstances.direct.field.tokenPlaceholder': '连接令牌(受信任的本地服务器可选)', 'settings.remoteInstances.direct.note': '连接令牌会保存在此设备上,并且只在此应用连接到该服务器时使用。', + 'settings.remoteInstances.direct.headers.title': 'Additional headers', + 'settings.remoteInstances.direct.headers.description': 'Optional HTTP headers for desktop API requests. Authorization is reserved for the connection token.', + 'settings.remoteInstances.direct.headers.field.namePlaceholder': 'Header name', + 'settings.remoteInstances.direct.headers.field.valuePlaceholder': 'Header value', + 'settings.remoteInstances.direct.headers.actions.add': 'Add header', + 'settings.remoteInstances.direct.headers.removeAria': 'Remove header', 'settings.remoteInstances.direct.actions.add': '添加服务器', 'settings.remoteInstances.direct.import.description': '粘贴来自另一个 OpenChamber 服务器的连接链接。', 'settings.remoteInstances.direct.import.placeholder': 'openchamber://connect?...', diff --git a/packages/ui/src/lib/i18n/messages/zh-TW.settings.ts b/packages/ui/src/lib/i18n/messages/zh-TW.settings.ts index 06989c74..835419c3 100644 --- a/packages/ui/src/lib/i18n/messages/zh-TW.settings.ts +++ b/packages/ui/src/lib/i18n/messages/zh-TW.settings.ts @@ -231,6 +231,12 @@ 'settings.remoteInstances.direct.field.urlPlaceholder': 'https://openchamber.example.com', 'settings.remoteInstances.direct.field.tokenPlaceholder': '用戶端 token(可選)', 'settings.remoteInstances.direct.note': '直接連線假設遠端伺服器已在執行並可從此裝置存取。', + 'settings.remoteInstances.direct.headers.title': 'Additional headers', + 'settings.remoteInstances.direct.headers.description': 'Optional HTTP headers for desktop API requests. Authorization is reserved for the connection token.', + 'settings.remoteInstances.direct.headers.field.namePlaceholder': 'Header name', + 'settings.remoteInstances.direct.headers.field.valuePlaceholder': 'Header value', + 'settings.remoteInstances.direct.headers.actions.add': 'Add header', + 'settings.remoteInstances.direct.headers.removeAria': 'Remove header', 'settings.remoteInstances.direct.actions.add': '新增直接連線', 'settings.remoteInstances.direct.import.description': '貼上 connect-url 輸出或 openchamber://connect 連結來匯入。', 'settings.remoteInstances.direct.import.placeholder': '貼上連線連結', diff --git a/packages/ui/src/lib/runtime-auth.test.ts b/packages/ui/src/lib/runtime-auth.test.ts index 270acc21..f5cbb58d 100644 --- a/packages/ui/src/lib/runtime-auth.test.ts +++ b/packages/ui/src/lib/runtime-auth.test.ts @@ -2,9 +2,12 @@ import { describe, expect, test } from 'bun:test'; import { buildRuntimeAuthHeaders, clearRuntimeAuthCredentialProvider, + clearRuntimeUrlAuthToken, getRuntimeBearerTokenSync, + refreshRuntimeUrlAuthToken, setRuntimeAuthCredentialProvider, setRuntimeBearerToken, + setRuntimeExtraHeaders, } from './runtime-auth'; describe('runtime auth headers', () => { @@ -60,4 +63,56 @@ describe('runtime auth headers', () => { } } }); + + test('adds runtime extra headers without overriding bearer authorization', async () => { + try { + setRuntimeBearerToken('runtime-token'); + setRuntimeExtraHeaders({ + 'CF-Access-Client-Id': 'client-id', + Authorization: 'Bearer proxy-token', + }); + + const headers = await buildRuntimeAuthHeaders(); + + expect(headers.get('CF-Access-Client-Id')).toBe('client-id'); + expect(headers.get('Authorization')).toBe('Bearer runtime-token'); + } finally { + setRuntimeExtraHeaders(null); + clearRuntimeAuthCredentialProvider(); + } + }); + + test('sends runtime extra headers when minting URL auth tokens', async () => { + const previousFetch = globalThis.fetch; + let seenUrl = ''; + let seenHeaders = new Headers(); + try { + clearRuntimeUrlAuthToken(); + setRuntimeBearerToken('runtime-token'); + setRuntimeExtraHeaders({ + 'CF-Access-Client-Id': 'client-id', + Authorization: 'Bearer proxy-token', + }); + globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + seenUrl = String(input); + seenHeaders = new Headers(init?.headers); + return new Response(JSON.stringify({ token: 'url-token', expiresAt: Date.now() + 60_000 }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }); + }) as typeof fetch; + + const token = await refreshRuntimeUrlAuthToken('https://runtime.example'); + + expect(token).toBe('url-token'); + expect(seenUrl).toBe('https://runtime.example/auth/url-token'); + expect(seenHeaders.get('CF-Access-Client-Id')).toBe('client-id'); + expect(seenHeaders.get('Authorization')).toBe('Bearer runtime-token'); + } finally { + globalThis.fetch = previousFetch; + clearRuntimeUrlAuthToken(); + setRuntimeExtraHeaders(null); + clearRuntimeAuthCredentialProvider(); + } + }); }); diff --git a/packages/ui/src/lib/runtime-auth.ts b/packages/ui/src/lib/runtime-auth.ts index 811a8232..b02fd8f5 100644 --- a/packages/ui/src/lib/runtime-auth.ts +++ b/packages/ui/src/lib/runtime-auth.ts @@ -6,6 +6,7 @@ export type RuntimeAuthCredentialProvider = () => RuntimeAuthCredential | Promis let credentialProvider: RuntimeAuthCredentialProvider = () => null; let runtimeBearerToken = ''; +let runtimeExtraHeaders: Record = {}; let runtimeUrlAuthToken = ''; let runtimeUrlAuthTokenExpiresAt = 0; let runtimeUrlAuthRefreshPromise: Promise | null = null; @@ -13,6 +14,18 @@ let runtimeAuthGeneration = 0; const URL_AUTH_REFRESH_SKEW_MS = 10_000; +const isReservedRuntimeExtraHeaderName = (name: string): boolean => name.toLowerCase() === 'authorization'; + +const sanitizeRuntimeExtraHeaders = (headers: Record | null | undefined): Record => { + const next: Record = {}; + for (const [key, value] of Object.entries(headers || {})) { + const name = key.trim(); + const headerValue = value.trim(); + if (name && headerValue && !isReservedRuntimeExtraHeaderName(name)) next[name] = headerValue; + } + return next; +}; + const normalizeBearerToken = (token: string | null | undefined): string => { if (typeof token !== 'string') return ''; return token.trim(); @@ -74,6 +87,20 @@ export const setRuntimeBearerToken = (token: string | null | undefined): void => credentialProvider = () => normalized ? { type: 'bearer', token: normalized } : null; }; +export const setRuntimeExtraHeaders = (headers: Record | null | undefined): void => { + // These headers are for runtime HTTP fetches and URL-token minting. Browser-owned + // realtime transports (EventSource/WebSocket) cannot attach arbitrary headers. + runtimeExtraHeaders = sanitizeRuntimeExtraHeaders(headers); + resetRuntimeAuthGeneration(); +}; + +export const getRuntimeExtraHeadersSync = (): Record => { + if (Object.keys(runtimeExtraHeaders).length > 0) return runtimeExtraHeaders; + if (typeof window === 'undefined') return {}; + const injected = (window as typeof window & { __OPENCHAMBER_RUNTIME_HEADERS__?: Record }).__OPENCHAMBER_RUNTIME_HEADERS__; + return injected && typeof injected === 'object' ? sanitizeRuntimeExtraHeaders(injected) : {}; +}; + export const getRuntimeBearerTokenSync = (): string => runtimeBearerToken || readInjectedBearerToken(); export const setRuntimeUrlAuthToken = (token: string | null | undefined, expiresAt: number | null | undefined): void => { @@ -127,6 +154,9 @@ const mintRuntimeUrlAuthToken = (apiBaseUrl?: string | null): Promise => const refreshPromise = (async () => { const credential = await getRuntimeAuthCredential(); const headers = new Headers(); + for (const [key, value] of Object.entries(getRuntimeExtraHeadersSync())) { + headers.set(key, value); + } if (credential?.type === 'bearer') { headers.set('Authorization', `Bearer ${credential.token}`); } @@ -257,6 +287,9 @@ export const subscribeRuntimeUrlAuthToken = (listener: () => void): (() => void) export const buildRuntimeAuthHeaders = async (headers?: HeadersInit): Promise => { const next = new Headers(headers); + for (const [key, value] of Object.entries(getRuntimeExtraHeadersSync())) { + if (!next.has(key)) next.set(key, value); + } if (next.has('Authorization')) { return next; } diff --git a/packages/ui/src/lib/runtime-switch.ts b/packages/ui/src/lib/runtime-switch.ts index 6d8084d6..6cb02dfd 100644 --- a/packages/ui/src/lib/runtime-switch.ts +++ b/packages/ui/src/lib/runtime-switch.ts @@ -1,4 +1,4 @@ -import { refreshRuntimeUrlAuthToken, setRuntimeBearerToken } from '@/lib/runtime-auth'; +import { refreshRuntimeUrlAuthToken, setRuntimeBearerToken, setRuntimeExtraHeaders } from '@/lib/runtime-auth'; import { configureRuntimeUrlResolver } from '@/lib/runtime-url'; export type RuntimeEndpointChangedDetail = { @@ -68,7 +68,7 @@ export const initializeRuntimeEndpoint = (options: { apiBaseUrl?: string | null; activeRuntimeKey = options.runtimeKey?.trim() || (sameOrigin(apiBaseUrl, readInjectedLocalOrigin()) ? 'local' : normalizeRuntimeUrlKey(apiBaseUrl)); }; -export const switchRuntimeEndpoint = (options: { apiBaseUrl: string; clientToken?: string | null; runtimeKey?: string | null }): void => { +export const switchRuntimeEndpoint = (options: { apiBaseUrl: string; clientToken?: string | null; runtimeKey?: string | null; requestHeaders?: Record | null }): void => { const apiBaseUrl = options.apiBaseUrl.trim(); const previousApiBaseUrl = getRuntimeApiBaseUrl(); const previousRuntimeKey = getRuntimeKey(); @@ -79,11 +79,14 @@ export const switchRuntimeEndpoint = (options: { apiBaseUrl: string; clientToken const runtimeWindow = window as typeof window & { __OPENCHAMBER_API_BASE_URL__?: string; __OPENCHAMBER_CLIENT_TOKEN__?: string; + __OPENCHAMBER_RUNTIME_HEADERS__?: Record; }; runtimeWindow.__OPENCHAMBER_API_BASE_URL__ = apiBaseUrl; runtimeWindow.__OPENCHAMBER_CLIENT_TOKEN__ = options.clientToken || undefined; + runtimeWindow.__OPENCHAMBER_RUNTIME_HEADERS__ = options.requestHeaders || undefined; } configureRuntimeUrlResolver({ apiBaseUrl, realtimeBaseUrl: apiBaseUrl }); + setRuntimeExtraHeaders(options.requestHeaders || null); setRuntimeBearerToken(options.clientToken || null); void refreshRuntimeUrlAuthToken(apiBaseUrl).catch(() => {}); if (typeof window !== 'undefined') { diff --git a/packages/ui/src/lib/settings/search.ts b/packages/ui/src/lib/settings/search.ts index 89032db3..7c97b377 100644 --- a/packages/ui/src/lib/settings/search.ts +++ b/packages/ui/src/lib/settings/search.ts @@ -404,7 +404,7 @@ const SETTINGS_SEARCH_ITEMS: readonly SettingsSearchItem[] = [ page: 'remote-instances', titleKey: 'settings.remoteInstances.direct.title', descriptionKey: 'settings.remoteInstances.direct.description', - keywords: ['server url', 'connection token', 'import link', 'host switcher'], + keywords: ['server url', 'connection token', 'import link', 'host switcher', 'additional headers', 'request headers', 'cloudflare access', 'service token'], isAvailable: (ctx) => ctx.isDesktop, }, { diff --git a/packages/web/src/runtimeConfig.ts b/packages/web/src/runtimeConfig.ts index 3d18044e..ba91ca9b 100644 --- a/packages/web/src/runtimeConfig.ts +++ b/packages/web/src/runtimeConfig.ts @@ -1,4 +1,4 @@ -import { refreshRuntimeUrlAuthToken, setRuntimeBearerToken } from '@openchamber/ui/lib/runtime-auth'; +import { 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 { configureRuntimeUrlResolver } from '@openchamber/ui/lib/runtime-url'; @@ -17,6 +17,7 @@ declare global { interface Window { __OPENCHAMBER_API_BASE_URL__?: string; __OPENCHAMBER_CLIENT_TOKEN__?: string; + __OPENCHAMBER_RUNTIME_HEADERS__?: Record; __OPENCHAMBER_LOCAL_ORIGIN__?: string; } } @@ -41,6 +42,7 @@ export const createConfiguredWebAPIs = () => { runtimeKey: sameOrigin(apiBaseUrl, localOrigin) ? 'local' : null, }); setRuntimeBearerToken(clientToken || null); + setRuntimeExtraHeaders(window.__OPENCHAMBER_RUNTIME_HEADERS__ || null); void refreshRuntimeUrlAuthToken(apiBaseUrl || undefined).catch(() => {}); installRuntimeFetchBridge(); return createWebAPIs({ urls });