@steipete/oracle 0.14.1 → 0.15.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 (85) hide show
  1. package/dist/bin/oracle-cli.js +16 -6
  2. package/dist/bin/oracle.js +569 -0
  3. package/dist/docs-site/.nojekyll +0 -0
  4. package/dist/docs-site/CNAME +1 -0
  5. package/dist/docs-site/RELEASING.html +410 -0
  6. package/dist/docs-site/agents.html +374 -0
  7. package/dist/docs-site/anthropic.html +368 -0
  8. package/dist/docs-site/bridge.html +416 -0
  9. package/dist/docs-site/browser-mode.html +594 -0
  10. package/dist/docs-site/chromium-forks.html +347 -0
  11. package/dist/docs-site/cli-reference.html +346 -0
  12. package/dist/docs-site/configuration.html +462 -0
  13. package/dist/docs-site/favicon.svg +14 -0
  14. package/dist/docs-site/followup.html +375 -0
  15. package/dist/docs-site/gemini.html +383 -0
  16. package/dist/docs-site/grok.html +325 -0
  17. package/dist/docs-site/index.html +360 -0
  18. package/dist/docs-site/install.html +335 -0
  19. package/dist/docs-site/linux.html +321 -0
  20. package/dist/docs-site/llms.txt +43 -0
  21. package/dist/docs-site/manual-tests.html +596 -0
  22. package/dist/docs-site/mcp.html +391 -0
  23. package/dist/docs-site/multimodel.html +364 -0
  24. package/dist/docs-site/mythical-pro-agents.html +360 -0
  25. package/dist/docs-site/notifier.html +338 -0
  26. package/dist/docs-site/openai-endpoints.html +399 -0
  27. package/dist/docs-site/openrouter.html +344 -0
  28. package/dist/docs-site/quickstart.html +369 -0
  29. package/dist/docs-site/refactor/ux.html +532 -0
  30. package/dist/docs-site/sessions.html +388 -0
  31. package/dist/docs-site/social-card.png +0 -0
  32. package/dist/docs-site/social-card.svg +79 -0
  33. package/dist/docs-site/spec.html +363 -0
  34. package/dist/docs-site/testing.html +320 -0
  35. package/dist/docs-site/tui-debug.html +326 -0
  36. package/dist/docs-site/windows-work.html +323 -0
  37. package/dist/docs-site/windows.html +320 -0
  38. package/dist/src/browser/actions/assistantResponse.js +40 -31
  39. package/dist/src/browser/actions/attachments.js +28 -1
  40. package/dist/src/browser/actions/deepResearch.js +344 -128
  41. package/dist/src/browser/actions/modelSelection.js +75 -9
  42. package/dist/src/browser/actions/promptComposer.js +71 -14
  43. package/dist/src/browser/actions/thinkingStatus.js +19 -1
  44. package/dist/src/browser/actions/thinkingTime.js +65 -20
  45. package/dist/src/browser/artifacts.js +193 -14
  46. package/dist/src/browser/chatgptFiles.js +674 -91
  47. package/dist/src/browser/chromeCookies.js +312 -0
  48. package/dist/src/browser/chromeLifecycle.js +35 -4
  49. package/dist/src/browser/constants.js +5 -0
  50. package/dist/src/browser/deepResearchResult.js +23 -0
  51. package/dist/src/browser/index.js +112 -41
  52. package/dist/src/browser/keytarShim.js +56 -0
  53. package/dist/src/browser/profileCopy.js +93 -0
  54. package/dist/src/browser/sessionRunner.js +9 -3
  55. package/dist/src/browser/windowsCookies.js +219 -0
  56. package/dist/src/cli/bridge/client.js +4 -1
  57. package/dist/src/cli/bridge/doctor.js +19 -0
  58. package/dist/src/cli/browserConfig.js +17 -1
  59. package/dist/src/cli/runOptions.js +11 -2
  60. package/dist/src/cli/sessionDisplay.js +6 -1
  61. package/dist/src/cli/sessionRunner.js +41 -17
  62. package/dist/src/config.js +3 -0
  63. package/dist/src/oracle/client.js +2 -0
  64. package/dist/src/oracle/modelResolver.js +85 -0
  65. package/dist/src/oracle/multiModelRunner.js +4 -1
  66. package/dist/src/oracle/run.js +4 -1
  67. package/dist/src/remote/client.js +253 -22
  68. package/dist/src/remote/health.js +27 -0
  69. package/dist/src/remote/server.js +239 -4
  70. package/dist/src/remote/types.js +1 -1
  71. package/dist/src/sessionManager.js +1 -0
  72. package/dist/vendor/oracle-notifier/OracleNotifier.app/Contents/CodeResources +0 -0
  73. package/dist/vendor/oracle-notifier/OracleNotifier.app/Contents/Info.plist +20 -0
  74. package/dist/vendor/oracle-notifier/OracleNotifier.app/Contents/MacOS/OracleNotifier +0 -0
  75. package/dist/vendor/oracle-notifier/OracleNotifier.app/Contents/Resources/OracleIcon.icns +0 -0
  76. package/dist/vendor/oracle-notifier/OracleNotifier.app/Contents/_CodeSignature/CodeResources +128 -0
  77. package/dist/vendor/oracle-notifier/build-notifier.sh +0 -0
  78. package/package.json +45 -43
  79. package/vendor/oracle-notifier/OracleNotifier.app/Contents/CodeResources +0 -0
  80. package/vendor/oracle-notifier/OracleNotifier.app/Contents/Info.plist +20 -0
  81. package/vendor/oracle-notifier/OracleNotifier.app/Contents/MacOS/OracleNotifier +0 -0
  82. package/vendor/oracle-notifier/OracleNotifier.app/Contents/Resources/OracleIcon.icns +0 -0
  83. package/vendor/oracle-notifier/OracleNotifier.app/Contents/_CodeSignature/CodeResources +128 -0
  84. package/vendor/oracle-notifier/README.md +26 -0
  85. package/vendor/oracle-notifier/build-notifier.sh +0 -0
@@ -0,0 +1,219 @@
1
+ import crypto from 'node:crypto';
2
+ import fs from 'node:fs/promises';
3
+ import { existsSync, promises as fsp } from 'node:fs';
4
+ import os from 'node:os';
5
+ import path from 'node:path';
6
+ import sqlite3 from 'sqlite3';
7
+ import { createRequire } from 'node:module';
8
+ import { spawnSync } from 'node:child_process';
9
+ let cachedUnprotect = null;
10
+ function getUnprotectData() {
11
+ if (cachedUnprotect) {
12
+ return cachedUnprotect;
13
+ }
14
+ try {
15
+ // win-dpapi is CommonJS; require it explicitly and support both named/default shapes.
16
+ const dpapiModule = createRequire(import.meta.url)('win-dpapi');
17
+ const unprotect = dpapiModule?.Dpapi?.unprotectData ??
18
+ dpapiModule?.default?.unprotectData ??
19
+ ((_) => {
20
+ throw new Error('win-dpapi unprotectData not available');
21
+ });
22
+ cachedUnprotect = unprotect;
23
+ return unprotect;
24
+ }
25
+ catch (error) {
26
+ if (process.platform !== 'win32') {
27
+ // On macOS/Linux we don't need DPAPI; return a function that makes misuse obvious.
28
+ cachedUnprotect = () => {
29
+ throw new Error('win-dpapi is unavailable on non-Windows platforms');
30
+ };
31
+ return cachedUnprotect;
32
+ }
33
+ throw error;
34
+ }
35
+ }
36
+ export async function loadWindowsCookies(dbPath, filterNames) {
37
+ if (process.platform !== 'win32') {
38
+ throw new Error('loadWindowsCookies is only supported on Windows');
39
+ }
40
+ const localStatePath = await locateLocalState(dbPath);
41
+ const cookiesCopy = await copyLockedFile(dbPath, 'Cookies'); // name the copy exactly "Cookies"
42
+ const cookiesDirForFallback = await ensureCookiesDirForFallback(cookiesCopy);
43
+ const localStateCopy = await copyLockedFile(localStatePath, 'Local State');
44
+ const aesKey = await extractWindowsAesKey(localStateCopy);
45
+ const rows = await readChromeCookiesDb(cookiesCopy, filterNames);
46
+ const cookies = [];
47
+ for (const row of rows) {
48
+ const enc = row.encrypted_value && row.encrypted_value.length > 0 ? row.encrypted_value : Buffer.from(row.value ?? '', 'utf8');
49
+ const decrypted = enc.length > 0 ? decryptCookie(enc, aesKey) : '';
50
+ cookies.push({
51
+ name: row.name,
52
+ value: decrypted,
53
+ domain: row.host_key?.startsWith('.') ? row.host_key.slice(1) : row.host_key,
54
+ path: row.path ?? '/',
55
+ expires: normalizeExpiration(row.expires_utc),
56
+ secure: Boolean(row.is_secure),
57
+ httpOnly: Boolean(row.is_httponly),
58
+ });
59
+ }
60
+ if (process.env.ORACLE_DEBUG_COOKIES === '1') {
61
+ // eslint-disable-next-line no-console
62
+ console.log(`[cookies] windows decrypt decoded ${cookies.length} cookies from ${cookiesCopy} (fallback dir ${cookiesDirForFallback})`);
63
+ }
64
+ return cookies.filter((c) => c.value);
65
+ }
66
+ function decryptCookie(value, aesKey) {
67
+ const prefix = value.slice(0, 3).toString();
68
+ // Chrome prefixes AES-GCM encrypted cookies with version markers like v10/v11/v20; treat all v** the same.
69
+ if (/^v\d{2}$/u.test(prefix)) {
70
+ const iv = value.slice(3, 15);
71
+ const tag = value.slice(value.length - 16);
72
+ const data = value.slice(15, value.length - 16);
73
+ const decipher = crypto.createDecipheriv('aes-256-gcm', aesKey, iv);
74
+ decipher.setAuthTag(tag);
75
+ const decrypted = Buffer.concat([decipher.update(data), decipher.final()]);
76
+ return decrypted.toString('utf8');
77
+ }
78
+ const unprotectData = getUnprotectData();
79
+ const unprotected = unprotectData(value, undefined, 'CurrentUser');
80
+ return Buffer.from(unprotected).toString('utf8');
81
+ }
82
+ async function locateLocalState(dbPath) {
83
+ // Prefer sibling Local State near the profile path; fall back to default location.
84
+ const guess = path.resolve(path.join(path.dirname(dbPath), '..', 'Local State'));
85
+ if (existsSync(guess))
86
+ return guess;
87
+ const localState = path.join(os.homedir(), 'AppData', 'Local', 'Google', 'Chrome', 'User Data', 'Local State');
88
+ if (existsSync(localState))
89
+ return localState;
90
+ throw new Error('Chrome Local State not found for AES key');
91
+ }
92
+ async function extractWindowsAesKey(localStatePath) {
93
+ const raw = await fs.readFile(localStatePath, 'utf8');
94
+ const state = JSON.parse(raw);
95
+ const encKeyB64 = state?.os_crypt?.encrypted_key;
96
+ if (!encKeyB64)
97
+ throw new Error('encrypted_key missing in Local State');
98
+ const encKey = Buffer.from(encKeyB64, 'base64');
99
+ const dpapiBlob = encKey.slice(5); // strip "DPAPI"
100
+ const unprotectData = getUnprotectData();
101
+ const unprotected = unprotectData(dpapiBlob, undefined, 'CurrentUser');
102
+ return Buffer.from(unprotected);
103
+ }
104
+ async function readChromeCookiesDb(dbPath, filterNames) {
105
+ const db = await openSqlite(dbPath);
106
+ const placeholders = filterNames && filterNames.size > 0 ? `AND name IN (${Array.from(filterNames).map(() => '?').join(',')})` : '';
107
+ const params = filterNames ? Array.from(filterNames) : [];
108
+ const sql = `SELECT name,value,encrypted_value,host_key,path,expires_utc,is_secure,is_httponly FROM cookies WHERE host_key LIKE '%chatgpt.com%' ${placeholders}`;
109
+ try {
110
+ return await allSqlite(db, sql, params);
111
+ }
112
+ finally {
113
+ db.close();
114
+ }
115
+ }
116
+ function openSqlite(dbPath) {
117
+ return new Promise((resolve, reject) => {
118
+ const db = new sqlite3.Database(dbPath, sqlite3.OPEN_READONLY, (err) => {
119
+ if (err)
120
+ reject(err);
121
+ else
122
+ resolve(db);
123
+ });
124
+ });
125
+ }
126
+ async function copyLockedFile(sourcePath, targetName) {
127
+ const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'oracle-cookie-'));
128
+ const tempPath = targetName ? path.join(tempDir, targetName) : path.join(tempDir, path.basename(sourcePath));
129
+ const psScript = `
130
+ $src = '${sourcePath.replace(/'/g, "''")}'
131
+ $dst = '${tempPath.replace(/'/g, "''")}'
132
+ try {
133
+ $fs = [System.IO.File]::Open($src, [System.IO.FileMode]::Open, [System.IO.FileAccess]::Read, [System.IO.FileShare]::ReadWrite)
134
+ $bytes = New-Object byte[] $fs.Length
135
+ $null = $fs.Read($bytes, 0, $fs.Length)
136
+ $fs.Close()
137
+ [System.IO.File]::WriteAllBytes($dst, $bytes)
138
+ exit 0
139
+ } catch {
140
+ exit 1
141
+ }
142
+ `;
143
+ const encoded = Buffer.from(psScript, 'utf16le').toString('base64');
144
+ const result = spawnSync('powershell.exe', ['-NoProfile', '-NonInteractive', '-EncodedCommand', encoded], {
145
+ timeout: 15000,
146
+ encoding: 'utf8',
147
+ });
148
+ if (result.status !== 0) {
149
+ // Fallback: best-effort normal copy
150
+ await fs.copyFile(sourcePath, tempPath);
151
+ }
152
+ if (process.env.ORACLE_DEBUG_COOKIES === '1') {
153
+ // eslint-disable-next-line no-console
154
+ console.log(`[cookies] copied locked file ${sourcePath} -> ${tempPath}`);
155
+ }
156
+ return tempPath;
157
+ }
158
+ // Create a directory containing a file named "Cookies" for chrome-cookies-secure fallback
159
+ export async function ensureCookiesDirForFallback(cookieFile) {
160
+ const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'oracle-cookies-secure-'));
161
+ const target = path.join(dir, 'Cookies');
162
+ await fs.copyFile(cookieFile, target);
163
+ return dir;
164
+ }
165
+ function allSqlite(db, sql, params) {
166
+ return new Promise((resolve, reject) => {
167
+ db.all(sql, params, (err, rows) => {
168
+ if (err)
169
+ reject(err);
170
+ else
171
+ resolve((rows ?? []));
172
+ });
173
+ });
174
+ }
175
+ function normalizeExpiration(expires) {
176
+ if (!expires || Number.isNaN(expires)) {
177
+ return undefined;
178
+ }
179
+ const value = Number(expires);
180
+ if (value <= 0)
181
+ return undefined;
182
+ if (value > 1_000_000_000_000) {
183
+ return Math.round(value / 1_000_000 - 11644473600);
184
+ }
185
+ if (value > 1_000_000_000) {
186
+ return Math.round(value / 1000);
187
+ }
188
+ return Math.round(value);
189
+ }
190
+ export async function materializeCookieFile(sourcePath) {
191
+ if (process.platform !== 'win32')
192
+ return sourcePath;
193
+ const resolved = await resolveDirectoryCandidate(sourcePath);
194
+ const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'oracle-cookies-'));
195
+ const tempPath = path.join(tempDir, 'Cookies');
196
+ await fs.copyFile(resolved, tempPath);
197
+ return tempPath;
198
+ }
199
+ async function resolveDirectoryCandidate(inputPath) {
200
+ const stat = await fs.stat(inputPath).catch(() => null);
201
+ if (!stat?.isDirectory())
202
+ return inputPath;
203
+ const network = path.join(inputPath, 'Network', 'Cookies');
204
+ const direct = path.join(inputPath, 'Cookies');
205
+ if (await fileExists(network))
206
+ return network;
207
+ if (await fileExists(direct))
208
+ return direct;
209
+ return inputPath;
210
+ }
211
+ async function fileExists(candidate) {
212
+ try {
213
+ const stat = await fsp.stat(candidate);
214
+ return stat.isFile();
215
+ }
216
+ catch {
217
+ return false;
218
+ }
219
+ }
@@ -21,7 +21,10 @@ export async function runBridgeClient(options) {
21
21
  const suffix = health.statusCode ? ` (HTTP ${health.statusCode})` : "";
22
22
  throw new Error(`Remote service health check failed: ${health.error ?? "unknown error"}${suffix}`);
23
23
  }
24
- console.log(chalk.green(`Remote service OK (${remoteHost})${health.version ? ` — oracle ${health.version}` : ""}`));
24
+ const artifactTransfer = health.capabilities?.artifactTransfer
25
+ ? ` — artifacts bridge v${health.capabilities.artifactProtocolVersion}`
26
+ : " — artifact transfer unavailable; file downloads require manual copy";
27
+ console.log(chalk.green(`Remote service OK (${remoteHost})${health.version ? ` — oracle ${health.version}` : ""}${artifactTransfer}`));
25
28
  }
26
29
  const configFilePath = options.config?.trim() || defaultConfigPath();
27
30
  if (options.writeConfig !== false) {
@@ -60,6 +60,13 @@ export async function runBridgeDoctor(_options) {
60
60
  if (health.ok) {
61
61
  const meta = health.version ? `oracle ${health.version}` : "ok";
62
62
  lines.push(chalk.dim(`Auth (/health): ${chalk.green(meta)}`));
63
+ if (health.capabilities?.artifactTransfer) {
64
+ lines.push(chalk.dim(`Artifact transfer: ${chalk.green(`bridge v${health.capabilities.artifactProtocolVersion}`)} (${formatBytes(health.capabilities.maxArtifactBytes)} max)`));
65
+ }
66
+ else {
67
+ warn.push("Remote host does not advertise bridge artifact transfer; ChatGPT-generated files may need manual copy from the browser host.");
68
+ lines.push(chalk.dim(`Artifact transfer: ${chalk.yellow("manual fallback")}`));
69
+ }
63
70
  }
64
71
  else {
65
72
  const detail = health.error ?? "unknown error";
@@ -118,3 +125,15 @@ export async function runBridgeDoctor(_options) {
118
125
  console.log(lines.join("\n"));
119
126
  process.exitCode = fail.length ? 1 : 0;
120
127
  }
128
+ function formatBytes(bytes) {
129
+ if (!Number.isFinite(bytes) || bytes <= 0)
130
+ return "unknown";
131
+ const units = ["B", "KB", "MB", "GB"];
132
+ let value = bytes;
133
+ let unit = 0;
134
+ while (value >= 1024 && unit < units.length - 1) {
135
+ value /= 1024;
136
+ unit += 1;
137
+ }
138
+ return `${value.toFixed(value >= 10 || unit === 0 ? 0 : 1)} ${units[unit]}`;
139
+ }
@@ -64,6 +64,18 @@ export function normalizeChatGptModelForBrowser(model) {
64
64
  return model;
65
65
  }
66
66
  export async function buildBrowserConfig(options) {
67
+ if (options.copyProfile && options.browserKeepBrowser) {
68
+ throw new Error("--copy-profile cannot be combined with --browser-keep-browser: the copied profile is a throwaway that is deleted after the run, so it must not be retained.");
69
+ }
70
+ if (options.copyProfile && options.browserManualLogin) {
71
+ throw new Error("--copy-profile cannot be combined with --browser-manual-login: choose either a throwaway copied profile or the persistent manual-login profile.");
72
+ }
73
+ if (options.copyProfile && options.remoteChrome) {
74
+ throw new Error("--copy-profile cannot be combined with --remote-chrome: copied profiles require a locally launched Chrome instance.");
75
+ }
76
+ if (options.copyProfile && options.remoteHost) {
77
+ throw new Error("--copy-profile cannot be combined with --remote-host: the local profile source is not available to the remote browser service.");
78
+ }
67
79
  const desiredModelOverride = options.browserModelLabel?.trim();
68
80
  const normalizedOverride = desiredModelOverride?.toLowerCase() ?? "";
69
81
  const baseModel = options.model.toLowerCase();
@@ -98,7 +110,9 @@ export async function buildBrowserConfig(options) {
98
110
  ? desiredModelOverride
99
111
  : mapModelToBrowserLabel(options.model);
100
112
  return {
101
- chromeProfile: options.browserChromeProfile ?? DEFAULT_CHROME_PROFILE,
113
+ chromeProfile: options.copyProfile
114
+ ? (options.browserChromeProfile ?? null)
115
+ : (options.browserChromeProfile ?? DEFAULT_CHROME_PROFILE),
102
116
  chromePath: options.browserChromePath ?? null,
103
117
  chromeCookiePath: options.browserCookiePath ?? null,
104
118
  attachRunning,
@@ -146,6 +160,7 @@ export async function buildBrowserConfig(options) {
146
160
  keepBrowser: options.browserKeepBrowser ? true : undefined,
147
161
  manualLogin: options.browserManualLogin === undefined ? undefined : options.browserManualLogin,
148
162
  manualLoginProfileDir: options.browserManualLoginProfileDir ?? undefined,
163
+ copyProfileSource: options.copyProfile ?? undefined,
149
164
  hideWindow: options.browserHideWindow ? true : undefined,
150
165
  desiredModel,
151
166
  modelStrategy,
@@ -171,6 +186,7 @@ function validateAttachRunningOptions(options, { attachRunning, hasInlineCookies
171
186
  options.browserKeepBrowser ? "--browser-keep-browser" : null,
172
187
  options.browserManualLogin ? "--browser-manual-login" : null,
173
188
  options.browserManualLoginProfileDir ? "--browser-manual-login-profile-dir" : null,
189
+ options.copyProfile ? "--copy-profile" : null,
174
190
  hasInlineCookies ? "--browser-inline-cookies/--browser-inline-cookies-file" : null,
175
191
  options.browserPort != null || options.browserDebugPort != null
176
192
  ? "--browser-port/--browser-debug-port"
@@ -2,6 +2,7 @@ import { DEFAULT_MODEL, MODEL_CONFIGS } from "../oracle.js";
2
2
  import { resolveEngine } from "./engine.js";
3
3
  import { normalizeModelOption, inferModelFromLabel, resolveApiModel, normalizeBaseUrl, } from "./options.js";
4
4
  import { resolveGeminiModelId } from "../oracle/gemini.js";
5
+ import { resolveOverriddenApiModel } from "../oracle/modelResolver.js";
5
6
  import { PromptValidationError } from "../oracle/errors.js";
6
7
  import { normalizeChatGptModelForBrowser } from "./browserConfig.js";
7
8
  import { resolveConfiguredMaxFileSizeBytes } from "./fileSize.js";
@@ -62,7 +63,8 @@ export function resolveRunOptionsFromConfig({ prompt, files = [], model, models,
62
63
  // Silent coerce; multi-model still forces API.
63
64
  }
64
65
  const chosenModel = uniqueMultiModels[0] ?? resolvedModel;
65
- const effectiveModelId = resolveEffectiveModelId(chosenModel);
66
+ const apiModelOverrides = fixedEngine === "api" ? userConfig?.modelOverrides : undefined;
67
+ const effectiveModelId = resolveEffectiveModelId(chosenModel, apiModelOverrides);
66
68
  const runOptions = {
67
69
  prompt: promptWithSuffix,
68
70
  model: chosenModel,
@@ -76,6 +78,7 @@ export function resolveRunOptionsFromConfig({ prompt, files = [], model, models,
76
78
  baseUrl,
77
79
  azure,
78
80
  effectiveModelId,
81
+ modelOverrides: apiModelOverrides,
79
82
  };
80
83
  return { runOptions, resolvedEngine: fixedEngine, engineCoercedToApi };
81
84
  }
@@ -90,7 +93,13 @@ function resolveAzureOptions(userConfig, env) {
90
93
  apiVersion: env.AZURE_OPENAI_API_VERSION ?? userConfig?.azure?.apiVersion,
91
94
  };
92
95
  }
93
- function resolveEffectiveModelId(model) {
96
+ function resolveEffectiveModelId(model, modelOverrides) {
97
+ // A user-config override of a known model's apiModel must win, since this id
98
+ // becomes the on-wire request model id in run.ts (including for Gemini aliases).
99
+ const overridden = resolveOverriddenApiModel(model, modelOverrides);
100
+ if (overridden) {
101
+ return overridden;
102
+ }
94
103
  if (typeof model === "string" && model.startsWith("gemini")) {
95
104
  return resolveGeminiModelId(model);
96
105
  }
@@ -306,7 +306,12 @@ export async function attachSession(sessionId, options) {
306
306
  for (const artifact of metadata.artifacts) {
307
307
  const label = artifact.label ?? artifact.kind;
308
308
  const size = artifact.sizeBytes ? ` (${formatBytes(artifact.sizeBytes)})` : "";
309
- console.log(`- ${chalk.cyan(label)} ${artifact.path}${size}`);
309
+ const checksum = artifact.sha256 ? ` sha256=${artifact.sha256.slice(0, 12)}…` : "";
310
+ const validation = artifact.validation
311
+ ? ` validation=${artifact.validation.ok ? "ok" : (artifact.validation.error ?? "failed")}`
312
+ : "";
313
+ const transfer = artifact.transfer?.status ? ` transfer=${artifact.transfer.status}` : "";
314
+ console.log(`- ${chalk.cyan(label)} — ${artifact.path}${size}${checksum}${validation}${transfer}`);
310
315
  }
311
316
  }
312
317
  const responseSummary = formatResponseMetadata(metadata.response);
@@ -33,6 +33,9 @@ export async function performSessionRun({ sessionMeta, runOptions, mode, browser
33
33
  write(chunk);
34
34
  return muteStdout ? true : process.stdout.write(chunk);
35
35
  };
36
+ let currentBrowser = browserConfig
37
+ ? { config: browserConfig }
38
+ : sessionMeta.browser;
36
39
  await sessionStore.updateSession(sessionMeta.id, {
37
40
  status: "running",
38
41
  startedAt: new Date().toISOString(),
@@ -54,11 +57,19 @@ export async function performSessionRun({ sessionMeta, runOptions, mode, browser
54
57
  }
55
58
  const runnerDeps = {
56
59
  ...browserDeps,
57
- persistRuntimeHint: async (runtime) => {
60
+ persistRuntimeHint: async (runtime, modelSelection) => {
61
+ const browser = {
62
+ config: browserConfig,
63
+ runtime,
64
+ ...(modelSelection ? { modelSelection } : {}),
65
+ };
58
66
  await sessionStore.updateSession(sessionMeta.id, {
59
67
  status: "running",
60
- browser: { config: browserConfig, runtime },
68
+ browser,
61
69
  });
70
+ // Keep this attempt's copy fresh so error paths fall back to the
71
+ // latest persisted browser evidence instead of stale session input.
72
+ currentBrowser = browser;
62
73
  },
63
74
  };
64
75
  const result = await runBrowserSessionExecution({
@@ -112,6 +123,7 @@ export async function performSessionRun({ sessionMeta, runOptions, mode, browser
112
123
  const modelConfig = await resolveModelConfig(primaryModel, {
113
124
  baseUrl: runOptions.baseUrl,
114
125
  openRouterApiKey: process.env.OPENROUTER_API_KEY,
126
+ modelOverrides: runOptions.modelOverrides,
115
127
  });
116
128
  const files = await readFiles(runOptions.file ?? [], {
117
129
  cwd,
@@ -389,6 +401,7 @@ export async function performSessionRun({ sessionMeta, runOptions, mode, browser
389
401
  userError.details?.stage === "assistant-timeout";
390
402
  const cloudflareChallenge = userError?.category === "browser-automation" &&
391
403
  userError.details?.stage === "cloudflare-challenge";
404
+ const browserCanReattach = !browserConfig?.copyProfileSource;
392
405
  let reattachGuidanceLogged = false;
393
406
  const logBrowserReattachGuidance = (runtime) => {
394
407
  if (reattachGuidanceLogged || mode !== "browser")
@@ -399,10 +412,10 @@ export async function performSessionRun({ sessionMeta, runOptions, mode, browser
399
412
  reattachGuidanceLogged = true;
400
413
  log(formatBrowserReattachGuidance(sessionMeta.id));
401
414
  };
402
- if (connectionLost && mode === "browser") {
415
+ if (connectionLost && mode === "browser" && browserCanReattach) {
403
416
  const runtime = userError.details
404
417
  ?.runtime;
405
- const recoverableRuntime = runtime ?? sessionMeta.browser?.runtime;
418
+ const recoverableRuntime = runtime ?? currentBrowser?.runtime;
406
419
  if (!hasRecoverableChatGptConversation(recoverableRuntime) &&
407
420
  recoverableRuntime?.promptSubmitted !== true) {
408
421
  log(dim("Chrome disconnected before a ChatGPT conversation was created; marking session error."));
@@ -424,6 +437,7 @@ export async function performSessionRun({ sessionMeta, runOptions, mode, browser
424
437
  errorMessage: message,
425
438
  mode,
426
439
  browser: {
440
+ ...currentBrowser,
427
441
  config: browserConfig,
428
442
  runtime: recoverableRuntime,
429
443
  },
@@ -448,15 +462,16 @@ export async function performSessionRun({ sessionMeta, runOptions, mode, browser
448
462
  errorMessage: message,
449
463
  mode,
450
464
  browser: {
465
+ ...currentBrowser,
451
466
  config: browserConfig,
452
- runtime: runtime ?? sessionMeta.browser?.runtime,
467
+ runtime: runtime ?? currentBrowser?.runtime,
453
468
  },
454
469
  response: { status: "running", incompleteReason: "chrome-disconnected" },
455
470
  });
456
471
  logBrowserReattachGuidance(recoverableRuntime);
457
472
  return;
458
473
  }
459
- if (assistantTimeout && mode === "browser") {
474
+ if (assistantTimeout && mode === "browser" && browserCanReattach) {
460
475
  const runtime = userError.details
461
476
  ?.runtime;
462
477
  log(dim("Assistant response timed out; marking capture incomplete for reattach."));
@@ -478,8 +493,9 @@ export async function performSessionRun({ sessionMeta, runOptions, mode, browser
478
493
  errorMessage: message,
479
494
  mode,
480
495
  browser: {
496
+ ...currentBrowser,
481
497
  config: browserConfig,
482
- runtime: runtime ?? sessionMeta.browser?.runtime,
498
+ runtime: runtime ?? currentBrowser?.runtime,
483
499
  },
484
500
  response: { status: "incomplete", incompleteReason: "incomplete-capture" },
485
501
  error: {
@@ -490,11 +506,12 @@ export async function performSessionRun({ sessionMeta, runOptions, mode, browser
490
506
  });
491
507
  const autoReattachIntervalMs = browserConfig?.autoReattachIntervalMs ?? 0;
492
508
  if (autoReattachIntervalMs > 0) {
493
- const autoRuntime = runtime ?? sessionMeta.browser?.runtime;
509
+ const autoRuntime = runtime ?? currentBrowser?.runtime;
494
510
  const success = await autoReattachUntilComplete({
495
511
  sessionMeta,
496
512
  runtime: autoRuntime ?? undefined,
497
513
  browserConfig,
514
+ browserMetadata: currentBrowser,
498
515
  runOptions,
499
516
  modelForStatus,
500
517
  notificationSettings,
@@ -504,14 +521,19 @@ export async function performSessionRun({ sessionMeta, runOptions, mode, browser
504
521
  return;
505
522
  }
506
523
  }
507
- logBrowserReattachGuidance(runtime ?? sessionMeta.browser?.runtime);
524
+ logBrowserReattachGuidance(runtime ?? currentBrowser?.runtime);
508
525
  return;
509
526
  }
510
527
  if (cloudflareChallenge && mode === "browser") {
511
528
  const details = userError.details;
512
- log(dim("Cloudflare challenge detected; browser left running so you can complete the check."));
513
- if (details?.reuseProfileHint) {
514
- log(dim(`Reuse this browser profile with: ${details.reuseProfileHint}`));
529
+ if (browserCanReattach) {
530
+ log(dim("Cloudflare challenge detected; browser left running so you can complete the check."));
531
+ if (details?.reuseProfileHint) {
532
+ log(dim(`Reuse this browser profile with: ${details.reuseProfileHint}`));
533
+ }
534
+ }
535
+ else {
536
+ log(dim("Cloudflare challenge detected; copied profile closed and removed."));
515
537
  }
516
538
  }
517
539
  if (userError) {
@@ -527,11 +549,11 @@ export async function performSessionRun({ sessionMeta, runOptions, mode, browser
527
549
  if (transportLine) {
528
550
  log(dim(`Transport: ${transportLine}`));
529
551
  }
530
- const browserRuntime = mode === "browser"
552
+ const browserRuntime = mode === "browser" && browserCanReattach
531
553
  ? userError?.details?.runtime
532
554
  : undefined;
533
- if (!cloudflareChallenge) {
534
- logBrowserReattachGuidance(browserRuntime ?? sessionMeta.browser?.runtime);
555
+ if (!cloudflareChallenge && browserCanReattach) {
556
+ logBrowserReattachGuidance(browserRuntime ?? currentBrowser?.runtime);
535
557
  }
536
558
  await sessionStore.updateSession(sessionMeta.id, {
537
559
  status: "error",
@@ -540,8 +562,9 @@ export async function performSessionRun({ sessionMeta, runOptions, mode, browser
540
562
  mode,
541
563
  browser: browserConfig
542
564
  ? {
565
+ ...currentBrowser,
543
566
  config: browserConfig,
544
- runtime: browserRuntime ?? undefined,
567
+ runtime: browserRuntime ?? currentBrowser?.runtime,
545
568
  }
546
569
  : undefined,
547
570
  response: responseMetadata,
@@ -810,7 +833,7 @@ async function writeAssistantOutput(targetPath, content, log) {
810
833
  log(dim(`write-output failed (${reason}); session completed anyway.`));
811
834
  }
812
835
  }
813
- async function autoReattachUntilComplete({ sessionMeta, runtime, browserConfig, runOptions, modelForStatus, notificationSettings, log, }) {
836
+ async function autoReattachUntilComplete({ sessionMeta, runtime, browserConfig, browserMetadata, runOptions, modelForStatus, notificationSettings, log, }) {
814
837
  if (!runtime || !browserConfig) {
815
838
  log(dim("Auto-reattach disabled: missing runtime or browser config."));
816
839
  return false;
@@ -892,6 +915,7 @@ async function autoReattachUntilComplete({ sessionMeta, runtime, browserConfig,
892
915
  },
893
916
  errorMessage: undefined,
894
917
  browser: {
918
+ ...browserMetadata,
895
919
  config: browserConfig,
896
920
  runtime,
897
921
  },
@@ -125,6 +125,9 @@ function sanitizeProjectConfig(config) {
125
125
  sanitized.background = config.background;
126
126
  if (config.promptSuffix !== undefined)
127
127
  sanitized.promptSuffix = config.promptSuffix;
128
+ // NOTE: `modelOverrides` is intentionally NOT copied here. Model routing
129
+ // overrides are user-config only; allowing them from project configs would let
130
+ // an untrusted repository silently redirect API calls (apiModel) or reasoning.
128
131
  if (config.browser) {
129
132
  sanitized.browser = {};
130
133
  const browser = config.browser;
@@ -141,6 +141,8 @@ function buildOpenRouterCompletionClient(instance) {
141
141
  model: body.model,
142
142
  messages,
143
143
  max_tokens: body.max_output_tokens,
144
+ // Custom gateways use Chat Completions, whose effort field differs from Responses.
145
+ ...(body.reasoning?.effort ? { reasoning_effort: body.reasoning.effort } : {}),
144
146
  };
145
147
  const streaming = { ...base, stream: true };
146
148
  const nonStreaming = { ...base, stream: false };
@@ -120,6 +120,12 @@ function mapToOpenRouterId(candidate, catalog, providerHint) {
120
120
  return candidate;
121
121
  }
122
122
  export async function resolveModelConfig(model, options = {}) {
123
+ const base = await resolveBaseModelConfig(model, options);
124
+ // Apply user-config per-model overrides last, after known/OpenRouter/synthesized
125
+ // resolution, so an explicit override always wins.
126
+ return applyModelOverride(base, model, options.modelOverrides);
127
+ }
128
+ async function resolveBaseModelConfig(model, options = {}) {
123
129
  const known = isKnownModel(model) ? MODEL_CONFIGS[model] : null;
124
130
  const fetcher = options.fetcher ?? globalThis.fetch.bind(globalThis);
125
131
  const openRouterActive = isOpenRouterBaseUrl(options.baseUrl) || Boolean(options.openRouterApiKey);
@@ -186,6 +192,85 @@ export async function resolveModelConfig(model, options = {}) {
186
192
  export function isProModel(model) {
187
193
  return isKnownModel(model) && PRO_MODELS.has(model);
188
194
  }
195
+ const VALID_REASONING_EFFORTS = ["low", "medium", "high", "xhigh"];
196
+ function isRecord(value) {
197
+ return typeof value === "object" && value !== null && !Array.isArray(value);
198
+ }
199
+ /**
200
+ * Returns the override's `apiModel` for a *known* model when present and non-empty,
201
+ * otherwise `undefined`. Single source of truth for the override apiModel rule,
202
+ * shared by {@link applyModelOverride} and the CLI's `effectiveModelId` resolution.
203
+ */
204
+ export function resolveOverriddenApiModel(model, overrides) {
205
+ if (!overrides || !isKnownModel(model))
206
+ return undefined;
207
+ const override = overrides[model];
208
+ if (!isRecord(override))
209
+ return undefined;
210
+ if (typeof override.apiModel === "string" && override.apiModel.trim() !== "") {
211
+ return override.apiModel.trim();
212
+ }
213
+ return undefined;
214
+ }
215
+ /**
216
+ * Apply a user-config per-model override on top of a resolved config.
217
+ *
218
+ * Scope is intentionally narrow: only *known* models can be overridden, so the
219
+ * tokenizer (a function, not expressible in JSON) and any unspecified fields are
220
+ * inherited from the base config. Override fields are validated defensively
221
+ * because they come from user-authored JSON5.
222
+ */
223
+ export function applyModelOverride(base, model, overrides) {
224
+ if (!overrides || !isKnownModel(model))
225
+ return base;
226
+ const override = overrides[model];
227
+ if (!isRecord(override))
228
+ return base;
229
+ const result = { ...base };
230
+ const apiModel = resolveOverriddenApiModel(model, overrides);
231
+ if (apiModel) {
232
+ result.apiModel = apiModel;
233
+ }
234
+ if (Object.hasOwn(override, "reasoning")) {
235
+ const reasoning = override.reasoning;
236
+ if (reasoning === null) {
237
+ // Explicit null clears the known model's reasoning effort.
238
+ result.reasoning = null;
239
+ }
240
+ else if (isRecord(reasoning) &&
241
+ typeof reasoning.effort === "string" &&
242
+ VALID_REASONING_EFFORTS.includes(reasoning.effort)) {
243
+ result.reasoning = { effort: reasoning.effort };
244
+ }
245
+ // Malformed reasoning override is ignored (base value preserved).
246
+ }
247
+ if (typeof override.inputLimit === "number" &&
248
+ Number.isSafeInteger(override.inputLimit) &&
249
+ override.inputLimit > 0) {
250
+ result.inputLimit = override.inputLimit;
251
+ }
252
+ // Non-positive or non-integer inputLimit (e.g. 0, 0.5, NaN, Infinity) is ignored.
253
+ if (Object.hasOwn(override, "pricing")) {
254
+ const pricing = override.pricing;
255
+ if (pricing === null) {
256
+ result.pricing = null;
257
+ }
258
+ else if (isRecord(pricing) &&
259
+ typeof pricing.inputPerToken === "number" &&
260
+ Number.isFinite(pricing.inputPerToken) &&
261
+ pricing.inputPerToken >= 0 &&
262
+ typeof pricing.outputPerToken === "number" &&
263
+ Number.isFinite(pricing.outputPerToken) &&
264
+ pricing.outputPerToken >= 0) {
265
+ result.pricing = {
266
+ inputPerToken: pricing.inputPerToken,
267
+ outputPerToken: pricing.outputPerToken,
268
+ };
269
+ }
270
+ // Malformed pricing override is ignored (base value preserved).
271
+ }
272
+ return result;
273
+ }
189
274
  export function resetOpenRouterCatalogCacheForTest() {
190
275
  catalogCache.clear();
191
276
  }