mirror of
https://github.com/openswarm-ai/openswarm.git
synced 2026-08-30 19:59:38 +02:00
[eric] onboarding: harvest subscription history silently offscreen (no card, no extra login step)
This commit is contained in:
@@ -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 };
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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 };
|
||||
@@ -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 */ }
|
||||
})();
|
||||
|
||||
@@ -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<UsageProvider, string> = {
|
||||
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<UsageProvider, string> = {
|
||||
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 '';
|
||||
|
||||
Vendored
+1
@@ -50,6 +50,7 @@ declare global {
|
||||
onReloadShortcut?: (cb: () => void) => () => void;
|
||||
onBrowserShortcut?: (cb: (payload: { action: string; webContentsId: number }) => void) => () => void;
|
||||
openExternal: (url: string) => Promise<void>;
|
||||
harvestUsage?: (provider: string) => Promise<{ ok: boolean; total: number; titles: string[]; memories: string[] } | null>;
|
||||
hardReset?: () => Promise<void>;
|
||||
clearBrowserData?: () => Promise<{ ok: boolean }>;
|
||||
onAuthUrl?: (cb: (url: string) => void) => () => void;
|
||||
|
||||
Reference in New Issue
Block a user