Decouple bundled UI from runtime API and add remote instance tooling (#1228)

Add a packaged-client runtime boundary so the shared UI can talk to local,
desktop, remote, and VS Code runtimes through the right transport instead of
assuming one same-origin web server.

Centralize OpenChamber-owned API access behind RuntimeAPIs, runtimeFetch, and
runtime URL helpers, while keeping official OpenCode traffic on the SDK path.
Support runtime switching, remote host selection, desktop client credentials,
and headless connection links for pairing packaged clients with remote
OpenChamber servers.

Harden the new auth model by moving long-lived client tokens out of browser
URLs, introducing short-lived scoped URL tokens for browser-owned transports,
restricting URL-token access to explicit readable/realtime routes, and making
client-token management session-scoped or self-scoped as appropriate.

Update browser-owned assets and preview proxy flows to work with the split
runtime model, including authenticated project icons, preview token propagation,
CSP-safe preview bridge injection, and preview proxy auth that survives
short-lived URL-token expiry.

Tighten Electron security boundaries for packaged clients by gating privileged
preload state to trusted origins and requiring explicit confirmation before
connect deep-links import or switch remote runtimes.

Also refresh agent guidance and project skills so future runtime/API, auth,
preview, UI, CLI, settings, locale, and drag-to-reorder work follows the new
architecture.
This commit is contained in:
Bohdan Triapitsyn
2026-06-02 00:43:05 +03:00
committed by GitHub
parent a4314c189b
commit 2031e3b4a8
282 changed files with 16524 additions and 4259 deletions
+208 -57
View File
@@ -1,5 +1,8 @@
const DEFAULT_TARGET_TTL_MS = 30 * 60 * 1000;
const TOKEN_COOKIE_NAME = 'oc_preview_token';
const TOKEN_QUERY_PARAM = 'oc_preview_token';
const CLIENT_TOKEN_QUERY_PARAM = 'oc_client_token';
const URL_AUTH_TOKEN_QUERY_PARAM = 'oc_url_token';
const LOOPBACK_HOSTS = new Set([
'localhost',
@@ -177,11 +180,23 @@ export const classifyPreviewNavigation = ({ url, currentUrl, targetOrigin }) =>
let nativeMatchMedia = null;
const colorSchemeListeners = new Set();
const parentOrigin = (() => {
try {
const ancestorOrigins = window.location && window.location.ancestorOrigins;
const ancestorOrigin = ancestorOrigins && ancestorOrigins.length > 0 ? ancestorOrigins[0] : '';
if (ancestorOrigin && ancestorOrigin !== 'null') return ancestorOrigin;
const origin = document.referrer ? new URL(document.referrer).origin : '';
return origin && origin !== 'null' ? origin : '';
} catch {
return '';
}
})();
const post = (payload) => {
try {
if (window.parent && typeof window.parent.postMessage === 'function') {
if (parentOrigin && window.parent && typeof window.parent.postMessage === 'function') {
const message = Object.assign({ source: SOURCE, version: VERSION }, payload || {});
window.parent.postMessage(message, window.location.origin);
window.parent.postMessage(message, parentOrigin);
}
} catch {}
};
@@ -435,6 +450,9 @@ export const classifyPreviewNavigation = ({ url, currentUrl, targetOrigin }) =>
const proxyMatch = window.location.pathname.match(/^(\/api\/preview\/proxy\/[a-f0-9]{16,64})(?:\/|$)/i);
if (!proxyMatch) return;
const proxyBase = proxyMatch[1] + '/';
const currentSearchParams = new URL(window.location.href).searchParams;
const previewToken = currentSearchParams.get('oc_preview_token') || '';
const urlAuthToken = currentSearchParams.get('oc_url_token') || '';
let reloadTimer = 0;
const schedulePreviewReload = () => {
@@ -454,8 +472,11 @@ export const classifyPreviewNavigation = ({ url, currentUrl, targetOrigin }) =>
try {
const parsed = new URL(String(url), window.location.href);
if (parsed.host !== window.location.host) return url;
if (parsed.pathname.indexOf(proxyBase) === 0) return url;
parsed.pathname = proxyBase;
if (parsed.pathname.indexOf(proxyBase) !== 0) {
parsed.pathname = proxyBase;
}
if (previewToken) parsed.searchParams.set('oc_preview_token', previewToken);
if (urlAuthToken) parsed.searchParams.set('oc_url_token', urlAuthToken);
return parsed.toString();
} catch {
return url;
@@ -496,6 +517,23 @@ export const classifyPreviewNavigation = ({ url, currentUrl, targetOrigin }) =>
const proxyMatch = window.location.pathname.match(/^(\/api\/preview\/proxy\/[a-f0-9]{16,64})(?:\/|$)/i);
if (!proxyMatch) return;
const proxyBase = proxyMatch[1];
const currentSearchParams = new URL(window.location.href).searchParams;
const previewToken = currentSearchParams.get('oc_preview_token') || '';
const urlAuthToken = currentSearchParams.get('oc_url_token') || '';
const withProxyAuth = (value) => {
if (typeof value !== 'string' || value.indexOf(proxyBase) !== 0) return value;
if (!previewToken && !urlAuthToken) return value;
try {
const parsed = new URL(value, window.location.origin);
parsed.searchParams.delete('oc_client_token');
if (previewToken) parsed.searchParams.set('oc_preview_token', previewToken);
if (urlAuthToken) parsed.searchParams.set('oc_url_token', urlAuthToken);
return parsed.pathname + parsed.search + parsed.hash;
} catch {
return value;
}
};
const shouldProxyPath = (pathname) => {
if (typeof pathname !== 'string' || !pathname.startsWith('/') || pathname.startsWith('//')) return false;
@@ -506,14 +544,15 @@ export const classifyPreviewNavigation = ({ url, currentUrl, targetOrigin }) =>
const proxiedUrl = (value) => {
if (typeof value !== 'string') return value;
if (value.startsWith('/')) {
if (value.indexOf(proxyBase) === 0) return withProxyAuth(value);
if (!shouldProxyPath(value)) return value;
return proxyBase + value;
return withProxyAuth(proxyBase + value);
}
try {
const parsed = new URL(value, window.location.href);
if (parsed.origin === window.location.origin && shouldProxyPath(parsed.pathname)) {
return proxyBase + parsed.pathname + parsed.search + parsed.hash;
return withProxyAuth(proxyBase + parsed.pathname + parsed.search + parsed.hash);
}
} catch {}
@@ -529,12 +568,47 @@ export const classifyPreviewNavigation = ({ url, currentUrl, targetOrigin }) =>
const isWebSocketProtocol = parsed.protocol === 'ws:' || parsed.protocol === 'wss:';
if (sameHost && isWebSocketProtocol && shouldProxyPath(parsed.pathname)) {
parsed.pathname = proxyBase + parsed.pathname;
parsed.searchParams.delete('oc_client_token');
if (previewToken) parsed.searchParams.set('oc_preview_token', previewToken);
if (urlAuthToken) parsed.searchParams.set('oc_url_token', urlAuthToken);
return parsed.toString();
}
} catch {}
return value;
};
const proxiedNavigationUrl = (value) => {
if (typeof value !== 'string') return value;
try {
const parsed = new URL(value, window.location.href);
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') return value;
if (parsed.origin === window.location.origin && parsed.pathname.indexOf(proxyBase) === 0) {
return withProxyAuth(parsed.pathname + parsed.search + parsed.hash);
}
const host = parsed.hostname;
const isLoopback = host === 'localhost' || host === '127.0.0.1' || host === '0.0.0.0' || host === '::1' || host === '[::1]';
if (!isLoopback && parsed.origin !== window.location.origin) return value;
if (!shouldProxyPath(parsed.pathname)) return value;
return withProxyAuth(proxyBase + parsed.pathname + parsed.search + parsed.hash);
} catch {
return proxiedUrl(value);
}
};
if (window.history && typeof window.history.pushState === 'function') {
const nativePushState = window.history.pushState.bind(window.history);
window.history.pushState = function(state, unused, url) {
return nativePushState(state, unused, url === undefined ? url : proxiedNavigationUrl(String(url)));
};
}
if (window.history && typeof window.history.replaceState === 'function') {
const nativeReplaceState = window.history.replaceState.bind(window.history);
window.history.replaceState = function(state, unused, url) {
return nativeReplaceState(state, unused, url === undefined ? url : proxiedNavigationUrl(String(url)));
};
}
if (typeof window.fetch === 'function') {
const nativeFetch = window.fetch.bind(window);
window.fetch = function(input, init) {
@@ -545,7 +619,7 @@ export const classifyPreviewNavigation = ({ url, currentUrl, targetOrigin }) =>
try {
const parsed = new URL(input.url);
if (parsed.origin === window.location.origin && shouldProxyPath(parsed.pathname)) {
const nextUrl = proxyBase + parsed.pathname + parsed.search + parsed.hash;
const nextUrl = withProxyAuth(proxyBase + parsed.pathname + parsed.search + parsed.hash);
return nativeFetch(new Request(nextUrl, input), init);
}
} catch {}
@@ -912,9 +986,28 @@ export const normalizeProxyTargetUrl = (rawUrl, { allowExternal = false } = {})
return { ok: true, origin: url.origin };
};
const appendProxyAuthToProxyUrl = (value, { previewToken = '', urlAuthToken = '' } = {}) => {
if (typeof value !== 'string' || !value) return value;
const needsQueryRewrite = previewToken
|| urlAuthToken
|| value.includes(CLIENT_TOKEN_QUERY_PARAM)
|| value.includes(URL_AUTH_TOKEN_QUERY_PARAM);
if (!needsQueryRewrite) return value;
try {
const parsed = new URL(value, 'http://openchamber-preview.local');
parsed.searchParams.delete(CLIENT_TOKEN_QUERY_PARAM);
parsed.searchParams.delete(URL_AUTH_TOKEN_QUERY_PARAM);
if (previewToken) parsed.searchParams.set(TOKEN_QUERY_PARAM, previewToken);
if (urlAuthToken) parsed.searchParams.set(URL_AUTH_TOKEN_QUERY_PARAM, urlAuthToken);
return `${parsed.pathname}${parsed.search}${parsed.hash}`;
} catch {
return value;
}
};
const normalizeLoopbackUrl = (rawUrl) => normalizeProxyTargetUrl(rawUrl, { allowExternal: false });
export const rewritePreviewBody = ({ bodyText, proxyBasePath, targetOrigin, kind }) => {
export const rewritePreviewBody = ({ bodyText, proxyBasePath, targetOrigin, kind, previewToken = '', urlAuthToken = '' }) => {
if (typeof bodyText !== 'string' || bodyText.length === 0) {
return bodyText;
}
@@ -935,13 +1028,13 @@ export const rewritePreviewBody = ({ bodyText, proxyBasePath, targetOrigin, kind
const rewriteResourceUrl = (value) => {
if (typeof value !== 'string' || value.length === 0) return value;
if (value.startsWith('/') && !value.startsWith('//')) {
if (value.startsWith('/api/preview/proxy/')) return value;
return `${prefix}${value}`;
if (value.startsWith('/api/preview/proxy/')) return appendProxyAuthToProxyUrl(value, { previewToken, urlAuthToken });
return appendProxyAuthToProxyUrl(`${prefix}${value}`, { previewToken, urlAuthToken });
}
try {
const parsed = new URL(value);
if (isSameTargetOrigin(parsed)) {
return `${prefix}${parsed.pathname}${parsed.search}${parsed.hash}`;
return appendProxyAuthToProxyUrl(`${prefix}${parsed.pathname}${parsed.search}${parsed.hash}`, { previewToken, urlAuthToken });
}
} catch {
return value;
@@ -963,6 +1056,9 @@ export const rewritePreviewBody = ({ bodyText, proxyBasePath, targetOrigin, kind
}).join(', ');
return `srcset=${quote}${rewritten}${quote}`;
});
const stripPreviewCspMeta = (text) => text
.replace(/<meta\b(?=[^>]*\bhttp-equiv\s*=\s*(['"])content-security-policy\1)[^>]*>/gi, '')
.replace(/<meta\b(?=[^>]*\bhttp-equiv\s*=\s*content-security-policy\b)[^>]*>/gi, '');
const rewriteCss = (text) => text
.replace(/url\((['"]?)([^)'"]*)\1\)/gi, (_match, quote, value) => {
const q = quote || '';
@@ -982,12 +1078,67 @@ export const rewritePreviewBody = ({ bodyText, proxyBasePath, targetOrigin, kind
return `import(${quote}${rewriteResourceUrl(`/${path}`)}${quote})`;
});
if (kind === 'html') return rewriteHtml(bodyText);
if (kind === 'html') return stripPreviewCspMeta(rewriteHtml(bodyText));
if (kind === 'css') return rewriteCss(bodyText);
if (kind === 'javascript') return rewriteJavaScript(bodyText);
return bodyText;
};
// Rewrite a dev server's CSP so the injected preview bridge can run via a
// per-response nonce, while keeping the dev server's own script restrictions.
// frame-ancestors is dropped (it blocks embedding) and require-trusted-types-for
// is dropped (it can block the bridge's DOM use); everything else is preserved.
export const rewritePreviewCspHeader = (cspValue, nonce) => {
if (typeof cspValue !== 'string' || cspValue.length === 0) return cspValue;
const nonceSource = nonce ? `'nonce-${nonce}'` : '';
const directives = cspValue
.split(';')
.map((part) => part.trim())
.filter(Boolean)
.map((part) => {
const tokens = part.split(/\s+/);
return { name: (tokens[0] || '').toLowerCase(), tokens };
})
.filter((directive) => directive.name !== 'frame-ancestors' && directive.name !== 'require-trusted-types-for');
if (nonceSource) {
const byName = new Map(directives.map((directive) => [directive.name, directive]));
const allowNonce = (directive) => {
// Drop a lone 'none' so the nonce takes effect, then add our nonce.
directive.tokens = directive.tokens.filter((token) => token.toLowerCase() !== "'none'");
if (!directive.tokens.includes(nonceSource)) directive.tokens.push(nonceSource);
};
const scriptElem = byName.get('script-src-elem');
const scriptSrc = byName.get('script-src');
if (scriptElem) allowNonce(scriptElem);
if (scriptSrc) allowNonce(scriptSrc);
if (!scriptElem && !scriptSrc && byName.has('default-src')) {
const base = byName.get('default-src').tokens.slice(1).filter((token) => token.toLowerCase() !== "'none'");
directives.push({ name: 'script-src', tokens: ['script-src', ...base, nonceSource] });
}
}
const rebuilt = directives.map((directive) => directive.tokens.join(' '));
return rebuilt.length > 0 ? rebuilt.join('; ') : null;
};
export const rewritePreviewRedirectLocation = ({ location, proxyBasePath, targetOrigin, previewToken = '', urlAuthToken = '' }) => {
if (typeof location !== 'string' || !location) return location;
const prefix = proxyBasePath.endsWith('/') ? proxyBasePath.slice(0, -1) : proxyBasePath;
const target = targetOrigin ? new URL(targetOrigin) : null;
if (!target) return location;
try {
const parsed = new URL(location, target);
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') return location;
const host = parsed.hostname;
const isLoopback = host === 'localhost' || host === '127.0.0.1' || host === '0.0.0.0' || host === '::1' || host === '[::1]';
if (!isLoopback || parsed.port !== target.port) return location;
return appendProxyAuthToProxyUrl(`${prefix}${parsed.pathname}${parsed.search}${parsed.hash}`, { previewToken, urlAuthToken });
} catch {
return location;
}
};
export const createPreviewProxyRuntime = ({
crypto,
URL,
@@ -1050,7 +1201,7 @@ export const createPreviewProxyRuntime = ({
}
const cookies = parseCookieHeader(req.headers?.cookie);
const token = cookies.get(TOKEN_COOKIE_NAME) || '';
const token = parsed.searchParams.get(TOKEN_QUERY_PARAM) || cookies.get(TOKEN_COOKIE_NAME) || '';
if (!token || token !== entry.token) {
return { ok: false, status: 403, error: 'Preview token missing' };
}
@@ -1079,31 +1230,9 @@ export const createPreviewProxyRuntime = ({
return parts.length > 0 ? `?${parts.join('&')}` : '';
};
// Strip the `frame-ancestors` directive from a CSP header value while
// preserving every other directive. Returns null if no directives remain.
const removeFrameAncestorsDirective = (cspValue) => {
if (typeof cspValue !== 'string' || cspValue.length === 0) {
return cspValue;
}
const directives = cspValue
.split(';')
.map((part) => part.trim())
.filter((part) => part.length > 0);
const filtered = directives.filter((directive) => {
const name = directive.split(/\s+/, 1)[0]?.toLowerCase() ?? '';
return name !== 'frame-ancestors';
});
if (filtered.length === 0) {
return null;
}
return filtered.join('; ');
};
// Drop response headers that prevent the dev server from being framed.
// The proxy itself is same-origin, so embedding is otherwise safe.
const stripFrameBustingHeaders = (headers) => {
// Drop only CSP directives that prevent framing or the injected preview bridge.
// Preview targets are restricted to loopback dev servers.
const stripFrameBustingHeaders = (headers, bridgeNonce) => {
if (!headers || typeof headers !== 'object') {
return;
}
@@ -1119,7 +1248,7 @@ export const createPreviewProxyRuntime = ({
const original = headers[key];
const values = Array.isArray(original) ? original : [original];
const rewritten = values
.map((value) => removeFrameAncestorsDirective(value))
.map((value) => rewritePreviewCspHeader(value, bridgeNonce))
.filter((value) => typeof value === 'string' && value.length > 0);
if (rewritten.length === 0) {
delete headers[key];
@@ -1139,13 +1268,14 @@ export const createPreviewProxyRuntime = ({
}) => {
ensureSweeper();
const injectPreviewBridge = (bodyText, targetOrigin) => {
const injectPreviewBridge = (bodyText, targetOrigin, bridgeNonce) => {
if (typeof bodyText !== 'string' || bodyText.includes(PREVIEW_BRIDGE_SCRIPT_ID)) {
return bodyText;
}
const targetOriginScript = `<script>window.__openchamberPreviewTargetOrigin=${JSON.stringify(targetOrigin || '')};</script>`;
const script = `${targetOriginScript}<script id="${PREVIEW_BRIDGE_SCRIPT_ID}">${PREVIEW_BRIDGE_SCRIPT}</script>`;
const nonceAttr = bridgeNonce ? ` nonce="${bridgeNonce}"` : '';
const targetOriginScript = `<script${nonceAttr}>window.__openchamberPreviewTargetOrigin=${JSON.stringify(targetOrigin || '')};</script>`;
const script = `${targetOriginScript}<script id="${PREVIEW_BRIDGE_SCRIPT_ID}"${nonceAttr}>${PREVIEW_BRIDGE_SCRIPT}</script>`;
if (/<head(?:\s[^>]*)?>/i.test(bodyText)) {
return bodyText.replace(/<head(\s[^>]*)?>/i, (match) => `${match}${script}`);
}
@@ -1216,6 +1346,7 @@ export const createPreviewProxyRuntime = ({
return res.json({
id: target.id,
proxyBasePath: cookiePath,
previewToken: target.token,
expiresAt: target.expiresAt,
});
} catch (error) {
@@ -1260,7 +1391,11 @@ export const createPreviewProxyRuntime = ({
const parsed = new URL(req.originalUrl || req.url || '', 'http://localhost');
// Never forward our auth cookie token to the dev server.
const strippedPath = stripProxyPrefix(parsed.pathname, resolved.id);
return `${strippedPath}${removeRawQueryParam(parsed.search, 'ocPreview')}`;
const withoutReloadParam = removeRawQueryParam(parsed.search, 'ocPreview');
const withoutPreviewToken = removeRawQueryParam(withoutReloadParam, TOKEN_QUERY_PARAM);
const withoutClientToken = removeRawQueryParam(withoutPreviewToken, CLIENT_TOKEN_QUERY_PARAM);
const withoutUrlAuthToken = removeRawQueryParam(withoutClientToken, URL_AUTH_TOKEN_QUERY_PARAM);
return `${strippedPath}${withoutUrlAuthToken}`;
},
on: {
proxyReq: (proxyReq) => {
@@ -1271,10 +1406,30 @@ export const createPreviewProxyRuntime = ({
proxyReq.setHeader('accept-encoding', 'identity');
},
proxyRes: responseInterceptor(async (responseBuffer, proxyRes, req) => {
// Per-response nonce lets the injected bridge run under the dev
// server's CSP without dropping its script restrictions wholesale.
const bridgeNonce = crypto.randomBytes(16).toString('base64');
// Allow the dev server response to be framed inside OpenChamber even
// if it normally sets X-Frame-Options or a CSP frame-ancestors rule.
// The proxy is same-origin so embedding is otherwise safe.
stripFrameBustingHeaders(proxyRes.headers);
stripFrameBustingHeaders(proxyRes.headers, bridgeNonce);
const resolved = resolveTargetFromRequest(req);
if (!resolved.ok) {
return responseBuffer;
}
const proxyBasePath = `/api/preview/proxy/${resolved.id}`;
const urlAuthToken = resolved.parsed.searchParams.get(URL_AUTH_TOKEN_QUERY_PARAM) || '';
if (typeof proxyRes.headers?.location === 'string') {
proxyRes.headers.location = rewritePreviewRedirectLocation({
location: proxyRes.headers.location,
proxyBasePath,
targetOrigin: resolved.entry.origin,
previewToken: resolved.entry.token,
urlAuthToken,
});
}
const contentType = String(proxyRes.headers?.['content-type'] || '').toLowerCase();
const isHtml = contentType.includes('text/html');
@@ -1290,12 +1445,6 @@ export const createPreviewProxyRuntime = ({
delete proxyRes.headers.etag;
delete proxyRes.headers['last-modified'];
const resolved = resolveTargetFromRequest(req);
if (!resolved.ok) {
return responseBuffer;
}
const proxyBasePath = `/api/preview/proxy/${resolved.id}`;
const parsed = new URL(req.originalUrl || req.url || '', 'http://localhost');
const upstreamPath = stripProxyPrefix(parsed.pathname, resolved.id);
if (isJavaScript && upstreamPath === '/@vite/client') {
@@ -1304,6 +1453,8 @@ export const createPreviewProxyRuntime = ({
proxyBasePath,
targetOrigin: resolved.entry.origin,
kind: 'javascript',
previewToken: resolved.entry.token,
urlAuthToken,
});
}
@@ -1312,8 +1463,10 @@ export const createPreviewProxyRuntime = ({
proxyBasePath,
targetOrigin: resolved.entry.origin,
kind: isHtml ? 'html' : isCss ? 'css' : 'javascript',
previewToken: resolved.entry.token,
urlAuthToken,
});
return isHtml ? injectPreviewBridge(rewrittenBody, resolved.entry.origin) : rewrittenBody;
return isHtml ? injectPreviewBridge(rewrittenBody, resolved.entry.origin, bridgeNonce) : rewrittenBody;
}),
error: (err, _req, res) => {
const isDev = typeof process !== 'undefined'
@@ -1366,12 +1519,6 @@ export const createPreviewProxyRuntime = ({
const handleUpgrade = async () => {
try {
if (uiAuthController?.enabled) {
const sessionToken = await uiAuthController?.ensureSessionToken?.(req, null);
if (!sessionToken) {
rejectWebSocketUpgrade(socket, 401, 'UI authentication required');
return;
}
const originAllowed = await isRequestOriginAllowed(req);
if (!originAllowed) {
rejectWebSocketUpgrade(socket, 403, 'Invalid origin');
@@ -1384,6 +1531,10 @@ export const createPreviewProxyRuntime = ({
req.originalUrl = rawUrl;
const parsed = new URL(rawUrl, 'http://localhost');
const nextPath = stripProxyPrefix(parsed.pathname, resolved.id);
parsed.searchParams.delete('ocPreview');
parsed.searchParams.delete(TOKEN_QUERY_PARAM);
parsed.searchParams.delete(CLIENT_TOKEN_QUERY_PARAM);
parsed.searchParams.delete(URL_AUTH_TOKEN_QUERY_PARAM);
const search = parsed.searchParams.toString();
req.url = `${nextPath}${search ? `?${search}` : ''}`;
proxy.upgrade(req, socket, head);