import { execFile } from "node:child_process"; import { pbkdf2Sync, createDecipheriv } from "node:crypto"; import { copyFileSync, existsSync, mkdtempSync, readdirSync, realpathSync, rmSync } from "node:fs"; import { tmpdir, homedir, platform } from "node:os"; import { isAbsolute, join, sep } from "node:path"; import { isBrowserCookieAccessAllowed } from "./gemini-web-config.ts"; export type CookieMap = Record; interface BrowserConfig { name: string; baseDir: string; keychainService?: string; keychainAccount?: string; secretToolApp?: string; } type SqliteRow = Record; type SqliteFailure = "unavailable" | "query"; const GOOGLE_ORIGINS = [ "https://gemini.google.com", "https://accounts.google.com", "https://www.google.com", ]; const ALL_COOKIE_NAMES = new Set([ "__Secure-1PSID", "__Secure-1PSIDTS", "__Secure-1PSIDCC", "__Secure-1PAPISID", "NID", "AEC", "SOCS", "__Secure-BUCKET", "__Secure-ENID", "SID", "HSID", "SSID", "APISID", "SAPISID", "__Secure-3PSID", "__Secure-3PSIDTS", "__Secure-3PAPISID", "SIDCC", ]); const MACOS_BROWSER_CONFIGS: BrowserConfig[] = [ { name: "Helium", baseDir: "Library/Application Support/net.imput.helium", keychainService: "Helium Storage Key", keychainAccount: "Helium" }, { name: "Chrome", baseDir: "Library/Application Support/Google/Chrome", keychainService: "Chrome Safe Storage", keychainAccount: "Chrome" }, { name: "Arc", baseDir: "Library/Application Support/Arc/User Data", keychainService: "Arc Safe Storage", keychainAccount: "Arc" }, ]; const LINUX_BROWSER_CONFIGS: BrowserConfig[] = [ { name: "Chromium", baseDir: ".config/chromium", secretToolApp: "chromium" }, { name: "Chrome", baseDir: ".config/google-chrome", secretToolApp: "chrome" }, ]; const browserPasswordCache = new Map>(); let lastCookieDiagnostic: string | null = null; let sqliteModule: typeof import("node:sqlite") | null = null; let sqliteImportAttempted = false; export function getLastGoogleCookieDiagnostic(): string | null { return lastCookieDiagnostic; } export async function getGoogleCookies( options?: { profile?: string; requiredCookies?: string[] }, ): Promise<{ cookies: CookieMap; warnings: string[] } | null> { lastCookieDiagnostic = null; if (!isBrowserCookieAccessAllowed()) { lastCookieDiagnostic = "Browser cookie access is disabled; enable allowBrowserCookies to use Gemini Web cookies."; return null; } const currentPlatform = platform(); const configs = currentPlatform === "darwin" ? MACOS_BROWSER_CONFIGS : currentPlatform === "linux" ? LINUX_BROWSER_CONFIGS : []; if (configs.length === 0) { lastCookieDiagnostic = "Chromium cookie extraction is unsupported on this platform."; return null; } const warningSet = new Set(); const rawProfile = typeof options?.profile === "string" ? options.profile.trim() : ""; const requestedProfile = normalizeProfileName(options?.profile); if (rawProfile && !requestedProfile) { lastCookieDiagnostic = "Configured Chromium profile must be a profile directory name, not a path."; return null; } const requiredCookies = normalizeCookieNames(options?.requiredCookies); const hosts = GOOGLE_ORIGINS.map((origin) => new URL(origin).hostname); const home = homedir(); let sawCookieDatabase = false; let sawRequiredCookies = false; let sawBackendFailure: SqliteFailure | undefined; let sawUnsafeProfilePath = false; for (const config of configs) { const profiles = requestedProfile ? [requestedProfile] : listBrowserProfiles(home, config); for (const profile of profiles) { const profilePath = resolveProfilePath(home, config, profile); if (profilePath === "outside-root") { sawUnsafeProfilePath = true; continue; } if (!profilePath) continue; const cookiesPath = join(profilePath, "Cookies"); sawCookieDatabase = true; const tempDir = mkdtempSync(join(tmpdir(), "pi-chrome-cookies-")); try { const tempDb = join(tempDir, "Cookies"); copyFileSync(cookiesPath, tempDb); copySidecar(cookiesPath, tempDb, "-wal"); copySidecar(cookiesPath, tempDb, "-shm"); if (requiredCookies?.length) { const preflight = await hasCookieNames(tempDb, hosts, requiredCookies); if (preflight.failure) sawBackendFailure = preflight.failure; if (!preflight.present) continue; sawRequiredCookies = true; } const password = await readBrowserPassword(config, currentPlatform); if (!password) { warningSet.add(`Could not read ${config.name} cookie encryption password`); continue; } const key = pbkdf2Sync(password, "saltysalt", currentPlatform === "darwin" ? 1003 : 1, 16, "sha1"); const metaVersion = await readMetaVersion(tempDb); if (metaVersion.failure) sawBackendFailure = metaVersion.failure; if (metaVersion.value === null) continue; const rowsResult = await queryCookieRows(tempDb, hosts, ALL_COOKIE_NAMES); if (rowsResult.status === "failure") { sawBackendFailure = rowsResult.failure; continue; } const cookies: CookieMap = {}; for (const row of rowsResult.rows) { const name = typeof row.name === "string" ? row.name : ""; if (!ALL_COOKIE_NAMES.has(name) || cookies[name]) continue; let value = typeof row.value === "string" && row.value.length > 0 ? row.value : null; if (!value && typeof row.encrypted_value_hex === "string" && /^[0-9a-f]*$/i.test(row.encrypted_value_hex)) { value = decryptCookieValue(Buffer.from(row.encrypted_value_hex, "hex"), key, metaVersion.value >= 24); } if (value) cookies[name] = value; } if (requiredCookies?.length && !requiredCookies.every((name) => Boolean(cookies[name]))) continue; return { cookies, warnings: [...warningSet] }; } finally { rmSync(tempDir, { recursive: true, force: true }); } } } if (sawBackendFailure === "unavailable") { lastCookieDiagnostic = "SQLite backend unavailable: install sqlite3 or use a runtime with SQLite support."; } else if (sawBackendFailure === "query") { lastCookieDiagnostic = "SQLite query failed while reading the copied Chromium cookie database."; } else if (sawUnsafeProfilePath) { lastCookieDiagnostic = "Configured Chromium profile must resolve inside the browser profile root."; } else if (!sawCookieDatabase) { lastCookieDiagnostic = requestedProfile ? `Chromium profile '${requestedProfile}' does not contain a cookie database.` : "No detected Chromium profile contains a cookie database."; } else if (requiredCookies?.length && !sawRequiredCookies) { lastCookieDiagnostic = "No detected Chromium profile contains the required Gemini cookies."; } else if (warningSet.size > 0) { lastCookieDiagnostic = [...warningSet][0]; } else { lastCookieDiagnostic = "Required Gemini cookies were not available or could not be decrypted."; } return null; } function normalizeProfileName(value: string | undefined): string | undefined { if (typeof value !== "string") return undefined; const normalized = value.trim(); if (!normalized) return undefined; if (isAbsolute(normalized) || normalized === "." || normalized === ".." || normalized.includes("/") || normalized.includes("\\")) { return undefined; } return normalized; } function resolveProfilePath(home: string, config: BrowserConfig, profile: string): string | "outside-root" | null { const basePath = join(home, config.baseDir); const profilePath = join(basePath, profile); const cookiesPath = join(profilePath, "Cookies"); if (!existsSync(cookiesPath)) return null; try { const baseRealPath = realpathSync(basePath); const profileRealPath = realpathSync(profilePath); if (profileRealPath !== baseRealPath && !profileRealPath.startsWith(`${baseRealPath}${sep}`)) return "outside-root"; return profileRealPath; } catch { return null; } } function normalizeCookieNames(names: string[] | undefined): string[] | undefined { if (!names?.length) return undefined; const normalized = names.filter((name): name is string => typeof name === "string").map((name) => name.trim()).filter(Boolean); return normalized.length > 0 ? [...new Set(normalized)] : undefined; } function listBrowserProfiles(home: string, config: BrowserConfig): string[] { const basePath = join(home, config.baseDir); if (!existsSync(basePath)) return ["Default"]; const profiles = new Set(); try { for (const entry of readdirSync(basePath, { withFileTypes: true })) { if (entry.isDirectory() && existsSync(join(basePath, entry.name, "Cookies"))) profiles.add(entry.name); } } catch { } if (profiles.size === 0) return ["Default"]; return [...profiles].sort(compareProfileNames); } function compareProfileNames(a: string, b: string): number { const key = (name: string): [number, number] => { if (name === "Default") return [0, 0]; const profile = /^Profile\s+(\d+)$/i.exec(name); if (profile) return [1, Number(profile[1])]; const person = /^Person\s+(\d+)$/i.exec(name); if (person) return [2, Number(person[1])]; return [3, Number.MAX_SAFE_INTEGER]; }; const [ap, ai] = key(a); const [bp, bi] = key(b); return ap - bp || ai - bi || a.localeCompare(b, undefined, { sensitivity: "base", numeric: true }); } function decryptCookieValue(encrypted: Uint8Array, key: Buffer, stripHash: boolean): string | null { const buf = Buffer.from(encrypted); if (buf.length < 3 || !/^v\d\d$/.test(buf.subarray(0, 3).toString("utf8"))) return null; const ciphertext = buf.subarray(3); if (!ciphertext.length) return ""; try { const decipher = createDecipheriv("aes-128-cbc", key, Buffer.alloc(16, 0x20)); decipher.setAutoPadding(false); const unpadded = removePkcs7Padding(Buffer.concat([decipher.update(ciphertext), decipher.final()])); const bytes = stripHash && unpadded.length >= 32 ? unpadded.subarray(32) : unpadded; const decoded = new TextDecoder("utf-8", { fatal: true }).decode(bytes); let i = 0; while (i < decoded.length && decoded.charCodeAt(i) < 0x20) i++; return decoded.slice(i); } catch { return null; } } function removePkcs7Padding(buf: Buffer): Buffer { if (!buf.length) return buf; const padding = buf[buf.length - 1]; return !padding || padding > 16 ? buf : buf.subarray(0, buf.length - padding); } function readBrowserPassword(config: BrowserConfig, currentPlatform: ReturnType): Promise { const cacheKey = `${currentPlatform}:${config.name}`; const cached = browserPasswordCache.get(cacheKey); if (cached) return cached; const passwordResult = currentPlatform === "darwin" ? config.keychainAccount && config.keychainService ? readKeychainPassword(config.keychainAccount, config.keychainService).then(password => ({ password, cacheable: Boolean(password) })) : Promise.resolve({ password: null, cacheable: false }) : currentPlatform === "linux" ? readLinuxPassword(config.secretToolApp) : Promise.resolve({ password: null, cacheable: false }); const passwordPromise = passwordResult.then(({ password, cacheable }) => { if (!cacheable) browserPasswordCache.delete(cacheKey); return password; }, (error) => { browserPasswordCache.delete(cacheKey); throw error; }); browserPasswordCache.set(cacheKey, passwordPromise); return passwordPromise; } function readKeychainPassword(account: string, service: string): Promise { return new Promise((resolve) => { execFile("security", ["find-generic-password", "-w", "-a", account, "-s", service], { timeout: 5000 }, (err, stdout) => { if (err) { resolve(null); return; } resolve(stdout.trim() || null); }); }); } function readLinuxPassword(secretToolApp: string | undefined): Promise<{ password: string; cacheable: boolean }> { if (!secretToolApp) return Promise.resolve({ password: "peanuts", cacheable: true }); return new Promise((resolve) => { execFile("secret-tool", ["lookup", "application", secretToolApp], { timeout: 5000 }, (err, stdout) => { if (err) { resolve({ password: "peanuts", cacheable: false }); return; } const password = stdout.trim(); resolve(password ? { password, cacheable: true } : { password: "peanuts", cacheable: false }); }); }); } async function importSqlite(): Promise { if (process.env.PI_WEB_ACCESS_DISABLE_NODE_SQLITE === "1") return null; if (sqliteImportAttempted) return sqliteModule; sqliteImportAttempted = true; const orig = process.emitWarning.bind(process); process.emitWarning = ((warning: string | Error, ...args: unknown[]) => { const msg = typeof warning === "string" ? warning : warning?.message ?? ""; if (msg.includes("SQLite is an experimental feature")) return; return (orig as Function)(warning, ...args); }) as typeof process.emitWarning; try { sqliteModule = await import("node:sqlite"); } catch { sqliteModule = null; } finally { process.emitWarning = orig; } return sqliteModule; } type QueryResult = | { status: "success"; rows: SqliteRow[] } | { status: "failure"; failure: SqliteFailure }; async function runSqliteQuery(dbPath: string, sql: string): Promise { const sqlite = await importSqlite(); let queryFailed = false; if (sqlite) { try { const db = new sqlite.DatabaseSync(dbPath, { readOnly: true }); try { return { status: "success", rows: db.prepare(sql).all() as SqliteRow[] }; } finally { db.close(); } } catch { queryFailed = true; } } const cli = await runSqliteCli(dbPath, sql); if (cli.status === "success") return cli; if (cli.failure === "query") queryFailed = true; const python = await runPythonSqlite(dbPath, sql); if (python.status === "success") return python; if (python.failure === "query") queryFailed = true; return { status: "failure", failure: queryFailed ? "query" : "unavailable" }; } function runSqliteCli(dbPath: string, sql: string): Promise { return new Promise((resolve) => { execFile("sqlite3", ["-readonly", "-json", dbPath, sql], { timeout: 5000, maxBuffer: 1024 * 1024 }, (err, stdout) => { if (err) { resolve({ status: "failure", failure: err.code === "ENOENT" ? "unavailable" : "query" }); return; } try { const parsed = JSON.parse(stdout || "[]"); resolve(Array.isArray(parsed) ? { status: "success", rows: parsed as SqliteRow[] } : { status: "failure", failure: "query" }); } catch { resolve({ status: "failure", failure: "query" }); } }); }); } function runPythonSqlite(dbPath: string, sql: string): Promise { const script = "import json,sqlite3,sys\ntry:\n c=sqlite3.connect('file:'+sys.argv[1]+'?mode=ro',uri=True)\n c.row_factory=sqlite3.Row\n print(json.dumps([dict(r) for r in c.execute(sys.argv[2]).fetchall()]))\nexcept Exception:\n sys.exit(1)"; return new Promise((resolve) => { execFile("python3", ["-c", script, dbPath, sql], { timeout: 5000, maxBuffer: 1024 * 1024 }, (err, stdout) => { if (err) { resolve({ status: "failure", failure: err.code === "ENOENT" ? "unavailable" : "query" }); return; } try { const parsed = JSON.parse(stdout || "[]"); resolve(Array.isArray(parsed) ? { status: "success", rows: parsed as SqliteRow[] } : { status: "failure", failure: "query" }); } catch { resolve({ status: "failure", failure: "query" }); } }); }); } async function readMetaVersion(dbPath: string): Promise<{ value: number | null; failure?: SqliteFailure }> { const result = await runSqliteQuery(dbPath, "SELECT value FROM meta WHERE key = 'version'"); if (result.status === "failure") { return result.failure === "unavailable" ? { value: null, failure: result.failure } : { value: 0 }; } const value = result.rows[0]?.value; if (typeof value === "number") return { value: Math.floor(value) }; if (typeof value === "string") return { value: parseInt(value, 10) || 0 }; return { value: 0 }; } async function hasCookieNames(dbPath: string, hosts: string[], names: string[]): Promise<{ present: boolean; failure?: SqliteFailure }> { const result = await runSqliteQuery(dbPath, `SELECT DISTINCT name FROM cookies WHERE ${buildCookieWhere(hosts, names)}`); if (result.status === "failure") return { present: false, failure: result.failure }; const present = new Set(result.rows.map((row) => typeof row.name === "string" ? row.name : "")); return { present: names.every((name) => present.has(name)) }; } async function queryCookieRows(dbPath: string, hosts: string[], names: Iterable): Promise { return runSqliteQuery(dbPath, `SELECT name, value, host_key, hex(encrypted_value) AS encrypted_value_hex FROM cookies WHERE ${buildCookieWhere(hosts, names)} ORDER BY expires_utc DESC`); } function buildCookieWhere(hosts: string[], cookieNames?: Iterable): string { const hostClauses: string[] = []; for (const host of hosts) { for (const candidate of expandHosts(host)) { const escaped = escapeSqlString(candidate); hostClauses.push(`host_key = '${escaped}'`, `host_key = '.${escaped}'`, `host_key LIKE '%.${escaped}'`); } } let where = `(${hostClauses.join(" OR ")})`; const names = cookieNames ? [...cookieNames].filter(Boolean) : []; if (names.length) where += ` AND name IN (${names.map((name) => `'${escapeSqlString(name)}'`).join(", ")})`; return where; } function escapeSqlString(value: string): string { return value.replaceAll("'", "''"); } function expandHosts(host: string): string[] { const parts = host.split(".").filter(Boolean); if (parts.length <= 1) return [host]; const candidates = new Set([host]); for (let i = 1; i <= parts.length - 2; i++) candidates.add(parts.slice(i).join(".")); return [...candidates]; } function copySidecar(srcDb: string, targetDb: string, suffix: string): void { const sidecar = `${srcDb}${suffix}`; if (!existsSync(sidecar)) return; try { copyFileSync(sidecar, `${targetDb}${suffix}`); } catch { } }