diff --git a/backend/apps/onboarding/usage/browser_cookies.py b/backend/apps/onboarding/usage/browser_cookies.py index 9f4148f9..828ee62d 100644 --- a/backend/apps/onboarding/usage/browser_cookies.py +++ b/backend/apps/onboarding/usage/browser_cookies.py @@ -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()) diff --git a/backend/apps/onboarding/usage/dump_cookies.py b/backend/apps/onboarding/usage/dump_cookies.py index aa6f95d5..565611db 100644 --- a/backend/apps/onboarding/usage/dump_cookies.py +++ b/backend/apps/onboarding/usage/dump_cookies.py @@ -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)) diff --git a/backend/tests/test_onboarding.py b/backend/tests/test_onboarding.py index 780a97b6..c2546db9 100644 --- a/backend/tests/test_onboarding.py +++ b/backend/tests/test_onboarding.py @@ -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(): diff --git a/electron/usageHarvest.js b/electron/usageHarvest.js index e74d1ade..414d96bf 100644 --- a/electron/usageHarvest.js +++ b/electron/usageHarvest.js @@ -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 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. diff --git a/frontend/src/app/components/OnboardingV3/useOnboardingV3Pipeline.ts b/frontend/src/app/components/OnboardingV3/useOnboardingV3Pipeline.ts index 7e7c816e..418fac79 100644 --- a/frontend/src/app/components/OnboardingV3/useOnboardingV3Pipeline.ts +++ b/frontend/src/app/components/OnboardingV3/useOnboardingV3Pipeline.ts @@ -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 { diff --git a/frontend/src/shared/providerUsage.ts b/frontend/src/shared/providerUsage.ts index 6afa85d8..dbf8040f 100644 --- a/frontend/src/shared/providerUsage.ts +++ b/frontend/src/shared/providerUsage.ts @@ -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;