pi-oracle 0.7.19 → 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 +23 -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/lib/tools.ts +3 -3
- 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 +57 -14
- package/extensions/oracle/worker/run-job.mjs +96 -27
- 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,28 @@
|
|
|
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
|
+
|
|
17
|
+
## 0.7.20 - 2026-07-28
|
|
18
|
+
|
|
19
|
+
### Fixed
|
|
20
|
+
- accepted undifferentiated ChatGPT Pro compact menus (bare Pro / Instant 5.5) so pro presets can configure without Standard/Extended rows
|
|
21
|
+
- replaced `\S` tool-schema non-blank patterns with a GBNF-safe character class so llama.cpp-backed OpenAI-compatible servers can compile oracle tool schemas
|
|
22
|
+
|
|
23
|
+
### Validation
|
|
24
|
+
- ran the full `npm run release:check` local gate before release
|
|
25
|
+
|
|
3
26
|
## 0.7.19 - 2026-07-16
|
|
4
27
|
|
|
5
28
|
### Changed
|
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
|
|
|
@@ -81,7 +81,7 @@ const ORACLE_SUBMIT_PARAMS = Type.Object({
|
|
|
81
81
|
files: Type.Array(Type.String({
|
|
82
82
|
description: "Project-relative file or directory path to include in the archive.",
|
|
83
83
|
minLength: 1,
|
|
84
|
-
pattern: "
|
|
84
|
+
pattern: "^.*[^ \\t\\r\\n].*$",
|
|
85
85
|
}), {
|
|
86
86
|
description: "Exact project-relative files/directories to include in the oracle archive.",
|
|
87
87
|
minItems: 1,
|
|
@@ -99,7 +99,7 @@ const ORACLE_SUBMIT_PARAMS = Type.Object({
|
|
|
99
99
|
chatGptConversationId: Type.Optional(Type.String({
|
|
100
100
|
description: "Existing ChatGPT conversation id, or full https://chatgpt.com/c/... URL, to continue. Omit for default behavior: starting a fresh oracle thread. Do not combine with followUpJobId.",
|
|
101
101
|
minLength: 1,
|
|
102
|
-
pattern: "
|
|
102
|
+
pattern: "^.*[^ \\t\\r\\n].*$",
|
|
103
103
|
})),
|
|
104
104
|
}, { additionalProperties: false });
|
|
105
105
|
|
|
@@ -109,7 +109,7 @@ const ORACLE_PREFLIGHT_PARAMS = Type.Object({
|
|
|
109
109
|
chatGptConversationId: Type.Optional(Type.String({
|
|
110
110
|
description: "Existing ChatGPT conversation id, or full https://chatgpt.com/c/... URL, whose provider/thread readiness should be checked. Do not combine with followUpJobId.",
|
|
111
111
|
minLength: 1,
|
|
112
|
-
pattern: "
|
|
112
|
+
pattern: "^.*[^ \\t\\r\\n].*$",
|
|
113
113
|
})),
|
|
114
114
|
}, { additionalProperties: false });
|
|
115
115
|
|
|
@@ -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",
|
|
@@ -29,9 +31,10 @@ const AUTO_SWITCH_LABEL = "Auto-switch to Thinking";
|
|
|
29
31
|
const THINKING_EFFORT_COMBOBOX_LABEL = "Thinking effort";
|
|
30
32
|
const PRO_THINKING_EFFORT_COMBOBOX_LABEL = "Pro thinking effort";
|
|
31
33
|
const EFFORT_LABELS = new Set(["Light", "Standard", "Extended", "Heavy"]);
|
|
32
|
-
const COMPACT_INTELLIGENCE_MENU_PATTERN = /(?:Intelligence.*Instant.*Medium.*High.*Pro|^(?:Instant|Medium|High|Extra High|Pro Standard|
|
|
33
|
-
|
|
34
|
-
const
|
|
34
|
+
const COMPACT_INTELLIGENCE_MENU_PATTERN = /(?:Intelligence.*Instant.*Medium.*High.*Pro|^(?:Instant|Medium|High|Extra High|Pro(?: Standard| Extended)?)$)/i;
|
|
35
|
+
// Bare Instant is the legacy top-level family radio; compact Instant rows are versioned (Instant 5s / Instant 5.5).
|
|
36
|
+
const COMPACT_INTELLIGENCE_CONTROL_PATTERN = /^(?:Instant\s+[\d.]+s?|Medium(?:\s+5\s*[–-]\s*30s)?|High(?:\s+15\s*[–-]\s*60s)?|Extra High|Pro(?:\s+5\+\s*min|\s+Standard|\s+Extended)?)$/i;
|
|
37
|
+
const COMPACT_INTELLIGENCE_OPENER_PATTERN = /^(?:Instant(?:\s+[\d.]+s?)?|Medium|High|Extra High|Pro(?: Standard| Extended)?)$/i;
|
|
35
38
|
const BARE_EFFORT_PATTERN = /^(light|standard|extended|heavy)(?:, click to remove)?$/i;
|
|
36
39
|
const INSTANT_CHIP_PATTERN = /^instant(?:, click to remove)?$/i;
|
|
37
40
|
const THINKING_CHIP_PATTERN = /^(?:(light|standard|extended|heavy)\s+)?thinking(?:, click to remove)?$/i;
|
|
@@ -131,7 +134,7 @@ export function requestedEffortLabel(selection) {
|
|
|
131
134
|
* @returns {string}
|
|
132
135
|
*/
|
|
133
136
|
function normalizeChipLabel(label) {
|
|
134
|
-
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();
|
|
135
138
|
}
|
|
136
139
|
|
|
137
140
|
function parseComposerChipSelection(label) {
|
|
@@ -184,7 +187,7 @@ function parseCompactIntelligenceSelection(label) {
|
|
|
184
187
|
const normalized = normalizeChipLabel(label);
|
|
185
188
|
if (!COMPACT_INTELLIGENCE_CONTROL_PATTERN.test(normalized)) return undefined;
|
|
186
189
|
|
|
187
|
-
if (/^Instant
|
|
190
|
+
if (/^Instant\s+[\d.]+s?$/i.test(normalized)) {
|
|
188
191
|
return {
|
|
189
192
|
modelFamily: /** @type {OracleUiModelFamily} */ ("instant"),
|
|
190
193
|
compactTier: "instant",
|
|
@@ -219,6 +222,8 @@ function parseCompactIntelligenceSelection(label) {
|
|
|
219
222
|
compactTier: "pro",
|
|
220
223
|
};
|
|
221
224
|
}
|
|
225
|
+
// "Pro 5+ min" is always the compact Pro tier. Bare "Pro" is ambiguous with the
|
|
226
|
+
// legacy top-level family radio and is handled with sibling context below.
|
|
222
227
|
if (/^Pro\s+5\+\s*min$/i.test(normalized)) {
|
|
223
228
|
return {
|
|
224
229
|
modelFamily: /** @type {OracleUiModelFamily} */ ("pro"),
|
|
@@ -229,6 +234,24 @@ function parseCompactIntelligenceSelection(label) {
|
|
|
229
234
|
return undefined;
|
|
230
235
|
}
|
|
231
236
|
|
|
237
|
+
function parseBareProCompactSelection(label) {
|
|
238
|
+
if (/click to remove/i.test(String(label || ""))) return undefined;
|
|
239
|
+
if (!/^Pro$/i.test(normalizeChipLabel(label))) return undefined;
|
|
240
|
+
return {
|
|
241
|
+
modelFamily: /** @type {OracleUiModelFamily} */ ("pro"),
|
|
242
|
+
compactTier: "pro",
|
|
243
|
+
};
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
function snapshotHasCompactTierSiblings(entries, exceptLabel) {
|
|
247
|
+
const except = normalizeChipLabel(exceptLabel).toLowerCase();
|
|
248
|
+
return entries.some((entry) => {
|
|
249
|
+
if (entry.disabled || entry.kind !== "menuitemradio") return false;
|
|
250
|
+
if (normalizeChipLabel(entry.label).toLowerCase() === except) return false;
|
|
251
|
+
return Boolean(parseCompactIntelligenceSelection(entry.label));
|
|
252
|
+
});
|
|
253
|
+
}
|
|
254
|
+
|
|
232
255
|
function hasRemovableComposerModelChip(entries) {
|
|
233
256
|
return entries.some(
|
|
234
257
|
(entry) => entry.kind === "button" && /click to remove/i.test(String(entry.label || "")) && parseComposerChipSelection(entry.label),
|
|
@@ -237,7 +260,7 @@ function hasRemovableComposerModelChip(entries) {
|
|
|
237
260
|
|
|
238
261
|
function hasCompactIntelligenceMenuContext(entries) {
|
|
239
262
|
return entries.some((entry) => !entry.disabled && entry.kind === "menu" && COMPACT_INTELLIGENCE_MENU_PATTERN.test(normalizeText(entry.label)))
|
|
240
|
-
|| entries.some((entry) => !entry.disabled && entry.kind === "menuitemradio" && checkedState(entry) === true &&
|
|
263
|
+
|| entries.some((entry) => !entry.disabled && entry.kind === "menuitemradio" && checkedState(entry) === true && compactSelectionFromEntry(entry, entries));
|
|
241
264
|
}
|
|
242
265
|
|
|
243
266
|
function hasLegacyEffortCombobox(entries) {
|
|
@@ -248,18 +271,36 @@ function hasLegacyEffortCombobox(entries) {
|
|
|
248
271
|
});
|
|
249
272
|
}
|
|
250
273
|
|
|
251
|
-
function compactSelectionFromEntry(entry,
|
|
274
|
+
function compactSelectionFromEntry(entry, entries = [], options = {}) {
|
|
252
275
|
if (entry.disabled) return undefined;
|
|
253
276
|
const kind = entry.kind || "";
|
|
254
|
-
if (COMPACT_INTELLIGENCE_CONTROL_KINDS.has(kind))
|
|
277
|
+
if (COMPACT_INTELLIGENCE_CONTROL_KINDS.has(kind)) {
|
|
278
|
+
const parsed = parseCompactIntelligenceSelection(entry.label);
|
|
279
|
+
if (parsed) return parsed;
|
|
280
|
+
// Bare "Pro" is compact only when versioned Instant / Medium / High / Extra High siblings exist.
|
|
281
|
+
if (snapshotHasCompactTierSiblings(entries, entry.label)) {
|
|
282
|
+
return parseBareProCompactSelection(entry.label);
|
|
283
|
+
}
|
|
284
|
+
return undefined;
|
|
285
|
+
}
|
|
255
286
|
if (options.allowClosedButtons && kind === "button" && !/\bexpanded=true\b/.test(String(entry.line || ""))) {
|
|
256
|
-
|
|
287
|
+
const parsed = parseCompactIntelligenceSelection(entry.label);
|
|
288
|
+
if (parsed) return parsed;
|
|
289
|
+
const barePro = parseBareProCompactSelection(entry.label);
|
|
290
|
+
if (barePro) return barePro;
|
|
291
|
+
// Closed composer pills keep bare Instant after the compact menu closes.
|
|
292
|
+
if (/^Instant$/i.test(normalizeChipLabel(entry.label))) {
|
|
293
|
+
return {
|
|
294
|
+
modelFamily: /** @type {OracleUiModelFamily} */ ("instant"),
|
|
295
|
+
compactTier: "instant",
|
|
296
|
+
};
|
|
297
|
+
}
|
|
257
298
|
}
|
|
258
299
|
return undefined;
|
|
259
300
|
}
|
|
260
301
|
|
|
261
302
|
export function matchesCompactIntelligenceControlLabel(label) {
|
|
262
|
-
return Boolean(parseCompactIntelligenceSelection(label));
|
|
303
|
+
return Boolean(parseCompactIntelligenceSelection(label) || parseBareProCompactSelection(label));
|
|
263
304
|
}
|
|
264
305
|
|
|
265
306
|
export function snapshotHasClosedCompactSelection(snapshot, selection) {
|
|
@@ -330,7 +371,7 @@ function detectCompactIntelligenceSelection(entries) {
|
|
|
330
371
|
}
|
|
331
372
|
|
|
332
373
|
export function matchesRequestedModelControlLabel(label, selection) {
|
|
333
|
-
const compactSelection = parseCompactIntelligenceSelection(label);
|
|
374
|
+
const compactSelection = parseCompactIntelligenceSelection(label) || parseBareProCompactSelection(label);
|
|
334
375
|
if (compactSelection) return compactSelectionMatchesRequested(selection, compactSelection);
|
|
335
376
|
return matchesModelFamilyLabel(label, selection.modelFamily);
|
|
336
377
|
}
|
|
@@ -455,7 +496,7 @@ export function snapshotHasModelConfigurationUi(snapshot) {
|
|
|
455
496
|
),
|
|
456
497
|
);
|
|
457
498
|
const visibleCompactControls = entries.filter(
|
|
458
|
-
(entry) => !entry.disabled && entry.kind === "menuitemradio" &&
|
|
499
|
+
(entry) => !entry.disabled && entry.kind === "menuitemradio" && compactSelectionFromEntry(entry, entries),
|
|
459
500
|
);
|
|
460
501
|
const hasCompactIntelligenceMenu = entries.some(
|
|
461
502
|
(entry) => !entry.disabled && entry.kind === "menu" && COMPACT_INTELLIGENCE_MENU_PATTERN.test(normalizeText(entry.label)),
|
|
@@ -464,7 +505,9 @@ export function snapshotHasModelConfigurationUi(snapshot) {
|
|
|
464
505
|
const hasEffortCombobox = entries.some(
|
|
465
506
|
(entry) => entry.kind === "combobox" && EFFORT_LABELS.has(entry.value || "") && !entry.disabled,
|
|
466
507
|
);
|
|
467
|
-
|
|
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;
|
|
468
511
|
}
|
|
469
512
|
|
|
470
513
|
/**
|
|
@@ -474,7 +517,7 @@ export function snapshotHasModelConfigurationUi(snapshot) {
|
|
|
474
517
|
export function snapshotHasUsableComposerControls(snapshot) {
|
|
475
518
|
/** @type {SnapshotEntry[]} */
|
|
476
519
|
const entries = parseSnapshotEntries(snapshot);
|
|
477
|
-
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);
|
|
478
521
|
const hasAddFiles = entries.some((entry) => entry.kind === "button" && entry.label === "Add files and more" && !entry.disabled);
|
|
479
522
|
return hasComposer && hasAddFiles;
|
|
480
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
|
}
|
|
@@ -895,8 +900,8 @@ function snapshotHasCompactIntelligenceMenuControls(snapshot) {
|
|
|
895
900
|
return Boolean(findEntry(snapshot, (candidate) => {
|
|
896
901
|
if (candidate.disabled) return false;
|
|
897
902
|
const label = normalizeSnapshotLabel(candidate.label);
|
|
898
|
-
return (candidate.kind === "menu" && /(?:Intelligence.*Instant.*Medium.*High.*Pro|^(?:Instant|Medium|High|Extra High|Pro Extended)$)/i.test(label))
|
|
899
|
-
|| (candidate.kind === "menuitemradio" && /^(?:Instant
|
|
903
|
+
return (candidate.kind === "menu" && /(?:Intelligence.*Instant.*Medium.*High.*Pro|^(?:Instant|Medium|High|Extra High|Pro(?: Standard| Extended)?)$)/i.test(label))
|
|
904
|
+
|| (candidate.kind === "menuitemradio" && /^(?:Instant\s+[\d.]+s?|Medium(?:\s+5\s*[–-]\s*30s)?|High(?:\s+15\s*[–-]\s*60s)?|Extra High|Pro(?:\s+5\+\s*min|\s+Standard|\s+Extended)?)$/i.test(label));
|
|
900
905
|
}));
|
|
901
906
|
}
|
|
902
907
|
|
|
@@ -904,7 +909,7 @@ function matchesRequestedModelControl(candidate, selection, options = {}) {
|
|
|
904
909
|
if (!["button", "radio", "menuitemradio"].includes(candidate.kind || "") || typeof candidate.label !== "string" || candidate.disabled) return false;
|
|
905
910
|
if (candidate.kind === "button") {
|
|
906
911
|
if (/\bexpanded=true\b/.test(String(candidate.line || ""))) return false;
|
|
907
|
-
if (options.ignoreCompactTierButtons && /^(?:Instant
|
|
912
|
+
if (options.ignoreCompactTierButtons && /^(?:Instant(?:\s+[\d.]+s?)?|Medium|High|Extra High|Pro(?: Standard| Extended)?)$/i.test(candidate.label)) return false;
|
|
908
913
|
if (options.ignoreCompactOnlyButtons && /^(?:Medium|High|Extra High)$/i.test(candidate.label)) return false;
|
|
909
914
|
}
|
|
910
915
|
if (selection.modelFamily === "pro" && /^Pro(?:\s+Extended)?$/i.test(candidate.label)) return true;
|
|
@@ -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);
|
|
@@ -1582,25 +1642,34 @@ async function configureModel(job) {
|
|
|
1582
1642
|
if (effortLabel && !effortSelectionVisible(familySnapshot, effortLabel)) {
|
|
1583
1643
|
const opened = await openEffortDropdown(job);
|
|
1584
1644
|
if (!opened) {
|
|
1585
|
-
|
|
1586
|
-
|
|
1587
|
-
|
|
1588
|
-
|
|
1589
|
-
|
|
1645
|
+
// Current ChatGPT Pro menus sometimes expose only undifferentiated "Pro" with no Standard/Extended rows.
|
|
1646
|
+
const afterOpenAttempt = await snapshotText(job);
|
|
1647
|
+
if (job.selection.modelFamily === "pro" && snapshotStronglyMatchesRequestedModel(afterOpenAttempt, job.selection)) {
|
|
1648
|
+
await log(`Pro effort dropdown unavailable for ${effortLabel}; accepting undifferentiated Pro selection`);
|
|
1649
|
+
verificationSnapshot = afterOpenAttempt;
|
|
1650
|
+
familySnapshot = afterOpenAttempt;
|
|
1651
|
+
} else {
|
|
1652
|
+
throw new Error(`Could not open effort dropdown for requested effort: ${effortLabel}`);
|
|
1653
|
+
}
|
|
1590
1654
|
} else {
|
|
1591
|
-
await
|
|
1592
|
-
|
|
1593
|
-
|
|
1594
|
-
|
|
1595
|
-
|
|
1596
|
-
|
|
1597
|
-
|
|
1598
|
-
|
|
1599
|
-
|
|
1600
|
-
|
|
1601
|
-
|
|
1655
|
+
await agentBrowser(job, "wait", "300");
|
|
1656
|
+
if (job.selection.modelFamily === "pro" && await maybeClickLabeledEntry(job, `Pro ${effortLabel}`, { kind: "menuitemradio" })) {
|
|
1657
|
+
// Current ChatGPT exposes Pro effort choices as nested menu radio items.
|
|
1658
|
+
} else {
|
|
1659
|
+
await clickLabeledEntry(job, effortLabel, { kind: "option" });
|
|
1660
|
+
}
|
|
1661
|
+
await agentBrowser(job, "wait", "400");
|
|
1662
|
+
const effortSnapshot = await snapshotText(job);
|
|
1663
|
+
verificationSnapshot = effortSnapshot;
|
|
1664
|
+
const selectedEffort = findEntry(
|
|
1665
|
+
effortSnapshot,
|
|
1666
|
+
(candidate) => candidate.kind === "combobox" && candidate.value === effortLabel && !candidate.disabled,
|
|
1667
|
+
);
|
|
1668
|
+
if (!selectedEffort && !effortSelectionVisible(effortSnapshot, effortLabel)) {
|
|
1669
|
+
throw new Error(`Requested effort did not remain selected: ${effortLabel}`);
|
|
1670
|
+
}
|
|
1671
|
+
familySnapshot = effortSnapshot;
|
|
1602
1672
|
}
|
|
1603
|
-
familySnapshot = effortSnapshot;
|
|
1604
1673
|
}
|
|
1605
1674
|
}
|
|
1606
1675
|
|
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() {
|