diff --git a/.github/workflows/pr-build.yml b/.github/workflows/pr-build.yml index ce3818db7..ebc0f2678 100644 --- a/.github/workflows/pr-build.yml +++ b/.github/workflows/pr-build.yml @@ -38,7 +38,10 @@ jobs: - name: Build frontend working-directory: apps/codex-plus-manager - run: npm run vite:build + shell: bash + run: | + ./node_modules/.bin/vite build + test -d dist - name: Rust tests run: cargo test --workspace @@ -116,7 +119,10 @@ jobs: - name: Build frontend working-directory: apps/codex-plus-manager - run: npm run vite:build + shell: bash + run: | + ./node_modules/.bin/vite build + test -d dist - name: Build release binaries run: cargo build --release --target ${{ matrix.target }} diff --git a/.github/workflows/release-assets.yml b/.github/workflows/release-assets.yml index 3fc0ad6a5..8123c184d 100644 --- a/.github/workflows/release-assets.yml +++ b/.github/workflows/release-assets.yml @@ -45,13 +45,6 @@ jobs: Copy-Item target/release/codex-plus-plus.exe dist/windows/app/ Copy-Item target/release/codex-plus-plus-manager.exe dist/windows/app/ - - name: Build Windows zip asset - shell: pwsh - run: | - $version = "${{ github.event.release.tag_name }}".TrimStart("v", "V") - New-Item -ItemType Directory -Force dist/windows | Out-Null - Compress-Archive -Path dist/windows/app/* -DestinationPath "dist/windows/CodexPlusPlus-$version-windows-x64.zip" -Force - - name: Build Windows installer shell: pwsh run: | @@ -68,9 +61,7 @@ jobs: uses: softprops/action-gh-release@v2 with: tag_name: ${{ github.event.release.tag_name }} - files: | - dist/windows/*.exe - dist/windows/*.zip + files: dist/windows/*.exe macos-dmg: name: macOS DMG (${{ matrix.arch }}) @@ -118,16 +109,6 @@ jobs: VERSION="${VERSION#V}" BINARY_DIR="$PWD/target/${{ matrix.target }}/release" bash scripts/installer/macos/package-dmg.sh "$VERSION" "${{ matrix.arch }}" - - name: Build macOS zip asset - shell: bash - run: | - VERSION="${GITHUB_REF_NAME#v}" - VERSION="${VERSION#V}" - mkdir -p dist/macos/app-${{ matrix.arch }} - cp "target/${{ matrix.target }}/release/codex-plus-plus" "dist/macos/app-${{ matrix.arch }}/" - cp "target/${{ matrix.target }}/release/codex-plus-plus-manager" "dist/macos/app-${{ matrix.arch }}/" - (cd dist/macos && zip -r "CodexPlusPlus-${VERSION}-macos-${{ matrix.arch }}.zip" "app-${{ matrix.arch }}") - - name: Verify macOS bundle structure run: | set -euo pipefail @@ -146,9 +127,7 @@ jobs: uses: softprops/action-gh-release@v2 with: tag_name: ${{ github.event.release.tag_name }} - files: | - dist/macos/*.dmg - dist/macos/*.zip + files: dist/macos/*.dmg latest-json: name: Upload static latest.json diff --git a/apps/codex-plus-manager/src-tauri/src/commands.rs b/apps/codex-plus-manager/src-tauri/src/commands.rs index 4f5157ffa..3da80af1a 100644 --- a/apps/codex-plus-manager/src-tauri/src/commands.rs +++ b/apps/codex-plus-manager/src-tauri/src/commands.rs @@ -16,6 +16,11 @@ use serde_json::{Value, json}; use crate::install::{self, InstallActionResult, InstallOptions}; +fn test_env_lock() -> std::sync::MutexGuard<'static, ()> { + static LOCK: OnceLock> = OnceLock::new(); + LOCK.get_or_init(|| Mutex::new(())).lock().expect("test env lock poisoned") +} + #[derive(Debug, Clone, Serialize)] pub struct CommandResult where @@ -3489,6 +3494,7 @@ mod tests { #[test] fn env_conflict_commands_ignore_codex_home_and_remove_openai_vars() { + let _lock = test_env_lock(); let test_openai_name = "OPENAI_CODEX_PLUS_ENV_CONFLICT_TEST"; let previous_openai = std::env::var_os(test_openai_name); let previous_codex_home = std::env::var_os("CODEX_HOME"); @@ -3540,6 +3546,7 @@ mod tests { #[test] fn delete_local_session_falls_back_when_requested_db_no_longer_contains_thread() { + let _lock = test_env_lock(); let temp = tempfile::tempdir().unwrap(); let previous_codex_home = std::env::var_os("CODEX_HOME"); let codex_home = temp.path().join("codex-home"); @@ -3606,6 +3613,7 @@ mod tests { #[test] fn list_local_sessions_deduplicates_threads_across_current_and_legacy_dbs() { + let _lock = test_env_lock(); let temp = tempfile::tempdir().unwrap(); let previous_codex_home = std::env::var_os("CODEX_HOME"); let codex_home = temp.path().join("codex-home"); @@ -3634,6 +3642,7 @@ mod tests { #[test] fn delete_local_session_removes_duplicate_threads_from_all_candidate_dbs() { + let _lock = test_env_lock(); let temp = tempfile::tempdir().unwrap(); let previous_codex_home = std::env::var_os("CODEX_HOME"); let codex_home = temp.path().join("codex-home"); diff --git a/apps/codex-plus-manager/src/App.tsx b/apps/codex-plus-manager/src/App.tsx index 220bfcc38..3e9ba71fe 100644 --- a/apps/codex-plus-manager/src/App.tsx +++ b/apps/codex-plus-manager/src/App.tsx @@ -679,7 +679,7 @@ const defaultSettings: BackendSettings = { codexAppUpstreamWorktreeCreate: true, codexAppNativeMenuPlacement: true, codexAppNativeMenuLocalization: true, - codexAppServiceTierControls: false, + codexAppServiceTierControls: true, codexAppStepwiseEnabled: false, codexAppStepwiseDirectSend: false, codexAppStepwiseBaseUrl: "", @@ -2655,7 +2655,7 @@ function EnhanceScreen({ setEnhanceFlag("codexAppPluginMarketplaceUnlock", value)} /> setEnhanceFlag("codexAppPluginAutoExpand", value)} /> setEnhanceFlag("codexAppModelWhitelistUnlock", value)} /> - setEnhanceFlag("codexAppServiceTierControls", value)} /> + {}} />
{t("官方远端插件缓存")} diff --git a/apps/codex-plus-manager/src/model-windows.test.ts b/apps/codex-plus-manager/src/model-windows.test.ts index 9f995cea7..50e12f3f6 100644 --- a/apps/codex-plus-manager/src/model-windows.test.ts +++ b/apps/codex-plus-manager/src/model-windows.test.ts @@ -1,6 +1,5 @@ import assert from "node:assert"; import { describe, it } from "node:test"; -import type { RelayProfile } from "./App.tsx"; import { buildModelWindows, modelWindowRowsFromProfile, @@ -10,8 +9,31 @@ import { mergeModelWindowRows, } from "./model-windows.ts"; +type RelayProfileShape = { + id: string; + name: string; + model: string; + baseUrl: string; + upstreamBaseUrl: string; + apiKey: string; + protocol: "responses" | "chatCompletions"; + relayMode: "official" | "mixedApi" | "pureApi" | "aggregate"; + officialMixApiKey: boolean; + testModel: string; + configContents: string; + authContents: string; + useCommonConfig: boolean; + contextSelection: { mcpServers: string[]; skills: string[]; plugins: string[] }; + contextSelectionInitialized: boolean; + contextWindow: string; + autoCompactLimit: string; + modelList: string; + modelWindows: string; + userAgent: string; +}; + // 类型检查:确保 RelayProfile 包含 modelWindows 字段 -const _profileTypeCheck: RelayProfile = { +const _profileTypeCheck: RelayProfileShape = { id: "test", name: "", model: "", diff --git a/assets/inject/renderer-inject.js b/assets/inject/renderer-inject.js index f926f9687..8c08cb477 100644 --- a/assets/inject/renderer-inject.js +++ b/assets/inject/renderer-inject.js @@ -1143,7 +1143,7 @@ } function defaultCodexPlusSettings() { - return { pluginMarketplaceUnlock: true, pluginAutoExpand: true, modelWhitelistUnlock: true, sessionDelete: true, markdownExport: true, pasteFix: false, projectMove: true, threadIdBadge: false, conversationView: false, conversationViewMaxWidth: conversationViewDefaultWidth, threadScrollRestore: true, zedRemoteOpen: true, upstreamWorktreeCreate: true, nativeMenuPlacement: true, serviceTierControls: false, stepwise: false }; + return { pluginMarketplaceUnlock: true, pluginAutoExpand: true, modelWhitelistUnlock: true, sessionDelete: true, markdownExport: true, pasteFix: false, projectMove: true, threadIdBadge: false, conversationView: false, conversationViewMaxWidth: conversationViewDefaultWidth, threadScrollRestore: true, zedRemoteOpen: true, upstreamWorktreeCreate: true, nativeMenuPlacement: true, serviceTierControls: true, stepwise: false }; } const codexPlusBackendSettingMap = { @@ -2003,10 +2003,7 @@ function applyCodexServiceTierRequestOverride(method, params, threadIdHint = "") { const override = codexServiceTierOverrideForRequest(method, params, threadIdHint); if (!override) return params; - const nextParams = { ...(params || {}), serviceTier: override.serviceTier }; - if (Object.prototype.hasOwnProperty.call(nextParams, "service_tier") || override.fastBlocked) { - nextParams.service_tier = override.serviceTier; - } + const nextParams = { ...(params || {}), serviceTier: override.serviceTier, service_tier: override.serviceTier }; sendCodexPlusDiagnostic("service_tier_request_override_applied", { method, threadId: override.threadId || "", @@ -2435,8 +2432,8 @@
-
Fast 按钮
显示服务模式切换按钮;Fast 仅支持 ${codexServiceTierFastModelListLabel()},其他模型按 Standard 发送。
- +
系统 Fast 开关
是否开启系统 Fast 开关:已默认开启,API Key 登录复用 Codex 原生速度选项与标识;具体 Fast / Standard 在 Codex 界面选择,Fast 仅支持 ${codexServiceTierFastModelListLabel()}。
+
Stepwise
在当前 Codex 页面显示可拖动的下一步建议浮层,可在设置页配置模型和直接发送。
diff --git a/crates/codex-plus-core/src/launcher.rs b/crates/codex-plus-core/src/launcher.rs index fcdeb0467..466b3e489 100644 --- a/crates/codex-plus-core/src/launcher.rs +++ b/crates/codex-plus-core/src/launcher.rs @@ -3,6 +3,7 @@ use std::path::{Path, PathBuf}; use std::process::Stdio; use std::sync::Arc; use std::time::{SystemTime, UNIX_EPOCH}; +use std::env; use anyhow::Context; use async_trait::async_trait; @@ -693,10 +694,36 @@ impl LaunchHooks for DefaultLaunchHooks { let executable = command .first() .ok_or_else(|| anyhow::anyhow!("macOS open command is empty"))?; - let child = Command::new(executable) + let mut child_command = Command::new(executable); + child_command .args(&command[1..]) .stdout(Stdio::null()) - .stderr(Stdio::null()) + .stderr(Stdio::null()); + if settings.enhancements_enabled { + let preload_path = crate::service_tier_preload::ensure_service_tier_preload() + .context("failed to prepare service tier preload")?; + let node_options = + crate::service_tier_preload::node_options_with_service_tier_preload( + env::var("NODE_OPTIONS").ok().as_deref(), + &preload_path.to_string_lossy(), + ); + child_command.env("NODE_OPTIONS", node_options.clone()); + let _ = crate::diagnostic_log::append_diagnostic_log( + "launcher.service_tier_preload_enabled", + serde_json::json!({ + "preload_path": preload_path.to_string_lossy(), + "node_options": node_options, + }), + ); + } else { + let _ = crate::diagnostic_log::append_diagnostic_log( + "launcher.service_tier_preload_disabled", + serde_json::json!({ + "enhancements_enabled": settings.enhancements_enabled, + }), + ); + } + let child = child_command .spawn() .context("failed to launch macOS Codex app")?; *self.child.lock().await = Some(child); diff --git a/crates/codex-plus-core/src/lib.rs b/crates/codex-plus-core/src/lib.rs index 0c7ae1c49..7ea06e11a 100644 --- a/crates/codex-plus-core/src/lib.rs +++ b/crates/codex-plus-core/src/lib.rs @@ -29,6 +29,7 @@ pub mod relay_rotation; pub mod relay_switch; pub mod routes; pub mod script_market; +pub mod service_tier_preload; pub mod settings; pub mod status; pub mod stepwise; diff --git a/crates/codex-plus-core/src/ports.rs b/crates/codex-plus-core/src/ports.rs index 2263b21fa..93b4014f4 100644 --- a/crates/codex-plus-core/src/ports.rs +++ b/crates/codex-plus-core/src/ports.rs @@ -1,6 +1,7 @@ use std::fs::File; use std::net::{TcpListener, ToSocketAddrs}; use std::path::{Path, PathBuf}; +use std::sync::{Mutex, OnceLock}; use fs2::FileExt; @@ -344,7 +345,7 @@ mod tests { #[test] fn launcher_guard_port_honors_env_override() { - let _guard = guard_port_env_lock(); + let _lock = _guard_port_env_test_lock(); _clear_guard_port_env_vars(); unsafe { std::env::set_var("CODEX_PLUS_GUARD_PORT", "9999") }; let port = launcher_guard_port(); @@ -354,7 +355,7 @@ mod tests { #[test] fn launcher_guard_port_honors_specific_env_override() { - let _guard = guard_port_env_lock(); + let _lock = _guard_port_env_test_lock(); _clear_guard_port_env_vars(); unsafe { std::env::set_var("CODEX_PLUS_LAUNCHER_GUARD_PORT", "8888") }; let port = launcher_guard_port(); @@ -364,7 +365,7 @@ mod tests { #[test] fn manager_guard_port_honors_specific_env_override() { - let _guard = guard_port_env_lock(); + let _lock = _guard_port_env_test_lock(); _clear_guard_port_env_vars(); unsafe { std::env::set_var("CODEX_PLUS_MANAGER_GUARD_PORT", "7777") }; let port = manager_guard_port(); @@ -374,7 +375,7 @@ mod tests { #[test] fn launcher_guard_port_honors_offset_env() { - let _guard = guard_port_env_lock(); + let _lock = _guard_port_env_test_lock(); _clear_guard_port_env_vars(); unsafe { std::env::set_var("CODEX_PLUS_GUARD_PORT_OFFSET", "50") }; let port = launcher_guard_port(); @@ -399,3 +400,8 @@ fn _clear_guard_port_env_vars() { let _ = std::env::remove_var("CODEX_PLUS_GUARD_PORT_OFFSET"); } } + +fn _guard_port_env_test_lock() -> std::sync::MutexGuard<'static, ()> { + static LOCK: OnceLock> = OnceLock::new(); + LOCK.get_or_init(|| Mutex::new(())).lock().expect("guard port env test lock poisoned") +} diff --git a/crates/codex-plus-core/src/relay_config.rs b/crates/codex-plus-core/src/relay_config.rs index fc9e76a44..e61568fbd 100644 --- a/crates/codex-plus-core/src/relay_config.rs +++ b/crates/codex-plus-core/src/relay_config.rs @@ -1956,6 +1956,14 @@ fn complete_relay_profile_config(profile: &RelayProfile) -> anyhow::Result anyhow::Result<()> { + if profile.relay_mode == crate::settings::RelayMode::Aggregate { + profile.model.clear(); + profile.base_url.clear(); + profile.upstream_base_url.clear(); + profile.api_key.clear(); + profile.config_contents.clear(); + profile.auth_contents.clear(); + } if profile.model_windows.trim().is_empty() && profile.model_list.contains('[') { let (clean_list, windows) = crate::model_suffix::migrate_model_list_with_suffixes(&profile.model_list); diff --git a/crates/codex-plus-core/src/service_tier_preload.rs b/crates/codex-plus-core/src/service_tier_preload.rs new file mode 100644 index 000000000..81943eca7 --- /dev/null +++ b/crates/codex-plus-core/src/service_tier_preload.rs @@ -0,0 +1,326 @@ +use std::fs; +use std::path::PathBuf; + +use anyhow::Context; + +const PRELOAD_FILE: &str = "service-tier-preload.js"; + +pub fn ensure_service_tier_preload() -> anyhow::Result { + let dir = crate::paths::default_app_state_dir().join("preload"); + fs::create_dir_all(&dir).with_context(|| { + format!( + "failed to create service tier preload directory {}", + dir.display() + ) + })?; + let path = dir.join(PRELOAD_FILE); + fs::write(&path, service_tier_preload_script()).with_context(|| { + format!( + "failed to write service tier preload script {}", + path.display() + ) + })?; + Ok(path) +} + +pub fn node_options_with_service_tier_preload( + existing: Option<&str>, + preload_path: &str, +) -> String { + let require_arg = format!("--require={preload_path}"); + match existing.map(str::trim).filter(|value| !value.is_empty()) { + Some(existing) if existing.contains(&require_arg) => existing.to_string(), + Some(existing) => format!("{require_arg} {existing}"), + None => require_arg, + } +} + +pub fn service_tier_preload_script() -> &'static str { + r#""use strict"; + +const fs = require("fs"); +const path = require("path"); +const Module = require("module"); + +const PATCH_MARK = Symbol.for("codex-plus.service-tier-protocol-handle-patched"); +const PATCH_VERSION = "protocol-handle-3"; +const LOG_PATH = path.join(process.env.HOME || process.cwd(), ".codex-session-delete", "codex-plus.log"); +const SETTINGS_PATH = path.join(process.env.HOME || process.cwd(), ".codex-session-delete", "settings.json"); + +function log(event, detail) { + try { + fs.mkdirSync(path.dirname(LOG_PATH), { recursive: true }); + fs.appendFileSync(LOG_PATH, JSON.stringify({ + timestamp_ms: Date.now(), + pid: process.pid, + event, + detail: detail || {}, + }) + "\n"); + } catch {} +} + +function patchServiceTierSettingsAsset(source) { + let patched = source; + if (!isServiceTierSettingsCandidate(source)) { + return source; + } + + patched = replaceOneOf( + patched, + [ + [ + "s=o?.authMethod===`chatgpt`", + "s=o?.authMethod===`chatgpt`||o?.authMethod===`apikey`", + ], + [ + "a=i?.authMethod===`chatgpt`", + "a=i?.authMethod===`chatgpt`||i?.authMethod===`apikey`", + ], + ], + "service tier settings auth gate" + ); + patched = replaceOneOf( + patched, + [ + ["s&&!f&&u!=null", "s&&!f"], + ["d=a&&!u&&c!=null&&c?.requirements?.featureRequirements?.fast_mode!==!1", "d=a&&!u&&c?.requirements?.featureRequirements?.fast_mode!==!1"], + ], + "service tier settings API key config requirement" + ); + return patched; +} + +function patchReadServiceTierAsset(source) { + let patched = source; + if (!isReadServiceTierCandidate(source)) { + return source; + } + + patched = replaceOneOf( + patched, + [ + [ + "return n===`chatgpt`?(await e.query.fetch(c,{authMethod:n,hostId:t})).requirements?.featureRequirements?.fast_mode!==!1:!1", + "return n===`chatgpt`?(await e.query.fetch(c,{authMethod:n,hostId:t})).requirements?.featureRequirements?.fast_mode!==!1:n===`apikey`", + ], + [ + "return n===`chatgpt`?(await e.query.fetch(li,{authMethod:n,hostId:t})).requirements?.featureRequirements?.fast_mode!==!1:!1", + "return n===`chatgpt`?(await e.query.fetch(li,{authMethod:n,hostId:t})).requirements?.featureRequirements?.fast_mode!==!1:n===`apikey`", + ], + [ + "return n===`chatgpt`?(await e.query.fetch(Bu,{authMethod:n,hostId:t})).requirements?.featureRequirements?.fast_mode!==!1:!1", + "return n===`chatgpt`?(await e.query.fetch(Bu,{authMethod:n,hostId:t})).requirements?.featureRequirements?.fast_mode!==!1:n===`apikey`", + ], + [ + "return n===`chatgpt`?(await e.query.fetch(Gd,{authMethod:n,hostId:t})).requirements?.featureRequirements?.fast_mode!==!1:!1", + "return n===`chatgpt`?(await e.query.fetch(Gd,{authMethod:n,hostId:t})).requirements?.featureRequirements?.fast_mode!==!1:n===`apikey`", + ], + ], + "read service tier auth gate" + ); + patched = replaceOneOf( + patched, + [ + [ + "return d.service_tier==null?t(await m(o,c??d.model),d.service_tier,s):t(null,d.service_tier,s)", + "return d.service_tier==null?t(await m(o,c??d.model),d.service_tier,s):t(await m(o,c??d.model),d.service_tier,s)", + ], + [ + "return o.service_tier==null?Ia(await np(t,n??o.model),o.service_tier,r):Ia(null,o.service_tier,r)", + "return o.service_tier==null?Ia(await np(t,n??o.model),o.service_tier,r):Ia(await np(t,n??o.model),o.service_tier,r)", + ], + [ + "return o.service_tier==null?Pc(await xAe(t,n??o.model),o.service_tier,r):Pc(null,o.service_tier,r)", + "return o.service_tier==null?Pc(await xAe(t,n??o.model),o.service_tier,r):Pc(await xAe(t,n??o.model),o.service_tier,r)", + ], + [ + "return o.service_tier==null?jd(await fQe(t,n??o.model),o.service_tier,r):jd(null,o.service_tier,r)", + "return o.service_tier==null?jd(await fQe(t,n??o.model),o.service_tier,r):jd(await fQe(t,n??o.model),o.service_tier,r)", + ], + ], + "read service tier explicit config model lookup" + ); + return patched; +} + +function replaceOnce(source, from, to, label) { + if (source.includes(to)) return source; + if (!source.includes(from)) throw new Error(`${label} pattern not found`); + return source.replace(from, to); +} + +function replaceOneOf(source, variants, label) { + for (const [from, to] of variants) { + if (source.includes(to)) { + return source; + } + if (source.includes(from)) { + return source.replace(from, to); + } + } + throw new Error(`${label} pattern not found`); +} + +function isServiceTierSettingsCandidate(source) { + return ( + source.includes("isServiceTierAllowed") && + source.includes("featureRequirements?.fast_mode") && + source.includes("authMethod") + ); +} + +function isReadServiceTierCandidate(source) { + return ( + source.includes("Failed to read service tier for request") && + source.includes("featureRequirements?.fast_mode") && + source.includes("service_tier") + ); +} + +function appProtocolAssetName(url) { + if (typeof url !== "string") return ""; + try { + const parsed = new URL(url); + if (parsed.protocol !== "app:" || parsed.host !== "-") return ""; + const segments = decodeURIComponent(parsed.pathname).split("/").filter(Boolean); + return segments.length >= 2 && segments[0] === "assets" ? segments[segments.length - 1] : ""; + } catch { + return ""; + } +} + +function serviceTierControlsEnabled() { + try { + const settings = JSON.parse(fs.readFileSync(SETTINGS_PATH, "utf8")); + return settings && settings.enhancementsEnabled !== false; + } catch { + return true; + } +} + +function discoverPatchedAssets() { + if (!serviceTierControlsEnabled()) { + log("service_tier_preload_disabled_by_settings", {}); + return new Map(); + } + const assetsDir = path.join(process.resourcesPath, "app.asar", "webview", "assets"); + const assets = new Map(); + for (const name of fs.readdirSync(assetsDir)) { + const filePath = path.join(assetsDir, name); + if (!name.endsWith(".js")) continue; + const source = fs.readFileSync(filePath, "utf8"); + let patched = source; + let kinds = []; + + const settingsPatched = patchServiceTierSettingsAsset(patched); + if (settingsPatched !== patched) { + patched = settingsPatched; + kinds.push("native-service-tier-settings"); + } + + const readPatched = patchReadServiceTierAsset(patched); + if (readPatched !== patched) { + patched = readPatched; + kinds.push("native-read-service-tier"); + } + + if (patched !== source) { + assets.set(name, { + kind: kinds.join("+"), + patched, + }); + } + } + if (assets.size === 0) throw new Error("target native speed UI assets were not found"); + return assets; +} + +function installProtocolHandlePatch(electron) { + const protocol = electron && electron.protocol; + if (!protocol || typeof protocol.handle !== "function") { + log("service_tier_preload_protocol_unavailable", {}); + return; + } + if (protocol.handle[PATCH_MARK] === PATCH_VERSION) return; + + const patchedAssets = discoverPatchedAssets(); + if (patchedAssets.size === 0) return; + const originalHandle = protocol.handle; + const wrappedHandle = function codexPlusServiceTierProtocolHandle(scheme, handler) { + if (String(scheme) !== "app" || typeof handler !== "function") { + return originalHandle.apply(this, arguments); + } + const wrappedHandler = async function codexPlusServiceTierAppProtocolHandler(request) { + const asset = patchedAssets.get(appProtocolAssetName(request && request.url)); + if (!asset) return handler.call(this, request); + log("service_tier_preload_asset_patched", { kind: asset.kind, url: request && request.url, version: PATCH_VERSION }); + return new Response(Buffer.from(asset.patched, "utf8"), { + headers: { + "Content-Length": String(Buffer.byteLength(asset.patched, "utf8")), + "Content-Type": "text/javascript; charset=utf-8", + "X-Codex-Plus-Patch": PATCH_VERSION, + }, + }); + }; + return originalHandle.call(this, scheme, wrappedHandler); + }; + + Object.defineProperty(wrappedHandle, PATCH_MARK, { + configurable: false, + enumerable: false, + value: PATCH_VERSION, + }); + protocol.handle = wrappedHandle; + log("service_tier_preload_protocol_patch_installed", { + assets: Array.from(patchedAssets.keys()), + version: PATCH_VERSION, + }); +} + +const originalLoad = Module._load; +Module._load = function codexPlusServiceTierModuleLoad(request, parent, isMain) { + const result = originalLoad.apply(this, arguments); + if (request === "electron") { + try { + installProtocolHandlePatch(result); + } catch (error) { + log("service_tier_preload_protocol_patch_failed", { message: String(error) }); + } + } + return result; +}; + +log("service_tier_preload_loaded", { version: PATCH_VERSION }); +"# +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn node_options_prepends_preload() { + assert_eq!( + node_options_with_service_tier_preload(Some("--trace-warnings"), "/tmp/preload.js"), + "--require=/tmp/preload.js --trace-warnings" + ); + } + + #[test] + fn preload_script_wraps_electron_module_load_and_app_protocol() { + let script = service_tier_preload_script(); + + assert!(script.contains("Module._load")); + assert!(script.contains("protocol.handle")); + assert!(script.contains("serviceTierControlsEnabled")); + assert!(script.contains("settings.enhancementsEnabled !== false")); + assert!(script.contains("service_tier_preload_disabled_by_settings")); + assert!(script.contains("isServiceTierSettingsCandidate")); + assert!(script.contains("isReadServiceTierCandidate")); + assert!(script.contains("replaceOneOf")); + assert!(script.contains("a=i?.authMethod===`chatgpt`||i?.authMethod===`apikey`")); + assert!(script.contains("e.query.fetch(Gd,{authMethod:n,hostId:t})")); + assert!(script.contains("jd(await fQe(t,n??o.model),o.service_tier,r)")); + assert!(script.contains("n===`apikey`")); + } +} diff --git a/crates/codex-plus-core/src/settings.rs b/crates/codex-plus-core/src/settings.rs index 436c9ab5e..2284d6115 100644 --- a/crates/codex-plus-core/src/settings.rs +++ b/crates/codex-plus-core/src/settings.rs @@ -237,7 +237,7 @@ pub struct BackendSettings { pub codex_app_native_menu_placement: bool, #[serde(rename = "codexAppNativeMenuLocalization", default = "default_true")] pub codex_app_native_menu_localization: bool, - #[serde(rename = "codexAppServiceTierControls", default)] + #[serde(rename = "codexAppServiceTierControls", default = "default_true")] pub codex_app_service_tier_controls: bool, #[serde(rename = "codexAppStepwiseEnabled", default)] pub codex_app_stepwise_enabled: bool, @@ -356,7 +356,7 @@ impl Default for BackendSettings { codex_app_upstream_worktree_create: true, codex_app_native_menu_placement: true, codex_app_native_menu_localization: true, - codex_app_service_tier_controls: false, + codex_app_service_tier_controls: true, codex_app_stepwise_enabled: false, codex_app_stepwise_direct_send: false, codex_app_stepwise_base_url: String::new(), diff --git a/crates/codex-plus-core/tests/bridge_routes.rs b/crates/codex-plus-core/tests/bridge_routes.rs index cf63669b1..cdf83729e 100644 --- a/crates/codex-plus-core/tests/bridge_routes.rs +++ b/crates/codex-plus-core/tests/bridge_routes.rs @@ -1035,12 +1035,13 @@ impl BridgeSettingsService for FakeSettings { raw.insert("enhancementsEnabled".to_string(), json!(value)); } for key in [ + "codexAppPluginEntryUnlock", "codexAppPluginMarketplaceUnlock", "codexAppModelWhitelistUnlock", "codexAppSessionDelete", "codexAppMarkdownExport", - "codexAppForceChineseLocale", "codexAppProjectMove", + "codexAppConversationTimeline", "codexAppThreadIdBadge", "codexAppConversationView", "codexAppThreadScrollRestore", diff --git a/crates/codex-plus-core/tests/cdp_bridge.rs b/crates/codex-plus-core/tests/cdp_bridge.rs index e7683a5d3..73a63731b 100644 --- a/crates/codex-plus-core/tests/cdp_bridge.rs +++ b/crates/codex-plus-core/tests/cdp_bridge.rs @@ -568,8 +568,8 @@ fn injection_script_exposes_fast_service_tier_control() { assert!(script.contains("codexServiceTierMaybeLoadModelCatalog")); assert!(script.contains("fastBlocked")); assert!(script.contains("data-tier=\"unsupported\"")); - assert!(script.contains("nextParams.service_tier = override.serviceTier")); - assert!(script.contains("serviceTierControls: false")); + assert!(script.contains("service_tier: override.serviceTier")); + assert!(script.contains("serviceTierControls: true")); assert!(script.contains("data-codex-plus-setting=\"serviceTierControls\"")); assert!(script.contains("data-codex-service-tier-controls")); assert!(script.contains("removeCodexServiceTierBadges")); @@ -676,6 +676,7 @@ fn injection_script_applies_fast_service_tier_contract() { ); assert_eq!(cases["turnWithoutModel"]["serviceTier"], "priority"); + assert_eq!(cases["turnWithoutModel"]["service_tier"], "priority"); assert_eq!(cases["turnWithoutModelDiagnosticModel"], "gpt-5.4"); assert_eq!( @@ -688,6 +689,7 @@ fn injection_script_applies_fast_service_tier_contract() { ); assert_eq!(cases["startConversation"]["serviceTier"], "priority"); + assert_eq!(cases["startConversation"]["service_tier"], "priority"); } fn run_service_tier_contract_harness() -> serde_json::Value {