@steipete/oracle 0.12.1 → 0.13.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.
@@ -149,7 +149,7 @@ export async function ensureLoggedIn(Runtime, logger, options = {}) {
149
149
  });
150
150
  const probe = normalizeLoginProbe(outcome.result?.value);
151
151
  if (probe.ok) {
152
- logger(`Login check passed (status=${probe.status}, domLoginCta=${Boolean(probe.domLoginCta)})`);
152
+ logger(`Login check passed (status=${probe.status}, domLoginCta=${Boolean(probe.domLoginCta)}, appAuthenticated=${Boolean(probe.appAuthenticated)})`);
153
153
  return;
154
154
  }
155
155
  const accepted = await attemptWelcomeBackLogin(Runtime, logger);
@@ -167,9 +167,9 @@ export async function ensureLoggedIn(Runtime, logger, options = {}) {
167
167
  logger("Login restored via Welcome back account picker");
168
168
  return;
169
169
  }
170
- logger(`Login retry after Welcome back failed (status=${retryProbe.status}, domLoginCta=${Boolean(retryProbe.domLoginCta)})`);
170
+ logger(`Login retry after Welcome back failed (status=${retryProbe.status}, domLoginCta=${Boolean(retryProbe.domLoginCta)}, appAuthenticated=${Boolean(retryProbe.appAuthenticated)})`);
171
171
  }
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"})`);
172
+ logger(`Login probe failed (status=${probe.status}, 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
173
  const domLabel = probe.domLoginCta ? " Login button detected on page." : "";
174
174
  const cookieHint = options.remoteSession
175
175
  ? "The remote Chrome session is not signed into ChatGPT. Sign in there, then rerun."
@@ -418,51 +418,111 @@ function buildLoginProbeExpression(timeoutMs) {
418
418
  return false;
419
419
  };
420
420
 
421
- const readBackendStatus = async () => {
421
+ // Learned 2026-05-16: ChatGPT's /backend-api/* endpoints now sit behind Cloudflare bot
422
+ // mitigation. Programmatic fetch from the page can return 403 with cf-mitigated:challenge
423
+ // even when the user is logged in via cookies and the SPA renders normally. Detect that
424
+ // case via the response body shape (Cloudflare interstitial HTML) and fall back to a
425
+ // DOM-based logged-in signal instead of looping waitForLogin until the 20-min timeout.
426
+ const isCloudflareBody = (body) => {
427
+ if (typeof body !== 'string' || body.length === 0) return false;
428
+ const head = body.slice(0, 2000).toLowerCase();
429
+ return (
430
+ head.includes('cf-mitigated') ||
431
+ head.includes('cloudflare') ||
432
+ (head.includes('<style global>') && head.includes('scale-appear'))
433
+ );
434
+ };
435
+ const readBackendDetail = async () => {
422
436
  try {
423
- if (typeof fetch === 'function') {
424
- const controller = new AbortController();
425
- const timeout = setTimeout(() => controller.abort(), ${timeoutMs});
426
- 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);
437
+ if (typeof fetch !== 'function') return { status: 0, cfBlocked: false, error: null };
438
+ const controller = new AbortController();
439
+ const timeout = setTimeout(() => controller.abort(), ${timeoutMs});
440
+ try {
441
+ const response = await fetch('/backend-api/me', {
442
+ cache: 'no-store',
443
+ credentials: 'include',
444
+ signal: controller.signal,
445
+ });
446
+ let cfBlocked = false;
447
+ if (response.status === 403 || response.status === 503 || response.status === 429) {
448
+ try {
449
+ const text = await response.clone().text();
450
+ cfBlocked = isCloudflareBody(text);
451
+ } catch {}
436
452
  }
453
+ return { status: response.status || 0, cfBlocked, error: null };
454
+ } finally {
455
+ clearTimeout(timeout);
437
456
  }
438
457
  } catch (err) {
439
- return { status: 0, error: err ? String(err) : 'unknown' };
458
+ return { status: 0, cfBlocked: false, error: err ? String(err) : 'unknown' };
440
459
  }
441
- return { status: 0, error: null };
442
460
  };
443
461
 
444
- let { status, error } = await readBackendStatus();
462
+ const hasAppAuthSignal = () => {
463
+ // Composer must be present and visible — the auth/login page never renders one.
464
+ if (typeof document.querySelector !== 'function') return false;
465
+ const composerSelectors = [
466
+ '#prompt-textarea',
467
+ '.ProseMirror',
468
+ 'textarea[data-id="prompt-textarea"]',
469
+ 'textarea[name="prompt-textarea"]',
470
+ '[contenteditable="true"][role="textbox"]',
471
+ ];
472
+ const composer = composerSelectors.map((s) => document.querySelector(s)).find(Boolean);
473
+ if (!composer) return false;
474
+ const rect = composer.getBoundingClientRect && composer.getBoundingClientRect();
475
+ const style = window.getComputedStyle(composer);
476
+ if (!rect || rect.width <= 0 || rect.height <= 0 || style.display === 'none' || style.visibility === 'hidden') {
477
+ return false;
478
+ }
479
+ // Logged-in users should have an account affordance or prior chat history. Generic
480
+ // composer/model pills also appear in guest sessions, so they are not auth proof.
481
+ const profileButton = document.querySelector('[data-testid="accounts-profile-button"]');
482
+ const historyItem = document.querySelector('[data-testid^="history-item-"]');
483
+ return Boolean(profileButton || historyItem);
484
+ };
485
+
486
+ let backend = await readBackendDetail();
487
+ let status = backend.status;
488
+ let cfBlocked = backend.cfBlocked;
489
+ let error = backend.error;
445
490
  let domLoginCta = hasLoginCta();
491
+ let appAuthenticated = hasAppAuthSignal();
492
+ const isRetryableStatus = () =>
493
+ status === 0 || status === 401 || status === 403 || status === 503 || status === 429;
446
494
  const settleDeadline = Date.now() + Math.min(${timeoutMs}, 2500);
447
- while (!domLoginCta && Date.now() < settleDeadline) {
495
+ while (!domLoginCta && status !== 200 && isRetryableStatus() && Date.now() < settleDeadline) {
448
496
  await new Promise((resolve) => setTimeout(resolve, 100));
449
497
  domLoginCta = hasLoginCta();
450
- if (status === 0 || status === 401 || status === 403) {
451
- const next = await readBackendStatus();
452
- status = next.status;
453
- error = next.error;
454
- }
498
+ appAuthenticated = hasAppAuthSignal();
499
+ backend = await readBackendDetail();
500
+ status = backend.status;
501
+ cfBlocked = backend.cfBlocked;
502
+ error = backend.error;
455
503
  }
456
504
 
457
505
  const loginSignals = domLoginCta || onAuthPage;
506
+ // Accept the SPA-level signal only when the API path is blocked or unavailable
507
+ // (CF challenge, throttling, transient 5xx, network shaping) but the DOM shows
508
+ // an authenticated logged-in shell. Plain 401/403 remain authoritative because
509
+ // they can mean the ChatGPT session really expired.
510
+ const apiBlocked =
511
+ cfBlocked ||
512
+ status === 429 ||
513
+ status === 503 ||
514
+ status === 0;
515
+ const ok = !loginSignals && (status === 200 || (apiBlocked && appAuthenticated));
458
516
  return {
459
- ok: !loginSignals && status === 200,
517
+ ok,
460
518
  status,
461
519
  redirected: false,
462
520
  url: pageUrl,
463
521
  pageUrl,
464
522
  domLoginCta,
465
523
  onAuthPage,
524
+ appAuthenticated,
525
+ cfBlocked,
466
526
  error,
467
527
  };
468
528
  })()`;
@@ -487,6 +547,8 @@ function normalizeLoginProbe(raw) {
487
547
  pageUrl: typeof value.pageUrl === "string" ? value.pageUrl : null,
488
548
  domLoginCta: Boolean(value.domLoginCta),
489
549
  onAuthPage: Boolean(value.onAuthPage),
550
+ appAuthenticated: Boolean(value.appAuthenticated),
551
+ cfBlocked: Boolean(value.cfBlocked),
490
552
  };
491
553
  }
492
554
  export function buildLoginProbeExpressionForTest(timeoutMs = LOGIN_CHECK_TIMEOUT_MS) {
@@ -166,7 +166,7 @@ export async function submitPrompt(deps, prompt, logger) {
166
166
  observedLength,
167
167
  });
168
168
  }
169
- const clicked = await attemptSendButton(runtime, logger, deps?.attachmentNames);
169
+ const clicked = await attemptSendButton(runtime, logger, deps?.attachmentNames, deps?.attachmentTimeoutMs);
170
170
  if (!clicked) {
171
171
  await input.dispatchKeyEvent({
172
172
  type: "keyDown",
@@ -283,18 +283,136 @@ async function waitForDomReady(Runtime, logger, timeoutMs = 10_000) {
283
283
  logger?.(`Page did not reach ready/composer state within ${timeoutMs}ms; continuing cautiously.`);
284
284
  }
285
285
  function buildAttachmentReadyExpression(attachmentNames) {
286
- const namesLiteral = JSON.stringify(attachmentNames.map((name) => name.toLowerCase()));
286
+ const attachmentExpectations = attachmentNames.map((name) => {
287
+ const normalized = name.toLowerCase().replace(/\s+/g, " ").trim();
288
+ return {
289
+ name: normalized,
290
+ stem: normalized.replace(/\.[a-z0-9]{1,10}$/i, ""),
291
+ extension: normalized.match(/(\.[a-z0-9]{1,10})$/i)?.[1] ?? "",
292
+ };
293
+ });
294
+ const namesLiteral = JSON.stringify(attachmentExpectations);
287
295
  return `(() => {
288
- const names = ${namesLiteral};
296
+ const expected = ${namesLiteral};
297
+ const sendSelectors = ${JSON.stringify(SEND_BUTTON_SELECTORS)};
298
+ const normalize = (value) => String(value || '').toLowerCase().replace(/\\s+/g, ' ').trim();
299
+ const hasNameBoundary = (text, name) => {
300
+ if (!name) return false;
301
+ let from = 0;
302
+ while (from < text.length) {
303
+ const index = text.indexOf(name, from);
304
+ if (index === -1) return false;
305
+ const previous = text[index - 1] || '';
306
+ const next = text[index + name.length] || '';
307
+ const previousOk = !previous || !/[a-z0-9]/.test(previous);
308
+ const nextOk = !next || !/[a-z0-9]/.test(next);
309
+ if (previousOk && nextOk) return true;
310
+ from = index + name.length;
311
+ }
312
+ return false;
313
+ };
314
+ const hasExtensionBoundary = (text, extension) => {
315
+ if (!extension) return false;
316
+ let from = 0;
317
+ while (from < text.length) {
318
+ const index = text.indexOf(extension, from);
319
+ if (index === -1) return false;
320
+ const next = text[index + extension.length] || '';
321
+ if (!next || !/[a-z0-9]/.test(next)) return true;
322
+ from = index + extension.length;
323
+ }
324
+ return false;
325
+ };
326
+ const matchesExpected = (value, item) => {
327
+ const text = normalize(value);
328
+ if (!text) return false;
329
+ if (hasNameBoundary(text, item.name)) return true;
330
+ if (
331
+ item.stem &&
332
+ item.stem.length >= 4 &&
333
+ item.extension &&
334
+ text.includes(item.stem + '(') &&
335
+ hasExtensionBoundary(text, item.extension)
336
+ ) {
337
+ return true;
338
+ }
339
+ if (text.includes('…') || text.includes('...')) {
340
+ const marker = text.includes('…') ? '…' : '...';
341
+ const [prefixRaw, suffixRaw] = text.split(marker);
342
+ const prefix = normalize(prefixRaw);
343
+ const suffix = normalize(suffixRaw);
344
+ const prefixParts = prefix.split(' ').filter(Boolean);
345
+ const suffixParts = suffix.split(' ').filter(Boolean);
346
+ const prefixCandidates = prefixParts.map((_, index) => prefixParts.slice(index).join(' '));
347
+ const suffixCandidates = suffixParts.map((_, index) =>
348
+ suffixParts.slice(0, suffixParts.length - index).join(' '),
349
+ );
350
+ if (prefixCandidates.length === 0 || suffixCandidates.length === 0) return false;
351
+ const targets = [item.name, item.stem && item.stem.length >= 4 ? item.stem : ''].filter(Boolean);
352
+ return targets.some((target) => {
353
+ return prefixCandidates.some((prefixPart) =>
354
+ suffixCandidates.some((suffixPart) => {
355
+ const strongEnough =
356
+ suffixPart.length >= 2 &&
357
+ (prefixPart.length >= 3 || (prefixPart.length >= 2 && suffixPart.length >= 4));
358
+ return strongEnough && target.startsWith(prefixPart) && target.endsWith(suffixPart);
359
+ }),
360
+ );
361
+ });
362
+ }
363
+ return false;
364
+ };
365
+ // Restrict to attachment affordances; never scan generic div/span nodes (prompt text can contain the file name).
366
+ const attachmentSelectors = [
367
+ '[data-testid*="chip"]',
368
+ '[data-testid*="attachment"]',
369
+ '[data-testid*="upload"]',
370
+ '[data-testid*="file"]',
371
+ '[aria-label*="Remove file"]',
372
+ 'button[aria-label*="Remove file"]',
373
+ '[aria-label*="remove file"]',
374
+ 'button[aria-label*="remove file"]',
375
+ '[aria-label*="Remove attachment"]',
376
+ 'button[aria-label*="Remove attachment"]',
377
+ '[aria-label*="remove attachment"]',
378
+ 'button[aria-label*="remove attachment"]',
379
+ ];
380
+ const sendButton = sendSelectors
381
+ .map((selector) => document.querySelector(selector))
382
+ .find(Boolean);
383
+ const isUsableComposerRoot = (node) => {
384
+ if (!(node instanceof HTMLElement)) return false;
385
+ if (String(node.tagName || '').toLowerCase() === 'button') return false;
386
+ const testId = String(node.getAttribute?.('data-testid') || '').toLowerCase();
387
+ if (!testId.includes('composer')) return false;
388
+ return !(
389
+ testId.includes('footer') ||
390
+ testId.includes('action') ||
391
+ testId.includes('plus') ||
392
+ testId.includes('send')
393
+ );
394
+ };
395
+ const closestComposerRoot = (node) => {
396
+ let current = node instanceof HTMLElement ? node : null;
397
+ while (current) {
398
+ if (isUsableComposerRoot(current)) return current;
399
+ current = current.parentElement;
400
+ }
401
+ return null;
402
+ };
403
+ const firstComposerRoot = () =>
404
+ Array.from(document.querySelectorAll('[data-testid*="composer"]')).find(isUsableComposerRoot) || null;
289
405
  const composer =
290
- document.querySelector('[data-testid*="composer"]') ||
406
+ closestComposerRoot(sendButton) ||
407
+ sendButton?.closest?.('form') ||
408
+ firstComposerRoot() ||
291
409
  document.querySelector('form') ||
292
410
  document.body ||
293
411
  document;
294
412
  // Walk node + ancestors (up to grandparent) + descendants to gather every textual hint.
295
413
  // ChatGPT's current chip DOM nests the filename inside truncated child spans, so checking
296
414
  // only the node's own textContent/aria/title misses the match.
297
- const collectLabelHaystack = (node) => {
415
+ const collectOwnLabelHaystack = (node) => {
298
416
  if (!node) return '';
299
417
  const pieces = [];
300
418
  const pushAttrs = (el) => {
@@ -311,31 +429,21 @@ function buildAttachmentReadyExpression(attachmentNames) {
311
429
  };
312
430
  pushAttrs(node);
313
431
  pushText(node);
432
+ return pieces.join(' ').toLowerCase();
433
+ };
434
+ const collectLabelHaystack = (node) => {
435
+ if (!node) return '';
436
+ const pieces = [collectOwnLabelHaystack(node)];
437
+ const push = (el) => {
438
+ const text = collectOwnLabelHaystack(el);
439
+ if (text) pieces.push(text);
440
+ };
314
441
  const parent = node.parentElement;
315
- pushAttrs(parent);
316
- pushText(parent);
442
+ push(parent);
317
443
  const grandparent = parent?.parentElement;
318
- pushAttrs(grandparent);
319
- pushText(grandparent);
444
+ push(grandparent);
320
445
  return pieces.join(' ').toLowerCase();
321
446
  };
322
- const match = (node, name) => collectLabelHaystack(node).includes(name);
323
-
324
- // Restrict to attachment affordances; never scan generic div/span nodes (prompt text can contain the file name).
325
- const attachmentSelectors = [
326
- '[data-testid*="chip"]',
327
- '[data-testid*="attachment"]',
328
- '[data-testid*="upload"]',
329
- '[data-testid*="file"]',
330
- '[aria-label*="Remove file"]',
331
- 'button[aria-label*="Remove file"]',
332
- '[aria-label*="remove file"]',
333
- 'button[aria-label*="remove file"]',
334
- '[aria-label*="Remove attachment"]',
335
- 'button[aria-label*="Remove attachment"]',
336
- '[aria-label*="remove attachment"]',
337
- 'button[aria-label*="remove attachment"]',
338
- ];
339
447
  const attachmentRoots = Array.from(new Set([composer])).filter(Boolean);
340
448
  const collectChipNodes = () => {
341
449
  const seen = new Set();
@@ -354,32 +462,66 @@ function buildAttachmentReadyExpression(attachmentNames) {
354
462
  return collected;
355
463
  };
356
464
  const chipNodes = collectChipNodes();
357
-
358
- const chipsReady = names.every((name) =>
359
- chipNodes.some((node) => match(node, name)),
465
+ const chipLabels = chipNodes.map((node) => collectLabelHaystack(node));
466
+ const chipOwnLabels = chipNodes.map((node) => collectOwnLabelHaystack(node));
467
+ const hasEllipsisSuffix = (label) => {
468
+ const marker = label.includes('…') ? '…' : label.includes('...') ? '...' : '';
469
+ if (!marker) return false;
470
+ return normalize(label.split(marker)[1] || '').length > 0;
471
+ };
472
+ const chipOwnLabelsWithVisibleNames = chipOwnLabels.filter((label) =>
473
+ /\\.[a-z][a-z0-9]{0,9}(?:\\b|$)/i.test(label) ||
474
+ hasEllipsisSuffix(label),
475
+ );
476
+ const visibleExtensionLabelsMatchExpected = chipOwnLabelsWithVisibleNames.every((label) =>
477
+ expected.some((item) => matchesExpected(label, item)),
360
478
  );
361
- const inputsReady = names.every((name) =>
479
+
480
+ const chipsReady = (() => {
481
+ const used = new Set();
482
+ return expected.every((item) => {
483
+ const index = chipLabels.findIndex((label, candidateIndex) =>
484
+ !used.has(candidateIndex) && matchesExpected(label, item),
485
+ );
486
+ if (index === -1) return false;
487
+ used.add(index);
488
+ return true;
489
+ });
490
+ })();
491
+ const inputsReady = expected.every((item) =>
362
492
  attachmentRoots.some((root) =>
363
493
  Array.from(root.querySelectorAll('input[type="file"]')).some((el) =>
364
494
  Array.from((el instanceof HTMLInputElement ? el.files : []) || []).some((file) =>
365
- file?.name?.toLowerCase?.().includes(name),
495
+ matchesExpected(file?.name, item),
366
496
  ),
367
497
  ),
368
498
  ),
369
499
  );
370
500
  // Count-based fallback: if we cannot match names individually (ChatGPT may strip
371
501
  // the filename out of attribute-readable text into a deeply nested span), but we
372
- // do see at least as many distinct chip-shaped nodes as attachments we uploaded,
373
- // and a sibling "Remove" affordance exists per chip, trust the upload.
374
- const removeAffordanceCount = chipNodes.filter((node) => {
375
- const aria = (node.getAttribute?.('aria-label') ?? '').toLowerCase();
376
- if (aria.includes('remove')) return true;
377
- const removeSibling = node.querySelector?.(
502
+ // do see at least as many distinct "Remove" affordances as attachments we
503
+ // uploaded, trust the upload without double-counting nested chip/remove nodes.
504
+ const removeAffordances = [];
505
+ const removeSeen = new Set();
506
+ for (const root of attachmentRoots) {
507
+ for (const node of Array.from(root.querySelectorAll(
378
508
  '[aria-label*="Remove" i], [aria-label*="remove" i], button[aria-label*="Remove" i], button[aria-label*="remove" i]',
379
- );
380
- return Boolean(removeSibling);
381
- }).length;
382
- const countReady = chipNodes.length >= names.length && removeAffordanceCount >= names.length;
509
+ ))) {
510
+ if (!(node instanceof HTMLElement)) continue;
511
+ if (node.closest('textarea,[contenteditable="true"]')) continue;
512
+ const aria = (node.getAttribute?.('aria-label') ?? '').toLowerCase();
513
+ const fileSpecific = aria.includes('remove file') || aria.includes('remove attachment');
514
+ const attachmentOwner = node.closest(
515
+ '[data-testid*="chip"], [data-testid*="attachment"], [data-testid*="upload"], [data-testid*="file"]',
516
+ );
517
+ if (!fileSpecific && !attachmentOwner) continue;
518
+ if (removeSeen.has(node)) continue;
519
+ removeSeen.add(node);
520
+ removeAffordances.push(node);
521
+ }
522
+ }
523
+ const countReady =
524
+ visibleExtensionLabelsMatchExpected && removeAffordances.length >= expected.length;
383
525
 
384
526
  return chipsReady || inputsReady || countReady;
385
527
  })()`;
@@ -387,7 +529,7 @@ function buildAttachmentReadyExpression(attachmentNames) {
387
529
  export function buildAttachmentReadyExpressionForTest(attachmentNames) {
388
530
  return buildAttachmentReadyExpression(attachmentNames);
389
531
  }
390
- async function attemptSendButton(Runtime, _logger, attachmentNames) {
532
+ async function attemptSendButton(Runtime, _logger, attachmentNames, attachmentTimeoutMs) {
391
533
  const needAttachment = Array.isArray(attachmentNames) && attachmentNames.length > 0;
392
534
  const script = `(() => {
393
535
  ${buildClickDispatcher()}
@@ -424,7 +566,8 @@ async function attemptSendButton(Runtime, _logger, attachmentNames) {
424
566
  // Give attachment-bearing submissions more headroom. ChatGPT's chip render can
425
567
  // settle slowly for multi-file uploads, but plain text sends should keep the
426
568
  // shorter historical deadline.
427
- const deadline = Date.now() + sendButtonTimeoutMs(attachmentNames);
569
+ const timeoutMs = sendButtonTimeoutMs(attachmentNames, attachmentTimeoutMs);
570
+ const deadline = Date.now() + timeoutMs;
428
571
  while (Date.now() < deadline) {
429
572
  if (needAttachment) {
430
573
  const ready = await Runtime.evaluate({
@@ -446,16 +589,22 @@ async function attemptSendButton(Runtime, _logger, attachmentNames) {
446
589
  await delay(100);
447
590
  }
448
591
  if (Array.isArray(attachmentNames) && attachmentNames.length > 0) {
449
- throw new BrowserAutomationError("Attachments never reached a clickable send button before timeout.", {
592
+ throw new BrowserAutomationError(`Attachments never reached a clickable send button after ${Math.ceil(timeoutMs / 1000)}s; tune --browser-attachment-timeout.`, {
450
593
  stage: "submit-prompt",
451
594
  code: "attachment-send-not-ready",
452
595
  attachmentNames,
596
+ timeoutMs,
453
597
  });
454
598
  }
455
599
  return false;
456
600
  }
457
- function sendButtonTimeoutMs(attachmentNames) {
458
- return Array.isArray(attachmentNames) && attachmentNames.length > 0 ? 45_000 : 20_000;
601
+ function sendButtonTimeoutMs(attachmentNames, attachmentTimeoutMs) {
602
+ if (!Array.isArray(attachmentNames) || attachmentNames.length === 0) {
603
+ return 20_000;
604
+ }
605
+ return typeof attachmentTimeoutMs === "number" && Number.isFinite(attachmentTimeoutMs)
606
+ ? Math.max(1_000, attachmentTimeoutMs)
607
+ : 45_000;
459
608
  }
460
609
  async function verifyPromptCommitted(Runtime, prompt, timeoutMs, logger, baselineTurns) {
461
610
  const deadline = Date.now() + timeoutMs;
@@ -26,6 +26,7 @@ export const DEFAULT_BROWSER_CONFIG = {
26
26
  timeoutMs: 1_200_000,
27
27
  debugPort: null,
28
28
  inputTimeoutMs: 60_000,
29
+ attachmentTimeoutMs: 45_000,
29
30
  assistantRecheckDelayMs: 0,
30
31
  assistantRecheckTimeoutMs: 120_000,
31
32
  reuseChromeWaitMs: 10_000,
@@ -80,6 +81,7 @@ export function resolveBrowserConfig(config) {
80
81
  timeoutMs: config?.timeoutMs ?? defaultTimeoutMs,
81
82
  debugPort: config?.debugPort ?? debugPortEnv ?? DEFAULT_BROWSER_CONFIG.debugPort,
82
83
  inputTimeoutMs: config?.inputTimeoutMs ?? DEFAULT_BROWSER_CONFIG.inputTimeoutMs,
84
+ attachmentTimeoutMs: config?.attachmentTimeoutMs ?? DEFAULT_BROWSER_CONFIG.attachmentTimeoutMs,
83
85
  assistantRecheckDelayMs: config?.assistantRecheckDelayMs ?? DEFAULT_BROWSER_CONFIG.assistantRecheckDelayMs,
84
86
  assistantRecheckTimeoutMs: config?.assistantRecheckTimeoutMs ?? DEFAULT_BROWSER_CONFIG.assistantRecheckTimeoutMs,
85
87
  reuseChromeWaitMs: config?.reuseChromeWaitMs ?? DEFAULT_BROWSER_CONFIG.reuseChromeWaitMs,
@@ -800,7 +800,8 @@ export async function runBrowserMode(options) {
800
800
  const baseTimeout = config.inputTimeoutMs ?? 30_000;
801
801
  const perFileTimeout = 20_000;
802
802
  const waitBudget = Math.max(baseTimeout, 45_000) + (submissionAttachments.length - 1) * perFileTimeout;
803
- await waitForAttachmentCompletion(Runtime, waitBudget, attachmentNames, logger);
803
+ const attachmentWaitBudget = Math.max(config.attachmentTimeoutMs ?? 0, waitBudget);
804
+ await waitForAttachmentCompletion(Runtime, attachmentWaitBudget, attachmentNames, logger);
804
805
  logger("All attachments uploaded");
805
806
  }
806
807
  let baselineTurns = await readConversationTurnCount(Runtime, logger);
@@ -811,6 +812,7 @@ export async function runBrowserMode(options) {
811
812
  logger,
812
813
  timeoutMs: config.timeoutMs,
813
814
  inputTimeoutMs: config.inputTimeoutMs ?? undefined,
815
+ attachmentTimeoutMs: config.attachmentTimeoutMs ?? undefined,
814
816
  baselineTurns: baselineTurns ?? undefined,
815
817
  attachmentNames,
816
818
  onPromptSubmitted: markPromptSubmitted,
@@ -1920,7 +1922,8 @@ async function runRemoteBrowserMode(promptText, attachments, config, logger, opt
1920
1922
  const baseTimeout = config.inputTimeoutMs ?? 30_000;
1921
1923
  const perFileTimeout = 15_000;
1922
1924
  const waitBudget = Math.max(baseTimeout, 30_000) + (submissionAttachments.length - 1) * perFileTimeout;
1923
- await waitForAttachmentCompletion(Runtime, waitBudget, attachmentNames, logger);
1925
+ const attachmentWaitBudget = Math.max(config.attachmentTimeoutMs ?? 0, waitBudget);
1926
+ await waitForAttachmentCompletion(Runtime, attachmentWaitBudget, attachmentNames, logger);
1924
1927
  logger("All attachments uploaded");
1925
1928
  }
1926
1929
  let baselineTurns = await readConversationTurnCount(Runtime, logger);
@@ -1930,6 +1933,7 @@ async function runRemoteBrowserMode(promptText, attachments, config, logger, opt
1930
1933
  logger,
1931
1934
  timeoutMs: config.timeoutMs,
1932
1935
  inputTimeoutMs: config.inputTimeoutMs ?? undefined,
1936
+ attachmentTimeoutMs: config.attachmentTimeoutMs ?? undefined,
1933
1937
  baselineTurns: baselineTurns ?? undefined,
1934
1938
  attachmentNames,
1935
1939
  onPromptSubmitted: markPromptSubmitted,
@@ -23,6 +23,7 @@ async function submitPromptViaAdapter(ctx) {
23
23
  attachmentNames: state.attachmentNames ?? [],
24
24
  baselineTurns: state.baselineTurns ?? undefined,
25
25
  inputTimeoutMs: state.inputTimeoutMs ?? undefined,
26
+ attachmentTimeoutMs: state.attachmentTimeoutMs ?? undefined,
26
27
  onPromptSubmitted: state.onPromptSubmitted,
27
28
  }, ctx.prompt, state.logger);
28
29
  state.committedTurns =
@@ -7,8 +7,9 @@ import { checkTcpConnection, checkRemoteHealth } from "../../remote/health.js";
7
7
  import { detectChromeBinary, detectChromeCookieDb } from "../../browser/detect.js";
8
8
  import { formatCodexMcpSnippet } from "./codexConfig.js";
9
9
  export async function runBridgeDoctor(_options) {
10
- const { config: userConfig, path: configPath, loaded } = await loadUserConfig();
10
+ const { config: userConfig, path: configPath, paths: configPaths, loaded: userConfigLoaded, } = await loadUserConfig();
11
11
  const version = getCliVersion();
12
+ const projectConfigPaths = configPaths.filter((entry) => entry !== configPath);
12
13
  const resolvedRemote = resolveRemoteServiceConfig({
13
14
  cliHost: undefined,
14
15
  cliToken: undefined,
@@ -22,7 +23,11 @@ export async function runBridgeDoctor(_options) {
22
23
  lines.push(chalk.dim(`OS: ${process.platform} ${os.release()} (${process.arch})`));
23
24
  lines.push(chalk.dim(`Node: ${process.version}`));
24
25
  lines.push(chalk.dim(`Oracle: ${version}`));
25
- lines.push(chalk.dim(`Config: ${loaded ? configPath : "(missing)"}`));
26
+ lines.push(chalk.dim(`Config: ${userConfigLoaded ? configPath : `${configPath} (missing)`}`));
27
+ if (projectConfigPaths.length > 0) {
28
+ const label = projectConfigPaths.length === 1 ? "Project config" : "Project configs";
29
+ lines.push(chalk.dim(`${label}: ${projectConfigPaths.join(", ")}`));
30
+ }
26
31
  if (userConfig.engine) {
27
32
  lines.push(chalk.dim(`Default engine: ${userConfig.engine}`));
28
33
  }
@@ -7,6 +7,7 @@ import { normalizeBrowserModelStrategy } from "../browser/modelStrategy.js";
7
7
  import { getOracleHomeDir } from "../oracleHome.js";
8
8
  const DEFAULT_BROWSER_TIMEOUT_MS = 1_200_000;
9
9
  const DEFAULT_BROWSER_INPUT_TIMEOUT_MS = 60_000;
10
+ const DEFAULT_BROWSER_ATTACHMENT_TIMEOUT_MS = 45_000;
10
11
  const DEFAULT_BROWSER_RECHECK_TIMEOUT_MS = 120_000;
11
12
  const DEFAULT_BROWSER_AUTO_REATTACH_TIMEOUT_MS = 120_000;
12
13
  const DEFAULT_CHROME_PROFILE = "Default";
@@ -15,6 +16,7 @@ const DEFAULT_CHROME_PROFILE = "Default";
15
16
  const BROWSER_MODEL_LABELS = [
16
17
  // Most specific first (e.g., "gpt-5.2-thinking" before "gpt-5.2")
17
18
  ["gpt-5.5-pro", "Pro"],
19
+ ["gpt-5.5-instant", "GPT-5.5 Instant"],
18
20
  ["gpt-5.5", "Thinking 5.5"],
19
21
  ["gpt-5.4-pro", "Pro"],
20
22
  ["gpt-5.2-thinking", "GPT-5.2 Thinking"],
@@ -34,7 +36,10 @@ export function normalizeChatGptModelForBrowser(model) {
34
36
  if (!normalized.startsWith("gpt-") || normalized.includes("codex")) {
35
37
  return model;
36
38
  }
37
- if (normalized === "gpt-5.5-pro" || normalized === "gpt-5.5" || normalized === "gpt-5.4") {
39
+ if (normalized === "gpt-5.5-pro" ||
40
+ normalized === "gpt-5.5-instant" ||
41
+ normalized === "gpt-5.5" ||
42
+ normalized === "gpt-5.4") {
38
43
  return normalized;
39
44
  }
40
45
  // Pro variants: resolve to the latest Pro model in ChatGPT.
@@ -101,6 +106,9 @@ export async function buildBrowserConfig(options) {
101
106
  inputTimeoutMs: options.browserInputTimeout
102
107
  ? parseDuration(options.browserInputTimeout, DEFAULT_BROWSER_INPUT_TIMEOUT_MS)
103
108
  : undefined,
109
+ attachmentTimeoutMs: options.browserAttachmentTimeout
110
+ ? parseDuration(options.browserAttachmentTimeout, DEFAULT_BROWSER_ATTACHMENT_TIMEOUT_MS)
111
+ : undefined,
104
112
  assistantRecheckDelayMs: options.browserRecheckDelay
105
113
  ? parseDuration(options.browserRecheckDelay, 0)
106
114
  : undefined,
@@ -43,6 +43,9 @@ export function applyBrowserDefaultsFromConfig(options, config, getSource) {
43
43
  if (isUnset("browserInputTimeout") && typeof browser.inputTimeoutMs === "number") {
44
44
  options.browserInputTimeout = String(browser.inputTimeoutMs);
45
45
  }
46
+ if (isUnset("browserAttachmentTimeout") && typeof browser.attachmentTimeoutMs === "number") {
47
+ options.browserAttachmentTimeout = String(browser.attachmentTimeoutMs);
48
+ }
46
49
  if (isUnset("browserRecheckDelay") && typeof browser.assistantRecheckDelayMs === "number") {
47
50
  options.browserRecheckDelay = String(browser.assistantRecheckDelayMs);
48
51
  }
@@ -14,9 +14,10 @@ export function defaultWaitPreference(model, engine) {
14
14
  * 2) Explicit --engine value.
15
15
  * 3) Explicit API provider routing flags force API.
16
16
  * 4) ORACLE_ENGINE environment override (api|browser).
17
- * 5) API environment decides: api when set, otherwise browser.
17
+ * 5) Config engine value.
18
+ * 6) API environment decides: api when set, otherwise browser.
18
19
  */
19
- export function resolveEngine({ engine, browserFlag, apiProviderRequested, env, }) {
20
+ export function resolveEngine({ engine, configEngine, browserFlag, apiProviderRequested, env, }) {
20
21
  if (browserFlag) {
21
22
  return "browser";
22
23
  }
@@ -30,6 +31,9 @@ export function resolveEngine({ engine, browserFlag, apiProviderRequested, env,
30
31
  if (envEngine) {
31
32
  return envEngine;
32
33
  }
34
+ if (configEngine) {
35
+ return configEngine;
36
+ }
33
37
  return hasApiEnvironment(env) ? "api" : "browser";
34
38
  }
35
39
  function hasApiEnvironment(env) {
@@ -280,6 +280,10 @@ export function inferModelFromLabel(modelValue) {
280
280
  if ((normalized.includes("5.5") || normalized.includes("5_5")) && normalized.includes("pro")) {
281
281
  return "gpt-5.5-pro";
282
282
  }
283
+ if ((normalized.includes("5.5") || normalized.includes("5_5")) &&
284
+ (normalized.includes("instant") || normalized.includes("fast"))) {
285
+ return "gpt-5.5-instant";
286
+ }
283
287
  if (normalized.includes("5.5") || normalized.includes("5_5")) {
284
288
  return "gpt-5.5";
285
289
  }