diff --git a/backend/apps/agents/core/ws_manager.py b/backend/apps/agents/core/ws_manager.py index 5b31aa54..b5ec0ee5 100644 --- a/backend/apps/agents/core/ws_manager.py +++ b/backend/apps/agents/core/ws_manager.py @@ -46,6 +46,8 @@ class ConnectionManager: self.active_dashboard_id: Optional[str] = None self.pending_futures: dict[str, asyncio.Future] = {} self.browser_futures: dict[str, asyncio.Future] = {} + # The Electron MAIN process (not the renderer) holds a single WS here. Cookie reads route to it so they don't ride the renderer, which macOS throttles when the window is backgrounded (the source of the session-borrow bridge's intermittent timeouts). + self.main_connection: Optional[WebSocket] = None async def connect_session(self, session_id: str, websocket: WebSocket): await websocket.accept() @@ -57,6 +59,15 @@ class ConnectionManager: await websocket.accept() self.global_connections.append(websocket) + async def connect_main(self, websocket: WebSocket): + """Register the single Electron-main bridge socket (replaces any stale prior one).""" + await websocket.accept() + self.main_connection = websocket + + def disconnect_main(self, websocket: WebSocket): + if self.main_connection is websocket: + self.main_connection = None + def disconnect_session(self, session_id: str, websocket: WebSocket): if session_id in self.connections: self.connections[session_id] = [ @@ -213,11 +224,15 @@ class ConnectionManager: async def broadcast_global(self, event: str, data: dict): """Send to all dashboard connections; bypasses seq_log (dashboard resumes via full state refetch).""" payload = json.dumps({"event": event, "data": data}) + dead: list[WebSocket] = [] for ws in list(self.global_connections): try: await ws.send_text(payload) except Exception: - pass + dead.append(ws) + # A renderer that reloaded without a clean close leaves a half-open socket here; a browser command broadcast into it is lost forever (the future then times out). Drop any socket that fails a send so the next command only targets live renderers. + for ws in dead: + self.disconnect_global(ws) async def send_approval_request( self, session_id: str, request_id: str, tool_name: str, tool_input: dict, @@ -293,6 +308,27 @@ class ConnectionManager: finally: self.browser_futures.pop(request_id, None) + async def send_main_command(self, request_id: str, action: str, params: dict) -> dict: + """Send a command straight to the throttle-free Electron MAIN socket (cookie reads only); returns a not-connected error so the caller can fall back to the renderer.""" + ws = self.main_connection + if ws is None: + return {"error": "Electron main bridge not connected"} + loop = asyncio.get_event_loop() + future = loop.create_future() + self.browser_futures[request_id] = future + payload = {"request_id": request_id, "action": action, "browser_id": "", "tab_id": "", "params": params} + try: + await ws.send_text(json.dumps({"event": "browser:command", "data": payload})) + done, _ = await asyncio.wait({future}, timeout=BROWSER_CMD_TIMEOUTS.get(action, BROWSER_CMD_TIMEOUT_DEFAULT)) + if done: + return future.result() + return {"error": "Electron main bridge timed out"} + except Exception as e: + self.disconnect_main(ws) + return {"error": f"Electron main bridge send failed: {e}"} + finally: + self.browser_futures.pop(request_id, None) + def resolve_browser_command(self, request_id: str, result: dict): """Resolve a pending browser command Future with the frontend's result.""" future = self.browser_futures.get(request_id) diff --git a/backend/main.py b/backend/main.py index f493b22f..7c16cf89 100644 --- a/backend/main.py +++ b/backend/main.py @@ -363,6 +363,23 @@ async def websocket_dashboard(websocket: WebSocket): ws_manager.disconnect_global(websocket) +@app.websocket("/ws/electron-main") +async def websocket_electron_main(websocket: WebSocket): + """The Electron MAIN process (not the renderer) attaches here to serve partition-cookie + reads for the session-borrow bridge. Main doesn't throttle when the window is backgrounded, + so cookie reads over this socket don't hit the renderer's intermittent-timeout problem.""" + if not p_ws_auth_ok(websocket): + return + await ws_manager.connect_main(websocket) + try: + while True: + msg = json.loads(await websocket.receive_text()) + if msg.get("event") == "browser:result": + ws_manager.resolve_browser_command(msg.get("data", {}).get("request_id", ""), msg.get("data", {})) + except WebSocketDisconnect: + ws_manager.disconnect_main(websocket) + + @app.get("/api/dev/token") async def dev_token(): """Hand the per-install token to the dev frontend, which has no Electron @@ -508,19 +525,28 @@ P_SESSION_AUTH_COOKIES = { } +async def p_read_session_cookies(domain: str) -> dict: + """Read a vetted platform's live partition cookies. Prefers the Electron MAIN bridge + (throttle-free) and falls back to the renderer when main hasn't attached or can't answer.""" + if ws_manager.main_connection is not None: + result = await ws_manager.send_main_command(uuid4().hex, "get_session_cookies", {"domain": domain}) + if not result.get("error"): + return result + return await ws_manager.send_browser_command(uuid4().hex, "get_session_cookies", "", {"domain": domain}) + + @app.get("/api/browser-session/cookies") async def browser_session_cookies(domain: str = ""): """Hand a vetted platform's live partition cookies + UA to its own-session MCP shim. Auth is the standard localhost token (middleware). Cookies are read live from - Electron's persist:openswarm-browser partition via the dashboard bridge and are - never persisted server-side; the shim talks to the site as the user's browser. + Electron's persist:openswarm-browser partition and are never persisted server-side; + the shim talks to the site as the user's browser. """ d = (domain or "").lower().strip().lstrip(".") if d not in P_SESSION_COOKIE_DOMAINS: return JSONResponse({"error": f"domain not allowed: {d or '(empty)'}", "cookies": []}, status_code=400) - rid = uuid4().hex - result = await ws_manager.send_browser_command(rid, "get_session_cookies", "", {"domain": d}) + result = await p_read_session_cookies(d) if result.get("error"): return JSONResponse({"error": result["error"], "cookies": []}) return JSONResponse({"cookies": result.get("cookies", []), "userAgent": result.get("userAgent", "")}) @@ -537,8 +563,7 @@ async def browser_session_status(domain: str = ""): d = (domain or "").lower().strip().lstrip(".") if d not in P_SESSION_COOKIE_DOMAINS: return JSONResponse({"error": f"domain not allowed: {d or '(empty)'}", "connected": False}, status_code=400) - rid = uuid4().hex - result = await ws_manager.send_browser_command(rid, "get_session_cookies", "", {"domain": d}) + result = await p_read_session_cookies(d) if result.get("error"): return JSONResponse({"connected": False, "error": result["error"]}) wanted = P_SESSION_AUTH_COOKIES.get(d, ()) diff --git a/electron/main.js b/electron/main.js index e611a73e..30c138e0 100644 --- a/electron/main.js +++ b/electron/main.js @@ -1078,6 +1078,7 @@ function markBackendReady() { workflowsLifecycle.setBackend({ port: backendPort, token: authToken }); workflowsLifecycle.startPolling(); } catch (_) {} + try { connectMainBridge(); } catch (_) {} } function getAuthTokenFilePath() { @@ -2778,7 +2779,7 @@ ipcMain.handle('browser:clear-data', async () => { // Hand the user's own logged-in cookies for a vetted social platform to its session-backed MCP shim (Reddit/X/TikTok). Reads from the browser partition's main-process cookie store, so httpOnly auth cookies (e.g. reddit_session) are included, which document.cookie can't see. Allowlisted domains ONLY, so this can never become a general cookie-theft surface; the backend re-checks the same allowlist before it ever calls this. const SESSION_COOKIE_DOMAINS = ['reddit.com', 'x.com', 'twitter.com', 'tiktok.com']; -ipcMain.handle('get-partition-cookies', async (_e, domain) => { +async function readPartitionCookies(domain) { const d = String(domain || '').toLowerCase().trim().replace(/^\./, ''); if (!SESSION_COOKIE_DOMAINS.includes(d)) { return { cookies: [], userAgent: '', error: `domain not allowed: ${d || '(empty)'}` }; @@ -2795,7 +2796,38 @@ ipcMain.handle('get-partition-cookies', async (_e, domain) => { } catch (err) { return { cookies: [], userAgent: '', error: `cookie read failed: ${err && err.message}` }; } -}); +} +ipcMain.handle('get-partition-cookies', (_e, domain) => readPartitionCookies(domain)); + +// The renderer relays cookie reads for the session-borrow bridge, but macOS throttles it when the +// window is backgrounded, so those reads intermittently time out. Main never throttles: hold our own +// socket to the backend and answer get_session_cookies here. Cookie reads only; the renderer still +// owns everything that needs a live webview (navigate/click/perform_action). +let p_mainBridgeWs = null; +let p_mainBridgeStopped = false; +function connectMainBridge() { + if (p_mainBridgeStopped || !backendPort || !authToken || p_mainBridgeWs) return; + let ws; + try { + ws = new WebSocket(`ws://127.0.0.1:${backendPort}/ws/electron-main?token=${encodeURIComponent(authToken)}`); + } catch (_) { + setTimeout(connectMainBridge, 3000); + return; + } + p_mainBridgeWs = ws; + ws.addEventListener('message', async (ev) => { + let msg; + try { msg = JSON.parse(typeof ev.data === 'string' ? ev.data : ''); } catch (_) { return; } + if (!msg || msg.event !== 'browser:command') return; + const cmd = msg.data || {}; + if (cmd.action !== 'get_session_cookies') return; + const result = await readPartitionCookies((cmd.params && cmd.params.domain) || ''); + try { ws.send(JSON.stringify({ event: 'browser:result', data: { request_id: cmd.request_id, ...result } })); } catch (_) {} + }); + const retry = () => { p_mainBridgeWs = null; if (!p_mainBridgeStopped) setTimeout(connectMainBridge, 3000); }; + ws.addEventListener('close', retry); + ws.addEventListener('error', () => { try { ws.close(); } catch (_) { retry(); } }); +} ipcMain.handle('get-update-status', () => cachedUpdateStatus); diff --git a/linter/config/config.json b/linter/config/config.json index 20ae95f8..f3d7a864 100644 --- a/linter/config/config.json +++ b/linter/config/config.json @@ -68,6 +68,7 @@ "backend/apps/agents/browser/browser_agent.py", "backend/apps/agents/browser_agent_mcp_server.py", "backend/apps/agents/browser/browser_schema.py", + "backend/apps/agents/core/ws_manager.py", "backend/apps/agents/manager/prompt/prompt_context.py", "backend/apps/agents/manager/prompt/attachments.py", "backend/apps/agents/providers/pricing.py",