@steipete/oracle 0.4.5 → 0.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. package/README.md +11 -9
  2. package/dist/.DS_Store +0 -0
  3. package/dist/bin/oracle-cli.js +16 -48
  4. package/dist/scripts/agent-send.js +147 -0
  5. package/dist/scripts/docs-list.js +110 -0
  6. package/dist/scripts/git-policy.js +125 -0
  7. package/dist/scripts/runner.js +1378 -0
  8. package/dist/scripts/test-browser.js +103 -0
  9. package/dist/scripts/test-remote-chrome.js +68 -0
  10. package/dist/src/browser/actions/attachments.js +47 -16
  11. package/dist/src/browser/actions/promptComposer.js +67 -18
  12. package/dist/src/browser/actions/remoteFileTransfer.js +36 -4
  13. package/dist/src/browser/chromeCookies.js +44 -6
  14. package/dist/src/browser/chromeLifecycle.js +166 -25
  15. package/dist/src/browser/config.js +25 -1
  16. package/dist/src/browser/constants.js +22 -3
  17. package/dist/src/browser/index.js +384 -22
  18. package/dist/src/browser/profileSync.js +141 -0
  19. package/dist/src/browser/prompt.js +3 -1
  20. package/dist/src/browser/reattach.js +59 -0
  21. package/dist/src/browser/sessionRunner.js +15 -1
  22. package/dist/src/browser/windowsCookies.js +2 -1
  23. package/dist/src/cli/browserConfig.js +11 -0
  24. package/dist/src/cli/browserDefaults.js +41 -0
  25. package/dist/src/cli/detach.js +2 -2
  26. package/dist/src/cli/dryRun.js +4 -2
  27. package/dist/src/cli/engine.js +2 -2
  28. package/dist/src/cli/help.js +2 -2
  29. package/dist/src/cli/options.js +2 -1
  30. package/dist/src/cli/runOptions.js +1 -1
  31. package/dist/src/cli/sessionDisplay.js +102 -104
  32. package/dist/src/cli/sessionRunner.js +39 -6
  33. package/dist/src/cli/sessionTable.js +88 -0
  34. package/dist/src/cli/tui/index.js +19 -89
  35. package/dist/src/heartbeat.js +2 -2
  36. package/dist/src/oracle/background.js +10 -2
  37. package/dist/src/oracle/client.js +107 -0
  38. package/dist/src/oracle/config.js +10 -2
  39. package/dist/src/oracle/errors.js +24 -4
  40. package/dist/src/oracle/modelResolver.js +144 -0
  41. package/dist/src/oracle/oscProgress.js +1 -1
  42. package/dist/src/oracle/run.js +83 -34
  43. package/dist/src/oracle/runUtils.js +12 -8
  44. package/dist/src/remote/server.js +214 -23
  45. package/dist/src/sessionManager.js +5 -2
  46. package/dist/vendor/oracle-notifier/OracleNotifier.app/Contents/CodeResources +0 -0
  47. package/dist/vendor/oracle-notifier/OracleNotifier.app/Contents/MacOS/OracleNotifier +0 -0
  48. package/package.json +14 -14
@@ -0,0 +1,103 @@
1
+ #!/usr/bin/env tsx
2
+ /**
3
+ * Lightweight browser connectivity smoke test.
4
+ * - Launches Chrome headful with a fixed DevTools port (default 45871 or env ORACLE_BROWSER_PORT/ORACLE_BROWSER_DEBUG_PORT).
5
+ * - Verifies the DevTools /json/version endpoint responds.
6
+ * - Prints a WSL-friendly firewall hint if the port is unreachable.
7
+ */
8
+ import { setTimeout as sleep } from 'node:timers/promises';
9
+ import { launch } from 'chrome-launcher';
10
+ import os from 'node:os';
11
+ import { readFileSync } from 'node:fs';
12
+ const DEFAULT_PORT = 45871;
13
+ const port = normalizePort(process.env.ORACLE_BROWSER_PORT ?? process.env.ORACLE_BROWSER_DEBUG_PORT) ?? DEFAULT_PORT;
14
+ const hostHint = resolveWslHost();
15
+ const targetHost = hostHint ?? '127.0.0.1';
16
+ function normalizePort(raw) {
17
+ if (!raw)
18
+ return null;
19
+ const value = Number.parseInt(raw, 10);
20
+ if (!Number.isFinite(value) || value <= 0 || value > 65535)
21
+ return null;
22
+ return value;
23
+ }
24
+ function isWsl() {
25
+ if (process.platform !== 'linux')
26
+ return false;
27
+ if (process.env.WSL_DISTRO_NAME)
28
+ return true;
29
+ return os.release().toLowerCase().includes('microsoft');
30
+ }
31
+ function resolveWslHost() {
32
+ if (!isWsl())
33
+ return null;
34
+ try {
35
+ const resolv = readFileSync('/etc/resolv.conf', 'utf8');
36
+ for (const line of resolv.split('\n')) {
37
+ const match = line.match(/^nameserver\s+([0-9.]+)/);
38
+ if (match?.[1])
39
+ return match[1];
40
+ }
41
+ }
42
+ catch {
43
+ // ignore
44
+ }
45
+ return null;
46
+ }
47
+ function firewallHint(host, devtoolsPort) {
48
+ if (!isWsl())
49
+ return null;
50
+ return [
51
+ `DevTools port ${host}:${devtoolsPort} is blocked from WSL.`,
52
+ '',
53
+ 'PowerShell (admin):',
54
+ `New-NetFirewallRule -DisplayName 'Chrome DevTools ${devtoolsPort}' -Direction Inbound -Action Allow -Protocol TCP -LocalPort ${devtoolsPort}`,
55
+ "New-NetFirewallRule -DisplayName 'Chrome DevTools (chrome.exe)' -Direction Inbound -Action Allow -Program 'C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe' -Protocol TCP",
56
+ '',
57
+ 'Re-run ./runner pnpm test:browser after adding the rule.',
58
+ ].join('\n');
59
+ }
60
+ async function fetchVersion(host, devtoolsPort) {
61
+ const controller = new AbortController();
62
+ const timer = setTimeout(() => controller.abort(), 5000);
63
+ try {
64
+ const res = await fetch(`http://${host}:${devtoolsPort}/json/version`, { signal: controller.signal });
65
+ if (!res.ok)
66
+ return false;
67
+ const json = (await res.json());
68
+ return Boolean(json.webSocketDebuggerUrl);
69
+ }
70
+ catch {
71
+ return false;
72
+ }
73
+ finally {
74
+ clearTimeout(timer);
75
+ }
76
+ }
77
+ async function main() {
78
+ console.log(`[browser-test] launching Chrome on ${targetHost}:${port} (headful)…`);
79
+ const chrome = await launch({
80
+ port,
81
+ chromeFlags: ['--remote-debugging-address=0.0.0.0'],
82
+ });
83
+ let ok = await fetchVersion(targetHost, chrome.port);
84
+ if (!ok) {
85
+ await sleep(500);
86
+ ok = await fetchVersion(targetHost, chrome.port);
87
+ }
88
+ await chrome.kill();
89
+ if (ok) {
90
+ console.log(`[browser-test] PASS: DevTools responding on ${targetHost}:${chrome.port}`);
91
+ process.exit(0);
92
+ }
93
+ const hint = firewallHint(targetHost, chrome.port);
94
+ console.error(`[browser-test] FAIL: DevTools not reachable at ${targetHost}:${chrome.port}`);
95
+ if (hint) {
96
+ console.error(hint);
97
+ }
98
+ process.exit(1);
99
+ }
100
+ main().catch((error) => {
101
+ console.error('[browser-test] Unexpected failure:', error instanceof Error ? error.message : String(error));
102
+ process.exit(1);
103
+ });
@@ -0,0 +1,68 @@
1
+ #!/usr/bin/env npx tsx
2
+ /**
3
+ * POC: Test connecting to remote Chrome instance
4
+ *
5
+ * On remote machine with display, run:
6
+ * google-chrome --remote-debugging-port=9222 --remote-debugging-address=0.0.0.0
7
+ *
8
+ * Then run this script:
9
+ * npx tsx scripts/test-remote-chrome.ts <remote-host> [port]
10
+ */
11
+ import CDP from 'chrome-remote-interface';
12
+ async function main() {
13
+ const host = process.argv[2] || 'localhost';
14
+ const port = parseInt(process.argv[3] || '9222', 10);
15
+ console.log(`Attempting to connect to Chrome at ${host}:${port}...`);
16
+ try {
17
+ // Test connection
18
+ const client = await CDP({ host, port });
19
+ console.log('✓ Connected to Chrome DevTools Protocol');
20
+ const { Network, Page, Runtime } = client;
21
+ // Enable domains
22
+ await Promise.all([Network.enable(), Page.enable()]);
23
+ console.log('✓ Enabled Network and Page domains');
24
+ // Get browser version info
25
+ const version = await CDP.Version({ host, port });
26
+ console.log(`✓ Browser: ${version.Browser}`);
27
+ console.log(`✓ Protocol: ${version['Protocol-Version']}`);
28
+ // Navigate to ChatGPT
29
+ console.log('\nNavigating to ChatGPT...');
30
+ await Page.navigate({ url: 'https://chatgpt.com/' });
31
+ await Page.loadEventFired();
32
+ console.log('✓ Page loaded');
33
+ // Check current URL
34
+ const evalResult = await Runtime.evaluate({ expression: 'window.location.href' });
35
+ console.log(`✓ Current URL: ${evalResult.result.value}`);
36
+ // Check if logged in (look for specific elements)
37
+ const checkLogin = await Runtime.evaluate({
38
+ expression: `
39
+ // Check for composer textarea (indicates logged in)
40
+ const composer = document.querySelector('textarea, [contenteditable="true"]');
41
+ const hasComposer = !!composer;
42
+
43
+ // Check for login button (indicates logged out)
44
+ const loginBtn = document.querySelector('a[href*="login"], button[data-testid*="login"]');
45
+ const hasLogin = !!loginBtn;
46
+
47
+ ({ hasComposer, hasLogin, loggedIn: hasComposer && !hasLogin })
48
+ `,
49
+ });
50
+ console.log(`✓ Login status: ${JSON.stringify(checkLogin.result.value)}`);
51
+ await client.close();
52
+ console.log('\n✓ POC successful! Remote Chrome connection works.');
53
+ console.log('\nTo use Oracle with remote Chrome, you would need to:');
54
+ console.log('1. Ensure cookies are loaded in remote Chrome');
55
+ console.log('2. Configure Oracle with --remote-chrome <host:port> to use this instance');
56
+ console.log('3. Ensure Oracle skips local Chrome launch when --remote-chrome is specified');
57
+ }
58
+ catch (error) {
59
+ console.error('✗ Connection failed:', error instanceof Error ? error.message : error);
60
+ console.log('\nTroubleshooting:');
61
+ console.log('1. Ensure Chrome is running on remote machine with:');
62
+ console.log(` google-chrome --remote-debugging-port=${port} --remote-debugging-address=0.0.0.0`);
63
+ console.log('2. Check firewall allows connections to port', port);
64
+ console.log('3. Verify network connectivity to', host);
65
+ process.exit(1);
66
+ }
67
+ }
68
+ void main();
@@ -1,5 +1,5 @@
1
1
  import path from 'node:path';
2
- import { FILE_INPUT_SELECTOR, GENERIC_FILE_INPUT_SELECTOR, SEND_BUTTON_SELECTOR, UPLOAD_STATUS_SELECTORS, } from '../constants.js';
2
+ import { FILE_INPUT_SELECTORS, SEND_BUTTON_SELECTORS, UPLOAD_STATUS_SELECTORS } from '../constants.js';
3
3
  import { delay } from '../utils.js';
4
4
  import { logDomFailure } from '../domDebug.js';
5
5
  export async function uploadAttachmentFile(deps, attachment, logger) {
@@ -8,7 +8,7 @@ export async function uploadAttachmentFile(deps, attachment, logger) {
8
8
  throw new Error('DOM domain unavailable while uploading attachments.');
9
9
  }
10
10
  const documentNode = await dom.getDocument();
11
- const selectors = [FILE_INPUT_SELECTOR, GENERIC_FILE_INPUT_SELECTOR];
11
+ const selectors = FILE_INPUT_SELECTORS;
12
12
  let targetNodeId;
13
13
  for (const selector of selectors) {
14
14
  const result = await dom.querySelector({ nodeId: documentNode.root.nodeId, selector });
@@ -33,25 +33,49 @@ export async function uploadAttachmentFile(deps, attachment, logger) {
33
33
  export async function waitForAttachmentCompletion(Runtime, timeoutMs, logger) {
34
34
  const deadline = Date.now() + timeoutMs;
35
35
  const expression = `(() => {
36
- const button = document.querySelector('${SEND_BUTTON_SELECTOR}');
37
- if (!button) {
38
- return { state: 'missing', uploading: false };
36
+ const sendSelectors = ${JSON.stringify(SEND_BUTTON_SELECTORS)};
37
+ let button = null;
38
+ for (const selector of sendSelectors) {
39
+ button = document.querySelector(selector);
40
+ if (button) break;
39
41
  }
40
- const disabled = button.hasAttribute('disabled') || button.getAttribute('aria-disabled') === 'true';
42
+ const disabled = button
43
+ ? button.hasAttribute('disabled') ||
44
+ button.getAttribute('aria-disabled') === 'true' ||
45
+ button.getAttribute('data-disabled') === 'true' ||
46
+ window.getComputedStyle(button).pointerEvents === 'none'
47
+ : null;
41
48
  const uploadingSelectors = ${JSON.stringify(UPLOAD_STATUS_SELECTORS)};
42
49
  const uploading = uploadingSelectors.some((selector) => {
43
50
  return Array.from(document.querySelectorAll(selector)).some((node) => {
51
+ const ariaBusy = node.getAttribute?.('aria-busy');
52
+ const dataState = node.getAttribute?.('data-state');
53
+ if (ariaBusy === 'true' || dataState === 'loading' || dataState === 'uploading' || dataState === 'pending') {
54
+ return true;
55
+ }
44
56
  const text = node.textContent?.toLowerCase?.() ?? '';
45
- return text.includes('upload') || text.includes('processing');
57
+ return text.includes('upload') || text.includes('processing') || text.includes('uploading');
46
58
  });
47
59
  });
48
- return { state: disabled ? 'disabled' : 'ready', uploading };
60
+ const fileSelectors = ${JSON.stringify(FILE_INPUT_SELECTORS)};
61
+ const filesAttached = fileSelectors.some((selector) =>
62
+ Array.from(document.querySelectorAll(selector)).some((node) => {
63
+ const el = node instanceof HTMLInputElement ? node : null;
64
+ return Boolean(el?.files?.length);
65
+ }),
66
+ );
67
+ return { state: button ? (disabled ? 'disabled' : 'ready') : 'missing', uploading, filesAttached };
49
68
  })()`;
50
69
  while (Date.now() < deadline) {
51
70
  const { result } = await Runtime.evaluate({ expression, returnByValue: true });
52
71
  const value = result?.value;
53
- if (value && value.state === 'ready' && !value.uploading) {
54
- return;
72
+ if (value && !value.uploading) {
73
+ if (value.state === 'ready') {
74
+ return;
75
+ }
76
+ if (value.state === 'missing' && value.filesAttached) {
77
+ return;
78
+ }
55
79
  }
56
80
  await delay(250);
57
81
  }
@@ -62,13 +86,20 @@ export async function waitForAttachmentCompletion(Runtime, timeoutMs, logger) {
62
86
  async function waitForAttachmentSelection(Runtime, expectedName, timeoutMs) {
63
87
  const deadline = Date.now() + timeoutMs;
64
88
  const expression = `(() => {
65
- const selector = ${JSON.stringify(GENERIC_FILE_INPUT_SELECTOR)};
66
- const input = document.querySelector(selector);
67
- if (!input || !input.files) {
68
- return { matched: false, names: [] };
89
+ const selectors = ${JSON.stringify(FILE_INPUT_SELECTORS)};
90
+ for (const selector of selectors) {
91
+ const inputs = Array.from(document.querySelectorAll(selector));
92
+ for (const input of inputs) {
93
+ if (!(input instanceof HTMLInputElement) || !input.files) {
94
+ continue;
95
+ }
96
+ const names = Array.from(input.files ?? []).map((file) => file?.name ?? '');
97
+ if (names.some((name) => name === ${JSON.stringify(expectedName)})) {
98
+ return { matched: true, names };
99
+ }
100
+ }
69
101
  }
70
- const names = Array.from(input.files).map((file) => file?.name ?? '');
71
- return { matched: names.some((name) => name === ${JSON.stringify(expectedName)}), names };
102
+ return { matched: false, names: [] };
72
103
  })()`;
73
104
  while (Date.now() < deadline) {
74
105
  const { result } = await Runtime.evaluate({ expression, returnByValue: true });
@@ -1,6 +1,13 @@
1
- import { INPUT_SELECTORS, PROMPT_PRIMARY_SELECTOR, PROMPT_FALLBACK_SELECTOR, SEND_BUTTON_SELECTOR, CONVERSATION_TURN_SELECTOR, } from '../constants.js';
1
+ import { INPUT_SELECTORS, PROMPT_PRIMARY_SELECTOR, PROMPT_FALLBACK_SELECTOR, SEND_BUTTON_SELECTORS, CONVERSATION_TURN_SELECTOR, } from '../constants.js';
2
2
  import { delay } from '../utils.js';
3
3
  import { logDomFailure } from '../domDebug.js';
4
+ const ENTER_KEY_EVENT = {
5
+ key: 'Enter',
6
+ code: 'Enter',
7
+ windowsVirtualKeyCode: 13,
8
+ nativeVirtualKeyCode: 13,
9
+ };
10
+ const ENTER_KEY_TEXT = '\r';
4
11
  export async function submitPrompt(deps, prompt, logger) {
5
12
  const { runtime, input } = deps;
6
13
  const encodedPrompt = JSON.stringify(prompt);
@@ -52,6 +59,9 @@ export async function submitPrompt(deps, prompt, logger) {
52
59
  throw new Error('Failed to focus prompt textarea');
53
60
  }
54
61
  await input.insertText({ text: prompt });
62
+ // Some pages (notably ChatGPT when subscriptions/widgets load) need a brief settle
63
+ // before the send button becomes enabled; give it a short breather to avoid races.
64
+ await delay(500);
55
65
  const primarySelectorLiteral = JSON.stringify(PROMPT_PRIMARY_SELECTOR);
56
66
  const fallbackSelectorLiteral = JSON.stringify(PROMPT_FALLBACK_SELECTOR);
57
67
  const verification = await runtime.evaluate({
@@ -86,18 +96,14 @@ export async function submitPrompt(deps, prompt, logger) {
86
96
  const clicked = await attemptSendButton(runtime);
87
97
  if (!clicked) {
88
98
  await input.dispatchKeyEvent({
89
- type: 'rawKeyDown',
90
- key: 'Enter',
91
- code: 'Enter',
92
- windowsVirtualKeyCode: 13,
93
- nativeVirtualKeyCode: 13,
99
+ type: 'keyDown',
100
+ ...ENTER_KEY_EVENT,
101
+ text: ENTER_KEY_TEXT,
102
+ unmodifiedText: ENTER_KEY_TEXT,
94
103
  });
95
104
  await input.dispatchKeyEvent({
96
105
  type: 'keyUp',
97
- key: 'Enter',
98
- code: 'Enter',
99
- windowsVirtualKeyCode: 13,
100
- nativeVirtualKeyCode: 13,
106
+ ...ENTER_KEY_EVENT,
101
107
  });
102
108
  logger('Submitted prompt via Enter key');
103
109
  }
@@ -105,19 +111,28 @@ export async function submitPrompt(deps, prompt, logger) {
105
111
  logger('Clicked send button');
106
112
  }
107
113
  await verifyPromptCommitted(runtime, prompt, 30_000, logger);
114
+ await clickAnswerNowIfPresent(runtime, logger);
108
115
  }
109
116
  async function attemptSendButton(Runtime) {
110
117
  const script = `(() => {
111
- const button = document.querySelector('${SEND_BUTTON_SELECTOR}');
112
- if (!button) {
113
- return 'missing';
118
+ const selectors = ${JSON.stringify(SEND_BUTTON_SELECTORS)};
119
+ let button = null;
120
+ for (const selector of selectors) {
121
+ button = document.querySelector(selector);
122
+ if (button) break;
114
123
  }
124
+ if (!button) return 'missing';
115
125
  const ariaDisabled = button.getAttribute('aria-disabled');
116
- const disabled = button.hasAttribute('disabled') || ariaDisabled === 'true';
117
- if (disabled || window.getComputedStyle(button).display === 'none') {
118
- return 'disabled';
119
- }
120
- button.click();
126
+ const dataDisabled = button.getAttribute('data-disabled');
127
+ const style = window.getComputedStyle(button);
128
+ const disabled =
129
+ button.hasAttribute('disabled') ||
130
+ ariaDisabled === 'true' ||
131
+ dataDisabled === 'true' ||
132
+ style.pointerEvents === 'none' ||
133
+ style.display === 'none';
134
+ if (disabled) return 'disabled';
135
+ (button as HTMLElement).click();
121
136
  return 'clicked';
122
137
  })()`;
123
138
  const deadline = Date.now() + 2_000;
@@ -133,6 +148,40 @@ async function attemptSendButton(Runtime) {
133
148
  }
134
149
  return false;
135
150
  }
151
+ async function clickAnswerNowIfPresent(Runtime, logger) {
152
+ const script = `(() => {
153
+ const matchesText = (el) => (el?.textContent || '').trim().toLowerCase() === 'answer now';
154
+ const candidate = Array.from(document.querySelectorAll('button,span')).find(matchesText);
155
+ if (!candidate) return 'missing';
156
+ const button = candidate.closest('button') ?? candidate;
157
+ const style = window.getComputedStyle(button);
158
+ const disabled =
159
+ button.hasAttribute('disabled') ||
160
+ button.getAttribute('aria-disabled') === 'true' ||
161
+ style.pointerEvents === 'none' ||
162
+ style.display === 'none';
163
+ if (disabled) return 'disabled';
164
+ (button).dispatchEvent(new MouseEvent('pointerdown', { bubbles: true, cancelable: true }));
165
+ (button).dispatchEvent(new MouseEvent('mousedown', { bubbles: true, cancelable: true }));
166
+ (button).dispatchEvent(new MouseEvent('pointerup', { bubbles: true, cancelable: true }));
167
+ (button).dispatchEvent(new MouseEvent('mouseup', { bubbles: true, cancelable: true }));
168
+ (button).dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true }));
169
+ return 'clicked';
170
+ })()`;
171
+ const deadline = Date.now() + 3_000;
172
+ while (Date.now() < deadline) {
173
+ const { result } = await Runtime.evaluate({ expression: script, returnByValue: true });
174
+ const status = result.value;
175
+ if (status === 'clicked') {
176
+ logger?.('Clicked "Answer now" gate');
177
+ await delay(500);
178
+ return;
179
+ }
180
+ if (status === 'missing')
181
+ return;
182
+ await delay(100);
183
+ }
184
+ }
136
185
  async function verifyPromptCommitted(Runtime, prompt, timeoutMs, logger) {
137
186
  const deadline = Date.now() + timeoutMs;
138
187
  const encodedPrompt = JSON.stringify(prompt.trim());
@@ -1,6 +1,6 @@
1
1
  import { readFile } from 'node:fs/promises';
2
2
  import path from 'node:path';
3
- import { FILE_INPUT_SELECTOR, GENERIC_FILE_INPUT_SELECTOR } from '../constants.js';
3
+ import { FILE_INPUT_SELECTORS } from '../constants.js';
4
4
  import { delay } from '../utils.js';
5
5
  import { logDomFailure } from '../domDebug.js';
6
6
  /**
@@ -25,9 +25,8 @@ export async function uploadAttachmentViaDataTransfer(deps, attachment, logger)
25
25
  logger(`Transferring ${fileName} (${fileContent.length} bytes) to remote browser...`);
26
26
  // Find file input element
27
27
  const documentNode = await dom.getDocument();
28
- const selectors = [FILE_INPUT_SELECTOR, GENERIC_FILE_INPUT_SELECTOR];
29
28
  let fileInputSelector;
30
- for (const selector of selectors) {
29
+ for (const selector of FILE_INPUT_SELECTORS) {
31
30
  const result = await dom.querySelector({ nodeId: documentNode.root.nodeId, selector });
32
31
  if (result.nodeId) {
33
32
  fileInputSelector = selector;
@@ -74,7 +73,40 @@ export async function uploadAttachmentViaDataTransfer(deps, attachment, logger)
74
73
  // Create DataTransfer and assign to input
75
74
  const dataTransfer = new DataTransfer();
76
75
  dataTransfer.items.add(file);
77
- fileInput.files = dataTransfer.files;
76
+ let assigned = false;
77
+
78
+ const proto = Object.getPrototypeOf(fileInput);
79
+ const descriptor = proto ? Object.getOwnPropertyDescriptor(proto, 'files') : null;
80
+ if (descriptor?.set) {
81
+ try {
82
+ descriptor.set.call(fileInput, dataTransfer.files);
83
+ assigned = true;
84
+ } catch {
85
+ assigned = false;
86
+ }
87
+ }
88
+ if (!assigned) {
89
+ try {
90
+ Object.defineProperty(fileInput, 'files', {
91
+ configurable: true,
92
+ get: () => dataTransfer.files,
93
+ });
94
+ assigned = true;
95
+ } catch {
96
+ assigned = false;
97
+ }
98
+ }
99
+ if (!assigned) {
100
+ try {
101
+ fileInput.files = dataTransfer.files;
102
+ assigned = true;
103
+ } catch {
104
+ assigned = false;
105
+ }
106
+ }
107
+ if (!assigned) {
108
+ return { success: false, error: 'Unable to assign FileList to input' };
109
+ }
78
110
 
79
111
  // Trigger both input and change events for better compatibility
80
112
  fileInput.dispatchEvent(new Event('input', { bubbles: true }));
@@ -2,14 +2,17 @@ import path from 'node:path';
2
2
  import os from 'node:os';
3
3
  import fs from 'node:fs/promises';
4
4
  import { existsSync } from 'node:fs';
5
+ import { createRequire } from 'node:module';
5
6
  import chromeCookies from 'chrome-cookies-secure';
6
7
  import { COOKIE_URLS } from './constants.js';
7
- import './keytarShim.js';
8
8
  import { ensureCookiesDirForFallback } from './windowsCookies.js';
9
9
  const COOKIE_READ_TIMEOUT_MS = readDuration('ORACLE_COOKIE_LOAD_TIMEOUT_MS', 5_000);
10
10
  const KEYCHAIN_PROBE_TIMEOUT_MS = readDuration('ORACLE_KEYCHAIN_PROBE_TIMEOUT_MS', 3_000);
11
11
  const MAC_KEYCHAIN_LABELS = loadKeychainLabels();
12
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
+ }
13
16
  const urlsToCheck = Array.from(new Set([stripQuery(targetUrl), ...COOKIE_URLS]));
14
17
  const merged = new Map();
15
18
  const cookieFile = await resolveCookieFilePath({ explicitPath: explicitCookiePath, profile });
@@ -49,13 +52,26 @@ export async function loadChromeCookies({ targetUrl, profile, explicitCookiePath
49
52
  return Array.from(merged.values());
50
53
  }
51
54
  async function ensureMacKeychainReadable() {
52
- if (process.platform !== 'darwin') {
55
+ if (process.platform === 'win32') {
53
56
  return;
54
57
  }
55
- // chrome-cookies-secure can hang forever when macOS Keychain rejects access (e.g., SSH/no GUI).
56
- // Probe the keychain ourselves with a timeout so callers fail fast instead of blocking the run.
57
- const keytarModule = await import('keytar');
58
- const keytar = (keytarModule.default ?? keytarModule);
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
+ }
59
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.`);
60
76
  if (!password) {
61
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.');
@@ -204,6 +220,23 @@ async function defaultProfileRoot() {
204
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'));
205
221
  }
206
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
+ }
207
240
  candidates.push(path.join(os.homedir(), '.config', 'google-chrome'), path.join(os.homedir(), '.config', 'microsoft-edge'), path.join(os.homedir(), '.config', 'chromium'),
208
241
  // Snap Chromium profiles
209
242
  path.join(os.homedir(), 'snap', 'chromium', 'common', 'chromium'), path.join(os.homedir(), 'snap', 'chromium', 'current', 'chromium'));
@@ -241,6 +274,11 @@ function readDuration(envKey, fallback) {
241
274
  const parsed = Number.parseInt(raw, 10);
242
275
  return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
243
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
+ }
244
282
  function loadKeychainLabels() {
245
283
  const defaults = [
246
284
  { service: 'Chrome Safe Storage', account: 'Chrome' },