[eric] browser: let a hidden window take the bot challenge before the card loads a borrowed site

This commit is contained in:
ciregenz
2026-07-27 20:15:39 -07:00
parent 3003b1cd06
commit 59b07cd575
4 changed files with 99 additions and 1 deletions
+1
View File
@@ -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.
@@ -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
+10 -1
View File
@@ -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));
+71
View File
@@ -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 <webview>
// 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 };