From 2d499f8316234a5dc8bef49605cac6e1a7c64df2 Mon Sep 17 00:00:00 2001 From: ciregenz Date: Thu, 16 Jul 2026 00:33:28 -0700 Subject: [PATCH] [eric] onboarding: offscreen real-browser harvest with injected cookies (beats Cloudflare, unifies providers) --- .../apps/onboarding/usage/browser_cookies.py | 43 ++++++++++++++++++- backend/apps/onboarding/usage/dump_cookies.py | 25 +++++++++++ backend/tests/test_onboarding.py | 18 +++++++- electron/hiddenBrowser.js | 38 +++++++++++++++- electron/main.js | 29 +++++++++++++ electron/usageHarvest.js | 41 +++++++++++++++--- 6 files changed, 185 insertions(+), 9 deletions(-) create mode 100644 backend/apps/onboarding/usage/dump_cookies.py diff --git a/backend/apps/onboarding/usage/browser_cookies.py b/backend/apps/onboarding/usage/browser_cookies.py index 6039cf4f..9f4148f9 100644 --- a/backend/apps/onboarding/usage/browser_cookies.py +++ b/backend/apps/onboarding/usage/browser_cookies.py @@ -18,7 +18,7 @@ import shutil import sqlite3 import subprocess import tempfile -from typing import Dict, List, Optional, Tuple +from typing import Any, Dict, List, Optional, Tuple from typeguard import typechecked @@ -158,6 +158,47 @@ def read_provider_cookies(domain: str) -> Dict[str, str]: return jar +@typechecked +def read_provider_cookie_records(domain: str) -> List[Dict[str, Any]]: + """Full cookie records ({name,value,domain,path,secure,httponly}) for `domain`, so Electron's offscreen browser can re-inject the session faithfully and pass Cloudflare with a real Chrome TLS handshake. Same one-store, one-keychain-touch path as read_provider_cookies.""" + store = p_best_store(domain) + if store is None: + return [] + browser, db_path = store + key = p_safe_storage_key(browser) + if key is None: + return [] + records: List[Dict[str, Any]] = [] + tmp = tempfile.mktemp() + try: + shutil.copy2(db_path, tmp) + con = sqlite3.connect(f"file:{tmp}?mode=ro", uri=True) + cur = con.cursor() + cur.execute( + "SELECT name, encrypted_value, host_key, path, is_secure, is_httponly FROM cookies WHERE host_key LIKE ?", + (f"%{domain}",), + ) + for name, enc, host_key, path, is_secure, is_httponly in cur.fetchall(): + if not enc: + continue + val = p_decrypt(bytes(enc), key) + if val is None: + continue + records.append({ + "name": str(name), "value": val, "domain": str(host_key), + "path": str(path) or "/", "secure": bool(is_secure), "httponly": bool(is_httponly), + }) + con.close() + except Exception: + pass + finally: + try: + os.remove(tmp) + except OSError: + pass + return records + + @typechecked def cookie_header(jar: Dict[str, str]) -> str: return "; ".join(f"{k}={v}" for k, v in jar.items()) diff --git a/backend/apps/onboarding/usage/dump_cookies.py b/backend/apps/onboarding/usage/dump_cookies.py new file mode 100644 index 00000000..aa6f95d5 --- /dev/null +++ b/backend/apps/onboarding/usage/dump_cookies.py @@ -0,0 +1,25 @@ +"""One-shot CLI: print the user's provider cookie records as JSON for a domain. + +Electron main spawns `python -m backend.apps.onboarding.usage.dump_cookies ` +to get the session cookies to inject into its offscreen browser (real Chrome TLS to +beat Cloudflare). Kept as a spawned one-shot, not an HTTP endpoint, so a token-holding +agent can never reach it: only the trusted app shell can invoke it. Prints [] on +anything (bad domain, no session, denied keychain). Never logs the cookie values. +""" + +import json +import sys + +from backend.apps.onboarding.usage.browser_cookies import read_provider_cookie_records + +ALLOWED_DOMAINS = {"chatgpt.com", "claude.ai", "gemini.google.com"} + + +def main() -> None: + domain = sys.argv[1] if len(sys.argv) > 1 else "" + records = read_provider_cookie_records(domain) if domain in ALLOWED_DOMAINS else [] + sys.stdout.write(json.dumps(records)) + + +if __name__ == "__main__": + main() diff --git a/backend/tests/test_onboarding.py b/backend/tests/test_onboarding.py index 54fbb75f..780a97b6 100644 --- a/backend/tests/test_onboarding.py +++ b/backend/tests/test_onboarding.py @@ -135,9 +135,25 @@ async def test_harvest_chatgpt_usage_fails_open_without_codex(monkeypatch): def test_read_provider_cookies_fails_open_without_a_store(monkeypatch): from backend.apps.onboarding.usage import browser_cookies - # No browser store has the domain -> empty jar, and the keychain is never touched. + # No browser store has the domain -> empty jar/records, and the keychain is never touched. monkeypatch.setattr(browser_cookies, "p_best_store", lambda domain: None) assert browser_cookies.read_provider_cookies("claude.ai") == {} + assert browser_cookies.read_provider_cookie_records("claude.ai") == [] + + +def test_dump_cookies_only_serves_allowlisted_domains(monkeypatch, capsys): + from backend.apps.onboarding.usage import dump_cookies + + # Patch the name in dump_cookies' own namespace, so a real read (+ keychain) never fires. + monkeypatch.setattr(dump_cookies, "read_provider_cookie_records", lambda domain: [{"name": "x", "value": "y"}]) + # An off-list domain must never trigger a read, prints []. + monkeypatch.setattr("sys.argv", ["dump_cookies", "evil.example.com"]) + dump_cookies.main() + assert capsys.readouterr().out == "[]" + # An allowlisted domain passes through to the reader. + monkeypatch.setattr("sys.argv", ["dump_cookies", "claude.ai"]) + dump_cookies.main() + assert '"name": "x"' in capsys.readouterr().out def test_summarize_claude_usage_counts_and_caps(): diff --git a/electron/hiddenBrowser.js b/electron/hiddenBrowser.js index a01007db..f3872cd0 100644 --- a/electron/hiddenBrowser.js +++ b/electron/hiddenBrowser.js @@ -6,7 +6,7 @@ // // Main-process only (offscreen BrowserWindow isn't a renderer webview). Every // path destroys its window in a finally, so a failure can never leak a window. -const { BrowserWindow } = require('electron'); +const { BrowserWindow, session } = require('electron'); const SCRAPE_UA = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36'; const SETTLE_MS = 2800; @@ -81,6 +81,40 @@ async function hiddenEval(partition, url, js) { }); } +// Inject the user's own session cookies (read + decrypted by the Python backend) into a +// throwaway IN-MEMORY session, then run an app-authored read from a real Chromium context. +// This is how we beat provider Cloudflare: a raw HTTP client's TLS handshake gets fingerprint- +// blocked, but this IS Chrome, so it passes exactly like the user's browser. The partition has +// no "persist:" prefix, so nothing ever hits disk; cookies are cleared before AND after. +const HARVEST_PARTITION = 'osw-usage-harvest'; + +async function hiddenEvalWithCookies(url, cookieRecords, js) { + const ses = session.fromPartition(HARVEST_PARTITION); + const wipe = async () => { try { await ses.clearStorageData({ storages: ['cookies'] }); } catch (_) {} }; + await wipe(); + for (const c of cookieRecords || []) { + try { + await ses.cookies.set({ + url, + name: c.name, + value: c.value, + domain: c.domain || undefined, + path: c.path || '/', + secure: c.secure !== false, + httpOnly: !!c.httponly, + }); + } catch (_) { /* skip a malformed cookie, never abort the set */ } + } + try { + return await withWindow(HARVEST_PARTITION, async (win) => { + await loadAndSettle(win, url); + return win.webContents.executeJavaScript(js, true).catch(() => null); + }); + } finally { + await wipe(); + } +} + // Google first (direct result URLs, best quality); DuckDuckGo in a real browser // second (immune to the httpx 202 throttle); Bing last (results are redirect-wrapped). const ENGINES = [ @@ -114,4 +148,4 @@ async function hiddenSearch(partition, query, numResults) { return { error: 'all browser search engines failed', detail: errors.join('; ') }; } -module.exports = { hiddenFetch, hiddenSearch, hiddenEval }; +module.exports = { hiddenFetch, hiddenSearch, hiddenEval, hiddenEvalWithCookies }; diff --git a/electron/main.js b/electron/main.js index c74d28ea..4c9427c6 100644 --- a/electron/main.js +++ b/electron/main.js @@ -767,6 +767,35 @@ function getPythonPath() { return path.join(__dirname, '..', 'backend', '.venv', 'bin', 'python3'); } +// Read the user's provider session cookies via a one-shot bundled-python invocation, so the +// offscreen harvest can inject them and pass provider Cloudflare with a real Chrome TLS +// handshake. Spawned, NEVER an HTTP endpoint, so a token-holding agent can't reach it: only +// the app shell invokes it. Always resolves (to [] on any failure) so the harvest just falls +// back to the opportunistic path. mirrors startBackend's python env (projectRoot + site-packages). +function p_readProviderCookies(domain) { + return new Promise((resolve) => { + let done = false; + const finish = (v) => { if (!done) { done = true; resolve(v); } }; + try { + const root = isPackaged ? process.resourcesPath : path.join(__dirname, '..'); + const env = { ...process.env, PYTHONUTF8: '1', PYTHONDONTWRITEBYTECODE: '1' }; + if (isPackaged) { + const sitePackages = process.platform === 'win32' + ? path.join(process.resourcesPath, 'python-env', 'Lib', 'site-packages') + : path.join(process.resourcesPath, 'python-env', 'lib', 'python3.13', 'site-packages'); + env.PYTHONPATH = [root, sitePackages].join(path.delimiter); + } + const proc = spawn(getPythonPath(), ['-m', 'backend.apps.onboarding.usage.dump_cookies', String(domain)], { cwd: root, env }); + let out = ''; + proc.stdout.on('data', (d) => { out += d.toString(); }); + proc.on('error', () => finish([])); + proc.on('close', () => { try { const j = JSON.parse(out); finish(Array.isArray(j) ? j : []); } catch (_) { finish([]); } }); + setTimeout(() => { try { proc.kill(); } catch (_) {} finish([]); }, 25000); + } catch (_) { finish([]); } + }); +} +usageHarvest.configure({ readCookies: p_readProviderCookies }); + // Path to a real Node.js binary bundled in extraResources, or null if not // shipped (dev mode, or build that skipped the node-fetch step). Backend // reads OPENSWARM_NODE_PATH env var to prefer this over both system `node` diff --git a/electron/usageHarvest.js b/electron/usageHarvest.js index a55d6629..fc366664 100644 --- a/electron/usageHarvest.js +++ b/electron/usageHarvest.js @@ -15,6 +15,18 @@ const ORIGIN = { claude: 'https://claude.ai/', }; +const DOMAIN = { + codex: 'chatgpt.com', + claude: 'claude.ai', +}; + +// Main injects a (domain) => Promise that spawns the Python cookie reader. +// Left null off-Electron / before boot, so harvest silently skips the imported-cookie path. +let p_readCookies = null; +function configure(opts) { + if (opts && typeof opts.readCookies === 'function') p_readCookies = opts.readCookies; +} + // Runs in the page context. Sweeps the full conversation history (all titles, // paginated + deduped) plus ChatGPT Memory. Hard caps bound the work + PII footprint // even for a user with thousands of chats; every fetch fails open to empty. @@ -73,10 +85,29 @@ const SCRIPT = { const EMPTY = { ok: false, total: 0, titles: [], memories: [] }; -async function harvest(partition, provider) { - if (provider !== 'codex' && provider !== 'claude') return EMPTY; - const res = await hiddenBrowser.hiddenEval(partition, ORIGIN[provider], SCRIPT[provider]).catch(() => null); - return res && typeof res === 'object' && res.ok ? res : EMPTY; +function p_usable(res) { + return res && typeof res === 'object' && res.ok && + (res.total > 0 || (res.memories && res.memories.length) || (res.titles && res.titles.length)); } -module.exports = { harvest }; +async function harvest(partition, provider) { + if (provider !== 'codex' && provider !== 'claude') return EMPTY; + // First run: read the user's own browser session cookies, inject into a throwaway real-Chrome + // context, and harvest there. This is the only path that beats provider Cloudflare AND works + // before the user has opened the site in-app. + if (p_readCookies) { + try { + const records = await p_readCookies(DOMAIN[provider]); + if (records && records.length) { + const viaCookies = await hiddenBrowser.hiddenEvalWithCookies(ORIGIN[provider], records, SCRIPT[provider]).catch(() => null); + if (p_usable(viaCookies)) return viaCookies; + } + } catch (_) { /* fall through to the opportunistic path */ } + } + // Opportunistic: the user already logged into the site in an in-app card, so the browser + // partition holds the session; read it directly (also a real Chrome context). + const res = await hiddenBrowser.hiddenEval(partition, ORIGIN[provider], SCRIPT[provider]).catch(() => null); + return p_usable(res) ? res : EMPTY; +} + +module.exports = { harvest, configure };