diff --git a/apps/desktop/src/main/services/builtInBrowser/builtInBrowserService.test.ts b/apps/desktop/src/main/services/builtInBrowser/builtInBrowserService.test.ts index 2865a7a9b..dd95bf0c6 100644 --- a/apps/desktop/src/main/services/builtInBrowser/builtInBrowserService.test.ts +++ b/apps/desktop/src/main/services/builtInBrowser/builtInBrowserService.test.ts @@ -11,7 +11,16 @@ const fakes = vi.hoisted(() => { action: "allow" | "deny"; createWindow?: (options: Record) => FakeWebContents; }; - type WindowOpenHandler = (details: { url: string }) => WindowOpenHandlerResponse; + type WindowOpenDetails = { + url: string; + referrer?: { url: string; policy: string }; + postBody?: { + boundary?: string; + contentType: string; + data: Array>; + }; + }; + type WindowOpenHandler = (details: WindowOpenDetails) => WindowOpenHandlerResponse; type BeforeSendHeadersHandler = ( details: { requestHeaders: Record }, callback: (response: { requestHeaders: Record }) => void, @@ -77,10 +86,12 @@ const fakes = vi.hoisted(() => { session: unknown = null; audioMutedCalls: boolean[] = []; userAgentCalls: string[] = []; + loadURLCalls: Array<{ url: string; options?: Record }> = []; currentUrl = ""; private listeners: Record void>> = {}; private windowOpenHandler: WindowOpenHandler | null = null; - loadURL = async (url: string): Promise => { + loadURL = async (url: string, options?: Record): Promise => { + this.loadURLCalls.push({ url, options }); const event = { preventDefault: vi.fn() }; this.emit("will-navigate", event, url); if (event.preventDefault.mock.calls.length === 0) { @@ -116,7 +127,7 @@ const fakes = vi.hoisted(() => { setWindowOpenHandler = (handler: WindowOpenHandler): void => { this.windowOpenHandler = handler; }; - openWindow = (url: string): WindowOpenHandlerResponse | null => this.windowOpenHandler?.({ url }) ?? null; + openWindow = (url: string, details: Partial = {}): WindowOpenHandlerResponse | null => this.windowOpenHandler?.({ ...details, url }) ?? null; on = (event: string, fn: (...args: unknown[]) => void): void => { (this.listeners[event] ??= []).push(fn); }; @@ -989,6 +1000,64 @@ describe("createBuiltInBrowserService — bounds and status dedupe", () => { expect(fakes.openExternal).not.toHaveBeenCalled(); }); + it("recovers crashed browser renderers to a blank tab with an error event", async () => { + const logger = { + debug: vi.fn(), + error: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + }; + let resolveRecovery: () => void = () => undefined; + const recovered = new Promise((resolve) => { + resolveRecovery = resolve; + }); + const service = createBuiltInBrowserService({ + getLogger: () => logger, + onEvent: (event) => { + collector.onEvent(event); + if ( + event.type === "error" + && event.message.includes("renderer exited (crashed, exit code 133)") + && event.message.includes("Recovered the tab to a blank page") + ) { + resolveRecovery(); + } + }, + }); + service.attachToWindow(fakeBrowserWindow() as unknown as Parameters[0]); + await service.createTab({ url: "https://linear.app/integrations/agents?code=secret", activate: true }); + collector.events.length = 0; + + const wc = fakes.webContentsInstances[0]; + expect(wc, "browser tab webContents exists").toBeTruthy(); + const originalLoadURL = wc.loadURL; + wc.loadURL = vi.fn(async (url: string) => { + await originalLoadURL(url); + }); + + wc.emit("render-process-gone", {}, { + reason: "crashed", + exitCode: 133, + }); + await recovered; + + expect(service.getStatus().url).toBe("about:blank"); + expect(service.getStatus().tabs[0]).toMatchObject({ + url: "about:blank", + isLoading: false, + }); + expect(collector.events.some((event) => ( + event.type === "error" + && event.message.includes("renderer exited (crashed, exit code 133)") + && event.message.includes("Recovered the tab to a blank page") + ))).toBe(true); + expect(logger.warn).toHaveBeenCalledWith("built_in_browser.render_process_gone", expect.objectContaining({ + reason: "crashed", + exitCode: 133, + url: "https://linear.app", + })); + }); + it("does not impersonate Chrome or rewrite browser request headers", async () => { const service = createBuiltInBrowserService({ onEvent: collector.onEvent }); await service.createTab({ url: "https://example.test", activate: true }); @@ -1029,7 +1098,7 @@ describe("createBuiltInBrowserService — bounds and status dedupe", () => { })).toBe(false); }); - it("opens popup requests as real ADE browser tabs", async () => { + it("intercepts popup requests as real ADE browser tabs", async () => { const service = createBuiltInBrowserService({ onEvent: collector.onEvent }); await service.createTab({ url: "https://example.test", @@ -1039,17 +1108,49 @@ describe("createBuiltInBrowserService — bounds and status dedupe", () => { }); const firstTabId = service.getStatus().activeTabId; const firstWc = fakes.webContentsInstances[0]; + const postData = [{ bytes: Buffer.from("token=abc"), type: "rawData" }]; - const response = firstWc?.openWindow("https://accounts.google.com/gsi/select"); + const response = firstWc?.openWindow("https://accounts.google.com/gsi/select", { + referrer: { url: "https://example.test/sign-in", policy: "strict-origin-when-cross-origin" }, + postBody: { + contentType: "application/x-www-form-urlencoded", + data: postData, + }, + }); expect(response?.action).toBe("allow"); + expect(response?.createWindow).toEqual(expect.any(Function)); + const popupWc = response?.createWindow?.({ + webPreferences: { + additionalArguments: ["--popup"], + javascript: false, + nodeIntegration: true, + partition: "persist:other", + webviewTag: true, + }, + }); + expect(popupWc).toBe(fakes.webContentsInstances.at(-1)); + await popupWc?.loadURL("https://accounts.google.com/gsi/select"); + expect(service.getStatus().tabs).toHaveLength(2); expect(service.getStatus().activeTabId).not.toBe(firstTabId); - expect(response?.createWindow?.({})).toBe(fakes.webContentsInstances[1]); expect(service.getStatus().tabs.at(-1)).toMatchObject({ + url: "https://accounts.google.com/gsi/select", ownerLaneId: "lane-1", ownerChatSessionId: "chat-1", }); + const popupWebPreferences = fakes.webContentsViewInstances.at(-1)?.webPreferences as Record; + expect(popupWebPreferences).toMatchObject({ + partition: service.getStatus().partition, + nodeIntegration: false, + contextIsolation: true, + sandbox: true, + webSecurity: true, + backgroundThrottling: false, + }); + expect(popupWebPreferences.additionalArguments).toBeUndefined(); + expect(popupWebPreferences.javascript).toBeUndefined(); + expect(popupWebPreferences.webviewTag).toBeUndefined(); const openEvent = collector.events.findLast((event) => event.type === "open-request"); expect(openEvent).toMatchObject({ diff --git a/apps/desktop/src/main/services/builtInBrowser/builtInBrowserService.ts b/apps/desktop/src/main/services/builtInBrowser/builtInBrowserService.ts index faa79e37b..231987f25 100644 --- a/apps/desktop/src/main/services/builtInBrowser/builtInBrowserService.ts +++ b/apps/desktop/src/main/services/builtInBrowser/builtInBrowserService.ts @@ -575,6 +575,7 @@ function createBuiltInBrowserWindowService(args: { let browserSessionConfigured = false; let lastEmittedStatusKey: string | null = null; const configuredWebContents = new WeakSet(); + const renderProcessRecoveryTabs = new Set(); let configuredBrowserSession: ReturnType | null = null; const logger = (): Logger | null => { @@ -1057,6 +1058,62 @@ function createBuiltInBrowserWindowService(args: { } }; + const recoverTabAfterRenderProcessGone = async ( + tab: BrowserTabState, + details: Electron.RenderProcessGoneDetails, + ): Promise => { + const crashedWebContents = tab.webContents; + if (renderProcessRecoveryTabs.has(tab.id) || crashedWebContents.isDestroyed()) return; + const crashedUrl = emptyToNull(crashedWebContents.getURL()) ?? "about:blank"; + const reason = details.reason || "unknown"; + if (reason === "clean-exit") { + notifyTabActivity(tab); + emitStatus(); + return; + } + renderProcessRecoveryTabs.add(tab.id); + const exitCode = Number.isFinite(details.exitCode) ? `, exit code ${details.exitCode}` : ""; + const exitMessage = `ADE browser tab renderer exited (${reason}${exitCode}).`; + try { + tab.pendingNetworkRequests.clear(); + pushNetworkDiagnostic(tab, { + url: crashedUrl, + method: null, + resourceType: "mainFrame", + statusCode: null, + error: exitMessage, + startedAt: null, + endedAt: new Date().toISOString(), + durationMs: null, + }); + if (tab.id === activeTabId) { + clearSelectionInternal(); + await stopInspectQuietly("built_in_browser.render_process_gone_stop_inspect_failed"); + } + if (tab.webContents !== crashedWebContents) { + emitError(new Error(`${exitMessage} Recovery skipped because the tab target changed.`)); + return; + } + if (crashedWebContents.isDestroyed()) { + emitError(new Error(`${exitMessage} Recovery skipped because the browser tab was destroyed.`)); + return; + } + await crashedWebContents.loadURL("about:blank"); + emitError(new Error(`${exitMessage} Recovered the tab to a blank page.`)); + } catch (error) { + logger()?.warn("built_in_browser.render_process_recovery_failed", { + tabId: tab.id, + reason, + err: errorMessage(error), + }); + emitError(new Error(`ADE browser tab renderer exited and recovery failed: ${errorMessage(error)}`)); + } finally { + renderProcessRecoveryTabs.delete(tab.id); + notifyTabActivity(tab); + emitStatus(); + } + }; + const configureBrowserWebContents = (wc: WebContents): void => { if (configuredWebContents.has(wc)) return; configuredWebContents.add(wc); @@ -1073,12 +1130,18 @@ function createBuiltInBrowserWindowService(args: { timestamp: new Date().toISOString(), }); }); - wc.setWindowOpenHandler(({ url }) => { - const tab = createPopupTabState(url, tabForWebContents(wc) ?? activeTab()); - if (!tab) return { action: "deny" }; + wc.setWindowOpenHandler((details) => { + const opener = tabForWebContents(wc) ?? activeTab(); + const popupUrl = popupUrlForOpen(details.url); + if (!popupUrl) return { action: "deny" }; return { action: "allow", - createWindow: () => tab.webContents, + createWindow: () => { + const tab = createPopupTabStateFromView(popupUrl, opener, new WebContentsView({ + webPreferences: browserWebPreferences(), + })); + return tab.webContents; + }, }; }); wc.on("will-navigate", (event, url) => { @@ -1109,12 +1172,21 @@ function createBuiltInBrowserWindowService(args: { emitStatus(); }); wc.on("render-process-gone", (_event, details) => { + const tab = tabForWebContents(wc); logger()?.warn("built_in_browser.render_process_gone", { reason: details.reason, exitCode: details.exitCode, + tabId: tab?.id ?? null, + url: tab && !wc.isDestroyed() ? urlForBrowserLog(wc.getURL()) : null, + }); + if (!tab) { + emitStatus(); + return; + } + void recoverTabAfterRenderProcessGone(tab, details).catch((error) => { + emitError(new Error(`ADE browser tab renderer recovery failed: ${errorMessage(error)}`)); + emitStatus(); }); - notifyTabActivity(tabForWebContents(wc)); - emitStatus(); }); wc.on("did-fail-load", (_event, errorCode, errorDescription, validatedURL, isMainFrame) => { if (!isMainFrame || errorCode === -3) return; @@ -1142,19 +1214,16 @@ function createBuiltInBrowserWindowService(args: { }); }; - const createTabState = (): BrowserTabState => { - configureBrowserSession(); + const browserWebPreferences = (): Electron.WebPreferences => ({ + partition: args.profile.partition, + nodeIntegration: false, + contextIsolation: true, + sandbox: true, + webSecurity: true, + backgroundThrottling: false, + }); - const nextView = new WebContentsView({ - webPreferences: { - partition: args.profile.partition, - nodeIntegration: false, - contextIsolation: true, - sandbox: true, - webSecurity: true, - backgroundThrottling: false, - }, - }); + const createTabStateForView = (nextView: WebContentsView): BrowserTabState => { nextView.setBackgroundColor("#111827"); nextView.setBounds(toElectronRect(bounds)); nextView.setVisible(false); @@ -1179,7 +1248,14 @@ function createBuiltInBrowserWindowService(args: { }; }; - const createPopupTabState = (url: string, opener: BrowserTabState | null = activeTab()): BrowserTabState | null => { + const createTabState = (): BrowserTabState => { + configureBrowserSession(); + return createTabStateForView(new WebContentsView({ + webPreferences: browserWebPreferences(), + })); + }; + + const popupUrlForOpen = (url: string): string | null => { const popupUrl = stringOrNull(url) ?? "about:blank"; if (!isAllowedNavigationUrl(popupUrl)) { emitError(new Error(`Blocked unsupported browser popup protocol: ${url}`)); @@ -1189,7 +1265,16 @@ function createBuiltInBrowserWindowService(args: { emitError(new Error(`ADE browser is limited to ${MAX_BROWSER_TABS} tabs. Close a tab before opening another.`)); return null; } - const tab = createTabState(); + return popupUrl; + }; + + const createPopupTabStateFromView = ( + popupUrl: string, + opener: BrowserTabState | null, + nextView: WebContentsView, + ): BrowserTabState => { + configureBrowserSession(); + const tab = createTabStateForView(nextView); copyTabOwner(opener, tab); tabs = [...tabs, tab]; activeTabId = tab.id; @@ -2824,6 +2909,19 @@ function emptyToNull(value: string): string | null { return trimmed.length ? trimmed : null; } +function urlForBrowserLog(value: string): string | null { + const url = emptyToNull(value); + if (!url) return null; + try { + const parsed = new URL(url); + if (parsed.protocol === "http:" || parsed.protocol === "https:") return parsed.origin; + if (parsed.protocol === "about:") return parsed.href === "about:blank" ? parsed.href : "about:"; + return parsed.protocol; + } catch { + return null; + } +} + function tabStatus(tab: BrowserTabState): BuiltInBrowserTab { const wc = tab.webContents; return {