@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.
- package/dist/bin/oracle-cli.js +16 -6
- package/dist/bin/oracle.js +569 -0
- package/dist/docs-site/.nojekyll +0 -0
- package/dist/docs-site/CNAME +1 -0
- package/dist/docs-site/RELEASING.html +410 -0
- package/dist/docs-site/agents.html +374 -0
- package/dist/docs-site/anthropic.html +368 -0
- package/dist/docs-site/bridge.html +416 -0
- package/dist/docs-site/browser-mode.html +594 -0
- package/dist/docs-site/chromium-forks.html +347 -0
- package/dist/docs-site/cli-reference.html +346 -0
- package/dist/docs-site/configuration.html +462 -0
- package/dist/docs-site/favicon.svg +14 -0
- package/dist/docs-site/followup.html +375 -0
- package/dist/docs-site/gemini.html +383 -0
- package/dist/docs-site/grok.html +325 -0
- package/dist/docs-site/index.html +360 -0
- package/dist/docs-site/install.html +335 -0
- package/dist/docs-site/linux.html +321 -0
- package/dist/docs-site/llms.txt +43 -0
- package/dist/docs-site/manual-tests.html +596 -0
- package/dist/docs-site/mcp.html +391 -0
- package/dist/docs-site/multimodel.html +364 -0
- package/dist/docs-site/mythical-pro-agents.html +360 -0
- package/dist/docs-site/notifier.html +338 -0
- package/dist/docs-site/openai-endpoints.html +399 -0
- package/dist/docs-site/openrouter.html +344 -0
- package/dist/docs-site/quickstart.html +369 -0
- package/dist/docs-site/refactor/ux.html +532 -0
- package/dist/docs-site/sessions.html +388 -0
- package/dist/docs-site/social-card.png +0 -0
- package/dist/docs-site/social-card.svg +79 -0
- package/dist/docs-site/spec.html +363 -0
- package/dist/docs-site/testing.html +320 -0
- package/dist/docs-site/tui-debug.html +326 -0
- package/dist/docs-site/windows-work.html +323 -0
- package/dist/docs-site/windows.html +320 -0
- package/dist/src/browser/actions/assistantResponse.js +40 -31
- package/dist/src/browser/actions/attachments.js +28 -1
- package/dist/src/browser/actions/deepResearch.js +344 -128
- package/dist/src/browser/actions/modelSelection.js +75 -9
- package/dist/src/browser/actions/promptComposer.js +71 -14
- package/dist/src/browser/actions/thinkingStatus.js +19 -1
- package/dist/src/browser/actions/thinkingTime.js +65 -20
- package/dist/src/browser/artifacts.js +193 -14
- package/dist/src/browser/chatgptFiles.js +674 -91
- package/dist/src/browser/chromeCookies.js +312 -0
- package/dist/src/browser/chromeLifecycle.js +35 -4
- package/dist/src/browser/constants.js +5 -0
- package/dist/src/browser/deepResearchResult.js +23 -0
- package/dist/src/browser/index.js +112 -41
- package/dist/src/browser/keytarShim.js +56 -0
- package/dist/src/browser/profileCopy.js +93 -0
- package/dist/src/browser/sessionRunner.js +9 -3
- package/dist/src/browser/windowsCookies.js +219 -0
- package/dist/src/cli/bridge/client.js +4 -1
- package/dist/src/cli/bridge/doctor.js +19 -0
- package/dist/src/cli/browserConfig.js +17 -1
- package/dist/src/cli/runOptions.js +11 -2
- package/dist/src/cli/sessionDisplay.js +6 -1
- package/dist/src/cli/sessionRunner.js +41 -17
- package/dist/src/config.js +3 -0
- package/dist/src/oracle/client.js +2 -0
- package/dist/src/oracle/modelResolver.js +85 -0
- package/dist/src/oracle/multiModelRunner.js +4 -1
- package/dist/src/oracle/run.js +4 -1
- package/dist/src/remote/client.js +253 -22
- package/dist/src/remote/health.js +27 -0
- package/dist/src/remote/server.js +239 -4
- package/dist/src/remote/types.js +1 -1
- package/dist/src/sessionManager.js +1 -0
- package/dist/vendor/oracle-notifier/OracleNotifier.app/Contents/CodeResources +0 -0
- package/dist/vendor/oracle-notifier/OracleNotifier.app/Contents/Info.plist +20 -0
- package/dist/vendor/oracle-notifier/OracleNotifier.app/Contents/MacOS/OracleNotifier +0 -0
- package/dist/vendor/oracle-notifier/OracleNotifier.app/Contents/Resources/OracleIcon.icns +0 -0
- package/dist/vendor/oracle-notifier/OracleNotifier.app/Contents/_CodeSignature/CodeResources +128 -0
- package/dist/vendor/oracle-notifier/build-notifier.sh +0 -0
- package/package.json +45 -43
- package/vendor/oracle-notifier/OracleNotifier.app/Contents/CodeResources +0 -0
- package/vendor/oracle-notifier/OracleNotifier.app/Contents/Info.plist +20 -0
- package/vendor/oracle-notifier/OracleNotifier.app/Contents/MacOS/OracleNotifier +0 -0
- package/vendor/oracle-notifier/OracleNotifier.app/Contents/Resources/OracleIcon.icns +0 -0
- package/vendor/oracle-notifier/OracleNotifier.app/Contents/_CodeSignature/CodeResources +128 -0
- package/vendor/oracle-notifier/README.md +26 -0
- 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
|
-
|
|
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.
|
|
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
|
|
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
|
-
|
|
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
|
|
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 ??
|
|
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 ??
|
|
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 ??
|
|
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 ??
|
|
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 ??
|
|
524
|
+
logBrowserReattachGuidance(runtime ?? currentBrowser?.runtime);
|
|
508
525
|
return;
|
|
509
526
|
}
|
|
510
527
|
if (cloudflareChallenge && mode === "browser") {
|
|
511
528
|
const details = userError.details;
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
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 ??
|
|
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 ??
|
|
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
|
},
|
package/dist/src/config.js
CHANGED
|
@@ -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
|
}
|