@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.
- package/README.md +54 -54
- package/dist/bin/oracle-cli.js +15 -6
- package/dist/bin/oracle-mcp.js +0 -0
- package/dist/src/browser/actions/modelSelection.js +100 -15
- package/dist/src/browser/actions/navigation.js +89 -27
- package/dist/src/browser/actions/promptComposer.js +195 -46
- package/dist/src/browser/config.js +2 -0
- package/dist/src/browser/index.js +6 -2
- package/dist/src/browser/providers/chatgptDomProvider.js +1 -0
- package/dist/src/cli/bridge/doctor.js +7 -2
- package/dist/src/cli/browserConfig.js +9 -1
- package/dist/src/cli/browserDefaults.js +3 -0
- package/dist/src/cli/engine.js +6 -2
- package/dist/src/cli/options.js +4 -0
- package/dist/src/cli/runOptions.js +9 -20
- package/dist/src/config.js +164 -9
- package/package.json +2 -2
|
@@ -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
|
-
|
|
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
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
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
|
-
|
|
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
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
316
|
-
pushText(parent);
|
|
442
|
+
push(parent);
|
|
317
443
|
const grandparent = parent?.parentElement;
|
|
318
|
-
|
|
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
|
|
359
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
373
|
-
//
|
|
374
|
-
const
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
const
|
|
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
|
-
|
|
381
|
-
|
|
382
|
-
|
|
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
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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: ${
|
|
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" ||
|
|
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
|
}
|
package/dist/src/cli/engine.js
CHANGED
|
@@ -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)
|
|
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) {
|
package/dist/src/cli/options.js
CHANGED
|
@@ -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
|
}
|