diff --git a/.gitattributes b/.gitattributes index 59b94b8b..d48e2dfe 100644 --- a/.gitattributes +++ b/.gitattributes @@ -2,3 +2,8 @@ tests/fixtures/oracle-0.17.0/promptComposer.js text eol=lf tests/fixtures/oracle-0.17.1/thinkingTime.pristine.js text eol=lf bin/oracle-compat/0.17.2/*.patch text eol=lf whitespace=-blank-at-eol tests/fixtures/oracle-0.17.2/thinkingTime.pristine.js text eol=lf +bin/oracle-compat/0.17.3/*.patch text eol=lf whitespace=-blank-at-eol +tests/fixtures/oracle-0.17.3/assistantResponse.pristine.js text eol=lf +tests/fixtures/oracle-0.17.3/browserConfig.pristine.js text eol=lf +tests/fixtures/oracle-0.17.3/browserIndex.pristine.js text eol=lf +tests/fixtures/oracle-0.17.3/thinkingTime.pristine.js text eol=lf diff --git a/.github/workflows/upstream-drift.yml b/.github/workflows/upstream-drift.yml index b27a2944..b9da903c 100644 --- a/.github/workflows/upstream-drift.yml +++ b/.github/workflows/upstream-drift.yml @@ -19,7 +19,7 @@ jobs: - name: Summary if: always() run: cat upstream-drift.json >> "$GITHUB_STEP_SUMMARY" - - uses: actions/upload-artifact@v4 + - uses: actions/upload-artifact@v7 if: always() with: name: upstream-drift diff --git a/README.en.md b/README.en.md index 9a8f57f9..917418d5 100644 --- a/README.en.md +++ b/README.en.md @@ -6,7 +6,7 @@ A Windows automation toolkit that delegates planning, research, review, code changes, and testing to web ChatGPT while keeping local Codex work focused on transport, recovery, identity, hashes, and the final deterministic gate. -The current release is `1.8.0`. +The current release is `1.8.1`. It connects two upstream tools: @@ -105,10 +105,14 @@ backpressure queue: four by default, configurable from 1 through 20 with - An Oracle browser profile signed in to ChatGPT - One manually registered DevSpace app in ChatGPT Developer Mode -The validated combination is Oracle `0.17.2` and DevSpace `1.0.7`. The installer +The validated combination is Oracle `0.17.3` and DevSpace `1.0.7`. The installer applies Windows compatibility patches only when exact upstream file hashes -match. Oracle `0.16.1`, `0.17.0`, and `0.17.1` remain available only for exact -recovery of runs already persisted with those versions. +match. Oracle 0.17.3's upstream answer-placeholder bounding, manual-login +reattach cookie-sync opt-in, Japanese picker labels, and explicit headless +handling are preserved under the local hash-gated patches; live browser +validation is not yet performed. Oracle `0.16.1`, `0.17.0`, `0.17.1`, and +`0.17.2` remain available only for exact recovery of runs already persisted +with those versions. ## Install diff --git a/README.md b/README.md index 34caccb7..24347bd9 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ Codex가 웹 ChatGPT에 계획·리서치·검토·코드 구현을 맡기고, 로컬 Codex는 제출·복구·해시·최종 테스트만 담당하도록 만드는 Windows용 자동화 도구입니다. -현재 릴리스는 `1.8.0`입니다. +현재 릴리스는 `1.8.1`입니다. 이 프로젝트는 다음 두 도구를 연결합니다. @@ -101,10 +101,13 @@ SHA-256을 하위 프로세스 시작 전에 검사합니다. 작업 상태는 - 브라우저에서 ChatGPT에 로그인된 Oracle 프로필 - ChatGPT Developer Mode에 최초 한 번 수동 등록한 DevSpace 앱 -현재 검증된 조합은 Oracle `0.17.2`와 DevSpace `1.0.7`입니다. 설치기는 +현재 검증된 조합은 Oracle `0.17.3`와 DevSpace `1.0.7`입니다. 설치기는 정확한 파일 해시가 일치할 때만 Windows 호환 패치를 적용합니다. Oracle -`0.16.1`, `0.17.0`, `0.17.1`은 이미 저장된 해당 버전 실행의 정확한 복구에만 -사용할 수 있습니다. +0.17.3의 상위 변경(답변 placeholder 제한, manual-login 재연결의 명시적 +쿠키 동기화 opt-in, 일본어 picker 라벨, 명시적 headless 처리)은 로컬 +hash-gated 패치 아래 그대로 보존하며 아직 라이브 브라우저 검증은 하지 +않았습니다. Oracle `0.16.1`, `0.17.0`, `0.17.1`, `0.17.2`는 이미 저장된 +해당 버전 실행의 정확한 복구에만 사용할 수 있습니다. ## 설치 diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index 488675ed..afef6f3f 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -2,14 +2,15 @@ This repository ships no copy of the Oracle package, DevSpace, Codex, browser binaries, or account data. It ships narrow textual compatibility patches for -the active hash-verified Oracle 0.17.2 and DevSpace 1.0.7 npm distributions. -Oracle 0.16.1, 0.17.0, and 0.17.1 patch instructions remain only for exact -recovery of runs persisted with those versions. +the active hash-verified Oracle 0.17.3 and DevSpace 1.0.7 npm distributions. +Oracle 0.16.1, 0.17.0, 0.17.1, and 0.17.2 patch instructions remain only for +exact recovery of runs persisted with those versions. - `hehee9/multi-gpt@4f5e130` is MIT-licensed. Its attribution and the recorded `server.mjs` hash must be preserved when its upstream-compatible integration is changed. - `@steipete/oracle` is an external MIT-licensed browser automation package. - The active tested version is 0.17.2. Versions 0.16.1, 0.17.0, and 0.17.1 are - retained only to recover exact persisted runs. Its package source is not vendored. + The active tested version is 0.17.3. Versions 0.16.1, 0.17.0, 0.17.1, and + 0.17.2 are retained only to recover exact persisted runs. Its package source + is not vendored. Files under `bin/oracle-compat` are derivative patch instructions and retain the following upstream MIT notice. - `@waishnav/devspace` is an external MIT-licensed MCP workspace server. The diff --git a/bin/chatgpt_oracle_compat.py b/bin/chatgpt_oracle_compat.py index cd464762..d3a2e774 100644 --- a/bin/chatgpt_oracle_compat.py +++ b/bin/chatgpt_oracle_compat.py @@ -9,8 +9,8 @@ from pathlib import Path from typing import Any, Sequence -SUPPORTED_VERSION = "0.17.2" -RECOVERABLE_VERSIONS = ("0.16.1", "0.17.0", "0.17.1", SUPPORTED_VERSION) +SUPPORTED_VERSION = "0.17.3" +RECOVERABLE_VERSIONS = ("0.16.1", "0.17.0", "0.17.1", "0.17.2", SUPPORTED_VERSION) CREATE_NO_WINDOW = 0x08000000 PATCHES_0161 = { "dist/src/cli/browserTabs.js": { @@ -343,11 +343,55 @@ }, } +PATCHES_0173 = { + "dist/src/browser/chromeLifecycle.js": { + "patch": "chromeLifecycle.patch", + "pristine": "312b45c44d4cd69a3a057e7bd1584b58182b4b37bc88f6ce6c7d11e216267c81", + "patched": "61440e467d51031efb7bfc319aef05de7c9061585e5eec148d0e353938eb2093", + }, + "dist/src/browser/recoverConversation.js": { + "patch": "recoverConversation.patch", + "pristine": "d7e39d21acf07e6d227e761944519e11cd8d93930629cc87555d7de75a42d1ca", + "patched": "cc2a036f6e2409ae7edceee1f381a5062cd6cc5cd1618af465a1b384081ed69e", + }, + "dist/src/browser/profileCopy.js": { + "patch": "profileCopy.patch", + "pristine": "06c692861f8a4c1a8769f957b9c582426a13bf4972262c47c1f24a87b239064f", + "patched": "71459a25b7c46f57bae6f23a5498301f6f6a1d39addf0c1cd4eee1d99b03372c", + }, + "dist/src/cli/browserConfig.js": { + "patch": "browserConfig.patch", + "pristine": "13b304a1b41cbc85257d9340a620bccd4d18bc52a36285ba46c2f72af84f0f84", + "patched": "a76f338e1afb3573c3436cd261ccbcefacd9c879c71a45e110cf7a3602a06d22", + }, + "dist/src/browser/index.js": { + "patch": "browserIndex.patch", + "pristine": "421f15c6693799571d586d80b7fc35b10492a63acf78d901e21786bf6ec71a90", + "patched": "cb7b828902163bac941f5890f78edd136cf723e17e262c1347e2843df20c3e44", + }, + "dist/src/browser/actions/assistantResponse.js": { + "patch": "assistantResponse.patch", + "pristine": "93d2465ed7dce43d8093a91bada7656bc9ba7ba3729d2fcc43229fa8aa6e36de", + "patched": "aff8f7cb4e926b0e56c4b02456f54983b14fffa9e01f595fed4fd44a338d41f4", + }, + "dist/src/browser/actions/promptComposer.js": { + "patch": "promptComposer.patch", + "pristine": "db090a5fb6d13c4c88a68b5e474a53a19c3857295a64c3ba4a0eef1868d06000", + "patched": "3767d8a6702e42191e8195641ad2f0834882bed9cda1362a723c906249402d96", + }, + "dist/src/browser/actions/thinkingTime.js": { + "patch": "thinkingTime.strict.patch", + "pristine": "6ff4420e81570f6c0a4e277bdd993caf66739c3f633a7cdb733ed645bec2acda", + "patched": "98724eaf24e27d6f75b3eb7795c49650aee0be6a9a3698e09882c6d3a06c3185", + }, +} + VERSION_PATCHES = { "0.16.1": PATCHES_0161, "0.17.0": PATCHES_0170, "0.17.1": PATCHES_0171, "0.17.2": PATCHES_0172, + "0.17.3": PATCHES_0173, } # Active-version alias retained for existing callers and tests. PATCHES = VERSION_PATCHES[SUPPORTED_VERSION] diff --git a/bin/chatgpt_oracle_profiles.py b/bin/chatgpt_oracle_profiles.py index 70b4aadb..6a6c38d6 100644 --- a/bin/chatgpt_oracle_profiles.py +++ b/bin/chatgpt_oracle_profiles.py @@ -22,7 +22,7 @@ REGULAR_THINKING_TIME = "extra-high" DEVSPACE_APP_NAME = "DevSpace" # Current ChatGPT exposes Pro as the maximum effort for GPT-5.6 Sol, not as a -# separate model row. Oracle 0.17.2 verifies that Pro effort independently +# separate model row. Oracle 0.17.3 verifies that Pro effort independently # (Power 5 of 5 proof); `heavy` is only Oracle's compatibility token for it. PRO_MODEL = "gpt-5.6-sol" PRO_COMPOSER_PROMPT = "Read the attached prompt/instructions and all attached files, then complete the task." diff --git a/bin/chatgpt_oracle_state.py b/bin/chatgpt_oracle_state.py index 154409db..c08c8c89 100644 --- a/bin/chatgpt_oracle_state.py +++ b/bin/chatgpt_oracle_state.py @@ -23,10 +23,22 @@ REGULAR_MODEL = "gpt-5.6" REGULAR_MODEL_STRATEGY = "select" REGULAR_THINKING_TIME = "extra-high" -ORACLE_ACTIVE_VERSION = "0.17.2" -ORACLE_RECOVERABLE_VERSIONS = ("0.16.1", "0.17.0", "0.17.1", ORACLE_ACTIVE_VERSION) +ORACLE_ACTIVE_VERSION = "0.17.3" +ORACLE_RECOVERABLE_VERSIONS = ("0.16.1", "0.17.0", "0.17.1", "0.17.2", ORACLE_ACTIVE_VERSION) WAIT_CAPABLE_VERSIONS = {"0.17.0", "0.17.1", ORACLE_ACTIVE_VERSION} -ORACLE_UI_FAILURE_SETTLEMENT_VERSIONS = {"0.17.1", "0.17.2"} +ORACLE_UI_FAILURE_SETTLEMENT_VERSIONS = {"0.17.1", "0.17.2", ORACLE_ACTIVE_VERSION} +# Each strict pre-submit proof binds markers emitted by exactly two runtime +# generations: 0.17.2 (the previous active runtime, now exact-recovery-only) +# and the current active runtime. These are explicit per-marker sets on +# purpose — never an ORACLE_ACTIVE_VERSION alias, or promotion silently drops +# stored 0.17.2 runs from settlement. Older runtimes remain deliberately +# excluded because these marker-specific recovery proofs did not bind them +# before the 0.17.3 promotion. +ORACLE_APP_MENTION_ROUTE_UNCONFIRMED_PROOF_VERSIONS = {"0.17.2", ORACLE_ACTIVE_VERSION} +ORACLE_THINKING_TIME_STRICT_PROOF_VERSIONS = {"0.17.2", ORACLE_ACTIVE_VERSION} +ORACLE_MODEL_SWITCHER_PROOF_VERSIONS = {"0.17.2", ORACLE_ACTIVE_VERSION} +ORACLE_COPY_PROFILE_MANUAL_LOGIN_CONFLICT_PROOF_VERSIONS = {"0.17.2", ORACLE_ACTIVE_VERSION} +ORACLE_PROFILE_COPY_RSYNC_MISSING_PROOF_VERSIONS = {"0.17.2", ORACLE_ACTIVE_VERSION} ORACLE_PACKAGE = "@steipete/oracle" STATE_SCHEMA = "codex.chatgpt.oracle-run-state/v1" STATUSES = {"prepared", "running", "complete", "failed", "attention_required", "abandoned"} @@ -1073,6 +1085,9 @@ def _comprehensive_no_submission_evidence(state_path: Path) -> dict[str, Any] | only proves that the run is eligible for an explicit user adjudication: no output or conversation URL exists, Oracle reported an eligible composer failure, and exact recovery has neither a live tab nor a saved URL. + The APP_MENTION_ROUTE_UNCONFIRMED branch binds markers emitted by the + 0.17.2 runtime (exact-recovery-only) and the active runtime; the plain + prompt-timeout message is version-agnostic. """ state = load_state(state_path) authority = str(state.get("session_authority") or "") @@ -1111,7 +1126,8 @@ def _comprehensive_no_submission_evidence(state_path: Path) -> dict[str, Any] | app_route_unconfirmed = ( str(state.get("transport") or "").casefold() == "devspace" and str(state.get("app_name") or "").casefold() == "devspace" - and normalize_oracle_version(oracle.get("resolved_version")) == ORACLE_ACTIVE_VERSION + and normalize_oracle_version(oracle.get("resolved_version")) + in ORACLE_APP_MENTION_ROUTE_UNCONFIRMED_PROOF_VERSIONS and { f"ERROR: {ORACLE_APP_MENTION_ROUTE_UNCONFIRMED_MARKER}", ( @@ -1471,7 +1487,12 @@ def _settlement_logs_have_conversation_url(state_path: Path) -> bool: def _direct_app_route_no_submission_evidence(state_path: Path) -> dict[str, Any] | None: - """Bind an exact direct pre-send app-route rejection to user adjudication.""" + """Bind an exact direct pre-send app-route rejection to user adjudication. + + The APP_MENTION_ROUTE_UNCONFIRMED marker is bound only for the 0.17.2 + runtime (exact-recovery-only) and the active runtime; older runtimes stay + fail-closed because their promptComposer patches were never bound here. + """ state = load_state(state_path) if ( str(state.get("session_authority") or "") not in {"submitted_unknown", "pre_submit"} @@ -1513,7 +1534,8 @@ def _direct_app_route_no_submission_evidence(state_path: Path) -> dict[str, Any] locator = str(oracle.get("session_locator") or oracle.get("slug") or "").strip() stdout_lines = {line.strip() for line in stdout_text.splitlines()} if ( - normalize_oracle_version(oracle.get("resolved_version")) != ORACLE_ACTIVE_VERSION + normalize_oracle_version(oracle.get("resolved_version")) + not in ORACLE_APP_MENTION_ROUTE_UNCONFIRMED_PROOF_VERSIONS or not locator or f"Session: {locator}" not in stdout_text or { @@ -2172,11 +2194,13 @@ def proven_pre_submit_thinking_time_failure(state_path: Path) -> dict[str, Any] """Prove the final strict Power-slider selector refused before send. Accepts only Oracle's exact selection-unverified/unknown-outcome - diagnostics from the active thinking-time patch, with the configured + diagnostics from the strict thinking-time patch, with the configured Oracle effort mapped to its exact visible Power tier, and only while the exact conversation URL and any durable output are absent. Anything else keeps submitted-unknown ownership and therefore the project lock - fail-closed. + fail-closed. These markers are bound only for the 0.17.2 runtime + (exact-recovery-only) and the active runtime, which are the two + generations that shipped the strict patch. """ state = load_state(state_path) if str(state.get("session_authority") or "") not in {"pre_submit", "submitted_unknown"}: @@ -2220,7 +2244,11 @@ def proven_pre_submit_thinking_time_failure(state_path: Path) -> dict[str, Any] return None oracle = state.get("oracle") if isinstance(state.get("oracle"), dict) else {} locator = str(oracle.get("session_locator") or oracle.get("slug") or "").strip() - if not locator or normalize_oracle_version(oracle.get("resolved_version")) != ORACLE_ACTIVE_VERSION: + if ( + not locator + or normalize_oracle_version(oracle.get("resolved_version")) + not in ORACLE_THINKING_TIME_STRICT_PROOF_VERSIONS + ): return None return { "schema": "codex.chatgpt.oracle-pre-submit-ui-failure/v1", @@ -2242,6 +2270,8 @@ def proven_pre_submit_model_switcher_failure(state_path: Path) -> dict[str, Any] diagnostic, with both output and conversation evidence absent. A generic browser error, a recorded conversation URL, or any durable output remains submitted-unknown and therefore keeps the project lock fail-closed. + The diagnostic is bound only for the 0.17.2 runtime (exact-recovery-only) + and the active runtime, which both ship the model-switcher error path. """ state = load_state(state_path) if str(state.get("session_authority") or "") not in {"pre_submit", "submitted_unknown"}: @@ -2263,7 +2293,11 @@ def proven_pre_submit_model_switcher_failure(state_path: Path) -> dict[str, Any] return None oracle = state.get("oracle") if isinstance(state.get("oracle"), dict) else {} locator = str(oracle.get("session_locator") or oracle.get("slug") or "").strip() - if not locator or normalize_oracle_version(oracle.get("resolved_version")) != ORACLE_ACTIVE_VERSION: + if ( + not locator + or normalize_oracle_version(oracle.get("resolved_version")) + not in ORACLE_MODEL_SWITCHER_PROOF_VERSIONS + ): return None return { "schema": "codex.chatgpt.oracle-pre-submit-ui-failure/v1", @@ -2278,7 +2312,12 @@ def proven_pre_submit_model_switcher_failure(state_path: Path) -> dict[str, Any] def proven_pre_submit_copy_profile_manual_login_conflict(state_path: Path) -> dict[str, Any] | None: - """Prove Oracle rejected mutually exclusive profile modes before browser launch.""" + """Prove Oracle rejected mutually exclusive profile modes before browser launch. + + The conflict marker is bound only for the 0.17.2 runtime (exact-recovery- + only) and the active runtime, which both ship the browserIndex conflict + guard; older runtimes never emitted it. + """ state = load_state(state_path) if str(state.get("session_authority") or "") not in {"pre_submit", "submitted_unknown"}: return None @@ -2293,7 +2332,8 @@ def proven_pre_submit_copy_profile_manual_login_conflict(state_path: Path) -> di stderr_record = _artifact_bytes(state, "stderr") if ( not copy_profile - or str(oracle.get("resolved_version") or "").removeprefix("oracle ").strip() != ORACLE_ACTIVE_VERSION + or normalize_oracle_version(oracle.get("resolved_version")) + not in ORACLE_COPY_PROFILE_MANUAL_LOGIN_CONFLICT_PROOF_VERSIONS or output_is_nonempty(output) or stdout_record is None or stderr_record is None @@ -2319,7 +2359,12 @@ def proven_pre_submit_copy_profile_manual_login_conflict(state_path: Path) -> di def proven_pre_submit_profile_copy_rsync_missing(state_path: Path) -> dict[str, Any] | None: - """Prove profile copy failed before Chrome because Oracle invoked rsync.""" + """Prove profile copy failed before Chrome because Oracle invoked rsync. + + The rsync-spawn marker is bound only for the 0.17.2 runtime (exact- + recovery-only) and the active runtime, which both ship the profileCopy + rsync spawn; older runtimes never emitted it. + """ state = load_state(state_path) if str(state.get("session_authority") or "") not in {"pre_submit", "submitted_unknown"}: return None @@ -2334,7 +2379,8 @@ def proven_pre_submit_profile_copy_rsync_missing(state_path: Path) -> dict[str, stderr_record = _artifact_bytes(state, "stderr") if ( not copy_profile - or str(oracle.get("resolved_version") or "").removeprefix("oracle ").strip() != ORACLE_ACTIVE_VERSION + or normalize_oracle_version(oracle.get("resolved_version")) + not in ORACLE_PROFILE_COPY_RSYNC_MISSING_PROOF_VERSIONS or output_is_nonempty(output) or stdout_record is None or stderr_record is None diff --git a/bin/oracle-compat/0.17.3/assistantResponse.patch b/bin/oracle-compat/0.17.3/assistantResponse.patch new file mode 100644 index 00000000..4dd5caf4 --- /dev/null +++ b/bin/oracle-compat/0.17.3/assistantResponse.patch @@ -0,0 +1,14 @@ +diff --git a/dist/src/browser/actions/assistantResponse.js b/dist/src/browser/actions/assistantResponse.js +index c0b9f0a..8967e9e 100644 +--- a/dist/src/browser/actions/assistantResponse.js ++++ b/dist/src/browser/actions/assistantResponse.js +@@ -230,7 +230,8 @@ export async function waitForAssistantResponse(Runtime, timeoutMs, logger, minTu + throw error; + } + else if (source === "evaluation") { +- const recovered = await recoverAssistantResponse(Runtime, timeoutMs, logger, minTurnIndex, expectedConversationId); ++ const remainingMs = Math.max(0, timeoutMs - (Date.now() - start)); ++ const recovered = await recoverAssistantResponse(Runtime, remainingMs, logger, minTurnIndex, expectedConversationId); + if (recovered) { + return recovered; + } diff --git a/bin/oracle-compat/0.17.3/browserConfig.patch b/bin/oracle-compat/0.17.3/browserConfig.patch new file mode 100644 index 00000000..1a895731 --- /dev/null +++ b/bin/oracle-compat/0.17.3/browserConfig.patch @@ -0,0 +1,15 @@ +diff --git a/dist/src/cli/browserConfig.js b/dist/src/cli/browserConfig.js +index 3b16fe5..173c898 100644 +--- a/dist/src/cli/browserConfig.js ++++ b/dist/src/cli/browserConfig.js +@@ -73,7 +73,9 @@ export async function buildBrowserConfig(options) { + throw new Error("--copy-profile cannot be combined with --browser-keep-browser: the copied profile is a throwaway that is deleted after the run, so it must not be retained."); + } + if (options.copyProfile && options.browserManualLogin) { +- throw new Error("--copy-profile cannot be combined with --browser-manual-login: choose either a throwaway copied profile or the persistent manual-login profile."); ++ // Windows defaults to the persistent manual-login profile. An explicit ++ // copy-profile request is the isolation authority for this run. ++ options.browserManualLogin = false; + } + if (options.copyProfile && options.remoteChrome) { + throw new Error("--copy-profile cannot be combined with --remote-chrome: copied profiles require a locally launched Chrome instance."); diff --git a/bin/oracle-compat/0.17.3/browserIndex.patch b/bin/oracle-compat/0.17.3/browserIndex.patch new file mode 100644 index 00000000..8c46739f --- /dev/null +++ b/bin/oracle-compat/0.17.3/browserIndex.patch @@ -0,0 +1,51 @@ +diff --git a/dist/src/browser/index.js b/dist/src/browser/index.js +index 3dab970..b3e65ec 100644 +--- a/dist/src/browser/index.js ++++ b/dist/src/browser/index.js +@@ -671,6 +671,9 @@ export async function runBrowserMode(options) { + } + return runRemoteBrowserMode(promptText, attachments, config, logger, options); + } ++ if (usingCopiedProfile && config.manualLogin) { ++ config = { ...config, manualLogin: false, manualLoginProfileDir: null }; ++ } + const manualLogin = Boolean(config.manualLogin); + if (manualLogin && usingCopiedProfile) { + throw new BrowserAutomationError("--copy-profile cannot be combined with --browser-manual-login: choose either a throwaway copied profile or the persistent manual-login profile.", { stage: "profile-config" }); +@@ -3088,6 +3091,7 @@ export function isWebSocketClosureError(error) { + message.includes("target closed")); + } + async function waitForAssistantResponseWithReload(Runtime, Page, timeoutMs, logger, minTurnIndex, expectedConversationId) { ++ const startedAt = Date.now(); + try { + return await waitForAssistantResponse(Runtime, timeoutMs, logger, minTurnIndex, expectedConversationId); + } +@@ -3099,14 +3103,26 @@ async function waitForAssistantResponseWithReload(Runtime, Page, timeoutMs, logg + if (!conversationUrl || !isConversationUrl(conversationUrl)) { + throw error; + } ++ let remainingMs = Math.max(0, timeoutMs - (Date.now() - startedAt)); ++ if (remainingMs === 0) { ++ throw error; ++ } + logger("Assistant response stalled; reloading conversation and retrying once"); + await Page.navigate({ url: conversationUrl }); +- await waitForResumedConversationHydration(Runtime, timeoutMs, logger, { ++ remainingMs = Math.max(0, timeoutMs - (Date.now() - startedAt)); ++ if (remainingMs === 0) { ++ throw error; ++ } ++ await waitForResumedConversationHydration(Runtime, remainingMs, logger, { + requirePriorTurns: true, + requirePromptReady: false, + expectedConversationUrl: conversationUrl, + }); +- return await waitForAssistantResponse(Runtime, timeoutMs, logger, minTurnIndex, expectedConversationId); ++ remainingMs = Math.max(0, timeoutMs - (Date.now() - startedAt)); ++ if (remainingMs === 0) { ++ throw error; ++ } ++ return await waitForAssistantResponse(Runtime, remainingMs, logger, minTurnIndex, expectedConversationId); + } + } + function shouldReloadAfterAssistantError(error) { diff --git a/bin/oracle-compat/0.17.3/chromeLifecycle.patch b/bin/oracle-compat/0.17.3/chromeLifecycle.patch new file mode 100644 index 00000000..87eba60c --- /dev/null +++ b/bin/oracle-compat/0.17.3/chromeLifecycle.patch @@ -0,0 +1,30 @@ +diff --git a/dist/src/browser/chromeLifecycle.js b/dist/src/browser/chromeLifecycle.js +index a68a7c1..1ae7cc1 100644 +--- a/dist/src/browser/chromeLifecycle.js ++++ b/dist/src/browser/chromeLifecycle.js +@@ -41,8 +41,8 @@ export async function launchChrome(config, userDataDir, logger) { + return Object.assign(launcher, { host: connectHost ?? "127.0.0.1" }); + } + export async function positionChromeWindowOffscreen(client, logger) { +- if (process.platform !== "darwin") { +- logger("Window hiding is only supported on macOS"); ++ if (process.platform !== "darwin" && process.platform !== "win32") { ++ logger("Window hiding is only supported on macOS and Windows"); + return; + } + try { +@@ -560,10 +560,10 @@ function buildChromeFlags(headless, debugBindAddress, hideWindow = false) { + if (headless) { + flags.push("--headless=new"); + } +- else if (hideWindow && process.platform === "darwin") { +- // Cmd-H stops macOS Chrome from compositing the page, which can swallow +- // trusted CDP clicks and retain the prompt as a draft. Keeping the window +- // off-screen avoids desktop disruption while preserving normal rendering. ++ else if (hideWindow && (process.platform === "darwin" || process.platform === "win32")) { ++ // Hiding/minimizing Chrome can stop compositing and swallow trusted CDP ++ // clicks. Keeping the window off-screen avoids desktop disruption while ++ // preserving normal rendering on macOS and Windows. + flags.push("--window-position=-32000,-32000"); + } + // Opt-in only: container/CI Chromium often cannot use the sandbox. Callers must diff --git a/bin/oracle-compat/0.17.3/profileCopy.patch b/bin/oracle-compat/0.17.3/profileCopy.patch new file mode 100644 index 00000000..daf4f546 --- /dev/null +++ b/bin/oracle-compat/0.17.3/profileCopy.patch @@ -0,0 +1,47 @@ +diff --git a/dist/src/browser/profileCopy.js b/dist/src/browser/profileCopy.js +index c253a98..5e3913e 100644 +--- a/dist/src/browser/profileCopy.js ++++ b/dist/src/browser/profileCopy.js +@@ -47,18 +47,31 @@ export async function copyChromeProfile(srcUserDataDir, destDir, requestedProfil + // `Local State` is required (holds the Keychain-wrapped key that decrypts the + // cookies), so a copy failure must fail fast — otherwise the run continues with + // a profile that silently looks logged-out. +- const args = ["-a"]; +- for (const exclude of RSYNC_EXCLUDES) { +- args.push("--exclude", exclude); ++ if (process.platform === "win32") { ++ const excluded = RSYNC_EXCLUDES.map((value) => value.replaceAll("\\", "/").replace(/\/$/, "")); ++ await cp(srcProfile, destProfile, { ++ recursive: true, ++ force: true, ++ filter: (source) => { ++ const relative = path.relative(srcProfile, source).replaceAll("\\", "/"); ++ return !excluded.some((value) => relative === value || relative.startsWith(`${value}/`)); ++ }, ++ }); ++ } ++ else { ++ const args = ["-a"]; ++ for (const exclude of RSYNC_EXCLUDES) { ++ args.push("--exclude", exclude); ++ } ++ args.push(`${srcProfile}/`, `${destProfile}/`); ++ await new Promise((resolve, reject) => { ++ const child = spawn("rsync", args, { stdio: "ignore" }); ++ child.on("error", (err) => reject(new Error(`--copy-profile requires rsync on PATH (spawn failed): ${err.message}`))); ++ child.on("close", (code) => code === 0 || code === 24 ++ ? resolve() ++ : reject(new Error(`rsync failed copying Chrome profile (exit ${code})`))); ++ }); + } +- args.push(`${srcProfile}/`, `${destProfile}/`); +- await new Promise((resolve, reject) => { +- const child = spawn("rsync", args, { stdio: "ignore" }); +- child.on("error", (err) => reject(new Error(`--copy-profile requires rsync on PATH (spawn failed): ${err.message}`))); +- child.on("close", (code) => code === 0 || code === 24 +- ? resolve() +- : reject(new Error(`rsync failed copying Chrome profile (exit ${code})`))); +- }); + return profileDirectory; + } + catch (error) { diff --git a/bin/oracle-compat/0.17.3/promptComposer.patch b/bin/oracle-compat/0.17.3/promptComposer.patch new file mode 100644 index 00000000..3eaa782c --- /dev/null +++ b/bin/oracle-compat/0.17.3/promptComposer.patch @@ -0,0 +1,539 @@ +diff --git a/dist/src/browser/actions/promptComposer.js b/dist/src/browser/actions/promptComposer.js +index c643386..ab4e62c 100644 +--- a/dist/src/browser/actions/promptComposer.js ++++ b/dist/src/browser/actions/promptComposer.js +@@ -14,6 +14,10 @@ const ENTER_KEY_TEXT = "\r"; + export async function submitPrompt(deps, prompt, logger) { + const { runtime, input } = deps; + await waitForDomReady(runtime, logger, deps.inputTimeoutMs ?? undefined); ++ // Failed app-mention attempts can leave a server-persisted draft in the ++ // root composer. Always begin this one-shot submission from an empty ++ // composer so the next run cannot duplicate the mention. ++ await clearPromptComposer(runtime, logger); + const encodedPrompt = JSON.stringify(prompt); + const focusResult = await runtime.evaluate({ + expression: `(() => { +@@ -67,7 +71,455 @@ export async function submitPrompt(deps, prompt, logger) { + await logDomFailure(runtime, logger, "focus-textarea"); + throw new Error("Failed to focus prompt textarea"); + } +- await input.insertText({ text: prompt }); ++ // Use trusted editing keys as well as DOM clearing. ChatGPT can restore a ++ // stale Lexical draft after direct textContent mutation. ++ await input.dispatchKeyEvent({ ++ type: "keyDown", ++ key: "a", ++ code: "KeyA", ++ windowsVirtualKeyCode: 65, ++ nativeVirtualKeyCode: 65, ++ modifiers: 2, ++ }); ++ await input.dispatchKeyEvent({ ++ type: "keyUp", ++ key: "a", ++ code: "KeyA", ++ windowsVirtualKeyCode: 65, ++ nativeVirtualKeyCode: 65, ++ modifiers: 2, ++ }); ++ await input.dispatchKeyEvent({ ++ type: "keyDown", ++ key: "Backspace", ++ code: "Backspace", ++ windowsVirtualKeyCode: 8, ++ nativeVirtualKeyCode: 8, ++ }); ++ await input.dispatchKeyEvent({ ++ type: "keyUp", ++ key: "Backspace", ++ code: "Backspace", ++ windowsVirtualKeyCode: 8, ++ nativeVirtualKeyCode: 8, ++ }); ++ await delay(250); ++ // A literal @name is not routing authority. Select only an exact app ++ // identity, then require ChatGPT's semantic mention token before send. ++ const mentionMatch = prompt.match(/^(@[^\s]+)(\s+[\s\S]+)$/u); ++ let routedAppName = null; ++ let exactAppSuggestionSelected = false; ++ if (mentionMatch) { ++ const appName = mentionMatch[1].slice(1); ++ routedAppName = appName; ++ // CDP Input.insertText explicitly represents text that does not come ++ // from a key press. ChatGPT's current mention picker ignores both a ++ // one-shot `@App` insert and a separately inserted bare `@` (verified ++ // by two live fail-closed runs), so emit the trigger as one real key ++ // event while leaving the remaining text on the existing fast path. ++ await input.dispatchKeyEvent({ ++ type: "keyDown", ++ key: "@", ++ code: "Digit2", ++ text: "@", ++ unmodifiedText: "@", ++ windowsVirtualKeyCode: 50, ++ nativeVirtualKeyCode: 50, ++ modifiers: 8, ++ }); ++ await input.dispatchKeyEvent({ ++ type: "keyUp", ++ key: "@", ++ code: "Digit2", ++ windowsVirtualKeyCode: 50, ++ nativeVirtualKeyCode: 50, ++ modifiers: 8, ++ }); ++ // The exact discovery/click and semantic-pill guards below remain the ++ // authoritative gates and fail closed before either send path. ++ await delay(250); ++ // One shared evaluate-only census builder reports the mention-trigger ++ // state at P0 (after the @ key settle, before the appName insert), P1 ++ // (after the appName insert and after the bounded suggestion wait), ++ // and P2 (fail-closed route loss, with per-candidate rejection ++ // reasons). It never clicks, types, or submits: editor/caret ++ // membership, picker presence, and candidate filters are read-only. ++ const buildMentionCensusExpression = (stage, collectRejections) => `(() => { ++ const MENTION_CENSUS_STAGE = ${JSON.stringify(stage)}; ++ const MENTION_CENSUS_COLLECT_REJECTIONS = ${JSON.stringify(collectRejections)}; ++ const wanted = ${JSON.stringify(appName)}; ++ // Selection scans the same complete surface/node set as the click resolver. ++ // Caps below limit only serialized diagnostic output, never classification. ++ const MAX_TEXT = 120; ++ const MAX_REJECTIONS = 8; ++ const MAX_CONTROLS = 12; ++ const normalize = (value) => String(value || '').replace(/\\s+/g, ' ').trim(); ++ const cap = (value, length) => String(value || '').slice(0, length); ++ const exactIdentity = (node) => [ ++ node?.getAttribute?.('data-app-name'), ++ node?.getAttribute?.('aria-label'), ++ node?.getAttribute?.('title'), ++ node?.innerText, ++ node?.textContent, ++ ].some((value) => normalize(value).replace(/^@/, '') === wanted); ++ const visible = (node) => { ++ const rect = node?.getBoundingClientRect?.(); ++ if (!rect || rect.width <= 0 || rect.height <= 0) return false; ++ if (node?.hasAttribute?.('hidden') || node?.getAttribute?.('aria-hidden') === 'true') return false; ++ const style = window.getComputedStyle(node); ++ return style.display !== 'none' && style.visibility !== 'hidden' && style.opacity !== '0'; ++ }; ++ const enabled = (node) => { ++ const style = window.getComputedStyle(node); ++ return !node?.hasAttribute?.('disabled') && ++ node?.getAttribute?.('aria-disabled') !== 'true' && ++ node?.getAttribute?.('data-disabled') !== 'true' && ++ style.pointerEvents !== 'none'; ++ }; ++ const describe = (node) => { ++ const rect = node?.getBoundingClientRect?.(); ++ return { ++ tag: node?.tagName || null, ++ role: node?.getAttribute?.('role') || null, ++ testid: node?.getAttribute?.('data-testid') || null, ++ ariaLabel: node?.getAttribute?.('aria-label') || null, ++ title: node?.getAttribute?.('title') || null, ++ ariaExpanded: node?.getAttribute?.('aria-expanded') || null, ++ ariaHaspopup: node?.getAttribute?.('aria-haspopup') || null, ++ text: cap(normalize(node?.innerText || node?.textContent), MAX_TEXT), ++ visible: visible(node), ++ rect: rect ? { width: rect.width, height: rect.height } : null, ++ }; ++ }; ++ const editors = Array.from(document.querySelectorAll( ++ ${JSON.stringify(INPUT_SELECTORS.join(","))} ++ )); ++ const active = document.activeElement || null; ++ const selection = window.getSelection ? window.getSelection() : null; ++ const caretNode = selection?.anchorNode || null; ++ const editorContains = (node) => Boolean(node) && editors.some((editor) => editor.contains(node)); ++ const editorFound = editors.length > 0; ++ const editorFocused = editorContains(active); ++ const caretInEditor = editorContains(caretNode); ++ const surfaceSelector = ++ '[role="option"],[role="menuitem"],[role="group"],[data-radix-collection-item]'; ++ const actionSelector = ++ 'button,[role="option"],[role="menuitem"],[data-radix-collection-item],[data-fill][tabindex]'; ++ const surfaces = Array.from(document.querySelectorAll(surfaceSelector)); ++ const visibleSurfaces = surfaces.filter(visible); ++ const actionSurfaceVisible = visibleSurfaces.length > 0; ++ const rejections = []; ++ const recordRejection = (reason, node) => { ++ if (!MENTION_CENSUS_COLLECT_REJECTIONS || rejections.length >= MAX_REJECTIONS) return; ++ const existing = rejections.find((entry) => entry.reason === reason); ++ if (existing) { existing.count += 1; return; } ++ rejections.push({ ++ reason, ++ count: 1, ++ text: cap(normalize(node?.innerText || node?.textContent), MAX_TEXT), ++ }); ++ }; ++ let exactTextFound = false; ++ let visibleExactFound = false; ++ const dispatchableTargets = []; ++ for (const surface of surfaces) { ++ const nodes = [surface, ...Array.from(surface.querySelectorAll('*'))]; ++ const exactNodes = nodes.filter((node) => exactIdentity(node)); ++ const exactLeaves = exactNodes.filter((node) => !exactNodes.some((other) => ++ other !== node && node.contains?.(other) ++ )); ++ if (exactNodes.length > 0) exactTextFound = true; ++ if (exactNodes.some(visible)) visibleExactFound = true; ++ if (MENTION_CENSUS_STAGE === 'P0') continue; ++ if (!visible(surface)) { recordRejection('surface-not-visible', surface); continue; } ++ if (exactNodes.length === 0) { recordRejection('no-exact-text', surface); continue; } ++ const visibleExactLeaves = exactLeaves.filter(visible); ++ if (visibleExactLeaves.length === 0) { recordRejection('exact-node-hidden', surface); continue; } ++ for (const label of visibleExactLeaves) { ++ const action = label.closest?.(actionSelector); ++ const target = action && surface.contains(action) ? action : label; ++ if (!surface.contains(target)) { recordRejection('target-outside-surface', label); continue; } ++ if (target === surface && surface.getAttribute?.('role') === 'group') { ++ recordRejection('group-surface-only', surface); ++ continue; ++ } ++ if (typeof target.click !== 'function') { recordRejection('target-not-clickable', label); continue; } ++ if (!visible(target)) { recordRejection('target-not-visible', label); continue; } ++ if (!enabled(target)) { recordRejection('target-not-enabled', label); continue; } ++ if (!dispatchableTargets.includes(target)) dispatchableTargets.push(target); ++ } ++ } ++ const dispatchableCount = dispatchableTargets.length; ++ const composerScope = editors ++ .map((editor) => editor.closest?.('form') || editor.parentElement) ++ .find((scope) => scope?.querySelectorAll) || document; ++ const controls = Array.from(composerScope.querySelectorAll( ++ 'button,[role="button"],[aria-haspopup],[data-testid]' ++ )).filter(visible).slice(0, MAX_CONTROLS).map(describe); ++ const discriminator = !editorFound ? 'no-editor-found' ++ : !editorFocused ? 'editor-not-focused' ++ : !caretInEditor ? 'caret-outside-editor' ++ : !actionSurfaceVisible ? (MENTION_CENSUS_STAGE === 'P0' ? 'no-visible-action-surface-before-app-name' : 'no-visible-action-surface') ++ : MENTION_CENSUS_STAGE === 'P0' ? 'visible-action-surface-before-app-name' ++ : !exactTextFound ? 'no-exact-app-action-observed' ++ : visibleSurfaces.length === 0 ? 'candidate-rejected-by-locator' ++ : !visibleExactFound ? 'candidate-rejected-by-locator' ++ : dispatchableCount === 0 ? 'candidate-rejected-by-locator' ++ : dispatchableCount > 1 ? 'ambiguous-exact-actions' ++ : 'candidate-found'; ++ return { ++ stage: MENTION_CENSUS_STAGE, ++ discriminator, ++ editorFound, ++ editorFocused, ++ caretInEditor, ++ activeElement: describe(active), ++ caretNode: caretNode ? describe(caretNode) : null, ++ controls, ++ actionSurfaceVisible, ++ surfaceCount: surfaces.length, ++ visibleSurfaceCount: visibleSurfaces.length, ++ exactTextFound, ++ visibleExactFound, ++ dispatchableCount, ++ ...(MENTION_CENSUS_COLLECT_REJECTIONS ? { rejections } : {}), ++ }; ++ })()`; ++ const censusP0 = await runtime.evaluate({ ++ expression: buildMentionCensusExpression('P0', false), ++ returnByValue: true, ++ }); ++ logger(`App mention census P0: ${JSON.stringify(censusP0.result?.value ?? null)}`); ++ await input.insertText({ text: appName }); ++ const censusP1Insert = await runtime.evaluate({ ++ expression: buildMentionCensusExpression('P1', false), ++ returnByValue: true, ++ }); ++ logger(`App mention census P1 after insert: ${JSON.stringify(censusP1Insert.result?.value ?? null)}`); ++ // Discovery and click must resolve the same unique action containing ++ // the exact app label. Current ChatGPT groups also contain category ++ // and description text, so those sibling labels cannot define the ++ // action's identity; the post-click semantic pill remains authoritative. ++ const exactSuggestionExpression = (shouldClick) => `(() => { ++ const wanted = ${JSON.stringify(appName)}; ++ const normalize = (value) => String(value || '').replace(/\\s+/g, ' ').trim(); ++ const exactIdentity = (node) => [ ++ node?.getAttribute?.('data-app-name'), ++ node?.getAttribute?.('aria-label'), ++ node?.getAttribute?.('title'), ++ node?.innerText, ++ node?.textContent, ++ ].some((value) => normalize(value).replace(/^@/, '') === wanted); ++ const visible = (node) => { ++ const rect = node?.getBoundingClientRect?.(); ++ if (!rect || rect.width <= 0 || rect.height <= 0) return false; ++ if (node?.hasAttribute?.('hidden') || node?.getAttribute?.('aria-hidden') === 'true') return false; ++ const style = window.getComputedStyle(node); ++ return style.display !== 'none' && style.visibility !== 'hidden' && style.opacity !== '0'; ++ }; ++ const enabled = (node) => { ++ const style = window.getComputedStyle(node); ++ return !node?.hasAttribute?.('disabled') && ++ node?.getAttribute?.('aria-disabled') !== 'true' && ++ node?.getAttribute?.('data-disabled') !== 'true' && ++ style.pointerEvents !== 'none'; ++ }; ++ const describe = (node) => ({ ++ tag: node?.tagName || null, ++ role: node?.getAttribute?.('role') || null, ++ text: normalize(node?.innerText || node?.textContent).slice(0, 160), ++ }); ++ const surfaceSelector = ++ '[role="option"],[role="menuitem"],[role="group"],[data-radix-collection-item]'; ++ const actionSelector = ++ 'button,[role="option"],[role="menuitem"],[data-radix-collection-item],[data-fill][tabindex]'; ++ const candidates = []; ++ for (const surface of Array.from(document.querySelectorAll(surfaceSelector)).filter(visible)) { ++ const nodes = [surface, ...Array.from(surface.querySelectorAll('*'))]; ++ const exactNodes = nodes.filter((node) => visible(node) && exactIdentity(node)); ++ const exactLeaves = exactNodes.filter((node) => !exactNodes.some((other) => ++ other !== node && node.contains?.(other) ++ )); ++ for (const label of exactLeaves) { ++ const action = label.closest?.(actionSelector); ++ const target = action && surface.contains(action) ? action : label; ++ if (!surface.contains(target) || ++ (target === surface && surface.getAttribute?.('role') === 'group') || ++ typeof target.click !== 'function' || !visible(target) || !enabled(target)) { ++ continue; ++ } ++ candidates.push({ surface, label, target }); ++ } ++ } ++ const unique = candidates.filter((candidate, index, items) => ++ items.findIndex((item) => item.target === candidate.target) === index ++ ); ++ if (unique.length === 0) return { status: 'not-found' }; ++ if (unique.length !== 1) return { status: 'ambiguous', count: unique.length }; ++ const candidate = unique[0]; ++ const authority = { ++ surface: describe(candidate.surface), ++ label: describe(candidate.label), ++ target: describe(candidate.target), ++ }; ++ if (!${JSON.stringify(shouldClick)}) return { status: 'unique', authority }; ++ candidate.target.click(); ++ return { status: 'action-dispatched', actionDispatched: true, authority }; ++ })()`; ++ const suggestionDeadline = Date.now() + 5_000; ++ let suggestionProbe = null; ++ while (Date.now() < suggestionDeadline) { ++ const probe = await runtime.evaluate({ ++ expression: exactSuggestionExpression(false), ++ returnByValue: true, ++ }); ++ suggestionProbe = probe.result?.value ?? null; ++ if (suggestionProbe?.status === 'unique' || suggestionProbe?.status === 'ambiguous') { ++ break; ++ } ++ await delay(100); ++ } ++ const censusP1Wait = await runtime.evaluate({ ++ expression: buildMentionCensusExpression('P1', false), ++ returnByValue: true, ++ }); ++ logger(`App mention census P1 after suggestion wait: ${JSON.stringify(censusP1Wait.result?.value ?? null)}`); ++ const clickResult = await runtime.evaluate({ ++ expression: exactSuggestionExpression(true), ++ returnByValue: true, ++ }); ++ const exactAppSuggestionActionDispatched = ++ clickResult.result?.value?.status === 'action-dispatched' && ++ clickResult.result?.value?.actionDispatched === true; ++ if (!exactAppSuggestionActionDispatched) { ++ logger(`Exact ChatGPT app suggestion action was not uniquely dispatchable for @${appName}; status=${clickResult.result?.value?.status || suggestionProbe?.status || 'not-found'}.`); ++ } ++ const routeDeadline = Date.now() + 5_000; ++ let routeConfirmed = false; ++ while (Date.now() < routeDeadline) { ++ const probe = await runtime.evaluate({ ++ expression: `(() => { ++ const wanted = ${JSON.stringify(appName)}; ++ const exactAppIdentity = (node) => [ ++ node?.getAttribute?.('data-app-name'), ++ node?.getAttribute?.('data-mention'), ++ ].some((value) => typeof value === 'string' && value === wanted); ++ const exactSelectedAppPill = (node, editor) => { ++ if (!${JSON.stringify(exactAppSuggestionActionDispatched)}) return false; ++ const pill = node?.closest?.('[contenteditable="false"]'); ++ if (!pill || !editor.contains(pill) || !visible(pill)) return false; ++ const normalize = (value) => String(value || '').replace(/\s+/g, ' ').trim(); ++ const exactVisibleLabel = Array.from(pill.querySelectorAll('*')) ++ .some((child) => visible(child) && ++ normalize(child.innerText || child.textContent).replace(/^@/, '') === wanted); ++ return exactVisibleLabel; ++ }; ++ const visible = (node) => { ++ const rect = node?.getBoundingClientRect?.(); ++ if (!rect || rect.width <= 0 || rect.height <= 0) return false; ++ if (node?.hasAttribute?.('hidden') || node?.getAttribute?.('aria-hidden') === 'true') return false; ++ const style = window.getComputedStyle(node); ++ return style.display !== 'none' && style.visibility !== 'hidden' && style.opacity !== '0'; ++ }; ++ const editors = Array.from(document.querySelectorAll( ++ ${JSON.stringify(INPUT_SELECTORS.join(","))} ++ )); ++ const semanticMentionSelectors = '[data-app-name],[data-mention],[contenteditable="false"],[contenteditable="false"] *'; ++ return editors.filter(visible).some((editor) => ++ Array.from(editor.querySelectorAll(semanticMentionSelectors)) ++ .some((node) => visible(node) && ++ (${JSON.stringify(exactAppSuggestionActionDispatched)} && (exactAppIdentity(node) || exactSelectedAppPill(node, editor)))) ++ ); ++ })()`, ++ returnByValue: true, ++ }); ++ if (probe.result?.value === true) { ++ routeConfirmed = true; ++ break; ++ } ++ await delay(100); ++ } ++ // A dispatched DOM click is not an authoritative receipt. Promote it ++ // to selected only after ChatGPT exposes the exact semantic app pill. ++ exactAppSuggestionSelected = exactAppSuggestionActionDispatched && routeConfirmed; ++ const exactAppSuggestionClickReceipt = exactAppSuggestionSelected ++ ? { ...clickResult.result?.value?.authority, semanticRouteConfirmed: true } ++ : null; ++ if (!routeConfirmed) { ++ const censusP2 = await runtime.evaluate({ ++ expression: buildMentionCensusExpression('P2', true), ++ returnByValue: true, ++ }); ++ logger(`App mention census P2: ${JSON.stringify(censusP2.result?.value ?? null)}`); ++ await logDomFailure(runtime, logger, "app-mention-route-unconfirmed"); ++ await clearPromptComposer(runtime, logger); ++ throw new BrowserAutomationError("APP_MENTION_ROUTE_UNCONFIRMED", { ++ stage: "submit-prompt", ++ code: "APP_MENTION_ROUTE_UNCONFIRMED", ++ appName, ++ }); ++ } ++ logger(`Exact ChatGPT app suggestion receipt: ${JSON.stringify(exactAppSuggestionClickReceipt)}`); ++ await runtime.evaluate({ ++ expression: `(() => { ++ const wanted = ${JSON.stringify(appName.toLowerCase())}; ++ const watcherKey = '__oracleAppApprovalWatcher'; ++ const existing = window[watcherKey]; ++ if (existing?.wanted === wanted && existing?.timer) return true; ++ if (existing?.timer) clearInterval(existing.timer); ++ const normalize = (value) => ++ String(value || '').toLowerCase().replace(/\\s+/g, ' ').trim(); ++ const visible = (node) => { ++ const rect = node?.getBoundingClientRect?.(); ++ if (!rect || rect.width <= 0 || rect.height <= 0) return false; ++ const style = window.getComputedStyle(node); ++ return style.display !== 'none' && style.visibility !== 'hidden'; ++ }; ++ const tick = () => { ++ const allowLabels = new Set(['allow', '허용하기']); ++ const button = Array.from(document.querySelectorAll('button')) ++ .filter(visible) ++ .find((node) => allowLabels.has(normalize(node.innerText || node.textContent))); ++ if (!button) return; ++ let scope = button; ++ for (let i = 0; i < 10 && scope; i += 1, scope = scope.parentElement) { ++ if (!normalize(scope.innerText || scope.textContent).includes(wanted)) continue; ++ const rememberLabels = ['이 대화에 기억', 'remember for this chat', 'remember in this chat']; ++ const checkbox = Array.from(scope.querySelectorAll( ++ 'input[type="checkbox"],[role="checkbox"]' ++ )).find(visible); ++ const rememberLabel = Array.from(scope.querySelectorAll('label')) ++ .filter(visible) ++ .find((node) => rememberLabels.some((label) => ++ normalize(node.innerText || node.textContent).includes(label) ++ )); ++ const control = checkbox || rememberLabel?.querySelector( ++ 'input[type="checkbox"],[role="checkbox"]' ++ ) || rememberLabel; ++ const checked = checkbox instanceof HTMLInputElement ++ ? checkbox.checked ++ : checkbox?.getAttribute?.('aria-checked') === 'true'; ++ if (control && !checked) { ++ control.click(); ++ return; ++ } ++ button.click(); ++ return; ++ } ++ }; ++ const timer = setInterval(tick, 250); ++ window[watcherKey] = { wanted, timer }; ++ tick(); ++ return true; ++ })()`, ++ returnByValue: true, ++ }); ++ await input.insertText({ text: mentionMatch[2] }); ++ logger(`App mention @${appName} semantically confirmed in the composer.`); ++ } ++ else { ++ await input.insertText({ text: prompt }); ++ } + // Some pages (notably ChatGPT when subscriptions/widgets load) need a brief settle + // before the send button becomes enabled; give it a short breather to avoid races. + await delay(500); +@@ -167,6 +619,66 @@ export async function submitPrompt(deps, prompt, logger) { + observedLength, + }); + } ++ // The empty-readback fallback can replace Lexical content with literal ++ // text. Revalidate the semantic token after every possible composer ++ // mutation and immediately before either send-button or Enter submission. ++ if (routedAppName) { ++ const finalRoute = await runtime.evaluate({ ++ expression: `(() => { ++ const wanted = ${JSON.stringify(routedAppName)}; ++ const exactAppIdentity = (node) => [ ++ node?.getAttribute?.('data-app-name'), ++ node?.getAttribute?.('data-mention'), ++ ].some((value) => typeof value === 'string' && value === wanted); ++ const exactSelectedAppPill = (node, editor) => { ++ if (!${JSON.stringify(exactAppSuggestionSelected)}) return false; ++ const pill = node?.closest?.('[contenteditable="false"]'); ++ if (!pill || !editor.contains(pill) || !visible(pill)) return false; ++ const normalize = (value) => String(value || '').replace(/\s+/g, ' ').trim(); ++ const exactVisibleLabel = Array.from(pill.querySelectorAll('*')) ++ .some((child) => visible(child) && ++ normalize(child.innerText || child.textContent).replace(/^@/, '') === wanted); ++ return exactVisibleLabel; ++ }; ++ const visible = (node) => { ++ const rect = node?.getBoundingClientRect?.(); ++ if (!rect || rect.width <= 0 || rect.height <= 0) return false; ++ if (node?.hasAttribute?.('hidden') || node?.getAttribute?.('aria-hidden') === 'true') return false; ++ const style = window.getComputedStyle(node); ++ return style.display !== 'none' && style.visibility !== 'hidden' && style.opacity !== '0'; ++ }; ++ const editors = Array.from(document.querySelectorAll( ++ ${JSON.stringify(INPUT_SELECTORS.join(","))} ++ )); ++ const semanticMentionSelectors = '[data-app-name],[data-mention],[contenteditable="false"],[contenteditable="false"] *'; ++ return editors.filter(visible).some((editor) => ++ Array.from(editor.querySelectorAll(semanticMentionSelectors)) ++ .some((node) => visible(node) && ++ (${JSON.stringify(exactAppSuggestionSelected)} && (exactAppIdentity(node) || exactSelectedAppPill(node, editor)))) ++ ); ++ })()`, ++ returnByValue: true, ++ }); ++ if (finalRoute.result?.value !== true) { ++ logger(`Final app mention route validation failed for @${routedAppName}.`); ++ await logDomFailure(runtime, logger, "app-mention-route-lost-before-send"); ++ await clearPromptComposer(runtime, logger); ++ throw new BrowserAutomationError("APP_MENTION_ROUTE_UNCONFIRMED", { ++ stage: "submit-prompt", ++ code: "APP_MENTION_ROUTE_UNCONFIRMED", ++ appName: routedAppName, ++ }); ++ } ++ } ++ if (process.env.ORACLE_APP_MENTION_DIAGNOSTIC_NO_SUBMISSION === "1") { ++ logger(`APP_MENTION_DIAGNOSTIC_NO_SUBMISSION=1; ${routedAppName ? "semantic route confirmed" : "no routed app established"}; send suppressed.`); ++ await clearPromptComposer(runtime, logger); ++ throw new BrowserAutomationError("APP_MENTION_DIAGNOSTIC_NO_SUBMISSION", { ++ stage: "submit-prompt", ++ code: "APP_MENTION_DIAGNOSTIC_NO_SUBMISSION", ++ appName: routedAppName, ++ }); ++ } + const clicked = await attemptSendButton(runtime, input, logger, deps?.attachmentNames, deps?.attachmentTimeoutMs); + if (!clicked) { + await input.dispatchKeyEvent({ diff --git a/bin/oracle-compat/0.17.3/recoverConversation.patch b/bin/oracle-compat/0.17.3/recoverConversation.patch new file mode 100644 index 00000000..bee2c588 --- /dev/null +++ b/bin/oracle-compat/0.17.3/recoverConversation.patch @@ -0,0 +1,95 @@ +diff --git a/dist/src/browser/recoverConversation.js b/dist/src/browser/recoverConversation.js +index e1b4568..15d5612 100644 +--- a/dist/src/browser/recoverConversation.js ++++ b/dist/src/browser/recoverConversation.js +@@ -1,8 +1,13 @@ ++import { mkdtemp, rm } from "node:fs/promises"; ++import os from "node:os"; ++import path from "node:path"; + import { isAnswerNowPlaceholderText } from "./actions/assistantResponse.js"; ++import { launchChrome } from "./chromeLifecycle.js"; + import { resolveBrowserConfig } from "./config.js"; + import { acquireManualLoginChromeForRun, isImageOnlyUiChromeText } from "./index.js"; + import { isRecoverableChatGptConversationUrl } from "./reattachability.js"; + import { harvestChatGptTab, openChatGptTarget } from "./liveTabs.js"; ++import { copyChromeProfile } from "./profileCopy.js"; + const DEFAULT_READY_TIMEOUT_MS = 30_000; + const READY_POLL_MS = 1_000; + /** +@@ -29,6 +34,10 @@ export function resolveRecoveryUrl(meta) { + export function resolveRecoveryProfileDir(meta) { + const config = meta?.browser?.config; + const resolved = resolveBrowserConfig(config); ++ const copyProfileSource = resolved.copyProfileSource; ++ if (typeof copyProfileSource === "string" && copyProfileSource.trim().length > 0) { ++ return copyProfileSource.trim(); ++ } + if (!resolved.manualLogin) { + throw new Error("Cannot recover conversation: session was not run with a manual-login browser profile."); + } +@@ -39,6 +48,28 @@ export function resolveRecoveryProfileDir(meta) { + } + return profileDir; + } ++function wrapEphemeralRecoveryChrome(chrome, userDataDir) { ++ let cleanupPromise = null; ++ const cleanup = () => { ++ if (!cleanupPromise) { ++ cleanupPromise = Promise.resolve() ++ .then(() => chrome.kill()) ++ .catch(() => undefined) ++ .then(() => rm(userDataDir, { recursive: true, force: true })) ++ .catch(() => undefined); ++ } ++ return cleanupPromise; ++ }; ++ return { ++ ...chrome, ++ kill: cleanup, ++ process: { ++ unref: () => { ++ void cleanup(); ++ }, ++ }, ++ }; ++} + async function waitForRecoveredConversationReady(endpoint, ref, timeoutMs) { + const deadline = Date.now() + timeoutMs; + let lastError = null; +@@ -109,10 +140,33 @@ export async function recoverConversationTab(meta, logger, options = {}) { + logger(`[browser] Recovery: existing Chrome could not reopen the conversation (${message}).`); + } + } +- const userDataDir = resolveRecoveryProfileDir(meta); ++ const profileSource = resolveRecoveryProfileDir(meta); + const config = resolveBrowserConfig(meta.browser?.config); +- logger(`[browser] Recovery: relaunching Chrome with profile ${userDataDir} and navigating to ${url}`); +- const { chrome } = await acquireManualLoginChromeForRun(userDataDir, config, logger, meta.id, {}); ++ let chrome; ++ if (config.copyProfileSource) { ++ const userDataDir = await mkdtemp(path.join(os.tmpdir(), "oracle-recovery-")); ++ try { ++ const chromeProfile = await copyChromeProfile(profileSource, userDataDir, config.chromeProfile); ++ logger(`[browser] Recovery: launching an isolated profile copy and navigating to ${url}`); ++ const launched = await launchChrome({ ++ ...config, ++ manualLogin: false, ++ manualLoginProfileDir: null, ++ copyProfileSource: profileSource, ++ chromeProfile, ++ keepBrowser: false, ++ }, userDataDir, logger); ++ chrome = wrapEphemeralRecoveryChrome(launched, userDataDir); ++ } ++ catch (error) { ++ await rm(userDataDir, { recursive: true, force: true }).catch(() => undefined); ++ throw error; ++ } ++ } ++ else { ++ logger(`[browser] Recovery: relaunching Chrome with profile ${profileSource} and navigating to ${url}`); ++ ({ chrome } = await acquireManualLoginChromeForRun(profileSource, config, logger, meta.id, {})); ++ } + const host = chrome.host ?? "127.0.0.1"; + const port = chrome.port; + try { diff --git a/bin/oracle-compat/0.17.3/thinkingTime.strict.patch b/bin/oracle-compat/0.17.3/thinkingTime.strict.patch new file mode 100644 index 00000000..df8dca23 --- /dev/null +++ b/bin/oracle-compat/0.17.3/thinkingTime.strict.patch @@ -0,0 +1,398 @@ +diff --git a/dist/src/browser/actions/thinkingTime.js b/dist/src/browser/actions/thinkingTime.js +index 1c8e374..3f53c2c 100644 +--- a/dist/src/browser/actions/thinkingTime.js ++++ b/dist/src/browser/actions/thinkingTime.js +@@ -36,6 +36,131 @@ export async function ensureThinkingTime(Runtime, level, logger, desiredModel) { + // own, independently of the legacy Pro-model + "extended" combination. + const strictProEffort = level === "pro" || + ((targetModelKind === "pro" || observedModelKind === "pro") && level === "extended"); ++ const strictGpt56Effort = /(?:^|[^0-9])5[._ -]6(?:[^0-9]|$)/i.test(desiredModel ?? "") && ++ (level === "extra-high" || level === "heavy"); ++ const strictRequestedEffort = strictProEffort || strictGpt56Effort; ++ const requiredEffortLabel = strictProEffort ++ ? level === "pro" ? "Pro" : "Pro Extended" ++ : level === "heavy" ? "Power 5 of 5 (Pro)" : "Power 4 of 5 (Extra High)"; ++ // The in-page selector owns the normal proof. If the picker finished ++ // rendering between that probe and its diagnostic snapshot, require the ++ // same visible, aria-controls-bound picker twice before accepting Pro. ++ const diagnosticItems = (result?.diagnostic?.menus ?? []).flatMap((menu) => menu?.items ?? []); ++ const diagnosticProProof = strictGpt56Effort && level === "heavy" && ++ String(result?.diagnostic?.modelButton?.text ?? "").trim().toLowerCase() === "pro" && ++ diagnosticItems.some((item) => item?.testid === "composer-model-picker-slider-simple-view" && ++ /\bpro\b/i.test(String(item?.text ?? "")) && ++ /\b5\s+of\s+5\b/i.test(String(item?.text ?? ""))) && ++ diagnosticItems.some((item) => item?.testid === "composer-model-picker-slider-advanced-view" && ++ /model\s*gpt-?5[._ -]6\s*sol/i.test(String(item?.text ?? "")) && ++ /effort\s*pro/i.test(String(item?.text ?? ""))); ++ const confirmDiagnosticProProof = async () => { ++ const confirmation = await Runtime.evaluate({ ++ expression: `(async () => { ++ const marker = 'oracle-gpt56-pro-visible-proof-v1'; ++ const visible = (node, allowSelfOpacityZero = false) => { ++ const rect = node?.getBoundingClientRect?.(); ++ if (!rect?.width || !rect?.height) return false; ++ for (let current = node; current; current = current.parentElement) { ++ const style = window.getComputedStyle(current); ++ if (style.display === 'none' || ['hidden', 'collapse'].includes(style.visibility) || ++ ((!allowSelfOpacityZero || current !== node) && !(Number(style.opacity ?? '1') > 0)) || ++ current.getAttribute?.('aria-hidden') === 'true') return false; ++ } ++ return true; ++ }; ++ const text = (node) => String(node?.textContent ?? '') + ' ' + ++ String(node?.getAttribute?.('aria-label') ?? ''); ++ const pickerRoots = (button) => String(button.getAttribute?.('aria-controls') ?? '') ++ .trim().split(/\\s+/).filter(Boolean) ++ .map((id) => document.getElementById(id)).filter(visible); ++ const boundProofScopes = (button) => { ++ const controlled = pickerRoots(button); ++ const buttonMenu = button?.closest?.('[role="menu"]'); ++ const ownedControlled = controlled.filter((root) => ++ buttonMenu?.contains?.(root)); ++ if (buttonMenu && ownedControlled.length && visible(buttonMenu)) { ++ return [buttonMenu]; ++ } ++ return controlled.map((root) => { ++ const menu = root?.matches?.('[role="menu"]') ? root : ++ root?.closest?.('[role="menu"]'); ++ return menu && visible(menu) ? menu : root; ++ }); ++ }; ++ const readPowerTextValue = (view) => { ++ const compact = Array.from(String(view?.textContent ?? '').toLowerCase()) ++ .filter((character) => ++ (character >= 'a' && character <= 'z') || ++ (character >= '0' && character <= '9')) ++ .join(''); ++ for (let candidate = 1; candidate <= 5; candidate += 1) { ++ if (compact.includes(String(candidate) + 'of5')) return candidate; ++ } ++ return null; ++ }; ++ const readPowerValue = (view) => { ++ const control = view?.querySelector?.('[role="slider"], input[type="range"]') || view; ++ const rawAttribute = control?.getAttribute?.('aria-valuenow'); ++ const minimumAttribute = control?.getAttribute?.('aria-valuemin'); ++ const maximumAttribute = control?.getAttribute?.('aria-valuemax'); ++ const hasRaw = rawAttribute !== null && rawAttribute !== undefined && ++ String(rawAttribute).trim() !== ''; ++ const hasMinimum = minimumAttribute !== null && minimumAttribute !== undefined && ++ String(minimumAttribute).trim() !== ''; ++ const hasMaximum = maximumAttribute !== null && maximumAttribute !== undefined && ++ String(maximumAttribute).trim() !== ''; ++ let controlValue = null; ++ if (hasMinimum || hasMaximum) { ++ if (!hasRaw || !hasMinimum || !hasMaximum) return null; ++ const raw = Number(rawAttribute); ++ const minimum = Number(minimumAttribute); ++ const maximum = Number(maximumAttribute); ++ if (!Number.isInteger(raw) || minimum !== 0 || maximum !== 4 || ++ raw < minimum || raw > maximum) return null; ++ controlValue = raw + 1; ++ } else if (hasRaw) { ++ const raw = Number(rawAttribute); ++ if (!Number.isInteger(raw) || raw < 1 || raw > 5) return null; ++ controlValue = raw; ++ } ++ const textValue = readPowerTextValue(view); ++ if (controlValue !== null && textValue !== null && ++ controlValue !== textValue) return null; ++ return controlValue ?? textValue; ++ }; ++ const sliderPro = (root) => Array.from(root.querySelectorAll( ++ '[data-testid="composer-model-picker-slider-simple-view"]', ++ )).some((node) => visible(node, true) && readPowerValue(node) === 5 && ++ /\\bpro\\b/i.test(text(node))); ++ const advancedPro = (root) => Array.from(root.querySelectorAll( ++ '[data-testid="composer-model-picker-slider-advanced-view"]', ++ )).some((node) => visible(node) && ++ /model\\s*gpt-?5[._ -]6\\s*sol/i.test(text(node)) && ++ /effort\\s*pro/i.test(text(node))); ++ const proof = () => Array.from(document.querySelectorAll( ++ ${JSON.stringify(MODEL_BUTTON_SELECTOR)}, ++ )).some((button) => visible(button) && /\\bpro\\b/i.test(text(button)) && ++ boundProofScopes(button) ++ .some((root) => sliderPro(root) && advancedPro(root))); ++ const deadline = performance.now() + 500; ++ let consecutive = 0; ++ while (performance.now() < deadline) { ++ consecutive = proof() ? consecutive + 1 : 0; ++ if (consecutive >= 2) return marker; ++ await new Promise((resolve) => setTimeout(resolve, 100)); ++ } ++ return null; ++ })()`, ++ awaitPromise: true, ++ returnByValue: true, ++ }); ++ return confirmation.result?.value === 'oracle-gpt56-pro-visible-proof-v1'; ++ }; ++ if (diagnosticProProof && await confirmDiagnosticProProof()) { ++ logger(formatBrowserThinkingLog("Power 5 of 5 (Pro) (already selected)")); ++ return; ++ } + switch (result?.status) { + case "already-selected": + logger(formatBrowserThinkingLog(`${result.label ?? capitalizedLevel} (already selected)`)); +@@ -56,9 +181,8 @@ export async function ensureThinkingTime(Runtime, level, logger, desiredModel) { + ? ` for ${targetModelKind}` + : ""; + const message = `Thinking time: ${result.status.replaceAll("-", " ")}${kindHint} (requested ${capitalizedLevel})`; +- if (strictProEffort) { +- const target = level === "pro" ? "Pro" : "Pro Extended"; +- throw new Error(`${message}; refusing to submit without confirmed ${target}.`); ++ if (strictRequestedEffort) { ++ throw new Error(`${message}; refusing to submit without confirmed ${requiredEffortLabel}.`); + } + // "selection-unverified" is the one status here that already dispatched a + // click, so the effort may or may not have moved. Every other status left +@@ -72,9 +196,8 @@ export async function ensureThinkingTime(Runtime, level, logger, desiredModel) { + default: { + await logDomFailure(Runtime, logger, "thinking-time-unknown"); + logPickerDiagnostic(result, logger); +- if (strictProEffort) { +- const target = level === "pro" ? "Pro" : "Pro Extended"; +- throw new Error(`Thinking time: unknown outcome selecting ${capitalizedLevel}; refusing to submit without confirmed ${target}.`); ++ if (strictRequestedEffort) { ++ throw new Error(`Thinking time: unknown outcome selecting ${capitalizedLevel}; refusing to submit without confirmed ${requiredEffortLabel}.`); + } + logger(formatBrowserThinkingLog(`unknown outcome selecting ${capitalizedLevel}; continuing with ChatGPT default.`)); + return; +@@ -384,12 +507,157 @@ function buildThinkingTimeExpression(level, desiredModel) { + // 'intelligen' matches both "Intelligence" and German "Intelligenz". + return normalize(label?.textContent ?? '').includes('intelligen'); + }; +- const failure = (status, extra = {}) => ({ +- status, +- modelKind: effectiveTargetModelKind(), +- ...extra, +- diagnostic: collectPickerDiagnostic(), +- }); ++ const POWER_TARGET = { ++ 'extra-high': 4, ++ heavy: 5, ++ }[TARGET_LEVEL] ?? null; ++ const powerSliderControl = (view) => view?.querySelector?.( ++ '[role="slider"], input[type="range"]', ++ ) || view; ++ const readPowerTextValue = (view) => { ++ const compact = Array.from(String(view?.textContent ?? '').toLowerCase()) ++ .filter((character) => ++ (character >= 'a' && character <= 'z') || ++ (character >= '0' && character <= '9')) ++ .join(''); ++ for (let candidate = 1; candidate <= 5; candidate += 1) { ++ if (compact.includes(String(candidate) + 'of5')) return candidate; ++ } ++ return null; ++ }; ++ const readPowerValue = (view) => { ++ const control = powerSliderControl(view); ++ const rawAttribute = control?.getAttribute?.('aria-valuenow'); ++ const minimumAttribute = control?.getAttribute?.('aria-valuemin'); ++ const maximumAttribute = control?.getAttribute?.('aria-valuemax'); ++ const hasRaw = rawAttribute !== null && rawAttribute !== undefined && String(rawAttribute).trim() !== ''; ++ const hasMinimum = minimumAttribute !== null && minimumAttribute !== undefined && ++ String(minimumAttribute).trim() !== ''; ++ const hasMaximum = maximumAttribute !== null && maximumAttribute !== undefined && ++ String(maximumAttribute).trim() !== ''; ++ let controlValue = null; ++ if (hasMinimum || hasMaximum) { ++ if (!hasRaw || !hasMinimum || !hasMaximum) return null; ++ const raw = Number(rawAttribute); ++ const minimum = Number(minimumAttribute); ++ const maximum = Number(maximumAttribute); ++ if (!Number.isInteger(raw) || minimum !== 0 || maximum !== 4 || ++ raw < minimum || raw > maximum) return null; ++ controlValue = raw + 1; ++ } else if (hasRaw) { ++ const raw = Number(rawAttribute); ++ if (!Number.isInteger(raw) || raw < 1 || raw > 5) return null; ++ controlValue = raw; ++ } ++ const textValue = readPowerTextValue(view); ++ if (controlValue !== null && textValue !== null && controlValue !== textValue) return null; ++ return controlValue ?? textValue; ++ }; ++ const isPowerProofVisible = (node, allowSelfOpacityZero = false) => { ++ if (!isVisible(node)) return false; ++ for (let current = node; current; current = current.parentElement) { ++ const style = window.getComputedStyle?.(current); ++ if (!style || style.display === 'none' || ['hidden', 'collapse'].includes(style.visibility) || ++ ((!allowSelfOpacityZero || current !== node) && !(Number(style.opacity ?? '1') > 0)) || ++ current.getAttribute?.('aria-hidden') === 'true') return false; ++ } ++ return true; ++ }; ++ const compactNodeText = (node) => Array.from( ++ String(node?.textContent ?? '').toLowerCase() + ++ String(node?.getAttribute?.('aria-label') ?? '').toLowerCase(), ++ ).filter((character) => ++ (character >= 'a' && character <= 'z') || ++ (character >= '0' && character <= '9')).join(''); ++ const boundPowerProofScopes = (modelButton) => { ++ const controlledRoots = String(modelButton.getAttribute?.('aria-controls') ?? '') ++ .trim().split(/\s+/).filter(Boolean) ++ .map((id) => document.getElementById(id)) ++ .filter(isPowerProofVisible); ++ const buttonMenu = modelButton?.closest?.('[role="menu"]'); ++ const ownedControlledRoots = controlledRoots.filter((root) => ++ buttonMenu?.contains?.(root)); ++ if (buttonMenu && ownedControlledRoots.length && ++ isPowerProofVisible(buttonMenu)) { ++ return [buttonMenu]; ++ } ++ return controlledRoots.map((root) => { ++ const menu = root?.matches?.('[role="menu"]') ? root : ++ root?.closest?.('[role="menu"]'); ++ return menu && isPowerProofVisible(menu) ? menu : root; ++ }); ++ }; ++ // Power proof belongs to one visible effort pill and its controlled picker. ++ // A controlled subtree may use sibling proof only inside its same visible menu. ++ const collectGpt56PowerProofDiagnostic = () => { ++ if (POWER_TARGET === null || !TARGET_IS_GPT56_MODEL) return { passed: false }; ++ const targetEffort = POWER_TARGET === 5 ? 'pro' : 'extrahigh'; ++ const modelButtons = Array.from(document.querySelectorAll(MODEL_BUTTON_SELECTOR)) ++ .filter(isPowerProofVisible); ++ const proofTrees = modelButtons.flatMap((modelButton) => { ++ if (!compactNodeText(modelButton).includes(targetEffort)) return []; ++ return boundPowerProofScopes(modelButton) ++ .map((root) => { ++ const sliders = Array.from(root.querySelectorAll( ++ '[data-testid="composer-model-picker-slider-simple-view"]', ++ )).filter((candidate) => isPowerProofVisible(candidate, true)); ++ const advancedViews = Array.from(root.querySelectorAll( ++ '[data-testid="composer-model-picker-slider-advanced-view"]', ++ )).filter(isPowerProofVisible); ++ const sliderMatches = sliders.some((candidate) => ++ readPowerValue(candidate) === POWER_TARGET && ++ compactNodeText(candidate).includes(targetEffort)); ++ const advancedMatches = advancedViews.some((advanced) => { ++ const compact = compactNodeText(advanced); ++ return compact.includes('modelgpt56sol') && ++ compact.includes('effort' + targetEffort); ++ }); ++ return { sliders, advancedViews, sliderMatches, advancedMatches }; ++ }); ++ }); ++ return { ++ passed: proofTrees.some((tree) => tree.sliderMatches && tree.advancedMatches), ++ targetPower: POWER_TARGET, ++ visibleModelButtons: modelButtons.length, ++ sliderCandidates: proofTrees.flatMap((tree) => tree.sliders).length, ++ advancedCandidates: proofTrees.flatMap((tree) => tree.advancedViews).length, ++ }; ++ }; ++ const waitForStableGpt56PowerProof = async () => { ++ if (POWER_TARGET === null || !TARGET_IS_GPT56_MODEL) return true; ++ const deadline = performance.now() + INTELLIGENCE_WAIT_MS; ++ let consecutive = 0; ++ while (performance.now() < deadline) { ++ consecutive = collectGpt56PowerProofDiagnostic().passed ? consecutive + 1 : 0; ++ if (consecutive >= 2) return true; ++ await sleep(100); ++ } ++ return false; ++ }; ++ const failure = (status, extra = {}) => { ++ const diagnostic = collectPickerDiagnostic(); ++ if (POWER_TARGET !== null) { ++ diagnostic.powerProof = collectGpt56PowerProofDiagnostic(); ++ } ++ return { ++ status, ++ modelKind: effectiveTargetModelKind(), ++ ...extra, ++ diagnostic, ++ }; ++ }; ++ const finishVerifiedSelection = async (status, label, modelKind) => { ++ if (await waitForStableGpt56PowerProof()) { ++ closeOpenMenus(); ++ return { status, label }; ++ } ++ const result = failure('selection-unverified', { ++ modelKind, ++ power: collectGpt56PowerProofDiagnostic(), ++ }); ++ closeOpenMenus(); ++ return result; ++ }; + const findOptionInMenu = (menu, modelKindOverride = null) => { + // Container controls reveal other controls; they are not tiers you can pick. + // Two shapes exist and both can collide with a tier label: a submenu opener +@@ -406,6 +674,14 @@ function buildThinkingTimeExpression(level, desiredModel) { + (item) => !isContainerControl(item), + ); + const modelKind = modelKindOverride || effectiveTargetModelKind(); ++ if (TARGET_IS_GPT56_MODEL && TARGET_LEVEL === 'heavy') { ++ for (const item of items) { ++ const itemText = normalize( ++ (item.textContent ?? '') + ' ' + (item.getAttribute?.('aria-label') ?? ''), ++ ); ++ if (itemText === 'pro') return item; ++ } ++ } + if (modelKind === 'pro') { + // GPT-5.6's unified Intelligence picker exposes Pro as the highest + // effort radio directly. It no longer has a nested "Pro Extended" +@@ -609,27 +885,31 @@ function buildThinkingTimeExpression(level, desiredModel) { + hasToken(pillLabel, 'pro') || + currentEffortPillMatchesTarget(trigger, triggerModelKind) + ) { +- closeOpenMenus(); +- return { status: 'already-selected', label: trigger.textContent?.trim?.() || null }; ++ return finishVerifiedSelection( ++ 'already-selected', ++ trigger.textContent?.trim?.() || null, ++ triggerModelKind, ++ ); + } + } + if (!option) return failure('option-not-found', { modelKind: triggerModelKind }); + const label = option.textContent?.trim?.() || null; + if (optionIsSelected(option)) { +- closeOpenMenus(); +- return { status: 'already-selected', label }; ++ return finishVerifiedSelection('already-selected', label, triggerModelKind); + } + + dispatchClickSequence(option); + await sleep(STEP_WAIT_MS); + const refreshed = findOption(); + if (refreshed && optionIsSelected(refreshed)) { +- closeOpenMenus(); +- return { status: 'switched', label: refreshed.textContent?.trim?.() || label }; ++ return finishVerifiedSelection( ++ 'switched', ++ refreshed.textContent?.trim?.() || label, ++ triggerModelKind, ++ ); + } + if (currentEffortPillMatchesTarget(trigger, triggerModelKind)) { +- closeOpenMenus(); +- return { status: 'switched', label }; ++ return finishVerifiedSelection('switched', label, triggerModelKind); + } + + const reopenTrigger = freshComposerTrigger(trigger) || trigger; +@@ -641,12 +921,14 @@ function buildThinkingTimeExpression(level, desiredModel) { + while (performance.now() < deadline) { + const selected = findOption(); + if (selected && optionIsSelected(selected)) { +- closeOpenMenus(); +- return { status: 'switched', label: selected.textContent?.trim?.() || label }; ++ return finishVerifiedSelection( ++ 'switched', ++ selected.textContent?.trim?.() || label, ++ triggerModelKind, ++ ); + } + if (currentEffortPillMatchesTarget(trigger, triggerModelKind)) { +- closeOpenMenus(); +- return { status: 'switched', label }; ++ return finishVerifiedSelection('switched', label, triggerModelKind); + } + await sleep(100); + } diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 3e9e3988..3006d4ed 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -5,6 +5,35 @@ README는 현재 제품의 목적과 사용법만 설명합니다. 구현 변경 ## 현재 릴리스 +### 1.8.1 Oracle 0.17.3 승격 + +- 프로젝트 릴리스를 1.8.1로 올리고 신규 실행을 해시 검증한 Oracle + `0.17.3`으로 승격했습니다. DevSpace `1.0.7`, Node.js `>=24 <27`, npm + 의존성 없음은 그대로 유지합니다. +- Oracle `0.17.2`는 정확 복구 전용으로 내려갑니다. 신규 실행은 `0.17.3`만 + 허용하고 복구는 `0.16.1`, `0.17.0`, `0.17.1`, `0.17.2`만 추가로 + 받습니다. +- Oracle 0.17.3 상위 변경 네 가지—답변 placeholder 예측을 짧은 전체 문자열 + browser chrome으로 제한, manual-login 재연결 시 upstream 명시 opt-in을 + 통해서만 쿠키 동기화, 일본어 Advanced/Effort 라벨 인식, 명시적 + `--browser-headless` 준수—를 로컬 hash-gated 패치 아래 그대로 + 보존합니다. Power 증명·모델·transport·exact-session 의미는 바뀌지 + 않습니다. 아직 라이브 브라우저 검증은 하지 않았습니다. +- 설치 manifest에 0.17.3 패치 에셋 8개를 추가하고 Oracle npm 무결성 + sha512를 0.17.3 dist 값으로 갱신했습니다. +- upstream-drift 워크플로의 `actions/upload-artifact`를 v7(Node 24)로 + 올렸습니다. +- 활성 버전을 `0.17.3`으로 올려도 이미 시작된 정확한 `0.17.2` 실행은 해당 + 버전의 UI 실패 증거에만 결합해 `not_executed`로 정산할 수 있습니다. + `0.16.1`~`0.17.1` 및 미지의 버전은 이 승격으로 새 정산 권한을 얻지 않습니다. +- `doctor.ps1`은 최신 설치 영수증을 현재 manifest와 완료된 WAL에 결합하고, + manifest 전체 활성 파일 집합의 설치·소스·백업 SHA-256과 경로 소유권을 + 검증합니다. `bin/chatgpt_oracle_state.py`의 Oracle 버전·패키지 권위도 + 정확한 최상위 문자열 리터럴 한 쌍으로만 인정하며, 검증 하나라도 실패하면 + Oracle readback을 내보내지 않습니다. + +## 이전 릴리스 + ### 1.8.0 상위 런타임 갱신 - 지원 Node.js 범위를 `>=24 <27`로 올리고 프로젝트 릴리스를 1.8.0으로 @@ -71,8 +100,6 @@ README는 현재 제품의 목적과 사용법만 설명합니다. 구현 변경 디코딩하므로 Unicode 장치명이 있어도 setup doctor가 중단되거나 출력이 조용히 왜곡되지 않습니다. -## 이전 릴리스 - ### 1.7.0 신뢰성·보안 보강 - 설치 트랜잭션은 기존 WAL v1을 읽을 수 있는 상태로 보존하면서 새 WAL v2에 diff --git a/docs/RELEASE_CHECKLIST.md b/docs/RELEASE_CHECKLIST.md index 74e94961..599f9d10 100644 --- a/docs/RELEASE_CHECKLIST.md +++ b/docs/RELEASE_CHECKLIST.md @@ -1,7 +1,7 @@ # Release checklist -- Confirm package metadata remains `1.8.0` with Node.js `>=24 <27`. -- Confirm new runs pin Oracle `0.17.2`; exact recovery accepts only `0.16.1`, `0.17.0`, `0.17.1`, and `0.17.2`. +- Confirm package metadata remains `1.8.1` with Node.js `>=24 <27`. +- Confirm new runs pin Oracle `0.17.3`; exact recovery accepts only `0.16.1`, `0.17.0`, `0.17.1`, `0.17.2`, and `0.17.3`. - Confirm DevSpace setup pins `1.0.7` and both npm integrities match `install-manifest.json`. - Confirm regular routes use `GPT-5.6 Sol` and `extra-high` with the visible `Power 4 of 5` proof; Pro remains attachment-only `gpt-5.6-sol` and `heavy` with the `Power 5 of 5` proof. - Confirm no route enters Web Multi automatically or uses another backend as a fallback. diff --git a/docs/VS_UPSTREAM.md b/docs/VS_UPSTREAM.md index 0cace74f..ef427200 100644 --- a/docs/VS_UPSTREAM.md +++ b/docs/VS_UPSTREAM.md @@ -31,24 +31,24 @@ git diff --name-status FETCH_HEAD...HEAD ## B. Oracle compatibility layer -- npm package: `@steipete/oracle@0.17.2` -- npm integrity: `sha512-Y2I/sTML2YPZrmYaw1QbpNd7bt6so9ld1pTjRP/MiEKTWanYjoICkmCpWBplPXq+KzHiVsgyPqUZpwxxOpa2Jg==` -- npm tarball SHA-256: `983a1546d04bac99409124f12dfae32012b0cfd61b084f349a4d9f7d7c5b1350` -- source tag: `v0.17.2` at `4bd5989622532a3de4334a16d64a6ad982217f28` -- recovery versions: exact `0.16.1`, `0.17.0`, `0.17.1`, and `0.17.2`; only `0.17.2` may create a new run. +- npm package: `@steipete/oracle@0.17.3` +- npm integrity: `sha512-xoziw8brto9rEtOROHcMr4vHu70DDGQJ41bwMHpkJgA77MIZ11B+IQtGqKpZ48WkihmHkEUVEvWsf+eDwxtwgg==` +- npm tarball SHA-256: `9933f177884d6ca662f1131dbb9c17b95c0b01ccd877a2d93e5ee5f0778b357f` +- source tag: annotated `v0.17.3` object `0cc868ea1f8e769cbed90c71462f6d338ef7520b`, peeled to release commit `6b17e6db0caea40088cc80a741bb314db1cd566c` +- recovery versions: exact `0.16.1`, `0.17.0`, `0.17.1`, `0.17.2`, and `0.17.3`; only `0.17.3` may create a new run. -The exact 0.17.2 npm dist uses these hash-gated patches: +The exact 0.17.3 npm dist uses these hash-gated patches: | Dist target | Pristine SHA-256 | Patched SHA-256 | |---|---|---| | `dist/src/browser/chromeLifecycle.js` | `312b45c44d4cd69a3a057e7bd1584b58182b4b37bc88f6ce6c7d11e216267c81` | `61440e467d51031efb7bfc319aef05de7c9061585e5eec148d0e353938eb2093` | | `dist/src/browser/recoverConversation.js` | `d7e39d21acf07e6d227e761944519e11cd8d93930629cc87555d7de75a42d1ca` | `cc2a036f6e2409ae7edceee1f381a5062cd6cc5cd1618af465a1b384081ed69e` | | `dist/src/browser/profileCopy.js` | `06c692861f8a4c1a8769f957b9c582426a13bf4972262c47c1f24a87b239064f` | `71459a25b7c46f57bae6f23a5498301f6f6a1d39addf0c1cd4eee1d99b03372c` | -| `dist/src/cli/browserConfig.js` | `8a355cd8828a5025ea66c401b54140152bd1fe5538254893d577d52bc4a0f852` | `78d022150b959aa4cb26f2e2a743f88277246979f96813d91a4bcc55835dec18` | -| `dist/src/browser/index.js` | `335f29c8864399cf2795333e4da8b87bc1b3591c30862eb9e82ea12cd3b37d11` | `9a78695ba89a6e7eb6761dd06b9be74d500ac65b585158d75f8fd3c7a6eb8895` | -| `dist/src/browser/actions/assistantResponse.js` | `0bbc106f79c6abf253690c83794a2dab1b432378f57e16542d15cfcd5365e16d` | `18661304c7fb545bc327876d38045818cbd23257488137836d43661be8742af4` | +| `dist/src/cli/browserConfig.js` | `13b304a1b41cbc85257d9340a620bccd4d18bc52a36285ba46c2f72af84f0f84` | `a76f338e1afb3573c3436cd261ccbcefacd9c879c71a45e110cf7a3602a06d22` | +| `dist/src/browser/index.js` | `421f15c6693799571d586d80b7fc35b10492a63acf78d901e21786bf6ec71a90` | `cb7b828902163bac941f5890f78edd136cf723e17e262c1347e2843df20c3e44` | +| `dist/src/browser/actions/assistantResponse.js` | `93d2465ed7dce43d8093a91bada7656bc9ba7ba3729d2fcc43229fa8aa6e36de` | `aff8f7cb4e926b0e56c4b02456f54983b14fffa9e01f595fed4fd44a338d41f4` | | `dist/src/browser/actions/promptComposer.js` | `db090a5fb6d13c4c88a68b5e474a53a19c3857295a64c3ba4a0eef1868d06000` | `3767d8a6702e42191e8195641ad2f0834882bed9cda1362a723c906249402d96` | -| `dist/src/browser/actions/thinkingTime.js` | `303d33ebe915b27407ca22ec0da1d18729464ce50417f405ddb628c31f6fb867` | `77d00dadc13e77bd54b0254a7086a1c6d43a39deacd4f489da808f8d6334ab53` | +| `dist/src/browser/actions/thinkingTime.js` | `6ff4420e81570f6c0a4e277bdd993caf66739c3f633a7cdb733ed645bec2acda` | `98724eaf24e27d6f75b3eb7795c49650aee0be6a9a3698e09882c6d3a06c3185` | The promptComposer row emits the bare `@` through CDP `Input.dispatchKeyEvent`, then uses one fixed `delay(250)` settle before inserting the app name. Two live @@ -66,8 +66,9 @@ legacy levels `a3882c7881...`, `bb85c6f09f23...`, `87911b46026d...`, restored to pristine bytes through their exact legacy patches before the new patch is applied. -The thinking-time row preserves Oracle 0.17.2's upstream Advanced Model/Effort -navigation and self-ports the fork's stronger Power proof. The visible current +The thinking-time row preserves Oracle 0.17.3's upstream Advanced Model/Effort +navigation, including its Japanese Intelligence labels, and self-ports the +fork's stronger Power proof. The visible current effort pill must name a visible picker root through `aria-controls`. When that root is an Advanced subtree, proof may expand to the pill's closest visible menu only when that menu actually contains the controlled root. That one bound menu @@ -81,18 +82,22 @@ inner ARIA slider uses the explicit zero-based range `0..4`, so the proof maps that range to displayed Power `1..5` and rejects any control/text disagreement. Non-positive opacity is permitted only on the test-id-bound readout node; every ancestor, the owning pill, and the coherent Advanced view must remain visible -with positive opacity. The prior deployed 0.17.2 hashes `ba5cf86e...`, +with positive opacity. The 0.17.3 patch accepts only exact 0.17.3 pristine +bytes (`6ff4420e...`) and fails closed on every other hash. The 0.17.2 canonical +patched level `77d00dad...` and its older deployed hashes `ba5cf86e...`, `7ee4983f...`, `decfb683...`, `91c5d356...`, `9583e9b4...`, and -`fac49260...` are restored through their exact reverse assets before applying -the current hash-gated patch. -Oracle 0.17.1 remains exact-recovery-only with canonical patched hash -`c973d280...`; its deployed `01ad2aca...` proof level is restored through the -exact `thinkingTime.strict.pre-coherent-picker-proof.patch` reverse asset before +`fac49260...` belong exclusively to the 0.17.2 exact-recovery contract. That +contract restores each known level to the 0.17.2 pristine +`303d33eb...` through its exact reverse asset before applying the 0.17.2 +patch. Oracle 0.17.1 remains exact-recovery-only with canonical patched hash +`c973d280...`. Its +deployed `01ad2aca...` proof level is restored through the exact +`thinkingTime.strict.pre-coherent-picker-proof.patch` reverse asset before that recovery contract is applied. The prior `fd7e6fcf...` diagnostic-race level and the shipped `5378da62...` stable-visible and `2cf9f56a...` primary-CSS levels are restored through their exact reverse assets before the stricter patch is applied. -All package hashes are computed over canonical LF bytes: a fresh npx install keeps LF dist bytes +All package hashes are computed over canonical LF bytes. A fresh npx install keeps LF dist bytes while an older Windows deployment can carry CRLF bytes for the same patched result, so canonical hashing makes one contract hash bind both flavors instead of accepting two ambiguous hashes. Known fork legacy levels migrate @@ -104,7 +109,7 @@ patch plus the Pro-heavy upgrade (deployed raw CRLF recognized by their canonical hashes and restored to pristine bytes before the strict patch is applied; unknown bytes always fail closed. -Oracle 0.17.2 treats GPT-5.6 Sol effort as a visible Power slider. +Oracle 0.17.3 treats GPT-5.6 Sol effort as a visible Power slider. Regular runs are the single supported `extra-high` tier and require the visible `Power 4 of 5` proof before send; misleading `Medium` or `High` aliases are rejected without silent downgrade. Pro remains attachment-only @@ -116,9 +121,18 @@ selection-unverified failure settles only while the conversation URL and any durable output are absent, and monotonic exact-session authority is never regressed. +Oracle 0.17.3 also bounds the Answer-now placeholder predicate to short, +whole-string browser chrome so a substantive answer that mentions those labels +is retained. Manual-login reattach can synchronize cookies only through the +upstream explicit opt-in, explicit local `--browser-headless` is honored, and +Japanese Advanced/Effort labels are recognized without weakening the local +Power proof. All four upstream changes are preserved under the local patches. +The pnpm 11 repository migration does not change the published npm layout, +target paths, or Node `>=24` runtime floor. + For a new Oracle release, query registry metadata, download the exact npm tarball, verify integrity, calculate every pristine hash, dry-apply each patch, review changed upstream sources, calculate patched hashes, and only then update the version table and manifest. Source tags never substitute for npm dist bytes. -Oracle main was observed at `f5b9c8106cf6b826b3d48fc5a0fb19de26ee584b` on 2026-08-12 and re-observed unchanged on 2026-08-13. It remains newer than the `v0.17.2` release tag. The only runtime-source delta since the prior audit is upstream Japanese Intelligence effort-label recognition; it is not adopted because the local compatibility proof uses different Power-selector authority and must be ported and verified independently. No unreleased source-main code is adopted. +Oracle main was observed at `3a185f55918a8f0dd36f9c2f0144550616b88803` on 2026-08-14, two commits ahead of `v0.17.3`. Its unreleased cookie-sync-default change is not adopted: the local runner uses Oracle-owned throwaway copies of the signed-in profile, and source main never substitutes for exact released npm dist bytes. ## C. DevSpace compatibility layer diff --git a/doctor.ps1 b/doctor.ps1 index 1fa62276..d9bbcc08 100644 --- a/doctor.ps1 +++ b/doctor.ps1 @@ -6,12 +6,29 @@ param( $ErrorActionPreference = 'Stop' $CodexRoot = [IO.Path]::GetFullPath($CodexHome) +$RepoRoot = $PSScriptRoot $ReceiptRoot = Join-Path $CodexRoot 'receipts' +$BackupRoot = Join-Path $CodexRoot 'backups' $Issues = @() $Warnings = @() $Commands = @('powershell -ExecutionPolicy Bypass -File .\install.ps1 -WhatIf') $InstallReceiptValue = $null $ReceiptFiles = @{} +$Manifest = $null +$ManifestVersion = $null +$ManifestOk = $false +$ManifestFiles = @() +$OracleContract = $null +$OraclePackage = $null +$OracleVersion = $null +$OracleValidated = $false +$StateModulePath = $null +$Receipt = $null +$InstallJournal = $null +$ReceiptValid = $false +$ReceiptCurrent = $false +$PathCoverageOk = $false +$StateRecordOk = $false function Get-Sha256([string]$Path) { $stream = $null @@ -26,77 +43,352 @@ function Get-Sha256([string]$Path) { } } +function Test-PathEqual([string]$Left, [string]$Right) { + if ([string]::IsNullOrWhiteSpace($Left) -or [string]::IsNullOrWhiteSpace($Right)) { return $false } + ([IO.Path]::GetFullPath($Left)).Equals([IO.Path]::GetFullPath($Right), [StringComparison]::OrdinalIgnoreCase) +} + +function Test-Sha256Value($Value) { + $Value -is [string] -and $Value -match '^[a-f0-9]{64}$' +} + function Test-IsWithinRoot([string]$Root, [string]$Path) { - $rootPath = [IO.Path]::GetFullPath($Root).TrimEnd([IO.Path]::DirectorySeparatorChar,[IO.Path]::AltDirectorySeparatorChar) + $rootPath = [IO.Path]::GetFullPath($Root).TrimEnd([IO.Path]::DirectorySeparatorChar, [IO.Path]::AltDirectorySeparatorChar) $candidate = [IO.Path]::GetFullPath($Path) - $candidate.StartsWith($rootPath + [IO.Path]::DirectorySeparatorChar,[StringComparison]::OrdinalIgnoreCase) + $candidate.StartsWith($rootPath + [IO.Path]::DirectorySeparatorChar, [StringComparison]::OrdinalIgnoreCase) } function Get-SafeChild([string]$Root, [string]$Relative) { - if ([string]::IsNullOrWhiteSpace($Relative) -or [IO.Path]::IsPathRooted($Relative) -or $Relative -match '(^|[\/])\.{1,2}([\/]|$)') { - throw "unsafe receipt path: $Relative" + if ([string]::IsNullOrWhiteSpace($Relative) -or [IO.Path]::IsPathRooted($Relative) -or $Relative -match '(^|[\\/])\.{1,2}([\\/]|$)') { + throw "unsafe relative path: $Relative" } $candidate = [IO.Path]::GetFullPath((Join-Path $Root $Relative)) - if (!(Test-IsWithinRoot $Root $candidate)) { throw "receipt path escapes CODEX_HOME: $Relative" } + if (!(Test-IsWithinRoot $Root $candidate)) { throw "path escapes root: $Relative" } + $cursor = Split-Path -Parent $candidate + while ((Test-IsWithinRoot $Root $cursor) -and $cursor -ne [IO.Path]::GetFullPath($Root)) { + if (Test-Path -LiteralPath $cursor) { + $item = Get-Item -LiteralPath $cursor -Force + if ($item.LinkType) { throw "symlink/reparse path refused: $cursor" } + } + $cursor = Split-Path -Parent $cursor + } $candidate } +function Get-ManifestFiles([string]$Root, $Value) { + $files = @() + $itemsByBase = @{} + foreach ($pattern in $Value.include) { + if ($pattern -match '(^|/)\.{1,2}($|/)' -or [IO.Path]::IsPathRooted($pattern)) { throw "unsafe manifest pattern: $pattern" } + $base = if ($pattern.StartsWith('bin/')) { Join-Path $Root 'bin' } + elseif ($pattern.StartsWith('skills/')) { Join-Path $Root 'skills' } + elseif ($pattern.StartsWith('mcp_servers/')) { Join-Path $Root 'mcp_servers' } + elseif ($pattern.StartsWith('scripts/')) { Join-Path $Root 'scripts' } + elseif ($pattern.StartsWith('contracts/')) { Join-Path $Root 'contracts' } + elseif ($pattern.StartsWith('tests/fixtures/')) { Join-Path $Root 'tests/fixtures' } + else { throw "unsupported manifest root: $pattern" } + if (!$itemsByBase.ContainsKey($base)) { + $baseItems = @() + foreach ($item in @(Get-ChildItem -LiteralPath $base -File -Recurse -Force)) { + if ($item.LinkType) { throw "manifest refuses symlink: $($item.FullName)" } + $relative = $item.FullName.Substring($Root.Length).TrimStart([char[]]'\/').Replace('\', '/') + [void](Get-SafeChild $Root $relative) + $baseItems += $relative + } + $itemsByBase[$base] = @($baseItems) + } + $patternMatches = @($itemsByBase[$base] | Where-Object { $_ -like $pattern }) + if (!$patternMatches.Count) { throw "manifest pattern matched no files: $pattern" } + $files += $patternMatches + } + @($files | Sort-Object -Unique) +} + +function Assert-ReceiptBinding([string]$Root, $Journal, $ReceiptValue) { + if ($Journal.schema -eq 'codexpro.install-wal/v1') { return } + $receiptRoot = Join-Path $Root 'receipts' + if (!(Test-IsWithinRoot $receiptRoot ([string]$Journal.receipt))) { throw 'receipt_binding_ambiguous: receipt path is outside CODEX_HOME/receipts' } + if ($ReceiptValue.files -isnot [System.Array]) { throw 'receipt_binding_ambiguous: receipt files must be an array' } + $expectedSchema = if ($Journal.schema -eq 'codexpro.install-wal/v2') { 'codexpro.install-receipt/v3' } else { 'codexpro.install-receipt/v4' } + if ($ReceiptValue.schema -ne $expectedSchema -or [string]$ReceiptValue.transaction_id -ne [string]$Journal.transaction_id) { throw 'receipt_binding_ambiguous: receipt identity mismatch' } + if (!(Test-PathEqual ([string]$ReceiptValue.wal) ([string]$Journal.wal_path)) -or !(Test-PathEqual ([string]$ReceiptValue.backup) ([string]$Journal.backup))) { throw 'receipt_binding_ambiguous: receipt path binding mismatch' } + if ([string]$ReceiptValue.manifest_version -ne [string]$Journal.manifest_version) { throw 'receipt_binding_ambiguous: manifest version mismatch' } + $expected = @($Journal.files); $observed = @($ReceiptValue.files) + if ($expected.Count -ne $observed.Count) { throw 'receipt_binding_ambiguous: receipt file count mismatch' } + for ($index = 0; $index -lt $expected.Count; $index++) { + $left = $expected[$index]; $right = $observed[$index] + foreach ($field in @('path', 'action')) { if ($right.$field -isnot [string]) { throw "receipt_binding_ambiguous: receipt file field $field must be a string at index $index" } } + if ($null -ne $right.backup_sha256 -and $right.backup_sha256 -isnot [string]) { throw "receipt_binding_ambiguous: receipt backup_sha256 must be null or string at index $index" } + if (($null -eq $left.backup_sha256) -ne ($null -eq $right.backup_sha256)) { throw "receipt_binding_ambiguous: receipt backup_sha256 nullability mismatch at index $index" } + $fields = if ($left.action -eq 'removed') { @('path', 'action', 'expected_sha256', 'backup', 'backup_sha256', 'expected_absence', 'transaction_id', 'replacement', 'rollback_binding') } else { @('path', 'action', 'installed_sha256', 'backup_sha256') } + foreach ($field in $fields) { if ([string]$left.$field -ne [string]$right.$field) { throw "receipt_binding_ambiguous: receipt file record mismatch at index $index" } } + } +} + +function Assert-InstallWal([string]$Root, $Journal, [string]$DiscoveredWalPath) { + foreach ($field in @('schema', 'status', 'backup')) { if ($Journal.$field -isnot [string]) { throw "install WAL field $field must be a string" } } + if ($Journal.files -isnot [System.Array]) { throw 'install WAL files must be an array' } + if (@('codexpro.install-wal/v1', 'codexpro.install-wal/v2', 'codexpro.install-wal/v3') -notcontains [string]$Journal.schema) { throw 'unsupported install WAL schema' } + $validStatuses = if ($Journal.schema -eq 'codexpro.install-wal/v1') { @('ACTIVE', 'COMPLETE', 'ROLLED_BACK_AFTER_CRASH') } else { @('ACTIVE', 'COMPLETE', 'ROLLED_BACK_AFTER_CRASH', 'ROLLED_BACK_AFTER_ERROR') } + if ($validStatuses -notcontains [string]$Journal.status) { throw 'invalid install WAL status' } + if ([string]::IsNullOrWhiteSpace([string]$Journal.backup) -or [IO.Path]::IsPathRooted([string]$Journal.backup) -eq $false) { throw 'invalid install WAL backup' } + if (!(Test-IsWithinRoot (Join-Path $Root 'backups') ([string]$Journal.backup))) { throw 'install WAL backup is outside CODEX_HOME/backups' } + $orders = @{ + 'codexpro.install-wal/v1' = @('INTENT', 'MUTATED', 'VERIFIED', 'COMPLETE') + 'codexpro.install-wal/v2' = @('INTENT', 'BACKUP_DURABLE', 'MUTATED', 'VERIFIED', 'REPLACEMENT_RECEIPT_DURABLE', 'COMPLETE') + 'codexpro.install-wal/v3' = @('INTENT', 'BACKUP_DURABLE', 'MUTATED', 'VERIFIED', 'REPLACEMENT_RECEIPT_DURABLE', 'COMPLETE') + } + if ($Journal.schema -in @('codexpro.install-wal/v2', 'codexpro.install-wal/v3')) { + foreach ($field in @('transaction_id', 'manifest_version', 'receipt', 'wal_path')) { if ($Journal.$field -isnot [string]) { throw "WAL v2 field $field must be a string" } } + if ([string]$Journal.transaction_id -notmatch '^[a-f0-9]{32}$') { throw 'invalid WAL v2 transaction_id' } + if ([string]::IsNullOrWhiteSpace([string]$Journal.manifest_version)) { throw 'invalid WAL v2 manifest_version' } + if (!(Test-PathEqual ([string]$Journal.wal_path) $DiscoveredWalPath)) { throw 'WAL v2 serialized wal_path mismatch' } + if (!(Test-PathEqual ([string]$Journal.backup) (Split-Path -Parent $DiscoveredWalPath))) { throw 'WAL v2 backup does not own discovered WAL' } + if (!(Test-IsWithinRoot (Join-Path $Root 'receipts') ([string]$Journal.receipt))) { throw 'receipt_binding_ambiguous: WAL v2 receipt path is outside CODEX_HOME/receipts' } + } + $seen = @{}; $entries = @($Journal.files) + for ($index = 0; $index -lt $entries.Count; $index++) { + $entry = $entries[$index] + foreach ($field in @('path', 'action', 'phase', 'replacement')) { if ($entry.$field -isnot [string]) { throw "install WAL file field $field must be a string" } } + if ($entry.transitions -isnot [System.Array]) { throw 'install WAL transitions must be an array' } + foreach ($transition in @($entry.transitions)) { if ($transition -isnot [string]) { throw 'install WAL transition values must be strings' } } + if ($null -ne $entry.backup_sha256 -and $entry.backup_sha256 -isnot [string]) { throw 'install WAL backup_sha256 must be null or string' } + $relativePath = [string]$entry.path + if ([string]::IsNullOrWhiteSpace($relativePath) -or [IO.Path]::IsPathRooted($relativePath) -or $relativePath -match '(^|[\\/])\.{1,2}([\\/]|$)' -or !(Test-IsWithinRoot $Root ([IO.Path]::GetFullPath((Join-Path $Root $relativePath))))) { throw 'unsafe install WAL destination' } + if ($seen.ContainsKey([string]$entry.path)) { throw 'duplicate install WAL destination' }; $seen[[string]$entry.path] = $true + $validActions = if ($Journal.schema -eq 'codexpro.install-wal/v3') { @('created', 'overwritten', 'removed') } else { @('created', 'overwritten') } + if ($validActions -notcontains [string]$entry.action) { throw 'invalid install WAL action' } + if ($entry.action -ne 'removed' -and !(Test-Sha256Value $entry.installed_sha256)) { throw 'invalid installed_sha256 in install WAL' } + if ([string]::IsNullOrWhiteSpace([string]$entry.replacement) -or !(Test-IsWithinRoot ([string]$Journal.backup) ([string]$entry.replacement))) { throw 'invalid install WAL replacement path' } + $order = @($orders[[string]$Journal.schema]); $phaseIndex = [Array]::IndexOf($order, [string]$entry.phase) + if ($phaseIndex -lt 0) { throw 'invalid install WAL phase' } + $transitions = @($entry.transitions); $expectedTransitions = @($order[0..$phaseIndex]) + if (($transitions -join '|') -ne ($expectedTransitions -join '|')) { throw 'invalid install WAL transition order' } + if ($Journal.schema -in @('codexpro.install-wal/v2', 'codexpro.install-wal/v3')) { + if (($entry.sequence_number -isnot [int] -and $entry.sequence_number -isnot [long]) -or [int64]$entry.sequence_number -ne $index) { throw 'invalid WAL v2 sequence_number' } + if ($entry.action -eq 'created' -and $null -ne $entry.backup_sha256) { throw 'created WAL entry cannot carry backup_sha256' } + if ($entry.action -eq 'overwritten' -and $phaseIndex -eq 0 -and $null -ne $entry.backup_sha256) { throw 'overwritten WAL INTENT entry cannot carry backup_sha256' } + if ($entry.action -eq 'overwritten' -and $phaseIndex -ge 1 -and !(Test-Sha256Value $entry.backup_sha256)) { throw 'overwritten WAL entry lacks durable backup hash' } + if ($entry.action -eq 'removed') { + foreach ($field in @('expected_sha256', 'backup', 'transaction_id', 'rollback_binding')) { if ($entry.$field -isnot [string]) { throw "removed WAL entry field $field must be a string" } } + if (!(Test-Sha256Value $entry.expected_sha256) -or $entry.expected_absence -ne $true) { throw 'invalid removed WAL expected state' } + if ([string]$entry.transaction_id -ne [string]$Journal.transaction_id -or [string]$entry.rollback_binding -ne [string]$Journal.transaction_id) { throw 'removed WAL transaction binding mismatch' } + if (!(Test-PathEqual ([string]$entry.backup) (Get-SafeChild ([string]$Journal.backup) ([string]$entry.path)))) { throw 'removed WAL backup path mismatch' } + if ($phaseIndex -eq 0 -and $null -ne $entry.backup_sha256) { throw 'removed WAL INTENT entry cannot carry backup_sha256' } + if ($phaseIndex -ge 1 -and !(Test-Sha256Value $entry.backup_sha256)) { throw 'removed WAL entry lacks durable backup hash' } + } + if ($phaseIndex -ge 4) { + if (!(Test-Path -LiteralPath ([string]$entry.replacement) -PathType Leaf)) { throw 'install WAL replacement receipt is missing' } + $replacementValue = Get-Content -LiteralPath ([string]$entry.replacement) -Raw | ConvertFrom-Json + $expectedReplacementSchema = if ($entry.action -eq 'removed') { 'codexpro.install-removal/v1' } else { 'codexpro.install-replacement/v1' } + if ($replacementValue.schema -ne $expectedReplacementSchema) { throw 'install WAL replacement receipt schema mismatch' } + foreach ($field in @('path', 'action')) { if ($replacementValue.$field -isnot [string]) { throw "install WAL replacement receipt field $field must be a string" } } + if ($null -ne $replacementValue.backup_sha256 -and $replacementValue.backup_sha256 -isnot [string]) { throw 'install WAL replacement receipt backup_sha256 must be null or string' } + if (($null -eq $entry.backup_sha256) -ne ($null -eq $replacementValue.backup_sha256)) { throw 'install WAL replacement receipt backup_sha256 nullability mismatch' } + $bindingFields = if ($entry.action -eq 'removed') { @('path', 'action', 'expected_sha256', 'backup', 'backup_sha256', 'expected_absence', 'transaction_id', 'rollback_binding') } else { @('path', 'action', 'installed_sha256', 'backup_sha256') } + foreach ($field in $bindingFields) { if ([string]$replacementValue.$field -ne [string]$entry.$field) { throw 'install WAL replacement receipt binding mismatch' } } + } + } + } + if ($Journal.schema -in @('codexpro.install-wal/v2', 'codexpro.install-wal/v3')) { + $receiptExists = Test-Path -LiteralPath ([string]$Journal.receipt) -PathType Leaf + if ($Journal.status -eq 'COMPLETE' -and !$receiptExists) { throw 'receipt_binding_ambiguous: completed WAL receipt is missing' } + if ($Journal.status -eq 'COMPLETE' -and @($Journal.files | Where-Object { $_.phase -ne 'COMPLETE' }).Count) { throw 'receipt_binding_ambiguous: completed WAL contains incomplete entries' } + if ($receiptExists) { + if (@($Journal.files | Where-Object { $_.phase -ne 'COMPLETE' }).Count) { throw 'receipt_binding_ambiguous: receipt exists before all WAL entries are complete' } + $receiptValue = Get-Content -LiteralPath ([string]$Journal.receipt) -Raw | ConvertFrom-Json + Assert-ReceiptBinding $Root $Journal $receiptValue + } + } +} + +# --- Manifest authority --- +try { + $Manifest = Get-Content -LiteralPath (Join-Path $RepoRoot 'install-manifest.json') -Raw | ConvertFrom-Json + if ([string]$Manifest.schema -cne 'codexpro.install-manifest/v1') { throw 'install-manifest.json schema is not codexpro.install-manifest/v1' } + $ManifestVersion = [string]$Manifest.version + if ([string]::IsNullOrWhiteSpace($ManifestVersion)) { throw 'install-manifest.json version is missing' } + if ($Manifest.include -isnot [System.Array] -or !@($Manifest.include).Count) { throw 'install-manifest.json include is missing or empty' } + foreach ($pattern in @($Manifest.include)) { + if ($pattern -isnot [string] -or [string]::IsNullOrWhiteSpace($pattern)) { throw 'install-manifest.json include contains a non-string pattern' } + } + $OracleContract = $Manifest.external.oracle + if (!$OracleContract) { throw 'install-manifest.json external.oracle is missing' } + foreach ($field in @('package', 'tested_version', 'installation')) { + if ($OracleContract.$field -isnot [string] -or [string]::IsNullOrWhiteSpace($OracleContract.$field)) { throw "install-manifest.json external.oracle.$field is missing" } + } + $ManifestOk = $true + $ManifestFiles = @(Get-ManifestFiles $RepoRoot $Manifest) +} catch { + $ManifestOk = $false + $Issues += @{code = 'MANIFEST_INVALID'; detail = $_.Exception.Message} +} + +# --- Receipt authority: newest receipt must be v4 bound to a COMPLETE v3 WAL --- $Receipt = Get-ChildItem -LiteralPath $ReceiptRoot -Filter 'codexpro-automation-*.json' -File -ErrorAction SilentlyContinue | Sort-Object LastWriteTimeUtc -Descending | Select-Object -First 1 if (!$Receipt) { - $Issues += @{code='RECEIPT_MISSING'; detail='No install receipt found'} + $Issues += @{code = 'RECEIPT_MISSING'; detail = 'No install receipt found'} } else { try { + $ReceiptItem = Get-Item -LiteralPath $Receipt.FullName -Force + $ReceiptRelative = $Receipt.FullName.Substring($CodexRoot.Length).TrimStart([char[]]'\/') + [void](Get-SafeChild $CodexRoot $ReceiptRelative) + if ($ReceiptItem.LinkType) { throw 'newest install receipt is a reparse point' } $Value = Get-Content -LiteralPath $Receipt.FullName -Raw | ConvertFrom-Json $InstallReceiptValue = $Value - if (@('codexpro.install-receipt/v2','codexpro.install-receipt/v3','codexpro.install-receipt/v4') -notcontains [string]$Value.schema) { + if (@('codexpro.install-receipt/v2', 'codexpro.install-receipt/v3', 'codexpro.install-receipt/v4') -notcontains [string]$Value.schema) { throw 'unsupported install receipt schema' } - foreach ($Record in $Value.files) { - $Relative = ([string]$Record.path).Replace('\','/') - $ReceiptFiles[$Relative] = $true + if ([string]$Value.schema -ne 'codexpro.install-receipt/v4') { + throw 'newest install receipt is not codexpro.install-receipt/v4' + } + if ($Value.files -isnot [System.Array]) { throw 'install receipt files must be an array' } + $WalPath = [string]$Value.wal + if ([string]::IsNullOrWhiteSpace($WalPath)) { throw 'install receipt wal path is missing' } + $WalFull = [IO.Path]::GetFullPath($WalPath) + $WalRelative = $WalFull.Substring($CodexRoot.Length).TrimStart([char[]]'\/') + [void](Get-SafeChild $CodexRoot $WalRelative) + if (!(Test-IsWithinRoot $BackupRoot $WalFull)) { throw 'install receipt wal is outside CODEX_HOME/backups' } + if (!(Test-Path -LiteralPath $WalFull -PathType Leaf)) { throw 'install receipt wal is missing' } + $WalItem = Get-Item -LiteralPath $WalFull -Force + if ($WalItem.LinkType) { throw 'install receipt wal is a reparse point' } + $Journal = Get-Content -LiteralPath $WalFull -Raw | ConvertFrom-Json + if ([string]$Journal.schema -ne 'codexpro.install-wal/v3') { throw 'current receipt requires codexpro.install-wal/v3' } + if ([string]$Journal.status -ne 'COMPLETE') { throw 'install WAL status is not COMPLETE' } + if (!(Test-PathEqual ([string]$Journal.receipt) $Receipt.FullName)) { throw 'install WAL does not bind to the newest receipt' } + Assert-InstallWal $CodexRoot $Journal $WalFull + Assert-ReceiptBinding $CodexRoot $Journal $Value + $InstallJournal = $Journal + if ([string]::IsNullOrWhiteSpace($ManifestVersion) -or [string]::IsNullOrWhiteSpace([string]$Value.manifest_version) -or [string]$Value.manifest_version -ne $ManifestVersion) { + $Issues += @{code = 'RECEIPT_MANIFEST_VERSION_MISMATCH'; receipt = [string]$Value.manifest_version; current = $ManifestVersion; detail = 'newest receipt was not installed from the current release manifest'} + $ReceiptCurrent = $false + } else { + $ReceiptCurrent = $true + } + foreach ($Record in @($Journal.files)) { + $ReceiptFiles[([string]$Record.path).Replace('\', '/')] = [string]$Record.action + } + $ReceiptValid = $true + } catch { + $Issues += @{code = 'RECEIPT_INVALID'; detail = $_.Exception.Message} + $ReceiptCurrent = $false + $ReceiptValid = $false + } +} + +# --- Active record authority: every active record must be a regular non-reparse +# destination matching both its bound receipt/WAL hash and the current source --- +if ($ReceiptValid) { + $PathCoverageOk = $true + $ActivePaths = @{} + foreach ($Record in @($InstallJournal.files)) { + $Relative = ([string]$Record.path).Replace('\', '/') + $Action = [string]$Record.action + if ($Action -eq 'removed') { + try { + $Path = Get-SafeChild $CodexRoot $Relative + if (Test-Path -LiteralPath $Path) { + $Issues += @{code = 'REMOVED_FILE_PRESENT'; path = $Relative} + $PathCoverageOk = $false + } + } catch { + $Issues += @{code = 'RECEIPT_RECORD_INVALID'; path = $Relative; detail = $_.Exception.Message} + $PathCoverageOk = $false + } + continue + } + $ActivePaths[$Relative] = $true + $RecordOk = $true + try { $Path = Get-SafeChild $CodexRoot $Relative - if ($Record.action -eq 'removed') { - if (Test-Path -LiteralPath $Path) { $Issues += @{code='REMOVED_FILE_PRESENT'; path=$Record.path} } - continue + if (!(Test-Path -LiteralPath $Path -PathType Leaf)) { + $Issues += @{code = 'FILE_MISSING'; path = $Relative} + $RecordOk = $false + } else { + $Item = Get-Item -LiteralPath $Path -Force + if ($Item.LinkType -or $Item.PSIsContainer) { + $Issues += @{code = 'FILE_NOT_REGULAR'; path = $Relative} + $RecordOk = $false + } } - if (!(Test-Path -LiteralPath $Path)) { - $Issues += @{code='FILE_MISSING'; path=$Record.path} - continue + if (!(Test-Sha256Value ([string]$Record.installed_sha256))) { + $Issues += @{code = 'RECEIPT_RECORD_INVALID'; path = $Relative; detail = 'installed_sha256 is not a valid sha256'} + $RecordOk = $false + } + $Source = Get-SafeChild $RepoRoot $Relative + if (!(Test-Path -LiteralPath $Source -PathType Leaf)) { + $Issues += @{code = 'SOURCE_NOT_REGULAR'; path = $Relative} + $RecordOk = $false + } else { + $SourceItem = Get-Item -LiteralPath $Source -Force + if ($SourceItem.LinkType -or $SourceItem.PSIsContainer) { + $Issues += @{code = 'SOURCE_NOT_REGULAR'; path = $Relative} + $RecordOk = $false + } + } + if ($RecordOk -and (Test-Path -LiteralPath $Path -PathType Leaf)) { + $Actual = Get-Sha256 $Path + if ($Actual -ne [string]$Record.installed_sha256) { + $Issues += @{code = 'HASH_MISMATCH'; path = $Relative; actual = $Actual} + $RecordOk = $false + } + } + if ($RecordOk -and (Test-Path -LiteralPath $Source -PathType Leaf)) { + $SourceHash = Get-Sha256 $Source + if ($SourceHash -ne [string]$Record.installed_sha256) { + $Issues += @{code = 'SOURCE_HASH_MISMATCH'; path = $Relative; source = $SourceHash; expected = [string]$Record.installed_sha256} + $RecordOk = $false + } } - $Actual = Get-Sha256 $Path - if ($Actual -ne $Record.installed_sha256) { - $Issues += @{code='HASH_MISMATCH'; path=$Record.path; actual=$Actual} + if ($RecordOk -and $Relative -eq 'bin/chatgpt_oracle_state.py') { + $StateRecordOk = $true } + } catch { + $Issues += @{code = 'RECEIPT_RECORD_INVALID'; path = $Relative; detail = $_.Exception.Message} + $RecordOk = $false } - } catch { - $Issues += @{code='RECEIPT_INVALID'; detail=$_.Exception.Message} + if (!$RecordOk) { $PathCoverageOk = $false } + } + if ($ManifestOk) { + $ManifestSet = @{} + foreach ($file in $ManifestFiles) { $ManifestSet[$file] = $true } + $MissingFromReceipt = @($ManifestSet.Keys | Where-Object { !$ActivePaths.ContainsKey($_) }) + $ExtraInReceipt = @($ActivePaths.Keys | Where-Object { !$ManifestSet.ContainsKey($_) }) + if ($MissingFromReceipt.Count -or $ExtraInReceipt.Count) { + $Issues += @{code = 'ACTIVE_RECORD_SET_MISMATCH'; missing = @($MissingFromReceipt); extra = @($ExtraInReceipt); detail = 'active receipt records do not exactly match the expanded current manifest'} + $PathCoverageOk = $false + } + } else { + $PathCoverageOk = $false } } -$Node = Get-Command node.exe,node -ErrorAction SilentlyContinue | Select-Object -First 1 -$Npx = Get-Command npx.cmd,npx -ErrorAction SilentlyContinue | Select-Object -First 1 +$Node = Get-Command node.exe, node -ErrorAction SilentlyContinue | Select-Object -First 1 +$Npx = Get-Command npx.cmd, npx -ErrorAction SilentlyContinue | Select-Object -First 1 $GitBash = Get-Item -LiteralPath 'C:\Program Files\Git\bin\bash.exe' -ErrorAction SilentlyContinue if (!$Node -or !$Npx) { - $Issues += @{code='ORACLE_DEVSPACE_NODE_TOOLING_MISSING'; detail='Node and npx are required for Oracle and DevSpace'} + $Issues += @{code = 'ORACLE_DEVSPACE_NODE_TOOLING_MISSING'; detail = 'Node and npx are required for Oracle and DevSpace'} } else { try { $NodeVersion = (& $Node.Source --version).Trim().TrimStart('v') $NodeMajor = [int]($NodeVersion.Split('.')[0]) if ($NodeMajor -lt 24 -or $NodeMajor -ge 27) { - $Issues += @{code='DEVSPACE_NODE_VERSION_UNSUPPORTED'; actual=$NodeVersion; required='>=24 <27'} + $Issues += @{code = 'DEVSPACE_NODE_VERSION_UNSUPPORTED'; actual = $NodeVersion; required = '>=24 <27'} } } catch { - $Issues += @{code='NODE_VERSION_UNREADABLE'; detail=$_.Exception.Message} + $Issues += @{code = 'NODE_VERSION_UNREADABLE'; detail = $_.Exception.Message} } } if (!$GitBash) { - $Issues += @{code='DEVSPACE_GIT_BASH_MISSING'; detail='Windows DevSpace requires Git Bash'} + $Issues += @{code = 'DEVSPACE_GIT_BASH_MISSING'; detail = 'Windows DevSpace requires Git Bash'} +} +if ($ManifestOk -and $OracleContract) { + $Commands += "$($OracleContract.installation) --version" } -$Commands += 'npx -y @steipete/oracle@0.17.2 --version' $Commands += 'python .\skills\chatgpt-workspace-setup\scripts\devspace_tailscale_setup.py doctor --root C:\project --hostname your-device.your-tailnet.ts.net' -$Python = Get-Command python.exe,python -ErrorAction SilentlyContinue | Select-Object -First 1 +$Python = Get-Command python.exe, python -ErrorAction SilentlyContinue | Select-Object -First 1 $CompatibilityProbe = @' import ast,json,sys root=ast.parse(open(sys.argv[1],encoding='utf-8').read()) @@ -114,10 +406,87 @@ for node in root.body: versions={version:values[name] for version,name in refs.get('VERSION_PATCHES',{}).items()} or {values['SUPPORTED_VERSION']:values['PATCHES']} print(json.dumps([{'version':str(version),'patch':str(contract['patch'])} for version,patches in versions.items() for contract in patches.values()])) '@ -if ($Python) { +$StateProbe = @' +import ast,json,sys +root=ast.parse(open(sys.argv[1],encoding='utf-8').read()) +names={'ORACLE_ACTIVE_VERSION','ORACLE_PACKAGE'} +accepted={} +for node in root.body: + if isinstance(node,ast.Assign) and len(node.targets)==1 and isinstance(node.targets[0],ast.Name) and node.targets[0].id in names: + accepted.setdefault(node.targets[0].id,[]).append(node) +rejections=[] +for node in ast.walk(root): + if isinstance(node,ast.Assign): + kind='ASSIGN';targets=node.targets + elif isinstance(node,ast.AnnAssign): + kind='ANNASSIGN';targets=[node.target] if node.target else [] + elif isinstance(node,ast.AugAssign): + kind='AUGASSIGN';targets=[node.target] + elif isinstance(node,ast.Delete): + kind='DELETE';targets=node.targets + else: + continue + for target in targets: + hit=set() + for sub in ([target] if isinstance(target,ast.Name) else ast.walk(target)): + if isinstance(sub,ast.Name) and sub.id in names: + hit.add(sub.id) + for name in hit: + if kind=='ASSIGN' and isinstance(target,ast.Name) and node in root.body and node in accepted.get(name,[]): + continue + if kind=='ASSIGN' and isinstance(target,ast.Name) and node in root.body: + rejections.append('DUPLICATE:'+name) + elif kind=='ASSIGN': + rejections.append('NESTED:'+name) + else: + rejections.append(kind+':'+name) +accepted_target_ids={id(node.targets[0]) for nodes in accepted.values() for node in nodes} +for node in ast.walk(root): + if isinstance(node,ast.Name) and node.id in names and isinstance(node.ctx,(ast.Store,ast.Del)) and id(node) not in accepted_target_ids: + rejections.append('REBIND:'+node.id) + bound=[] + if isinstance(node,(ast.FunctionDef,ast.AsyncFunctionDef,ast.ClassDef)): + bound=[node.name] + elif isinstance(node,ast.alias): + bound=[node.asname or node.name.split('.')[0]] + elif isinstance(node,ast.ExceptHandler) and isinstance(node.name,str): + bound=[node.name] + elif isinstance(node,ast.arg): + bound=[node.arg] + elif isinstance(node,(ast.MatchAs,ast.MatchStar)) and isinstance(node.name,str): + bound=[node.name] + elif isinstance(node,ast.MatchMapping) and isinstance(node.rest,str): + bound=[node.rest] + for name in bound: + if name in names: + rejections.append('BINDER:'+name) +values={} +for name in names: + nodes=accepted.get(name,[]) + if len(nodes)>1: + rejections.append('DUPLICATE:'+name) + elif len(nodes)==1: + node=nodes[0] + if not isinstance(node.value,ast.Constant): + rejections.append('NONLITERAL:'+name) + else: + try: + values[name]=ast.literal_eval(node.value) + except Exception: + rejections.append('NONLITERAL:'+name) + else: + rejections.append('MISSING:'+name) +if rejections: + print(json.dumps({'valid':False,'rejection':rejections[0]})) +else: + print(json.dumps({'valid':True,'values':values})) +'@ +if (!$Python) { + $Issues += @{code = 'PYTHON_MISSING'; detail = 'Python is required for the installed automation'} +} else { foreach ($Compat in @( - @{module='bin/chatgpt_oracle_compat.py'; asset_root='bin/oracle-compat'}, - @{module='bin/chatgpt_devspace_compat.py'; asset_root='bin/devspace-compat'} + @{module = 'bin/chatgpt_oracle_compat.py'; asset_root = 'bin/oracle-compat'}, + @{module = 'bin/chatgpt_devspace_compat.py'; asset_root = 'bin/devspace-compat'} )) { if (!$ReceiptFiles.ContainsKey($Compat.module)) { continue } try { @@ -129,28 +498,62 @@ if ($Python) { $Relative = "$($Compat.asset_root)/$($Reference.version)/$($Reference.patch)" $PatchPath = Get-SafeChild $CodexRoot $Relative if (!$ReceiptFiles.ContainsKey($Relative) -or !(Test-Path -LiteralPath $PatchPath -PathType Leaf)) { - $Issues += @{code='COMPAT_PATCH_ASSET_MISSING'; module=$Compat.module; path=$Relative} + $Issues += @{code = 'COMPAT_PATCH_ASSET_MISSING'; module = $Compat.module; path = $Relative} } } } catch { - $Issues += @{code='COMPAT_REFERENCE_INVALID'; module=$Compat.module; detail=$_.Exception.Message} + $Issues += @{code = 'COMPAT_REFERENCE_INVALID'; module = $Compat.module; detail = $_.Exception.Message} + } + } + if (!$ReceiptValid) { + # State authority cannot be bound without a fully validated current receipt. + } elseif (!$ManifestOk) { + # State authority cannot be compared without a valid manifest. + } elseif (!$ReceiptFiles.ContainsKey('bin/chatgpt_oracle_state.py') -or $ReceiptFiles['bin/chatgpt_oracle_state.py'] -eq 'removed') { + $Issues += @{code = 'ORACLE_STATE_MODULE_NOT_RECEIPTED'; path = 'bin/chatgpt_oracle_state.py'} + } else { + try { + $StateModulePath = Get-SafeChild $CodexRoot 'bin/chatgpt_oracle_state.py' + $StateProbeOutput = @(& $Python.Source -c $StateProbe $StateModulePath) + if ($LASTEXITCODE) { throw "oracle state probe failed with exit code $LASTEXITCODE" } + $StateProbeResult = (($StateProbeOutput -join [Environment]::NewLine) | ConvertFrom-Json) + if ($StateProbeResult.valid -ne $true) { + $Issues += @{code = 'ORACLE_STATE_INVALID'; path = 'bin/chatgpt_oracle_state.py'; detail = "state authority binding rejected: $([string]$StateProbeResult.rejection)"} + } else { + $OracleVersion = [string]$StateProbeResult.values.ORACLE_ACTIVE_VERSION + $OraclePackage = [string]$StateProbeResult.values.ORACLE_PACKAGE + if ([string]::IsNullOrWhiteSpace($OracleVersion)) { throw 'ORACLE_ACTIVE_VERSION is missing from the installed state module' } + if ([string]::IsNullOrWhiteSpace($OraclePackage)) { throw 'ORACLE_PACKAGE is missing from the installed state module' } + $StateOk = $true + if ($OracleVersion -cne [string]$OracleContract.tested_version) { + $StateOk = $false + $Issues += @{code = 'ORACLE_ACTIVE_VERSION_MISMATCH'; installed = $OracleVersion; expected = [string]$OracleContract.tested_version; path = 'bin/chatgpt_oracle_state.py'; detail = 'installed Oracle active version does not exactly match the release contract tested version'} + } + if ($OraclePackage -cne [string]$OracleContract.package) { + $StateOk = $false + $Issues += @{code = 'ORACLE_PACKAGE_MISMATCH'; installed = $OraclePackage; expected = [string]$OracleContract.package; path = 'bin/chatgpt_oracle_state.py'; detail = 'installed Oracle package does not exactly match the release contract package'} + } + if ($StateOk) { $OracleValidated = $true } + } + } catch { + $Issues += @{code = 'ORACLE_STATE_INVALID'; detail = $_.Exception.Message} } } } -if (!$Python) { - $Issues += @{code='PYTHON_MISSING'; detail='Python is required for the installed automation'} -} + +$OracleEligible = $ManifestOk -and $ReceiptValid -and $ReceiptCurrent -and $PathCoverageOk -and $StateRecordOk -and $OracleValidated [ordered]@{ schema = 'codexpro.doctor/v2' codex_home = $CodexRoot + manifest_version = $ManifestVersion receipt = $(if ($Receipt) { $Receipt.FullName } else { $null }) status = $(if ($Issues) { 'FAIL' } else { 'PASS' }) issues = $Issues warnings = $Warnings commands = $Commands - oracle = @{package='@steipete/oracle@0.17.2';tested_version='0.17.2';resolution='exact npx runtime pin'} - devspace = @{package='@waishnav/devspace';tested_version='1.0.7';setup='explicit setup skill only'} + oracle = $(if ($OracleEligible) { @{package = "$OraclePackage@$OracleVersion"; tested_version = $OracleVersion; command = "$($OracleContract.installation)"; resolution = 'exact npx runtime pin'; evidence = $StateModulePath} } else { $null }) + devspace = $(if ($ManifestOk -and $Manifest.external.devspace) { @{package = [string]$Manifest.external.devspace.package; tested_version = [string]$Manifest.external.devspace.tested_version; setup = 'explicit setup skill only'} } else { @{package = '@waishnav/devspace'; tested_version = '1.0.7'; setup = 'explicit setup skill only'} }) what_if = [bool]$WhatIf } | ConvertTo-Json -Depth 7 if ($Issues) { exit 1 } diff --git a/install-manifest.json b/install-manifest.json index 27c86a01..73e7df63 100644 --- a/install-manifest.json +++ b/install-manifest.json @@ -1,6 +1,6 @@ { "schema": "codexpro.install-manifest/v1", - "version": "1.8.0", + "version": "1.8.1", "include": [ "bin/chatgpt_oracle_run.py", "bin/chatgpt_devspace_compat.py", @@ -71,6 +71,14 @@ "bin/oracle-compat/0.17.2/thinkingTime.strict.pre-power-range-validation.patch", "bin/oracle-compat/0.17.2/thinkingTime.strict.pre-self-transparent-slider.patch", "bin/oracle-compat/0.17.2/thinkingTime.strict.patch", + "bin/oracle-compat/0.17.3/assistantResponse.patch", + "bin/oracle-compat/0.17.3/browserConfig.patch", + "bin/oracle-compat/0.17.3/browserIndex.patch", + "bin/oracle-compat/0.17.3/chromeLifecycle.patch", + "bin/oracle-compat/0.17.3/profileCopy.patch", + "bin/oracle-compat/0.17.3/promptComposer.patch", + "bin/oracle-compat/0.17.3/recoverConversation.patch", + "bin/oracle-compat/0.17.3/thinkingTime.strict.patch", "bin/chatgpt_oracle_state.py", "bin/chatgpt_oracle_profiles.py", "bin/chatgpt_oracle_projects.py", @@ -121,10 +129,10 @@ "external": { "oracle": { "package": "@steipete/oracle", - "tested_version": "0.17.2", + "tested_version": "0.17.3", "license": "MIT", - "integrity": "sha512-Y2I/sTML2YPZrmYaw1QbpNd7bt6so9ld1pTjRP/MiEKTWanYjoICkmCpWBplPXq+KzHiVsgyPqUZpwxxOpa2Jg==", - "installation": "npx -y @steipete/oracle@0.17.2", + "integrity": "sha512-xoziw8brto9rEtOROHcMr4vHu70DDGQJ41bwMHpkJgA77MIZ11B+IQtGqKpZ48WkihmHkEUVEvWsf+eDwxtwgg==", + "installation": "npx -y @steipete/oracle@0.17.3", "repository": "steipete/oracle", "release_tag_convention": "v{version}" }, diff --git a/package-lock.json b/package-lock.json index 9c5384a8..ec5dd289 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "codexpro-automation", - "version": "1.8.0", + "version": "1.8.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "codexpro-automation", - "version": "1.8.0", + "version": "1.8.1", "license": "MIT", "engines": { "node": ">=24 <27" diff --git a/package.json b/package.json index eb396475..574494e1 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "codexpro-automation", - "version": "1.8.0", + "version": "1.8.1", "private": false, "description": "Windows orchestrator that delegates Codex planning, research, implementation, and review to web ChatGPT through Oracle and DevSpace", "license": "MIT", diff --git a/scripts/check_upstream.py b/scripts/check_upstream.py index 9e1ed8f1..751488c1 100644 --- a/scripts/check_upstream.py +++ b/scripts/check_upstream.py @@ -31,7 +31,10 @@ def fetch(url: str) -> Any: def patch_targets(module: Path, assignment: str) -> set[str]: tree = ast.parse(module.read_text(encoding="utf-8")) for node in tree.body: - if isinstance(node, ast.Assign) and any(isinstance(target, ast.Name) and target.id == assignment for target in node.targets): + if isinstance(node, ast.Assign) and any( + isinstance(target, ast.Name) and target.id == assignment + for target in node.targets + ): value = ast.literal_eval(node.value) return set(value) raise ValueError(f"{assignment} is not a literal mapping in {module}") @@ -181,8 +184,9 @@ def check(name: str, contract: dict[str, Any], targets: set[str]) -> dict[str, A def report() -> dict[str, Any]: manifest = json.loads((ROOT / "install-manifest.json").read_text(encoding="utf-8")) external = manifest["external"] + oracle_version = str(external["oracle"]["tested_version"]).replace(".", "") specs = { - "oracle": (external["oracle"], patch_targets(ROOT / "bin/chatgpt_oracle_compat.py", "PATCHES_0172")), + "oracle": (external["oracle"], patch_targets(ROOT / "bin/chatgpt_oracle_compat.py", f"PATCHES_{oracle_version}")), "devspace": (external["devspace"], patch_targets(ROOT / "bin/chatgpt_devspace_compat.py", "PATCHES")), } results = [] diff --git a/skills/chatgpt-oracle-runtime/SKILL.md b/skills/chatgpt-oracle-runtime/SKILL.md index e1321820..1544eea2 100644 --- a/skills/chatgpt-oracle-runtime/SKILL.md +++ b/skills/chatgpt-oracle-runtime/SKILL.md @@ -7,8 +7,12 @@ description: "Current Oracle runtime path for new ChatGPT work: regular modes us This is the only active browser path for all GPT work. Regular modes use DevSpace; Pro uses Oracle attachment transport without any app. New runs pin Oracle -`0.17.2`; Oracle `0.16.1`, `0.17.0`, and `0.17.1` are accepted only when -recovering an exact run already persisted with that version. +`0.17.3`; Oracle `0.16.1`, `0.17.0`, `0.17.1`, and `0.17.2` are accepted only +when recovering an exact run already persisted with that version. +Oracle 0.17.3's upstream answer-placeholder bounding, manual-login reattach +cookie-sync opt-in, Japanese Advanced/Effort labels, and explicit +`--browser-headless` handling are preserved under the local hash-gated +patches; live browser validation is not yet performed. `chatgpt_oracle_dispatch.py` supports exactly `direct`, `plan`, `review`, `edit`, `orchestrator`, `deep-research`, `manual`, and `pro`. `manual` is a supported diff --git a/skills/chatgpt-thinking-browser/SKILL.md b/skills/chatgpt-thinking-browser/SKILL.md index 4bcf2aa6..7bd37d93 100644 --- a/skills/chatgpt-thinking-browser/SKILL.md +++ b/skills/chatgpt-thinking-browser/SKILL.md @@ -35,8 +35,9 @@ sends plain `@DevSpace` plus the absolute mission path. It never attaches files, opens ChatGPT settings, inspects/selects/deletes an app, or falls back to another backend, Playwright, in-app Browser, or Chrome. -New runs pin Oracle `0.17.2`. Oracle `0.16.1`, `0.17.0`, and `0.17.1` are available -only for exact recovery of a run already persisted with that version. +New runs pin Oracle `0.17.3`. Oracle `0.16.1`, `0.17.0`, `0.17.1`, and +`0.17.2` are available only for exact recovery of a run already persisted with +that version. `orchestrator` is a single web submission that carries the orchestrator ownership contract: that one GPT session owns delegated exploration, code @@ -59,7 +60,7 @@ regular or failed run into Web Multi automatically. Never probe, register, repair, or select an alternate app or backend. Oracle explicitly selects `GPT-5.6 Sol` and `extra-high`, verifies the visible -`Extra High` tier before prompt send, and records both in Oracle evidence. The active 0.17.2 +`Extra High` tier before prompt send, and records both in Oracle evidence. The active 0.17.3 compatibility layer is hash-gated and fails closed on an unknown version or third-party file. Never invent xhigh or silently downgrade. diff --git a/tests/fixtures/oracle-0.17.3/assistantResponse.pristine.js b/tests/fixtures/oracle-0.17.3/assistantResponse.pristine.js new file mode 100644 index 00000000..c0b9f0aa --- /dev/null +++ b/tests/fixtures/oracle-0.17.3/assistantResponse.pristine.js @@ -0,0 +1,1395 @@ +import { ANSWER_SELECTORS, ASSISTANT_ROLE_SELECTOR, CONVERSATION_TURN_SELECTOR, COPY_BUTTON_SELECTOR, FINISHED_ACTIONS_SELECTOR, STOP_BUTTON_SELECTORS, } from "../constants.js"; +import { buildConversationTurnListExpression } from "../conversationTurns.js"; +import { buildThinkingActivePredicateJs, readThinkingActivity } from "./thinkingStatus.js"; +import { delay } from "../utils.js"; +import { logDomFailure, logConversationSnapshot, buildConversationDebugExpression, } from "../domDebug.js"; +import { buildClickDispatcher } from "./domEvents.js"; +const ASSISTANT_POLL_TIMEOUT_ERROR = "assistant-response-watchdog-timeout"; +const STOP_CONTROL_SELECTOR = STOP_BUTTON_SELECTORS.join(", "); +// Still used by the in-page settle heuristic's length buckets (see buildResponseObserverExpression). +const MIN_CONFIDENT_ANSWER_LENGTH = 16; +function readPositiveIntEnv(name, fallback) { + const raw = Number(process.env[name]); + return Number.isFinite(raw) && raw > 0 ? Math.floor(raw) : fallback; +} +const TERMINAL_GATE_CONFIG = { + barConfirmCycles: readPositiveIntEnv("ORACLE_BAR_CONFIRM_CYCLES", 3), + minStableMs: readPositiveIntEnv("ORACLE_TERMINAL_MIN_STABLE_MS", 1_200), +}; +export function createTerminalGateState(now) { + return { + lastKey: "", + lastChangeAt: now, + barStableCycles: 0, + seen: false, + }; +} +// Pure, unit-testable per-cycle classifier. Feed it one sample every poll; when it returns +// terminal:true the capture is proven complete and safe to finalize. +export function classifyTurnTerminal(state, sample, config) { + const changed = !state.seen || sample.contentKey !== state.lastKey; + const lastChangeAt = changed ? sample.now : state.lastChangeAt; + // proofA debounce: weak/stale sidecar evidence may be overridden, but strong live activity + // must reset the debounce. It also resets on ANY content change so a bar that appears while + // the answer is still rendering (the transient-bar / first-tokens race) cannot finalize. + const barStableCycles = sample.barVisible && !sample.stopVisible && !sample.strongThinkingActive && !changed + ? state.barStableCycles + 1 + : 0; + const next = { + lastKey: sample.contentKey, + lastChangeAt, + barStableCycles, + seen: true, + }; + let terminal = false; + if (!sample.stopVisible && sample.len > 0) { + const stableMs = sample.now - lastChangeAt; + // Debounced action bar AND content stable for a minimum time. The time-stability + // requirement guards the documented race where finished-action controls surface while only + // the first tokens have rendered. Weak sidecar evidence cannot hang a finished turn, but + // strong live activity vetoes this proof and restarts its debounce. + terminal = + sample.barVisible && + !sample.strongThinkingActive && + barStableCycles >= config.barConfirmCycles && + stableMs >= config.minStableMs; + } + return { state: next, terminal }; +} +const THINKING_STATUS_LABELS = [ + "thinking", + "pro thinking", + "thinking longer for a better answer", + "reasoning", + "finalizing answer", + "finalizing", + "analyzing", + "researching", + "working on it", + "working", + "planning", + "searching the web", + "searching", + "reading", +]; +function matchesThinkingStatusLabel(trimmed) { + if (!trimmed) + return false; + if (THINKING_STATUS_LABELS.includes(trimmed)) + return true; + // includes, not startsWith: the completed summary can carry a heading prefix + // ("Reasoning Thought for 12s"); the length cap keeps real answers out. + if (trimmed.includes("thought for ") && trimmed.length <= 40) + return true; + return trimmed.startsWith("pro thinking") && trimmed.length <= 40; +} +// Single source of truth for the "Answer now" placeholder test. +// +// This function is ALSO injected verbatim into page expressions via +// buildAnswerNowPlaceholderPredicateJs (it is stringified with +// Function.prototype.toString), so it MUST stay closure-free: no imports, no module +// constants, no helpers. Anything it references must be declared inside its own body +// or it will throw a ReferenceError inside the renderer. +// +// Learned the hard way: ChatGPT's Pro UI keeps the "Answer now" skip-ahead control +// mounted for the whole reasoning phase, so a bare `text.includes('answer now')` test +// matched real answers that merely ended with that chrome and discarded them wholesale +// at every extraction layer. A placeholder is short UI furniture, so this matches the +// WHOLE trimmed string against known chrome labels behind a length cap, the way the +// sibling predicates in this file already do. +// +// The cap is 60, taken from isActiveLabel in ./thinkingStatus.ts rather than the 40 used +// by matchesThinkingStatusLabel above: 40 bounds a single status label, while the longest +// genuine placeholder here is the composite +// "chatgpt said: file upload request pro thinking answer now" (57 chars), which a 40 cap +// would wrongly let through. +export function isAnswerNowPlaceholderText(value) { + let raw = ""; + if (typeof value === "string") { + raw = value; + } + else if (value && typeof value === "object" && "text" in value) { + const candidate = value.text; + if (typeof candidate === "string") + raw = candidate; + } + const text = raw.toLowerCase().replace(/\s+/g, " ").trim(); + if (!text) + return false; + if (text === "chatgpt said:" || text === "chatgpt said") + return true; + if (text.length > 60) + return false; + // Whole-string match: consume the text left to right, longest chrome label first. + // Any residue means real content is present, so this is an answer, not a placeholder. + const chromeLabels = [ + "chatgpt said:", + "chatgpt said", + "file upload request", + "pro thinking", + "answer now", + ]; + let rest = text; + let sawOwner = false; // "pro thinking" / "chatgpt said" + let sawGate = false; // "answer now" / "file upload request" + while (rest.length > 0) { + let matched = ""; + for (const label of chromeLabels) { + if (label.length > matched.length && rest.startsWith(label)) + matched = label; + } + if (!matched) + return false; + if (matched === "answer now" || matched === "file upload request") + sawGate = true; + else + sawOwner = true; + rest = rest.slice(matched.length).replace(/^[\s:.,;|\u00b7\u2022-]+/, ""); + } + return sawGate && sawOwner; +} +// Exported: the two page expressions below and the tests all consume this one builder, +// so the predicate cannot drift between the node side and the renderer. +export function buildAnswerNowPlaceholderPredicateJs(fnName) { + // Inject the node-side definition itself so a correction can never land in only one + // copy. `const x = function name(){}` is a valid expression, so the exported + // declaration stringifies straight into the page expression. + return `const ${fnName} = ${isAnswerNowPlaceholderText.toString()};`; +} +function buildActiveThinkingStatusPredicateJs(fnName) { + const labelsLiteral = JSON.stringify(THINKING_STATUS_LABELS); + return `${buildStopButtonVisibilityPredicateJs("isStopControlVisible")} + const ${fnName} = (snapshot) => { + const normalized = String(snapshot?.text ?? '').toLowerCase().replace(/\\s+/g, ' ').trim(); + if (!normalized) return false; + const labels = ${labelsLiteral}; + const matches = + labels.includes(normalized) || + (normalized.includes('thought for ') && normalized.length <= 40) || + (normalized.startsWith('pro thinking') && normalized.length <= 40); + return matches && isStopControlVisible(); + };`; +} +export function matchesThinkingStatusLabelForTest(text) { + return matchesThinkingStatusLabel(text.toLowerCase().replace(/\s+/g, " ").trim()); +} +export function buildActiveThinkingStatusPredicateJsForTest(fnName) { + return buildActiveThinkingStatusPredicateJs(fnName); +} +export async function waitForAssistantResponse(Runtime, timeoutMs, logger, minTurnIndex, expectedConversationId) { + const start = Date.now(); + logger("Waiting for ChatGPT response"); + // Learned: two paths are needed: + // 1) DOM observer (fast when mutations fire), + // 2) snapshot poller (fallback when observers miss or JS stalls). + const expression = buildResponseObserverExpression(timeoutMs, minTurnIndex, expectedConversationId); + const evaluationPromise = Runtime.evaluate({ + expression, + awaitPromise: true, + returnByValue: true, + }); + const raceReadyEvaluation = evaluationPromise.then((value) => ({ kind: "evaluation", value }), (error) => { + throw { source: "evaluation", error }; + }); + // Use AbortController to stop the poller when the evaluation wins the race, + // preventing abandoned polling loops from consuming resources. + const pollerAbort = new AbortController(); + const pollerPromise = pollAssistantCompletion(Runtime, timeoutMs, minTurnIndex, expectedConversationId, pollerAbort.signal).then((value) => ({ kind: "poll", value }), (error) => { + throw { source: "poll", error }; + }); + let evaluation = null; + try { + const winner = await Promise.race([raceReadyEvaluation, pollerPromise]); + if (winner.kind === "poll") { + if (!winner.value) { + throw { source: "poll", error: new Error(ASSISTANT_POLL_TIMEOUT_ERROR) }; + } + logger("Captured assistant response via snapshot watchdog"); + evaluationPromise.catch(() => undefined); + await terminateRuntimeExecution(Runtime); + return winner.value; + } + // Evaluation won - abort the poller to prevent it from running until timeout + pollerAbort.abort(); + evaluation = winner.value; + } + catch (wrappedError) { + if (wrappedError && + typeof wrappedError === "object" && + "source" in wrappedError && + "error" in wrappedError) { + const { source, error } = wrappedError; + if (source === "poll" && + error instanceof Error && + error.message === ASSISTANT_POLL_TIMEOUT_ERROR) { + evaluationPromise.catch(() => undefined); + await terminateRuntimeExecution(Runtime); + throw error; + } + else if (source === "poll") { + throw error; + } + else if (source === "evaluation") { + const recovered = await recoverAssistantResponse(Runtime, timeoutMs, logger, minTurnIndex, expectedConversationId); + if (recovered) { + return recovered; + } + await logDomFailure(Runtime, logger, "assistant-response"); + throw error ?? new Error("Failed to capture assistant response"); + } + } + else { + throw wrappedError; + } + } + if (!evaluation) { + await logDomFailure(Runtime, logger, "assistant-response"); + throw new Error("Failed to capture assistant response"); + } + const parsed = await parseAssistantEvaluationResult(Runtime, evaluation, logger); + if (!parsed) { + let remainingMs = Math.max(0, timeoutMs - (Date.now() - start)); + if (remainingMs > 0) { + const recovered = await recoverAssistantResponse(Runtime, remainingMs, logger, minTurnIndex, expectedConversationId); + if (recovered) { + return recovered; + } + remainingMs = Math.max(0, timeoutMs - (Date.now() - start)); + if (remainingMs > 0) { + const polled = await Promise.race([ + pollerPromise.catch(() => null), + delay(remainingMs).then(() => null), + ]); + if (polled && polled.kind === "poll" && polled.value) { + return polled.value; + } + } + } + await logDomFailure(Runtime, logger, "assistant-response"); + throw new Error("Unable to capture assistant response"); + } + const refreshed = await refreshAssistantSnapshot(Runtime, parsed, logger, minTurnIndex, expectedConversationId); + const candidate = refreshed ?? parsed; + if (isGeneratedImageAssistantAnswer(candidate)) { + logger("Captured assistant generated image response"); + return candidate; + } + // The observer/refresh path can race ahead of true completion: a settled GPT-5.5 Pro + // preamble (or any mid-stream capture) looks done for a moment before the reasoning/tool + // phase begins. Re-confirm EVERY captured text through the terminal-only poller, which + // finalizes only on positive proof (a debounced action bar, or a quiet window with no + // active thinking). We deliberately drop the old ">= candidate length" acceptance: the + // poller is turn-scoped (minTurnIndex), so whatever it proves terminal is the right turn, + // even when the real answer is shorter than a verbose preamble. + const elapsedMs = Date.now() - start; + const remainingMs = Math.max(0, timeoutMs - elapsedMs); + if (remainingMs > 0) { + logger("Confirming the capture is terminal (not a mid-stream/preamble capture)"); + const completed = await pollAssistantCompletion(Runtime, remainingMs, minTurnIndex, expectedConversationId); + if (completed) { + return completed; + } + // Could not prove completion within the budget: refuse rather than finalize a possibly + // incomplete capture. A clean, fast failure is recoverable (retry/salvage) and never + // ships a preamble as if it were the answer. + await logDomFailure(Runtime, logger, "assistant-response-unconfirmed"); + throw new Error("assistant-response could not be confirmed complete before timeout; refusing to finalize a possibly-incomplete capture"); + } + // Budget already exhausted before we could confirm: refuse rather than fall through and ship + // an unconfirmed capture. A settled preamble that arrived near the deadline must not be + // finalized just because there was no time left to prove it terminal. + await logDomFailure(Runtime, logger, "assistant-response-unconfirmed"); + throw new Error("assistant-response could not be confirmed complete before the deadline; refusing to finalize a possibly-incomplete capture"); +} +export async function readAssistantSnapshot(Runtime, minTurnIndex, expectedConversationId) { + const { result } = await Runtime.evaluate({ + expression: buildAssistantSnapshotExpression(minTurnIndex, expectedConversationId), + returnByValue: true, + }); + const value = result?.value; + if (value && typeof value === "object") { + const snapshot = value; + if (typeof minTurnIndex === "number" && Number.isFinite(minTurnIndex)) { + const turnIndex = typeof snapshot.turnIndex === "number" ? snapshot.turnIndex : null; + if (turnIndex === null) { + return snapshot; + } + if (turnIndex < minTurnIndex) { + return null; + } + } + return snapshot; + } + return null; +} +export async function captureAssistantMarkdown(Runtime, meta, logger) { + const { result } = await Runtime.evaluate({ + expression: buildCopyExpression(meta), + returnByValue: true, + awaitPromise: true, + }); + if (result?.value?.success && typeof result.value.markdown === "string") { + return result.value.markdown; + } + const status = result?.value?.status; + if (status && status !== "missing-button") { + logger(`Copy button fallback status: ${status}`); + await logDomFailure(Runtime, logger, "copy-markdown"); + } + if (!status) { + await logDomFailure(Runtime, logger, "copy-markdown"); + } + return null; +} +export function buildAssistantExtractorForTest(name) { + return buildAssistantExtractor(name); +} +export function buildAssistantSnapshotExpressionForTest(minTurnIndex, expectedConversationId) { + return buildAssistantSnapshotExpression(minTurnIndex, expectedConversationId); +} +export function buildResponseObserverExpressionForTest(timeoutMs, minTurnIndex, expectedConversationId) { + return buildResponseObserverExpression(timeoutMs, minTurnIndex, expectedConversationId); +} +export function buildConversationDebugExpressionForTest() { + return buildConversationDebugExpression(); +} +export function buildMarkdownFallbackExtractorForTest(minTurnLiteral = "0") { + return buildMarkdownFallbackExtractor(minTurnLiteral); +} +export function buildCopyExpressionForTest(meta = {}) { + return buildCopyExpression(meta); +} +async function recoverAssistantResponse(Runtime, timeoutMs, logger, minTurnIndex, expectedConversationId) { + const recoveryTimeoutMs = Math.max(0, timeoutMs); + if (recoveryTimeoutMs === 0) { + return null; + } + const recoveryStartedAt = Date.now(); + const recovered = await waitForCondition(async () => { + const snapshot = await readAssistantSnapshot(Runtime, minTurnIndex, expectedConversationId); + return normalizeAssistantSnapshot(snapshot); + }, recoveryTimeoutMs, 400); + if (recovered) { + // Route EVERY recovered snapshot through the terminal-only poller (not just short ones): + // a recovered long preamble is exactly the raw-return bug this gate exists to prevent. + logger("Recovered a candidate response; confirming it is terminal before finalizing"); + const remainingMs = Math.max(0, recoveryTimeoutMs - (Date.now() - recoveryStartedAt)); + if (remainingMs > 0) { + const confirmed = await pollAssistantCompletion(Runtime, remainingMs, minTurnIndex, expectedConversationId); + if (confirmed) { + logger("Recovered and confirmed assistant response via polling fallback"); + return confirmed; + } + // Unconfirmable within budget: refuse (return null) so the caller fails fast instead + // of finalizing a possibly-incomplete recovered capture. + await logConversationSnapshot(Runtime, logger).catch(() => undefined); + return null; + } + // No confirmation time left: refuse rather than return the unconfirmed recovered snapshot + // (returning it raw would reopen the recovered-long-preamble leak this gate closes). + await logConversationSnapshot(Runtime, logger).catch(() => undefined); + return null; + } + await logConversationSnapshot(Runtime, logger).catch(() => undefined); + return null; +} +async function parseAssistantEvaluationResult(_Runtime, evaluation, _logger) { + const { result } = evaluation; + if (result.type === "object" && + result.value && + typeof result.value === "object" && + "text" in result.value) { + const html = typeof result.value.html === "string" + ? (result.value.html ?? undefined) + : undefined; + const turnId = typeof result.value.turnId === "string" + ? (result.value.turnId ?? undefined) + : undefined; + const messageId = typeof result.value.messageId === "string" + ? (result.value.messageId ?? undefined) + : undefined; + const text = cleanAssistantText(String(result.value.text ?? "")); + const normalized = text.toLowerCase(); + if (isAnswerNowPlaceholderText(normalized)) { + return null; + } + return { text, html, meta: { turnId, messageId } }; + } + const fallbackText = typeof result.value === "string" ? cleanAssistantText(result.value) : ""; + if (!fallbackText) { + return null; + } + if (isAnswerNowPlaceholderText(fallbackText.toLowerCase())) { + return null; + } + return { text: fallbackText, html: undefined, meta: {} }; +} +async function refreshAssistantSnapshot(Runtime, current, logger, minTurnIndex, expectedConversationId) { + const deadline = Date.now() + 5_000; + let best = null; + let stableCycles = 0; + const stableTarget = 3; + while (Date.now() < deadline) { + // Learned: short/fast answers can race; poll a few extra cycles to pick up messageId + full text. + const latestSnapshot = await readAssistantSnapshot(Runtime, minTurnIndex, expectedConversationId).catch(() => null); + const latest = normalizeAssistantSnapshot(latestSnapshot); + if (latest) { + if (!best || + latest.text.length > best.text.length || + (!best.meta.messageId && latest.meta.messageId)) { + best = latest; + stableCycles = 0; + } + else if (latest.text.trim() === best.text.trim()) { + stableCycles += 1; + } + } + if (best && stableCycles >= stableTarget) { + break; + } + await delay(300); + } + if (!best) { + return null; + } + const currentLength = cleanAssistantText(current.text).trim().length; + const latestLength = best.text.length; + const hasBetterId = !current.meta?.messageId && Boolean(best.meta.messageId); + const isLonger = latestLength > currentLength; + const hasDifferentText = best.text.trim() !== current.text.trim(); + if (isLonger || hasBetterId || hasDifferentText) { + logger("Refreshed assistant response via latest snapshot"); + return best; + } + return null; +} +async function terminateRuntimeExecution(Runtime) { + if (typeof Runtime.terminateExecution !== "function") { + return; + } + try { + await Runtime.terminateExecution(); + } + catch { + // ignore termination failures + } +} +async function pollAssistantCompletion(Runtime, timeoutMs, minTurnIndex, expectedConversationId, abortSignal) { + const watchdogDeadline = Date.now() + timeoutMs; + let gate = createTerminalGateState(Date.now()); + while (Date.now() < watchdogDeadline) { + // Check abort signal to stop polling when another path won the race + if (abortSignal?.aborted) { + return null; + } + const snapshot = await readAssistantSnapshot(Runtime, minTurnIndex, expectedConversationId); + const normalized = normalizeAssistantSnapshot(snapshot); + if (normalized) { + // Generated-image answers stream no text and mount no action bar; accept immediately. + if (isGeneratedImageAssistantAnswer(normalized)) { + return normalized; + } + const [stopVisible, barVisible, thinkingActivity] = await Promise.all([ + isStopButtonVisible(Runtime), + isCompletionVisible(Runtime, normalized.meta, minTurnIndex), + readThinkingActivity(Runtime), + ]); + const decision = classifyTurnTerminal(gate, { + now: Date.now(), + len: normalized.text.length, + // Fingerprint = turn/message identity + the full text, so a same-length rewrite, a + // shorter final answer replacing a longer preamble, or a new turn all count as change. + contentKey: `${normalized.meta.messageId ?? normalized.meta.turnId ?? ""}::${normalized.text}`, + stopVisible, + barVisible, + strongThinkingActive: thinkingActivity.strong, + }, TERMINAL_GATE_CONFIG); + gate = decision.state; + if (decision.terminal) { + return normalized; + } + } + else { + // The turn disappeared/reset (navigation, re-render): restart the gate so a stale + // action-bar debounce cannot carry over onto a fresh turn. + gate = createTerminalGateState(Date.now()); + } + await delay(400); + } + return null; +} +async function isStopButtonVisible(Runtime) { + try { + const { result } = await Runtime.evaluate({ + expression: buildStopButtonVisibilityExpression(), + returnByValue: true, + }); + return Boolean(result?.value); + } + catch { + return false; + } +} +function buildStopButtonVisibilityExpression() { + return `(() => { + ${buildStopButtonVisibilityPredicateJs("isStopControlVisible")} + return isStopControlVisible(); + })()`; +} +function buildStopButtonVisibilityPredicateJs(fnName) { + const selectorLiteral = JSON.stringify(STOP_CONTROL_SELECTOR); + return `const ${fnName} = () => { + const isVisible = (node) => { + if (!(node instanceof HTMLElement)) return false; + const rect = node.getBoundingClientRect(); + if (!rect || rect.width <= 0 || rect.height <= 0) return false; + const style = window.getComputedStyle(node); + return !( + style.display === 'none' || + style.visibility === 'hidden' || + (style.opacity !== '' && Number(style.opacity) === 0) + ); + }; + return Array.from(document.querySelectorAll(${selectorLiteral})).some((node) => isVisible(node)); + };`; +} +export const buildStopButtonVisibilityExpressionForTest = buildStopButtonVisibilityExpression; +function buildCompletionVisibilityExpression(meta, minTurnIndex) { + const expectedMessageId = meta.messageId ? JSON.stringify(meta.messageId) : "null"; + const expectedTurnId = meta.turnId ? JSON.stringify(meta.turnId) : "null"; + const minTurnLiteral = typeof minTurnIndex === "number" && Number.isFinite(minTurnIndex) && minTurnIndex >= 0 + ? Math.floor(minTurnIndex) + : -1; + return `(() => { + const EXPECTED_MESSAGE_ID = ${expectedMessageId}; + const EXPECTED_TURN_ID = ${expectedTurnId}; + const MIN_TURN_INDEX = ${minTurnLiteral}; + // Find the LAST assistant turn to check completion status. Must match the same logic as + // buildAssistantExtractor, then correlate the controls to the sampled response. + const ASSISTANT_SELECTOR = '${ASSISTANT_ROLE_SELECTOR}'; + const isAssistantTurn = (node) => { + if (!(node instanceof HTMLElement)) return false; + const turnAttr = (node.getAttribute('data-turn') || node.dataset?.turn || '').toLowerCase(); + if (turnAttr === 'assistant') return true; + const role = (node.getAttribute('data-message-author-role') || node.dataset?.messageAuthorRole || '').toLowerCase(); + if (role === 'assistant') return true; + const testId = (node.getAttribute('data-testid') || '').toLowerCase(); + if (testId.includes('assistant')) return true; + return Boolean(node.querySelector(ASSISTANT_SELECTOR) || node.querySelector('[data-testid*="assistant"]')); + }; + + const turns = ${buildConversationTurnListExpression()}; + let lastAssistantTurn = null; + let lastAssistantIndex = -1; + for (let i = turns.length - 1; i >= 0; i--) { + if (isAssistantTurn(turns[i])) { + lastAssistantTurn = turns[i]; + lastAssistantIndex = i; + break; + } + } + if (!lastAssistantTurn) return false; + + const hasExpectedIdentity = Boolean(EXPECTED_MESSAGE_ID || EXPECTED_TURN_ID); + if (hasExpectedIdentity) { + const identityNodes = [ + lastAssistantTurn, + ...Array.from(lastAssistantTurn.querySelectorAll('[data-message-id], [data-testid]')), + ]; + const identityMatches = identityNodes.some((node) => + (EXPECTED_MESSAGE_ID && node.getAttribute?.('data-message-id') === EXPECTED_MESSAGE_ID) || + (EXPECTED_TURN_ID && node.getAttribute?.('data-testid') === EXPECTED_TURN_ID), + ); + if (!identityMatches) return false; + } else if (MIN_TURN_INDEX < 0 || lastAssistantIndex < MIN_TURN_INDEX) { + // Fallback/project snapshots without an identity may use the new-turn baseline, but an + // uncorrelated persistent action bar from an older turn must never prove completion. + return false; + } + + if (lastAssistantTurn.querySelector('${FINISHED_ACTIONS_SELECTOR}')) return true; + const markdowns = lastAssistantTurn.querySelectorAll('.markdown'); + return Array.from(markdowns).some((node) => (node.textContent || '').trim() === 'Done'); + })()`; +} +async function isCompletionVisible(Runtime, meta, minTurnIndex) { + if (hasScopedCompletionProof(meta)) + return true; + try { + const { result } = await Runtime.evaluate({ + expression: buildCompletionVisibilityExpression(meta, minTurnIndex), + returnByValue: true, + }); + return Boolean(result?.value); + } + catch { + return false; + } +} +export function hasScopedCompletionProof(meta) { + return meta.completionVisible === true; +} +export const buildCompletionVisibilityExpressionForTest = buildCompletionVisibilityExpression; +function normalizeAssistantSnapshot(snapshot) { + const text = snapshot?.text ? cleanAssistantText(snapshot.text) : ""; + if (!text.trim()) { + return null; + } + const normalized = text.toLowerCase(); + // "Pro thinking" often renders a placeholder turn containing an "Answer now" gate. + // Treat it as incomplete so browser mode keeps waiting for the real assistant text. + if (isAnswerNowPlaceholderText(normalized)) { + return null; + } + // Ignore user echo turns that can show up in project view fallbacks. + if (normalized.startsWith("you said")) { + return null; + } + return { + text, + html: snapshot?.html ?? undefined, + meta: { + turnId: snapshot?.turnId ?? undefined, + messageId: snapshot?.messageId ?? undefined, + ...(snapshot?.completionVisible === true ? { completionVisible: true } : {}), + }, + }; +} +function isGeneratedImageAssistantAnswer(answer) { + return Boolean(answer?.html?.includes("/backend-api/estuary/content?id=file_")); +} +async function waitForCondition(getter, timeoutMs, pollIntervalMs = 400) { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + const value = await getter(); + if (value) { + return value; + } + await delay(pollIntervalMs); + } + return null; +} +function buildAssistantSnapshotExpression(minTurnIndex, expectedConversationId) { + const minTurnLiteral = typeof minTurnIndex === "number" && Number.isFinite(minTurnIndex) && minTurnIndex >= 0 + ? Math.floor(minTurnIndex) + : -1; + const expectedConversationLiteral = typeof expectedConversationId === "string" && expectedConversationId.trim().length > 0 + ? JSON.stringify(expectedConversationId.trim()) + : "null"; + return `(() => { + const MIN_TURN_INDEX = ${minTurnLiteral}; + const EXPECTED_CONVERSATION_ID = ${expectedConversationLiteral}; + const currentHref = typeof location === 'object' && location.href ? location.href : ''; + const currentConversationId = currentHref.match(/\\/c\\/([a-zA-Z0-9-]+)/)?.[1] ?? null; + if ( + EXPECTED_CONVERSATION_ID && + currentConversationId && + currentConversationId !== EXPECTED_CONVERSATION_ID + ) { + return null; + } + // Learned: the default turn DOM misses project view; keep a fallback extractor. + ${buildAssistantExtractor("extractAssistantTurn")} + const extracted = extractAssistantTurn(); + ${buildAnswerNowPlaceholderPredicateJs("isPlaceholder")} + ${buildActiveThinkingStatusPredicateJs("isActiveThinkingStatus")} + if ( + extracted && + extracted.text && + !isPlaceholder(extracted) && + !isActiveThinkingStatus(extracted) + ) { + return extracted; + } + // Fallback for ChatGPT project view: answers can live outside conversation turns. + const extractFallback = ${buildMarkdownFallbackExtractor("MIN_TURN_INDEX")}; + const fallback = extractFallback(); + if (fallback && !isPlaceholder(fallback) && !isActiveThinkingStatus(fallback)) { + return fallback; + } + return null; + })()`; +} +function buildResponseObserverExpression(timeoutMs, minTurnIndex, expectedConversationId) { + const selectorsLiteral = JSON.stringify(ANSWER_SELECTORS); + const assistantLiteral = JSON.stringify(ASSISTANT_ROLE_SELECTOR); + const minTurnLiteral = typeof minTurnIndex === "number" && Number.isFinite(minTurnIndex) && minTurnIndex >= 0 + ? Math.floor(minTurnIndex) + : -1; + const expectedConversationLiteral = typeof expectedConversationId === "string" && expectedConversationId.trim().length > 0 + ? JSON.stringify(expectedConversationId.trim()) + : "null"; + return `(() => { + ${buildClickDispatcher()} + const SELECTORS = ${selectorsLiteral}; + const STOP_SELECTOR = ${JSON.stringify(STOP_CONTROL_SELECTOR)}; + const FINISHED_SELECTOR = '${FINISHED_ACTIONS_SELECTOR}'; + const ASSISTANT_SELECTOR = ${assistantLiteral}; + const EXPECTED_CONVERSATION_ID = ${expectedConversationLiteral}; + // Learned: settling avoids capturing mid-stream HTML; keep short. + const settleDelayMs = 800; + const currentConversationId = () => { + const href = typeof location === 'object' && location.href ? location.href : ''; + return href.match(/\\/c\\/([a-zA-Z0-9-]+)/)?.[1] ?? null; + }; + const matchesExpectedConversation = () => { + if (!EXPECTED_CONVERSATION_ID) return true; + const currentId = currentConversationId(); + return !currentId || currentId === EXPECTED_CONVERSATION_ID; + }; + ${buildAnswerNowPlaceholderPredicateJs("isAnswerNowPlaceholder")} + ${buildActiveThinkingStatusPredicateJs("isActiveThinkingStatus")} + ${buildThinkingActivePredicateJs("isThinkingActiveNow")} + + // Helper to detect assistant turns - must match buildAssistantExtractor logic for consistency. + const isAssistantTurn = (node) => { + if (!(node instanceof HTMLElement)) return false; + const turnAttr = (node.getAttribute('data-turn') || node.dataset?.turn || '').toLowerCase(); + if (turnAttr === 'assistant') return true; + const role = (node.getAttribute('data-message-author-role') || node.dataset?.messageAuthorRole || '').toLowerCase(); + if (role === 'assistant') return true; + const testId = (node.getAttribute('data-testid') || '').toLowerCase(); + if (testId.includes('assistant')) return true; + return Boolean(node.querySelector(ASSISTANT_SELECTOR) || node.querySelector('[data-testid*="assistant"]')); + }; + + const MIN_TURN_INDEX = ${minTurnLiteral}; + ${buildAssistantExtractor("extractFromTurns")} + // Learned: some layouts (project view) render markdown without assistant turn wrappers. + const extractFromMarkdownFallback = ${buildMarkdownFallbackExtractor("MIN_TURN_INDEX")}; + + const acceptSnapshot = (snapshot) => { + if (!snapshot) return null; + if (!matchesExpectedConversation()) return null; + const index = typeof snapshot.turnIndex === 'number' ? snapshot.turnIndex : -1; + if (MIN_TURN_INDEX >= 0) { + if (index < 0 || index < MIN_TURN_INDEX) { + return null; + } + } + return snapshot; + }; + + const captureViaObserver = () => + new Promise((resolve, reject) => { + const deadline = Date.now() + ${timeoutMs}; + let timeoutId = null; + let cleanedUp = false; + let observer = null; + + // Centralized cleanup to prevent resource leaks + const cleanup = () => { + if (cleanedUp) return; + cleanedUp = true; + if (timeoutId) { + clearTimeout(timeoutId); + timeoutId = null; + } + if (observer) { + try { + observer.disconnect(); + } catch { + // ignore disconnect errors + } + observer = null; + } + }; + + const observerCallback = () => { + if (cleanedUp) return; + try { + const extractedRaw = extractFromTurns(); + const extractedCandidate = + extractedRaw && + !isAnswerNowPlaceholder(extractedRaw) && + !isActiveThinkingStatus(extractedRaw) + ? extractedRaw + : null; + let extracted = acceptSnapshot(extractedCandidate); + if (!extracted) { + const fallbackRaw = extractFromMarkdownFallback(); + const fallbackCandidate = + fallbackRaw && + !isAnswerNowPlaceholder(fallbackRaw) && + !isActiveThinkingStatus(fallbackRaw) + ? fallbackRaw + : null; + extracted = acceptSnapshot(fallbackCandidate); + } + if (extracted) { + cleanup(); + resolve(extracted); + } else if (Date.now() > deadline) { + cleanup(); + reject(new Error('Response timeout')); + } + } catch (error) { + cleanup(); + reject(error); + } + }; + + observer = new MutationObserver(observerCallback); + observer.observe(document.body, { childList: true, subtree: true, characterData: true }); + + timeoutId = setTimeout(() => { + cleanup(); + reject(new Error('Response timeout')); + }, ${timeoutMs}); + }); + + // Check if the last assistant turn has finished (scoped to avoid detecting old turns). + const isLastAssistantTurnFinished = () => { + const turns = ${buildConversationTurnListExpression()}; + let lastAssistantTurn = null; + for (let i = turns.length - 1; i >= 0; i--) { + if (isAssistantTurn(turns[i])) { + lastAssistantTurn = turns[i]; + break; + } + } + if (!lastAssistantTurn) return false; + // Check for action buttons in this specific turn + if (lastAssistantTurn.querySelector(FINISHED_SELECTOR)) return true; + // Check for "Done" text in this turn's markdown + const markdowns = lastAssistantTurn.querySelectorAll('.markdown'); + return Array.from(markdowns).some((n) => (n.textContent || '').trim() === 'Done'); + }; + + const waitForSettle = async (snapshot) => { + if (String(snapshot?.html ?? '').includes('/backend-api/estuary/content?id=file_')) { + return snapshot; + } + // Learned: short answers can be 1-2 tokens; enforce longer settle windows to avoid truncation. + // Learned: long streaming responses (esp. thinking models) can pause mid-stream; + // use progressively longer windows to avoid truncation (#71). + const initialLength = snapshot?.text?.length ?? 0; + const shortAnswer = initialLength > 0 && initialLength < ${MIN_CONFIDENT_ANSWER_LENGTH}; + const mediumAnswer = initialLength >= ${MIN_CONFIDENT_ANSWER_LENGTH} && initialLength < 40; + const longAnswer = initialLength >= 40 && initialLength < 500; + const settleWindowMs = shortAnswer ? 12_000 : mediumAnswer ? 5_000 : longAnswer ? 8_000 : 10_000; + const settleIntervalMs = 400; + const deadline = Date.now() + settleWindowMs; + let latest = snapshot; + let lastLength = snapshot?.text?.length ?? 0; + let stableCycles = 0; + const stableTarget = shortAnswer ? 6 : mediumAnswer ? 3 : longAnswer ? 5 : 6; + while (Date.now() < deadline) { + await new Promise((resolve) => setTimeout(resolve, settleIntervalMs)); + const refreshedRaw = extractFromTurns(); + const refreshedCandidate = + refreshedRaw && + !isAnswerNowPlaceholder(refreshedRaw) && + !isActiveThinkingStatus(refreshedRaw) + ? refreshedRaw + : null; + let refreshed = acceptSnapshot(refreshedCandidate); + if (!refreshed) { + const fallbackRaw = extractFromMarkdownFallback(); + const fallbackCandidate = + fallbackRaw && + !isAnswerNowPlaceholder(fallbackRaw) && + !isActiveThinkingStatus(fallbackRaw) + ? fallbackRaw + : null; + refreshed = acceptSnapshot(fallbackCandidate); + } + const nextLength = refreshed?.text?.length ?? lastLength; + if (refreshed && nextLength >= lastLength) { + latest = refreshed; + } + if (nextLength > lastLength) { + lastLength = nextLength; + stableCycles = 0; + } else { + stableCycles += 1; + } + const stopVisible = Boolean(document.querySelector(STOP_SELECTOR)); + const finishedVisible = isLastAssistantTurnFinished(); + // Defense in depth (the node side re-confirms every capture): never settle on a + // stable-but-quiet candidate while the model is actively thinking/generating, so the + // observer does not hand a settled preamble to the node path during the reasoning gap. + const thinkingActiveNow = isThinkingActiveNow(); + + if ( + finishedVisible || + (!stopVisible && !thinkingActiveNow && stableCycles >= stableTarget) + ) { + break; + } + } + return latest ?? snapshot; + }; + + const extractedRaw = extractFromTurns(); + const extractedCandidate = + extractedRaw && + !isAnswerNowPlaceholder(extractedRaw) && + !isActiveThinkingStatus(extractedRaw) + ? extractedRaw + : null; + let extracted = acceptSnapshot(extractedCandidate); + if (!extracted) { + const fallbackRaw = extractFromMarkdownFallback(); + const fallbackCandidate = + fallbackRaw && + !isAnswerNowPlaceholder(fallbackRaw) && + !isActiveThinkingStatus(fallbackRaw) + ? fallbackRaw + : null; + extracted = acceptSnapshot(fallbackCandidate); + } + if (extracted) { + return waitForSettle(extracted); + } + return captureViaObserver().then((payload) => waitForSettle(payload)); + })()`; +} +function buildAssistantExtractor(functionName) { + const assistantLiteral = JSON.stringify(ASSISTANT_ROLE_SELECTOR); + return `const ${functionName} = () => { + ${buildClickDispatcher()} + const ASSISTANT_SELECTOR = ${assistantLiteral}; + const isAssistantTurn = (node) => { + if (!(node instanceof HTMLElement)) return false; + const turnAttr = (node.getAttribute('data-turn') || node.dataset?.turn || '').toLowerCase(); + if (turnAttr === 'assistant') { + return true; + } + const role = (node.getAttribute('data-message-author-role') || node.dataset?.messageAuthorRole || '').toLowerCase(); + if (role === 'assistant') { + return true; + } + const testId = (node.getAttribute('data-testid') || '').toLowerCase(); + if (testId.includes('assistant')) { + return true; + } + return Boolean(node.querySelector(ASSISTANT_SELECTOR) || node.querySelector('[data-testid*="assistant"]')); + }; + + const expandCollapsibles = (root) => { + const buttons = Array.from(root.querySelectorAll('button')); + for (const button of buttons) { + const label = (button.textContent || '').toLowerCase(); + const testid = (button.getAttribute('data-testid') || '').toLowerCase(); + if ( + label.includes('more') || + label.includes('expand') || + label.includes('show') || + testid.includes('markdown') || + testid.includes('toggle') + ) { + dispatchClickSequence(button); + } + } + }; + + const turns = ${buildConversationTurnListExpression()}; + for (let index = turns.length - 1; index >= 0; index -= 1) { + const turn = turns[index]; + if (!isAssistantTurn(turn)) { + continue; + } + const messageRoot = turn.querySelector(ASSISTANT_SELECTOR) ?? turn; + expandCollapsibles(messageRoot); + const preferred = + (messageRoot.matches?.('.markdown') || messageRoot.matches?.('[data-message-content]') ? messageRoot : null) || + messageRoot.querySelector('.markdown') || + messageRoot.querySelector('[data-message-content]') || + messageRoot.querySelector('[data-testid*="message"]') || + messageRoot.querySelector('[data-testid*="assistant"]') || + messageRoot.querySelector('.prose') || + messageRoot.querySelector('[class*="markdown"]'); + const contentRoot = preferred ?? messageRoot; + if (!contentRoot) { + continue; + } + const innerText = contentRoot?.innerText ?? ''; + const textContent = contentRoot?.textContent ?? ''; + const text = innerText.trim().length > 0 ? innerText : textContent; + const html = contentRoot?.innerHTML ?? ''; + const messageId = messageRoot.getAttribute('data-message-id'); + const turnId = messageRoot.getAttribute('data-testid'); + const generatedImages = Array.from(messageRoot.querySelectorAll('img')).filter((img) => + String(img?.src || '').includes('/backend-api/estuary/content?id=file_') + ); + const normalizedText = String(text || '').toLowerCase().replace(/\\s+/g, ' ').trim(); + const imageOnlyChrome = + !normalizedText || + normalizedText === 'edit' || + normalizedText === 'stopped thinking' || + normalizedText === 'stopped thinking edit' || + /^(?:reasoning\\s+|pro thinking\\s+)?thought for \\d+(?:\\.\\d+)?\\s*(?:s|sec|secs|second|seconds|m|min|mins|minute|minutes|h|hr|hrs|hour|hours)\\s+edit$/.test(normalizedText); + if (generatedImages.length > 0 && imageOnlyChrome) { + const label = generatedImages.length === 1 ? 'Generated image.' : \`Generated \${generatedImages.length} images.\`; + return { text: label, html: messageRoot?.innerHTML ?? html, messageId, turnId, turnIndex: index }; + } + if (text.trim()) { + return { text, html, messageId, turnId, turnIndex: index }; + } + } + return null; + };`; +} +function buildMarkdownFallbackExtractor(minTurnLiteral) { + const turnIndexValue = minTurnLiteral + ? `(${minTurnLiteral} >= 0 ? ${minTurnLiteral} : null)` + : "null"; + return `(() => { + const __minTurn = ${turnIndexValue}; + const roots = [ + document.querySelector('section[data-testid="screen-threadFlyOut"]'), + document.querySelector('[data-testid="chat-thread"]'), + document.querySelector('main'), + document.querySelector('[role="main"]'), + ].filter(Boolean); + if (roots.length === 0) return null; + const markdownSelector = '.markdown,[data-message-content],[data-testid*="message"],.prose,[class*="markdown"]'; + const isExcluded = (node) => + Boolean( + node?.closest?.( + 'nav, aside, [data-testid*="sidebar"], [data-testid*="chat-history"], [data-testid*="composer"], form', + ), + ); + const scoreRoot = (node) => { + const actions = node.querySelectorAll('${FINISHED_ACTIONS_SELECTOR}').length; + const assistants = node.querySelectorAll('[data-message-author-role="assistant"], [data-turn="assistant"]').length; + const markdowns = node.querySelectorAll(markdownSelector).length; + return actions * 10 + assistants * 5 + markdowns; + }; + let root = roots[0]; + let bestScore = scoreRoot(root); + for (let i = 1; i < roots.length; i += 1) { + const candidate = roots[i]; + const score = scoreRoot(candidate); + if (score > bestScore) { + bestScore = score; + root = candidate; + } + } + if (!root) return null; + const turnNodes = ${buildConversationTurnListExpression()}; + const hasTurns = turnNodes.length > 0; + const resolveTurnIndex = (node) => { + const idx = turnNodes.findIndex((turn) => turn === node || turn.contains?.(node)); + return idx >= 0 ? idx : null; + }; + const normalize = (value) => String(value || '').toLowerCase().replace(/\\s+/g, ' ').trim(); + const collectLastUser = (scope) => { + if (!scope?.querySelectorAll) return null; + const userTurns = Array.from(scope.querySelectorAll('[data-message-author-role="user"], [data-turn="user"]')); + return userTurns[userTurns.length - 1] ?? null; + }; + const lastUser = collectLastUser(root) || collectLastUser(document); + const userText = lastUser ? normalize(lastUser.innerText || lastUser.textContent || '') : ''; + const isAfterCurrentUser = (node) => { + if (!lastUser || typeof lastUser.compareDocumentPosition !== 'function') return false; + // Node.DOCUMENT_POSITION_FOLLOWING = 4. Use the numeric bit so the injected expression + // also works in stripped browser test contexts without a global Node constructor. + return Boolean(lastUser.compareDocumentPosition(node) & 4); + }; + const isAfterMinTurn = (node) => { + if (__minTurn === null) return true; + if (!hasTurns) return isAfterCurrentUser(node); + const idx = resolveTurnIndex(node); + return idx !== null && idx >= __minTurn; + }; + const isUserEcho = (text) => { + if (!userText) return false; + const normalized = normalize(text); + if (!normalized) return false; + return normalized === userText || normalized.startsWith(userText); + }; + const markdowns = Array.from(root.querySelectorAll(markdownSelector)) + .filter((node) => !isExcluded(node)) + .filter((node) => { + const container = node.closest('[data-message-author-role], [data-turn]'); + if (!container) return true; + const role = + (container.getAttribute('data-message-author-role') || container.getAttribute('data-turn') || '').toLowerCase(); + return role !== 'user'; + }); + if (markdowns.length === 0) return null; + const actionButtons = Array.from(root.querySelectorAll('${FINISHED_ACTIONS_SELECTOR}')); + const actionMarkdowns = []; + for (const button of actionButtons) { + const container = + button.closest('${CONVERSATION_TURN_SELECTOR}') || + button.closest('[data-message-author-role="assistant"], [data-turn="assistant"]') || + button.closest('[data-message-author-role], [data-turn]') || + button.closest('[data-testid*="assistant"]'); + if (!container || container === root || container === document.body) continue; + const scoped = Array.from(container.querySelectorAll(markdownSelector)) + .filter((node) => !isExcluded(node)) + .filter((node) => { + const roleNode = node.closest('[data-message-author-role], [data-turn]'); + if (!roleNode) return true; + const role = + (roleNode.getAttribute('data-message-author-role') || roleNode.getAttribute('data-turn') || '').toLowerCase(); + return role !== 'user'; + }); + if (scoped.length === 0) continue; + for (const node of scoped) { + actionMarkdowns.push(node); + } + } + const assistantMarkdowns = markdowns.filter((node) => { + const container = node.closest('[data-message-author-role], [data-turn], [data-testid*="assistant"]'); + if (!container) return false; + const role = + (container.getAttribute('data-message-author-role') || container.getAttribute('data-turn') || '').toLowerCase(); + if (role === 'assistant') return true; + const testId = (container.getAttribute('data-testid') || '').toLowerCase(); + return testId.includes('assistant'); + }); + const hasAssistantIndicators = Boolean( + root.querySelector('${FINISHED_ACTIONS_SELECTOR}') || + root.querySelector('[data-message-author-role="assistant"], [data-turn="assistant"], [data-testid*="assistant"]'), + ); + const allowMarkdownFallback = hasAssistantIndicators || hasTurns || Boolean(userText); + const candidates = + actionMarkdowns.length > 0 + ? actionMarkdowns + : assistantMarkdowns.length > 0 + ? assistantMarkdowns + : allowMarkdownFallback + ? markdowns + : []; + for (let i = candidates.length - 1; i >= 0; i -= 1) { + const node = candidates[i]; + if (!node) continue; + if (!isAfterMinTurn(node)) continue; + const text = (node.innerText || node.textContent || '').trim(); + if (!text) continue; + if (isUserEcho(text)) continue; + const html = node.innerHTML ?? ''; + const turnIndex = resolveTurnIndex(node); + return { + text, + html, + messageId: null, + turnId: null, + turnIndex, + completionVisible: actionMarkdowns.includes(node), + }; + } + return null; + })`; +} +function buildCopyExpression(meta) { + return `(() => { + ${buildClickDispatcher()} + const BUTTON_SELECTOR = '${COPY_BUTTON_SELECTOR}'; + const TIMEOUT_MS = 10000; + + const locateButton = () => { + const hint = ${JSON.stringify(meta ?? {})}; + if (hint?.messageId) { + const node = document.querySelector('[data-message-id="' + hint.messageId + '"]'); + const buttons = node ? Array.from(node.querySelectorAll('${COPY_BUTTON_SELECTOR}')) : []; + const button = buttons.at(-1) ?? null; + if (button) { + return button; + } + } + if (hint?.turnId) { + const node = document.querySelector('[data-testid="' + hint.turnId + '"]'); + const buttons = node ? Array.from(node.querySelectorAll('${COPY_BUTTON_SELECTOR}')) : []; + const button = buttons.at(-1) ?? null; + if (button) { + return button; + } + } + const CONVERSATION_SELECTOR = ${JSON.stringify(CONVERSATION_TURN_SELECTOR)}; + const ASSISTANT_SELECTOR = '${ASSISTANT_ROLE_SELECTOR}'; + const isAssistantTurn = (node) => { + if (!(node instanceof HTMLElement)) return false; + const turnAttr = (node.getAttribute('data-turn') || node.dataset?.turn || '').toLowerCase(); + if (turnAttr === 'assistant') return true; + const role = (node.getAttribute('data-message-author-role') || node.dataset?.messageAuthorRole || '').toLowerCase(); + if (role === 'assistant') return true; + const testId = (node.getAttribute('data-testid') || '').toLowerCase(); + if (testId.includes('assistant')) return true; + return Boolean(node.querySelector(ASSISTANT_SELECTOR) || node.querySelector('[data-testid*="assistant"]')); + }; + const turns = ${buildConversationTurnListExpression()}; + for (let i = turns.length - 1; i >= 0; i -= 1) { + const turn = turns[i]; + if (!isAssistantTurn(turn)) continue; + const button = turn.querySelector(BUTTON_SELECTOR); + if (button) { + return button; + } + } + const all = Array.from(document.querySelectorAll(BUTTON_SELECTOR)); + for (let i = all.length - 1; i >= 0; i -= 1) { + const button = all[i]; + const turn = button?.closest?.(CONVERSATION_SELECTOR); + if (turn && isAssistantTurn(turn)) { + return button; + } + } + return null; + }; + + const interceptClipboard = () => { + const clipboard = navigator.clipboard; + const state = { text: '', updatedAt: 0 }; + if (!clipboard) { + return { state, restore: () => {} }; + } + const originalWriteText = clipboard.writeText; + const originalWrite = clipboard.write; + clipboard.writeText = (value) => { + state.text = typeof value === 'string' ? value : ''; + state.updatedAt = Date.now(); + return Promise.resolve(); + }; + clipboard.write = async (items) => { + try { + const list = Array.isArray(items) ? items : items ? [items] : []; + for (const item of list) { + if (!item) continue; + const types = Array.isArray(item.types) ? item.types : []; + if (types.includes('text/plain') && typeof item.getType === 'function') { + const blob = await item.getType('text/plain'); + const text = await blob.text(); + state.text = text ?? ''; + state.updatedAt = Date.now(); + break; + } + } + } catch { + state.text = ''; + state.updatedAt = Date.now(); + } + return Promise.resolve(); + }; + return { + state, + restore: () => { + clipboard.writeText = originalWriteText; + clipboard.write = originalWrite; + }, + }; + }; + + return new Promise((resolve) => { + const deadline = Date.now() + TIMEOUT_MS; + const waitForButton = () => { + const button = locateButton(); + if (button) { + const interception = interceptClipboard(); + let settled = false; + let pollId = null; + let timeoutId = null; + const finish = (payload) => { + if (settled) { + return; + } + settled = true; + if (pollId) { + clearInterval(pollId); + } + if (timeoutId) { + clearTimeout(timeoutId); + } + button.removeEventListener('copy', handleCopy, true); + interception.restore?.(); + resolve(payload); + }; + + const readIntercepted = () => { + const markdown = interception.state.text ?? ''; + const updatedAt = interception.state.updatedAt ?? 0; + return { success: Boolean(markdown.trim()), markdown, updatedAt }; + }; + + let lastText = ''; + let stableTicks = 0; + const requiredStableTicks = 3; + const requiredStableMs = 250; + const maybeFinish = () => { + const payload = readIntercepted(); + if (!payload.success) return; + if (payload.markdown !== lastText) { + lastText = payload.markdown; + stableTicks = 0; + return; + } + stableTicks += 1; + const ageMs = Date.now() - (payload.updatedAt || 0); + if (stableTicks >= requiredStableTicks && ageMs >= requiredStableMs) { + finish(payload); + } + }; + + const handleCopy = () => { + maybeFinish(); + }; + + button.addEventListener('copy', handleCopy, true); + button.scrollIntoView({ block: 'center', behavior: 'instant' }); + dispatchClickSequence(button); + pollId = setInterval(maybeFinish, 120); + timeoutId = setTimeout(() => { + button.removeEventListener('copy', handleCopy, true); + finish({ success: false, status: 'timeout' }); + }, TIMEOUT_MS); + return; + } + if (Date.now() > deadline) { + resolve({ success: false, status: 'missing-button' }); + return; + } + setTimeout(waitForButton, 120); + }; + + waitForButton(); + }); + })()`; +} +const LANGUAGE_TAGS = new Set([ + "copy code", + "markdown", + "bash", + "sh", + "shell", + "javascript", + "typescript", + "ts", + "js", + "yaml", + "json", + "python", + "py", + "go", + "java", + "c", + "c++", + "cpp", + "c#", + "php", + "ruby", + "rust", + "swift", + "kotlin", + "html", + "css", + "sql", + "text", +].map((token) => token.toLowerCase())); +function cleanAssistantText(text) { + const normalized = text.replace(/\u00a0/g, " "); + const lines = normalized.split(/\r?\n/); + const filtered = lines.filter((line) => { + const trimmed = line.trim().toLowerCase(); + if (LANGUAGE_TAGS.has(trimmed)) + return false; + return true; + }); + return filtered + .join("\n") + .replace(/\n{3,}/g, "\n\n") + .trim(); +} diff --git a/tests/fixtures/oracle-0.17.3/browserConfig.pristine.js b/tests/fixtures/oracle-0.17.3/browserConfig.pristine.js new file mode 100644 index 00000000..3b16fe5f --- /dev/null +++ b/tests/fixtures/oracle-0.17.3/browserConfig.pristine.js @@ -0,0 +1,394 @@ +import fs from "node:fs/promises"; +import path from "node:path"; +import chalk from "chalk"; +import { normalizeThinkingTimeLevel } from "../oracle/thinkingTime.js"; +import { CHATGPT_URL, DEFAULT_MODEL_STRATEGY, DEFAULT_MODEL_TARGET } from "../browser/constants.js"; +import { normalizeChatgptUrl } from "../browser/utils.js"; +import { parseDuration } from "../duration.js"; +import { normalizeBrowserModelStrategy } from "../browser/modelStrategy.js"; +import { getOracleHomeDir } from "../oracleHome.js"; +const DEFAULT_BROWSER_TIMEOUT_MS = 1_200_000; +const DEFAULT_BROWSER_INPUT_TIMEOUT_MS = 60_000; +const DEFAULT_BROWSER_ATTACHMENT_TIMEOUT_MS = 45_000; +const DEFAULT_BROWSER_RECHECK_TIMEOUT_MS = 120_000; +const DEFAULT_BROWSER_AUTO_REATTACH_TIMEOUT_MS = 120_000; +const DEFAULT_CHROME_PROFILE = "Default"; +// Ordered array: most specific models first to ensure correct selection. +// The browser label is passed to the model picker which fuzzy-matches against ChatGPT's UI. +const BROWSER_MODEL_LABELS = [ + // Most specific first (e.g., "gpt-5.2-thinking" before "gpt-5.2") + ["gpt-5.6-sol", "GPT-5.6 Sol"], + ["gpt-5.6", "GPT-5.6 Sol"], + ["gpt-5.5-pro", "GPT-5.5"], + ["gpt-5.5-instant", "GPT-5.5 Instant"], + ["gpt-5.5", "Thinking 5.5"], + ["gpt-5.4-pro", "Pro"], + ["gpt-5.2-thinking", "GPT-5.2 Thinking"], + ["gpt-5.2-instant", "GPT-5.2 Instant"], + ["gpt-5.2-pro", "Pro"], + ["gpt-5.1-pro", "Pro"], + ["gpt-5-pro", "Pro"], + // Base models last (least specific) + ["gpt-5.4", "Thinking 5.4"], + ["gpt-5.2", "GPT-5.2"], // Selects "Auto" in ChatGPT UI + ["gpt-5.1", "GPT-5.2"], // Legacy alias → Auto + ["gemini-3.1-flash-lite", "Gemini 3.1 Flash-Lite"], + ["gemini-3.5-flash", "Gemini 3.5 Flash"], + ["gemini-3.1-pro", "Gemini 3.1 Pro"], + ["gemini-3-pro", "Gemini 3.1 Pro"], + ["gemini-3-pro-deep-think", "gemini-3-deep-think"], +]; +export function normalizeChatGptModelForBrowser(model) { + const normalized = model.toLowerCase(); + if (!normalized.startsWith("gpt-") || normalized.includes("codex")) { + return model; + } + if (normalized === "gpt-5.6-sol" || + normalized === "gpt-5.6" || + normalized === "gpt-5.5-pro" || + normalized === "gpt-5.5-instant" || + normalized === "gpt-5.5" || + normalized === "gpt-5.4") { + return normalized; + } + // Pro variants: resolve to the latest Pro model in ChatGPT. + if (normalized === "gpt-5-pro" || + normalized === "gpt-5.1-pro" || + normalized === "gpt-5.2-pro" || + normalized === "gpt-5.4-pro") { + return "gpt-5.5-pro"; + } + // Explicit model variants: keep as-is (they have their own browser labels) + if (normalized === "gpt-5.2-thinking" || normalized === "gpt-5.2-instant") { + return normalized; + } + // Legacy aliases: map to base GPT-5.2 (Auto) + if (normalized === "gpt-5.1") { + return "gpt-5.2"; + } + return model; +} +export async function buildBrowserConfig(options) { + if (options.copyProfile && options.browserKeepBrowser) { + throw new Error("--copy-profile cannot be combined with --browser-keep-browser: the copied profile is a throwaway that is deleted after the run, so it must not be retained."); + } + if (options.copyProfile && options.browserManualLogin) { + throw new Error("--copy-profile cannot be combined with --browser-manual-login: choose either a throwaway copied profile or the persistent manual-login profile."); + } + if (options.copyProfile && options.remoteChrome) { + throw new Error("--copy-profile cannot be combined with --remote-chrome: copied profiles require a locally launched Chrome instance."); + } + if (options.copyProfile && options.remoteHost) { + throw new Error("--copy-profile cannot be combined with --remote-host: the local profile source is not available to the remote browser service."); + } + const desiredModelOverride = options.browserModelLabel?.trim(); + const normalizedOverride = desiredModelOverride?.toLowerCase() ?? ""; + const baseModel = options.model.toLowerCase(); + const isChatGptModel = baseModel.startsWith("gpt-") && !baseModel.includes("codex"); + const normalizedBrowserModel = normalizeChatGptModelForBrowser(options.model); + const shouldUseOverride = !isChatGptModel && normalizedOverride.length > 0 && normalizedOverride !== baseModel; + const modelStrategy = normalizeBrowserModelStrategy(options.browserModelStrategy) ?? DEFAULT_MODEL_STRATEGY; + const thinkingTime = normalizeThinkingTimeLevel(options.browserThinkingTime) ?? + (modelStrategy === "select" && normalizedBrowserModel === "gpt-5.5-pro" ? "pro" : undefined); + assertBrowserModelAvailable(options.model, modelStrategy); + const cookieNames = parseCookieNames(options.browserCookieNames ?? process.env.ORACLE_BROWSER_COOKIE_NAMES); + let inline = await resolveInlineCookies({ + inlineArg: options.browserInlineCookies, + inlineFileArg: options.browserInlineCookiesFile, + envPayload: process.env.ORACLE_BROWSER_COOKIES_JSON, + envFile: process.env.ORACLE_BROWSER_COOKIES_FILE, + cwd: process.cwd(), + }); + if (inline?.source?.startsWith("home:") && options.browserNoCookieSync !== true) { + inline = undefined; + } + let remoteChrome; + if (options.remoteChrome) { + remoteChrome = parseRemoteChromeTarget(options.remoteChrome); + } + const attachRunning = options.browserAttachRunning === true; + validateAttachRunningOptions(options, { + attachRunning, + hasInlineCookies: Boolean(inline?.cookies), + }); + const rawUrl = options.chatgptUrl ?? options.browserUrl; + const url = rawUrl ? normalizeChatgptUrl(rawUrl, CHATGPT_URL) : undefined; + const desiredModel = isChatGptModel + ? mapModelToBrowserLabel(options.model) + : shouldUseOverride + ? desiredModelOverride + : mapModelToBrowserLabel(options.model); + return { + chromeProfile: options.copyProfile + ? (options.browserChromeProfile ?? null) + : (options.browserChromeProfile ?? DEFAULT_CHROME_PROFILE), + chromePath: options.browserChromePath ?? null, + chromeCookiePath: options.browserCookiePath ?? null, + attachRunning, + url, + debugPort: selectBrowserPort(options), + timeoutMs: options.browserTimeout + ? parseBrowserDuration(options.browserTimeout, "--browser-timeout", DEFAULT_BROWSER_TIMEOUT_MS) + : undefined, + inputTimeoutMs: options.browserInputTimeout + ? parseBrowserDuration(options.browserInputTimeout, "--browser-input-timeout", DEFAULT_BROWSER_INPUT_TIMEOUT_MS) + : undefined, + attachmentTimeoutMs: options.browserAttachmentTimeout + ? parseBrowserDuration(options.browserAttachmentTimeout, "--browser-attachment-timeout", DEFAULT_BROWSER_ATTACHMENT_TIMEOUT_MS) + : undefined, + assistantRecheckDelayMs: options.browserRecheckDelay + ? parseBrowserDuration(options.browserRecheckDelay, "--browser-recheck-delay", 0) + : undefined, + assistantRecheckTimeoutMs: options.browserRecheckTimeout + ? parseBrowserDuration(options.browserRecheckTimeout, "--browser-recheck-timeout", DEFAULT_BROWSER_RECHECK_TIMEOUT_MS) + : undefined, + reuseChromeWaitMs: options.browserReuseWait + ? parseBrowserDuration(options.browserReuseWait, "--browser-reuse-wait", 0) + : undefined, + profileLockTimeoutMs: options.browserProfileLockTimeout + ? parseBrowserDuration(options.browserProfileLockTimeout, "--browser-profile-lock-timeout", 0) + : undefined, + maxConcurrentTabs: parseMaxConcurrentTabs(options.browserMaxConcurrentTabs), + autoReattachDelayMs: options.browserAutoReattachDelay + ? parseBrowserDuration(options.browserAutoReattachDelay, "--browser-auto-reattach-delay", 0) + : undefined, + autoReattachIntervalMs: options.browserAutoReattachInterval + ? parseBrowserDuration(options.browserAutoReattachInterval, "--browser-auto-reattach-interval", 0) + : undefined, + autoReattachTimeoutMs: options.browserAutoReattachTimeout + ? parseBrowserDuration(options.browserAutoReattachTimeout, "--browser-auto-reattach-timeout", DEFAULT_BROWSER_AUTO_REATTACH_TIMEOUT_MS) + : undefined, + cookieSyncWaitMs: options.browserCookieWait + ? parseBrowserDuration(options.browserCookieWait, "--browser-cookie-wait", 0) + : undefined, + cookieSync: options.browserNoCookieSync + ? false + : options.browserManualLoginCookieSync === true + ? true + : undefined, + cookieNames, + inlineCookies: inline?.cookies, + inlineCookiesSource: inline?.source ?? null, + headless: options.browserHeadless === true ? true : undefined, + keepBrowser: options.browserKeepBrowser ? true : undefined, + manualLogin: options.browserManualLogin === undefined ? undefined : options.browserManualLogin, + manualLoginProfileDir: options.browserManualLoginProfileDir ?? undefined, + manualLoginCookieSync: options.browserManualLoginCookieSync, + copyProfileSource: options.copyProfile ?? undefined, + hideWindow: options.browserHideWindow ? true : undefined, + desiredModel, + modelStrategy, + debug: options.verbose ? true : undefined, + // Allow cookie failures by default so runs can continue without Chrome/Keychain secrets. + allowCookieErrors: options.browserAllowCookieErrors ?? true, + remoteChrome, + browserTabRef: options.browserTab ?? undefined, + thinkingTime, + researchMode: options.browserResearch === "deep" ? "deep" : "off", + archiveConversations: options.browserArchive, + }; +} +function assertBrowserModelAvailable(model, modelStrategy) { + if (modelStrategy !== "select") + return; + const normalized = normalizeChatGptModelForBrowser(model); + if (normalized !== "gpt-5.2" && + normalized !== "gpt-5.2-instant" && + normalized !== "gpt-5.2-thinking") { + return; + } + throw new Error(`Browser model "${model}" is retired because ChatGPT no longer offers GPT-5.2 base, Instant, or Thinking. Choose a current GPT-5.5/GPT-5.6 browser model, use --browser-model-strategy current to keep ChatGPT's active model, or use --engine api to retain the GPT-5.2 API alias.`); +} +function validateAttachRunningOptions(options, { attachRunning, hasInlineCookies, }) { + if (!attachRunning) { + return; + } + const conflicts = [ + options.browserChromeProfile ? "--browser-chrome-profile" : null, + options.browserCookiePath ? "--browser-cookie-path" : null, + options.browserNoCookieSync ? "--browser-no-cookie-sync" : null, + options.browserHeadless ? "--browser-headless" : null, + options.browserHideWindow ? "--browser-hide-window" : null, + options.browserKeepBrowser ? "--browser-keep-browser" : null, + options.browserManualLogin ? "--browser-manual-login" : null, + options.browserManualLoginProfileDir ? "--browser-manual-login-profile-dir" : null, + options.copyProfile ? "--copy-profile" : null, + hasInlineCookies ? "--browser-inline-cookies/--browser-inline-cookies-file" : null, + options.browserPort != null || options.browserDebugPort != null + ? "--browser-port/--browser-debug-port" + : null, + ].filter((value) => Boolean(value)); + if (conflicts.length > 0) { + throw new Error(`--browser-attach-running cannot be combined with ${conflicts.join(", ")} because attach mode reuses an already-running browser instead of launching and configuring its own Chrome instance.`); + } +} +function selectBrowserPort(options) { + const candidate = options.browserPort ?? options.browserDebugPort; + if (candidate === undefined || candidate === null) + return null; + if (!Number.isFinite(candidate) || candidate <= 0 || candidate > 65_535) { + throw new Error(`Invalid browser port: ${candidate}. Expected a number between 1 and 65535.`); + } + return candidate; +} +function parseMaxConcurrentTabs(raw) { + if (!raw) + return undefined; + const value = Number.parseInt(raw, 10); + if (!Number.isFinite(value) || value <= 0) { + throw new Error(`Invalid browser max concurrent tabs: ${raw}. Expected a positive integer.`); + } + return Math.trunc(value); +} +function parseBrowserDuration(raw, optionName, fallbackMs) { + const parsed = parseDuration(raw, Number.NaN); + if (Number.isFinite(parsed)) + return parsed; + console.log(chalk.yellow(`Warning: invalid ${optionName} duration "${raw}"; using fallback ${fallbackMs}ms.`)); + return fallbackMs; +} +export function mapModelToBrowserLabel(model) { + const normalized = normalizeChatGptModelForBrowser(model); + // Iterate ordered array to find first match (most specific first) + for (const [key, label] of BROWSER_MODEL_LABELS) { + if (key === normalized) { + return label; + } + } + return DEFAULT_MODEL_TARGET; +} +export function resolveBrowserModelLabel(input, model) { + const trimmed = input?.trim?.() ?? ""; + if (!trimmed) { + return mapModelToBrowserLabel(model); + } + const normalizedInput = trimmed.toLowerCase(); + if (normalizedInput === model.toLowerCase()) { + return mapModelToBrowserLabel(model); + } + return trimmed; +} +function parseRemoteChromeTarget(raw) { + const target = raw.trim(); + if (!target) { + throw new Error("Invalid remote-chrome value: expected host:port but received an empty string."); + } + const ipv6Match = target.match(/^\[(.+)]:(\d+)$/); + let host; + let portSegment; + if (ipv6Match) { + host = ipv6Match[1]?.trim(); + portSegment = ipv6Match[2]?.trim(); + } + else { + const lastColon = target.lastIndexOf(":"); + if (lastColon === -1) { + throw new Error(`Invalid remote-chrome format: ${target}. Expected host:port (IPv6 must use [host]:port notation).`); + } + host = target.slice(0, lastColon).trim(); + portSegment = target.slice(lastColon + 1).trim(); + if (host.includes(":")) { + throw new Error(`Invalid remote-chrome format: ${target}. Wrap IPv6 addresses in brackets, e.g. --remote-chrome "[2001:db8::1]:9222".`); + } + } + if (!host) { + throw new Error(`Invalid remote-chrome format: ${target}. Host portion is missing; expected host:port.`); + } + const port = Number.parseInt(portSegment ?? "", 10); + if (!Number.isFinite(port) || port <= 0 || port > 65_535) { + throw new Error(`Invalid remote-chrome port: "${portSegment ?? ""}". Expected a number between 1 and 65535.`); + } + return { host, port }; +} +function parseCookieNames(raw) { + if (!raw) + return undefined; + const names = raw + .split(",") + .map((entry) => entry.trim()) + .filter(Boolean); + return names.length ? names : undefined; +} +async function resolveInlineCookies({ inlineArg, inlineFileArg, envPayload, envFile, cwd, }) { + const tryLoad = async (source, allowPathResolution) => { + if (!source) + return undefined; + const trimmed = source.trim(); + if (!trimmed) + return undefined; + if (allowPathResolution) { + const resolved = path.isAbsolute(trimmed) ? trimmed : path.join(cwd, trimmed); + try { + const stat = await fs.stat(resolved); + if (stat.isFile()) { + const fileContent = await fs.readFile(resolved, "utf8"); + const parsed = parseInlineCookiesPayload(fileContent); + if (parsed) + return parsed; + } + } + catch { + // not a file; treat as payload below + } + } + return parseInlineCookiesPayload(trimmed); + }; + const sources = [ + { value: inlineFileArg, allowPath: true, source: "inline-file" }, + { value: inlineArg, allowPath: true, source: "inline-arg" }, + { value: envFile, allowPath: true, source: "env-file" }, + { value: envPayload, allowPath: false, source: "env-payload" }, + ]; + for (const { value, allowPath, source } of sources) { + const parsed = await tryLoad(value, allowPath); + if (parsed) + return { cookies: parsed, source }; + } + // fallback: ~/.oracle/cookies.{json,base64} + const oracleHome = getOracleHomeDir(); + const candidates = ["cookies.json", "cookies.base64"]; + for (const file of candidates) { + const fullPath = path.join(oracleHome, file); + try { + const stat = await fs.stat(fullPath); + if (!stat.isFile()) + continue; + const content = await fs.readFile(fullPath, "utf8"); + const parsed = parseInlineCookiesPayload(content); + if (parsed) + return { cookies: parsed, source: `home:${file}` }; + } + catch { + // ignore missing/invalid + } + } + return undefined; +} +function parseInlineCookiesPayload(raw) { + if (!raw) + return undefined; + const text = raw.trim(); + if (!text) + return undefined; + let jsonPayload = text; + // Attempt base64 decode first; fall back to raw text on failure. + try { + const decoded = Buffer.from(text, "base64").toString("utf8"); + if (decoded.trim().startsWith("[")) { + jsonPayload = decoded; + } + } + catch { + // not base64; continue with raw text + } + try { + const parsed = JSON.parse(jsonPayload); + if (Array.isArray(parsed)) { + return parsed; + } + } + catch { + // invalid json; skip silently to keep this hidden flag non-fatal + } + return undefined; +} diff --git a/tests/fixtures/oracle-0.17.3/browserIndex.pristine.js b/tests/fixtures/oracle-0.17.3/browserIndex.pristine.js new file mode 100644 index 00000000..3dab9705 --- /dev/null +++ b/tests/fixtures/oracle-0.17.3/browserIndex.pristine.js @@ -0,0 +1,3336 @@ +import { mkdtemp, rm, mkdir } from "node:fs/promises"; +import path from "node:path"; +import os from "node:os"; +import net from "node:net"; +import { resolveBrowserConfig } from "./config.js"; +import { copyChromeProfile } from "./profileCopy.js"; +import { launchChrome, registerTerminationHooks, positionChromeWindowOffscreen, connectToRemoteChrome, connectWithNewTab, closeTab, createChromePageTarget, ensureChromePageTargetAfterClose, closeBlankChromeTabs, } from "./chromeLifecycle.js"; +import { clearStaleChatGptConversationCookies, syncCookies } from "./cookies.js"; +import { navigateToChatGPT, navigateToPromptReadyWithFallback, ensureNotBlocked, ensureLoggedIn, ensurePromptReady, ensureChatMode, waitForResumedConversationHydration, installJavaScriptDialogAutoDismissal, ensureModelSelection, clearPromptComposer, waitForAssistantResponse, captureAssistantMarkdown, clearComposerAttachments, uploadAttachmentFile, waitForAttachmentCompletion, waitForUserTurnAttachments, readAssistantSnapshot, } from "./pageActions.js"; +import { INPUT_SELECTORS } from "./constants.js"; +import { uploadAttachmentViaDataTransfer } from "./actions/remoteFileTransfer.js"; +import { ensureThinkingTime } from "./actions/thinkingTime.js"; +import { startThinkingStatusMonitor } from "./actions/thinkingStatus.js"; +import { activateDeepResearch, captureDeepResearchTargetKeys, waitForDeepResearchCompletion, waitForResearchPlanAutoConfirm, } from "./actions/deepResearch.js"; +import { estimateTokenCount, withRetries, delay } from "./utils.js"; +import { formatElapsed } from "../oracle/format.js"; +import { CHATGPT_URL, DEFAULT_MODEL_STRATEGY } from "./constants.js"; +import { BrowserAutomationError } from "../oracle/errors.js"; +import { alignPromptEchoPair, buildPromptEchoMatcher } from "./reattachHelpers.js"; +import { buildConversationTurnCountExpression } from "./conversationTurns.js"; +import { cleanupStaleProfileState, acquireProfileRunLock, findRunningChromeDebugTargetForProfile, readChromePid, readDevToolsPort, shouldCleanupManualLoginProfileState, terminateRecordedChromeForProfile, verifyDevToolsReachable, writeChromePid, writeDevToolsActivePort, } from "./profileState.js"; +import { connectionLostUserMessage, isRecoverableChromeDisconnect, probeChromeTargetLiveness, } from "./cdpLiveness.js"; +import { acquireBrowserTabLease, hasOtherActiveBrowserTabLeases, } from "./tabLeaseRegistry.js"; +import { appendArtifacts, saveBrowserTranscriptArtifact, saveDeepResearchReportArtifact, } from "./artifacts.js"; +import { collectGeneratedImageArtifacts } from "./chatgptImages.js"; +import { collectChatGptFileArtifacts } from "./chatgptFiles.js"; +import { runProviderSubmissionFlow } from "./providerDomFlow.js"; +import { chatgptDomProvider } from "./providers/index.js"; +import { resolveAttachRunningConnection } from "./attachRunning.js"; +import { connectToExistingChatGptTab } from "./liveTabs.js"; +import { captureBrowserDiagnostics } from "./domDebug.js"; +import { archiveChatGptConversation, resolveBrowserArchiveDecision, } from "./actions/archiveConversation.js"; +import { assertManualLoginProfileReadyForRun, defaultManualLoginProfileDir, formatManualLoginSetupCommand, isManualLoginProfileInitialized, resolveManualLoginWaitMs, } from "./manualLoginProfile.js"; +import { describeBrowserControlPlan, formatBrowserControlPlan } from "./controlPlan.js"; +import { shouldSyncBrowserCookies } from "./policies.js"; +import { createConversationUrlMonitor, } from "./conversationUrlMonitor.js"; +import { extractStableConversationIdFromUrl as extractConversationIdFromUrl, isStableConversationUrl as isConversationUrl, } from "./conversationUrl.js"; +export { CHATGPT_URL, DEFAULT_MODEL_STRATEGY, DEFAULT_MODEL_TARGET } from "./constants.js"; +export { parseDuration, delay, normalizeChatgptUrl, isTemporaryChatUrl } from "./utils.js"; +export { formatThinkingLog, formatThinkingWaitingLog, buildThinkingStatusExpressionForTest, readThinkingStatusForTest, sanitizeThinkingText, startThinkingStatusMonitorForTest, } from "./actions/thinkingStatus.js"; +function redactBrowserConfigForDebugLog(config) { + const redacted = { ...config }; + if (Array.isArray(config.inlineCookies)) { + redacted.inlineCookies = `[redacted:${config.inlineCookies.length} cookies]`; + redacted.inlineCookieCount = config.inlineCookies.length; + } + return redacted; +} +export function redactBrowserConfigForDebugLogForTest(config) { + return redactBrowserConfigForDebugLog(config); +} +function isCloudflareChallengeError(error) { + if (!(error instanceof BrowserAutomationError)) + return false; + return error.details?.stage === "cloudflare-challenge"; +} +function isReattachableCaptureError(error) { + if (!(error instanceof BrowserAutomationError)) + return false; + const stage = error.details?.stage; + return stage === "assistant-timeout" || stage === "assistant-recheck"; +} +function classifyPreservedBrowserError(error, headless) { + if (headless) + return null; + if (isCloudflareChallengeError(error)) + return "cloudflare-challenge"; + if (isReattachableCaptureError(error)) + return "reattachable-capture"; + return null; +} +function shouldPreserveBrowserOnError(error, headless) { + return classifyPreservedBrowserError(error, headless) !== null; +} +function normalizeAuthenticatedModelSelectionError(error) { + return error instanceof Error ? error : new Error(String(error)); +} +function shouldKeepLocalBrowserOpen(options) { + if (options.usingCopiedProfile) + return false; + return options.effectiveKeepBrowser || options.preserveBrowserOnError; +} +export function shouldPreserveBrowserOnErrorForTest(error, headless) { + return shouldPreserveBrowserOnError(error, headless); +} +export function classifyPreservedBrowserErrorForTest(error, headless) { + return classifyPreservedBrowserError(error, headless); +} +const MAX_CHATGPT_UI_WARNING_CHARS = 300; +const MAX_CHATGPT_UI_WARNINGS = 3; +function classifyChatGptUiWarningText(text) { + const normalized = text.toLowerCase(); + if (/\btoo many requests\b/.test(normalized) || + /\bsending too many requests\b/.test(normalized) || + /\btoo quickly\b/.test(normalized) || + /\btemporarily limited access\b/.test(normalized) || + /\bplease wait a few minutes\b/.test(normalized) || + /\brate limit(?:ed)?\b/.test(normalized) || + /\bslow down\b/.test(normalized)) { + return "rate_limit"; + } + if (/\btemporarily unavailable\b/.test(normalized) || + /\bsomething went wrong\b/.test(normalized) || + /\bfailed to generate\b/.test(normalized) || + /\btry again later\b/.test(normalized)) { + return "temporary_unavailable"; + } + if (/\bverify you are human\b/.test(normalized) || + /\bunusual activity\b/.test(normalized) || + /\bcloudflare\b/.test(normalized) || + /\bchallenge\b/.test(normalized) || + /\blogin required\b/.test(normalized) || + /\bsign in\b/.test(normalized)) { + return "auth_or_challenge"; + } + return null; +} +function sanitizeChatGptUiWarningText(text) { + return text + .replace(/\b[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}\b/gi, "[redacted-email]") + .replace(/\b((?:access|auth|session)[-_ ]?token|token)\s*[:=]\s*["']?[^\s"',;]+/gi, "$1=[redacted]") + .replace(/\b(?:sk-(?:ant-|or-)?|xai-)[A-Za-z0-9_-]{8,}\b/g, "[redacted-token]"); +} +function normalizeUiWarningCandidate(value) { + if (!value || typeof value !== "object") + return null; + const candidate = value; + const text = typeof candidate.text === "string" + ? sanitizeChatGptUiWarningText(candidate.text.replace(/\s+/g, " ").trim()) + : ""; + if (!text) + return null; + return { + text: text.slice(0, MAX_CHATGPT_UI_WARNING_CHARS), + source: typeof candidate.source === "string" ? candidate.source : null, + role: typeof candidate.role === "string" ? candidate.role : null, + ariaLive: typeof candidate.ariaLive === "string" ? candidate.ariaLive : null, + selector: typeof candidate.selector === "string" ? candidate.selector : null, + }; +} +async function collectChatGptUiWarnings(Runtime) { + try { + const { result } = await Runtime.evaluate({ + awaitPromise: true, + returnByValue: true, + expression: `(() => { + const warningPattern = /too many requests|sending too many requests|too quickly|temporarily limited access|please wait a few minutes|rate limit|rate limited|slow down|try again later|temporarily unavailable|something went wrong|failed to generate|verify you are human|unusual activity|cloudflare|challenge|login required|sign in/i; + const selectors = [ + '[role="alert"]', + '[role="status"]', + '[role="dialog"]', + '[aria-live]', + '[data-testid*="toast" i]', + '[data-testid*="banner" i]', + '[data-testid*="error" i]', + '[class*="toast" i]', + '[class*="banner" i]' + ]; + const isVisible = (element) => { + if (!(element instanceof HTMLElement)) return false; + let current = element; + while (current) { + const currentStyle = window.getComputedStyle(current); + if ( + !currentStyle || + currentStyle.display === 'none' || + currentStyle.visibility === 'hidden' || + currentStyle.visibility === 'collapse' || + Number.parseFloat(currentStyle.opacity || '1') === 0 + ) { + return false; + } + current = current.parentElement; + } + const rect = element.getBoundingClientRect(); + return rect.width > 0 && rect.height > 0; + }; + const describe = (element, source, selector = null) => ({ + text: (element.innerText || element.textContent || '').replace(/\\s+/g, ' ').trim().slice(0, 1000), + source, + selector, + role: element.getAttribute('role'), + ariaLive: element.getAttribute('aria-live') + }); + const out = []; + const seen = new Set(); + const warningContainers = []; + const overlapsWarningContainer = (element) => warningContainers.some((container) => ( + container !== element && (container.contains(element) || element.contains(container)) + )); + const add = (element, entry) => { + if (!entry.text || !warningPattern.test(entry.text)) return; + const key = entry.text + '|' + (entry.role || '') + '|' + (entry.ariaLive || ''); + if (seen.has(key)) return; + seen.add(key); + warningContainers.push(element); + out.push(entry); + }; + for (const selector of selectors) { + if (out.length >= 5) break; + let elements = []; + try { + elements = Array.from(document.querySelectorAll(selector)); + } catch { + elements = []; + } + for (const element of elements) { + if (out.length >= 5) break; + if (overlapsWarningContainer(element)) continue; + if (isVisible(element)) add(element, describe(element, 'selector', selector)); + } + } + return out.slice(0, 5); + })()`, + }); + const rawWarnings = Array.isArray(result?.value) ? result.value : []; + const warnings = []; + const seen = new Set(); + for (const raw of rawWarnings) { + const candidate = normalizeUiWarningCandidate(raw); + if (!candidate) + continue; + const type = classifyChatGptUiWarningText(candidate.text); + if (!type) + continue; + const key = `${type}:${candidate.text}`; + if (seen.has(key)) + continue; + seen.add(key); + warnings.push({ + type, + message: candidate.text, + source: candidate.source, + role: candidate.role, + ariaLive: candidate.ariaLive, + selector: candidate.selector, + }); + if (warnings.length >= MAX_CHATGPT_UI_WARNINGS) + break; + } + return warnings; + } + catch { + return []; + } +} +function formatChatGptUiWarningType(type) { + switch (type) { + case "rate_limit": + return "rate-limit"; + case "temporary_unavailable": + return "temporary-unavailable"; + case "auth_or_challenge": + return "authentication/challenge"; + } +} +async function createChatGptUiWarningError(params) { + const [uiWarning] = await collectChatGptUiWarnings(params.Runtime); + if (!uiWarning) + return null; + params.logger(`[browser] ChatGPT UI warning detected (${uiWarning.type}): ${uiWarning.message}`); + return new BrowserAutomationError(`ChatGPT displayed a ${formatChatGptUiWarningType(uiWarning.type)} warning while waiting for ${params.waitTarget}: ${uiWarning.message}`, { + stage: params.stage, + code: "chatgpt-ui-warning", + uiWarning, + runtime: params.runtime, + diagnostics: params.diagnostics, + }, params.cause); +} +async function throwChatGptUiWarningIfPresent(params) { + const error = await createChatGptUiWarningError(params); + if (error) + throw error; +} +async function createAssistantTimeoutError(params) { + const warningError = await createChatGptUiWarningError({ + Runtime: params.Runtime, + logger: params.logger, + runtime: params.runtime, + stage: "assistant-timeout", + waitTarget: "the assistant", + diagnostics: params.diagnostics, + cause: params.cause, + }); + if (!warningError) { + return new BrowserAutomationError("Assistant response timed out before completion; reattach later to capture the answer.", { stage: "assistant-timeout", runtime: params.runtime, diagnostics: params.diagnostics }, params.cause); + } + return warningError; +} +/** + * Make the page behave like a focused foreground tab. + * + * The send button is activated with trusted CDP input events dispatched at + * viewport coordinates. Chrome delivers those only to a window that is being + * composited, so a hidden (`--browser-hide-window`), minimized, or occluded + * window swallows the click while the automation still believes it clicked. + * Soft-fails: focus emulation is an optimization, never a hard requirement. + */ +async function enableFocusEmulation(client, logger, label) { + try { + await client.Emulation.setFocusEmulationEnabled({ enabled: true }); + logger(`[browser] Focus emulation enabled for ${label}`); + } + catch (error) { + const message = error instanceof Error ? error.message : String(error); + logger(`[browser] Focus emulation unavailable: ${message}`); + } +} +function listIgnoredRemoteChromeFlags(config) { + return [ + config.headless ? "--browser-headless" : null, + config.hideWindow ? "--browser-hide-window" : null, + config.keepBrowser ? "--browser-keep-browser" : null, + !config.attachRunning && config.chromePath ? "--browser-chrome-path" : null, + ].filter((value) => Boolean(value)); +} +function hasBrowserErrorCode(error, code) { + return (error instanceof BrowserAutomationError && + error.details?.code === code); +} +async function saveOptionalArtifact(operation, logger) { + try { + return await operation(); + } + catch (error) { + const message = error instanceof Error ? error.message : String(error); + logger(`[browser] Failed to save session artifact: ${message}`); + return null; + } +} +async function waitForAssistantOrGeneratedImageResponse(params) { + if (!params.imageOutputRequested) { + return params.waitForText(); + } + params.logger("[browser] Waiting for ChatGPT generated image response."); + const response = await pollGeneratedImageOrTextAssistantResponse(params.Runtime, params.timeoutMs, params.minTurnIndex, params.expectedConversationId); + if (response) { + if (response.html?.includes("/backend-api/estuary/content?id=file_")) { + params.logger("[browser] Captured generated image response before text appeared."); + } + return response; + } + throw new Error("assistant response timeout while waiting for generated image or text"); +} +async function attemptAssistantRecheckOrRethrow(operation) { + try { + return await operation(); + } + catch (error) { + if (error instanceof BrowserAutomationError) { + throw error; + } + return null; + } +} +async function pollGeneratedImageOrTextAssistantResponse(Runtime, timeoutMs, minTurnIndex, expectedConversationId) { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + let snapshot = await readAssistantSnapshot(Runtime, minTurnIndex, expectedConversationId).catch(() => null); + if (!snapshot && typeof minTurnIndex === "number" && Number.isFinite(minTurnIndex)) { + const relaxedSnapshot = await readAssistantSnapshot(Runtime, undefined, expectedConversationId).catch(() => null); + const relaxedHtml = typeof relaxedSnapshot?.html === "string" ? relaxedSnapshot.html : ""; + if (relaxedHtml.includes("/backend-api/estuary/content?id=file_")) { + snapshot = relaxedSnapshot; + } + } + const text = typeof snapshot?.text === "string" ? snapshot.text.trim() : ""; + const html = typeof snapshot?.html === "string" ? snapshot.html : ""; + const hasGeneratedImage = html.includes("/backend-api/estuary/content?id=file_"); + if (text && (hasGeneratedImage || !isImageOnlyUiChromeText(text))) { + return { + text, + html, + meta: { + turnId: snapshot?.turnId ?? undefined, + messageId: snapshot?.messageId ?? undefined, + }, + }; + } + await delay(750); + } + return null; +} +export function isImageOnlyUiChromeText(text) { + const normalized = text.toLowerCase().replace(/\s+/g, " ").trim(); + return (normalized.length === 0 || + normalized === "edit" || + normalized === "stopped thinking" || + normalized === "stopped thinking edit" || + /^(?:reasoning\s+|pro thinking\s+)?thought for \d+(?:\.\d+)?\s*(?:s|sec|secs|second|seconds|m|min|mins|minute|minutes|h|hr|hrs|hour|hours)\s+edit$/.test(normalized)); +} +function normalizeBrowserFollowUpPrompts(values) { + return (values ?? []).map((entry) => entry.trim()).filter(Boolean); +} +export function formatBrowserTurnTranscript(turns) { + if (turns.length <= 1) { + const turn = turns[0]; + return { + answerText: turn?.answerText ?? "", + answerMarkdown: turn?.answerMarkdown ?? turn?.answerText ?? "", + }; + } + const answerMarkdown = turns + .map((turn, index) => { + const label = turn.label.trim() || `Turn ${index + 1}`; + const prompt = turn.prompt?.trim(); + const promptBlock = prompt ? `\n\n### Prompt\n\n${prompt}` : ""; + const answer = (turn.answerMarkdown || turn.answerText).trim() || "_No text captured._"; + return `## ${label}${promptBlock}\n\n### Answer\n\n${answer}`; + }) + .join("\n\n") + .trim(); + return { + answerText: answerMarkdown, + answerMarkdown, + }; +} +async function maybeArchiveCompletedConversation({ Runtime, logger, config, conversationUrl, followUpCount, requiredArtifactsSaved, }) { + const decision = resolveBrowserArchiveDecision({ + mode: config.archiveConversations, + chatgptUrl: config.chatgptUrl ?? config.url, + conversationUrl, + researchMode: config.researchMode, + followUpCount, + }); + if (!decision.shouldArchive) { + logger(`[browser] ChatGPT archive skipped (${decision.reason}).`); + return { + mode: decision.mode, + attempted: false, + archived: false, + reason: decision.reason, + conversationUrl: conversationUrl ?? undefined, + }; + } + if (!requiredArtifactsSaved) { + logger("[browser] ChatGPT archive skipped (artifact-save-failed)."); + return { + mode: decision.mode, + attempted: false, + archived: false, + reason: "artifact-save-failed", + conversationUrl: conversationUrl ?? undefined, + }; + } + return archiveChatGptConversation(Runtime, logger, { + mode: decision.mode, + conversationUrl, + }).catch((error) => { + const message = error instanceof Error ? error.message : String(error); + logger(`[browser] ChatGPT archive failed (${message}).`); + return { + mode: decision.mode, + attempted: true, + archived: false, + reason: "archive-failed", + conversationUrl: conversationUrl ?? undefined, + error: message, + }; + }); +} +export function maybeArchiveCompletedConversationForTest(args) { + return maybeArchiveCompletedConversation(args); +} +async function captureDeepResearchTargetBaseline(client, logger) { + try { + return { targetKeys: await captureDeepResearchTargetKeys(client), captured: true }; + } + catch { + logger("[browser] Deep Research target baseline unavailable; retaining conversation-turn owner scoping."); + return { targetKeys: [], captured: false }; + } +} +async function runSubmissionWithRecovery({ prompt, attachments, fallbackSubmission, submit, reloadPromptComposer, prepareFallbackSubmission, logger, }) { + let currentPrompt = prompt; + let currentAttachments = attachments; + let retriedDeadComposer = false; + let usedFallbackSubmission = false; + while (true) { + try { + return await submit(currentPrompt, currentAttachments); + } + catch (error) { + const isDeadComposer = hasBrowserErrorCode(error, "dead-composer"); + if (isDeadComposer && !retriedDeadComposer) { + retriedDeadComposer = true; + await reloadPromptComposer(); + continue; + } + const isPromptTooLarge = hasBrowserErrorCode(error, "prompt-too-large"); + if (fallbackSubmission && isPromptTooLarge && !usedFallbackSubmission) { + usedFallbackSubmission = true; + logger("[browser] Inline prompt too large; retrying with file uploads."); + await prepareFallbackSubmission(); + currentPrompt = fallbackSubmission.prompt; + currentAttachments = fallbackSubmission.attachments; + continue; + } + throw error; + } + } +} +export async function runSubmissionWithRecoveryForTest(args) { + return runSubmissionWithRecovery(args); +} +function resolveRemoteTabLeaseProfileDir(config) { + if (!config.remoteChrome || !config.manualLogin || !config.manualLoginProfileDir) { + return null; + } + return path.resolve(config.manualLoginProfileDir); +} +export function resolveRemoteTabLeaseProfileDirForTest(config) { + return resolveRemoteTabLeaseProfileDir(config); +} +function isLocalChromeHost(host) { + const normalized = host + .trim() + .toLowerCase() + .replace(/^\[|\]$/g, ""); + if (normalized === "localhost" || normalized === "::1") { + return true; + } + return net.isIPv4(normalized) && normalized.startsWith("127."); +} +export function isLocalChromeHostForTest(host) { + return isLocalChromeHost(host); +} +async function closeRemoteConnectionAfterRun(options) { + if (options.connectionClosedUnexpectedly) { + return; + } + if (!options.connection) { + await options.client?.close(); + return; + } + if (options.runStatus === "complete") { + await options.connection.close(); + } + else { + await options.client?.close(); + } +} +function shouldCloseOwnedRunTargetAfterRun(options) { + return (options.runStatus === "complete" && + options.ownsTarget && + (Boolean(options.closeOwnedTabOnComplete) || !options.keepBrowser)); +} +function shouldCleanupBlankTabsAfterLastLease(options) { + return (options.runStatus === "complete" && + options.ownsTarget && + !options.connectionClosedUnexpectedly && + options.manualLogin && + options.keepBrowser && + Boolean(options.chromePort)); +} +function buildSkippedModelSelectionEvidence(desiredModel, strategy) { + return { + requestedModel: desiredModel ?? null, + resolvedLabel: null, + strategy, + status: "skipped", + verified: false, + source: "config", + capturedAt: new Date().toISOString(), + }; +} +export async function runBrowserMode(options) { + const promptText = options.prompt?.trim(); + if (!promptText) { + throw new Error("Prompt text is required when using browser mode."); + } + const attachments = options.attachments ?? []; + const fallbackSubmission = options.fallbackSubmission; + let config = resolveBrowserConfig(options.config); + const usingCopiedProfile = Boolean(config.copyProfileSource); + if (usingCopiedProfile && (config.attachRunning || config.remoteChrome)) { + throw new BrowserAutomationError("--copy-profile requires a locally launched Chrome instance and cannot be combined with attach-running or remote Chrome.", { stage: "profile-config" }); + } + const isResumingConversation = Boolean(config.resumeConversationUrl); + const followUpPrompts = normalizeBrowserFollowUpPrompts(options.followUpPrompts); + if (config.researchMode === "deep" && followUpPrompts.length > 0) { + throw new BrowserAutomationError("Browser follow-ups are not supported with Deep Research mode. Put the full research plan into the initial prompt or run a normal browser consult for multi-turn review.", { + stage: "browser-follow-ups", + details: { researchMode: "deep", followUps: followUpPrompts.length }, + }); + } + const logger = options.log ?? ((_message) => { }); + if (logger.verbose === undefined) { + logger.verbose = Boolean(config.debug); + } + if (logger.sessionLog === undefined && options.log?.sessionLog) { + logger.sessionLog = options.log.sessionLog; + } + const runtimeHintCb = options.runtimeHintCb; + let lastTargetId; + let lastUrl; + let promptSubmitted = false; + let modelSelectionEvidence; + let tabLease = null; + let conversationUrlMonitor = null; + const emitRuntimeHint = async () => { + if (!chrome?.port) { + return; + } + const conversationId = lastUrl ? extractConversationIdFromUrl(lastUrl) : undefined; + const hint = { + chromePid: chrome.pid, + chromePort: chrome.port, + chromeHost, + chromeTargetId: lastTargetId, + tabUrl: lastUrl, + conversationId, + promptSubmitted, + userDataDir, + controllerPid: process.pid, + }; + try { + await runtimeHintCb?.(hint, modelSelectionEvidence); + await tabLease?.update({ + chromeHost, + chromePort: chrome.port, + chromeTargetId: lastTargetId, + tabUrl: lastUrl, + }); + } + catch (error) { + const message = error instanceof Error ? error.message : String(error); + logger(`Failed to persist runtime hint: ${message}`); + } + }; + const markPromptSubmitted = async () => { + if (promptSubmitted) { + return; + } + promptSubmitted = true; + await emitRuntimeHint(); + void conversationUrlMonitor?.schedule("post-submit", config.timeoutMs ?? 120_000); + }; + if (config.debug || process.env.CHATGPT_DEVTOOLS_TRACE === "1") { + logger(`[browser-mode] config: ${JSON.stringify({ + ...redactBrowserConfigForDebugLog(config), + promptLength: promptText.length, + })}`); + } + for (const line of formatBrowserControlPlan(describeBrowserControlPlan(config), "browser")) { + logger(line); + } + if (config.attachRunning) { + const attached = await resolveAttachRunningConnection(config, logger); + config = { + ...config, + remoteChrome: { host: attached.host, port: attached.port }, + remoteChromeBrowserWSEndpoint: attached.browserWSEndpoint, + remoteChromeProfileRoot: attached.profileRoot, + }; + } + if (!config.remoteChrome && !config.manualLogin) { + const preferredPort = config.debugPort ?? DEFAULT_DEBUG_PORT; + const availablePort = await pickAvailableDebugPort(preferredPort, logger); + if (availablePort !== preferredPort) { + logger(`DevTools port ${preferredPort} busy; using ${availablePort} to avoid attaching to stray Chrome.`); + } + config = { ...config, debugPort: availablePort }; + } + // Remote Chrome mode - connect to existing browser + if (config.remoteChrome) { + // Warn about ignored local-only options + const ignoredFlags = listIgnoredRemoteChromeFlags(config); + if (ignoredFlags.length > 0) { + logger(`Note: --remote-chrome ignores local Chrome flags (${ignoredFlags.join(", ")}).`); + } + return runRemoteBrowserMode(promptText, attachments, config, logger, options); + } + const manualLogin = Boolean(config.manualLogin); + if (manualLogin && usingCopiedProfile) { + throw new BrowserAutomationError("--copy-profile cannot be combined with --browser-manual-login: choose either a throwaway copied profile or the persistent manual-login profile.", { stage: "profile-config" }); + } + // Manual-login and copy-profile both start from an already-signed-in profile, + // so neither clears nor syncs cookies. + const profileIsPreSigned = manualLogin || usingCopiedProfile; + const manualProfileDir = config.manualLoginProfileDir + ? path.resolve(config.manualLoginProfileDir) + : defaultManualLoginProfileDir(); + const userDataDir = manualLogin + ? manualProfileDir + : await mkdtemp(path.join(await resolveUserDataBaseDir(), "oracle-browser-")); + const effectiveKeepBrowser = Boolean(config.keepBrowser); + if (manualLogin) { + // Learned: manual login reuses a persistent profile so cookies/SSO survive. + await mkdir(userDataDir, { recursive: true }); + logger(`Manual login mode enabled; reusing persistent profile at ${userDataDir}`); + await assertManualLoginProfileReadyForRun({ + userDataDir, + keepBrowser: effectiveKeepBrowser, + }); + } + else if (config.copyProfileSource) { + const copiedProfileDirectory = await copyChromeProfile(config.copyProfileSource, userDataDir, config.chromeProfile); + config = { ...config, chromeProfile: copiedProfileDirectory }; + logger(`Seeded temporary Chrome profile ${copiedProfileDirectory} from ${config.copyProfileSource} (copy-profile mode; signed-in session reused without manual login)`); + } + else { + logger(`Created temporary Chrome profile at ${userDataDir}`); + } + if (manualLogin) { + tabLease = await acquireBrowserTabLease(userDataDir, { + maxConcurrentTabs: config.maxConcurrentTabs, + timeoutMs: config.timeoutMs, + logger, + sessionId: options.sessionId, + }); + } + let acquiredChrome; + try { + acquiredChrome = manualLogin + ? await acquireManualLoginChromeForRun(userDataDir, config, logger, options.sessionId) + : { + chrome: await launchChrome({ + ...config, + remoteChrome: config.remoteChrome, + }, userDataDir, logger), + reusedChrome: null, + }; + } + catch (error) { + if (tabLease) { + const handle = tabLease; + tabLease = null; + await handle.release().catch(() => undefined); + } + if (usingCopiedProfile) { + await rm(userDataDir, { recursive: true, force: true }).catch(() => undefined); + } + throw error; + } + const { chrome, reusedChrome } = acquiredChrome; + const chromeHost = chrome.host ?? "127.0.0.1"; + if (tabLease) { + await tabLease.update({ + chromeHost, + chromePort: chrome.port, + }); + } + let removeTerminationHooks = null; + try { + removeTerminationHooks = registerTerminationHooks(chrome, userDataDir, effectiveKeepBrowser, logger, { + isInFlight: () => runStatus !== "complete", + emitRuntimeHint, + preserveUserDataDir: manualLogin, + // copy-profile is a throwaway copy of a signed-in profile; never leave it on disk. + forceProfileCleanup: usingCopiedProfile, + }); + } + catch { + // ignore failure; cleanup still happens below + } + let client = null; + let isolatedTargetId = null; + let ownsTarget = true; + const startedAt = Date.now(); + let answerText = ""; + let answerMarkdown = ""; + let answerHtml = ""; + let runStatus = "attempted"; + let connectionClosedUnexpectedly = false; + let stopThinkingMonitor = null; + let removeDialogHandler = null; + let appliedCookies = 0; + let preserveBrowserOnError = false; + try { + try { + if (config.browserTabRef) { + const attached = await connectToExistingChatGptTab({ + host: chromeHost, + port: chrome.port, + ref: config.browserTabRef, + }); + client = attached.client; + isolatedTargetId = attached.targetId ?? null; + lastTargetId = attached.targetId ?? undefined; + lastUrl = attached.tab.url || lastUrl; + ownsTarget = false; + logger(`Attached to existing ChatGPT tab ${attached.targetId}${attached.tab.url ? ` (${attached.tab.url})` : ""}`); + } + else { + const strictTabIsolation = Boolean(manualLogin && reusedChrome); + const devtoolsRetries = manualLogin ? 6 : 0; + const connection = await connectWithNewTab(chrome.port, logger, "about:blank", chromeHost, { + fallbackToDefault: !strictTabIsolation, + retries: devtoolsRetries, + retryDelayMs: 500, + }); + client = connection.client; + isolatedTargetId = connection.targetId ?? null; + ownsTarget = true; + } + if (tabLease && isolatedTargetId) { + await tabLease.update({ + chromeHost, + chromePort: chrome.port, + chromeTargetId: isolatedTargetId, + }); + } + } + catch (error) { + const hint = describeDevtoolsFirewallHint(chromeHost, chrome.port); + if (hint) { + logger(hint); + } + throw error; + } + const disconnectPromise = new Promise((_, reject) => { + client?.on("disconnect", () => { + connectionClosedUnexpectedly = true; + void (async () => { + const liveness = await probeChromeTargetLiveness({ + host: chromeHost, + port: chrome.port, + targetId: lastTargetId ?? isolatedTargetId, + }); + const recoverable = isRecoverableChromeDisconnect(liveness); + if (recoverable) { + logger("CDP client disconnected; Chrome/target still reachable. Leaving run recoverable for reattach."); + } + else { + logger("Chrome window closed; attempting to abort run."); + } + reject(new BrowserAutomationError(connectionLostUserMessage({ recoverable }), { + stage: "connection-lost", + recoverableDisconnect: recoverable, + disconnectCause: recoverable ? "cdp-client-disconnect" : "chrome-closed", + runtime: { + chromePid: chrome.pid, + chromePort: chrome.port, + chromeHost, + userDataDir, + chromeTargetId: lastTargetId ?? isolatedTargetId ?? undefined, + tabUrl: liveness.matchedUrl ?? lastUrl, + conversationId: (liveness.matchedUrl ?? lastUrl) + ? extractConversationIdFromUrl(liveness.matchedUrl ?? lastUrl ?? "") + : undefined, + promptSubmitted, + controllerPid: process.pid, + }, + })); + })(); + }); + }); + const raceWithDisconnect = (promise) => Promise.race([promise, disconnectPromise]); + const { Network, Page, Runtime, Input, DOM, Target } = client; + const domainEnablers = [Network.enable({}), Page.enable(), Runtime.enable()]; + if (DOM && typeof DOM.enable === "function") { + domainEnablers.push(DOM.enable()); + } + await Promise.all(domainEnablers); + if (!config.headless && config.hideWindow) { + await positionChromeWindowOffscreen(client, logger); + } + // The send button is clicked with trusted CDP input events at viewport + // coordinates, which ChatGPT silently drops when the window is hidden or + // occluded. Emulate focus so the page behaves like a foreground tab. + await enableFocusEmulation(client, logger, "local target"); + removeDialogHandler = installJavaScriptDialogAutoDismissal(Page, logger); + if (!profileIsPreSigned) { + await Network.clearBrowserCookies(); + } + const manualLoginCookieSync = manualLogin && Boolean(config.manualLoginCookieSync); + const cookieSyncEnabled = shouldSyncBrowserCookies(config, { + manualLogin, + profileIsPreSigned, + }); + if (cookieSyncEnabled) { + if (manualLoginCookieSync) { + logger("Manual login mode: seeding persistent profile with cookies from your Chrome profile."); + } + if (!config.inlineCookies) { + logger("Heads-up: macOS may prompt for your Keychain password to read Chrome cookies; use --copy or --render for manual flow."); + } + else { + logger("Applying inline cookies (skipping Chrome profile read and Keychain prompt)"); + } + // Learned: always sync cookies before the first navigation so /backend-api/me succeeds. + const cookieCount = await syncCookies(Network, config.url, config.chromeProfile, logger, { + allowErrors: config.allowCookieErrors ?? false, + filterNames: config.cookieNames ?? undefined, + inlineCookies: config.inlineCookies ?? undefined, + cookiePath: config.chromeCookiePath ?? undefined, + waitMs: config.cookieSyncWaitMs ?? 0, + }); + appliedCookies = cookieCount; + if (config.inlineCookies && cookieCount === 0) { + throw new Error("No inline cookies were applied; aborting before navigation."); + } + logger(cookieCount > 0 + ? config.inlineCookies + ? `Applied ${cookieCount} inline cookies` + : `Copied ${cookieCount} cookies from Chrome profile ${config.chromeProfile ?? "Default"}` + : config.inlineCookies + ? "No inline cookies applied; continuing without session reuse" + : "No Chrome cookies found; continuing without session reuse"); + } + else { + logger(manualLogin + ? "Skipping Chrome cookie sync (--browser-manual-login enabled); reuse the opened profile after signing in." + : "Skipping Chrome cookie sync (--browser-no-cookie-sync)"); + } + await clearStaleChatGptConversationCookies(Network, Target, logger, { + preserveConversationIds: [ + extractConversationIdFromUrl(config.resumeConversationUrl ?? ""), + extractConversationIdFromUrl(lastUrl ?? ""), + ], + }); + if (cookieSyncEnabled && !manualLogin && (appliedCookies ?? 0) === 0 && !config.inlineCookies) { + // Learned: if the profile has no ChatGPT cookies, browser mode will just bounce to login. + // Fail early so the user knows to sign in. + throw new BrowserAutomationError("No ChatGPT cookies were applied from your Chrome profile; cannot proceed in browser mode. " + + "Make sure ChatGPT is signed in in the selected profile, use --browser-manual-login / inline cookies, " + + "or retry with --browser-cookie-wait 5s if Keychain prompts are slow.", { + stage: "execute-browser", + details: { + profile: config.chromeProfile ?? "Default", + cookiePath: config.chromeCookiePath ?? null, + hint: "If macOS Keychain prompts or denies access, run oracle from a GUI session or use --copy/--render for the manual flow.", + }, + }); + } + if (config.browserTabRef) { + if (isResumingConversation) { + await raceWithDisconnect(navigateToChatGPT(Page, Runtime, config.resumeConversationUrl, logger)); + } + await raceWithDisconnect(ensureNotBlocked(Runtime, config.headless, logger)); + await raceWithDisconnect(ensureLoggedIn(Runtime, logger)); + await raceWithDisconnect(ensurePromptReady(Runtime, config.inputTimeoutMs, logger)); + if (isResumingConversation) { + await raceWithDisconnect(waitForResumedConversationHydration(Runtime, config.inputTimeoutMs, logger, { + requirePriorTurns: true, + expectedConversationUrl: config.resumeConversationUrl, + })); + } + } + else { + const baseUrl = CHATGPT_URL; + // First load the base ChatGPT homepage to satisfy potential interstitials, + // then hop to the requested URL if it differs. + await raceWithDisconnect(navigateToChatGPT(Page, Runtime, baseUrl, logger)); + await raceWithDisconnect(ensureNotBlocked(Runtime, config.headless, logger)); + // Learned: login checks must happen on the base domain before jumping into project URLs. + await raceWithDisconnect(waitForLogin({ + runtime: Runtime, + logger, + appliedCookies, + manualLogin, + timeoutMs: config.timeoutMs, + profileDir: userDataDir, + keepBrowser: effectiveKeepBrowser, + })); + if (isResumingConversation) { + await raceWithDisconnect(navigateToChatGPT(Page, Runtime, config.resumeConversationUrl, logger)); + await raceWithDisconnect(ensureNotBlocked(Runtime, config.headless, logger)); + await raceWithDisconnect(ensurePromptReady(Runtime, config.inputTimeoutMs, logger)); + } + else if (config.url !== baseUrl) { + await raceWithDisconnect(navigateToPromptReadyWithFallback(Page, Runtime, { + url: config.url, + fallbackUrl: baseUrl, + timeoutMs: config.inputTimeoutMs, + headless: config.headless, + logger, + })); + } + else { + await raceWithDisconnect(ensurePromptReady(Runtime, config.inputTimeoutMs, logger)); + } + if (isResumingConversation) { + // A resumed thread loads its prior history after navigation; ChatGPT can reset the + // composer mid-hydration and wipe a freshly-typed prompt. Wait for hydration to settle + // and re-confirm the composer before the prompt is typed/submitted below. Wrapped in + // raceWithDisconnect so a dropped client aborts immediately instead of polling to the + // hydration deadline. Shared with the remote path via the same helper. + await raceWithDisconnect(waitForResumedConversationHydration(Runtime, config.inputTimeoutMs, logger, { + requirePriorTurns: true, + expectedConversationUrl: config.resumeConversationUrl, + })); + } + } + const chatMode = await raceWithDisconnect(ensureChatMode(Runtime, Input, config.inputTimeoutMs, logger, { + resetWorkConversation: config.browserTabRef && !isResumingConversation + ? async () => { + await navigateToChatGPT(Page, Runtime, config.url, logger); + await ensureNotBlocked(Runtime, config.headless, logger); + await ensurePromptReady(Runtime, config.inputTimeoutMs, logger); + } + : undefined, + })); + if (chatMode === "switched") { + await raceWithDisconnect(ensurePromptReady(Runtime, config.inputTimeoutMs, logger)); + } + logger(`Prompt textarea ready (initial focus, ${promptText.length.toLocaleString()} chars queued)`); + const captureRuntimeSnapshot = async () => { + try { + if (client?.Target?.getTargetInfo) { + const info = await client.Target.getTargetInfo({}); + lastTargetId = info?.targetInfo?.targetId ?? lastTargetId; + lastUrl = info?.targetInfo?.url ?? lastUrl; + } + } + catch { + // ignore + } + try { + const { result } = await Runtime.evaluate({ + expression: "location.href", + returnByValue: true, + }); + if (typeof result?.value === "string") { + lastUrl = result.value; + } + } + catch { + // ignore + } + if (lastUrl) { + logger(`[browser] url = ${lastUrl}`); + } + if (chrome?.port) { + const suffix = lastTargetId ? ` target=${lastTargetId}` : ""; + if (lastUrl) { + logger(`[reattach] chrome port=${chrome.port} host=${chromeHost} url=${lastUrl}${suffix}`); + } + else { + logger(`[reattach] chrome port=${chrome.port} host=${chromeHost}${suffix}`); + } + await emitRuntimeHint(); + } + }; + const activeConversationUrlMonitor = createConversationUrlMonitor({ + readUrl: async () => { + const { result } = await Runtime.evaluate({ + expression: "location.href", + returnByValue: true, + }); + return typeof result?.value === "string" ? result.value : null; + }, + persistUrl: async (url) => { + lastUrl = url; + await emitRuntimeHint(); + }, + logger, + }); + conversationUrlMonitor = activeConversationUrlMonitor; + const updateConversationHint = conversationUrlMonitor.update; + await captureRuntimeSnapshot(); + const modelStrategy = config.modelStrategy ?? DEFAULT_MODEL_STRATEGY; + if (config.desiredModel && modelStrategy !== "ignore" && !isResumingConversation) { + modelSelectionEvidence = await raceWithDisconnect(withRetries(() => ensureModelSelection(Runtime, config.desiredModel, logger, modelStrategy), { + retries: 2, + delayMs: 300, + onRetry: (attempt, error) => { + if (options.verbose) { + logger(`[retry] Model picker attempt ${attempt + 1}: ${error instanceof Error ? error.message : error}`); + } + }, + })).catch((error) => { + // Login has already been verified above. Preserve the picker failure instead of + // misdiagnosing an unavailable model as missing cookies. + throw normalizeAuthenticatedModelSelectionError(error); + }); + await raceWithDisconnect(ensurePromptReady(Runtime, config.inputTimeoutMs, logger)); + logger(`Prompt textarea ready (after model switch, ${promptText.length.toLocaleString()} chars queued)`); + } + else if (modelStrategy === "ignore" || isResumingConversation) { + modelSelectionEvidence = buildSkippedModelSelectionEvidence(config.desiredModel, modelStrategy); + logger(isResumingConversation + ? "Model picker: skipped (resumed conversation)" + : "Model picker: skipped (strategy=ignore)"); + } + const deepResearch = config.researchMode === "deep"; + // Handle thinking time selection if specified. Deep Research owns its own effort flow. + const thinkingTime = config.thinkingTime; + if (thinkingTime && !deepResearch) { + const thinkingTargetModel = modelStrategy === "select" ? config.desiredModel : null; + await raceWithDisconnect(withRetries(() => ensureThinkingTime(Runtime, thinkingTime, logger, thinkingTargetModel), { + retries: 2, + delayMs: 300, + onRetry: (attempt, error) => { + if (options.verbose) { + logger(`[retry] Thinking time (${thinkingTime}) attempt ${attempt + 1}: ${error instanceof Error ? error.message : error}`); + } + }, + })); + } + const profileLockTimeoutMs = manualLogin ? (config.profileLockTimeoutMs ?? 0) : 0; + let profileLock = null; + const acquireProfileLockIfNeeded = async () => { + if (profileLockTimeoutMs <= 0) + return; + profileLock = await acquireProfileRunLock(userDataDir, { + timeoutMs: profileLockTimeoutMs, + logger, + }); + }; + const releaseProfileLockIfHeld = async () => { + if (!profileLock) + return; + const handle = profileLock; + profileLock = null; + await handle.release().catch(() => undefined); + }; + const submitOnce = async (prompt, submissionAttachments) => { + const baselineSnapshot = await readAssistantSnapshot(Runtime).catch(() => null); + const baselineAssistantText = typeof baselineSnapshot?.text === "string" ? baselineSnapshot.text.trim() : ""; + const attachmentNames = submissionAttachments.map((a) => path.basename(a.path)); + const attachmentExpectations = submissionAttachments.map((a) => ({ + name: path.basename(a.path), + generatedBundle: a.generatedBundle === true, + })); + let inputOnlyAttachments = false; + await raceWithDisconnect(clearPromptComposer(Runtime, logger)); + await raceWithDisconnect(ensurePromptReady(Runtime, config.inputTimeoutMs, logger)); + if (submissionAttachments.length > 0) { + if (!DOM) { + throw new Error("Chrome DOM domain unavailable while uploading attachments."); + } + await clearComposerAttachments(Runtime, 5_000, logger); + for (let attachmentIndex = 0; attachmentIndex < submissionAttachments.length; attachmentIndex += 1) { + const attachment = submissionAttachments[attachmentIndex]; + logger(`Uploading attachment: ${attachment.displayPath}`); + const uiConfirmed = await uploadAttachmentFile({ runtime: Runtime, dom: DOM, input: Input }, attachment, logger, { expectedCount: attachmentIndex + 1 }); + if (!uiConfirmed) { + inputOnlyAttachments = true; + } + await delay(500); + } + // Scale timeout based on number of files: base 45s + 20s per additional file. + const baseTimeout = config.inputTimeoutMs ?? 30_000; + const perFileTimeout = 20_000; + const waitBudget = Math.max(baseTimeout, 45_000) + (submissionAttachments.length - 1) * perFileTimeout; + const attachmentWaitBudget = Math.max(config.attachmentTimeoutMs ?? 0, waitBudget); + await waitForAttachmentCompletion(Runtime, attachmentWaitBudget, attachmentNames, logger); + logger("All attachments uploaded"); + } + if (deepResearch) { + await raceWithDisconnect(withRetries(() => activateDeepResearch(Runtime, Input, logger), { + retries: 2, + delayMs: 500, + onRetry: (attempt, error) => { + if (options.verbose) { + logger(`[retry] Deep Research activation attempt ${attempt + 1}: ${error instanceof Error ? error.message : error}`); + } + }, + })); + await raceWithDisconnect(ensurePromptReady(Runtime, config.inputTimeoutMs, logger)); + logger(`Prompt textarea ready (after Deep Research activation, ${prompt.length.toLocaleString()} chars queued)`); + } + let baselineTurns = await readConversationTurnCount(Runtime, logger); + // Learned: return baselineTurns so assistant polling can ignore earlier content. + const providerState = { + runtime: Runtime, + input: Input, + logger, + timeoutMs: config.timeoutMs, + inputTimeoutMs: config.inputTimeoutMs ?? undefined, + attachmentTimeoutMs: config.attachmentTimeoutMs ?? undefined, + baselineTurns: baselineTurns ?? undefined, + attachmentNames: attachmentExpectations, + onPromptSubmitted: markPromptSubmitted, + }; + const deepResearchTargetBaseline = deepResearch && client + ? await captureDeepResearchTargetBaseline(client, logger) + : undefined; + await runProviderSubmissionFlow(chatgptDomProvider, { + prompt, + evaluate: async () => undefined, + delay, + log: logger, + state: providerState, + }); + await markPromptSubmitted(); + const providerBaselineTurns = providerState.baselineTurns; + if (typeof providerBaselineTurns === "number" && Number.isFinite(providerBaselineTurns)) { + baselineTurns = providerBaselineTurns; + } + if (attachmentNames.length > 0) { + if (inputOnlyAttachments) { + logger("Attachment UI did not render before send; skipping user-turn attachment verification."); + } + else { + const verified = await waitForUserTurnAttachments(Runtime, attachmentNames, 20_000, logger, { + minTurnIndex: baselineTurns ?? undefined, + expectedPrompt: prompt, + expectedConversationId: lastUrl ? extractConversationIdFromUrl(lastUrl) : undefined, + }); + if (!verified) { + logger("Sent user message did not expose attachment UI; continuing after upload check."); + } + else { + logger("Verified attachments present on sent user message"); + } + } + } + return { + baselineTurns, + baselineAssistantText, + deepResearchTargetKeys: deepResearchTargetBaseline?.targetKeys, + deepResearchTargetBaselineCaptured: deepResearchTargetBaseline?.captured, + }; + }; + const reloadPromptComposer = async () => { + logger("[browser] Composer became unresponsive; reloading page and retrying once."); + await raceWithDisconnect(Page.reload({ ignoreCache: true })); + await raceWithDisconnect(ensurePromptReady(Runtime, config.inputTimeoutMs, logger)); + }; + let baselineTurns = null; + let baselineAssistantText = null; + let deepResearchTargetKeys = []; + let deepResearchTargetBaselineCaptured = false; + await acquireProfileLockIfNeeded(); + try { + const submission = await runSubmissionWithRecovery({ + prompt: promptText, + attachments, + fallbackSubmission, + submit: (submissionPrompt, submissionAttachments) => raceWithDisconnect(submitOnce(submissionPrompt, submissionAttachments)), + reloadPromptComposer, + prepareFallbackSubmission: async () => { + await raceWithDisconnect(clearPromptComposer(Runtime, logger)); + await raceWithDisconnect(ensurePromptReady(Runtime, config.inputTimeoutMs, logger)); + }, + logger, + }); + baselineTurns = submission.baselineTurns; + baselineAssistantText = submission.baselineAssistantText; + deepResearchTargetKeys = submission.deepResearchTargetKeys ?? []; + deepResearchTargetBaselineCaptured = submission.deepResearchTargetBaselineCaptured ?? false; + } + finally { + await releaseProfileLockIfHeld(); + } + const imageArtifactMinTurnIndex = baselineTurns; + if (deepResearch) { + await raceWithDisconnect(waitForResearchPlanAutoConfirm(Runtime, logger)); + const researchResult = await raceWithDisconnect(waitForDeepResearchCompletion(Runtime, logger, config.timeoutMs, baselineTurns, Page, client, { + ignoredTargetKeys: deepResearchTargetKeys, + targetBaselineCaptured: deepResearchTargetBaselineCaptured, + })); + await updateConversationHint("post-deep-research", 15_000).catch(() => false); + runStatus = "complete"; + const durationMs = Date.now() - startedAt; + const tokens = estimateTokenCount(researchResult.text); + const reportArtifact = await saveOptionalArtifact(() => saveDeepResearchReportArtifact({ + sessionId: options.sessionId, + reportMarkdown: researchResult.text, + conversationUrl: lastUrl, + logger, + }), logger); + const transcriptArtifact = await saveOptionalArtifact(() => saveBrowserTranscriptArtifact({ + sessionId: options.sessionId, + prompt: promptText, + answerMarkdown: researchResult.text, + conversationUrl: lastUrl, + artifacts: appendArtifacts(undefined, [reportArtifact]), + logger, + }), logger); + const savedArtifacts = appendArtifacts(undefined, [reportArtifact, transcriptArtifact]); + const archive = await maybeArchiveCompletedConversation({ + Runtime, + logger, + config, + conversationUrl: lastUrl, + followUpCount: 0, + requiredArtifactsSaved: Boolean(reportArtifact && transcriptArtifact), + }); + return { + answerText: researchResult.text, + answerMarkdown: researchResult.text, + answerHtml: researchResult.html, + artifacts: savedArtifacts, + archive, + modelSelection: modelSelectionEvidence, + tookMs: durationMs, + answerTokens: tokens, + answerChars: researchResult.text.length, + chromePid: chrome.pid, + chromePort: chrome.port, + chromeHost, + userDataDir, + chromeTargetId: lastTargetId, + tabUrl: lastUrl, + conversationId: lastUrl ? extractConversationIdFromUrl(lastUrl) : undefined, + promptSubmitted, + controllerPid: process.pid, + }; + } + // Helper to normalize text for echo detection (collapse whitespace, lowercase) + const normalizeForComparison = (text) => text.toLowerCase().replace(/\s+/g, " ").trim(); + const expectedConversationId = () => lastUrl ? extractConversationIdFromUrl(lastUrl) : undefined; + const waitForFreshAssistantResponse = async (baselineNormalized, timeoutMs) => { + const baselinePrefix = baselineNormalized.length >= 80 + ? baselineNormalized.slice(0, Math.min(200, baselineNormalized.length)) + : ""; + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + const snapshot = await readAssistantSnapshot(Runtime, baselineTurns ?? undefined, expectedConversationId()).catch(() => null); + const text = typeof snapshot?.text === "string" ? snapshot.text.trim() : ""; + if (text) { + const normalized = normalizeForComparison(text); + const isBaseline = normalized === baselineNormalized || + (baselinePrefix.length > 0 && normalized.startsWith(baselinePrefix)); + if (!isBaseline) { + return { + text, + html: snapshot?.html ?? undefined, + meta: { + turnId: snapshot?.turnId ?? undefined, + messageId: snapshot?.messageId ?? undefined, + }, + }; + } + } + await delay(350); + } + return null; + }; + const waitWithThinkingMonitor = async (operation) => { + stopThinkingMonitor?.(); + stopThinkingMonitor = startThinkingStatusMonitor(Runtime, logger, { + intervalMs: options.heartbeatIntervalMs, + }); + try { + return await operation(); + } + finally { + stopThinkingMonitor?.(); + stopThinkingMonitor = null; + } + }; + const recheckDelayMs = Math.max(0, config.assistantRecheckDelayMs ?? 0); + const recheckTimeoutMs = Math.max(0, config.assistantRecheckTimeoutMs ?? 0); + const attemptAssistantRecheck = async () => { + if (!recheckDelayMs) + return null; + logger(`[browser] Assistant response timed out; waiting ${formatElapsed(recheckDelayMs)} before rechecking conversation.`); + await raceWithDisconnect(delay(recheckDelayMs)); + await updateConversationHint("assistant-recheck", 15_000).catch(() => false); + await captureRuntimeSnapshot().catch(() => undefined); + const conversationUrl = await readConversationUrl(Runtime); + if (conversationUrl && isConversationUrl(conversationUrl)) { + logger(`[browser] Rechecking assistant response at ${conversationUrl}`); + await raceWithDisconnect(Page.navigate({ url: conversationUrl })); + await raceWithDisconnect(waitForResumedConversationHydration(Runtime, recheckTimeoutMs || 30_000, logger, { + requirePriorTurns: true, + requirePromptReady: false, + expectedConversationUrl: conversationUrl, + })); + } + // Validate session before attempting recheck - sessions can expire during the delay + const sessionValid = await validateChatGPTSession(Runtime, logger); + if (!sessionValid.valid) { + logger(`[browser] Session validation failed: ${sessionValid.reason}`); + // Update session metadata to indicate login is needed + await emitRuntimeHint(); + throw new BrowserAutomationError(`ChatGPT session expired during recheck: ${sessionValid.reason}. ` + + `Conversation URL: ${conversationUrl || lastUrl || "unknown"}. ` + + `Please sign in and retry.`, { + stage: "assistant-recheck", + details: { + conversationUrl: conversationUrl || lastUrl || null, + sessionStatus: "needs_login", + validationReason: sessionValid.reason, + }, + runtime: { + chromePid: chrome.pid, + chromePort: chrome.port, + chromeHost, + userDataDir, + chromeTargetId: lastTargetId, + tabUrl: lastUrl, + conversationId: lastUrl ? extractConversationIdFromUrl(lastUrl) : undefined, + promptSubmitted, + controllerPid: process.pid, + }, + }); + } + const timeoutMs = recheckTimeoutMs > 0 ? recheckTimeoutMs : config.timeoutMs; + const rechecked = await waitWithThinkingMonitor(() => raceWithDisconnect(waitForAssistantOrGeneratedImageResponse({ + Runtime, + waitForText: () => waitForAssistantResponseWithReload(Runtime, Page, timeoutMs, logger, baselineTurns ?? undefined, expectedConversationId()), + timeoutMs, + logger, + minTurnIndex: baselineTurns ?? undefined, + expectedConversationId: expectedConversationId(), + imageOutputRequested, + }))); + logger("Recovered assistant response after delayed recheck"); + return rechecked; + }; + const imageOutputRequested = Boolean(options.generateImagePath || + options.outputPath || + options.generateImage); + const captureAssistantTurn = async (turnPrompt, label) => { + let turnAnswer; + try { + await updateConversationHint("assistant-wait", 15_000).catch(() => false); + turnAnswer = await waitWithThinkingMonitor(() => raceWithDisconnect(waitForAssistantOrGeneratedImageResponse({ + Runtime, + waitForText: () => waitForAssistantResponseWithReload(Runtime, Page, config.timeoutMs, logger, baselineTurns ?? undefined, expectedConversationId()), + timeoutMs: config.timeoutMs, + logger, + minTurnIndex: baselineTurns ?? undefined, + expectedConversationId: expectedConversationId(), + imageOutputRequested, + }))); + } + catch (error) { + if (isAssistantResponseTimeoutError(error)) { + const rechecked = await attemptAssistantRecheckOrRethrow(attemptAssistantRecheck); + if (rechecked) { + turnAnswer = rechecked; + } + else { + await updateConversationHint("assistant-timeout", 15_000).catch(() => false); + await captureRuntimeSnapshot().catch(() => undefined); + const diagnostics = await captureBrowserDiagnostics(Runtime, logger, "assistant-timeout", { + Page, + sessionId: options.sessionId, + }).catch(() => undefined); + const runtime = { + chromePid: chrome.pid, + chromePort: chrome.port, + chromeHost, + userDataDir, + chromeTargetId: lastTargetId, + tabUrl: lastUrl, + conversationId: lastUrl ? extractConversationIdFromUrl(lastUrl) : undefined, + promptSubmitted, + controllerPid: process.pid, + }; + throw await createAssistantTimeoutError({ + Runtime, + logger, + runtime, + diagnostics, + cause: error, + }); + } + } + else { + throw error; + } + } + // Ensure we store the final conversation URL even if the UI updated late. + await updateConversationHint("post-response", 15_000); + const baselineNormalized = baselineAssistantText + ? normalizeForComparison(baselineAssistantText) + : ""; + if (baselineNormalized) { + const normalizedAnswer = normalizeForComparison(turnAnswer.text ?? ""); + const baselinePrefix = baselineNormalized.length >= 80 + ? baselineNormalized.slice(0, Math.min(200, baselineNormalized.length)) + : ""; + const isBaseline = normalizedAnswer === baselineNormalized || + (baselinePrefix.length > 0 && normalizedAnswer.startsWith(baselinePrefix)); + if (isBaseline) { + logger("Detected stale assistant response; waiting for new response..."); + const refreshed = await waitForFreshAssistantResponse(baselineNormalized, 15_000); + if (refreshed) { + turnAnswer = refreshed; + } + } + } + let turnAnswerText = turnAnswer.text; + const turnAnswerHtml = turnAnswer.html ?? ""; + const copiedMarkdown = await raceWithDisconnect(withRetries(async () => { + const attempt = await captureAssistantMarkdown(Runtime, turnAnswer.meta, logger); + if (!attempt) { + throw new Error("copy-missing"); + } + return attempt; + }, { + retries: 2, + delayMs: 350, + onRetry: (attempt, error) => { + if (options.verbose) { + logger(`[retry] Markdown capture attempt ${attempt + 1}: ${error instanceof Error ? error.message : error}`); + } + }, + })).catch(() => null); + let turnAnswerMarkdown = copiedMarkdown ?? turnAnswerText; + const promptEchoMatcher = buildPromptEchoMatcher(turnPrompt); + ({ answerText: turnAnswerText, answerMarkdown: turnAnswerMarkdown } = + await maybeRecoverLongAssistantResponse({ + runtime: Runtime, + baselineTurns, + answerText: turnAnswerText, + answerMarkdown: turnAnswerMarkdown, + logger, + allowMarkdownUpdate: !copiedMarkdown, + })); + // Final sanity check: ensure we didn't accidentally capture the user prompt instead of the assistant turn. + const finalSnapshot = await readAssistantSnapshot(Runtime, baselineTurns ?? undefined, expectedConversationId()).catch(() => null); + const finalText = typeof finalSnapshot?.text === "string" ? finalSnapshot.text.trim() : ""; + if (finalText && finalText !== turnPrompt.trim()) { + const trimmedMarkdown = turnAnswerMarkdown.trim(); + const finalIsEcho = promptEchoMatcher ? promptEchoMatcher.isEcho(finalText) : false; + const lengthDelta = finalText.length - trimmedMarkdown.length; + const missingCopy = !copiedMarkdown && lengthDelta >= 0; + const likelyTruncatedCopy = copiedMarkdown && + trimmedMarkdown.length > 0 && + lengthDelta >= Math.max(12, Math.floor(trimmedMarkdown.length * 0.75)); + if ((missingCopy || likelyTruncatedCopy) && !finalIsEcho && finalText !== trimmedMarkdown) { + logger("Refreshed assistant response via final DOM snapshot"); + turnAnswerText = finalText; + turnAnswerMarkdown = finalText; + } + } + // Detect prompt echo using normalized comparison (whitespace-insensitive). + const alignedEcho = alignPromptEchoPair(turnAnswerText, turnAnswerMarkdown, promptEchoMatcher, copiedMarkdown ? logger : undefined, { + text: "Aligned assistant response text to copied markdown after prompt echo", + markdown: "Aligned assistant markdown to response text after prompt echo", + }); + turnAnswerText = alignedEcho.answerText; + turnAnswerMarkdown = alignedEcho.answerMarkdown; + const isPromptEcho = alignedEcho.isEcho; + if (isPromptEcho) { + logger("Detected prompt echo in response; waiting for actual assistant response..."); + const deadline = Date.now() + 15_000; + let bestText = null; + let stableCount = 0; + while (Date.now() < deadline) { + const snapshot = await readAssistantSnapshot(Runtime, baselineTurns ?? undefined, expectedConversationId()).catch(() => null); + const text = typeof snapshot?.text === "string" ? snapshot.text.trim() : ""; + const isStillEcho = !text || Boolean(promptEchoMatcher?.isEcho(text)); + if (!isStillEcho) { + if (!bestText || text.length > bestText.length) { + bestText = text; + stableCount = 0; + } + else if (text === bestText) { + stableCount += 1; + } + if (stableCount >= 2) { + break; + } + } + await new Promise((resolve) => setTimeout(resolve, 300)); + } + if (bestText) { + logger("Recovered assistant response after detecting prompt echo"); + turnAnswerText = bestText; + turnAnswerMarkdown = bestText; + } + } + const minAnswerChars = 16; + if (turnAnswerText.trim().length > 0 && turnAnswerText.trim().length < minAnswerChars) { + const deadline = Date.now() + 12_000; + let bestText = turnAnswerText.trim(); + let stableCycles = 0; + while (Date.now() < deadline) { + const snapshot = await readAssistantSnapshot(Runtime, baselineTurns ?? undefined, expectedConversationId()).catch(() => null); + const text = typeof snapshot?.text === "string" ? snapshot.text.trim() : ""; + if (text && text.length > bestText.length) { + bestText = text; + stableCycles = 0; + } + else { + stableCycles += 1; + } + if (stableCycles >= 3 && bestText.length >= minAnswerChars) { + break; + } + await delay(400); + } + if (bestText.length > turnAnswerText.trim().length) { + logger("Refreshed short assistant response from latest DOM snapshot"); + turnAnswerText = bestText; + turnAnswerMarkdown = bestText; + } + } + return { + label, + answerText: turnAnswerText, + answerMarkdown: turnAnswerMarkdown, + answerHtml: turnAnswerHtml, + }; + }; + const turns = []; + const initialTurn = await captureAssistantTurn(promptText, "Initial response"); + turns.push(initialTurn); + answerText = initialTurn.answerText; + answerMarkdown = initialTurn.answerMarkdown; + answerHtml = initialTurn.answerHtml; + for (let index = 0; index < followUpPrompts.length; index += 1) { + const followUpPrompt = followUpPrompts[index]; + logger(`[browser] Sending follow-up ${index + 1}/${followUpPrompts.length}`); + await acquireProfileLockIfNeeded(); + try { + await raceWithDisconnect(clearPromptComposer(Runtime, logger)); + await raceWithDisconnect(ensurePromptReady(Runtime, config.inputTimeoutMs, logger)); + const submission = await runSubmissionWithRecovery({ + prompt: followUpPrompt, + attachments: [], + submit: (submissionPrompt, submissionAttachments) => raceWithDisconnect(submitOnce(submissionPrompt, submissionAttachments)), + reloadPromptComposer, + prepareFallbackSubmission: async () => { + await raceWithDisconnect(clearPromptComposer(Runtime, logger)); + await raceWithDisconnect(ensurePromptReady(Runtime, config.inputTimeoutMs, logger)); + }, + logger, + }); + baselineTurns = submission.baselineTurns; + baselineAssistantText = submission.baselineAssistantText; + } + finally { + await releaseProfileLockIfHeld(); + } + const turn = await captureAssistantTurn(followUpPrompt, `Follow-up ${index + 1}`); + turns.push({ ...turn, prompt: followUpPrompt }); + answerText = turn.answerText; + answerMarkdown = turn.answerMarkdown; + answerHtml = turn.answerHtml; + } + if (turns.length > 1) { + const formatted = formatBrowserTurnTranscript(turns); + answerText = formatted.answerText; + answerMarkdown = formatted.answerMarkdown; + answerHtml = ""; + } + if (connectionClosedUnexpectedly) { + // Bail out on mid-run disconnects so the session stays reattachable. + throw new Error("Chrome disconnected before completion"); + } + const imageArtifacts = await collectGeneratedImageArtifacts({ + Browser: client.Browser, + Client: client, + Page, + Runtime, + Network, + logger, + minTurnIndex: imageArtifactMinTurnIndex, + sessionId: options.sessionId, + generateImagePath: options.generateImagePath, + outputPath: options.outputPath, + answerText, + waitTimeoutMs: options.config?.timeoutMs, + checkBlockingUiWarning: () => throwChatGptUiWarningIfPresent({ + Runtime, + logger, + stage: "image-artifact-wait", + waitTarget: "generated image artifacts", + runtime: { + chromePid: chrome.pid, + chromePort: chrome.port, + chromeHost, + userDataDir, + chromeTargetId: lastTargetId, + tabUrl: lastUrl, + conversationId: lastUrl ? extractConversationIdFromUrl(lastUrl) : undefined, + promptSubmitted, + controllerPid: process.pid, + }, + }), + }); + answerText = imageArtifacts.answerText || answerText; + if (imageArtifacts.markdownSuffix) { + answerMarkdown += imageArtifacts.markdownSuffix; + } + const fileArtifacts = await collectChatGptFileArtifacts({ + Browser: client.Browser, + Client: client, + Page, + Runtime, + Network, + answerText: [answerText, answerMarkdown, answerHtml].filter(Boolean).join("\n"), + logger, + minTurnIndex: imageArtifactMinTurnIndex, + sessionId: options.sessionId, + }); + const savedImageArtifacts = appendArtifacts(undefined, imageArtifacts.savedImages); + const savedBrowserArtifacts = appendArtifacts(savedImageArtifacts, fileArtifacts.savedFiles); + const transcriptArtifact = await saveOptionalArtifact(() => saveBrowserTranscriptArtifact({ + sessionId: options.sessionId, + prompt: promptText, + answerMarkdown, + conversationUrl: lastUrl, + artifacts: savedBrowserArtifacts, + logger, + }), logger); + const savedArtifacts = appendArtifacts(savedBrowserArtifacts, [transcriptArtifact]); + const archive = await maybeArchiveCompletedConversation({ + Runtime, + logger, + config, + conversationUrl: lastUrl, + followUpCount: followUpPrompts.length, + requiredArtifactsSaved: Boolean(transcriptArtifact) && + imageArtifacts.savedImages.length === imageArtifacts.imageCount && + fileArtifacts.savedFiles.length === fileArtifacts.fileCount, + }); + runStatus = "complete"; + const durationMs = Date.now() - startedAt; + const answerChars = answerText.length; + const answerTokens = estimateTokenCount(answerMarkdown); + return { + answerText, + answerMarkdown, + answerHtml: answerHtml.length > 0 ? answerHtml : undefined, + artifacts: savedArtifacts, + generatedImages: imageArtifacts.generatedImages, + savedImages: imageArtifacts.savedImages, + downloadableFiles: fileArtifacts.files, + savedFiles: fileArtifacts.savedFiles, + archive, + modelSelection: modelSelectionEvidence, + tookMs: durationMs, + answerTokens, + answerChars, + chromePid: chrome.pid, + chromePort: chrome.port, + chromeHost, + userDataDir, + chromeTargetId: lastTargetId, + tabUrl: lastUrl, + conversationId: lastUrl ? extractConversationIdFromUrl(lastUrl) : undefined, + promptSubmitted, + controllerPid: process.pid, + }; + } + catch (error) { + const normalizedError = error instanceof Error ? error : new Error(String(error)); + const socketClosed = connectionClosedUnexpectedly || isWebSocketClosureError(normalizedError); + connectionClosedUnexpectedly = connectionClosedUnexpectedly || socketClosed; + const preservedErrorKind = classifyPreservedBrowserError(normalizedError, config.headless); + if (preservedErrorKind === "cloudflare-challenge") { + if (usingCopiedProfile) { + logger("Cloudflare challenge detected; closing Chrome and removing the copied profile because copy-profile runs cannot be retained."); + throw new BrowserAutomationError("Cloudflare challenge detected. Copy-profile runs cannot be retained; complete the check in the source Chrome profile, then rerun.", { stage: "cloudflare-challenge", reattachable: false }, normalizedError); + } + preserveBrowserOnError = true; + const runtime = { + chromePid: chrome.pid, + chromePort: chrome.port, + chromeHost, + userDataDir, + chromeTargetId: lastTargetId, + tabUrl: lastUrl, + promptSubmitted, + controllerPid: process.pid, + }; + const reuseProfileHint = `oracle --engine browser --browser-manual-login ` + + `--browser-manual-login-profile-dir ${JSON.stringify(userDataDir)}`; + await emitRuntimeHint(); + logger("Cloudflare challenge detected; leaving browser open so you can complete the check."); + logger(`Reuse this browser profile with: ${reuseProfileHint}`); + throw new BrowserAutomationError("Cloudflare challenge detected. Complete the “Just a moment…” check in the open browser, then rerun.", { + stage: "cloudflare-challenge", + runtime, + reuseProfileHint, + }, normalizedError); + } + if (preservedErrorKind === "reattachable-capture") { + if (usingCopiedProfile) { + logger("Assistant capture incomplete; closing Chrome and removing the copied profile because copy-profile runs cannot be reattached."); + const details = normalizedError instanceof BrowserAutomationError + ? { ...normalizedError.details, runtime: undefined, reattachable: false } + : { stage: "assistant-recheck", reattachable: false }; + throw new BrowserAutomationError(normalizedError.message, details, normalizedError); + } + preserveBrowserOnError = true; + await emitRuntimeHint(); + logger("Assistant capture incomplete; leaving browser open for reattach."); + throw normalizedError; + } + if (!socketClosed) { + logger(`Failed to complete ChatGPT run: ${normalizedError.message}`); + if ((config.debug || process.env.CHATGPT_DEVTOOLS_TRACE === "1") && normalizedError.stack) { + logger(normalizedError.stack); + } + throw normalizedError; + } + if ((config.debug || process.env.CHATGPT_DEVTOOLS_TRACE === "1") && normalizedError.stack) { + logger(`Chrome connection lost before completion: ${normalizedError.message}`); + logger(normalizedError.stack); + } + await emitRuntimeHint(); + if (normalizedError instanceof BrowserAutomationError && + normalizedError.details?.stage === "connection-lost") { + throw normalizedError; + } + const liveness = await probeChromeTargetLiveness({ + host: chromeHost, + port: chrome.port, + targetId: lastTargetId ?? isolatedTargetId, + }); + const recoverable = isRecoverableChromeDisconnect(liveness); + throw new BrowserAutomationError(connectionLostUserMessage({ recoverable }), { + stage: "connection-lost", + recoverableDisconnect: recoverable, + disconnectCause: recoverable ? "cdp-client-disconnect" : "chrome-closed", + runtime: { + chromePid: chrome.pid, + chromePort: chrome.port, + chromeHost, + userDataDir, + chromeTargetId: lastTargetId, + tabUrl: liveness.matchedUrl ?? lastUrl, + conversationId: (liveness.matchedUrl ?? lastUrl) + ? extractConversationIdFromUrl(liveness.matchedUrl ?? lastUrl ?? "") + : undefined, + promptSubmitted, + controllerPid: process.pid, + }, + }, normalizedError); + } + finally { + await conversationUrlMonitor?.stop(); + try { + if (!connectionClosedUnexpectedly) { + await client?.close(); + } + } + catch { + // ignore + } + // Close the isolated tab once the response has been fully captured to prevent + // tab accumulation across repeated runs. Keep the tab open on incomplete runs + // so reattach can recover the response. + const shouldCloseOwnedRunTarget = shouldCloseOwnedRunTargetAfterRun({ + runStatus, + ownsTarget, + keepBrowser: effectiveKeepBrowser, + closeOwnedTabOnComplete: options.closeOwnedTabOnComplete, + }); + let keepBrowserOpen = shouldKeepLocalBrowserOpen({ + effectiveKeepBrowser, + preserveBrowserOnError, + usingCopiedProfile, + }); + let cleanupProfileLock = null; + let terminatedRecordedChrome = false; + let otherActiveBrowserTabLeases = null; + const hasOtherActiveLeases = async () => { + if (!manualLogin || !tabLease) { + return false; + } + if (otherActiveBrowserTabLeases === null) { + otherActiveBrowserTabLeases = await hasOtherActiveBrowserTabLeases(userDataDir, tabLease.id); + } + return otherActiveBrowserTabLeases; + }; + if (!keepBrowserOpen && manualLogin && tabLease) { + const cleanupLockTimeoutMs = Math.max(0, config.profileLockTimeoutMs ?? 0); + if (cleanupLockTimeoutMs > 0) { + cleanupProfileLock = await acquireProfileRunLock(userDataDir, { + timeoutMs: cleanupLockTimeoutMs, + logger, + sessionId: options.sessionId, + }).catch(() => null); + } + keepBrowserOpen = await hasOtherActiveLeases().catch(() => false); + if (keepBrowserOpen) { + logger("[browser] Other ChatGPT tab leases still active; leaving shared Chrome running."); + } + else if (reusedChrome && !connectionClosedUnexpectedly) { + terminatedRecordedChrome = await terminateRecordedChromeForProfile(userDataDir, logger).catch(() => false); + } + } + const closeOwnedRunTarget = async () => { + if (!shouldCloseOwnedRunTarget || !isolatedTargetId || !chrome?.port) { + return; + } + const safeToClose = !effectiveKeepBrowser || + Boolean(await ensureChromePageTargetAfterClose(chrome.port, isolatedTargetId, logger, chromeHost)); + if (!safeToClose) { + logger(`[browser] Leaving completed browser tab open because Chrome has no replacement page target.`); + return; + } + const closeConfirmed = await closeTab(chrome.port, isolatedTargetId, logger, chromeHost); + if (!closeConfirmed && effectiveKeepBrowser) { + const replacementTargetId = await createChromePageTarget(chrome.port, logger, chromeHost); + if (!replacementTargetId) { + logger(`[browser] Chrome page retention could not be verified after closing ${isolatedTargetId}.`); + } + } + }; + const cleanupBlankTabs = async () => { + if (!shouldCleanupBlankTabsAfterLastLease({ + runStatus, + ownsTarget, + connectionClosedUnexpectedly, + manualLogin, + keepBrowser: effectiveKeepBrowser, + chromePort: chrome?.port, + }) || + !chrome?.port) { + return; + } + await closeBlankChromeTabs(chrome.port, logger, chromeHost, { + excludeTargetIds: [isolatedTargetId, lastTargetId], + preserveOneBlank: true, + }); + }; + if (tabLease) { + const handle = tabLease; + tabLease = null; + const onRelease = async ({ isLastLease }) => { + await closeOwnedRunTarget(); + if (isLastLease) { + await cleanupBlankTabs(); + } + }; + await handle.release({ onRelease }).catch(() => undefined); + } + else { + await closeOwnedRunTarget(); + await cleanupBlankTabs(); + } + removeDialogHandler?.(); + removeTerminationHooks?.(); + if (!keepBrowserOpen) { + if (!connectionClosedUnexpectedly) { + try { + if (!terminatedRecordedChrome) { + await chrome.kill(); + } + } + catch { + // ignore kill failures + } + } + if (manualLogin) { + const shouldCleanup = await shouldCleanupManualLoginProfileState(userDataDir, logger.verbose ? logger : undefined, { + connectionClosedUnexpectedly, + host: chromeHost, + }); + if (shouldCleanup) { + // Preserve the persistent manual-login profile, but clear stale reattach hints. + await cleanupStaleProfileState(userDataDir, logger, { lockRemovalMode: "never" }).catch(() => undefined); + } + } + else { + await rm(userDataDir, { recursive: true, force: true }).catch(() => undefined); + } + if (!connectionClosedUnexpectedly) { + const totalSeconds = (Date.now() - startedAt) / 1000; + logger(`Cleanup ${runStatus} • ${totalSeconds.toFixed(1)}s total`); + } + } + else { + detachKeptChromeProcess(chrome); + if (!connectionClosedUnexpectedly) { + logger(`Chrome left running on port ${chrome.port} with profile ${userDataDir}`); + } + } + if (cleanupProfileLock) { + const handle = cleanupProfileLock; + cleanupProfileLock = null; + await handle.release().catch(() => undefined); + } + } +} +const DEFAULT_DEBUG_PORT = 9222; +async function pickAvailableDebugPort(preferredPort, logger) { + const start = Number.isFinite(preferredPort) && preferredPort > 0 ? preferredPort : DEFAULT_DEBUG_PORT; + for (let offset = 0; offset < 10; offset++) { + const candidate = start + offset; + if (await isPortAvailable(candidate)) { + return candidate; + } + } + const fallback = await findEphemeralPort(); + logger(`DevTools ports ${start}-${start + 9} are occupied; falling back to ${fallback}.`); + return fallback; +} +async function isPortAvailable(port) { + return new Promise((resolve) => { + const server = net.createServer(); + server.once("error", () => resolve(false)); + server.once("listening", () => { + server.close(() => resolve(true)); + }); + server.listen(port, "127.0.0.1"); + }); +} +async function findEphemeralPort() { + return new Promise((resolve, reject) => { + const server = net.createServer(); + server.once("error", (error) => { + server.close(); + reject(error); + }); + server.listen(0, "127.0.0.1", () => { + const address = server.address(); + if (address && typeof address === "object") { + const port = address.port; + server.close(() => resolve(port)); + } + else { + server.close(() => reject(new Error("Failed to acquire ephemeral port"))); + } + }); + }); +} +async function waitForLogin({ runtime, logger, appliedCookies, manualLogin, timeoutMs, profileDir, keepBrowser, }) { + if (!manualLogin) { + await ensureLoggedIn(runtime, logger, { appliedCookies }); + return; + } + const waitMs = resolveManualLoginWaitMs(timeoutMs, Boolean(keepBrowser)); + const deadline = Date.now() + waitMs; + let lastNotice = 0; + while (Date.now() < deadline) { + try { + await ensureLoggedIn(runtime, logger, { appliedCookies }); + return; + } + catch (error) { + const message = error instanceof Error ? error.message : String(error); + const loginDetected = message?.toLowerCase().includes("login button"); + const sessionMissing = message?.toLowerCase().includes("session not detected"); + if (!loginDetected && !sessionMissing) { + throw error; + } + const now = Date.now(); + if (now - lastNotice > 5000) { + logger("Manual login mode: please sign into chatgpt.com in the opened Chrome window; waiting for session to appear..."); + lastNotice = now; + } + await delay(1000); + } + } + const setupCommand = formatManualLoginSetupCommand(profileDir ?? defaultManualLoginProfileDir()); + throw new Error("Manual login mode timed out waiting for ChatGPT session. " + + `Browser mode is using Oracle's private Chrome profile at ${profileDir ?? "(default profile)"}, not your normal Chrome profile. ` + + `Run first-time setup, sign in there, then retry: ${setupCommand}`); +} +async function maybeRecoverLongAssistantResponse({ runtime, baselineTurns, answerText, answerMarkdown, logger, allowMarkdownUpdate, }) { + // Learned: long streaming responses can still be rendering after initial capture. + // Add a brief delay and re-poll to catch any additional content (#71). + const capturedLength = answerText.trim().length; + if (capturedLength <= 500) { + return { answerText, answerMarkdown }; + } + await delay(1500); + let bestLength = capturedLength; + let bestText = answerText; + for (let i = 0; i < 5; i++) { + const laterSnapshot = await readAssistantSnapshot(runtime, baselineTurns ?? undefined).catch(() => null); + const laterText = typeof laterSnapshot?.text === "string" ? laterSnapshot.text.trim() : ""; + if (laterText.length > bestLength) { + bestLength = laterText.length; + bestText = laterText; + await delay(800); // More content appeared, keep waiting + } + else { + break; // Stable, stop polling + } + } + if (bestLength > capturedLength) { + logger(`Recovered ${bestLength - capturedLength} additional chars via delayed re-read`); + return { + answerText: bestText, + answerMarkdown: allowMarkdownUpdate ? bestText : answerMarkdown, + }; + } + return { answerText, answerMarkdown }; +} +async function _assertNavigatedToHttp(runtime, _logger, timeoutMs = 10_000) { + const deadline = Date.now() + timeoutMs; + let lastUrl = ""; + while (Date.now() < deadline) { + const { result } = await runtime.evaluate({ + expression: 'typeof location === "object" && location.href ? location.href : ""', + returnByValue: true, + }); + const url = typeof result?.value === "string" ? result.value : ""; + lastUrl = url; + if (/^https?:\/\//i.test(url)) { + return url; + } + await delay(250); + } + throw new BrowserAutomationError("ChatGPT session not detected; page never left new tab.", { + stage: "execute-browser", + details: { url: lastUrl || "(empty)" }, + }); +} +function detachKeptChromeProcess(chrome) { + try { + chrome.process?.unref(); + } + catch { + // Best-effort only; cleanup should not mask the original browser result. + } +} +export async function acquireManualLoginChromeForRun(userDataDir, config, logger, sessionId, deps = {}) { + const maybeReuse = deps.maybeReuse ?? maybeReuseRunningChrome; + const launch = deps.launch ?? launchChrome; + const lockTimeoutMs = Math.max(0, config.profileLockTimeoutMs ?? 0); + let launchLock = null; + if (lockTimeoutMs > 0) { + launchLock = await acquireProfileRunLock(userDataDir, { + timeoutMs: lockTimeoutMs, + logger, + sessionId, + }); + } + try { + const reusedChrome = await maybeReuse(userDataDir, logger, { + waitForPortMs: config.reuseChromeWaitMs, + }); + const chrome = reusedChrome ?? + (await launch({ + ...config, + remoteChrome: config.remoteChrome, + }, userDataDir, logger)); + // Persist while the launch lock is still held so parallel callers reuse + // this Chrome instead of racing to start another one on the same profile. + if (chrome.port) { + await writeDevToolsActivePort(userDataDir, chrome.port); + if (!reusedChrome && chrome.pid) { + await writeChromePid(userDataDir, chrome.pid); + } + } + return { chrome, reusedChrome }; + } + finally { + if (launchLock) { + await launchLock.release().catch(() => undefined); + } + } +} +async function maybeReuseRunningChrome(userDataDir, logger, options = {}) { + const waitForPortMs = Math.max(0, options.waitForPortMs ?? 0); + let port = await readDevToolsPort(userDataDir); + if (!port && waitForPortMs > 0) { + const deadline = Date.now() + waitForPortMs; + logger(`Waiting up to ${formatElapsed(waitForPortMs)} for shared Chrome to appear...`); + while (!port && Date.now() < deadline) { + await delay(250); + port = await readDevToolsPort(userDataDir); + } + } + let pid = await readChromePid(userDataDir); + if (!port) { + const discovered = await findRunningChromeDebugTargetForProfile(userDataDir); + if (!discovered) { + if (pid) { + logger(`No reachable Chrome DevTools target found for ${userDataDir}; clearing stale profile state before launching new Chrome.`); + await cleanupStaleProfileState(userDataDir, logger, { + lockRemovalMode: "if_oracle_pid_dead", + }); + } + return null; + } + const discoveredProbe = await (options.probe ?? verifyDevToolsReachable)({ + port: discovered.port, + }); + if (!discoveredProbe.ok) { + logger(`Discovered Chrome for ${userDataDir} on port ${discovered.port} but it was unreachable (${discoveredProbe.error}); launching new Chrome.`); + await cleanupStaleProfileState(userDataDir, logger, { + lockRemovalMode: "if_oracle_pid_dead", + }); + return null; + } + await writeDevToolsActivePort(userDataDir, discovered.port); + await writeChromePid(userDataDir, discovered.pid); + port = discovered.port; + pid = discovered.pid; + logger(`Discovered running Chrome for ${userDataDir}; reusing (DevTools port ${port}, pid ${pid})`); + return { + port, + pid, + kill: async () => { }, + process: undefined, + }; + } + const probe = await (options.probe ?? verifyDevToolsReachable)({ port }); + if (!probe.ok) { + logger(`DevToolsActivePort found for ${userDataDir} but unreachable (${probe.error}); launching new Chrome.`); + // Safe cleanup: remove stale DevToolsActivePort; only remove lock files if this was an Oracle-owned pid that died. + await cleanupStaleProfileState(userDataDir, logger, { lockRemovalMode: "if_oracle_pid_dead" }); + return null; + } + logger(`Found running Chrome for ${userDataDir}; reusing (DevTools port ${port}${pid ? `, pid ${pid}` : ""})`); + return { + port, + pid: pid ?? undefined, + kill: async () => { }, + process: undefined, + }; +} +async function runRemoteBrowserMode(promptText, attachments, config, logger, options) { + const remoteChromeConfig = config.remoteChrome; + if (!remoteChromeConfig) { + throw new Error("Remote Chrome configuration missing. Pass --remote-chrome to use this mode."); + } + const { host, port } = remoteChromeConfig; + logger(`Connecting to remote Chrome at ${host}:${port}`); + let client = null; + let remoteTargetId = null; + let tabLease = null; + let lastUrl; + let promptSubmitted = false; + let modelSelectionEvidence; + let attachedExistingTab = false; + let ownsTarget = true; + let conversationUrlMonitor = null; + const runtimeHintCb = options.runtimeHintCb; + const emitRuntimeHint = async () => { + if (!runtimeHintCb) + return; + try { + await runtimeHintCb({ + chromePort: port, + chromeHost: host, + chromeBrowserWSEndpoint: browserWSEndpoint, + chromeProfileRoot, + chromeTargetId: remoteTargetId ?? undefined, + tabUrl: lastUrl, + conversationId: lastUrl ? extractConversationIdFromUrl(lastUrl) : undefined, + promptSubmitted, + controllerPid: process.pid, + }, modelSelectionEvidence); + await tabLease?.update({ + chromeHost: host, + chromePort: port, + chromeTargetId: remoteTargetId ?? undefined, + tabUrl: lastUrl, + }); + } + catch (error) { + const message = error instanceof Error ? error.message : String(error); + logger(`Failed to persist runtime hint: ${message}`); + } + }; + const markPromptSubmitted = async () => { + if (promptSubmitted) { + return; + } + promptSubmitted = true; + await emitRuntimeHint(); + void conversationUrlMonitor?.schedule("post-submit", config.timeoutMs ?? 120_000); + }; + const startedAt = Date.now(); + let answerText = ""; + let answerMarkdown = ""; + let answerHtml = ""; + let connectionClosedUnexpectedly = false; + let runStatus = "attempted"; + let stopThinkingMonitor = null; + let removeDialogHandler = null; + let connection = null; + const browserWSEndpoint = config.remoteChromeBrowserWSEndpoint ?? undefined; + const chromeProfileRoot = config.remoteChromeProfileRoot ?? undefined; + try { + const remoteLeaseProfileDir = config.browserTabRef + ? null + : resolveRemoteTabLeaseProfileDir(config); + if (remoteLeaseProfileDir) { + await mkdir(remoteLeaseProfileDir, { recursive: true }); + tabLease = await acquireBrowserTabLease(remoteLeaseProfileDir, { + maxConcurrentTabs: config.maxConcurrentTabs, + timeoutMs: config.timeoutMs, + logger, + sessionId: options.sessionId, + chromeHost: host, + chromePort: port, + }); + } + if (config.browserTabRef) { + const attached = await connectToExistingChatGptTab({ + host, + port, + ref: config.browserTabRef, + }); + client = attached.client; + remoteTargetId = attached.targetId ?? null; + lastUrl = attached.tab.url || lastUrl; + attachedExistingTab = true; + ownsTarget = false; + logger(`Attached to existing remote ChatGPT tab ${attached.targetId}${attached.tab.url ? ` (${attached.tab.url})` : ""}`); + } + else { + connection = await connectToRemoteChrome(host, port, logger, "about:blank", browserWSEndpoint, { + approvalWaitMs: config.attachRunning && browserWSEndpoint ? 20_000 : undefined, + }); + client = connection.client; + remoteTargetId = connection.targetId ?? null; + ownsTarget = true; + } + if (tabLease && remoteTargetId) { + await tabLease.update({ + chromeHost: host, + chromePort: port, + chromeTargetId: remoteTargetId, + }); + } + await emitRuntimeHint(); + const markConnectionLost = () => { + connectionClosedUnexpectedly = true; + }; + client.on("disconnect", markConnectionLost); + const { Network, Page, Runtime, Input, DOM, Target } = client; + const domainEnablers = [Network.enable({}), Page.enable(), Runtime.enable()]; + if (DOM && typeof DOM.enable === "function") { + domainEnablers.push(DOM.enable()); + } + await Promise.all(domainEnablers); + removeDialogHandler = installJavaScriptDialogAutoDismissal(Page, logger); + await enableFocusEmulation(client, logger, "remote target"); + const activeConversationUrlMonitor = createConversationUrlMonitor({ + readUrl: async () => { + const { result } = await Runtime.evaluate({ + expression: "location.href", + returnByValue: true, + }); + return typeof result?.value === "string" ? result.value : null; + }, + persistUrl: async (url) => { + lastUrl = url; + await emitRuntimeHint(); + }, + logger, + }); + conversationUrlMonitor = activeConversationUrlMonitor; + // Skip cookie sync for remote Chrome - it already has cookies + logger("Skipping cookie sync for remote Chrome (using existing session)"); + await clearStaleChatGptConversationCookies(Network, Target, logger, { + preserveConversationIds: [ + extractConversationIdFromUrl(config.resumeConversationUrl ?? ""), + extractConversationIdFromUrl(lastUrl ?? ""), + ], + }); + if (config.resumeConversationUrl) { + await navigateToChatGPT(Page, Runtime, config.resumeConversationUrl, logger); + } + else if (!attachedExistingTab) { + await navigateToChatGPT(Page, Runtime, config.url, logger); + } + await ensureNotBlocked(Runtime, config.headless, logger); + await ensureLoggedIn(Runtime, logger, { remoteSession: true }); + await ensurePromptReady(Runtime, config.inputTimeoutMs, logger); + if (config.resumeConversationUrl) { + await waitForResumedConversationHydration(Runtime, config.inputTimeoutMs, logger, { + requirePriorTurns: true, + expectedConversationUrl: config.resumeConversationUrl, + }); + } + const chatMode = await ensureChatMode(Runtime, Input, config.inputTimeoutMs, logger, { + resetWorkConversation: attachedExistingTab && !config.resumeConversationUrl + ? async () => { + await navigateToChatGPT(Page, Runtime, config.url, logger); + await ensureNotBlocked(Runtime, config.headless, logger); + await ensurePromptReady(Runtime, config.inputTimeoutMs, logger); + } + : undefined, + }); + if (chatMode === "switched") { + await ensurePromptReady(Runtime, config.inputTimeoutMs, logger); + } + logger(`Prompt textarea ready (initial focus, ${promptText.length.toLocaleString()} chars queued)`); + try { + const { result } = await Runtime.evaluate({ + expression: "location.href", + returnByValue: true, + }); + if (typeof result?.value === "string") { + lastUrl = result.value; + } + await emitRuntimeHint(); + } + catch { + // ignore + } + const modelStrategy = config.modelStrategy ?? DEFAULT_MODEL_STRATEGY; + if (config.desiredModel && modelStrategy !== "ignore" && !config.resumeConversationUrl) { + modelSelectionEvidence = await withRetries(() => ensureModelSelection(Runtime, config.desiredModel, logger, modelStrategy), { + retries: 2, + delayMs: 300, + onRetry: (attempt, error) => { + if (options.verbose) { + logger(`[retry] Model picker attempt ${attempt + 1}: ${error instanceof Error ? error.message : error}`); + } + }, + }); + await ensurePromptReady(Runtime, config.inputTimeoutMs, logger); + logger(`Prompt textarea ready (after model switch, ${promptText.length.toLocaleString()} chars queued)`); + } + else if (modelStrategy === "ignore" || config.resumeConversationUrl) { + modelSelectionEvidence = buildSkippedModelSelectionEvidence(config.desiredModel, modelStrategy); + logger(config.resumeConversationUrl + ? "Model picker: skipped (resumed conversation)" + : "Model picker: skipped (strategy=ignore)"); + } + const deepResearch = config.researchMode === "deep"; + // Handle thinking time selection if specified. Deep Research owns its own effort flow. + const thinkingTime = config.thinkingTime; + if (thinkingTime && !deepResearch) { + const thinkingTargetModel = modelStrategy === "select" ? config.desiredModel : null; + await withRetries(() => ensureThinkingTime(Runtime, thinkingTime, logger, thinkingTargetModel), { + retries: 2, + delayMs: 300, + onRetry: (attempt, error) => { + if (options.verbose) { + logger(`[retry] Thinking time (${thinkingTime}) attempt ${attempt + 1}: ${error instanceof Error ? error.message : error}`); + } + }, + }); + } + const submitOnce = async (prompt, submissionAttachments) => { + const baselineSnapshot = await readAssistantSnapshot(Runtime).catch(() => null); + const baselineAssistantText = typeof baselineSnapshot?.text === "string" ? baselineSnapshot.text.trim() : ""; + const attachmentNames = submissionAttachments.map((a) => path.basename(a.path)); + const attachmentExpectations = submissionAttachments.map((a) => ({ + name: path.basename(a.path), + generatedBundle: a.generatedBundle === true, + })); + await clearPromptComposer(Runtime, logger); + await ensurePromptReady(Runtime, config.inputTimeoutMs, logger); + if (submissionAttachments.length > 0) { + if (!DOM) { + throw new Error("Chrome DOM domain unavailable while uploading attachments."); + } + await clearComposerAttachments(Runtime, 5_000, logger); + // Use remote file transfer for remote Chrome (reads local files and injects via CDP) + for (const attachment of submissionAttachments) { + logger(`Uploading attachment: ${attachment.displayPath}`); + await uploadAttachmentViaDataTransfer({ runtime: Runtime, dom: DOM }, attachment, logger); + await delay(500); + } + // Scale timeout based on number of files: base 30s + 15s per additional file + const baseTimeout = config.inputTimeoutMs ?? 30_000; + const perFileTimeout = 15_000; + const waitBudget = Math.max(baseTimeout, 30_000) + (submissionAttachments.length - 1) * perFileTimeout; + const attachmentWaitBudget = Math.max(config.attachmentTimeoutMs ?? 0, waitBudget); + await waitForAttachmentCompletion(Runtime, attachmentWaitBudget, attachmentNames, logger); + logger("All attachments uploaded"); + } + if (deepResearch) { + await withRetries(() => activateDeepResearch(Runtime, Input, logger), { + retries: 2, + delayMs: 500, + onRetry: (attempt, error) => { + if (options.verbose) { + logger(`[retry] Deep Research activation attempt ${attempt + 1}: ${error instanceof Error ? error.message : error}`); + } + }, + }); + await ensurePromptReady(Runtime, config.inputTimeoutMs, logger); + logger(`Prompt textarea ready (after Deep Research activation, ${prompt.length.toLocaleString()} chars queued)`); + } + let baselineTurns = await readConversationTurnCount(Runtime, logger); + const providerState = { + runtime: Runtime, + input: Input, + logger, + timeoutMs: config.timeoutMs, + inputTimeoutMs: config.inputTimeoutMs ?? undefined, + attachmentTimeoutMs: config.attachmentTimeoutMs ?? undefined, + baselineTurns: baselineTurns ?? undefined, + attachmentNames: attachmentExpectations, + onPromptSubmitted: markPromptSubmitted, + }; + const deepResearchTargetBaseline = deepResearch && client + ? await captureDeepResearchTargetBaseline(client, logger) + : undefined; + await runProviderSubmissionFlow(chatgptDomProvider, { + prompt, + evaluate: async () => undefined, + delay, + log: logger, + state: providerState, + }); + await markPromptSubmitted(); + const providerBaselineTurns = providerState.baselineTurns; + if (typeof providerBaselineTurns === "number" && Number.isFinite(providerBaselineTurns)) { + baselineTurns = providerBaselineTurns; + } + return { + baselineTurns, + baselineAssistantText, + deepResearchTargetKeys: deepResearchTargetBaseline?.targetKeys, + deepResearchTargetBaselineCaptured: deepResearchTargetBaseline?.captured, + }; + }; + const reloadPromptComposer = async () => { + logger("[browser] Composer became unresponsive; reloading page and retrying once."); + await Page.reload({ ignoreCache: true }); + await ensurePromptReady(Runtime, config.inputTimeoutMs, logger); + }; + let baselineTurns = null; + let baselineAssistantText = null; + let deepResearchTargetKeys = []; + let deepResearchTargetBaselineCaptured = false; + const submission = await runSubmissionWithRecovery({ + prompt: promptText, + attachments, + fallbackSubmission: options.fallbackSubmission, + submit: submitOnce, + reloadPromptComposer, + prepareFallbackSubmission: async () => { + await clearPromptComposer(Runtime, logger); + await ensurePromptReady(Runtime, config.inputTimeoutMs, logger); + }, + logger, + }); + baselineTurns = submission.baselineTurns; + baselineAssistantText = submission.baselineAssistantText; + deepResearchTargetKeys = submission.deepResearchTargetKeys ?? []; + deepResearchTargetBaselineCaptured = submission.deepResearchTargetBaselineCaptured ?? false; + const imageArtifactMinTurnIndex = baselineTurns; + if (deepResearch) { + await waitForResearchPlanAutoConfirm(Runtime, logger); + const researchResult = await waitForDeepResearchCompletion(Runtime, logger, config.timeoutMs, baselineTurns, Page, client, { + ignoredTargetKeys: deepResearchTargetKeys, + targetBaselineCaptured: deepResearchTargetBaselineCaptured, + }); + await activeConversationUrlMonitor.update("post-deep-research", 15_000).catch(() => false); + const durationMs = Date.now() - startedAt; + const tokens = estimateTokenCount(researchResult.text); + const reportArtifact = await saveOptionalArtifact(() => saveDeepResearchReportArtifact({ + sessionId: options.sessionId, + reportMarkdown: researchResult.text, + conversationUrl: lastUrl, + logger, + }), logger); + const transcriptArtifact = await saveOptionalArtifact(() => saveBrowserTranscriptArtifact({ + sessionId: options.sessionId, + prompt: promptText, + answerMarkdown: researchResult.text, + conversationUrl: lastUrl, + artifacts: appendArtifacts(undefined, [reportArtifact]), + logger, + }), logger); + const savedArtifacts = appendArtifacts(undefined, [reportArtifact, transcriptArtifact]); + const archive = await maybeArchiveCompletedConversation({ + Runtime, + logger, + config, + conversationUrl: lastUrl, + followUpCount: 0, + requiredArtifactsSaved: Boolean(reportArtifact && transcriptArtifact), + }); + runStatus = "complete"; + return { + answerText: researchResult.text, + answerMarkdown: researchResult.text, + answerHtml: researchResult.html, + artifacts: savedArtifacts, + archive, + modelSelection: modelSelectionEvidence, + tookMs: durationMs, + answerTokens: tokens, + answerChars: researchResult.text.length, + chromePort: port, + chromeHost: host, + chromeTargetId: remoteTargetId ?? undefined, + tabUrl: lastUrl, + conversationId: lastUrl ? extractConversationIdFromUrl(lastUrl) : undefined, + promptSubmitted, + controllerPid: process.pid, + }; + } + // Helper to normalize text for echo detection (collapse whitespace, lowercase) + const normalizeForComparison = (text) => text.toLowerCase().replace(/\s+/g, " ").trim(); + const expectedConversationId = () => lastUrl ? extractConversationIdFromUrl(lastUrl) : undefined; + const waitForFreshAssistantResponse = async (baselineNormalized, timeoutMs) => { + const baselinePrefix = baselineNormalized.length >= 80 + ? baselineNormalized.slice(0, Math.min(200, baselineNormalized.length)) + : ""; + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + const snapshot = await readAssistantSnapshot(Runtime, baselineTurns ?? undefined, expectedConversationId()).catch(() => null); + const text = typeof snapshot?.text === "string" ? snapshot.text.trim() : ""; + if (text) { + const normalized = normalizeForComparison(text); + const isBaseline = normalized === baselineNormalized || + (baselinePrefix.length > 0 && normalized.startsWith(baselinePrefix)); + if (!isBaseline) { + return { + text, + html: snapshot?.html ?? undefined, + meta: { + turnId: snapshot?.turnId ?? undefined, + messageId: snapshot?.messageId ?? undefined, + }, + }; + } + } + await delay(350); + } + return null; + }; + const waitWithThinkingMonitor = async (operation) => { + stopThinkingMonitor?.(); + stopThinkingMonitor = startThinkingStatusMonitor(Runtime, logger, { + intervalMs: options.heartbeatIntervalMs, + }); + try { + return await operation(); + } + finally { + stopThinkingMonitor?.(); + stopThinkingMonitor = null; + } + }; + const recheckDelayMs = Math.max(0, config.assistantRecheckDelayMs ?? 0); + const recheckTimeoutMs = Math.max(0, config.assistantRecheckTimeoutMs ?? 0); + const attemptAssistantRecheck = async () => { + if (!recheckDelayMs) + return null; + logger(`[browser] Assistant response timed out; waiting ${formatElapsed(recheckDelayMs)} before rechecking conversation.`); + await delay(recheckDelayMs); + const conversationUrl = await readConversationUrl(Runtime); + if (conversationUrl && isConversationUrl(conversationUrl)) { + lastUrl = conversationUrl; + logger(`[browser] Rechecking assistant response at ${conversationUrl}`); + await Page.navigate({ url: conversationUrl }); + await waitForResumedConversationHydration(Runtime, recheckTimeoutMs || 30_000, logger, { + requirePriorTurns: true, + requirePromptReady: false, + expectedConversationUrl: conversationUrl, + }); + } + // Validate session before attempting recheck - sessions can expire during the delay + const sessionValid = await validateChatGPTSession(Runtime, logger); + if (!sessionValid.valid) { + logger(`[browser] Session validation failed: ${sessionValid.reason}`); + // Update session metadata to indicate login is needed + await emitRuntimeHint(); + throw new BrowserAutomationError(`ChatGPT session expired during recheck: ${sessionValid.reason}. ` + + `Conversation URL: ${conversationUrl || lastUrl || "unknown"}. ` + + `Please sign in and retry.`, { + stage: "assistant-recheck", + details: { + conversationUrl: conversationUrl || lastUrl || null, + sessionStatus: "needs_login", + validationReason: sessionValid.reason, + }, + runtime: { + chromeHost: host, + chromePort: port, + chromeBrowserWSEndpoint: browserWSEndpoint, + chromeProfileRoot, + chromeTargetId: remoteTargetId ?? undefined, + tabUrl: lastUrl, + conversationId: lastUrl ? extractConversationIdFromUrl(lastUrl) : undefined, + promptSubmitted, + controllerPid: process.pid, + }, + }); + } + await emitRuntimeHint(); + const timeoutMs = recheckTimeoutMs > 0 ? recheckTimeoutMs : config.timeoutMs; + const rechecked = await waitWithThinkingMonitor(() => waitForAssistantOrGeneratedImageResponse({ + Runtime, + waitForText: () => waitForAssistantResponseWithReload(Runtime, Page, timeoutMs, logger, baselineTurns ?? undefined, expectedConversationId()), + timeoutMs, + logger, + minTurnIndex: baselineTurns ?? undefined, + expectedConversationId: expectedConversationId(), + imageOutputRequested, + })); + logger("Recovered assistant response after delayed recheck"); + return rechecked; + }; + const imageOutputRequested = Boolean(options.generateImagePath || + options.outputPath || + options.generateImage); + const captureAssistantTurn = async (turnPrompt, label) => { + let turnAnswer; + try { + await activeConversationUrlMonitor.update("assistant-wait", 15_000).catch(() => false); + turnAnswer = await waitWithThinkingMonitor(() => waitForAssistantOrGeneratedImageResponse({ + Runtime, + waitForText: () => waitForAssistantResponseWithReload(Runtime, Page, config.timeoutMs, logger, baselineTurns ?? undefined, expectedConversationId()), + timeoutMs: config.timeoutMs, + logger, + minTurnIndex: baselineTurns ?? undefined, + expectedConversationId: expectedConversationId(), + imageOutputRequested, + })); + } + catch (error) { + if (isAssistantResponseTimeoutError(error)) { + const rechecked = await attemptAssistantRecheckOrRethrow(attemptAssistantRecheck); + if (rechecked) { + turnAnswer = rechecked; + } + else { + await activeConversationUrlMonitor + .update("assistant-timeout", 15_000) + .catch(() => false); + const diagnostics = await captureBrowserDiagnostics(Runtime, logger, "assistant-timeout", { + Page, + sessionId: options.sessionId, + }).catch(() => undefined); + const runtime = { + chromePort: port, + chromeHost: host, + chromeBrowserWSEndpoint: browserWSEndpoint, + chromeProfileRoot, + chromeTargetId: remoteTargetId ?? undefined, + tabUrl: lastUrl, + conversationId: lastUrl ? extractConversationIdFromUrl(lastUrl) : undefined, + promptSubmitted, + controllerPid: process.pid, + }; + throw await createAssistantTimeoutError({ + Runtime, + logger, + runtime, + diagnostics, + cause: error, + }); + } + } + else { + throw error; + } + } + await activeConversationUrlMonitor.update("post-response", 15_000).catch(() => false); + const baselineNormalized = baselineAssistantText + ? normalizeForComparison(baselineAssistantText) + : ""; + if (baselineNormalized) { + const normalizedAnswer = normalizeForComparison(turnAnswer.text ?? ""); + const baselinePrefix = baselineNormalized.length >= 80 + ? baselineNormalized.slice(0, Math.min(200, baselineNormalized.length)) + : ""; + const isBaseline = normalizedAnswer === baselineNormalized || + (baselinePrefix.length > 0 && normalizedAnswer.startsWith(baselinePrefix)); + if (isBaseline) { + logger("Detected stale assistant response; waiting for new response..."); + const refreshed = await waitForFreshAssistantResponse(baselineNormalized, 15_000); + if (refreshed) { + turnAnswer = refreshed; + } + } + } + let turnAnswerText = turnAnswer.text; + const turnAnswerHtml = turnAnswer.html ?? ""; + const copiedMarkdown = await withRetries(async () => { + const attempt = await captureAssistantMarkdown(Runtime, turnAnswer.meta, logger); + if (!attempt) { + throw new Error("copy-missing"); + } + return attempt; + }, { + retries: 2, + delayMs: 350, + onRetry: (attempt, error) => { + if (options.verbose) { + logger(`[retry] Markdown capture attempt ${attempt + 1}: ${error instanceof Error ? error.message : error}`); + } + }, + }).catch(() => null); + let turnAnswerMarkdown = copiedMarkdown ?? turnAnswerText; + ({ answerText: turnAnswerText, answerMarkdown: turnAnswerMarkdown } = + await maybeRecoverLongAssistantResponse({ + runtime: Runtime, + baselineTurns, + answerText: turnAnswerText, + answerMarkdown: turnAnswerMarkdown, + logger, + allowMarkdownUpdate: !copiedMarkdown, + })); + // Final sanity check: ensure we didn't accidentally capture the user prompt instead of the assistant turn. + const finalSnapshot = await readAssistantSnapshot(Runtime, baselineTurns ?? undefined, expectedConversationId()).catch(() => null); + const finalText = typeof finalSnapshot?.text === "string" ? finalSnapshot.text.trim() : ""; + if (finalText && + finalText !== turnAnswerMarkdown.trim() && + finalText !== turnPrompt.trim() && + finalText.length >= turnAnswerMarkdown.trim().length) { + logger("Refreshed assistant response via final DOM snapshot"); + turnAnswerText = finalText; + turnAnswerMarkdown = finalText; + } + // Detect prompt echo using normalized comparison (whitespace-insensitive). + const promptEchoMatcher = buildPromptEchoMatcher(turnPrompt); + const alignedEcho = alignPromptEchoPair(turnAnswerText, turnAnswerMarkdown, promptEchoMatcher, copiedMarkdown ? logger : undefined, { + text: "Aligned assistant response text to copied markdown after prompt echo", + markdown: "Aligned assistant markdown to response text after prompt echo", + }); + turnAnswerText = alignedEcho.answerText; + turnAnswerMarkdown = alignedEcho.answerMarkdown; + const isPromptEcho = alignedEcho.isEcho; + if (isPromptEcho) { + logger("Detected prompt echo in response; waiting for actual assistant response..."); + const deadline = Date.now() + 15_000; + let bestText = null; + let stableCount = 0; + while (Date.now() < deadline) { + const snapshot = await readAssistantSnapshot(Runtime, baselineTurns ?? undefined, expectedConversationId()).catch(() => null); + const text = typeof snapshot?.text === "string" ? snapshot.text.trim() : ""; + const isStillEcho = !text || Boolean(promptEchoMatcher?.isEcho(text)); + if (!isStillEcho) { + if (!bestText || text.length > bestText.length) { + bestText = text; + stableCount = 0; + } + else if (text === bestText) { + stableCount += 1; + } + if (stableCount >= 2) { + break; + } + } + await new Promise((resolve) => setTimeout(resolve, 300)); + } + if (bestText) { + logger("Recovered assistant response after detecting prompt echo"); + turnAnswerText = bestText; + turnAnswerMarkdown = bestText; + } + } + return { + label, + answerText: turnAnswerText, + answerMarkdown: turnAnswerMarkdown, + answerHtml: turnAnswerHtml, + }; + }; + const followUpPrompts = normalizeBrowserFollowUpPrompts(options.followUpPrompts); + const turns = []; + const initialTurn = await captureAssistantTurn(promptText, "Initial response"); + turns.push(initialTurn); + answerText = initialTurn.answerText; + answerMarkdown = initialTurn.answerMarkdown; + answerHtml = initialTurn.answerHtml; + for (let index = 0; index < followUpPrompts.length; index += 1) { + const followUpPrompt = followUpPrompts[index]; + logger(`[browser] Sending follow-up ${index + 1}/${followUpPrompts.length}`); + await clearPromptComposer(Runtime, logger); + await ensurePromptReady(Runtime, config.inputTimeoutMs, logger); + const submission = await runSubmissionWithRecovery({ + prompt: followUpPrompt, + attachments: [], + submit: submitOnce, + reloadPromptComposer, + prepareFallbackSubmission: async () => { + await clearPromptComposer(Runtime, logger); + await ensurePromptReady(Runtime, config.inputTimeoutMs, logger); + }, + logger, + }); + baselineTurns = submission.baselineTurns; + baselineAssistantText = submission.baselineAssistantText; + const turn = await captureAssistantTurn(followUpPrompt, `Follow-up ${index + 1}`); + turns.push({ ...turn, prompt: followUpPrompt }); + answerText = turn.answerText; + answerMarkdown = turn.answerMarkdown; + answerHtml = turn.answerHtml; + } + if (turns.length > 1) { + const formatted = formatBrowserTurnTranscript(turns); + answerText = formatted.answerText; + answerMarkdown = formatted.answerMarkdown; + answerHtml = ""; + } + const canSaveBrowserDownloadsLocally = isLocalChromeHost(host); + const imageArtifacts = await collectGeneratedImageArtifacts({ + Browser: canSaveBrowserDownloadsLocally ? client.Browser : undefined, + Client: canSaveBrowserDownloadsLocally ? client : undefined, + Page: canSaveBrowserDownloadsLocally ? Page : undefined, + Runtime, + Network, + logger, + minTurnIndex: imageArtifactMinTurnIndex, + sessionId: options.sessionId, + generateImagePath: options.generateImagePath, + outputPath: options.outputPath, + answerText, + waitTimeoutMs: options.config?.timeoutMs, + checkBlockingUiWarning: () => throwChatGptUiWarningIfPresent({ + Runtime, + logger, + stage: "image-artifact-wait", + waitTarget: "generated image artifacts", + runtime: { + chromePort: port, + chromeHost: host, + chromeBrowserWSEndpoint: browserWSEndpoint, + chromeProfileRoot, + chromeTargetId: remoteTargetId ?? undefined, + tabUrl: lastUrl, + conversationId: lastUrl ? extractConversationIdFromUrl(lastUrl) : undefined, + promptSubmitted, + controllerPid: process.pid, + }, + }), + }); + answerText = imageArtifacts.answerText || answerText; + if (imageArtifacts.markdownSuffix) { + answerMarkdown += imageArtifacts.markdownSuffix; + } + const fileArtifacts = await collectChatGptFileArtifacts({ + Browser: client.Browser, + Client: client, + Page, + Runtime, + Network, + answerText: [answerText, answerMarkdown, answerHtml].filter(Boolean).join("\n"), + logger, + minTurnIndex: imageArtifactMinTurnIndex, + sessionId: options.sessionId, + }); + const savedImageArtifacts = appendArtifacts(undefined, imageArtifacts.savedImages); + const savedBrowserArtifacts = appendArtifacts(savedImageArtifacts, fileArtifacts.savedFiles); + const transcriptArtifact = await saveOptionalArtifact(() => saveBrowserTranscriptArtifact({ + sessionId: options.sessionId, + prompt: promptText, + answerMarkdown, + conversationUrl: lastUrl, + artifacts: savedBrowserArtifacts, + logger, + }), logger); + const savedArtifacts = appendArtifacts(savedBrowserArtifacts, [transcriptArtifact]); + const archive = await maybeArchiveCompletedConversation({ + Runtime, + logger, + config, + conversationUrl: lastUrl, + followUpCount: followUpPrompts.length, + requiredArtifactsSaved: Boolean(transcriptArtifact) && + imageArtifacts.savedImages.length === imageArtifacts.imageCount && + fileArtifacts.savedFiles.length === fileArtifacts.fileCount, + }); + const durationMs = Date.now() - startedAt; + const answerChars = answerText.length; + const answerTokens = estimateTokenCount(answerMarkdown); + runStatus = "complete"; + return { + answerText, + answerMarkdown, + answerHtml: answerHtml.length > 0 ? answerHtml : undefined, + tookMs: durationMs, + answerTokens, + answerChars, + browserTransport: "cdp", + chromePid: undefined, + chromePort: port, + chromeHost: host, + chromeBrowserWSEndpoint: browserWSEndpoint, + chromeProfileRoot, + userDataDir: undefined, + chromeTargetId: remoteTargetId ?? undefined, + tabUrl: lastUrl, + conversationId: lastUrl ? extractConversationIdFromUrl(lastUrl) : undefined, + promptSubmitted, + artifacts: savedArtifacts, + generatedImages: imageArtifacts.generatedImages, + savedImages: imageArtifacts.savedImages, + downloadableFiles: fileArtifacts.files, + savedFiles: fileArtifacts.savedFiles, + archive, + modelSelection: modelSelectionEvidence, + controllerPid: process.pid, + }; + } + catch (error) { + const normalizedError = error instanceof Error ? error : new Error(String(error)); + const socketClosed = connectionClosedUnexpectedly || isWebSocketClosureError(normalizedError); + connectionClosedUnexpectedly = connectionClosedUnexpectedly || socketClosed; + if (!socketClosed) { + logger(`Failed to complete ChatGPT run: ${normalizedError.message}`); + if ((config.debug || process.env.CHATGPT_DEVTOOLS_TRACE === "1") && normalizedError.stack) { + logger(normalizedError.stack); + } + throw normalizedError; + } + const liveness = await probeChromeTargetLiveness({ + host, + port, + targetId: remoteTargetId, + browserWSEndpoint, + }); + const recoverable = isRecoverableChromeDisconnect(liveness); + throw new BrowserAutomationError(connectionLostUserMessage({ recoverable, remote: true }), { + stage: "connection-lost", + recoverableDisconnect: recoverable, + disconnectCause: recoverable ? "cdp-client-disconnect" : "chrome-closed", + runtime: { + chromeHost: host, + chromePort: port, + chromeBrowserWSEndpoint: browserWSEndpoint, + chromeProfileRoot, + chromeTargetId: remoteTargetId ?? undefined, + tabUrl: liveness.matchedUrl ?? lastUrl, + conversationId: (liveness.matchedUrl ?? lastUrl) + ? extractConversationIdFromUrl(liveness.matchedUrl ?? lastUrl ?? "") + : undefined, + promptSubmitted, + controllerPid: process.pid, + }, + }); + } + finally { + await conversationUrlMonitor?.stop(); + try { + await closeRemoteConnectionAfterRun({ + connectionClosedUnexpectedly, + connection, + client, + runStatus, + }); + } + catch { + // ignore + } + removeDialogHandler?.(); + const keepRemoteBrowser = Boolean(config.keepBrowser); + const shouldCloseOwnedRemoteTarget = shouldCloseOwnedRunTargetAfterRun({ + runStatus, + ownsTarget, + keepBrowser: keepRemoteBrowser, + closeOwnedTabOnComplete: options.closeOwnedTabOnComplete, + }); + const closeOwnedRemoteTarget = async () => { + if (!shouldCloseOwnedRemoteTarget || !remoteTargetId) { + return; + } + const safeToClose = !keepRemoteBrowser || + Boolean(await ensureChromePageTargetAfterClose(port, remoteTargetId, logger, host)); + if (!safeToClose) { + logger(`[browser] Leaving completed remote browser tab open because Chrome has no replacement page target.`); + return; + } + const closeConfirmed = await closeTab(port, remoteTargetId, logger, host); + if (!closeConfirmed && keepRemoteBrowser) { + const replacementTargetId = await createChromePageTarget(port, logger, host); + if (!replacementTargetId) { + logger(`[browser] Remote Chrome page retention could not be verified after closing ${remoteTargetId}.`); + } + } + }; + if (tabLease) { + const handle = tabLease; + tabLease = null; + await handle + .release({ onRelease: async () => closeOwnedRemoteTarget() }) + .catch(() => undefined); + } + else { + await closeOwnedRemoteTarget(); + } + // Don't kill remote Chrome - it's not ours to manage + const totalSeconds = (Date.now() - startedAt) / 1000; + logger(`Remote session complete • ${totalSeconds.toFixed(1)}s total`); + } +} +export { estimateTokenCount } from "./utils.js"; +export { resolveBrowserConfig, DEFAULT_BROWSER_CONFIG } from "./config.js"; +// biome-ignore lint/style/useNamingConvention: test-only export used in vitest suite +export const __test__ = { + assertManualLoginProfileReadyForRun, + closeRemoteConnectionAfterRun, + classifyChatGptUiWarningText, + collectChatGptUiWarnings, + createAssistantTimeoutError, + detachKeptChromeProcess, + formatManualLoginSetupCommand, + isAssistantResponseTimeoutError, + isManualLoginProfileInitialized, + isImageOnlyUiChromeText, + listIgnoredRemoteChromeFlags, + normalizeAuthenticatedModelSelectionError, + resolveManualLoginWaitMs, + shouldCleanupBlankTabsAfterLastLease, + shouldCloseOwnedRunTargetAfterRun, + shouldKeepLocalBrowserOpen, + waitForAssistantResponseWithReload, +}; +export { syncCookies } from "./cookies.js"; +export { navigateToChatGPT, ensureNotBlocked, ensurePromptReady, ensureModelSelection, submitPrompt, waitForAssistantResponse, captureAssistantMarkdown, uploadAttachmentFile, waitForAttachmentCompletion, } from "./pageActions.js"; +export async function maybeReuseRunningChromeForTest(userDataDir, logger, options = {}) { + return maybeReuseRunningChrome(userDataDir, logger, options); +} +export async function acquireManualLoginChromeForRunForTest(userDataDir, config, logger, sessionId, deps) { + return acquireManualLoginChromeForRun(userDataDir, config, logger, sessionId, deps); +} +export function isWebSocketClosureError(error) { + const message = error.message.toLowerCase(); + return (message.includes("websocket connection closed") || + message.includes("websocket is closed") || + message.includes("websocket error") || + message.includes("inspected target navigated or closed") || + message.includes("target closed")); +} +async function waitForAssistantResponseWithReload(Runtime, Page, timeoutMs, logger, minTurnIndex, expectedConversationId) { + try { + return await waitForAssistantResponse(Runtime, timeoutMs, logger, minTurnIndex, expectedConversationId); + } + catch (error) { + if (!shouldReloadAfterAssistantError(error)) { + throw error; + } + const conversationUrl = await readConversationUrl(Runtime); + if (!conversationUrl || !isConversationUrl(conversationUrl)) { + throw error; + } + logger("Assistant response stalled; reloading conversation and retrying once"); + await Page.navigate({ url: conversationUrl }); + await waitForResumedConversationHydration(Runtime, timeoutMs, logger, { + requirePriorTurns: true, + requirePromptReady: false, + expectedConversationUrl: conversationUrl, + }); + return await waitForAssistantResponse(Runtime, timeoutMs, logger, minTurnIndex, expectedConversationId); + } +} +function shouldReloadAfterAssistantError(error) { + if (!(error instanceof Error)) + return false; + const message = error.message.toLowerCase(); + return (message.includes("assistant-response") || + message.includes("watchdog") || + message.includes("timeout") || + message.includes("capture assistant response")); +} +function isAssistantResponseTimeoutError(error) { + if (!(error instanceof Error)) + return false; + const message = error.message.toLowerCase(); + if (!message) + return false; + return (message === "response timeout" || + message.includes("assistant-response") || + message.includes("assistant response") || + message.includes("watchdog") || + message.includes("capture assistant response")); +} +async function readConversationUrl(Runtime) { + try { + const currentUrl = await Runtime.evaluate({ expression: "location.href", returnByValue: true }); + return typeof currentUrl.result?.value === "string" ? currentUrl.result.value : null; + } + catch { + return null; + } +} +/** + * Validates that the ChatGPT session is still active by checking for login CTAs + * and textarea availability. Sessions can expire during long delays (e.g., recheck). + * + * @param Runtime - Chrome Runtime client + * @param logger - Browser logger for diagnostics + * @returns SessionValidationResult indicating if session is valid and reason if not + */ +async function validateChatGPTSession(Runtime, logger) { + try { + const outcome = await Runtime.evaluate({ + expression: buildSessionValidationExpression(), + awaitPromise: true, + returnByValue: true, + }); + const result = outcome.result?.value; + if (!result) { + return { valid: false, reason: "Failed to evaluate session state" }; + } + if (result.onAuthPage) { + return { valid: false, reason: "Redirected to auth page" }; + } + if (result.hasLoginCta) { + return { valid: false, reason: "Login button detected on page" }; + } + if (!result.hasTextarea) { + return { valid: false, reason: "Prompt textarea not available" }; + } + return { valid: true }; + } + catch (error) { + const message = error instanceof Error ? error.message : String(error); + logger(`[browser] Session validation error: ${message}`); + return { valid: false, reason: `Validation error: ${message}` }; + } +} +function buildSessionValidationExpression() { + const selectorLiteral = JSON.stringify(INPUT_SELECTORS); + return `(async () => { + const pageUrl = typeof location === 'object' && location?.href ? location.href : null; + const onAuthPage = + typeof location === 'object' && + typeof location.pathname === 'string' && + /^\\/(auth|login|signin)/i.test(location.pathname); + + // Check for login CTAs (similar to ensureLoggedIn logic) + const hasLoginCta = (() => { + const candidates = Array.from( + document.querySelectorAll( + [ + 'a[href*="/auth/login"]', + 'a[href*="/auth/signin"]', + 'button[type="submit"]', + 'button[data-testid*="login"]', + 'button[data-testid*="log-in"]', + 'button[data-testid*="sign-in"]', + 'button[data-testid*="signin"]', + 'button', + 'a', + ].join(','), + ), + ); + const textMatches = (text) => { + if (!text) return false; + const normalized = text.toLowerCase().trim(); + return ['log in', 'login', 'sign in', 'signin', 'continue with'].some((needle) => + normalized.startsWith(needle), + ); + }; + for (const node of candidates) { + if (!(node instanceof HTMLElement)) continue; + const label = + node.textContent?.trim() || + node.getAttribute('aria-label') || + node.getAttribute('title') || + ''; + if (textMatches(label)) { + return true; + } + } + return false; + })(); + + // Check for textarea availability + const hasTextarea = (() => { + const selectors = ${selectorLiteral}; + for (const selector of selectors) { + const node = document.querySelector(selector); + if (node) { + return true; + } + } + return false; + })(); + + return { + valid: !onAuthPage && !hasLoginCta && hasTextarea, + hasLoginCta, + hasTextarea, + onAuthPage, + pageUrl, + }; + })()`; +} +async function readConversationTurnCount(Runtime, logger) { + const expression = buildConversationTurnCountExpression(); + const attempts = 4; + for (let attempt = 0; attempt < attempts; attempt += 1) { + try { + const { result } = await Runtime.evaluate({ + expression, + returnByValue: true, + }); + const raw = typeof result?.value === "number" ? result.value : Number(result?.value); + if (!Number.isFinite(raw)) { + throw new Error("Turn count not numeric"); + } + return Math.max(0, Math.floor(raw)); + } + catch (error) { + if (attempt < attempts - 1) { + await delay(150); + continue; + } + if (logger?.verbose) { + logger(`Failed to read conversation turn count: ${error instanceof Error ? error.message : String(error)}`); + } + return null; + } + } + return null; +} +function describeDevtoolsFirewallHint(host, port) { + if (!isWsl()) + return null; + return [ + `DevTools port ${host}:${port} is blocked from WSL.`, + "", + "PowerShell (admin):", + `New-NetFirewallRule -DisplayName 'Chrome DevTools ${port}' -Direction Inbound -Action Allow -Protocol TCP -LocalPort ${port}`, + "New-NetFirewallRule -DisplayName 'Chrome DevTools (chrome.exe)' -Direction Inbound -Action Allow -Program 'C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe' -Protocol TCP", + "", + "Re-run the same oracle command after adding the rule.", + ].join("\n"); +} +function isWsl() { + if (process.platform !== "linux") + return false; + if (process.env.WSL_DISTRO_NAME) + return true; + return os.release().toLowerCase().includes("microsoft"); +} +async function resolveUserDataBaseDir() { + // On WSL, Chrome launched via Windows can choke on UNC paths; prefer a Windows-backed temp folder. + if (isWsl()) { + const candidates = [ + "/mnt/c/Users/Public/AppData/Local/Temp", + "/mnt/c/Temp", + "/mnt/c/Windows/Temp", + ]; + for (const candidate of candidates) { + try { + await mkdir(candidate, { recursive: true }); + return candidate; + } + catch { + // try next + } + } + } + const tmpDir = os.tmpdir(); + if (shouldPreferSystemTmpDir(process.platform, tmpDir, os.homedir())) { + try { + await mkdir("/tmp", { recursive: true }); + return "/tmp"; + } + catch { + // Fall back to the inherited tmpdir if /tmp is unavailable. + } + } + return tmpDir; +} +function shouldPreferSystemTmpDir(platform, tmpDir, homeDir) { + if (platform !== "linux" || !tmpDir || !homeDir) + return false; + const relativeToHome = path.relative(homeDir, tmpDir); + if (!relativeToHome || relativeToHome.startsWith("..") || path.isAbsolute(relativeToHome)) { + return false; + } + const firstSegment = relativeToHome.split(path.sep, 1)[0]; + return Boolean(firstSegment?.startsWith(".")); +} +export function shouldPreferSystemTmpDirForTest(platform, tmpDir, homeDir) { + return shouldPreferSystemTmpDir(platform, tmpDir, homeDir); +} diff --git a/tests/fixtures/oracle-0.17.3/thinkingTime.pristine.js b/tests/fixtures/oracle-0.17.3/thinkingTime.pristine.js new file mode 100644 index 00000000..1c8e374c --- /dev/null +++ b/tests/fixtures/oracle-0.17.3/thinkingTime.pristine.js @@ -0,0 +1,1159 @@ +import { MENU_CONTAINER_SELECTOR, MENU_ITEM_SELECTOR, MODEL_BUTTON_SELECTOR, } from "../constants.js"; +import { logDomFailure } from "../domDebug.js"; +import { buildClickDispatcher } from "./domEvents.js"; +const BROWSER_THINKING_LOG_PREFIX = "[browser] Thinking time:"; +function formatBrowserThinkingLog(message) { + return `${BROWSER_THINKING_LOG_PREFIX} ${message.replace(/^Thinking time:\s*/, "")}`; +} +/** + * Surfaces the model-picker snapshot captured alongside a failed detection. + * + * The browser prefix routes this through the session runner's non-verbose + * always-print path. The injected probe bounds and redacts all text values. + */ +function logPickerDiagnostic(result, logger) { + const diagnostic = result && "diagnostic" in result + ? result.diagnostic + : undefined; + if (!diagnostic) { + return; + } + logger(`[browser] Model picker diagnostic: ${JSON.stringify(diagnostic)}`); +} +/** + * Selects a thinking-time level in ChatGPT's composer. + * + * Missing controls remain best-effort except Pro Extended, which fails closed + * unless the selected option is confirmed. + */ +export async function ensureThinkingTime(Runtime, level, logger, desiredModel) { + const result = await evaluateThinkingTimeSelection(Runtime, level, desiredModel); + const capitalizedLevel = level.charAt(0).toUpperCase() + level.slice(1); + const targetModelKind = inferThinkingTargetModelKind(desiredModel); + const observedModelKind = result && "modelKind" in result ? result.modelKind : null; + // Pro is expensive and rate-limited, so a Pro request must never degrade quietly + // into a cheaper tier. Requesting it explicitly (level "pro") fails closed on its + // own, independently of the legacy Pro-model + "extended" combination. + const strictProEffort = level === "pro" || + ((targetModelKind === "pro" || observedModelKind === "pro") && level === "extended"); + switch (result?.status) { + case "already-selected": + logger(formatBrowserThinkingLog(`${result.label ?? capitalizedLevel} (already selected)`)); + return; + case "switched": + logger(formatBrowserThinkingLog(result.label ?? capitalizedLevel)); + return; + case "chip-not-found": + case "menu-not-found": + case "option-not-found": + case "selection-unverified": + case "model-kind-not-found": { + await logDomFailure(Runtime, logger, `thinking-${result.status}`); + logPickerDiagnostic(result, logger); + const kindHint = result.status === "model-kind-not-found" && result.modelKind + ? ` for ${result.modelKind}` + : targetModelKind + ? ` for ${targetModelKind}` + : ""; + const message = `Thinking time: ${result.status.replaceAll("-", " ")}${kindHint} (requested ${capitalizedLevel})`; + if (strictProEffort) { + const target = level === "pro" ? "Pro" : "Pro Extended"; + throw new Error(`${message}; refusing to submit without confirmed ${target}.`); + } + // "selection-unverified" is the one status here that already dispatched a + // click, so the effort may or may not have moved. Every other status left + // the tab on whatever effort it had — which is not necessarily the default. + const outcome = result.status === "selection-unverified" + ? "the effort in ChatGPT is unconfirmed" + : "keeping the effort already selected in ChatGPT"; + logger(formatBrowserThinkingLog(`${message}; ${outcome}.`)); + return; + } + default: { + await logDomFailure(Runtime, logger, "thinking-time-unknown"); + logPickerDiagnostic(result, logger); + if (strictProEffort) { + const target = level === "pro" ? "Pro" : "Pro Extended"; + throw new Error(`Thinking time: unknown outcome selecting ${capitalizedLevel}; refusing to submit without confirmed ${target}.`); + } + logger(formatBrowserThinkingLog(`unknown outcome selecting ${capitalizedLevel}; continuing with ChatGPT default.`)); + return; + } + } +} +/** + * Best-effort selection of a thinking time level in ChatGPT's composer pill menu. + * Safe by default: if the pill/menu/option isn't present, we continue without throwing. + * @param level - The thinking time intensity: 'light', 'standard', 'extended', 'extra-high', 'pro', or 'heavy' + */ +export async function ensureThinkingTimeIfAvailable(Runtime, level, logger, desiredModel) { + try { + const result = await evaluateThinkingTimeSelection(Runtime, level, desiredModel); + const capitalizedLevel = level.charAt(0).toUpperCase() + level.slice(1); + switch (result?.status) { + case "already-selected": + logger(formatBrowserThinkingLog(`${result.label ?? capitalizedLevel} (already selected)`)); + return true; + case "switched": + logger(formatBrowserThinkingLog(result.label ?? capitalizedLevel)); + return true; + case "chip-not-found": + case "menu-not-found": + case "option-not-found": + case "selection-unverified": + case "model-kind-not-found": + if (logger.verbose) { + logger(formatBrowserThinkingLog(`${result.status.replaceAll("-", " ")}; continuing with default.`)); + } + return false; + default: + if (logger.verbose) { + logger(formatBrowserThinkingLog("unknown outcome; continuing with default.")); + } + return false; + } + } + catch (error) { + const message = error instanceof Error ? error.message : String(error); + if (logger.verbose) { + logger(formatBrowserThinkingLog(`selection failed (${message}); continuing with default.`)); + await logDomFailure(Runtime, logger, "thinking-time"); + } + return false; + } +} +async function evaluateThinkingTimeSelection(Runtime, level, desiredModel) { + const outcome = await Runtime.evaluate({ + expression: buildThinkingTimeExpression(level, desiredModel), + awaitPromise: true, + returnByValue: true, + }); + return outcome.result?.value; +} +function buildThinkingTimeExpression(level, desiredModel) { + const menuContainerLiteral = JSON.stringify(MENU_CONTAINER_SELECTOR); + const menuItemLiteral = JSON.stringify(MENU_ITEM_SELECTOR); + const modelButtonLiteral = JSON.stringify(MODEL_BUTTON_SELECTOR); + const targetLevelLiteral = JSON.stringify(level.toLowerCase()); + const targetModelKindLiteral = JSON.stringify(inferThinkingTargetModelKind(desiredModel)); + const targetIsGpt56ModelLiteral = JSON.stringify(/(?:^|[^0-9])5[._ -]6(?:[^0-9]|$)/i.test(desiredModel ?? "")); + return `(async () => { + ${buildClickDispatcher()} + + const MENU_CONTAINER_SELECTOR = ${menuContainerLiteral}; + const MENU_ITEM_SELECTOR = ${menuItemLiteral}; + const MODEL_BUTTON_SELECTOR = ${modelButtonLiteral}; + const TARGET_LEVEL = ${targetLevelLiteral}; + const TARGET_MODEL_KIND = ${targetModelKindLiteral}; + const TARGET_IS_GPT56_MODEL = ${targetIsGpt56ModelLiteral}; + + // Multilingual matchers: English level token + observed German/Japanese/Chinese variants. + const LEVEL_TOKENS = { + light: ['light', 'instant', 'sofort', 'leicht', '最速', '轻', '极速'], + standard: ['standard', 'medium', 'mittel', '中程度', '标准', '中'], + extended: ['extended', 'high', 'hoch', 'erweitert', '高い', '扩展', '深度', '加强', '高'], + 'extra-high': ['extra high', 'sehr hoch', '非常に高い', '极高'], + heavy: ['heavy', 'schwer', '重度', '加重'], + }; + // Pro is a tier you can request, but it is also a MODEL name, so it must never + // be used to decide whether a control or a menu is an effort owner: a model pill + // reading "Pro" would be claimed as the effort pill, and a model menu listing + // "Instant"/"Pro" would look like a tier list. Keep it to target matching only. + const TARGET_LEVEL_TOKENS = { ...LEVEL_TOKENS, pro: ['pro'] }; + const targetTokens = TARGET_LEVEL_TOKENS[TARGET_LEVEL] || [TARGET_LEVEL]; + + const INITIAL_WAIT_MS = 150; + const STEP_WAIT_MS = 200; + const MAX_WAIT_MS = 8000; + // The "Intelligence" menu renders right after opening the composer pill, so + // a short probe is enough; if it's absent this is an older UI and we fall + // back to the legacy paths without paying the full MAX_WAIT_MS. + const INTELLIGENCE_WAIT_MS = 2500; + + const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); + // Keep CJK characters, including Japanese kana, so localized labels survive + // normalization before being matched against LEVEL_TOKENS and picker controls. + const normalize = (value) => (value || '') + // Compose first so NFD umlauts fold too, then map them onto ASCII before + // the strip below would drop them (and split the token in half). + .normalize('NFC') + .toLowerCase() + .replace(/ä/g, 'a') + .replace(/ö/g, 'o') + .replace(/ü/g, 'u') + .replace(/ß/g, 'ss') + .replace(/[^a-z0-9\\u3040-\\u30ff\\u4e00-\\u9fff]+/g, ' ') + .replace(/\\s+/g, ' ') + .trim(); + const hasToken = (text, token) => normalize(text).split(' ').includes(token); + // Whole-word/phrase containment. Latin effort labels are short words that also + // occur inside unrelated UI text ("Hochladen", "Ermitteln") and inside their own + // row descriptions ("Hoch – für sehr komplexe Aufgaben"), so plain substring + // matching misclassifies rows. CJK labels have no word separators, so they keep + // substring semantics. + const hasPhrase = (text, phrase) => { + const haystack = ' ' + normalize(text) + ' '; + const needle = normalize(phrase); + if (!needle) return false; + return /^[a-z0-9 ]+$/.test(needle) + ? haystack.includes(' ' + needle + ' ') + : haystack.includes(needle); + }; + // ChatGPT's Pro effort tiers are "Pro Extended"/"Pro Erweitert" per UI language. + const hasExtendedWord = (text) => hasPhrase(text, 'extended') || hasPhrase(text, 'erweitert'); + const matchesTokens = (text, tokens) => { + const t = normalize(text); + if (!t) return false; + return tokens.some((tok) => { + const token = normalize(tok); + if (!token) return false; + if (token === 'high') return hasPhrase(t, 'high') && !hasPhrase(t, 'extra high'); + if (token === 'extra high') return hasPhrase(t, 'extra high'); + if (token === 'hoch') return hasPhrase(t, 'hoch') && !hasPhrase(t, 'sehr hoch'); + if (token === 'sehr hoch') return hasPhrase(t, 'sehr hoch'); + if (token === '高い' || token === '非常に高い') { + return t === token || hasToken(t, token); + } + if (token === '极速') { + const suffix = t.slice(token.length); + return t === token || hasToken(t, token) || /^[0-9]/.test(suffix); + } + if (['中', '高', '极高'].includes(token)) { + return t === token || hasToken(t, token); + } + if (/^[a-z0-9 ]+$/.test(token)) { + return hasPhrase(t, token); + } + return t === token || hasToken(t, token) || t.includes(token); + }); + }; + const matchesLevel = (text) => matchesTokens(text, targetTokens); + const matchesAnyEffortLevel = (text) => + Object.values(LEVEL_TOKENS).some((tokens) => matchesTokens(text, tokens)); + const optionIsSelected = (node) => { + if (!(node instanceof HTMLElement)) return false; + const ariaChecked = node.getAttribute('aria-checked'); + const ariaSelected = node.getAttribute('aria-selected'); + const ariaCurrent = node.getAttribute('aria-current'); + const dataSelected = node.getAttribute('data-selected'); + const dataState = (node.getAttribute('data-state') || '').toLowerCase(); + if (ariaChecked === 'true' || ariaSelected === 'true' || ariaCurrent === 'true') return true; + return ( + dataSelected === 'true' || + dataState === 'checked' || + dataState === 'selected' || + dataState === 'on' || + dataState === 'true' + ); + }; + const closeOpenMenus = () => { + try { + document.dispatchEvent( + new KeyboardEvent('keydown', { key: 'Escape', code: 'Escape', keyCode: 27, which: 27, bubbles: true }), + ); + } catch {} + }; + const dispatchHoverSequence = (target) => { + if (!target || !(target instanceof EventTarget)) return false; + const types = ['pointerover', 'pointerenter', 'mouseover', 'mouseenter', 'pointermove', 'mousemove']; + for (const type of types) { + try { + const common = { bubbles: true, cancelable: true, view: window }; + const event = + type.startsWith('pointer') && 'PointerEvent' in window + ? new PointerEvent(type, { ...common, pointerId: 1, pointerType: 'mouse' }) + : new MouseEvent(type, common); + target.dispatchEvent(event); + } catch {} + } + try { + target.focus?.(); + } catch {} + return true; + }; + + const TRAILING_SELECTOR = '[data-model-picker-thinking-effort-action="true"]'; + const INTELLIGENCE_MENU_SELECTOR = '[data-testid="composer-intelligence-picker-content"]'; + const PRO_EFFORT_TRIGGER_SELECTOR = '[data-testid="composer-intelligence-pro-thinking-effort-trigger"]'; + + const findModelButton = () => document.querySelector(MODEL_BUTTON_SELECTOR); + const findTrailingButtons = () => Array.from(document.querySelectorAll(TRAILING_SELECTOR)); + const KIND_NOT_FOUND = { kindNotFound: true }; + + const isVisible = (node) => { + if (!node || node.getAttribute?.('aria-hidden') === 'true') return false; + const rect = node.getBoundingClientRect?.(); + return Boolean(rect && rect.width > 0 && rect.height > 0); + }; + const redactDiagnosticText = (value, maxLength = 120) => + String(value ?? '') + .replace(/[A-Z0-9._%+-]+@[A-Z0-9.-]+\\.[A-Z]{2,}/gi, '[redacted-email]') + .replace(/\\b[A-Za-z0-9_-]{32,}\\b/g, '[redacted]') + .replace(/\\s+/g, ' ') + .trim() + .slice(0, maxLength); + const describeNode = (el) => { + if (!el || typeof el.getAttribute !== 'function') return null; + let rect = null; + try { + const r = el.getBoundingClientRect?.(); + if (r) { + rect = { + w: Math.round(r.width), + h: Math.round(r.height), + visible: r.width > 0 && r.height > 0, + }; + } + } catch {} + return { + tag: el.tagName || null, + testid: el.getAttribute('data-testid'), + role: el.getAttribute('role'), + ariaLabel: redactDiagnosticText(el.getAttribute('aria-label')), + ariaExpanded: el.getAttribute('aria-expanded'), + ariaChecked: el.getAttribute('aria-checked'), + ariaSelected: el.getAttribute('aria-selected'), + ariaHaspopup: el.getAttribute('aria-haspopup'), + dataState: el.getAttribute('data-state'), + text: redactDiagnosticText(el.textContent, 80), + rect, + }; + }; + const describeMenu = (menu) => { + if (!menu || typeof menu.querySelectorAll !== 'function') return null; + const items = Array.from( + menu.querySelectorAll('[role="menuitem"], [role="menuitemradio"], [role="option"], button, [data-testid]'), + ) + .slice(0, 30) + .map(describeNode); + return { + role: menu.getAttribute?.('role') ?? null, + testid: menu.getAttribute?.('data-testid') ?? null, + itemCount: items.length, + items, + }; + }; + const collectPickerDiagnostic = () => { + try { + const trailings = findTrailingButtons(); + const switchers = Array.from(document.querySelectorAll('[data-testid*="model-switcher"]')); + const composerButtons = Array.from( + document.querySelectorAll( + 'form button[aria-haspopup="menu"], [data-testid="model-switcher-dropdown-button"]', + ), + ); + const menus = Array.from(document.querySelectorAll(MENU_CONTAINER_SELECTOR)).filter( + isVisible, + ); + const modelBtn = findModelButton(); + return { + targetModelKind: TARGET_MODEL_KIND, + targetLevel: TARGET_LEVEL, + modelButton: describeNode(modelBtn), + composerButtons: composerButtons.slice(0, 12).map(describeNode), + trailingCount: trailings.length, + trailings: trailings.slice(0, 12).map(describeNode), + modelSwitcherCount: switchers.length, + modelSwitcher: switchers.slice(0, 12).map(describeNode), + menuCount: menus.length, + menus: menus.slice(0, 4).map(describeMenu), + }; + } catch (err) { + return { error: redactDiagnosticText(err && err.message ? err.message : err) }; + } + }; + const modelKindFromNode = (button) => { + const label = normalize( + (button?.textContent ?? '') + ' ' + (button?.getAttribute?.('aria-label') ?? ''), + ); + if (hasToken(label, 'pro')) return 'pro'; + if (hasToken(label, 'thinking')) return 'thinking'; + if (hasToken(label, 'instant')) return 'instant'; + return null; + }; + const currentModelKind = () => modelKindFromNode(findModelButton()); + const effectiveTargetModelKind = () => TARGET_MODEL_KIND || currentModelKind(); + const isIntelligenceEffortMenu = (menu) => { + if (menu?.getAttribute?.('data-testid') === 'composer-intelligence-picker-content') { + return true; + } + if (menu?.querySelector?.(INTELLIGENCE_MENU_SELECTOR)) { + return true; + } + const label = menu?.querySelector?.('.__menu-label, [class*="menu-label"]'); + // 'intelligen' matches both "Intelligence" and German "Intelligenz". + return normalize(label?.textContent ?? '').includes('intelligen'); + }; + const failure = (status, extra = {}) => ({ + status, + modelKind: effectiveTargetModelKind(), + ...extra, + diagnostic: collectPickerDiagnostic(), + }); + const findOptionInMenu = (menu, modelKindOverride = null) => { + // Container controls reveal other controls; they are not tiers you can pick. + // Two shapes exist and both can collide with a tier label: a submenu opener + // ("ModelGPT-5.6 Pro" would satisfy a Pro request) and a disclosure toggle + // (German "Erweitert" is literally one of the extended tokens, so the + // Advanced toggle would satisfy an extended request and be clicked in place + // of the tier). Detect them structurally rather than by label: a real tier row + // carries a checked state, while a container carries expansion state. + const isContainerControl = (node) => + node?.getAttribute?.('aria-haspopup') === 'menu' || + (node?.getAttribute?.('aria-expanded') !== null && + node?.getAttribute?.('aria-checked') === null); + const items = Array.from(menu.querySelectorAll(MENU_ITEM_SELECTOR)).filter( + (item) => !isContainerControl(item), + ); + const modelKind = modelKindOverride || effectiveTargetModelKind(); + if (modelKind === 'pro') { + // GPT-5.6's unified Intelligence picker exposes Pro as the highest + // effort radio directly. It no longer has a nested "Pro Extended" + // row, so preserve the legacy request semantics by selecting Pro. + if ( + TARGET_LEVEL === 'extended' && + isIntelligenceEffortMenu(menu) && + !document.querySelector(PRO_EFFORT_TRIGGER_SELECTOR) + ) { + for (const item of items) { + const itemText = normalize( + (item.textContent ?? '') + ' ' + (item.getAttribute?.('aria-label') ?? ''), + ); + if (itemText === 'pro') return item; + } + } + for (const item of items) { + const itemText = normalize( + (item.textContent ?? '') + ' ' + (item.getAttribute?.('aria-label') ?? ''), + ); + if ( + hasToken(itemText, 'pro') && + (matchesLevel(item.textContent ?? '') || + matchesLevel(item.getAttribute?.('aria-label') ?? '')) + ) { + return item; + } + } + if (isIntelligenceEffortMenu(menu)) { + return null; + } + } + // Generic effort-label match for every model/level. GPT-5.6 heavy used to + // short-circuit to the Pro row before reaching here; it no longer does, so + // a UI without a matching tier (e.g. German, which has no "heavy") falls + // through to null and the caller keeps the current selection. + for (const item of items) { + const itemText = normalize( + (item.textContent ?? '') + ' ' + (item.getAttribute?.('aria-label') ?? ''), + ); + // Pro rows are skipped for non-Pro targets so "High" never resolves to + // "Pro". Two things lift this: an explicit TARGET_LEVEL of 'pro', and a + // legacy Pro-model menu (modelKind === 'pro'), whose rows are all Pro + // variants so excluding them would leave nothing to match. + if (TARGET_LEVEL !== 'pro' && modelKind !== 'pro' && hasToken(itemText, 'pro')) { + continue; + } + if ( + matchesLevel(item.textContent ?? '') || + matchesLevel(item.getAttribute?.('aria-label') ?? '') + ) { + return item; + } + } + if (TARGET_LEVEL === 'extra-high') { + // Older Chinese layouts used bare 高 for the highest non-Pro effort. + // Keep it only as a second-pass exact fallback so a current 高 row can + // never win before the primary 极高 row. + for (const item of items) { + const itemText = normalize(item.textContent ?? ''); + const ariaLabel = normalize(item.getAttribute?.('aria-label') ?? ''); + if (itemText === '高' || ariaLabel === '高') return item; + } + } + return null; + }; + // Menu-shape heuristic only. This reads the whole menu's textContent, where + // adjacent row labels concatenate without a separator ("Pro StandardPro + // Extended"), so word-boundary matching does not apply here — substring is + // deliberate. Row-level classification uses matchesLevel/matchesTokens. + const countEffortLevels = (menu) => { + const text = normalize(menu?.textContent ?? ''); + let hits = 0; + for (const tokens of Object.values(LEVEL_TOKENS)) { + if (tokens.some((token) => text.includes(normalize(token)))) hits += 1; + } + return hits; + }; + const isEffortMenu = (menu) => { + if (!isVisible(menu)) return false; + if (menu.getAttribute?.('data-testid') === 'composer-intelligence-picker-content') return true; + if (menu.querySelector?.(INTELLIGENCE_MENU_SELECTOR)) return true; + const label = menu.querySelector?.('.__menu-label, [class*="menu-label"]'); + const labelText = normalize(label?.textContent ?? ''); + return ( + labelText.includes('intelligen') || + labelText.includes('thinking time') || + labelText.includes('thinking effort') || + labelText.includes('denkdauer') || + labelText.includes('denkzeit') || + countEffortLevels(menu) >= 2 + ); + }; + const isProEffortMenu = (menu) => { + if (!isVisible(menu)) return false; + const text = normalize(menu?.textContent ?? ''); + // Aggregate menu text, so plain substring only (see countEffortLevels). + return ( + text.includes('pro standard') && + (text.includes('pro extended') || text.includes('pro erweitert')) + ); + }; + const controlledMenu = (trigger) => { + const id = trigger?.getAttribute?.('aria-controls'); + if (!id) return null; + const menu = document.getElementById?.(id); + return isEffortMenu(menu) ? menu : null; + }; + const findVisibleEffortMenu = (trigger) => { + const controlled = controlledMenu(trigger); + if (controlled) return controlled; + for (const menu of document.querySelectorAll(MENU_CONTAINER_SELECTOR)) { + if (isEffortMenu(menu)) return menu; + } + return null; + }; + const controlledProEffortMenu = (trigger) => { + const id = trigger?.getAttribute?.('aria-controls'); + if (!id) return null; + const menu = document.getElementById?.(id); + return isProEffortMenu(menu) ? menu : null; + }; + const findVisibleProEffortMenu = (trigger) => { + const controlled = controlledProEffortMenu(trigger); + if (controlled) return controlled; + for (const menu of document.querySelectorAll(MENU_CONTAINER_SELECTOR)) { + if (isProEffortMenu(menu)) return menu; + } + return null; + }; + const matchesProEffortLevel = (node) => { + const text = normalize( + (node?.textContent ?? '') + ' ' + (node?.getAttribute?.('aria-label') ?? ''), + ); + if (TARGET_LEVEL === 'standard') { + return hasPhrase(text, 'pro') && hasPhrase(text, 'standard'); + } + if (TARGET_LEVEL === 'extended') { + return hasPhrase(text, 'pro') && hasExtendedWord(text); + } + return false; + }; + const findProEffortOptionInMenu = (menu) => { + for (const item of menu.querySelectorAll(MENU_ITEM_SELECTOR)) { + if (matchesProEffortLevel(item)) return item; + } + return null; + }; + const freshComposerTrigger = (trigger) => { + if (!trigger?.matches?.('button.__composer-pill')) return null; + // React can replace the composer pill after an effort click. Keep using + // the captured node while it is live, but re-query once it is detached so + // verification does not read its stale pre-click label. + if (trigger.isConnected !== false) return trigger; + return findComposerEffortPill() || findModelButton() || trigger; + }; + const currentProEffortPillMatchesTarget = (trigger, modelKindOverride = null) => { + const button = freshComposerTrigger(trigger) || findModelButton(); + if ((modelKindOverride || TARGET_MODEL_KIND || modelKindFromNode(button)) !== 'pro') { + return false; + } + const label = normalize(button?.textContent ?? ''); + if (TARGET_LEVEL === 'standard') { + return hasToken(label, 'pro') && !hasExtendedWord(label); + } + if (TARGET_LEVEL === 'extended') { + return hasToken(label, 'pro') && hasExtendedWord(label); + } + return false; + }; + const currentEffortPillMatchesTarget = (trigger, modelKindOverride = null) => { + if (currentProEffortPillMatchesTarget(trigger, modelKindOverride)) return true; + const button = freshComposerTrigger(trigger) || findModelButton(); + const normalizedLabel = normalize( + (button?.textContent ?? '') + ' ' + (button?.getAttribute?.('aria-label') ?? ''), + ); + // No 5.6-heavy "a Pro pill counts as heavy" shortcut here: that would also + // make post-click verification pass on an unchanged Pro pill. selectAndVerify + // handles the already-on-Pro case explicitly before any click. + if ((modelKindOverride || TARGET_MODEL_KIND || modelKindFromNode(button)) === 'pro') { + return false; + } + return matchesLevel(normalizedLabel); + }; + const selectAndVerify = async (trigger, findOption, modelKindOverride = null) => { + const triggerModelKind = + modelKindOverride || + TARGET_MODEL_KIND || + modelKindFromNode(trigger) || + effectiveTargetModelKind(); + const option = findOption(); + if (!option && TARGET_IS_GPT56_MODEL && TARGET_LEVEL === 'heavy') { + // GPT-5.6 has no "heavy" tier: Pro is the closest thing. Accept a pill that + // is already on Pro as satisfying the request, but never click Pro to get + // there, and never let this stand in for post-click verification. + const pill = freshComposerTrigger(trigger) || findModelButton(); + const pillLabel = normalize( + (pill?.textContent ?? '') + ' ' + (pill?.getAttribute?.('aria-label') ?? ''), + ); + if ( + hasToken(pillLabel, 'pro') || + currentEffortPillMatchesTarget(trigger, triggerModelKind) + ) { + closeOpenMenus(); + return { status: 'already-selected', label: trigger.textContent?.trim?.() || null }; + } + } + if (!option) return failure('option-not-found', { modelKind: triggerModelKind }); + const label = option.textContent?.trim?.() || null; + if (optionIsSelected(option)) { + closeOpenMenus(); + return { status: 'already-selected', label }; + } + + dispatchClickSequence(option); + await sleep(STEP_WAIT_MS); + const refreshed = findOption(); + if (refreshed && optionIsSelected(refreshed)) { + closeOpenMenus(); + return { status: 'switched', label: refreshed.textContent?.trim?.() || label }; + } + if (currentEffortPillMatchesTarget(trigger, triggerModelKind)) { + closeOpenMenus(); + return { status: 'switched', label }; + } + + const reopenTrigger = freshComposerTrigger(trigger) || trigger; + if (!refreshed && reopenTrigger?.getAttribute?.('aria-expanded') !== 'true') { + dispatchClickSequence(reopenTrigger); + await sleep(INITIAL_WAIT_MS); + } + const deadline = performance.now() + 2000; + while (performance.now() < deadline) { + const selected = findOption(); + if (selected && optionIsSelected(selected)) { + closeOpenMenus(); + return { status: 'switched', label: selected.textContent?.trim?.() || label }; + } + if (currentEffortPillMatchesTarget(trigger, triggerModelKind)) { + closeOpenMenus(); + return { status: 'switched', label }; + } + await sleep(100); + } + const result = failure('selection-unverified', { modelKind: triggerModelKind }); + closeOpenMenus(); + return result; + }; + const selectProEffortFromSubmenu = async () => { + if (TARGET_MODEL_KIND !== 'pro' || (TARGET_LEVEL !== 'standard' && TARGET_LEVEL !== 'extended')) { + return null; + } + const trigger = document.querySelector(PRO_EFFORT_TRIGGER_SELECTOR); + if (!trigger) { + return null; + } + dispatchHoverSequence(trigger); + if (trigger.getAttribute?.('aria-expanded') !== 'true') { + dispatchClickSequence(trigger); + } + const deadline = performance.now() + MAX_WAIT_MS; + while (performance.now() < deadline) { + const menu = findVisibleProEffortMenu(trigger); + if (menu) { + return selectAndVerify(trigger, () => { + const currentMenu = findVisibleProEffortMenu(trigger); + return currentMenu ? findProEffortOptionInMenu(currentMenu) : null; + }); + } + await sleep(100); + } + return null; + }; + + // ---------- Unified Intelligence picker: Advanced -> Effort submenu ---------- + // A newer ChatGPT layout replaces the flat effort rows with a "power" slider + // (simple view) plus an "Advanced" view holding two submenu openers: Model and + // Effort. The tier rows only exist inside the Effort submenu, so the flat scan + // of the top-level menu finds nothing and we must expand and descend. + const ADVANCED_VIEW_SELECTOR = '[data-testid="composer-model-picker-slider-advanced-view"]'; + const SUBMENU_OPENER_SELECTOR = '[role="menuitem"][aria-haspopup="menu"]'; + const nodeLabel = (node) => + normalize((node?.getAttribute?.('aria-label') ?? '') + ' ' + (node?.textContent ?? '')); + // Row labels in this menu concatenate without separators ("EffortHigh"), so + // token matching cannot be used here — substring is deliberate, as in + // countEffortLevels above. + const ADVANCED_WORDS = [ + 'advanced', 'erweitert', '高级', '詳細設定', '詳細表示', + 'avanzado', 'avancado', 'avance', + ]; + const EFFORT_WORDS = [ + 'effort', 'aufwand', '强度', '努力', '推論レベル', + 'esfuerzo', 'esforco', 'sforzo', 'inspanning', 'wysilek', + ]; + const containsAny = (label, words) => words.some((word) => label.includes(word)); + const findAdvancedToggle = (menu) => { + for (const item of (menu || document).querySelectorAll('[role="menuitem"]')) { + if (!isVisible(item)) continue; + if (containsAny(nodeLabel(item), ADVANCED_WORDS)) return item; + } + return null; + }; + // Verify the shape rather than trusting the selector: a tier row must never be + // mistaken for the opener, or hovering it would silently change the effort. + const isSubmenuOpener = (node) => + node?.getAttribute?.('aria-haspopup') === 'menu' && + (node?.getAttribute?.('role') ?? '') === 'menuitem'; + // The Effort opener's label is the word "Effort" plus the tier it currently sits + // on ("EffortHigh"). Positive identification only: the sibling Model opener has + // the identical shape and can read "ModelGPT-5.6 Pro", so guessing from tier + // words would pick it and then click model rows as if they were efforts. An + // unrecognised language yields no opener, and the caller fails instead of + // gambling on the wrong control. + const findEffortSubmenuOpener = (menu) => { + const scope = menu?.querySelector?.(ADVANCED_VIEW_SELECTOR) || menu || document; + for (const item of scope.querySelectorAll(SUBMENU_OPENER_SELECTOR)) { + if (!isVisible(item) || !isSubmenuOpener(item)) continue; + if (containsAny(nodeLabel(item), EFFORT_WORDS)) return item; + } + return null; + }; + // countEffortLevels reads aggregate menu text, where one "Extra High" row scores + // twice (once as "high", once as "extra high"). Count distinct levels across + // distinct rows instead, so ">= 2" really means two selectable tiers. + const countDistinctTierRows = (menu) => { + if (!menu) return 0; + const matched = new Set(); + for (const row of menu.querySelectorAll(MENU_ITEM_SELECTOR)) { + if (isSubmenuOpener(row)) continue; + const text = nodeLabel(row); + for (const [level, tokens] of Object.entries(LEVEL_TOKENS)) { + if (matchesTokens(text, tokens)) matched.add(level); + } + } + return matched.size; + }; + const resolveSubmenuFor = (opener, parentMenu) => { + const id = opener?.getAttribute?.('aria-controls'); + if (id) { + const node = document.getElementById?.(id); + if (isVisible(node) && countDistinctTierRows(node) >= 2) return node; + } + let best = null; + for (const menu of document.querySelectorAll(MENU_CONTAINER_SELECTOR)) { + if (menu === parentMenu || menu.contains?.(opener) || !isVisible(menu)) continue; + const hits = countDistinctTierRows(menu); + if (hits >= 2 && (!best || hits > best.hits)) best = { menu, hits }; + } + return best?.menu ?? null; + }; + const selectEffortFromAdvancedSubmenu = async (parentMenu, modelKindOverride = null) => { + if (!parentMenu) return null; + // React can mount the advanced view well after the click under load, so poll + // for the opener to the same deadline the submenu gets instead of assuming it + // rendered within one STEP_WAIT_MS. Re-expand if the toggle collapses again. + let opener = null; + const openerDeadline = performance.now() + MAX_WAIT_MS; + while (!opener && performance.now() < openerDeadline) { + const toggle = findAdvancedToggle(parentMenu); + if (toggle && toggle.getAttribute?.('aria-expanded') === 'false') { + dispatchClickSequence(toggle); + await sleep(STEP_WAIT_MS); + } + opener = findEffortSubmenuOpener(parentMenu); + if (opener) break; + await sleep(100); + } + if (!opener) return null; + dispatchHoverSequence(opener); + if (opener.getAttribute?.('aria-expanded') !== 'true') { + dispatchClickSequence(opener); + } + const deadline = performance.now() + MAX_WAIT_MS; + while (performance.now() < deadline) { + const submenu = resolveSubmenuFor(opener, parentMenu); + if (submenu) { + return selectAndVerify( + opener, + () => { + const current = resolveSubmenuFor(opener, parentMenu); + return current ? findOptionInMenu(current, modelKindOverride) : null; + }, + modelKindOverride, + ); + } + await sleep(100); + } + return null; + }; + + // Current ChatGPT exposes a standalone Pro or Thinking composer pill whose + // controlled menu contains the effort levels. Prefer this ownership boundary + // before probing older model-picker layouts. + const COMPOSER_EFFORT_PILL_SELECTORS = [ + 'form button.__composer-pill', + '[data-testid="composer-footer-actions"] button.__composer-pill', + '.__composer-pill-composite button.__composer-pill', + ]; + const findComposerEffortPill = () => { + const seen = new Set(); + let gpt56Fallback = null; + for (const selector of COMPOSER_EFFORT_PILL_SELECTORS) { + for (const button of document.querySelectorAll(selector)) { + if (seen.has(button) || !isVisible(button)) continue; + seen.add(button); + if (button.getAttribute?.('data-testid') === 'model-switcher-dropdown-button') continue; + const label = normalize( + (button.getAttribute?.('aria-label') ?? '') + ' ' + + (button.getAttribute?.('data-testid') ?? '') + ' ' + + (button.textContent ?? ''), + ); + if ( + (TARGET_MODEL_KIND === 'pro' && hasToken(label, 'pro') && !hasToken(label, 'thinking')) || + (TARGET_MODEL_KIND === 'thinking' && hasToken(label, 'thinking') && !hasToken(label, 'pro')) || + (!TARGET_MODEL_KIND && hasToken(label, 'thinking')) || + (button.matches?.('button.__composer-pill') && matchesAnyEffortLevel(label)) + ) { + return button; + } + if ( + TARGET_IS_GPT56_MODEL && + button.matches?.('button.__composer-pill') && + normalize(button.textContent ?? '') === 'pro' + ) { + gpt56Fallback ||= button; + } + } + } + return gpt56Fallback; + }; + let composerEffortPill = findComposerEffortPill(); + let modelBtn = findModelButton(); + const modelKindFromLegacyTrailing = (trailing) => { + const row = trailing.closest?.( + '[role="menuitem"], [role="menuitemradio"], [data-radix-collection-item]', + ); + const idText = normalize( + (row?.getAttribute?.('data-testid') ?? '') + ' ' + + (trailing.getAttribute?.('data-testid') ?? '') + ); + if (!idText.includes('model switcher')) return null; + const modelPart = normalize(idText.replace(/\\bthinking effort\\b.*$/, '')); + if (hasToken(modelPart, 'pro')) return 'pro'; + if (hasToken(modelPart, 'thinking')) return 'thinking'; + if (hasToken(modelPart, 'instant')) return 'instant'; + return null; + }; + const legacyEffortOwnerIsReady = () => { + if ( + TARGET_MODEL_KIND === 'pro' && + TARGET_LEVEL === 'extended' && + isVisible(document.querySelector(INTELLIGENCE_MENU_SELECTOR)) + ) { + return true; + } + const expectedKind = TARGET_MODEL_KIND || modelKindFromNode(modelBtn); + return Boolean( + expectedKind && + findTrailingButtons().some( + (button) => isVisible(button) && modelKindFromLegacyTrailing(button) === expectedKind, + ), + ); + }; + let attemptedModelButton = + modelBtn?.getAttribute?.('aria-expanded') === 'true' ? modelBtn : null; + const effortOwnerDeadline = performance.now() + MAX_WAIT_MS; + while (!composerEffortPill && performance.now() < effortOwnerDeadline) { + if ( + modelBtn && + attemptedModelButton !== modelBtn && + modelBtn.getAttribute?.('aria-expanded') !== 'true' + ) { + dispatchClickSequence(modelBtn); + attemptedModelButton = modelBtn; + await sleep(INITIAL_WAIT_MS); + } + if (modelBtn && legacyEffortOwnerIsReady()) break; + await sleep(100); + composerEffortPill = findComposerEffortPill(); + modelBtn = findModelButton(); + if (modelBtn?.getAttribute?.('aria-expanded') === 'true') { + attemptedModelButton = modelBtn; + } + } + if (composerEffortPill) { + if (attemptedModelButton && attemptedModelButton !== composerEffortPill) closeOpenMenus(); + // In the unified Intelligence picker the composer pill shows the current + // EFFORT ("Pro", "High"), not the model. Reading a Pro *model* out of it would + // lift the Pro-row exclusion in findOptionInMenu and let a lower-tier request + // settle on Pro. Only a pill naming a tier and nothing else qualifies: legacy + // pills read "Pro Extended" (model + effort) and must keep naming their model, + // or a Pro Extended user asking for extended would be moved down to High. + const pillLabel = normalize( + (composerEffortPill.getAttribute?.('aria-label') ?? '') + + ' ' + + (composerEffortPill.textContent ?? ''), + ); + const pillIsBareEffortTier = Object.values(TARGET_LEVEL_TOKENS).some((tokens) => + tokens.some((token) => normalize(token) === pillLabel), + ); + const pillNamesEffortNotModel = + TARGET_IS_GPT56_MODEL || + (pillIsBareEffortTier && Boolean(document.querySelector(INTELLIGENCE_MENU_SELECTOR))); + const composerModelKind = + TARGET_MODEL_KIND || + (pillNamesEffortNotModel ? 'versioned' : modelKindFromNode(composerEffortPill)); + if (composerEffortPill.getAttribute?.('aria-expanded') !== 'true') { + dispatchClickSequence(composerEffortPill); + await sleep(INITIAL_WAIT_MS); + } + const deadline = performance.now() + MAX_WAIT_MS; + while (performance.now() < deadline) { + const menu = findVisibleEffortMenu(composerEffortPill); + if (menu) { + const proEffortResult = await selectProEffortFromSubmenu(); + if (proEffortResult) { + return proEffortResult; + } + // Flat rows win when present; only descend into Advanced -> Effort when + // this menu has no matching tier of its own (the slider layout). + if (!findOptionInMenu(menu, composerModelKind)) { + const advancedResult = await selectEffortFromAdvancedSubmenu(menu, composerModelKind); + if (advancedResult) { + return advancedResult; + } + } + return selectAndVerify( + composerEffortPill, + () => { + const currentMenu = findVisibleEffortMenu(composerEffortPill); + return currentMenu ? findOptionInMenu(currentMenu, composerModelKind) : null; + }, + composerModelKind, + ); + } + await sleep(100); + } + const result = failure('menu-not-found', { + modelKind: composerModelKind, + }); + closeOpenMenus(); + return result; + } + + // Older ChatGPT layouts attach effort controls to rows inside the model + // picker. Keep these compatibility paths after the standalone pill owner. + const findEffortRow = (node) => { + let current = node instanceof HTMLElement ? node.parentElement : null; + while (current && current !== document.body) { + if (current.getAttribute?.('data-model-picker-thinking-effort-row') === 'true') { + return current; + } + current = current.parentElement; + } + return null; + }; + const rowIsSelected = (row) => { + if (!(row instanceof HTMLElement)) return false; + const modelItem = row.querySelector('[data-model-picker-thinking-effort-menu-item="true"], [role="menuitemradio"]'); + if (optionIsSelected(modelItem)) return true; + return Boolean( + row.querySelector( + '[aria-checked="true"], [aria-selected="true"], [aria-current="true"], [data-selected="true"], [data-state="checked"], [data-state="selected"], [data-state="on"]', + ), + ); + }; + const rowForTrailing = (trailing) => + trailing.closest('[role="menuitem"], [role="menuitemradio"], [data-radix-collection-item]'); + const rowTextForTrailing = (trailing) => { + const row = rowForTrailing(trailing) || findEffortRow(trailing); + return normalize( + (row?.getAttribute?.('aria-label') ?? '') + ' ' + + (row?.getAttribute?.('data-testid') ?? '') + ' ' + + (row?.textContent ?? '') + ' ' + + (trailing.getAttribute?.('aria-label') ?? '') + ' ' + + (trailing.getAttribute?.('data-testid') ?? '') + ); + }; + const modelKindFromTrailing = modelKindFromLegacyTrailing; + const trailingMatchesTargetModelKind = (trailing) => { + if (!TARGET_MODEL_KIND) return false; + const idKind = modelKindFromTrailing(trailing); + if (idKind) return idKind === TARGET_MODEL_KIND; + const text = rowTextForTrailing(trailing); + if (TARGET_MODEL_KIND === 'pro') { + return hasToken(text, 'pro') && !hasToken(text, 'thinking'); + } + if (TARGET_MODEL_KIND === 'thinking') { + return hasToken(text, 'thinking') && !hasToken(text, 'pro'); + } + if (TARGET_MODEL_KIND === 'instant') { + return hasToken(text, 'instant') && !hasToken(text, 'thinking') && !hasToken(text, 'pro'); + } + return false; + }; + const pickSingleStableTrailing = (trailings) => { + const visible = trailings.filter((trailing) => isVisible(trailing)); + return visible.length === 1 ? visible[0] : null; + }; + const pickTrailingForCurrentModel = () => { + const trailings = findTrailingButtons(); + if (trailings.length === 0) return null; + if (trailings.length === 1) return trailings[0]; + // Prefer the trailing button whose model row is currently selected. + for (const t of trailings) { + const row = findEffortRow(t); + if (rowIsSelected(row)) return t; + } + if (TARGET_MODEL_KIND) { + const targetTrailings = trailings.filter((t) => trailingMatchesTargetModelKind(t)); + return pickSingleStableTrailing(targetTrailings) || KIND_NOT_FOUND; + } + return null; + }; + + const modelButtonDeadline = performance.now() + MAX_WAIT_MS; + while (!modelBtn && performance.now() < modelButtonDeadline) { + await sleep(100); + modelBtn = findModelButton(); + } + if (!modelBtn) { + return failure('chip-not-found'); + } + // Open model menu (idempotent — leaves it open if already open). + if ( + modelBtn.getAttribute('aria-expanded') !== 'true' && + !legacyEffortOwnerIsReady() + ) { + dispatchClickSequence(modelBtn); + await sleep(INITIAL_WAIT_MS); + } + + // ---------- COMPATIBILITY UI: unified "Intelligence" effort picker ---------- + // One observed ChatGPT layout replaced the per-model trailing buttons with a single + // "Intelligence" menu ([data-testid="composer-intelligence-picker-content"]), + // whose role="menuitemradio" rows are the effort tiers. We verify the checked + // radio instead of trusting the composer-pill label; non-Pro targets also + // explicitly skip Pro rows before matching effort labels. + if (TARGET_MODEL_KIND === 'pro' && TARGET_LEVEL === 'extended') { + const matchesProExtended = (node) => { + const text = normalize( + (node?.textContent ?? '') + ' ' + (node?.getAttribute?.('aria-label') ?? ''), + ); + return hasPhrase(text, 'pro') && hasExtendedWord(text); + }; + const findProExtendedOption = () => { + const menu = document.querySelector(INTELLIGENCE_MENU_SELECTOR); + if (!isVisible(menu)) return null; + for (const item of menu.querySelectorAll( + '[role="menuitemradio"], [role="menuitem"], [role="option"]', + )) { + if (matchesProExtended(item)) return item; + } + return null; + }; + let proExtended = null; + const intelligenceDeadline = performance.now() + INTELLIGENCE_WAIT_MS; + while (performance.now() < intelligenceDeadline) { + proExtended = findProExtendedOption(); + if (proExtended) break; + await sleep(100); + } + if (proExtended) { + return selectAndVerify(modelBtn, findProExtendedOption); + } + // Intelligence menu absent (older UI) or its Pro Extended row is missing: + // fall through to the legacy trailing-button path below. + } + + let trailing = null; + const trailingDeadline = performance.now() + MAX_WAIT_MS; + while (performance.now() < trailingDeadline) { + trailing = pickTrailingForCurrentModel(); + if (trailing) break; + await sleep(100); + } + if (!trailing) { + const result = failure('chip-not-found'); + closeOpenMenus(); + return result; + } + if (trailing.kindNotFound) { + const result = failure('model-kind-not-found', { modelKind: TARGET_MODEL_KIND }); + closeOpenMenus(); + return result; + } + + dispatchClickSequence(trailing); + await sleep(STEP_WAIT_MS); + + // Resolve the effort submenu via aria-controls when ChatGPT exposes it, + // otherwise fall back to scanning newly opened menus for our level tokens. + const resolveEffortMenu = () => { + const id = trailing.getAttribute('aria-controls'); + if (id) { + const node = document.getElementById?.(id); + if (isEffortMenu(node)) return node; + } + const menus = document.querySelectorAll(MENU_CONTAINER_SELECTOR); + let best = null; + for (const menu of menus) { + if (menu === modelBtn || menu.contains(trailing)) continue; + if (!isVisible(menu)) continue; + const hits = countEffortLevels(menu); + if (hits >= 2 && (!best || hits > best.hits)) best = { menu, hits }; + } + return best?.menu ?? null; + }; + + let effortMenu = null; + const effortDeadline = performance.now() + MAX_WAIT_MS; + while (performance.now() < effortDeadline) { + effortMenu = resolveEffortMenu(); + if (effortMenu) break; + await sleep(100); + } + if (!effortMenu) { + const result = failure('menu-not-found'); + closeOpenMenus(); + return result; + } + + return selectAndVerify(trailing, () => { + const currentMenu = resolveEffortMenu(); + return currentMenu ? findOptionInMenu(currentMenu) : null; + }); + })()`; +} +export function buildThinkingTimeExpressionForTest(level = "extended", desiredModel) { + return buildThinkingTimeExpression(level, desiredModel); +} +function inferThinkingTargetModelKind(desiredModel) { + const normalized = (desiredModel ?? "") + .toLowerCase() + .replace(/[^a-z0-9]+/g, " ") + .replace(/\s+/g, " ") + .trim(); + if (!normalized) + return null; + const tokens = normalized.split(" "); + if (tokens.includes("pro")) + return "pro"; + if (tokens.includes("thinking")) + return "thinking"; + if (tokens.includes("instant")) + return "instant"; + return null; +} +export function inferThinkingTargetModelKindForTest(desiredModel) { + return inferThinkingTargetModelKind(desiredModel); +} diff --git a/tests/test_chatgpt_oracle_compat.py b/tests/test_chatgpt_oracle_compat.py index 281d91cf..0a4bd827 100644 --- a/tests/test_chatgpt_oracle_compat.py +++ b/tests/test_chatgpt_oracle_compat.py @@ -18,6 +18,22 @@ PRISTINE_THINKING_TIME_0172 = ( Path(__file__).parent / "fixtures/oracle-0.17.2/thinkingTime.pristine.js" ).read_bytes() +PRISTINE_THINKING_TIME_0173 = ( + Path(__file__).parent / "fixtures/oracle-0.17.3/thinkingTime.pristine.js" +).read_bytes() +PRISTINE_0173_FIXTURES = { + "dist/src/browser/actions/assistantResponse.js": ( + Path(__file__).parent / "fixtures/oracle-0.17.3/assistantResponse.pristine.js" + ).read_bytes(), + "dist/src/browser/actions/thinkingTime.js": PRISTINE_THINKING_TIME_0173, + "dist/src/browser/actions/promptComposer.js": PRISTINE_PROMPT_COMPOSER.read_bytes(), + "dist/src/cli/browserConfig.js": ( + Path(__file__).parent / "fixtures/oracle-0.17.3/browserConfig.pristine.js" + ).read_bytes(), + "dist/src/browser/index.js": ( + Path(__file__).parent / "fixtures/oracle-0.17.3/browserIndex.pristine.js" + ).read_bytes(), +} def load_compat(): @@ -51,7 +67,11 @@ def run_gpt56_pro_diagnostic_recovery_case( target = package / relative target.parent.mkdir(parents=True) target.write_bytes( - PRISTINE_THINKING_TIME_0172 if version == "0.17.2" else PRISTINE_THINKING_TIME + { + "0.17.1": PRISTINE_THINKING_TIME, + "0.17.2": PRISTINE_THINKING_TIME_0172, + "0.17.3": PRISTINE_THINKING_TIME_0173, + }[version] ) compat._apply_patch(package, compat.patch_root(version) / "thinkingTime.strict.patch") source = "\n".join(target.read_text(encoding="utf-8").splitlines()[3:]) @@ -238,7 +258,11 @@ def run_gpt56_primary_css_visibility_cases( target = package / relative target.parent.mkdir(parents=True) target.write_bytes( - PRISTINE_THINKING_TIME_0172 if version == "0.17.2" else PRISTINE_THINKING_TIME + { + "0.17.1": PRISTINE_THINKING_TIME, + "0.17.2": PRISTINE_THINKING_TIME_0172, + "0.17.3": PRISTINE_THINKING_TIME_0173, + }[version] ) compat._apply_patch(package, compat.patch_root(version) / "thinkingTime.strict.patch") source = "\n".join(target.read_text(encoding="utf-8").splitlines()[3:]) @@ -335,14 +359,21 @@ def run_gpt56_primary_css_visibility_cases( return {str(label): str(status) for label, status in json.loads(completed.stdout.strip().splitlines()[-1]).items()} -def run_gpt56_0172_advanced_owner_cases(tmp_path: Path) -> dict[str, dict[str, int | str]]: +def run_gpt56_0172_advanced_owner_cases( + tmp_path: Path, *, version: str = "0.17.2" +) -> dict[str, dict[str, int | str]]: compat = load_compat() relative = Path("dist/src/browser/actions/thinkingTime.js") package = tmp_path / "package" target = package / relative target.parent.mkdir(parents=True) - target.write_bytes(PRISTINE_THINKING_TIME_0172) - compat._apply_patch(package, compat.patch_root("0.17.2") / "thinkingTime.strict.patch") + target.write_bytes( + { + "0.17.2": PRISTINE_THINKING_TIME_0172, + "0.17.3": PRISTINE_THINKING_TIME_0173, + }[version] + ) + compat._apply_patch(package, compat.patch_root(version) / "thinkingTime.strict.patch") source = "\n".join(target.read_text(encoding="utf-8").splitlines()[3:]) scenarios = json.dumps([ {"label": "pro-stable", "level": "heavy", "power": 5, "observations": [True, True]}, @@ -2042,8 +2073,8 @@ def test_oracle_0171_has_the_exact_eight_hash_gated_compatibility_patches() -> N compat = load_compat() contracts = compat.VERSION_PATCHES["0.17.1"] - assert compat.SUPPORTED_VERSION == "0.17.2" - assert compat.RECOVERABLE_VERSIONS == ("0.16.1", "0.17.0", "0.17.1", "0.17.2") + assert compat.SUPPORTED_VERSION == "0.17.3" + assert compat.RECOVERABLE_VERSIONS == ("0.16.1", "0.17.0", "0.17.1", "0.17.2", "0.17.3") assert "dist/src/browser/actions/modelSelection.js" not in contracts assert { path: (contract["pristine"], contract["patched"]) @@ -2359,7 +2390,242 @@ def test_oracle_0172_pro_diagnostic_proof_is_visible_stable_and_same_picker( assert mismatch_logs and mismatch_logs[0].startswith("[browser] Model picker diagnostic:") -def test_copy_profile_recovery_patch_reuses_only_the_persisted_profile_seed() -> None: +def test_oracle_0173_has_exact_hash_gated_patches_and_preserves_0172_recovery( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, +) -> None: + compat = load_compat() + contracts = compat.VERSION_PATCHES["0.17.3"] + old = compat.VERSION_PATCHES["0.17.2"]["dist/src/browser/actions/thinkingTime.js"] + + assert compat.SUPPORTED_VERSION == "0.17.3" + assert compat.RECOVERABLE_VERSIONS == ("0.16.1", "0.17.0", "0.17.1", "0.17.2", "0.17.3") + assert { + path: (contract["pristine"], contract["patched"]) + for path, contract in contracts.items() + } == { + "dist/src/browser/chromeLifecycle.js": ("312b45c44d4cd69a3a057e7bd1584b58182b4b37bc88f6ce6c7d11e216267c81", "61440e467d51031efb7bfc319aef05de7c9061585e5eec148d0e353938eb2093"), + "dist/src/browser/recoverConversation.js": ("d7e39d21acf07e6d227e761944519e11cd8d93930629cc87555d7de75a42d1ca", "cc2a036f6e2409ae7edceee1f381a5062cd6cc5cd1618af465a1b384081ed69e"), + "dist/src/browser/profileCopy.js": ("06c692861f8a4c1a8769f957b9c582426a13bf4972262c47c1f24a87b239064f", "71459a25b7c46f57bae6f23a5498301f6f6a1d39addf0c1cd4eee1d99b03372c"), + "dist/src/cli/browserConfig.js": ("13b304a1b41cbc85257d9340a620bccd4d18bc52a36285ba46c2f72af84f0f84", "a76f338e1afb3573c3436cd261ccbcefacd9c879c71a45e110cf7a3602a06d22"), + "dist/src/browser/index.js": ("421f15c6693799571d586d80b7fc35b10492a63acf78d901e21786bf6ec71a90", "cb7b828902163bac941f5890f78edd136cf723e17e262c1347e2843df20c3e44"), + "dist/src/browser/actions/assistantResponse.js": ("93d2465ed7dce43d8093a91bada7656bc9ba7ba3729d2fcc43229fa8aa6e36de", "aff8f7cb4e926b0e56c4b02456f54983b14fffa9e01f595fed4fd44a338d41f4"), + "dist/src/browser/actions/promptComposer.js": ("db090a5fb6d13c4c88a68b5e474a53a19c3857295a64c3ba4a0eef1868d06000", "3767d8a6702e42191e8195641ad2f0834882bed9cda1362a723c906249402d96"), + "dist/src/browser/actions/thinkingTime.js": ("6ff4420e81570f6c0a4e277bdd993caf66739c3f633a7cdb733ed645bec2acda", "98724eaf24e27d6f75b3eb7795c49650aee0be6a9a3698e09882c6d3a06c3185"), + } + assert all((compat.patch_root("0.17.3") / value["patch"]).is_file() for value in contracts.values()) + # 0.17.3 was never deployed: no legacy migration lists exist for it. + for contract in contracts.values(): + assert "legacy_patched" not in contract + assert "legacy_patch" not in contract + assert "legacy_patches" not in contract + + relative = Path("dist/src/browser/actions/thinkingTime.js") + package = tmp_path / "package" + target = package / relative + target.parent.mkdir(parents=True) + target.write_bytes(PRISTINE_THINKING_TIME_0173) + (package / "package.json").write_text('{"version":"0.17.3"}', encoding="utf-8") + monkeypatch.setattr( + compat, + "VERSION_PATCHES", + {"0.17.3": {str(relative).replace("\\", "/"): contracts[str(relative).replace("\\", "/")]}}, + ) + + result = compat.ensure_oracle_compatibility("oracle 0.17.3", package_root=package) + + assert result["changed"] == [str(relative).replace("\\", "/")] + assert digest(target.read_bytes()) == contracts[str(relative).replace("\\", "/")]["patched"] + compat._apply_patch( + package, + compat.patch_root("0.17.3") / "thinkingTime.strict.patch", + reverse=True, + ) + assert digest(target.read_bytes()) == contracts[str(relative).replace("\\", "/")]["pristine"] + compat._apply_patch(package, compat.patch_root("0.17.3") / "thinkingTime.strict.patch") + assert digest(target.read_bytes()) == contracts[str(relative).replace("\\", "/")]["patched"] + source = target.read_text(encoding="utf-8") + assert "selectEffortFromAdvancedSubmenu" in source + assert "collectGpt56PowerProofDiagnostic" in source + assert "consecutive >= 2" in source + # The 0.17.2 contract stays immutable and recoverable. + assert old["patched"] == "77d00dadc13e77bd54b0254a7086a1c6d43a39deacd4f489da808f8d6334ab53" + assert old["legacy_patch"] == "thinkingTime.strict.pre-picker-menu-scope.patch" + assert old["legacy_patches"]["decfb6830bf20cbfdc8ac0460b7d196599dc510ed8698e062b86161ee52d8829"] == ( + "thinkingTime.strict.pre-diagnostic-range-validation.patch" + ) + + +def test_oracle_0173_pro_diagnostic_proof_is_visible_stable_and_same_picker( + tmp_path: Path, +) -> None: + hidden_calls, hidden_error, _ = run_gpt56_pro_diagnostic_recovery_case( + tmp_path / "hidden", states=[True, True], hidden_ancestor=True, version="0.17.3" + ) + stable_calls, stable_error, stable_logs = run_gpt56_pro_diagnostic_recovery_case( + tmp_path / "stable", states=[True, True], version="0.17.3" + ) + sibling_calls, sibling_error, sibling_logs = run_gpt56_pro_diagnostic_recovery_case( + tmp_path / "sibling-slider", + states=[True, True], + sibling_slider=True, + version="0.17.3", + ) + transparent_calls, transparent_error, transparent_logs = run_gpt56_pro_diagnostic_recovery_case( + tmp_path / "transparent-slider", + states=[True, True], + sibling_slider=True, + transparent_slider=True, + version="0.17.3", + ) + mismatch_calls, mismatch_error, mismatch_logs = run_gpt56_pro_diagnostic_recovery_case( + tmp_path / "control-text-mismatch", + states=[True, True], + sibling_slider=True, + transparent_slider=True, + control_power="2", + version="0.17.3", + ) + + assert hidden_calls == stable_calls == sibling_calls == transparent_calls == mismatch_calls == 2 + assert "refusing to submit" in str(hidden_error) + assert stable_error is None + assert stable_logs == ["[browser] Thinking time: Power 5 of 5 (Pro) (already selected)"] + assert sibling_error is None + assert sibling_logs == ["[browser] Thinking time: Power 5 of 5 (Pro) (already selected)"] + assert transparent_error is None + assert transparent_logs == ["[browser] Thinking time: Power 5 of 5 (Pro) (already selected)"] + assert "refusing to submit" in str(mismatch_error) + assert mismatch_logs and mismatch_logs[0].startswith("[browser] Model picker diagnostic:") + + +def test_oracle_0173_generated_advanced_owner_proof_is_stable_and_bound( + tmp_path: Path, +) -> None: + results = run_gpt56_0172_advanced_owner_cases(tmp_path, version="0.17.3") + + assert results["pro-stable"] == {"status": "already-selected", "observations": 2} + assert results["power4-owned"] == {"status": "already-selected", "observations": 2} + assert results["live-sibling-slider"] == {"status": "already-selected", "observations": 2} + assert results["extra-high-sibling-slider"] == {"status": "already-selected", "observations": 2} + assert results["extra-high-self-transparent-slider"] == {"status": "already-selected", "observations": 2} + assert results["power5-zero-based"] == {"status": "already-selected", "observations": 2} + assert results["legacy-one-based"] == {"status": "already-selected", "observations": 2} + assert results["nested-controlled-menu"] == {"status": "already-selected", "observations": 2} + for label in ( + "ancestor-display-none", + "ancestor-opacity-zero", + "aria-hidden", + "split-picker", + "one-observation-then-lost", + "power4-unrelated", + "power4-control-text-mismatch", + "zero-based-raw-out-of-range", + "malformed-raw", + "minimum-only", + "maximum-only", + "cross-menu-controlled-root", + ): + assert results[label]["status"] == "selection-unverified" + + +def test_oracle_0173_japanese_intelligence_labels_survive_in_patched_output( + tmp_path: Path, +) -> None: + compat = load_compat() + relative = Path("dist/src/browser/actions/thinkingTime.js") + contract = compat.VERSION_PATCHES["0.17.3"][str(relative).replace("\\", "/")] + package = tmp_path / "package" + target = package / relative + target.parent.mkdir(parents=True) + target.write_bytes(PRISTINE_THINKING_TIME_0173) + compat._apply_patch(package, compat.patch_root("0.17.3") / contract["patch"]) + assert digest(target.read_bytes()) == contract["patched"] + + patched = target.read_text(encoding="utf-8") + # Upstream 0.17.3 Japanese Intelligence effort controls must remain in the + # patched output after the local power-proof machinery is rebased on top. + assert "最速" in patched + assert "中程度" in patched + assert "非常に高い" in patched + assert "詳細設定" in patched + assert "詳細表示" in patched + assert "推論レベル" in patched + assert r"\\u3040-\\u30ff" in patched + assert "if (token === '高い' || token === '非常に高い')" in patched + # Local GPT-5.6 Sol power-proof machinery still present alongside. + assert "strictGpt56Effort" in patched + assert "collectGpt56PowerProofDiagnostic" in patched + assert "finishVerifiedSelection" in patched + + +def test_oracle_0173_upstream_placeholder_headless_and_cookie_policy_constructs_are_retained( + tmp_path: Path, +) -> None: + compat = load_compat() + + def patched_text(relative: str) -> str: + contract = compat.VERSION_PATCHES["0.17.3"][relative] + package = tmp_path / relative.split("/")[-1].removesuffix(".js") + target = package / Path(relative) + target.parent.mkdir(parents=True) + target.write_bytes(PRISTINE_0173_FIXTURES[relative]) + compat._apply_patch(package, compat.patch_root("0.17.3") / contract["patch"]) + assert compat.sha256_file(target) == contract["patched"] + return target.read_text(encoding="utf-8") + + response = patched_text("dist/src/browser/actions/assistantResponse.js") + # Upstream placeholder predicate: whole-string chrome-label consumption with a + # length cap, exported builder shared with the page expressions. + assert "isAnswerNowPlaceholderText" in response + assert "buildAnswerNowPlaceholderPredicateJs" in response + assert "chromeLabels" in response + assert "text.length > 60" in response + assert "sawGate && sawOwner" in response + assert "buildResponseObserverExpressionForTest" in response + + browser_config = patched_text("dist/src/cli/browserConfig.js") + # Upstream explicit browser-headless and manual-login cookie sync. + assert "headless: options.browserHeadless === true ? true : undefined" in browser_config + assert "manualLoginCookieSync: options.browserManualLoginCookieSync" in browser_config + assert "options.browserManualLoginCookieSync === true" in browser_config + assert 'options.browserHeadless ? "--browser-headless" : null' in browser_config + # Local copy-profile isolation authority retained. + assert "options.browserManualLogin = false" in browser_config + + browser_index = patched_text("dist/src/browser/index.js") + # Upstream cookie policy moved into the shared policies module. + assert 'import { shouldSyncBrowserCookies } from "./policies.js";' in browser_index + assert "shouldSyncBrowserCookies(config, {" in browser_index + # Local exact-session override retained. + assert "config = { ...config, manualLogin: false, manualLoginProfileDir: null }" in browser_index + assert "const startedAt = Date.now();" in browser_index + + profile_copy_patch = ( + compat.patch_root("0.17.3") / "profileCopy.patch" + ).read_text(encoding="utf-8") + assert 'process.platform === "win32"' in profile_copy_patch + + +def test_oracle_0173_prompt_composer_patch_reaches_deployed_semantic_hash( + tmp_path: Path, +) -> None: + compat = load_compat() + relative = "dist/src/browser/actions/promptComposer.js" + contract = compat.VERSION_PATCHES["0.17.3"][relative] + # The 0.17.0 fixture is byte-identical to the 0.17.3 pristine dist file. + assert digest(PRISTINE_PROMPT_COMPOSER.read_bytes()) == contract["pristine"] + + package = tmp_path / "package" + target = package / Path(relative) + target.parent.mkdir(parents=True) + shutil.copy2(PRISTINE_PROMPT_COMPOSER, target) + compat._apply_patch(package, compat.patch_root("0.17.3") / contract["patch"]) + assert digest(target.read_bytes()) == contract["patched"] + assert digest(target.read_bytes()) == ( + "3767d8a6702e42191e8195641ad2f0834882bed9cda1362a723c906249402d96" + ) + compat._apply_patch(package, compat.patch_root("0.17.3") / contract["patch"], reverse=True) + assert digest(target.read_bytes()) == contract["pristine"] compat = load_compat() contract = compat.VERSION_PATCHES["0.17.0"]["dist/src/browser/recoverConversation.js"] patch = ( diff --git a/tests/test_chatgpt_oracle_run.py b/tests/test_chatgpt_oracle_run.py index cfbbf64a..cf4452b8 100644 --- a/tests/test_chatgpt_oracle_run.py +++ b/tests/test_chatgpt_oracle_run.py @@ -54,7 +54,7 @@ def manifest(tmp_path: Path, *, test_profile: bool = True, **extra) -> Path: "oracle_command": [ "npx.cmd" if os.name == "nt" else "npx", "-y", - "@steipete/oracle@0.17.2", + "@steipete/oracle@0.17.3", ], } if test_profile and "copy_profile" not in extra: @@ -123,7 +123,7 @@ def pro_manifest(tmp_path: Path, prompt_text: str = "pro instructions", **extra) def version_runner(command, **kwargs): - return subprocess.CompletedProcess(command, 0, stdout="oracle 0.17.2\n", stderr="") + return subprocess.CompletedProcess(command, 0, stdout="oracle 0.17.3\n", stderr="") def recovery_version_runner(command, **kwargs): @@ -284,20 +284,20 @@ def forbidden(*args, **kwargs): assert result["ok"] is True -def test_version_resolution_allows_a_bounded_slow_valid_oracle_0172() -> None: +def test_version_resolution_allows_a_bounded_slow_valid_oracle_0173() -> None: runner = load_runner() captured = {} def slow_valid(command, **kwargs): captured["command"] = command captured["timeout"] = kwargs["timeout"] - return subprocess.CompletedProcess(command, 0, stdout="oracle 0.17.2\n", stderr="") + return subprocess.CompletedProcess(command, 0, stdout="oracle 0.17.3\n", stderr="") assert runner.resolve_oracle_version( - ["npx.cmd", "-y", "@steipete/oracle@0.17.2"], run_factory=slow_valid - ) == "oracle 0.17.2" + ["npx.cmd", "-y", "@steipete/oracle@0.17.3"], run_factory=slow_valid + ) == "oracle 0.17.3" assert captured == { - "command": ["npx.cmd", "-y", "@steipete/oracle@0.17.2", "--version"], + "command": ["npx.cmd", "-y", "@steipete/oracle@0.17.3", "--version"], "timeout": runner.ORACLE_VERSION_RESOLUTION_TIMEOUT_SECONDS, } assert runner.ORACLE_VERSION_RESOLUTION_TIMEOUT_SECONDS == 90 @@ -309,19 +309,19 @@ def test_validated_package_root_is_the_exact_runtime_popen_target(tmp_path: Path cli = root / "dist" / "bin" / "oracle-cli.js" cli.parent.mkdir(parents=True) cli.write_text("// exact validated cli", encoding="utf-8") - (root / "package.json").write_text(json.dumps({"version": "0.17.2"}), encoding="utf-8") + (root / "package.json").write_text(json.dumps({"version": "0.17.3"}), encoding="utf-8") node = tmp_path / "node.exe" node.write_bytes(b"node") compatibility = { "ok": True, - "version": "0.17.2", + "version": "0.17.3", "package_root": str(root), "package_roots": [str(root)], } runtime_command = runner.validated_oracle_runtime_command( compatibility, - "oracle 0.17.2", + "oracle 0.17.3", which_runner=lambda name: str(node), ) assert runtime_command == (str(node.resolve()), str(cli.resolve())) @@ -370,17 +370,17 @@ def test_validated_runtime_rejects_an_unlisted_compatibility_root(tmp_path: Path runner = load_runner() root = tmp_path / "oracle" root.mkdir() - (root / "package.json").write_text(json.dumps({"version": "0.17.2"}), encoding="utf-8") + (root / "package.json").write_text(json.dumps({"version": "0.17.3"}), encoding="utf-8") with pytest.raises(runner.OracleRunError) as exc: runner.validated_oracle_runtime_command( { "ok": True, - "version": "0.17.2", + "version": "0.17.3", "package_root": str(root), "package_roots": [str(tmp_path / "different")], }, - "0.17.2", + "0.17.3", which_runner=lambda name: str(tmp_path / "node.exe"), ) @@ -391,7 +391,7 @@ def test_default_oracle_command_is_pinned_to_the_hash_validated_version() -> Non runner = load_runner() assert runner.STATE.default_oracle_command(platform_name="nt") == ( - "npx.cmd", "-y", "@steipete/oracle@0.17.2", + "npx.cmd", "-y", "@steipete/oracle@0.17.3", ) with pytest.raises(runner.STATE.OracleStateError) as exc: runner.STATE.validate_oracle_command(["npx.cmd", "-y", "@steipete/oracle@0.16.1"]) @@ -894,7 +894,7 @@ def test_complete_requires_zero_exit_and_nonempty_output(tmp_path: Path) -> None result = execute_run(runner, manifest(root), run_factory=version_runner, popen_factory=popen_for(code, output, captured, events)) assert result["ok"] is ok assert result["result"]["status"] == status - assert result["result"]["oracle"]["resolved_version"] == "oracle 0.17.2" + assert result["result"]["oracle"]["resolved_version"] == "oracle 0.17.3" assert "--file" not in captured["command"] assert events == ["popen", "wait"] assert Path(result["result"]["artifacts"]["transcript"]).is_file() @@ -1311,7 +1311,7 @@ def test_historical_0161_recovery_replaces_the_unpinned_stored_command(tmp_path: assert recovery["argv"][:3] == ["npx.cmd", "-y", "@steipete/oracle@0.16.1"] -@pytest.mark.parametrize("stored_version", ["0.16.1", "0.17.0", "0.17.1", "0.17.2"]) +@pytest.mark.parametrize("stored_version", ["0.16.1", "0.17.0", "0.17.1", "0.17.2", "0.17.3"]) def test_recovery_resolves_and_compat_checks_the_exact_stored_version( tmp_path: Path, stored_version: str, @@ -2001,6 +2001,118 @@ def mismatched_power_tier(command, **kwargs): ) ) == 1 + +@pytest.mark.parametrize( + ("pro_mode", "status"), + [ + (pro_mode, status) + for pro_mode in (False, True) + for status in ("selection unverified", "unknown outcome selecting") + ], +) +def test_recovery_settles_0172_strict_thinking_time_failure_and_releases_project( + tmp_path: Path, + pro_mode: bool, + status: str, +) -> None: + """A stored 0.17.2 run keeps its exact strict-thinking-time settlement. + + The strict thinking-time patch shipped in both the 0.17.2 (now + exact-recovery-only) and the active runtime, so recovery must still prove + the marker and release the project after the 0.17.3 promotion. + """ + runner = load_runner() + manifest_path = pro_manifest(tmp_path) if pro_mode else manifest(tmp_path) + requested = "Heavy" if pro_mode else "Extra-high" + required = "Power 5 of 5 (Pro)" if pro_mode else "Power 4 of 5 (Extra High)" + if status == "unknown outcome selecting": + marker = f"Thinking time: {status} {requested}; refusing to submit without confirmed {required}." + else: + marker = f"Thinking time: {status} (requested {requested}); refusing to submit without confirmed {required}." + initial = execute_run( + runner, + manifest_path, + run_factory=version_runner, + popen_factory=popen_for(4, None, {}, {}), + ) + run_dir = Path(initial["run_dir"]) + state_path = run_dir / "state.json" + state = runner.STATE.load_state(state_path) + state["session_authority"] = "submitted_unknown" + state["oracle"]["resolved_version"] = "oracle 0.17.2" + state.pop("pre_submit_failure", None) + runner.STATE.write_json_atomic(state_path, state) + locator = state["oracle"]["slug"] + (run_dir / "stdout.log").write_text( + f"Session: {locator}\n" + f"ERROR: {marker}\n" + f"User error (browser-automation): {marker}\n", + encoding="utf-8", + ) + (run_dir / "stderr.log").write_text("", encoding="utf-8") + + recovered = recover_run(runner, run_dir, action="harvest", dry_run=True) + recovered_state = runner.STATE.load_state(state_path) + + assert recovered["status"] == "pre_submit_failed" + assert recovered["safe_for_fresh_run"] is True + assert recovered_state["session_authority"] == "pre_submit" + assert recovered_state["task_outcome"] == "not_executed" + assert recovered_state["pre_submit_failure"]["code"] == ( + "ORACLE_THINKING_TIME_PRE_SUBMIT_FAILED" + ) + assert runner.STATE.unresolved_project_sessions( + runner.STATE.load_manifest(manifest_path).run_root, + tmp_path, + ) == [] + + +@pytest.mark.parametrize("stored_version", ["0.17.0", "0.17.1"]) +def test_recovery_keeps_lock_when_strict_thinking_time_marker_carries_non_contract_version( + tmp_path: Path, + stored_version: str, +) -> None: + """Runtimes older than the strict proof contract never settle its markers.""" + runner = load_runner() + manifest_path = manifest(tmp_path) + marker = ( + "Thinking time: selection unverified (requested Extra-high); " + "refusing to submit without confirmed Power 4 of 5 (Extra High)." + ) + initial = execute_run( + runner, + manifest_path, + run_factory=version_runner, + popen_factory=popen_for(4, None, {}, {}), + ) + run_dir = Path(initial["run_dir"]) + state_path = run_dir / "state.json" + state = runner.STATE.load_state(state_path) + state["oracle"]["resolved_version"] = f"oracle {stored_version}" + state["session_authority"] = "submitted_unknown" + state.pop("pre_submit_failure", None) + runner.STATE.write_json_atomic(state_path, state) + locator = state["oracle"]["slug"] + (run_dir / "stdout.log").write_text( + f"Session: {locator}\n" + f"ERROR: {marker}\n" + f"User error (browser-automation): {marker}\n", + encoding="utf-8", + ) + (run_dir / "stderr.log").write_text("", encoding="utf-8") + + recovered = recover_run(runner, run_dir, action="harvest", dry_run=True) + recovered_state = runner.STATE.load_state(state_path) + + assert recovered["status"] == "dry-run" + assert "pre_submit_failure" not in recovered_state + assert len( + runner.STATE.unresolved_project_sessions( + runner.STATE.load_manifest(manifest_path).run_root, + tmp_path, + ) + ) == 1 + def test_model_switcher_failure_is_proven_pre_submit_not_executed_and_releases_project( tmp_path: Path, ) -> None: @@ -2073,6 +2185,108 @@ def model_switcher_no_cookie(command, **kwargs): assert runner.STATE.load_state(state_path)["session_authority"] == "submitted_unknown" +def test_recovery_settles_0172_model_switcher_failure_and_releases_project( + tmp_path: Path, +) -> None: + """A stored 0.17.2 run keeps its exact model-switcher settlement. + + The model-switcher/no-cookie diagnostic shipped in both the 0.17.2 (now + exact-recovery-only) and the active runtime, so recovery must still prove + the marker and release the project after the 0.17.3 promotion. + """ + runner = load_runner() + manifest_path = pro_manifest(tmp_path, run_id="f" * 32) + marker = ( + 'Unable to find model option matching "Pro" in the model switcher. ' + "Available: Advanced, ModelGPT-5.6 Sol, EffortHigh. " + "No cookies were applied; log in to ChatGPT in Chrome or provide inline cookies." + ) + initial = execute_run( + runner, + manifest_path, + run_factory=version_runner, + popen_factory=popen_for(4, None, {}, {}), + ) + run_dir = Path(initial["run_dir"]) + state_path = run_dir / "state.json" + state = runner.STATE.load_state(state_path) + state["session_authority"] = "submitted_unknown" + state["oracle"]["resolved_version"] = "oracle 0.17.2" + state.pop("pre_submit_failure", None) + runner.STATE.write_json_atomic(state_path, state) + locator = state["oracle"]["slug"] + (run_dir / "stdout.log").write_text( + f"Session: {locator}\n" + f"ERROR: {marker}\n" + f"User error (browser-automation): {marker}\n", + encoding="utf-8", + ) + (run_dir / "stderr.log").write_text("", encoding="utf-8") + + recovered = recover_run(runner, run_dir, action="harvest", dry_run=True) + recovered_state = runner.STATE.load_state(state_path) + + assert recovered["status"] == "pre_submit_failed" + assert recovered["safe_for_fresh_run"] is True + assert recovered_state["session_authority"] == "pre_submit" + assert recovered_state["transport_status"] == "failed_pre_submit" + assert recovered_state["task_outcome"] == "not_executed" + assert recovered_state["pre_submit_failure"]["code"] == ( + "ORACLE_MODEL_SWITCHER_PRE_SUBMIT_FAILED" + ) + assert runner.STATE.unresolved_project_sessions( + runner.STATE.load_manifest(pro_manifest(tmp_path)).run_root, + tmp_path, + ) == [] + + +@pytest.mark.parametrize("stored_version", ["0.17.0", "0.17.1"]) +def test_recovery_keeps_lock_when_model_switcher_marker_carries_non_contract_version( + tmp_path: Path, + stored_version: str, +) -> None: + """Runtimes older than the model-switcher proof contract never settle it.""" + runner = load_runner() + manifest_path = pro_manifest(tmp_path, run_id="e" * 32) + marker = ( + 'Unable to find model option matching "Pro" in the model switcher. ' + "No cookies were applied; log in to ChatGPT in Chrome or provide inline cookies." + ) + initial = execute_run( + runner, + manifest_path, + run_factory=version_runner, + popen_factory=popen_for(4, None, {}, {}), + ) + run_dir = Path(initial["run_dir"]) + state_path = run_dir / "state.json" + state = runner.STATE.load_state(state_path) + state["oracle"]["resolved_version"] = f"oracle {stored_version}" + state["session_authority"] = "submitted_unknown" + state.pop("pre_submit_failure", None) + runner.STATE.write_json_atomic(state_path, state) + locator = state["oracle"]["slug"] + (run_dir / "stdout.log").write_text( + f"Session: {locator}\n" + f"ERROR: {marker}\n" + f"User error (browser-automation): {marker}\n", + encoding="utf-8", + ) + (run_dir / "stderr.log").write_text("", encoding="utf-8") + + recovered = recover_run(runner, run_dir, action="harvest", dry_run=True) + recovered_state = runner.STATE.load_state(state_path) + + assert recovered["status"] == "dry-run" + assert "pre_submit_failure" not in recovered_state + assert len( + runner.STATE.unresolved_project_sessions( + runner.STATE.load_manifest(pro_manifest(tmp_path)).run_root, + tmp_path, + ) + ) == 1 + + def test_profile_copy_ebusy_is_proven_pre_submit_and_releases_project(tmp_path: Path) -> None: runner = load_runner() seed = tmp_path.parent / f"{tmp_path.name}-profile" @@ -2135,6 +2349,122 @@ def test_recovery_repairs_legacy_profile_copy_ebusy_without_oracle_call(tmp_path assert calls == [] +@pytest.mark.parametrize( + ("marker", "failure_code"), + [ + ( + "--copy-profile cannot be combined with --browser-manual-login: choose either a " + "throwaway copied profile or the persistent manual-login profile.", + "ORACLE_LAUNCH_FLAGS_MUTUALLY_EXCLUSIVE_PRELAUNCH_FAILED", + ), + ( + "--copy-profile requires rsync on PATH (spawn failed): spawn rsync ENOENT", + "ORACLE_PROFILE_COPY_RSYNC_PRELAUNCH_FAILED", + ), + ], + ids=("manual-login-conflict", "rsync-missing"), +) +def test_recovery_settles_0172_copy_profile_host_failure_and_releases_project( + tmp_path: Path, + marker: str, + failure_code: str, +) -> None: + """Stored 0.17.2 runs keep exact host-failure settlement after promotion. + + The copy-profile conflict and rsync-spawn diagnostics shipped in both the + 0.17.2 (now exact-recovery-only) and the active runtime, so recovery must + still prove either host failure and release the project. + """ + runner = load_runner() + seed = tmp_path.parent / f"{tmp_path.name}-profile" + cookies = seed / "Default" / "Network" / "Cookies" + cookies.parent.mkdir(parents=True) + cookies.write_text("seed", encoding="utf-8") + manifest_path = pro_manifest(tmp_path, run_id="d" * 32, copy_profile=str(seed)) + initial = execute_run( + runner, + manifest_path, + run_factory=version_runner, + popen_factory=popen_for(4, None, {}, {}), + ) + run_dir = Path(initial["run_dir"]) + state_path = run_dir / "state.json" + state = runner.STATE.load_state(state_path) + state["session_authority"] = "submitted_unknown" + state["oracle"]["resolved_version"] = "oracle 0.17.2" + state.pop("pre_submit_failure", None) + runner.STATE.write_json_atomic(state_path, state) + (run_dir / "stdout.log").write_text(f"ERROR: {marker}\n", encoding="utf-8") + (run_dir / "stderr.log").write_text("", encoding="utf-8") + + recovered = recover_run(runner, run_dir, action="harvest", dry_run=True) + recovered_state = runner.STATE.load_state(state_path) + + assert recovered["status"] == "pre_submit_failed" + assert recovered["safe_for_fresh_run"] is True + assert recovered_state["session_authority"] == "pre_submit" + assert recovered_state["transport_status"] == "failed_pre_submit" + assert recovered_state["task_outcome"] == "not_executed" + assert recovered_state["pre_submit_failure"]["code"] == failure_code + assert runner.STATE.unresolved_project_sessions( + runner.STATE.load_manifest(pro_manifest(tmp_path)).run_root, + tmp_path, + ) == [] + + +@pytest.mark.parametrize( + ("marker", "stored_version"), + [ + (marker, stored_version) + for marker in ( + "--copy-profile cannot be combined with --browser-manual-login: choose either a " + "throwaway copied profile or the persistent manual-login profile.", + "--copy-profile requires rsync on PATH (spawn failed): spawn rsync ENOENT", + ) + for stored_version in ("0.17.0", "0.17.1") + ], +) +def test_recovery_keeps_lock_when_copy_profile_host_failure_carries_non_contract_version( + tmp_path: Path, + marker: str, + stored_version: str, +) -> None: + """Runtimes older than the host-failure proof contract never settle it.""" + runner = load_runner() + seed = tmp_path.parent / f"{tmp_path.name}-profile" + cookies = seed / "Default" / "Network" / "Cookies" + cookies.parent.mkdir(parents=True) + cookies.write_text("seed", encoding="utf-8") + manifest_path = pro_manifest(tmp_path, run_id="9" * 32, copy_profile=str(seed)) + initial = execute_run( + runner, + manifest_path, + run_factory=version_runner, + popen_factory=popen_for(4, None, {}, {}), + ) + run_dir = Path(initial["run_dir"]) + state_path = run_dir / "state.json" + state = runner.STATE.load_state(state_path) + state["oracle"]["resolved_version"] = f"oracle {stored_version}" + state["session_authority"] = "submitted_unknown" + state.pop("pre_submit_failure", None) + runner.STATE.write_json_atomic(state_path, state) + (run_dir / "stdout.log").write_text(f"ERROR: {marker}\n", encoding="utf-8") + (run_dir / "stderr.log").write_text("", encoding="utf-8") + + recovered = recover_run(runner, run_dir, action="harvest", dry_run=True) + recovered_state = runner.STATE.load_state(state_path) + + assert recovered["status"] == "dry-run" + assert "pre_submit_failure" not in recovered_state + assert len( + runner.STATE.unresolved_project_sessions( + runner.STATE.load_manifest(pro_manifest(tmp_path)).run_root, + tmp_path, + ) + ) == 1 + + def test_manual_login_profile_uninitialized_is_proven_pre_submit_and_releases_project( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, @@ -2693,6 +3023,195 @@ def app_route_unconfirmed(command, **kwargs): assert runner.STATE.proven_user_confirmed_no_submission(run_dir / "state.json") is not None +def test_direct_app_route_unconfirmed_with_stored_0172_settles_and_releases_project( + tmp_path: Path, +) -> None: + """A stored 0.17.2 run stays eligible for direct app-route adjudication. + + The APP_MENTION_ROUTE_UNCONFIRMED marker shipped in both the 0.17.2 (now + exact-recovery-only) and the active runtime, so user settlement of a + stored 0.17.2 direct run must still work after the 0.17.3 promotion. + """ + runner = load_runner() + manifest_path = manifest(tmp_path) + + def app_route_unconfirmed(command, **kwargs): + slug = command[command.index("--slug") + 1] + kwargs["stdout"].write( + ( + f"Session: {slug}\n" + "ERROR: APP_MENTION_ROUTE_UNCONFIRMED\n" + "User error (browser-automation): APP_MENTION_ROUTE_UNCONFIRMED\n" + ).encode() + ) + kwargs["stdout"].flush() + return Process(1, []) + + failed = execute_run( + runner, + manifest_path, + run_factory=version_runner, + popen_factory=app_route_unconfirmed, + ) + run_dir = Path(failed["run_dir"]) + state_path = run_dir / "state.json" + state = runner.STATE.load_state(state_path) + state["oracle"]["resolved_version"] = "oracle 0.17.2" + runner.STATE.write_json_atomic(state_path, state) + settled = runner.settle_user_confirmed_no_submission( + run_dir, + confirmation=runner.STATE.USER_CONFIRMED_NO_SUBMISSION, + reason="user confirmed the exact 0.17.2 direct run was not submitted", + ) + proof = runner.STATE.proven_user_confirmed_no_submission(state_path) + + assert settled["ok"] is True + assert settled["safe_for_fresh_run"] is True + assert settled["result"]["session_authority"] == "pre_submit" + assert settled["result"]["task_outcome_reason"] == ( + "user-confirmed-no-submission-after-app-route-unconfirmed" + ) + assert proof is not None + assert proof["settlement_eligibility"] == "oracle-direct-app-route-unconfirmed/v1" + + +@pytest.mark.parametrize("stored_version", ["0.16.1", "0.17.0", "0.17.1"]) +def test_direct_app_route_unconfirmed_with_non_contract_version_is_fail_closed( + tmp_path: Path, + stored_version: str, +) -> None: + """Runtimes outside the app-route proof contract never become settleable.""" + runner = load_runner() + manifest_path = manifest(tmp_path) + + def app_route_unconfirmed(command, **kwargs): + slug = command[command.index("--slug") + 1] + kwargs["stdout"].write( + ( + f"Session: {slug}\n" + "ERROR: APP_MENTION_ROUTE_UNCONFIRMED\n" + "User error (browser-automation): APP_MENTION_ROUTE_UNCONFIRMED\n" + ).encode() + ) + kwargs["stdout"].flush() + return Process(1, []) + + failed = execute_run( + runner, + manifest_path, + run_factory=version_runner, + popen_factory=app_route_unconfirmed, + ) + run_dir = Path(failed["run_dir"]) + state_path = run_dir / "state.json" + state = runner.STATE.load_state(state_path) + state["oracle"]["resolved_version"] = stored_version + runner.STATE.write_json_atomic(state_path, state) + + with pytest.raises(runner.STATE.OracleStateError) as exc: + runner.settle_user_confirmed_no_submission( + run_dir, + confirmation=runner.STATE.USER_CONFIRMED_NO_SUBMISSION, + reason="user said no submission", + ) + assert exc.value.code == "NO_SUBMISSION_EVIDENCE_INCOMPLETE" + + +def test_comprehensive_app_route_unconfirmed_with_stored_0172_is_user_settleable( + tmp_path: Path, +) -> None: + """A stored 0.17.2 comprehensive run stays app-route settleable. + + The comprehensive HOST_STAGE_CONTRACT path binds the same + APP_MENTION_ROUTE_UNCONFIRMED marker for the 0.17.2 (exact-recovery-only) + and active runtimes; a stored 0.17.2 run must stay user-adjudicable. + """ + runner = load_runner() + run_id = "b" * 32 + workflow_id = "4d8b1c12-6f2e-4c9a-b3e4-9f2d1c7a5b31" + parallel_parent_id = hashlib.sha256(workflow_id.encode("utf-8")).hexdigest() + manifest_path = manifest( + tmp_path, + run_id=run_id, + parallel_parent_id=parallel_parent_id, + ) + input_mission = tmp_path / "input.md" + input_mission.write_text("bound input", encoding="utf-8") + input_sha = hashlib.sha256(input_mission.read_bytes()).hexdigest() + (tmp_path / "mission.md").write_text( + "\n".join(( + "mission body", + "", + "[HOST_STAGE_CONTRACT]", + f"workflow_id={workflow_id}", + "stage=implementation", + f"attempt_id={run_id}", + f"input_mission_sha256={input_sha}", + f"exact_project_root={tmp_path.resolve()}", + f"exact_input_mission_path={input_mission.resolve()}", + f"Write the small UTF-8 stage receipt to: {(tmp_path / 'stage-result.json').resolve()}", + "", + "[DEVSPACE_WORKSPACE_ENTRY_CONTRACT]", + "workspace body", + "", + )), + encoding="utf-8", + ) + rebind_manifest_mission(manifest_path) + + def app_route_unconfirmed(command, **kwargs): + slug = command[command.index("--slug") + 1] + kwargs["stdout"].write( + ( + f"Session: {slug}\n" + "ERROR: APP_MENTION_ROUTE_UNCONFIRMED\n" + "User error (browser-automation): APP_MENTION_ROUTE_UNCONFIRMED\n" + ).encode() + ) + kwargs["stdout"].flush() + return Process(1, []) + + failed = execute_run( + runner, + manifest_path, + run_factory=version_runner, + popen_factory=app_route_unconfirmed, + ) + run_dir = Path(failed["run_dir"]) + state_path = run_dir / "state.json" + state = runner.STATE.load_state(state_path) + slug = state["oracle"]["slug"] + (run_dir / "recovery-harvest-stdout.log").write_text( + f'No live ChatGPT tab matched session "{slug}". Attempting recovery.\n', + encoding="utf-8", + ) + (run_dir / "recovery-harvest-stderr.log").write_text( + "Cannot recover conversation: session metadata has no recoverable ChatGPT conversation URL.\n", + encoding="utf-8", + ) + state["oracle"]["resolved_version"] = "oracle 0.17.2" + runner.STATE.write_json_atomic(state_path, state) + + settled = runner.settle_user_confirmed_no_submission( + run_dir, + confirmation=runner.STATE.USER_CONFIRMED_NO_SUBMISSION, + reason="user inspected the exact 0.17.2 comprehensive run and confirmed no submission", + ) + proof = runner.STATE.proven_user_confirmed_no_submission(state_path) + + assert settled["ok"] is True + assert settled["safe_for_fresh_run"] is True + assert settled["result"]["session_authority"] == "pre_submit" + assert settled["result"]["task_outcome_reason"] == ( + "user-confirmed-no-submission-after-app-route-unconfirmed" + ) + assert proof is not None + assert proof["workflow_id"] == workflow_id + assert proof["stage"] == "implementation" + assert proof["attempt_id"] == run_id + assert proof["input_mission_sha256"] == input_sha + + def test_direct_web_multi_child_no_submission_settlement_is_hash_bound(tmp_path: Path) -> None: runner = load_runner() parent_id = "d" * 64 diff --git a/tests/test_chatgpt_oracle_state.py b/tests/test_chatgpt_oracle_state.py index e2748683..d1bfd8f9 100644 --- a/tests/test_chatgpt_oracle_state.py +++ b/tests/test_chatgpt_oracle_state.py @@ -103,7 +103,7 @@ def manifest(tmp_path: Path, mission_path: Path | str, **extra) -> Path: "mission_path": str(mission_path), "app_name": "DevSpace", "mode": "browser", - "oracle_command": ["npx", "-y", "@steipete/oracle@0.17.2"], + "oracle_command": ["npx", "-y", "@steipete/oracle@0.17.3"], } candidate_mission = Path(str(mission_path)) if candidate_mission.is_absolute() and candidate_mission.is_file(): @@ -447,7 +447,7 @@ def test_project_url_is_normalized_and_retained_in_state(tmp_path: Path) -> None )) assert config.chatgpt_project_url == "https://chatgpt.com/g/g-p-example/project" layout = state.create_layout(config, run_id="20260725T151414Z-a3aeba967d99") - payload = state.state_payload(config, layout, status="prepared", resolved_version="oracle 0.17.2") + payload = state.state_payload(config, layout, status="prepared", resolved_version="oracle 0.17.3") assert payload["profile"]["chatgpt_project_url"] == config.chatgpt_project_url @@ -652,17 +652,17 @@ def test_regular_manifest_requires_exact_devspace_app(tmp_path: Path) -> None: def test_oracle_commands_pin_the_active_and_recoverable_versions() -> None: state = load_state() - assert state.ORACLE_ACTIVE_VERSION == "0.17.2" - assert state.ORACLE_RECOVERABLE_VERSIONS == ("0.16.1", "0.17.0", "0.17.1", "0.17.2") - assert state.WAIT_CAPABLE_VERSIONS == {"0.17.0", "0.17.1", "0.17.2"} - assert state.ORACLE_UI_FAILURE_SETTLEMENT_VERSIONS == {"0.17.1", "0.17.2"} + assert state.ORACLE_ACTIVE_VERSION == "0.17.3" + assert state.ORACLE_RECOVERABLE_VERSIONS == ("0.16.1", "0.17.0", "0.17.1", "0.17.2", "0.17.3") + assert state.WAIT_CAPABLE_VERSIONS == {"0.17.0", "0.17.1", "0.17.3"} + assert state.ORACLE_UI_FAILURE_SETTLEMENT_VERSIONS == {"0.17.1", "0.17.2", "0.17.3"} assert state.default_oracle_command(platform_name="nt") == ( - "npx.cmd", "-y", "@steipete/oracle@0.17.2", + "npx.cmd", "-y", "@steipete/oracle@0.17.3", ) assert state.pinned_oracle_command("oracle 0.16.1", platform_name="posix") == ( "npx", "-y", "@steipete/oracle@0.16.1", ) - assert state.validate_oracle_command(["npx", "--yes", "@steipete/oracle@0.17.2"]) + assert state.validate_oracle_command(["npx", "--yes", "@steipete/oracle@0.17.3"]) for command in ( ["oracle"], diff --git a/tests/test_global_gpt_browser_policy.py b/tests/test_global_gpt_browser_policy.py index c1f0db88..807d4e68 100644 --- a/tests/test_global_gpt_browser_policy.py +++ b/tests/test_global_gpt_browser_policy.py @@ -23,7 +23,7 @@ def test_regular_modes_route_only_through_oracle_and_devspace() -> None: assert "chatgpt_oracle_dispatch.py" in value assert "@DevSpace" in value and "never attaches files" in value assert "another backend, Playwright, in-app Browser, or Chrome" in value - assert "Oracle `0.17.2`" in value and "`Extra High`" in value + assert "Oracle `0.17.3`" in value and "`Extra High`" in value def test_pro_is_oracle_attachment_only_heavy_and_has_no_app_fallback() -> None: @@ -56,7 +56,7 @@ def test_oracle_recovery_is_exact_slug_monotonic_and_version_specific() -> None: assert "stored slug" in value and "never restarts/resubmits" in value assert "never downgrades durable COMPLETE" in value source = text(ROOT / "bin/chatgpt_oracle_state.py") - assert 'ORACLE_RECOVERABLE_VERSIONS = ("0.16.1", "0.17.0", "0.17.1", ORACLE_ACTIVE_VERSION)' in source + assert 'ORACLE_RECOVERABLE_VERSIONS = ("0.16.1", "0.17.0", "0.17.1", "0.17.2", ORACLE_ACTIVE_VERSION)' in source assert 'WAIT_CAPABLE_VERSIONS = {"0.17.0", "0.17.1", ORACLE_ACTIVE_VERSION}' in source diff --git a/tests/test_install_lifecycle.py b/tests/test_install_lifecycle.py index f75b3705..d0bc9dcf 100644 --- a/tests/test_install_lifecycle.py +++ b/tests/test_install_lifecycle.py @@ -91,6 +91,7 @@ def write_v3_receipt( name: str, *, previous_receipt: Path | None = None, + manifest_version: str = '1.8.0', ) -> Path: relative = installed.relative_to(home).as_posix() digest = hashlib.sha256(installed.read_bytes()).hexdigest() @@ -114,12 +115,12 @@ def write_v3_receipt( } wal.write_text(json.dumps({ 'schema': 'codexpro.install-wal/v2', 'transaction_id': transaction, - 'manifest_version': '1.8.0', 'status': 'COMPLETE', 'backup': str(backup), + 'manifest_version': manifest_version, 'status': 'COMPLETE', 'backup': str(backup), 'receipt': str(receipt), 'wal_path': str(wal), 'files': [record], }), encoding='utf-8') receipt_value = { 'schema': 'codexpro.install-receipt/v3', 'transaction_id': transaction, - 'manifest_version': '1.8.0', 'backup': str(backup), 'wal': str(wal), + 'manifest_version': manifest_version, 'backup': str(backup), 'wal': str(wal), 'files': [{k: record[k] for k in ('path', 'action', 'installed_sha256', 'backup_sha256')}], 'dependency': {'mode': 'skipped'}, } @@ -129,6 +130,93 @@ def write_v3_receipt( return receipt +def expand_manifest_files(manifest: dict) -> list[str]: + """Mirror install.ps1 Get-ManifestFiles: expand include patterns against ROOT.""" + import fnmatch + import re + files: list[str] = [] + for pattern in manifest['include']: + assert isinstance(pattern, str) and pattern, 'manifest include pattern must be a nonempty string' + assert not re.search(r'(^|/)\.{1,2}(/|$)', pattern), f'unsafe manifest pattern: {pattern}' + assert not os.path.isabs(pattern), f'unsafe manifest pattern: {pattern}' + prefix = next(( + p for p in ('bin/', 'skills/', 'mcp_servers/', 'scripts/', 'contracts/', 'tests/fixtures/') + if pattern.startswith(p) + ), None) + assert prefix is not None, f'unsupported manifest root: {pattern}' + matched = [] + for item in (ROOT / prefix.rstrip('/')).rglob('*'): + if not item.is_file(): + continue + assert not item.is_symlink(), f'manifest refuses symlink: {item}' + relative = item.relative_to(ROOT).as_posix() + if fnmatch.fnmatchcase(relative, pattern): + matched.append(relative) + assert matched, f'manifest pattern matched no files: {pattern}' + files.extend(matched) + return sorted(set(files)) + + +def install_v4_fixture( + home: Path, + name: str, + *, + manifest_version: str | None = None, + state_module_text: str | None = None, +) -> Path: + """Copy current manifest-expanded files into home and write a fully bound v4 + receipt + COMPLETE v3 WAL in install.ps1's exact shapes (no install.ps1 run). + Returns the receipt path. Mutate one dimension after building to fail doctor.""" + manifest = json.loads((ROOT / 'install-manifest.json').read_text(encoding='utf-8')) + version = manifest['version'] if manifest_version is None else manifest_version + transaction = hashlib.md5(name.encode(), usedforsecurity=False).hexdigest() + backup = home / 'backups' / name + wal = backup / 'install.wal.json' + receipt = home / 'receipts' / f'codexpro-automation-{name}.json' + receipt.parent.mkdir(parents=True, exist_ok=True) + (backup / 'steps').mkdir(parents=True) + records = [] + receipt_files = [] + for index, relative in enumerate(expand_manifest_files(manifest)): + destination = home / relative + destination.parent.mkdir(parents=True, exist_ok=True) + if relative == 'bin/chatgpt_oracle_state.py' and state_module_text is not None: + destination.write_text(state_module_text, encoding='utf-8') + else: + shutil.copy2(ROOT / relative, destination) + digest = hashlib.sha256(destination.read_bytes()).hexdigest() + replacement = backup / 'steps' / str(index) / 'replacement.json' + replacement.parent.mkdir(parents=True) + replacement.write_text(json.dumps({ + 'schema': 'codexpro.install-replacement/v1', 'path': relative, + 'action': 'created', 'installed_sha256': digest, 'backup_sha256': None, + }), encoding='utf-8') + record = { + 'sequence_number': index, 'path': relative, 'action': 'created', + 'installed_sha256': digest, 'backup_sha256': None, 'phase': 'COMPLETE', + 'transitions': ['INTENT', 'BACKUP_DURABLE', 'MUTATED', 'VERIFIED', + 'REPLACEMENT_RECEIPT_DURABLE', 'COMPLETE'], + 'replacement': str(replacement), + } + records.append(record) + receipt_files.append({ + k: record[k] for k in ('path', 'action', 'installed_sha256', 'backup_sha256') + }) + wal.write_text(json.dumps({ + 'schema': 'codexpro.install-wal/v3', 'transaction_id': transaction, + 'manifest_version': version, 'status': 'COMPLETE', 'backup': str(backup), + 'receipt': str(receipt), 'wal_path': str(wal), 'created_at': '2026-08-14T00:00:00.0000000Z', + 'completed_at': '2026-08-14T00:00:00.0000000Z', 'files': records, + }), encoding='utf-8') + receipt.write_text(json.dumps({ + 'schema': 'codexpro.install-receipt/v4', 'transaction_id': transaction, + 'installed_at': '2026-08-14T00:00:00.0000000Z', 'manifest_version': version, + 'backup': str(backup), 'files': receipt_files, 'previous_receipt': None, + 'wal': str(wal), + }), encoding='utf-8') + return receipt + + def run_fixture_install(repo: Path, home: Path, env: dict[str, str] | None = None) -> subprocess.CompletedProcess[str]: return run_powershell('-File', str(repo / 'install.ps1'), '-CodexHome', str(home), env=env) @@ -976,57 +1064,177 @@ def test_wal_v2_missing_overwritten_destination_is_a_conflict() -> None: assert json.loads(wal.read_text(encoding='utf-8'))['status'] == 'ACTIVE' -def test_doctor_accepts_current_v3_install_receipt_schema() -> None: - with tempfile.TemporaryDirectory() as home: - root = Path(home) - receipt = root / 'receipts' / 'codexpro-automation-current.json' - receipt.parent.mkdir(parents=True) - receipt.write_text( - json.dumps({ - 'schema': 'codexpro.install-receipt/v3', - 'backup': str(root / 'backups' / 'owned'), - 'files': [], - 'dependency': {'mode': 'skipped'}, - }), - encoding='utf-8', - ) - result = run_powershell('-File', str(ROOT / 'doctor.ps1'), '-CodexHome', home) +def test_doctor_rejects_stale_manifest_version_receipt(tmp_path: Path) -> None: + home = tmp_path / 'home' + manifest = json.loads((ROOT / 'install-manifest.json').read_text(encoding='utf-8')) + install_v4_fixture(home, 'stale-1-8-0', manifest_version='1.8.0') + + result = run_powershell('-File', str(ROOT / 'doctor.ps1'), '-CodexHome', str(home)) + + assert result.returncode != 0 + report = json.loads(result.stdout) + assert report['status'] == 'FAIL' + assert report['manifest_version'] == manifest['version'] + mismatch = next( + issue for issue in report['issues'] + if issue['code'] == 'RECEIPT_MANIFEST_VERSION_MISMATCH' + ) + assert mismatch['receipt'] == '1.8.0' + assert mismatch['current'] == manifest['version'] + assert report['oracle'] is None - assert result.returncode == 0, result.stdout - report = json.loads(result.stdout) - assert report['status'] == 'PASS' - assert report['oracle']['package'] == '@steipete/oracle@0.17.2' - assert report['devspace']['tested_version'] == '1.0.7' - assert 'npx -y @steipete/oracle@0.17.2 --version' in report['commands'] - assert 'RECEIPT_INVALID' not in result.stdout - assert 'unsupported install receipt schema' not in result.stdout +def test_doctor_rejects_installed_oracle_active_version_mismatch(tmp_path: Path) -> None: + home = tmp_path / 'home' + manifest = json.loads((ROOT / 'install-manifest.json').read_text(encoding='utf-8')) + tested = manifest['external']['oracle']['tested_version'] + source = (ROOT / 'bin' / 'chatgpt_oracle_state.py').read_text(encoding='utf-8') + state_module_text = source.replace( + f'ORACLE_ACTIVE_VERSION = "{tested}"', + 'ORACLE_ACTIVE_VERSION = "9.9.9"', + ) + install_v4_fixture(home, 'active-map-mismatch', state_module_text=state_module_text) -def test_doctor_rejects_compatibility_module_with_unreceipted_patch_asset() -> None: - with tempfile.TemporaryDirectory() as home: - codex_home = Path(home) - installed = run_powershell( - '-File', str(ROOT / 'install.ps1'), '-CodexHome', home, '-SkipDependencyInstall', - ) - assert installed.returncode == 0, installed.stderr - readback = run_powershell('-File', str(ROOT / 'doctor.ps1'), '-CodexHome', home) - assert readback.returncode == 0, readback.stdout - receipt = next((codex_home / 'receipts').glob('codexpro-automation-*.json')) - value = json.loads(receipt.read_text(encoding='utf-8-sig')) - missing = 'bin/oracle-compat/0.17.1/assistantResponse.patch' - value['files'] = [record for record in value['files'] if record['path'] != missing] - (codex_home / missing).unlink() - receipt.write_text(json.dumps(value), encoding='utf-8') + result = run_powershell('-File', str(ROOT / 'doctor.ps1'), '-CodexHome', str(home)) - result = run_powershell('-File', str(ROOT / 'doctor.ps1'), '-CodexHome', home) + assert result.returncode != 0 + report = json.loads(result.stdout) + assert report['status'] == 'FAIL' + mismatch = next( + issue for issue in report['issues'] + if issue['code'] == 'ORACLE_ACTIVE_VERSION_MISMATCH' + ) + assert mismatch['installed'] == '9.9.9' + assert mismatch['expected'] == tested + assert report['oracle'] is None - assert result.returncode != 0 - report = json.loads(result.stdout) - assert [ - issue['path'] for issue in report['issues'] - if issue['code'] == 'COMPAT_PATCH_ASSET_MISSING' - ] == [missing] + +def test_doctor_rejects_compatibility_module_with_unreceipted_patch_asset(tmp_path: Path) -> None: + home = tmp_path / 'home' + install_v4_fixture(home, 'current') + missing = 'bin/oracle-compat/0.17.1/assistantResponse.patch' + (home / missing).unlink() + + result = run_powershell('-File', str(ROOT / 'doctor.ps1'), '-CodexHome', str(home)) + + assert result.returncode != 0 + report = json.loads(result.stdout) + assert [ + issue['path'] for issue in report['issues'] + if issue['code'] == 'COMPAT_PATCH_ASSET_MISSING' + ] == [missing] + + +def test_doctor_rejects_truncated_receipt_with_null_oracle(tmp_path: Path) -> None: + home = tmp_path / 'home' + receipt = install_v4_fixture(home, 'truncated') + receipt.write_text('{"schema": "codexpro.install-receipt/v4"', encoding='utf-8') + + result = run_powershell('-File', str(ROOT / 'doctor.ps1'), '-CodexHome', str(home)) + + assert result.returncode != 0 + report = json.loads(result.stdout) + assert report['status'] == 'FAIL' + assert any(issue['code'] == 'RECEIPT_INVALID' for issue in report['issues']) + assert report['oracle'] is None + + +def test_doctor_rejects_tampered_state_module_hash_with_null_oracle(tmp_path: Path) -> None: + home = tmp_path / 'home' + install_v4_fixture(home, 'tampered') + state_module = home / 'bin' / 'chatgpt_oracle_state.py' + state_module.write_text( + state_module.read_text(encoding='utf-8') + '\n# user tampered after install\n', + encoding='utf-8', + ) + + result = run_powershell('-File', str(ROOT / 'doctor.ps1'), '-CodexHome', str(home)) + + assert result.returncode != 0 + report = json.loads(result.stdout) + assert report['status'] == 'FAIL' + assert any( + issue['code'] == 'HASH_MISMATCH' and issue['path'] == 'bin/chatgpt_oracle_state.py' + for issue in report['issues'] + ) + assert report['oracle'] is None + + +def test_doctor_rejects_exact_case_package_mismatch(tmp_path: Path) -> None: + home = tmp_path / 'home' + manifest = json.loads((ROOT / 'install-manifest.json').read_text(encoding='utf-8')) + package = manifest['external']['oracle']['package'] + source = (ROOT / 'bin' / 'chatgpt_oracle_state.py').read_text(encoding='utf-8') + state_module_text = source.replace( + f'ORACLE_PACKAGE = "{package}"', + f'ORACLE_PACKAGE = "{package.upper()}"', + ) + install_v4_fixture(home, 'case-package', state_module_text=state_module_text) + + result = run_powershell('-File', str(ROOT / 'doctor.ps1'), '-CodexHome', str(home)) + + assert result.returncode != 0 + report = json.loads(result.stdout) + assert report['status'] == 'FAIL' + mismatch = next( + issue for issue in report['issues'] + if issue['code'] == 'ORACLE_PACKAGE_MISMATCH' + ) + assert mismatch['installed'] == package.upper() + assert mismatch['expected'] == package + assert report['oracle'] is None + + +@pytest.mark.parametrize('mutation, fragment', [ + ( + lambda source: source + '\nORACLE_ACTIVE_VERSION = "0.17.3"\n', + 'DUPLICATE:ORACLE_ACTIVE_VERSION', + ), + ( + lambda source: source.replace( + 'ORACLE_ACTIVE_VERSION = "0.17.3"', + 'ORACLE_ACTIVE_VERSION = REGULAR_MODEL', + ), + 'NONLITERAL:ORACLE_ACTIVE_VERSION', + ), + ( + lambda source: source + '\nORACLE_PACKAGE: str = "@steipete/oracle"\n', + 'ANNASSIGN:ORACLE_PACKAGE', + ), + ( + lambda source: source + '\nORACLE_ACTIVE_VERSION += ""\n', + 'AUGASSIGN:ORACLE_ACTIVE_VERSION', + ), + ( + lambda source: source + '\ndel ORACLE_ACTIVE_VERSION\n', + 'DELETE:ORACLE_ACTIVE_VERSION', + ), + ( + lambda source: source + '\ndef _rebind():\n ORACLE_ACTIVE_VERSION = "0.17.3"\n', + 'NESTED:ORACLE_ACTIVE_VERSION', + ), + ( + lambda source: source + '\n(ORACLE_ACTIVE_VERSION := "9.9.9")\n', + 'REBIND:ORACLE_ACTIVE_VERSION', + ), +]) +def test_doctor_rejects_state_authority_rebinding(tmp_path: Path, mutation, fragment) -> None: + home = tmp_path / 'home' + source = (ROOT / 'bin' / 'chatgpt_oracle_state.py').read_text(encoding='utf-8') + install_v4_fixture(home, 'rebinding', state_module_text=mutation(source)) + + result = run_powershell('-File', str(ROOT / 'doctor.ps1'), '-CodexHome', str(home)) + + assert result.returncode != 0 + report = json.loads(result.stdout) + assert report['status'] == 'FAIL' + invalid = next( + issue for issue in report['issues'] + if issue['code'] == 'ORACLE_STATE_INVALID' + ) + assert fragment in invalid['detail'] + assert report['oracle'] is None def test_uninstall_and_rollback_require_receipt_ownership() -> None: @@ -1079,6 +1287,22 @@ def test_temp_codex_home_install_and_rollback_is_exact_inverse() -> None: assert installed_oracle_patch.is_file() assert installed_devspace_patch.is_file() + manifest = json.loads((ROOT / 'install-manifest.json').read_text(encoding='utf-8')) + oracle_contract = manifest['external']['oracle'] + doctor = run_powershell('-File', str(ROOT / 'doctor.ps1'), '-CodexHome', home) + assert doctor.returncode == 0, doctor.stdout + report = json.loads(doctor.stdout) + assert report['status'] == 'PASS' + assert report['manifest_version'] == manifest['version'] + assert report['oracle']['package'] == ( + f"{oracle_contract['package']}@{oracle_contract['tested_version']}" + ) + assert report['oracle']['tested_version'] == oracle_contract['tested_version'] + assert report['oracle']['command'] == oracle_contract['installation'] + assert Path(report['oracle']['evidence']).samefile(created) + assert report['devspace']['tested_version'] == manifest['external']['devspace']['tested_version'] + assert f"{oracle_contract['installation']} --version" in report['commands'] + rolled_back = run_powershell( '-File', str(ROOT / 'rollback.ps1'), '-CodexHome', home, diff --git a/tests/test_release_packaging.py b/tests/test_release_packaging.py index c5bc76b7..8d6b27c8 100644 --- a/tests/test_release_packaging.py +++ b/tests/test_release_packaging.py @@ -46,10 +46,10 @@ def test_exact_external_versions_integrities_and_source_authority() -> None: external = values()[0]["external"] assert external["oracle"] == { "package": "@steipete/oracle", - "tested_version": "0.17.2", + "tested_version": "0.17.3", "license": "MIT", - "integrity": "sha512-Y2I/sTML2YPZrmYaw1QbpNd7bt6so9ld1pTjRP/MiEKTWanYjoICkmCpWBplPXq+KzHiVsgyPqUZpwxxOpa2Jg==", - "installation": "npx -y @steipete/oracle@0.17.2", + "integrity": "sha512-xoziw8brto9rEtOROHcMr4vHu70DDGQJ41bwMHpkJgA77MIZ11B+IQtGqKpZ48WkihmHkEUVEvWsf+eDwxtwgg==", + "installation": "npx -y @steipete/oracle@0.17.3", "repository": "steipete/oracle", "release_tag_convention": "v{version}", } @@ -89,7 +89,7 @@ def test_package_metadata_is_publishable_and_lockfile_matches() -> None: lock = json.loads((ROOT / "package-lock.json").read_text(encoding="utf-8")) assert package["private"] is False assert package["name"] == lock["name"] == lock["packages"][""]["name"] - assert package["version"] == lock["version"] == lock["packages"][""]["version"] == "1.8.0" + assert package["version"] == lock["version"] == lock["packages"][""]["version"] == "1.8.1" assert package["engines"]["node"] == lock["packages"][""]["engines"]["node"] == ">=24 <27" assert package["repository"]["url"] == "git+https://github.com/1Morganmore/DevSpace-Oracle.git" @@ -106,12 +106,13 @@ def test_workflows_use_current_node24_action_majors() -> None: workflows = "\n".join( path.read_text(encoding="utf-8") for path in (ROOT / ".github/workflows").glob("*.yml") ) - for action in ("actions/checkout", "actions/setup-python", "actions/setup-node"): + for action in ("actions/checkout", "actions/setup-python", "actions/setup-node", "actions/upload-artifact"): if action in workflows: assert f"{action}@v7" in workflows assert "actions/checkout@v4" not in workflows assert "actions/setup-python@v5" not in workflows assert "actions/setup-node@v4" not in workflows + assert "actions/upload-artifact@v4" not in workflows def test_upstream_drift_workflow_is_separate_read_only_and_non_required() -> None: @@ -159,8 +160,8 @@ def test_upstream_typescript_source_change_impacts_compiled_patch_target(monkeyp def fake_fetch(url: str) -> dict: if "registry.npmjs.org" in url: return { - "dist-tags": {"latest": "0.17.3"}, - "versions": {"0.17.2": {"dist": {"integrity": integrity}}}, + "dist-tags": {"latest": "0.18.0"}, + "versions": {"0.17.3": {"dist": {"integrity": integrity}}}, } return {"files": [{"filename": "src/browser/actions/thinkingTime.ts"}]} @@ -168,7 +169,7 @@ def fake_fetch(url: str) -> dict: monkeypatch.setitem( check.__globals__, "latest_tag", - lambda _repository: "v0.17.3", + lambda _repository: "v0.18.0", ) result = check( @@ -176,7 +177,7 @@ def fake_fetch(url: str) -> dict: { "package": "@steipete/oracle", "repository": "steipete/oracle", - "tested_version": "0.17.2", + "tested_version": "0.17.3", "integrity": integrity, "release_tag_convention": "v{version}", }, @@ -188,6 +189,17 @@ def fake_fetch(url: str) -> dict: ] +def test_upstream_drift_tracks_the_active_oracle_patch_map() -> None: + module = runpy.run_path(str(ROOT / "scripts/check_upstream.py")) + compat = runpy.run_path(str(ROOT / "bin/chatgpt_oracle_compat.py")) + expected = set(compat["VERSION_PATCHES"][compat["SUPPORTED_VERSION"]]) + assignment = "PATCHES_" + compat["SUPPORTED_VERSION"].replace(".", "") + assert module["patch_targets"]( + ROOT / "bin/chatgpt_oracle_compat.py", assignment + ) == expected + assert expected == set(compat["PATCHES"]) + + def test_public_notices_and_no_vendoring() -> None: notice = (ROOT / "THIRD_PARTY_NOTICES.md").read_text(encoding="utf-8") assert "@steipete/oracle" in notice and "@waishnav/devspace" in notice