diff --git a/electron/hiddenBrowser.js b/electron/hiddenBrowser.js index 99bb5e64..a01007db 100644 --- a/electron/hiddenBrowser.js +++ b/electron/hiddenBrowser.js @@ -68,6 +68,19 @@ async function hiddenFetch(partition, url) { }); } +// Load a URL offscreen on the given partition (so it inherits that partition's +// logged-in session) and run an app-authored script in the page context, returning +// whatever it resolves to. Used to read the user's own provider history (chatgpt.com / +// claude.ai) with no visible card. The script is caller-owned and must be app code, +// never anything a remote page or the renderer can choose; the offscreen window is +// destroyed in withWindow's finally regardless of outcome. +async function hiddenEval(partition, url, js) { + return withWindow(partition, async (win) => { + await loadAndSettle(win, url); + return win.webContents.executeJavaScript(js, true).catch(() => null); + }); +} + // 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 = [ @@ -101,4 +114,4 @@ async function hiddenSearch(partition, query, numResults) { return { error: 'all browser search engines failed', detail: errors.join('; ') }; } -module.exports = { hiddenFetch, hiddenSearch }; +module.exports = { hiddenFetch, hiddenSearch, hiddenEval }; diff --git a/electron/main.js b/electron/main.js index f6debeac..c74d28ea 100644 --- a/electron/main.js +++ b/electron/main.js @@ -54,6 +54,7 @@ const { spawn, execFileSync } = require('child_process'); const os = require('os'); const fs = require('fs'); const hiddenBrowser = require('./hiddenBrowser'); +const usageHarvest = require('./usageHarvest'); const getPort = require('get-port'); const http = require('http'); const affiliateTracking = require('./affiliateTracking'); @@ -2809,6 +2810,14 @@ async function readPartitionCookies(domain) { } ipcMain.handle('get-partition-cookies', (_e, domain) => readPartitionCookies(domain)); +// Silently read the user's own chatgpt.com / claude.ai history from the browser +// partition's logged-in session (offscreen, no card) so onboarding can personalize. +// Provider-gated + main-owned script (see usageHarvest.js); fails open to the empty +// shape when no session exists in the partition. +ipcMain.handle('harvest-usage', (_e, provider) => + usageHarvest.harvest(BROWSER_PARTITION, provider).catch(() => ({ ok: false, total: 0, titles: [], memories: [] })), +); + // Suspend/resume state capsules: the app renderer stages a resumed webview's sessionStorage snapshot here (keyed by that guest's webContents id) right before loadURL; the guest preload sync-takes it at document-start with an origin match, so page scripts see restored state and logins survive suspension. In-memory only, single-shot, short TTL; a guest can only ever take its OWN capsule. const pendingSessionCapsules = new Map(); const SESSION_CAPSULE_TTL_MS = 2 * 60 * 1000; diff --git a/electron/preload.js b/electron/preload.js index 93a1e8b2..e82275ca 100644 --- a/electron/preload.js +++ b/electron/preload.js @@ -62,6 +62,8 @@ contextBridge.exposeInMainWorld('openswarm', { connectSlack: () => ipcRenderer.invoke('connect-slack'), // Hands a vetted social platform's partition cookies to its session-backed MCP shim (allowlisted domains only, gated again in the main process). getPartitionCookies: (domain) => ipcRenderer.invoke('get-partition-cookies', domain), + // Silently reads the user's own chatgpt.com/claude.ai history offscreen (no card) for onboarding personalization; main owns the injected script + gates the provider. + harvestUsage: (provider) => ipcRenderer.invoke('harvest-usage', provider), // Suspend/resume state capsule: stages a resumed webview's sessionStorage snapshot in main (keyed by webContents id, short TTL) so the guest preload can sync-take it at document-start. Fire-and-forget; main validates the sender. setSessionCapsule: (wcId, capsule) => ipcRenderer.send('browser-capsule-set', wcId, capsule), sendCdpCommand: (wcId, method, params, sessionId) => ipcRenderer.invoke('send-cdp-command', wcId, method, params, sessionId), diff --git a/electron/usageHarvest.js b/electron/usageHarvest.js new file mode 100644 index 00000000..a55d6629 --- /dev/null +++ b/electron/usageHarvest.js @@ -0,0 +1,82 @@ +// Silently read the user's OWN provider chat history (chatgpt.com / claude.ai) from +// the browser partition's logged-in session, with no visible card. Onboarding prep +// uses the result to profile what the user actually cares about. The injected script +// is defined HERE (main-owned), so the offscreen exec can never be pointed at a script +// the renderer or a remote page chose. Only reachable when a session already exists in +// the partition; otherwise it fails open to {ok:false} and prep falls back to the scan. +// +// The RAW read never touches disk or redux: it is returned once to the renderer, which +// derives a capped summary for prep and drops the rest. See summarizeUsage (frontend). + +const hiddenBrowser = require('./hiddenBrowser'); + +const ORIGIN = { + codex: 'https://chatgpt.com/', + claude: 'https://claude.ai/', +}; + +// 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. +const SCRIPT = { + codex: `(async () => { + const PAGE=100, CAP_PAGES=60, CAP_TITLES=1000, GAP_MS=90; + try { + const sess = await fetch('/api/auth/session', {credentials:'include'}).then(r=>r.json()); + if (!sess || !sess.accessToken) return {ok:false, total:0, titles:[], memories:[]}; + const H = {headers:{Authorization:'Bearer '+sess.accessToken, accept:'application/json'}, credentials:'include'}; + const seen = new Set(); const titles = []; + let offset = 0, page = 0; + while (page < CAP_PAGES && titles.length < CAP_TITLES) { + const j = await fetch('/backend-api/conversations?offset='+offset+'&limit='+PAGE+'&order=updated', H).then(r=>r.ok?r.json():null).catch(()=>null); + const items = (j && j.items) || []; + if (!items.length) break; + let fresh = 0; + for (const c of items) { if (c && c.id && !seen.has(c.id)) { seen.add(c.id); if (c.title) titles.push(c.title); fresh++; } } + if (fresh === 0) break; + if (items.length < PAGE) break; + offset += PAGE; page++; + await new Promise(r=>setTimeout(r, GAP_MS)); + } + const mem = await fetch('/backend-api/memories?include_memory_entries=true', H).then(r=>r.ok?r.json():null).catch(()=>null); + return { + ok: true, + total: seen.size, + titles: titles.slice(0, CAP_TITLES), + memories: ((mem && mem.memories) || []).map(m=>m.content).filter(Boolean).slice(0, 40), + }; + } catch (e) { return {ok:false, total:0, titles:[], memories:[]}; } + })()`, + claude: `(async () => { + const PAGE=100, CAP_PAGES=60, CAP_TITLES=1000, GAP_MS=90; + try { + const orgs = await fetch('/api/organizations', {credentials:'include', headers:{accept:'application/json'}}).then(r=>r.ok?r.json():null).catch(()=>null); + if (!Array.isArray(orgs) || !orgs.length) return {ok:false, total:0, titles:[], memories:[]}; + const org = orgs[0].uuid; + const seen = new Set(); const titles = []; + let offset = 0, page = 0; + while (page < CAP_PAGES && titles.length < CAP_TITLES) { + const convs = await fetch('/api/organizations/'+org+'/chat_conversations?limit='+PAGE+'&offset='+offset, {credentials:'include', headers:{accept:'application/json'}}).then(r=>r.ok?r.json():null).catch(()=>null); + const items = Array.isArray(convs) ? convs : []; + if (!items.length) break; + let fresh = 0; + for (const c of items) { const id = c && c.uuid; if (id && !seen.has(id)) { seen.add(id); if (c.name) titles.push(c.name); fresh++; } } + if (fresh === 0) break; + if (items.length < PAGE) break; + offset += PAGE; page++; + await new Promise(r=>setTimeout(r, GAP_MS)); + } + return {ok:true, total:seen.size, titles:titles.slice(0, CAP_TITLES), memories:[]}; + } catch (e) { return {ok:false, total:0, titles:[], memories:[]}; } + })()`, +}; + +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; +} + +module.exports = { harvest }; diff --git a/frontend/src/app/components/OnboardingV3/useOnboardingV3Pipeline.ts b/frontend/src/app/components/OnboardingV3/useOnboardingV3Pipeline.ts index 6b8fd7ba..7e7c816e 100644 --- a/frontend/src/app/components/OnboardingV3/useOnboardingV3Pipeline.ts +++ b/frontend/src/app/components/OnboardingV3/useOnboardingV3Pipeline.ts @@ -10,8 +10,7 @@ import { fetchIdentity, runPrep, runScan, summarizeScan, type PrepResponse, type ProviderIdentity, type ScanResult, } from './onboardingV3Api'; -import { USAGE_READ_JS, summarizeUsage, type ProviderUsage, type UsageProvider } from '@/shared/providerUsage'; -import { findWebviewByDomain } from '@/shared/browserRegistry'; +import { summarizeUsage, type ProviderUsage, type UsageProvider } from '@/shared/providerUsage'; import type { ModelOption } from '@/shared/state/modelsSlice'; // The auto-launched onboarding jobs must ride the CHEAP tier, not the user's premium default; running two Sonnet/Opus agents unprompted on first launch would burn real quota. Pick the lowest-intelligence (cheapest) model in the default's provider group, so a Claude user's demo runs on Haiku, a ChatGPT user's on mini. @@ -47,17 +46,16 @@ export function useOnboardingV3Pipeline() { fetchIdentity().then((ids) => { identityRef.current = ids; setIdentity(ids); }).catch(() => {}); }, []); - // Read what the user works on from an already-open, logged-in provider card (chatgpt.com / claude.ai) via the proven findWebviewByDomain + executeJavaScript path. Fail-open: no card, not logged in, or off-Electron => empty summary, prep falls back to scan + identity. + // 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; if (!key) return; usageReadRef.current = (async () => { try { - const domain = key === 'codex' ? 'chatgpt.com' : 'claude.ai'; - const wv = findWebviewByDomain(domain); - if (!wv || typeof wv.executeJavaScript !== 'function') return; - const raw = (await wv.executeJavaScript(USAGE_READ_JS[key])) as ProviderUsage | null; + const harvestUsage = window.openswarm?.harvestUsage; + if (typeof harvestUsage !== 'function') return; + const raw = (await harvestUsage(key)) as ProviderUsage | null; usageSummaryRef.current = summarizeUsage(raw); } catch { /* fail-open */ } })(); diff --git a/frontend/src/shared/providerUsage.ts b/frontend/src/shared/providerUsage.ts index 58370f77..6afa85d8 100644 --- a/frontend/src/shared/providerUsage.ts +++ b/frontend/src/shared/providerUsage.ts @@ -1,4 +1,4 @@ -// Reads what the user actually uses their AI for, from a logged-in provider website (chatgpt.com / claude.ai) using that site's own session. Sweeps the FULL conversation history (all titles, paginated) plus ChatGPT Memory, so prep can profile what the user keeps coming back to. Same-origin fetch from the page context (the proven technique); the RAW result never leaves the renderer, only the derived summary goes to prep, and even that is dropped after. +// 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'; @@ -9,65 +9,6 @@ export interface ProviderUsage { memories: string[]; } -// Runs in the WEBVIEW/offscreen page context (chatgpt.com / claude.ai), so it inherits the live session. Kept as a string because it is injected via executeJavaScript. Hard caps (pages, titles, memory) bound the work + the PII footprint even for a user with thousands of chats; every fetch fails open to empty. -export const USAGE_READ_JS: Record = { - codex: `(async () => { - const PAGE=100, CAP_PAGES=60, CAP_TITLES=1000, GAP_MS=90; - try { - const sess = await fetch('/api/auth/session', {credentials:'include'}).then(r=>r.json()); - if (!sess || !sess.accessToken) return {ok:false, total:0, titles:[], memories:[]}; - const H = {headers:{Authorization:'Bearer '+sess.accessToken, accept:'application/json'}, credentials:'include'}; - const seen = new Set(); const titles = []; - let offset = 0, page = 0; - while (page < CAP_PAGES && titles.length < CAP_TITLES) { - const j = await fetch('/backend-api/conversations?offset='+offset+'&limit='+PAGE+'&order=updated', H).then(r=>r.ok?r.json():null).catch(()=>null); - const items = (j && j.items) || []; - if (!items.length) break; - let fresh = 0; - for (const c of items) { if (c && c.id && !seen.has(c.id)) { seen.add(c.id); if (c.title) titles.push(c.title); fresh++; } } - if (fresh === 0) break; - if (items.length < PAGE) break; - offset += PAGE; page++; - await new Promise(r=>setTimeout(r, GAP_MS)); - } - const mem = await fetch('/backend-api/memories?include_memory_entries=true', H).then(r=>r.ok?r.json():null).catch(()=>null); - return { - ok: true, - total: seen.size, - titles: titles.slice(0, CAP_TITLES), - memories: ((mem && mem.memories) || []).map(m=>m.content).filter(Boolean).slice(0, 40), - }; - } catch (e) { return {ok:false, total:0, titles:[], memories:[]}; } - })()`, - claude: `(async () => { - const PAGE=100, CAP_PAGES=60, CAP_TITLES=1000, GAP_MS=90; - try { - const orgs = await fetch('/api/organizations', {credentials:'include', headers:{accept:'application/json'}}).then(r=>r.ok?r.json():null).catch(()=>null); - if (!Array.isArray(orgs) || !orgs.length) return {ok:false, total:0, titles:[], memories:[]}; - const org = orgs[0].uuid; - const seen = new Set(); const titles = []; - let offset = 0, page = 0; - while (page < CAP_PAGES && titles.length < CAP_TITLES) { - const convs = await fetch('/api/organizations/'+org+'/chat_conversations?limit='+PAGE+'&offset='+offset, {credentials:'include', headers:{accept:'application/json'}}).then(r=>r.ok?r.json():null).catch(()=>null); - const items = Array.isArray(convs) ? convs : []; - if (!items.length) break; - let fresh = 0; - for (const c of items) { const id = c && c.uuid; if (id && !seen.has(id)) { seen.add(id); if (c.name) titles.push(c.name); fresh++; } } - if (fresh === 0) break; - if (items.length < PAGE) break; - offset += PAGE; page++; - await new Promise(r=>setTimeout(r, GAP_MS)); - } - return {ok:true, total:seen.size, titles:titles.slice(0, CAP_TITLES), memories:[]}; - } catch (e) { return {ok:false, total:0, titles:[], memories:[]}; } - })()`, -}; - -export const USAGE_ORIGIN: Record = { - codex: 'https://chatgpt.com/', - claude: 'https://claude.ai/', -}; - // Turn the raw read into a compact profile block for the prep prompt: the memory facts (strongest), the scale, and the most-recent topics. Capped hard so we never ship a wall of PII even for a heavy user; the aux model turns this into the profile. export function summarizeUsage(u: ProviderUsage | null): string { if (!u || !u.ok) return ''; diff --git a/frontend/src/types/electron.d.ts b/frontend/src/types/electron.d.ts index 7148492a..38f904cc 100644 --- a/frontend/src/types/electron.d.ts +++ b/frontend/src/types/electron.d.ts @@ -50,6 +50,7 @@ declare global { onReloadShortcut?: (cb: () => void) => () => void; onBrowserShortcut?: (cb: (payload: { action: string; webContentsId: number }) => void) => () => void; openExternal: (url: string) => Promise; + harvestUsage?: (provider: string) => Promise<{ ok: boolean; total: number; titles: string[]; memories: string[] } | null>; hardReset?: () => Promise; clearBrowserData?: () => Promise<{ ok: boolean }>; onAuthUrl?: (cb: (url: string) => void) => () => void;