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.
- package/CHANGELOG.md +33 -0
- package/README.md +7 -0
- package/docs/ORACLE_DESIGN.md +1 -1
- package/docs/ORACLE_ISOLATED_PI_VALIDATION.md +249 -0
- package/docs/ORACLE_RECOVERY_DRILL.md +5 -4
- package/extensions/oracle/index.ts +8 -1
- package/extensions/oracle/lib/commands.ts +11 -24
- package/extensions/oracle/lib/config.ts +5 -0
- package/extensions/oracle/lib/jobs.ts +117 -217
- package/extensions/oracle/lib/locks.ts +41 -209
- package/extensions/oracle/lib/poller.ts +14 -51
- package/extensions/oracle/lib/queue.ts +75 -112
- package/extensions/oracle/lib/runtime.ts +60 -14
- package/extensions/oracle/lib/tools.ts +70 -67
- package/extensions/oracle/shared/job-coordination-helpers.d.mts +84 -0
- package/extensions/oracle/shared/job-coordination-helpers.mjs +168 -0
- package/extensions/oracle/shared/job-lifecycle-helpers.d.mts +130 -0
- package/extensions/oracle/shared/job-lifecycle-helpers.mjs +377 -0
- package/extensions/oracle/shared/job-observability-helpers.d.mts +59 -0
- package/extensions/oracle/shared/job-observability-helpers.mjs +143 -0
- package/extensions/oracle/shared/process-helpers.d.mts +20 -0
- package/extensions/oracle/shared/process-helpers.mjs +128 -0
- package/extensions/oracle/shared/state-coordination-helpers.d.mts +43 -0
- package/extensions/oracle/shared/state-coordination-helpers.mjs +381 -0
- package/extensions/oracle/worker/artifact-heuristics.mjs +5 -0
- package/extensions/oracle/worker/auth-bootstrap.mjs +100 -139
- package/extensions/oracle/worker/auth-cookie-policy.mjs +5 -0
- package/extensions/oracle/worker/auth-flow-helpers.d.mts +41 -0
- package/extensions/oracle/worker/auth-flow-helpers.mjs +165 -0
- package/extensions/oracle/worker/chatgpt-flow-helpers.d.mts +13 -0
- package/extensions/oracle/worker/chatgpt-flow-helpers.mjs +85 -0
- package/extensions/oracle/worker/chatgpt-ui-helpers.d.mts +33 -0
- package/extensions/oracle/worker/chatgpt-ui-helpers.mjs +292 -0
- package/extensions/oracle/worker/run-job.mjs +235 -380
- package/extensions/oracle/worker/state-locks.mjs +31 -216
- package/package.json +14 -5
- 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
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
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
|
-
...
|
|
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",
|
|
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"], {
|
|
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"], {
|
|
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"], {
|
|
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") ||
|
|
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
|
-
|
|
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
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
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
|
|
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
|
+
}
|