@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
@@ -458,7 +458,7 @@ async function configureBrowserDownloadPath(params) {
458
458
  }
459
459
  return false;
460
460
  }
461
- function buildClickAssistantDownloadButtonsExpression(minTurnIndex, expectedLabels = [], allowGenericDownloadLabels = true) {
461
+ function buildClickAssistantDownloadButtonsExpression(minTurnIndex, expectedLabels = [], allowGenericDownloadLabels = true, options = {}) {
462
462
  const minTurnLiteral = typeof minTurnIndex === "number" && Number.isFinite(minTurnIndex) && minTurnIndex >= 0
463
463
  ? Math.floor(minTurnIndex)
464
464
  : -1;
@@ -466,12 +466,22 @@ function buildClickAssistantDownloadButtonsExpression(minTurnIndex, expectedLabe
466
466
  const assistantLiteral = JSON.stringify(ASSISTANT_ROLE_SELECTOR);
467
467
  const expectedLabelsLiteral = JSON.stringify(expectedLabels);
468
468
  const allowGenericDownloadLabelsLiteral = JSON.stringify(allowGenericDownloadLabels);
469
+ const markClickedLiteral = JSON.stringify(options.markClicked === true);
470
+ const maxClicksLiteral = typeof options.maxClicks === "number" &&
471
+ Number.isFinite(options.maxClicks) &&
472
+ options.maxClicks > 0
473
+ ? Math.floor(options.maxClicks)
474
+ : 0;
469
475
  return `(() => {
470
476
  const MIN_TURN_INDEX = ${minTurnLiteral};
471
477
  const CONVERSATION_SELECTOR = ${conversationLiteral};
472
478
  const ASSISTANT_SELECTOR = ${assistantLiteral};
473
479
  const EXPECTED_LABELS = ${expectedLabelsLiteral};
474
480
  const ALLOW_GENERIC_DOWNLOAD_LABELS = ${allowGenericDownloadLabelsLiteral};
481
+ const MARK_CLICKED = ${markClickedLiteral};
482
+ const MAX_CLICKS = ${maxClicksLiteral};
483
+ const HAS_EXPECTED_LABELS = EXPECTED_LABELS.length > 0;
484
+ const CLICKED_ATTRIBUTE = 'data-oracle-download-clicked';
475
485
  const isAssistantTurn = (node) => {
476
486
  if (!(node instanceof HTMLElement)) return false;
477
487
  const turnAttr = (node.getAttribute('data-turn') || node.dataset?.turn || '').toLowerCase();
@@ -482,33 +492,67 @@ function buildClickAssistantDownloadButtonsExpression(minTurnIndex, expectedLabe
482
492
  if (testId.includes('assistant')) return true;
483
493
  return Boolean(node.querySelector(ASSISTANT_SELECTOR) || node.querySelector('[data-testid*="assistant"]'));
484
494
  };
495
+ const expectedFileButton = (button) => {
496
+ const text = (button.textContent || '').trim().toLowerCase();
497
+ return EXPECTED_LABELS.some((label) => {
498
+ const downloadLabel = 'download ' + label;
499
+ return text === label ||
500
+ text.startsWith(label + ' ') ||
501
+ text === downloadLabel ||
502
+ text.startsWith(downloadLabel + ' ');
503
+ });
504
+ };
505
+ const genericBehaviorButton = (button) => {
506
+ const text = (button.textContent || '').trim().toLowerCase();
507
+ return ALLOW_GENERIC_DOWNLOAD_LABELS && /^download\\b/.test(text);
508
+ };
509
+ const genericFallbackButton = (button) => {
510
+ if (!ALLOW_GENERIC_DOWNLOAD_LABELS) return false;
511
+ const text = (button.textContent || '').trim().toLowerCase();
512
+ const aria = (button.getAttribute('aria-label') || '').trim().toLowerCase();
513
+ const testId = (button.getAttribute('data-testid') || '').trim().toLowerCase();
514
+ return text === 'download' || aria === 'download' || testId === 'download-files-turn-action-button';
515
+ };
485
516
  const turns = Array.from(document.querySelectorAll(CONVERSATION_SELECTOR));
486
- const selected = new Set();
517
+ const expectedMatches = new Set();
518
+ const genericBehaviorMatches = new Set();
519
+ const genericFallbackMatches = new Set();
520
+ const genericAllMatches = new Set();
487
521
  for (let index = turns.length - 1; index >= 0; index -= 1) {
488
522
  const turn = turns[index];
489
523
  if (!isAssistantTurn(turn)) continue;
490
524
  if (MIN_TURN_INDEX >= 0 && index < MIN_TURN_INDEX) continue;
491
525
  const messageRoot = turn.querySelector(ASSISTANT_SELECTOR) || turn;
492
- const buttons = Array.from(messageRoot.querySelectorAll('button'));
493
- const primary = buttons.filter((button) => {
494
- const text = (button.textContent || '').trim().toLowerCase();
495
- const expectedFile = EXPECTED_LABELS.some(
496
- (label) => text === label || text.startsWith(label + ' ')
497
- );
498
- return String(button.className || '').includes('behavior-btn') &&
499
- (expectedFile || (ALLOW_GENERIC_DOWNLOAD_LABELS && /^download\\b/.test(text)));
500
- });
501
- const fallback = primary.length > 0 ? [] : buttons.filter((button) => {
502
- if (!ALLOW_GENERIC_DOWNLOAD_LABELS) return false;
503
- const text = (button.textContent || '').trim().toLowerCase();
504
- const aria = (button.getAttribute('aria-label') || '').trim().toLowerCase();
505
- const testId = (button.getAttribute('data-testid') || '').trim().toLowerCase();
506
- return text === 'download' || aria === 'download' || testId === 'download-files-turn-action-button';
526
+ const buttons = Array.from(messageRoot.querySelectorAll('button'))
527
+ .filter((button) => !(MARK_CLICKED && button.getAttribute(CLICKED_ATTRIBUTE) === 'true'));
528
+ const behaviorButtons = buttons.filter((button) =>
529
+ String(button.className || '').includes('behavior-btn')
530
+ );
531
+ behaviorButtons.filter(expectedFileButton).forEach((button) => expectedMatches.add(button));
532
+ const genericBehavior = behaviorButtons.filter(genericBehaviorButton);
533
+ genericBehavior.forEach((button) => {
534
+ genericBehaviorMatches.add(button);
535
+ genericAllMatches.add(button);
507
536
  });
508
- [...primary, ...fallback].forEach((button) => selected.add(button));
537
+ if (genericBehavior.length === 0) {
538
+ buttons.filter(genericFallbackButton).forEach((button) => {
539
+ genericFallbackMatches.add(button);
540
+ genericAllMatches.add(button);
541
+ });
542
+ }
509
543
  }
510
- const selectedButtons = Array.from(selected);
511
- selectedButtons.forEach((button) => button.click());
544
+ const selected = expectedMatches.size > 0
545
+ ? expectedMatches
546
+ : HAS_EXPECTED_LABELS
547
+ ? genericBehaviorMatches.size > 0
548
+ ? genericBehaviorMatches
549
+ : genericFallbackMatches
550
+ : genericAllMatches;
551
+ const selectedButtons = Array.from(selected).slice(0, MAX_CLICKS > 0 ? MAX_CLICKS : undefined);
552
+ selectedButtons.forEach((button) => {
553
+ if (MARK_CLICKED) button.setAttribute(CLICKED_ATTRIBUTE, 'true');
554
+ button.click();
555
+ });
512
556
  return selectedButtons.map((button) => ({
513
557
  text: (button.textContent || '').trim(),
514
558
  ariaLabel: button.getAttribute('aria-label') || '',
@@ -516,6 +560,80 @@ function buildClickAssistantDownloadButtonsExpression(minTurnIndex, expectedLabe
516
560
  }));
517
561
  })()`;
518
562
  }
563
+ function describeDownloadableFile(file) {
564
+ return (file.filename ??
565
+ file.label ??
566
+ filenameFromUrl(file.sandboxUrl) ??
567
+ filenameFromUrl(file.downloadUrl) ??
568
+ filenameFromUrl(file.url) ??
569
+ file.sandboxUrl ??
570
+ file.downloadUrl ??
571
+ file.url);
572
+ }
573
+ function expectedDownloadedFilename(file) {
574
+ const filename = file.filename ??
575
+ filenameFromUrl(file.sandboxUrl) ??
576
+ filenameFromUrl(file.downloadUrl) ??
577
+ filenameFromUrl(file.url);
578
+ const basename = path.basename(String(filename ?? "").trim());
579
+ return basename && basename !== "." ? basename : undefined;
580
+ }
581
+ async function moveDownloadedFileToExpectedName(filePath, file) {
582
+ const filename = expectedDownloadedFilename(file);
583
+ if (!filename) {
584
+ return filePath;
585
+ }
586
+ const targetPath = path.join(path.dirname(filePath), filename);
587
+ if (path.resolve(targetPath) === path.resolve(filePath)) {
588
+ return filePath;
589
+ }
590
+ const expected = path.parse(filename);
591
+ const escapeRegExp = (value) => value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
592
+ const duplicatePattern = new RegExp(`^${escapeRegExp(expected.name)} ?\\(\\d+\\)${escapeRegExp(expected.ext)}$`);
593
+ if (!duplicatePattern.test(path.basename(filePath))) {
594
+ return filePath;
595
+ }
596
+ const targetExists = await fs
597
+ .stat(targetPath)
598
+ .then((stat) => stat.isFile())
599
+ .catch(() => false);
600
+ if (targetExists) {
601
+ return filePath;
602
+ }
603
+ await fs.rename(filePath, targetPath);
604
+ return targetPath;
605
+ }
606
+ async function clickAssistantDownloadButtons(params) {
607
+ const expression = buildClickAssistantDownloadButtonsExpression(params.minTurnIndex, params.expectedLabels ?? [], params.allowGenericDownloadLabels, { markClicked: params.markClicked, maxClicks: params.maxClicks });
608
+ const deadline = Date.now() + (params.timeoutMs ?? DOWNLOAD_BUTTON_WAIT_MS);
609
+ while (Date.now() < deadline) {
610
+ const { result } = await params.Runtime.evaluate({
611
+ expression,
612
+ returnByValue: true,
613
+ });
614
+ const clicked = Array.isArray(result?.value) ? result.value : [];
615
+ if (clicked.length > 0) {
616
+ return clicked;
617
+ }
618
+ await new Promise((resolve) => setTimeout(resolve, 250));
619
+ }
620
+ return [];
621
+ }
622
+ async function savedBrowserFileFromPath(filePath) {
623
+ const filename = path.basename(filePath);
624
+ const stat = await fs.stat(filePath);
625
+ return {
626
+ kind: "file",
627
+ path: filePath,
628
+ label: filename,
629
+ mimeType: mimeTypeFromFilename(filename),
630
+ sizeBytes: stat.size,
631
+ sourceUrl: "browser-download",
632
+ url: "browser-download",
633
+ finalUrl: "browser-download",
634
+ filename,
635
+ };
636
+ }
519
637
  export async function saveAssistantDownloadButtonArtifacts(params) {
520
638
  if ((!params.sessionId && !params.downloadPath) ||
521
639
  (!params.Client && !params.Browser && !params.Page)) {
@@ -539,41 +657,72 @@ export async function saveAssistantDownloadButtonArtifacts(params) {
539
657
  params.logger?.("[browser] Browser download path could not be configured; skipping button fallback.");
540
658
  return [];
541
659
  }
542
- let clicked = [];
543
- const expression = buildClickAssistantDownloadButtonsExpression(params.minTurnIndex, resolveDownloadButtonLabels(params.files ?? []), params.allowGenericDownloadLabels);
544
- const deadline = Date.now() + DOWNLOAD_BUTTON_WAIT_MS;
545
- while (Date.now() < deadline) {
546
- const { result } = await params.Runtime.evaluate({
547
- expression,
548
- returnByValue: true,
660
+ const buttonWaitMs = params.buttonWaitMs ?? DOWNLOAD_BUTTON_WAIT_MS;
661
+ const downloadWaitMs = params.downloadWaitMs ?? DOWNLOAD_BUTTON_WAIT_MS;
662
+ const expectedFiles = params.files ?? [];
663
+ if (expectedFiles.length === 0) {
664
+ const clicked = await clickAssistantDownloadButtons({
665
+ Runtime: params.Runtime,
666
+ minTurnIndex: params.minTurnIndex,
667
+ expectedLabels: [],
668
+ allowGenericDownloadLabels: params.allowGenericDownloadLabels,
669
+ timeoutMs: buttonWaitMs,
549
670
  });
550
- clicked = Array.isArray(result?.value) ? result.value : [];
551
- if (clicked.length > 0) {
671
+ if (clicked.length === 0) {
672
+ params.logger?.("[browser] No assistant download buttons found for button fallback.");
673
+ return [];
674
+ }
675
+ params.logger?.(`[browser] Clicked ${clicked.length} assistant download button(s).`);
676
+ const downloaded = await waitForCompletedDownloadFiles(artifactsDir, before, clicked.length, downloadWaitMs);
677
+ return Promise.all(downloaded.map(savedBrowserFileFromPath));
678
+ }
679
+ let clickedCount = 0;
680
+ let knownEntries = before;
681
+ const downloadedPaths = [];
682
+ const missingFiles = [];
683
+ const unattemptedFiles = [];
684
+ for (const [fileIndex, file] of expectedFiles.entries()) {
685
+ const expectedLabels = resolveDownloadButtonLabels([file]);
686
+ const clicked = await clickAssistantDownloadButtons({
687
+ Runtime: params.Runtime,
688
+ minTurnIndex: params.minTurnIndex,
689
+ expectedLabels,
690
+ allowGenericDownloadLabels: params.allowGenericDownloadLabels === true,
691
+ markClicked: true,
692
+ maxClicks: 1,
693
+ timeoutMs: buttonWaitMs,
694
+ });
695
+ const displayName = describeDownloadableFile(file);
696
+ if (clicked.length === 0) {
697
+ missingFiles.push(displayName);
698
+ knownEntries = new Set(await fs.readdir(artifactsDir).catch(() => []));
699
+ continue;
700
+ }
701
+ clickedCount += clicked.length;
702
+ const downloaded = await waitForCompletedDownloadFiles(artifactsDir, knownEntries, 1, downloadWaitMs);
703
+ if (downloaded.length === 0) {
704
+ missingFiles.push(displayName);
705
+ unattemptedFiles.push(...expectedFiles.slice(fileIndex + 1).map(describeDownloadableFile));
706
+ missingFiles.push(...unattemptedFiles);
707
+ params.logger?.(`[browser] Download timed out for ${displayName}${unattemptedFiles.length > 0
708
+ ? `; skipped remaining expected file(s) to avoid misassigning a late completion: ${unattemptedFiles.join(", ")}`
709
+ : ""}`);
552
710
  break;
553
711
  }
554
- await new Promise((resolve) => setTimeout(resolve, 250));
712
+ const normalizedDownloads = await Promise.all(downloaded.map((filePath, index) => index === 0 ? moveDownloadedFileToExpectedName(filePath, file) : filePath));
713
+ downloadedPaths.push(...normalizedDownloads);
714
+ knownEntries = new Set(await fs.readdir(artifactsDir).catch(() => []));
555
715
  }
556
- if (clicked.length === 0) {
716
+ if (clickedCount === 0) {
557
717
  params.logger?.("[browser] No assistant download buttons found for button fallback.");
558
- return [];
559
718
  }
560
- params.logger?.(`[browser] Clicked ${clicked.length} assistant download button(s).`);
561
- const downloaded = await waitForCompletedDownloadFiles(artifactsDir, before, clicked.length);
562
- return Promise.all(downloaded.map(async (filePath) => {
563
- const filename = path.basename(filePath);
564
- const stat = await fs.stat(filePath);
565
- return {
566
- kind: "file",
567
- path: filePath,
568
- label: filename,
569
- mimeType: mimeTypeFromFilename(filename),
570
- sizeBytes: stat.size,
571
- sourceUrl: "browser-download",
572
- url: "browser-download",
573
- finalUrl: "browser-download",
574
- filename,
575
- };
576
- }));
719
+ else {
720
+ params.logger?.(`[browser] Clicked ${clickedCount} assistant download button(s).`);
721
+ }
722
+ if (missingFiles.length > 0) {
723
+ params.logger?.(`[browser] Download button fallback did not save expected file(s): ${missingFiles.join(", ")}`);
724
+ }
725
+ return Promise.all([...new Set(downloadedPaths)].map(savedBrowserFileFromPath));
577
726
  }
578
727
  async function fetchDownloadWithNode(downloadUrl, getCookieHeader) {
579
728
  let currentUrl = new URL(downloadUrl);
@@ -0,0 +1,312 @@
1
+ import path from 'node:path';
2
+ import os from 'node:os';
3
+ import fs from 'node:fs/promises';
4
+ import { existsSync } from 'node:fs';
5
+ import { createRequire } from 'node:module';
6
+ import chromeCookies from 'chrome-cookies-secure';
7
+ import { COOKIE_URLS } from './constants.js';
8
+ import { ensureCookiesDirForFallback } from './windowsCookies.js';
9
+ const COOKIE_READ_TIMEOUT_MS = readDuration('ORACLE_COOKIE_LOAD_TIMEOUT_MS', 5_000);
10
+ const KEYCHAIN_PROBE_TIMEOUT_MS = readDuration('ORACLE_KEYCHAIN_PROBE_TIMEOUT_MS', 3_000);
11
+ const MAC_KEYCHAIN_LABELS = loadKeychainLabels();
12
+ export async function loadChromeCookies({ targetUrl, profile, explicitCookiePath, filterNames, }) {
13
+ if (process.platform === 'win32') {
14
+ throw new Error('Cookie sync is disabled on Windows; use --browser-manual-login (persistent profile) or inline cookies instead.');
15
+ }
16
+ const urlsToCheck = Array.from(new Set([stripQuery(targetUrl), ...COOKIE_URLS]));
17
+ const merged = new Map();
18
+ const cookieFile = await resolveCookieFilePath({ explicitPath: explicitCookiePath, profile });
19
+ const cookiesPath = await materializeCookieFile(cookieFile); // returns the copied file (or source on non-Windows)
20
+ const fallbackDir = await ensureCookiesDirForFallback(cookiesPath);
21
+ if (process.env.ORACLE_DEBUG_COOKIES === '1') {
22
+ // eslint-disable-next-line no-console
23
+ console.log(`[cookies] resolved cookie file: ${cookiesPath}`);
24
+ console.log(`[cookies] fallback dir for chrome-cookies-secure: ${fallbackDir}`);
25
+ }
26
+ await ensureMacKeychainReadable();
27
+ for (const url of urlsToCheck) {
28
+ let raw;
29
+ try {
30
+ const pathForSecure = await adaptPathForChromeCookies(fallbackDir);
31
+ raw = await settleWithTimeout(chromeCookies.getCookiesPromised(url, 'puppeteer', pathForSecure), COOKIE_READ_TIMEOUT_MS, `Timed out reading Chrome cookies from ${pathForSecure} (after ${COOKIE_READ_TIMEOUT_MS} ms)`);
32
+ }
33
+ catch (error) {
34
+ const message = error instanceof Error ? error.message : String(error);
35
+ throw new Error(`Failed to load Chrome cookies for ${url}: ${message}`);
36
+ }
37
+ if (!Array.isArray(raw))
38
+ continue;
39
+ const fallbackHost = new URL(url).hostname;
40
+ for (const cookie of raw) {
41
+ if (filterNames && filterNames.size > 0 && !filterNames.has(cookie.name))
42
+ continue;
43
+ const normalized = normalizeCookie(cookie, fallbackHost);
44
+ if (!normalized)
45
+ continue;
46
+ const key = `${normalized.domain ?? fallbackHost}:${normalized.name}`;
47
+ if (!merged.has(key)) {
48
+ merged.set(key, normalized);
49
+ }
50
+ }
51
+ }
52
+ return Array.from(merged.values());
53
+ }
54
+ async function ensureMacKeychainReadable() {
55
+ if (process.platform === 'win32') {
56
+ return;
57
+ }
58
+ // chrome-cookies-secure can hang forever when the platform keyring rejects access (e.g., SSH/no GUI).
59
+ // Probe the keyring ourselves with a timeout so callers fail fast instead of blocking the run.
60
+ let keytar = null;
61
+ let keytarPath = null;
62
+ try {
63
+ const require = createRequire(import.meta.url);
64
+ keytarPath = require.resolve('keytar');
65
+ const keytarModule = await import('keytar');
66
+ keytar = (keytarModule.default ?? keytarModule);
67
+ }
68
+ catch (error) {
69
+ const base = error instanceof Error ? error.message : String(error);
70
+ const rebuildHint = keytarPath
71
+ ? ` You may need to rebuild keytar: PYTHON=/usr/bin/python3 /Users/steipete/Projects/oracle/runner npx node-gyp rebuild (run inside ${path.dirname(keytarPath)}).`
72
+ : '';
73
+ throw new Error(`Failed to load keytar for secure cookie copy (${base}). Install keyring deps (macOS Keychain / libsecret) or rerun with --render --copy to paste into ChatGPT manually.${rebuildHint}`);
74
+ }
75
+ const password = await settleWithTimeout(findKeychainPassword(keytar, MAC_KEYCHAIN_LABELS), KEYCHAIN_PROBE_TIMEOUT_MS, `Timed out reading macOS Keychain while looking up Chrome Safe Storage (after ${KEYCHAIN_PROBE_TIMEOUT_MS} ms). Unlock the login keychain or start oracle serve from a GUI session.`);
76
+ if (!password) {
77
+ throw new Error('macOS Keychain denied access to Chrome cookies. Unlock the login keychain or run oracle serve from a GUI session, then retry.');
78
+ }
79
+ }
80
+ async function findKeychainPassword(keytar, labels) {
81
+ let lastError = null;
82
+ for (const label of labels) {
83
+ try {
84
+ const value = await keytar.getPassword(label.service, label.account);
85
+ if (value)
86
+ return value;
87
+ }
88
+ catch (error) {
89
+ lastError = error instanceof Error ? error : new Error(String(error));
90
+ }
91
+ }
92
+ if (lastError) {
93
+ throw lastError;
94
+ }
95
+ return null;
96
+ }
97
+ function settleWithTimeout(promise, timeoutMs, timeoutMessage) {
98
+ return new Promise((resolve, reject) => {
99
+ const timer = setTimeout(() => reject(new Error(timeoutMessage)), timeoutMs);
100
+ timer.unref?.();
101
+ promise.then((value) => {
102
+ clearTimeout(timer);
103
+ resolve(value);
104
+ }, (error) => {
105
+ clearTimeout(timer);
106
+ reject(error);
107
+ });
108
+ });
109
+ }
110
+ function normalizeCookie(cookie, fallbackHost) {
111
+ if (!cookie?.name)
112
+ return null;
113
+ const domain = cookie.domain?.startsWith('.') ? cookie.domain.slice(1) : cookie.domain ?? fallbackHost;
114
+ const expires = normalizeExpiration(cookie.expires);
115
+ const secure = typeof cookie.Secure === 'boolean' ? cookie.Secure : true;
116
+ const httpOnly = typeof cookie.HttpOnly === 'boolean' ? cookie.HttpOnly : false;
117
+ return {
118
+ name: cookie.name,
119
+ value: cleanValue(cookie.value ?? ''),
120
+ domain,
121
+ path: cookie.path ?? '/',
122
+ expires,
123
+ secure,
124
+ httpOnly,
125
+ };
126
+ }
127
+ function cleanValue(value) {
128
+ let i = 0;
129
+ while (i < value.length && value.charCodeAt(i) < 0x20)
130
+ i += 1;
131
+ return value.slice(i);
132
+ }
133
+ function normalizeExpiration(expires) {
134
+ if (!expires || Number.isNaN(expires)) {
135
+ return undefined;
136
+ }
137
+ const value = Number(expires);
138
+ if (value <= 0)
139
+ return undefined;
140
+ if (value > 1_000_000_000_000) {
141
+ return Math.round(value / 1_000_000 - 11644473600);
142
+ }
143
+ if (value > 1_000_000_000) {
144
+ return Math.round(value / 1000);
145
+ }
146
+ return Math.round(value);
147
+ }
148
+ async function resolveCookieFilePath({ explicitPath, profile, }) {
149
+ if (explicitPath && explicitPath.trim().length > 0) {
150
+ return ensureCookieFile(explicitPath);
151
+ }
152
+ if (profile && looksLikePath(profile)) {
153
+ return ensureCookieFile(profile);
154
+ }
155
+ const profileName = profile && profile.trim().length > 0 ? profile : 'Default';
156
+ const baseDir = await defaultProfileRoot();
157
+ return ensureCookieFile(path.join(baseDir, profileName));
158
+ }
159
+ async function adaptPathForChromeCookies(resolved) {
160
+ const stat = await fs.stat(resolved).catch(() => null);
161
+ if (stat?.isFile()) {
162
+ // chrome-cookies-secure appends "Cookies" when given a directory; if we already have the file, return its directory.
163
+ return path.dirname(resolved);
164
+ }
165
+ return resolved;
166
+ }
167
+ async function ensureCookieFile(inputPath) {
168
+ const expanded = expandPath(inputPath);
169
+ const stat = await fs.stat(expanded).catch(() => null);
170
+ if (!stat) {
171
+ throw new Error(`Unable to locate Chrome cookie DB at ${expanded}`);
172
+ }
173
+ if (stat.isDirectory()) {
174
+ const directFile = path.join(expanded, 'Cookies');
175
+ if (await fileExists(directFile))
176
+ return directFile;
177
+ const networkFile = path.join(expanded, 'Network', 'Cookies');
178
+ if (await fileExists(networkFile))
179
+ return networkFile;
180
+ throw new Error(`No Cookies DB found under ${expanded}`);
181
+ }
182
+ return expanded;
183
+ }
184
+ async function fileExists(candidate) {
185
+ try {
186
+ const stat = await fs.stat(candidate);
187
+ return stat.isFile();
188
+ }
189
+ catch {
190
+ return false;
191
+ }
192
+ }
193
+ function expandPath(input) {
194
+ if (input.startsWith('~/')) {
195
+ return path.join(os.homedir(), input.slice(2));
196
+ }
197
+ return path.isAbsolute(input) ? input : path.resolve(process.cwd(), input);
198
+ }
199
+ function looksLikePath(value) {
200
+ return value.includes('/') || value.includes('\\');
201
+ }
202
+ async function materializeCookieFile(sourcePath) {
203
+ if (process.platform !== 'win32')
204
+ return sourcePath;
205
+ // Chrome can keep the Cookies DB locked; copy to a temp file so sqlite can open it reliably.
206
+ const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'oracle-cookies-'));
207
+ const tempPath = path.join(tempDir, 'Cookies');
208
+ try {
209
+ await fs.copyFile(sourcePath, tempPath);
210
+ return tempPath;
211
+ }
212
+ catch (_error) {
213
+ // Fall back to the original path if the copy fails; upstream error handling will surface issues.
214
+ return sourcePath;
215
+ }
216
+ }
217
+ async function defaultProfileRoot() {
218
+ const candidates = [];
219
+ if (process.platform === 'darwin') {
220
+ candidates.push(path.join(os.homedir(), 'Library', 'Application Support', 'Google', 'Chrome'), path.join(os.homedir(), 'Library', 'Application Support', 'Microsoft Edge'), path.join(os.homedir(), 'Library', 'Application Support', 'Chromium'));
221
+ }
222
+ else if (process.platform === 'linux') {
223
+ if (isWsl()) {
224
+ const windowsHomes = [process.env.USERPROFILE, process.env.WIN_HOME, process.env.HOME?.replace('/home', '/mnt/c/Users')]
225
+ .filter((p) => Boolean(p))
226
+ .map((p) => p.replace(/\\/g, '/'));
227
+ const wslCandidates = [];
228
+ for (const home of windowsHomes) {
229
+ const normalized = home.startsWith('/mnt/') ? home : `/mnt/c/Users/${path.basename(home)}`;
230
+ wslCandidates.push(path.join(normalized, 'AppData', 'Local', 'Google', 'Chrome', 'User Data'), path.join(normalized, 'AppData', 'Local', 'Microsoft', 'Edge', 'User Data'));
231
+ }
232
+ // Ensure we don't pick a non-user Default profile ahead of real ones.
233
+ for (const candidate of wslCandidates) {
234
+ const hasProfile = existsSync(path.join(candidate, 'Default'));
235
+ if (hasProfile) {
236
+ candidates.push(candidate);
237
+ }
238
+ }
239
+ }
240
+ candidates.push(path.join(os.homedir(), '.config', 'google-chrome'), path.join(os.homedir(), '.config', 'microsoft-edge'), path.join(os.homedir(), '.config', 'chromium'),
241
+ // Snap Chromium profiles
242
+ path.join(os.homedir(), 'snap', 'chromium', 'common', 'chromium'), path.join(os.homedir(), 'snap', 'chromium', 'current', 'chromium'));
243
+ }
244
+ else if (process.platform === 'win32') {
245
+ const localAppData = process.env.LOCALAPPDATA ?? path.join(os.homedir(), 'AppData', 'Local');
246
+ candidates.push(path.join(localAppData, 'Google', 'Chrome', 'User Data'), path.join(localAppData, 'Microsoft', 'Edge', 'User Data'), path.join(localAppData, 'Chromium', 'User Data'));
247
+ }
248
+ else {
249
+ throw new Error(`Unsupported platform: ${process.platform}`);
250
+ }
251
+ for (const candidate of candidates) {
252
+ if (existsSync(candidate)) {
253
+ return candidate;
254
+ }
255
+ }
256
+ // fallback: first candidate even if missing; upstream will throw clearer error
257
+ return candidates[0];
258
+ }
259
+ function stripQuery(url) {
260
+ try {
261
+ const parsed = new URL(url);
262
+ parsed.hash = '';
263
+ parsed.search = '';
264
+ return parsed.toString();
265
+ }
266
+ catch {
267
+ return url;
268
+ }
269
+ }
270
+ function readDuration(envKey, fallback) {
271
+ const raw = process.env[envKey];
272
+ if (!raw)
273
+ return fallback;
274
+ const parsed = Number.parseInt(raw, 10);
275
+ return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
276
+ }
277
+ function isWsl() {
278
+ if (process.platform !== 'linux')
279
+ return false;
280
+ return Boolean(process.env.WSL_DISTRO_NAME || os.release().toLowerCase().includes('microsoft'));
281
+ }
282
+ function loadKeychainLabels() {
283
+ const defaults = [
284
+ { service: 'Chrome Safe Storage', account: 'Chrome' },
285
+ { service: 'Chromium Safe Storage', account: 'Chromium' },
286
+ { service: 'Microsoft Edge Safe Storage', account: 'Microsoft Edge' },
287
+ { service: 'Brave Safe Storage', account: 'Brave' },
288
+ { service: 'Vivaldi Safe Storage', account: 'Vivaldi' },
289
+ ];
290
+ const rawEnv = process.env.ORACLE_KEYCHAIN_LABELS;
291
+ if (!rawEnv)
292
+ return defaults;
293
+ try {
294
+ const parsed = JSON.parse(rawEnv);
295
+ if (!Array.isArray(parsed))
296
+ return defaults;
297
+ const envLabels = parsed
298
+ .map((entry) => (entry && typeof entry === 'object' ? entry : null))
299
+ .filter((entry) => Boolean(entry?.service && entry?.account));
300
+ return envLabels.length ? [...envLabels, ...defaults] : defaults;
301
+ }
302
+ catch {
303
+ return defaults;
304
+ }
305
+ }
306
+ // biome-ignore lint/style/useNamingConvention: test-only export used in vitest suite
307
+ export const __test__ = {
308
+ normalizeExpiration,
309
+ cleanValue,
310
+ looksLikePath,
311
+ defaultProfileRoot,
312
+ };