pi-oracle 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,861 @@
1
+ import { createHash } from "node:crypto";
2
+ import { spawn } from "node:child_process";
3
+ import { existsSync } from "node:fs";
4
+ import { appendFile, chmod, lstat, mkdir, rename, rm, writeFile } from "node:fs/promises";
5
+ import { homedir } from "node:os";
6
+ import { dirname, join, resolve } from "node:path";
7
+ import { getCookies } from "@steipete/sweet-cookie";
8
+
9
+ const rawConfig = process.argv[2];
10
+ if (!rawConfig) {
11
+ console.error("Usage: auth-bootstrap.mjs <oracle-config-json>");
12
+ process.exit(1);
13
+ }
14
+
15
+ const config = JSON.parse(rawConfig);
16
+ const CHATGPT_LABELS = {
17
+ composer: "Chat with ChatGPT",
18
+ addFiles: "Add files and more",
19
+ };
20
+ const LOGIN_PROBE_TIMEOUT_MS = 5_000;
21
+ const CHATGPT_COOKIE_ORIGINS = [
22
+ "https://chatgpt.com",
23
+ "https://chat.openai.com",
24
+ "https://atlas.openai.com",
25
+ "https://auth.openai.com",
26
+ "https://sentinel.openai.com",
27
+ "https://ws.chatgpt.com",
28
+ ];
29
+ const LOG_PATH = "/tmp/oracle-auth.log";
30
+ const URL_PATH = "/tmp/oracle-auth.url.txt";
31
+ const SNAPSHOT_PATH = "/tmp/oracle-auth.snapshot.txt";
32
+ const BODY_PATH = "/tmp/oracle-auth.body.txt";
33
+ const SCREENSHOT_PATH = "/tmp/oracle-auth.png";
34
+ const REAL_CHROME_USER_DATA_DIR = resolve(homedir(), "Library", "Application Support", "Google", "Chrome");
35
+ const ORACLE_STATE_DIR = "/tmp/pi-oracle-state";
36
+ const LOCKS_DIR = join(ORACLE_STATE_DIR, "locks");
37
+
38
+ let runtimeProfileDir = config.browser.authSeedProfileDir;
39
+
40
+ function authSessionName() {
41
+ return `${config.browser.sessionPrefix}-auth`;
42
+ }
43
+
44
+ function sleep(ms) {
45
+ return new Promise((resolve) => setTimeout(resolve, ms));
46
+ }
47
+
48
+ function leaseKey(kind, key) {
49
+ return `${kind}-${createHash("sha256").update(key).digest("hex").slice(0, 24)}`;
50
+ }
51
+
52
+ async function acquireLock(kind, key, metadata, timeoutMs = 30_000) {
53
+ const path = join(LOCKS_DIR, leaseKey(kind, key));
54
+ const deadline = Date.now() + timeoutMs;
55
+ await mkdir(ORACLE_STATE_DIR, { recursive: true, mode: 0o700 });
56
+ await mkdir(LOCKS_DIR, { recursive: true, mode: 0o700 });
57
+
58
+ while (Date.now() < deadline) {
59
+ try {
60
+ await mkdir(path, { recursive: false, mode: 0o700 });
61
+ await writeFile(join(path, "metadata.json"), `${JSON.stringify(metadata, null, 2)}\n`, { encoding: "utf8", mode: 0o600 });
62
+ return path;
63
+ } catch (error) {
64
+ if (!(error && typeof error === "object" && "code" in error && error.code === "EEXIST")) throw error;
65
+ }
66
+ await sleep(200);
67
+ }
68
+
69
+ throw new Error(`Timed out waiting for oracle ${kind} lock: ${key}`);
70
+ }
71
+
72
+ async function releaseLock(path) {
73
+ if (!path) return;
74
+ await rm(path, { recursive: true, force: true }).catch(() => undefined);
75
+ }
76
+
77
+ async function withLock(kind, key, metadata, fn, timeoutMs) {
78
+ const handle = await acquireLock(kind, key, metadata, timeoutMs);
79
+ try {
80
+ return await fn();
81
+ } finally {
82
+ await releaseLock(handle);
83
+ }
84
+ }
85
+
86
+ async function initLog() {
87
+ await writeFile(LOG_PATH, "", { mode: 0o600 });
88
+ await chmod(LOG_PATH, 0o600).catch(() => undefined);
89
+ }
90
+
91
+ async function log(message) {
92
+ const line = `[${new Date().toISOString()}] ${message}\n`;
93
+ await appendFile(LOG_PATH, line, { encoding: "utf8", mode: 0o600 });
94
+ await chmod(LOG_PATH, 0o600).catch(() => undefined);
95
+ }
96
+
97
+ function spawnCommand(command, args, options = {}) {
98
+ return new Promise((resolve, reject) => {
99
+ const child = spawn(command, args, {
100
+ stdio: ["pipe", "pipe", "pipe"],
101
+ ...options,
102
+ });
103
+ let stdout = "";
104
+ let stderr = "";
105
+ if (options.input) child.stdin.end(options.input);
106
+ else child.stdin.end();
107
+ child.stdout.on("data", (data) => {
108
+ stdout += String(data);
109
+ });
110
+ child.stderr.on("data", (data) => {
111
+ stderr += String(data);
112
+ });
113
+ child.on("error", reject);
114
+ child.on("close", (code) => {
115
+ if (code === 0 || options.allowFailure) resolve({ code, stdout: stdout.trim(), stderr: stderr.trim() });
116
+ else reject(new Error(stderr || stdout || `${command} exited with code ${code}`));
117
+ });
118
+ });
119
+ }
120
+
121
+ function targetBrowserBaseArgs(options = {}) {
122
+ const args = ["--session", authSessionName()];
123
+ if (options.withLaunchOptions) {
124
+ args.push("--profile", runtimeProfileDir);
125
+ if (config.browser.executablePath) args.push("--executable-path", config.browser.executablePath);
126
+ if (config.browser.userAgent) args.push("--user-agent", config.browser.userAgent);
127
+ if (Array.isArray(config.browser.args) && config.browser.args.length > 0) args.push("--args", config.browser.args.join(","));
128
+ if (options.mode === "headed") args.push("--headed");
129
+ }
130
+ return args;
131
+ }
132
+
133
+ async function closeTargetBrowser() {
134
+ await log(`Closing target browser session ${authSessionName()} if present`);
135
+ const result = await spawnCommand("agent-browser", [...targetBrowserBaseArgs(), "close"], { allowFailure: true });
136
+ await log(`close result: code=${result.code} stdout=${JSON.stringify(result.stdout)} stderr=${JSON.stringify(result.stderr)}`);
137
+ }
138
+
139
+ async function ensureNotSymlink(path, label) {
140
+ try {
141
+ const stats = await lstat(path);
142
+ if (stats.isSymbolicLink()) {
143
+ throw new Error(`${label} must not be a symlink: ${path}`);
144
+ }
145
+ } catch (error) {
146
+ if (error && typeof error === "object" && "code" in error && error.code === "ENOENT") return;
147
+ throw error;
148
+ }
149
+ }
150
+
151
+ async function createProfilePlan(profileDir) {
152
+ const targetDir = resolve(profileDir);
153
+ if (!targetDir.startsWith("/")) {
154
+ throw new Error(`Oracle profileDir must be an absolute path: ${profileDir}`);
155
+ }
156
+ if (targetDir === "/" || targetDir === homedir()) {
157
+ throw new Error(`Oracle profileDir is unsafe: ${targetDir}`);
158
+ }
159
+ if (targetDir === REAL_CHROME_USER_DATA_DIR || targetDir.startsWith(`${REAL_CHROME_USER_DATA_DIR}/`)) {
160
+ throw new Error(`Oracle profileDir must not point into the real Chrome user-data directory: ${targetDir}`);
161
+ }
162
+
163
+ const stagingDir = `${targetDir}.staging-${Date.now()}`;
164
+ const backupDir = `${targetDir}.prev`;
165
+ await mkdir(dirname(targetDir), { recursive: true, mode: 0o700 });
166
+ await ensureNotSymlink(dirname(targetDir), "Oracle profile parent directory");
167
+ await ensureNotSymlink(targetDir, "Oracle profile directory");
168
+ await ensureNotSymlink(backupDir, "Oracle backup profile directory");
169
+ return { targetDir, stagingDir, backupDir };
170
+ }
171
+
172
+ async function prepareStagedProfile(plan) {
173
+ runtimeProfileDir = plan.stagingDir;
174
+ await log(`Preparing staged oracle profile ${plan.stagingDir}`);
175
+ await rm(plan.stagingDir, { recursive: true, force: true }).catch(async (error) => {
176
+ await log(`Staging profile cleanup warning: ${error instanceof Error ? error.message : String(error)}`);
177
+ });
178
+ }
179
+
180
+ async function commitStagedProfile(plan) {
181
+ await log(`Committing staged oracle profile ${plan.stagingDir} -> ${plan.targetDir}`);
182
+ await rm(plan.backupDir, { recursive: true, force: true }).catch(() => undefined);
183
+
184
+ const hadPreviousProfile = existsSync(plan.targetDir);
185
+ if (hadPreviousProfile) {
186
+ await rename(plan.targetDir, plan.backupDir);
187
+ }
188
+
189
+ try {
190
+ await rename(plan.stagingDir, plan.targetDir);
191
+ runtimeProfileDir = plan.targetDir;
192
+ if (hadPreviousProfile) {
193
+ await log(`Previous oracle profile moved to ${plan.backupDir}`);
194
+ }
195
+ } catch (error) {
196
+ if (!existsSync(plan.targetDir) && existsSync(plan.backupDir)) {
197
+ await rename(plan.backupDir, plan.targetDir).catch(() => undefined);
198
+ }
199
+ throw error;
200
+ }
201
+ }
202
+
203
+ async function launchTargetBrowser() {
204
+ await closeTargetBrowser();
205
+ const args = [...targetBrowserBaseArgs({ withLaunchOptions: true, mode: "headed" }), "open", "about:blank"];
206
+ await log(`Launching isolated browser: agent-browser ${JSON.stringify(args)}`);
207
+ const result = await spawnCommand("agent-browser", args, { allowFailure: true });
208
+ await log(`launch result: code=${result.code} stdout=${JSON.stringify(result.stdout)} stderr=${JSON.stringify(result.stderr)}`);
209
+ if (result.code !== 0) {
210
+ throw new Error(result.stderr || result.stdout || "Failed to launch isolated oracle browser");
211
+ }
212
+ }
213
+
214
+ async function streamStatus() {
215
+ const result = await spawnCommand("agent-browser", [...targetBrowserBaseArgs(), "--json", "stream", "status"], { allowFailure: true });
216
+ await log(`stream status: code=${result.code} stdout=${JSON.stringify(result.stdout)} stderr=${JSON.stringify(result.stderr)}`);
217
+ try {
218
+ const parsed = JSON.parse(result.stdout || "{}");
219
+ return parsed?.data || {};
220
+ } catch {
221
+ return {};
222
+ }
223
+ }
224
+
225
+ async function ensureBrowserConnected() {
226
+ const status = await streamStatus();
227
+ if (status.connected === false) {
228
+ throw new Error("The isolated oracle browser was closed before auth verification completed.");
229
+ }
230
+ }
231
+
232
+ async function targetCommand(...args) {
233
+ let options;
234
+ const maybeOptions = args.at(-1);
235
+ if (
236
+ maybeOptions &&
237
+ typeof maybeOptions === "object" &&
238
+ !Array.isArray(maybeOptions) &&
239
+ (Object.hasOwn(maybeOptions, "allowFailure") || Object.hasOwn(maybeOptions, "input") || Object.hasOwn(maybeOptions, "cwd") || Object.hasOwn(maybeOptions, "logLabel"))
240
+ ) {
241
+ options = args.pop();
242
+ }
243
+ await ensureBrowserConnected();
244
+ const result = await spawnCommand("agent-browser", [...targetBrowserBaseArgs(), ...args], options);
245
+ const label = options?.logLabel || `agent-browser ${args.join(" ")}`;
246
+ await log(`${label}: code=${result.code} stdout=${JSON.stringify(result.stdout)} stderr=${JSON.stringify(result.stderr)}`);
247
+ return result;
248
+ }
249
+
250
+ function parseEvalResult(stdout) {
251
+ if (!stdout) return undefined;
252
+ let value = stdout.trim();
253
+ try {
254
+ let parsed = JSON.parse(value);
255
+ while (typeof parsed === "string") parsed = JSON.parse(parsed);
256
+ return parsed;
257
+ } catch {
258
+ return value;
259
+ }
260
+ }
261
+
262
+ async function evalPage(script, logLabel = "eval") {
263
+ const result = await targetCommand("eval", "--stdin", { input: script, logLabel });
264
+ return parseEvalResult(result.stdout);
265
+ }
266
+
267
+ function toAsyncJsonScript(expression) {
268
+ return `(async () => JSON.stringify(await (async () => { ${expression} })(), null, 2))()`;
269
+ }
270
+
271
+ async function openUrl(url, label = url) {
272
+ await log(`Opening URL ${url}`);
273
+ await targetCommand("open", url, { logLabel: `open ${label}` });
274
+ }
275
+
276
+ async function getUrl() {
277
+ const { stdout } = await targetCommand("get", "url", { logLabel: "get url" });
278
+ return stdout || "";
279
+ }
280
+
281
+ async function snapshotText() {
282
+ const { stdout } = await targetCommand("snapshot", "-i", { logLabel: "snapshot -i" });
283
+ return stdout || "";
284
+ }
285
+
286
+ async function pageText() {
287
+ const { stdout } = await targetCommand("get", "text", "body", { allowFailure: true, logLabel: "get text body" });
288
+ return stdout || "";
289
+ }
290
+
291
+ function parseSnapshotEntries(snapshot) {
292
+ return snapshot
293
+ .split("\n")
294
+ .map((line) => {
295
+ const refMatch = line.match(/\bref=(e\d+)\b/);
296
+ if (!refMatch) return undefined;
297
+ const kindMatch = line.match(/^\s*-\s*([^\s]+)/);
298
+ const quotedMatch = line.match(/"([^"]*)"/);
299
+ const valueMatch = line.match(/:\s*(.+)$/);
300
+ return {
301
+ line,
302
+ ref: `@${refMatch[1]}`,
303
+ kind: kindMatch ? kindMatch[1] : undefined,
304
+ label: quotedMatch ? quotedMatch[1] : undefined,
305
+ value: valueMatch ? valueMatch[1].trim() : undefined,
306
+ disabled: /\bdisabled\b/.test(line),
307
+ };
308
+ })
309
+ .filter(Boolean);
310
+ }
311
+
312
+ function findEntry(snapshot, predicate) {
313
+ return parseSnapshotEntries(snapshot).find(predicate);
314
+ }
315
+
316
+ function findLastEntry(snapshot, predicate) {
317
+ const entries = parseSnapshotEntries(snapshot);
318
+ for (let index = entries.length - 1; index >= 0; index -= 1) {
319
+ if (predicate(entries[index])) return entries[index];
320
+ }
321
+ return undefined;
322
+ }
323
+
324
+ async function clickRef(ref, logLabel = `click ${ref}`) {
325
+ await targetCommand("click", ref, { logLabel });
326
+ }
327
+
328
+ function stripQuery(url) {
329
+ try {
330
+ const parsed = new URL(url);
331
+ parsed.hash = "";
332
+ parsed.search = "";
333
+ return parsed.toString();
334
+ } catch {
335
+ return url;
336
+ }
337
+ }
338
+
339
+ function normalizeSameSite(value) {
340
+ if (value === "Lax" || value === "Strict" || value === "None") return value;
341
+ return undefined;
342
+ }
343
+
344
+ function normalizeExpiration(expires) {
345
+ if (!expires || Number.isNaN(expires)) return undefined;
346
+ const value = Number(expires);
347
+ if (!Number.isFinite(value) || value <= 0) return undefined;
348
+ // Chrome cookie readers can surface expiries in a few formats:
349
+ // - Unix seconds (~1.7e9 in 2026)
350
+ // - Unix milliseconds (~1.7e12)
351
+ // - WebKit microseconds since 1601 (~1.3e16)
352
+ if (value > 10_000_000_000_000) return Math.round(value / 1_000_000 - 11644473600);
353
+ if (value > 10_000_000_000) return Math.round(value / 1000);
354
+ return Math.round(value);
355
+ }
356
+
357
+ function normalizeCookie(cookie, fallbackHost) {
358
+ if (!cookie?.name) return undefined;
359
+ const domain = typeof cookie.domain === "string" && cookie.domain.trim() ? cookie.domain.trim() : fallbackHost;
360
+ if (!domain) return undefined;
361
+
362
+ const expires = normalizeExpiration(cookie.expires);
363
+ return {
364
+ name: cookie.name,
365
+ value: cookie.value ?? "",
366
+ domain,
367
+ path: cookie.path || "/",
368
+ expires,
369
+ httpOnly: cookie.httpOnly ?? false,
370
+ secure: cookie.secure ?? true,
371
+ sameSite: normalizeSameSite(cookie.sameSite),
372
+ };
373
+ }
374
+
375
+ function cookieOrigins() {
376
+ return Array.from(new Set([stripQuery(config.browser.chatUrl), ...CHATGPT_COOKIE_ORIGINS]));
377
+ }
378
+
379
+ function cookieSource() {
380
+ return config.auth.chromeCookiePath || config.auth.chromeProfile;
381
+ }
382
+
383
+ function cookieSourceLabel() {
384
+ return config.auth.chromeCookiePath
385
+ ? `Chrome cookie DB ${config.auth.chromeCookiePath}`
386
+ : `Chrome profile ${config.auth.chromeProfile}`;
387
+ }
388
+
389
+ async function readSourceCookies() {
390
+ await log(`Reading ChatGPT cookies from ${cookieSourceLabel()}`);
391
+ const { cookies, warnings } = await getCookies({
392
+ url: config.browser.chatUrl,
393
+ origins: cookieOrigins(),
394
+ browsers: ["chrome"],
395
+ mode: "merge",
396
+ chromeProfile: cookieSource(),
397
+ timeoutMs: 5_000,
398
+ });
399
+
400
+ if (warnings.length) {
401
+ await log(`sweet-cookie warnings: ${warnings.join(" | ")}`);
402
+ }
403
+
404
+ const fallbackHost = new URL(config.browser.chatUrl).hostname;
405
+ const merged = new Map();
406
+ for (const cookie of cookies) {
407
+ const normalized = normalizeCookie(cookie, fallbackHost);
408
+ if (!normalized) continue;
409
+ const key = `${normalized.domain}:${normalized.name}`;
410
+ if (!merged.has(key)) merged.set(key, normalized);
411
+ }
412
+
413
+ const normalizedCookies = Array.from(merged.values());
414
+ await log(
415
+ `Read ${normalizedCookies.length} merged cookies: ${normalizedCookies.map((cookie) => `${cookie.name}@${cookie.domain}`).join(", ")}`,
416
+ );
417
+
418
+ const hasSessionToken = normalizedCookies.some((cookie) => cookie.name.startsWith("__Secure-next-auth.session-token"));
419
+ const hasAccountCookie = normalizedCookies.some((cookie) => cookie.name === "_account");
420
+ const fedrampCookie = normalizedCookies.find((cookie) => cookie.name === "_account_is_fedramp");
421
+ await log(`Cookie presence: sessionToken=${hasSessionToken} account=${hasAccountCookie}`);
422
+
423
+ if (!hasSessionToken) {
424
+ throw new Error(
425
+ `No ChatGPT session-token cookies were found in ${cookieSourceLabel()}. Make sure ChatGPT is logged into that Chrome profile, or set auth.chromeProfile / auth.chromeCookiePath in ~/.pi/agent/extensions/oracle.json.`,
426
+ );
427
+ }
428
+
429
+ if (!hasAccountCookie) {
430
+ const isFedramp = /^(1|true|yes)$/i.test(String(fedrampCookie?.value || ""));
431
+ const fallbackAccountValue = isFedramp ? "fedramp" : "personal";
432
+ normalizedCookies.push({
433
+ name: "_account",
434
+ value: fallbackAccountValue,
435
+ domain: new URL(config.browser.chatUrl).hostname,
436
+ path: "/",
437
+ secure: true,
438
+ httpOnly: false,
439
+ sameSite: "Lax",
440
+ });
441
+ await log(`Synthesized missing _account cookie with value=${fallbackAccountValue}`);
442
+ }
443
+
444
+ return normalizedCookies;
445
+ }
446
+
447
+ function cookieSetArgs(cookie) {
448
+ const args = ["cookies", "set", cookie.name, cookie.value, "--domain", cookie.domain, "--path", cookie.path || "/"];
449
+ if (cookie.httpOnly) args.push("--httpOnly");
450
+ if (cookie.secure) args.push("--secure");
451
+ if (cookie.sameSite) args.push("--sameSite", cookie.sameSite);
452
+ if (cookie.expires) args.push("--expires", String(cookie.expires));
453
+ return args;
454
+ }
455
+
456
+ async function seedCookiesIntoTarget(cookies) {
457
+ await log("Clearing isolated browser cookies before seeding fresh ChatGPT cookies");
458
+ await targetCommand("cookies", "clear", { logLabel: "cookies clear" });
459
+
460
+ let applied = 0;
461
+ for (const cookie of cookies) {
462
+ const args = cookieSetArgs(cookie);
463
+ await log(`Applying cookie ${cookie.name}@${cookie.domain} path=${cookie.path} httpOnly=${cookie.httpOnly} secure=${cookie.secure} sameSite=${cookie.sameSite || "(none)"} expires=${cookie.expires ?? "session"}`);
464
+ const result = await targetCommand(...args, { logLabel: `cookies set ${cookie.name}@${cookie.domain}` });
465
+ if (result.code === 0) applied += 1;
466
+ }
467
+
468
+ await log(`Applied ${applied}/${cookies.length} cookies into isolated browser profile`);
469
+ return applied;
470
+ }
471
+
472
+ function buildLoginProbeScript(timeoutMs) {
473
+ return toAsyncJsonScript(`
474
+ const pageUrl = typeof location === 'object' && location?.href ? location.href : null;
475
+ const onAuthPage =
476
+ typeof location === 'object' &&
477
+ ((typeof location.hostname === 'string' && /^auth\.openai\.com$/i.test(location.hostname)) ||
478
+ (typeof location.pathname === 'string' && /^\\/(auth|login|signin|log-in)/i.test(location.pathname)));
479
+
480
+ const hasLoginCta = () => {
481
+ const candidates = Array.from(
482
+ document.querySelectorAll(
483
+ [
484
+ 'a[href*="/auth/login"]',
485
+ 'a[href*="/auth/signin"]',
486
+ 'button[type="submit"]',
487
+ 'button[data-testid*="login"]',
488
+ 'button[data-testid*="log-in"]',
489
+ 'button[data-testid*="sign-in"]',
490
+ 'button[data-testid*="signin"]',
491
+ 'button',
492
+ 'a',
493
+ ].join(','),
494
+ ),
495
+ );
496
+ const textMatches = (text) => {
497
+ if (!text) return false;
498
+ const normalized = text.toLowerCase().trim();
499
+ return ['log in', 'login', 'sign in', 'signin', 'continue with'].some((needle) => normalized.startsWith(needle));
500
+ };
501
+ for (const node of candidates) {
502
+ if (!(node instanceof HTMLElement)) continue;
503
+ const label =
504
+ node.textContent?.trim() ||
505
+ node.getAttribute('aria-label') ||
506
+ node.getAttribute('title') ||
507
+ '';
508
+ if (textMatches(label)) return true;
509
+ }
510
+ return false;
511
+ };
512
+
513
+ let status = 0;
514
+ let error = null;
515
+ let bodyKeys = [];
516
+ let bodyHasId = false;
517
+ let bodyHasEmail = false;
518
+ let resultName = '';
519
+ let responsePreview = '';
520
+ try {
521
+ if (typeof fetch === 'function') {
522
+ const controller = new AbortController();
523
+ const timeout = setTimeout(() => controller.abort(), ${timeoutMs});
524
+ try {
525
+ const response = await fetch('/backend-api/me', {
526
+ cache: 'no-store',
527
+ credentials: 'include',
528
+ signal: controller.signal,
529
+ });
530
+ status = response.status || 0;
531
+ const contentType = response.headers.get('content-type') || '';
532
+ if (contentType.includes('application/json')) {
533
+ const data = await response.clone().json().catch(() => null);
534
+ if (data && typeof data === 'object' && !Array.isArray(data)) {
535
+ bodyKeys = Object.keys(data).slice(0, 12);
536
+ bodyHasId = typeof data.id === 'string' && data.id.length > 0;
537
+ bodyHasEmail = typeof data.email === 'string' && data.email.includes('@');
538
+ const name = typeof data.name === 'string' ? data.name.trim() : '';
539
+ if (name) resultName = name;
540
+ try {
541
+ responsePreview = JSON.stringify(data).slice(0, 2000);
542
+ } catch (_error) {
543
+ responsePreview = '[unserializable]';
544
+ }
545
+ }
546
+ }
547
+ } finally {
548
+ clearTimeout(timeout);
549
+ }
550
+ }
551
+ } catch (err) {
552
+ error = err ? String(err) : 'unknown';
553
+ }
554
+
555
+ const domLoginCta = hasLoginCta();
556
+ const loginSignals = domLoginCta || onAuthPage;
557
+ return {
558
+ ok: !loginSignals && (status === 0 || status === 200),
559
+ status,
560
+ pageUrl,
561
+ domLoginCta,
562
+ onAuthPage,
563
+ error,
564
+ bodyKeys,
565
+ bodyHasId,
566
+ bodyHasEmail,
567
+ name: resultName,
568
+ responsePreview,
569
+ };
570
+ `);
571
+ }
572
+
573
+ async function loginProbe() {
574
+ const result = await evalPage(buildLoginProbeScript(LOGIN_PROBE_TIMEOUT_MS), "login probe eval");
575
+ if (!result || typeof result !== "object") {
576
+ return { ok: false, status: 0, error: "invalid-probe-result" };
577
+ }
578
+ return {
579
+ ok: result.ok === true,
580
+ status: typeof result.status === "number" ? result.status : 0,
581
+ pageUrl: typeof result.pageUrl === "string" ? result.pageUrl : undefined,
582
+ domLoginCta: result.domLoginCta === true,
583
+ onAuthPage: result.onAuthPage === true,
584
+ error: typeof result.error === "string" ? result.error : undefined,
585
+ bodyKeys: Array.isArray(result.bodyKeys) ? result.bodyKeys : [],
586
+ bodyHasId: result.bodyHasId === true,
587
+ bodyHasEmail: result.bodyHasEmail === true,
588
+ name: typeof result.name === "string" ? result.name : undefined,
589
+ responsePreview: typeof result.responsePreview === "string" ? result.responsePreview : undefined,
590
+ };
591
+ }
592
+
593
+ async function captureDiagnostics(reason) {
594
+ try {
595
+ const [url, snapshot, body] = await Promise.all([getUrl().catch(() => ""), snapshotText().catch(() => ""), pageText().catch(() => "")]);
596
+ await writeFile(URL_PATH, `${url}\n`, { mode: 0o600 });
597
+ await writeFile(SNAPSHOT_PATH, `${snapshot}\n`, { mode: 0o600 });
598
+ await writeFile(BODY_PATH, `${body}\n`, { mode: 0o600 });
599
+ await chmod(URL_PATH, 0o600).catch(() => undefined);
600
+ await chmod(SNAPSHOT_PATH, 0o600).catch(() => undefined);
601
+ await chmod(BODY_PATH, 0o600).catch(() => undefined);
602
+ await targetCommand("screenshot", SCREENSHOT_PATH, { allowFailure: true, logLabel: `screenshot ${reason}` }).catch(() => undefined);
603
+ await log(`Captured diagnostics for ${reason}: ${URL_PATH}, ${SNAPSHOT_PATH}, ${BODY_PATH}, ${SCREENSHOT_PATH}`);
604
+ } catch (error) {
605
+ await log(`Failed to capture diagnostics for ${reason}: ${error instanceof Error ? error.message : String(error)}`);
606
+ }
607
+ }
608
+
609
+ function classifyChatPage({ url, snapshot, body, probe }) {
610
+ const text = `${snapshot}\n${body}`;
611
+ const allowedOrigins = [new URL(config.browser.chatUrl).origin, new URL(config.browser.authUrl).origin, "https://auth.openai.com"];
612
+
613
+ const challengePatterns = [
614
+ /just a moment/i,
615
+ /verify you are human/i,
616
+ /cloudflare/i,
617
+ /captcha|turnstile|hcaptcha/i,
618
+ /unusual activity detected/i,
619
+ /we detect suspicious activity/i,
620
+ ];
621
+ if (challengePatterns.some((pattern) => pattern.test(text))) {
622
+ return {
623
+ state: "challenge_blocking",
624
+ message:
625
+ `ChatGPT challenge detected after syncing cookies from ${cookieSourceLabel()}. ` +
626
+ `The isolated oracle browser was left open on profile ${runtimeProfileDir}; complete the challenge there, then rerun /oracle-auth. Logs: ${LOG_PATH}`,
627
+ };
628
+ }
629
+
630
+ const outagePatterns = [
631
+ /something went wrong/i,
632
+ /a network error occurred/i,
633
+ /an error occurred while connecting to the websocket/i,
634
+ /try again later/i,
635
+ ];
636
+ if (outagePatterns.some((pattern) => pattern.test(text))) {
637
+ return { state: "transient_outage_error", message: `ChatGPT is showing a transient outage/error page. Logs: ${LOG_PATH}` };
638
+ }
639
+
640
+ const onAllowedOrigin = allowedOrigins.some((origin) => url.startsWith(origin));
641
+ const hasComposer = snapshot.includes(`textbox \"${CHATGPT_LABELS.composer}\"`);
642
+ const hasAddFiles = snapshot.includes(`button \"${CHATGPT_LABELS.addFiles}\"`);
643
+ const hasModelControl =
644
+ snapshot.includes('button "Model selector"') ||
645
+ /button "(Instant|Thinking|Pro)(?: [^"]*)?"/.test(snapshot);
646
+
647
+ if (probe?.status === 401 || probe?.status === 403) {
648
+ return {
649
+ state: "login_required",
650
+ message:
651
+ `Synced cookies from ${cookieSourceLabel()}, but ChatGPT still rejected the session ` +
652
+ `(status=${probe?.status ?? 0}). Check auth.chromeProfile/auth.chromeCookiePath and inspect ${LOG_PATH}.`,
653
+ };
654
+ }
655
+
656
+ if (probe?.onAuthPage) {
657
+ if (probe?.bodyHasId || probe?.bodyHasEmail) {
658
+ return {
659
+ state: "auth_transitioning",
660
+ message:
661
+ `ChatGPT is on /auth/login, but /backend-api/me returned a partial authenticated session. ` +
662
+ `Trying to drive the login resolution flow. Logs: ${LOG_PATH}`,
663
+ };
664
+ }
665
+ return {
666
+ state: "login_required",
667
+ message:
668
+ `Synced cookies from ${cookieSourceLabel()}, but ChatGPT still rejected the session ` +
669
+ `(status=${probe?.status ?? 0}). Check auth.chromeProfile/auth.chromeCookiePath and inspect ${LOG_PATH}.`,
670
+ };
671
+ }
672
+
673
+ if (onAllowedOrigin && probe?.status === 200 && hasComposer && hasAddFiles && hasModelControl) {
674
+ if (!probe?.domLoginCta) {
675
+ return {
676
+ state: "authenticated_and_ready",
677
+ message: `Imported ChatGPT auth from ${cookieSourceLabel()} into the isolated oracle profile. Logs: ${LOG_PATH}`,
678
+ };
679
+ }
680
+
681
+ return {
682
+ state: "auth_transitioning",
683
+ message:
684
+ probe?.bodyHasId || probe?.bodyHasEmail
685
+ ? `ChatGPT backend session is authenticated but the shell still shows public CTA chrome. Logs: ${LOG_PATH}`
686
+ : `ChatGPT accepted cookies but is still hydrating/auth-selecting. Logs: ${LOG_PATH}`,
687
+ };
688
+ }
689
+
690
+ if (onAllowedOrigin && probe?.ok && hasComposer && hasAddFiles && hasModelControl) {
691
+ return {
692
+ state: "authenticated_and_ready",
693
+ message: `Imported ChatGPT auth from ${cookieSourceLabel()} into the isolated oracle profile. Logs: ${LOG_PATH}`,
694
+ };
695
+ }
696
+
697
+ if (url && !onAllowedOrigin) {
698
+ return { state: "login_required", message: `Imported auth redirected away from the expected ChatGPT origin. Logs: ${LOG_PATH}` };
699
+ }
700
+
701
+ return { state: "unknown", message: `ChatGPT page state is not yet ready. Logs: ${LOG_PATH}` };
702
+ }
703
+
704
+ async function maybeSelectAccountIdentity(snapshot, probe) {
705
+ const candidates = [];
706
+ if (typeof probe?.name === "string" && probe.name.trim()) {
707
+ candidates.push(probe.name.trim());
708
+ const firstToken = probe.name.trim().split(/\s+/)[0];
709
+ if (firstToken && firstToken !== probe.name.trim()) candidates.push(firstToken);
710
+ }
711
+
712
+ for (const label of candidates) {
713
+ const entry = findEntry(
714
+ snapshot,
715
+ (candidate) => candidate.kind === "button" && candidate.label === label && !candidate.disabled,
716
+ );
717
+ if (!entry) continue;
718
+ await log(`Clicking account chooser button ${JSON.stringify(label)} via ${entry.ref}`);
719
+ await clickRef(entry.ref, `click account chooser ${label}`);
720
+ return true;
721
+ }
722
+
723
+ const loginEntry = findLastEntry(
724
+ snapshot,
725
+ (candidate) => candidate.kind === "button" && candidate.label === "Log in" && !candidate.disabled,
726
+ );
727
+ if (loginEntry) {
728
+ await log(`Clicking visible Log in CTA via ${loginEntry.ref} while backend session is already authenticated`);
729
+ await clickRef(loginEntry.ref, "click login cta");
730
+ return true;
731
+ }
732
+
733
+ return false;
734
+ }
735
+
736
+ function preserveBrowserError(message) {
737
+ const error = new Error(message);
738
+ error.preserveBrowser = true;
739
+ return error;
740
+ }
741
+
742
+ async function waitForImportedAuthReady() {
743
+ const startedAt = Date.now();
744
+ const timeoutAt = startedAt + config.auth.bootstrapTimeoutMs;
745
+ let retriedOutage = false;
746
+ let retriedAuthTransition = false;
747
+ let attemptedAccountChooser = false;
748
+ let attemptedAuthUrl = false;
749
+ let iteration = 0;
750
+ while (Date.now() < timeoutAt) {
751
+ iteration += 1;
752
+ const [url, snapshot, body, probe] = await Promise.all([getUrl(), snapshotText(), pageText(), loginProbe()]);
753
+ await writeFile(URL_PATH, `${url}\n`, { mode: 0o600 }).catch(() => undefined);
754
+ await writeFile(SNAPSHOT_PATH, `${snapshot}\n`, { mode: 0o600 }).catch(() => undefined);
755
+ await writeFile(BODY_PATH, `${body}\n`, { mode: 0o600 }).catch(() => undefined);
756
+ const classification = classifyChatPage({ url, snapshot, body, probe });
757
+ await log(
758
+ `poll ${iteration}: url=${JSON.stringify(url)} probe=${JSON.stringify(probe)} classification=${classification.state} hasComposer=${snapshot.includes(`textbox \"${CHATGPT_LABELS.composer}\"`)} hasAddFiles=${snapshot.includes(`button \"${CHATGPT_LABELS.addFiles}\"`)}`,
759
+ );
760
+ if (classification.state === "authenticated_and_ready") return classification;
761
+ if (classification.state === "auth_transitioning") {
762
+ const elapsedMs = Date.now() - startedAt;
763
+ if (!attemptedAuthUrl && (probe?.bodyHasId || probe?.bodyHasEmail)) {
764
+ attemptedAuthUrl = true;
765
+ await log(`Backend session is authenticated but shell is public; opening auth URL ${config.browser.authUrl} to force session resolution`);
766
+ await openUrl(config.browser.authUrl, config.browser.authUrl);
767
+ await sleep(1500);
768
+ continue;
769
+ }
770
+ if (!attemptedAccountChooser && (probe?.bodyHasId || probe?.bodyHasEmail)) {
771
+ attemptedAccountChooser = await maybeSelectAccountIdentity(snapshot, probe);
772
+ if (attemptedAccountChooser) {
773
+ await log("Auth transition click dispatched; waiting for authenticated shell to settle");
774
+ await sleep(1500);
775
+ continue;
776
+ }
777
+ await log(`No account/login resolution click target found. Snapshot entries: ${parseSnapshotEntries(snapshot).map((entry) => `${entry.kind}:${entry.label || entry.value || entry.ref}`).join(' | ')}`);
778
+ }
779
+ if (!retriedAuthTransition && elapsedMs >= 5_000) {
780
+ retriedAuthTransition = true;
781
+ await log("Auth looks accepted but page is still public-looking; reloading once after hydration grace period");
782
+ await targetCommand("reload", { allowFailure: true, logLabel: "reload-after-auth-transition" }).catch(() => undefined);
783
+ await sleep(1500);
784
+ continue;
785
+ }
786
+ if (elapsedMs >= 20_000) {
787
+ await captureDiagnostics("auth-transition-timeout");
788
+ throw new Error(`ChatGPT accepted the session cookies but never left the public-looking homepage. Inspect ${LOG_PATH}.`);
789
+ }
790
+ await sleep(config.auth.pollMs);
791
+ continue;
792
+ }
793
+ if (classification.state === "transient_outage_error" && !retriedOutage) {
794
+ retriedOutage = true;
795
+ await log("Transient outage detected; reloading once");
796
+ await targetCommand("reload", { allowFailure: true, logLabel: "reload" }).catch(() => undefined);
797
+ await sleep(1500);
798
+ continue;
799
+ }
800
+ if (classification.state === "challenge_blocking") {
801
+ await captureDiagnostics("challenge");
802
+ throw preserveBrowserError(classification.message);
803
+ }
804
+ if (classification.state === "login_required") {
805
+ await captureDiagnostics("login-required");
806
+ throw new Error(classification.message);
807
+ }
808
+ await sleep(config.auth.pollMs);
809
+ }
810
+ await captureDiagnostics("timeout");
811
+ throw new Error(`Timed out verifying synced ChatGPT cookies in the isolated oracle profile. Logs: ${LOG_PATH}`);
812
+ }
813
+
814
+ async function run() {
815
+ await initLog();
816
+ await withLock("auth", "global", { processPid: process.pid, action: "oracle-auth" }, async () => {
817
+ let shouldPreserveBrowser = false;
818
+ let committedProfile = false;
819
+ let profilePlan;
820
+ try {
821
+ profilePlan = await createProfilePlan(config.browser.authSeedProfileDir);
822
+ await log(`Starting oracle auth bootstrap`);
823
+ await log(
824
+ `Config summary: session=${authSessionName()} seedProfileDir=${profilePlan.targetDir} stagingProfileDir=${profilePlan.stagingDir} executable=${config.browser.executablePath || "(default)"} source=${cookieSourceLabel()}`,
825
+ );
826
+ const cookies = await readSourceCookies();
827
+ await prepareStagedProfile(profilePlan);
828
+ await launchTargetBrowser();
829
+ const appliedCount = await seedCookiesIntoTarget(cookies);
830
+ await log(`Cookie seeding complete: applied=${appliedCount}`);
831
+ await openUrl(config.browser.chatUrl, config.browser.chatUrl);
832
+ const classification = await waitForImportedAuthReady();
833
+ await log(`Auth bootstrap success: ${classification.message}`);
834
+ await closeTargetBrowser();
835
+ await commitStagedProfile(profilePlan);
836
+ const generation = new Date().toISOString();
837
+ await writeFile(join(profilePlan.targetDir, ".oracle-seed-generation"), `${generation}\n`, { encoding: "utf8", mode: 0o600 });
838
+ committedProfile = true;
839
+ process.stdout.write(
840
+ `${classification.message} Synced ${appliedCount} cookies into ${profilePlan.targetDir}`,
841
+ );
842
+ } catch (error) {
843
+ shouldPreserveBrowser = Boolean(error && typeof error === "object" && error.preserveBrowser === true);
844
+ await log(`Auth bootstrap failed: ${error instanceof Error ? error.message : String(error)}`);
845
+ if (!shouldPreserveBrowser) {
846
+ await closeTargetBrowser().catch(() => undefined);
847
+ }
848
+ if (profilePlan && !committedProfile && !shouldPreserveBrowser) {
849
+ await rm(profilePlan.stagingDir, { recursive: true, force: true }).catch(() => undefined);
850
+ }
851
+ throw error;
852
+ }
853
+ }, 10 * 60 * 1000);
854
+ }
855
+
856
+ run().catch((error) => {
857
+ process.stderr.write(
858
+ `${error instanceof Error ? error.message : String(error)}\nSee ${LOG_PATH} and diagnostics in /tmp/oracle-auth.*\nIf needed, ensure the configured real Chrome profile is already logged into ChatGPT and grant macOS Keychain access when prompted.`,
859
+ );
860
+ process.exit(1);
861
+ });