@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.
Files changed (97) hide show
  1. package/README.md +72 -61
  2. package/dist/bin/oracle-cli.js +77 -68
  3. package/dist/bin/oracle-mcp.js +0 -0
  4. package/dist/docs-site/.nojekyll +0 -0
  5. package/dist/docs-site/CNAME +1 -0
  6. package/dist/docs-site/RELEASING.html +410 -0
  7. package/dist/docs-site/agents.html +374 -0
  8. package/dist/docs-site/anthropic.html +368 -0
  9. package/dist/docs-site/bridge.html +400 -0
  10. package/dist/docs-site/browser-mode.html +593 -0
  11. package/dist/docs-site/chromium-forks.html +347 -0
  12. package/dist/docs-site/cli-reference.html +346 -0
  13. package/dist/docs-site/configuration.html +452 -0
  14. package/dist/docs-site/favicon.svg +14 -0
  15. package/dist/docs-site/followup.html +375 -0
  16. package/dist/docs-site/gemini.html +383 -0
  17. package/dist/docs-site/grok.html +325 -0
  18. package/dist/docs-site/index.html +360 -0
  19. package/dist/docs-site/install.html +335 -0
  20. package/dist/docs-site/linux.html +321 -0
  21. package/dist/docs-site/llms.txt +43 -0
  22. package/dist/docs-site/manual-tests.html +596 -0
  23. package/dist/docs-site/mcp.html +391 -0
  24. package/dist/docs-site/multimodel.html +364 -0
  25. package/dist/docs-site/mythical-pro-agents.html +360 -0
  26. package/dist/docs-site/notifier.html +338 -0
  27. package/dist/docs-site/openai-endpoints.html +387 -0
  28. package/dist/docs-site/openrouter.html +344 -0
  29. package/dist/docs-site/quickstart.html +369 -0
  30. package/dist/docs-site/refactor/ux.html +532 -0
  31. package/dist/docs-site/sessions.html +388 -0
  32. package/dist/docs-site/social-card.png +0 -0
  33. package/dist/docs-site/social-card.svg +79 -0
  34. package/dist/docs-site/spec.html +363 -0
  35. package/dist/docs-site/testing.html +320 -0
  36. package/dist/docs-site/tui-debug.html +326 -0
  37. package/dist/docs-site/windows-work.html +323 -0
  38. package/dist/docs-site/windows.html +320 -0
  39. package/dist/src/browser/actions/assistantResponse.js +91 -9
  40. package/dist/src/browser/actions/deepResearch.js +180 -52
  41. package/dist/src/browser/actions/modelSelection.js +111 -24
  42. package/dist/src/browser/actions/navigation.js +358 -67
  43. package/dist/src/browser/actions/promptComposer.js +241 -46
  44. package/dist/src/browser/actions/thinkingTime.js +308 -89
  45. package/dist/src/browser/artifacts.js +19 -0
  46. package/dist/src/browser/chatgptFiles.js +797 -0
  47. package/dist/src/browser/chatgptImages.js +130 -3
  48. package/dist/src/browser/chromeLifecycle.js +4 -0
  49. package/dist/src/browser/config.js +4 -0
  50. package/dist/src/browser/index.js +353 -41
  51. package/dist/src/browser/pageActions.js +1 -1
  52. package/dist/src/browser/policies.js +2 -6
  53. package/dist/src/browser/projectSourcesRunner.js +14 -2
  54. package/dist/src/browser/prompt.js +11 -15
  55. package/dist/src/browser/providers/chatgptDomProvider.js +1 -0
  56. package/dist/src/browser/reattach.js +6 -2
  57. package/dist/src/browser/reattachability.js +22 -10
  58. package/dist/src/browser/recoverConversation.js +73 -0
  59. package/dist/src/browser/sessionRunner.js +4 -1
  60. package/dist/src/cli/bridge/doctor.js +7 -2
  61. package/dist/src/cli/browserConfig.js +13 -2
  62. package/dist/src/cli/browserDefaults.js +3 -0
  63. package/dist/src/cli/browserTabs.js +129 -54
  64. package/dist/src/cli/engine.js +6 -2
  65. package/dist/src/cli/followup.js +72 -0
  66. package/dist/src/cli/help.js +1 -1
  67. package/dist/src/cli/options.js +20 -0
  68. package/dist/src/cli/reattachGuidance.js +8 -0
  69. package/dist/src/cli/runOptions.js +10 -31
  70. package/dist/src/cli/sessionCommand.js +4 -0
  71. package/dist/src/cli/sessionDisplay.js +17 -3
  72. package/dist/src/cli/sessionLineage.js +7 -4
  73. package/dist/src/cli/sessionRunner.js +16 -1
  74. package/dist/src/config.js +164 -9
  75. package/dist/src/gemini-web/client.js +5 -10
  76. package/dist/src/gemini-web/executor.js +1 -24
  77. package/dist/src/gemini-web/models.js +83 -0
  78. package/dist/src/mcp/server.js +2 -0
  79. package/dist/src/mcp/tools/chatgptImage.js +127 -0
  80. package/dist/src/mcp/tools/consult.js +261 -167
  81. package/dist/src/mcp/types.js +2 -0
  82. package/dist/src/mcp/utils.js +87 -1
  83. package/dist/src/oracle/config.js +27 -1
  84. package/dist/src/oracle/files.js +4 -6
  85. package/dist/src/oracle/geminiModels.js +2 -0
  86. package/dist/src/oracle/markdown.js +36 -3
  87. package/dist/src/oracle/modelResolver.js +21 -13
  88. package/dist/src/oracle/promptAssembly.js +2 -4
  89. package/dist/src/oracle/request.js +3 -5
  90. package/dist/src/oracle/tokenStats.js +5 -1
  91. package/dist/src/oracle.js +1 -1
  92. package/dist/src/sessionManager.js +1 -0
  93. package/dist/vendor/oracle-notifier/OracleNotifier.app/Contents/CodeResources +0 -0
  94. package/dist/vendor/oracle-notifier/OracleNotifier.app/Contents/MacOS/OracleNotifier +0 -0
  95. package/package.json +20 -29
  96. package/vendor/oracle-notifier/OracleNotifier.app/Contents/CodeResources +0 -0
  97. 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
- // A backend-api probe plus DOM login CTA check catches both cases.
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 (status=${probe.status}, domLoginCta=${Boolean(probe.domLoginCta)})`);
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 accepted = await attemptWelcomeBackLogin(Runtime, logger);
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 (status=${retryProbe.status}, domLoginCta=${Boolean(retryProbe.domLoginCta)})`);
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 (status=${probe.status}, domLoginCta=${Boolean(probe.domLoginCta)}, onAuthPage=${Boolean(probe.onAuthPage)}, url=${probe.pageUrl ?? "n/a"}, error=${probe.error ?? "none"})`);
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
- throw new Error(`ChatGPT session not detected.${domLabel} ${cookieHint}`);
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(`Welcome back modal detected; selected account ${result.label ?? "(unknown)"}`);
244
- return true;
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
- // Learned: /backend-api/me is the most reliable "am I logged in" signal.
364
- // Some UIs render without a session; use DOM + network for a robust answer.
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
- const readBackendStatus = async () => {
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 === 'function') {
424
- const controller = new AbortController();
425
- const timeout = setTimeout(() => controller.abort(), ${timeoutMs});
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
- // Credentials included so we see a 200 only when cookies are valid.
428
- const response = await fetch('/backend-api/me', {
429
- cache: 'no-store',
430
- credentials: 'include',
431
- signal: controller.signal,
432
- });
433
- return { status: response.status || 0, error: null };
434
- } finally {
435
- clearTimeout(timeout);
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 { status: 0, error: err ? String(err) : 'unknown' };
619
+ return {
620
+ status: 0,
621
+ resolved: false,
622
+ authenticated: false,
623
+ cfBlocked: false,
624
+ error: err ? String(err) : 'unknown',
625
+ };
440
626
  }
441
- return { status: 0, error: null };
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
- let { status, error } = await readBackendStatus();
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 (!domLoginCta && Date.now() < settleDeadline) {
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
- if (status === 0 || status === 401 || status === 403) {
451
- const next = await readBackendStatus();
452
- status = next.status;
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: !loginSignals && status === 200,
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
+ }