@steipete/oracle 0.14.0 → 0.15.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (31) hide show
  1. package/README.md +2 -2
  2. package/dist/bin/oracle-cli.js +10 -8
  3. package/dist/docs-site/browser-mode.html +3 -2
  4. package/dist/docs-site/cli-reference.html +1 -1
  5. package/dist/docs-site/mcp.html +1 -1
  6. package/dist/src/browser/actions/assistantResponse.js +2 -1
  7. package/dist/src/browser/actions/deepResearch.js +132 -61
  8. package/dist/src/browser/actions/modelSelection.js +388 -30
  9. package/dist/src/browser/actions/thinkingTime.js +303 -65
  10. package/dist/src/browser/artifacts.js +2 -8
  11. package/dist/src/browser/chatgptFiles.js +198 -49
  12. package/dist/src/browser/chatgptImages.js +126 -24
  13. package/dist/src/browser/chromeLifecycle.js +35 -4
  14. package/dist/src/browser/deepResearchResult.js +23 -0
  15. package/dist/src/browser/index.js +145 -19
  16. package/dist/src/browser/profileCopy.js +93 -0
  17. package/dist/src/browser/projectSourcesRunner.js +2 -1
  18. package/dist/src/browser/prompt.js +151 -22
  19. package/dist/src/cli/browserConfig.js +19 -2
  20. package/dist/src/cli/browserDefaults.js +2 -1
  21. package/dist/src/cli/options.js +8 -0
  22. package/dist/src/cli/sessionRunner.js +13 -7
  23. package/dist/src/mcp/tools/chatgptImage.js +8 -3
  24. package/dist/src/mcp/tools/consult.js +9 -8
  25. package/dist/src/mcp/types.js +11 -2
  26. package/dist/src/oracle/thinkingTime.js +40 -0
  27. package/dist/vendor/oracle-notifier/OracleNotifier.app/Contents/CodeResources +0 -0
  28. package/dist/vendor/oracle-notifier/OracleNotifier.app/Contents/MacOS/OracleNotifier +0 -0
  29. package/package.json +6 -6
  30. package/vendor/oracle-notifier/OracleNotifier.app/Contents/CodeResources +0 -0
  31. package/vendor/oracle-notifier/OracleNotifier.app/Contents/MacOS/OracleNotifier +0 -0
@@ -3,6 +3,7 @@ import path from "node:path";
3
3
  import os from "node:os";
4
4
  import net from "node:net";
5
5
  import { resolveBrowserConfig } from "./config.js";
6
+ import { copyChromeProfile } from "./profileCopy.js";
6
7
  import { launchChrome, registerTerminationHooks, hideChromeWindow, connectToRemoteChrome, connectWithNewTab, closeTab, closeRemoteChromeTarget, closeBlankChromeTabs, } from "./chromeLifecycle.js";
7
8
  import { syncCookies } from "./cookies.js";
8
9
  import { navigateToChatGPT, navigateToPromptReadyWithFallback, ensureNotBlocked, ensureLoggedIn, ensurePromptReady, waitForResumedConversationHydration, installJavaScriptDialogAutoDismissal, ensureModelSelection, clearPromptComposer, waitForAssistantResponse, captureAssistantMarkdown, clearComposerAttachments, uploadAttachmentFile, waitForAttachmentCompletion, waitForUserTurnAttachments, readAssistantSnapshot, } from "./pageActions.js";
@@ -66,6 +67,11 @@ function classifyPreservedBrowserError(error, headless) {
66
67
  function shouldPreserveBrowserOnError(error, headless) {
67
68
  return classifyPreservedBrowserError(error, headless) !== null;
68
69
  }
70
+ function shouldKeepLocalBrowserOpen(options) {
71
+ if (options.usingCopiedProfile)
72
+ return false;
73
+ return options.effectiveKeepBrowser || options.preserveBrowserOnError;
74
+ }
69
75
  export function shouldPreserveBrowserOnErrorForTest(error, headless) {
70
76
  return shouldPreserveBrowserOnError(error, headless);
71
77
  }
@@ -240,20 +246,39 @@ function formatChatGptUiWarningType(type) {
240
246
  return "authentication/challenge";
241
247
  }
242
248
  }
243
- async function createAssistantTimeoutError(params) {
249
+ async function createChatGptUiWarningError(params) {
244
250
  const [uiWarning] = await collectChatGptUiWarnings(params.Runtime);
245
- if (!uiWarning) {
246
- return new BrowserAutomationError("Assistant response timed out before completion; reattach later to capture the answer.", { stage: "assistant-timeout", runtime: params.runtime, diagnostics: params.diagnostics }, params.cause);
247
- }
251
+ if (!uiWarning)
252
+ return null;
248
253
  params.logger(`[browser] ChatGPT UI warning detected (${uiWarning.type}): ${uiWarning.message}`);
249
- return new BrowserAutomationError(`ChatGPT displayed a ${formatChatGptUiWarningType(uiWarning.type)} warning while waiting for the assistant: ${uiWarning.message}`, {
250
- stage: "assistant-timeout",
254
+ return new BrowserAutomationError(`ChatGPT displayed a ${formatChatGptUiWarningType(uiWarning.type)} warning while waiting for ${params.waitTarget}: ${uiWarning.message}`, {
255
+ stage: params.stage,
251
256
  code: "chatgpt-ui-warning",
252
257
  uiWarning,
253
258
  runtime: params.runtime,
254
259
  diagnostics: params.diagnostics,
255
260
  }, params.cause);
256
261
  }
262
+ async function throwChatGptUiWarningIfPresent(params) {
263
+ const error = await createChatGptUiWarningError(params);
264
+ if (error)
265
+ throw error;
266
+ }
267
+ async function createAssistantTimeoutError(params) {
268
+ const warningError = await createChatGptUiWarningError({
269
+ Runtime: params.Runtime,
270
+ logger: params.logger,
271
+ runtime: params.runtime,
272
+ stage: "assistant-timeout",
273
+ waitTarget: "the assistant",
274
+ diagnostics: params.diagnostics,
275
+ cause: params.cause,
276
+ });
277
+ if (!warningError) {
278
+ return new BrowserAutomationError("Assistant response timed out before completion; reattach later to capture the answer.", { stage: "assistant-timeout", runtime: params.runtime, diagnostics: params.diagnostics }, params.cause);
279
+ }
280
+ return warningError;
281
+ }
257
282
  function listIgnoredRemoteChromeFlags(config) {
258
283
  return [
259
284
  config.headless ? "--browser-headless" : null,
@@ -334,7 +359,8 @@ function isImageOnlyUiChromeText(text) {
334
359
  return (normalized.length === 0 ||
335
360
  normalized === "edit" ||
336
361
  normalized === "stopped thinking" ||
337
- normalized === "stopped thinking edit");
362
+ normalized === "stopped thinking edit" ||
363
+ /^thought for \d+(?:\.\d+)?\s*(?:s|sec|secs|second|seconds|m|min|mins|minute|minutes|h|hr|hrs|hour|hours)\s+edit$/.test(normalized));
338
364
  }
339
365
  function normalizeBrowserFollowUpPrompts(values) {
340
366
  return (values ?? []).map((entry) => entry.trim()).filter(Boolean);
@@ -409,6 +435,15 @@ async function maybeArchiveCompletedConversation({ Runtime, logger, config, conv
409
435
  export function maybeArchiveCompletedConversationForTest(args) {
410
436
  return maybeArchiveCompletedConversation(args);
411
437
  }
438
+ async function captureDeepResearchTargetBaseline(client, logger) {
439
+ try {
440
+ return { targetKeys: await captureDeepResearchTargetKeys(client), captured: true };
441
+ }
442
+ catch {
443
+ logger("[browser] Deep Research target baseline unavailable; retaining conversation-turn owner scoping.");
444
+ return { targetKeys: [], captured: false };
445
+ }
446
+ }
412
447
  async function runSubmissionWithRecovery({ prompt, attachments, fallbackSubmission, submit, reloadPromptComposer, prepareFallbackSubmission, logger, }) {
413
448
  let currentPrompt = prompt;
414
449
  let currentAttachments = attachments;
@@ -500,6 +535,10 @@ export async function runBrowserMode(options) {
500
535
  const attachments = options.attachments ?? [];
501
536
  const fallbackSubmission = options.fallbackSubmission;
502
537
  let config = resolveBrowserConfig(options.config);
538
+ const usingCopiedProfile = Boolean(config.copyProfileSource);
539
+ if (usingCopiedProfile && (config.attachRunning || config.remoteChrome)) {
540
+ throw new BrowserAutomationError("--copy-profile requires a locally launched Chrome instance and cannot be combined with attach-running or remote Chrome.", { stage: "profile-config" });
541
+ }
503
542
  const isResumingConversation = Boolean(config.resumeConversationUrl);
504
543
  const followUpPrompts = normalizeBrowserFollowUpPrompts(options.followUpPrompts);
505
544
  if (config.researchMode === "deep" && followUpPrompts.length > 0) {
@@ -593,6 +632,12 @@ export async function runBrowserMode(options) {
593
632
  return runRemoteBrowserMode(promptText, attachments, config, logger, options);
594
633
  }
595
634
  const manualLogin = Boolean(config.manualLogin);
635
+ if (manualLogin && usingCopiedProfile) {
636
+ throw new BrowserAutomationError("--copy-profile cannot be combined with --browser-manual-login: choose either a throwaway copied profile or the persistent manual-login profile.", { stage: "profile-config" });
637
+ }
638
+ // Manual-login and copy-profile both start from an already-signed-in profile,
639
+ // so neither clears nor syncs cookies.
640
+ const profileIsPreSigned = manualLogin || usingCopiedProfile;
596
641
  const manualProfileDir = config.manualLoginProfileDir
597
642
  ? path.resolve(config.manualLoginProfileDir)
598
643
  : defaultManualLoginProfileDir();
@@ -609,6 +654,11 @@ export async function runBrowserMode(options) {
609
654
  keepBrowser: effectiveKeepBrowser,
610
655
  });
611
656
  }
657
+ else if (config.copyProfileSource) {
658
+ const copiedProfileDirectory = await copyChromeProfile(config.copyProfileSource, userDataDir, config.chromeProfile);
659
+ config = { ...config, chromeProfile: copiedProfileDirectory };
660
+ logger(`Seeded temporary Chrome profile ${copiedProfileDirectory} from ${config.copyProfileSource} (copy-profile mode; signed-in session reused without manual login)`);
661
+ }
612
662
  else {
613
663
  logger(`Created temporary Chrome profile at ${userDataDir}`);
614
664
  }
@@ -638,6 +688,9 @@ export async function runBrowserMode(options) {
638
688
  tabLease = null;
639
689
  await handle.release().catch(() => undefined);
640
690
  }
691
+ if (usingCopiedProfile) {
692
+ await rm(userDataDir, { recursive: true, force: true }).catch(() => undefined);
693
+ }
641
694
  throw error;
642
695
  }
643
696
  const { chrome, reusedChrome } = acquiredChrome;
@@ -654,6 +707,8 @@ export async function runBrowserMode(options) {
654
707
  isInFlight: () => runStatus !== "complete",
655
708
  emitRuntimeHint,
656
709
  preserveUserDataDir: manualLogin,
710
+ // copy-profile is a throwaway copy of a signed-in profile; never leave it on disk.
711
+ forceProfileCleanup: usingCopiedProfile,
657
712
  });
658
713
  }
659
714
  catch {
@@ -690,9 +745,10 @@ export async function runBrowserMode(options) {
690
745
  }
691
746
  else {
692
747
  const strictTabIsolation = Boolean(manualLogin && reusedChrome);
748
+ const devtoolsRetries = manualLogin ? 6 : 0;
693
749
  const connection = await connectWithNewTab(chrome.port, logger, config.url, chromeHost, {
694
750
  fallbackToDefault: !strictTabIsolation,
695
- retries: strictTabIsolation ? 3 : 0,
751
+ retries: devtoolsRetries,
696
752
  retryDelayMs: 500,
697
753
  });
698
754
  client = connection.client;
@@ -732,11 +788,11 @@ export async function runBrowserMode(options) {
732
788
  }
733
789
  await Promise.all(domainEnablers);
734
790
  removeDialogHandler = installJavaScriptDialogAutoDismissal(Page, logger);
735
- if (!manualLogin) {
791
+ if (!profileIsPreSigned) {
736
792
  await Network.clearBrowserCookies();
737
793
  }
738
794
  const manualLoginCookieSync = manualLogin && Boolean(config.manualLoginCookieSync);
739
- const cookieSyncEnabled = config.cookieSync && (!manualLogin || manualLoginCookieSync);
795
+ const cookieSyncEnabled = config.cookieSync && (!profileIsPreSigned || manualLoginCookieSync);
740
796
  if (cookieSyncEnabled) {
741
797
  if (manualLoginCookieSync) {
742
798
  logger("Manual login mode: seeding persistent profile with cookies from your Chrome profile.");
@@ -1039,8 +1095,8 @@ export async function runBrowserMode(options) {
1039
1095
  attachmentNames: attachmentExpectations,
1040
1096
  onPromptSubmitted: markPromptSubmitted,
1041
1097
  };
1042
- const deepResearchTargetKeys = deepResearch && client
1043
- ? await captureDeepResearchTargetKeys(client).catch(() => [])
1098
+ const deepResearchTargetBaseline = deepResearch && client
1099
+ ? await captureDeepResearchTargetBaseline(client, logger)
1044
1100
  : undefined;
1045
1101
  await runProviderSubmissionFlow(chatgptDomProvider, {
1046
1102
  prompt,
@@ -1074,7 +1130,12 @@ export async function runBrowserMode(options) {
1074
1130
  }
1075
1131
  // Reattach needs a /c/ URL; ChatGPT can update it late, so poll in the background.
1076
1132
  scheduleConversationHint("post-submit", config.timeoutMs ?? 120_000);
1077
- return { baselineTurns, baselineAssistantText, deepResearchTargetKeys };
1133
+ return {
1134
+ baselineTurns,
1135
+ baselineAssistantText,
1136
+ deepResearchTargetKeys: deepResearchTargetBaseline?.targetKeys,
1137
+ deepResearchTargetBaselineCaptured: deepResearchTargetBaseline?.captured,
1138
+ };
1078
1139
  };
1079
1140
  const reloadPromptComposer = async () => {
1080
1141
  logger("[browser] Composer became unresponsive; reloading page and retrying once.");
@@ -1084,6 +1145,7 @@ export async function runBrowserMode(options) {
1084
1145
  let baselineTurns = null;
1085
1146
  let baselineAssistantText = null;
1086
1147
  let deepResearchTargetKeys = [];
1148
+ let deepResearchTargetBaselineCaptured = false;
1087
1149
  await acquireProfileLockIfNeeded();
1088
1150
  try {
1089
1151
  const submission = await runSubmissionWithRecovery({
@@ -1101,6 +1163,7 @@ export async function runBrowserMode(options) {
1101
1163
  baselineTurns = submission.baselineTurns;
1102
1164
  baselineAssistantText = submission.baselineAssistantText;
1103
1165
  deepResearchTargetKeys = submission.deepResearchTargetKeys ?? [];
1166
+ deepResearchTargetBaselineCaptured = submission.deepResearchTargetBaselineCaptured ?? false;
1104
1167
  }
1105
1168
  finally {
1106
1169
  await releaseProfileLockIfHeld();
@@ -1108,7 +1171,10 @@ export async function runBrowserMode(options) {
1108
1171
  const imageArtifactMinTurnIndex = baselineTurns;
1109
1172
  if (deepResearch) {
1110
1173
  await raceWithDisconnect(waitForResearchPlanAutoConfirm(Runtime, logger));
1111
- const researchResult = await raceWithDisconnect(waitForDeepResearchCompletion(Runtime, logger, config.timeoutMs, baselineTurns, Page, client, { ignoredTargetKeys: deepResearchTargetKeys }));
1174
+ const researchResult = await raceWithDisconnect(waitForDeepResearchCompletion(Runtime, logger, config.timeoutMs, baselineTurns, Page, client, {
1175
+ ignoredTargetKeys: deepResearchTargetKeys,
1176
+ targetBaselineCaptured: deepResearchTargetBaselineCaptured,
1177
+ }));
1112
1178
  await updateConversationHint("post-deep-research", 15_000).catch(() => false);
1113
1179
  runStatus = "complete";
1114
1180
  const durationMs = Date.now() - startedAt;
@@ -1504,6 +1570,23 @@ export async function runBrowserMode(options) {
1504
1570
  outputPath: options.outputPath,
1505
1571
  answerText,
1506
1572
  waitTimeoutMs: options.config?.timeoutMs,
1573
+ checkBlockingUiWarning: () => throwChatGptUiWarningIfPresent({
1574
+ Runtime,
1575
+ logger,
1576
+ stage: "image-artifact-wait",
1577
+ waitTarget: "generated image artifacts",
1578
+ runtime: {
1579
+ chromePid: chrome.pid,
1580
+ chromePort: chrome.port,
1581
+ chromeHost,
1582
+ userDataDir,
1583
+ chromeTargetId: lastTargetId,
1584
+ tabUrl: lastUrl,
1585
+ conversationId: lastUrl ? extractConversationIdFromUrl(lastUrl) : undefined,
1586
+ promptSubmitted,
1587
+ controllerPid: process.pid,
1588
+ },
1589
+ }),
1507
1590
  });
1508
1591
  answerText = imageArtifacts.answerText || answerText;
1509
1592
  if (imageArtifacts.markdownSuffix) {
@@ -1576,6 +1659,10 @@ export async function runBrowserMode(options) {
1576
1659
  connectionClosedUnexpectedly = connectionClosedUnexpectedly || socketClosed;
1577
1660
  const preservedErrorKind = classifyPreservedBrowserError(normalizedError, config.headless);
1578
1661
  if (preservedErrorKind === "cloudflare-challenge") {
1662
+ if (usingCopiedProfile) {
1663
+ logger("Cloudflare challenge detected; closing Chrome and removing the copied profile because copy-profile runs cannot be retained.");
1664
+ throw new BrowserAutomationError("Cloudflare challenge detected. Copy-profile runs cannot be retained; complete the check in the source Chrome profile, then rerun.", { stage: "cloudflare-challenge", reattachable: false }, normalizedError);
1665
+ }
1579
1666
  preserveBrowserOnError = true;
1580
1667
  const runtime = {
1581
1668
  chromePid: chrome.pid,
@@ -1599,6 +1686,13 @@ export async function runBrowserMode(options) {
1599
1686
  }, normalizedError);
1600
1687
  }
1601
1688
  if (preservedErrorKind === "reattachable-capture") {
1689
+ if (usingCopiedProfile) {
1690
+ logger("Assistant capture incomplete; closing Chrome and removing the copied profile because copy-profile runs cannot be reattached.");
1691
+ const details = normalizedError instanceof BrowserAutomationError
1692
+ ? { ...normalizedError.details, runtime: undefined, reattachable: false }
1693
+ : { stage: "assistant-recheck", reattachable: false };
1694
+ throw new BrowserAutomationError(normalizedError.message, details, normalizedError);
1695
+ }
1602
1696
  preserveBrowserOnError = true;
1603
1697
  await emitRuntimeHint();
1604
1698
  logger("Assistant capture incomplete; leaving browser open for reattach.");
@@ -1651,7 +1745,11 @@ export async function runBrowserMode(options) {
1651
1745
  chrome?.port) {
1652
1746
  await closeTab(chrome.port, isolatedTargetId, logger, chromeHost).catch(() => undefined);
1653
1747
  }
1654
- let keepBrowserOpen = effectiveKeepBrowser || preserveBrowserOnError;
1748
+ let keepBrowserOpen = shouldKeepLocalBrowserOpen({
1749
+ effectiveKeepBrowser,
1750
+ preserveBrowserOnError,
1751
+ usingCopiedProfile,
1752
+ });
1655
1753
  let cleanupProfileLock = null;
1656
1754
  let terminatedRecordedChrome = false;
1657
1755
  let otherActiveBrowserTabLeases = null;
@@ -2209,8 +2307,8 @@ async function runRemoteBrowserMode(promptText, attachments, config, logger, opt
2209
2307
  attachmentNames: attachmentExpectations,
2210
2308
  onPromptSubmitted: markPromptSubmitted,
2211
2309
  };
2212
- const deepResearchTargetKeys = deepResearch && client
2213
- ? await captureDeepResearchTargetKeys(client).catch(() => [])
2310
+ const deepResearchTargetBaseline = deepResearch && client
2311
+ ? await captureDeepResearchTargetBaseline(client, logger)
2214
2312
  : undefined;
2215
2313
  await runProviderSubmissionFlow(chatgptDomProvider, {
2216
2314
  prompt,
@@ -2224,7 +2322,12 @@ async function runRemoteBrowserMode(promptText, attachments, config, logger, opt
2224
2322
  if (typeof providerBaselineTurns === "number" && Number.isFinite(providerBaselineTurns)) {
2225
2323
  baselineTurns = providerBaselineTurns;
2226
2324
  }
2227
- return { baselineTurns, baselineAssistantText, deepResearchTargetKeys };
2325
+ return {
2326
+ baselineTurns,
2327
+ baselineAssistantText,
2328
+ deepResearchTargetKeys: deepResearchTargetBaseline?.targetKeys,
2329
+ deepResearchTargetBaselineCaptured: deepResearchTargetBaseline?.captured,
2330
+ };
2228
2331
  };
2229
2332
  const reloadPromptComposer = async () => {
2230
2333
  logger("[browser] Composer became unresponsive; reloading page and retrying once.");
@@ -2234,6 +2337,7 @@ async function runRemoteBrowserMode(promptText, attachments, config, logger, opt
2234
2337
  let baselineTurns = null;
2235
2338
  let baselineAssistantText = null;
2236
2339
  let deepResearchTargetKeys = [];
2340
+ let deepResearchTargetBaselineCaptured = false;
2237
2341
  const submission = await runSubmissionWithRecovery({
2238
2342
  prompt: promptText,
2239
2343
  attachments,
@@ -2249,10 +2353,14 @@ async function runRemoteBrowserMode(promptText, attachments, config, logger, opt
2249
2353
  baselineTurns = submission.baselineTurns;
2250
2354
  baselineAssistantText = submission.baselineAssistantText;
2251
2355
  deepResearchTargetKeys = submission.deepResearchTargetKeys ?? [];
2356
+ deepResearchTargetBaselineCaptured = submission.deepResearchTargetBaselineCaptured ?? false;
2252
2357
  const imageArtifactMinTurnIndex = baselineTurns;
2253
2358
  if (deepResearch) {
2254
2359
  await waitForResearchPlanAutoConfirm(Runtime, logger);
2255
- const researchResult = await waitForDeepResearchCompletion(Runtime, logger, config.timeoutMs, baselineTurns, Page, client, { ignoredTargetKeys: deepResearchTargetKeys });
2360
+ const researchResult = await waitForDeepResearchCompletion(Runtime, logger, config.timeoutMs, baselineTurns, Page, client, {
2361
+ ignoredTargetKeys: deepResearchTargetKeys,
2362
+ targetBaselineCaptured: deepResearchTargetBaselineCaptured,
2363
+ });
2256
2364
  await emitRuntimeHint();
2257
2365
  const durationMs = Date.now() - startedAt;
2258
2366
  const tokens = estimateTokenCount(researchResult.text);
@@ -2616,6 +2724,23 @@ async function runRemoteBrowserMode(promptText, attachments, config, logger, opt
2616
2724
  outputPath: options.outputPath,
2617
2725
  answerText,
2618
2726
  waitTimeoutMs: options.config?.timeoutMs,
2727
+ checkBlockingUiWarning: () => throwChatGptUiWarningIfPresent({
2728
+ Runtime,
2729
+ logger,
2730
+ stage: "image-artifact-wait",
2731
+ waitTarget: "generated image artifacts",
2732
+ runtime: {
2733
+ chromePort: port,
2734
+ chromeHost: host,
2735
+ chromeBrowserWSEndpoint: browserWSEndpoint,
2736
+ chromeProfileRoot,
2737
+ chromeTargetId: remoteTargetId ?? undefined,
2738
+ tabUrl: lastUrl,
2739
+ conversationId: lastUrl ? extractConversationIdFromUrl(lastUrl) : undefined,
2740
+ promptSubmitted,
2741
+ controllerPid: process.pid,
2742
+ },
2743
+ }),
2619
2744
  });
2620
2745
  answerText = imageArtifacts.answerText || answerText;
2621
2746
  if (imageArtifacts.markdownSuffix) {
@@ -2757,6 +2882,7 @@ export const __test__ = {
2757
2882
  listIgnoredRemoteChromeFlags,
2758
2883
  resolveManualLoginWaitMs,
2759
2884
  shouldCloseOwnedRunTargetAfterRun,
2885
+ shouldKeepLocalBrowserOpen,
2760
2886
  };
2761
2887
  export { syncCookies } from "./cookies.js";
2762
2888
  export { navigateToChatGPT, ensureNotBlocked, ensurePromptReady, ensureModelSelection, submitPrompt, waitForAssistantResponse, captureAssistantMarkdown, uploadAttachmentFile, waitForAttachmentCompletion, } from "./pageActions.js";
@@ -0,0 +1,93 @@
1
+ import { spawn } from "node:child_process";
2
+ import { cp, mkdir, readFile, rm } from "node:fs/promises";
3
+ import path from "node:path";
4
+ /**
5
+ * Cache/derived subdirectories that bloat the copy and carry no signed-in-session
6
+ * signal, so they are skipped when seeding a copied Chrome profile.
7
+ */
8
+ const RSYNC_EXCLUDES = [
9
+ "Cache/",
10
+ "Code Cache/",
11
+ "GPUCache/",
12
+ "DawnGraphiteCache/",
13
+ "DawnWebGPUCache/",
14
+ "GrShaderCache/",
15
+ "ShaderCache/",
16
+ "Service Worker/CacheStorage/",
17
+ "Service Worker/ScriptCache/",
18
+ "Service Worker/Database/",
19
+ ];
20
+ /**
21
+ * Copy a signed-in Chrome user-data directory into `destDir` so a throwaway
22
+ * Chrome can launch on the copy and reuse the live session WITHOUT a manual
23
+ * sign-in. Copies the `Default/` profile (minus cache dirs) plus the top-level
24
+ * `Local State` file.
25
+ *
26
+ * `Local State` is required: on macOS it holds the Keychain-wrapped
27
+ * "Chrome Safe Storage" key that decrypts the profile's cookies — a cookies-only
28
+ * copy fails the logged-in check. Decryption only succeeds when the copy is
29
+ * launched by the real Chrome binary (the one on the Keychain ACL).
30
+ *
31
+ * Uses rsync (present on macOS/Linux) so a live, in-use source profile copies
32
+ * cleanly — rsync exit 24 ("source files vanished") is tolerated.
33
+ */
34
+ export async function copyChromeProfile(srcUserDataDir, destDir, requestedProfile) {
35
+ try {
36
+ const localStatePath = path.join(srcUserDataDir, "Local State");
37
+ const copiedLocalStatePath = path.join(destDir, "Local State");
38
+ await cp(localStatePath, copiedLocalStatePath).catch((err) => {
39
+ throw new Error(`--copy-profile: could not copy required "Local State" from ${srcUserDataDir} ` +
40
+ `(needed to select and decrypt the signed-in profile): ${err.message}`);
41
+ });
42
+ const localState = await readFile(copiedLocalStatePath, "utf8");
43
+ const profileDirectory = resolveChromeProfileDirectory(srcUserDataDir, localState, requestedProfile);
44
+ const srcProfile = path.join(srcUserDataDir, profileDirectory);
45
+ const destProfile = path.join(destDir, profileDirectory);
46
+ await mkdir(destProfile, { recursive: true });
47
+ // `Local State` is required (holds the Keychain-wrapped key that decrypts the
48
+ // cookies), so a copy failure must fail fast — otherwise the run continues with
49
+ // a profile that silently looks logged-out.
50
+ const args = ["-a"];
51
+ for (const exclude of RSYNC_EXCLUDES) {
52
+ args.push("--exclude", exclude);
53
+ }
54
+ args.push(`${srcProfile}/`, `${destProfile}/`);
55
+ await new Promise((resolve, reject) => {
56
+ const child = spawn("rsync", args, { stdio: "ignore" });
57
+ child.on("error", (err) => reject(new Error(`--copy-profile requires rsync on PATH (spawn failed): ${err.message}`)));
58
+ child.on("close", (code) => code === 0 || code === 24
59
+ ? resolve()
60
+ : reject(new Error(`rsync failed copying Chrome profile (exit ${code})`)));
61
+ });
62
+ return profileDirectory;
63
+ }
64
+ catch (error) {
65
+ // The destination is always a newly-created throwaway profile. Remove partial
66
+ // session-bearing copies before surfacing setup failures.
67
+ await rm(destDir, { recursive: true, force: true }).catch(() => undefined);
68
+ throw error;
69
+ }
70
+ }
71
+ function resolveChromeProfileDirectory(srcUserDataDir, localState, requestedProfile) {
72
+ let profile = requestedProfile?.trim();
73
+ if (!profile) {
74
+ try {
75
+ const parsed = JSON.parse(localState);
76
+ profile =
77
+ typeof parsed.profile?.last_used === "string" ? parsed.profile.last_used.trim() : "";
78
+ }
79
+ catch (error) {
80
+ throw new Error(`--copy-profile: could not parse "Local State" to select the active Chrome profile: ${error.message}`);
81
+ }
82
+ }
83
+ profile ||= "Default";
84
+ const root = path.resolve(srcUserDataDir);
85
+ const resolved = path.resolve(root, profile);
86
+ if (path.dirname(resolved) !== root) {
87
+ throw new Error(`--copy-profile: Chrome profile must be a direct child of the user-data directory; received ${JSON.stringify(profile)}.`);
88
+ }
89
+ return path.basename(resolved);
90
+ }
91
+ export function resolveChromeProfileDirectoryForTest(srcUserDataDir, localState, requestedProfile) {
92
+ return resolveChromeProfileDirectory(srcUserDataDir, localState, requestedProfile);
93
+ }
@@ -97,9 +97,10 @@ export async function runBrowserProjectSources(request) {
97
97
  preserveUserDataDir: manualLogin,
98
98
  });
99
99
  const strictTabIsolation = Boolean(manualLogin && reusedChrome);
100
+ const devtoolsRetries = manualLogin ? 6 : 0;
100
101
  const connection = await connectWithNewTab(chrome.port, logger, "about:blank", chromeHost, {
101
102
  fallbackToDefault: !strictTabIsolation,
102
- retries: strictTabIsolation ? 3 : 0,
103
+ retries: devtoolsRetries,
103
104
  retryDelayMs: 500,
104
105
  });
105
106
  client = connection.client;