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:
committed by
GitHub
parent
a4314c189b
commit
2031e3b4a8
@@ -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 });
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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 }> {
|
||||
|
||||
@@ -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 };
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user