@steipete/oracle 0.4.5 → 0.5.1
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 +11 -9
- package/dist/.DS_Store +0 -0
- package/dist/bin/oracle-cli.js +16 -48
- package/dist/scripts/agent-send.js +147 -0
- package/dist/scripts/docs-list.js +110 -0
- package/dist/scripts/git-policy.js +125 -0
- package/dist/scripts/runner.js +1378 -0
- package/dist/scripts/test-browser.js +103 -0
- package/dist/scripts/test-remote-chrome.js +68 -0
- package/dist/src/browser/actions/attachments.js +47 -16
- package/dist/src/browser/actions/promptComposer.js +67 -18
- package/dist/src/browser/actions/remoteFileTransfer.js +36 -4
- package/dist/src/browser/chromeCookies.js +44 -6
- package/dist/src/browser/chromeLifecycle.js +166 -25
- package/dist/src/browser/config.js +25 -1
- package/dist/src/browser/constants.js +22 -3
- package/dist/src/browser/index.js +384 -22
- package/dist/src/browser/profileSync.js +141 -0
- package/dist/src/browser/prompt.js +3 -1
- package/dist/src/browser/reattach.js +59 -0
- package/dist/src/browser/sessionRunner.js +15 -1
- package/dist/src/browser/windowsCookies.js +2 -1
- package/dist/src/cli/browserConfig.js +11 -0
- package/dist/src/cli/browserDefaults.js +41 -0
- package/dist/src/cli/detach.js +2 -2
- package/dist/src/cli/dryRun.js +4 -2
- package/dist/src/cli/engine.js +2 -2
- package/dist/src/cli/help.js +2 -2
- package/dist/src/cli/options.js +2 -1
- package/dist/src/cli/runOptions.js +1 -1
- package/dist/src/cli/sessionDisplay.js +102 -104
- package/dist/src/cli/sessionRunner.js +39 -6
- package/dist/src/cli/sessionTable.js +88 -0
- package/dist/src/cli/tui/index.js +19 -89
- package/dist/src/heartbeat.js +2 -2
- package/dist/src/oracle/background.js +10 -2
- package/dist/src/oracle/client.js +107 -0
- package/dist/src/oracle/config.js +10 -2
- package/dist/src/oracle/errors.js +24 -4
- package/dist/src/oracle/modelResolver.js +144 -0
- package/dist/src/oracle/oscProgress.js +1 -1
- package/dist/src/oracle/run.js +83 -34
- package/dist/src/oracle/runUtils.js +12 -8
- package/dist/src/remote/server.js +214 -23
- package/dist/src/sessionManager.js +5 -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 +14 -14
|
@@ -1,14 +1,16 @@
|
|
|
1
|
-
import { mkdtemp, rm } from 'node:fs/promises';
|
|
1
|
+
import { mkdtemp, rm, mkdir, readFile } from 'node:fs/promises';
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
import os from 'node:os';
|
|
4
|
+
import net from 'node:net';
|
|
4
5
|
import { resolveBrowserConfig } from './config.js';
|
|
5
|
-
import { launchChrome, registerTerminationHooks, hideChromeWindow, connectToChrome, connectToRemoteChrome, } from './chromeLifecycle.js';
|
|
6
|
+
import { launchChrome, registerTerminationHooks, hideChromeWindow, connectToChrome, connectToRemoteChrome, closeRemoteChromeTarget, } from './chromeLifecycle.js';
|
|
6
7
|
import { syncCookies } from './cookies.js';
|
|
7
8
|
import { navigateToChatGPT, ensureNotBlocked, ensureLoggedIn, ensurePromptReady, ensureModelSelection, submitPrompt, waitForAssistantResponse, captureAssistantMarkdown, uploadAttachmentFile, waitForAttachmentCompletion, readAssistantSnapshot, } from './pageActions.js';
|
|
8
9
|
import { uploadAttachmentViaDataTransfer } from './actions/remoteFileTransfer.js';
|
|
9
|
-
import { estimateTokenCount, withRetries } from './utils.js';
|
|
10
|
+
import { estimateTokenCount, withRetries, delay } from './utils.js';
|
|
10
11
|
import { formatElapsed } from '../oracle/format.js';
|
|
11
12
|
import { CHATGPT_URL } from './constants.js';
|
|
13
|
+
import { BrowserAutomationError } from '../oracle/errors.js';
|
|
12
14
|
export { CHATGPT_URL, DEFAULT_MODEL_TARGET } from './constants.js';
|
|
13
15
|
export { parseDuration, delay, normalizeChatgptUrl } from './utils.js';
|
|
14
16
|
export async function runBrowserMode(options) {
|
|
@@ -17,7 +19,7 @@ export async function runBrowserMode(options) {
|
|
|
17
19
|
throw new Error('Prompt text is required when using browser mode.');
|
|
18
20
|
}
|
|
19
21
|
const attachments = options.attachments ?? [];
|
|
20
|
-
|
|
22
|
+
let config = resolveBrowserConfig(options.config);
|
|
21
23
|
const logger = options.log ?? ((_message) => { });
|
|
22
24
|
if (logger.verbose === undefined) {
|
|
23
25
|
logger.verbose = Boolean(config.debug);
|
|
@@ -25,12 +27,44 @@ export async function runBrowserMode(options) {
|
|
|
25
27
|
if (logger.sessionLog === undefined && options.log?.sessionLog) {
|
|
26
28
|
logger.sessionLog = options.log.sessionLog;
|
|
27
29
|
}
|
|
30
|
+
const runtimeHintCb = options.runtimeHintCb;
|
|
31
|
+
let lastTargetId;
|
|
32
|
+
let lastUrl;
|
|
33
|
+
const emitRuntimeHint = async () => {
|
|
34
|
+
if (!runtimeHintCb || !chrome?.port) {
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
const hint = {
|
|
38
|
+
chromePid: chrome.pid,
|
|
39
|
+
chromePort: chrome.port,
|
|
40
|
+
chromeHost,
|
|
41
|
+
chromeTargetId: lastTargetId,
|
|
42
|
+
tabUrl: lastUrl,
|
|
43
|
+
userDataDir,
|
|
44
|
+
controllerPid: process.pid,
|
|
45
|
+
};
|
|
46
|
+
try {
|
|
47
|
+
await runtimeHintCb(hint);
|
|
48
|
+
}
|
|
49
|
+
catch (error) {
|
|
50
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
51
|
+
logger(`Failed to persist runtime hint: ${message}`);
|
|
52
|
+
}
|
|
53
|
+
};
|
|
28
54
|
if (config.debug || process.env.CHATGPT_DEVTOOLS_TRACE === '1') {
|
|
29
55
|
logger(`[browser-mode] config: ${JSON.stringify({
|
|
30
56
|
...config,
|
|
31
57
|
promptLength: promptText.length,
|
|
32
58
|
})}`);
|
|
33
59
|
}
|
|
60
|
+
if (!config.remoteChrome && !config.manualLogin) {
|
|
61
|
+
const preferredPort = config.debugPort ?? DEFAULT_DEBUG_PORT;
|
|
62
|
+
const availablePort = await pickAvailableDebugPort(preferredPort, logger);
|
|
63
|
+
if (availablePort !== preferredPort) {
|
|
64
|
+
logger(`DevTools port ${preferredPort} busy; using ${availablePort} to avoid attaching to stray Chrome.`);
|
|
65
|
+
}
|
|
66
|
+
config = { ...config, debugPort: availablePort };
|
|
67
|
+
}
|
|
34
68
|
// Remote Chrome mode - connect to existing browser
|
|
35
69
|
if (config.remoteChrome) {
|
|
36
70
|
// Warn about ignored local-only options
|
|
@@ -40,12 +74,31 @@ export async function runBrowserMode(options) {
|
|
|
40
74
|
}
|
|
41
75
|
return runRemoteBrowserMode(promptText, attachments, config, logger, options);
|
|
42
76
|
}
|
|
43
|
-
const
|
|
44
|
-
|
|
45
|
-
|
|
77
|
+
const manualLogin = Boolean(config.manualLogin);
|
|
78
|
+
const manualProfileDir = config.manualLoginProfileDir
|
|
79
|
+
? path.resolve(config.manualLoginProfileDir)
|
|
80
|
+
: path.join(os.homedir(), '.oracle', 'browser-profile');
|
|
81
|
+
const userDataDir = manualLogin
|
|
82
|
+
? manualProfileDir
|
|
83
|
+
: await mkdtemp(path.join(await resolveUserDataBaseDir(), 'oracle-browser-'));
|
|
84
|
+
if (manualLogin) {
|
|
85
|
+
await mkdir(userDataDir, { recursive: true });
|
|
86
|
+
logger(`Manual login mode enabled; reusing persistent profile at ${userDataDir}`);
|
|
87
|
+
}
|
|
88
|
+
else {
|
|
89
|
+
logger(`Created temporary Chrome profile at ${userDataDir}`);
|
|
90
|
+
}
|
|
91
|
+
const effectiveKeepBrowser = config.keepBrowser || manualLogin;
|
|
92
|
+
const reusedChrome = manualLogin ? await maybeReuseRunningChrome(userDataDir, logger) : null;
|
|
93
|
+
const chrome = reusedChrome ??
|
|
94
|
+
(await launchChrome({
|
|
95
|
+
...config,
|
|
96
|
+
remoteChrome: config.remoteChrome,
|
|
97
|
+
}, userDataDir, logger));
|
|
98
|
+
const chromeHost = chrome.host ?? '127.0.0.1';
|
|
46
99
|
let removeTerminationHooks = null;
|
|
47
100
|
try {
|
|
48
|
-
removeTerminationHooks = registerTerminationHooks(chrome, userDataDir,
|
|
101
|
+
removeTerminationHooks = registerTerminationHooks(chrome, userDataDir, effectiveKeepBrowser, logger);
|
|
49
102
|
}
|
|
50
103
|
catch {
|
|
51
104
|
// ignore failure; cleanup still happens below
|
|
@@ -60,7 +113,16 @@ export async function runBrowserMode(options) {
|
|
|
60
113
|
let stopThinkingMonitor = null;
|
|
61
114
|
let appliedCookies = 0;
|
|
62
115
|
try {
|
|
63
|
-
|
|
116
|
+
try {
|
|
117
|
+
client = await connectToChrome(chrome.port, logger, chromeHost);
|
|
118
|
+
}
|
|
119
|
+
catch (error) {
|
|
120
|
+
const hint = describeDevtoolsFirewallHint(chromeHost, chrome.port);
|
|
121
|
+
if (hint) {
|
|
122
|
+
logger(hint);
|
|
123
|
+
}
|
|
124
|
+
throw error;
|
|
125
|
+
}
|
|
64
126
|
const disconnectPromise = new Promise((_, reject) => {
|
|
65
127
|
client?.on('disconnect', () => {
|
|
66
128
|
connectionClosedUnexpectedly = true;
|
|
@@ -78,8 +140,11 @@ export async function runBrowserMode(options) {
|
|
|
78
140
|
domainEnablers.push(DOM.enable());
|
|
79
141
|
}
|
|
80
142
|
await Promise.all(domainEnablers);
|
|
81
|
-
|
|
82
|
-
|
|
143
|
+
if (!manualLogin) {
|
|
144
|
+
await Network.clearBrowserCookies();
|
|
145
|
+
}
|
|
146
|
+
const cookieSyncEnabled = config.cookieSync && !manualLogin;
|
|
147
|
+
if (cookieSyncEnabled) {
|
|
83
148
|
if (!config.inlineCookies) {
|
|
84
149
|
logger('Heads-up: macOS may prompt for your Keychain password to read Chrome cookies; use --copy or --render for manual flow.');
|
|
85
150
|
}
|
|
@@ -105,20 +170,68 @@ export async function runBrowserMode(options) {
|
|
|
105
170
|
: 'No Chrome cookies found; continuing without session reuse');
|
|
106
171
|
}
|
|
107
172
|
else {
|
|
108
|
-
logger(
|
|
173
|
+
logger(manualLogin
|
|
174
|
+
? 'Skipping Chrome cookie sync (--browser-manual-login enabled); reuse the opened profile after signing in.'
|
|
175
|
+
: 'Skipping Chrome cookie sync (--browser-no-cookie-sync)');
|
|
176
|
+
}
|
|
177
|
+
if (cookieSyncEnabled && !manualLogin && (appliedCookies ?? 0) === 0 && !config.inlineCookies) {
|
|
178
|
+
throw new BrowserAutomationError('No ChatGPT cookies were applied from your Chrome profile; cannot proceed in browser mode. ' +
|
|
179
|
+
'Make sure ChatGPT is signed in in the selected profile or rebuild the keytar native module if it failed to load.', {
|
|
180
|
+
stage: 'execute-browser',
|
|
181
|
+
details: {
|
|
182
|
+
profile: config.chromeProfile ?? 'Default',
|
|
183
|
+
cookiePath: config.chromeCookiePath ?? null,
|
|
184
|
+
hint: 'Rebuild keytar: PYTHON=/usr/bin/python3 /Users/steipete/Projects/oracle/runner npx node-gyp rebuild (run inside the keytar path from the error), then retry.',
|
|
185
|
+
},
|
|
186
|
+
});
|
|
109
187
|
}
|
|
110
188
|
const baseUrl = CHATGPT_URL;
|
|
111
189
|
// First load the base ChatGPT homepage to satisfy potential interstitials,
|
|
112
190
|
// then hop to the requested URL if it differs.
|
|
113
191
|
await raceWithDisconnect(navigateToChatGPT(Page, Runtime, baseUrl, logger));
|
|
114
192
|
await raceWithDisconnect(ensureNotBlocked(Runtime, config.headless, logger));
|
|
115
|
-
await raceWithDisconnect(
|
|
193
|
+
await raceWithDisconnect(waitForLogin({ runtime: Runtime, logger, appliedCookies, manualLogin, timeoutMs: config.timeoutMs }));
|
|
116
194
|
if (config.url !== baseUrl) {
|
|
117
195
|
await raceWithDisconnect(navigateToChatGPT(Page, Runtime, config.url, logger));
|
|
118
196
|
await raceWithDisconnect(ensureNotBlocked(Runtime, config.headless, logger));
|
|
119
197
|
}
|
|
120
198
|
await raceWithDisconnect(ensurePromptReady(Runtime, config.inputTimeoutMs, logger));
|
|
121
199
|
logger(`Prompt textarea ready (initial focus, ${promptText.length.toLocaleString()} chars queued)`);
|
|
200
|
+
const captureRuntimeSnapshot = async () => {
|
|
201
|
+
try {
|
|
202
|
+
if (client?.Target?.getTargetInfo) {
|
|
203
|
+
const info = await client.Target.getTargetInfo({});
|
|
204
|
+
lastTargetId = info?.targetInfo?.targetId ?? lastTargetId;
|
|
205
|
+
lastUrl = info?.targetInfo?.url ?? lastUrl;
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
catch {
|
|
209
|
+
// ignore
|
|
210
|
+
}
|
|
211
|
+
try {
|
|
212
|
+
const { result } = await Runtime.evaluate({
|
|
213
|
+
expression: 'location.href',
|
|
214
|
+
returnByValue: true,
|
|
215
|
+
});
|
|
216
|
+
if (typeof result?.value === 'string') {
|
|
217
|
+
lastUrl = result.value;
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
catch {
|
|
221
|
+
// ignore
|
|
222
|
+
}
|
|
223
|
+
if (chrome?.port) {
|
|
224
|
+
const suffix = lastTargetId ? ` target=${lastTargetId}` : '';
|
|
225
|
+
if (lastUrl) {
|
|
226
|
+
logger(`[reattach] chrome port=${chrome.port} host=${chromeHost} url=${lastUrl}${suffix}`);
|
|
227
|
+
}
|
|
228
|
+
else {
|
|
229
|
+
logger(`[reattach] chrome port=${chrome.port} host=${chromeHost}${suffix}`);
|
|
230
|
+
}
|
|
231
|
+
await emitRuntimeHint();
|
|
232
|
+
}
|
|
233
|
+
};
|
|
234
|
+
await captureRuntimeSnapshot();
|
|
122
235
|
if (config.desiredModel) {
|
|
123
236
|
await raceWithDisconnect(withRetries(() => ensureModelSelection(Runtime, config.desiredModel, logger), {
|
|
124
237
|
retries: 2,
|
|
@@ -223,7 +336,11 @@ export async function runBrowserMode(options) {
|
|
|
223
336
|
answerChars,
|
|
224
337
|
chromePid: chrome.pid,
|
|
225
338
|
chromePort: chrome.port,
|
|
339
|
+
chromeHost,
|
|
226
340
|
userDataDir,
|
|
341
|
+
chromeTargetId: lastTargetId,
|
|
342
|
+
tabUrl: lastUrl,
|
|
343
|
+
controllerPid: process.pid,
|
|
227
344
|
};
|
|
228
345
|
}
|
|
229
346
|
catch (error) {
|
|
@@ -242,9 +359,19 @@ export async function runBrowserMode(options) {
|
|
|
242
359
|
logger(`Chrome window closed before completion: ${normalizedError.message}`);
|
|
243
360
|
logger(normalizedError.stack);
|
|
244
361
|
}
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
362
|
+
await emitRuntimeHint();
|
|
363
|
+
throw new BrowserAutomationError('Chrome window closed before oracle finished. Please keep it open until completion.', {
|
|
364
|
+
stage: 'connection-lost',
|
|
365
|
+
runtime: {
|
|
366
|
+
chromePid: chrome.pid,
|
|
367
|
+
chromePort: chrome.port,
|
|
368
|
+
chromeHost,
|
|
369
|
+
userDataDir,
|
|
370
|
+
chromeTargetId: lastTargetId,
|
|
371
|
+
tabUrl: lastUrl,
|
|
372
|
+
controllerPid: process.pid,
|
|
373
|
+
},
|
|
374
|
+
}, normalizedError);
|
|
248
375
|
}
|
|
249
376
|
finally {
|
|
250
377
|
try {
|
|
@@ -256,7 +383,7 @@ export async function runBrowserMode(options) {
|
|
|
256
383
|
// ignore
|
|
257
384
|
}
|
|
258
385
|
removeTerminationHooks?.();
|
|
259
|
-
if (!
|
|
386
|
+
if (!effectiveKeepBrowser) {
|
|
260
387
|
if (!connectionClosedUnexpectedly) {
|
|
261
388
|
try {
|
|
262
389
|
await chrome.kill();
|
|
@@ -276,6 +403,153 @@ export async function runBrowserMode(options) {
|
|
|
276
403
|
}
|
|
277
404
|
}
|
|
278
405
|
}
|
|
406
|
+
const DEFAULT_DEBUG_PORT = 9222;
|
|
407
|
+
async function pickAvailableDebugPort(preferredPort, logger) {
|
|
408
|
+
const start = Number.isFinite(preferredPort) && preferredPort > 0 ? preferredPort : DEFAULT_DEBUG_PORT;
|
|
409
|
+
for (let offset = 0; offset < 10; offset++) {
|
|
410
|
+
const candidate = start + offset;
|
|
411
|
+
if (await isPortAvailable(candidate)) {
|
|
412
|
+
return candidate;
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
const fallback = await findEphemeralPort();
|
|
416
|
+
logger(`DevTools ports ${start}-${start + 9} are occupied; falling back to ${fallback}.`);
|
|
417
|
+
return fallback;
|
|
418
|
+
}
|
|
419
|
+
async function isPortAvailable(port) {
|
|
420
|
+
return new Promise((resolve) => {
|
|
421
|
+
const server = net.createServer();
|
|
422
|
+
server.once('error', () => resolve(false));
|
|
423
|
+
server.once('listening', () => {
|
|
424
|
+
server.close(() => resolve(true));
|
|
425
|
+
});
|
|
426
|
+
server.listen(port, '127.0.0.1');
|
|
427
|
+
});
|
|
428
|
+
}
|
|
429
|
+
async function findEphemeralPort() {
|
|
430
|
+
return new Promise((resolve, reject) => {
|
|
431
|
+
const server = net.createServer();
|
|
432
|
+
server.once('error', (error) => {
|
|
433
|
+
server.close();
|
|
434
|
+
reject(error);
|
|
435
|
+
});
|
|
436
|
+
server.listen(0, '127.0.0.1', () => {
|
|
437
|
+
const address = server.address();
|
|
438
|
+
if (address && typeof address === 'object') {
|
|
439
|
+
const port = address.port;
|
|
440
|
+
server.close(() => resolve(port));
|
|
441
|
+
}
|
|
442
|
+
else {
|
|
443
|
+
server.close(() => reject(new Error('Failed to acquire ephemeral port')));
|
|
444
|
+
}
|
|
445
|
+
});
|
|
446
|
+
});
|
|
447
|
+
}
|
|
448
|
+
async function waitForLogin({ runtime, logger, appliedCookies, manualLogin, timeoutMs, }) {
|
|
449
|
+
if (!manualLogin) {
|
|
450
|
+
await ensureLoggedIn(runtime, logger, { appliedCookies });
|
|
451
|
+
return;
|
|
452
|
+
}
|
|
453
|
+
const deadline = Date.now() + Math.min(timeoutMs ?? 1_200_000, 20 * 60_000);
|
|
454
|
+
let lastNotice = 0;
|
|
455
|
+
while (Date.now() < deadline) {
|
|
456
|
+
try {
|
|
457
|
+
await ensureLoggedIn(runtime, logger, { appliedCookies });
|
|
458
|
+
return;
|
|
459
|
+
}
|
|
460
|
+
catch (error) {
|
|
461
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
462
|
+
const loginDetected = message?.toLowerCase().includes('login button');
|
|
463
|
+
const sessionMissing = message?.toLowerCase().includes('session not detected');
|
|
464
|
+
if (!loginDetected && !sessionMissing) {
|
|
465
|
+
throw error;
|
|
466
|
+
}
|
|
467
|
+
const now = Date.now();
|
|
468
|
+
if (now - lastNotice > 5000) {
|
|
469
|
+
logger('Manual login mode: please sign into chatgpt.com in the opened Chrome window; waiting for session to appear...');
|
|
470
|
+
lastNotice = now;
|
|
471
|
+
}
|
|
472
|
+
await delay(1000);
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
throw new Error('Manual login mode timed out waiting for ChatGPT session; please sign in and retry.');
|
|
476
|
+
}
|
|
477
|
+
async function _assertNavigatedToHttp(runtime, _logger, timeoutMs = 10_000) {
|
|
478
|
+
const deadline = Date.now() + timeoutMs;
|
|
479
|
+
let lastUrl = '';
|
|
480
|
+
while (Date.now() < deadline) {
|
|
481
|
+
const { result } = await runtime.evaluate({
|
|
482
|
+
expression: 'typeof location === "object" && location.href ? location.href : ""',
|
|
483
|
+
returnByValue: true,
|
|
484
|
+
});
|
|
485
|
+
const url = typeof result?.value === 'string' ? result.value : '';
|
|
486
|
+
lastUrl = url;
|
|
487
|
+
if (/^https?:\/\//i.test(url)) {
|
|
488
|
+
return url;
|
|
489
|
+
}
|
|
490
|
+
await delay(250);
|
|
491
|
+
}
|
|
492
|
+
throw new BrowserAutomationError('ChatGPT session not detected; page never left new tab.', {
|
|
493
|
+
stage: 'execute-browser',
|
|
494
|
+
details: { url: lastUrl || '(empty)' },
|
|
495
|
+
});
|
|
496
|
+
}
|
|
497
|
+
async function maybeReuseRunningChrome(userDataDir, logger) {
|
|
498
|
+
const port = await readDevToolsPort(userDataDir);
|
|
499
|
+
if (!port)
|
|
500
|
+
return null;
|
|
501
|
+
const versionUrl = `http://127.0.0.1:${port}/json/version`;
|
|
502
|
+
try {
|
|
503
|
+
const controller = new AbortController();
|
|
504
|
+
const timeout = setTimeout(() => controller.abort(), 1500);
|
|
505
|
+
const response = await fetch(versionUrl, { signal: controller.signal });
|
|
506
|
+
clearTimeout(timeout);
|
|
507
|
+
if (!response.ok)
|
|
508
|
+
throw new Error(`HTTP ${response.status}`);
|
|
509
|
+
const pidPath = path.join(userDataDir, 'chrome.pid');
|
|
510
|
+
let pid;
|
|
511
|
+
try {
|
|
512
|
+
const rawPid = (await readFile(pidPath, 'utf8')).trim();
|
|
513
|
+
pid = Number.parseInt(rawPid, 10);
|
|
514
|
+
if (Number.isNaN(pid))
|
|
515
|
+
pid = undefined;
|
|
516
|
+
}
|
|
517
|
+
catch {
|
|
518
|
+
pid = undefined;
|
|
519
|
+
}
|
|
520
|
+
logger(`Found running Chrome for ${userDataDir}; reusing (DevTools port ${port}${pid ? `, pid ${pid}` : ''})`);
|
|
521
|
+
return {
|
|
522
|
+
port,
|
|
523
|
+
pid,
|
|
524
|
+
kill: async () => { },
|
|
525
|
+
process: undefined,
|
|
526
|
+
};
|
|
527
|
+
}
|
|
528
|
+
catch (error) {
|
|
529
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
530
|
+
logger(`DevToolsActivePort found for ${userDataDir} but unreachable (${message}); launching new Chrome.`);
|
|
531
|
+
return null;
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
async function readDevToolsPort(userDataDir) {
|
|
535
|
+
const candidates = [
|
|
536
|
+
path.join(userDataDir, 'DevToolsActivePort'),
|
|
537
|
+
path.join(userDataDir, 'Default', 'DevToolsActivePort'),
|
|
538
|
+
];
|
|
539
|
+
for (const candidate of candidates) {
|
|
540
|
+
try {
|
|
541
|
+
const raw = await readFile(candidate, 'utf8');
|
|
542
|
+
const firstLine = raw.split(/\r?\n/u)[0]?.trim();
|
|
543
|
+
const port = Number.parseInt(firstLine ?? '', 10);
|
|
544
|
+
if (Number.isFinite(port)) {
|
|
545
|
+
return port;
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
catch {
|
|
549
|
+
}
|
|
550
|
+
}
|
|
551
|
+
return null;
|
|
552
|
+
}
|
|
279
553
|
async function runRemoteBrowserMode(promptText, attachments, config, logger, options) {
|
|
280
554
|
const remoteChromeConfig = config.remoteChrome;
|
|
281
555
|
if (!remoteChromeConfig) {
|
|
@@ -284,6 +558,26 @@ async function runRemoteBrowserMode(promptText, attachments, config, logger, opt
|
|
|
284
558
|
const { host, port } = remoteChromeConfig;
|
|
285
559
|
logger(`Connecting to remote Chrome at ${host}:${port}`);
|
|
286
560
|
let client = null;
|
|
561
|
+
let remoteTargetId = null;
|
|
562
|
+
let lastUrl;
|
|
563
|
+
const runtimeHintCb = options.runtimeHintCb;
|
|
564
|
+
const emitRuntimeHint = async () => {
|
|
565
|
+
if (!runtimeHintCb)
|
|
566
|
+
return;
|
|
567
|
+
try {
|
|
568
|
+
await runtimeHintCb({
|
|
569
|
+
chromePort: port,
|
|
570
|
+
chromeHost: host,
|
|
571
|
+
chromeTargetId: remoteTargetId ?? undefined,
|
|
572
|
+
tabUrl: lastUrl,
|
|
573
|
+
controllerPid: process.pid,
|
|
574
|
+
});
|
|
575
|
+
}
|
|
576
|
+
catch (error) {
|
|
577
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
578
|
+
logger(`Failed to persist runtime hint: ${message}`);
|
|
579
|
+
}
|
|
580
|
+
};
|
|
287
581
|
const startedAt = Date.now();
|
|
288
582
|
let answerText = '';
|
|
289
583
|
let answerMarkdown = '';
|
|
@@ -291,7 +585,10 @@ async function runRemoteBrowserMode(promptText, attachments, config, logger, opt
|
|
|
291
585
|
let connectionClosedUnexpectedly = false;
|
|
292
586
|
let stopThinkingMonitor = null;
|
|
293
587
|
try {
|
|
294
|
-
|
|
588
|
+
const connection = await connectToRemoteChrome(host, port, logger, config.url);
|
|
589
|
+
client = connection.client;
|
|
590
|
+
remoteTargetId = connection.targetId ?? null;
|
|
591
|
+
await emitRuntimeHint();
|
|
295
592
|
const markConnectionLost = () => {
|
|
296
593
|
connectionClosedUnexpectedly = true;
|
|
297
594
|
};
|
|
@@ -309,6 +606,19 @@ async function runRemoteBrowserMode(promptText, attachments, config, logger, opt
|
|
|
309
606
|
await ensureLoggedIn(Runtime, logger, { remoteSession: true });
|
|
310
607
|
await ensurePromptReady(Runtime, config.inputTimeoutMs, logger);
|
|
311
608
|
logger(`Prompt textarea ready (initial focus, ${promptText.length.toLocaleString()} chars queued)`);
|
|
609
|
+
try {
|
|
610
|
+
const { result } = await Runtime.evaluate({
|
|
611
|
+
expression: 'location.href',
|
|
612
|
+
returnByValue: true,
|
|
613
|
+
});
|
|
614
|
+
if (typeof result?.value === 'string') {
|
|
615
|
+
lastUrl = result.value;
|
|
616
|
+
}
|
|
617
|
+
await emitRuntimeHint();
|
|
618
|
+
}
|
|
619
|
+
catch {
|
|
620
|
+
// ignore
|
|
621
|
+
}
|
|
312
622
|
if (config.desiredModel) {
|
|
313
623
|
await withRetries(() => ensureModelSelection(Runtime, config.desiredModel, logger), {
|
|
314
624
|
retries: 2,
|
|
@@ -369,7 +679,11 @@ async function runRemoteBrowserMode(promptText, attachments, config, logger, opt
|
|
|
369
679
|
answerChars,
|
|
370
680
|
chromePid: undefined,
|
|
371
681
|
chromePort: port,
|
|
682
|
+
chromeHost: host,
|
|
372
683
|
userDataDir: undefined,
|
|
684
|
+
chromeTargetId: remoteTargetId ?? undefined,
|
|
685
|
+
tabUrl: lastUrl,
|
|
686
|
+
controllerPid: process.pid,
|
|
373
687
|
};
|
|
374
688
|
}
|
|
375
689
|
catch (error) {
|
|
@@ -384,8 +698,15 @@ async function runRemoteBrowserMode(promptText, attachments, config, logger, opt
|
|
|
384
698
|
}
|
|
385
699
|
throw normalizedError;
|
|
386
700
|
}
|
|
387
|
-
throw new
|
|
388
|
-
|
|
701
|
+
throw new BrowserAutomationError('Remote Chrome connection lost before Oracle finished.', {
|
|
702
|
+
stage: 'connection-lost',
|
|
703
|
+
runtime: {
|
|
704
|
+
chromeHost: host,
|
|
705
|
+
chromePort: port,
|
|
706
|
+
chromeTargetId: remoteTargetId ?? undefined,
|
|
707
|
+
tabUrl: lastUrl,
|
|
708
|
+
controllerPid: process.pid,
|
|
709
|
+
},
|
|
389
710
|
});
|
|
390
711
|
}
|
|
391
712
|
finally {
|
|
@@ -397,6 +718,7 @@ async function runRemoteBrowserMode(promptText, attachments, config, logger, opt
|
|
|
397
718
|
catch {
|
|
398
719
|
// ignore
|
|
399
720
|
}
|
|
721
|
+
await closeRemoteChromeTarget(host, port, remoteTargetId ?? undefined, logger);
|
|
400
722
|
// Don't kill remote Chrome - it's not ours to manage
|
|
401
723
|
const totalSeconds = (Date.now() - startedAt) / 1000;
|
|
402
724
|
logger(`Remote session complete • ${totalSeconds.toFixed(1)}s total`);
|
|
@@ -429,7 +751,7 @@ function startThinkingStatusMonitor(Runtime, logger, includeDiagnostics = false)
|
|
|
429
751
|
let lastMessage = null;
|
|
430
752
|
const startedAt = Date.now();
|
|
431
753
|
const interval = setInterval(async () => {
|
|
432
|
-
//
|
|
754
|
+
// stop flag flips asynchronously
|
|
433
755
|
if (stopped || pending) {
|
|
434
756
|
return;
|
|
435
757
|
}
|
|
@@ -460,7 +782,7 @@ function startThinkingStatusMonitor(Runtime, logger, includeDiagnostics = false)
|
|
|
460
782
|
}, 1500);
|
|
461
783
|
interval.unref?.();
|
|
462
784
|
return () => {
|
|
463
|
-
//
|
|
785
|
+
// multiple callers may race to stop
|
|
464
786
|
if (stopped) {
|
|
465
787
|
return;
|
|
466
788
|
}
|
|
@@ -491,6 +813,46 @@ function sanitizeThinkingText(raw) {
|
|
|
491
813
|
}
|
|
492
814
|
return trimmed;
|
|
493
815
|
}
|
|
816
|
+
function describeDevtoolsFirewallHint(host, port) {
|
|
817
|
+
if (!isWsl())
|
|
818
|
+
return null;
|
|
819
|
+
return [
|
|
820
|
+
`DevTools port ${host}:${port} is blocked from WSL.`,
|
|
821
|
+
'',
|
|
822
|
+
'PowerShell (admin):',
|
|
823
|
+
`New-NetFirewallRule -DisplayName 'Chrome DevTools ${port}' -Direction Inbound -Action Allow -Protocol TCP -LocalPort ${port}`,
|
|
824
|
+
"New-NetFirewallRule -DisplayName 'Chrome DevTools (chrome.exe)' -Direction Inbound -Action Allow -Program 'C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe' -Protocol TCP",
|
|
825
|
+
'',
|
|
826
|
+
'Re-run the same oracle command after adding the rule.',
|
|
827
|
+
].join('\n');
|
|
828
|
+
}
|
|
829
|
+
function isWsl() {
|
|
830
|
+
if (process.platform !== 'linux')
|
|
831
|
+
return false;
|
|
832
|
+
if (process.env.WSL_DISTRO_NAME)
|
|
833
|
+
return true;
|
|
834
|
+
return os.release().toLowerCase().includes('microsoft');
|
|
835
|
+
}
|
|
836
|
+
async function resolveUserDataBaseDir() {
|
|
837
|
+
// On WSL, Chrome launched via Windows can choke on UNC paths; prefer a Windows-backed temp folder.
|
|
838
|
+
if (isWsl()) {
|
|
839
|
+
const candidates = [
|
|
840
|
+
'/mnt/c/Users/Public/AppData/Local/Temp',
|
|
841
|
+
'/mnt/c/Temp',
|
|
842
|
+
'/mnt/c/Windows/Temp',
|
|
843
|
+
];
|
|
844
|
+
for (const candidate of candidates) {
|
|
845
|
+
try {
|
|
846
|
+
await mkdir(candidate, { recursive: true });
|
|
847
|
+
return candidate;
|
|
848
|
+
}
|
|
849
|
+
catch {
|
|
850
|
+
// try next
|
|
851
|
+
}
|
|
852
|
+
}
|
|
853
|
+
}
|
|
854
|
+
return os.tmpdir();
|
|
855
|
+
}
|
|
494
856
|
function buildThinkingStatusExpression() {
|
|
495
857
|
const selectors = [
|
|
496
858
|
'span.loading-shimmer',
|