mirror of
https://github.com/openswarm-ai/openswarm.git
synced 2026-09-12 04:37:44 +02:00
[eric] onboarding: offscreen real-browser harvest with injected cookies (beats Cloudflare, unifies providers)
This commit is contained in:
@@ -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()
|
||||
@@ -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():
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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 };
|
||||
|
||||
Reference in New Issue
Block a user