diff --git a/backend/apps/agents/core/ws_manager.py b/backend/apps/agents/core/ws_manager.py index 7d53d64d..efbb1d01 100644 --- a/backend/apps/agents/core/ws_manager.py +++ b/backend/apps/agents/core/ws_manager.py @@ -16,6 +16,7 @@ BROWSER_CMD_TIMEOUTS = { "wait": 12.0, # smart-wait already caps itself well under this "perform_action": 35.0, # session-borrow shims pack navigate + wait + scrape into ONE command, so it needs more than navigate alone "find_composer": 30.0, # packs trigger + scroll-ladder + retop + open-first into ONE command; on the 15s default the last two tiers were unreachable and heavy pages died mid-ladder (measured: linkedin timed out 2 of 3 runs). The in-page routine self-caps well under this. + "import_session": 40.0, # applies the borrowed cookies AND lets a hidden window sit through the site's bot challenge so the card inherits the clearance; warmBorrowedSession.js self-caps at 15+2.5+5s, and this must outlast that or the warm is killed mid-challenge and we throw away the whole point of it (same trap as find_composer). } BROWSER_CMD_REBROADCAST_S = 3.0 # A CPU-starved renderer can briefly drop its WS (a missed heartbeat) and the frontend auto-reconnects a beat later; bridge that gap instead of hard-failing a live run into it. Short enough that a genuinely-closed window still fails quickly (and no LLM turns are ever burned waiting); long enough to ride out a reconnect even on a loaded machine. diff --git a/backend/tests/test_browser_session_import.py b/backend/tests/test_browser_session_import.py index 206650bf..b5b3a2dc 100644 --- a/backend/tests/test_browser_session_import.py +++ b/backend/tests/test_browser_session_import.py @@ -236,6 +236,23 @@ async def test_wall_handoff_asks_a_human_once_the_door_borrow_did_not_take(monke browser_agent.p_signin_borrowed.discard("acme.example") +def test_import_timeout_outlasts_the_hidden_window_warm(): + """INVARIANT, and the second time this exact trap has bitten (see find_composer): the warm sits + through the site's bot challenge inside the import command, so the command's timeout has to + outlast the warm's own budget. Set them past each other and the window is killed mid-challenge, + which throws away the entire reason the warm exists while still looking like a clean import.""" + from backend.apps.agents.core.ws_manager import BROWSER_CMD_TIMEOUTS, BROWSER_CMD_TIMEOUT_DEFAULT + + with open(os.path.join(P_REPO_ROOT, "electron", "warmBorrowedSession.js"), encoding="utf-8") as fh: + src = fh.read() + budget_ms = sum(int(m) for m in re.findall( + r"^const (?:LOAD_TIMEOUT_MS|SETTLE_MS|DESTROY_GRACE_MS) = (\d+);", src, re.M)) + assert budget_ms > 0, "warm budget constants not found; did warmBorrowedSession change shape?" + timeout_s = BROWSER_CMD_TIMEOUTS.get("import_session", BROWSER_CMD_TIMEOUT_DEFAULT) + assert timeout_s * 1000 > budget_ms, ( + f"import_session timeout {timeout_s}s must outlast the warm budget {budget_ms}ms") + + def test_agent_checks_the_opt_in_before_reading_anything(): """INVARIANT: the borrow helper must consult the setting FIRST. Pinned by source because the ordering is the whole consent story, and an innocent-looking reorder would start reading the diff --git a/electron/main.js b/electron/main.js index 31cb6a56..bedd54c7 100644 --- a/electron/main.js +++ b/electron/main.js @@ -16,6 +16,7 @@ const BROWSER_PARTITION = 'persist:openswarm-browser'; // token because we never borrow for it: its own sign-in is the thing the token exists to satisfy. const p_borrowedSessionDomains = new Set(); const p_uaSwapLogged = new Set(); +const { warmBorrowedSession } = require('./warmBorrowedSession'); function bareChromeUserAgent(ua) { return String(ua || '').replace(/\s*(?:openswarm|Electron)\/\S+/gi, '').replace(/\s{2,}/g, ' ').trim(); @@ -2897,7 +2898,15 @@ async function writePartitionCookies(domain, cookies) { // Borrowing the session and presenting as the browser that earned it are one decision, not two: // apply the cookies without the matching UA and the site refuses them. if (set > 0) p_borrowedSessionDomains.add(d); - return { ok: set > 0, set, total: list.length }; + // Let a plain hidden window take the site's challenge before the card does. It shares this + // partition, so whatever clearance it earns is already waiting when the card loads. + let warmed = false; + if (set > 0) { + const ua = bareChromeUserAgent(session.fromPartition(BROWSER_PARTITION).getUserAgent()); + warmed = await warmBorrowedSession(BROWSER_PARTITION, `https://${d}/`, ua); + console.log(`[borrowed-warm] ${d} warmed=${warmed}`); + } + return { ok: set > 0, set, total: list.length, warmed }; } ipcMain.handle('set-partition-cookies', (_e, domain, cookies) => writePartitionCookies(domain, cookies)); diff --git a/electron/warmBorrowedSession.js b/electron/warmBorrowedSession.js new file mode 100644 index 00000000..80aa541a --- /dev/null +++ b/electron/warmBorrowedSession.js @@ -0,0 +1,71 @@ +// Warm a borrowed sign-in in a hidden window before the visible card loads the site. +// +// Measured 2026-07-27: transplanting the user's own Chrome session into the browser partition works +// (claude.ai accepted it and showed the real account name and plan), but sites behind a serious +// anti-bot edge refuse it in a browser CARD: chatgpt.com, medium.com and instagram.com all had the +// cookies applied and the user agent matched and still reported signed-out. The decisive clue is +// that onboarding's harvest beats Cloudflare on chatgpt.com with those SAME cookies, and the only +// thing it does differently is load them in a plain hidden BrowserWindow instead of a +// guest. A guest carries a preload and the automation tells that come with being embedded; a hidden +// window is just a browser. +// +// So we let the context that passes do the handshake. The hidden window shares the card's partition, +// which means any clearance it earns lands in the same cookie jar the card is about to use. The +// card then arrives already cleared instead of being challenged on its first request. +// +// Main-process only (an offscreen BrowserWindow is not a renderer webview). Always destroys its +// window in a finally, so a failure can never leak one, and never throws: a warm that does not work +// just leaves the card exactly as it would have been. +const { BrowserWindow } = require('electron'); + +const LOAD_TIMEOUT_MS = 15000; +// Anti-bot edges run a JS challenge after the document lands; the clearance cookie is only written +// once that finishes, so returning at load time would throw away the entire point of doing this. +const SETTLE_MS = 2500; +const DESTROY_GRACE_MS = 5000; + +async function warmBorrowedSession(partition, url, userAgent) { + let win = null; + try { + win = new BrowserWindow({ + show: false, + width: 1280, + height: 900, + webPreferences: { + partition, + sandbox: true, + contextIsolation: true, + nodeIntegration: false, + backgroundThrottling: false, + }, + }); + } catch { + return false; + } + + const killer = setTimeout(() => { + try { if (win && !win.isDestroyed()) win.destroy(); } catch { /* already gone */ } + }, LOAD_TIMEOUT_MS + SETTLE_MS + DESTROY_GRACE_MS); + + try { + // Passed to loadURL, not just setUserAgent: the popup-UA spoofer in main.js rewrites any + // contents of type 'window' during construction, and the per-load option is what wins. + if (userAgent) { + try { win.webContents.setUserAgent(userAgent); } catch { /* the load option still carries it */ } + } + const opts = userAgent ? { userAgent } : undefined; + // loadURL rejects when any sub-resource aborts even though the main frame is fine, so a + // rejection here is noise; what matters is that the challenge got time to run. + const load = win.loadURL(url, opts).catch(() => {}); + await Promise.race([load, new Promise((r) => setTimeout(r, LOAD_TIMEOUT_MS))]); + await new Promise((r) => setTimeout(r, SETTLE_MS)); + return true; + } catch { + return false; + } finally { + clearTimeout(killer); + try { if (win && !win.isDestroyed()) win.destroy(); } catch { /* already gone */ } + } +} + +module.exports = { warmBorrowedSession, LOAD_TIMEOUT_MS, SETTLE_MS, DESTROY_GRACE_MS };