pi-oracle 0.3.3 → 0.4.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.
Files changed (37) hide show
  1. package/CHANGELOG.md +33 -0
  2. package/README.md +7 -0
  3. package/docs/ORACLE_DESIGN.md +1 -1
  4. package/docs/ORACLE_ISOLATED_PI_VALIDATION.md +249 -0
  5. package/docs/ORACLE_RECOVERY_DRILL.md +5 -4
  6. package/extensions/oracle/index.ts +8 -1
  7. package/extensions/oracle/lib/commands.ts +11 -24
  8. package/extensions/oracle/lib/config.ts +5 -0
  9. package/extensions/oracle/lib/jobs.ts +117 -217
  10. package/extensions/oracle/lib/locks.ts +41 -209
  11. package/extensions/oracle/lib/poller.ts +14 -51
  12. package/extensions/oracle/lib/queue.ts +75 -112
  13. package/extensions/oracle/lib/runtime.ts +60 -14
  14. package/extensions/oracle/lib/tools.ts +70 -67
  15. package/extensions/oracle/shared/job-coordination-helpers.d.mts +84 -0
  16. package/extensions/oracle/shared/job-coordination-helpers.mjs +168 -0
  17. package/extensions/oracle/shared/job-lifecycle-helpers.d.mts +130 -0
  18. package/extensions/oracle/shared/job-lifecycle-helpers.mjs +377 -0
  19. package/extensions/oracle/shared/job-observability-helpers.d.mts +59 -0
  20. package/extensions/oracle/shared/job-observability-helpers.mjs +143 -0
  21. package/extensions/oracle/shared/process-helpers.d.mts +20 -0
  22. package/extensions/oracle/shared/process-helpers.mjs +128 -0
  23. package/extensions/oracle/shared/state-coordination-helpers.d.mts +43 -0
  24. package/extensions/oracle/shared/state-coordination-helpers.mjs +381 -0
  25. package/extensions/oracle/worker/artifact-heuristics.mjs +5 -0
  26. package/extensions/oracle/worker/auth-bootstrap.mjs +100 -139
  27. package/extensions/oracle/worker/auth-cookie-policy.mjs +5 -0
  28. package/extensions/oracle/worker/auth-flow-helpers.d.mts +41 -0
  29. package/extensions/oracle/worker/auth-flow-helpers.mjs +165 -0
  30. package/extensions/oracle/worker/chatgpt-flow-helpers.d.mts +13 -0
  31. package/extensions/oracle/worker/chatgpt-flow-helpers.mjs +85 -0
  32. package/extensions/oracle/worker/chatgpt-ui-helpers.d.mts +33 -0
  33. package/extensions/oracle/worker/chatgpt-ui-helpers.mjs +292 -0
  34. package/extensions/oracle/worker/run-job.mjs +235 -380
  35. package/extensions/oracle/worker/state-locks.mjs +31 -216
  36. package/package.json +14 -5
  37. package/prompts/oracle.md +1 -1
@@ -1,11 +1,18 @@
1
+ // Purpose: Bootstrap isolated oracle browser auth by importing real Chrome cookies and validating ChatGPT session readiness.
2
+ // Responsibilities: Copy/import cookies, classify auth pages, drive lightweight account-selection flows, and persist diagnostics for auth failures.
3
+ // Scope: Auth bootstrap worker only; long-running oracle job execution stays in run-job.mjs and shared lifecycle/state helpers stay elsewhere.
4
+ // Usage: Spawned by /oracle-auth to prepare the shared auth seed profile used by future oracle jobs.
5
+ // Invariants/Assumptions: Runs against a local macOS Chrome profile, preserves private diagnostics, and must fail clearly when auth state cannot be verified.
1
6
  import { withLock } from "./state-locks.mjs";
2
7
  import { spawn } from "node:child_process";
3
8
  import { existsSync } from "node:fs";
4
- import { appendFile, chmod, lstat, mkdir, readdir, readFile, rename, rm, stat, writeFile } from "node:fs/promises";
5
- import { homedir } from "node:os";
9
+ import { appendFile, chmod, lstat, mkdir, mkdtemp, readdir, readFile, rename, rm, stat, writeFile } from "node:fs/promises";
10
+ import { homedir, tmpdir } from "node:os";
6
11
  import { basename, dirname, join, resolve } from "node:path";
7
12
  import { getCookies } from "@steipete/sweet-cookie";
8
13
  import { ensureAccountCookie, filterImportableAuthCookies } from "./auth-cookie-policy.mjs";
14
+ import { buildAllowedChatGptOrigins } from "./chatgpt-ui-helpers.mjs";
15
+ import { buildAccountChooserCandidateLabels, classifyChatAuthPage, normalizeLoginProbeResult } from "./auth-flow-helpers.mjs";
9
16
 
10
17
  const rawConfig = process.argv[2];
11
18
  if (!rawConfig) {
@@ -27,11 +34,12 @@ const CHATGPT_COOKIE_ORIGINS = [
27
34
  "https://sentinel.openai.com",
28
35
  "https://ws.chatgpt.com",
29
36
  ];
30
- const LOG_PATH = "/tmp/oracle-auth.log";
31
- const URL_PATH = "/tmp/oracle-auth.url.txt";
32
- const SNAPSHOT_PATH = "/tmp/oracle-auth.snapshot.txt";
33
- const BODY_PATH = "/tmp/oracle-auth.body.txt";
34
- const SCREENSHOT_PATH = "/tmp/oracle-auth.png";
37
+ let DIAGNOSTICS_DIR;
38
+ let LOG_PATH = "(oracle-auth log path unavailable)";
39
+ let URL_PATH = "(oracle-auth url path unavailable)";
40
+ let SNAPSHOT_PATH = "(oracle-auth snapshot path unavailable)";
41
+ let BODY_PATH = "(oracle-auth body path unavailable)";
42
+ let SCREENSHOT_PATH = "(oracle-auth screenshot path unavailable)";
35
43
  const REAL_CHROME_USER_DATA_DIR = resolve(homedir(), "Library", "Application Support", "Google", "Chrome");
36
44
  const DEFAULT_ORACLE_STATE_DIR = "/tmp/pi-oracle-state";
37
45
  const ORACLE_STATE_DIR = process.env.PI_ORACLE_STATE_DIR?.trim() || DEFAULT_ORACLE_STATE_DIR;
@@ -40,6 +48,17 @@ const AGENT_BROWSER_BIN = [process.env.AGENT_BROWSER_PATH, "/opt/homebrew/bin/ag
40
48
  (candidate) => typeof candidate === "string" && candidate && existsSync(candidate),
41
49
  ) || "agent-browser";
42
50
 
51
+ function readPositiveIntEnv(name, fallback) {
52
+ const value = process.env[name]?.trim();
53
+ if (!value) return fallback;
54
+ const parsed = Number.parseInt(value, 10);
55
+ return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
56
+ }
57
+
58
+ const AGENT_BROWSER_COMMAND_TIMEOUT_MS = readPositiveIntEnv("PI_ORACLE_AUTH_AGENT_BROWSER_TIMEOUT_MS", 30_000);
59
+ const AGENT_BROWSER_CLOSE_TIMEOUT_MS = readPositiveIntEnv("PI_ORACLE_AUTH_CLOSE_TIMEOUT_MS", 10_000);
60
+ const AGENT_BROWSER_KILL_GRACE_MS = readPositiveIntEnv("PI_ORACLE_AUTH_KILL_GRACE_MS", 2_000);
61
+
43
62
  let runtimeProfileDir = config.browser.authSeedProfileDir;
44
63
 
45
64
  function authSessionName() {
@@ -50,12 +69,25 @@ function sleep(ms) {
50
69
  return new Promise((resolve) => setTimeout(resolve, ms));
51
70
  }
52
71
 
72
+ async function initDiagnosticsBundle() {
73
+ if (DIAGNOSTICS_DIR) return;
74
+ DIAGNOSTICS_DIR = await mkdtemp(join(tmpdir(), "pi-oracle-auth-"));
75
+ await chmod(DIAGNOSTICS_DIR, 0o700).catch(() => undefined);
76
+ LOG_PATH = join(DIAGNOSTICS_DIR, "oracle-auth.log");
77
+ URL_PATH = join(DIAGNOSTICS_DIR, "oracle-auth.url.txt");
78
+ SNAPSHOT_PATH = join(DIAGNOSTICS_DIR, "oracle-auth.snapshot.txt");
79
+ BODY_PATH = join(DIAGNOSTICS_DIR, "oracle-auth.body.txt");
80
+ SCREENSHOT_PATH = join(DIAGNOSTICS_DIR, "oracle-auth.png");
81
+ }
82
+
53
83
  async function initLog() {
84
+ await initDiagnosticsBundle();
54
85
  await writeFile(LOG_PATH, "", { mode: 0o600 });
55
86
  await chmod(LOG_PATH, 0o600).catch(() => undefined);
56
87
  }
57
88
 
58
89
  async function log(message) {
90
+ await initDiagnosticsBundle();
59
91
  const line = `[${new Date().toISOString()}] ${message}\n`;
60
92
  await appendFile(LOG_PATH, line, { encoding: "utf8", mode: 0o600 });
61
93
  await chmod(LOG_PATH, 0o600).catch(() => undefined);
@@ -63,12 +95,25 @@ async function log(message) {
63
95
 
64
96
  function spawnCommand(command, args, options = {}) {
65
97
  return new Promise((resolve, reject) => {
98
+ const { timeoutMs = AGENT_BROWSER_COMMAND_TIMEOUT_MS, ...spawnOptions } = options;
66
99
  const child = spawn(command, args, {
67
100
  stdio: ["pipe", "pipe", "pipe"],
68
- ...options,
101
+ ...spawnOptions,
69
102
  });
70
103
  let stdout = "";
71
104
  let stderr = "";
105
+ let timedOut = false;
106
+ let killTimer;
107
+ let killGraceTimer;
108
+ if (typeof timeoutMs === "number" && timeoutMs > 0) {
109
+ killTimer = setTimeout(() => {
110
+ timedOut = true;
111
+ child.kill("SIGTERM");
112
+ killGraceTimer = setTimeout(() => child.kill("SIGKILL"), AGENT_BROWSER_KILL_GRACE_MS);
113
+ killGraceTimer.unref?.();
114
+ }, timeoutMs);
115
+ killTimer.unref?.();
116
+ }
72
117
  if (options.input) child.stdin.end(options.input);
73
118
  else child.stdin.end();
74
119
  child.stdout.on("data", (data) => {
@@ -77,8 +122,20 @@ function spawnCommand(command, args, options = {}) {
77
122
  child.stderr.on("data", (data) => {
78
123
  stderr += String(data);
79
124
  });
80
- child.on("error", reject);
125
+ child.on("error", (error) => {
126
+ if (killTimer) clearTimeout(killTimer);
127
+ if (killGraceTimer) clearTimeout(killGraceTimer);
128
+ reject(error);
129
+ });
81
130
  child.on("close", (code) => {
131
+ if (killTimer) clearTimeout(killTimer);
132
+ if (killGraceTimer) clearTimeout(killGraceTimer);
133
+ if (timedOut) {
134
+ const error = new Error(stderr || stdout || `${command} timed out after ${timeoutMs}ms`);
135
+ if (options.allowFailure) resolve({ code, stdout: stdout.trim(), stderr: error.message });
136
+ else reject(error);
137
+ return;
138
+ }
82
139
  if (code === 0 || options.allowFailure) resolve({ code, stdout: stdout.trim(), stderr: stderr.trim() });
83
140
  else reject(new Error(stderr || stdout || `${command} exited with code ${code}`));
84
141
  });
@@ -99,7 +156,10 @@ function targetBrowserBaseArgs(options = {}) {
99
156
 
100
157
  async function closeTargetBrowser() {
101
158
  await log(`Closing target browser session ${authSessionName()} if present`);
102
- const result = await spawnCommand(AGENT_BROWSER_BIN, [...targetBrowserBaseArgs(), "close"], { allowFailure: true });
159
+ const result = await spawnCommand(AGENT_BROWSER_BIN, [...targetBrowserBaseArgs(), "close"], {
160
+ allowFailure: true,
161
+ timeoutMs: AGENT_BROWSER_CLOSE_TIMEOUT_MS,
162
+ });
103
163
  await log(`close result: code=${result.code} stdout=${JSON.stringify(result.stdout)} stderr=${JSON.stringify(result.stderr)}`);
104
164
  }
105
165
 
@@ -116,7 +176,10 @@ async function ensureNotSymlink(path, label) {
116
176
  }
117
177
 
118
178
  async function isAuthBrowserConnected() {
119
- const result = await spawnCommand(AGENT_BROWSER_BIN, [...targetBrowserBaseArgs(), "--json", "stream", "status"], { allowFailure: true });
179
+ const result = await spawnCommand(AGENT_BROWSER_BIN, [...targetBrowserBaseArgs(), "--json", "stream", "status"], {
180
+ allowFailure: true,
181
+ timeoutMs: AGENT_BROWSER_COMMAND_TIMEOUT_MS,
182
+ });
120
183
  try {
121
184
  const parsed = JSON.parse(result.stdout || "{}");
122
185
  return parsed?.data?.connected === true;
@@ -208,7 +271,7 @@ async function launchTargetBrowser() {
208
271
  await closeTargetBrowser();
209
272
  const args = [...targetBrowserBaseArgs({ withLaunchOptions: true, mode: "headed" }), "open", "about:blank"];
210
273
  await log(`Launching isolated browser: agent-browser ${JSON.stringify(args)}`);
211
- const result = await spawnCommand(AGENT_BROWSER_BIN, args, { allowFailure: true });
274
+ const result = await spawnCommand(AGENT_BROWSER_BIN, args, { allowFailure: true, timeoutMs: AGENT_BROWSER_COMMAND_TIMEOUT_MS });
212
275
  await log(`launch result: code=${result.code} stdout=${JSON.stringify(result.stdout)} stderr=${JSON.stringify(result.stderr)}`);
213
276
  if (result.code !== 0) {
214
277
  throw new Error(result.stderr || result.stdout || "Failed to launch isolated oracle browser");
@@ -216,7 +279,10 @@ async function launchTargetBrowser() {
216
279
  }
217
280
 
218
281
  async function streamStatus() {
219
- const result = await spawnCommand(AGENT_BROWSER_BIN, [...targetBrowserBaseArgs(), "--json", "stream", "status"], { allowFailure: true });
282
+ const result = await spawnCommand(AGENT_BROWSER_BIN, [...targetBrowserBaseArgs(), "--json", "stream", "status"], {
283
+ allowFailure: true,
284
+ timeoutMs: AGENT_BROWSER_COMMAND_TIMEOUT_MS,
285
+ });
220
286
  await log(`stream status: code=${result.code} stdout=${JSON.stringify(result.stdout)} stderr=${JSON.stringify(result.stderr)}`);
221
287
  try {
222
288
  const parsed = JSON.parse(result.stdout || "{}");
@@ -240,7 +306,11 @@ async function targetCommand(...args) {
240
306
  maybeOptions &&
241
307
  typeof maybeOptions === "object" &&
242
308
  !Array.isArray(maybeOptions) &&
243
- (Object.hasOwn(maybeOptions, "allowFailure") || Object.hasOwn(maybeOptions, "input") || Object.hasOwn(maybeOptions, "cwd") || Object.hasOwn(maybeOptions, "logLabel"))
309
+ (Object.hasOwn(maybeOptions, "allowFailure") ||
310
+ Object.hasOwn(maybeOptions, "input") ||
311
+ Object.hasOwn(maybeOptions, "cwd") ||
312
+ Object.hasOwn(maybeOptions, "logLabel") ||
313
+ Object.hasOwn(maybeOptions, "timeoutMs"))
244
314
  ) {
245
315
  options = args.pop();
246
316
  }
@@ -530,22 +600,7 @@ function buildLoginProbeScript(timeoutMs) {
530
600
 
531
601
  async function loginProbe() {
532
602
  const result = await evalPage(buildLoginProbeScript(LOGIN_PROBE_TIMEOUT_MS), "login probe eval");
533
- if (!result || typeof result !== "object") {
534
- return { ok: false, status: 0, error: "invalid-probe-result" };
535
- }
536
- return {
537
- ok: result.ok === true,
538
- status: typeof result.status === "number" ? result.status : 0,
539
- pageUrl: typeof result.pageUrl === "string" ? result.pageUrl : undefined,
540
- domLoginCta: result.domLoginCta === true,
541
- onAuthPage: result.onAuthPage === true,
542
- error: typeof result.error === "string" ? result.error : undefined,
543
- bodyKeys: Array.isArray(result.bodyKeys) ? result.bodyKeys : [],
544
- bodyHasId: result.bodyHasId === true,
545
- bodyHasEmail: result.bodyHasEmail === true,
546
- name: typeof result.name === "string" ? result.name : undefined,
547
- responsePreview: typeof result.responsePreview === "string" ? result.responsePreview : undefined,
548
- };
603
+ return normalizeLoginProbeResult(result);
549
604
  }
550
605
 
551
606
  async function captureDiagnostics(reason) {
@@ -565,116 +620,22 @@ async function captureDiagnostics(reason) {
565
620
  }
566
621
 
567
622
  function classifyChatPage({ url, snapshot, body, probe }) {
568
- const text = `${snapshot}\n${body}`;
569
- const allowedOrigins = [new URL(config.browser.chatUrl).origin, new URL(config.browser.authUrl).origin, "https://auth.openai.com"];
570
-
571
- const challengePatterns = [
572
- /just a moment/i,
573
- /verify you are human/i,
574
- /cloudflare/i,
575
- /captcha|turnstile|hcaptcha/i,
576
- /unusual activity detected/i,
577
- /we detect suspicious activity/i,
578
- ];
579
- if (challengePatterns.some((pattern) => pattern.test(text))) {
580
- return {
581
- state: "challenge_blocking",
582
- message:
583
- `ChatGPT challenge detected after syncing cookies from ${cookieSourceLabel()}. ` +
584
- `The isolated oracle browser was left open on profile ${runtimeProfileDir}; complete the challenge there, then rerun /oracle-auth. Logs: ${LOG_PATH}`,
585
- };
586
- }
587
-
588
- if (/http error 431|request header or cookie too large/i.test(text)) {
589
- return {
590
- state: "login_required",
591
- message:
592
- `Imported auth hit HTTP 431 during ChatGPT auth resolution, which usually means the imported cookie set is too large or stale. ` +
593
- `Inspect ${LOG_PATH}.`,
594
- };
595
- }
596
-
597
- const outagePatterns = [
598
- /something went wrong/i,
599
- /a network error occurred/i,
600
- /an error occurred while connecting to the websocket/i,
601
- /try again later/i,
602
- ];
603
- if (outagePatterns.some((pattern) => pattern.test(text))) {
604
- return { state: "transient_outage_error", message: `ChatGPT is showing a transient outage/error page. Logs: ${LOG_PATH}` };
605
- }
606
-
607
- const onAllowedOrigin = allowedOrigins.some((origin) => url.startsWith(origin));
608
- const hasComposer = snapshot.includes(`textbox \"${CHATGPT_LABELS.composer}\"`);
609
- const hasAddFiles = snapshot.includes(`button \"${CHATGPT_LABELS.addFiles}\"`);
610
- const hasModelControl =
611
- snapshot.includes('button "Model selector"') ||
612
- /button "(Instant|Thinking|Pro)(?: [^"]*)?"/.test(snapshot);
613
-
614
- if (probe?.status === 401 || probe?.status === 403) {
615
- return {
616
- state: "login_required",
617
- message:
618
- `Synced cookies from ${cookieSourceLabel()}, but ChatGPT still rejected the session ` +
619
- `(status=${probe?.status ?? 0}). Check auth.chromeProfile/auth.chromeCookiePath and inspect ${LOG_PATH}.`,
620
- };
621
- }
622
-
623
- if (probe?.onAuthPage) {
624
- if (probe?.bodyHasId || probe?.bodyHasEmail) {
625
- return {
626
- state: "auth_transitioning",
627
- message:
628
- `ChatGPT is on /auth/login, but /backend-api/me returned a partial authenticated session. ` +
629
- `Trying to drive the login resolution flow. Logs: ${LOG_PATH}`,
630
- };
631
- }
632
- return {
633
- state: "login_required",
634
- message:
635
- `Synced cookies from ${cookieSourceLabel()}, but ChatGPT still rejected the session ` +
636
- `(status=${probe?.status ?? 0}). Check auth.chromeProfile/auth.chromeCookiePath and inspect ${LOG_PATH}.`,
637
- };
638
- }
639
-
640
- if (onAllowedOrigin && probe?.status === 200 && hasComposer && hasAddFiles && hasModelControl) {
641
- if (!probe?.domLoginCta) {
642
- return {
643
- state: "authenticated_and_ready",
644
- message: `Imported ChatGPT auth from ${cookieSourceLabel()} into the isolated oracle profile. Logs: ${LOG_PATH}`,
645
- };
646
- }
647
-
648
- return {
649
- state: "auth_transitioning",
650
- message:
651
- probe?.bodyHasId || probe?.bodyHasEmail
652
- ? `ChatGPT backend session is authenticated but the shell still shows public CTA chrome. Logs: ${LOG_PATH}`
653
- : `ChatGPT accepted cookies but is still hydrating/auth-selecting. Logs: ${LOG_PATH}`,
654
- };
655
- }
656
-
657
- if (onAllowedOrigin && probe?.ok && hasComposer && hasAddFiles && hasModelControl) {
658
- return {
659
- state: "authenticated_and_ready",
660
- message: `Imported ChatGPT auth from ${cookieSourceLabel()} into the isolated oracle profile. Logs: ${LOG_PATH}`,
661
- };
662
- }
663
-
664
- if (url && !onAllowedOrigin) {
665
- return { state: "login_required", message: `Imported auth redirected away from the expected ChatGPT origin. Logs: ${LOG_PATH}` };
666
- }
667
-
668
- return { state: "unknown", message: `ChatGPT page state is not yet ready. Logs: ${LOG_PATH}` };
623
+ return classifyChatAuthPage({
624
+ url,
625
+ snapshot,
626
+ body,
627
+ probe,
628
+ allowedOrigins: buildAllowedChatGptOrigins(config.browser.chatUrl, config.browser.authUrl),
629
+ cookieSourceLabel: cookieSourceLabel(),
630
+ runtimeProfileDir,
631
+ logPath: LOG_PATH,
632
+ composerLabel: CHATGPT_LABELS.composer,
633
+ addFilesLabel: CHATGPT_LABELS.addFiles,
634
+ });
669
635
  }
670
636
 
671
637
  async function maybeSelectAccountIdentity(snapshot, probe) {
672
- const candidates = [];
673
- if (typeof probe?.name === "string" && probe.name.trim()) {
674
- candidates.push(probe.name.trim());
675
- const firstToken = probe.name.trim().split(/\s+/)[0];
676
- if (firstToken && firstToken !== probe.name.trim()) candidates.push(firstToken);
677
- }
638
+ const candidates = buildAccountChooserCandidateLabels(probe?.name);
678
639
 
679
640
  for (const label of candidates) {
680
641
  const entry = findEntry(
@@ -804,7 +765,7 @@ async function run() {
804
765
  await writeFile(join(profilePlan.targetDir, ".oracle-seed-generation"), `${generation}\n`, { encoding: "utf8", mode: 0o600 });
805
766
  committedProfile = true;
806
767
  process.stdout.write(
807
- `${classification.message} Synced ${appliedCount} cookies into ${profilePlan.targetDir}`,
768
+ `${classification.message} Synced ${appliedCount} cookies into ${profilePlan.targetDir}. Diagnostics: ${DIAGNOSTICS_DIR}`,
808
769
  );
809
770
  } catch (error) {
810
771
  shouldPreserveBrowser = Boolean(error && typeof error === "object" && error.preserveBrowser === true);
@@ -822,7 +783,7 @@ async function run() {
822
783
 
823
784
  run().catch((error) => {
824
785
  process.stderr.write(
825
- `${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.`,
786
+ `${error instanceof Error ? error.message : String(error)}\nSee ${LOG_PATH} and diagnostics in ${DIAGNOSTICS_DIR || "(oracle-auth diagnostics dir unavailable)"}\nIf needed, ensure the configured real Chrome profile is already logged into ChatGPT and grant macOS Keychain access when prompted.`,
826
787
  );
827
788
  process.exit(1);
828
789
  });
@@ -1,3 +1,8 @@
1
+ // Purpose: Define the allowlist/drop policy for importing ChatGPT/OpenAI auth cookies into the isolated oracle browser profile.
2
+ // Responsibilities: Recognize required auth cookies, drop noisy/irrelevant cookies, and normalize cookie import decisions.
3
+ // Scope: Pure cookie-policy logic only; reading cookies from Chrome and writing them into the isolated profile happen elsewhere.
4
+ // Usage: Imported by auth-bootstrap and sanity tests to keep cookie import behavior deterministic and reviewable.
5
+ // Invariants/Assumptions: Security-sensitive auth cookies are allowlisted intentionally, and analytics/ambient cookies should be excluded by default.
1
6
  const AUTH_COOKIE_NAME_PATTERNS = [
2
7
  /^__Secure-next-auth\.session-token(?:\.|$)/,
3
8
  /^__Secure-next-auth\.callback-url$/,
@@ -0,0 +1,41 @@
1
+ export interface OracleAuthLoginProbe {
2
+ ok: boolean;
3
+ status: number;
4
+ pageUrl?: string;
5
+ domLoginCta?: boolean;
6
+ onAuthPage?: boolean;
7
+ error?: string;
8
+ bodyKeys?: string[];
9
+ bodyHasId?: boolean;
10
+ bodyHasEmail?: boolean;
11
+ name?: string;
12
+ responsePreview?: string;
13
+ }
14
+
15
+ export type OracleAuthPageState =
16
+ | "challenge_blocking"
17
+ | "login_required"
18
+ | "transient_outage_error"
19
+ | "auth_transitioning"
20
+ | "authenticated_and_ready"
21
+ | "unknown";
22
+
23
+ export interface OracleAuthPageClassification {
24
+ state: OracleAuthPageState;
25
+ message: string;
26
+ }
27
+
28
+ export declare function normalizeLoginProbeResult(result: unknown): OracleAuthLoginProbe;
29
+ export declare function buildAccountChooserCandidateLabels(name?: string): string[];
30
+ export declare function classifyChatAuthPage(args: {
31
+ url: string;
32
+ snapshot: string;
33
+ body: string;
34
+ probe?: OracleAuthLoginProbe;
35
+ allowedOrigins: readonly string[];
36
+ cookieSourceLabel: string;
37
+ runtimeProfileDir: string;
38
+ logPath: string;
39
+ composerLabel?: string;
40
+ addFilesLabel?: string;
41
+ }): OracleAuthPageClassification;
@@ -0,0 +1,165 @@
1
+ // Purpose: Provide pure auth-bootstrap classification helpers for ChatGPT browser state handling.
2
+ // Responsibilities: Normalize login-probe payloads, classify ChatGPT auth page states, and derive account chooser candidate labels.
3
+ // Scope: Pure worker/auth decision logic only; browser I/O, logging, and side effects stay in auth-bootstrap.mjs.
4
+ // Usage: Imported by auth-bootstrap.mjs and sanity tests to exercise auth classification behavior without driving a browser.
5
+ // Invariants/Assumptions: Inputs are already captured snapshots/probe results from the live browser session; outputs are deterministic and side-effect free.
6
+
7
+ /** @typedef {import("./auth-flow-helpers.d.mts").OracleAuthLoginProbe} OracleAuthLoginProbe */
8
+ /** @typedef {import("./auth-flow-helpers.d.mts").OracleAuthPageClassification} OracleAuthPageClassification */
9
+
10
+ const DEFAULT_COMPOSER_LABEL = "Chat with ChatGPT";
11
+ const DEFAULT_ADD_FILES_LABEL = "Add files and more";
12
+
13
+ /**
14
+ * @param {unknown} result
15
+ * @returns {OracleAuthLoginProbe}
16
+ */
17
+ export function normalizeLoginProbeResult(result) {
18
+ if (!result || typeof result !== "object") {
19
+ return { ok: false, status: 0, error: "invalid-probe-result" };
20
+ }
21
+ const value = /** @type {Record<string, unknown>} */ (result);
22
+ return {
23
+ ok: value.ok === true,
24
+ status: typeof value.status === "number" ? value.status : 0,
25
+ pageUrl: typeof value.pageUrl === "string" ? value.pageUrl : undefined,
26
+ domLoginCta: value.domLoginCta === true,
27
+ onAuthPage: value.onAuthPage === true,
28
+ error: typeof value.error === "string" ? value.error : undefined,
29
+ bodyKeys: Array.isArray(value.bodyKeys) ? value.bodyKeys.filter((entry) => typeof entry === "string") : [],
30
+ bodyHasId: value.bodyHasId === true,
31
+ bodyHasEmail: value.bodyHasEmail === true,
32
+ name: typeof value.name === "string" ? value.name : undefined,
33
+ responsePreview: typeof value.responsePreview === "string" ? value.responsePreview : undefined,
34
+ };
35
+ }
36
+
37
+ /**
38
+ * @param {string | undefined} name
39
+ * @returns {string[]}
40
+ */
41
+ export function buildAccountChooserCandidateLabels(name) {
42
+ const normalized = typeof name === "string" ? name.trim() : "";
43
+ if (!normalized) return [];
44
+ const firstToken = normalized.split(/\s+/)[0] || "";
45
+ return firstToken && firstToken !== normalized ? [normalized, firstToken] : [normalized];
46
+ }
47
+
48
+ /**
49
+ * @param {{
50
+ * url: string;
51
+ * snapshot: string;
52
+ * body: string;
53
+ * probe?: OracleAuthLoginProbe;
54
+ * allowedOrigins: readonly string[];
55
+ * cookieSourceLabel: string;
56
+ * runtimeProfileDir: string;
57
+ * logPath: string;
58
+ * composerLabel?: string;
59
+ * addFilesLabel?: string;
60
+ * }} args
61
+ * @returns {OracleAuthPageClassification}
62
+ */
63
+ export function classifyChatAuthPage(args) {
64
+ const text = `${args.snapshot}\n${args.body}`;
65
+ const composerLabel = args.composerLabel || DEFAULT_COMPOSER_LABEL;
66
+ const addFilesLabel = args.addFilesLabel || DEFAULT_ADD_FILES_LABEL;
67
+ const onAllowedOrigin = args.allowedOrigins.some((origin) => args.url.startsWith(origin));
68
+ const hasComposer = args.snapshot.includes(`textbox "${composerLabel}"`);
69
+ const hasAddFiles = args.snapshot.includes(`button "${addFilesLabel}"`);
70
+ const hasModelControl =
71
+ args.snapshot.includes('button "Model selector"') ||
72
+ /button "(Instant|Thinking|Pro)(?: [^"]*)?"/.test(args.snapshot);
73
+
74
+ const challengePatterns = [
75
+ /just a moment/i,
76
+ /verify you are human/i,
77
+ /cloudflare/i,
78
+ /captcha|turnstile|hcaptcha/i,
79
+ /unusual activity detected/i,
80
+ /we detect suspicious activity/i,
81
+ ];
82
+ if (challengePatterns.some((pattern) => pattern.test(text))) {
83
+ return {
84
+ state: "challenge_blocking",
85
+ message:
86
+ `ChatGPT challenge detected after syncing cookies from ${args.cookieSourceLabel}. ` +
87
+ `The isolated oracle browser was left open on profile ${args.runtimeProfileDir}; complete the challenge there, then rerun /oracle-auth. Logs: ${args.logPath}`,
88
+ };
89
+ }
90
+
91
+ if (/http error 431|request header or cookie too large/i.test(text)) {
92
+ return {
93
+ state: "login_required",
94
+ message:
95
+ `Imported auth hit HTTP 431 during ChatGPT auth resolution, which usually means the imported cookie set is too large or stale. ` +
96
+ `Inspect ${args.logPath}.`,
97
+ };
98
+ }
99
+
100
+ const outagePatterns = [
101
+ /something went wrong/i,
102
+ /a network error occurred/i,
103
+ /an error occurred while connecting to the websocket/i,
104
+ /try again later/i,
105
+ ];
106
+ if (outagePatterns.some((pattern) => pattern.test(text))) {
107
+ return { state: "transient_outage_error", message: `ChatGPT is showing a transient outage/error page. Logs: ${args.logPath}` };
108
+ }
109
+
110
+ if (args.probe?.status === 401 || args.probe?.status === 403) {
111
+ return {
112
+ state: "login_required",
113
+ message:
114
+ `Synced cookies from ${args.cookieSourceLabel}, but ChatGPT still rejected the session ` +
115
+ `(status=${args.probe?.status ?? 0}). Check auth.chromeProfile/auth.chromeCookiePath and inspect ${args.logPath}.`,
116
+ };
117
+ }
118
+
119
+ if (args.probe?.onAuthPage) {
120
+ if (args.probe?.bodyHasId || args.probe?.bodyHasEmail) {
121
+ return {
122
+ state: "auth_transitioning",
123
+ message:
124
+ `ChatGPT is on /auth/login, but /backend-api/me returned a partial authenticated session. ` +
125
+ `Trying to drive the login resolution flow. Logs: ${args.logPath}`,
126
+ };
127
+ }
128
+ return {
129
+ state: "login_required",
130
+ message:
131
+ `Synced cookies from ${args.cookieSourceLabel}, but ChatGPT still rejected the session ` +
132
+ `(status=${args.probe?.status ?? 0}). Check auth.chromeProfile/auth.chromeCookiePath and inspect ${args.logPath}.`,
133
+ };
134
+ }
135
+
136
+ if (onAllowedOrigin && args.probe?.status === 200 && hasComposer && hasAddFiles && hasModelControl) {
137
+ if (!args.probe?.domLoginCta) {
138
+ return {
139
+ state: "authenticated_and_ready",
140
+ message: `Imported ChatGPT auth from ${args.cookieSourceLabel} into the isolated oracle profile. Logs: ${args.logPath}`,
141
+ };
142
+ }
143
+
144
+ return {
145
+ state: "auth_transitioning",
146
+ message:
147
+ args.probe?.bodyHasId || args.probe?.bodyHasEmail
148
+ ? `ChatGPT backend session is authenticated but the shell still shows public CTA chrome. Logs: ${args.logPath}`
149
+ : `ChatGPT accepted cookies but is still hydrating/auth-selecting. Logs: ${args.logPath}`,
150
+ };
151
+ }
152
+
153
+ if (onAllowedOrigin && args.probe?.ok && hasComposer && hasAddFiles && hasModelControl) {
154
+ return {
155
+ state: "authenticated_and_ready",
156
+ message: `Imported ChatGPT auth from ${args.cookieSourceLabel} into the isolated oracle profile. Logs: ${args.logPath}`,
157
+ };
158
+ }
159
+
160
+ if (args.url && !onAllowedOrigin) {
161
+ return { state: "login_required", message: `Imported auth redirected away from the expected ChatGPT origin. Logs: ${args.logPath}` };
162
+ }
163
+
164
+ return { state: "unknown", message: `ChatGPT page state is not yet ready. Logs: ${args.logPath}` };
165
+ }
@@ -0,0 +1,13 @@
1
+ export interface OracleStableValueState {
2
+ lastValue: string;
3
+ stableCount: number;
4
+ }
5
+
6
+ export declare function assistantSnapshotSlice(snapshot: string, composerLabel: string, responseIndex: number): string | undefined;
7
+ export declare function stripUrlQueryAndHash(url: string | undefined): string;
8
+ export declare function isConversationPathUrl(url: string): boolean;
9
+ export declare function resolveStableConversationUrlCandidate(url: string, previousChatUrl?: string): string | undefined;
10
+ export declare function nextStableValueState(
11
+ state: Partial<OracleStableValueState> | undefined,
12
+ nextValue: string,
13
+ ): OracleStableValueState;
@@ -0,0 +1,85 @@
1
+ // Purpose: Provide pure ChatGPT conversation-state helpers used by the oracle worker.
2
+ // Responsibilities: Slice assistant snapshot regions, normalize URLs, and track stable conversation URL observations.
3
+ // Scope: Pure worker flow logic only; browser I/O and polling loops stay in run-job.mjs.
4
+ // Usage: Imported by run-job.mjs and sanity tests to validate conversation-state heuristics without driving a browser.
5
+ // Invariants/Assumptions: Snapshot text comes from agent-browser `snapshot -i`; URL inputs may be malformed and must fail safely.
6
+
7
+ /** @typedef {import("./chatgpt-flow-helpers.d.mts").OracleStableValueState} OracleStableValueState */
8
+
9
+ /**
10
+ * @param {string} snapshot
11
+ * @param {string} composerLabel
12
+ * @param {number} responseIndex
13
+ * @returns {string | undefined}
14
+ */
15
+ export function assistantSnapshotSlice(snapshot, composerLabel, responseIndex) {
16
+ const lines = snapshot.split("\n");
17
+ const assistantHeadingIndices = lines.flatMap((line, index) => (line.includes('heading "ChatGPT said:"') ? [index] : []));
18
+ const startIndex = assistantHeadingIndices[responseIndex];
19
+ if (startIndex === undefined) return undefined;
20
+
21
+ const endCandidates = [];
22
+ const nextAssistantIndex = assistantHeadingIndices[responseIndex + 1];
23
+ if (nextAssistantIndex !== undefined) endCandidates.push(nextAssistantIndex);
24
+
25
+ const composerIndex = lines.findIndex(
26
+ (line, index) => index > startIndex && line.includes(`textbox "${composerLabel}"`),
27
+ );
28
+ if (composerIndex !== -1) endCandidates.push(composerIndex);
29
+
30
+ const endIndex = endCandidates.length > 0 ? Math.min(...endCandidates) : undefined;
31
+ return lines.slice(startIndex, endIndex).join("\n");
32
+ }
33
+
34
+ /**
35
+ * @param {string | undefined} url
36
+ * @returns {string}
37
+ */
38
+ export function stripUrlQueryAndHash(url) {
39
+ if (typeof url !== "string") return "";
40
+ try {
41
+ const parsed = new URL(url);
42
+ parsed.hash = "";
43
+ parsed.search = "";
44
+ return parsed.toString();
45
+ } catch {
46
+ return url;
47
+ }
48
+ }
49
+
50
+ /**
51
+ * @param {string} url
52
+ * @returns {boolean}
53
+ */
54
+ export function isConversationPathUrl(url) {
55
+ try {
56
+ return /\/c\/[A-Za-z0-9-]+$/i.test(new URL(url).pathname);
57
+ } catch {
58
+ return false;
59
+ }
60
+ }
61
+
62
+ /**
63
+ * @param {string} url
64
+ * @param {string | undefined} previousChatUrl
65
+ * @returns {string | undefined}
66
+ */
67
+ export function resolveStableConversationUrlCandidate(url, previousChatUrl) {
68
+ const normalizedUrl = stripUrlQueryAndHash(url);
69
+ if (!normalizedUrl) return undefined;
70
+ if (isConversationPathUrl(normalizedUrl)) return normalizedUrl;
71
+ const normalizedPrevious = stripUrlQueryAndHash(previousChatUrl);
72
+ return normalizedPrevious && normalizedPrevious === normalizedUrl ? normalizedUrl : undefined;
73
+ }
74
+
75
+ /**
76
+ * @param {Partial<OracleStableValueState> | undefined} state
77
+ * @param {string} nextValue
78
+ * @returns {OracleStableValueState}
79
+ */
80
+ export function nextStableValueState(state, nextValue) {
81
+ return {
82
+ lastValue: nextValue,
83
+ stableCount: state?.lastValue === nextValue ? (state?.stableCount ?? 0) + 1 : 1,
84
+ };
85
+ }