@steipete/oracle 0.12.1 → 0.14.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/README.md +72 -61
- package/dist/bin/oracle-cli.js +77 -68
- package/dist/bin/oracle-mcp.js +0 -0
- package/dist/docs-site/.nojekyll +0 -0
- package/dist/docs-site/CNAME +1 -0
- package/dist/docs-site/RELEASING.html +410 -0
- package/dist/docs-site/agents.html +374 -0
- package/dist/docs-site/anthropic.html +368 -0
- package/dist/docs-site/bridge.html +400 -0
- package/dist/docs-site/browser-mode.html +593 -0
- package/dist/docs-site/chromium-forks.html +347 -0
- package/dist/docs-site/cli-reference.html +346 -0
- package/dist/docs-site/configuration.html +452 -0
- package/dist/docs-site/favicon.svg +14 -0
- package/dist/docs-site/followup.html +375 -0
- package/dist/docs-site/gemini.html +383 -0
- package/dist/docs-site/grok.html +325 -0
- package/dist/docs-site/index.html +360 -0
- package/dist/docs-site/install.html +335 -0
- package/dist/docs-site/linux.html +321 -0
- package/dist/docs-site/llms.txt +43 -0
- package/dist/docs-site/manual-tests.html +596 -0
- package/dist/docs-site/mcp.html +391 -0
- package/dist/docs-site/multimodel.html +364 -0
- package/dist/docs-site/mythical-pro-agents.html +360 -0
- package/dist/docs-site/notifier.html +338 -0
- package/dist/docs-site/openai-endpoints.html +387 -0
- package/dist/docs-site/openrouter.html +344 -0
- package/dist/docs-site/quickstart.html +369 -0
- package/dist/docs-site/refactor/ux.html +532 -0
- package/dist/docs-site/sessions.html +388 -0
- package/dist/docs-site/social-card.png +0 -0
- package/dist/docs-site/social-card.svg +79 -0
- package/dist/docs-site/spec.html +363 -0
- package/dist/docs-site/testing.html +320 -0
- package/dist/docs-site/tui-debug.html +326 -0
- package/dist/docs-site/windows-work.html +323 -0
- package/dist/docs-site/windows.html +320 -0
- package/dist/src/browser/actions/assistantResponse.js +91 -9
- package/dist/src/browser/actions/deepResearch.js +180 -52
- package/dist/src/browser/actions/modelSelection.js +111 -24
- package/dist/src/browser/actions/navigation.js +358 -67
- package/dist/src/browser/actions/promptComposer.js +241 -46
- package/dist/src/browser/actions/thinkingTime.js +308 -89
- package/dist/src/browser/artifacts.js +19 -0
- package/dist/src/browser/chatgptFiles.js +797 -0
- package/dist/src/browser/chatgptImages.js +130 -3
- package/dist/src/browser/chromeLifecycle.js +4 -0
- package/dist/src/browser/config.js +4 -0
- package/dist/src/browser/index.js +353 -41
- package/dist/src/browser/pageActions.js +1 -1
- package/dist/src/browser/policies.js +2 -6
- package/dist/src/browser/projectSourcesRunner.js +14 -2
- package/dist/src/browser/prompt.js +11 -15
- package/dist/src/browser/providers/chatgptDomProvider.js +1 -0
- package/dist/src/browser/reattach.js +6 -2
- package/dist/src/browser/reattachability.js +22 -10
- package/dist/src/browser/recoverConversation.js +73 -0
- package/dist/src/browser/sessionRunner.js +4 -1
- package/dist/src/cli/bridge/doctor.js +7 -2
- package/dist/src/cli/browserConfig.js +13 -2
- package/dist/src/cli/browserDefaults.js +3 -0
- package/dist/src/cli/browserTabs.js +129 -54
- package/dist/src/cli/engine.js +6 -2
- package/dist/src/cli/followup.js +72 -0
- package/dist/src/cli/help.js +1 -1
- package/dist/src/cli/options.js +20 -0
- package/dist/src/cli/reattachGuidance.js +8 -0
- package/dist/src/cli/runOptions.js +10 -31
- package/dist/src/cli/sessionCommand.js +4 -0
- package/dist/src/cli/sessionDisplay.js +17 -3
- package/dist/src/cli/sessionLineage.js +7 -4
- package/dist/src/cli/sessionRunner.js +16 -1
- package/dist/src/config.js +164 -9
- package/dist/src/gemini-web/client.js +5 -10
- package/dist/src/gemini-web/executor.js +1 -24
- package/dist/src/gemini-web/models.js +83 -0
- package/dist/src/mcp/server.js +2 -0
- package/dist/src/mcp/tools/chatgptImage.js +127 -0
- package/dist/src/mcp/tools/consult.js +261 -167
- package/dist/src/mcp/types.js +2 -0
- package/dist/src/mcp/utils.js +87 -1
- package/dist/src/oracle/config.js +27 -1
- package/dist/src/oracle/files.js +4 -6
- package/dist/src/oracle/geminiModels.js +2 -0
- package/dist/src/oracle/markdown.js +36 -3
- package/dist/src/oracle/modelResolver.js +21 -13
- package/dist/src/oracle/promptAssembly.js +2 -4
- package/dist/src/oracle/request.js +3 -5
- package/dist/src/oracle/tokenStats.js +5 -1
- package/dist/src/oracle.js +1 -1
- package/dist/src/sessionManager.js +1 -0
- package/dist/vendor/oracle-notifier/OracleNotifier.app/Contents/CodeResources +0 -0
- package/dist/vendor/oracle-notifier/OracleNotifier.app/Contents/MacOS/OracleNotifier +0 -0
- package/package.json +20 -29
- package/vendor/oracle-notifier/OracleNotifier.app/Contents/CodeResources +0 -0
- package/vendor/oracle-notifier/OracleNotifier.app/Contents/MacOS/OracleNotifier +0 -0
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { CLOUDFLARE_SCRIPT_SELECTOR, CLOUDFLARE_TITLE, INPUT_SELECTORS } from "../constants.js";
|
|
1
|
+
import { CLOUDFLARE_SCRIPT_SELECTOR, CLOUDFLARE_TITLE, CONVERSATION_TURN_SELECTOR, INPUT_SELECTORS, } from "../constants.js";
|
|
2
2
|
import { delay } from "../utils.js";
|
|
3
3
|
import { logDomFailure } from "../domDebug.js";
|
|
4
4
|
import { BrowserAutomationError } from "../../oracle/errors.js";
|
|
@@ -139,9 +139,14 @@ export async function ensureNotBlocked(Runtime, headless, logger) {
|
|
|
139
139
|
}
|
|
140
140
|
}
|
|
141
141
|
const LOGIN_CHECK_TIMEOUT_MS = 5_000;
|
|
142
|
+
const CHATGPT_ACCOUNT_EMAIL_ENV = "ORACLE_CHATGPT_ACCOUNT_EMAIL";
|
|
143
|
+
function preferredChatGptAccountEmail() {
|
|
144
|
+
const email = process.env[CHATGPT_ACCOUNT_EMAIL_ENV]?.trim().toLowerCase();
|
|
145
|
+
return email ? email : null;
|
|
146
|
+
}
|
|
142
147
|
export async function ensureLoggedIn(Runtime, logger, options = {}) {
|
|
143
148
|
// Learned: ChatGPT can render the UI (project view) while auth silently failed.
|
|
144
|
-
//
|
|
149
|
+
// Session state plus DOM login signals catch both valid and stale app shells.
|
|
145
150
|
const outcome = await Runtime.evaluate({
|
|
146
151
|
expression: buildLoginProbeExpression(LOGIN_CHECK_TIMEOUT_MS),
|
|
147
152
|
awaitPromise: true,
|
|
@@ -149,11 +154,11 @@ export async function ensureLoggedIn(Runtime, logger, options = {}) {
|
|
|
149
154
|
});
|
|
150
155
|
const probe = normalizeLoginProbe(outcome.result?.value);
|
|
151
156
|
if (probe.ok) {
|
|
152
|
-
logger(`Login check passed (
|
|
157
|
+
logger(`Login check passed (sessionStatus=${probe.status}, sessionAuthenticated=${Boolean(probe.sessionAuthenticated)}, backendStatus=${probe.backendStatus ?? "n/a"}, domLoginCta=${Boolean(probe.domLoginCta)}, appAuthenticated=${Boolean(probe.appAuthenticated)})`);
|
|
153
158
|
return;
|
|
154
159
|
}
|
|
155
|
-
const
|
|
156
|
-
if (accepted) {
|
|
160
|
+
const welcomeBack = await attemptWelcomeBackLogin(Runtime, logger, preferredChatGptAccountEmail());
|
|
161
|
+
if (welcomeBack.accepted) {
|
|
157
162
|
// Learned: "Welcome back" account picker needs a click even when cookies are valid,
|
|
158
163
|
// and the redirect can lag, so re-probe before failing hard.
|
|
159
164
|
await delay(1500);
|
|
@@ -167,46 +172,25 @@ export async function ensureLoggedIn(Runtime, logger, options = {}) {
|
|
|
167
172
|
logger("Login restored via Welcome back account picker");
|
|
168
173
|
return;
|
|
169
174
|
}
|
|
170
|
-
logger(`Login retry after Welcome back failed (
|
|
175
|
+
logger(`Login retry after Welcome back failed (sessionStatus=${retryProbe.status}, sessionAuthenticated=${Boolean(retryProbe.sessionAuthenticated)}, backendStatus=${retryProbe.backendStatus ?? "n/a"}, domLoginCta=${Boolean(retryProbe.domLoginCta)}, appAuthenticated=${Boolean(retryProbe.appAuthenticated)})`);
|
|
171
176
|
}
|
|
172
|
-
logger(`Login probe failed (
|
|
177
|
+
logger(`Login probe failed (sessionStatus=${probe.status}, sessionAuthenticated=${Boolean(probe.sessionAuthenticated)}, sessionResolved=${Boolean(probe.sessionResolved)}, backendStatus=${probe.backendStatus ?? "n/a"}, domLoginCta=${Boolean(probe.domLoginCta)}, onAuthPage=${Boolean(probe.onAuthPage)}, appAuthenticated=${Boolean(probe.appAuthenticated)}, cfBlocked=${Boolean(probe.cfBlocked)}, url=${probe.pageUrl ?? "n/a"}, error=${probe.error ?? "none"})`);
|
|
173
178
|
const domLabel = probe.domLoginCta ? " Login button detected on page." : "";
|
|
174
179
|
const cookieHint = options.remoteSession
|
|
175
180
|
? "The remote Chrome session is not signed into ChatGPT. Sign in there, then rerun."
|
|
176
181
|
: (options.appliedCookies ?? 0) === 0
|
|
177
182
|
? "No ChatGPT cookies were applied; sign in to chatgpt.com in Chrome or pass inline cookies (--browser-inline-cookies[(-file)] / ORACLE_BROWSER_COOKIES_JSON)."
|
|
178
183
|
: "ChatGPT login appears missing; open chatgpt.com in Chrome to refresh the session or provide inline cookies (--browser-inline-cookies[(-file)] / ORACLE_BROWSER_COOKIES_JSON).";
|
|
179
|
-
|
|
184
|
+
const accountHint = welcomeBack.hint ? ` ${welcomeBack.hint}` : "";
|
|
185
|
+
throw new Error(`ChatGPT session not detected.${domLabel}${accountHint} ${cookieHint}`);
|
|
180
186
|
}
|
|
181
|
-
async function attemptWelcomeBackLogin(Runtime, logger) {
|
|
187
|
+
async function attemptWelcomeBackLogin(Runtime, logger, preferredEmail = null) {
|
|
182
188
|
const deadline = Date.now() + 30_000;
|
|
183
189
|
while (Date.now() < deadline) {
|
|
184
190
|
let outcome;
|
|
185
191
|
try {
|
|
186
192
|
outcome = await Runtime.evaluate({
|
|
187
|
-
expression:
|
|
188
|
-
// Learned: "Welcome back" shows as a modal with account chips; click the email chip.
|
|
189
|
-
const getLabel = (node) =>
|
|
190
|
-
(node?.textContent || node?.getAttribute?.('aria-label') || '').trim();
|
|
191
|
-
const isAccount = (label) =>
|
|
192
|
-
Boolean(label) &&
|
|
193
|
-
label.includes('@') &&
|
|
194
|
-
!/log in|sign up|create account|another account/i.test(label);
|
|
195
|
-
const candidates = Array.from(document.querySelectorAll('[role="button"],button,a'));
|
|
196
|
-
const account = candidates.find((node) => isAccount(getLabel(node))) || null;
|
|
197
|
-
if (!account) {
|
|
198
|
-
return { clicked: false, reason: 'not-found' };
|
|
199
|
-
}
|
|
200
|
-
const label = getLabel(account);
|
|
201
|
-
setTimeout(() => {
|
|
202
|
-
try {
|
|
203
|
-
account.click();
|
|
204
|
-
} catch {
|
|
205
|
-
// ignore; caller will re-probe login state
|
|
206
|
-
}
|
|
207
|
-
}, 0);
|
|
208
|
-
return { clicked: true, label };
|
|
209
|
-
})()`,
|
|
193
|
+
expression: buildWelcomeBackAccountPickerExpression(preferredEmail),
|
|
210
194
|
awaitPromise: false,
|
|
211
195
|
returnByValue: true,
|
|
212
196
|
});
|
|
@@ -215,10 +199,10 @@ async function attemptWelcomeBackLogin(Runtime, logger) {
|
|
|
215
199
|
const message = error instanceof Error ? error.message : String(error);
|
|
216
200
|
if (/navigated or closed|context was destroyed|target closed/i.test(message)) {
|
|
217
201
|
logger("Welcome back account click triggered navigation.");
|
|
218
|
-
return true;
|
|
202
|
+
return { accepted: true };
|
|
219
203
|
}
|
|
220
204
|
logger(`Welcome back auto-select probe failed: ${message}`);
|
|
221
|
-
return false;
|
|
205
|
+
return { accepted: false };
|
|
222
206
|
}
|
|
223
207
|
if (outcome.exceptionDetails) {
|
|
224
208
|
const details = outcome.exceptionDetails;
|
|
@@ -228,29 +212,93 @@ async function attemptWelcomeBackLogin(Runtime, logger) {
|
|
|
228
212
|
details.text ||
|
|
229
213
|
"unknown error";
|
|
230
214
|
logger(`Welcome back auto-select probe failed: ${description}`);
|
|
231
|
-
return false;
|
|
215
|
+
return { accepted: false };
|
|
232
216
|
}
|
|
233
217
|
const result = outcome.result?.value;
|
|
234
218
|
if (!result) {
|
|
235
219
|
logger("Welcome back auto-select probe returned no result.");
|
|
236
|
-
return false;
|
|
220
|
+
return { accepted: false };
|
|
237
221
|
}
|
|
238
222
|
if (!("clicked" in result) && !("reason" in result)) {
|
|
239
223
|
logger("Welcome back auto-select probe returned an unexpected result.");
|
|
240
|
-
return false;
|
|
224
|
+
return { accepted: false };
|
|
241
225
|
}
|
|
242
226
|
if (result.clicked) {
|
|
243
|
-
logger(
|
|
244
|
-
|
|
227
|
+
logger(result.selection === "preferred"
|
|
228
|
+
? "Welcome back modal detected; selected configured account."
|
|
229
|
+
: "Welcome back modal detected; selected only saved account.");
|
|
230
|
+
return { accepted: true };
|
|
231
|
+
}
|
|
232
|
+
if (result.reason === "preferred-not-found") {
|
|
233
|
+
logger(`Welcome back modal present but ${CHATGPT_ACCOUNT_EMAIL_ENV} did not match any saved account (${result.accountCount ?? 0} account chips found).`);
|
|
234
|
+
return {
|
|
235
|
+
accepted: false,
|
|
236
|
+
hint: `${CHATGPT_ACCOUNT_EMAIL_ENV} did not match a saved account. Set it to the exact account email on the browser host or sign in manually.`,
|
|
237
|
+
};
|
|
238
|
+
}
|
|
239
|
+
if (result.reason === "multiple-accounts") {
|
|
240
|
+
logger(`Welcome back modal present with multiple saved accounts; refusing to select one without ${CHATGPT_ACCOUNT_EMAIL_ENV}.`);
|
|
241
|
+
return {
|
|
242
|
+
accepted: false,
|
|
243
|
+
hint: `Multiple saved ChatGPT accounts were found. Set ${CHATGPT_ACCOUNT_EMAIL_ENV} to the exact account email on the browser host.`,
|
|
244
|
+
};
|
|
245
245
|
}
|
|
246
246
|
if (result.reason && result.reason !== "not-found") {
|
|
247
247
|
logger(`Welcome back modal present but auto-select failed (${result.reason}).`);
|
|
248
|
-
return false;
|
|
248
|
+
return { accepted: false };
|
|
249
249
|
}
|
|
250
250
|
await delay(500);
|
|
251
251
|
}
|
|
252
252
|
logger("Welcome back modal not detected after login probe failure.");
|
|
253
|
-
return false;
|
|
253
|
+
return { accepted: false };
|
|
254
|
+
}
|
|
255
|
+
function buildWelcomeBackAccountPickerExpression(preferredEmail = null) {
|
|
256
|
+
const normalizedPreferredEmail = preferredEmail?.trim().toLowerCase() || null;
|
|
257
|
+
return `(() => {
|
|
258
|
+
// Learned: "Welcome back" can list several saved accounts; substring matching can select the wrong identity.
|
|
259
|
+
const preferredEmail = ${JSON.stringify(normalizedPreferredEmail)};
|
|
260
|
+
const getLabel = (node) =>
|
|
261
|
+
[node?.textContent, node?.getAttribute?.('aria-label')]
|
|
262
|
+
.filter((value) => typeof value === 'string' && value.trim())
|
|
263
|
+
.join(' ')
|
|
264
|
+
.trim();
|
|
265
|
+
const extractEmails = (label) =>
|
|
266
|
+
String(label || '')
|
|
267
|
+
.toLowerCase()
|
|
268
|
+
.match(/[a-z0-9.!#$%&'*+/=?^_{|}~-]+@[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?(?:\\.[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)*/g) || [];
|
|
269
|
+
const isAccount = (label) => !/log in|sign up|create account|another account/i.test(label);
|
|
270
|
+
const candidates = Array.from(document.querySelectorAll('[role="button"],button,a'));
|
|
271
|
+
const accounts = candidates
|
|
272
|
+
.map((node) => {
|
|
273
|
+
const label = getLabel(node);
|
|
274
|
+
return { node, emails: isAccount(label) ? extractEmails(label) : [] };
|
|
275
|
+
})
|
|
276
|
+
.filter((entry) => entry.emails.length > 0);
|
|
277
|
+
if (!accounts.length) {
|
|
278
|
+
return { clicked: false, reason: 'not-found' };
|
|
279
|
+
}
|
|
280
|
+
const savedEmails = Array.from(new Set(accounts.flatMap((entry) => entry.emails)));
|
|
281
|
+
if (!preferredEmail && savedEmails.length !== 1) {
|
|
282
|
+
return { clicked: false, reason: 'multiple-accounts', accountCount: savedEmails.length };
|
|
283
|
+
}
|
|
284
|
+
const selectedEmail = preferredEmail || savedEmails[0];
|
|
285
|
+
const account = accounts.find((entry) => entry.emails.includes(selectedEmail));
|
|
286
|
+
if (!account) {
|
|
287
|
+
return { clicked: false, reason: 'preferred-not-found', accountCount: savedEmails.length };
|
|
288
|
+
}
|
|
289
|
+
setTimeout(() => {
|
|
290
|
+
try {
|
|
291
|
+
account.node.click();
|
|
292
|
+
} catch {
|
|
293
|
+
// ignore; caller will re-probe login state
|
|
294
|
+
}
|
|
295
|
+
}, 0);
|
|
296
|
+
return {
|
|
297
|
+
clicked: true,
|
|
298
|
+
selection: preferredEmail ? 'preferred' : 'only-account',
|
|
299
|
+
accountCount: savedEmails.length,
|
|
300
|
+
};
|
|
301
|
+
})()`;
|
|
254
302
|
}
|
|
255
303
|
export async function ensurePromptReady(Runtime, timeoutMs, logger) {
|
|
256
304
|
const ready = await waitForPrompt(Runtime, timeoutMs);
|
|
@@ -269,6 +317,89 @@ export async function ensurePromptReady(Runtime, timeoutMs, logger) {
|
|
|
269
317
|
throw new Error("Prompt textarea did not appear before timeout");
|
|
270
318
|
}
|
|
271
319
|
}
|
|
320
|
+
function conversationIdFromUrl(value) {
|
|
321
|
+
if (!value)
|
|
322
|
+
return null;
|
|
323
|
+
try {
|
|
324
|
+
return new URL(value).pathname.match(/(?:^|\/)c\/([^/]+)/)?.[1] ?? null;
|
|
325
|
+
}
|
|
326
|
+
catch {
|
|
327
|
+
return null;
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
/**
|
|
331
|
+
* After navigating to a *resumed* ChatGPT conversation, its prior turns hydrate
|
|
332
|
+
* asynchronously and ChatGPT can reset the composer mid-hydration — wiping a
|
|
333
|
+
* freshly-typed prompt. A fresh chat has no history, so it never hits this race;
|
|
334
|
+
* a large resumed thread reliably does.
|
|
335
|
+
*
|
|
336
|
+
* Wait for the prior turns to render AND stop growing (a big thread keeps
|
|
337
|
+
* appending turns as it hydrates), let React settle, then re-confirm the
|
|
338
|
+
* composer is ready — before the caller types/submits. Shared by the local and
|
|
339
|
+
* remote browser execution paths so neither loses the submitted prompt.
|
|
340
|
+
*
|
|
341
|
+
* Returns the number of prior turns observed once hydration settled.
|
|
342
|
+
*/
|
|
343
|
+
export async function waitForResumedConversationHydration(Runtime, timeoutMs, logger, deps = {}) {
|
|
344
|
+
const ensureReady = deps.ensurePromptReady ?? ensurePromptReady;
|
|
345
|
+
const hydrationDeadline = Date.now() + Math.min(timeoutMs || 30_000, 30_000);
|
|
346
|
+
let priorTurns = 0;
|
|
347
|
+
let stableChecks = 0;
|
|
348
|
+
let settled = false;
|
|
349
|
+
while (Date.now() < hydrationDeadline) {
|
|
350
|
+
let turns = 0;
|
|
351
|
+
try {
|
|
352
|
+
const { result } = await Runtime.evaluate({
|
|
353
|
+
expression: `document.querySelectorAll(${JSON.stringify(CONVERSATION_TURN_SELECTOR)}).length`,
|
|
354
|
+
returnByValue: true,
|
|
355
|
+
});
|
|
356
|
+
turns = typeof result?.value === "number" ? result.value : 0;
|
|
357
|
+
}
|
|
358
|
+
catch {
|
|
359
|
+
// keep polling until the conversation hydrates
|
|
360
|
+
}
|
|
361
|
+
if (turns > 0 && turns === priorTurns) {
|
|
362
|
+
stableChecks += 1;
|
|
363
|
+
if (stableChecks >= 3) {
|
|
364
|
+
settled = true;
|
|
365
|
+
break;
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
else {
|
|
369
|
+
stableChecks = 0;
|
|
370
|
+
}
|
|
371
|
+
priorTurns = turns;
|
|
372
|
+
await delay(250);
|
|
373
|
+
}
|
|
374
|
+
await delay(1_000); // final settle so React won't wipe the composer after we type
|
|
375
|
+
await ensureReady(Runtime, timeoutMs, logger);
|
|
376
|
+
if ((deps.requirePriorTurns ?? false) && (!settled || priorTurns <= 0)) {
|
|
377
|
+
throw new BrowserAutomationError("Saved ChatGPT conversation did not load stable prior turns; refusing to submit follow-up as a fresh chat.", {
|
|
378
|
+
stage: "resume-conversation",
|
|
379
|
+
priorTurns,
|
|
380
|
+
settled,
|
|
381
|
+
});
|
|
382
|
+
}
|
|
383
|
+
if (deps.expectedConversationUrl) {
|
|
384
|
+
const { result } = await Runtime.evaluate({
|
|
385
|
+
expression: "location.href",
|
|
386
|
+
returnByValue: true,
|
|
387
|
+
});
|
|
388
|
+
const actualUrl = typeof result?.value === "string" ? result.value : undefined;
|
|
389
|
+
const expectedConversationId = conversationIdFromUrl(deps.expectedConversationUrl);
|
|
390
|
+
const actualConversationId = conversationIdFromUrl(actualUrl);
|
|
391
|
+
if (!expectedConversationId || actualConversationId !== expectedConversationId) {
|
|
392
|
+
throw new BrowserAutomationError("Saved ChatGPT conversation redirected to a different thread; refusing to submit follow-up.", {
|
|
393
|
+
stage: "resume-conversation",
|
|
394
|
+
expectedConversationId,
|
|
395
|
+
actualConversationId,
|
|
396
|
+
actualUrl,
|
|
397
|
+
});
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
logger(`[browser] Resumed conversation hydrated (${priorTurns} prior turns); composer settled.`);
|
|
401
|
+
return priorTurns;
|
|
402
|
+
}
|
|
272
403
|
async function waitForDocumentReady(Runtime, timeoutMs) {
|
|
273
404
|
const start = Date.now();
|
|
274
405
|
while (Date.now() - start < timeoutMs) {
|
|
@@ -360,8 +491,8 @@ async function isChatGptAccountSecurityBlock(Runtime) {
|
|
|
360
491
|
}
|
|
361
492
|
function buildLoginProbeExpression(timeoutMs) {
|
|
362
493
|
return `(async () => {
|
|
363
|
-
//
|
|
364
|
-
//
|
|
494
|
+
// /api/auth/session remains cookie-authenticated and exposes user presence without
|
|
495
|
+
// requiring the bearer token used by /backend-api/*. Never return or log its token.
|
|
365
496
|
const pageUrl = typeof location === 'object' && location?.href ? location.href : null;
|
|
366
497
|
const onAuthPage =
|
|
367
498
|
typeof location === 'object' &&
|
|
@@ -418,51 +549,203 @@ function buildLoginProbeExpression(timeoutMs) {
|
|
|
418
549
|
return false;
|
|
419
550
|
};
|
|
420
551
|
|
|
421
|
-
|
|
552
|
+
// Learned 2026-05-16: ChatGPT's /backend-api/* endpoints now sit behind Cloudflare bot
|
|
553
|
+
// mitigation. Programmatic fetch from the page can return 403 with cf-mitigated:challenge
|
|
554
|
+
// even when the user is logged in via cookies and the SPA renders normally. Detect that
|
|
555
|
+
// case via the response body shape (Cloudflare interstitial HTML) and fall back to a
|
|
556
|
+
// DOM-based logged-in signal instead of looping waitForLogin until the 20-min timeout.
|
|
557
|
+
const isCloudflareBody = (body) => {
|
|
558
|
+
if (typeof body !== 'string' || body.length === 0) return false;
|
|
559
|
+
const head = body.slice(0, 2000).toLowerCase();
|
|
560
|
+
return (
|
|
561
|
+
head.includes('cf-mitigated') ||
|
|
562
|
+
head.includes('cloudflare') ||
|
|
563
|
+
(head.includes('<style global>') && head.includes('scale-appear'))
|
|
564
|
+
);
|
|
565
|
+
};
|
|
566
|
+
const readSessionDetail = async () => {
|
|
422
567
|
try {
|
|
423
|
-
if (typeof fetch
|
|
424
|
-
|
|
425
|
-
|
|
568
|
+
if (typeof fetch !== 'function') {
|
|
569
|
+
return { status: 0, resolved: false, authenticated: false, cfBlocked: false, error: null };
|
|
570
|
+
}
|
|
571
|
+
const controller = new AbortController();
|
|
572
|
+
const timeout = setTimeout(() => controller.abort(), ${timeoutMs});
|
|
573
|
+
try {
|
|
574
|
+
const response = await fetch('/api/auth/session', {
|
|
575
|
+
cache: 'no-store',
|
|
576
|
+
credentials: 'include',
|
|
577
|
+
signal: controller.signal,
|
|
578
|
+
});
|
|
579
|
+
let cfBlocked = false;
|
|
580
|
+
if (response.status === 403 || response.status === 503 || response.status === 429) {
|
|
581
|
+
try {
|
|
582
|
+
const text = await response.clone().text();
|
|
583
|
+
cfBlocked = isCloudflareBody(text);
|
|
584
|
+
} catch {}
|
|
585
|
+
}
|
|
586
|
+
if (response.status !== 200) {
|
|
587
|
+
return {
|
|
588
|
+
status: response.status || 0,
|
|
589
|
+
resolved: false,
|
|
590
|
+
authenticated: false,
|
|
591
|
+
cfBlocked,
|
|
592
|
+
error: null,
|
|
593
|
+
};
|
|
594
|
+
}
|
|
426
595
|
try {
|
|
427
|
-
|
|
428
|
-
const
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
596
|
+
const body = await response.json();
|
|
597
|
+
const resolved =
|
|
598
|
+
Boolean(body) && typeof body === 'object' && !Array.isArray(body);
|
|
599
|
+
return {
|
|
600
|
+
status: response.status || 0,
|
|
601
|
+
resolved,
|
|
602
|
+
authenticated: resolved && Boolean(body.user),
|
|
603
|
+
cfBlocked,
|
|
604
|
+
error: null,
|
|
605
|
+
};
|
|
606
|
+
} catch {
|
|
607
|
+
return {
|
|
608
|
+
status: response.status || 0,
|
|
609
|
+
resolved: false,
|
|
610
|
+
authenticated: false,
|
|
611
|
+
cfBlocked,
|
|
612
|
+
error: null,
|
|
613
|
+
};
|
|
436
614
|
}
|
|
615
|
+
} finally {
|
|
616
|
+
clearTimeout(timeout);
|
|
437
617
|
}
|
|
438
618
|
} catch (err) {
|
|
439
|
-
return {
|
|
619
|
+
return {
|
|
620
|
+
status: 0,
|
|
621
|
+
resolved: false,
|
|
622
|
+
authenticated: false,
|
|
623
|
+
cfBlocked: false,
|
|
624
|
+
error: err ? String(err) : 'unknown',
|
|
625
|
+
};
|
|
440
626
|
}
|
|
441
|
-
|
|
627
|
+
};
|
|
628
|
+
const readBackendDetail = async () => {
|
|
629
|
+
try {
|
|
630
|
+
if (typeof fetch !== 'function') return { status: 0, cfBlocked: false, error: null };
|
|
631
|
+
const controller = new AbortController();
|
|
632
|
+
const timeout = setTimeout(() => controller.abort(), ${timeoutMs});
|
|
633
|
+
try {
|
|
634
|
+
const response = await fetch('/backend-api/me', {
|
|
635
|
+
cache: 'no-store',
|
|
636
|
+
credentials: 'include',
|
|
637
|
+
signal: controller.signal,
|
|
638
|
+
});
|
|
639
|
+
let cfBlocked = false;
|
|
640
|
+
if (response.status === 403 || response.status === 503 || response.status === 429) {
|
|
641
|
+
try {
|
|
642
|
+
const text = await response.clone().text();
|
|
643
|
+
cfBlocked = isCloudflareBody(text);
|
|
644
|
+
} catch {}
|
|
645
|
+
}
|
|
646
|
+
return { status: response.status || 0, cfBlocked, error: null };
|
|
647
|
+
} finally {
|
|
648
|
+
clearTimeout(timeout);
|
|
649
|
+
}
|
|
650
|
+
} catch (err) {
|
|
651
|
+
return { status: 0, cfBlocked: false, error: err ? String(err) : 'unknown' };
|
|
652
|
+
}
|
|
653
|
+
};
|
|
654
|
+
const readAuthDetail = async () => {
|
|
655
|
+
const session = await readSessionDetail();
|
|
656
|
+
const sessionDenied =
|
|
657
|
+
!session.cfBlocked && (session.status === 401 || session.status === 403);
|
|
658
|
+
if (session.resolved || sessionDenied) {
|
|
659
|
+
return { session, backend: null };
|
|
660
|
+
}
|
|
661
|
+
return { session, backend: await readBackendDetail() };
|
|
442
662
|
};
|
|
443
663
|
|
|
444
|
-
|
|
664
|
+
const hasAppAuthSignal = () => {
|
|
665
|
+
// Composer must be present and visible — the auth/login page never renders one.
|
|
666
|
+
if (typeof document.querySelector !== 'function') return false;
|
|
667
|
+
const composerSelectors = [
|
|
668
|
+
'#prompt-textarea',
|
|
669
|
+
'.ProseMirror',
|
|
670
|
+
'textarea[data-id="prompt-textarea"]',
|
|
671
|
+
'textarea[name="prompt-textarea"]',
|
|
672
|
+
'[contenteditable="true"][role="textbox"]',
|
|
673
|
+
];
|
|
674
|
+
const composer = composerSelectors.map((s) => document.querySelector(s)).find(Boolean);
|
|
675
|
+
if (!composer) return false;
|
|
676
|
+
const rect = composer.getBoundingClientRect && composer.getBoundingClientRect();
|
|
677
|
+
const style = window.getComputedStyle(composer);
|
|
678
|
+
if (!rect || rect.width <= 0 || rect.height <= 0 || style.display === 'none' || style.visibility === 'hidden') {
|
|
679
|
+
return false;
|
|
680
|
+
}
|
|
681
|
+
// Logged-in users should have an account affordance or prior chat history. Generic
|
|
682
|
+
// composer/model pills also appear in guest sessions, so they are not auth proof.
|
|
683
|
+
const profileButton = document.querySelector('[data-testid="accounts-profile-button"]');
|
|
684
|
+
const historyItem = document.querySelector('[data-testid^="history-item-"]');
|
|
685
|
+
return Boolean(profileButton || historyItem);
|
|
686
|
+
};
|
|
687
|
+
|
|
688
|
+
const classifyAuth = (auth, appSignal) => {
|
|
689
|
+
const sessionResolved = auth.session.status === 200 && auth.session.resolved;
|
|
690
|
+
const sessionDenied =
|
|
691
|
+
!auth.session.cfBlocked &&
|
|
692
|
+
(auth.session.status === 401 || auth.session.status === 403);
|
|
693
|
+
const sessionUnavailable = !sessionResolved && !sessionDenied;
|
|
694
|
+
const backendStatus = auth.backend ? auth.backend.status : null;
|
|
695
|
+
const backendUnavailable =
|
|
696
|
+
Boolean(auth.backend) &&
|
|
697
|
+
(auth.backend.cfBlocked ||
|
|
698
|
+
backendStatus === 0 ||
|
|
699
|
+
backendStatus === 401 ||
|
|
700
|
+
backendStatus === 403 ||
|
|
701
|
+
backendStatus === 429 ||
|
|
702
|
+
backendStatus === 503);
|
|
703
|
+
return {
|
|
704
|
+
authenticated:
|
|
705
|
+
auth.session.authenticated ||
|
|
706
|
+
(sessionUnavailable &&
|
|
707
|
+
(backendStatus === 200 || (backendUnavailable && appSignal))),
|
|
708
|
+
sessionResolved,
|
|
709
|
+
sessionUnavailable,
|
|
710
|
+
};
|
|
711
|
+
};
|
|
712
|
+
|
|
713
|
+
let auth = await readAuthDetail();
|
|
445
714
|
let domLoginCta = hasLoginCta();
|
|
715
|
+
let appAuthenticated = hasAppAuthSignal();
|
|
716
|
+
let classification = classifyAuth(auth, appAuthenticated);
|
|
446
717
|
const settleDeadline = Date.now() + Math.min(${timeoutMs}, 2500);
|
|
447
|
-
while (
|
|
718
|
+
while (
|
|
719
|
+
!domLoginCta &&
|
|
720
|
+
!classification.authenticated &&
|
|
721
|
+
classification.sessionUnavailable &&
|
|
722
|
+
Date.now() < settleDeadline
|
|
723
|
+
) {
|
|
448
724
|
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
449
725
|
domLoginCta = hasLoginCta();
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
error = next.error;
|
|
454
|
-
}
|
|
726
|
+
appAuthenticated = hasAppAuthSignal();
|
|
727
|
+
auth = await readAuthDetail();
|
|
728
|
+
classification = classifyAuth(auth, appAuthenticated);
|
|
455
729
|
}
|
|
456
730
|
|
|
457
731
|
const loginSignals = domLoginCta || onAuthPage;
|
|
732
|
+
const backendStatus = auth.backend ? auth.backend.status : null;
|
|
733
|
+
const cfBlocked = auth.session.cfBlocked || Boolean(auth.backend?.cfBlocked);
|
|
734
|
+
const error = auth.session.error || auth.backend?.error || null;
|
|
735
|
+
const ok = !loginSignals && classification.authenticated;
|
|
458
736
|
return {
|
|
459
|
-
ok
|
|
460
|
-
status,
|
|
737
|
+
ok,
|
|
738
|
+
status: auth.session.status,
|
|
739
|
+
backendStatus,
|
|
461
740
|
redirected: false,
|
|
462
741
|
url: pageUrl,
|
|
463
742
|
pageUrl,
|
|
464
743
|
domLoginCta,
|
|
465
744
|
onAuthPage,
|
|
745
|
+
appAuthenticated,
|
|
746
|
+
cfBlocked,
|
|
747
|
+
sessionAuthenticated: auth.session.authenticated,
|
|
748
|
+
sessionResolved: classification.sessionResolved,
|
|
466
749
|
error,
|
|
467
750
|
};
|
|
468
751
|
})()`;
|
|
@@ -487,8 +770,16 @@ function normalizeLoginProbe(raw) {
|
|
|
487
770
|
pageUrl: typeof value.pageUrl === "string" ? value.pageUrl : null,
|
|
488
771
|
domLoginCta: Boolean(value.domLoginCta),
|
|
489
772
|
onAuthPage: Boolean(value.onAuthPage),
|
|
773
|
+
appAuthenticated: Boolean(value.appAuthenticated),
|
|
774
|
+
backendStatus: typeof value.backendStatus === "number" ? value.backendStatus : null,
|
|
775
|
+
cfBlocked: Boolean(value.cfBlocked),
|
|
776
|
+
sessionAuthenticated: Boolean(value.sessionAuthenticated),
|
|
777
|
+
sessionResolved: Boolean(value.sessionResolved),
|
|
490
778
|
};
|
|
491
779
|
}
|
|
492
780
|
export function buildLoginProbeExpressionForTest(timeoutMs = LOGIN_CHECK_TIMEOUT_MS) {
|
|
493
781
|
return buildLoginProbeExpression(timeoutMs);
|
|
494
782
|
}
|
|
783
|
+
export function buildWelcomeBackAccountPickerExpressionForTest(preferredEmail = null) {
|
|
784
|
+
return buildWelcomeBackAccountPickerExpression(preferredEmail);
|
|
785
|
+
}
|