mirror of
https://github.com/openswarm-ai/openswarm.git
synced 2026-08-31 20:29:56 +02:00
[eric] onboarding: gemini history harvest via offscreen scrape + scoped google SSO cookies
This commit is contained in:
@@ -199,6 +199,22 @@ def read_provider_cookie_records(domain: str) -> List[Dict[str, Any]]:
|
||||
return records
|
||||
|
||||
|
||||
# Gemini authenticates on the parent .google.com SSO domain, not gemini.google.com, so its
|
||||
# session lives in these named cookies. We read ONLY these (never the whole google cookie
|
||||
# jar) and only to load a Gemini page offscreen, keeping the ChatGPT/Claude trust frame.
|
||||
GOOGLE_AUTH_COOKIE_NAMES = {
|
||||
"SID", "HSID", "SSID", "APISID", "SAPISID", "SIDCC", "NID",
|
||||
"__Secure-1PSID", "__Secure-3PSID", "__Secure-1PSIDTS", "__Secure-3PSIDTS",
|
||||
"__Secure-1PSIDCC", "__Secure-3PSIDCC", "__Secure-1PAPISID", "__Secure-3PAPISID",
|
||||
}
|
||||
|
||||
|
||||
@typechecked
|
||||
def read_google_session_records() -> List[Dict[str, Any]]:
|
||||
"""The named Google SSO cookies from .google.com, so the offscreen browser can load Gemini logged in. Scoped to the auth set by name, never a general google-cookie sweep."""
|
||||
return [r for r in read_provider_cookie_records(".google.com") if r.get("name") in GOOGLE_AUTH_COOKIE_NAMES]
|
||||
|
||||
|
||||
@typechecked
|
||||
def cookie_header(jar: Dict[str, str]) -> str:
|
||||
return "; ".join(f"{k}={v}" for k, v in jar.items())
|
||||
|
||||
@@ -9,15 +9,27 @@ anything (bad domain, no session, denied keychain). Never logs the cookie values
|
||||
|
||||
import json
|
||||
import sys
|
||||
from typing import Any, Dict, List
|
||||
|
||||
from backend.apps.onboarding.usage.browser_cookies import read_provider_cookie_records
|
||||
from backend.apps.onboarding.usage.browser_cookies import (
|
||||
read_google_session_records,
|
||||
read_provider_cookie_records,
|
||||
)
|
||||
|
||||
ALLOWED_DOMAINS = {"chatgpt.com", "claude.ai", "gemini.google.com"}
|
||||
|
||||
|
||||
def records_for(domain: str) -> List[Dict[str, Any]]:
|
||||
# Gemini's login lives on the parent .google.com SSO domain, so read the scoped google
|
||||
# auth set for it; every other provider reads only its own domain.
|
||||
if domain == "gemini.google.com":
|
||||
return read_google_session_records()
|
||||
return read_provider_cookie_records(domain)
|
||||
|
||||
|
||||
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 []
|
||||
records = records_for(domain) if domain in ALLOWED_DOMAINS else []
|
||||
sys.stdout.write(json.dumps(records))
|
||||
|
||||
|
||||
|
||||
@@ -144,8 +144,9 @@ def test_read_provider_cookies_fails_open_without_a_store(monkeypatch):
|
||||
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.
|
||||
# Patch the names 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"}])
|
||||
monkeypatch.setattr(dump_cookies, "read_google_session_records", lambda: [{"name": "SID", "value": "g"}])
|
||||
# An off-list domain must never trigger a read, prints [].
|
||||
monkeypatch.setattr("sys.argv", ["dump_cookies", "evil.example.com"])
|
||||
dump_cookies.main()
|
||||
@@ -154,6 +155,31 @@ def test_dump_cookies_only_serves_allowlisted_domains(monkeypatch, capsys):
|
||||
monkeypatch.setattr("sys.argv", ["dump_cookies", "claude.ai"])
|
||||
dump_cookies.main()
|
||||
assert '"name": "x"' in capsys.readouterr().out
|
||||
# Gemini routes to the SCOPED google reader, not a raw gemini.google.com read.
|
||||
monkeypatch.setattr("sys.argv", ["dump_cookies", "gemini.google.com"])
|
||||
dump_cookies.main()
|
||||
assert '"name": "SID"' in capsys.readouterr().out
|
||||
|
||||
|
||||
def test_read_google_session_records_scopes_to_named_auth_cookies(monkeypatch):
|
||||
from backend.apps.onboarding.usage import browser_cookies
|
||||
|
||||
seen_domain = {}
|
||||
|
||||
def fake_records(domain: str):
|
||||
seen_domain["d"] = domain
|
||||
return [
|
||||
{"name": "SID", "value": "a"},
|
||||
{"name": "__Secure-1PSID", "value": "b"},
|
||||
{"name": "SEARCH_SAMESITE", "value": "c"}, # non-auth google cookie
|
||||
{"name": "OTZ", "value": "d"}, # non-auth google cookie
|
||||
]
|
||||
|
||||
monkeypatch.setattr(browser_cookies, "read_provider_cookie_records", fake_records)
|
||||
recs = browser_cookies.read_google_session_records()
|
||||
# Reads the parent SSO domain, then keeps ONLY the named auth cookies (never a full sweep).
|
||||
assert seen_domain["d"] == ".google.com"
|
||||
assert {r["name"] for r in recs} == {"SID", "__Secure-1PSID"}
|
||||
|
||||
|
||||
def test_summarize_claude_usage_counts_and_caps():
|
||||
|
||||
@@ -13,11 +13,13 @@ const hiddenBrowser = require('./hiddenBrowser');
|
||||
const ORIGIN = {
|
||||
codex: 'https://chatgpt.com/',
|
||||
claude: 'https://claude.ai/',
|
||||
gemini: 'https://gemini.google.com/app',
|
||||
};
|
||||
|
||||
const DOMAIN = {
|
||||
codex: 'chatgpt.com',
|
||||
claude: 'claude.ai',
|
||||
gemini: 'gemini.google.com',
|
||||
};
|
||||
|
||||
// Main injects a (domain) => Promise<cookieRecords[]> that spawns the Python cookie reader.
|
||||
@@ -89,6 +91,27 @@ const SCRIPT = {
|
||||
return {ok:true, total:seen.size, titles:titles.slice(0, CAP_TITLES), memories:[]};
|
||||
} catch (e) { return {ok:false, total:0, titles:[], memories:[]}; }
|
||||
})()`,
|
||||
// Gemini has no clean history JSON (it's the obfuscated batchexecute RPC), so we scrape the
|
||||
// rendered rail instead: it starts collapsed, so click "Open sidebar", then read the recent
|
||||
// conversation titles as they hydrate. Each title is length-capped so a stray long node can't
|
||||
// pollute the profile; bounded by the same wall-clock budget as the fetch providers.
|
||||
gemini: `(async () => {
|
||||
const BUDGET_MS=14000, CAP_TITLES=200, TITLE_MAX=140; const startedAt=Date.now();
|
||||
try {
|
||||
const btn = Array.from(document.querySelectorAll('button,[role="button"]')).find(b => /open sidebar|main menu|expand/i.test(b.getAttribute('aria-label')||''));
|
||||
if (btn) { try { btn.click(); } catch(_){} }
|
||||
const seen = new Set(); const titles = []; let zeroStreak = 0;
|
||||
while (Date.now()-startedAt < BUDGET_MS && titles.length < CAP_TITLES) {
|
||||
let nodes = document.querySelectorAll('[data-test-id="conversation"] .title-text');
|
||||
if (!nodes.length) nodes = document.querySelectorAll('[data-test-id="conversation"] a');
|
||||
let fresh = 0;
|
||||
nodes.forEach(e => { const t=(e.textContent||'').trim().slice(0, TITLE_MAX); if (t && !seen.has(t)) { seen.add(t); titles.push(t); fresh++; } });
|
||||
if (titles.length > 0 && fresh === 0) { if (++zeroStreak >= 2) break; } else { zeroStreak = 0; }
|
||||
await new Promise(r=>setTimeout(r, 700));
|
||||
}
|
||||
return { ok: titles.length>0, total: titles.length, titles: titles.slice(0, CAP_TITLES), memories: [] };
|
||||
} catch (e) { return {ok:false, total:0, titles:[], memories:[]}; }
|
||||
})()`,
|
||||
};
|
||||
|
||||
const EMPTY = { ok: false, total: 0, titles: [], memories: [] };
|
||||
@@ -99,7 +122,7 @@ function p_usable(res) {
|
||||
}
|
||||
|
||||
async function harvest(partition, provider) {
|
||||
if (provider !== 'codex' && provider !== 'claude') return EMPTY;
|
||||
if (provider !== 'codex' && provider !== 'claude' && provider !== 'gemini') 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.
|
||||
|
||||
@@ -49,7 +49,8 @@ export function useOnboardingV3Pipeline() {
|
||||
// Read what the user works on, silently and with no card: main opens the provider site offscreen on the browser partition and runs its own harvest script (see electron/usageHarvest.js). Fail-open: no session in the partition, off-Electron, or an error => empty summary, prep falls back to scan + identity.
|
||||
const kickUsageRead = useCallback((provider: string, consented: boolean) => {
|
||||
if (usageReadRef.current || !consented) return;
|
||||
const key: UsageProvider | null = provider === 'codex' ? 'codex' : provider === 'claude' ? 'claude' : null;
|
||||
const geminiIds = provider === 'antigravity' || provider === 'gemini-cli' || provider === 'gemini';
|
||||
const key: UsageProvider | null = provider === 'codex' ? 'codex' : provider === 'claude' ? 'claude' : geminiIds ? 'gemini' : null;
|
||||
if (!key) return;
|
||||
usageReadRef.current = (async () => {
|
||||
try {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// The user's provider chat history is read offscreen in the main process (see electron/usageHarvest.js), which owns the injected script + the partition session. This module holds only the shared shape + the pure summarizer that turns the raw read into the compact profile block prep sees. The raw read is dropped after; only this summary travels.
|
||||
|
||||
export type UsageProvider = 'codex' | 'claude';
|
||||
export type UsageProvider = 'codex' | 'claude' | 'gemini';
|
||||
|
||||
export interface ProviderUsage {
|
||||
ok: boolean;
|
||||
|
||||
Reference in New Issue
Block a user