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.
Files changed (30) hide show
  1. package/CHANGELOG.md +11 -0
  2. package/README.md +49 -385
  3. package/docs/ORACLE_DESIGN.md +1 -1
  4. package/docs/development.md +99 -0
  5. package/docs/platform-smoke.md +3 -3
  6. package/docs/reference.md +331 -0
  7. package/extensions/oracle/index.ts +1 -0
  8. package/extensions/oracle/lib/poller.ts +3 -8
  9. package/extensions/oracle/lib/queue.ts +2 -4
  10. package/extensions/oracle/shared/browser-profile-helpers.d.mts +1 -0
  11. package/extensions/oracle/shared/browser-profile-helpers.mjs +32 -0
  12. package/extensions/oracle/worker/auth-bootstrap.mjs +35 -6
  13. package/extensions/oracle/worker/chromium-cookie-source.mjs +50 -6
  14. package/extensions/oracle/worker/linux-chromium-cookie-source.d.mts +6 -0
  15. package/extensions/oracle/worker/linux-chromium-cookie-source.mjs +98 -0
  16. package/extensions/oracle/worker/run-job.mjs +10 -5
  17. package/package.json +12 -11
  18. package/platform-smoke.config.mjs +1 -22
  19. package/scripts/oracle-auth.test.mjs +35 -0
  20. package/scripts/oracle-chatgpt-preset-proof.mjs +19 -12
  21. package/scripts/oracle-cookie-import.test.mjs +204 -0
  22. package/scripts/oracle-model.test.mjs +5 -1
  23. package/scripts/oracle-real-smoke.mjs +17 -7
  24. package/scripts/platform-smoke/Dockerfile.ubuntu +3 -2
  25. package/scripts/platform-smoke/crabbox-runner.mjs +3 -5
  26. package/scripts/platform-smoke/doctor.mjs +9 -24
  27. package/scripts/platform-smoke/invariants.mjs +234 -22
  28. package/scripts/platform-smoke/platform-build-windows.ps1 +4 -16
  29. package/scripts/platform-smoke/targets.mjs +35 -27
  30. 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 result = await spawnCommand(AGENT_BROWSER_BIN, args, { allowFailure: true, timeoutMs: AGENT_BROWSER_COMMAND_TIMEOUT_MS });
344
- await log(`launch result: code=${result.code} stdout=${JSON.stringify(result.stdout)} stderr=${JSON.stringify(result.stderr)}`);
345
- if (result.code !== 0) {
346
- throw new Error(result.stderr || result.stdout || "Failed to launch isolated oracle browser");
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
- return await getCookies({
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 arbitrary macOS Chromium-family cookie stores when sweet-cookie's built-in browser list is too narrow.
2
- // Responsibilities: Snapshot a Chromium Cookies SQLite DB, decrypt AES-CBC cookie values with a configured Keychain item, and return sweet-cookie-shaped cookie objects.
3
- // Scope: macOS Chromium cookie extraction only; auth policy filtering and browser seeding stay in auth-bootstrap.mjs.
4
- // Usage: auth-bootstrap.mjs uses this when auth.chromiumKeychain is configured alongside auth.chromeCookiePath.
5
- // Invariants/Assumptions: The configured cookie path points at a Chromium Cookies DB and the configured Keychain item is the browser's safe-storage secret.
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,6 @@
1
+ import type { GetCookiesOptions, GetCookiesResult } from "@steipete/sweet-cookie";
2
+
3
+ export function getCookiesFromNativeLinuxChromium(
4
+ options: GetCookiesOptions,
5
+ readCookies: typeof import("@steipete/sweet-cookie").getCookies,
6
+ ): Promise<GetCookiesResult>;
@@ -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 = '[data-testid="composer-intelligence-picker-content"] [role="menuitem"][aria-label="Power"]';
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
- if (findEntry(familySnapshot, (entry) => entry.kind === "menuitem" && entry.label === "Power" && !entry.disabled)) {
1596
- await configurePowerSlider(job);
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.21",
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.0.1",
90
- "protobufjs": "7.6.1"
90
+ "basic-ftp": "6.2.1",
91
+ "protobufjs": "7.6.6"
91
92
  },
92
93
  "devDependencies": {
93
- "@earendil-works/pi-ai": "0.86.1",
94
- "@earendil-works/pi-coding-agent": "0.86.1",
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": "^1.1.39",
99
- "typescript": "^6.0.3"
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.16.0",
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.16",
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
- const configSource = readFileSync(resolve(REPO_ROOT, "extensions/oracle/lib/config.ts"), "utf8");
73
- const registryMatch = configSource.match(/export const ORACLE_SUBMIT_PRESETS = \{([\s\S]*?)\n\} as const;/);
74
- if (!registryMatch) throw new Error("Could not locate ORACLE_SUBMIT_PRESETS registry in extensions/oracle/lib/config.ts");
75
- const entries = [...registryMatch[1].matchAll(
76
- /^\s{2}([a-z0-9_]+):\s*\{\s*label:\s*"[^"]+",\s*modelFamily:\s*"([a-z]+)"\s+as const(?:,\s*effort:\s*"([a-z]+)"\s+as const)?,\s*autoSwitchToThinking:\s*(true|false)\s*\}/gm,
77
- )];
78
- if (entries.length === 0) throw new Error("Could not parse ORACLE_SUBMIT_PRESETS registry entries");
79
- return Object.fromEntries(entries.map((match) => [match[1], {
80
- modelFamily: match[2],
81
- effort: match[3],
82
- autoSwitchToThinking: match[4] === "true",
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() {