pi-oracle 0.7.3 → 0.7.5

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 (29) hide show
  1. package/CHANGELOG.md +27 -0
  2. package/README.md +51 -17
  3. package/docs/ORACLE_DESIGN.md +12 -5
  4. package/docs/platform-smoke.md +153 -0
  5. package/extensions/oracle/lib/config.ts +53 -27
  6. package/extensions/oracle/lib/jobs.ts +9 -5
  7. package/extensions/oracle/lib/runtime.ts +107 -32
  8. package/extensions/oracle/lib/tools.ts +138 -12
  9. package/extensions/oracle/shared/browser-profile-helpers.d.mts +59 -0
  10. package/extensions/oracle/shared/browser-profile-helpers.mjs +395 -0
  11. package/extensions/oracle/shared/process-helpers.mjs +12 -1
  12. package/extensions/oracle/shared/state-coordination-helpers.mjs +8 -2
  13. package/extensions/oracle/worker/auth-bootstrap.mjs +39 -10
  14. package/extensions/oracle/worker/chatgpt-ui-helpers.d.mts +2 -0
  15. package/extensions/oracle/worker/chatgpt-ui-helpers.mjs +157 -1
  16. package/extensions/oracle/worker/chromium-cookie-source.mjs +2 -1
  17. package/extensions/oracle/worker/run-job.mjs +107 -25
  18. package/package.json +40 -11
  19. package/platform-smoke.config.mjs +59 -0
  20. package/scripts/oracle-real-smoke.mjs +497 -0
  21. package/scripts/platform-smoke/Dockerfile.ubuntu +8 -0
  22. package/scripts/platform-smoke/artifacts.mjs +87 -0
  23. package/scripts/platform-smoke/assertions.mjs +34 -0
  24. package/scripts/platform-smoke/crabbox-runner.mjs +135 -0
  25. package/scripts/platform-smoke/doctor.mjs +239 -0
  26. package/scripts/platform-smoke/invariants.mjs +108 -0
  27. package/scripts/platform-smoke/platform-build-windows.ps1 +168 -0
  28. package/scripts/platform-smoke/targets.mjs +434 -0
  29. package/scripts/platform-smoke.mjs +149 -0
@@ -29,11 +29,15 @@ const AUTO_SWITCH_LABEL = "Auto-switch to Thinking";
29
29
  const THINKING_EFFORT_COMBOBOX_LABEL = "Thinking effort";
30
30
  const PRO_THINKING_EFFORT_COMBOBOX_LABEL = "Pro thinking effort";
31
31
  const EFFORT_LABELS = new Set(["Light", "Standard", "Extended", "Heavy"]);
32
+ const COMPACT_INTELLIGENCE_MENU_PATTERN = /Intelligence.*Instant.*Medium.*High.*Pro/i;
33
+ const COMPACT_INTELLIGENCE_CONTROL_PATTERN = /^(?:Instant(?:\s+5s)?|Medium(?:\s+5\s*[–-]\s*30s)?|High(?:\s+15\s*[–-]\s*60s)?|Pro(?:\s+5\+\s*min)?)$/i;
34
+ const COMPACT_INTELLIGENCE_OPENER_PATTERN = /^(?:Instant|Medium|High|Pro)$/i;
32
35
  const BARE_EFFORT_PATTERN = /^(light|standard|extended|heavy)(?:, click to remove)?$/i;
33
36
  const INSTANT_CHIP_PATTERN = /^instant(?:, click to remove)?$/i;
34
37
  const THINKING_CHIP_PATTERN = /^(?:(light|standard|extended|heavy)\s+)?thinking(?:, click to remove)?$/i;
35
38
  const PRO_CHIP_PATTERN = /^(?:(light|standard|extended|heavy)\s+)?pro(?:, click to remove)?$/i;
36
39
  const MODEL_FAMILY_CONTROL_KINDS = new Set(["button", "radio", "menuitemradio"]);
40
+ const COMPACT_INTELLIGENCE_CONTROL_KINDS = new Set(["menuitemradio"]);
37
41
 
38
42
  /**
39
43
  * @param {string | undefined} url
@@ -151,9 +155,134 @@ function parseComposerChipSelection(label) {
151
155
  return undefined;
152
156
  }
153
157
 
158
+ function parseCompactIntelligenceSelection(label) {
159
+ if (/click to remove/i.test(String(label || ""))) return undefined;
160
+ const normalized = normalizeChipLabel(label);
161
+ if (!COMPACT_INTELLIGENCE_CONTROL_PATTERN.test(normalized)) return undefined;
162
+
163
+ if (/^Instant(?:\s+5s)?$/i.test(normalized)) {
164
+ return {
165
+ modelFamily: /** @type {OracleUiModelFamily} */ ("instant"),
166
+ compactTier: "instant",
167
+ };
168
+ }
169
+ if (/^Medium(?:\s+5\s*[–-]\s*30s)?$/i.test(normalized)) {
170
+ return {
171
+ modelFamily: /** @type {OracleUiModelFamily} */ ("thinking"),
172
+ effort: /** @type {import("./chatgpt-ui-helpers.d.mts").OracleUiEffort} */ ("standard"),
173
+ compactTier: "medium",
174
+ };
175
+ }
176
+ if (/^High(?:\s+15\s*[–-]\s*60s)?$/i.test(normalized)) {
177
+ return {
178
+ modelFamily: /** @type {OracleUiModelFamily} */ ("thinking"),
179
+ effort: /** @type {import("./chatgpt-ui-helpers.d.mts").OracleUiEffort} */ ("extended"),
180
+ compactTier: "high",
181
+ };
182
+ }
183
+ if (/^Pro(?:\s+5\+\s*min)?$/i.test(normalized)) {
184
+ return {
185
+ modelFamily: /** @type {OracleUiModelFamily} */ ("pro"),
186
+ compactTier: "pro",
187
+ };
188
+ }
189
+
190
+ return undefined;
191
+ }
192
+
193
+ function hasRemovableComposerModelChip(entries) {
194
+ return entries.some(
195
+ (entry) => entry.kind === "button" && /click to remove/i.test(String(entry.label || "")) && parseComposerChipSelection(entry.label),
196
+ );
197
+ }
198
+
199
+ function hasCompactIntelligenceMenuContext(entries) {
200
+ return entries.some((entry) => !entry.disabled && entry.kind === "menu" && COMPACT_INTELLIGENCE_MENU_PATTERN.test(normalizeText(entry.label)))
201
+ || entries.some((entry) => !entry.disabled && entry.kind === "menuitemradio" && checkedState(entry) === true && /\d/.test(String(entry.label || "")) && parseCompactIntelligenceSelection(entry.label));
202
+ }
203
+
204
+ function hasLegacyEffortCombobox(entries) {
205
+ return entries.some((entry) => {
206
+ if (entry.disabled || entry.kind !== "combobox") return false;
207
+ const label = normalizeText(entry.label).toLowerCase();
208
+ return label === THINKING_EFFORT_COMBOBOX_LABEL.toLowerCase() || label === PRO_THINKING_EFFORT_COMBOBOX_LABEL.toLowerCase();
209
+ });
210
+ }
211
+
212
+ function compactSelectionFromEntry(entry, _entries, _options = {}) {
213
+ if (entry.disabled || !COMPACT_INTELLIGENCE_CONTROL_KINDS.has(entry.kind || "")) return undefined;
214
+ if (!/\d/.test(String(entry.label || ""))) return undefined;
215
+ return parseCompactIntelligenceSelection(entry.label);
216
+ }
217
+
218
+ function compactSelectionMatchesRequested(selection, compactSelection) {
219
+ if (!compactSelection || compactSelection.modelFamily !== selection.modelFamily) return false;
220
+
221
+ if (selection.modelFamily === "instant") {
222
+ // The compact Intelligence picker has no explicit auto-switch toggle. Treat
223
+ // Instant 5s as the closest available target for both instant presets.
224
+ return compactSelection.compactTier === "instant";
225
+ }
226
+
227
+ if (selection.modelFamily === "pro") {
228
+ // The compact picker exposes one Pro tier instead of separate Pro efforts.
229
+ return compactSelection.compactTier === "pro";
230
+ }
231
+
232
+ if (selection.modelFamily === "thinking") {
233
+ const requestedEffort = selection.effort || "standard";
234
+ if (compactSelection.compactTier === "medium") return requestedEffort === "light" || requestedEffort === "standard";
235
+ if (compactSelection.compactTier === "high") return requestedEffort === "extended" || requestedEffort === "heavy";
236
+ }
237
+
238
+ return false;
239
+ }
240
+
241
+ function compactSelectionMatchesRequestedInSnapshot(snapshot, selection, compactSelection, { weak = false } = {}) {
242
+ if (!compactSelectionMatchesRequested(selection, compactSelection)) return false;
243
+ if (selection.modelFamily !== "instant") return true;
244
+
245
+ const autoSwitchState = autoSwitchToThinkingSelectionVisible(snapshot);
246
+ if (autoSwitchState === undefined) return true;
247
+ if (weak) return selection.autoSwitchToThinking ? autoSwitchState !== false : autoSwitchState !== true;
248
+ return selection.autoSwitchToThinking ? autoSwitchState === true : autoSwitchState !== true;
249
+ }
250
+
251
+ function detectCompactIntelligenceSelection(entries) {
252
+ if (hasRemovableComposerModelChip(entries)) return undefined;
253
+ if (hasLegacyEffortCombobox(entries)) return undefined;
254
+
255
+ for (const entry of entries) {
256
+ if (entry.kind !== "menuitemradio" || checkedState(entry) !== true) continue;
257
+ const compactSelection = compactSelectionFromEntry(entry, entries, { allowClosedButtons: false });
258
+ if (compactSelection) return compactSelection;
259
+ }
260
+
261
+ if (hasCompactIntelligenceMenuContext(entries)) return undefined;
262
+
263
+ for (const entry of entries) {
264
+ if (entry.kind !== "button") continue;
265
+ const compactSelection = compactSelectionFromEntry(entry, entries);
266
+ if (!compactSelection) continue;
267
+ return compactSelection;
268
+ }
269
+ return undefined;
270
+ }
271
+
272
+ export function matchesRequestedModelControlLabel(label, selection) {
273
+ const compactSelection = parseCompactIntelligenceSelection(label);
274
+ if (compactSelection) return compactSelectionMatchesRequested(selection, compactSelection);
275
+ return matchesModelFamilyLabel(label, selection.modelFamily);
276
+ }
277
+
278
+ export function matchesCompactIntelligenceOpenerLabel(label) {
279
+ return COMPACT_INTELLIGENCE_OPENER_PATTERN.test(normalizeChipLabel(label));
280
+ }
281
+
154
282
  function detectComposerChipSelection(entries) {
155
283
  for (const entry of entries) {
156
284
  if (entry.disabled || entry.kind !== "button") continue;
285
+ if (/\bexpanded=true\b/.test(String(entry.line || "")) && !/click to remove/i.test(String(entry.label || ""))) continue;
157
286
  const selection = parseComposerChipSelection(entry.label);
158
287
  if (selection) return selection;
159
288
  }
@@ -168,6 +297,9 @@ function checkedState(entry) {
168
297
  }
169
298
 
170
299
  function detectSelectedModelFamily(entries) {
300
+ const compactSelection = detectCompactIntelligenceSelection(entries);
301
+ if (compactSelection) return compactSelection.modelFamily;
302
+
171
303
  for (const entry of entries) {
172
304
  if (entry.disabled || !MODEL_FAMILY_CONTROL_KINDS.has(entry.kind || "") || checkedState(entry) !== true) continue;
173
305
  for (const family of /** @type {OracleUiModelFamily[]} */ (["instant", "thinking", "pro"])) {
@@ -213,8 +345,15 @@ export function effortSelectionVisible(snapshot, effortLabel) {
213
345
  /** @type {SnapshotEntry[]} */
214
346
  const entries = parseSnapshotEntries(snapshot);
215
347
  const normalizedEffort = effortLabel.toLowerCase();
348
+ const compactClosedButtonsAllowed = !hasRemovableComposerModelChip(entries) && !hasLegacyEffortCombobox(entries) && !hasCompactIntelligenceMenuContext(entries);
216
349
  return entries.some((entry) => {
217
350
  if (entry.disabled) return false;
351
+ const compactSelection = compactSelectionFromEntry(entry, entries, { allowClosedButtons: compactClosedButtonsAllowed });
352
+ if (compactSelection && entry.kind === "menuitemradio" && checkedState(entry) !== true) return false;
353
+ if (compactSelection?.modelFamily === "thinking") {
354
+ return compactSelectionMatchesRequested({ modelFamily: "thinking", effort: /** @type {import("./chatgpt-ui-helpers.d.mts").OracleUiEffort} */ (normalizedEffort), autoSwitchToThinking: false }, compactSelection);
355
+ }
356
+ if (compactSelection?.modelFamily === "pro") return true;
218
357
  if (entry.kind === "combobox" && normalizeText(entry.value).toLowerCase() === normalizedEffort) return true;
219
358
  const chipSelection = entry.kind === "button" ? parseComposerChipSelection(entry.label) : undefined;
220
359
  if (chipSelection?.effort === normalizedEffort) return true;
@@ -255,12 +394,18 @@ export function snapshotHasModelConfigurationUi(snapshot) {
255
394
  .filter((family) => matchesModelFamilyLabel(entry.label, family)),
256
395
  ),
257
396
  );
397
+ const visibleCompactControls = entries.filter(
398
+ (entry) => !entry.disabled && entry.kind === "menuitemradio" && /\d/.test(String(entry.label || "")) && parseCompactIntelligenceSelection(entry.label),
399
+ );
400
+ const hasCompactIntelligenceMenu = entries.some(
401
+ (entry) => !entry.disabled && entry.kind === "menu" && COMPACT_INTELLIGENCE_MENU_PATTERN.test(normalizeText(entry.label)),
402
+ );
258
403
  const hasCloseButton = entries.some((entry) => entry.kind === "button" && entry.label === "Close" && !entry.disabled);
259
404
  const hasIntelligenceHeading = entries.some((entry) => entry.kind === "heading" && normalizeText(entry.label) === "Intelligence" && !entry.disabled);
260
405
  const hasEffortCombobox = entries.some(
261
406
  (entry) => entry.kind === "combobox" && EFFORT_LABELS.has(entry.value || "") && !entry.disabled,
262
407
  );
263
- return visibleFamilies.size >= 2 || visibleRadioFamilies.size >= 2 || hasCloseButton || hasIntelligenceHeading || hasEffortCombobox;
408
+ return visibleFamilies.size >= 2 || visibleRadioFamilies.size >= 2 || visibleCompactControls.length >= 2 || hasCompactIntelligenceMenu || hasCloseButton || hasIntelligenceHeading || hasEffortCombobox;
264
409
  }
265
410
 
266
411
  /**
@@ -287,6 +432,7 @@ export function snapshotHasModelOpener(snapshot) {
287
432
  const label = normalizeChipLabel(entry.label);
288
433
  return label === "Model"
289
434
  || label === "Model selector"
435
+ || COMPACT_INTELLIGENCE_OPENER_PATTERN.test(label)
290
436
  || EFFORT_LABELS.has(label)
291
437
  || ["instant", "thinking", "pro"].some((family) => matchesModelFamilyLabel(label, /** @type {OracleUiModelFamily} */ (family)))
292
438
  || THINKING_CHIP_PATTERN.test(label)
@@ -325,6 +471,10 @@ export function autoSwitchToThinkingSelectionVisible(snapshot) {
325
471
  */
326
472
  export function snapshotCanSafelySkipModelConfiguration(snapshot, selection) {
327
473
  if (!snapshotStronglyMatchesRequestedModel(snapshot, selection)) return false;
474
+ const hasBareProPill = selection.modelFamily === "pro" && parseSnapshotEntries(snapshot).some(
475
+ (entry) => entry.kind === "button" && !entry.disabled && normalizeChipLabel(entry.label) === "Pro",
476
+ );
477
+ if (hasBareProPill && !snapshotHasModelConfigurationUi(snapshot)) return false;
328
478
  if (selection.modelFamily === "instant" && selection.autoSwitchToThinking) {
329
479
  return autoSwitchToThinkingSelectionVisible(snapshot) === true;
330
480
  }
@@ -339,6 +489,9 @@ export function snapshotCanSafelySkipModelConfiguration(snapshot, selection) {
339
489
  export function snapshotStronglyMatchesRequestedModel(snapshot, selection) {
340
490
  /** @type {SnapshotEntry[]} */
341
491
  const entries = parseSnapshotEntries(snapshot);
492
+ const compactSelection = detectCompactIntelligenceSelection(entries);
493
+ if (compactSelection) return compactSelectionMatchesRequestedInSnapshot(snapshot, selection, compactSelection);
494
+
342
495
  const chipSelection = detectComposerChipSelection(entries);
343
496
  if (chipSelection) return selectionMatchesChipSelection(selection, chipSelection);
344
497
 
@@ -366,6 +519,9 @@ export function snapshotStronglyMatchesRequestedModel(snapshot, selection) {
366
519
  export function snapshotWeaklyMatchesRequestedModel(snapshot, selection) {
367
520
  /** @type {SnapshotEntry[]} */
368
521
  const entries = parseSnapshotEntries(snapshot);
522
+ const compactSelection = detectCompactIntelligenceSelection(entries);
523
+ if (compactSelection) return compactSelectionMatchesRequestedInSnapshot(snapshot, selection, compactSelection, { weak: true });
524
+
369
525
  const chipSelection = detectComposerChipSelection(entries);
370
526
  if (chipSelection) return selectionMatchesChipSelection(selection, chipSelection);
371
527
 
@@ -4,6 +4,7 @@
4
4
  // Usage: auth-bootstrap.mjs uses this when auth.chromiumKeychain is configured alongside auth.chromeCookiePath.
5
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.
6
6
  import { spawn } from "node:child_process";
7
+ import { sweetCookieSafeStoragePasswordScrubbedEnv } from "../shared/browser-profile-helpers.mjs";
7
8
  import { createDecipheriv, pbkdf2Sync } from "node:crypto";
8
9
  import { copyFileSync, existsSync, mkdtempSync, rmSync } from "node:fs";
9
10
  import { tmpdir } from "node:os";
@@ -16,7 +17,7 @@ const MACOS_CHROMIUM_KEY_ITERATIONS = 1003;
16
17
 
17
18
  function spawnCapture(command, args, options = {}) {
18
19
  return new Promise((resolve) => {
19
- const child = spawn(command, args, { stdio: ["ignore", "pipe", "pipe"] });
20
+ const child = spawn(command, args, { env: sweetCookieSafeStoragePasswordScrubbedEnv(), stdio: ["ignore", "pipe", "pipe"], shell: process.platform === "win32" });
20
21
  let stdout = "";
21
22
  let stderr = "";
22
23
  const timeoutMs = options.timeoutMs ?? 5_000;
@@ -5,7 +5,7 @@
5
5
  // Invariants/Assumptions: Job state is persisted under worker-held locks, browser/session artifacts live under the configured oracle directories, and cleanup preserves durable recovery semantics.
6
6
  import { createHash, randomUUID } from "node:crypto";
7
7
  import { existsSync, readdirSync, readFileSync } from "node:fs";
8
- import { appendFile, chmod, mkdir, readFile, rename, rm, stat, writeFile } from "node:fs/promises";
8
+ import { appendFile, chmod, cp as copyDirectory, mkdir, readFile, rename, rm, stat, writeFile } from "node:fs/promises";
9
9
  import { basename, dirname, join } from "node:path";
10
10
  import { fileURLToPath } from "node:url";
11
11
  import { spawn } from "node:child_process";
@@ -23,7 +23,9 @@ import { extractArtifactLabels, FILE_LABEL_PATTERN_SOURCE, GENERIC_ARTIFACT_LABE
23
23
  import {
24
24
  buildAllowedChatGptOrigins,
25
25
  deriveAssistantCompletionSignature,
26
+ matchesCompactIntelligenceOpenerLabel,
26
27
  matchesModelFamilyLabel,
28
+ matchesRequestedModelControlLabel,
27
29
  requestedEffortLabel,
28
30
  effortSelectionVisible,
29
31
  snapshotCanSafelySkipModelConfiguration,
@@ -34,6 +36,7 @@ import {
34
36
  autoSwitchToThinkingSelectionVisible,
35
37
  } from "./chatgpt-ui-helpers.mjs";
36
38
  import { assistantSnapshotSlice, nextStableValueState, resolveStableConversationUrlCandidate, stripUrlQueryAndHash } from "./chatgpt-flow-helpers.mjs";
39
+ import { assertNotKnownBrowserUserDataPath, scrubSweetCookieSafeStoragePasswordEnv, sweetCookieSafeStoragePasswordScrubbedEnv } from "../shared/browser-profile-helpers.mjs";
37
40
  import { createLease, listLeaseMetadata, readLeaseMetadata, releaseLease, withLock } from "./state-locks.mjs";
38
41
 
39
42
  const jobId = process.argv[2];
@@ -80,6 +83,8 @@ const POST_SEND_SETTLE_MS = 15_000;
80
83
  const AGENT_BROWSER_BIN = [process.env.AGENT_BROWSER_PATH, "/opt/homebrew/bin/agent-browser", "/usr/local/bin/agent-browser"].find(
81
84
  (candidate) => typeof candidate === "string" && candidate && existsSync(candidate),
82
85
  ) || "agent-browser";
86
+ const CP_BIN = process.env.PI_ORACLE_CP_PATH?.trim() || "cp";
87
+ scrubSweetCookieSafeStoragePasswordEnv();
83
88
 
84
89
  let currentJob;
85
90
  let browserStarted = false;
@@ -209,12 +214,30 @@ function sleep(ms) {
209
214
  return new Promise((resolve) => setTimeout(resolve, ms));
210
215
  }
211
216
 
217
+ function killProcessTree(child) {
218
+ if (process.platform === "win32" && child.pid) {
219
+ spawn("taskkill", ["/pid", String(child.pid), "/t", "/f"], { stdio: "ignore", windowsHide: true }).on("error", () => undefined);
220
+ return;
221
+ }
222
+ child.kill("SIGTERM");
223
+ }
224
+
225
+ function killProcess(child) {
226
+ if (process.platform === "win32" && child.pid) {
227
+ spawn("taskkill", ["/pid", String(child.pid), "/f"], { stdio: "ignore", windowsHide: true }).on("error", () => undefined);
228
+ return;
229
+ }
230
+ child.kill("SIGKILL");
231
+ }
232
+
212
233
  function spawnCommand(command, args, options = {}) {
213
234
  return new Promise((resolve, reject) => {
214
235
  const { timeoutMs, ...spawnOptions } = options;
215
236
  const child = spawn(command, args, {
216
237
  stdio: ["pipe", "pipe", "pipe"],
217
238
  ...spawnOptions,
239
+ env: sweetCookieSafeStoragePasswordScrubbedEnv(spawnOptions.env),
240
+ shell: spawnOptions.shell ?? process.platform === "win32",
218
241
  });
219
242
  let stdout = "";
220
243
  let stderr = "";
@@ -223,8 +246,8 @@ function spawnCommand(command, args, options = {}) {
223
246
  if (typeof timeoutMs === "number" && timeoutMs > 0) {
224
247
  killTimer = setTimeout(() => {
225
248
  timedOut = true;
226
- child.kill("SIGTERM");
227
- setTimeout(() => child.kill("SIGKILL"), 2_000).unref?.();
249
+ killProcessTree(child);
250
+ setTimeout(() => killProcess(child), 2_000).unref?.();
228
251
  }, timeoutMs);
229
252
  killTimer.unref?.();
230
253
  }
@@ -274,8 +297,20 @@ async function removeChromiumProcessSingletonArtifacts(profileDir) {
274
297
  ]);
275
298
  }
276
299
 
300
+ function assertSafeRuntimeProfilePath(path, label, config = undefined) {
301
+ try {
302
+ assertNotKnownBrowserUserDataPath(path, label, {
303
+ cookieSources: config ? { chromeProfile: config.auth.chromeProfile, chromeCookiePath: config.auth.chromeCookiePath } : undefined,
304
+ });
305
+ } catch (error) {
306
+ throw new Error(`Oracle ${label} path is unsafe: ${path}. ${error instanceof Error ? error.message : String(error)}`);
307
+ }
308
+ }
309
+
277
310
  async function cloneSeedProfileToRuntime(job) {
278
311
  const seedDir = job.config.browser.authSeedProfileDir;
312
+ assertSafeRuntimeProfilePath(seedDir, "auth seed profile", job.config);
313
+ assertSafeRuntimeProfilePath(job.runtimeProfileDir, "runtime profile", job.config);
279
314
  if (!existsSync(seedDir)) {
280
315
  throw new Error(`Oracle auth seed profile not found: ${seedDir}. Run /oracle-auth first.`);
281
316
  }
@@ -286,17 +321,17 @@ async function cloneSeedProfileToRuntime(job) {
286
321
  await withLock(ORACLE_STATE_DIR, "auth", "global", { jobId: job.id, processPid: process.pid, action: "cloneSeedProfile" }, async () => {
287
322
  await rm(job.runtimeProfileDir, { recursive: true, force: true }).catch(() => undefined);
288
323
  await ensurePrivateDir(dirname(job.runtimeProfileDir));
289
- if (job.config.browser.cloneStrategy === "apfs-clone") {
324
+ if (job.config.browser.cloneStrategy === "apfs-clone" && process.platform === "darwin") {
290
325
  try {
291
- await spawnCommand("/bin/cp", ["-cR", seedDir, job.runtimeProfileDir], { timeoutMs: PROFILE_CLONE_TIMEOUT_MS });
326
+ await spawnCommand(CP_BIN, ["-cR", seedDir, job.runtimeProfileDir], { timeoutMs: PROFILE_CLONE_TIMEOUT_MS });
292
327
  } catch (error) {
293
328
  const message = error instanceof Error ? error.message : String(error);
294
329
  await log(`APFS clone copy failed; falling back to recursive copy: ${message}`);
295
330
  await rm(job.runtimeProfileDir, { recursive: true, force: true }).catch(() => undefined);
296
- await spawnCommand("/bin/cp", ["-R", seedDir, job.runtimeProfileDir], { timeoutMs: PROFILE_CLONE_TIMEOUT_MS });
331
+ await spawnCommand(CP_BIN, ["-R", seedDir, job.runtimeProfileDir], { timeoutMs: PROFILE_CLONE_TIMEOUT_MS });
297
332
  }
298
333
  } else {
299
- await spawnCommand("/bin/cp", ["-R", seedDir, job.runtimeProfileDir], { timeoutMs: PROFILE_CLONE_TIMEOUT_MS });
334
+ await copyDirectory(seedDir, job.runtimeProfileDir, { recursive: true, force: true, verbatimSymlinks: true });
300
335
  }
301
336
  await removeChromiumProcessSingletonArtifacts(job.runtimeProfileDir);
302
337
  }, 10 * 60 * 1000);
@@ -314,27 +349,28 @@ async function cleanupRuntime(job) {
314
349
  warnings.push(message);
315
350
  await log(message).catch(() => undefined);
316
351
  });
317
- await rm(job.runtimeProfileDir, { recursive: true, force: true }).catch(async (error) => {
352
+ try {
353
+ assertSafeRuntimeProfilePath(job.runtimeProfileDir, "runtime profile", job.config);
354
+ await rm(job.runtimeProfileDir, { recursive: true, force: true });
355
+ } catch (error) {
318
356
  const message = `Runtime profile cleanup warning: ${error instanceof Error ? error.message : String(error)}`;
319
357
  warnings.push(message);
320
358
  await log(message).catch(() => undefined);
321
- });
322
- if (warnings.length === 0) {
323
- await releaseLease(ORACLE_STATE_DIR, "conversation", job.conversationId).catch(async (error) => {
324
- const message = `Conversation lease cleanup warning: ${error instanceof Error ? error.message : String(error)}`;
325
- warnings.push(message);
326
- await log(message).catch(() => undefined);
327
- });
328
- await releaseLease(ORACLE_STATE_DIR, "runtime", job.runtimeId).catch(async (error) => {
329
- const message = `Runtime lease cleanup warning: ${error instanceof Error ? error.message : String(error)}`;
330
- warnings.push(message);
331
- await log(message).catch(() => undefined);
332
- });
333
359
  }
360
+ await releaseLease(ORACLE_STATE_DIR, "conversation", job.conversationId).catch(async (error) => {
361
+ const message = `Conversation lease cleanup warning: ${error instanceof Error ? error.message : String(error)}`;
362
+ warnings.push(message);
363
+ await log(message).catch(() => undefined);
364
+ });
365
+ await releaseLease(ORACLE_STATE_DIR, "runtime", job.runtimeId).catch(async (error) => {
366
+ const message = `Runtime lease cleanup warning: ${error instanceof Error ? error.message : String(error)}`;
367
+ warnings.push(message);
368
+ await log(message).catch(() => undefined);
369
+ });
334
370
  if (warnings.length === 0) {
335
371
  await log(`Cleanup summary: runtime ${job.runtimeId} released with no warnings`).catch(() => undefined);
336
372
  } else {
337
- await log(`Cleanup summary: runtime ${job.runtimeId} released with ${warnings.length} warning(s)`).catch(() => undefined);
373
+ await log(`Cleanup summary: runtime ${job.runtimeId} released after ${warnings.length} warning(s)`).catch(() => undefined);
338
374
  }
339
375
  return warnings;
340
376
  } finally {
@@ -723,11 +759,42 @@ function matchesModelFamilyControl(candidate, family) {
723
759
  return ["button", "radio", "menuitemradio"].includes(candidate.kind || "") && typeof candidate.label === "string" && matchesModelFamilyLabel(candidate.label, family) && !candidate.disabled;
724
760
  }
725
761
 
762
+ function normalizeSnapshotLabel(value) {
763
+ return String(value || "").replace(/\s+/g, " ").trim();
764
+ }
765
+
766
+ function snapshotHasLegacyEffortCombobox(snapshot) {
767
+ return Boolean(findEntry(snapshot, (candidate) => {
768
+ if (candidate.kind !== "combobox" || candidate.disabled) return false;
769
+ return /^(?:Thinking effort|Pro thinking effort)$/i.test(normalizeSnapshotLabel(candidate.label));
770
+ }));
771
+ }
772
+
773
+ function snapshotHasCompactIntelligenceMenuControls(snapshot) {
774
+ return Boolean(findEntry(snapshot, (candidate) => {
775
+ if (candidate.disabled) return false;
776
+ const label = normalizeSnapshotLabel(candidate.label);
777
+ return (candidate.kind === "menu" && /Intelligence.*Instant.*Medium.*High.*Pro/i.test(label))
778
+ || (candidate.kind === "menuitemradio" && /^(?:Instant\s+5s|Medium\s+5\s*[–-]\s*30s|High\s+15\s*[–-]\s*60s|Pro\s+5\+\s*min)$/i.test(label));
779
+ }));
780
+ }
781
+
782
+ function matchesRequestedModelControl(candidate, selection, options = {}) {
783
+ if (!["button", "radio", "menuitemradio"].includes(candidate.kind || "") || typeof candidate.label !== "string" || candidate.disabled) return false;
784
+ if (candidate.kind === "button") {
785
+ if (/\bexpanded=true\b/.test(String(candidate.line || ""))) return false;
786
+ if (options.ignoreCompactTierButtons && /^(?:Instant|Medium|High|Pro)$/i.test(candidate.label)) return false;
787
+ if (options.ignoreCompactOnlyButtons && /^(?:Medium|High)$/i.test(candidate.label)) return false;
788
+ }
789
+ return matchesRequestedModelControlLabel(candidate.label, selection);
790
+ }
791
+
726
792
  function matchesModelConfigurationOpener(candidate) {
727
793
  if (candidate.kind !== "button" || typeof candidate.label !== "string" || candidate.disabled) return false;
728
794
  const label = String(candidate.label || "");
729
795
  return candidate.label === "Model"
730
796
  || candidate.label === "Model selector"
797
+ || matchesCompactIntelligenceOpenerLabel(label)
731
798
  || /^(?:Light|Standard|Extended|Heavy)(?:, click to remove)?$/i.test(label)
732
799
  || ["instant", "thinking", "pro"].some((family) => matchesModelFamilyLabel(label, /** @type {import("./chatgpt-ui-helpers.d.mts").OracleUiModelFamily} */ (family)))
733
800
  || /^(?:(?:Light|Standard|Extended|Heavy) )?Thinking(?:, click to remove)?$/i.test(label)
@@ -1196,19 +1263,33 @@ async function configureModel(job) {
1196
1263
  let verificationSnapshot = familySnapshot;
1197
1264
 
1198
1265
  const alreadyConfiguredInUi = snapshotStronglyMatchesRequestedModel(familySnapshot, job.selection);
1199
- let familyEntry = findEntry(familySnapshot, (candidate) => matchesModelFamilyControl(candidate, job.selection.modelFamily));
1266
+ const legacyEffortComboboxVisible = snapshotHasLegacyEffortCombobox(familySnapshot);
1267
+ const familyAlreadySelectedInUi = !alreadyConfiguredInUi && legacyEffortComboboxVisible && snapshotWeaklyMatchesRequestedModel(familySnapshot, job.selection);
1268
+ const controlOptions = {
1269
+ ignoreCompactTierButtons: snapshotHasCompactIntelligenceMenuControls(familySnapshot),
1270
+ ignoreCompactOnlyButtons: legacyEffortComboboxVisible,
1271
+ };
1272
+ let familyEntry = alreadyConfiguredInUi || familyAlreadySelectedInUi
1273
+ ? undefined
1274
+ : findEntry(familySnapshot, (candidate) => matchesRequestedModelControl(candidate, job.selection, controlOptions));
1200
1275
  if (alreadyConfiguredInUi) {
1201
1276
  await log("Model configuration UI opened with requested settings already selected");
1277
+ } else if (familyAlreadySelectedInUi) {
1278
+ await log("Model family already appears selected; verifying effort-specific settings");
1202
1279
  } else if (!familyEntry) {
1203
1280
  throw new Error(`Could not find model family control for ${job.selection.modelFamily}`);
1204
1281
  }
1205
1282
 
1206
- if (!alreadyConfiguredInUi && familyEntry) {
1283
+ if (!alreadyConfiguredInUi && !familyAlreadySelectedInUi && familyEntry) {
1207
1284
  await clickRef(job, familyEntry.ref);
1208
1285
  await agentBrowser(job, "wait", "800");
1209
1286
  familySnapshot = await snapshotText(job);
1210
1287
  verificationSnapshot = familySnapshot;
1211
- familyEntry = findEntry(familySnapshot, (candidate) => matchesModelFamilyControl(candidate, job.selection.modelFamily));
1288
+ const postClickControlOptions = {
1289
+ ignoreCompactTierButtons: snapshotHasCompactIntelligenceMenuControls(familySnapshot),
1290
+ ignoreCompactOnlyButtons: snapshotHasLegacyEffortCombobox(familySnapshot),
1291
+ };
1292
+ familyEntry = findEntry(familySnapshot, (candidate) => matchesRequestedModelControl(candidate, job.selection, postClickControlOptions));
1212
1293
  if (!familyEntry && !snapshotStronglyMatchesRequestedModel(familySnapshot, job.selection)) {
1213
1294
  throw new Error(`Requested model family did not remain selected: ${job.selection.modelFamily}`);
1214
1295
  }
@@ -1240,7 +1321,8 @@ async function configureModel(job) {
1240
1321
  if (job.selection.modelFamily === "instant") {
1241
1322
  const desiredAutoSwitchState = job.selection.autoSwitchToThinking === true;
1242
1323
  const currentAutoSwitchState = autoSwitchToThinkingSelectionVisible(familySnapshot);
1243
- if (currentAutoSwitchState !== desiredAutoSwitchState && (desiredAutoSwitchState || currentAutoSwitchState === true)) {
1324
+ const compactInstantAlreadyVerified = desiredAutoSwitchState && currentAutoSwitchState === undefined && snapshotStronglyMatchesRequestedModel(familySnapshot, job.selection);
1325
+ if (!compactInstantAlreadyVerified && currentAutoSwitchState !== desiredAutoSwitchState && (desiredAutoSwitchState || currentAutoSwitchState === true)) {
1244
1326
  await clickAutoSwitchToThinkingControl(job);
1245
1327
  await agentBrowser(job, "wait", "400");
1246
1328
  verificationSnapshot = await snapshotText(job);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-oracle",
3
- "version": "0.7.3",
3
+ "version": "0.7.5",
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",
@@ -32,7 +32,11 @@
32
32
  "docs",
33
33
  "README.md",
34
34
  "CHANGELOG.md",
35
- "LICENSE"
35
+ "LICENSE",
36
+ "platform-smoke.config.mjs",
37
+ "scripts/platform-smoke.mjs",
38
+ "scripts/platform-smoke",
39
+ "scripts/oracle-real-smoke.mjs"
36
40
  ],
37
41
  "pi": {
38
42
  "extensions": [
@@ -43,14 +47,29 @@
43
47
  ]
44
48
  },
45
49
  "scripts": {
46
- "check:oracle-extension": "node --check extensions/oracle/shared/process-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-coding-agent --external:@earendil-works/pi-ai --external:typebox --outfile=/tmp/pi-oracle-extension-check.js",
50
+ "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-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-coding-agent --external:@earendil-works/pi-ai --external:typebox --outfile=/tmp/pi-oracle-extension-check.js",
47
51
  "typecheck": "tsc --noEmit -p tsconfig.json",
48
52
  "typecheck:worker-helpers": "tsc --noEmit -p tsconfig.worker-helpers.json",
49
53
  "sanity:oracle": "node scripts/oracle-sanity-runner.mjs",
50
54
  "pack:check": "npm pack --dry-run",
51
- "verify:oracle": "npm run check:oracle-extension && npm run typecheck && npm run typecheck:worker-helpers && npm run sanity:oracle && npm run pack:check",
55
+ "verify:oracle": "npm run check:oracle-extension && npm run check:platform-smoke && npm run check:oracle-real-smoke && npm run typecheck && npm run typecheck:worker-helpers && npm run sanity:oracle && npm run pack:check",
52
56
  "test": "npm run verify:oracle",
53
- "prepublishOnly": "npm run verify:oracle"
57
+ "prepublishOnly": "npm run release:check",
58
+ "check:platform-smoke": "node --check scripts/platform-smoke.mjs && node --check scripts/platform-smoke/assertions.mjs && node --check scripts/platform-smoke/artifacts.mjs && node --check scripts/platform-smoke/crabbox-runner.mjs && node --check scripts/platform-smoke/doctor.mjs && node --check scripts/platform-smoke/targets.mjs && node scripts/platform-smoke/invariants.mjs",
59
+ "smoke:platform": "node scripts/platform-smoke.mjs",
60
+ "smoke:platform:doctor": "node scripts/platform-smoke.mjs doctor",
61
+ "smoke:platform:ubuntu": "node scripts/platform-smoke.mjs run --target ubuntu",
62
+ "smoke:platform:all": "npm run smoke:platform:doctor && node scripts/platform-smoke.mjs run --target macos,ubuntu,windows-native",
63
+ "smoke:platform:macos": "node scripts/platform-smoke.mjs run --target macos",
64
+ "smoke:platform:windows-native": "node scripts/platform-smoke.mjs run --target windows-native",
65
+ "smoke:real": "npm run smoke:real:packed",
66
+ "smoke:real:doctor": "node scripts/oracle-real-smoke.mjs doctor",
67
+ "release:check": "npm run verify:oracle && npm run smoke:platform:all",
68
+ "check:oracle-real-smoke": "node --check scripts/oracle-real-smoke.mjs",
69
+ "smoke:real:packed": "node scripts/oracle-real-smoke.mjs run --mode packed",
70
+ "smoke:real:source": "node scripts/oracle-real-smoke.mjs run --mode source",
71
+ "sanity:oracle:platform": "node scripts/oracle-sanity-runner.mjs --mode platform",
72
+ "verify:oracle:platform": "npm run check:oracle-extension && npm run check:platform-smoke && npm run check:oracle-real-smoke && npm run sanity:oracle:platform && npm run pack:check"
54
73
  },
55
74
  "dependencies": {
56
75
  "@steipete/sweet-cookie": "^0.3.0"
@@ -64,19 +83,29 @@
64
83
  "protobufjs": "7.6.1"
65
84
  },
66
85
  "devDependencies": {
67
- "@earendil-works/pi-ai": "^0.76.0",
68
- "@earendil-works/pi-coding-agent": "^0.76.0",
86
+ "@earendil-works/pi-ai": "^0.77.0",
87
+ "@earendil-works/pi-coding-agent": "^0.77.0",
69
88
  "@types/node": "^25.9.1",
70
89
  "esbuild": "^0.28.0",
71
90
  "tsx": "^4.22.3",
72
- "typebox": "^1.1.38",
91
+ "typebox": "^1.1.39",
73
92
  "typescript": "^6.0.3"
74
93
  },
75
94
  "engines": {
76
- "node": ">=22.19.0 <25"
95
+ "node": ">=22.19.0"
77
96
  },
78
97
  "os": [
79
- "darwin"
98
+ "darwin",
99
+ "linux",
100
+ "win32"
80
101
  ],
81
- "packageManager": "npm@11.0.0"
102
+ "packageManager": "npm@11.16.0",
103
+ "peerDependenciesMeta": {
104
+ "@earendil-works/pi-coding-agent": {
105
+ "optional": true
106
+ },
107
+ "typebox": {
108
+ "optional": true
109
+ }
110
+ }
82
111
  }
@@ -0,0 +1,59 @@
1
+ // Platform smoke configuration for pi-oracle.
2
+ // Crabbox is used as the local cross-platform release/readiness gate.
3
+
4
+ export default {
5
+ packageName: "pi-oracle",
6
+ artifactRoot: ".artifacts/platform-smoke",
7
+ requiredTargets: ["macos", "ubuntu", "windows-native"],
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
+ release: {
22
+ description: "Doctor-first packed-install macOS/Ubuntu/Windows release proof.",
23
+ commands: ["npm run smoke:platform:all"],
24
+ },
25
+ },
26
+ requiredCrabbox: {
27
+ source: "https://github.com/openclaw/crabbox",
28
+ minVersion: "0.24.0",
29
+ },
30
+ ubuntuContainerImage: "pi-oracle-platform-smoke:node24",
31
+ ubuntuContainerBaseImage: "cimg/node:24.16",
32
+ windowsParallels: {
33
+ sourceVm: "pi-extension-windows-template",
34
+ snapshot: "crabbox-ready",
35
+ },
36
+ nodeValidationMajor: 24,
37
+ realSmoke: {
38
+ defaultProvider: "zai",
39
+ defaultModel: "glm-5.1",
40
+ authEnvByProvider: {
41
+ zai: ["ZAI_API_KEY"],
42
+ openai: ["OPENAI_API_KEY"],
43
+ anthropic: ["ANTHROPIC_API_KEY", "ANTHROPIC_OAUTH_TOKEN"],
44
+ google: ["GEMINI_API_KEY"],
45
+ xai: ["XAI_API_KEY"],
46
+ groq: ["GROQ_API_KEY"],
47
+ deepseek: ["DEEPSEEK_API_KEY"],
48
+ cerebras: ["CEREBRAS_API_KEY"],
49
+ fireworks: ["FIREWORKS_API_KEY"],
50
+ together: ["TOGETHER_API_KEY"],
51
+ openrouter: ["OPENROUTER_API_KEY"],
52
+ ai_gateway: ["AI_GATEWAY_API_KEY"],
53
+ mistral: ["MISTRAL_API_KEY"],
54
+ minimax: ["MINIMAX_API_KEY"],
55
+ moonshot: ["MOONSHOT_API_KEY"],
56
+ kimi: ["KIMI_API_KEY"],
57
+ },
58
+ },
59
+ };