pi-oracle 0.7.21 → 0.7.22
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/CHANGELOG.md +11 -0
- package/README.md +49 -385
- package/docs/ORACLE_DESIGN.md +1 -1
- package/docs/development.md +99 -0
- package/docs/platform-smoke.md +3 -3
- package/docs/reference.md +331 -0
- package/extensions/oracle/index.ts +1 -0
- package/extensions/oracle/lib/poller.ts +3 -8
- package/extensions/oracle/lib/queue.ts +2 -4
- package/extensions/oracle/shared/browser-profile-helpers.d.mts +1 -0
- package/extensions/oracle/shared/browser-profile-helpers.mjs +32 -0
- package/extensions/oracle/worker/auth-bootstrap.mjs +35 -6
- package/extensions/oracle/worker/chromium-cookie-source.mjs +50 -6
- package/extensions/oracle/worker/linux-chromium-cookie-source.d.mts +6 -0
- package/extensions/oracle/worker/linux-chromium-cookie-source.mjs +98 -0
- package/extensions/oracle/worker/run-job.mjs +10 -5
- package/package.json +12 -11
- package/platform-smoke.config.mjs +1 -22
- package/scripts/oracle-auth.test.mjs +35 -0
- package/scripts/oracle-chatgpt-preset-proof.mjs +19 -12
- package/scripts/oracle-cookie-import.test.mjs +204 -0
- package/scripts/oracle-model.test.mjs +5 -1
- package/scripts/oracle-real-smoke.mjs +17 -7
- package/scripts/platform-smoke/Dockerfile.ubuntu +3 -2
- package/scripts/platform-smoke/crabbox-runner.mjs +3 -5
- package/scripts/platform-smoke/doctor.mjs +9 -24
- package/scripts/platform-smoke/invariants.mjs +234 -22
- package/scripts/platform-smoke/platform-build-windows.ps1 +4 -16
- package/scripts/platform-smoke/targets.mjs +35 -27
- package/scripts/platform-smoke.mjs +2 -2
|
@@ -12,6 +12,7 @@ import { basename, dirname, isAbsolute, join, resolve } from "node:path";
|
|
|
12
12
|
import { getCookies } from "@steipete/sweet-cookie";
|
|
13
13
|
import {
|
|
14
14
|
assertNotKnownBrowserUserDataPath,
|
|
15
|
+
isNativeLinuxChromiumCookieSource,
|
|
15
16
|
sweetCookieSafeStoragePasswordScrubbedEnv,
|
|
16
17
|
} from "../shared/browser-profile-helpers.mjs";
|
|
17
18
|
import { ensureAccountCookie, filterImportableAuthCookies } from "./auth-cookie-policy.mjs";
|
|
@@ -340,10 +341,19 @@ async function launchTargetBrowser() {
|
|
|
340
341
|
await closeTargetBrowser();
|
|
341
342
|
const args = [...targetBrowserBaseArgs({ withLaunchOptions: true, mode: "headed" }), "open", "about:blank"];
|
|
342
343
|
await log(`Launching isolated browser: agent-browser ${JSON.stringify(args)}`);
|
|
343
|
-
const
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
344
|
+
const deadline = Date.now() + AGENT_BROWSER_COMMAND_TIMEOUT_MS;
|
|
345
|
+
while (true) {
|
|
346
|
+
const result = await spawnCommand(AGENT_BROWSER_BIN, args, { allowFailure: true, timeoutMs: Math.max(1, deadline - Date.now()) });
|
|
347
|
+
await log(`launch result: code=${result.code} stdout=${JSON.stringify(result.stdout)} stderr=${JSON.stringify(result.stderr)}`);
|
|
348
|
+
if (result.code === 0) return;
|
|
349
|
+
const message = result.stderr || result.stdout || "Failed to launch isolated oracle browser";
|
|
350
|
+
// agent-browser 0.39 can return from close before its daemon socket disappears.
|
|
351
|
+
// This configure failure happens before open is dispatched; other launch errors are not safe to retry.
|
|
352
|
+
if (!/Could not configure browser: Failed to connect: No such file or directory/i.test(message) || Date.now() >= deadline) {
|
|
353
|
+
throw new Error(message);
|
|
354
|
+
}
|
|
355
|
+
await log("Waiting for the closing agent-browser daemon before retrying launch");
|
|
356
|
+
await sleep(Math.min(250, deadline - Date.now()));
|
|
347
357
|
}
|
|
348
358
|
}
|
|
349
359
|
|
|
@@ -573,6 +583,15 @@ function formatAuthFailureGuidance(error) {
|
|
|
573
583
|
return lines.join("\n");
|
|
574
584
|
}
|
|
575
585
|
|
|
586
|
+
function canUseCalibratedChromeKey(source) {
|
|
587
|
+
if (!source.includes("/") && !source.includes("\\")) return false;
|
|
588
|
+
// Match Sweet Cookie's candidate expansion, not normalized source qualification:
|
|
589
|
+
// a literal absolute Cookies path can retain a Brave selector before "..".
|
|
590
|
+
const candidate = source.startsWith("~/") ? join(homedir(), source.slice(2))
|
|
591
|
+
: isAbsolute(source) ? source : resolve(process.cwd(), source);
|
|
592
|
+
return !/bravesoftware|brave-browser|brave browser/i.test(candidate);
|
|
593
|
+
}
|
|
594
|
+
|
|
576
595
|
async function readRawSourceCookies() {
|
|
577
596
|
if (config.auth.chromeCookiePath && config.auth.chromiumKeychain) {
|
|
578
597
|
return await getCookiesFromConfiguredChromiumSource({
|
|
@@ -584,14 +603,24 @@ async function readRawSourceCookies() {
|
|
|
584
603
|
});
|
|
585
604
|
}
|
|
586
605
|
|
|
587
|
-
|
|
606
|
+
const options = {
|
|
588
607
|
url: providerChatUrl(),
|
|
589
608
|
origins: cookieOrigins(),
|
|
590
609
|
browsers: ["chrome"],
|
|
591
610
|
mode: "merge",
|
|
592
611
|
chromeProfile: cookieSource(),
|
|
593
612
|
timeoutMs: COOKIE_READ_TIMEOUT_MS,
|
|
594
|
-
}
|
|
613
|
+
};
|
|
614
|
+
if (process.platform === "linux") {
|
|
615
|
+
if (process.env.SWEET_COOKIE_CHROME_SAFE_STORAGE_PASSWORD?.trim() && canUseCalibratedChromeKey(options.chromeProfile)) {
|
|
616
|
+
return await getCookies(options);
|
|
617
|
+
}
|
|
618
|
+
if (isNativeLinuxChromiumCookieSource(options.chromeProfile)) {
|
|
619
|
+
const { getCookiesFromNativeLinuxChromium } = await import("./linux-chromium-cookie-source.mjs");
|
|
620
|
+
return await getCookiesFromNativeLinuxChromium(options, getCookies);
|
|
621
|
+
}
|
|
622
|
+
}
|
|
623
|
+
return await getCookies(options);
|
|
595
624
|
}
|
|
596
625
|
|
|
597
626
|
async function readSourceCookies() {
|
|
@@ -1,8 +1,8 @@
|
|
|
1
|
-
// Purpose: Read provider cookies from
|
|
2
|
-
// Responsibilities: Snapshot
|
|
3
|
-
// Scope: macOS
|
|
4
|
-
// Usage: auth-bootstrap.mjs
|
|
5
|
-
// Invariants/Assumptions:
|
|
1
|
+
// Purpose: Read provider cookies from configured macOS Chromium stores.
|
|
2
|
+
// Responsibilities: Snapshot and decrypt configured macOS cookie stores.
|
|
3
|
+
// Scope: macOS cookie extraction only; auth policy filtering and browser seeding stay in auth-bootstrap.mjs.
|
|
4
|
+
// Usage: auth-bootstrap.mjs calls the configured macOS API after selecting the source.
|
|
5
|
+
// Invariants/Assumptions: Configured Keychain items identify the browser secret; helper environments are scrubbed.
|
|
6
6
|
import { spawn } from "node:child_process";
|
|
7
7
|
import { sweetCookieSafeStoragePasswordScrubbedEnv } from "../shared/browser-profile-helpers.mjs";
|
|
8
8
|
import { createDecipheriv, pbkdf2Sync } from "node:crypto";
|
|
@@ -11,10 +11,20 @@ import { tmpdir } from "node:os";
|
|
|
11
11
|
import { join } from "node:path";
|
|
12
12
|
import { DatabaseSync } from "node:sqlite";
|
|
13
13
|
|
|
14
|
+
/** @typedef {import("./chromium-cookie-source.d.mts").ChromiumKeychainConfig} ChromiumKeychainConfig */
|
|
15
|
+
/** @typedef {import("./chromium-cookie-source.d.mts").ConfiguredChromiumSourceOptions} ConfiguredChromiumSourceOptions */
|
|
16
|
+
/** @typedef {import("./auth-cookie-policy.mjs").ImportedAuthCookie} ImportedAuthCookie */
|
|
17
|
+
|
|
14
18
|
const CHROMIUM_EPOCH_OFFSET_SECONDS = 11_644_473_600n;
|
|
15
19
|
const COOKIE_VALUE_DECODER = new TextDecoder("utf-8", { fatal: true });
|
|
16
20
|
const MACOS_CHROMIUM_KEY_ITERATIONS = 1003;
|
|
17
21
|
|
|
22
|
+
/**
|
|
23
|
+
* @param {string} command
|
|
24
|
+
* @param {string[]} args
|
|
25
|
+
* @param {{timeoutMs?: number}} [options]
|
|
26
|
+
* @returns {Promise<{ok: boolean, stdout: string, stderr: string, error: string}>}
|
|
27
|
+
*/
|
|
18
28
|
function spawnCapture(command, args, options = {}) {
|
|
19
29
|
return new Promise((resolve) => {
|
|
20
30
|
const child = spawn(command, args, { env: sweetCookieSafeStoragePasswordScrubbedEnv(), stdio: ["ignore", "pipe", "pipe"], shell: process.platform === "win32" });
|
|
@@ -46,9 +56,14 @@ function spawnCapture(command, args, options = {}) {
|
|
|
46
56
|
});
|
|
47
57
|
}
|
|
48
58
|
|
|
59
|
+
/**
|
|
60
|
+
* @param {ChromiumKeychainConfig} keychain
|
|
61
|
+
* @param {number} timeoutMs
|
|
62
|
+
* @returns {Promise<{ok: true, password: string, service: string} | {ok: false, error: string}>}
|
|
63
|
+
*/
|
|
49
64
|
async function readKeychainPassword(keychain, timeoutMs) {
|
|
50
65
|
const services = Array.isArray(keychain.services) && keychain.services.length > 0 ? keychain.services : [keychain.service];
|
|
51
|
-
for (const service of services.filter(Boolean)) {
|
|
66
|
+
for (const service of services.filter(/** @returns {service is string} */ (service) => Boolean(service))) {
|
|
52
67
|
const result = await spawnCapture("security", ["find-generic-password", "-w", "-a", keychain.account, "-s", service], { timeoutMs });
|
|
53
68
|
if (result.ok) {
|
|
54
69
|
const password = result.stdout.trim();
|
|
@@ -59,6 +74,7 @@ async function readKeychainPassword(keychain, timeoutMs) {
|
|
|
59
74
|
return { ok: false, error: `Failed to read macOS Keychain (${keychain.label || keychain.account}): no configured service returned a password.` };
|
|
60
75
|
}
|
|
61
76
|
|
|
77
|
+
/** @param {string} dbPath */
|
|
62
78
|
function snapshotCookieDb(dbPath) {
|
|
63
79
|
const tempDir = mkdtempSync(join(tmpdir(), "pi-oracle-chromium-cookies-"));
|
|
64
80
|
const tempDbPath = join(tempDir, "Cookies");
|
|
@@ -73,6 +89,7 @@ function snapshotCookieDb(dbPath) {
|
|
|
73
89
|
}
|
|
74
90
|
}
|
|
75
91
|
|
|
92
|
+
/** @param {string} sourceDbPath @param {string} targetPath @param {string} suffix */
|
|
76
93
|
function copySidecar(sourceDbPath, targetPath, suffix) {
|
|
77
94
|
const sidecarPath = `${sourceDbPath}${suffix}`;
|
|
78
95
|
try {
|
|
@@ -83,10 +100,12 @@ function copySidecar(sourceDbPath, targetPath, suffix) {
|
|
|
83
100
|
}
|
|
84
101
|
}
|
|
85
102
|
|
|
103
|
+
/** @param {unknown} error */
|
|
86
104
|
function isMissingFileError(error) {
|
|
87
105
|
return Boolean(error && typeof error === "object" && "code" in error && error.code === "ENOENT");
|
|
88
106
|
}
|
|
89
107
|
|
|
108
|
+
/** @param {DatabaseSync} db */
|
|
90
109
|
function readMetaVersion(db) {
|
|
91
110
|
try {
|
|
92
111
|
const row = db.prepare("SELECT value FROM meta WHERE key = 'version'").get();
|
|
@@ -97,6 +116,7 @@ function readMetaVersion(db) {
|
|
|
97
116
|
}
|
|
98
117
|
}
|
|
99
118
|
|
|
119
|
+
/** @param {string} host */
|
|
100
120
|
function parentCookieDomains(host) {
|
|
101
121
|
const labels = host.split(".").filter(Boolean);
|
|
102
122
|
const domains = new Set([host, `.${host}`]);
|
|
@@ -108,11 +128,14 @@ function parentCookieDomains(host) {
|
|
|
108
128
|
return [...domains];
|
|
109
129
|
}
|
|
110
130
|
|
|
131
|
+
/** @param {string} value */
|
|
111
132
|
function sqlStringLiteral(value) {
|
|
112
133
|
return `'${value.replaceAll("'", "''")}'`;
|
|
113
134
|
}
|
|
114
135
|
|
|
136
|
+
/** @param {string[]} origins */
|
|
115
137
|
function buildHostWhereClause(origins) {
|
|
138
|
+
/** @type {Set<string>} */
|
|
116
139
|
const domains = new Set();
|
|
117
140
|
for (const origin of origins) {
|
|
118
141
|
try {
|
|
@@ -125,11 +148,13 @@ function buildHostWhereClause(origins) {
|
|
|
125
148
|
return `host_key IN (${[...domains].map(sqlStringLiteral).join(", ")})`;
|
|
126
149
|
}
|
|
127
150
|
|
|
151
|
+
/** @param {string[]} originHosts @param {string} hostKey */
|
|
128
152
|
function hostMatchesAny(originHosts, hostKey) {
|
|
129
153
|
const cookieDomain = hostKey.startsWith(".") ? hostKey.slice(1) : hostKey;
|
|
130
154
|
return originHosts.some((host) => host === cookieDomain || host.endsWith(`.${cookieDomain}`));
|
|
131
155
|
}
|
|
132
156
|
|
|
157
|
+
/** @param {unknown} value */
|
|
133
158
|
function chromiumExpirationToUnixSeconds(value) {
|
|
134
159
|
if (value === undefined || value === null || String(value) === "0") return undefined;
|
|
135
160
|
try {
|
|
@@ -142,6 +167,7 @@ function chromiumExpirationToUnixSeconds(value) {
|
|
|
142
167
|
}
|
|
143
168
|
}
|
|
144
169
|
|
|
170
|
+
/** @param {unknown} value @returns {ImportedAuthCookie["sameSite"]} */
|
|
145
171
|
function normalizeSameSite(value) {
|
|
146
172
|
const normalized = String(value ?? "").toLowerCase();
|
|
147
173
|
if (normalized === "2" || normalized === "strict") return "Strict";
|
|
@@ -150,10 +176,12 @@ function normalizeSameSite(value) {
|
|
|
150
176
|
return undefined;
|
|
151
177
|
}
|
|
152
178
|
|
|
179
|
+
/** @param {string} password */
|
|
153
180
|
function deriveMacosChromiumKey(password) {
|
|
154
181
|
return pbkdf2Sync(password, "saltysalt", MACOS_CHROMIUM_KEY_ITERATIONS, 16, "sha1");
|
|
155
182
|
}
|
|
156
183
|
|
|
184
|
+
/** @param {Uint8Array} encryptedValue @param {Buffer} key @param {{stripHashPrefix: boolean}} options */
|
|
157
185
|
function decryptCookieValue(encryptedValue, key, options) {
|
|
158
186
|
const buffer = Buffer.from(encryptedValue);
|
|
159
187
|
if (buffer.length < 3) return null;
|
|
@@ -172,6 +200,7 @@ function decryptCookieValue(encryptedValue, key, options) {
|
|
|
172
200
|
}
|
|
173
201
|
}
|
|
174
202
|
|
|
203
|
+
/** @param {Buffer} value */
|
|
175
204
|
function removePkcs7Padding(value) {
|
|
176
205
|
if (!value.length) return value;
|
|
177
206
|
const padding = value[value.length - 1];
|
|
@@ -179,6 +208,7 @@ function removePkcs7Padding(value) {
|
|
|
179
208
|
return value.subarray(0, value.length - padding);
|
|
180
209
|
}
|
|
181
210
|
|
|
211
|
+
/** @param {Uint8Array} value @param {boolean} stripHashPrefix */
|
|
182
212
|
function decodeCookieBytes(value, stripHashPrefix) {
|
|
183
213
|
const bytes = stripHashPrefix && value.length >= 32 ? value.subarray(32) : value;
|
|
184
214
|
try {
|
|
@@ -188,12 +218,19 @@ function decodeCookieBytes(value, stripHashPrefix) {
|
|
|
188
218
|
}
|
|
189
219
|
}
|
|
190
220
|
|
|
221
|
+
/** @param {string} value */
|
|
191
222
|
function stripLeadingControlChars(value) {
|
|
192
223
|
let index = 0;
|
|
193
224
|
while (index < value.length && value.charCodeAt(index) < 0x20) index += 1;
|
|
194
225
|
return value.slice(index);
|
|
195
226
|
}
|
|
196
227
|
|
|
228
|
+
/**
|
|
229
|
+
* @param {Record<string, unknown>[]} rows
|
|
230
|
+
* @param {Pick<ConfiguredChromiumSourceOptions, "origins" | "profile" | "includeExpired"> & {stripHashPrefix: boolean}} options
|
|
231
|
+
* @param {Buffer} key
|
|
232
|
+
* @param {string[]} warnings
|
|
233
|
+
*/
|
|
197
234
|
function collectCookies(rows, options, key, warnings) {
|
|
198
235
|
const cookies = [];
|
|
199
236
|
const now = Math.floor(Date.now() / 1000);
|
|
@@ -221,6 +258,7 @@ function collectCookies(rows, options, key, warnings) {
|
|
|
221
258
|
const expires = chromiumExpirationToUnixSeconds(row.expires_utc);
|
|
222
259
|
if (!options.includeExpired && expires !== undefined && expires < now) continue;
|
|
223
260
|
|
|
261
|
+
/** @type {ImportedAuthCookie & {source: {browser: string, profile: string}}} */
|
|
224
262
|
const cookie = {
|
|
225
263
|
name,
|
|
226
264
|
value,
|
|
@@ -239,7 +277,9 @@ function collectCookies(rows, options, key, warnings) {
|
|
|
239
277
|
return dedupeCookies(cookies);
|
|
240
278
|
}
|
|
241
279
|
|
|
280
|
+
/** @param {ImportedAuthCookie[]} cookies */
|
|
242
281
|
function dedupeCookies(cookies) {
|
|
282
|
+
/** @type {Map<string, ImportedAuthCookie>} */
|
|
243
283
|
const seen = new Map();
|
|
244
284
|
for (const cookie of cookies) {
|
|
245
285
|
const key = `${cookie.domain}\t${cookie.path}\t${cookie.name}`;
|
|
@@ -248,6 +288,10 @@ function dedupeCookies(cookies) {
|
|
|
248
288
|
return [...seen.values()];
|
|
249
289
|
}
|
|
250
290
|
|
|
291
|
+
/**
|
|
292
|
+
* @param {ConfiguredChromiumSourceOptions} options
|
|
293
|
+
* @returns {Promise<{cookies: ImportedAuthCookie[], warnings: string[]}>}
|
|
294
|
+
*/
|
|
251
295
|
export async function getCookiesFromConfiguredChromiumSource(options) {
|
|
252
296
|
const warnings = [];
|
|
253
297
|
if (!options.dbPath || !existsSync(options.dbPath)) {
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
// Purpose: Select native Linux Chromium's safe-storage key for the public Sweet Cookie parser.
|
|
2
|
+
// Scope: Called only after source qualification in the short-lived auth worker.
|
|
3
|
+
// Invariants: Restore password/backend overrides and scrub keyring helper environments.
|
|
4
|
+
import { execFile } from "node:child_process";
|
|
5
|
+
import { sweetCookieSafeStoragePasswordScrubbedEnv } from "../shared/browser-profile-helpers.mjs";
|
|
6
|
+
|
|
7
|
+
// ponytail: Native Linux Chromium key selection only. Keep Sweet Cookie 0.3's
|
|
8
|
+
// public parser/decryptor; remove when an upstream fix passes unchanged-path
|
|
9
|
+
// performance checks. This API runs only in the short-lived auth worker.
|
|
10
|
+
const PASSWORD_ENV = "SWEET_COOKIE_CHROME_SAFE_STORAGE_PASSWORD";
|
|
11
|
+
const BRAVE_PASSWORD_ENV = "SWEET_COOKIE_BRAVE_SAFE_STORAGE_PASSWORD";
|
|
12
|
+
const BACKEND_ENV = "SWEET_COOKIE_LINUX_KEYRING";
|
|
13
|
+
|
|
14
|
+
/** @param {string} command @param {string[]} args @returns {Promise<string | undefined>} */
|
|
15
|
+
function readHelper(command, args) {
|
|
16
|
+
return new Promise((resolve) => {
|
|
17
|
+
execFile(command, args, {
|
|
18
|
+
env: sweetCookieSafeStoragePasswordScrubbedEnv(),
|
|
19
|
+
encoding: "utf8", timeout: 3_000, killSignal: "SIGKILL",
|
|
20
|
+
}, (error, stdout) => resolve(error ? undefined : stdout.trim()));
|
|
21
|
+
});
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function keyringBackend() {
|
|
25
|
+
const configured = process.env[BACKEND_ENV]?.trim().toLowerCase();
|
|
26
|
+
if (configured === "gnome" || configured === "kwallet" || configured === "basic") return configured;
|
|
27
|
+
const kde = process.env.XDG_CURRENT_DESKTOP?.split(":").some((part) => part.trim().toLowerCase() === "kde")
|
|
28
|
+
|| Boolean(process.env.KDE_FULL_SESSION?.trim());
|
|
29
|
+
return kde ? "kwallet" : "gnome";
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
async function readChromiumPassword() {
|
|
33
|
+
const backend = keyringBackend();
|
|
34
|
+
if (backend === "basic") return { password: "", warnings: [] };
|
|
35
|
+
if (backend === "gnome") {
|
|
36
|
+
const password = await readHelper("secret-tool", ["lookup", "service", "Chromium Safe Storage", "account", "Chromium"])
|
|
37
|
+
|| await readHelper("secret-tool", ["lookup", "application", "chromium"]);
|
|
38
|
+
return { password: password || "", warnings: password ? [] : [
|
|
39
|
+
"Failed to read Linux keyring via secret-tool; v11 cookies may be unavailable.",
|
|
40
|
+
] };
|
|
41
|
+
}
|
|
42
|
+
const version = process.env.KDE_SESSION_VERSION?.trim();
|
|
43
|
+
const suffix = version === "5" || version === "6" ? version : "";
|
|
44
|
+
const wallet = await readHelper("dbus-send", [
|
|
45
|
+
"--session", "--print-reply=literal", `--dest=org.kde.kwalletd${suffix}`,
|
|
46
|
+
`/modules/kwalletd${suffix}`, "org.kde.KWallet.networkWallet",
|
|
47
|
+
]);
|
|
48
|
+
const password = await readHelper("kwallet-query", [
|
|
49
|
+
"--read-password", "Chromium Safe Storage", "--folder", "Chromium Keys",
|
|
50
|
+
wallet?.replaceAll('"', "").trim() || "kdewallet",
|
|
51
|
+
]);
|
|
52
|
+
return {
|
|
53
|
+
password: password?.toLowerCase().startsWith("failed to read") ? "" : password || "",
|
|
54
|
+
warnings: password === undefined ? [
|
|
55
|
+
"Failed to read Linux keyring via kwallet-query; v11 cookies may be unavailable.",
|
|
56
|
+
] : [],
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* @param {import("@steipete/sweet-cookie").GetCookiesOptions} options
|
|
62
|
+
* @param {typeof import("@steipete/sweet-cookie").getCookies} readCookies
|
|
63
|
+
*/
|
|
64
|
+
export async function getCookiesFromNativeLinuxChromium(options, readCookies) {
|
|
65
|
+
const override = process.env[PASSWORD_ENV]?.trim();
|
|
66
|
+
const { password, warnings } = override
|
|
67
|
+
? { password: override, warnings: [] }
|
|
68
|
+
: await readChromiumPassword();
|
|
69
|
+
const previous = {
|
|
70
|
+
[PASSWORD_ENV]: process.env[PASSWORD_ENV],
|
|
71
|
+
[BRAVE_PASSWORD_ENV]: process.env[BRAVE_PASSWORD_ENV],
|
|
72
|
+
[BACKEND_ENV]: process.env[BACKEND_ENV],
|
|
73
|
+
};
|
|
74
|
+
try {
|
|
75
|
+
// Sweet Cookie 0.3 classifies Brave by substrings anywhere in the DB path.
|
|
76
|
+
// Even a Chromium XDG/HOME ancestor can trigger it. Supply the selected
|
|
77
|
+
// Chromium key to either branch so neither can spawn a vendor keyring helper.
|
|
78
|
+
if (password) {
|
|
79
|
+
process.env[PASSWORD_ENV] = password;
|
|
80
|
+
process.env[BRAVE_PASSWORD_ENV] = password;
|
|
81
|
+
}
|
|
82
|
+
// Empty env values are ignored by Sweet Cookie. Its basic backend supplies
|
|
83
|
+
// the empty key without accidentally probing Chrome's keyring instead.
|
|
84
|
+
else {
|
|
85
|
+
delete process.env[PASSWORD_ENV];
|
|
86
|
+
delete process.env[BRAVE_PASSWORD_ENV];
|
|
87
|
+
process.env[BACKEND_ENV] = "basic";
|
|
88
|
+
}
|
|
89
|
+
const result = await readCookies(options);
|
|
90
|
+
result.warnings.unshift(...warnings);
|
|
91
|
+
return result;
|
|
92
|
+
} finally {
|
|
93
|
+
for (const [name, value] of Object.entries(previous)) {
|
|
94
|
+
if (value === undefined) delete process.env[name];
|
|
95
|
+
else process.env[name] = value;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
}
|
|
@@ -1534,7 +1534,7 @@ async function waitForModelConfigurationToSettle(job, options = {}) {
|
|
|
1534
1534
|
throw new Error(`Could not verify requested model settings after configuration for ${job.selection.modelFamily}`);
|
|
1535
1535
|
}
|
|
1536
1536
|
|
|
1537
|
-
async function configurePowerSlider(job) {
|
|
1537
|
+
async function configurePowerSlider(job, powerRef) {
|
|
1538
1538
|
// The current picker retains the compact tier semantics, but exposes keyboard
|
|
1539
1539
|
// interaction on a Power menuitem rather than accessible tier radio buttons.
|
|
1540
1540
|
const tiers = ["Instant", "Medium", "High", "Extra High", "Pro"];
|
|
@@ -1543,10 +1543,10 @@ async function configurePowerSlider(job) {
|
|
|
1543
1543
|
: job.selection.effort === "heavy" ? "Extra High"
|
|
1544
1544
|
: job.selection.effort === "extended" ? "High" : "Medium";
|
|
1545
1545
|
const target = tiers.indexOf(requested);
|
|
1546
|
-
const selector = '[
|
|
1547
|
-
await agentBrowser(job, "focus", selector);
|
|
1546
|
+
const selector = '[role="menuitem"][aria-label="Power"]';
|
|
1548
1547
|
const timeoutAt = Date.now() + MODEL_CONFIGURATION_SETTLE_TIMEOUT_MS;
|
|
1549
1548
|
let moves = 0;
|
|
1549
|
+
let focused = false;
|
|
1550
1550
|
while (Date.now() < timeoutAt && moves < tiers.length) {
|
|
1551
1551
|
const state = await evalPage(job, toJsonScript(`
|
|
1552
1552
|
const control = document.querySelector(${JSON.stringify(selector)});
|
|
@@ -1566,6 +1566,10 @@ async function configurePowerSlider(job) {
|
|
|
1566
1566
|
if (state.min !== "0" || state.max !== "4" || typeof state.value !== "string" || !Number.isInteger(value) || value < 0 || value > 4) {
|
|
1567
1567
|
throw new Error("ChatGPT Power slider has an unrecognized range; refusing to guess model settings");
|
|
1568
1568
|
}
|
|
1569
|
+
if (!focused) {
|
|
1570
|
+
await agentBrowser(job, "focus", powerRef);
|
|
1571
|
+
focused = true;
|
|
1572
|
+
}
|
|
1569
1573
|
if (value === target) {
|
|
1570
1574
|
if (!state.description.startsWith(`${requested}, ${target + 1} of ${tiers.length}.`)) {
|
|
1571
1575
|
throw new Error(`Could not verify ChatGPT Power tier: ${requested}`);
|
|
@@ -1592,8 +1596,9 @@ async function configureModel(job) {
|
|
|
1592
1596
|
|
|
1593
1597
|
await log(`Configuring model family=${job.selection.modelFamily} effort=${job.selection?.effort || "(none)"}`);
|
|
1594
1598
|
let familySnapshot = await openModelConfiguration(job);
|
|
1595
|
-
|
|
1596
|
-
|
|
1599
|
+
const powerEntry = findEntry(familySnapshot, (entry) => entry.kind === "menuitem" && entry.label === "Power" && !entry.disabled);
|
|
1600
|
+
if (powerEntry) {
|
|
1601
|
+
await configurePowerSlider(job, powerEntry.ref);
|
|
1597
1602
|
return;
|
|
1598
1603
|
}
|
|
1599
1604
|
let verificationSnapshot = familySnapshot;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-oracle",
|
|
3
|
-
"version": "0.7.
|
|
3
|
+
"version": "0.7.22",
|
|
4
4
|
"description": "ChatGPT and Grok web-oracle extension for pi with isolated browser auth, async jobs, and project-context archives.",
|
|
5
5
|
"private": false,
|
|
6
6
|
"license": "MIT",
|
|
@@ -38,6 +38,7 @@
|
|
|
38
38
|
"scripts/platform-smoke",
|
|
39
39
|
"scripts/oracle-real-smoke.mjs",
|
|
40
40
|
"scripts/oracle-auth.test.mjs",
|
|
41
|
+
"scripts/oracle-cookie-import.test.mjs",
|
|
41
42
|
"scripts/oracle-model.test.mjs",
|
|
42
43
|
"scripts/oracle-chatgpt-preset-proof.mjs"
|
|
43
44
|
],
|
|
@@ -50,10 +51,10 @@
|
|
|
50
51
|
]
|
|
51
52
|
},
|
|
52
53
|
"scripts": {
|
|
53
|
-
"check:oracle-extension": "node --check extensions/oracle/shared/browser-profile-helpers.mjs && node --check extensions/oracle/shared/process-helpers.mjs && node --check extensions/oracle/shared/state-path-helpers.mjs && node --check extensions/oracle/shared/time-helpers.mjs && node --check extensions/oracle/shared/state-coordination-helpers.mjs && node --check extensions/oracle/shared/job-coordination-helpers.mjs && node --check extensions/oracle/shared/job-lifecycle-helpers.mjs && node --check extensions/oracle/shared/job-observability-helpers.mjs && node --check extensions/oracle/worker/run-job.mjs && node --check extensions/oracle/worker/state-locks.mjs && node --check extensions/oracle/worker/artifact-heuristics.mjs && node --check extensions/oracle/worker/chatgpt-ui-helpers.mjs && node --check extensions/oracle/worker/chatgpt-flow-helpers.mjs && node --check extensions/oracle/worker/auth-flow-helpers.mjs && node --check extensions/oracle/worker/auth-cookie-policy.mjs && node --check extensions/oracle/worker/chromium-cookie-source.mjs && node --check extensions/oracle/worker/auth-bootstrap.mjs && esbuild extensions/oracle/index.ts --bundle --platform=node --format=esm --external:@earendil-works/pi-ai --external:@earendil-works/pi-coding-agent --external:typebox --outfile=/tmp/pi-oracle-extension-check.js",
|
|
54
|
+
"check:oracle-extension": "node --check extensions/oracle/shared/browser-profile-helpers.mjs && node --check extensions/oracle/shared/process-helpers.mjs && node --check extensions/oracle/shared/state-path-helpers.mjs && node --check extensions/oracle/shared/time-helpers.mjs && node --check extensions/oracle/shared/state-coordination-helpers.mjs && node --check extensions/oracle/shared/job-coordination-helpers.mjs && node --check extensions/oracle/shared/job-lifecycle-helpers.mjs && node --check extensions/oracle/shared/job-observability-helpers.mjs && node --check extensions/oracle/worker/run-job.mjs && node --check extensions/oracle/worker/state-locks.mjs && node --check extensions/oracle/worker/artifact-heuristics.mjs && node --check extensions/oracle/worker/chatgpt-ui-helpers.mjs && node --check extensions/oracle/worker/chatgpt-flow-helpers.mjs && node --check extensions/oracle/worker/auth-flow-helpers.mjs && node --check extensions/oracle/worker/auth-cookie-policy.mjs && node --check extensions/oracle/worker/chromium-cookie-source.mjs && node --check extensions/oracle/worker/linux-chromium-cookie-source.mjs && node --check extensions/oracle/worker/auth-bootstrap.mjs && esbuild extensions/oracle/index.ts --bundle --platform=node --format=esm --external:@earendil-works/pi-ai --external:@earendil-works/pi-coding-agent --external:typebox --outfile=/tmp/pi-oracle-extension-check.js",
|
|
54
55
|
"typecheck": "tsc --noEmit -p tsconfig.json",
|
|
55
56
|
"typecheck:worker-helpers": "tsc --noEmit -p tsconfig.worker-helpers.json",
|
|
56
|
-
"sanity:oracle": "node --test scripts/oracle-auth.test.mjs scripts/oracle-model.test.mjs && node scripts/oracle-sanity-runner.mjs",
|
|
57
|
+
"sanity:oracle": "node --test scripts/oracle-auth.test.mjs scripts/oracle-cookie-import.test.mjs scripts/oracle-model.test.mjs && node scripts/oracle-sanity-runner.mjs",
|
|
57
58
|
"pack:check": "npm pack --dry-run",
|
|
58
59
|
"verify:oracle": "npm run check:oracle-extension && npm run check:platform-smoke && npm run check:oracle-real-smoke && npm run check:oracle-release-proof && npm run typecheck && npm run typecheck:worker-helpers && npm run sanity:oracle && npm run pack:check",
|
|
59
60
|
"test": "npm run verify:oracle",
|
|
@@ -74,7 +75,7 @@
|
|
|
74
75
|
"release:proof:chatgpt-presets": "node scripts/oracle-chatgpt-preset-proof.mjs check",
|
|
75
76
|
"smoke:real:packed": "node scripts/oracle-real-smoke.mjs run --mode packed",
|
|
76
77
|
"smoke:real:source": "node scripts/oracle-real-smoke.mjs run --mode source",
|
|
77
|
-
"sanity:oracle:platform": "node --test scripts/oracle-auth.test.mjs scripts/oracle-model.test.mjs && node scripts/oracle-sanity-runner.mjs --mode platform",
|
|
78
|
+
"sanity:oracle:platform": "node --test scripts/oracle-auth.test.mjs scripts/oracle-cookie-import.test.mjs scripts/oracle-model.test.mjs && node scripts/oracle-sanity-runner.mjs --mode platform",
|
|
78
79
|
"verify:oracle:platform": "npm run check:oracle-extension && npm run check:platform-smoke && npm run check:oracle-real-smoke && npm run check:oracle-release-proof && npm run sanity:oracle:platform && npm run pack:check"
|
|
79
80
|
},
|
|
80
81
|
"dependencies": {
|
|
@@ -86,17 +87,17 @@
|
|
|
86
87
|
"typebox": "*"
|
|
87
88
|
},
|
|
88
89
|
"overrides": {
|
|
89
|
-
"basic-ftp": "6.
|
|
90
|
-
"protobufjs": "7.6.
|
|
90
|
+
"basic-ftp": "6.2.1",
|
|
91
|
+
"protobufjs": "7.6.6"
|
|
91
92
|
},
|
|
92
93
|
"devDependencies": {
|
|
93
|
-
"@earendil-works/pi-ai": "0.
|
|
94
|
-
"@earendil-works/pi-coding-agent": "0.
|
|
94
|
+
"@earendil-works/pi-ai": "1.0.0",
|
|
95
|
+
"@earendil-works/pi-coding-agent": "1.0.0",
|
|
95
96
|
"@types/node": "^22.19.19",
|
|
96
97
|
"esbuild": "^0.28.0",
|
|
97
98
|
"tsx": "^4.22.3",
|
|
98
|
-
"typebox": "
|
|
99
|
-
"typescript": "^
|
|
99
|
+
"typebox": "1.3.34",
|
|
100
|
+
"typescript": "^7.0.0"
|
|
100
101
|
},
|
|
101
102
|
"engines": {
|
|
102
103
|
"node": ">=22.19.0"
|
|
@@ -106,7 +107,7 @@
|
|
|
106
107
|
"linux",
|
|
107
108
|
"win32"
|
|
108
109
|
],
|
|
109
|
-
"packageManager": "npm@11.
|
|
110
|
+
"packageManager": "npm@11.21.0",
|
|
110
111
|
"peerDependenciesMeta": {
|
|
111
112
|
"@earendil-works/pi-ai": {
|
|
112
113
|
"optional": true
|
|
@@ -6,33 +6,12 @@ export default {
|
|
|
6
6
|
artifactRoot: ".artifacts/platform-smoke",
|
|
7
7
|
requiredTargets: ["macos", "ubuntu", "windows-native"],
|
|
8
8
|
requiredSuites: ["platform-build", "real-extension"],
|
|
9
|
-
workflows: {
|
|
10
|
-
everyday: {
|
|
11
|
-
description: "Fast local validation for normal iteration.",
|
|
12
|
-
commands: ["npm run verify:oracle"],
|
|
13
|
-
},
|
|
14
|
-
platformSensitive: {
|
|
15
|
-
description: "Doctor plus focused platform target/suite runs for platform-sensitive changes.",
|
|
16
|
-
commands: [
|
|
17
|
-
"npm run smoke:platform:doctor",
|
|
18
|
-
"node scripts/platform-smoke.mjs run --target <target> --suite <suite>",
|
|
19
|
-
],
|
|
20
|
-
},
|
|
21
|
-
platformMatrix: {
|
|
22
|
-
description: "Doctor-first packed-install macOS/Ubuntu/Windows platform proof.",
|
|
23
|
-
commands: ["npm run smoke:platform:all"],
|
|
24
|
-
},
|
|
25
|
-
release: {
|
|
26
|
-
description: "Full release gate: local verification, fresh ChatGPT preset proof, plus the doctor-first platform matrix.",
|
|
27
|
-
commands: ["npm run release:check"],
|
|
28
|
-
},
|
|
29
|
-
},
|
|
30
9
|
requiredCrabbox: {
|
|
31
10
|
source: "https://github.com/openclaw/crabbox",
|
|
32
11
|
minVersion: "0.26.0",
|
|
33
12
|
},
|
|
34
13
|
ubuntuContainerImage: "pi-oracle-platform-smoke:node24",
|
|
35
|
-
ubuntuContainerBaseImage: "cimg/node:24.
|
|
14
|
+
ubuntuContainerBaseImage: "cimg/node:24.21",
|
|
36
15
|
windowsParallels: {
|
|
37
16
|
sourceVm: "pi-extension-windows-template",
|
|
38
17
|
snapshot: "crabbox-ready",
|
|
@@ -46,6 +46,41 @@ function pollingHarness(overrides = {}) {
|
|
|
46
46
|
return { poll, logs, get captures() { return captures; } };
|
|
47
47
|
}
|
|
48
48
|
|
|
49
|
+
test("auth launch waits for a closing daemon only on the known pre-launch socket failure", async () => {
|
|
50
|
+
const socketFailure = "Could not configure browser: Failed to connect: No such file or directory (os error 2)";
|
|
51
|
+
for (const [failures, message, succeeds] of [
|
|
52
|
+
[1, socketFailure, true],
|
|
53
|
+
[Infinity, socketFailure, false],
|
|
54
|
+
[1, "Chrome exited before startup", false],
|
|
55
|
+
]) {
|
|
56
|
+
let now = 0;
|
|
57
|
+
let launches = 0;
|
|
58
|
+
const launch = vm.runInNewContext(`(${functionSource(bootstrap, "launchTargetBrowser")})`, {
|
|
59
|
+
Date: { now: () => now }, Math, Error, JSON,
|
|
60
|
+
AGENT_BROWSER_BIN: "agent-browser", AGENT_BROWSER_COMMAND_TIMEOUT_MS: 500,
|
|
61
|
+
closeTargetBrowser: async () => {},
|
|
62
|
+
targetBrowserBaseArgs: () => ["--session", "fixture-auth", "--profile", "/fixture/seed"],
|
|
63
|
+
log: async () => {},
|
|
64
|
+
sleep: async (ms) => { now += ms; },
|
|
65
|
+
spawnCommand: async (_command, args, options) => {
|
|
66
|
+
assert.equal(args.at(-1), "about:blank");
|
|
67
|
+
assert(options.timeoutMs > 0 && options.timeoutMs <= 500);
|
|
68
|
+
return ++launches <= failures
|
|
69
|
+
? { code: 1, stdout: "", stderr: message }
|
|
70
|
+
: { code: 0, stdout: "Browser opened", stderr: "" };
|
|
71
|
+
},
|
|
72
|
+
});
|
|
73
|
+
if (succeeds) {
|
|
74
|
+
await launch();
|
|
75
|
+
assert.equal(launches, 2);
|
|
76
|
+
} else {
|
|
77
|
+
await assert.rejects(launch(), (error) => error.message === message);
|
|
78
|
+
if (message === socketFailure) assert(now <= 500 && launches > 1, "persistent socket failure must stop at the launch deadline");
|
|
79
|
+
else assert.equal(launches, 1, "other launch failures must not be retried");
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
});
|
|
83
|
+
|
|
49
84
|
test("auth polling retries a complete observation after each navigation-interrupted read", async () => {
|
|
50
85
|
for (const read of ["getUrl", "snapshotText", "pageText", "loginProbe"]) {
|
|
51
86
|
let calls = 0;
|
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
|
|
7
7
|
import { execFileSync } from "node:child_process";
|
|
8
8
|
import { existsSync, readFileSync } from "node:fs";
|
|
9
|
+
import { createRequire } from "node:module";
|
|
9
10
|
import { dirname, resolve } from "node:path";
|
|
10
11
|
import { fileURLToPath } from "node:url";
|
|
11
12
|
|
|
@@ -69,18 +70,24 @@ function currentGitStatus() {
|
|
|
69
70
|
}
|
|
70
71
|
|
|
71
72
|
function canonicalPresets() {
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
const
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
autoSwitchToThinking:
|
|
83
|
-
|
|
73
|
+
// Derive the canonical registry by importing the real config module so a
|
|
74
|
+
// formatting change in config.ts can never silently drop a preset from the
|
|
75
|
+
// release proof's required set.
|
|
76
|
+
const tsx = createRequire(import.meta.url).resolve("tsx");
|
|
77
|
+
const stdout = execFileSync(process.execPath, [
|
|
78
|
+
"--import", tsx,
|
|
79
|
+
"--input-type=module",
|
|
80
|
+
"--eval",
|
|
81
|
+
"const mod = await import('./extensions/oracle/lib/config.ts');" +
|
|
82
|
+
"const presets = Object.fromEntries(Object.entries(mod.ORACLE_SUBMIT_PRESETS).map(([id, preset]) => [id, {" +
|
|
83
|
+
"modelFamily: preset.modelFamily, effort: preset.effort, autoSwitchToThinking: preset.autoSwitchToThinking }]));" +
|
|
84
|
+
"process.stdout.write(JSON.stringify(presets));",
|
|
85
|
+
], { cwd: REPO_ROOT, encoding: "utf8" });
|
|
86
|
+
const presets = JSON.parse(stdout);
|
|
87
|
+
if (!presets || typeof presets !== "object" || Object.keys(presets).length === 0) {
|
|
88
|
+
throw new Error("ORACLE_SUBMIT_PRESETS registry resolved to no presets");
|
|
89
|
+
}
|
|
90
|
+
return presets;
|
|
84
91
|
}
|
|
85
92
|
|
|
86
93
|
function canonicalPresetIds() {
|