@steipete/oracle 0.17.2 → 0.18.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 (96) hide show
  1. package/dist/bin/oracle-cli.js +10 -1
  2. package/dist/bin/oracle-mcp.js +0 -0
  3. package/dist/bin/oracle.js +683 -0
  4. package/dist/docs-site/bridge.html +1 -17
  5. package/dist/docs-site/browser-mode.html +18 -26
  6. package/dist/docs-site/cli-reference.html +6 -6
  7. package/dist/docs-site/configuration.html +7 -33
  8. package/dist/docs-site/followup.html +9 -16
  9. package/dist/docs-site/gemini.html +6 -10
  10. package/dist/docs-site/index.html +7 -7
  11. package/dist/docs-site/install.html +1 -1
  12. package/dist/docs-site/manual-tests.html +2 -4
  13. package/dist/docs-site/mcp.html +5 -28
  14. package/dist/docs-site/mythical-pro-agents.html +10 -10
  15. package/dist/docs-site/openai-endpoints.html +1 -24
  16. package/dist/docs-site/quickstart.html +1 -1
  17. package/dist/docs-site/sessions.html +5 -6
  18. package/dist/docs-site/spec.html +1 -1
  19. package/dist/docs-site/windows-work.html +1 -2
  20. package/dist/docs-site/windows.html +1 -1
  21. package/dist/markdansi/types/index.js +4 -0
  22. package/dist/oracle/bin/oracle-cli.js +472 -0
  23. package/dist/oracle/src/browser/actions/assistantResponse.js +471 -0
  24. package/dist/oracle/src/browser/actions/attachments.js +82 -0
  25. package/dist/oracle/src/browser/actions/modelSelection.js +190 -0
  26. package/dist/oracle/src/browser/actions/navigation.js +75 -0
  27. package/dist/oracle/src/browser/actions/promptComposer.js +167 -0
  28. package/dist/oracle/src/browser/chromeLifecycle.js +104 -0
  29. package/dist/oracle/src/browser/config.js +33 -0
  30. package/dist/oracle/src/browser/constants.js +40 -0
  31. package/dist/oracle/src/browser/cookies.js +210 -0
  32. package/dist/oracle/src/browser/domDebug.js +36 -0
  33. package/dist/oracle/src/browser/index.js +331 -0
  34. package/dist/oracle/src/browser/pageActions.js +5 -0
  35. package/dist/oracle/src/browser/prompt.js +88 -0
  36. package/dist/oracle/src/browser/promptSummary.js +20 -0
  37. package/dist/oracle/src/browser/sessionRunner.js +80 -0
  38. package/dist/oracle/src/browser/types.js +1 -0
  39. package/dist/oracle/src/browser/utils.js +62 -0
  40. package/dist/oracle/src/browserMode.js +1 -0
  41. package/dist/oracle/src/cli/browserConfig.js +44 -0
  42. package/dist/oracle/src/cli/dryRun.js +59 -0
  43. package/dist/oracle/src/cli/engine.js +17 -0
  44. package/dist/oracle/src/cli/errorUtils.js +9 -0
  45. package/dist/oracle/src/cli/help.js +70 -0
  46. package/dist/oracle/src/cli/markdownRenderer.js +15 -0
  47. package/dist/oracle/src/cli/options.js +103 -0
  48. package/dist/oracle/src/cli/promptRequirement.js +14 -0
  49. package/dist/oracle/src/cli/rootAlias.js +30 -0
  50. package/dist/oracle/src/cli/sessionCommand.js +77 -0
  51. package/dist/oracle/src/cli/sessionDisplay.js +270 -0
  52. package/dist/oracle/src/cli/sessionRunner.js +94 -0
  53. package/dist/oracle/src/heartbeat.js +43 -0
  54. package/dist/oracle/src/oracle/client.js +48 -0
  55. package/dist/oracle/src/oracle/config.js +29 -0
  56. package/dist/oracle/src/oracle/errors.js +101 -0
  57. package/dist/oracle/src/oracle/files.js +220 -0
  58. package/dist/oracle/src/oracle/format.js +33 -0
  59. package/dist/oracle/src/oracle/fsAdapter.js +7 -0
  60. package/dist/oracle/src/oracle/oscProgress.js +60 -0
  61. package/dist/oracle/src/oracle/request.js +48 -0
  62. package/dist/oracle/src/oracle/run.js +444 -0
  63. package/dist/oracle/src/oracle/tokenStats.js +39 -0
  64. package/dist/oracle/src/oracle/types.js +1 -0
  65. package/dist/oracle/src/oracle.js +9 -0
  66. package/dist/oracle/src/sessionManager.js +205 -0
  67. package/dist/oracle/src/version.js +39 -0
  68. package/dist/scripts/chrome/browser-tools.js +295 -0
  69. package/dist/src/browser/actions/assistantResponse.js +71 -23
  70. package/dist/src/browser/actions/thinkingTime.js +165 -9
  71. package/dist/src/browser/chromeCookies.js +312 -0
  72. package/dist/src/browser/config.js +2 -3
  73. package/dist/src/browser/index.js +7 -2
  74. package/dist/src/browser/keytarShim.js +56 -0
  75. package/dist/src/browser/policies.js +10 -2
  76. package/dist/src/browser/profileSync.js +141 -0
  77. package/dist/src/browser/projectSourcesRunner.js +6 -3
  78. package/dist/src/browser/reattach.js +50 -35
  79. package/dist/src/browser/windowsCookies.js +219 -0
  80. package/dist/src/cli/browserConfig.js +39 -10
  81. package/dist/src/cli/browserDefaults.js +9 -1
  82. package/dist/src/cli/dryRun.js +1 -0
  83. package/dist/src/cli/projectSources.js +10 -1
  84. package/dist/src/mcp/tools/consult.js +13 -4
  85. package/dist/src/remote/server.js +8 -3
  86. package/dist/vendor/oracle-notifier/OracleNotifier.app/Contents/CodeResources +0 -0
  87. package/dist/vendor/oracle-notifier/OracleNotifier.app/Contents/Info.plist +20 -0
  88. package/dist/vendor/oracle-notifier/OracleNotifier.app/Contents/MacOS/OracleNotifier +0 -0
  89. package/dist/vendor/oracle-notifier/OracleNotifier.app/Contents/Resources/OracleIcon.icns +0 -0
  90. package/dist/vendor/oracle-notifier/OracleNotifier.app/Contents/_CodeSignature/CodeResources +128 -0
  91. package/package.json +8 -8
  92. package/vendor/oracle-notifier/OracleNotifier.app/Contents/CodeResources +0 -0
  93. package/vendor/oracle-notifier/OracleNotifier.app/Contents/Info.plist +20 -0
  94. package/vendor/oracle-notifier/OracleNotifier.app/Contents/MacOS/OracleNotifier +0 -0
  95. package/vendor/oracle-notifier/OracleNotifier.app/Contents/Resources/OracleIcon.icns +0 -0
  96. package/vendor/oracle-notifier/OracleNotifier.app/Contents/_CodeSignature/CodeResources +128 -0
@@ -1,6 +1,35 @@
1
1
  import { MENU_CONTAINER_SELECTOR, MENU_ITEM_SELECTOR, MODEL_BUTTON_SELECTOR, } from "../constants.js";
2
2
  import { logDomFailure } from "../domDebug.js";
3
3
  import { buildClickDispatcher } from "./domEvents.js";
4
+ import { BrowserAutomationError } from "../../oracle/errors.js";
5
+ export class ThinkingTierUnavailableError extends BrowserAutomationError {
6
+ requestedLevel;
7
+ requestedLabel;
8
+ optionLabel;
9
+ notice;
10
+ confirmedTarget;
11
+ constructor(requestedLevel, requestedLabel, optionLabel, notice, confirmedTarget) {
12
+ const message = `Thinking time: ${optionLabel ?? requestedLabel} is unavailable on this account (${notice ?? "no reason given"}); refusing to submit without confirmed ${confirmedTarget}.`;
13
+ super(message, {
14
+ stage: "thinking-tier-unavailable",
15
+ requestedLevel,
16
+ requestedLabel,
17
+ optionLabel,
18
+ notice,
19
+ confirmedTarget,
20
+ });
21
+ this.name = "ThinkingTierUnavailableError";
22
+ this.requestedLevel = requestedLevel;
23
+ this.requestedLabel = requestedLabel;
24
+ this.optionLabel = optionLabel;
25
+ this.notice = notice;
26
+ this.confirmedTarget = confirmedTarget;
27
+ }
28
+ }
29
+ function confirmedThinkingTarget(level, capitalizedLevel, targetModelKind, observedModelKind) {
30
+ const strictModelKind = targetModelKind ?? observedModelKind;
31
+ return level === "pro" ? "Pro" : strictModelKind === "pro" ? "Pro Extended" : capitalizedLevel;
32
+ }
4
33
  const BROWSER_THINKING_LOG_PREFIX = "[browser] Thinking time:";
5
34
  function formatBrowserThinkingLog(message) {
6
35
  return `${BROWSER_THINKING_LOG_PREFIX} ${message.replace(/^Thinking time:\s*/, "")}`;
@@ -43,6 +72,18 @@ export async function ensureThinkingTime(Runtime, level, logger, desiredModel) {
43
72
  case "switched":
44
73
  logger(formatBrowserThinkingLog(result.label ?? capitalizedLevel));
45
74
  return;
75
+ case "option-disabled": {
76
+ await logDomFailure(Runtime, logger, "thinking-option-disabled");
77
+ logPickerDiagnostic(result, logger);
78
+ if (strictProEffort) {
79
+ throw new ThinkingTierUnavailableError(level, capitalizedLevel, result.label ?? null, result.notice ?? null, confirmedThinkingTarget(level, capitalizedLevel, targetModelKind, observedModelKind));
80
+ }
81
+ // A non-strict caller goes on to submit, so this log must not borrow the
82
+ // strict error's "refusing to submit" wording: the request really is sent,
83
+ // at whatever effort ChatGPT already had selected.
84
+ logger(formatBrowserThinkingLog(`${result.label ?? capitalizedLevel} is unavailable on this account (${result.notice ?? "no reason given"}); keeping the effort already selected in ChatGPT.`));
85
+ return;
86
+ }
46
87
  case "chip-not-found":
47
88
  case "menu-not-found":
48
89
  case "option-not-found":
@@ -97,6 +138,9 @@ export async function ensureThinkingTimeIfAvailable(Runtime, level, logger, desi
97
138
  case "switched":
98
139
  logger(formatBrowserThinkingLog(result.label ?? capitalizedLevel));
99
140
  return true;
141
+ case "option-disabled":
142
+ logger(formatBrowserThinkingLog(`${result.label ?? capitalizedLevel} is unavailable on this account (${result.notice ?? "no reason given"}); keeping the effort already selected in ChatGPT.`));
143
+ return false;
100
144
  case "chip-not-found":
101
145
  case "menu-not-found":
102
146
  case "option-not-found":
@@ -147,12 +191,12 @@ function buildThinkingTimeExpression(level, desiredModel) {
147
191
  const TARGET_MODEL_KIND = ${targetModelKindLiteral};
148
192
  const TARGET_IS_GPT56_MODEL = ${targetIsGpt56ModelLiteral};
149
193
 
150
- // Multilingual matchers: English level token + observed German/Chinese variants.
194
+ // Multilingual matchers: English level token + observed German/Japanese/Chinese variants.
151
195
  const LEVEL_TOKENS = {
152
- light: ['light', 'instant', 'sofort', 'leicht', '轻', '极速'],
153
- standard: ['standard', 'medium', 'mittel', '标准', '中'],
154
- extended: ['extended', 'high', 'hoch', 'erweitert', '扩展', '深度', '加强', '高'],
155
- 'extra-high': ['extra high', 'sehr hoch', '极高'],
196
+ light: ['light', 'instant', 'sofort', 'leicht', '最速', '轻', '极速'],
197
+ standard: ['standard', 'medium', 'mittel', '中程度', '标准', '中'],
198
+ extended: ['extended', 'high', 'hoch', 'erweitert', '高い', '扩展', '深度', '加强', '高'],
199
+ 'extra-high': ['extra high', 'sehr hoch', '非常に高い', '极高'],
156
200
  heavy: ['heavy', 'schwer', '重度', '加重'],
157
201
  };
158
202
  // Pro is a tier you can request, but it is also a MODEL name, so it must never
@@ -171,7 +215,8 @@ function buildThinkingTimeExpression(level, desiredModel) {
171
215
  const INTELLIGENCE_WAIT_MS = 2500;
172
216
 
173
217
  const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
174
- // Keep CJK characters so we can match Chinese labels against LEVEL_TOKENS.
218
+ // Keep CJK characters, including Japanese kana, so localized labels survive
219
+ // normalization before being matched against LEVEL_TOKENS and picker controls.
175
220
  const normalize = (value) => (value || '')
176
221
  // Compose first so NFD umlauts fold too, then map them onto ASCII before
177
222
  // the strip below would drop them (and split the token in half).
@@ -181,7 +226,7 @@ function buildThinkingTimeExpression(level, desiredModel) {
181
226
  .replace(/ö/g, 'o')
182
227
  .replace(/ü/g, 'u')
183
228
  .replace(/ß/g, 'ss')
184
- .replace(/[^a-z0-9\\u4e00-\\u9fa5]+/g, ' ')
229
+ .replace(/[^a-z0-9\\u3040-\\u30ff\\u4e00-\\u9fff]+/g, ' ')
185
230
  .replace(/\\s+/g, ' ')
186
231
  .trim();
187
232
  const hasToken = (text, token) => normalize(text).split(' ').includes(token);
@@ -210,6 +255,9 @@ function buildThinkingTimeExpression(level, desiredModel) {
210
255
  if (token === 'extra high') return hasPhrase(t, 'extra high');
211
256
  if (token === 'hoch') return hasPhrase(t, 'hoch') && !hasPhrase(t, 'sehr hoch');
212
257
  if (token === 'sehr hoch') return hasPhrase(t, 'sehr hoch');
258
+ if (token === '高い' || token === '非常に高い') {
259
+ return t === token || hasToken(t, token);
260
+ }
213
261
  if (token === '极速') {
214
262
  const suffix = t.slice(token.length);
215
263
  return t === token || hasToken(t, token) || /^[0-9]/.test(suffix);
@@ -288,6 +336,21 @@ function buildThinkingTimeExpression(level, desiredModel) {
288
336
  .replace(/\\s+/g, ' ')
289
337
  .trim()
290
338
  .slice(0, maxLength);
339
+ const isOptionDisabled = (node) => {
340
+ if (!node || typeof node.getAttribute !== 'function') return false;
341
+ // data-disabled is Radix's valueless-presence convention, but an explicit
342
+ // "false" must not read as disabled: that would refuse a perfectly usable
343
+ // tier and report it as unavailable.
344
+ const dataDisabled = node.getAttribute('data-disabled');
345
+ const dataDisabledOn = dataDisabled !== null && String(dataDisabled).toLowerCase() !== 'false';
346
+ return (
347
+ node.getAttribute('aria-disabled') === 'true' ||
348
+ dataDisabledOn ||
349
+ (node.getAttribute('data-state') || '').toLowerCase() === 'disabled' ||
350
+ Boolean(node.disabled) ||
351
+ node.getAttribute('disabled') !== null
352
+ );
353
+ };
291
354
  const describeNode = (el) => {
292
355
  if (!el || typeof el.getAttribute !== 'function') return null;
293
356
  let rect = null;
@@ -310,7 +373,10 @@ function buildThinkingTimeExpression(level, desiredModel) {
310
373
  ariaChecked: el.getAttribute('aria-checked'),
311
374
  ariaSelected: el.getAttribute('aria-selected'),
312
375
  ariaHaspopup: el.getAttribute('aria-haspopup'),
376
+ ariaDisabled: el.getAttribute('aria-disabled'),
377
+ dataDisabled: el.getAttribute('data-disabled'),
313
378
  dataState: el.getAttribute('data-state'),
379
+ disabled: isOptionDisabled(el),
314
380
  text: redactDiagnosticText(el.textContent, 80),
315
381
  rect,
316
382
  };
@@ -586,6 +652,77 @@ function buildThinkingTimeExpression(level, desiredModel) {
586
652
  }
587
653
  return matchesLevel(normalizedLabel);
588
654
  };
655
+ const nonEmptyNotice = (value) => {
656
+ const text = redactDiagnosticText(value, 160);
657
+ return text || null;
658
+ };
659
+ // The notice must be ROW-OWNED, and preferably this hover's own.
660
+ //
661
+ // NOTE: no backticks in this comment — it lives inside the injected template
662
+ // literal, where a backtick would terminate the string.
663
+ //
664
+ // A document-wide role=tooltip scan is what this must never become: novelty is
665
+ // not causality, and an unrelated control with an armed open-delay can mount its
666
+ // tooltip inside this hover's window. Every pass below is anchored on the row.
667
+ //
668
+ // Preference order, strongest provenance first:
669
+ // 1. an id this hover ADDED whose target is role=tooltip — Radix keeps an
670
+ // application-supplied description and APPENDS its tooltip id when opening,
671
+ // so the delta is what separates the real notice from a permanent blurb;
672
+ // 2. any other id this hover added;
673
+ // 3. an already-associated role=tooltip target, for a tooltip that was open
674
+ // before the probe arrived;
675
+ // 4. the row's static title, only once the poll has expired.
676
+ //
677
+ // Passes 3 and 4 are row-owned but NOT causal: a page that permanently points a
678
+ // disabled row at generic role=tooltip help, or gives it a generic title, will
679
+ // have that text reported. That is accepted deliberately — the value is an opaque
680
+ // notice for a human or a caller to interpret, not a parsed reset time — and the
681
+ // verified live target has neither at rest.
682
+ const describedIds = (option) =>
683
+ (option?.getAttribute?.('aria-describedby') || '').split(/\\s+/).filter(Boolean);
684
+ const isTooltipNode = (node) => node?.getAttribute?.('role') === 'tooltip';
685
+ const readDisabledNotice = (option, priorIds, allowTitle) => {
686
+ const ids = describedIds(option);
687
+ const prior = priorIds instanceof Set ? priorIds : new Set();
688
+ const fresh = ids.filter((id) => !prior.has(id));
689
+ for (const pass of [
690
+ fresh.filter((id) => isTooltipNode(document.getElementById?.(id))),
691
+ fresh,
692
+ ids.filter((id) => isTooltipNode(document.getElementById?.(id))),
693
+ ]) {
694
+ for (const id of pass) {
695
+ const notice = nonEmptyNotice(document.getElementById?.(id)?.textContent);
696
+ if (notice) return notice;
697
+ }
698
+ }
699
+ if (!allowTitle) return null;
700
+ return nonEmptyNotice(option?.getAttribute?.('title'));
701
+ };
702
+ const waitForDisabledNotice = async (option, priorIds) => {
703
+ const deadline = performance.now() + 400;
704
+ while (performance.now() < deadline) {
705
+ const notice = readDisabledNotice(option, priorIds, false);
706
+ if (notice) return notice;
707
+ await sleep(50);
708
+ }
709
+ // Only now may a static title speak: the association had its full window.
710
+ return readDisabledNotice(option, priorIds, true);
711
+ };
712
+ // Opening a tooltip stacks another Radix dismissable layer over the menu, and
713
+ // the topmost layer eats the Escape. One blind Escape therefore leaves the
714
+ // effort menu open, which matters for the non-strict caller that goes on to
715
+ // submit. Dismiss, let the layer unmount, and only Escape again while a menu is
716
+ // still there — never two unconditional Escapes, which could close an unrelated
717
+ // outer surface.
718
+ const closeMenusAfterTooltip = async () => {
719
+ closeOpenMenus();
720
+ for (let attempt = 0; attempt < 3; attempt += 1) {
721
+ await sleep(50);
722
+ if (!Array.from(document.querySelectorAll(MENU_CONTAINER_SELECTOR)).some(isVisible)) return;
723
+ closeOpenMenus();
724
+ }
725
+ };
589
726
  const selectAndVerify = async (trigger, findOption, modelKindOverride = null) => {
590
727
  const triggerModelKind =
591
728
  modelKindOverride ||
@@ -611,6 +748,22 @@ function buildThinkingTimeExpression(level, desiredModel) {
611
748
  }
612
749
  if (!option) return failure('option-not-found', { modelKind: triggerModelKind });
613
750
  const label = option.textContent?.trim?.() || null;
751
+ if (isOptionDisabled(option)) {
752
+ // Captured BEFORE the hover: the ids already here describe the row for other
753
+ // reasons and cannot be this hover's reason.
754
+ const priorIds = new Set(describedIds(option));
755
+ dispatchHoverSequence(option);
756
+ const notice = await waitForDisabledNotice(option, priorIds);
757
+ const result = failure('option-disabled', {
758
+ // The label is page text that reaches logs and diagnostics, so it is
759
+ // redacted like every other reported string.
760
+ label: redactDiagnosticText(option.textContent, 80) || null,
761
+ notice,
762
+ modelKind: triggerModelKind,
763
+ });
764
+ await closeMenusAfterTooltip();
765
+ return result;
766
+ }
614
767
  if (optionIsSelected(option)) {
615
768
  closeOpenMenus();
616
769
  return { status: 'already-selected', label };
@@ -688,9 +841,12 @@ function buildThinkingTimeExpression(level, desiredModel) {
688
841
  // Row labels in this menu concatenate without separators ("EffortHigh"), so
689
842
  // token matching cannot be used here — substring is deliberate, as in
690
843
  // countEffortLevels above.
691
- const ADVANCED_WORDS = ['advanced', 'erweitert', '高级', 'avanzado', 'avancado', 'avance'];
844
+ const ADVANCED_WORDS = [
845
+ 'advanced', 'erweitert', '高级', '詳細設定', '詳細表示',
846
+ 'avanzado', 'avancado', 'avance',
847
+ ];
692
848
  const EFFORT_WORDS = [
693
- 'effort', 'aufwand', '强度', '努力',
849
+ 'effort', 'aufwand', '强度', '努力', '推論レベル',
694
850
  'esfuerzo', 'esforco', 'sforzo', 'inspanning', 'wysilek',
695
851
  ];
696
852
  const containsAny = (label, words) => words.some((word) => label.includes(word));
@@ -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
+ };
@@ -35,7 +35,7 @@ export const DEFAULT_BROWSER_CONFIG = {
35
35
  autoReattachDelayMs: 0,
36
36
  autoReattachIntervalMs: 0,
37
37
  autoReattachTimeoutMs: 120_000,
38
- cookieSync: true,
38
+ cookieSync: false,
39
39
  cookieNames: DEFAULT_CHATGPT_COOKIE_NAMES,
40
40
  cookieSyncWaitMs: 0,
41
41
  inlineCookies: null,
@@ -70,7 +70,6 @@ export function resolveBrowserConfig(config) {
70
70
  DEFAULT_MODEL_STRATEGY;
71
71
  const isWindows = process.platform === "win32";
72
72
  const manualLogin = config?.manualLogin ?? (isWindows ? true : DEFAULT_BROWSER_CONFIG.manualLogin);
73
- const cookieSyncDefault = isWindows ? false : DEFAULT_BROWSER_CONFIG.cookieSync;
74
73
  const resolvedProfileDir = resolveManualLoginProfileDir(config?.manualLoginProfileDir, process.env.ORACLE_BROWSER_PROFILE_DIR);
75
74
  const researchMode = normalizeResearchMode(config?.researchMode);
76
75
  const archiveConversations = normalizeArchiveMode(config?.archiveConversations);
@@ -92,7 +91,7 @@ export function resolveBrowserConfig(config) {
92
91
  autoReattachDelayMs: config?.autoReattachDelayMs ?? DEFAULT_BROWSER_CONFIG.autoReattachDelayMs,
93
92
  autoReattachIntervalMs: config?.autoReattachIntervalMs ?? DEFAULT_BROWSER_CONFIG.autoReattachIntervalMs,
94
93
  autoReattachTimeoutMs: config?.autoReattachTimeoutMs ?? DEFAULT_BROWSER_CONFIG.autoReattachTimeoutMs,
95
- cookieSync: config?.cookieSync ?? cookieSyncDefault,
94
+ cookieSync: config?.cookieSync ?? DEFAULT_BROWSER_CONFIG.cookieSync,
96
95
  cookieNames: config?.cookieNames ?? DEFAULT_BROWSER_CONFIG.cookieNames,
97
96
  cookieSyncWaitMs: config?.cookieSyncWaitMs ?? DEFAULT_BROWSER_CONFIG.cookieSyncWaitMs,
98
97
  inlineCookies: config?.inlineCookies ?? DEFAULT_BROWSER_CONFIG.inlineCookies,
@@ -32,6 +32,7 @@ import { captureBrowserDiagnostics } from "./domDebug.js";
32
32
  import { archiveChatGptConversation, resolveBrowserArchiveDecision, } from "./actions/archiveConversation.js";
33
33
  import { assertManualLoginProfileReadyForRun, defaultManualLoginProfileDir, formatManualLoginSetupCommand, isManualLoginProfileInitialized, resolveManualLoginWaitMs, } from "./manualLoginProfile.js";
34
34
  import { describeBrowserControlPlan, formatBrowserControlPlan } from "./controlPlan.js";
35
+ import { CHROME_COOKIE_SYNC_WARNING, shouldSyncBrowserCookies } from "./policies.js";
35
36
  import { createConversationUrlMonitor, } from "./conversationUrlMonitor.js";
36
37
  import { extractStableConversationIdFromUrl as extractConversationIdFromUrl, isStableConversationUrl as isConversationUrl, } from "./conversationUrl.js";
37
38
  export { CHATGPT_URL, DEFAULT_MODEL_STRATEGY, DEFAULT_MODEL_TARGET } from "./constants.js";
@@ -864,12 +865,16 @@ export async function runBrowserMode(options) {
864
865
  await Network.clearBrowserCookies();
865
866
  }
866
867
  const manualLoginCookieSync = manualLogin && Boolean(config.manualLoginCookieSync);
867
- const cookieSyncEnabled = config.cookieSync && (!profileIsPreSigned || manualLoginCookieSync);
868
+ const cookieSyncEnabled = shouldSyncBrowserCookies(config, {
869
+ manualLogin,
870
+ profileIsPreSigned,
871
+ });
868
872
  if (cookieSyncEnabled) {
869
873
  if (manualLoginCookieSync) {
870
874
  logger("Manual login mode: seeding persistent profile with cookies from your Chrome profile.");
871
875
  }
872
876
  if (!config.inlineCookies) {
877
+ logger(CHROME_COOKIE_SYNC_WARNING);
873
878
  logger("Heads-up: macOS may prompt for your Keychain password to read Chrome cookies; use --copy or --render for manual flow.");
874
879
  }
875
880
  else {
@@ -898,7 +903,7 @@ export async function runBrowserMode(options) {
898
903
  else {
899
904
  logger(manualLogin
900
905
  ? "Skipping Chrome cookie sync (--browser-manual-login enabled); reuse the opened profile after signing in."
901
- : "Skipping Chrome cookie sync (--browser-no-cookie-sync)");
906
+ : "Skipping Chrome cookie copy (disabled by default; use --browser-cookie-sync to opt in).");
902
907
  }
903
908
  await clearStaleChatGptConversationCookies(Network, Target, logger, {
904
909
  preserveConversationIds: [