From d27f289ceab7aa467aa0b4abdc0959f8d528b684 Mon Sep 17 00:00:00 2001 From: Eric Date: Mon, 25 May 2026 16:06:58 -0700 Subject: [PATCH] [eric] windows: fix UA-spoof leak onto recreated main window + add diag logs across preload, createWindow, recreateMainWindow, resumeSession, launchAndSendFirstMessage, AgentChat, WebSocketManager so packaged-build stderr captures the chat-spawn crash trail --- electron/main.js | 20 +++++++++++++++- electron/package.json | 2 +- electron/preload.js | 3 +++ .../src/app/pages/AgentChat/AgentChat.tsx | 2 ++ .../app/pages/Dashboard/cards/BrowserCard.tsx | 2 ++ frontend/src/shared/state/agentsSlice.ts | 24 ++++++++++++++++--- frontend/src/shared/ws/WebSocketManager.ts | 2 ++ 7 files changed, 50 insertions(+), 5 deletions(-) diff --git a/electron/main.js b/electron/main.js index ed4ef3a8..9191e5ec 100644 --- a/electron/main.js +++ b/electron/main.js @@ -147,6 +147,8 @@ let isQuittingFromSplash = false; // guards against double-quit during error sh let rendererCrashTimes = []; // timestamps of recent render-process-gone events; caps the auto-reload retry storm const recentBackendStderr = []; // ring buffer (last ~60 lines) for splash error UI let splashDataUrlCache = null; +// Set to true around `new BrowserWindow()` for the top-level main window so the popup-UA spoofer in app.on('web-contents-created') doesn't accidentally rewrite the main window's UA. The web-contents-created event fires synchronously inside the BrowserWindow constructor, before mainWindow assignment returns; without this flag, the previous identity check (contents !== mainWindow.webContents) is racy across recreateMainWindow() because mainWindow still points to the OLD window during construction of the NEW one. +let isCreatingMainWindow = false; const isPackaged = app.isPackaged; const isDev = process.env.ELECTRON_DEV === '1'; @@ -673,6 +675,8 @@ async function loadAuthToken() { } function createWindow() { + isCreatingMainWindow = true; + console.log('[diag][main] createWindow start'); mainWindow = new BrowserWindow({ width: 1400, height: 900, @@ -784,6 +788,16 @@ function createWindow() { }; mainWindow.on('blur', () => sendFocusEvent('blur')); mainWindow.on('focus', () => sendFocusEvent('focus')); + + // Forward renderer console output to main stderr so packaged-build diagnostics survive without DevTools open. + mainWindow.webContents.on('console-message', (_e, level, message, line, sourceId) => { + const tag = ['LOG', 'INFO', 'WARN', 'ERROR'][level] || 'LOG'; + const src = sourceId ? sourceId.split('/').pop() : ''; + console.log(`[renderer:${tag}] ${message}${src ? ` (${src}:${line})` : ''}`); + }); + + isCreatingMainWindow = false; + console.log('[diag][main] createWindow end, ua=', mainWindow.webContents.getUserAgent()); } // Crash recovery path A: tear down the dead BrowserWindow and stand up a fresh one. Used by the render-process-gone handler under the 3-in-60s cap. @@ -795,14 +809,16 @@ function createWindow() { // Why setImmediate for the destroy: // - We're INSIDE the old window's render-process-gone handler. Destroying its BrowserWindow from inside its own event callback works in current Electron but is fragile across version bumps; deferring one tick is free insurance. function recreateMainWindow() { + console.log('[diag][main] recreateMainWindow START, crashesInWindow=', rendererCrashTimes.length); const oldWindow = mainWindow; mainWindowReady = false; try { createWindow(); } catch (err) { - console.error('[main] recreateMainWindow: createWindow failed:', err && err.message); + console.error('[diag][main] recreateMainWindow: createWindow failed:', err && err.message); return; } + console.log('[diag][main] recreateMainWindow created fresh window, ua=', mainWindow && mainWindow.webContents.getUserAgent()); const freshWindow = (mainWindow && mainWindow !== oldWindow) ? mainWindow : null; if (freshWindow) { freshWindow.once('ready-to-show', () => { @@ -1159,9 +1175,11 @@ app.on('web-contents-created', (_event, contents) => { // skipped — they render user-visited sites and must advertise the real UA. if ( contents.getType() === 'window' && + !isCreatingMainWindow && mainWindow && contents !== mainWindow.webContents ) { + console.log('[diag][main] spoofing UA for popup webContents id=', contents.id); const OAUTH_POPUP_UA = process.platform === 'win32' ? 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 ' + '(KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36' diff --git a/electron/package.json b/electron/package.json index 465c5dae..3f18cd44 100644 --- a/electron/package.json +++ b/electron/package.json @@ -1,6 +1,6 @@ { "name": "openswarm", - "version": "1.1.44", + "version": "1.1.45", "description": "OpenSwarm — AI Agent Orchestrator", "author": "openswarm-ai", "main": "main.js", diff --git a/electron/preload.js b/electron/preload.js index 04bcd369..95f4d613 100644 --- a/electron/preload.js +++ b/electron/preload.js @@ -1,5 +1,8 @@ const { contextBridge, ipcRenderer } = require('electron'); +// eslint-disable-next-line no-console +console.log('[diag][preload] start, ua=', navigator.userAgent); + // Synchronous exposure. The previous async IIFE (await ipcRenderer.invoke) raced React mount: any code reading window.openswarm during the gap (BrowserCard's Electron-detection falling back to iframe mode, AgentChat's auth-token call throwing) saw undefined. sendSync blocks the renderer for one IPC round-trip during preload before any user-visible paint, so window.openswarm is guaranteed to exist before the first frontend bundle evaluates. const port = ipcRenderer.sendSync('get-backend-port-sync'); const webviewPreloadPath = ipcRenderer.sendSync('get-webview-preload-path-sync'); diff --git a/frontend/src/app/pages/AgentChat/AgentChat.tsx b/frontend/src/app/pages/AgentChat/AgentChat.tsx index 12a65ad2..603c83e3 100644 --- a/frontend/src/app/pages/AgentChat/AgentChat.tsx +++ b/frontend/src/app/pages/AgentChat/AgentChat.tsx @@ -159,6 +159,8 @@ interface AgentChatProps { } const AgentChat: React.FC = ({ sessionId: sessionIdProp, onClose, embedded, autoFocus, isGlowing, onDismissGlow, initialContextPaths, onBranch }) => { + // eslint-disable-next-line no-console + console.log('[diag][AgentChat] render', { sessionIdProp, embedded }); const c = useClaudeTokens(); const STATUS_STYLES: Record = { running: { color: c.status.success, bg: c.status.successBg }, diff --git a/frontend/src/app/pages/Dashboard/cards/BrowserCard.tsx b/frontend/src/app/pages/Dashboard/cards/BrowserCard.tsx index 60e970de..dc78aa00 100644 --- a/frontend/src/app/pages/Dashboard/cards/BrowserCard.tsx +++ b/frontend/src/app/pages/Dashboard/cards/BrowserCard.tsx @@ -74,6 +74,8 @@ const HANDLE_DEFS: { dir: ResizeDir; sx: Record }[] = [ ]; const isElectron = navigator.userAgent.includes('Electron'); +// eslint-disable-next-line no-console +console.log('[diag][BrowserCard] isElectron=', isElectron, 'ua=', navigator.userAgent, 'hasOpenswarm=', !!(window as any).openswarm); const chromeUserAgent = navigator.userAgent .replace(/\s*Electron\/\S+/, '') diff --git a/frontend/src/shared/state/agentsSlice.ts b/frontend/src/shared/state/agentsSlice.ts index b956102e..16073b2d 100644 --- a/frontend/src/shared/state/agentsSlice.ts +++ b/frontend/src/shared/state/agentsSlice.ts @@ -291,13 +291,19 @@ export const fetchSession = createAsyncThunk( export const launchAndSendFirstMessage = createAsyncThunk( 'agents/launchAndSendFirstMessage', async ({ draftId, config, prompt, mode, model, provider, images, contextPaths, forcedTools, attachedSkills, selectedBrowserIds }: LaunchAndSendPayload) => { + // eslint-disable-next-line no-console + console.log('[diag][thunk] launchAndSendFirstMessage START draft=', draftId, 'mode=', mode, 'model=', model, 'provider=', provider); const launchRes = await fetch(`${AGENTS_API}/launch`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(config), }); + // eslint-disable-next-line no-console + console.log('[diag][thunk] launch fetch ok=', launchRes.ok, 'status=', launchRes.status); const launchData = await launchRes.json(); const session = launchData.session as AgentSession; + // eslint-disable-next-line no-console + console.log('[diag][thunk] launch parsed, sessionId=', session && session.id); await fetch(`${AGENTS_API}/sessions/${session.id}/message`, { method: 'POST', @@ -470,9 +476,21 @@ export const searchHistory = createAsyncThunk( export const resumeSession = createAsyncThunk( 'agents/resumeSession', async ({ sessionId }: { sessionId: string }) => { - const res = await fetch(`${AGENTS_API}/sessions/${sessionId}/resume`, { method: 'POST' }); - const data = await res.json(); - return data.session as AgentSession; + // eslint-disable-next-line no-console + console.log('[diag][thunk] resumeSession START', sessionId); + try { + const res = await fetch(`${AGENTS_API}/sessions/${sessionId}/resume`, { method: 'POST' }); + // eslint-disable-next-line no-console + console.log('[diag][thunk] resumeSession fetch ok=', res.ok, 'status=', res.status); + const data = await res.json(); + // eslint-disable-next-line no-console + console.log('[diag][thunk] resumeSession parsed, keys=', Object.keys(data || {}).join(',')); + return data.session as AgentSession; + } catch (e: any) { + // eslint-disable-next-line no-console + console.error('[diag][thunk] resumeSession THREW', e && e.message); + throw e; + } } ); diff --git a/frontend/src/shared/ws/WebSocketManager.ts b/frontend/src/shared/ws/WebSocketManager.ts index 35a8edc5..e7802d2b 100644 --- a/frontend/src/shared/ws/WebSocketManager.ts +++ b/frontend/src/shared/ws/WebSocketManager.ts @@ -192,6 +192,8 @@ class WebSocketManager { const token = _getAuthTokenSafe(); const sep = this.url.includes('?') ? '&' : '?'; const urlWithToken = token ? `${this.url}${sep}token=${encodeURIComponent(token)}` : this.url; + // eslint-disable-next-line no-console + console.log('[diag][ws] connect', this.url, 'sessionId=', this.options.sessionId, 'hasToken=', !!token); this.ws = new WebSocket(urlWithToken); this.ws.onopen = () => {