mirror of
https://github.com/openswarm-ai/openswarm.git
synced 2026-08-17 18:25:42 +02:00
[eric] browser-bridge: serve cookie reads from Electron main (fixes backgrounded timeouts)
This commit is contained in:
@@ -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
@@ -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
@@ -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);
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user