@steipete/oracle 0.17.1 → 0.17.3

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 (92) hide show
  1. package/dist/bin/oracle-cli.js +1 -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 +14 -20
  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 +1 -2
  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 +0 -1
  20. package/dist/markdansi/types/index.js +4 -0
  21. package/dist/oracle/bin/oracle-cli.js +472 -0
  22. package/dist/oracle/src/browser/actions/assistantResponse.js +471 -0
  23. package/dist/oracle/src/browser/actions/attachments.js +82 -0
  24. package/dist/oracle/src/browser/actions/modelSelection.js +190 -0
  25. package/dist/oracle/src/browser/actions/navigation.js +75 -0
  26. package/dist/oracle/src/browser/actions/promptComposer.js +167 -0
  27. package/dist/oracle/src/browser/chromeLifecycle.js +104 -0
  28. package/dist/oracle/src/browser/config.js +33 -0
  29. package/dist/oracle/src/browser/constants.js +40 -0
  30. package/dist/oracle/src/browser/cookies.js +210 -0
  31. package/dist/oracle/src/browser/domDebug.js +36 -0
  32. package/dist/oracle/src/browser/index.js +331 -0
  33. package/dist/oracle/src/browser/pageActions.js +5 -0
  34. package/dist/oracle/src/browser/prompt.js +88 -0
  35. package/dist/oracle/src/browser/promptSummary.js +20 -0
  36. package/dist/oracle/src/browser/sessionRunner.js +80 -0
  37. package/dist/oracle/src/browser/types.js +1 -0
  38. package/dist/oracle/src/browser/utils.js +62 -0
  39. package/dist/oracle/src/browserMode.js +1 -0
  40. package/dist/oracle/src/cli/browserConfig.js +44 -0
  41. package/dist/oracle/src/cli/dryRun.js +59 -0
  42. package/dist/oracle/src/cli/engine.js +17 -0
  43. package/dist/oracle/src/cli/errorUtils.js +9 -0
  44. package/dist/oracle/src/cli/help.js +70 -0
  45. package/dist/oracle/src/cli/markdownRenderer.js +15 -0
  46. package/dist/oracle/src/cli/options.js +103 -0
  47. package/dist/oracle/src/cli/promptRequirement.js +14 -0
  48. package/dist/oracle/src/cli/rootAlias.js +30 -0
  49. package/dist/oracle/src/cli/sessionCommand.js +77 -0
  50. package/dist/oracle/src/cli/sessionDisplay.js +270 -0
  51. package/dist/oracle/src/cli/sessionRunner.js +94 -0
  52. package/dist/oracle/src/heartbeat.js +43 -0
  53. package/dist/oracle/src/oracle/client.js +48 -0
  54. package/dist/oracle/src/oracle/config.js +29 -0
  55. package/dist/oracle/src/oracle/errors.js +101 -0
  56. package/dist/oracle/src/oracle/files.js +220 -0
  57. package/dist/oracle/src/oracle/format.js +33 -0
  58. package/dist/oracle/src/oracle/fsAdapter.js +7 -0
  59. package/dist/oracle/src/oracle/oscProgress.js +60 -0
  60. package/dist/oracle/src/oracle/request.js +48 -0
  61. package/dist/oracle/src/oracle/run.js +444 -0
  62. package/dist/oracle/src/oracle/tokenStats.js +39 -0
  63. package/dist/oracle/src/oracle/types.js +1 -0
  64. package/dist/oracle/src/oracle.js +9 -0
  65. package/dist/oracle/src/sessionManager.js +205 -0
  66. package/dist/oracle/src/version.js +39 -0
  67. package/dist/scripts/chrome/browser-tools.js +295 -0
  68. package/dist/scripts/git-policy.js +0 -8
  69. package/dist/scripts/runner.js +1 -5
  70. package/dist/src/browser/actions/assistantResponse.js +71 -23
  71. package/dist/src/browser/actions/modelSelection.js +164 -5
  72. package/dist/src/browser/actions/thinkingTime.js +195 -18
  73. package/dist/src/browser/chromeCookies.js +312 -0
  74. package/dist/src/browser/index.js +5 -1
  75. package/dist/src/browser/keytarShim.js +56 -0
  76. package/dist/src/browser/policies.js +4 -0
  77. package/dist/src/browser/profileSync.js +141 -0
  78. package/dist/src/browser/projectSourcesRunner.js +2 -2
  79. package/dist/src/browser/reattach.js +47 -35
  80. package/dist/src/browser/windowsCookies.js +219 -0
  81. package/dist/src/cli/browserConfig.js +13 -4
  82. package/dist/src/cli/browserDefaults.js +6 -1
  83. package/dist/src/cli/options.js +1 -1
  84. package/dist/src/cli/projectSources.js +8 -1
  85. package/dist/src/mcp/tools/consult.js +1 -1
  86. package/dist/src/oracle/thinkingTime.js +6 -0
  87. package/dist/src/sessionManager.js +90 -9
  88. package/dist/vendor/oracle-notifier/OracleNotifier.app/Contents/CodeResources +0 -0
  89. package/dist/vendor/oracle-notifier/OracleNotifier.app/Contents/MacOS/OracleNotifier +0 -0
  90. package/package.json +6 -6
  91. package/vendor/oracle-notifier/OracleNotifier.app/Contents/CodeResources +0 -0
  92. package/vendor/oracle-notifier/OracleNotifier.app/Contents/MacOS/OracleNotifier +0 -0
@@ -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
+ };
@@ -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 { 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,7 +865,10 @@ 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.");
@@ -0,0 +1,56 @@
1
+ const defaultLabels = [
2
+ { service: 'Chrome Safe Storage', account: 'Chrome' },
3
+ { service: 'Chromium Safe Storage', account: 'Chromium' },
4
+ { service: 'Microsoft Edge Safe Storage', account: 'Microsoft Edge' },
5
+ { service: 'Brave Safe Storage', account: 'Brave' },
6
+ { service: 'Vivaldi Safe Storage', account: 'Vivaldi' },
7
+ ];
8
+ function loadEnvLabels() {
9
+ const raw = process.env.ORACLE_KEYCHAIN_LABELS;
10
+ if (!raw)
11
+ return [];
12
+ try {
13
+ const parsed = JSON.parse(raw);
14
+ if (Array.isArray(parsed)) {
15
+ return parsed
16
+ .map((entry) => (entry && typeof entry === 'object' ? entry : null))
17
+ .filter((entry) => Boolean(entry?.service && entry?.account));
18
+ }
19
+ }
20
+ catch {
21
+ // ignore invalid env payload
22
+ }
23
+ return [];
24
+ }
25
+ const fallbackLabels = [...loadEnvLabels(), ...defaultLabels];
26
+ const disableKeytar = process.env.ORACLE_DISABLE_KEYTAR === '1' || process.env.CI === 'true';
27
+ let keytar;
28
+ if (disableKeytar) {
29
+ keytar = {
30
+ getPassword: async () => null,
31
+ setPassword: async () => undefined,
32
+ deletePassword: async () => false,
33
+ };
34
+ }
35
+ else {
36
+ const keytarModule = await import('keytar');
37
+ keytar = (keytarModule.default ?? keytarModule);
38
+ const originalGetPassword = keytar.getPassword.bind(keytar);
39
+ keytar.getPassword = async (service, account) => {
40
+ const primary = await originalGetPassword(service, account);
41
+ if (primary) {
42
+ return primary;
43
+ }
44
+ for (const label of fallbackLabels) {
45
+ if (label.service === service && label.account === account) {
46
+ continue; // already tried
47
+ }
48
+ const value = await originalGetPassword(label.service, label.account);
49
+ if (value) {
50
+ return value;
51
+ }
52
+ }
53
+ return null;
54
+ };
55
+ }
56
+ export default keytar;
@@ -24,6 +24,10 @@ export function buildAttachmentPlan(sections, { inlineFiles, bundleRequested, ma
24
24
  shouldBundle,
25
25
  };
26
26
  }
27
+ export function shouldSyncBrowserCookies(config, { manualLogin, profileIsPreSigned = manualLogin, }) {
28
+ const explicitManualLoginSync = manualLogin && config.manualLoginCookieSync === true;
29
+ return config.cookieSync && (!profileIsPreSigned || explicitManualLoginSync);
30
+ }
27
31
  export function buildCookiePlan(config) {
28
32
  if (config?.inlineCookies && config.inlineCookies.length > 0) {
29
33
  const source = config.inlineCookiesSource ?? "inline";
@@ -0,0 +1,141 @@
1
+ import { mkdir, rm, cp as copyDir } from 'node:fs/promises';
2
+ import { existsSync } from 'node:fs';
3
+ import { spawnSync } from 'node:child_process';
4
+ import path from 'node:path';
5
+ import { defaultProfileRoot, expandPath, looksLikePath } from './chromeCookies.js';
6
+ const DIR_EXCLUDES = [
7
+ 'Cache',
8
+ 'Code Cache',
9
+ 'GPUCache',
10
+ 'Service Worker',
11
+ 'Crashpad',
12
+ 'BrowserMetrics*',
13
+ 'GrShaderCache',
14
+ 'ShaderCache',
15
+ 'OptimizationGuide',
16
+ ];
17
+ const FILE_EXCLUDES = [
18
+ 'SingletonLock',
19
+ 'SingletonSocket',
20
+ 'SingletonCookie',
21
+ '*.lock',
22
+ 'lockfile',
23
+ 'Lock',
24
+ '*.tmp',
25
+ 'DevToolsActivePort',
26
+ path.join('Default', 'DevToolsActivePort'),
27
+ path.join('Sessions', '*'),
28
+ 'Current Session',
29
+ 'Current Tabs',
30
+ 'Last Session',
31
+ 'Last Tabs',
32
+ ];
33
+ export async function syncChromeProfile(options) {
34
+ const { targetDir } = options;
35
+ await mkdir(targetDir, { recursive: true });
36
+ const { sourceDir, profileName } = await resolveProfileSource(options.profile, options.explicitPath);
37
+ const logger = options.logger;
38
+ if (!existsSync(sourceDir)) {
39
+ throw new Error(`Chrome profile not found at ${sourceDir}. Log in once in Chrome, then retry.`);
40
+ }
41
+ // Clean any stale DevTools ports/locks in the target before copying.
42
+ await rm(targetDir, { recursive: true, force: true }).catch(() => undefined);
43
+ await mkdir(targetDir, { recursive: true });
44
+ const result = process.platform === 'win32'
45
+ ? await copyWithRobocopy(sourceDir, targetDir, logger)
46
+ : await copyWithRsync(sourceDir, targetDir, logger);
47
+ // Remove lock files in the copied profile to avoid "already running" errors.
48
+ await removeLocks(targetDir);
49
+ return {
50
+ source: sourceDir,
51
+ profileName,
52
+ method: result.method,
53
+ status: result.copied ? 'copied' : 'skipped',
54
+ };
55
+ }
56
+ async function copyWithRsync(sourceDir, targetDir, logger) {
57
+ const rsyncArgs = [
58
+ '-a',
59
+ '--delete',
60
+ ...DIR_EXCLUDES.flatMap((entry) => ['--exclude', entry]),
61
+ ...FILE_EXCLUDES.flatMap((entry) => ['--exclude', entry]),
62
+ `${sourceDir}/`,
63
+ `${targetDir}/`,
64
+ ];
65
+ const attempt = spawnSync('rsync', rsyncArgs, { stdio: 'pipe' });
66
+ if (!attempt.error && (attempt.status ?? 0) === 0) {
67
+ return { copied: true, method: 'rsync' };
68
+ }
69
+ logger?.('rsync unavailable or failed; falling back to Node copy');
70
+ await copyDirWithFilter(sourceDir, targetDir);
71
+ return copyWithNodeFs();
72
+ }
73
+ async function copyWithRobocopy(sourceDir, targetDir, logger) {
74
+ const args = [sourceDir, targetDir, '/MIR', '/NFL', '/NDL', '/NJH', '/NJS', '/NP', '/Z'];
75
+ if (DIR_EXCLUDES.length) {
76
+ args.push('/XD', ...DIR_EXCLUDES);
77
+ }
78
+ if (FILE_EXCLUDES.length) {
79
+ args.push('/XF', ...FILE_EXCLUDES);
80
+ }
81
+ const attempt = spawnSync('robocopy', args, { stdio: 'pipe' });
82
+ const exitCode = attempt.status ?? 0;
83
+ // Robocopy treats 0-7 as success/partial success; >=8 is failure.
84
+ if (!attempt.error && exitCode < 8) {
85
+ return { copied: true, method: 'robocopy' };
86
+ }
87
+ logger?.('robocopy failed; falling back to Node copy');
88
+ await copyDirWithFilter(sourceDir, targetDir);
89
+ return copyWithNodeFs();
90
+ }
91
+ function copyWithNodeFs() {
92
+ return { copied: true, method: 'node' };
93
+ }
94
+ function shouldExclude(relativePath) {
95
+ const normalized = relativePath.replace(/\\/g, '/');
96
+ return DIR_EXCLUDES.some((entry) => normalized === entry || normalized.startsWith(`${entry}/`)) ||
97
+ FILE_EXCLUDES.some((entry) => {
98
+ if (entry.endsWith('*')) {
99
+ return normalized.startsWith(entry.slice(0, -1));
100
+ }
101
+ if (entry.includes('*')) {
102
+ // simple glob support for BrowserMetrics*
103
+ const prefix = entry.replace('*', '');
104
+ return normalized.startsWith(prefix);
105
+ }
106
+ return path.basename(normalized) === entry || normalized.endsWith(`/${entry}`);
107
+ });
108
+ }
109
+ async function removeLocks(targetDir) {
110
+ const lockNames = ['SingletonLock', 'SingletonSocket', 'SingletonCookie', 'DevToolsActivePort'];
111
+ for (const lock of lockNames) {
112
+ await rm(path.join(targetDir, lock), { force: true }).catch(() => undefined);
113
+ await rm(path.join(targetDir, 'Default', lock), { force: true }).catch(() => undefined);
114
+ }
115
+ }
116
+ async function resolveProfileSource(profile, explicitPath) {
117
+ const profileName = profile?.trim() ? profile.trim() : 'Default';
118
+ if (explicitPath?.trim()) {
119
+ const resolved = expandPath(explicitPath.trim());
120
+ if (resolved.toLowerCase().endsWith('cookies')) {
121
+ return { sourceDir: path.dirname(resolved), profileName };
122
+ }
123
+ return { sourceDir: resolved, profileName };
124
+ }
125
+ if (looksLikePath(profileName)) {
126
+ return { sourceDir: expandPath(profileName), profileName };
127
+ }
128
+ const baseRoot = await defaultProfileRoot();
129
+ return { sourceDir: path.join(baseRoot, profileName), profileName };
130
+ }
131
+ async function copyDirWithFilter(sourceDir, targetDir) {
132
+ await copyDir(sourceDir, targetDir, {
133
+ recursive: true,
134
+ filter: async (source) => {
135
+ const rel = path.relative(sourceDir, source);
136
+ if (!rel)
137
+ return true;
138
+ return !shouldExclude(rel);
139
+ },
140
+ });
141
+ }
@@ -13,6 +13,7 @@ import { assertManualLoginProfileReadyForRun, defaultManualLoginProfileDir, form
13
13
  import { openProjectSourcesTab, uploadProjectSources, waitForProjectSourcesReady, waitForProjectSourcesListSettled, } from "./actions/projectSources.js";
14
14
  import { normalizeProjectSourcesUrl } from "../projectSources/url.js";
15
15
  import { buildProjectSourcesUploadPlan, diffAddedProjectSources } from "../projectSources/plan.js";
16
+ import { shouldSyncBrowserCookies } from "./policies.js";
16
17
  export async function runBrowserProjectSources(request) {
17
18
  const startedAt = Date.now();
18
19
  const logger = ((message) => request.log?.(message));
@@ -248,8 +249,7 @@ export async function runBrowserProjectSources(request) {
248
249
  }
249
250
  }
250
251
  async function applyProjectSourcesCookies({ config, network, manualLogin, logger, }) {
251
- const manualLoginCookieSync = manualLogin && Boolean(config.manualLoginCookieSync);
252
- const cookieSyncEnabled = config.cookieSync && (!manualLogin || manualLoginCookieSync);
252
+ const cookieSyncEnabled = shouldSyncBrowserCookies(config, { manualLogin });
253
253
  if (!cookieSyncEnabled) {
254
254
  logger(manualLogin
255
255
  ? "Skipping Chrome cookie sync (--browser-manual-login enabled); reuse the opened profile after signing in."
@@ -12,6 +12,7 @@ import { cleanupStaleProfileState } from "./profileState.js";
12
12
  import { readDevToolsActivePortInfo } from "./detect.js";
13
13
  import { pickTarget, extractConversationIdFromUrl, buildConversationUrl, withTimeout, openConversationFromSidebar, openConversationFromSidebarWithRetry, waitForLocationChange, readConversationTurnIndex, buildPromptEchoMatcher, recoverPromptEcho, alignPromptEchoMarkdown, } from "./reattachHelpers.js";
14
14
  import { waitForDeepResearchCompletion } from "./actions/deepResearch.js";
15
+ import { shouldSyncBrowserCookies } from "./policies.js";
15
16
  export async function resumeBrowserSession(runtime, config, logger, deps = {}) {
16
17
  const recoverSession = deps.recoverSession ??
17
18
  (async (runtimeMeta, configMeta) => resumeBrowserSessionViaNewChrome(runtimeMeta, configMeta, logger, deps));
@@ -176,9 +177,37 @@ async function resumeBrowserSessionViaNewChrome(runtime, config, logger, deps) {
176
177
  if (manualLogin) {
177
178
  await mkdir(userDataDir, { recursive: true });
178
179
  }
179
- const chrome = await launchChrome(resolved, userDataDir, logger);
180
- const chromeHost = chrome.host ?? "127.0.0.1";
181
- const client = await connectToChrome(chrome.port, logger, chromeHost);
180
+ const launch = deps.launchChrome ?? launchChrome;
181
+ const connectToLaunchedChrome = deps.connectToChrome ?? connectToChrome;
182
+ const chrome = await launch(resolved, userDataDir, logger);
183
+ const chromeHost = chrome && typeof chrome === "object" && "host" in chrome && typeof chrome.host === "string"
184
+ ? chrome.host
185
+ : "127.0.0.1";
186
+ const client = await connectToLaunchedChrome(chrome.port, logger, chromeHost);
187
+ const cleanup = async () => {
188
+ if (client && typeof client.close === "function") {
189
+ try {
190
+ await client.close();
191
+ }
192
+ catch {
193
+ // ignore
194
+ }
195
+ }
196
+ if (!resolved.keepBrowser) {
197
+ try {
198
+ await chrome.kill();
199
+ }
200
+ catch {
201
+ // ignore
202
+ }
203
+ if (manualLogin) {
204
+ await cleanupStaleProfileState(userDataDir, logger, { lockRemovalMode: "never" }).catch(() => undefined);
205
+ }
206
+ else {
207
+ await rm(userDataDir, { recursive: true, force: true }).catch(() => undefined);
208
+ }
209
+ }
210
+ };
182
211
  const { Network, Page, Runtime, DOM, Target } = client;
183
212
  if (Runtime?.enable) {
184
213
  await Runtime.enable();
@@ -190,14 +219,21 @@ async function resumeBrowserSessionViaNewChrome(runtime, config, logger, deps) {
190
219
  await positionChromeWindowOffscreen(client, logger);
191
220
  }
192
221
  let appliedCookies = 0;
193
- if (!manualLogin && resolved.cookieSync) {
194
- appliedCookies = await syncCookies(Network, resolved.url, resolved.chromeProfile, logger, {
195
- allowErrors: resolved.allowCookieErrors,
196
- filterNames: resolved.cookieNames ?? undefined,
197
- inlineCookies: resolved.inlineCookies ?? undefined,
198
- cookiePath: resolved.chromeCookiePath ?? undefined,
199
- waitMs: resolved.cookieSyncWaitMs ?? 0,
200
- });
222
+ if (shouldSyncBrowserCookies(resolved, { manualLogin })) {
223
+ const sync = deps.syncCookies ?? syncCookies;
224
+ try {
225
+ appliedCookies = await sync(Network, resolved.url, resolved.chromeProfile, logger, {
226
+ allowErrors: resolved.allowCookieErrors,
227
+ filterNames: resolved.cookieNames ?? undefined,
228
+ inlineCookies: resolved.inlineCookies ?? undefined,
229
+ cookiePath: resolved.chromeCookiePath ?? undefined,
230
+ waitMs: resolved.cookieSyncWaitMs ?? 0,
231
+ });
232
+ }
233
+ catch (error) {
234
+ await cleanup();
235
+ throw error;
236
+ }
201
237
  }
202
238
  await clearStaleChatGptConversationCookies(Network, Target, logger, {
203
239
  preserveConversationIds: [
@@ -242,30 +278,6 @@ async function resumeBrowserSessionViaNewChrome(runtime, config, logger, deps) {
242
278
  const waitForResponse = deps.waitForAssistantResponse ?? waitForAssistantResponse;
243
279
  const captureMarkdown = deps.captureAssistantMarkdown ?? captureAssistantMarkdown;
244
280
  const timeoutMs = resolved.timeoutMs ?? 120_000;
245
- const cleanup = async () => {
246
- if (client && typeof client.close === "function") {
247
- try {
248
- await client.close();
249
- }
250
- catch {
251
- // ignore
252
- }
253
- }
254
- if (!resolved.keepBrowser) {
255
- try {
256
- await chrome.kill();
257
- }
258
- catch {
259
- // ignore
260
- }
261
- if (manualLogin) {
262
- await cleanupStaleProfileState(userDataDir, logger, { lockRemovalMode: "never" }).catch(() => undefined);
263
- }
264
- else {
265
- await rm(userDataDir, { recursive: true, force: true }).catch(() => undefined);
266
- }
267
- }
268
- };
269
281
  const minTurnIndex = (await readPromptPreviewTurnIndex(Runtime, deps.promptPreview)) ??
270
282
  (deps.promptPreview ? null : await readConversationTurnIndex(Runtime, logger));
271
283
  if (resolved.researchMode === "deep") {