feat(desktop): support remote runtime headers

This commit is contained in:
Bohdan Triapitsyn
2026-06-30 00:30:48 +03:00
parent 9c1eb755f9
commit 359c73fcf3
23 changed files with 458 additions and 45 deletions
+55 -24
View File
@@ -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.
+11
View File
@@ -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).
@@ -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;
};
@@ -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' });
});
});
@@ -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),
});
@@ -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<string, string> | undefined => {
const next: Record<string, string> = {};
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<string, string> | 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<HeaderDraft[]>([]);
const [directConnectLink, setDirectConnectLink] = React.useState('');
const [directError, setDirectError] = React.useState<string | null>(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<HeaderDraft[]>([]);
const [remoteClients, setRemoteClients] = React.useState<RemoteClientRecord[]>([]);
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 = () => {
<Input className="h-8" value={directLabel} onChange={(event) => setDirectLabel(event.target.value)} placeholder={t('settings.remoteInstances.direct.field.labelPlaceholder')} disabled={directSaving} />
<Input className="h-8" value={directUrl} onChange={(event) => setDirectUrl(event.target.value)} placeholder={t('settings.remoteInstances.direct.field.urlPlaceholder')} disabled={directSaving} autoFocus />
<Input className="h-8" value={directToken} onChange={(event) => setDirectToken(event.target.value)} placeholder={t('settings.remoteInstances.direct.field.tokenPlaceholder')} type="password" disabled={directSaving} />
<div className="space-y-2">
<div>
<p className="typography-ui-label text-foreground">{t('settings.remoteInstances.direct.headers.title')}</p>
<p className="typography-meta text-muted-foreground">{t('settings.remoteInstances.direct.headers.description')}</p>
</div>
{directHeaders.map((header) => (
<div key={header.id} className="flex w-full gap-2">
<Input className="h-8 font-mono text-xs" value={header.name} onChange={(event) => 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} />
<Input className="h-8 font-mono text-xs" value={header.value} onChange={(event) => 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} />
<button type="button" onClick={() => setDirectHeaders((headers) => headers.filter((item) => item.id !== header.id))} className="flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-md text-muted-foreground hover:bg-[var(--status-error-background)] hover:text-[var(--status-error)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--interactive-focus-ring)]" aria-label={t('settings.remoteInstances.direct.headers.removeAria')} disabled={directSaving}>
<Icon name="close" className="h-4 w-4" />
</button>
</div>
))}
<Button type="button" variant="ghost" size="xs" className="!font-normal" onClick={() => setDirectHeaders((headers) => [...headers, createHeaderDraft()])} disabled={directSaving}>
<Icon name="add" className="h-3.5 w-3.5" />
{t('settings.remoteInstances.direct.headers.actions.add')}
</Button>
</div>
<div className="flex justify-end gap-2">
<Button type="button" variant="outline" size="xs" className="!font-normal" onClick={() => setDirectAddDialogOpen(false)} disabled={directSaving}>{t('settings.common.actions.cancel')}</Button>
<Button type="submit" size="xs" className="!font-normal" disabled={directSaving || !directUrl.trim()}>{t('settings.remoteInstances.direct.actions.add')}</Button>
@@ -1113,6 +1168,25 @@ export const RemoteInstancesPage: React.FC = () => {
<Input className="h-8" value={directEditLabel} onChange={(event) => setDirectEditLabel(event.target.value)} placeholder={t('settings.remoteInstances.direct.field.labelPlaceholder')} disabled={directSaving} />
<Input className="h-8" value={directEditUrl} onChange={(event) => setDirectEditUrl(event.target.value)} placeholder={t('settings.remoteInstances.direct.field.urlPlaceholder')} disabled={directSaving} autoFocus />
<Input className="h-8" value={directEditToken} onChange={(event) => setDirectEditToken(event.target.value)} placeholder={t('settings.remoteInstances.direct.field.tokenPlaceholder')} type="password" disabled={directSaving} />
<div className="space-y-2">
<div>
<p className="typography-ui-label text-foreground">{t('settings.remoteInstances.direct.headers.title')}</p>
<p className="typography-meta text-muted-foreground">{t('settings.remoteInstances.direct.headers.description')}</p>
</div>
{directEditHeaders.map((header) => (
<div key={header.id} className="flex w-full gap-2">
<Input className="h-8 font-mono text-xs" value={header.name} onChange={(event) => 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} />
<Input className="h-8 font-mono text-xs" value={header.value} onChange={(event) => 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} />
<button type="button" onClick={() => setDirectEditHeaders((headers) => headers.filter((item) => item.id !== header.id))} className="flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-md text-muted-foreground hover:bg-[var(--status-error-background)] hover:text-[var(--status-error)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--interactive-focus-ring)]" aria-label={t('settings.remoteInstances.direct.headers.removeAria')} disabled={directSaving}>
<Icon name="close" className="h-4 w-4" />
</button>
</div>
))}
<Button type="button" variant="ghost" size="xs" className="!font-normal" onClick={() => setDirectEditHeaders((headers) => [...headers, createHeaderDraft()])} disabled={directSaving}>
<Icon name="add" className="h-3.5 w-3.5" />
{t('settings.remoteInstances.direct.headers.actions.add')}
</Button>
</div>
<div className="flex justify-end gap-2">
<Button type="button" variant="outline" size="xs" className="!font-normal" onClick={() => setDirectEditingId(null)} disabled={directSaving}>{t('settings.common.actions.cancel')}</Button>
<Button type="submit" size="xs" className="!font-normal" disabled={directSaving}>{t('settings.common.actions.saveChanges')}</Button>
+84 -1
View File
@@ -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 <T>(handler: (cmd: string, args: Record<string, unknown>) => unknown | Promise<unknown>, run: () => Promise<T>): Promise<T> => {
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<string, unknown> }> = [];
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' },
},
});
});
});
+27 -8
View File
@@ -2,6 +2,25 @@ import { hasDesktopInvoke, invokeDesktop } from '@/lib/desktop';
type DesktopInvoke = (cmd: string, args?: Record<string, unknown>) => Promise<unknown>;
const isRecord = (value: unknown): value is Record<string, unknown> => {
return typeof value === 'object' && value !== null;
};
const isReservedRequestHeaderName = (name: string): boolean => name.trim().toLowerCase() === 'authorization';
const sanitizeRequestHeaders = (headers: unknown): Record<string, string> | undefined => {
if (!isRecord(headers)) return undefined;
const next: Record<string, string> = {};
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<string, string>;
};
export type DesktopHostsConfig = {
@@ -135,10 +156,6 @@ export const locationMatchesHost = (locationHref: string, hostUrl: string): bool
}
};
const isRecord = (value: unknown): value is Record<string, unknown> => {
return typeof value === 'object' && value !== null;
};
const readString = (obj: Record<string, unknown>, 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<string> => {
return typeof raw === 'string' ? raw.trim() : '';
};
export const desktopHostProbe = async (url: string, options?: { clientToken?: string | null }): Promise<HostProbeResult> => {
export const desktopHostProbe = async (url: string, options?: { clientToken?: string | null; requestHeaders?: Record<string, string> | null }): Promise<HostProbeResult> => {
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<void> => {
export const desktopOpenNewWindowAtUrl = async (url: string, options?: { clientToken?: string | null; requestHeaders?: Record<string, string> | null }): Promise<void> => {
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 });
};
@@ -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?...',
@@ -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?...",
@@ -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 dun autre serveur OpenChamber.',
'settings.remoteInstances.direct.import.placeholder': 'openchamber://connect?...',
@@ -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?...',
@@ -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?...',
@@ -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?...',
@@ -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?...",
@@ -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?...",
@@ -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?...',
@@ -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': '貼上連線連結',
+55
View File
@@ -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();
}
});
});
+33
View File
@@ -6,6 +6,7 @@ export type RuntimeAuthCredentialProvider = () => RuntimeAuthCredential | Promis
let credentialProvider: RuntimeAuthCredentialProvider = () => null;
let runtimeBearerToken = '';
let runtimeExtraHeaders: Record<string, string> = {};
let runtimeUrlAuthToken = '';
let runtimeUrlAuthTokenExpiresAt = 0;
let runtimeUrlAuthRefreshPromise: Promise<string> | 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<string, string> | null | undefined): Record<string, string> => {
const next: Record<string, string> = {};
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<string, string> | 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<string, string> => {
if (Object.keys(runtimeExtraHeaders).length > 0) return runtimeExtraHeaders;
if (typeof window === 'undefined') return {};
const injected = (window as typeof window & { __OPENCHAMBER_RUNTIME_HEADERS__?: Record<string, string> }).__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<string> =>
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<Headers> => {
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;
}
+5 -2
View File
@@ -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<string, string> | 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<string, string>;
};
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') {
+1 -1
View File
@@ -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,
},
{
+3 -1
View File
@@ -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<string, string>;
__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 });