pi-oracle 0.7.21 → 0.7.23

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 (32) hide show
  1. package/CHANGELOG.md +40 -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 +18 -7
  6. package/docs/reference.md +338 -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/chatgpt-ui-helpers.d.mts +1 -0
  14. package/extensions/oracle/worker/chatgpt-ui-helpers.mjs +18 -13
  15. package/extensions/oracle/worker/chromium-cookie-source.mjs +50 -6
  16. package/extensions/oracle/worker/linux-chromium-cookie-source.d.mts +6 -0
  17. package/extensions/oracle/worker/linux-chromium-cookie-source.mjs +98 -0
  18. package/extensions/oracle/worker/run-job.mjs +34 -34
  19. package/package.json +12 -11
  20. package/platform-smoke.config.mjs +1 -22
  21. package/scripts/oracle-auth.test.mjs +35 -0
  22. package/scripts/oracle-chatgpt-preset-proof.mjs +50 -21
  23. package/scripts/oracle-cookie-import.test.mjs +204 -0
  24. package/scripts/oracle-model.test.mjs +24 -17
  25. package/scripts/oracle-real-smoke.mjs +30 -9
  26. package/scripts/platform-smoke/Dockerfile.ubuntu +3 -2
  27. package/scripts/platform-smoke/crabbox-runner.mjs +18 -6
  28. package/scripts/platform-smoke/doctor.mjs +58 -41
  29. package/scripts/platform-smoke/invariants.mjs +254 -26
  30. package/scripts/platform-smoke/platform-build-windows.ps1 +21 -17
  31. package/scripts/platform-smoke/targets.mjs +59 -30
  32. package/scripts/platform-smoke.mjs +47 -14
@@ -26,7 +26,7 @@ import {
26
26
  CHATGPT_COMPOSER_LABELS,
27
27
  deriveAssistantCompletionSignature,
28
28
  matchesCompactIntelligenceControlLabel,
29
- matchesCompactIntelligenceOpenerLabel,
29
+ matchesModelConfigurationOpener,
30
30
  matchesModelFamilyLabel,
31
31
  matchesRequestedModelControlLabel,
32
32
  requestedEffortLabel,
@@ -58,7 +58,7 @@ const jobPath = `${jobDir}/job.json`;
58
58
  const CHATGPT_LABELS = {
59
59
  composer: "Chat with ChatGPT",
60
60
  addFiles: "Add files and more",
61
- send: "Send prompt",
61
+ send: ["Send prompt", "Send"],
62
62
  close: "Close",
63
63
  autoSwitchToThinking: "Auto-switch to Thinking",
64
64
  configure: "Configure...",
@@ -66,7 +66,7 @@ const CHATGPT_LABELS = {
66
66
  const GROK_LABELS = {
67
67
  composer: "Ask Grok anything",
68
68
  addFiles: "Attach",
69
- send: "Submit",
69
+ send: ["Submit"],
70
70
  modelSelect: "Model select",
71
71
  stop: "Stop model response",
72
72
  };
@@ -916,18 +916,6 @@ function matchesRequestedModelControl(candidate, selection, options = {}) {
916
916
  return matchesRequestedModelControlLabel(candidate.label, selection);
917
917
  }
918
918
 
919
- function matchesModelConfigurationOpener(candidate) {
920
- if (candidate.kind !== "button" || typeof candidate.label !== "string" || candidate.disabled) return false;
921
- const label = String(candidate.label || "");
922
- return candidate.label === "Model"
923
- || candidate.label === "Model selector"
924
- || matchesCompactIntelligenceOpenerLabel(label)
925
- || /^(?:Light|Standard|Extended|Heavy)(?:, click to remove)?$/i.test(label)
926
- || ["instant", "thinking", "pro"].some((family) => matchesModelFamilyLabel(label, /** @type {import("./chatgpt-ui-helpers.d.mts").OracleUiModelFamily} */ (family)))
927
- || /^(?:(?:Light|Standard|Extended|Heavy) )?Thinking(?:, click to remove)?$/i.test(label)
928
- || /^(?:(?:Light|Standard|Extended|Heavy) )?Pro(?:, click to remove)?$/i.test(label);
929
- }
930
-
931
919
  function canUseOpenModelMenuForSelection(snapshot, selection) {
932
920
  if (selection.modelFamily !== "instant" || selection.autoSwitchToThinking === true) return false;
933
921
  return Boolean(findEntry(
@@ -1312,7 +1300,7 @@ async function waitForUploadConfirmed(job, fileLabel, baselineCount) {
1312
1300
  const labels = labelsForJob(job);
1313
1301
  const sendEntry = findEntry(
1314
1302
  snapshot,
1315
- (candidate) => candidate.kind === "button" && candidate.label === labels.send && !candidate.disabled,
1303
+ (candidate) => candidate.kind === "button" && labels.send.includes(candidate.label || "") && !candidate.disabled,
1316
1304
  );
1317
1305
  const fileCount = isGrokJob(job) && snapshot.includes(fileLabel)
1318
1306
  ? baselineCount + 1
@@ -1346,12 +1334,12 @@ async function waitForSendReady(job) {
1346
1334
  const labels = labelsForJob(job);
1347
1335
  const entry = findEntry(
1348
1336
  snapshot,
1349
- (candidate) => candidate.kind === "button" && candidate.label === labels.send && !candidate.disabled,
1337
+ (candidate) => candidate.kind === "button" && labels.send.includes(candidate.label || "") && !candidate.disabled,
1350
1338
  );
1351
1339
  if (entry) return entry;
1352
1340
  await sleep(1000);
1353
1341
  }
1354
- throw new Error(`Timed out waiting for ${labelsForJob(job).send} to become enabled`);
1342
+ throw new Error(`Timed out waiting for ${labelsForJob(job).send.join(" or ")} to become enabled`);
1355
1343
  }
1356
1344
 
1357
1345
  async function activateSendButton(job) {
@@ -1360,12 +1348,12 @@ async function activateSendButton(job) {
1360
1348
  const buttons = Array.from(document.querySelectorAll('button'));
1361
1349
  const button = buttons.find((candidate) => {
1362
1350
  const label = (candidate.getAttribute('aria-label') || candidate.textContent || '').trim();
1363
- return label === labels.send;
1351
+ return labels.send.includes(label);
1364
1352
  });
1365
1353
  if (!button) return { ok: false, reason: 'send button not found' };
1366
1354
  if (button.disabled || button.getAttribute('aria-disabled') === 'true') return { ok: false, reason: 'send button disabled' };
1367
1355
  button.click();
1368
- return { ok: true };
1356
+ return { ok: true, label: (button.getAttribute('aria-label') || button.textContent || '').trim() };
1369
1357
  `));
1370
1358
  return result;
1371
1359
  }
@@ -1380,7 +1368,7 @@ async function sendAcceptanceState(job, baselineAssistantCount) {
1380
1368
  url: urlResult.url,
1381
1369
  urlKnown: urlResult.ok,
1382
1370
  assistantCount: Math.max(baselineAssistantCount, messages.length),
1383
- stopStreaming: isGrokJob(job) ? snapshot.includes(GROK_LABELS.stop) : snapshot.includes("Stop streaming"),
1371
+ stopStreaming: isGrokJob(job) ? snapshot.includes(GROK_LABELS.stop) : snapshot.includes("Stop streaming") || snapshot.includes('button "Stop"'),
1384
1372
  transientErrorText: detectProviderVisibleBlockerText(snapshot) || "",
1385
1373
  };
1386
1374
  }
@@ -1389,12 +1377,13 @@ async function clickSend(job, baselineAssistantCount) {
1389
1377
  await waitForSendReady(job);
1390
1378
  const beforeSend = await sendAcceptanceState(job, baselineAssistantCount);
1391
1379
  const activation = await activateSendButton(job);
1392
- if (!activation?.ok) throw new Error(`Could not activate ${labelsForJob(job).send}: ${activation?.reason || "DOM activation failed"}`);
1393
- await log(`Activated ${labelsForJob(job).send}; waiting for provider acceptance evidence`);
1380
+ const sendLabel = activation?.label || labelsForJob(job).send.join(" or ");
1381
+ if (!activation?.ok) throw new Error(`Could not activate ${sendLabel}: ${activation?.reason || "DOM activation failed"}`);
1382
+ await log(`Activated ${sendLabel}; waiting for provider acceptance evidence`);
1394
1383
  if (await waitForSendAccepted(job, beforeSend, { timeoutMs: 20_000 })) return;
1395
1384
 
1396
1385
  await captureDiagnostics(job, "send-not-accepted");
1397
- throw new Error(`${isGrokJob(job) ? "Grok" : "ChatGPT"} message did not leave the composer after activating ${labelsForJob(job).send}`);
1386
+ throw new Error(`${isGrokJob(job) ? "Grok" : "ChatGPT"} message did not leave the composer after activating ${sendLabel}`);
1398
1387
  }
1399
1388
 
1400
1389
  async function waitForSendAccepted(job, beforeSend, options = {}) {
@@ -1534,7 +1523,7 @@ async function waitForModelConfigurationToSettle(job, options = {}) {
1534
1523
  throw new Error(`Could not verify requested model settings after configuration for ${job.selection.modelFamily}`);
1535
1524
  }
1536
1525
 
1537
- async function configurePowerSlider(job) {
1526
+ async function configurePowerSlider(job, powerRef) {
1538
1527
  // The current picker retains the compact tier semantics, but exposes keyboard
1539
1528
  // interaction on a Power menuitem rather than accessible tier radio buttons.
1540
1529
  const tiers = ["Instant", "Medium", "High", "Extra High", "Pro"];
@@ -1543,10 +1532,10 @@ async function configurePowerSlider(job) {
1543
1532
  : job.selection.effort === "heavy" ? "Extra High"
1544
1533
  : job.selection.effort === "extended" ? "High" : "Medium";
1545
1534
  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);
1535
+ const selector = '[role="menuitem"][aria-label="Power"]';
1548
1536
  const timeoutAt = Date.now() + MODEL_CONFIGURATION_SETTLE_TIMEOUT_MS;
1549
1537
  let moves = 0;
1538
+ let focused = false;
1550
1539
  while (Date.now() < timeoutAt && moves < tiers.length) {
1551
1540
  const state = await evalPage(job, toJsonScript(`
1552
1541
  const control = document.querySelector(${JSON.stringify(selector)});
@@ -1566,6 +1555,10 @@ async function configurePowerSlider(job) {
1566
1555
  if (state.min !== "0" || state.max !== "4" || typeof state.value !== "string" || !Number.isInteger(value) || value < 0 || value > 4) {
1567
1556
  throw new Error("ChatGPT Power slider has an unrecognized range; refusing to guess model settings");
1568
1557
  }
1558
+ if (!focused) {
1559
+ await agentBrowser(job, "focus", powerRef);
1560
+ focused = true;
1561
+ }
1569
1562
  if (value === target) {
1570
1563
  if (!state.description.startsWith(`${requested}, ${target + 1} of ${tiers.length}.`)) {
1571
1564
  throw new Error(`Could not verify ChatGPT Power tier: ${requested}`);
@@ -1592,8 +1585,9 @@ async function configureModel(job) {
1592
1585
 
1593
1586
  await log(`Configuring model family=${job.selection.modelFamily} effort=${job.selection?.effort || "(none)"}`);
1594
1587
  let familySnapshot = await openModelConfiguration(job);
1595
- if (findEntry(familySnapshot, (entry) => entry.kind === "menuitem" && entry.label === "Power" && !entry.disabled)) {
1596
- await configurePowerSlider(job);
1588
+ const powerEntry = findEntry(familySnapshot, (entry) => entry.kind === "menuitem" && entry.label === "Power" && !entry.disabled);
1589
+ if (powerEntry) {
1590
+ await configurePowerSlider(job, powerEntry.ref);
1597
1591
  return;
1598
1592
  }
1599
1593
  let verificationSnapshot = familySnapshot;
@@ -1776,7 +1770,7 @@ async function assistantMessages(job) {
1776
1770
  job,
1777
1771
  toJsonScript(`
1778
1772
  const headings = Array.from(document.querySelectorAll('h1,h2,h3,h4,h5,h6,[role="heading"]'))
1779
- .filter((el) => (el.textContent || '').trim() === 'ChatGPT said:');
1773
+ .filter((el) => el.getAttribute('data-conversation-role') === 'assistant' || (el.textContent || '').trim() === 'ChatGPT said:');
1780
1774
  const renderText = (node) => {
1781
1775
  if (!node) return '';
1782
1776
  const clone = node.cloneNode(true);
@@ -1802,7 +1796,10 @@ async function assistantMessages(job) {
1802
1796
  .trim();
1803
1797
  return text;
1804
1798
  };
1805
- const headingMessages = headings.map((heading) => ({ text: renderText(heading.nextElementSibling) }));
1799
+ const headingMessages = headings.map((heading) => ({
1800
+ text: renderText(heading.nextElementSibling),
1801
+ turnComplete: heading.closest('[data-talvt-turn-state]')?.getAttribute('data-talvt-turn-state') === 'complete',
1802
+ }));
1806
1803
  const messageNodes = Array.from(document.querySelectorAll('[data-testid="assistant-message"], [data-message-author-role="assistant"]'));
1807
1804
  const nodeMessages = messageNodes.map((node) => ({ text: renderText(node) }));
1808
1805
  return {
@@ -1812,7 +1809,10 @@ async function assistantMessages(job) {
1812
1809
  );
1813
1810
 
1814
1811
  if (!Array.isArray(result?.messages)) return [];
1815
- return result.messages.map((message) => ({ text: typeof message?.text === "string" ? message.text : "" }));
1812
+ return result.messages.map((message) => ({
1813
+ text: typeof message?.text === "string" ? message.text : "",
1814
+ turnComplete: message?.turnComplete === true,
1815
+ }));
1816
1816
  }
1817
1817
 
1818
1818
  async function grokAssistantMessages(job) {
@@ -1881,7 +1881,7 @@ async function waitForChatCompletion(job, baselineAssistantCount) {
1881
1881
  while (Date.now() < timeoutAt) {
1882
1882
  await heartbeat();
1883
1883
  const [snapshot, body] = await Promise.all([snapshotText(job), pageText(job).catch(() => "")]);
1884
- const hasStopStreaming = isGrokJob(job) ? snapshot.includes(GROK_LABELS.stop) : snapshot.includes("Stop streaming");
1884
+ const hasStopStreaming = isGrokJob(job) ? snapshot.includes(GROK_LABELS.stop) : snapshot.includes("Stop streaming") || snapshot.includes('button "Stop"');
1885
1885
  const hasRetryButton = snapshot.includes('button "Retry"');
1886
1886
  const copyResponseCount = isGrokJob(job) ? (snapshot.match(/button "Copy"/g) || []).length : (snapshot.match(/Copy response/g) || []).length;
1887
1887
  throwIfProviderTransientError(job, snapshot, "waiting for response completion");
@@ -1889,7 +1889,7 @@ async function waitForChatCompletion(job, baselineAssistantCount) {
1889
1889
  const messages = await assistantMessages(job);
1890
1890
  const targetMessage = messages[baselineAssistantCount];
1891
1891
  const targetText = targetMessage?.text || "";
1892
- const hasTargetCopyResponse = copyResponseCount > baselineAssistantCount;
1892
+ const hasTargetCopyResponse = copyResponseCount > baselineAssistantCount || targetMessage?.turnComplete === true;
1893
1893
 
1894
1894
  if (!hasStopStreaming && hasRetryButton && responseFailureText) {
1895
1895
  if (!retriedAfterFailure) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-oracle",
3
- "version": "0.7.21",
3
+ "version": "0.7.23",
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.3",
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
 
@@ -60,8 +61,25 @@ function currentGitHead() {
60
61
  return git(["rev-parse", "HEAD"]);
61
62
  }
62
63
 
63
- function currentGitHeadCommittedAt() {
64
- return git(["show", "-s", "--format=%cI", "HEAD"]);
64
+ function gitCommitCommittedAt(ref = "HEAD") {
65
+ return git(["show", "-s", "--format=%cI", ref]);
66
+ }
67
+
68
+ function isRuntimeIdenticalAncestor(candidateHead, currentHead) {
69
+ if (typeof candidateHead !== "string" || !/^[0-9a-f]{40}$/i.test(candidateHead)) return false;
70
+ if (candidateHead === currentHead) return true;
71
+ try {
72
+ execFileSync("git", ["merge-base", "--is-ancestor", candidateHead, currentHead], { cwd: REPO_ROOT, stdio: "ignore" });
73
+ execFileSync("git", ["diff", "--exit-code", candidateHead, currentHead, "--", "extensions", "prompts"], { cwd: REPO_ROOT, stdio: "ignore" });
74
+ return true;
75
+ } catch {
76
+ return false;
77
+ }
78
+ }
79
+
80
+ function packageMetadataAtCommit(ref) {
81
+ const pkg = JSON.parse(git(["show", `${ref}:package.json`]));
82
+ return { name: pkg.name, version: pkg.version };
65
83
  }
66
84
 
67
85
  function currentGitStatus() {
@@ -69,18 +87,24 @@ function currentGitStatus() {
69
87
  }
70
88
 
71
89
  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
- }]));
90
+ // Derive the canonical registry by importing the real config module so a
91
+ // formatting change in config.ts can never silently drop a preset from the
92
+ // release proof's required set.
93
+ const tsx = createRequire(import.meta.url).resolve("tsx");
94
+ const stdout = execFileSync(process.execPath, [
95
+ "--import", tsx,
96
+ "--input-type=module",
97
+ "--eval",
98
+ "const mod = await import('./extensions/oracle/lib/config.ts');" +
99
+ "const presets = Object.fromEntries(Object.entries(mod.ORACLE_SUBMIT_PRESETS).map(([id, preset]) => [id, {" +
100
+ "modelFamily: preset.modelFamily, effort: preset.effort, autoSwitchToThinking: preset.autoSwitchToThinking }]));" +
101
+ "process.stdout.write(JSON.stringify(presets));",
102
+ ], { cwd: REPO_ROOT, encoding: "utf8" });
103
+ const presets = JSON.parse(stdout);
104
+ if (!presets || typeof presets !== "object" || Object.keys(presets).length === 0) {
105
+ throw new Error("ORACLE_SUBMIT_PRESETS registry resolved to no presets");
106
+ }
107
+ return presets;
84
108
  }
85
109
 
86
110
  function canonicalPresetIds() {
@@ -223,8 +247,6 @@ function validateProof(proof, path) {
223
247
  const errors = [];
224
248
  const { name, version } = packageMetadata();
225
249
  const gitHead = currentGitHead();
226
- const gitHeadCommittedAt = currentGitHeadCommittedAt();
227
- const gitHeadCommittedAtMs = Date.parse(gitHeadCommittedAt);
228
250
  const gitStatus = currentGitStatus();
229
251
  const presetRegistry = canonicalPresets();
230
252
  const requiredPresets = Object.keys(presetRegistry);
@@ -239,10 +261,17 @@ function validateProof(proof, path) {
239
261
  return errors;
240
262
  }
241
263
 
264
+ const proofGitHead = typeof proof.gitHead === "string" ? proof.gitHead : "";
265
+ const validProofHead = isRuntimeIdenticalAncestor(proofGitHead, gitHead);
266
+ const expectedProofHead = validProofHead ? proofGitHead : gitHead;
267
+ const expectedVersion = validProofHead ? packageMetadataAtCommit(expectedProofHead).version : version;
268
+ const gitHeadCommittedAt = gitCommitCommittedAt(expectedProofHead);
269
+ const gitHeadCommittedAtMs = Date.parse(gitHeadCommittedAt);
270
+
242
271
  if (proof.schemaVersion !== 1) errors.push("schemaVersion must be 1");
243
272
  if (proof.packageName !== name) errors.push(`packageName must be ${name}`);
244
- if (proof.packageVersion !== version) errors.push(`packageVersion must match package.json version ${version}`);
245
- if (proof.gitHead !== gitHead) errors.push(`gitHead must match current HEAD ${gitHead}`);
273
+ if (proof.packageVersion !== expectedVersion) errors.push(`packageVersion must match package.json version ${expectedVersion}`);
274
+ if (!validProofHead) errors.push(`gitHead must match current HEAD ${gitHead} or a runtime-identical ancestor commit`);
246
275
  if (proof.provider !== "chatgpt") errors.push('provider must be "chatgpt"');
247
276
  if (proof.extensionUnderTest !== "loaded-extension") errors.push('extensionUnderTest must be "loaded-extension"');
248
277
 
@@ -255,7 +284,7 @@ function validateProof(proof, path) {
255
284
  const maxAgeMs = maxAgeHours() * 60 * 60 * 1000;
256
285
  if (ageMs < 0) errors.push("validatedAt must not be in the future");
257
286
  if (ageMs > maxAgeMs) errors.push(`validatedAt is older than ${maxAgeHours()} hours`);
258
- if (proofValidatedAtMs <= gitHeadCommittedAtMs) errors.push("validatedAt must be after current git HEAD commit time");
287
+ if (proofValidatedAtMs <= gitHeadCommittedAtMs) errors.push("validatedAt must be after proven git commit time");
259
288
  }
260
289
 
261
290
  const jobs = proof.jobs;
@@ -270,8 +299,8 @@ function validateProof(proof, path) {
270
299
  canonicalPreset: presetRegistry[preset],
271
300
  proofJob: jobs[preset],
272
301
  packageName: name,
273
- packageVersion: version,
274
- gitHead,
302
+ packageVersion: expectedVersion,
303
+ gitHead: expectedProofHead,
275
304
  gitHeadCommittedAtMs,
276
305
  proofValidatedAtMs,
277
306
  errors,