pi-oracle 0.7.20 → 0.7.21
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 +14 -0
- package/README.md +10 -2
- package/docs/ORACLE_DESIGN.md +4 -1
- package/docs/ORACLE_ISOLATED_PI_VALIDATION.md +9 -7
- package/extensions/oracle/worker/auth-bootstrap.mjs +30 -6
- 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/run-job.mjs +67 -7
- package/package.json +8 -5
- package/scripts/oracle-auth.test.mjs +129 -0
- package/scripts/oracle-model.test.mjs +83 -0
- package/scripts/oracle-real-smoke.mjs +13 -6
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,19 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.7.21 - 2026-09-26
|
|
4
|
+
|
|
5
|
+
### Fixed
|
|
6
|
+
- recognize ChatGPT's current `Ask ChatGPT` input as well as `Chat with ChatGPT`, so authenticated browser verification finishes and closes the browser; share the aliases with prompt entry and response/upload boundaries
|
|
7
|
+
- retry auth observations interrupted by CDP navigation without closing the browser while concurrent reads are still running
|
|
8
|
+
- stop treating anonymous `ua-*` visitor IDs as signed-in account identities that need account-selection clicks or reloads
|
|
9
|
+
- distinguish browser verification failures from rejected source cookies in auth recovery guidance
|
|
10
|
+
- support ChatGPT's current Power slider and version-prefixed Pro button, verifying the selected tier before submitting
|
|
11
|
+
- restore the worker jobs-directory binding so cleanup can promote queued jobs
|
|
12
|
+
|
|
13
|
+
### Validation
|
|
14
|
+
- reproduced the renamed-composer failure before the fix and verified a live `/oracle-auth` refresh saved the auth seed and closed Chrome automatically
|
|
15
|
+
- passed the local Oracle verification suite and independent correctness review
|
|
16
|
+
|
|
3
17
|
## 0.7.20 - 2026-07-28
|
|
4
18
|
|
|
5
19
|
### Fixed
|
package/README.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
`pi-oracle` lets a `pi` agent send hard, long-running work to ChatGPT.com or Grok through the web app, with repo archives, background execution, saved results, and a best-effort wake-up back into `pi` when the answer is ready.
|
|
4
4
|
|
|
5
|
-
> Status: experimental public beta. Current
|
|
5
|
+
> Status: experimental public beta. Current source qualification baseline is official pi `0.86.1`; the platform-smoke harness covers macOS, Linux, and Windows native with Chromium-family browsers. Pi `0.80.9+` is the suggested tested floor for project-trust-aware package/runtime validation, but pi-bundled runtime packages remain optional wildcard peers so npm peer ranges do not block users from trying newer pi releases. Normal oracle jobs run in an isolated browser profile, not your active browser window.
|
|
6
6
|
|
|
7
7
|
## What a successful run looks like
|
|
8
8
|
|
|
@@ -284,6 +284,8 @@ Grok uploads now use `.tar.gz` archives. Grok may accept `.tar.zst` uploads, but
|
|
|
284
284
|
|
|
285
285
|
For ChatGPT, `oracle_submit` accepts canonical preset ids or a matching human-readable preset label. Keep config values on canonical ids. For Grok, use `provider: "grok"`; only Heavy is supported today.
|
|
286
286
|
|
|
287
|
+
ChatGPT's compact picker and current **Power** slider expose fewer choices than the legacy settings dialog: both Instant presets use Instant when no auto-switch toggle exists; Thinking Light/Standard use Medium, Extended uses High, and Heavy uses Extra High. Both Pro presets use the single Pro tier when separate effort choices are unavailable. Oracle verifies the visible selection rather than assuming the saved preference.
|
|
288
|
+
|
|
287
289
|
## Outputs and cleanup
|
|
288
290
|
|
|
289
291
|
- Jobs persist response text, metadata, logs, and artifacts under `${PI_ORACLE_JOBS_DIR:-/tmp}/oracle-<job-id>/` by default.
|
|
@@ -319,7 +321,9 @@ Review the code and design docs before using it with private or regulated materi
|
|
|
319
321
|
- For custom Chromium cookie sources, confirm `auth.chromeCookiePath` points at that profile's `Cookies` DB. On macOS, also confirm `auth.chromiumKeychain.services` names the browser's safe-storage Keychain service. On Linux, leave `auth.chromiumKeychain` unset and use Sweet Cookie's `SWEET_COOKIE_LINUX_KEYRING`, `SWEET_COOKIE_CHROME_SAFE_STORAGE_PASSWORD`, or `SWEET_COOKIE_BRAVE_SAFE_STORAGE_PASSWORD` options for encrypted Chrome/Chromium/Brave cookies.
|
|
320
322
|
- Re-run `/oracle-auth`.
|
|
321
323
|
- Agent callers can use `oracle_auth({})` once before retrying a stale-auth oracle submission.
|
|
322
|
-
- If
|
|
324
|
+
- If ChatGPT reports an anonymous visitor session, sign in in the configured **source browser profile**, quit that browser fully, and rerun `/oracle-auth`. This command imports an existing login; the isolated verification window is not a manual-login setup flow.
|
|
325
|
+
- Navigation interruptions during verification are retried until the auth deadline. A closed browser or verification timeout does not by itself mean the source cookies are invalid; leave the isolated window open and inspect the printed diagnostics if retries fail.
|
|
326
|
+
- If the provider presents a challenge and Oracle explicitly leaves the headed auth browser open, complete the challenge there and retry.
|
|
323
327
|
|
|
324
328
|
### Custom Chromium auth says cookies synced but the session is rejected
|
|
325
329
|
|
|
@@ -371,6 +375,8 @@ Inspect the job directory under `${PI_ORACLE_JOBS_DIR:-/tmp}/oracle-<job-id>/`.
|
|
|
371
375
|
|
|
372
376
|
## Verification
|
|
373
377
|
|
|
378
|
+
For credential-free host qualification, run `npm ci --ignore-scripts` then `npm run check:compat` with an empty HOME/agent profile. It runs the existing syntax, type, isolated sanity, and pack checks plus native Pi `/oracle-status` dispatch, asserting that no browser job is created. Sanity uses fixture browser/keychain commands and private job/state directories; it needs local archive utilities (`tar` and `zstd`), not browser credentials. This is source/host proof, not authenticated ChatGPT/Grok or packed cross-platform release proof. The older suggested floor is historical support guidance, not requalified by a current-host run.
|
|
379
|
+
|
|
374
380
|
Useful local checks:
|
|
375
381
|
|
|
376
382
|
```bash
|
|
@@ -384,6 +390,8 @@ npm test
|
|
|
384
390
|
npm run verify:oracle
|
|
385
391
|
```
|
|
386
392
|
|
|
393
|
+
`npm test` runs `npm run verify:oracle`, so it is not a separate gate from the final line above.
|
|
394
|
+
|
|
387
395
|
`npm publish` is guarded by `prepublishOnly`, which runs `npm run release:check`. That release gate now blocks unless fresh live ChatGPT preset proof exists for every canonical preset, then requires doctor-first macOS, Ubuntu, and Windows native Crabbox evidence. The required Crabbox runtime suite uses packed-install proof, not source-tree `pi -e` loading.
|
|
388
396
|
|
|
389
397
|
Use the narrowest validation workflow that proves the change:
|
package/docs/ORACLE_DESIGN.md
CHANGED
|
@@ -143,6 +143,9 @@ Auth bootstrap flow:
|
|
|
143
143
|
7. clear isolated browser cookies and seed the staged profile with imported provider cookies
|
|
144
144
|
8. open the configured provider in the isolated browser
|
|
145
145
|
9. verify auth with provider-specific readiness checks
|
|
146
|
+
- recognize both `Chat with ChatGPT` and `Ask ChatGPT` composer labels via the shared UI helpers; the same aliases apply to prompt entry and response/upload boundaries
|
|
147
|
+
- drain concurrent page reads and discard the whole observation if a CDP evaluation was interrupted by navigation; retry within the existing bootstrap deadline without relaunching a closed browser
|
|
148
|
+
- ChatGPT's anonymous `ua-*` visitor IDs are not account identity evidence; a login page/public login controls with an anonymous probe require refreshing the source browser login, not account-selection clicks or reloads
|
|
146
149
|
10. on success, close the isolated browser so Chrome flushes profile state cleanly
|
|
147
150
|
11. atomically swap the staged profile into `browser.authSeedProfileDir`, keeping `*.prev` as rollback
|
|
148
151
|
12. write a seed-generation marker used by future runtime clones
|
|
@@ -195,7 +198,7 @@ Per job:
|
|
|
195
198
|
- login required
|
|
196
199
|
- challenge/verification page
|
|
197
200
|
- transient outage after one retry
|
|
198
|
-
6. configure ChatGPT model family/effort or Grok Heavy
|
|
201
|
+
6. configure ChatGPT model family/effort or Grok Heavy; for the current Power slider, use its keyboard controls and verify both the numeric position and accessible tier announcement before closing it. Preserve the compact-menu mapping (Instant; Medium for Light/Standard; High for Extended; Extra High for Heavy; undifferentiated Pro). Reject unknown ranges or mismatched announcements instead of guessing.
|
|
199
202
|
7. upload archive
|
|
200
203
|
8. wait for upload confirmation scoped to the active composer
|
|
201
204
|
9. fill prompt
|
|
@@ -33,7 +33,7 @@ The local extension now intercepts TUI `/oracle` and `/oracle-followup` before p
|
|
|
33
33
|
|
|
34
34
|
Do not add `https://github.com/fitchmultz/pi-oracle` to this repository's `.pi/settings.json` just to test local oracle changes. If you already keep `npm:pi-oracle` installed globally, mixing the global npm package with a project-local git package creates two distinct package identities and can trigger prompt/tool conflicts. Use the explicit CLI extension flag above instead.
|
|
35
35
|
|
|
36
|
-
`oracle_submit` now preflights missing, unreadable, or unverified auth seed profiles before it creates an archive or persists a job. For archive-inspection smoke tests that intentionally run without real auth, use `oracle_preflight` for the blocker path or create a test seed only in a purpose-built fixture
|
|
36
|
+
`oracle_submit` now preflights missing, unreadable, or unverified auth seed profiles before it creates an archive or persists a job. For archive-inspection smoke tests that intentionally run without real auth, use `oracle_preflight` for the blocker path or create a test seed only in a purpose-built fixture with a non-empty `.oracle-seed-generation` value.
|
|
37
37
|
|
|
38
38
|
## Preset requirements
|
|
39
39
|
|
|
@@ -98,9 +98,11 @@ mkdir -p \
|
|
|
98
98
|
mkdir -p \
|
|
99
99
|
"$TEST1_AGENT/extensions/oracle-auth-seed-profile" \
|
|
100
100
|
"$TEST2_AGENT/extensions/oracle-auth-seed-profile"
|
|
101
|
-
|
|
102
|
-
"$TEST1_AGENT/extensions/oracle-auth-seed-profile
|
|
103
|
-
"$TEST2_AGENT/extensions/oracle-auth-seed-profile
|
|
101
|
+
for seed in \
|
|
102
|
+
"$TEST1_AGENT/extensions/oracle-auth-seed-profile" \
|
|
103
|
+
"$TEST2_AGENT/extensions/oracle-auth-seed-profile"; do
|
|
104
|
+
printf '%s\n' 'isolated-smoke-fixture' > "$seed/.oracle-seed-generation"
|
|
105
|
+
done
|
|
104
106
|
|
|
105
107
|
echo 'secret' > "$OUTSIDE/secret.txt"
|
|
106
108
|
ln -s "$OUTSIDE" "$FIXTURE/linked-outside"
|
|
@@ -181,8 +183,8 @@ Expected behavior:
|
|
|
181
183
|
Notes:
|
|
182
184
|
|
|
183
185
|
- this smoke test does not require `/oracle-auth`
|
|
184
|
-
- the snippet creates an isolated test
|
|
185
|
-
-
|
|
186
|
+
- the snippet creates an isolated test seed profile with a non-empty `.oracle-seed-generation` value; an empty file is rejected before archiving
|
|
187
|
+
- this marker is fixture setup, not authenticated account access; the worker still fails later due to missing real auth, leaving the archive available for inspection
|
|
186
188
|
|
|
187
189
|
### Test 2: symlink escape rejection
|
|
188
190
|
|
|
@@ -204,7 +206,7 @@ TMUX_CMD1="cd '$REPO' && env PI_CODING_AGENT_DIR='$TEST1_AGENT' PI_ORACLE_JOBS_D
|
|
|
204
206
|
|
|
205
207
|
Use the same pattern for additional sessions, swapping the session/job directories as needed. This keeps the test on the in-repo extension and hidden in-repo command prompt without depending on `.pi/settings.json` package entries.
|
|
206
208
|
|
|
207
|
-
`/oracle` now starts by calling `oracle_preflight`. If you want the command flow to proceed past that early guard in an isolated test without using your normal auth state, run `/oracle-auth` in the isolated agent dir or create a purpose-built
|
|
209
|
+
`/oracle` now starts by calling `oracle_preflight`. If you want the command flow to proceed past that early guard in an isolated test without using your normal auth state, run `/oracle-auth` in the isolated agent dir or create a purpose-built test seed fixture with a non-empty `.oracle-seed-generation` value.
|
|
208
210
|
|
|
209
211
|
## Additional failure-mode smoke tests
|
|
210
212
|
|
|
@@ -17,9 +17,9 @@ import {
|
|
|
17
17
|
import { ensureAccountCookie, filterImportableAuthCookies } from "./auth-cookie-policy.mjs";
|
|
18
18
|
import { getCookiesFromConfiguredChromiumSource } from "./chromium-cookie-source.mjs";
|
|
19
19
|
import { parseSnapshotEntries } from "./artifact-heuristics.mjs";
|
|
20
|
-
import { buildAllowedChatGptOrigins } from "./chatgpt-ui-helpers.mjs";
|
|
20
|
+
import { buildAllowedChatGptOrigins, CHATGPT_COMPOSER_LABELS } from "./chatgpt-ui-helpers.mjs";
|
|
21
21
|
import { stripUrlQueryAndHash } from "./chatgpt-flow-helpers.mjs";
|
|
22
|
-
import { buildAccountChooserCandidateLabels, classifyChatAuthPage, normalizeLoginProbeResult } from "./auth-flow-helpers.mjs";
|
|
22
|
+
import { buildAccountChooserCandidateLabels, classifyChatAuthPage, isAuthNavigationError, normalizeLoginProbeResult } from "./auth-flow-helpers.mjs";
|
|
23
23
|
|
|
24
24
|
const rawConfig = process.argv[2];
|
|
25
25
|
if (!rawConfig) {
|
|
@@ -427,7 +427,7 @@ async function snapshotText() {
|
|
|
427
427
|
}
|
|
428
428
|
|
|
429
429
|
async function pageText() {
|
|
430
|
-
const { stdout } = await targetCommand("get", "text", "body", {
|
|
430
|
+
const { stdout } = await targetCommand("get", "text", "body", { logLabel: "get text body" });
|
|
431
431
|
return stdout || "";
|
|
432
432
|
}
|
|
433
433
|
|
|
@@ -515,7 +515,16 @@ function formatAuthFailureGuidance(error) {
|
|
|
515
515
|
const reason = error instanceof Error ? error.message : String(error);
|
|
516
516
|
const lines = ["Oracle auth failed.", "", `Reason: ${reason}`, "", "Likely causes:"];
|
|
517
517
|
|
|
518
|
-
if (
|
|
518
|
+
if (isAuthNavigationError(error) || /browser was closed|Timed out verifying synced/i.test(reason)) {
|
|
519
|
+
lines.push(
|
|
520
|
+
"- the isolated browser navigated, closed, or never settled during verification",
|
|
521
|
+
"- this browser verification failure does not by itself prove the source cookies are invalid",
|
|
522
|
+
"",
|
|
523
|
+
"Next:",
|
|
524
|
+
"1. Re-run /oracle-auth and leave the isolated browser open while verification completes.",
|
|
525
|
+
"2. If it fails again, inspect the diagnostics below.",
|
|
526
|
+
);
|
|
527
|
+
} else if (usesConfiguredChromiumCookieSource()) {
|
|
519
528
|
lines.push(
|
|
520
529
|
"- the configured cookie DB is stale or from the wrong browser profile",
|
|
521
530
|
`- ${providerName()} is logged out in that browser profile`,
|
|
@@ -708,6 +717,7 @@ function buildLoginProbeScript(timeoutMs) {
|
|
|
708
717
|
let bodyKeys = [];
|
|
709
718
|
let bodyHasId = false;
|
|
710
719
|
let bodyHasEmail = false;
|
|
720
|
+
let bodyIsAnonymous = false;
|
|
711
721
|
let resultName = '';
|
|
712
722
|
let responsePreview = '';
|
|
713
723
|
try {
|
|
@@ -727,6 +737,7 @@ function buildLoginProbeScript(timeoutMs) {
|
|
|
727
737
|
if (data && typeof data === 'object' && !Array.isArray(data)) {
|
|
728
738
|
bodyKeys = Object.keys(data).slice(0, 12);
|
|
729
739
|
bodyHasId = typeof data.id === 'string' && data.id.length > 0;
|
|
740
|
+
bodyIsAnonymous = typeof data.id === 'string' && data.id.startsWith('ua-');
|
|
730
741
|
bodyHasEmail = typeof data.email === 'string' && data.email.includes('@');
|
|
731
742
|
const name = typeof data.name === 'string' ? data.name.trim() : '';
|
|
732
743
|
if (name) resultName = name;
|
|
@@ -757,6 +768,7 @@ function buildLoginProbeScript(timeoutMs) {
|
|
|
757
768
|
bodyKeys,
|
|
758
769
|
bodyHasId,
|
|
759
770
|
bodyHasEmail,
|
|
771
|
+
bodyIsAnonymous,
|
|
760
772
|
name: resultName,
|
|
761
773
|
responsePreview,
|
|
762
774
|
};
|
|
@@ -868,13 +880,25 @@ async function waitForImportedAuthReady() {
|
|
|
868
880
|
let iteration = 0;
|
|
869
881
|
while (Date.now() < timeoutAt) {
|
|
870
882
|
iteration += 1;
|
|
871
|
-
|
|
883
|
+
// Drain all reads before retrying or closing the browser. Promise.all can
|
|
884
|
+
// reject while the other CDP commands are still running against the page.
|
|
885
|
+
const reads = await Promise.allSettled([getUrl(), snapshotText(), pageText(), loginProbe()]);
|
|
886
|
+
const failures = reads.filter((read) => read.status === "rejected");
|
|
887
|
+
const fatal = failures.find((read) => !isAuthNavigationError(read.reason));
|
|
888
|
+
if (fatal) throw fatal.reason;
|
|
889
|
+
if (failures.length > 0) {
|
|
890
|
+
await log(`poll ${iteration}: auth page navigated during verification; discarding partial reads and retrying`);
|
|
891
|
+
await ensureBrowserConnected();
|
|
892
|
+
await sleep(config.auth.pollMs);
|
|
893
|
+
continue;
|
|
894
|
+
}
|
|
895
|
+
const [url, snapshot, body, probe] = reads.map((read) => read.value);
|
|
872
896
|
await writeFile(URL_PATH, `${url}\n`, { mode: 0o600 }).catch(() => undefined);
|
|
873
897
|
await writeFile(SNAPSHOT_PATH, `${snapshot}\n`, { mode: 0o600 }).catch(() => undefined);
|
|
874
898
|
await writeFile(BODY_PATH, `${body}\n`, { mode: 0o600 }).catch(() => undefined);
|
|
875
899
|
const classification = classifyChatPage({ url, snapshot, body, probe });
|
|
876
900
|
await log(
|
|
877
|
-
`poll ${iteration}: url=${JSON.stringify(url)} probe=${JSON.stringify(probe)} classification=${classification.state} hasComposer=${preferredProvider() === "grok" ? snapshot.includes('Ask Grok anything') || snapshot.includes('contenteditable') : snapshot.includes(`textbox \"${
|
|
901
|
+
`poll ${iteration}: url=${JSON.stringify(url)} probe=${JSON.stringify(probe)} classification=${classification.state} hasComposer=${preferredProvider() === "grok" ? snapshot.includes('Ask Grok anything') || snapshot.includes('contenteditable') : CHATGPT_COMPOSER_LABELS.some((label) => snapshot.includes(`textbox \"${label}\"`))} hasAddFiles=${preferredProvider() === "grok" ? snapshot.includes('button \"Attach\"') : snapshot.includes(`button \"${CHATGPT_LABELS.addFiles}\"`)}`,
|
|
878
902
|
);
|
|
879
903
|
if (classification.state === "authenticated_and_ready") return classification;
|
|
880
904
|
if (classification.state === "auth_transitioning") {
|
|
@@ -8,6 +8,7 @@ export interface OracleAuthLoginProbe {
|
|
|
8
8
|
bodyKeys?: string[];
|
|
9
9
|
bodyHasId?: boolean;
|
|
10
10
|
bodyHasEmail?: boolean;
|
|
11
|
+
bodyIsAnonymous?: boolean;
|
|
11
12
|
name?: string;
|
|
12
13
|
responsePreview?: string;
|
|
13
14
|
}
|
|
@@ -26,6 +27,7 @@ export interface OracleAuthPageClassification {
|
|
|
26
27
|
}
|
|
27
28
|
|
|
28
29
|
export declare function normalizeLoginProbeResult(result: unknown): OracleAuthLoginProbe;
|
|
30
|
+
export declare function isAuthNavigationError(error: unknown): boolean;
|
|
29
31
|
export declare function buildAccountChooserCandidateLabels(name?: string): string[];
|
|
30
32
|
export declare function classifyChatAuthPage(args: {
|
|
31
33
|
url: string;
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
// Usage: Imported by auth-bootstrap.mjs and sanity tests to exercise auth classification behavior without driving a browser.
|
|
5
5
|
// Invariants/Assumptions: Inputs are already captured snapshots/probe results from the live browser session; outputs are deterministic and side-effect free.
|
|
6
6
|
|
|
7
|
-
import { snapshotHasUsableComposerControls } from "./chatgpt-ui-helpers.mjs";
|
|
7
|
+
import { CHATGPT_COMPOSER_LABELS, snapshotHasUsableComposerControls } from "./chatgpt-ui-helpers.mjs";
|
|
8
8
|
|
|
9
9
|
/** @typedef {import("./auth-flow-helpers.d.mts").OracleAuthLoginProbe} OracleAuthLoginProbe */
|
|
10
10
|
/** @typedef {import("./auth-flow-helpers.d.mts").OracleAuthPageClassification} OracleAuthPageClassification */
|
|
@@ -31,11 +31,23 @@ export function normalizeLoginProbeResult(result) {
|
|
|
31
31
|
bodyKeys: Array.isArray(value.bodyKeys) ? value.bodyKeys.filter((entry) => typeof entry === "string") : [],
|
|
32
32
|
bodyHasId: value.bodyHasId === true,
|
|
33
33
|
bodyHasEmail: value.bodyHasEmail === true,
|
|
34
|
+
bodyIsAnonymous: value.bodyIsAnonymous === true,
|
|
34
35
|
name: typeof value.name === "string" ? value.name : undefined,
|
|
35
36
|
responsePreview: typeof value.responsePreview === "string" ? value.responsePreview : undefined,
|
|
36
37
|
};
|
|
37
38
|
}
|
|
38
39
|
|
|
40
|
+
/**
|
|
41
|
+
* CDP evaluations can be interrupted by normal redirects. Do not retry arbitrary
|
|
42
|
+
* protocol failures or explicitly closed/disconnected browser errors.
|
|
43
|
+
* @param {unknown} error
|
|
44
|
+
* @returns {boolean}
|
|
45
|
+
*/
|
|
46
|
+
export function isAuthNavigationError(error) {
|
|
47
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
48
|
+
return /Inspected target navigated or closed|Execution context was destroyed|Cannot find context with specified id/i.test(message);
|
|
49
|
+
}
|
|
50
|
+
|
|
39
51
|
/**
|
|
40
52
|
* @param {string | undefined} name
|
|
41
53
|
* @returns {string[]}
|
|
@@ -69,7 +81,7 @@ export function classifyChatAuthPage(args) {
|
|
|
69
81
|
const onAllowedOrigin = args.allowedOrigins.some((origin) => args.url.startsWith(origin));
|
|
70
82
|
const hasComposer = args.snapshot.includes(`textbox "${composerLabel}"`);
|
|
71
83
|
const hasAddFiles = args.snapshot.includes(`button "${addFilesLabel}"`);
|
|
72
|
-
const hasUsableComposer = composerLabel
|
|
84
|
+
const hasUsableComposer = CHATGPT_COMPOSER_LABELS.includes(composerLabel) && addFilesLabel === DEFAULT_ADD_FILES_LABEL
|
|
73
85
|
? snapshotHasUsableComposerControls(args.snapshot)
|
|
74
86
|
: hasComposer && hasAddFiles;
|
|
75
87
|
|
|
@@ -112,7 +124,16 @@ export function classifyChatAuthPage(args) {
|
|
|
112
124
|
return { state: "transient_outage_error", message: `ChatGPT is showing a transient outage/error page. Logs: ${args.logPath}` };
|
|
113
125
|
}
|
|
114
126
|
|
|
115
|
-
|
|
127
|
+
// /backend-api/me also returns HTTP 200 and an id for anonymous visitors.
|
|
128
|
+
// Those ua-* identities must not drive account selection or auth reloads.
|
|
129
|
+
if (args.probe?.bodyIsAnonymous && (args.probe?.onAuthPage || args.probe?.domLoginCta)) {
|
|
130
|
+
return {
|
|
131
|
+
state: "login_required",
|
|
132
|
+
message: `Synced cookies from ${args.cookieSourceLabel}, but ChatGPT returned an anonymous visitor session, not a signed-in account. Sign in to ChatGPT in the source browser profile, quit that browser, then rerun /oracle-auth. Logs: ${args.logPath}`,
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const probeHasAccountIdentity = !args.probe?.bodyIsAnonymous && (args.probe?.bodyHasId === true || args.probe?.bodyHasEmail === true);
|
|
116
137
|
|
|
117
138
|
if (args.probe?.status === 401 || (args.probe?.status === 403 && (!onAllowedOrigin || !hasUsableComposer))) {
|
|
118
139
|
return {
|
|
@@ -4,6 +4,8 @@
|
|
|
4
4
|
// Usage: Imported by run-job.mjs and sanity tests to validate conversation-state heuristics without driving a browser.
|
|
5
5
|
// Invariants/Assumptions: Snapshot text comes from agent-browser `snapshot -i`; URL inputs may be malformed and must fail safely.
|
|
6
6
|
|
|
7
|
+
import { CHATGPT_COMPOSER_LABELS } from "./chatgpt-ui-helpers.mjs";
|
|
8
|
+
|
|
7
9
|
/** @typedef {import("./chatgpt-flow-helpers.d.mts").OracleStableValueState} OracleStableValueState */
|
|
8
10
|
/** @typedef {import("./chatgpt-flow-helpers.d.mts").OracleSendAcceptanceState} OracleSendAcceptanceState */
|
|
9
11
|
|
|
@@ -23,8 +25,9 @@ export function assistantSnapshotSlice(snapshot, composerLabel, responseIndex) {
|
|
|
23
25
|
const nextAssistantIndex = assistantHeadingIndices[responseIndex + 1];
|
|
24
26
|
if (nextAssistantIndex !== undefined) endCandidates.push(nextAssistantIndex);
|
|
25
27
|
|
|
28
|
+
const composerLabels = CHATGPT_COMPOSER_LABELS.includes(composerLabel) ? CHATGPT_COMPOSER_LABELS : [composerLabel];
|
|
26
29
|
const composerIndex = lines.findIndex(
|
|
27
|
-
(line, index) => index > startIndex && line.includes(`textbox "${
|
|
30
|
+
(line, index) => index > startIndex && composerLabels.some((label) => line.includes(`textbox "${label}"`)),
|
|
28
31
|
);
|
|
29
32
|
if (composerIndex !== -1) endCandidates.push(composerIndex);
|
|
30
33
|
|
|
@@ -7,6 +7,7 @@ export interface OracleUiSelection {
|
|
|
7
7
|
autoSwitchToThinking?: boolean;
|
|
8
8
|
}
|
|
9
9
|
|
|
10
|
+
export declare const CHATGPT_COMPOSER_LABELS: readonly string[];
|
|
10
11
|
export declare const CHATGPT_CANONICAL_APP_ORIGINS: readonly string[];
|
|
11
12
|
|
|
12
13
|
export declare function buildAllowedChatGptOrigins(chatUrl: string, authUrl?: string): string[];
|
|
@@ -13,6 +13,8 @@ import { parseSnapshotEntries } from "./artifact-heuristics.mjs";
|
|
|
13
13
|
/** @typedef {{ responseText: string; artifactLabels?: string[]; suspiciousArtifactLabels?: string[] }} CompletionSignatureArgs */
|
|
14
14
|
/** @typedef {{ hasStopStreaming: boolean; hasTargetCopyResponse: boolean; responseText: string; artifactLabels?: string[]; suspiciousArtifactLabels?: string[] }} DerivedCompletionSignatureArgs */
|
|
15
15
|
|
|
16
|
+
export const CHATGPT_COMPOSER_LABELS = Object.freeze(["Chat with ChatGPT", "Ask ChatGPT"]);
|
|
17
|
+
|
|
16
18
|
export const CHATGPT_CANONICAL_APP_ORIGINS = Object.freeze([
|
|
17
19
|
"https://chatgpt.com",
|
|
18
20
|
"https://chat.openai.com",
|
|
@@ -132,7 +134,7 @@ export function requestedEffortLabel(selection) {
|
|
|
132
134
|
* @returns {string}
|
|
133
135
|
*/
|
|
134
136
|
function normalizeChipLabel(label) {
|
|
135
|
-
return normalizeText(label).replace(/, click to remove$/i, "").trim();
|
|
137
|
+
return normalizeText(label).replace(/, click to remove$/i, "").replace(/^\d+(?:\.\d+)?\s+(?=Pro$)/i, "").trim();
|
|
136
138
|
}
|
|
137
139
|
|
|
138
140
|
function parseComposerChipSelection(label) {
|
|
@@ -503,7 +505,9 @@ export function snapshotHasModelConfigurationUi(snapshot) {
|
|
|
503
505
|
const hasEffortCombobox = entries.some(
|
|
504
506
|
(entry) => entry.kind === "combobox" && EFFORT_LABELS.has(entry.value || "") && !entry.disabled,
|
|
505
507
|
);
|
|
506
|
-
|
|
508
|
+
const hasPowerSliderMenu = entries.some((entry) => entry.kind === "menuitem" && entry.label === "Power" && !entry.disabled)
|
|
509
|
+
&& entries.some((entry) => entry.kind === "menuitem" && entry.label === "Select model" && !entry.disabled);
|
|
510
|
+
return visibleFamilies.size >= 2 || visibleRadioFamilies.size >= 2 || visibleCompactControls.length >= 2 || hasCompactIntelligenceMenu || hasIntelligenceHeading || hasEffortCombobox || hasPowerSliderMenu;
|
|
507
511
|
}
|
|
508
512
|
|
|
509
513
|
/**
|
|
@@ -513,7 +517,7 @@ export function snapshotHasModelConfigurationUi(snapshot) {
|
|
|
513
517
|
export function snapshotHasUsableComposerControls(snapshot) {
|
|
514
518
|
/** @type {SnapshotEntry[]} */
|
|
515
519
|
const entries = parseSnapshotEntries(snapshot);
|
|
516
|
-
const hasComposer = entries.some((entry) => entry.kind === "textbox" && entry.label
|
|
520
|
+
const hasComposer = entries.some((entry) => entry.kind === "textbox" && CHATGPT_COMPOSER_LABELS.includes(entry.label || "") && !entry.disabled);
|
|
517
521
|
const hasAddFiles = entries.some((entry) => entry.kind === "button" && entry.label === "Add files and more" && !entry.disabled);
|
|
518
522
|
return hasComposer && hasAddFiles;
|
|
519
523
|
}
|
|
@@ -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,54 @@ 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) {
|
|
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 = '[data-testid="composer-intelligence-picker-content"] [role="menuitem"][aria-label="Power"]';
|
|
1547
|
+
await agentBrowser(job, "focus", selector);
|
|
1548
|
+
const timeoutAt = Date.now() + MODEL_CONFIGURATION_SETTLE_TIMEOUT_MS;
|
|
1549
|
+
let moves = 0;
|
|
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 (value === target) {
|
|
1570
|
+
if (!state.description.startsWith(`${requested}, ${target + 1} of ${tiers.length}.`)) {
|
|
1571
|
+
throw new Error(`Could not verify ChatGPT Power tier: ${requested}`);
|
|
1572
|
+
}
|
|
1573
|
+
await log(`Verified ChatGPT Power tier ${requested} for family=${job.selection.modelFamily} effort=${job.selection.effort || "(none)"}`);
|
|
1574
|
+
await agentBrowser(job, "press", "Escape");
|
|
1575
|
+
await waitForModelConfigurationToSettle(job, { stronglyVerified: true });
|
|
1576
|
+
return;
|
|
1577
|
+
}
|
|
1578
|
+
await agentBrowser(job, "press", value < target ? "ArrowRight" : "ArrowLeft");
|
|
1579
|
+
moves++;
|
|
1580
|
+
await sleep(150);
|
|
1581
|
+
}
|
|
1582
|
+
throw new Error(`ChatGPT Power slider did not reach requested tier: ${requested}`);
|
|
1583
|
+
}
|
|
1584
|
+
|
|
1529
1585
|
async function configureModel(job) {
|
|
1530
1586
|
if (isGrokJob(job)) return configureGrokModel(job);
|
|
1531
1587
|
const initialSnapshot = await snapshotText(job);
|
|
@@ -1536,6 +1592,10 @@ async function configureModel(job) {
|
|
|
1536
1592
|
|
|
1537
1593
|
await log(`Configuring model family=${job.selection.modelFamily} effort=${job.selection?.effort || "(none)"}`);
|
|
1538
1594
|
let familySnapshot = await openModelConfiguration(job);
|
|
1595
|
+
if (findEntry(familySnapshot, (entry) => entry.kind === "menuitem" && entry.label === "Power" && !entry.disabled)) {
|
|
1596
|
+
await configurePowerSlider(job);
|
|
1597
|
+
return;
|
|
1598
|
+
}
|
|
1539
1599
|
let verificationSnapshot = familySnapshot;
|
|
1540
1600
|
|
|
1541
1601
|
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.21",
|
|
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,8 @@
|
|
|
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-model.test.mjs",
|
|
40
42
|
"scripts/oracle-chatgpt-preset-proof.mjs"
|
|
41
43
|
],
|
|
42
44
|
"pi": {
|
|
@@ -51,10 +53,11 @@
|
|
|
51
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",
|
|
52
54
|
"typecheck": "tsc --noEmit -p tsconfig.json",
|
|
53
55
|
"typecheck:worker-helpers": "tsc --noEmit -p tsconfig.worker-helpers.json",
|
|
54
|
-
"sanity:oracle": "node scripts/oracle-sanity-runner.mjs",
|
|
56
|
+
"sanity:oracle": "node --test scripts/oracle-auth.test.mjs scripts/oracle-model.test.mjs && node scripts/oracle-sanity-runner.mjs",
|
|
55
57
|
"pack:check": "npm pack --dry-run",
|
|
56
58
|
"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
59
|
"test": "npm run verify:oracle",
|
|
60
|
+
"check:compat": "npm run verify:oracle && node --test scripts/oracle-native.test.mjs",
|
|
58
61
|
"prepublishOnly": "npm run release:check",
|
|
59
62
|
"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
63
|
"smoke:platform": "node scripts/platform-smoke.mjs",
|
|
@@ -71,7 +74,7 @@
|
|
|
71
74
|
"release:proof:chatgpt-presets": "node scripts/oracle-chatgpt-preset-proof.mjs check",
|
|
72
75
|
"smoke:real:packed": "node scripts/oracle-real-smoke.mjs run --mode packed",
|
|
73
76
|
"smoke:real:source": "node scripts/oracle-real-smoke.mjs run --mode source",
|
|
74
|
-
"sanity:oracle:platform": "node scripts/oracle-sanity-runner.mjs --mode platform",
|
|
77
|
+
"sanity:oracle:platform": "node --test scripts/oracle-auth.test.mjs scripts/oracle-model.test.mjs && node scripts/oracle-sanity-runner.mjs --mode platform",
|
|
75
78
|
"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
79
|
},
|
|
77
80
|
"dependencies": {
|
|
@@ -87,8 +90,8 @@
|
|
|
87
90
|
"protobufjs": "7.6.1"
|
|
88
91
|
},
|
|
89
92
|
"devDependencies": {
|
|
90
|
-
"@earendil-works/pi-ai": "
|
|
91
|
-
"@earendil-works/pi-coding-agent": "
|
|
93
|
+
"@earendil-works/pi-ai": "0.86.1",
|
|
94
|
+
"@earendil-works/pi-coding-agent": "0.86.1",
|
|
92
95
|
"@types/node": "^22.19.19",
|
|
93
96
|
"esbuild": "^0.28.0",
|
|
94
97
|
"tsx": "^4.22.3",
|
|
@@ -0,0 +1,129 @@
|
|
|
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 polling retries a complete observation after each navigation-interrupted read", async () => {
|
|
50
|
+
for (const read of ["getUrl", "snapshotText", "pageText", "loginProbe"]) {
|
|
51
|
+
let calls = 0;
|
|
52
|
+
const good = { getUrl: "https://chatgpt.com/", snapshotText: readySnapshot, pageText: "", loginProbe: readyProbe };
|
|
53
|
+
const h = pollingHarness({ [read]: async () => { if (++calls === 1) throw navigationError(); return good[read]; } });
|
|
54
|
+
assert.equal((await h.poll()).state, "authenticated_and_ready");
|
|
55
|
+
assert.equal(calls, 2);
|
|
56
|
+
assert(h.logs.some((line) => line.includes("discarding partial reads")));
|
|
57
|
+
}
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test("auth polling drains concurrent commands before surfacing a fatal failure", async () => {
|
|
61
|
+
let drained = false;
|
|
62
|
+
const failure = new Error("CDP permission denied");
|
|
63
|
+
const h = pollingHarness({ loginProbe: async () => { throw failure; }, pageText: async () => {
|
|
64
|
+
await new Promise((resolve) => setTimeout(resolve, 10)); drained = true; return "";
|
|
65
|
+
} });
|
|
66
|
+
await assert.rejects(h.poll(), (error) => error === failure && drained);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test("fatal failures take precedence over concurrent navigation failures", async () => {
|
|
70
|
+
const h = pollingHarness({ loginProbe: async () => { throw navigationError(); }, getUrl: async () => { throw new Error("Browser disconnected"); } });
|
|
71
|
+
await assert.rejects(h.poll(), /Browser disconnected/);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test("closed browsers are not relaunched and persistent navigation respects the deadline", async () => {
|
|
75
|
+
const closed = pollingHarness({ loginProbe: async () => { throw navigationError(); },
|
|
76
|
+
ensureBrowserConnected: async () => { throw new Error("The isolated oracle browser was closed"); } });
|
|
77
|
+
await assert.rejects(closed.poll(), /browser was closed/);
|
|
78
|
+
const navigating = pollingHarness({ loginProbe: async () => { throw navigationError(); },
|
|
79
|
+
config: { auth: { bootstrapTimeoutMs: 20, pollMs: 1 } } });
|
|
80
|
+
await assert.rejects(navigating.poll(), /Timed out verifying/);
|
|
81
|
+
assert.equal(navigating.captures, 1);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
test("only known navigation/context destruction errors are retryable", () => {
|
|
85
|
+
for (const message of ["Execution context was destroyed, most likely because of a navigation", "Cannot find context with specified id", navigationError().message]) {
|
|
86
|
+
assert(isAuthNavigationError(new Error(message)));
|
|
87
|
+
}
|
|
88
|
+
for (const message of ["Target closed", "Browser disconnected", "CDP permission denied", "command timed out"]) {
|
|
89
|
+
assert.equal(isAuthNavigationError(new Error(message)), false);
|
|
90
|
+
}
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
for (const [name, source] of [["bootstrap", bootstrap], ["job worker", worker]]) {
|
|
94
|
+
test(`${name} probe distinguishes anonymous ua-* IDs from account IDs`, async () => {
|
|
95
|
+
const build = vm.runInNewContext(`(${functionSource(source, "buildLoginProbeScript")})`, {
|
|
96
|
+
toAsyncJsonScript: (body) => `(async () => { ${body} })()`,
|
|
97
|
+
});
|
|
98
|
+
for (const id of ["ua-visitor", "user-signed-in"]) {
|
|
99
|
+
const probe = normalizeLoginProbeResult(await vm.runInNewContext(build(50), {
|
|
100
|
+
location: { href: "https://chatgpt.com/auth/login", hostname: "chatgpt.com", pathname: "/auth/login" },
|
|
101
|
+
document: { querySelectorAll: () => [] }, AbortController, setTimeout, clearTimeout,
|
|
102
|
+
fetch: async () => ({ status: 200, headers: { get: () => "application/json" },
|
|
103
|
+
clone: () => ({ json: async () => ({ id, email: "", name: "" }) }) }),
|
|
104
|
+
}));
|
|
105
|
+
assert.equal(probe.bodyIsAnonymous, id.startsWith("ua-"));
|
|
106
|
+
const result = classify({ url: "https://chatgpt.com/auth/login", snapshot: "", body: "Log in or sign up", probe });
|
|
107
|
+
assert.equal(result.state, id.startsWith("ua-") ? "login_required" : "auth_transitioning");
|
|
108
|
+
if (id.startsWith("ua-")) {
|
|
109
|
+
const h = pollingHarness({ loginProbe: async () => probe });
|
|
110
|
+
await assert.rejects(h.poll(), /anonymous visitor session/);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
test("auth recognizes current and legacy ready composers without trusting anonymous identity", () => {
|
|
117
|
+
// Cookie-only /me probes may run before hydration or without the app's bearer token.
|
|
118
|
+
const probe = { ...readyProbe, bodyIsAnonymous: true, bodyHasEmail: false };
|
|
119
|
+
const observe = (snapshot, overrides = {}) => classify({ url: "https://chatgpt.com/", snapshot, body: "", probe: { ...probe, ...overrides } }).state;
|
|
120
|
+
assert.equal(observe(""), "unknown");
|
|
121
|
+
for (const label of ["Chat with ChatGPT", "Ask ChatGPT"]) {
|
|
122
|
+
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]`;
|
|
123
|
+
assert.equal(observe(snapshot), "authenticated_and_ready", `${label} should finish auth verification`);
|
|
124
|
+
assert.equal(observe(snapshot, { domLoginCta: true }), "login_required");
|
|
125
|
+
assert.equal(observe(snapshot.replace('[ref=e3]', '[disabled, ref=e3]')), "unknown");
|
|
126
|
+
assert.equal(observe(snapshot.replace('- button "Add files and more" [ref=e2]', '')), "unknown");
|
|
127
|
+
}
|
|
128
|
+
assert.equal(observe('- textbox "Search" [ref=e1]\n- button "Add files and more" [ref=e2]'), "unknown");
|
|
129
|
+
});
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { readFile } from "node:fs/promises";
|
|
3
|
+
import vm from "node:vm";
|
|
4
|
+
import test from "node:test";
|
|
5
|
+
import * as ui from "../extensions/oracle/worker/chatgpt-ui-helpers.mjs";
|
|
6
|
+
import { parseSnapshotEntries } from "../extensions/oracle/worker/artifact-heuristics.mjs";
|
|
7
|
+
|
|
8
|
+
const worker = await readFile(new URL("../extensions/oracle/worker/run-job.mjs", import.meta.url), "utf8");
|
|
9
|
+
const names = ["findEntry", "matchesModelConfigurationOpener", "canUseOpenModelMenuForSelection",
|
|
10
|
+
"openModelConfiguration", "configurePowerSlider", "configureModel", "waitForModelConfigurationToSettle", "toJsonScript"];
|
|
11
|
+
const functions = names.flatMap((name) => {
|
|
12
|
+
const start = worker.indexOf(`function ${name}(`);
|
|
13
|
+
if (start < 0) return []; // Allows the same regression to run against the pre-slider worker.
|
|
14
|
+
const end = worker.indexOf("\n}", start) + 2;
|
|
15
|
+
return `${worker.slice(start - 6, start) === "async " ? "async " : ""}${worker.slice(start, end)}`;
|
|
16
|
+
}).join("\n");
|
|
17
|
+
|
|
18
|
+
function harness({ initial = 3, max = "4", wrongLabel = false, stuck = false, mountingReads = 0 } = {}) {
|
|
19
|
+
const labels = ["Instant", "Medium", "High", "Extra High", "Pro"];
|
|
20
|
+
let value = initial;
|
|
21
|
+
let open = false;
|
|
22
|
+
let verified = false;
|
|
23
|
+
const snapshot = () => open
|
|
24
|
+
? '- button "Thinking effort" [expanded=true, ref=e1]\n- menu "Thinking effort" [ref=e2]\n- menuitem "Select model" [ref=e3]\n- menuitem "Power" [ref=e4]'
|
|
25
|
+
: `- button "${value === 4 ? "6 Pro" : labels[value]}" [expanded=false, ref=e1]\n- textbox "Ask ChatGPT" [ref=e5]\n- button "Add files and more" [ref=e6]`;
|
|
26
|
+
const control = {
|
|
27
|
+
closest: () => null,
|
|
28
|
+
getAttribute: () => "description",
|
|
29
|
+
querySelector: () => mountingReads-- > 0 ? null : ({ getAttribute: (name) => ({ "aria-valuemin": "0", "aria-valuemax": max, "aria-valuenow": String(value) })[name] }),
|
|
30
|
+
};
|
|
31
|
+
const context = vm.createContext({ ...ui, parseSnapshotEntries, Date, Error, JSON, Number,
|
|
32
|
+
CHATGPT_LABELS: { configure: "Configure..." },
|
|
33
|
+
MODEL_CONFIGURATION_OPEN_TIMEOUT_MS: 30, MODEL_CONFIGURATION_SETTLE_TIMEOUT_MS: 30,
|
|
34
|
+
MODEL_CONFIGURATION_CLOSE_RETRY_MS: 1, MODEL_CONFIGURATION_SETTLE_POLL_MS: 0,
|
|
35
|
+
isGrokJob: () => false, snapshotText: async () => snapshot(),
|
|
36
|
+
throwIfProviderTransientError: () => {}, dismissProFeedbackModal: async () => false,
|
|
37
|
+
clickRef: async () => { open = !open; }, sleep: async () => {},
|
|
38
|
+
composerControlsVisible: ui.snapshotHasUsableComposerControls,
|
|
39
|
+
log: async (line) => { if (line.startsWith("Verified ChatGPT Power tier")) verified = true; },
|
|
40
|
+
agentBrowser: async (_job, command, arg) => {
|
|
41
|
+
if (command === "focus") { assert(open); return; }
|
|
42
|
+
if (command !== "press") return;
|
|
43
|
+
if (arg === "Escape") open = false;
|
|
44
|
+
else if (!stuck) value = Math.max(0, Math.min(4, value + (arg === "ArrowRight" ? 1 : -1)));
|
|
45
|
+
},
|
|
46
|
+
evalPage: async (_job, script) => JSON.parse(vm.runInNewContext(script, { document: {
|
|
47
|
+
querySelector: () => open ? control : null,
|
|
48
|
+
getElementById: () => ({ textContent: `${wrongLabel ? "Unknown" : labels[value]}, ${value + 1} of 5.` }),
|
|
49
|
+
} })),
|
|
50
|
+
});
|
|
51
|
+
vm.runInContext(functions, context);
|
|
52
|
+
return { configure: (selection) => context.configureModel({ selection }),
|
|
53
|
+
get state() { return { value, open, verified }; } };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
test("worker configures every preset through the current Power picker and closes it", async () => {
|
|
57
|
+
for (const [selection, expected] of [
|
|
58
|
+
[{ modelFamily: "instant", autoSwitchToThinking: false }, 0],
|
|
59
|
+
[{ modelFamily: "instant", autoSwitchToThinking: true }, 0],
|
|
60
|
+
[{ modelFamily: "thinking", effort: "light" }, 1],
|
|
61
|
+
[{ modelFamily: "thinking", effort: "standard" }, 1],
|
|
62
|
+
[{ modelFamily: "thinking", effort: "extended" }, 2],
|
|
63
|
+
[{ modelFamily: "thinking", effort: "heavy" }, 3],
|
|
64
|
+
[{ modelFamily: "pro", effort: "standard" }, 4],
|
|
65
|
+
[{ modelFamily: "pro", effort: "extended" }, 4],
|
|
66
|
+
]) {
|
|
67
|
+
const h = harness({ initial: expected === 4 ? 0 : 4, mountingReads: 2 });
|
|
68
|
+
await h.configure(selection);
|
|
69
|
+
assert.deepEqual(h.state, { value: expected, open: false, verified: true });
|
|
70
|
+
}
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
test("worker refuses unknown, mislabelled, or unresponsive Power controls", async () => {
|
|
74
|
+
for (const [options, error] of [
|
|
75
|
+
[{ max: "5" }, /unrecognized range/],
|
|
76
|
+
[{ wrongLabel: true }, /Could not verify ChatGPT Power tier/],
|
|
77
|
+
[{ stuck: true }, /did not reach requested tier/],
|
|
78
|
+
]) {
|
|
79
|
+
const h = harness(options);
|
|
80
|
+
await assert.rejects(h.configure({ modelFamily: "instant", autoSwitchToThinking: false }), error);
|
|
81
|
+
assert.equal(h.state.verified, false);
|
|
82
|
+
}
|
|
83
|
+
});
|
|
@@ -11,7 +11,8 @@ import { basename, dirname, join, resolve } from "node:path";
|
|
|
11
11
|
const DEFAULT_PROVIDER = "zai";
|
|
12
12
|
const DEFAULT_MODEL = "glm-5.2";
|
|
13
13
|
const DEFAULT_TIMEOUT_MS = 180_000;
|
|
14
|
-
const EXPECTED_PI_VERSION =
|
|
14
|
+
const EXPECTED_PI_VERSION = process.env.PI_COMPAT_EXPECTED_VERSION
|
|
15
|
+
?? JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")).devDependencies["@earendil-works/pi-coding-agent"];
|
|
15
16
|
const PACKAGE_NAME = "pi-oracle";
|
|
16
17
|
|
|
17
18
|
function usage() {
|
|
@@ -22,6 +23,8 @@ Modes:
|
|
|
22
23
|
source Inner-loop/debug only. Loads this checkout with pi --no-extensions -e extensions/oracle/index.ts.
|
|
23
24
|
|
|
24
25
|
Environment:
|
|
26
|
+
PI_HOST_CLI selected Pi manifest bin entry, invoked with this Node executable (default: pi on PATH)
|
|
27
|
+
PI_COMPAT_EXPECTED_VERSION expected Pi version (default: package development baseline)
|
|
25
28
|
PI_ORACLE_REAL_TEST_PROVIDER pi provider for the test agent (default: ${DEFAULT_PROVIDER})
|
|
26
29
|
PI_ORACLE_REAL_TEST_MODEL pi model for the test agent (default: ${DEFAULT_MODEL})
|
|
27
30
|
PI_ORACLE_REAL_TEST_TIMEOUT_MS per-agent timeout in ms (default: ${DEFAULT_TIMEOUT_MS})
|
|
@@ -65,7 +68,10 @@ function positiveTimeoutMs(value, label) {
|
|
|
65
68
|
|
|
66
69
|
function commandExists(command, args = ["--version"]) {
|
|
67
70
|
return new Promise((resolvePromise) => {
|
|
68
|
-
const
|
|
71
|
+
const selectedCli = command === piCommand() && env("PI_HOST_CLI");
|
|
72
|
+
const child = spawn(selectedCli ? process.execPath : command, selectedCli ? [selectedCli, ...args] : args, {
|
|
73
|
+
stdio: "ignore", shell: !selectedCli && process.platform === "win32",
|
|
74
|
+
});
|
|
69
75
|
child.on("error", () => resolvePromise(false));
|
|
70
76
|
child.on("exit", (code) => resolvePromise(code === 0 || code === 1));
|
|
71
77
|
});
|
|
@@ -104,7 +110,7 @@ async function doctor() {
|
|
|
104
110
|
const failures = [];
|
|
105
111
|
const warnings = [];
|
|
106
112
|
const requiredCommands = [
|
|
107
|
-
[
|
|
113
|
+
[piCommand(), ["--version"], "pi CLI"],
|
|
108
114
|
[process.platform === "win32" ? "tar.exe" : "tar", ["--version"], "tar"],
|
|
109
115
|
["zstd", ["--version"], "zstd"],
|
|
110
116
|
[process.platform === "win32" ? "agent-browser.cmd" : "agent-browser", ["--version"], "agent-browser"],
|
|
@@ -134,10 +140,11 @@ async function doctor() {
|
|
|
134
140
|
function runCommand(command, args, options) {
|
|
135
141
|
const timeoutMs = positiveTimeoutMs(options.timeoutMs, "command timeout");
|
|
136
142
|
return new Promise((resolvePromise) => {
|
|
137
|
-
const
|
|
143
|
+
const selectedCli = command === piCommand() && env("PI_HOST_CLI");
|
|
144
|
+
const child = spawn(selectedCli ? process.execPath : command, selectedCli ? [selectedCli, ...args] : args, {
|
|
138
145
|
cwd: options.cwd,
|
|
139
146
|
env: options.env,
|
|
140
|
-
shell: process.platform === "win32",
|
|
147
|
+
shell: !selectedCli && process.platform === "win32",
|
|
141
148
|
stdio: ["ignore", "pipe", "pipe"],
|
|
142
149
|
});
|
|
143
150
|
let stdout = "";
|
|
@@ -234,7 +241,7 @@ function entryExists(entries, path) {
|
|
|
234
241
|
}
|
|
235
242
|
|
|
236
243
|
function piCommand() {
|
|
237
|
-
return process.platform === "win32" ? "pi.cmd" : "pi";
|
|
244
|
+
return env("PI_HOST_CLI") ?? (process.platform === "win32" ? "pi.cmd" : "pi");
|
|
238
245
|
}
|
|
239
246
|
|
|
240
247
|
function npmCommand() {
|