@steipete/oracle 0.8.5 → 0.8.6
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 +23 -1
- package/dist/bin/oracle-cli.js +189 -7
- package/dist/src/browser/actions/assistantResponse.js +72 -37
- package/dist/src/browser/actions/promptComposer.js +141 -32
- package/dist/src/browser/chromeLifecycle.js +25 -9
- package/dist/src/browser/config.js +14 -0
- package/dist/src/browser/index.js +341 -24
- package/dist/src/browser/profileState.js +93 -0
- package/dist/src/cli/browserConfig.js +21 -0
- package/dist/src/cli/browserDefaults.js +21 -0
- package/dist/src/cli/sessionRunner.js +149 -0
- package/dist/src/cli/tui/index.js +1 -0
- package/dist/src/mcp/tools/consult.js +1 -0
- package/dist/src/oracle/modelResolver.js +33 -1
- package/dist/src/sessionManager.js +9 -2
- package/dist/src/sessionStore.js +2 -2
- package/dist/vendor/oracle-notifier/OracleNotifier.app/Contents/CodeResources +0 -0
- package/dist/vendor/oracle-notifier/OracleNotifier.app/Contents/MacOS/OracleNotifier +0 -0
- package/package.json +19 -19
- package/vendor/oracle-notifier/OracleNotifier.app/Contents/CodeResources +0 -0
- package/vendor/oracle-notifier/OracleNotifier.app/Contents/MacOS/OracleNotifier +0 -0
|
@@ -6,6 +6,7 @@ import { resolveBrowserConfig } from './config.js';
|
|
|
6
6
|
import { launchChrome, registerTerminationHooks, hideChromeWindow, connectToRemoteChrome, closeRemoteChromeTarget, connectWithNewTab, closeTab, } from './chromeLifecycle.js';
|
|
7
7
|
import { syncCookies } from './cookies.js';
|
|
8
8
|
import { navigateToChatGPT, navigateToPromptReadyWithFallback, ensureNotBlocked, ensureLoggedIn, ensurePromptReady, installJavaScriptDialogAutoDismissal, ensureModelSelection, submitPrompt, clearPromptComposer, waitForAssistantResponse, captureAssistantMarkdown, clearComposerAttachments, uploadAttachmentFile, waitForAttachmentCompletion, waitForUserTurnAttachments, readAssistantSnapshot, } from './pageActions.js';
|
|
9
|
+
import { INPUT_SELECTORS } from './constants.js';
|
|
9
10
|
import { uploadAttachmentViaDataTransfer } from './actions/remoteFileTransfer.js';
|
|
10
11
|
import { ensureThinkingTime } from './actions/thinkingTime.js';
|
|
11
12
|
import { estimateTokenCount, withRetries, delay } from './utils.js';
|
|
@@ -13,7 +14,7 @@ import { formatElapsed } from '../oracle/format.js';
|
|
|
13
14
|
import { CHATGPT_URL, CONVERSATION_TURN_SELECTOR, DEFAULT_MODEL_STRATEGY } from './constants.js';
|
|
14
15
|
import { BrowserAutomationError } from '../oracle/errors.js';
|
|
15
16
|
import { alignPromptEchoPair, buildPromptEchoMatcher } from './reattachHelpers.js';
|
|
16
|
-
import { cleanupStaleProfileState, readChromePid, readDevToolsPort, shouldCleanupManualLoginProfileState, verifyDevToolsReachable, writeChromePid, writeDevToolsActivePort, } from './profileState.js';
|
|
17
|
+
import { cleanupStaleProfileState, acquireProfileRunLock, readChromePid, readDevToolsPort, shouldCleanupManualLoginProfileState, verifyDevToolsReachable, writeChromePid, writeDevToolsActivePort, } from './profileState.js';
|
|
17
18
|
export { CHATGPT_URL, DEFAULT_MODEL_STRATEGY, DEFAULT_MODEL_TARGET } from './constants.js';
|
|
18
19
|
export { parseDuration, delay, normalizeChatgptUrl, isTemporaryChatUrl } from './utils.js';
|
|
19
20
|
export async function runBrowserMode(options) {
|
|
@@ -96,7 +97,9 @@ export async function runBrowserMode(options) {
|
|
|
96
97
|
logger(`Created temporary Chrome profile at ${userDataDir}`);
|
|
97
98
|
}
|
|
98
99
|
const effectiveKeepBrowser = Boolean(config.keepBrowser);
|
|
99
|
-
const reusedChrome = manualLogin
|
|
100
|
+
const reusedChrome = manualLogin
|
|
101
|
+
? await maybeReuseRunningChrome(userDataDir, logger, { waitForPortMs: config.reuseChromeWaitMs })
|
|
102
|
+
: null;
|
|
100
103
|
const chrome = reusedChrome ??
|
|
101
104
|
(await launchChrome({
|
|
102
105
|
...config,
|
|
@@ -134,7 +137,12 @@ export async function runBrowserMode(options) {
|
|
|
134
137
|
let appliedCookies = 0;
|
|
135
138
|
try {
|
|
136
139
|
try {
|
|
137
|
-
const
|
|
140
|
+
const strictTabIsolation = Boolean(manualLogin && reusedChrome);
|
|
141
|
+
const connection = await connectWithNewTab(chrome.port, logger, undefined, chromeHost, {
|
|
142
|
+
fallbackToDefault: !strictTabIsolation,
|
|
143
|
+
retries: strictTabIsolation ? 3 : 0,
|
|
144
|
+
retryDelayMs: 500,
|
|
145
|
+
});
|
|
138
146
|
client = connection.client;
|
|
139
147
|
isolatedTargetId = connection.targetId ?? null;
|
|
140
148
|
}
|
|
@@ -346,6 +354,23 @@ export async function runBrowserMode(options) {
|
|
|
346
354
|
},
|
|
347
355
|
}));
|
|
348
356
|
}
|
|
357
|
+
const profileLockTimeoutMs = manualLogin ? (config.profileLockTimeoutMs ?? 0) : 0;
|
|
358
|
+
let profileLock = null;
|
|
359
|
+
const acquireProfileLockIfNeeded = async () => {
|
|
360
|
+
if (profileLockTimeoutMs <= 0)
|
|
361
|
+
return;
|
|
362
|
+
profileLock = await acquireProfileRunLock(userDataDir, {
|
|
363
|
+
timeoutMs: profileLockTimeoutMs,
|
|
364
|
+
logger,
|
|
365
|
+
});
|
|
366
|
+
};
|
|
367
|
+
const releaseProfileLockIfHeld = async () => {
|
|
368
|
+
if (!profileLock)
|
|
369
|
+
return;
|
|
370
|
+
const handle = profileLock;
|
|
371
|
+
profileLock = null;
|
|
372
|
+
await handle.release().catch(() => undefined);
|
|
373
|
+
};
|
|
349
374
|
const submitOnce = async (prompt, submissionAttachments) => {
|
|
350
375
|
const baselineSnapshot = await readAssistantSnapshot(Runtime).catch(() => null);
|
|
351
376
|
const baselineAssistantText = typeof baselineSnapshot?.text === 'string' ? baselineSnapshot.text.trim() : '';
|
|
@@ -421,27 +446,33 @@ export async function runBrowserMode(options) {
|
|
|
421
446
|
};
|
|
422
447
|
let baselineTurns = null;
|
|
423
448
|
let baselineAssistantText = null;
|
|
449
|
+
await acquireProfileLockIfNeeded();
|
|
424
450
|
try {
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
baselineAssistantText = submission.baselineAssistantText;
|
|
428
|
-
}
|
|
429
|
-
catch (error) {
|
|
430
|
-
const isPromptTooLarge = error instanceof BrowserAutomationError &&
|
|
431
|
-
error.details?.code === 'prompt-too-large';
|
|
432
|
-
if (fallbackSubmission && isPromptTooLarge) {
|
|
433
|
-
// Learned: when prompts truncate, retry with file uploads so the UI receives the full content.
|
|
434
|
-
logger('[browser] Inline prompt too large; retrying with file uploads.');
|
|
435
|
-
await raceWithDisconnect(clearPromptComposer(Runtime, logger));
|
|
436
|
-
await raceWithDisconnect(ensurePromptReady(Runtime, config.inputTimeoutMs, logger));
|
|
437
|
-
const submission = await raceWithDisconnect(submitOnce(fallbackSubmission.prompt, fallbackSubmission.attachments));
|
|
451
|
+
try {
|
|
452
|
+
const submission = await raceWithDisconnect(submitOnce(promptText, attachments));
|
|
438
453
|
baselineTurns = submission.baselineTurns;
|
|
439
454
|
baselineAssistantText = submission.baselineAssistantText;
|
|
440
455
|
}
|
|
441
|
-
|
|
442
|
-
|
|
456
|
+
catch (error) {
|
|
457
|
+
const isPromptTooLarge = error instanceof BrowserAutomationError &&
|
|
458
|
+
error.details?.code === 'prompt-too-large';
|
|
459
|
+
if (fallbackSubmission && isPromptTooLarge) {
|
|
460
|
+
// Learned: when prompts truncate, retry with file uploads so the UI receives the full content.
|
|
461
|
+
logger('[browser] Inline prompt too large; retrying with file uploads.');
|
|
462
|
+
await raceWithDisconnect(clearPromptComposer(Runtime, logger));
|
|
463
|
+
await raceWithDisconnect(ensurePromptReady(Runtime, config.inputTimeoutMs, logger));
|
|
464
|
+
const submission = await raceWithDisconnect(submitOnce(fallbackSubmission.prompt, fallbackSubmission.attachments));
|
|
465
|
+
baselineTurns = submission.baselineTurns;
|
|
466
|
+
baselineAssistantText = submission.baselineAssistantText;
|
|
467
|
+
}
|
|
468
|
+
else {
|
|
469
|
+
throw error;
|
|
470
|
+
}
|
|
443
471
|
}
|
|
444
472
|
}
|
|
473
|
+
finally {
|
|
474
|
+
await releaseProfileLockIfHeld();
|
|
475
|
+
}
|
|
445
476
|
stopThinkingMonitor = startThinkingStatusMonitor(Runtime, logger, options.verbose ?? false);
|
|
446
477
|
// Helper to normalize text for echo detection (collapse whitespace, lowercase)
|
|
447
478
|
const normalizeForComparison = (text) => text.toLowerCase().replace(/\s+/g, ' ').trim();
|
|
@@ -468,7 +499,83 @@ export async function runBrowserMode(options) {
|
|
|
468
499
|
}
|
|
469
500
|
return null;
|
|
470
501
|
};
|
|
471
|
-
let answer
|
|
502
|
+
let answer;
|
|
503
|
+
const recheckDelayMs = Math.max(0, config.assistantRecheckDelayMs ?? 0);
|
|
504
|
+
const recheckTimeoutMs = Math.max(0, config.assistantRecheckTimeoutMs ?? 0);
|
|
505
|
+
const attemptAssistantRecheck = async () => {
|
|
506
|
+
if (!recheckDelayMs)
|
|
507
|
+
return null;
|
|
508
|
+
logger(`[browser] Assistant response timed out; waiting ${formatElapsed(recheckDelayMs)} before rechecking conversation.`);
|
|
509
|
+
await raceWithDisconnect(delay(recheckDelayMs));
|
|
510
|
+
await updateConversationHint('assistant-recheck', 15_000).catch(() => false);
|
|
511
|
+
await captureRuntimeSnapshot().catch(() => undefined);
|
|
512
|
+
const conversationUrl = await readConversationUrl(Runtime);
|
|
513
|
+
if (conversationUrl && isConversationUrl(conversationUrl)) {
|
|
514
|
+
logger(`[browser] Rechecking assistant response at ${conversationUrl}`);
|
|
515
|
+
await raceWithDisconnect(Page.navigate({ url: conversationUrl }));
|
|
516
|
+
await raceWithDisconnect(delay(1000));
|
|
517
|
+
}
|
|
518
|
+
// Validate session before attempting recheck - sessions can expire during the delay
|
|
519
|
+
const sessionValid = await validateChatGPTSession(Runtime, logger);
|
|
520
|
+
if (!sessionValid.valid) {
|
|
521
|
+
logger(`[browser] Session validation failed: ${sessionValid.reason}`);
|
|
522
|
+
// Update session metadata to indicate login is needed
|
|
523
|
+
await emitRuntimeHint();
|
|
524
|
+
throw new BrowserAutomationError(`ChatGPT session expired during recheck: ${sessionValid.reason}. ` +
|
|
525
|
+
`Conversation URL: ${conversationUrl || lastUrl || 'unknown'}. ` +
|
|
526
|
+
`Please sign in and retry.`, {
|
|
527
|
+
stage: 'assistant-recheck',
|
|
528
|
+
details: {
|
|
529
|
+
conversationUrl: conversationUrl || lastUrl || null,
|
|
530
|
+
sessionStatus: 'needs_login',
|
|
531
|
+
validationReason: sessionValid.reason,
|
|
532
|
+
},
|
|
533
|
+
runtime: {
|
|
534
|
+
chromePid: chrome.pid,
|
|
535
|
+
chromePort: chrome.port,
|
|
536
|
+
chromeHost,
|
|
537
|
+
userDataDir,
|
|
538
|
+
chromeTargetId: lastTargetId,
|
|
539
|
+
tabUrl: lastUrl,
|
|
540
|
+
conversationId: lastUrl ? extractConversationIdFromUrl(lastUrl) : undefined,
|
|
541
|
+
controllerPid: process.pid,
|
|
542
|
+
},
|
|
543
|
+
});
|
|
544
|
+
}
|
|
545
|
+
const timeoutMs = recheckTimeoutMs > 0 ? recheckTimeoutMs : config.timeoutMs;
|
|
546
|
+
const rechecked = await raceWithDisconnect(waitForAssistantResponseWithReload(Runtime, Page, timeoutMs, logger, baselineTurns ?? undefined));
|
|
547
|
+
logger('Recovered assistant response after delayed recheck');
|
|
548
|
+
return rechecked;
|
|
549
|
+
};
|
|
550
|
+
try {
|
|
551
|
+
answer = await raceWithDisconnect(waitForAssistantResponseWithReload(Runtime, Page, config.timeoutMs, logger, baselineTurns ?? undefined));
|
|
552
|
+
}
|
|
553
|
+
catch (error) {
|
|
554
|
+
if (isAssistantResponseTimeoutError(error)) {
|
|
555
|
+
const rechecked = await attemptAssistantRecheck().catch(() => null);
|
|
556
|
+
if (rechecked) {
|
|
557
|
+
answer = rechecked;
|
|
558
|
+
}
|
|
559
|
+
else {
|
|
560
|
+
await updateConversationHint('assistant-timeout', 15_000).catch(() => false);
|
|
561
|
+
await captureRuntimeSnapshot().catch(() => undefined);
|
|
562
|
+
const runtime = {
|
|
563
|
+
chromePid: chrome.pid,
|
|
564
|
+
chromePort: chrome.port,
|
|
565
|
+
chromeHost,
|
|
566
|
+
userDataDir,
|
|
567
|
+
chromeTargetId: lastTargetId,
|
|
568
|
+
tabUrl: lastUrl,
|
|
569
|
+
conversationId: lastUrl ? extractConversationIdFromUrl(lastUrl) : undefined,
|
|
570
|
+
controllerPid: process.pid,
|
|
571
|
+
};
|
|
572
|
+
throw new BrowserAutomationError('Assistant response timed out before completion; reattach later to capture the answer.', { stage: 'assistant-timeout', runtime }, error);
|
|
573
|
+
}
|
|
574
|
+
}
|
|
575
|
+
else {
|
|
576
|
+
throw error;
|
|
577
|
+
}
|
|
578
|
+
}
|
|
472
579
|
// Ensure we store the final conversation URL even if the UI updated late.
|
|
473
580
|
await updateConversationHint('post-response', 15_000);
|
|
474
581
|
const baselineNormalized = baselineAssistantText ? normalizeForComparison(baselineAssistantText) : '';
|
|
@@ -658,7 +765,10 @@ export async function runBrowserMode(options) {
|
|
|
658
765
|
catch {
|
|
659
766
|
// ignore
|
|
660
767
|
}
|
|
661
|
-
|
|
768
|
+
// Close the isolated tab once the response has been fully captured to prevent
|
|
769
|
+
// tab accumulation across repeated runs. Keep the tab open on incomplete runs
|
|
770
|
+
// so reattach can recover the response.
|
|
771
|
+
if (runStatus === 'complete' && isolatedTargetId && chrome?.port) {
|
|
662
772
|
await closeTab(chrome.port, isolatedTargetId, logger, chromeHost).catch(() => undefined);
|
|
663
773
|
}
|
|
664
774
|
removeDialogHandler?.();
|
|
@@ -817,11 +927,20 @@ async function _assertNavigatedToHttp(runtime, _logger, timeoutMs = 10_000) {
|
|
|
817
927
|
details: { url: lastUrl || '(empty)' },
|
|
818
928
|
});
|
|
819
929
|
}
|
|
820
|
-
async function maybeReuseRunningChrome(userDataDir, logger) {
|
|
821
|
-
const
|
|
930
|
+
async function maybeReuseRunningChrome(userDataDir, logger, options = {}) {
|
|
931
|
+
const waitForPortMs = Math.max(0, options.waitForPortMs ?? 0);
|
|
932
|
+
let port = await readDevToolsPort(userDataDir);
|
|
933
|
+
if (!port && waitForPortMs > 0) {
|
|
934
|
+
const deadline = Date.now() + waitForPortMs;
|
|
935
|
+
logger(`Waiting up to ${formatElapsed(waitForPortMs)} for shared Chrome to appear...`);
|
|
936
|
+
while (!port && Date.now() < deadline) {
|
|
937
|
+
await delay(250);
|
|
938
|
+
port = await readDevToolsPort(userDataDir);
|
|
939
|
+
}
|
|
940
|
+
}
|
|
822
941
|
if (!port)
|
|
823
942
|
return null;
|
|
824
|
-
const probe = await verifyDevToolsReachable({ port });
|
|
943
|
+
const probe = await (options.probe ?? verifyDevToolsReachable)({ port });
|
|
825
944
|
if (!probe.ok) {
|
|
826
945
|
logger(`DevToolsActivePort found for ${userDataDir} but unreachable (${probe.error}); launching new Chrome.`);
|
|
827
946
|
// Safe cleanup: remove stale DevToolsActivePort; only remove lock files if this was an Oracle-owned pid that died.
|
|
@@ -1023,7 +1142,87 @@ async function runRemoteBrowserMode(promptText, attachments, config, logger, opt
|
|
|
1023
1142
|
}
|
|
1024
1143
|
return null;
|
|
1025
1144
|
};
|
|
1026
|
-
let answer
|
|
1145
|
+
let answer;
|
|
1146
|
+
const recheckDelayMs = Math.max(0, config.assistantRecheckDelayMs ?? 0);
|
|
1147
|
+
const recheckTimeoutMs = Math.max(0, config.assistantRecheckTimeoutMs ?? 0);
|
|
1148
|
+
const attemptAssistantRecheck = async () => {
|
|
1149
|
+
if (!recheckDelayMs)
|
|
1150
|
+
return null;
|
|
1151
|
+
logger(`[browser] Assistant response timed out; waiting ${formatElapsed(recheckDelayMs)} before rechecking conversation.`);
|
|
1152
|
+
await delay(recheckDelayMs);
|
|
1153
|
+
const conversationUrl = await readConversationUrl(Runtime);
|
|
1154
|
+
if (conversationUrl && isConversationUrl(conversationUrl)) {
|
|
1155
|
+
lastUrl = conversationUrl;
|
|
1156
|
+
logger(`[browser] Rechecking assistant response at ${conversationUrl}`);
|
|
1157
|
+
await Page.navigate({ url: conversationUrl });
|
|
1158
|
+
await delay(1000);
|
|
1159
|
+
}
|
|
1160
|
+
// Validate session before attempting recheck - sessions can expire during the delay
|
|
1161
|
+
const sessionValid = await validateChatGPTSession(Runtime, logger);
|
|
1162
|
+
if (!sessionValid.valid) {
|
|
1163
|
+
logger(`[browser] Session validation failed: ${sessionValid.reason}`);
|
|
1164
|
+
// Update session metadata to indicate login is needed
|
|
1165
|
+
await emitRuntimeHint();
|
|
1166
|
+
throw new BrowserAutomationError(`ChatGPT session expired during recheck: ${sessionValid.reason}. ` +
|
|
1167
|
+
`Conversation URL: ${conversationUrl || lastUrl || 'unknown'}. ` +
|
|
1168
|
+
`Please sign in and retry.`, {
|
|
1169
|
+
stage: 'assistant-recheck',
|
|
1170
|
+
details: {
|
|
1171
|
+
conversationUrl: conversationUrl || lastUrl || null,
|
|
1172
|
+
sessionStatus: 'needs_login',
|
|
1173
|
+
validationReason: sessionValid.reason,
|
|
1174
|
+
},
|
|
1175
|
+
runtime: {
|
|
1176
|
+
chromeHost: host,
|
|
1177
|
+
chromePort: port,
|
|
1178
|
+
chromeTargetId: remoteTargetId ?? undefined,
|
|
1179
|
+
tabUrl: lastUrl,
|
|
1180
|
+
conversationId: lastUrl ? extractConversationIdFromUrl(lastUrl) : undefined,
|
|
1181
|
+
controllerPid: process.pid,
|
|
1182
|
+
},
|
|
1183
|
+
});
|
|
1184
|
+
}
|
|
1185
|
+
await emitRuntimeHint();
|
|
1186
|
+
const timeoutMs = recheckTimeoutMs > 0 ? recheckTimeoutMs : config.timeoutMs;
|
|
1187
|
+
const rechecked = await waitForAssistantResponseWithReload(Runtime, Page, timeoutMs, logger, baselineTurns ?? undefined);
|
|
1188
|
+
logger('Recovered assistant response after delayed recheck');
|
|
1189
|
+
return rechecked;
|
|
1190
|
+
};
|
|
1191
|
+
try {
|
|
1192
|
+
answer = await waitForAssistantResponseWithReload(Runtime, Page, config.timeoutMs, logger, baselineTurns ?? undefined);
|
|
1193
|
+
}
|
|
1194
|
+
catch (error) {
|
|
1195
|
+
if (isAssistantResponseTimeoutError(error)) {
|
|
1196
|
+
const rechecked = await attemptAssistantRecheck().catch(() => null);
|
|
1197
|
+
if (rechecked) {
|
|
1198
|
+
answer = rechecked;
|
|
1199
|
+
}
|
|
1200
|
+
else {
|
|
1201
|
+
try {
|
|
1202
|
+
const conversationUrl = await readConversationUrl(Runtime);
|
|
1203
|
+
if (conversationUrl) {
|
|
1204
|
+
lastUrl = conversationUrl;
|
|
1205
|
+
}
|
|
1206
|
+
}
|
|
1207
|
+
catch {
|
|
1208
|
+
// ignore
|
|
1209
|
+
}
|
|
1210
|
+
await emitRuntimeHint();
|
|
1211
|
+
const runtime = {
|
|
1212
|
+
chromePort: port,
|
|
1213
|
+
chromeHost: host,
|
|
1214
|
+
chromeTargetId: remoteTargetId ?? undefined,
|
|
1215
|
+
tabUrl: lastUrl,
|
|
1216
|
+
conversationId: lastUrl ? extractConversationIdFromUrl(lastUrl) : undefined,
|
|
1217
|
+
controllerPid: process.pid,
|
|
1218
|
+
};
|
|
1219
|
+
throw new BrowserAutomationError('Assistant response timed out before completion; reattach later to capture the answer.', { stage: 'assistant-timeout', runtime }, error);
|
|
1220
|
+
}
|
|
1221
|
+
}
|
|
1222
|
+
else {
|
|
1223
|
+
throw error;
|
|
1224
|
+
}
|
|
1225
|
+
}
|
|
1027
1226
|
const baselineNormalized = baselineAssistantText ? normalizeForComparison(baselineAssistantText) : '';
|
|
1028
1227
|
if (baselineNormalized) {
|
|
1029
1228
|
const normalizedAnswer = normalizeForComparison(answer.text ?? '');
|
|
@@ -1178,6 +1377,9 @@ export { estimateTokenCount } from './utils.js';
|
|
|
1178
1377
|
export { resolveBrowserConfig, DEFAULT_BROWSER_CONFIG } from './config.js';
|
|
1179
1378
|
export { syncCookies } from './cookies.js';
|
|
1180
1379
|
export { navigateToChatGPT, ensureNotBlocked, ensurePromptReady, ensureModelSelection, submitPrompt, waitForAssistantResponse, captureAssistantMarkdown, uploadAttachmentFile, waitForAttachmentCompletion, } from './pageActions.js';
|
|
1380
|
+
export async function maybeReuseRunningChromeForTest(userDataDir, logger, options = {}) {
|
|
1381
|
+
return maybeReuseRunningChrome(userDataDir, logger, options);
|
|
1382
|
+
}
|
|
1181
1383
|
function isWebSocketClosureError(error) {
|
|
1182
1384
|
const message = error.message.toLowerCase();
|
|
1183
1385
|
return (message.includes('websocket connection closed') ||
|
|
@@ -1222,6 +1424,17 @@ function shouldReloadAfterAssistantError(error) {
|
|
|
1222
1424
|
message.includes('timeout') ||
|
|
1223
1425
|
message.includes('capture assistant response'));
|
|
1224
1426
|
}
|
|
1427
|
+
function isAssistantResponseTimeoutError(error) {
|
|
1428
|
+
if (!(error instanceof Error))
|
|
1429
|
+
return false;
|
|
1430
|
+
const message = error.message.toLowerCase();
|
|
1431
|
+
if (!message)
|
|
1432
|
+
return false;
|
|
1433
|
+
return (message.includes('assistant-response') ||
|
|
1434
|
+
message.includes('assistant response') ||
|
|
1435
|
+
message.includes('watchdog') ||
|
|
1436
|
+
message.includes('capture assistant response'));
|
|
1437
|
+
}
|
|
1225
1438
|
async function readConversationUrl(Runtime) {
|
|
1226
1439
|
try {
|
|
1227
1440
|
const currentUrl = await Runtime.evaluate({ expression: 'location.href', returnByValue: true });
|
|
@@ -1231,6 +1444,110 @@ async function readConversationUrl(Runtime) {
|
|
|
1231
1444
|
return null;
|
|
1232
1445
|
}
|
|
1233
1446
|
}
|
|
1447
|
+
/**
|
|
1448
|
+
* Validates that the ChatGPT session is still active by checking for login CTAs
|
|
1449
|
+
* and textarea availability. Sessions can expire during long delays (e.g., recheck).
|
|
1450
|
+
*
|
|
1451
|
+
* @param Runtime - Chrome Runtime client
|
|
1452
|
+
* @param logger - Browser logger for diagnostics
|
|
1453
|
+
* @returns SessionValidationResult indicating if session is valid and reason if not
|
|
1454
|
+
*/
|
|
1455
|
+
async function validateChatGPTSession(Runtime, logger) {
|
|
1456
|
+
try {
|
|
1457
|
+
const outcome = await Runtime.evaluate({
|
|
1458
|
+
expression: buildSessionValidationExpression(),
|
|
1459
|
+
awaitPromise: true,
|
|
1460
|
+
returnByValue: true,
|
|
1461
|
+
});
|
|
1462
|
+
const result = outcome.result?.value;
|
|
1463
|
+
if (!result) {
|
|
1464
|
+
return { valid: false, reason: 'Failed to evaluate session state' };
|
|
1465
|
+
}
|
|
1466
|
+
if (result.onAuthPage) {
|
|
1467
|
+
return { valid: false, reason: 'Redirected to auth page' };
|
|
1468
|
+
}
|
|
1469
|
+
if (result.hasLoginCta) {
|
|
1470
|
+
return { valid: false, reason: 'Login button detected on page' };
|
|
1471
|
+
}
|
|
1472
|
+
if (!result.hasTextarea) {
|
|
1473
|
+
return { valid: false, reason: 'Prompt textarea not available' };
|
|
1474
|
+
}
|
|
1475
|
+
return { valid: true };
|
|
1476
|
+
}
|
|
1477
|
+
catch (error) {
|
|
1478
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
1479
|
+
logger(`[browser] Session validation error: ${message}`);
|
|
1480
|
+
return { valid: false, reason: `Validation error: ${message}` };
|
|
1481
|
+
}
|
|
1482
|
+
}
|
|
1483
|
+
function buildSessionValidationExpression() {
|
|
1484
|
+
const selectorLiteral = JSON.stringify(INPUT_SELECTORS);
|
|
1485
|
+
return `(async () => {
|
|
1486
|
+
const pageUrl = typeof location === 'object' && location?.href ? location.href : null;
|
|
1487
|
+
const onAuthPage =
|
|
1488
|
+
typeof location === 'object' &&
|
|
1489
|
+
typeof location.pathname === 'string' &&
|
|
1490
|
+
/^\\/(auth|login|signin)/i.test(location.pathname);
|
|
1491
|
+
|
|
1492
|
+
// Check for login CTAs (similar to ensureLoggedIn logic)
|
|
1493
|
+
const hasLoginCta = (() => {
|
|
1494
|
+
const candidates = Array.from(
|
|
1495
|
+
document.querySelectorAll(
|
|
1496
|
+
[
|
|
1497
|
+
'a[href*="/auth/login"]',
|
|
1498
|
+
'a[href*="/auth/signin"]',
|
|
1499
|
+
'button[type="submit"]',
|
|
1500
|
+
'button[data-testid*="login"]',
|
|
1501
|
+
'button[data-testid*="log-in"]',
|
|
1502
|
+
'button[data-testid*="sign-in"]',
|
|
1503
|
+
'button[data-testid*="signin"]',
|
|
1504
|
+
'button',
|
|
1505
|
+
'a',
|
|
1506
|
+
].join(','),
|
|
1507
|
+
),
|
|
1508
|
+
);
|
|
1509
|
+
const textMatches = (text) => {
|
|
1510
|
+
if (!text) return false;
|
|
1511
|
+
const normalized = text.toLowerCase().trim();
|
|
1512
|
+
return ['log in', 'login', 'sign in', 'signin', 'continue with'].some((needle) =>
|
|
1513
|
+
normalized.startsWith(needle),
|
|
1514
|
+
);
|
|
1515
|
+
};
|
|
1516
|
+
for (const node of candidates) {
|
|
1517
|
+
if (!(node instanceof HTMLElement)) continue;
|
|
1518
|
+
const label =
|
|
1519
|
+
node.textContent?.trim() ||
|
|
1520
|
+
node.getAttribute('aria-label') ||
|
|
1521
|
+
node.getAttribute('title') ||
|
|
1522
|
+
'';
|
|
1523
|
+
if (textMatches(label)) {
|
|
1524
|
+
return true;
|
|
1525
|
+
}
|
|
1526
|
+
}
|
|
1527
|
+
return false;
|
|
1528
|
+
})();
|
|
1529
|
+
|
|
1530
|
+
// Check for textarea availability
|
|
1531
|
+
const hasTextarea = (() => {
|
|
1532
|
+
const selectors = ${selectorLiteral};
|
|
1533
|
+
for (const selector of selectors) {
|
|
1534
|
+
const node = document.querySelector(selector);
|
|
1535
|
+
if (node) {
|
|
1536
|
+
return true;
|
|
1537
|
+
}
|
|
1538
|
+
}
|
|
1539
|
+
return false;
|
|
1540
|
+
})();
|
|
1541
|
+
|
|
1542
|
+
return {
|
|
1543
|
+
valid: !onAuthPage && !hasLoginCta && hasTextarea,
|
|
1544
|
+
hasLoginCta,
|
|
1545
|
+
hasTextarea,
|
|
1546
|
+
onAuthPage,
|
|
1547
|
+
pageUrl,
|
|
1548
|
+
};
|
|
1549
|
+
})()`;
|
|
1550
|
+
}
|
|
1234
1551
|
async function readConversationTurnCount(Runtime, logger) {
|
|
1235
1552
|
const selectorLiteral = JSON.stringify(CONVERSATION_TURN_SELECTOR);
|
|
1236
1553
|
const attempts = 4;
|
|
@@ -1,13 +1,16 @@
|
|
|
1
1
|
import path from 'node:path';
|
|
2
|
+
import { randomUUID } from 'node:crypto';
|
|
2
3
|
import { mkdir, readFile, rm, writeFile } from 'node:fs/promises';
|
|
3
4
|
import { execFile } from 'node:child_process';
|
|
4
5
|
import { promisify } from 'node:util';
|
|
6
|
+
import { delay } from './utils.js';
|
|
5
7
|
const DEVTOOLS_ACTIVE_PORT_FILENAME = 'DevToolsActivePort';
|
|
6
8
|
const DEVTOOLS_ACTIVE_PORT_RELATIVE_PATHS = [
|
|
7
9
|
DEVTOOLS_ACTIVE_PORT_FILENAME,
|
|
8
10
|
path.join('Default', DEVTOOLS_ACTIVE_PORT_FILENAME),
|
|
9
11
|
];
|
|
10
12
|
const CHROME_PID_FILENAME = 'chrome.pid';
|
|
13
|
+
const ORACLE_PROFILE_LOCK_FILENAME = 'oracle-automation.lock';
|
|
11
14
|
const execFileAsync = promisify(execFile);
|
|
12
15
|
export function getDevToolsActivePortPaths(userDataDir) {
|
|
13
16
|
return DEVTOOLS_ACTIVE_PORT_RELATIVE_PATHS.map((relative) => path.join(userDataDir, relative));
|
|
@@ -81,6 +84,96 @@ export function isProcessAlive(pid) {
|
|
|
81
84
|
return false;
|
|
82
85
|
}
|
|
83
86
|
}
|
|
87
|
+
function parseProfileRunLock(payload) {
|
|
88
|
+
if (!payload)
|
|
89
|
+
return null;
|
|
90
|
+
try {
|
|
91
|
+
const parsed = JSON.parse(payload);
|
|
92
|
+
if (!Number.isFinite(parsed.pid) || parsed.pid <= 0)
|
|
93
|
+
return null;
|
|
94
|
+
if (!parsed.lockId || typeof parsed.lockId !== 'string')
|
|
95
|
+
return null;
|
|
96
|
+
return parsed;
|
|
97
|
+
}
|
|
98
|
+
catch {
|
|
99
|
+
return null;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
export async function acquireProfileRunLock(userDataDir, options) {
|
|
103
|
+
const timeoutMs = options.timeoutMs;
|
|
104
|
+
if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) {
|
|
105
|
+
return null;
|
|
106
|
+
}
|
|
107
|
+
const pollMs = typeof options.pollMs === 'number' && Number.isFinite(options.pollMs) && options.pollMs > 0
|
|
108
|
+
? options.pollMs
|
|
109
|
+
: 1000;
|
|
110
|
+
const lockPath = path.join(userDataDir, ORACLE_PROFILE_LOCK_FILENAME);
|
|
111
|
+
const lockId = randomUUID();
|
|
112
|
+
const startedAt = Date.now();
|
|
113
|
+
let warned = false;
|
|
114
|
+
for (;;) {
|
|
115
|
+
try {
|
|
116
|
+
const payload = {
|
|
117
|
+
pid: process.pid,
|
|
118
|
+
lockId,
|
|
119
|
+
createdAt: new Date().toISOString(),
|
|
120
|
+
sessionId: options.sessionId,
|
|
121
|
+
};
|
|
122
|
+
await mkdir(path.dirname(lockPath), { recursive: true });
|
|
123
|
+
await writeFile(lockPath, JSON.stringify(payload), { encoding: 'utf8', flag: 'wx' });
|
|
124
|
+
options.logger?.(`Acquired Oracle profile lock at ${lockPath}`);
|
|
125
|
+
return {
|
|
126
|
+
path: lockPath,
|
|
127
|
+
lockId,
|
|
128
|
+
release: async () => releaseProfileRunLock(lockPath, lockId, options.logger),
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
catch (error) {
|
|
132
|
+
const code = error.code;
|
|
133
|
+
if (code !== 'EEXIST') {
|
|
134
|
+
throw error;
|
|
135
|
+
}
|
|
136
|
+
let existing = parseProfileRunLock(await readFile(lockPath, 'utf8').catch(() => null));
|
|
137
|
+
if (!existing) {
|
|
138
|
+
// Likely partial write / corruption; re-read once, then delete (user preference: delete unreadable lockfiles).
|
|
139
|
+
await delay(200);
|
|
140
|
+
existing = parseProfileRunLock(await readFile(lockPath, 'utf8').catch(() => null));
|
|
141
|
+
if (!existing) {
|
|
142
|
+
options.logger?.('Oracle profile lock unreadable; deleting lockfile.');
|
|
143
|
+
await rm(lockPath, { force: true }).catch(() => undefined);
|
|
144
|
+
continue;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
if (!existing || !isProcessAlive(existing.pid)) {
|
|
148
|
+
await rm(lockPath, { force: true }).catch(() => undefined);
|
|
149
|
+
continue;
|
|
150
|
+
}
|
|
151
|
+
if (!warned) {
|
|
152
|
+
const waited = Math.round(timeoutMs / 1000);
|
|
153
|
+
options.logger?.(`Oracle profile lock held by pid ${existing.pid}; waiting up to ${waited}s.`);
|
|
154
|
+
warned = true;
|
|
155
|
+
}
|
|
156
|
+
const elapsed = Date.now() - startedAt;
|
|
157
|
+
if (elapsed >= timeoutMs) {
|
|
158
|
+
throw new Error(`Oracle profile lock still held by pid ${existing.pid} after ${Math.round(elapsed / 1000)}s`);
|
|
159
|
+
}
|
|
160
|
+
await delay(Math.min(pollMs, timeoutMs - elapsed));
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
export async function releaseProfileRunLock(lockPath, lockId, logger) {
|
|
165
|
+
try {
|
|
166
|
+
const existing = parseProfileRunLock(await readFile(lockPath, 'utf8').catch(() => null));
|
|
167
|
+
if (!existing || existing.lockId !== lockId) {
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
await rm(lockPath, { force: true });
|
|
171
|
+
logger?.(`Released Oracle profile lock ${lockPath}`);
|
|
172
|
+
}
|
|
173
|
+
catch {
|
|
174
|
+
// best effort
|
|
175
|
+
}
|
|
176
|
+
}
|
|
84
177
|
export async function verifyDevToolsReachable({ port, host = '127.0.0.1', attempts = 3, timeoutMs = 3000, }) {
|
|
85
178
|
const versionUrl = `http://${host}:${port}/json/version`;
|
|
86
179
|
for (let attempt = 0; attempt < attempts; attempt++) {
|
|
@@ -5,6 +5,8 @@ import { normalizeBrowserModelStrategy } from '../browser/modelStrategy.js';
|
|
|
5
5
|
import { getOracleHomeDir } from '../oracleHome.js';
|
|
6
6
|
const DEFAULT_BROWSER_TIMEOUT_MS = 1_200_000;
|
|
7
7
|
const DEFAULT_BROWSER_INPUT_TIMEOUT_MS = 60_000;
|
|
8
|
+
const DEFAULT_BROWSER_RECHECK_TIMEOUT_MS = 120_000;
|
|
9
|
+
const DEFAULT_BROWSER_AUTO_REATTACH_TIMEOUT_MS = 120_000;
|
|
8
10
|
const DEFAULT_CHROME_PROFILE = 'Default';
|
|
9
11
|
// Ordered array: most specific models first to ensure correct selection.
|
|
10
12
|
// The browser label is passed to the model picker which fuzzy-matches against ChatGPT's UI.
|
|
@@ -82,6 +84,25 @@ export async function buildBrowserConfig(options) {
|
|
|
82
84
|
inputTimeoutMs: options.browserInputTimeout
|
|
83
85
|
? parseDuration(options.browserInputTimeout, DEFAULT_BROWSER_INPUT_TIMEOUT_MS)
|
|
84
86
|
: undefined,
|
|
87
|
+
assistantRecheckDelayMs: options.browserRecheckDelay
|
|
88
|
+
? parseDuration(options.browserRecheckDelay, 0)
|
|
89
|
+
: undefined,
|
|
90
|
+
assistantRecheckTimeoutMs: options.browserRecheckTimeout
|
|
91
|
+
? parseDuration(options.browserRecheckTimeout, DEFAULT_BROWSER_RECHECK_TIMEOUT_MS)
|
|
92
|
+
: undefined,
|
|
93
|
+
reuseChromeWaitMs: options.browserReuseWait ? parseDuration(options.browserReuseWait, 0) : undefined,
|
|
94
|
+
profileLockTimeoutMs: options.browserProfileLockTimeout
|
|
95
|
+
? parseDuration(options.browserProfileLockTimeout, 0)
|
|
96
|
+
: undefined,
|
|
97
|
+
autoReattachDelayMs: options.browserAutoReattachDelay
|
|
98
|
+
? parseDuration(options.browserAutoReattachDelay, 0)
|
|
99
|
+
: undefined,
|
|
100
|
+
autoReattachIntervalMs: options.browserAutoReattachInterval
|
|
101
|
+
? parseDuration(options.browserAutoReattachInterval, 0)
|
|
102
|
+
: undefined,
|
|
103
|
+
autoReattachTimeoutMs: options.browserAutoReattachTimeout
|
|
104
|
+
? parseDuration(options.browserAutoReattachTimeout, DEFAULT_BROWSER_AUTO_REATTACH_TIMEOUT_MS)
|
|
105
|
+
: undefined,
|
|
85
106
|
cookieSyncWaitMs: options.browserCookieWait ? parseDuration(options.browserCookieWait, 0) : undefined,
|
|
86
107
|
cookieSync: options.browserNoCookieSync ? false : undefined,
|
|
87
108
|
cookieNames,
|
|
@@ -33,6 +33,27 @@ export function applyBrowserDefaultsFromConfig(options, config, getSource) {
|
|
|
33
33
|
if (isUnset('browserInputTimeout') && typeof browser.inputTimeoutMs === 'number') {
|
|
34
34
|
options.browserInputTimeout = String(browser.inputTimeoutMs);
|
|
35
35
|
}
|
|
36
|
+
if (isUnset('browserRecheckDelay') && typeof browser.assistantRecheckDelayMs === 'number') {
|
|
37
|
+
options.browserRecheckDelay = String(browser.assistantRecheckDelayMs);
|
|
38
|
+
}
|
|
39
|
+
if (isUnset('browserRecheckTimeout') && typeof browser.assistantRecheckTimeoutMs === 'number') {
|
|
40
|
+
options.browserRecheckTimeout = String(browser.assistantRecheckTimeoutMs);
|
|
41
|
+
}
|
|
42
|
+
if (isUnset('browserReuseWait') && typeof browser.reuseChromeWaitMs === 'number') {
|
|
43
|
+
options.browserReuseWait = String(browser.reuseChromeWaitMs);
|
|
44
|
+
}
|
|
45
|
+
if (isUnset('browserProfileLockTimeout') && typeof browser.profileLockTimeoutMs === 'number') {
|
|
46
|
+
options.browserProfileLockTimeout = String(browser.profileLockTimeoutMs);
|
|
47
|
+
}
|
|
48
|
+
if (isUnset('browserAutoReattachDelay') && typeof browser.autoReattachDelayMs === 'number') {
|
|
49
|
+
options.browserAutoReattachDelay = String(browser.autoReattachDelayMs);
|
|
50
|
+
}
|
|
51
|
+
if (isUnset('browserAutoReattachInterval') && typeof browser.autoReattachIntervalMs === 'number') {
|
|
52
|
+
options.browserAutoReattachInterval = String(browser.autoReattachIntervalMs);
|
|
53
|
+
}
|
|
54
|
+
if (isUnset('browserAutoReattachTimeout') && typeof browser.autoReattachTimeoutMs === 'number') {
|
|
55
|
+
options.browserAutoReattachTimeout = String(browser.autoReattachTimeoutMs);
|
|
56
|
+
}
|
|
36
57
|
if (isUnset('browserCookieWait') && typeof browser.cookieSyncWaitMs === 'number') {
|
|
37
58
|
options.browserCookieWait = String(browser.cookieSyncWaitMs);
|
|
38
59
|
}
|