pi-oracle 0.7.20 → 0.7.22
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +25 -0
- package/README.md +49 -377
- package/docs/ORACLE_DESIGN.md +5 -2
- package/docs/ORACLE_ISOLATED_PI_VALIDATION.md +9 -7
- package/docs/development.md +99 -0
- package/docs/platform-smoke.md +3 -3
- package/docs/reference.md +331 -0
- package/extensions/oracle/index.ts +1 -0
- package/extensions/oracle/lib/poller.ts +3 -8
- package/extensions/oracle/lib/queue.ts +2 -4
- package/extensions/oracle/shared/browser-profile-helpers.d.mts +1 -0
- package/extensions/oracle/shared/browser-profile-helpers.mjs +32 -0
- package/extensions/oracle/worker/auth-bootstrap.mjs +65 -12
- package/extensions/oracle/worker/auth-flow-helpers.d.mts +2 -0
- package/extensions/oracle/worker/auth-flow-helpers.mjs +24 -3
- package/extensions/oracle/worker/chatgpt-flow-helpers.mjs +4 -1
- package/extensions/oracle/worker/chatgpt-ui-helpers.d.mts +1 -0
- package/extensions/oracle/worker/chatgpt-ui-helpers.mjs +7 -3
- package/extensions/oracle/worker/chromium-cookie-source.mjs +50 -6
- package/extensions/oracle/worker/linux-chromium-cookie-source.d.mts +6 -0
- package/extensions/oracle/worker/linux-chromium-cookie-source.mjs +98 -0
- package/extensions/oracle/worker/run-job.mjs +72 -7
- package/package.json +15 -11
- package/platform-smoke.config.mjs +1 -22
- package/scripts/oracle-auth.test.mjs +164 -0
- package/scripts/oracle-chatgpt-preset-proof.mjs +19 -12
- package/scripts/oracle-cookie-import.test.mjs +204 -0
- package/scripts/oracle-model.test.mjs +87 -0
- package/scripts/oracle-real-smoke.mjs +30 -13
- package/scripts/platform-smoke/Dockerfile.ubuntu +3 -2
- package/scripts/platform-smoke/crabbox-runner.mjs +3 -5
- package/scripts/platform-smoke/doctor.mjs +9 -24
- package/scripts/platform-smoke/invariants.mjs +234 -22
- package/scripts/platform-smoke/platform-build-windows.ps1 +4 -16
- package/scripts/platform-smoke/targets.mjs +35 -27
- package/scripts/platform-smoke.mjs +2 -2
|
@@ -23,6 +23,7 @@ import { getOracleJobsDir } from "../shared/state-path-helpers.mjs";
|
|
|
23
23
|
import { extractArtifactLabels, FILE_LABEL_PATTERN_SOURCE, GENERIC_ARTIFACT_LABELS, parseSnapshotEntries, partitionStructuralArtifactCandidates } from "./artifact-heuristics.mjs";
|
|
24
24
|
import {
|
|
25
25
|
buildAllowedChatGptOrigins,
|
|
26
|
+
CHATGPT_COMPOSER_LABELS,
|
|
26
27
|
deriveAssistantCompletionSignature,
|
|
27
28
|
matchesCompactIntelligenceControlLabel,
|
|
28
29
|
matchesCompactIntelligenceOpenerLabel,
|
|
@@ -51,7 +52,8 @@ if (!jobId) {
|
|
|
51
52
|
process.exit(1);
|
|
52
53
|
}
|
|
53
54
|
|
|
54
|
-
const
|
|
55
|
+
const ORACLE_JOBS_DIR = getOracleJobsDir();
|
|
56
|
+
const jobDir = join(ORACLE_JOBS_DIR, `oracle-${jobId}`);
|
|
55
57
|
const jobPath = `${jobDir}/job.json`;
|
|
56
58
|
const CHATGPT_LABELS = {
|
|
57
59
|
composer: "Chat with ChatGPT",
|
|
@@ -820,6 +822,7 @@ function buildLoginProbeScript(timeoutMs) {
|
|
|
820
822
|
let bodyKeys = [];
|
|
821
823
|
let bodyHasId = false;
|
|
822
824
|
let bodyHasEmail = false;
|
|
825
|
+
let bodyIsAnonymous = false;
|
|
823
826
|
try {
|
|
824
827
|
if (typeof fetch === 'function') {
|
|
825
828
|
const controller = new AbortController();
|
|
@@ -837,6 +840,7 @@ function buildLoginProbeScript(timeoutMs) {
|
|
|
837
840
|
if (data && typeof data === 'object' && !Array.isArray(data)) {
|
|
838
841
|
bodyKeys = Object.keys(data).slice(0, 12);
|
|
839
842
|
bodyHasId = typeof data.id === 'string' && data.id.length > 0;
|
|
843
|
+
bodyIsAnonymous = typeof data.id === 'string' && data.id.startsWith('ua-');
|
|
840
844
|
bodyHasEmail = typeof data.email === 'string' && data.email.includes('@');
|
|
841
845
|
}
|
|
842
846
|
}
|
|
@@ -860,6 +864,7 @@ function buildLoginProbeScript(timeoutMs) {
|
|
|
860
864
|
bodyKeys,
|
|
861
865
|
bodyHasId,
|
|
862
866
|
bodyHasEmail,
|
|
867
|
+
bodyIsAnonymous,
|
|
863
868
|
};
|
|
864
869
|
`);
|
|
865
870
|
}
|
|
@@ -936,7 +941,7 @@ function composerControlsVisible(snapshot, job = currentJob) {
|
|
|
936
941
|
const entries = parseSnapshotEntries(snapshot);
|
|
937
942
|
const hasComposer = isGrokJob(job)
|
|
938
943
|
? entries.some((entry) => !entry.disabled && ((entry.kind === "textbox" && entry.label === labels.composer) || /editable/.test(String(entry.line || ""))))
|
|
939
|
-
: entries.some((entry) => entry.kind === "textbox" && entry.label
|
|
944
|
+
: entries.some((entry) => entry.kind === "textbox" && CHATGPT_COMPOSER_LABELS.includes(entry.label) && !entry.disabled);
|
|
940
945
|
const hasAddFiles = entries.some(
|
|
941
946
|
(entry) => entry.kind === "button" && entry.label === labels.addFiles && !entry.disabled,
|
|
942
947
|
);
|
|
@@ -1041,8 +1046,7 @@ async function setComposerText(job, text) {
|
|
|
1041
1046
|
return;
|
|
1042
1047
|
}
|
|
1043
1048
|
const snapshot = await snapshotText(job);
|
|
1044
|
-
const
|
|
1045
|
-
const entry = findEntry(snapshot, (candidate) => candidate.kind === "textbox" && candidate.label === labels.composer);
|
|
1049
|
+
const entry = findEntry(snapshot, (candidate) => candidate.kind === "textbox" && CHATGPT_COMPOSER_LABELS.includes(candidate.label) && !candidate.disabled);
|
|
1046
1050
|
if (!entry) throw new Error("Could not find ChatGPT composer textbox");
|
|
1047
1051
|
await agentBrowser(job, "fill", entry.ref, text);
|
|
1048
1052
|
}
|
|
@@ -1075,7 +1079,11 @@ function classifyChatPage({ job, url, snapshot, body, probe }) {
|
|
|
1075
1079
|
const onAuthPath = typeof url === "string" && url.includes("/auth/");
|
|
1076
1080
|
const hasUsableComposer = snapshotHasUsableComposerControls(snapshot);
|
|
1077
1081
|
|
|
1078
|
-
|
|
1082
|
+
if (probe?.bodyIsAnonymous && (probe?.onAuthPage || probe?.domLoginCta)) {
|
|
1083
|
+
return { state: "login_required", message: "ChatGPT shows login controls and returned an anonymous visitor session, not a signed-in account. Sign in to ChatGPT in the configured source browser profile, then run /oracle-auth." };
|
|
1084
|
+
}
|
|
1085
|
+
|
|
1086
|
+
const probeHasAccountIdentity = !probe?.bodyIsAnonymous && (probe?.bodyHasId === true || probe?.bodyHasEmail === true);
|
|
1079
1087
|
|
|
1080
1088
|
if (probe?.status === 401 || (probe?.status === 403 && (!onAllowedOrigin || !hasUsableComposer))) {
|
|
1081
1089
|
return { state: "login_required", message: "ChatGPT login is required. Run /oracle-auth." };
|
|
@@ -1268,10 +1276,10 @@ function detectResponseFailureText(text) {
|
|
|
1268
1276
|
|
|
1269
1277
|
function composerSnapshotSlice(snapshot, job = currentJob) {
|
|
1270
1278
|
const lines = snapshot.split("\n");
|
|
1271
|
-
const
|
|
1279
|
+
const composerLabels = isGrokJob(job) ? [GROK_LABELS.composer] : CHATGPT_COMPOSER_LABELS;
|
|
1272
1280
|
let composerIndex = -1;
|
|
1273
1281
|
for (let index = lines.length - 1; index >= 0; index -= 1) {
|
|
1274
|
-
if (lines[index].includes(`textbox "${
|
|
1282
|
+
if (composerLabels.some((label) => lines[index].includes(`textbox "${label}"`)) || (isGrokJob(job) && lines[index].includes("contenteditable"))) {
|
|
1275
1283
|
composerIndex = index;
|
|
1276
1284
|
break;
|
|
1277
1285
|
}
|
|
@@ -1526,6 +1534,58 @@ async function waitForModelConfigurationToSettle(job, options = {}) {
|
|
|
1526
1534
|
throw new Error(`Could not verify requested model settings after configuration for ${job.selection.modelFamily}`);
|
|
1527
1535
|
}
|
|
1528
1536
|
|
|
1537
|
+
async function configurePowerSlider(job, powerRef) {
|
|
1538
|
+
// The current picker retains the compact tier semantics, but exposes keyboard
|
|
1539
|
+
// interaction on a Power menuitem rather than accessible tier radio buttons.
|
|
1540
|
+
const tiers = ["Instant", "Medium", "High", "Extra High", "Pro"];
|
|
1541
|
+
const requested = job.selection.modelFamily === "instant" ? "Instant"
|
|
1542
|
+
: job.selection.modelFamily === "pro" ? "Pro"
|
|
1543
|
+
: job.selection.effort === "heavy" ? "Extra High"
|
|
1544
|
+
: job.selection.effort === "extended" ? "High" : "Medium";
|
|
1545
|
+
const target = tiers.indexOf(requested);
|
|
1546
|
+
const selector = '[role="menuitem"][aria-label="Power"]';
|
|
1547
|
+
const timeoutAt = Date.now() + MODEL_CONFIGURATION_SETTLE_TIMEOUT_MS;
|
|
1548
|
+
let moves = 0;
|
|
1549
|
+
let focused = false;
|
|
1550
|
+
while (Date.now() < timeoutAt && moves < tiers.length) {
|
|
1551
|
+
const state = await evalPage(job, toJsonScript(`
|
|
1552
|
+
const control = document.querySelector(${JSON.stringify(selector)});
|
|
1553
|
+
const slider = control?.querySelector('[role="slider"]');
|
|
1554
|
+
if (!slider || control.closest('[inert]')) return null;
|
|
1555
|
+
const description = (control.getAttribute('aria-describedby') || '').split(/\\s+/)
|
|
1556
|
+
.map(id => document.getElementById(id)?.textContent || '').join(' ');
|
|
1557
|
+
return { min: slider.getAttribute('aria-valuemin'), max: slider.getAttribute('aria-valuemax'),
|
|
1558
|
+
value: slider.getAttribute('aria-valuenow'), description };
|
|
1559
|
+
`));
|
|
1560
|
+
// The menuitem can appear before React mounts its slider, especially headless.
|
|
1561
|
+
if (!state) {
|
|
1562
|
+
await sleep(150);
|
|
1563
|
+
continue;
|
|
1564
|
+
}
|
|
1565
|
+
const value = Number(state.value);
|
|
1566
|
+
if (state.min !== "0" || state.max !== "4" || typeof state.value !== "string" || !Number.isInteger(value) || value < 0 || value > 4) {
|
|
1567
|
+
throw new Error("ChatGPT Power slider has an unrecognized range; refusing to guess model settings");
|
|
1568
|
+
}
|
|
1569
|
+
if (!focused) {
|
|
1570
|
+
await agentBrowser(job, "focus", powerRef);
|
|
1571
|
+
focused = true;
|
|
1572
|
+
}
|
|
1573
|
+
if (value === target) {
|
|
1574
|
+
if (!state.description.startsWith(`${requested}, ${target + 1} of ${tiers.length}.`)) {
|
|
1575
|
+
throw new Error(`Could not verify ChatGPT Power tier: ${requested}`);
|
|
1576
|
+
}
|
|
1577
|
+
await log(`Verified ChatGPT Power tier ${requested} for family=${job.selection.modelFamily} effort=${job.selection.effort || "(none)"}`);
|
|
1578
|
+
await agentBrowser(job, "press", "Escape");
|
|
1579
|
+
await waitForModelConfigurationToSettle(job, { stronglyVerified: true });
|
|
1580
|
+
return;
|
|
1581
|
+
}
|
|
1582
|
+
await agentBrowser(job, "press", value < target ? "ArrowRight" : "ArrowLeft");
|
|
1583
|
+
moves++;
|
|
1584
|
+
await sleep(150);
|
|
1585
|
+
}
|
|
1586
|
+
throw new Error(`ChatGPT Power slider did not reach requested tier: ${requested}`);
|
|
1587
|
+
}
|
|
1588
|
+
|
|
1529
1589
|
async function configureModel(job) {
|
|
1530
1590
|
if (isGrokJob(job)) return configureGrokModel(job);
|
|
1531
1591
|
const initialSnapshot = await snapshotText(job);
|
|
@@ -1536,6 +1596,11 @@ async function configureModel(job) {
|
|
|
1536
1596
|
|
|
1537
1597
|
await log(`Configuring model family=${job.selection.modelFamily} effort=${job.selection?.effort || "(none)"}`);
|
|
1538
1598
|
let familySnapshot = await openModelConfiguration(job);
|
|
1599
|
+
const powerEntry = findEntry(familySnapshot, (entry) => entry.kind === "menuitem" && entry.label === "Power" && !entry.disabled);
|
|
1600
|
+
if (powerEntry) {
|
|
1601
|
+
await configurePowerSlider(job, powerEntry.ref);
|
|
1602
|
+
return;
|
|
1603
|
+
}
|
|
1539
1604
|
let verificationSnapshot = familySnapshot;
|
|
1540
1605
|
|
|
1541
1606
|
const alreadyConfiguredInUi = snapshotStronglyMatchesRequestedModel(familySnapshot, job.selection);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-oracle",
|
|
3
|
-
"version": "0.7.
|
|
3
|
+
"version": "0.7.22",
|
|
4
4
|
"description": "ChatGPT and Grok web-oracle extension for pi with isolated browser auth, async jobs, and project-context archives.",
|
|
5
5
|
"private": false,
|
|
6
6
|
"license": "MIT",
|
|
@@ -37,6 +37,9 @@
|
|
|
37
37
|
"scripts/platform-smoke.mjs",
|
|
38
38
|
"scripts/platform-smoke",
|
|
39
39
|
"scripts/oracle-real-smoke.mjs",
|
|
40
|
+
"scripts/oracle-auth.test.mjs",
|
|
41
|
+
"scripts/oracle-cookie-import.test.mjs",
|
|
42
|
+
"scripts/oracle-model.test.mjs",
|
|
40
43
|
"scripts/oracle-chatgpt-preset-proof.mjs"
|
|
41
44
|
],
|
|
42
45
|
"pi": {
|
|
@@ -48,13 +51,14 @@
|
|
|
48
51
|
]
|
|
49
52
|
},
|
|
50
53
|
"scripts": {
|
|
51
|
-
"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",
|
|
52
55
|
"typecheck": "tsc --noEmit -p tsconfig.json",
|
|
53
56
|
"typecheck:worker-helpers": "tsc --noEmit -p tsconfig.worker-helpers.json",
|
|
54
|
-
"sanity:oracle": "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",
|
|
55
58
|
"pack:check": "npm pack --dry-run",
|
|
56
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",
|
|
57
60
|
"test": "npm run verify:oracle",
|
|
61
|
+
"check:compat": "npm run verify:oracle && node --test scripts/oracle-native.test.mjs",
|
|
58
62
|
"prepublishOnly": "npm run release:check",
|
|
59
63
|
"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",
|
|
60
64
|
"smoke:platform": "node scripts/platform-smoke.mjs",
|
|
@@ -71,7 +75,7 @@
|
|
|
71
75
|
"release:proof:chatgpt-presets": "node scripts/oracle-chatgpt-preset-proof.mjs check",
|
|
72
76
|
"smoke:real:packed": "node scripts/oracle-real-smoke.mjs run --mode packed",
|
|
73
77
|
"smoke:real:source": "node scripts/oracle-real-smoke.mjs run --mode source",
|
|
74
|
-
"sanity:oracle:platform": "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",
|
|
75
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"
|
|
76
80
|
},
|
|
77
81
|
"dependencies": {
|
|
@@ -83,17 +87,17 @@
|
|
|
83
87
|
"typebox": "*"
|
|
84
88
|
},
|
|
85
89
|
"overrides": {
|
|
86
|
-
"basic-ftp": "6.
|
|
87
|
-
"protobufjs": "7.6.
|
|
90
|
+
"basic-ftp": "6.2.1",
|
|
91
|
+
"protobufjs": "7.6.6"
|
|
88
92
|
},
|
|
89
93
|
"devDependencies": {
|
|
90
|
-
"@earendil-works/pi-ai": "
|
|
91
|
-
"@earendil-works/pi-coding-agent": "
|
|
94
|
+
"@earendil-works/pi-ai": "1.0.0",
|
|
95
|
+
"@earendil-works/pi-coding-agent": "1.0.0",
|
|
92
96
|
"@types/node": "^22.19.19",
|
|
93
97
|
"esbuild": "^0.28.0",
|
|
94
98
|
"tsx": "^4.22.3",
|
|
95
|
-
"typebox": "
|
|
96
|
-
"typescript": "^
|
|
99
|
+
"typebox": "1.3.34",
|
|
100
|
+
"typescript": "^7.0.0"
|
|
97
101
|
},
|
|
98
102
|
"engines": {
|
|
99
103
|
"node": ">=22.19.0"
|
|
@@ -103,7 +107,7 @@
|
|
|
103
107
|
"linux",
|
|
104
108
|
"win32"
|
|
105
109
|
],
|
|
106
|
-
"packageManager": "npm@11.
|
|
110
|
+
"packageManager": "npm@11.21.0",
|
|
107
111
|
"peerDependenciesMeta": {
|
|
108
112
|
"@earendil-works/pi-ai": {
|
|
109
113
|
"optional": true
|
|
@@ -6,33 +6,12 @@ export default {
|
|
|
6
6
|
artifactRoot: ".artifacts/platform-smoke",
|
|
7
7
|
requiredTargets: ["macos", "ubuntu", "windows-native"],
|
|
8
8
|
requiredSuites: ["platform-build", "real-extension"],
|
|
9
|
-
workflows: {
|
|
10
|
-
everyday: {
|
|
11
|
-
description: "Fast local validation for normal iteration.",
|
|
12
|
-
commands: ["npm run verify:oracle"],
|
|
13
|
-
},
|
|
14
|
-
platformSensitive: {
|
|
15
|
-
description: "Doctor plus focused platform target/suite runs for platform-sensitive changes.",
|
|
16
|
-
commands: [
|
|
17
|
-
"npm run smoke:platform:doctor",
|
|
18
|
-
"node scripts/platform-smoke.mjs run --target <target> --suite <suite>",
|
|
19
|
-
],
|
|
20
|
-
},
|
|
21
|
-
platformMatrix: {
|
|
22
|
-
description: "Doctor-first packed-install macOS/Ubuntu/Windows platform proof.",
|
|
23
|
-
commands: ["npm run smoke:platform:all"],
|
|
24
|
-
},
|
|
25
|
-
release: {
|
|
26
|
-
description: "Full release gate: local verification, fresh ChatGPT preset proof, plus the doctor-first platform matrix.",
|
|
27
|
-
commands: ["npm run release:check"],
|
|
28
|
-
},
|
|
29
|
-
},
|
|
30
9
|
requiredCrabbox: {
|
|
31
10
|
source: "https://github.com/openclaw/crabbox",
|
|
32
11
|
minVersion: "0.26.0",
|
|
33
12
|
},
|
|
34
13
|
ubuntuContainerImage: "pi-oracle-platform-smoke:node24",
|
|
35
|
-
ubuntuContainerBaseImage: "cimg/node:24.
|
|
14
|
+
ubuntuContainerBaseImage: "cimg/node:24.21",
|
|
36
15
|
windowsParallels: {
|
|
37
16
|
sourceVm: "pi-extension-windows-template",
|
|
38
17
|
snapshot: "crabbox-ready",
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
// Regression coverage for auth observations interrupted by navigation and
|
|
2
|
+
// ChatGPT's anonymous /backend-api/me responses. No real cookies or browser.
|
|
3
|
+
import assert from "node:assert/strict";
|
|
4
|
+
import { readFile } from "node:fs/promises";
|
|
5
|
+
import vm from "node:vm";
|
|
6
|
+
import test from "node:test";
|
|
7
|
+
import { classifyChatAuthPage, isAuthNavigationError, normalizeLoginProbeResult } from "../extensions/oracle/worker/auth-flow-helpers.mjs";
|
|
8
|
+
import { CHATGPT_COMPOSER_LABELS } from "../extensions/oracle/worker/chatgpt-ui-helpers.mjs";
|
|
9
|
+
|
|
10
|
+
const bootstrap = await readFile(new URL("../extensions/oracle/worker/auth-bootstrap.mjs", import.meta.url), "utf8");
|
|
11
|
+
const worker = await readFile(new URL("../extensions/oracle/worker/run-job.mjs", import.meta.url), "utf8");
|
|
12
|
+
function functionSource(source, name) {
|
|
13
|
+
const start = source.indexOf(`function ${name}(`);
|
|
14
|
+
assert.notEqual(start, -1);
|
|
15
|
+
const end = source.indexOf("\n}", start) + 2;
|
|
16
|
+
return `${source.slice(start - 6, start) === "async " ? "async " : ""}${source.slice(start, end)}`;
|
|
17
|
+
}
|
|
18
|
+
const readySnapshot = '- textbox "Chat with ChatGPT" [ref=e1]\n- button "Add files and more" [ref=e2]';
|
|
19
|
+
const readyProbe = { ok: true, status: 200, bodyHasId: true, bodyHasEmail: true };
|
|
20
|
+
const classify = (observation) => classifyChatAuthPage({ ...observation,
|
|
21
|
+
allowedOrigins: ["https://chatgpt.com"], cookieSourceLabel: "Chrome profile Default",
|
|
22
|
+
runtimeProfileDir: "/fixture/seed", logPath: "/fixture/auth.log" });
|
|
23
|
+
const navigationError = () => new Error("✗ CDP error (Runtime.evaluate): Inspected target navigated or closed");
|
|
24
|
+
|
|
25
|
+
function pollingHarness(overrides = {}) {
|
|
26
|
+
const logs = [];
|
|
27
|
+
let captures = 0;
|
|
28
|
+
const context = {
|
|
29
|
+
Date, Promise, Error, isAuthNavigationError, CHATGPT_COMPOSER_LABELS,
|
|
30
|
+
config: { auth: { bootstrapTimeoutMs: 2000, pollMs: 1 } },
|
|
31
|
+
getUrl: async () => "https://chatgpt.com/", snapshotText: async () => readySnapshot,
|
|
32
|
+
pageText: async () => "", loginProbe: async () => readyProbe,
|
|
33
|
+
writeFile: async () => {}, URL_PATH: "url", SNAPSHOT_PATH: "snapshot", BODY_PATH: "body", LOG_PATH: "log",
|
|
34
|
+
classifyChatPage: classify, log: async (line) => { logs.push(line); },
|
|
35
|
+
ensureBrowserConnected: async () => {},
|
|
36
|
+
sleep: (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
|
|
37
|
+
preferredProvider: () => "chatgpt", providerName: () => "ChatGPT",
|
|
38
|
+
CHATGPT_LABELS: { composer: "Chat with ChatGPT", addFiles: "Add files and more" },
|
|
39
|
+
captureDiagnostics: async () => { captures++; }, authConfigRemediation: () => "Refresh source login.",
|
|
40
|
+
// Any reload/click on an anonymous login page is a regression.
|
|
41
|
+
openUrl: async () => assert.fail("unexpected auth navigation"),
|
|
42
|
+
targetCommand: async () => assert.fail("unexpected reload"),
|
|
43
|
+
...overrides,
|
|
44
|
+
};
|
|
45
|
+
const poll = vm.runInNewContext(`(${functionSource(bootstrap, "waitForImportedAuthReady")})`, context);
|
|
46
|
+
return { poll, logs, get captures() { return captures; } };
|
|
47
|
+
}
|
|
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
|
+
|
|
84
|
+
test("auth polling retries a complete observation after each navigation-interrupted read", async () => {
|
|
85
|
+
for (const read of ["getUrl", "snapshotText", "pageText", "loginProbe"]) {
|
|
86
|
+
let calls = 0;
|
|
87
|
+
const good = { getUrl: "https://chatgpt.com/", snapshotText: readySnapshot, pageText: "", loginProbe: readyProbe };
|
|
88
|
+
const h = pollingHarness({ [read]: async () => { if (++calls === 1) throw navigationError(); return good[read]; } });
|
|
89
|
+
assert.equal((await h.poll()).state, "authenticated_and_ready");
|
|
90
|
+
assert.equal(calls, 2);
|
|
91
|
+
assert(h.logs.some((line) => line.includes("discarding partial reads")));
|
|
92
|
+
}
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
test("auth polling drains concurrent commands before surfacing a fatal failure", async () => {
|
|
96
|
+
let drained = false;
|
|
97
|
+
const failure = new Error("CDP permission denied");
|
|
98
|
+
const h = pollingHarness({ loginProbe: async () => { throw failure; }, pageText: async () => {
|
|
99
|
+
await new Promise((resolve) => setTimeout(resolve, 10)); drained = true; return "";
|
|
100
|
+
} });
|
|
101
|
+
await assert.rejects(h.poll(), (error) => error === failure && drained);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
test("fatal failures take precedence over concurrent navigation failures", async () => {
|
|
105
|
+
const h = pollingHarness({ loginProbe: async () => { throw navigationError(); }, getUrl: async () => { throw new Error("Browser disconnected"); } });
|
|
106
|
+
await assert.rejects(h.poll(), /Browser disconnected/);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
test("closed browsers are not relaunched and persistent navigation respects the deadline", async () => {
|
|
110
|
+
const closed = pollingHarness({ loginProbe: async () => { throw navigationError(); },
|
|
111
|
+
ensureBrowserConnected: async () => { throw new Error("The isolated oracle browser was closed"); } });
|
|
112
|
+
await assert.rejects(closed.poll(), /browser was closed/);
|
|
113
|
+
const navigating = pollingHarness({ loginProbe: async () => { throw navigationError(); },
|
|
114
|
+
config: { auth: { bootstrapTimeoutMs: 20, pollMs: 1 } } });
|
|
115
|
+
await assert.rejects(navigating.poll(), /Timed out verifying/);
|
|
116
|
+
assert.equal(navigating.captures, 1);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
test("only known navigation/context destruction errors are retryable", () => {
|
|
120
|
+
for (const message of ["Execution context was destroyed, most likely because of a navigation", "Cannot find context with specified id", navigationError().message]) {
|
|
121
|
+
assert(isAuthNavigationError(new Error(message)));
|
|
122
|
+
}
|
|
123
|
+
for (const message of ["Target closed", "Browser disconnected", "CDP permission denied", "command timed out"]) {
|
|
124
|
+
assert.equal(isAuthNavigationError(new Error(message)), false);
|
|
125
|
+
}
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
for (const [name, source] of [["bootstrap", bootstrap], ["job worker", worker]]) {
|
|
129
|
+
test(`${name} probe distinguishes anonymous ua-* IDs from account IDs`, async () => {
|
|
130
|
+
const build = vm.runInNewContext(`(${functionSource(source, "buildLoginProbeScript")})`, {
|
|
131
|
+
toAsyncJsonScript: (body) => `(async () => { ${body} })()`,
|
|
132
|
+
});
|
|
133
|
+
for (const id of ["ua-visitor", "user-signed-in"]) {
|
|
134
|
+
const probe = normalizeLoginProbeResult(await vm.runInNewContext(build(50), {
|
|
135
|
+
location: { href: "https://chatgpt.com/auth/login", hostname: "chatgpt.com", pathname: "/auth/login" },
|
|
136
|
+
document: { querySelectorAll: () => [] }, AbortController, setTimeout, clearTimeout,
|
|
137
|
+
fetch: async () => ({ status: 200, headers: { get: () => "application/json" },
|
|
138
|
+
clone: () => ({ json: async () => ({ id, email: "", name: "" }) }) }),
|
|
139
|
+
}));
|
|
140
|
+
assert.equal(probe.bodyIsAnonymous, id.startsWith("ua-"));
|
|
141
|
+
const result = classify({ url: "https://chatgpt.com/auth/login", snapshot: "", body: "Log in or sign up", probe });
|
|
142
|
+
assert.equal(result.state, id.startsWith("ua-") ? "login_required" : "auth_transitioning");
|
|
143
|
+
if (id.startsWith("ua-")) {
|
|
144
|
+
const h = pollingHarness({ loginProbe: async () => probe });
|
|
145
|
+
await assert.rejects(h.poll(), /anonymous visitor session/);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
test("auth recognizes current and legacy ready composers without trusting anonymous identity", () => {
|
|
152
|
+
// Cookie-only /me probes may run before hydration or without the app's bearer token.
|
|
153
|
+
const probe = { ...readyProbe, bodyIsAnonymous: true, bodyHasEmail: false };
|
|
154
|
+
const observe = (snapshot, overrides = {}) => classify({ url: "https://chatgpt.com/", snapshot, body: "", probe: { ...probe, ...overrides } }).state;
|
|
155
|
+
assert.equal(observe(""), "unknown");
|
|
156
|
+
for (const label of ["Chat with ChatGPT", "Ask ChatGPT"]) {
|
|
157
|
+
const snapshot = `- button "Open profile menu" [ref=e1]\n- button "Add files and more" [ref=e2]\n- textbox "${label}" [ref=e3]\n- button "Select ChatGPT model" [ref=e4]`;
|
|
158
|
+
assert.equal(observe(snapshot), "authenticated_and_ready", `${label} should finish auth verification`);
|
|
159
|
+
assert.equal(observe(snapshot, { domLoginCta: true }), "login_required");
|
|
160
|
+
assert.equal(observe(snapshot.replace('[ref=e3]', '[disabled, ref=e3]')), "unknown");
|
|
161
|
+
assert.equal(observe(snapshot.replace('- button "Add files and more" [ref=e2]', '')), "unknown");
|
|
162
|
+
}
|
|
163
|
+
assert.equal(observe('- textbox "Search" [ref=e1]\n- button "Add files and more" [ref=e2]'), "unknown");
|
|
164
|
+
});
|
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
|
|
7
7
|
import { execFileSync } from "node:child_process";
|
|
8
8
|
import { existsSync, readFileSync } from "node:fs";
|
|
9
|
+
import { createRequire } from "node:module";
|
|
9
10
|
import { dirname, resolve } from "node:path";
|
|
10
11
|
import { fileURLToPath } from "node:url";
|
|
11
12
|
|
|
@@ -69,18 +70,24 @@ function currentGitStatus() {
|
|
|
69
70
|
}
|
|
70
71
|
|
|
71
72
|
function canonicalPresets() {
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
const
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
autoSwitchToThinking:
|
|
83
|
-
|
|
73
|
+
// Derive the canonical registry by importing the real config module so a
|
|
74
|
+
// formatting change in config.ts can never silently drop a preset from the
|
|
75
|
+
// release proof's required set.
|
|
76
|
+
const tsx = createRequire(import.meta.url).resolve("tsx");
|
|
77
|
+
const stdout = execFileSync(process.execPath, [
|
|
78
|
+
"--import", tsx,
|
|
79
|
+
"--input-type=module",
|
|
80
|
+
"--eval",
|
|
81
|
+
"const mod = await import('./extensions/oracle/lib/config.ts');" +
|
|
82
|
+
"const presets = Object.fromEntries(Object.entries(mod.ORACLE_SUBMIT_PRESETS).map(([id, preset]) => [id, {" +
|
|
83
|
+
"modelFamily: preset.modelFamily, effort: preset.effort, autoSwitchToThinking: preset.autoSwitchToThinking }]));" +
|
|
84
|
+
"process.stdout.write(JSON.stringify(presets));",
|
|
85
|
+
], { cwd: REPO_ROOT, encoding: "utf8" });
|
|
86
|
+
const presets = JSON.parse(stdout);
|
|
87
|
+
if (!presets || typeof presets !== "object" || Object.keys(presets).length === 0) {
|
|
88
|
+
throw new Error("ORACLE_SUBMIT_PRESETS registry resolved to no presets");
|
|
89
|
+
}
|
|
90
|
+
return presets;
|
|
84
91
|
}
|
|
85
92
|
|
|
86
93
|
function canonicalPresetIds() {
|