[eric] onboarding: offscreen real-browser harvest with injected cookies (beats Cloudflare, unifies providers)

This commit is contained in:
ciregenz
2026-07-16 00:33:28 -07:00
parent b5fcf56d24
commit 2d499f8316
6 changed files with 185 additions and 9 deletions
@@ -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())
@@ -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 <domain>`
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()
+17 -1
View File
@@ -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():
+36 -2
View File
@@ -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 };
+29
View File
@@ -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`
+36 -5
View File
@@ -15,6 +15,18 @@ const ORIGIN = {
claude: 'https://claude.ai/',
};
const DOMAIN = {
codex: 'chatgpt.com',
claude: 'claude.ai',
};
// Main injects a (domain) => Promise<cookieRecords[]> 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 };