fix(vscode,ui): stop postMessage crash when opening chat in Cursor (#2335)
VS Code webviews delete window.parent, so ChatContainer's settings-sync effect threw TypeError on chat open. Also harden the webview bridge when acquireVsCodeApi() returns undefined and SSE panel disposal races. Co-authored-by: Cursor Agent <cursoragent@cursor.com> Co-authored-by: Serhii Dziupin <makeittech@users.noreply.github.com>
This commit is contained in:
committed by
GitHub
co-authored by
Cursor Agent
Serhii Dziupin
parent
200c02cd00
commit
485efc7117
@@ -715,10 +715,15 @@ export const ChatContainer: React.FC<ChatContainerProps> = ({ autoOpenDraft = tr
|
|||||||
const promptReadOnly = parentSession ? !allowPromptingSubagentSessions : readOnly;
|
const promptReadOnly = parentSession ? !allowPromptingSubagentSessions : readOnly;
|
||||||
|
|
||||||
React.useEffect(() => {
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const parentWindow = window.parent;
|
||||||
const applySetting = (value: boolean) => {
|
const applySetting = (value: boolean) => {
|
||||||
useUIStore.getState().setAllowPromptingSubagentSessions(value);
|
useUIStore.getState().setAllowPromptingSubagentSessions(value);
|
||||||
};
|
};
|
||||||
@@ -729,7 +734,7 @@ export const ChatContainer: React.FC<ChatContainerProps> = ({ autoOpenDraft = tr
|
|||||||
applySetting(payload.allowPromptingSubagentSessions);
|
applySetting(payload.allowPromptingSubagentSessions);
|
||||||
};
|
};
|
||||||
const handleMessage = (event: MessageEvent) => {
|
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 } };
|
const data = event.data as { type?: unknown; payload?: { allowPromptingSubagentSessions?: unknown } };
|
||||||
if (data?.type !== 'openchamber:chat-settings-sync'
|
if (data?.type !== 'openchamber:chat-settings-sync'
|
||||||
|| typeof data.payload?.allowPromptingSubagentSessions !== 'boolean') return;
|
|| typeof data.payload?.allowPromptingSubagentSessions !== 'boolean') return;
|
||||||
@@ -738,7 +743,7 @@ export const ChatContainer: React.FC<ChatContainerProps> = ({ autoOpenDraft = tr
|
|||||||
|
|
||||||
scopedWindow.__openchamberApplyChatSettingsSync = applySync;
|
scopedWindow.__openchamberApplyChatSettingsSync = applySync;
|
||||||
window.addEventListener('message', handleMessage);
|
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 () => {
|
return () => {
|
||||||
window.removeEventListener('message', handleMessage);
|
window.removeEventListener('message', handleMessage);
|
||||||
if (scopedWindow.__openchamberApplyChatSettingsSync === applySync) {
|
if (scopedWindow.__openchamberApplyChatSettingsSync === applySync) {
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -437,7 +437,8 @@ export class SessionEditorPanelProvider {
|
|||||||
headers: this._buildSseHeaders(headers),
|
headers: this._buildSseHeaders(headers),
|
||||||
signal: controller.signal,
|
signal: controller.signal,
|
||||||
onChunk: (chunk) => {
|
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
|
start.run
|
||||||
.then(() => {
|
.then(() => {
|
||||||
entry.panel.webview.postMessage({ type: 'api:sse:end', streamId });
|
entry.panel?.webview?.postMessage({ type: 'api:sse:end', streamId });
|
||||||
})
|
})
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
if (!controller.signal.aborted) {
|
if (!controller.signal.aborted) {
|
||||||
const messageText = error instanceof Error ? error.message : String(error);
|
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(() => {
|
.finally(() => {
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -9,10 +9,24 @@ interface VSCodeAPI {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let vscodeApi: VSCodeAPI | null = null;
|
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 {
|
function getVSCodeAPI(): VSCodeAPI {
|
||||||
if (!vscodeApi) {
|
if (!vscodeApi) {
|
||||||
vscodeApi = acquireVsCodeApi();
|
const acquired = typeof acquireVsCodeApi === 'function' ? acquireVsCodeApi() : undefined;
|
||||||
|
vscodeApi = acquired ?? noopVSCodeApi;
|
||||||
}
|
}
|
||||||
return vscodeApi;
|
return vscodeApi;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user