[eric] browser-bridge: serve cookie reads from Electron main (fixes backgrounded timeouts)

This commit is contained in:
ciregenz
2026-07-01 23:50:17 -07:00
parent cb2ccdc37b
commit 169eb0db8a
4 changed files with 103 additions and 9 deletions
+37 -1
View File
@@ -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)
+31 -6
View File
@@ -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, ())
+34 -2
View File
@@ -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);
+1
View File
@@ -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",