@steipete/oracle 0.14.1 → 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 (65) hide show
  1. package/dist/bin/oracle-cli.js +2 -0
  2. package/dist/bin/oracle.js +569 -0
  3. package/dist/docs-site/.nojekyll +0 -0
  4. package/dist/docs-site/CNAME +1 -0
  5. package/dist/docs-site/RELEASING.html +410 -0
  6. package/dist/docs-site/agents.html +374 -0
  7. package/dist/docs-site/anthropic.html +368 -0
  8. package/dist/docs-site/bridge.html +400 -0
  9. package/dist/docs-site/browser-mode.html +594 -0
  10. package/dist/docs-site/chromium-forks.html +347 -0
  11. package/dist/docs-site/cli-reference.html +346 -0
  12. package/dist/docs-site/configuration.html +452 -0
  13. package/dist/docs-site/favicon.svg +14 -0
  14. package/dist/docs-site/followup.html +375 -0
  15. package/dist/docs-site/gemini.html +383 -0
  16. package/dist/docs-site/grok.html +325 -0
  17. package/dist/docs-site/index.html +360 -0
  18. package/dist/docs-site/install.html +335 -0
  19. package/dist/docs-site/linux.html +321 -0
  20. package/dist/docs-site/llms.txt +43 -0
  21. package/dist/docs-site/manual-tests.html +596 -0
  22. package/dist/docs-site/mcp.html +391 -0
  23. package/dist/docs-site/multimodel.html +364 -0
  24. package/dist/docs-site/mythical-pro-agents.html +360 -0
  25. package/dist/docs-site/notifier.html +338 -0
  26. package/dist/docs-site/openai-endpoints.html +387 -0
  27. package/dist/docs-site/openrouter.html +344 -0
  28. package/dist/docs-site/quickstart.html +369 -0
  29. package/dist/docs-site/refactor/ux.html +532 -0
  30. package/dist/docs-site/sessions.html +388 -0
  31. package/dist/docs-site/social-card.png +0 -0
  32. package/dist/docs-site/social-card.svg +79 -0
  33. package/dist/docs-site/spec.html +363 -0
  34. package/dist/docs-site/testing.html +320 -0
  35. package/dist/docs-site/tui-debug.html +326 -0
  36. package/dist/docs-site/windows-work.html +323 -0
  37. package/dist/docs-site/windows.html +320 -0
  38. package/dist/src/browser/actions/deepResearch.js +132 -61
  39. package/dist/src/browser/actions/modelSelection.js +45 -2
  40. package/dist/src/browser/actions/thinkingTime.js +65 -20
  41. package/dist/src/browser/artifacts.js +2 -8
  42. package/dist/src/browser/chatgptFiles.js +198 -49
  43. package/dist/src/browser/chromeCookies.js +312 -0
  44. package/dist/src/browser/chromeLifecycle.js +35 -4
  45. package/dist/src/browser/deepResearchResult.js +23 -0
  46. package/dist/src/browser/index.js +82 -11
  47. package/dist/src/browser/keytarShim.js +56 -0
  48. package/dist/src/browser/profileCopy.js +93 -0
  49. package/dist/src/browser/windowsCookies.js +219 -0
  50. package/dist/src/cli/browserConfig.js +17 -1
  51. package/dist/src/cli/sessionRunner.js +13 -7
  52. package/dist/vendor/oracle-notifier/OracleNotifier.app/Contents/CodeResources +0 -0
  53. package/dist/vendor/oracle-notifier/OracleNotifier.app/Contents/Info.plist +20 -0
  54. package/dist/vendor/oracle-notifier/OracleNotifier.app/Contents/MacOS/OracleNotifier +0 -0
  55. package/dist/vendor/oracle-notifier/OracleNotifier.app/Contents/Resources/OracleIcon.icns +0 -0
  56. package/dist/vendor/oracle-notifier/OracleNotifier.app/Contents/_CodeSignature/CodeResources +128 -0
  57. package/dist/vendor/oracle-notifier/build-notifier.sh +0 -0
  58. package/package.json +33 -31
  59. package/vendor/oracle-notifier/OracleNotifier.app/Contents/CodeResources +0 -0
  60. package/vendor/oracle-notifier/OracleNotifier.app/Contents/Info.plist +20 -0
  61. package/vendor/oracle-notifier/OracleNotifier.app/Contents/MacOS/OracleNotifier +0 -0
  62. package/vendor/oracle-notifier/OracleNotifier.app/Contents/Resources/OracleIcon.icns +0 -0
  63. package/vendor/oracle-notifier/OracleNotifier.app/Contents/_CodeSignature/CodeResources +128 -0
  64. package/vendor/oracle-notifier/README.md +26 -0
  65. package/vendor/oracle-notifier/build-notifier.sh +0 -0
@@ -15,20 +15,31 @@ export async function launchChrome(config, userDataDir, logger) {
15
15
  const debugPort = config.debugPort ?? parseDebugPortEnv();
16
16
  const chromeFlags = buildChromeFlags(config.headless ?? false, debugBindAddress);
17
17
  const usePatchedLauncher = Boolean(connectHost && connectHost !== "127.0.0.1");
18
+ // copy-profile reuses a copied signed-in profile whose cookies are
19
+ // Keychain-encrypted, so it must launch with the real Keychain (not mocked):
20
+ // strip the keychain-mocking flags from both chrome-launcher's defaults and
21
+ // Oracle's set, and ignore the defaults so they aren't re-added.
22
+ const usingCopiedProfile = Boolean(config.copyProfileSource);
23
+ if (usingCopiedProfile && config.chromeProfile) {
24
+ chromeFlags.push(`--profile-directory=${config.chromeProfile}`);
25
+ }
26
+ const launchOptions = resolveChromeLaunchOptions(chromeFlags, usingCopiedProfile);
18
27
  const launcher = usePatchedLauncher
19
28
  ? await launchWithCustomHost({
20
- chromeFlags,
29
+ chromeFlags: launchOptions.chromeFlags,
21
30
  chromePath: config.chromePath ?? undefined,
22
31
  userDataDir,
23
32
  host: connectHost ?? "127.0.0.1",
24
33
  requestedPort: debugPort ?? undefined,
34
+ ignoreDefaultFlags: launchOptions.ignoreDefaultFlags,
25
35
  })
26
36
  : await launch({
27
37
  chromePath: config.chromePath ?? undefined,
28
- chromeFlags,
38
+ chromeFlags: launchOptions.chromeFlags,
29
39
  userDataDir,
30
40
  handleSIGINT: false,
31
41
  port: debugPort ?? undefined,
42
+ ignoreDefaultFlags: launchOptions.ignoreDefaultFlags,
32
43
  });
33
44
  const pidLabel = typeof launcher.pid === "number" ? ` (pid ${launcher.pid})` : "";
34
45
  const hostLabel = connectHost ? ` on ${connectHost}` : "";
@@ -44,10 +55,14 @@ export function registerTerminationHooks(chrome, userDataDir, keepBrowser, logge
44
55
  }
45
56
  handling = true;
46
57
  const inFlight = opts?.isInFlight?.() ?? false;
47
- const leaveRunning = keepBrowser || inFlight;
58
+ const forceCleanup = opts?.forceProfileCleanup ?? false;
59
+ const leaveRunning = (keepBrowser || inFlight) && !forceCleanup;
48
60
  if (leaveRunning) {
49
61
  logger(`Received ${signal}; leaving Chrome running${inFlight ? " (assistant response pending)" : ""}`);
50
62
  }
63
+ else if (forceCleanup && (keepBrowser || inFlight)) {
64
+ logger(`Received ${signal}; terminating Chrome and removing the copied profile (copy-profile is not retained)`);
65
+ }
51
66
  else {
52
67
  logger(`Received ${signal}; terminating Chrome process`);
53
68
  }
@@ -343,6 +358,9 @@ function createSessionBoundChromeClient(browser, sessionId) {
343
358
  // Raw `send` here is the browser-level send (not session-bound), so callers
344
359
  // that issue Target.* via `send` must pass this page session id explicitly to
345
360
  // stay scoped to this tab (e.g. Deep Research OOPIF auto-attach).
361
+ // chrome-remote-interface defines `send` on the client prototype, so object
362
+ // spread does not preserve it. Bind it explicitly for raw session commands.
363
+ send: typeof browser.send === "function" ? browser.send.bind(browser) : undefined,
346
364
  oraclePageSessionId: sessionId,
347
365
  Network: bindDomain("Network"),
348
366
  Page: bindDomain("Page"),
@@ -472,6 +490,18 @@ function buildChromeFlags(headless, debugBindAddress) {
472
490
  }
473
491
  return flags;
474
492
  }
493
+ function resolveChromeLaunchOptions(chromeFlags, usingCopiedProfile) {
494
+ if (!usingCopiedProfile) {
495
+ return { chromeFlags, ignoreDefaultFlags: false };
496
+ }
497
+ return {
498
+ chromeFlags: [...Launcher.defaultFlags(), ...chromeFlags].filter((flag) => flag !== "--use-mock-keychain" && flag !== "--password-store=basic"),
499
+ ignoreDefaultFlags: true,
500
+ };
501
+ }
502
+ export function resolveChromeLaunchOptionsForTest(chromeFlags, usingCopiedProfile) {
503
+ return resolveChromeLaunchOptions(chromeFlags, usingCopiedProfile);
504
+ }
475
505
  function parseDebugPortEnv() {
476
506
  const raw = process.env.ORACLE_BROWSER_PORT ?? process.env.ORACLE_BROWSER_DEBUG_PORT;
477
507
  if (!raw)
@@ -514,13 +544,14 @@ function isWsl() {
514
544
  const release = os.release();
515
545
  return release.toLowerCase().includes("microsoft");
516
546
  }
517
- async function launchWithCustomHost({ chromeFlags, chromePath, userDataDir, host, requestedPort, }) {
547
+ async function launchWithCustomHost({ chromeFlags, chromePath, userDataDir, host, requestedPort, ignoreDefaultFlags, }) {
518
548
  const launcher = new Launcher({
519
549
  chromePath: chromePath ?? undefined,
520
550
  chromeFlags,
521
551
  userDataDir,
522
552
  handleSIGINT: false,
523
553
  port: requestedPort ?? undefined,
554
+ ignoreDefaultFlags,
524
555
  });
525
556
  if (host) {
526
557
  const patched = launcher;
@@ -0,0 +1,23 @@
1
+ export function isDeepResearchIncompleteText(text) {
2
+ const normalized = text.toLowerCase().replace(/\s+/g, " ").trim();
3
+ const lines = text
4
+ .split(/\n+/)
5
+ .map((line) => line.trim())
6
+ .filter(Boolean);
7
+ const tailIsPlanningPanel = text.length <= 1_500 &&
8
+ lines.length >= 4 &&
9
+ lines.length <= 20 &&
10
+ /^update$/i.test(lines[1] ?? "") &&
11
+ /^stop research$/i.test(lines.at(-1) ?? "") &&
12
+ /^determining steps for creating a report(?:\.\.\.)?$/i.test(lines.at(-2) ?? "");
13
+ return (normalized === "called tool" ||
14
+ normalized === "used tool" ||
15
+ normalized === "użyto narzędzia" ||
16
+ normalized === "narzędzie wywołane" ||
17
+ normalized === "planning" ||
18
+ normalized === "researching" ||
19
+ normalized === "searching the web" ||
20
+ (text.trimStart().startsWith("<system-reminder>") &&
21
+ /<system-reminder>[\s\S]*#\s*plan mode\b/i.test(text)) ||
22
+ tailIsPlanningPanel);
23
+ }
@@ -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
  }
@@ -429,6 +435,15 @@ async function maybeArchiveCompletedConversation({ Runtime, logger, config, conv
429
435
  export function maybeArchiveCompletedConversationForTest(args) {
430
436
  return maybeArchiveCompletedConversation(args);
431
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
+ }
432
447
  async function runSubmissionWithRecovery({ prompt, attachments, fallbackSubmission, submit, reloadPromptComposer, prepareFallbackSubmission, logger, }) {
433
448
  let currentPrompt = prompt;
434
449
  let currentAttachments = attachments;
@@ -520,6 +535,10 @@ export async function runBrowserMode(options) {
520
535
  const attachments = options.attachments ?? [];
521
536
  const fallbackSubmission = options.fallbackSubmission;
522
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
+ }
523
542
  const isResumingConversation = Boolean(config.resumeConversationUrl);
524
543
  const followUpPrompts = normalizeBrowserFollowUpPrompts(options.followUpPrompts);
525
544
  if (config.researchMode === "deep" && followUpPrompts.length > 0) {
@@ -613,6 +632,12 @@ export async function runBrowserMode(options) {
613
632
  return runRemoteBrowserMode(promptText, attachments, config, logger, options);
614
633
  }
615
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;
616
641
  const manualProfileDir = config.manualLoginProfileDir
617
642
  ? path.resolve(config.manualLoginProfileDir)
618
643
  : defaultManualLoginProfileDir();
@@ -629,6 +654,11 @@ export async function runBrowserMode(options) {
629
654
  keepBrowser: effectiveKeepBrowser,
630
655
  });
631
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
+ }
632
662
  else {
633
663
  logger(`Created temporary Chrome profile at ${userDataDir}`);
634
664
  }
@@ -658,6 +688,9 @@ export async function runBrowserMode(options) {
658
688
  tabLease = null;
659
689
  await handle.release().catch(() => undefined);
660
690
  }
691
+ if (usingCopiedProfile) {
692
+ await rm(userDataDir, { recursive: true, force: true }).catch(() => undefined);
693
+ }
661
694
  throw error;
662
695
  }
663
696
  const { chrome, reusedChrome } = acquiredChrome;
@@ -674,6 +707,8 @@ export async function runBrowserMode(options) {
674
707
  isInFlight: () => runStatus !== "complete",
675
708
  emitRuntimeHint,
676
709
  preserveUserDataDir: manualLogin,
710
+ // copy-profile is a throwaway copy of a signed-in profile; never leave it on disk.
711
+ forceProfileCleanup: usingCopiedProfile,
677
712
  });
678
713
  }
679
714
  catch {
@@ -753,11 +788,11 @@ export async function runBrowserMode(options) {
753
788
  }
754
789
  await Promise.all(domainEnablers);
755
790
  removeDialogHandler = installJavaScriptDialogAutoDismissal(Page, logger);
756
- if (!manualLogin) {
791
+ if (!profileIsPreSigned) {
757
792
  await Network.clearBrowserCookies();
758
793
  }
759
794
  const manualLoginCookieSync = manualLogin && Boolean(config.manualLoginCookieSync);
760
- const cookieSyncEnabled = config.cookieSync && (!manualLogin || manualLoginCookieSync);
795
+ const cookieSyncEnabled = config.cookieSync && (!profileIsPreSigned || manualLoginCookieSync);
761
796
  if (cookieSyncEnabled) {
762
797
  if (manualLoginCookieSync) {
763
798
  logger("Manual login mode: seeding persistent profile with cookies from your Chrome profile.");
@@ -1060,8 +1095,8 @@ export async function runBrowserMode(options) {
1060
1095
  attachmentNames: attachmentExpectations,
1061
1096
  onPromptSubmitted: markPromptSubmitted,
1062
1097
  };
1063
- const deepResearchTargetKeys = deepResearch && client
1064
- ? await captureDeepResearchTargetKeys(client).catch(() => [])
1098
+ const deepResearchTargetBaseline = deepResearch && client
1099
+ ? await captureDeepResearchTargetBaseline(client, logger)
1065
1100
  : undefined;
1066
1101
  await runProviderSubmissionFlow(chatgptDomProvider, {
1067
1102
  prompt,
@@ -1095,7 +1130,12 @@ export async function runBrowserMode(options) {
1095
1130
  }
1096
1131
  // Reattach needs a /c/ URL; ChatGPT can update it late, so poll in the background.
1097
1132
  scheduleConversationHint("post-submit", config.timeoutMs ?? 120_000);
1098
- return { baselineTurns, baselineAssistantText, deepResearchTargetKeys };
1133
+ return {
1134
+ baselineTurns,
1135
+ baselineAssistantText,
1136
+ deepResearchTargetKeys: deepResearchTargetBaseline?.targetKeys,
1137
+ deepResearchTargetBaselineCaptured: deepResearchTargetBaseline?.captured,
1138
+ };
1099
1139
  };
1100
1140
  const reloadPromptComposer = async () => {
1101
1141
  logger("[browser] Composer became unresponsive; reloading page and retrying once.");
@@ -1105,6 +1145,7 @@ export async function runBrowserMode(options) {
1105
1145
  let baselineTurns = null;
1106
1146
  let baselineAssistantText = null;
1107
1147
  let deepResearchTargetKeys = [];
1148
+ let deepResearchTargetBaselineCaptured = false;
1108
1149
  await acquireProfileLockIfNeeded();
1109
1150
  try {
1110
1151
  const submission = await runSubmissionWithRecovery({
@@ -1122,6 +1163,7 @@ export async function runBrowserMode(options) {
1122
1163
  baselineTurns = submission.baselineTurns;
1123
1164
  baselineAssistantText = submission.baselineAssistantText;
1124
1165
  deepResearchTargetKeys = submission.deepResearchTargetKeys ?? [];
1166
+ deepResearchTargetBaselineCaptured = submission.deepResearchTargetBaselineCaptured ?? false;
1125
1167
  }
1126
1168
  finally {
1127
1169
  await releaseProfileLockIfHeld();
@@ -1129,7 +1171,10 @@ export async function runBrowserMode(options) {
1129
1171
  const imageArtifactMinTurnIndex = baselineTurns;
1130
1172
  if (deepResearch) {
1131
1173
  await raceWithDisconnect(waitForResearchPlanAutoConfirm(Runtime, logger));
1132
- 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
+ }));
1133
1178
  await updateConversationHint("post-deep-research", 15_000).catch(() => false);
1134
1179
  runStatus = "complete";
1135
1180
  const durationMs = Date.now() - startedAt;
@@ -1614,6 +1659,10 @@ export async function runBrowserMode(options) {
1614
1659
  connectionClosedUnexpectedly = connectionClosedUnexpectedly || socketClosed;
1615
1660
  const preservedErrorKind = classifyPreservedBrowserError(normalizedError, config.headless);
1616
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
+ }
1617
1666
  preserveBrowserOnError = true;
1618
1667
  const runtime = {
1619
1668
  chromePid: chrome.pid,
@@ -1637,6 +1686,13 @@ export async function runBrowserMode(options) {
1637
1686
  }, normalizedError);
1638
1687
  }
1639
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
+ }
1640
1696
  preserveBrowserOnError = true;
1641
1697
  await emitRuntimeHint();
1642
1698
  logger("Assistant capture incomplete; leaving browser open for reattach.");
@@ -1689,7 +1745,11 @@ export async function runBrowserMode(options) {
1689
1745
  chrome?.port) {
1690
1746
  await closeTab(chrome.port, isolatedTargetId, logger, chromeHost).catch(() => undefined);
1691
1747
  }
1692
- let keepBrowserOpen = effectiveKeepBrowser || preserveBrowserOnError;
1748
+ let keepBrowserOpen = shouldKeepLocalBrowserOpen({
1749
+ effectiveKeepBrowser,
1750
+ preserveBrowserOnError,
1751
+ usingCopiedProfile,
1752
+ });
1693
1753
  let cleanupProfileLock = null;
1694
1754
  let terminatedRecordedChrome = false;
1695
1755
  let otherActiveBrowserTabLeases = null;
@@ -2247,8 +2307,8 @@ async function runRemoteBrowserMode(promptText, attachments, config, logger, opt
2247
2307
  attachmentNames: attachmentExpectations,
2248
2308
  onPromptSubmitted: markPromptSubmitted,
2249
2309
  };
2250
- const deepResearchTargetKeys = deepResearch && client
2251
- ? await captureDeepResearchTargetKeys(client).catch(() => [])
2310
+ const deepResearchTargetBaseline = deepResearch && client
2311
+ ? await captureDeepResearchTargetBaseline(client, logger)
2252
2312
  : undefined;
2253
2313
  await runProviderSubmissionFlow(chatgptDomProvider, {
2254
2314
  prompt,
@@ -2262,7 +2322,12 @@ async function runRemoteBrowserMode(promptText, attachments, config, logger, opt
2262
2322
  if (typeof providerBaselineTurns === "number" && Number.isFinite(providerBaselineTurns)) {
2263
2323
  baselineTurns = providerBaselineTurns;
2264
2324
  }
2265
- return { baselineTurns, baselineAssistantText, deepResearchTargetKeys };
2325
+ return {
2326
+ baselineTurns,
2327
+ baselineAssistantText,
2328
+ deepResearchTargetKeys: deepResearchTargetBaseline?.targetKeys,
2329
+ deepResearchTargetBaselineCaptured: deepResearchTargetBaseline?.captured,
2330
+ };
2266
2331
  };
2267
2332
  const reloadPromptComposer = async () => {
2268
2333
  logger("[browser] Composer became unresponsive; reloading page and retrying once.");
@@ -2272,6 +2337,7 @@ async function runRemoteBrowserMode(promptText, attachments, config, logger, opt
2272
2337
  let baselineTurns = null;
2273
2338
  let baselineAssistantText = null;
2274
2339
  let deepResearchTargetKeys = [];
2340
+ let deepResearchTargetBaselineCaptured = false;
2275
2341
  const submission = await runSubmissionWithRecovery({
2276
2342
  prompt: promptText,
2277
2343
  attachments,
@@ -2287,10 +2353,14 @@ async function runRemoteBrowserMode(promptText, attachments, config, logger, opt
2287
2353
  baselineTurns = submission.baselineTurns;
2288
2354
  baselineAssistantText = submission.baselineAssistantText;
2289
2355
  deepResearchTargetKeys = submission.deepResearchTargetKeys ?? [];
2356
+ deepResearchTargetBaselineCaptured = submission.deepResearchTargetBaselineCaptured ?? false;
2290
2357
  const imageArtifactMinTurnIndex = baselineTurns;
2291
2358
  if (deepResearch) {
2292
2359
  await waitForResearchPlanAutoConfirm(Runtime, logger);
2293
- 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
+ });
2294
2364
  await emitRuntimeHint();
2295
2365
  const durationMs = Date.now() - startedAt;
2296
2366
  const tokens = estimateTokenCount(researchResult.text);
@@ -2812,6 +2882,7 @@ export const __test__ = {
2812
2882
  listIgnoredRemoteChromeFlags,
2813
2883
  resolveManualLoginWaitMs,
2814
2884
  shouldCloseOwnedRunTargetAfterRun,
2885
+ shouldKeepLocalBrowserOpen,
2815
2886
  };
2816
2887
  export { syncCookies } from "./cookies.js";
2817
2888
  export { navigateToChatGPT, ensureNotBlocked, ensurePromptReady, ensureModelSelection, submitPrompt, waitForAssistantResponse, captureAssistantMarkdown, uploadAttachmentFile, waitForAttachmentCompletion, } from "./pageActions.js";
@@ -0,0 +1,56 @@
1
+ const defaultLabels = [
2
+ { service: 'Chrome Safe Storage', account: 'Chrome' },
3
+ { service: 'Chromium Safe Storage', account: 'Chromium' },
4
+ { service: 'Microsoft Edge Safe Storage', account: 'Microsoft Edge' },
5
+ { service: 'Brave Safe Storage', account: 'Brave' },
6
+ { service: 'Vivaldi Safe Storage', account: 'Vivaldi' },
7
+ ];
8
+ function loadEnvLabels() {
9
+ const raw = process.env.ORACLE_KEYCHAIN_LABELS;
10
+ if (!raw)
11
+ return [];
12
+ try {
13
+ const parsed = JSON.parse(raw);
14
+ if (Array.isArray(parsed)) {
15
+ return parsed
16
+ .map((entry) => (entry && typeof entry === 'object' ? entry : null))
17
+ .filter((entry) => Boolean(entry?.service && entry?.account));
18
+ }
19
+ }
20
+ catch {
21
+ // ignore invalid env payload
22
+ }
23
+ return [];
24
+ }
25
+ const fallbackLabels = [...loadEnvLabels(), ...defaultLabels];
26
+ const disableKeytar = process.env.ORACLE_DISABLE_KEYTAR === '1' || process.env.CI === 'true';
27
+ let keytar;
28
+ if (disableKeytar) {
29
+ keytar = {
30
+ getPassword: async () => null,
31
+ setPassword: async () => undefined,
32
+ deletePassword: async () => false,
33
+ };
34
+ }
35
+ else {
36
+ const keytarModule = await import('keytar');
37
+ keytar = (keytarModule.default ?? keytarModule);
38
+ const originalGetPassword = keytar.getPassword.bind(keytar);
39
+ keytar.getPassword = async (service, account) => {
40
+ const primary = await originalGetPassword(service, account);
41
+ if (primary) {
42
+ return primary;
43
+ }
44
+ for (const label of fallbackLabels) {
45
+ if (label.service === service && label.account === account) {
46
+ continue; // already tried
47
+ }
48
+ const value = await originalGetPassword(label.service, label.account);
49
+ if (value) {
50
+ return value;
51
+ }
52
+ }
53
+ return null;
54
+ };
55
+ }
56
+ export default keytar;
@@ -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
+ }