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
@@ -0,0 +1,44 @@
import { describe, test } from 'node:test';
import assert from 'node:assert/strict';
describe('VS Code webview bridge requests', () => {
test('rejects immediately when signal is already aborted', async () => {
const originalWindow = globalThis.window;
const originalAcquire = (globalThis as typeof globalThis & { acquireVsCodeApi?: unknown }).acquireVsCodeApi;
const messages: unknown[] = [];
try {
Object.defineProperty(globalThis, 'window', {
configurable: true,
value: new EventTarget(),
});
Object.defineProperty(globalThis, 'acquireVsCodeApi', {
configurable: true,
value: () => ({
postMessage: (message: unknown) => messages.push(message),
getState: () => undefined,
setState: () => undefined,
}),
});
const { sendBridgeMessageWithOptions } = await import('./bridge');
const controller = new AbortController();
controller.abort();
const result = await Promise.race([
sendBridgeMessageWithOptions('api:proxy', undefined, { signal: controller.signal }).then(
() => 'resolved',
(error: unknown) => error,
),
new Promise((resolve) => setTimeout(() => resolve('timeout'), 20)),
]);
assert.ok(result instanceof DOMException);
assert.equal(result.name, 'AbortError');
assert.equal(messages.length, 0);
} finally {
Object.defineProperty(globalThis, 'window', { configurable: true, value: originalWindow });
Object.defineProperty(globalThis, 'acquireVsCodeApi', { configurable: true, value: originalAcquire });
}
});
});
+42 -3
View File
@@ -40,6 +40,7 @@ const pendingRequests = new Map<string, {
resolve: (value: unknown) => void;
reject: (reason: Error) => void;
timeout?: ReturnType<typeof setTimeout>;
onAbort?: () => void;
}>();
let requestIdCounter = 0;
@@ -59,6 +60,9 @@ window.addEventListener('message', (event: MessageEvent<BridgeResponse>) => {
if (pending.timeout) {
clearTimeout(pending.timeout);
}
if (pending.onAbort) {
pending.onAbort();
}
if (response.success) {
pending.resolve(response.data);
} else {
@@ -74,7 +78,7 @@ export function sendBridgeMessage<T = unknown>(type: string, payload?: unknown):
export function sendBridgeMessageWithOptions<T = unknown>(
type: string,
payload?: unknown,
options?: { timeoutMs?: number }
options?: { timeoutMs?: number; signal?: AbortSignal; onAbort?: (id: string) => void }
): Promise<T> {
return new Promise((resolve, reject) => {
const id = `req_${++requestIdCounter}_${Date.now()}`;
@@ -84,10 +88,30 @@ export function sendBridgeMessageWithOptions<T = unknown>(
resolve: (value: unknown) => void;
reject: (reason: Error) => void;
timeout?: ReturnType<typeof setTimeout>;
onAbort?: () => void;
} = {
resolve: resolve as (value: unknown) => void,
reject,
};
if (options?.signal) {
const abort = () => {
if (!pendingRequests.has(id)) return;
pendingRequests.delete(id);
if (pending.timeout) {
clearTimeout(pending.timeout);
}
options.onAbort?.(id);
reject(new DOMException('Aborted', 'AbortError'));
};
if (options.signal.aborted) {
reject(new DOMException('Aborted', 'AbortError'));
return;
}
options.signal.addEventListener('abort', abort, { once: true });
pending.onAbort = () => options.signal?.removeEventListener('abort', abort);
}
pendingRequests.set(id, pending);
const timeoutMs = typeof options?.timeoutMs === 'number' ? options.timeoutMs : 30000;
@@ -95,6 +119,9 @@ export function sendBridgeMessageWithOptions<T = unknown>(
pending.timeout = setTimeout(() => {
if (pendingRequests.has(id)) {
pendingRequests.delete(id);
if (pending.onAbort) {
pending.onAbort();
}
reject(new Error(`Request ${type} timed out`));
}
}, timeoutMs);
@@ -116,19 +143,31 @@ export async function proxyApiRequest(options: {
path: string;
headers?: Record<string, string>;
bodyBase64?: string;
signal?: AbortSignal;
}): Promise<ProxiedApiResponse> {
// Do not impose a bridge-level timeout. Let the original fetch's AbortSignal
// (or OpenCode server response timing) control the lifecycle.
return sendBridgeMessageWithOptions<ProxiedApiResponse>('api:proxy', options, { timeoutMs: 0 });
const { signal, ...payload } = options;
return sendBridgeMessageWithOptions<ProxiedApiResponse>('api:proxy', payload, {
timeoutMs: 0,
signal,
onAbort: (requestID) => getVSCodeAPI().postMessage({ id: `abort_${requestID}`, type: 'api:proxy:abort', payload: { requestID } }),
});
}
export async function proxySessionMessageRequest(options: {
path: string;
headers?: Record<string, string>;
bodyText: string;
signal?: AbortSignal;
}): Promise<ProxiedApiResponse> {
// Keep parity with server-side direct forwarder: let extension host control timeout.
return sendBridgeMessageWithOptions<ProxiedApiResponse>('api:session:message', options, { timeoutMs: 0 });
const { signal, ...payload } = options;
return sendBridgeMessageWithOptions<ProxiedApiResponse>('api:session:message', payload, {
timeoutMs: 0,
signal,
onAbort: (requestID) => getVSCodeAPI().postMessage({ id: `abort_${requestID}`, type: 'api:proxy:abort', payload: { requestID } }),
});
}
export type ProxiedSseStartResponse = {
+22 -22
View File
@@ -36,30 +36,30 @@ export const createVSCodeFilesAPI = (): FilesAPI => ({
async search(payload: FileSearchQuery): Promise<FileSearchResult[]> {
const directory = normalizePath(payload.directory);
const params = new URLSearchParams();
if (directory) {
params.set('directory', directory);
}
params.set('query', payload.query);
params.set('dirs', 'false');
params.set('type', 'file');
if (typeof payload.maxResults === 'number' && Number.isFinite(payload.maxResults)) {
params.set('limit', String(payload.maxResults));
}
const data = await sendBridgeMessage<{
files?: Array<{ path?: string; relativePath?: string }>;
}>('api:fs:search', {
directory,
query: payload.query,
limit: payload.maxResults,
includeHidden: false,
respectGitignore: true,
});
const response = await fetch(`/api/find/file?${params.toString()}`);
if (!response.ok) {
const error = await response.json().catch(() => ({ error: response.statusText }));
throw new Error((error as { error?: string }).error || 'Failed to search files');
}
const files = Array.isArray(data?.files) ? data.files : [];
const result = (await response.json()) as string[];
const files = Array.isArray(result) ? result : [];
return files.map((relativePath) => ({
path: normalizePath(`${directory}/${relativePath}`),
preview: [normalizePath(relativePath)],
}));
return files.map((file) => {
const relativePath = typeof file.relativePath === 'string'
? normalizePath(file.relativePath)
: normalizePath(file.path || '');
const absolutePath = typeof file.path === 'string'
? normalizePath(file.path)
: normalizePath(`${directory}/${relativePath}`);
return {
path: absolutePath,
preview: [relativePath || absolutePath],
};
});
},
async createDirectory(path: string): Promise<{ success: boolean; path: string }> {
+14 -41
View File
@@ -1,8 +1,5 @@
import type { SettingsAPI, SettingsLoadResult, SettingsPayload } from '@openchamber/ui/lib/api/types';
// Use same endpoints as web - fetch interceptor handles URL rewriting
const SETTINGS_ENDPOINT = '/api/config/settings';
const RELOAD_ENDPOINT = '/api/config/reload';
import { sendBridgeMessage } from './bridge';
const sanitizePayload = (data: unknown): SettingsPayload => {
if (!data || typeof data !== 'object') {
@@ -13,12 +10,17 @@ const sanitizePayload = (data: unknown): SettingsPayload => {
export const createVSCodeSettingsAPI = (): SettingsAPI => ({
async load(): Promise<SettingsLoadResult> {
const response = await fetch(SETTINGS_ENDPOINT, {
method: 'GET',
headers: { Accept: 'application/json' },
});
if (!response.ok) {
try {
const payload = sanitizePayload(await sendBridgeMessage('api:config/settings:get'));
return {
settings: {
...payload,
// Override with VS Code settings
lastDirectory: window.__VSCODE_CONFIG__?.workspaceFolder || payload.lastDirectory || '',
},
source: 'web',
};
} catch {
// Fallback to VS Code config
return {
settings: {
@@ -28,43 +30,14 @@ export const createVSCodeSettingsAPI = (): SettingsAPI => ({
source: 'web',
};
}
const payload = sanitizePayload(await response.json().catch(() => ({})));
return {
settings: {
...payload,
// Override with VS Code settings
lastDirectory: window.__VSCODE_CONFIG__?.workspaceFolder || payload.lastDirectory || '',
},
source: 'web',
};
},
async save(changes: Partial<SettingsPayload>): Promise<SettingsPayload> {
const response = await fetch(SETTINGS_ENDPOINT, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
Accept: 'application/json',
},
body: JSON.stringify(changes),
});
if (!response.ok) {
const error = await response.json().catch(() => ({ error: response.statusText }));
throw new Error(error.error || 'Failed to save settings');
}
const payload = sanitizePayload(await response.json().catch(() => ({})));
return payload;
return sanitizePayload(await sendBridgeMessage('api:config/settings:save', changes));
},
async restartOpenCode(): Promise<{ restarted: boolean }> {
const response = await fetch(RELOAD_ENDPOINT, { method: 'POST' });
if (!response.ok) {
const error = await response.json().catch(() => ({ error: response.statusText }));
throw new Error(error.error || 'Failed to restart OpenCode');
}
await sendBridgeMessage('api:config/reload');
return { restarted: true };
},
});
+2 -9
View File
@@ -1,16 +1,9 @@
import type { ToolsAPI } from '@openchamber/ui/lib/api/types';
import { opencodeClient } from '@openchamber/ui/lib/opencode/client';
// Use same endpoint as web - fetch interceptor handles URL rewriting
export const createVSCodeToolsAPI = (): ToolsAPI => ({
async getAvailableTools(): Promise<string[]> {
const response = await fetch('/api/experimental/tool/ids');
if (!response.ok) {
throw new Error(`Tools API returned ${response.status} ${response.statusText}`);
}
const data = await response.json();
const data = await opencodeClient.listToolIds();
if (!Array.isArray(data)) {
throw new Error('Tools API returned invalid data format');
}
+13 -1
View File
@@ -1,5 +1,5 @@
import type { VSCodeAPI } from '@openchamber/ui/lib/api/types';
import { executeVSCodeCommand, openVSCodeExternalUrl } from './bridge';
import { executeVSCodeCommand, openVSCodeExternalUrl, sendBridgeMessage } from './bridge';
export const createVSCodeActionsAPI = (): VSCodeAPI => ({
async executeCommand(command: string, ...args: unknown[]): Promise<unknown> {
@@ -14,4 +14,16 @@ export const createVSCodeActionsAPI = (): VSCodeAPI => ({
async openExternalUrl(url: string): Promise<void> {
await openVSCodeExternalUrl(url);
},
async pickFiles(): Promise<unknown> {
return sendBridgeMessage('api:files/pick');
},
async saveImage(payload: unknown): Promise<unknown> {
return sendBridgeMessage('api:files/save-image', payload);
},
async saveMarkdown(payload: unknown): Promise<unknown> {
return sendBridgeMessage('api:files/save-markdown', payload);
},
});
+233 -192
View File
@@ -1,7 +1,9 @@
import { createVSCodeAPIs } from './api';
import { onCommand, onThemeChange, proxyApiRequest, proxySessionMessageRequest, sendBridgeMessage, startSseProxy, stopSseProxy } from './api/bridge';
import { vscodeStreamPerfCount, vscodeStreamPerfMeasure, vscodeStreamPerfObserve } from './api/streamPerf';
import { extractBodyBase64, extractBodyText, extractJsonBody, hasInitBody } from './requestBodyTransport';
import type { RuntimeAPIs } from '@openchamber/ui/lib/api/types';
import { opencodeClient } from '@openchamber/ui/lib/opencode/client';
import {
buildVSCodeThemeFromPalette,
readVSCodeThemePalette,
@@ -315,6 +317,22 @@ const headersToRecord = (headers: HeadersInit | undefined): Record<string, strin
return result;
};
const getRequestHeaders = (input?: RequestInfo | URL, init?: RequestInit): Record<string, string> => {
const headersFromRequest = input instanceof Request ? headersToRecord(input.headers) : {};
const headersFromInit = headersToRecord(init?.headers);
return { ...headersFromRequest, ...headersFromInit };
};
const getRequestDirectoryHint = (url: URL, input?: RequestInfo | URL, init?: RequestInit): string | undefined => {
const queryDirectory = url.searchParams.get('directory') || undefined;
if (queryDirectory) return queryDirectory;
const headers = getRequestHeaders(input, init);
for (const [key, value] of Object.entries(headers)) {
if (key.toLowerCase() === 'x-opencode-directory') return value;
}
return undefined;
};
const decodeBase64 = (value: string): Uint8Array => {
const binary = atob(value);
const bytes = new Uint8Array(binary.length);
@@ -324,6 +342,22 @@ const decodeBase64 = (value: string): Uint8Array => {
return bytes;
};
const jsonResponse = (body: unknown, status = 200): Response => {
return new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json' } });
};
const unsupportedWebRouteResponse = (feature: string): Response => {
return jsonResponse({ error: `${feature} is not supported in VS Code` }, 501);
};
const pluginConfigErrorStatus = (message: string): number => {
const lower = message.toLowerCase();
if (lower.includes('already exists')) return 409;
if (lower.includes('not found')) return 404;
if (lower.includes('required') || lower.includes('invalid') || lower.includes('must ')) return 400;
return 500;
};
const isNullBodyStatus = (status: number): boolean => status === 204 || status === 205 || status === 304;
const buildProxiedResponse = (
@@ -341,80 +375,36 @@ const buildProxiedResponse = (
return new Response(body, { status: proxied.status, headers: proxied.headers });
};
const encodeBase64 = (bytes: Uint8Array): string => {
const CHUNK = 0x8000;
let binary = '';
for (let i = 0; i < bytes.length; i += CHUNK) {
binary += String.fromCharCode(...bytes.subarray(i, i + CHUNK));
}
return btoa(binary);
};
const extractBodyBase64 = async (input: RequestInfo | URL, init: RequestInit | undefined, method: string): Promise<string | undefined> => {
if (method === 'GET' || method === 'HEAD') return undefined;
if (input instanceof Request) {
const cloned = input.clone();
const buffer = await cloned.arrayBuffer();
const bytes = new Uint8Array(buffer);
return bytes.length > 0 ? encodeBase64(bytes) : undefined;
}
const body = init?.body;
if (!body) return undefined;
if (typeof body === 'string') {
return encodeBase64(new TextEncoder().encode(body));
}
if (body instanceof URLSearchParams) {
return encodeBase64(new TextEncoder().encode(body.toString()));
}
if (body instanceof Blob) {
const buffer = await body.arrayBuffer();
const bytes = new Uint8Array(buffer);
return bytes.length > 0 ? encodeBase64(bytes) : undefined;
}
console.warn('[OpenChamber] Unsupported request body type for proxy request:', body);
return undefined;
};
const extractBodyText = async (input: RequestInfo | URL, init: RequestInit | undefined, method: string): Promise<string> => {
if (method === 'GET' || method === 'HEAD') return '';
if (input instanceof Request) {
const cloned = input.clone();
return await cloned.text();
}
const body = init?.body;
if (!body) return '';
if (typeof body === 'string') {
return body;
}
if (body instanceof URLSearchParams) {
return body.toString();
}
if (body instanceof Blob) {
return await body.text();
}
console.warn('[OpenChamber] Unsupported request body type for direct session proxy:', body);
return '';
};
const isSseApiPath = (pathname: string) => pathname === '/api/event' || pathname === '/api/global/event';
const isSessionMessageApiPath = (pathname: string) => /^\/api\/session\/[^/]+\/message$/.test(pathname);
const isApiPath = (pathname: string) => pathname === '/api' || pathname.startsWith('/api/');
const isLocalRuntimePath = (pathname: string) => isApiPath(pathname) || pathname === '/auth/session';
const handleLocalApiRequest = async (url: URL, init?: RequestInit) => {
const handleLocalApiRequest = async (input: RequestInfo | URL, url: URL, init: RequestInit | undefined, method: string) => {
const pathname = url.pathname;
const normalizedPathname = pathname !== '/' ? pathname.replace(/\/+$/, '') : pathname;
const method = ((init?.method || 'GET') as string).toUpperCase();
if (normalizedPathname === '/api/system/info' && method === 'GET') {
const config = window.__VSCODE_CONFIG__;
return jsonResponse({
openchamberVersion: config?.extensionVersion || 'VS Code Extension',
runtime: 'vscode',
platform: config?.platform || '',
arch: config?.arch || '',
});
}
if (normalizedPathname === '/api/preview/targets') {
return unsupportedWebRouteResponse('Preview proxy');
}
if (normalizedPathname.startsWith('/api/openchamber/tunnel/')) {
return unsupportedWebRouteResponse('Remote tunnel settings');
}
if (/^\/api\/projects\/[^/]+\/scheduled-tasks(?:\/[^/]+)?$/.test(normalizedPathname)) {
return unsupportedWebRouteResponse('Scheduled tasks');
}
if (normalizedPathname === '/api/sessions/snapshot' && method === 'GET') {
const activity = await sendBridgeMessage<Record<string, { type: 'idle' | 'busy' | 'cooldown' }>>('api:session-activity:get')
@@ -575,7 +565,7 @@ const handleLocalApiRequest = async (url: URL, init?: RequestInit) => {
}
if (pathname.startsWith('/api/fs/mkdir')) {
const body = init?.body ? JSON.parse(init.body as string) : {};
const body = await extractJsonBody(input, init, method);
const data = await sendBridgeMessage('api:fs:mkdir', { path: body.path });
return new Response(JSON.stringify(data), { status: 200, headers: { 'Content-Type': 'application/json' } });
}
@@ -591,7 +581,7 @@ const handleLocalApiRequest = async (url: URL, init?: RequestInit) => {
}
if (pathname.startsWith('/api/vscode/drop-files') && method === 'POST') {
const body = init?.body ? JSON.parse(init.body as string) : {};
const body = await extractJsonBody(input, init, method);
const uris = Array.isArray((body as { uris?: unknown[] }).uris)
? (body as { uris: unknown[] }).uris.filter((value): value is string => typeof value === 'string')
: [];
@@ -600,7 +590,7 @@ const handleLocalApiRequest = async (url: URL, init?: RequestInit) => {
}
if (pathname.startsWith('/api/vscode/save-image') && method === 'POST') {
const body = init?.body ? JSON.parse(init.body as string) : {};
const body = await extractJsonBody(input, init, method);
const fileName = typeof (body as { fileName?: unknown }).fileName === 'string'
? (body as { fileName: string }).fileName
: undefined;
@@ -612,7 +602,7 @@ const handleLocalApiRequest = async (url: URL, init?: RequestInit) => {
}
if (pathname.startsWith('/api/vscode/save-markdown') && method === 'POST') {
const body = init?.body ? JSON.parse(init.body as string) : {};
const body = await extractJsonBody(input, init, method);
const fileName = typeof (body as { fileName?: unknown }).fileName === 'string'
? (body as { fileName: string }).fileName
: undefined;
@@ -626,29 +616,9 @@ const handleLocalApiRequest = async (url: URL, init?: RequestInit) => {
if (pathname.startsWith('/api/config/agents/')) {
const encodedName = pathname.slice('/api/config/agents/'.length);
const name = decodeURIComponent(encodedName);
const verb = ((init?.method || 'GET') as string).toUpperCase();
const body = init?.body ? JSON.parse(init.body as string) : {};
const queryDirectory = url.searchParams.get('directory') || undefined;
const headerDirectory = (() => {
const headers = init?.headers;
if (!headers) return undefined;
if (headers instanceof Headers) {
return headers.get('x-opencode-directory') || undefined;
}
if (Array.isArray(headers)) {
const found = headers.find(([key]) => key.toLowerCase() === 'x-opencode-directory');
return found?.[1] || undefined;
}
if (typeof headers === 'object') {
for (const [key, value] of Object.entries(headers)) {
if (key.toLowerCase() === 'x-opencode-directory' && typeof value === 'string') {
return value;
}
}
}
return undefined;
})();
const directory = queryDirectory || headerDirectory;
const verb = method;
const body = await extractJsonBody(input, init, method);
const directory = getRequestDirectoryHint(url, input, init);
try {
const data = await sendBridgeMessage('api:config/agents', { method: verb, name, body, directory });
return new Response(JSON.stringify(data), { status: 200, headers: { 'Content-Type': 'application/json' } });
@@ -661,29 +631,9 @@ const handleLocalApiRequest = async (url: URL, init?: RequestInit) => {
if (pathname.startsWith('/api/config/commands/')) {
const encodedName = pathname.slice('/api/config/commands/'.length);
const name = decodeURIComponent(encodedName);
const verb = ((init?.method || 'GET') as string).toUpperCase();
const body = init?.body ? JSON.parse(init.body as string) : {};
const queryDirectory = url.searchParams.get('directory') || undefined;
const headerDirectory = (() => {
const headers = init?.headers;
if (!headers) return undefined;
if (headers instanceof Headers) {
return headers.get('x-opencode-directory') || undefined;
}
if (Array.isArray(headers)) {
const found = headers.find(([key]) => key.toLowerCase() === 'x-opencode-directory');
return found?.[1] || undefined;
}
if (typeof headers === 'object') {
for (const [key, value] of Object.entries(headers)) {
if (key.toLowerCase() === 'x-opencode-directory' && typeof value === 'string') {
return value;
}
}
}
return undefined;
})();
const directory = queryDirectory || headerDirectory;
const verb = method;
const body = await extractJsonBody(input, init, method);
const directory = getRequestDirectoryHint(url, input, init);
try {
const data = await sendBridgeMessage('api:config/commands', { method: verb, name, body, directory });
return new Response(JSON.stringify(data), { status: 200, headers: { 'Content-Type': 'application/json' } });
@@ -694,29 +644,9 @@ const handleLocalApiRequest = async (url: URL, init?: RequestInit) => {
}
if (pathname === '/api/config/mcp') {
const verb = ((init?.method || 'GET') as string).toUpperCase();
const body = init?.body ? JSON.parse(init.body as string) : {};
const queryDirectory = url.searchParams.get('directory') || undefined;
const headerDirectory = (() => {
const headers = init?.headers;
if (!headers) return undefined;
if (headers instanceof Headers) {
return headers.get('x-opencode-directory') || undefined;
}
if (Array.isArray(headers)) {
const found = headers.find(([key]) => key.toLowerCase() === 'x-opencode-directory');
return found?.[1] || undefined;
}
if (typeof headers === 'object') {
for (const [key, value] of Object.entries(headers)) {
if (key.toLowerCase() === 'x-opencode-directory' && typeof value === 'string') {
return value;
}
}
}
return undefined;
})();
const directory = queryDirectory || headerDirectory;
const verb = method;
const body = await extractJsonBody(input, init, method);
const directory = getRequestDirectoryHint(url, input, init);
try {
const data = await sendBridgeMessage('api:config/mcp', { method: verb, body, directory });
return new Response(JSON.stringify(data), { status: 200, headers: { 'Content-Type': 'application/json' } });
@@ -729,29 +659,9 @@ const handleLocalApiRequest = async (url: URL, init?: RequestInit) => {
if (pathname.startsWith('/api/config/mcp/')) {
const encodedName = pathname.slice('/api/config/mcp/'.length);
const name = decodeURIComponent(encodedName);
const verb = ((init?.method || 'GET') as string).toUpperCase();
const body = init?.body ? JSON.parse(init.body as string) : {};
const queryDirectory = url.searchParams.get('directory') || undefined;
const headerDirectory = (() => {
const headers = init?.headers;
if (!headers) return undefined;
if (headers instanceof Headers) {
return headers.get('x-opencode-directory') || undefined;
}
if (Array.isArray(headers)) {
const found = headers.find(([key]) => key.toLowerCase() === 'x-opencode-directory');
return found?.[1] || undefined;
}
if (typeof headers === 'object') {
for (const [key, value] of Object.entries(headers)) {
if (key.toLowerCase() === 'x-opencode-directory' && typeof value === 'string') {
return value;
}
}
}
return undefined;
})();
const directory = queryDirectory || headerDirectory;
const verb = method;
const body = await extractJsonBody(input, init, method);
const directory = getRequestDirectoryHint(url, input, init);
try {
const data = await sendBridgeMessage('api:config/mcp', { method: verb, name, body, directory });
return new Response(JSON.stringify(data), { status: 200, headers: { 'Content-Type': 'application/json' } });
@@ -761,13 +671,53 @@ const handleLocalApiRequest = async (url: URL, init?: RequestInit) => {
}
}
if (pathname === '/api/config/snippets') {
const verb = method;
const directory = getRequestDirectoryHint(url, input, init);
try {
const data = await sendBridgeMessage('api:config/snippets', { method: verb, directory });
return new Response(JSON.stringify(data), { status: 200, headers: { 'Content-Type': 'application/json' } });
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
return new Response(JSON.stringify({ error: message }), { status: 500, headers: { 'Content-Type': 'application/json' } });
}
}
if (pathname === '/api/config/snippets/expand') {
const verb = method === 'GET' && !hasInitBody(init) && !(input instanceof Request) ? 'POST' : method;
const body = await extractJsonBody(input, init, method);
const directory = getRequestDirectoryHint(url, input, init);
try {
const data = await sendBridgeMessage('api:config/snippets', { method: verb, body, directory });
return new Response(JSON.stringify(data), { status: 200, headers: { 'Content-Type': 'application/json' } });
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
return new Response(JSON.stringify({ error: message }), { status: 500, headers: { 'Content-Type': 'application/json' } });
}
}
if (pathname.startsWith('/api/config/snippets/')) {
const encodedName = pathname.slice('/api/config/snippets/'.length);
const name = decodeURIComponent(encodedName);
const verb = method;
const body = await extractJsonBody(input, init, method);
const directory = getRequestDirectoryHint(url, input, init);
try {
const data = await sendBridgeMessage('api:config/snippets', { method: verb, name, body, directory });
return new Response(JSON.stringify(data), { status: 200, headers: { 'Content-Type': 'application/json' } });
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
return new Response(JSON.stringify({ error: message }), { status: 500, headers: { 'Content-Type': 'application/json' } });
}
}
// Skills file operations: /api/config/skills/:name/files/:filePath
const skillsFilesMatch = pathname.match(/^\/api\/config\/skills\/([^/]+)\/files\/(.+)$/);
if (skillsFilesMatch) {
const name = decodeURIComponent(skillsFilesMatch[1]);
const filePath = decodeURIComponent(skillsFilesMatch[2]);
const verb = ((init?.method || 'GET') as string).toUpperCase();
const body = init?.body ? JSON.parse(init.body as string) : {};
const verb = method;
const body = await extractJsonBody(input, init, method);
try {
const data = await sendBridgeMessage('api:config/skills/files', {
method: verb,
@@ -808,7 +758,7 @@ const handleLocalApiRequest = async (url: URL, init?: RequestInit) => {
// Skills scan: /api/config/skills/scan
if (pathname === '/api/config/skills/scan') {
const body = init?.body ? JSON.parse(init.body as string) : {};
const body = await extractJsonBody(input, init, method);
try {
const data = await sendBridgeMessage('api:config/skills:scan', body);
return new Response(JSON.stringify(data), { status: skillsCatalogStatusFromPayload(data), headers: { 'Content-Type': 'application/json' } });
@@ -820,7 +770,7 @@ const handleLocalApiRequest = async (url: URL, init?: RequestInit) => {
// Skills install: /api/config/skills/install
if (pathname === '/api/config/skills/install') {
const body = init?.body ? JSON.parse(init.body as string) : {};
const body = await extractJsonBody(input, init, method);
try {
const data = await sendBridgeMessage('api:config/skills:install', body);
return new Response(JSON.stringify(data), { status: skillsCatalogStatusFromPayload(data), headers: { 'Content-Type': 'application/json' } });
@@ -844,8 +794,8 @@ const handleLocalApiRequest = async (url: URL, init?: RequestInit) => {
if (pathname.startsWith('/api/config/skills/')) {
const encodedName = pathname.slice('/api/config/skills/'.length);
const name = decodeURIComponent(encodedName);
const verb = ((init?.method || 'GET') as string).toUpperCase();
const body = init?.body ? JSON.parse(init.body as string) : {};
const verb = method;
const body = await extractJsonBody(input, init, method);
try {
const data = await sendBridgeMessage('api:config/skills', { method: verb, name, body });
return new Response(JSON.stringify(data), { status: 200, headers: { 'Content-Type': 'application/json' } });
@@ -856,11 +806,11 @@ const handleLocalApiRequest = async (url: URL, init?: RequestInit) => {
}
if (pathname.startsWith('/api/config/settings')) {
if ((init?.method || 'GET').toUpperCase() === 'GET') {
if (method === 'GET') {
const settings = await sendBridgeMessage('api:config/settings:get');
return new Response(JSON.stringify(settings), { status: 200, headers: { 'Content-Type': 'application/json' } });
}
const body = init?.body ? JSON.parse(init.body as string) : {};
const body = await extractJsonBody(input, init, method);
const updated = await sendBridgeMessage('api:config/settings:save', body);
return new Response(JSON.stringify(updated), { status: 200, headers: { 'Content-Type': 'application/json' } });
}
@@ -871,7 +821,7 @@ const handleLocalApiRequest = async (url: URL, init?: RequestInit) => {
return new Response(JSON.stringify(data), { status: 200, headers: { 'Content-Type': 'application/json' } });
}
if (method === 'PUT') {
const body = init?.body ? JSON.parse(init.body as string) : {};
const body = await extractJsonBody(input, init, method);
const data = await sendBridgeMessage('api:behavior/agents-md:save', body);
return new Response(JSON.stringify(data), { status: 200, headers: { 'Content-Type': 'application/json' } });
}
@@ -891,7 +841,7 @@ const handleLocalApiRequest = async (url: URL, init?: RequestInit) => {
if (pathname.startsWith('/api/magic-prompts/')) {
const id = decodeURIComponent(pathname.slice('/api/magic-prompts/'.length));
if (method === 'PUT') {
const body = init?.body ? JSON.parse(init.body as string) : {};
const body = await extractJsonBody(input, init, method);
const data = await sendBridgeMessage('api:magic-prompts:save', { id, text: body?.text });
return new Response(JSON.stringify(data), { status: 200, headers: { 'Content-Type': 'application/json' } });
}
@@ -916,6 +866,98 @@ const handleLocalApiRequest = async (url: URL, init?: RequestInit) => {
return new Response(JSON.stringify({ restarted: true }), { status: 200, headers: { 'Content-Type': 'application/json' } });
}
if (pathname === '/api/config/plugins' && method === 'GET') {
try {
const directory = getRequestDirectoryHint(url, input, init);
const data = await sendBridgeMessage('api:config/plugins', { method, target: 'list', directory });
return jsonResponse(data);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
return jsonResponse({ error: message }, pluginConfigErrorStatus(message));
}
}
if (pathname === '/api/config/plugins/registry' && method === 'GET') {
try {
const rawSpecs = url.searchParams.get('specs') || '';
const specs = rawSpecs ? rawSpecs.split(',').map((spec) => spec.trim()).filter(Boolean) : [];
const directory = getRequestDirectoryHint(url, input, init);
const data = await sendBridgeMessage('api:config/plugins', {
method,
target: 'registry',
specs,
refresh: url.searchParams.get('refresh') === 'true',
directory,
});
return jsonResponse(data);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
return jsonResponse({ error: message }, pluginConfigErrorStatus(message));
}
}
if (pathname === '/api/config/plugins/entry' && method === 'POST') {
try {
const body = await extractJsonBody(input, init, method);
const directory = getRequestDirectoryHint(url, input, init);
const data = await sendBridgeMessage('api:config/plugins', { method, target: 'entry', body, directory });
return jsonResponse(data);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
return jsonResponse({ error: message }, pluginConfigErrorStatus(message));
}
}
const pluginEntryMatch = pathname.match(/^\/api\/config\/plugins\/entry\/([^/]+)$/);
if (pluginEntryMatch) {
try {
const body = method === 'GET' || method === 'DELETE' ? undefined : await extractJsonBody(input, init, method);
const directory = getRequestDirectoryHint(url, input, init);
const data = await sendBridgeMessage('api:config/plugins', {
method,
target: 'entry',
pluginId: decodeURIComponent(pluginEntryMatch[1]),
body,
directory,
});
return jsonResponse(data);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
return jsonResponse({ error: message }, pluginConfigErrorStatus(message));
}
}
if (pathname === '/api/config/plugins/file' && method === 'POST') {
try {
const body = await extractJsonBody(input, init, method);
const directory = getRequestDirectoryHint(url, input, init);
const data = await sendBridgeMessage('api:config/plugins', { method, target: 'file', body, directory });
return jsonResponse(data);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
return jsonResponse({ error: message }, pluginConfigErrorStatus(message));
}
}
const pluginFileMatch = pathname.match(/^\/api\/config\/plugins\/file\/([^/]+)$/);
if (pluginFileMatch) {
try {
const body = method === 'GET' || method === 'DELETE' ? undefined : await extractJsonBody(input, init, method);
const directory = getRequestDirectoryHint(url, input, init);
const data = await sendBridgeMessage('api:config/plugins', {
method,
target: 'file',
pluginId: decodeURIComponent(pluginFileMatch[1]),
body,
directory,
});
return jsonResponse(data);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
return jsonResponse({ error: message }, pluginConfigErrorStatus(message));
}
}
if (pathname.startsWith('/api/openchamber/models-metadata')) {
try {
const data = await sendBridgeMessage('api:models/metadata');
@@ -971,7 +1013,7 @@ const handleLocalApiRequest = async (url: URL, init?: RequestInit) => {
}
if (pathname.startsWith('/api/opencode/directory')) {
const body = init?.body ? JSON.parse(init.body as string) : {};
const body = await extractJsonBody(input, init, method);
const result = await sendBridgeMessage('api:opencode/directory', { path: body.path });
return new Response(JSON.stringify(result), { status: 200, headers: { 'Content-Type': 'application/json' } });
}
@@ -987,7 +1029,7 @@ const handleLocalApiRequest = async (url: URL, init?: RequestInit) => {
}
const quotaMatch = pathname.match(/^\/api\/quota\/([^/]+)$/);
if (quotaMatch && (init?.method || 'GET').toUpperCase() === 'GET') {
if (quotaMatch && method === 'GET') {
const providerId = decodeURIComponent(quotaMatch[1]);
try {
const data = await sendBridgeMessage('api:quota:get', { providerId });
@@ -1000,7 +1042,7 @@ const handleLocalApiRequest = async (url: URL, init?: RequestInit) => {
// Handle provider auth deletion: DELETE /api/provider/:providerId/auth
const providerAuthMatch = pathname.match(/^\/api\/provider\/([^/]+)\/auth$/);
if (providerAuthMatch && (init?.method || 'GET').toUpperCase() === 'DELETE') {
if (providerAuthMatch && method === 'DELETE') {
const providerId = decodeURIComponent(providerAuthMatch[1]);
const scope = url.searchParams.get('scope') || 'auth';
const queryDirectory = url.searchParams.get('directory') || undefined;
@@ -1015,7 +1057,7 @@ const handleLocalApiRequest = async (url: URL, init?: RequestInit) => {
// Handle provider source lookup: GET /api/provider/:providerId/source
const providerSourceMatch = pathname.match(/^\/api\/provider\/([^/]+)\/source$/);
if (providerSourceMatch && (init?.method || 'GET').toUpperCase() === 'GET') {
if (providerSourceMatch && method === 'GET') {
const providerId = decodeURIComponent(providerSourceMatch[1]);
const queryDirectory = url.searchParams.get('directory') || undefined;
try {
@@ -1036,7 +1078,7 @@ window.fetch = async (input: RequestInfo | URL, init?: RequestInit) => {
const method = (init?.method || (input instanceof Request ? input.method : 'GET')).toUpperCase();
const pathname = targetUrl?.pathname || '';
const normalizedPathname = pathname.replace(/\/+/, '/');
const normalizedPathname = pathname.replace(/\/{2,}/g, '/');
if (targetUrl && normalizedPathname === '/health') {
const connectionStatus = window.__OPENCHAMBER_CONNECTION__?.status;
const isReady = connectionStatus === 'connected';
@@ -1051,14 +1093,18 @@ window.fetch = async (input: RequestInfo | URL, init?: RequestInit) => {
});
}
if (targetUrl && targetUrl.pathname.startsWith('/api/')) {
const localResponse = await handleLocalApiRequest(targetUrl, init);
if (targetUrl && isLocalRuntimePath(normalizedPathname)) {
const localResponse = await handleLocalApiRequest(input, targetUrl, init, method);
if (localResponse) {
recordBootstrapFetch(targetUrl.pathname, localResponse.ok);
maybeHideLoadingOverlay();
return localResponse;
}
if (!isApiPath(normalizedPathname)) {
return originalFetch(input as RequestInfo, init);
}
const suffixPath = `${targetUrl.pathname.replace(/^\/api/, '')}${targetUrl.search}`;
const headersFromRequest = input instanceof Request ? headersToRecord(input.headers) : {};
@@ -1135,7 +1181,8 @@ window.fetch = async (input: RequestInfo | URL, init?: RequestInit) => {
if (method === 'POST' && isSessionMessageApiPath(targetUrl.pathname)) {
const bodyText = await extractBodyText(input, init, method);
const proxied = await proxySessionMessageRequest({ path: suffixPath, headers, bodyText });
const signal = (input instanceof Request ? input.signal : init?.signal) as AbortSignal | undefined;
const proxied = await proxySessionMessageRequest({ path: suffixPath, headers, bodyText, signal });
const response = buildProxiedResponse(proxied);
recordBootstrapFetch(targetUrl.pathname, response.ok);
maybeHideLoadingOverlay();
@@ -1143,7 +1190,8 @@ window.fetch = async (input: RequestInfo | URL, init?: RequestInit) => {
}
const bodyBase64 = await extractBodyBase64(input, init, method);
const proxied = await proxyApiRequest({ method, path: suffixPath, headers, bodyBase64 });
const signal = (input instanceof Request ? input.signal : init?.signal) as AbortSignal | undefined;
const proxied = await proxyApiRequest({ method, path: suffixPath, headers, bodyBase64, signal });
const response = buildProxiedResponse(proxied);
recordBootstrapFetch(targetUrl.pathname, response.ok);
maybeHideLoadingOverlay();
@@ -1464,14 +1512,7 @@ const fetchLastAssistantMessageText = async (sessionId: string, messageId?: stri
if (!sessionId) return '';
try {
const response = await fetch(`/api/session/${encodeURIComponent(sessionId)}/message?limit=5`, {
method: 'GET',
headers: { Accept: 'application/json' },
signal: AbortSignal.timeout(3000),
});
if (!response.ok) return '';
const messages = await response.json().catch(() => null) as unknown;
const messages = await opencodeClient.getSessionMessages(sessionId, 5);
if (!Array.isArray(messages)) return '';
let target = messageId
@@ -0,0 +1,55 @@
import { describe, expect, test } from 'bun:test';
import { extractBodyBase64, extractBodyText } from './requestBodyTransport';
const decodeBase64Text = (value: string | undefined): string => {
expect(typeof value).toBe('string');
const binary = atob(value ?? '');
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i += 1) {
bytes[i] = binary.charCodeAt(i);
}
return new TextDecoder().decode(bytes);
};
describe('VS Code webview request body transport', () => {
test('preserves body from SDK-style Request objects', async () => {
const request = new Request('https://openchamber.local/api/session/abc/prompt_async', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ messageID: 'msg_1' }),
});
expect(decodeBase64Text(await extractBodyBase64(request, undefined, 'POST'))).toBe('{"messageID":"msg_1"}');
expect(await request.text()).toBe('{"messageID":"msg_1"}');
});
test('preserves string, URLSearchParams, Blob, ArrayBuffer, typed array, and FormData bodies', async () => {
const cases: Array<{ name: string; init: RequestInit; expected: string | RegExp }> = [
{ name: 'string', init: { body: 'plain text' }, expected: 'plain text' },
{ name: 'URLSearchParams', init: { body: new URLSearchParams({ a: '1', b: 'two' }) }, expected: 'a=1&b=two' },
{ name: 'Blob', init: { body: new Blob(['blob text'], { type: 'text/plain' }) }, expected: 'blob text' },
{ name: 'ArrayBuffer', init: { body: new TextEncoder().encode('array buffer').buffer }, expected: 'array buffer' },
{ name: 'typed array', init: { body: new Uint8Array(new TextEncoder().encode('typed array')) }, expected: 'typed array' },
];
for (const entry of cases) {
const encoded = await extractBodyBase64('https://openchamber.local/api/test', entry.init, 'POST');
expect(decodeBase64Text(encoded)).toBe(entry.expected);
}
const form = new FormData();
form.set('messageID', 'msg_1');
form.set('file', new Blob(['file contents'], { type: 'text/plain' }), 'test.txt');
const encodedForm = await extractBodyBase64('https://openchamber.local/api/upload', { body: form }, 'POST');
const decodedForm = decodeBase64Text(encodedForm);
expect(decodedForm).toContain('name="messageID"');
expect(decodedForm).toContain('msg_1');
expect(decodedForm).toContain('filename="test.txt"');
expect(decodedForm).toContain('file contents');
});
test('extracts text for direct session message bridge bodies', async () => {
expect(await extractBodyText('https://openchamber.local/api/session/abc/message', { body: new URLSearchParams({ q: 'hello' }) }, 'POST'))
.toBe('q=hello');
});
});
@@ -0,0 +1,81 @@
export const encodeBase64 = (bytes: Uint8Array): string => {
const CHUNK = 0x8000;
let binary = '';
for (let i = 0; i < bytes.length; i += CHUNK) {
binary += String.fromCharCode(...bytes.subarray(i, i + CHUNK));
}
return btoa(binary);
};
export const hasInitBody = (init: RequestInit | undefined): boolean => init?.body !== undefined && init.body !== null;
export const readBodyBytes = async (body: BodyInit): Promise<Uint8Array> => {
if (typeof body === 'string') {
return new TextEncoder().encode(body);
}
if (body instanceof URLSearchParams) {
return new TextEncoder().encode(body.toString());
}
if (body instanceof Blob) {
return new Uint8Array(await body.arrayBuffer());
}
if (body instanceof ArrayBuffer) {
return new Uint8Array(body);
}
if (ArrayBuffer.isView(body)) {
return new Uint8Array(body.buffer, body.byteOffset, body.byteLength);
}
if (body instanceof FormData) {
return new Uint8Array(await new Request('https://openchamber.local/body', { method: 'POST', body }).arrayBuffer());
}
throw new Error('Unsupported request body type');
};
export const readBodyText = async (body: BodyInit): Promise<string> => {
if (typeof body === 'string') return body;
if (body instanceof URLSearchParams) return body.toString();
if (body instanceof Blob) return await body.text();
return new TextDecoder().decode(await readBodyBytes(body));
};
export const extractBodyBase64 = async (input: RequestInfo | URL, init: RequestInit | undefined, method: string): Promise<string | undefined> => {
if (method === 'GET' || method === 'HEAD') return undefined;
if (input instanceof Request && !hasInitBody(init)) {
const cloned = input.clone();
const buffer = await cloned.arrayBuffer();
const bytes = new Uint8Array(buffer);
return bytes.length > 0 ? encodeBase64(bytes) : undefined;
}
const body = init?.body;
if (!body) return undefined;
const bytes = await readBodyBytes(body);
return bytes.length > 0 ? encodeBase64(bytes) : undefined;
};
export const extractBodyText = async (input: RequestInfo | URL, init: RequestInit | undefined, method: string): Promise<string> => {
if (method === 'GET' || method === 'HEAD') return '';
if (input instanceof Request && !hasInitBody(init)) {
const cloned = input.clone();
return await cloned.text();
}
const body = init?.body;
if (!body) return '';
return readBodyText(body);
};
export const extractJsonBody = async (input: RequestInfo | URL, init: RequestInit | undefined, method: string): Promise<Record<string, unknown>> => {
const bodyText = await extractBodyText(input, init, method);
return bodyText ? JSON.parse(bodyText) as Record<string, unknown> : {};
};