diff --git a/packages/ui/src/components/chat/ChatContainer.tsx b/packages/ui/src/components/chat/ChatContainer.tsx index a48d352f..034e5b13 100644 --- a/packages/ui/src/components/chat/ChatContainer.tsx +++ b/packages/ui/src/components/chat/ChatContainer.tsx @@ -715,10 +715,15 @@ export const ChatContainer: React.FC = ({ autoOpenDraft = tr const promptReadOnly = parentSession ? !allowPromptingSubagentSessions : readOnly; React.useEffect(() => { - if (typeof window === 'undefined' || window.parent === window) { + // VS Code/Cursor/Positron webviews delete window.parent (and window.top). + // The old `window.parent === window` check does not catch that, so + // `window.parent.postMessage(...)` threw on chat open: + // TypeError: Cannot read properties of undefined (reading 'postMessage') + if (typeof window === 'undefined' || !window.parent || window.parent === window) { return; } + const parentWindow = window.parent; const applySetting = (value: boolean) => { useUIStore.getState().setAllowPromptingSubagentSessions(value); }; @@ -729,7 +734,7 @@ export const ChatContainer: React.FC = ({ autoOpenDraft = tr applySetting(payload.allowPromptingSubagentSessions); }; const handleMessage = (event: MessageEvent) => { - if (event.source !== window.parent || event.origin !== window.location.origin) return; + if (event.source !== parentWindow || event.origin !== window.location.origin) return; const data = event.data as { type?: unknown; payload?: { allowPromptingSubagentSessions?: unknown } }; if (data?.type !== 'openchamber:chat-settings-sync' || typeof data.payload?.allowPromptingSubagentSessions !== 'boolean') return; @@ -738,7 +743,7 @@ export const ChatContainer: React.FC = ({ autoOpenDraft = tr scopedWindow.__openchamberApplyChatSettingsSync = applySync; window.addEventListener('message', handleMessage); - window.parent.postMessage({ type: 'openchamber:chat-settings-request' }, window.location.origin); + parentWindow.postMessage({ type: 'openchamber:chat-settings-request' }, window.location.origin); return () => { window.removeEventListener('message', handleMessage); if (scopedWindow.__openchamberApplyChatSettingsSync === applySync) { diff --git a/packages/ui/src/components/chat/__tests__/parentFramePostMessageGuard.test.ts b/packages/ui/src/components/chat/__tests__/parentFramePostMessageGuard.test.ts new file mode 100644 index 00000000..9bb990d2 --- /dev/null +++ b/packages/ui/src/components/chat/__tests__/parentFramePostMessageGuard.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, test } from 'bun:test'; + +/** + * Mirrors the ChatContainer chat-settings-sync guard. + * VS Code/Cursor/Positron webviews delete `window.parent`, so the old + * `window.parent === window` check still fell through to `.postMessage` and + * crashed chat open with: + * TypeError: Cannot read properties of undefined (reading 'postMessage') + */ +const canPostMessageToParentFrame = (win: { parent?: unknown } | undefined): boolean => { + if (typeof win === 'undefined' || !win) return false; + return Boolean(win.parent) && win.parent !== win; +}; + +describe('parent-frame postMessage guard (VS Code webview)', () => { + test('rejects when parent was deleted (VS Code webview injector behavior)', () => { + const vscodeLikeWindow = { parent: undefined }; + expect(canPostMessageToParentFrame(vscodeLikeWindow)).toBe(false); + }); + + test('rejects when parent is null', () => { + expect(canPostMessageToParentFrame({ parent: null })).toBe(false); + }); + + test('rejects top-level windows where parent === self', () => { + const topLevel = {} as { parent?: unknown }; + topLevel.parent = topLevel; + expect(canPostMessageToParentFrame(topLevel)).toBe(false); + }); + + test('allows real embedded iframe parent windows', () => { + const parent = {}; + const child = { parent }; + expect(canPostMessageToParentFrame(child)).toBe(true); + }); + + test('old guard incorrectly allows deleted parent', () => { + const vscodeLikeWindow = { parent: undefined as unknown }; + const oldGuardWouldSkip = vscodeLikeWindow.parent === vscodeLikeWindow; + expect(oldGuardWouldSkip).toBe(false); + }); +}); diff --git a/packages/vscode/src/SessionEditorPanelProvider.ts b/packages/vscode/src/SessionEditorPanelProvider.ts index 0553dbd3..68e21daa 100644 --- a/packages/vscode/src/SessionEditorPanelProvider.ts +++ b/packages/vscode/src/SessionEditorPanelProvider.ts @@ -437,7 +437,8 @@ export class SessionEditorPanelProvider { headers: this._buildSseHeaders(headers), signal: controller.signal, onChunk: (chunk) => { - entry.panel.webview.postMessage({ type: 'api:sse:chunk', streamId, chunk }); + // Panel may be disposed before SSE callbacks fire. + entry.panel?.webview?.postMessage({ type: 'api:sse:chunk', streamId, chunk }); }, }); @@ -445,12 +446,12 @@ export class SessionEditorPanelProvider { start.run .then(() => { - entry.panel.webview.postMessage({ type: 'api:sse:end', streamId }); + entry.panel?.webview?.postMessage({ type: 'api:sse:end', streamId }); }) .catch((error) => { if (!controller.signal.aborted) { const messageText = error instanceof Error ? error.message : String(error); - entry.panel.webview.postMessage({ type: 'api:sse:end', streamId, error: messageText }); + entry.panel?.webview?.postMessage({ type: 'api:sse:end', streamId, error: messageText }); } }) .finally(() => { diff --git a/packages/vscode/webview/api/bridge-acquire-fallback.test.ts b/packages/vscode/webview/api/bridge-acquire-fallback.test.ts new file mode 100644 index 00000000..369f4934 --- /dev/null +++ b/packages/vscode/webview/api/bridge-acquire-fallback.test.ts @@ -0,0 +1,51 @@ +import { describe, test } from 'node:test'; +import assert from 'node:assert/strict'; + +/** + * When acquireVsCodeApi() returns undefined (broken Cursor/VSCodium webview slot), + * getVSCodeAPI().postMessage used to throw: + * TypeError: Cannot read properties of undefined (reading 'postMessage') + * + * The bridge must fall back to a noop API and fail via normal request timeout instead. + */ +describe('VS Code webview bridge acquireVsCodeApi fallback', () => { + test('does not throw TypeError when acquireVsCodeApi returns undefined', async () => { + const originalWindow = globalThis.window; + const originalAcquire = (globalThis as typeof globalThis & { acquireVsCodeApi?: unknown }).acquireVsCodeApi; + const originalWarn = console.warn; + const warnings: unknown[][] = []; + + try { + Object.defineProperty(globalThis, 'window', { + configurable: true, + value: new EventTarget(), + }); + Object.defineProperty(globalThis, 'acquireVsCodeApi', { + configurable: true, + value: () => undefined, + }); + console.warn = (...args: unknown[]) => { + warnings.push(args); + }; + + const { sendBridgeMessageWithOptions } = await import(`./bridge?acquire-fallback-${Date.now()}`); + + const result = await sendBridgeMessageWithOptions('api:proxy', { path: '/health' }, { timeoutMs: 20 }).then( + () => 'resolved' as const, + (error: unknown) => error, + ); + + assert.ok(result instanceof Error, `expected Error, got ${String(result)}`); + assert.notEqual((result as Error).name, 'TypeError'); + assert.match((result as Error).message, /timed out/i); + assert.ok( + warnings.some((entry) => String(entry[0] ?? '').includes('VS Code API unavailable')), + 'expected a one-time warning that the VS Code API was unavailable', + ); + } finally { + console.warn = originalWarn; + Object.defineProperty(globalThis, 'window', { configurable: true, value: originalWindow }); + Object.defineProperty(globalThis, 'acquireVsCodeApi', { configurable: true, value: originalAcquire }); + } + }); +}); diff --git a/packages/vscode/webview/api/bridge.ts b/packages/vscode/webview/api/bridge.ts index e63b5f64..8692a5aa 100644 --- a/packages/vscode/webview/api/bridge.ts +++ b/packages/vscode/webview/api/bridge.ts @@ -9,10 +9,24 @@ interface VSCodeAPI { } let vscodeApi: VSCodeAPI | null = null; +let noopWarned = false; + +const noopVSCodeApi: VSCodeAPI = { + postMessage: (message) => { + // acquireVsCodeApi() can return undefined in broken/non-standard webview slots + // (Cursor after extension update, VSCodium, headless). Drop the message instead + // of throwing TypeError: Cannot read properties of undefined (reading 'postMessage'). + if (!noopWarned) { + noopWarned = true; + console.warn('[openchamber] VS Code API unavailable; dropping postMessage', message); + } + }, +}; function getVSCodeAPI(): VSCodeAPI { if (!vscodeApi) { - vscodeApi = acquireVsCodeApi(); + const acquired = typeof acquireVsCodeApi === 'function' ? acquireVsCodeApi() : undefined; + vscodeApi = acquired ?? noopVSCodeApi; } return vscodeApi; }