diff --git a/apps/codex-plus-launcher/src/main.rs b/apps/codex-plus-launcher/src/main.rs index 2fafb6992..5ac503dbc 100644 --- a/apps/codex-plus-launcher/src/main.rs +++ b/apps/codex-plus-launcher/src/main.rs @@ -304,6 +304,16 @@ impl LaunchHooks for LauncherHooks { self.core.ensure_computer_use_config(settings).await } + async fn ensure_builtin_plugin_state_after_provider_switch( + &self, + settings: &codex_plus_core::settings::BackendSettings, + source: &str, + ) -> anyhow::Result<()> { + self.core + .ensure_builtin_plugin_state_after_provider_switch(settings, source) + .await + } + async fn ensure_plugin_marketplace_config( &self, settings: &codex_plus_core::settings::BackendSettings, @@ -819,6 +829,10 @@ mod tests { assert!(source.contains("async fn ensure_computer_use_config")); assert!(source.contains("self.core.ensure_computer_use_config(settings).await")); + assert!(source.contains("async fn ensure_builtin_plugin_state_after_provider_switch")); + assert!( + source.contains(".ensure_builtin_plugin_state_after_provider_switch(settings, source)") + ); assert!(source.contains("async fn ensure_plugin_marketplace_config")); assert!(source.contains("self.core.ensure_plugin_marketplace_config(settings).await")); assert!(source.contains("async fn start_computer_use_guard_watchdog")); diff --git a/apps/codex-plus-manager/src-tauri/src/commands.rs b/apps/codex-plus-manager/src-tauri/src/commands.rs index 4456c8546..cf094e9ea 100644 --- a/apps/codex-plus-manager/src-tauri/src/commands.rs +++ b/apps/codex-plus-manager/src-tauri/src/commands.rs @@ -2505,6 +2505,7 @@ fn provider_doctor_recommendation(checks: &[ProviderDoctorCheck]) -> String { pub fn apply_relay_injection() -> CommandResult { let home = codex_plus_core::relay_config::default_codex_home_dir(); let settings = SettingsStore::default().load().unwrap_or_default(); + prepare_codex_app_state_before_provider_switch(&home, "manager.apply_relay_injection.before"); if !settings.relay_profiles_enabled { let status = codex_plus_core::relay_config::relay_status_from_home(&home); return failed( @@ -2515,16 +2516,29 @@ pub fn apply_relay_injection() -> CommandResult { let relay = settings.active_relay_profile(); log_relay_apply_request("manager.apply_relay_injection", &settings, &relay); if settings.active_aggregate_relay_profile().is_some() { - return apply_aggregate_relay_injection_to_home(&home); + let result = apply_aggregate_relay_injection_to_home(&home); + if result.status == "ok" { + finish_codex_app_state_after_provider_switch( + &home, + &settings, + "manager.apply_relay_injection.aggregate", + ); + } + return result; } if relay_has_complete_files(&relay) { return match codex_plus_core::relay_config::apply_relay_profile_to_home_with_switch_rules_and_computer_use_guard( &home, &relay, &relay_combined_common_config(&settings), - settings.computer_use_guard_enabled, + settings.builtin_plugin_guard_enabled(), ) { Ok(result) => { + finish_codex_app_state_after_provider_switch( + &home, + &settings, + "manager.apply_relay_injection.profile", + ); let status = codex_plus_core::relay_config::relay_status_from_home(&home); log_relay_apply_result( "manager.apply_relay_injection.ok", @@ -2579,6 +2593,11 @@ pub fn apply_relay_injection() -> CommandResult { codex_plus_core::protocol_proxy::DEFAULT_PROTOCOL_PROXY_PORT, ) { Ok(result) => { + finish_codex_app_state_after_provider_switch( + &home, + &settings, + "manager.apply_relay_injection.generated", + ); let status = codex_plus_core::relay_config::relay_status_from_home(&home); log_relay_apply_result( "manager.apply_relay_injection.ok", @@ -2640,6 +2659,10 @@ fn apply_aggregate_relay_injection_to_home(home: &Path) -> CommandResult CommandResult { let home = codex_plus_core::relay_config::default_codex_home_dir(); let settings = SettingsStore::default().load().unwrap_or_default(); + prepare_codex_app_state_before_provider_switch( + &home, + "manager.apply_pure_api_injection.before", + ); if !settings.relay_profiles_enabled { let status = codex_plus_core::relay_config::relay_status_from_home(&home); return failed( @@ -2654,9 +2677,14 @@ pub fn apply_pure_api_injection() -> CommandResult { &home, &relay, &relay_combined_common_config(&settings), - settings.computer_use_guard_enabled, + settings.builtin_plugin_guard_enabled(), ) { Ok(result) => { + finish_codex_app_state_after_provider_switch( + &home, + &settings, + "manager.apply_pure_api_injection.profile", + ); let status = codex_plus_core::relay_config::relay_status_from_home(&home); log_relay_apply_result( "manager.apply_pure_api_injection.ok", @@ -2701,6 +2729,11 @@ pub fn apply_pure_api_injection() -> CommandResult { codex_plus_core::protocol_proxy::DEFAULT_PROTOCOL_PROXY_PORT, ) { Ok(result) => { + finish_codex_app_state_after_provider_switch( + &home, + &settings, + "manager.apply_pure_api_injection.generated", + ); let status = codex_plus_core::relay_config::relay_status_from_home(&home); log_relay_apply_result( "manager.apply_pure_api_injection.ok", @@ -2743,6 +2776,7 @@ pub fn clear_relay_injection() -> CommandResult { let settings = SettingsStore::default().load().unwrap_or_default(); let relay = settings.active_relay_profile(); log_manager_event("manager.clear_relay_injection.start", json!({})); + prepare_codex_app_state_before_provider_switch(&home, "manager.clear_relay_injection.before"); let auth_contents = (relay.relay_mode == codex_plus_core::settings::RelayMode::Official && !relay.official_mix_api_key && !relay.auth_contents.trim().is_empty()) @@ -2750,6 +2784,11 @@ pub fn clear_relay_injection() -> CommandResult { match codex_plus_core::relay_config::clear_relay_config_to_home_with_auth(&home, auth_contents) { Ok(result) => { + finish_codex_app_state_after_provider_switch( + &home, + &settings, + "manager.clear_relay_injection.after", + ); let status = codex_plus_core::relay_config::relay_status_from_home(&home); log_manager_event( "manager.clear_relay_injection.ok", @@ -2780,6 +2819,50 @@ pub fn clear_relay_injection() -> CommandResult { } } +fn prepare_codex_app_state_before_provider_switch(home: &Path, source: &str) { + codex_plus_core::codex_app_state::capture_app_state_snapshot_nonfatal(home, source); +} + +fn finish_codex_app_state_after_provider_switch( + home: &Path, + settings: &BackendSettings, + source: &str, +) { + if settings.codex_app_plugin_marketplace_unlock { + match codex_plus_core::plugin_marketplace::ensure_openai_curated_remote_marketplace_available( + home, + ) { + Ok(result) => { + if result.initialized || result.configured { + log_manager_event( + "manager.remote_plugin_marketplace_ready", + json!({ + "source": source, + "initialized": result.initialized, + "configured": result.configured, + }), + ); + } + } + Err(error) => { + log_manager_event( + "manager.remote_plugin_marketplace_failed", + json!({ + "source": source, + "error": error.to_string(), + }), + ); + } + } + } + if settings.builtin_plugin_guard_enabled() { + codex_plus_core::codex_app_state::ensure_builtin_plugin_state_after_provider_switch_nonfatal( + home, source, + ); + } + codex_plus_core::codex_app_state::sync_app_state_after_provider_switch_nonfatal(home, source); +} + fn relay_has_complete_files(relay: &codex_plus_core::settings::RelayProfile) -> bool { if relay.relay_mode == codex_plus_core::settings::RelayMode::Official && relay.official_mix_api_key @@ -3781,6 +3864,123 @@ mod tests { assert_eq!(result.payload.settings.relay_profiles[0].api_key, "sk-test"); } + #[test] + fn provider_switch_state_helpers_restore_allowed_app_state() { + let temp = tempfile::tempdir().unwrap(); + let home = temp.path().join("codex-home"); + std::fs::create_dir(&home).unwrap(); + let state_path = home.join(".codex-global-state.json"); + std::fs::write( + &state_path, + json!({ + "electron-saved-workspace-roots": ["C:/work/app"], + "thread-workspace-root-hints": { + "thread-1": "C:/work/app" + }, + "electron-persisted-atom-state": { + "default-service-tier": "priority", + "plugin-marketplace-unlocked": true, + "prompt-history": ["secret"] + }, + "computer-use-bundled-plugin-auto-install-disabled": true, + "prompt-history": ["secret"] + }) + .to_string(), + ) + .unwrap(); + #[cfg(windows)] + { + let marketplace = home + .join(".tmp") + .join("bundled-marketplaces") + .join("openai-bundled"); + std::fs::create_dir_all(marketplace.join(".agents").join("plugins")).unwrap(); + std::fs::write( + marketplace + .join(".agents") + .join("plugins") + .join("marketplace.json"), + "{}", + ) + .unwrap(); + for plugin in ["browser", "chrome", "computer-use", "latex"] { + let plugin_root = marketplace + .join("plugins") + .join(plugin) + .join(".codex-plugin"); + std::fs::create_dir_all(&plugin_root).unwrap(); + std::fs::write(plugin_root.join("plugin.json"), "{}").unwrap(); + } + } + prepare_codex_app_state_before_provider_switch(&home, "test.before"); + std::fs::write( + &state_path, + json!({ + "electron-saved-workspace-roots": ["D:/fresh/app"], + "computer-use-bundled-plugin-auto-install-disabled": true + }) + .to_string(), + ) + .unwrap(); + let settings = BackendSettings { + codex_app_plugin_marketplace_unlock: false, + computer_use_guard_enabled: true, + ..BackendSettings::default() + }; + + finish_codex_app_state_after_provider_switch(&home, &settings, "test.after"); + + let state: Value = + serde_json::from_str(&std::fs::read_to_string(&state_path).unwrap()).unwrap(); + assert_eq!( + state["electron-saved-workspace-roots"], + json!(["D:\\fresh\\app", "C:\\work\\app"]) + ); + assert_eq!( + state["thread-workspace-root-hints"]["thread-1"], + "C:/work/app" + ); + assert_eq!( + state["electron-persisted-atom-state"]["default-service-tier"], + "priority" + ); + assert_eq!( + state["electron-persisted-atom-state"]["plugin-marketplace-unlocked"], + true + ); + assert_eq!( + state["computer-use-bundled-plugin-auto-install-disabled"], + false + ); + assert!(state.get("prompt-history").is_none()); + assert!( + state["electron-persisted-atom-state"] + .get("prompt-history") + .is_none() + ); + assert!( + home.join("backups_state/app-state-sync/latest-safe-state.json") + .is_file() + ); + #[cfg(windows)] + { + let config = std::fs::read_to_string(home.join("config.toml")).unwrap(); + assert!(config.contains("[marketplaces.openai-bundled]")); + assert!(config.contains("[plugins.\"browser@openai-bundled\"]")); + assert!(config.contains("[plugins.\"chrome@openai-bundled\"]")); + assert!(config.contains("[plugins.\"computer-use@openai-bundled\"]")); + assert!(config.contains("computer_use = true")); + assert!(config.contains("sandbox = \"unelevated\"")); + } + let backup_root = home.join("backups_state/app-state-sync"); + let backup_count = std::fs::read_dir(&backup_root) + .unwrap() + .filter_map(Result::ok) + .filter(|entry| entry.path().join(".codex-global-state.json").is_file()) + .count(); + assert_eq!(backup_count, 1); + } + #[test] fn normalize_settings_before_save_preserves_official_profile_auth() { let settings = BackendSettings { diff --git a/apps/codex-plus-manager/src/App.tsx b/apps/codex-plus-manager/src/App.tsx index 11a2c709b..a729913ad 100644 --- a/apps/codex-plus-manager/src/App.tsx +++ b/apps/codex-plus-manager/src/App.tsx @@ -2650,8 +2650,8 @@ function EnhanceScreen({ type="checkbox" /> - {t("启用 Windows Computer Use Guard")} - {t("默认关闭;开启后启动 Codex 时会自动保留官方 Computer Use 插件所需的 config.toml、bundled 插件和 notify 配置。")} + {t("强制启用 Windows Computer Use Guard")} + {t("插件市场解锁在完整增强模式下会自动准备 Browser、Chrome、Computer Use 的本地 bundled 状态;此开关用于在其他场景中强制执行同一守护。")} diff --git a/apps/codex-plus-manager/src/i18n-en.ts b/apps/codex-plus-manager/src/i18n-en.ts index afcb02de2..effed1724 100644 --- a/apps/codex-plus-manager/src/i18n-en.ts +++ b/apps/codex-plus-manager/src/i18n-en.ts @@ -216,7 +216,7 @@ export const EN_PLAIN: Record = { "启用": "Enable", "启用 Codex 图片覆盖层": "Enable Codex image overlay", "启用 Codex增强": "Enable Codex enhancements", - "启用 Windows Computer Use Guard": "Enable Windows Computer Use Guard", + "强制启用 Windows Computer Use Guard": "Force-enable Windows Computer Use Guard", "启用供应商配置切换": "Enable provider configuration switching", "启用此扩展项": "Enable this entry", "启用目标功能": "Enable goals feature", @@ -631,8 +631,8 @@ export const EN_PLAIN: Record = { "额外参数": "Extra arguments", "高级选项,默认关闭;当前实现不主动改写 Zed settings。": "Advanced option, off by default; the current implementation doesn't actively modify Zed settings.", "默认中转": "Default relay", - "默认关闭;开启后启动 Codex 时会自动保留官方 Computer Use 插件所需的 config.toml、bundled 插件和 notify 配置。": - "Off by default; when on, launching Codex automatically preserves the config.toml, bundled plugins and notify config required by the official Computer Use plugin.", + "插件市场解锁在完整增强模式下会自动准备 Browser、Chrome、Computer Use 的本地 bundled 状态;此开关用于在其他场景中强制执行同一守护。": + "Plugin marketplace unlock automatically prepares local bundled Browser, Chrome and Computer Use state in full enhancement mode; this switch force-runs the same guard in other scenarios.", "默认启动 Codex 时使用的模型名,请勿带后缀;上下文窗口请在下方「模型列表」中按模型单独配置。": "The model name used by default when launching Codex; don't include a suffix. Configure context windows per model in the “Model list” below.", "默认关闭;无 VPN 时可开启,让 Statsig 初始化快速失败,减少启动时长。需重启 Codex 才生效。": diff --git a/assets/inject/renderer-inject.js b/assets/inject/renderer-inject.js index 4bd8a8e5a..e2452d1ce 100644 --- a/assets/inject/renderer-inject.js +++ b/assets/inject/renderer-inject.js @@ -279,7 +279,7 @@ const codexThreadIdBadgeVersion = "1"; const codexThreadServiceTierVersion = "1"; const codexServiceTierBadgeClass = "codex-service-tier-badge"; - const codexServiceTierBadgeVersion = "3"; + const codexServiceTierBadgeVersion = "5"; const codexMenuLocalizationVersion = "1"; const codexMenuLocalizationMap = new Map([ ["Toggle Sidebar", "切换侧边栏"], @@ -328,7 +328,7 @@ const codexThreadServiceTierKey = "codexThreadServiceTierOverrides"; const codexThreadServiceTierMaxEntries = 120; const codexThreadServiceTierDraftBindWindowMs = 60 * 1000; - const codexServiceTierRequestOverrideVersion = "3"; + const codexServiceTierRequestOverrideVersion = "4"; const codexAppServerModelRequestPatchVersion = "1"; const codexPluginMarketplaceUnlockVersion = "12"; const codexPluginAutoExpandVersion = "1"; @@ -1402,6 +1402,7 @@ const codexServiceTierFallbackFastValue = "priority"; const codexServiceTierModulePromises = new Map(); const codexServiceTierSupportedFastModels = new Set(["gpt-5.4", "gpt-5.5"]); + const codexServiceTierLastSupportedModelKey = "codexServiceTierLastSupportedModel"; const codexThreadServiceTierModes = new Set(["inherit", "standard", "fast"]); const codexServiceTierControlModes = new Set(["inherit", "global-standard", "global-fast", "custom"]); @@ -1445,6 +1446,16 @@ return await codexServiceTierModulePromises.get(namePart); } + async function loadOptionalCodexAppModule(namePart) { + try { + return await loadCodexAppModule(namePart); + } catch (error) { + const message = String(error?.message || error); + if (message.includes(`未找到 Codex App asset: ${namePart}`)) return null; + throw error; + } + } + async function codexSettingStorageModule() { const module = await loadCodexAppModule("setting-storage-"); if (typeof module.n !== "function" || typeof module.s !== "function") { @@ -1487,6 +1498,13 @@ if (typeof value === "string") return value.trim(); if (!value || typeof value !== "object" || visited.has(value) || depth > 3) return ""; visited.add(value); + if (Array.isArray(value)) { + for (const item of value) { + const model = codexServiceTierModelFromValue(item, visited, depth + 1); + if (model) return model; + } + return ""; + } for (const key of ["model", "modelId", "model_id", "selectedModel", "selected_model", "defaultModel", "default_model"]) { const model = codexServiceTierModelFromValue(value[key], visited, depth + 1); if (model) return model; @@ -1498,12 +1516,95 @@ return ""; } + function codexServiceTierRememberSupportedModel(modelName) { + const normalizedModel = normalizeCodexServiceTierModelName(modelName); + if (!codexServiceTierSupportedFastModels.has(normalizedModel)) return ""; + try { + localStorage.setItem(codexServiceTierLastSupportedModelKey, normalizedModel); + } catch (_) {} + return normalizedModel; + } + + function codexServiceTierLastSupportedModelName() { + try { + const stored = normalizeCodexServiceTierModelName(localStorage.getItem(codexServiceTierLastSupportedModelKey)); + return codexServiceTierSupportedFastModels.has(stored) ? stored : ""; + } catch (_) { + return ""; + } + } + + function codexServiceTierCatalogModelCandidates() { + const values = [ + codexServiceTierModelFromValue(codexModelCatalog.model), + codexServiceTierModelFromValue(codexModelCatalog.default_model), + ]; + if (Array.isArray(codexModelCatalog.models)) { + codexModelCatalog.models.forEach((item) => { + const model = codexServiceTierModelFromValue(item); + if (model) values.push(model); + }); + } + return uniqueValues(values); + } + + function codexServiceTierElementVisibleForModelScan(element) { + try { + if (!element || !element.isConnected) return false; + if (typeof HTMLElement !== "undefined" && !(element instanceof HTMLElement)) return false; + const style = typeof getComputedStyle === "function" ? getComputedStyle(element) : null; + if (style && (style.display === "none" || style.visibility === "hidden")) return false; + if (typeof element.getBoundingClientRect !== "function") return true; + const rect = element.getBoundingClientRect(); + return rect.width > 0 && rect.height > 0; + } catch (_) { + return false; + } + } + + function codexServiceTierModelFromVisibleUi() { + if (typeof document === "undefined" || typeof document.querySelectorAll !== "function") return ""; + try { + const nodes = Array.from(document.querySelectorAll("button, [role='button'], [aria-label], [title], [data-testid]")); + for (const node of nodes) { + if (node.closest?.("#codex-plus-menu, [data-codex-service-tier-controls], [data-codex-service-tier-badge]")) continue; + if (!codexServiceTierElementVisibleForModelScan(node)) continue; + const parts = [ + node.textContent, + node.getAttribute?.("aria-label"), + node.getAttribute?.("title"), + node.getAttribute?.("data-testid"), + ]; + const text = parts.map((part) => String(part || "").trim()).filter(Boolean).join(" ").toLowerCase(); + if (!text || text.length > 180) continue; + if (text.includes("service_tier")) continue; + if (text.includes("fast") && text.includes("gpt-5.4") && text.includes("gpt-5.5")) continue; + for (const model of codexServiceTierSupportedFastModels) { + if (text.includes(model)) return model; + } + } + } catch (_) {} + return ""; + } + function codexServiceTierCurrentModelName() { - return codexServiceTierModelFromValue(codexModelCatalog.model) || codexServiceTierModelFromValue(codexModelCatalog.default_model); + const visibleModel = codexServiceTierModelFromVisibleUi(); + if (visibleModel) return codexServiceTierRememberSupportedModel(visibleModel) || visibleModel; + const candidates = codexServiceTierCatalogModelCandidates(); + const directModel = candidates[0] || ""; + if (codexServiceTierFastSupportedForModel(directModel)) return codexServiceTierRememberSupportedModel(directModel) || directModel; + const supportedCatalogModel = candidates.find(codexServiceTierFastSupportedForModel) || ""; + if (supportedCatalogModel) return codexServiceTierRememberSupportedModel(supportedCatalogModel) || supportedCatalogModel; + return directModel || codexServiceTierLastSupportedModelName(); } function codexServiceTierModelForRequest(params, modelHint = "") { - return codexServiceTierModelFromValue(params) || codexServiceTierModelFromValue(modelHint) || codexServiceTierCurrentModelName(); + const requestModel = codexServiceTierModelFromValue(params) || codexServiceTierModelFromValue(modelHint); + if (requestModel) { + codexServiceTierRememberSupportedModel(requestModel); + return requestModel; + } + return codexServiceTierCurrentModelName(); } function codexServiceTierFastSupportedForModel(modelName) { @@ -1528,9 +1629,25 @@ function codexServiceTierFastAvailability(modelName = codexServiceTierCurrentModelName()) { const normalizedModel = normalizeCodexServiceTierModelName(modelName); + if (!normalizedModel) { + return { + modelName: "", + normalizedModel: "", + known: false, + pending: true, + supported: true, + blocked: false, + }; + } + const supported = codexServiceTierSupportedFastModels.has(normalizedModel); + if (supported) codexServiceTierRememberSupportedModel(normalizedModel); return { - modelName: modelName || "", - supported: !!normalizedModel && codexServiceTierSupportedFastModels.has(normalizedModel), + modelName: modelName || normalizedModel, + normalizedModel, + known: true, + pending: false, + supported, + blocked: !supported, }; } @@ -1723,7 +1840,7 @@ const normalizedMode = normalizeCodexServiceTierControlMode(mode); if (normalizedMode === "global-fast") { const fastAvailability = codexServiceTierFastAvailability(); - if (!fastAvailability.supported) { + if (fastAvailability.blocked) { codexServiceTierMaybeLoadModelCatalog(true); showToast(codexServiceTierFastUnsupportedMessage(fastAvailability.modelName), null); refreshCodexServiceTierControls(); @@ -1772,7 +1889,7 @@ const effectiveServiceTier = codexServiceTierValueForControlMode(controlMode, threadMode, defaultMode); const effectiveMode = codexServiceTierEffectiveMode(effectiveServiceTier); const fastAvailability = codexServiceTierFastAvailability(); - const message = effectiveMode === "fast" && !fastAvailability.supported + const message = effectiveMode === "fast" && fastAvailability.blocked ? codexServiceTierFastUnsupportedMessage(fastAvailability.modelName) : serviceTierStatusMessage(controlMode, threadMode, effectiveMode, defaultMode); codexServiceTierState = { @@ -1790,7 +1907,7 @@ } function codexServiceTierBadgeState() { - if (codexPlusBackendStatus.status === "checking") return { tier: "loading", label: "...", disabled: true, title: "服务模式:正在检查后端连接" }; + if (codexPlusBackendStatus.status === "checking" || (codexPlusBackendStatus.status === "ok" && !codexPlusBackendSettingsLoaded)) return { tier: "loading", label: "...", disabled: true, title: "服务模式:正在检查后端连接" }; if (codexPlusBackendStatus.status && codexPlusBackendStatus.status !== "ok") return { tier: "failed", label: "未连接", disabled: true, title: "服务模式:后端未连接,无法切换" }; if (codexServiceTierState.status === "loading") return { tier: "loading", label: "...", title: "服务模式:正在读取" }; if (codexServiceTierState.status === "failed") return { tier: "failed", label: "?", title: "服务模式:读取失败" }; @@ -1804,7 +1921,7 @@ "Standard:使用标准处理;不在请求上设置 priority。", `Fast:仅支持 ${codexServiceTierFastModelListLabel()};对支持模型使用 service_tier=\"priority\",官方说明其延迟更低且更一致,但会按更高价格计费;rate limit 与 Standard 共享,流量快速上涨时可能回落到 Standard。`, ].join("\n"); - if (effectiveMode === "fast" && !fastAvailability.supported) { + if (effectiveMode === "fast" && fastAvailability.blocked) { return { tier: "unsupported", label: "不支持", title: `${title}\n${codexServiceTierFastUnsupportedMessage(fastAvailability.modelName)};当前请求会按 Standard 发送。` }; } if (effectiveMode === "fast") return { tier: "fast", label: "fast", title }; @@ -1825,15 +1942,18 @@ function refreshCodexServiceTierControls() { syncCodexServiceTierEffectiveState(); const featureEnabled = !!codexPlusSettings().serviceTierControls; - const backendConnected = codexPlusBackendStatus.status === "ok"; - const backendChecking = codexPlusBackendStatus.status === "checking"; + const backendConnected = codexPlusBackendStatus.status === "ok" && codexPlusBackendSettingsLoaded; + const backendChecking = codexPlusBackendStatus.status === "checking" || (codexPlusBackendStatus.status === "ok" && !codexPlusBackendSettingsLoaded); if (featureEnabled && backendConnected) codexServiceTierMaybeLoadModelCatalog(); const fastAvailability = codexServiceTierFastAvailability(); - const fastDisabled = !featureEnabled || !backendConnected || codexServiceTierState.status === "loading" || !fastAvailability.supported; - const fastTitle = fastAvailability.supported - ? "Fast:使用 service_tier=\"priority\"" - : codexServiceTierFastUnsupportedMessage(fastAvailability.modelName); - const fastUnsupportedActive = codexServiceTierState.effectiveMode === "fast" && !fastAvailability.supported; + const fastDisabled = !featureEnabled || !backendConnected || fastAvailability.blocked; + let fastTitle = "Fast: use service_tier=\"priority\""; + if (fastAvailability.blocked) { + fastTitle = codexServiceTierFastUnsupportedMessage(fastAvailability.modelName); + } else if (fastAvailability.pending) { + fastTitle = `Fast: model pending; only known non-${codexServiceTierFastModelListLabel()} models are sent as Standard.`; + } + const fastUnsupportedActive = codexServiceTierState.effectiveMode === "fast" && fastAvailability.blocked; document.querySelectorAll("[data-codex-service-tier-controls]").forEach((node) => { node.hidden = !featureEnabled; }); @@ -1917,7 +2037,7 @@ const normalizedMode = normalizeCodexThreadServiceTierMode(mode); if (normalizedMode === "fast") { const fastAvailability = codexServiceTierFastAvailability(); - if (!fastAvailability.supported) { + if (fastAvailability.blocked) { codexServiceTierMaybeLoadModelCatalog(true); showToast(codexServiceTierFastUnsupportedMessage(fastAvailability.modelName), null); refreshCodexServiceTierControls(); @@ -1941,7 +2061,7 @@ const nextMode = codexServiceTierState.effectiveMode === "fast" ? "standard" : "fast"; if (nextMode === "fast") { const fastAvailability = codexServiceTierFastAvailability(); - if (!fastAvailability.supported) { + if (fastAvailability.blocked) { codexServiceTierMaybeLoadModelCatalog(true); showToast(codexServiceTierFastUnsupportedMessage(fastAvailability.modelName), null); refreshCodexServiceTierControls(); @@ -1964,15 +2084,17 @@ const threadId = codexServiceTierThreadIdForRequest(method, params, threadIdHint); const requestedFast = isFastServiceTierValue(requestedServiceTier); const modelName = codexServiceTierModelForRequest(params, modelHint); - const fastSupported = !requestedFast || codexServiceTierFastSupportedForModel(modelName); + const fastAvailability = codexServiceTierFastAvailability(modelName); + const fastBlocked = requestedFast && fastAvailability.blocked; return { threadId, mode, - serviceTier: requestedFast && fastSupported ? codexFastServiceTierValue() : null, + serviceTier: requestedFast && !fastBlocked ? codexFastServiceTierValue() : null, requestedServiceTier: requestedServiceTier || null, modelName, - fastSupported, - fastBlocked: requestedFast && !fastSupported, + fastSupported: !fastBlocked, + fastSupportKnown: fastAvailability.known, + fastBlocked, }; } @@ -2025,6 +2147,7 @@ serviceTier: override.serviceTier || "standard", model: override.modelName || "", fastSupported: override.fastSupported !== false, + fastSupportKnown: !!override.fastSupportKnown, fastBlocked: !!override.fastBlocked, }); return nextParams; @@ -2118,6 +2241,10 @@ codexPlusBackendSettings = { ...codexPlusBackendSettings, ...settings }; codexPlusBackendSettingsLoaded = true; refreshCodexPlusBackendToggles(); + if (codexPlusSettings().serviceTierControls) { + codexServiceTierMaybeLoadModelCatalog(true); + void loadCodexServiceTierState(); + } return true; } catch (_) { refreshCodexPlusBackendToggles(); @@ -3505,7 +3632,14 @@ if (!codexPlusSettings().pluginMarketplaceUnlock) return; const patch = async () => { try { - const module = await loadCodexAppModule("app-server-manager-signals-"); + const module = await loadOptionalCodexAppModule("app-server-manager-signals-"); + if (!module) { + window.__codexPluginMarketplaceUnlockInstalled = codexPluginMarketplaceUnlockVersion; + sendCodexPlusDiagnostic("plugin_marketplace_request_patch_skipped", { + reason: "app_server_manager_signals_asset_missing", + }); + return; + } const candidates = Object.values(module).filter((value) => value && typeof value === "object"); let patchedCount = 0; for (const candidate of candidates) { @@ -4554,6 +4688,8 @@ applyServiceTierOverride: (method, params, threadIdHint = "") => applyCodexServiceTierRequestOverride(method, params, threadIdHint), requestOverride: (message) => codexServiceTierRequestOverride(message), diagnostics: () => [...(window.__codexPlusServiceTierTestDiagnostics || [])], + currentModelName: () => codexServiceTierCurrentModelName(), + fastAvailability: (modelName = codexServiceTierCurrentModelName()) => codexServiceTierFastAvailability(modelName), setModelCatalog: (catalog = {}) => { codexModelCatalog = { status: "ok", @@ -4569,6 +4705,9 @@ codexModelCatalogLoadedAt = Date.now(); codexModelCatalogPromise = null; }, + clearLastSupportedModel: () => { + localStorage.removeItem(codexServiceTierLastSupportedModelKey); + }, setServiceTierState: (state = {}) => { codexServiceTierState = { ...codexServiceTierState, ...state }; }, @@ -4983,7 +5122,14 @@ if (window.__codexPlusAppServerModelRequestPatchInstalled === codexAppServerModelRequestPatchVersion) return; const patch = async () => { try { - const module = await loadCodexAppModule("app-server-manager-signals-"); + const module = await loadOptionalCodexAppModule("app-server-manager-signals-"); + if (!module) { + window.__codexPlusAppServerModelRequestPatchInstalled = codexAppServerModelRequestPatchVersion; + sendCodexPlusDiagnostic("model_app_server_request_patch_skipped", { + reason: "app_server_manager_signals_asset_missing", + }); + return; + } const candidates = Object.values(module).filter((value) => value && typeof value === "object"); let patchedCount = 0; for (const candidate of candidates) { @@ -5697,7 +5843,8 @@ async function refreshRecentConversationsForHost() { try { - const signals = await import("./assets/app-server-manager-signals-C1h8B-R-.js"); + const signals = await loadOptionalCodexAppModule("app-server-manager-signals-"); + if (!signals) return; if (typeof signals.rn === "function") await signals.rn("refresh-recent-conversations-for-host", { hostId: "local", sortKey: "updated_at" }); } catch (error) { window.__codexProjectMoveRefreshFailures = window.__codexProjectMoveRefreshFailures || []; @@ -7640,6 +7787,61 @@ return true; } + function codexServiceTierLooksLikeModelButton(button) { + const text = [ + button?.textContent, + button?.getAttribute?.("aria-label"), + button?.getAttribute?.("title"), + ].map((value) => String(value || "").replace(/\s+/g, " ").trim()).filter(Boolean).join(" "); + if (!text || text.length > 80) return false; + if (/^(fast|standard|default|send|stop|new|worktree)$/i.test(text)) return false; + return /\b(gpt[-\s]?)?5\.(4|5)\b/i.test(text) || + /\b(o[1-9]|gpt|claude|gemini|deepseek|qwen|kimi|moonshot|mistral|llama)\b/i.test(text) || + /超高|高|中|低|ultra|high|medium|low|reasoning/i.test(text); + } + + function codexServiceTierVisibleTextInputCandidates() { + return Array.from(document.querySelectorAll("textarea, [contenteditable='true'], [role='textbox']")) + .filter(codexServiceTierBadgeVisibleElement) + .filter((node) => { + const text = codexServiceTierBadgeText(node).toLowerCase(); + const aria = String(node.getAttribute?.("aria-label") || "").toLowerCase(); + const rect = node.getBoundingClientRect(); + if (rect.bottom < window.innerHeight * 0.35) return false; + if (/search|filter|command|rename|title/.test(aria)) return false; + return text.length < 4000; + }) + .sort((left, right) => right.getBoundingClientRect().bottom - left.getBoundingClientRect().bottom); + } + + function codexServiceTierComposerFromTextInput(input) { + for (let node = input?.parentElement, depth = 0; node instanceof HTMLElement && depth < 8; depth += 1, node = node.parentElement) { + if (!codexServiceTierBadgeVisibleElement(node)) continue; + const rect = node.getBoundingClientRect(); + if (rect.width < 320 || rect.height < 48) continue; + if (rect.bottom < window.innerHeight * 0.45) continue; + const buttons = Array.from(node.querySelectorAll("button, [role='button']")).filter(codexServiceTierBadgeVisibleElement); + if (buttons.length >= 2 && buttons.some(codexServiceTierLooksLikeModelButton)) return node; + if (buttons.length >= 2 && node.querySelector?.(".composer-footer")) return node; + } + return null; + } + + function codexServiceTierModelButtonPlacement(root = document) { + const scope = root?.querySelectorAll ? root : document; + const buttons = Array.from(scope.querySelectorAll("button, [role='button']")) + .filter((button) => !button.closest?.(`[data-codex-service-tier-badge="true"]`)) + .filter(codexServiceTierBadgeVisibleElement) + .filter(codexServiceTierLooksLikeModelButton) + .sort((left, right) => { + const leftRect = left.getBoundingClientRect(); + const rightRect = right.getBoundingClientRect(); + return (rightRect.bottom - leftRect.bottom) || (rightRect.left - leftRect.left); + }); + const anchor = buttons.find((button) => button.getBoundingClientRect().bottom >= window.innerHeight * 0.35) || buttons[0]; + return anchor?.parentElement ? { parent: anchor.parentElement, before: anchor } : null; + } + function codexServiceTierBadgeButtonCandidates(composer) { const composerRect = composer.getBoundingClientRect(); return Array.from(composer.querySelectorAll("button, [role='button']")) @@ -7681,6 +7883,8 @@ if (composer.querySelector?.(".composer-footer")) score += 8; const buttons = Array.from(composer.querySelectorAll?.("button, [role='button']") || []).filter(codexServiceTierBadgeVisibleElement); if (buttons.some((button) => codexServiceTierLooksLikeProviderButton(button, providerNames))) score += 30; + if (buttons.some(codexServiceTierLooksLikeModelButton)) score += 26; + if (composer.querySelector?.("textarea, [contenteditable='true'], [role='textbox']")) score += 12; score += Math.min(10, buttons.length); return score; } @@ -7696,6 +7900,10 @@ if (codexServiceTierBadgeVisibleElement(node)) candidates.add(node); } }); + codexServiceTierVisibleTextInputCandidates().forEach((input) => { + const composer = codexServiceTierComposerFromTextInput(input); + if (composer) candidates.add(composer); + }); return Array.from(candidates); } @@ -7717,6 +7925,11 @@ const exact = buttons.find((button) => providerNames.includes(codexServiceTierBadgeText(button).toLowerCase())); if (exact) return exact; const composerRect = composer.getBoundingClientRect(); + const modelButton = buttons.find((button) => { + const rect = button.getBoundingClientRect(); + return rect.left >= composerRect.left + composerRect.width * 0.42 && codexServiceTierLooksLikeModelButton(button); + }); + if (modelButton) return modelButton; return buttons.find((button) => { const rect = button.getBoundingClientRect(); return rect.left >= composerRect.left + composerRect.width * 0.42 && codexServiceTierLooksLikeProviderButton(button, providerNames); @@ -7744,8 +7957,12 @@ function codexServiceTierBadgePlacement(composer) { const anchor = composer ? codexServiceTierBadgeAnchor(composer) : null; if (anchor?.parentElement) return { parent: anchor.parentElement, before: anchor }; + const modelPlacement = codexServiceTierModelButtonPlacement(composer || document); + if (modelPlacement?.parent) return modelPlacement; const group = composer ? codexServiceTierBadgeFooterGroup(composer) : null; if (group) return { parent: group, before: group.firstChild }; + const globalModelPlacement = codexServiceTierModelButtonPlacement(document); + if (globalModelPlacement?.parent) return globalModelPlacement; return null; } @@ -8665,6 +8882,9 @@ '[class*="user-message"]', '[class*="UserMessage"]', ".composer-footer", + "textarea", + "[contenteditable='true']", + "[role='textbox']", selectors.appHeader, selectors.archiveNav, codexMenuLocalizationScopeSelector(), @@ -8724,7 +8944,6 @@ } void loadBackendSettingsForStartup(); - void loadCodexServiceTierState(); installUpstreamBranchDropdownAdapter(); installUpstreamWorktreeNativeAdapter(); scan(); diff --git a/crates/codex-plus-core/src/codex_app_state.rs b/crates/codex-plus-core/src/codex_app_state.rs new file mode 100644 index 000000000..bdcbd11c0 --- /dev/null +++ b/crates/codex-plus-core/src/codex_app_state.rs @@ -0,0 +1,656 @@ +use std::collections::{BTreeSet, HashSet}; +use std::fs; +use std::path::{Path, PathBuf}; +use std::time::{SystemTime, UNIX_EPOCH}; + +use anyhow::Context; +use serde_json::{Map, Value, json}; + +const GLOBAL_STATE_FILE: &str = ".codex-global-state.json"; +const GLOBAL_STATE_BACKUP_FILE: &str = ".codex-global-state.json.bak"; +const BACKUP_ROOT: &str = "backups_state/app-state-sync"; +const SNAPSHOT_FILE: &str = "latest-safe-state.json"; +const SNAPSHOT_VERSION: u64 = 1; + +const WORKSPACE_PATH_ARRAY_KEYS: &[&str] = &["electron-saved-workspace-roots", "project-order"]; + +const ACTIVE_WORKSPACE_ROOTS_KEY: &str = "active-workspace-roots"; + +const WORKSPACE_PATH_MAP_KEYS: &[&str] = &["electron-workspace-root-labels"]; + +const THREAD_STATE_MAP_KEYS: &[&str] = &[ + "thread-workspace-root-hints", + "thread-projectless-output-directories", + "thread-writable-roots", +]; + +const THREAD_ID_ARRAY_KEYS: &[&str] = &["projectless-thread-ids"]; + +const SAFE_TOP_LEVEL_KEYS: &[&str] = &[ + "electron-avatar-overlay-bounds", + "electron-avatar-overlay-open", + "electron-internal-update-cdn-enabled", + "electron-main-window-bounds", + "electron-openai-mcp-form-elicitations-enabled", + "remote-project-connection-backfill-completed", +]; + +const FALSE_AFTER_PROVIDER_SWITCH_KEYS: &[&str] = + &["computer-use-bundled-plugin-auto-install-disabled"]; + +const SAFE_ATOM_KEYS: &[&str] = &[ + "default-service-tier", + "avatar-overlay-mascot-width-px", + "browser-sidebar-comment-mode-coachmark-dismissed", + "composer-auto-context-enabled", + "diff-filter", + "enter-behavior", + "fast-mode-personalized-estimate", + "first-awake-pet-notification-avatar-ids", + "has-opened-plugin-creator-prefill-v1", + "has-opened-skill-creator-prefill-v1", + "has-seen-codex-mobile-announcement", + "has-seen-fast-mode-announcement", + "has-seen-fast-mode-home-banner", + "has-seen-multi-agent-composer-banner", + "has-user-changed-service-tier", + "last_completed_onboarding", + "preferred-non-full-access-agent-mode-by-host-id", + "seen-model-upgrade-list", + "sidebar-collapsed-groups", + "sidebar-collapsed-sections-v1", + "sidebar-width", + "thread-summary-panel-section-expanded-progress", + "unread-thread-ids-by-host-v1", +]; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct AppStateSyncResult { + pub changed: bool, + pub changed_keys: Vec, + pub backup_path: Option, + pub snapshot_path: Option, +} + +pub fn capture_app_state_snapshot(home: &Path) -> anyhow::Result> { + let Some(state) = load_global_state(home)? else { + return Ok(None); + }; + let snapshot = safe_snapshot_from_state(&state); + let snapshot_state = snapshot + .get("state") + .and_then(Value::as_object) + .cloned() + .unwrap_or_default(); + if snapshot_state.is_empty() { + return Ok(None); + } + let path = snapshot_path(home); + if let Some(parent) = path.parent() { + fs::create_dir_all(parent)?; + } + crate::settings::atomic_write(&path, serde_json::to_string_pretty(&snapshot)?.as_bytes())?; + Ok(Some(path)) +} + +pub fn capture_app_state_snapshot_nonfatal(home: &Path, source: &str) { + if let Err(error) = capture_app_state_snapshot(home) { + let _ = crate::diagnostic_log::append_diagnostic_log( + "codex_app_state.snapshot_failed", + json!({ + "source": source, + "error": error.to_string(), + }), + ); + } +} + +pub fn sync_app_state_after_provider_switch(home: &Path) -> anyhow::Result { + let Some(mut state) = load_global_state(home)? else { + return Ok(AppStateSyncResult { + changed: false, + changed_keys: Vec::new(), + backup_path: None, + snapshot_path: None, + }); + }; + let original = Value::Object(state.clone()); + let mut changed_keys = BTreeSet::new(); + + normalize_current_state(&mut state, &mut changed_keys); + if let Some(snapshot) = load_snapshot(home)? { + merge_safe_snapshot(&mut state, &snapshot, &mut changed_keys); + } + + let next = Value::Object(state); + if next == original { + let snapshot_path = capture_app_state_snapshot(home)?; + return Ok(AppStateSyncResult { + changed: false, + changed_keys: Vec::new(), + backup_path: None, + snapshot_path, + }); + } + + let backup_path = create_backup(home, &original)?; + let text = serde_json::to_string_pretty(&next)?; + let state_path = state_path(home); + crate::settings::atomic_write(&state_path, text.as_bytes())?; + if let Some(parent) = state_path.parent() { + let _ = + crate::settings::atomic_write(&parent.join(GLOBAL_STATE_BACKUP_FILE), text.as_bytes()); + } + let snapshot_path = capture_app_state_snapshot(home)?; + + Ok(AppStateSyncResult { + changed: true, + changed_keys: changed_keys.into_iter().collect(), + backup_path: Some(backup_path), + snapshot_path, + }) +} + +pub fn sync_app_state_after_provider_switch_nonfatal(home: &Path, source: &str) { + match sync_app_state_after_provider_switch(home) { + Ok(result) => { + if result.changed { + let _ = crate::diagnostic_log::append_diagnostic_log( + "codex_app_state.synced", + json!({ + "source": source, + "changedKeys": result.changed_keys, + "backupPath": result.backup_path.map(|path| path.to_string_lossy().to_string()), + "snapshotPath": result.snapshot_path.map(|path| path.to_string_lossy().to_string()), + }), + ); + } + } + Err(error) => { + let _ = crate::diagnostic_log::append_diagnostic_log( + "codex_app_state.sync_failed", + json!({ + "source": source, + "error": error.to_string(), + }), + ); + } + } +} + +pub fn ensure_builtin_plugin_state_after_provider_switch_nonfatal(home: &Path, source: &str) { + match crate::computer_use_guard::ensure_builtin_plugin_state_after_provider_switch(home) { + Ok(result) => { + if result.changed { + let _ = crate::diagnostic_log::append_diagnostic_log( + "codex_app_state.builtin_plugin_state_synced", + json!({ + "source": source, + "notifyPath": result.notify_exe.map(|path| path.to_string_lossy().to_string()), + }), + ); + } + } + Err(error) => { + let _ = crate::diagnostic_log::append_diagnostic_log( + "codex_app_state.builtin_plugin_state_failed", + json!({ + "source": source, + "error": error.to_string(), + }), + ); + } + } +} + +fn load_global_state(home: &Path) -> anyhow::Result>> { + let path = state_path(home); + if !path.exists() { + return Ok(None); + } + let text = + fs::read_to_string(&path).with_context(|| format!("failed to read {}", path.display()))?; + let value: Value = serde_json::from_str(&text) + .with_context(|| format!("failed to parse {}", path.display()))?; + value + .as_object() + .cloned() + .map(Some) + .ok_or_else(|| anyhow::anyhow!("{} must be a JSON object", path.display())) +} + +fn load_snapshot(home: &Path) -> anyhow::Result>> { + let path = snapshot_path(home); + if !path.exists() { + return Ok(None); + } + let text = + fs::read_to_string(&path).with_context(|| format!("failed to read {}", path.display()))?; + let value: Value = serde_json::from_str(&text) + .with_context(|| format!("failed to parse {}", path.display()))?; + let state = value + .get("state") + .and_then(Value::as_object) + .or_else(|| value.as_object()) + .cloned() + .unwrap_or_default(); + Ok(Some(state)) +} + +fn safe_snapshot_from_state(state: &Map) -> Value { + let mut safe = Map::new(); + for key in WORKSPACE_PATH_ARRAY_KEYS { + if let Some(value) = state.get(*key) { + safe.insert((*key).to_string(), json!(dedupe_paths(path_array(value)))); + } + } + if let Some(value) = state.get(ACTIVE_WORKSPACE_ROOTS_KEY) { + safe.insert( + ACTIVE_WORKSPACE_ROOTS_KEY.to_string(), + normalize_active_workspace_roots(value), + ); + } + for key in WORKSPACE_PATH_MAP_KEYS { + if let Some(value) = state.get(*key).and_then(Value::as_object) { + safe.insert( + (*key).to_string(), + Value::Object(normalize_path_keyed_map(value)), + ); + } + } + for key in THREAD_STATE_MAP_KEYS { + if let Some(value) = state.get(*key).and_then(Value::as_object) { + safe.insert( + (*key).to_string(), + Value::Object(normalize_string_keyed_map(value)), + ); + } + } + for key in THREAD_ID_ARRAY_KEYS { + if let Some(value) = state.get(*key) { + safe.insert( + (*key).to_string(), + json!(dedupe_strings(string_array(value))), + ); + } + } + for key in SAFE_TOP_LEVEL_KEYS { + if let Some(value) = state.get(*key) { + safe.insert((*key).to_string(), value.clone()); + } + } + if let Some(atom) = state + .get("electron-persisted-atom-state") + .and_then(Value::as_object) + { + let atom = safe_atom_state(atom); + if !atom.is_empty() { + safe.insert( + "electron-persisted-atom-state".to_string(), + Value::Object(atom), + ); + } + } + json!({ + "version": SNAPSHOT_VERSION, + "state": safe, + }) +} + +fn normalize_current_state(state: &mut Map, changed: &mut BTreeSet) { + for key in WORKSPACE_PATH_ARRAY_KEYS { + if let Some(value) = state.get(*key).cloned() { + let next = json!(dedupe_paths(path_array(&value))); + replace_if_changed(state, key, next, changed); + } + } + if let Some(value) = state.get(ACTIVE_WORKSPACE_ROOTS_KEY).cloned() { + replace_if_changed( + state, + ACTIVE_WORKSPACE_ROOTS_KEY, + normalize_active_workspace_roots(&value), + changed, + ); + } + for key in WORKSPACE_PATH_MAP_KEYS { + if let Some(value) = state.get(*key).and_then(Value::as_object) { + let next = Value::Object(normalize_path_keyed_map(value)); + replace_if_changed(state, key, next, changed); + } + } + for key in THREAD_STATE_MAP_KEYS { + if let Some(value) = state.get(*key).and_then(Value::as_object) { + let next = Value::Object(normalize_string_keyed_map(value)); + replace_if_changed(state, key, next, changed); + } + } + for key in THREAD_ID_ARRAY_KEYS { + if let Some(value) = state.get(*key).cloned() { + let next = json!(dedupe_strings(string_array(&value))); + replace_if_changed(state, key, next, changed); + } + } + for key in FALSE_AFTER_PROVIDER_SWITCH_KEYS { + if state.get(*key).is_some() { + replace_if_changed(state, key, Value::Bool(false), changed); + } + } + if let Some(value) = state + .get("electron-persisted-atom-state") + .and_then(Value::as_object) + .cloned() + { + let mut atom = value; + normalize_atom_state(&mut atom); + replace_if_changed( + state, + "electron-persisted-atom-state", + Value::Object(atom), + changed, + ); + } +} + +fn merge_safe_snapshot( + target: &mut Map, + snapshot: &Map, + changed: &mut BTreeSet, +) { + for key in WORKSPACE_PATH_ARRAY_KEYS { + let mut paths = target.get(*key).map(path_array).unwrap_or_default(); + paths.extend(snapshot.get(*key).map(path_array).unwrap_or_default()); + if !paths.is_empty() { + replace_if_changed(target, key, json!(dedupe_paths(paths)), changed); + } + } + let mut active_paths = target + .get(ACTIVE_WORKSPACE_ROOTS_KEY) + .map(path_array) + .unwrap_or_default(); + active_paths.extend( + snapshot + .get(ACTIVE_WORKSPACE_ROOTS_KEY) + .map(path_array) + .unwrap_or_default(), + ); + let active_paths = dedupe_paths(active_paths); + if !active_paths.is_empty() { + let target_is_array = target + .get(ACTIVE_WORKSPACE_ROOTS_KEY) + .is_some_and(Value::is_array); + let snapshot_is_array = snapshot + .get(ACTIVE_WORKSPACE_ROOTS_KEY) + .is_some_and(Value::is_array); + let next = if target_is_array || snapshot_is_array || active_paths.len() > 1 { + json!(active_paths) + } else { + json!(active_paths[0]) + }; + replace_if_changed(target, ACTIVE_WORKSPACE_ROOTS_KEY, next, changed); + } + for key in WORKSPACE_PATH_MAP_KEYS { + let snapshot_map = snapshot.get(*key).and_then(Value::as_object); + let current_map = target.get(*key).and_then(Value::as_object); + let merged = merge_path_keyed_maps(snapshot_map, current_map); + if !merged.is_empty() { + replace_if_changed(target, key, Value::Object(merged), changed); + } + } + for key in THREAD_STATE_MAP_KEYS { + let snapshot_map = snapshot.get(*key).and_then(Value::as_object); + let current_map = target.get(*key).and_then(Value::as_object); + let merged = merge_string_keyed_maps(snapshot_map, current_map); + if !merged.is_empty() { + replace_if_changed(target, key, Value::Object(merged), changed); + } + } + for key in THREAD_ID_ARRAY_KEYS { + let mut ids = target.get(*key).map(string_array).unwrap_or_default(); + ids.extend(snapshot.get(*key).map(string_array).unwrap_or_default()); + if !ids.is_empty() { + replace_if_changed(target, key, json!(dedupe_strings(ids)), changed); + } + } + for key in SAFE_TOP_LEVEL_KEYS { + if let Some(value) = snapshot.get(*key) { + replace_if_changed(target, key, value.clone(), changed); + } + } + if let Some(snapshot_atom) = snapshot + .get("electron-persisted-atom-state") + .and_then(Value::as_object) + { + let mut atom = target + .get("electron-persisted-atom-state") + .and_then(Value::as_object) + .cloned() + .unwrap_or_default(); + for (key, value) in safe_atom_state(snapshot_atom) { + atom.insert(key, value); + } + normalize_atom_state(&mut atom); + replace_if_changed( + target, + "electron-persisted-atom-state", + Value::Object(atom), + changed, + ); + } +} + +fn replace_if_changed( + target: &mut Map, + key: &str, + value: Value, + changed: &mut BTreeSet, +) { + if target.get(key) != Some(&value) { + target.insert(key.to_string(), value); + changed.insert(key.to_string()); + } +} + +fn safe_atom_state(atom: &Map) -> Map { + atom.iter() + .filter(|(key, _)| is_safe_atom_key(key)) + .map(|(key, value)| (key.clone(), value.clone())) + .collect() +} + +fn normalize_atom_state(atom: &mut Map) { + if let Some(value) = atom.remove("service-tier-default") { + atom.entry("default-service-tier".to_string()) + .or_insert(value); + } +} + +fn is_safe_atom_key(key: &str) -> bool { + SAFE_ATOM_KEYS.contains(&key) + || key.starts_with("app-shell:right-panel-width:") + || key.starts_with("avatar-overlay-") + || key.starts_with("electron:onboarding-") + || key.starts_with("fast-mode-") + || key.starts_with("first-awake-pet-notification-") + || key.starts_with("has-seen-fast-mode-") + || key.starts_with("has-opened-plugin-") + || key.starts_with("has-opened-skill-") + || key.starts_with("plugin-") + || key.starts_with("codex-plugin-") + || key.starts_with("openai-mcp-") + || key.starts_with("sidebar-project-expanded-") + || key.starts_with("thread-summary-panel-section-expanded-") +} + +fn merge_path_keyed_maps( + snapshot: Option<&Map>, + current: Option<&Map>, +) -> Map { + let mut merged = Map::new(); + if let Some(snapshot) = snapshot { + for (key, value) in normalize_path_keyed_map(snapshot) { + merged.insert(key, value); + } + } + if let Some(current) = current { + for (key, value) in normalize_path_keyed_map(current) { + merged.insert(key, value); + } + } + merged +} + +fn merge_string_keyed_maps( + snapshot: Option<&Map>, + current: Option<&Map>, +) -> Map { + let mut merged = Map::new(); + if let Some(snapshot) = snapshot { + for (key, value) in normalize_string_keyed_map(snapshot) { + merged.insert(key, value); + } + } + if let Some(current) = current { + for (key, value) in normalize_string_keyed_map(current) { + merged.insert(key, value); + } + } + merged +} + +fn normalize_path_keyed_map(map: &Map) -> Map { + let mut next = Map::new(); + for (key, value) in map { + if let Some(path) = normalize_desktop_path(key) { + next.insert(path, value.clone()); + } + } + next +} + +fn normalize_string_keyed_map(map: &Map) -> Map { + let mut next = Map::new(); + for (key, value) in map { + let key = key.trim(); + if !key.is_empty() { + next.insert(key.to_string(), value.clone()); + } + } + next +} + +fn path_array(value: &Value) -> Vec { + if let Some(items) = value.as_array() { + items + .iter() + .filter_map(Value::as_str) + .filter_map(normalize_desktop_path) + .collect() + } else if let Some(value) = value.as_str() { + normalize_desktop_path(value).into_iter().collect() + } else { + Vec::new() + } +} + +fn normalize_active_workspace_roots(value: &Value) -> Value { + let normalized = dedupe_paths(path_array(value)); + if value.is_array() { + json!(normalized) + } else if let Some(first) = normalized.first() { + json!(first) + } else { + value.clone() + } +} + +fn dedupe_paths(paths: Vec) -> Vec { + let mut seen = HashSet::new(); + let mut result = Vec::new(); + for path in paths { + let comparable = path + .replace('/', r"\") + .trim_end_matches('\\') + .to_ascii_lowercase(); + if seen.insert(comparable) { + result.push(path); + } + } + result +} + +fn string_array(value: &Value) -> Vec { + if let Some(items) = value.as_array() { + items + .iter() + .filter_map(Value::as_str) + .map(str::trim) + .filter(|item| !item.is_empty()) + .map(ToString::to_string) + .collect() + } else if let Some(value) = value.as_str() { + let value = value.trim(); + if value.is_empty() { + Vec::new() + } else { + vec![value.to_string()] + } + } else { + Vec::new() + } +} + +fn dedupe_strings(items: Vec) -> Vec { + let mut seen = HashSet::new(); + let mut result = Vec::new(); + for item in items { + if seen.insert(item.clone()) { + result.push(item); + } + } + result +} + +fn normalize_desktop_path(value: &str) -> Option { + let trimmed = value.trim(); + if trimmed.is_empty() { + return None; + } + let mut path = trimmed.replace('/', r"\"); + while path.len() > 3 && path.ends_with('\\') { + path.pop(); + } + Some(path) +} + +fn create_backup(home: &Path, original: &Value) -> anyhow::Result { + let root = home.join(BACKUP_ROOT).join(format!("{}", now_ms())); + fs::create_dir_all(&root)?; + fs::write( + root.join(GLOBAL_STATE_FILE), + serde_json::to_string_pretty(original)?, + )?; + fs::write( + root.join("metadata.json"), + serde_json::to_string_pretty(&json!({ + "version": SNAPSHOT_VERSION, + "managedBy": "Codex++ app state sync", + "createdAtMs": now_ms(), + }))?, + )?; + Ok(root) +} + +fn state_path(home: &Path) -> PathBuf { + home.join(GLOBAL_STATE_FILE) +} + +fn snapshot_path(home: &Path) -> PathBuf { + home.join(BACKUP_ROOT).join(SNAPSHOT_FILE) +} + +fn now_ms() -> u128 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_millis() +} diff --git a/crates/codex-plus-core/src/computer_use_guard.rs b/crates/codex-plus-core/src/computer_use_guard.rs index 122b514ce..256a90582 100644 --- a/crates/codex-plus-core/src/computer_use_guard.rs +++ b/crates/codex-plus-core/src/computer_use_guard.rs @@ -1,6 +1,7 @@ #![cfg_attr(not(windows), allow(dead_code))] use std::path::{Path, PathBuf}; +use std::time::{SystemTime, UNIX_EPOCH}; use anyhow::Context; use toml_edit::{Array, DocumentMut, Item, Table}; @@ -77,6 +78,13 @@ pub(crate) fn ensure_computer_use_config_with_artifacts( } } +pub(crate) fn ensure_builtin_plugin_state_after_provider_switch( + home: &Path, +) -> anyhow::Result { + let artifacts = resolve_computer_use_guard_artifacts(home)?; + ensure_computer_use_config_with_artifacts(home, &artifacts) +} + #[cfg(windows)] fn ensure_computer_use_config_with_artifacts_windows( home: &Path, @@ -102,6 +110,7 @@ fn ensure_computer_use_config_with_artifacts_windows( }; let changed = updated.as_bytes() != existing.as_bytes(); if changed { + backup_config_before_guard_write(home, &existing)?; crate::settings::atomic_write(&config_path, updated.as_bytes())?; } let runtime_compat = ensure_computer_use_runtime_exports_compat_windows( @@ -219,6 +228,11 @@ pub(crate) fn guard_config_text_with_marketplace( let features = table_mut_or_insert(&mut doc, "features")?; features["js_repl"] = toml_edit::value(true); + features["local_shell"] = toml_edit::value(true); + features["computer_use"] = toml_edit::value(true); + + let windows = table_mut_or_insert(&mut doc, "windows")?; + windows["sandbox"] = toml_edit::value("unelevated"); for plugin_id in COMPUTER_USE_PLUGINS { ensure_plugin_enabled(&mut doc, plugin_id)?; @@ -238,6 +252,28 @@ pub(crate) fn guard_config_text_with_marketplace( Ok(ensure_trailing_newline(doc.to_string())) } +#[cfg(windows)] +fn backup_config_before_guard_write( + home: &Path, + existing: &str, +) -> anyhow::Result> { + let config_path = home.join("config.toml"); + if existing.is_empty() && !config_path.is_file() { + return Ok(None); + } + let backup_dir = home.join("backups").join("computer-use-guard"); + std::fs::create_dir_all(&backup_dir)?; + let backup_path = backup_dir.join(format!( + "config.toml.{}.bak", + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_millis() + )); + std::fs::write(&backup_path, existing)?; + Ok(Some(backup_path)) +} + pub(crate) fn find_computer_use_notify_exe(home: &Path) -> Option { #[cfg(windows)] { @@ -791,6 +827,10 @@ mod tests { assert!(!updated.as_bytes().starts_with(&[0xef, 0xbb, 0xbf])); assert!(updated.contains("js_repl = true")); + assert!(updated.contains("local_shell = true")); + assert!(updated.contains("computer_use = true")); + assert!(updated.contains("[windows]")); + assert!(updated.contains("sandbox = \"unelevated\"")); assert!(updated.contains("[plugins.\"browser@openai-bundled\"]")); assert!(updated.contains("[plugins.\"chrome@openai-bundled\"]")); assert!(updated.contains("[plugins.\"computer-use@openai-bundled\"]")); @@ -814,6 +854,10 @@ mod tests { assert!(updated.contains("[features]")); assert!(updated.contains("js_repl = true")); + assert!(updated.contains("local_shell = true")); + assert!(updated.contains("computer_use = true")); + assert!(updated.contains("[windows]")); + assert!(updated.contains("sandbox = \"unelevated\"")); for plugin_id in COMPUTER_USE_PLUGINS { assert!(updated.contains(&format!("[plugins.\"{plugin_id}\"]"))); } @@ -839,6 +883,45 @@ mod tests { ); } + #[cfg(windows)] + #[test] + fn ensure_computer_use_config_writes_backup_before_config_update() { + let temp = tempfile::tempdir().unwrap(); + let home = temp.path(); + let config_path = home.join("config.toml"); + let original = "model = \"gpt-5\"\n"; + std::fs::write(&config_path, original).unwrap(); + let marketplace = home + .join(".tmp") + .join("bundled-marketplaces") + .join(BUNDLED_MARKETPLACE); + let artifacts = GuardArtifacts { + notify_exe: None, + marketplace_path: Some(marketplace), + sky_package_json: None, + runtime_exports_needed: false, + }; + + let result = ensure_computer_use_config_with_artifacts_windows(home, &artifacts).unwrap(); + + assert!(result.changed); + let updated = std::fs::read_to_string(&config_path).unwrap(); + assert!(updated.contains("computer_use = true")); + assert!(updated.contains("[windows]")); + assert!(updated.contains("sandbox = \"unelevated\"")); + assert!(updated.contains("[marketplaces.openai-bundled]")); + let backup_dir = home.join("backups").join("computer-use-guard"); + let backups = std::fs::read_dir(&backup_dir) + .unwrap() + .flatten() + .collect::>(); + assert_eq!(backups.len(), 1); + assert_eq!( + std::fs::read_to_string(backups[0].path()).unwrap(), + original + ); + } + #[test] fn add_sky_internal_computer_use_export_adds_exact_subpath() { let updated = add_sky_internal_computer_use_export( diff --git a/crates/codex-plus-core/src/launcher.rs b/crates/codex-plus-core/src/launcher.rs index 0bba0fa12..bbdf96624 100644 --- a/crates/codex-plus-core/src/launcher.rs +++ b/crates/codex-plus-core/src/launcher.rs @@ -127,6 +127,9 @@ pub trait LaunchHooks: Send + Sync { ) -> anyhow::Result; fn select_debug_port(&self, requested: u16) -> u16; fn select_helper_port(&self, requested: u16) -> u16; + fn codex_home(&self) -> PathBuf { + crate::relay_config::default_codex_home_dir() + } async fn load_settings(&self) -> anyhow::Result; async fn run_provider_sync(&self) -> anyhow::Result<()>; async fn apply_active_relay_profile(&self, _settings: &BackendSettings) -> anyhow::Result<()> { @@ -135,6 +138,13 @@ pub trait LaunchHooks: Send + Sync { async fn ensure_computer_use_config(&self, _settings: &BackendSettings) -> anyhow::Result<()> { Ok(()) } + async fn ensure_builtin_plugin_state_after_provider_switch( + &self, + _settings: &BackendSettings, + _source: &str, + ) -> anyhow::Result<()> { + Ok(()) + } async fn ensure_plugin_marketplace_config( &self, _settings: &BackendSettings, @@ -255,6 +265,10 @@ where let mut keep_launched_on_error = false; let result: anyhow::Result = async { + let home = hooks.codex_home(); + if settings.provider_sync_enabled { + crate::codex_app_state::capture_app_state_snapshot_nonfatal(&home, "launcher.before"); + } if settings.provider_sync_enabled { hooks.run_provider_sync().await?; } @@ -266,10 +280,23 @@ where }), ); } - if settings.computer_use_guard_enabled { + if settings.builtin_plugin_guard_enabled() { hooks.ensure_computer_use_config(&settings).await?; } - let home = crate::relay_config::default_codex_home_dir(); + if settings.provider_sync_enabled && settings.builtin_plugin_guard_enabled() { + hooks + .ensure_builtin_plugin_state_after_provider_switch( + &settings, + "launcher.before_launch", + ) + .await?; + } + if settings.provider_sync_enabled { + crate::codex_app_state::sync_app_state_after_provider_switch_nonfatal( + &home, + "launcher.before_launch", + ); + } match crate::codex_sqlite::sanitize_historical_model_suffixes(&home) { Ok(result) if result.updated > 0 => { let _ = crate::diagnostic_log::append_diagnostic_log( @@ -304,7 +331,7 @@ where .await?; launched = Some(launch.clone()); keep_launched_on_error = true; - if settings.computer_use_guard_enabled { + if settings.builtin_plugin_guard_enabled() { hooks.start_computer_use_guard_watchdog(&settings).await?; } @@ -530,7 +557,7 @@ impl LaunchHooks for DefaultLaunchHooks { crate::relay_config::clear_relay_config_to_home_with_auth_and_computer_use_guard( &home, auth_contents, - settings.computer_use_guard_enabled, + settings.builtin_plugin_guard_enabled(), )?; return Ok(()); } @@ -538,13 +565,13 @@ impl LaunchHooks for DefaultLaunchHooks { &home, &profile, &common_config, - settings.computer_use_guard_enabled, + settings.builtin_plugin_guard_enabled(), )?; Ok(()) } async fn ensure_computer_use_config(&self, settings: &BackendSettings) -> anyhow::Result<()> { - if !settings.computer_use_guard_enabled { + if !settings.builtin_plugin_guard_enabled() { return Ok(()); } let home = crate::relay_config::default_codex_home_dir(); @@ -554,6 +581,21 @@ impl LaunchHooks for DefaultLaunchHooks { Ok(()) } + async fn ensure_builtin_plugin_state_after_provider_switch( + &self, + settings: &BackendSettings, + source: &str, + ) -> anyhow::Result<()> { + if !settings.builtin_plugin_guard_enabled() { + return Ok(()); + } + let home = crate::relay_config::default_codex_home_dir(); + crate::codex_app_state::ensure_builtin_plugin_state_after_provider_switch_nonfatal( + &home, source, + ); + Ok(()) + } + async fn ensure_plugin_marketplace_config( &self, settings: &BackendSettings, @@ -568,7 +610,7 @@ impl LaunchHooks for DefaultLaunchHooks { let _ = crate::diagnostic_log::append_diagnostic_log( "launcher.openai_curated_marketplace_configured", serde_json::json!({ - "home": home, + "home": home.to_string_lossy().to_string(), }), ); } @@ -577,7 +619,30 @@ impl LaunchHooks for DefaultLaunchHooks { let _ = crate::diagnostic_log::append_diagnostic_log( "launcher.openai_curated_marketplace_config_failed", serde_json::json!({ - "home": home, + "home": home.to_string_lossy().to_string(), + "message": error.to_string(), + }), + ); + } + } + match crate::plugin_marketplace::ensure_openai_curated_remote_marketplace_available(&home) { + Ok(result) => { + if result.initialized || result.configured { + let _ = crate::diagnostic_log::append_diagnostic_log( + "launcher.openai_curated_remote_marketplace_configured", + serde_json::json!({ + "home": home.to_string_lossy().to_string(), + "initialized": result.initialized, + "configured": result.configured, + }), + ); + } + } + Err(error) => { + let _ = crate::diagnostic_log::append_diagnostic_log( + "launcher.openai_curated_remote_marketplace_config_failed", + serde_json::json!({ + "home": home.to_string_lossy().to_string(), "message": error.to_string(), }), ); @@ -751,6 +816,10 @@ impl LaunchHooks for DefaultLaunchHooks { .stderr(Stdio::null()); #[cfg(windows)] child_command.creation_flags(crate::windows_integration::CREATE_NO_WINDOW); + #[cfg(windows)] + if settings.builtin_plugin_guard_enabled() { + child_command.env("CODEX_ELECTRON_ENABLE_WINDOWS_COMPUTER_USE", "1"); + } let child = child_command .spawn() .with_context(|| format!("failed to launch Codex executable {executable}"))?; @@ -799,7 +868,7 @@ impl LaunchHooks for DefaultLaunchHooks { ) -> anyhow::Result<()> { #[cfg(windows)] { - if !settings.computer_use_guard_enabled { + if !settings.builtin_plugin_guard_enabled() { return Ok(()); } let home = crate::relay_config::default_codex_home_dir(); diff --git a/crates/codex-plus-core/src/lib.rs b/crates/codex-plus-core/src/lib.rs index 6d974dd9f..4b7967939 100644 --- a/crates/codex-plus-core/src/lib.rs +++ b/crates/codex-plus-core/src/lib.rs @@ -4,6 +4,7 @@ pub mod assets; pub mod bridge; pub mod ccs_import; pub mod cdp; +pub mod codex_app_state; pub mod codex_home; pub mod codex_local_storage; pub mod codex_sqlite; diff --git a/crates/codex-plus-core/src/model_catalog.rs b/crates/codex-plus-core/src/model_catalog.rs index b0723a06a..90262d695 100644 --- a/crates/codex-plus-core/src/model_catalog.rs +++ b/crates/codex-plus-core/src/model_catalog.rs @@ -64,9 +64,20 @@ pub async fn read_codex_model_catalog() -> Value { } fn relay_profile_model_catalog_value(home: &Path, profile: &RelayProfile) -> Value { - let models = relay_profile_model_ids(profile); - let model = profile.model.trim().to_string(); - let default_model = models.first().cloned().unwrap_or_default(); + let (_live_config, live_effective, _live_error) = load_codex_config(&home.join("config.toml")); + let live_model = config_model_from_effective(&live_effective); + let profile_model = crate::relay_config::relay_profile_model(profile); + let model = if live_model.trim().is_empty() { + profile_model.trim().to_string() + } else { + live_model.trim().to_string() + }; + let models = relay_profile_model_ids(profile, &model, &live_effective); + let default_model = if model.trim().is_empty() { + models.first().cloned().unwrap_or_default() + } else { + model.clone() + }; let provider_name = if profile.name.trim().is_empty() { profile.id.trim() } else { @@ -96,17 +107,40 @@ fn relay_profile_model_catalog_value(home: &Path, profile: &RelayProfile) -> Val }) } -fn relay_profile_model_ids(profile: &RelayProfile) -> Vec { - unique_strings( - profile - .model_list - .split(['\r', '\n', ',']) - .chain(std::iter::once(profile.model.as_str())) - .map(str::trim) - .filter(|value| !value.is_empty()) - .map(ToString::to_string) - .collect(), - ) +fn relay_profile_model_ids( + profile: &RelayProfile, + current_model: &str, + live_effective: &HashMap, +) -> Vec { + let mut models = Vec::new(); + push_model_candidate(&mut models, current_model); + push_model_candidate( + &mut models, + &crate::relay_config::relay_profile_model(profile), + ); + push_model_candidate(&mut models, &config_model_from_effective(live_effective)); + for item in profile.model_list.split(['\r', '\n', ',']) { + push_model_candidate(&mut models, item); + } + push_model_candidate(&mut models, &profile.model); + unique_strings(models) +} + +fn push_model_candidate(models: &mut Vec, value: &str) { + let (model, _) = crate::model_suffix::parse_model_suffix(value.trim()); + let model = model.trim(); + if !model.is_empty() { + models.push(model.to_string()); + } +} + +fn config_model_from_effective(effective: &HashMap) -> String { + let model = string_value(effective.get("model")); + if model.is_empty() { + string_value(effective.get("default_model")) + } else { + model + } } pub async fn read_codex_model_catalog_from_home( diff --git a/crates/codex-plus-core/src/relay_config.rs b/crates/codex-plus-core/src/relay_config.rs index 21076b5a9..1510cd0ad 100644 --- a/crates/codex-plus-core/src/relay_config.rs +++ b/crates/codex-plus-core/src/relay_config.rs @@ -1076,7 +1076,10 @@ fn write_codex_live_atomic( let config_text = guarded_config_text.as_deref(); let config_text = match config_text { - Some(config_text) => Some(preserve_live_marketplace_configs(home, config_text)?), + Some(config_text) => { + let config_text = preserve_live_desktop_settings(home, config_text)?; + Some(preserve_live_marketplace_configs(home, &config_text)?) + } None => None, }; let config_text = config_text.as_deref(); @@ -1396,6 +1399,27 @@ fn normalize_config_text_for_write(config_text: &str) -> String { config_text.trim_start_matches('\u{feff}').to_string() } +fn preserve_live_desktop_settings(home: &Path, config_text: &str) -> anyhow::Result { + let normalized = normalize_config_text_for_write(config_text); + let live_text = read_optional_text(&home.join("config.toml"))?; + if live_text.trim().is_empty() { + return Ok(normalized); + } + let Ok(live_doc) = parse_toml_document(&live_text) else { + return Ok(normalized); + }; + let Some(live_desktop) = live_doc.get("desktop").cloned() else { + return Ok(normalized); + }; + if live_desktop.is_none() { + return Ok(normalized); + } + + let mut target_doc = parse_toml_document(&normalized)?; + merge_toml_item(&mut target_doc["desktop"], &live_desktop); + Ok(normalize_optional_toml(target_doc)) +} + fn validate_auth_json(auth_bytes: &[u8], path: &Path) -> anyhow::Result<()> { if auth_bytes.iter().all(|byte| byte.is_ascii_whitespace()) { return Ok(()); diff --git a/crates/codex-plus-core/src/relay_switch.rs b/crates/codex-plus-core/src/relay_switch.rs index 6a84d37a9..d26b6bfc2 100644 --- a/crates/codex-plus-core/src/relay_switch.rs +++ b/crates/codex-plus-core/src/relay_switch.rs @@ -24,6 +24,7 @@ pub fn switch_relay_profile_in_home( if !selected_settings.relay_profiles_enabled { anyhow::bail!("供应商配置总开关已关闭,未写入 config.toml / auth.json。"); } + crate::codex_app_state::capture_app_state_snapshot_nonfatal(home, "relay_switch.before"); let original_settings = store.load().unwrap_or_default(); if !previous_active_relay_id.trim().is_empty() @@ -40,7 +41,10 @@ pub fn switch_relay_profile_in_home( let selected_settings = store.load().context("读取供应商设置失败")?; match apply_selected_relay_profile(home, &selected_settings) { - Ok(result) => Ok(result), + Ok(result) => { + finish_provider_switch_state_sync(home, &selected_settings, "relay_switch.after"); + Ok(result) + } Err(error) => { let _ = store.save(&original_settings); Err(error) @@ -48,6 +52,40 @@ pub fn switch_relay_profile_in_home( } } +fn finish_provider_switch_state_sync(home: &Path, settings: &BackendSettings, source: &str) { + if settings.codex_app_plugin_marketplace_unlock { + match crate::plugin_marketplace::ensure_openai_curated_remote_marketplace_available(home) { + Ok(result) => { + if result.initialized || result.configured { + let _ = crate::diagnostic_log::append_diagnostic_log( + "relay_switch.remote_plugin_marketplace_ready", + serde_json::json!({ + "source": source, + "initialized": result.initialized, + "configured": result.configured, + }), + ); + } + } + Err(error) => { + let _ = crate::diagnostic_log::append_diagnostic_log( + "relay_switch.remote_plugin_marketplace_failed", + serde_json::json!({ + "source": source, + "error": error.to_string(), + }), + ); + } + } + } + if settings.builtin_plugin_guard_enabled() { + crate::codex_app_state::ensure_builtin_plugin_state_after_provider_switch_nonfatal( + home, source, + ); + } + crate::codex_app_state::sync_app_state_after_provider_switch_nonfatal(home, source); +} + fn backfill_profile_before_switch( home: &Path, settings: &mut BackendSettings, @@ -78,7 +116,7 @@ fn apply_selected_relay_profile( crate::relay_config::clear_relay_config_to_home_with_auth_and_computer_use_guard( home, auth_contents, - settings.computer_use_guard_enabled, + settings.builtin_plugin_guard_enabled(), )? } else { validate_switch_profile_files(&relay)?; @@ -86,7 +124,7 @@ fn apply_selected_relay_profile( home, &relay, &common_config, - settings.computer_use_guard_enabled, + settings.builtin_plugin_guard_enabled(), )? }; let status = relay_config_status_from_home(home); diff --git a/crates/codex-plus-core/src/settings.rs b/crates/codex-plus-core/src/settings.rs index e54611b61..1ab679d5c 100644 --- a/crates/codex-plus-core/src/settings.rs +++ b/crates/codex-plus-core/src/settings.rs @@ -494,6 +494,13 @@ impl BackendSettings { self.active_aggregate_relay_profile().is_some() || self.active_relay_profile().protocol == RelayProtocol::ChatCompletions } + + pub fn builtin_plugin_guard_enabled(&self) -> bool { + self.computer_use_guard_enabled + || (self.enhancements_enabled + && self.launch_mode != LaunchMode::Relay + && self.codex_app_plugin_marketplace_unlock) + } } pub fn default_stepwise_api_key_env() -> String { @@ -1221,6 +1228,7 @@ mod tests { assert!(settings.enhancements_enabled); assert!(!settings.computer_use_guard_enabled); assert!(settings.codex_app_plugin_marketplace_unlock); + assert!(settings.builtin_plugin_guard_enabled()); assert!(settings.codex_app_plugin_auto_expand); assert!(!settings.codex_app_thread_id_badge); assert!(settings.codex_app_force_chinese_locale); @@ -1255,6 +1263,41 @@ mod tests { assert_eq!(settings.codex_app_stepwise_timeout_ms, 8000); } + #[test] + fn builtin_plugin_guard_follows_plugin_unlock_and_force_switch() { + assert!(BackendSettings::default().builtin_plugin_guard_enabled()); + + let disabled = BackendSettings { + codex_app_plugin_marketplace_unlock: false, + computer_use_guard_enabled: false, + ..BackendSettings::default() + }; + assert!(!disabled.builtin_plugin_guard_enabled()); + + let force_enabled = BackendSettings { + codex_app_plugin_marketplace_unlock: false, + computer_use_guard_enabled: true, + ..BackendSettings::default() + }; + assert!(force_enabled.builtin_plugin_guard_enabled()); + + let relay_mode = BackendSettings { + launch_mode: LaunchMode::Relay, + computer_use_guard_enabled: false, + codex_app_plugin_marketplace_unlock: true, + ..BackendSettings::default() + }; + assert!(!relay_mode.builtin_plugin_guard_enabled()); + + let enhancements_disabled = BackendSettings { + enhancements_enabled: false, + computer_use_guard_enabled: false, + codex_app_plugin_marketplace_unlock: true, + ..BackendSettings::default() + }; + assert!(!enhancements_disabled.builtin_plugin_guard_enabled()); + } + #[test] fn settings_deserialize_ignores_removed_cli_wrapper_keys() { let settings: BackendSettings = serde_json::from_str( diff --git a/crates/codex-plus-core/tests/cdp_bridge.rs b/crates/codex-plus-core/tests/cdp_bridge.rs index 96d7e22ae..e13a98506 100644 --- a/crates/codex-plus-core/tests/cdp_bridge.rs +++ b/crates/codex-plus-core/tests/cdp_bridge.rs @@ -576,6 +576,8 @@ fn injection_script_unlocks_custom_model_catalog() { assert!(script.contains("installAppServerModelRequestPatch")); assert!(script.contains("list-models-for-host")); assert!(script.contains("appServerModelRequestMethod")); + assert!(script.contains("loadOptionalCodexAppModule")); + assert!(script.contains("model_app_server_request_patch_skipped")); assert!(script.contains("send-cli-request-for-host")); assert!(script.contains("Response.prototype.json")); assert!(script.contains("scheduleCodexModelWhitelistRefresh")); @@ -591,6 +593,7 @@ fn injection_script_unlocks_custom_model_catalog() { #[test] fn injection_script_exposes_fast_service_tier_control() { let script = assets::injection_script(57321); + let normalized_script = script.replace("\r\n", "\n"); assert!(script.contains("default-service-tier")); assert!(script.contains("setting-storage-")); @@ -603,7 +606,16 @@ fn injection_script_exposes_fast_service_tier_control() { assert!(script.contains("\"gpt-5.5\"")); assert!(script.contains("codexServiceTierFastSupportedForModel")); assert!(script.contains("codexServiceTierModelForRequest")); + assert!(script.contains("codexServiceTierLastSupportedModelKey")); assert!(script.contains("codexServiceTierMaybeLoadModelCatalog")); + assert!( + script + .contains("codexPlusBackendStatus.status === \"ok\" && codexPlusBackendSettingsLoaded") + ); + assert!( + !normalized_script + .contains("void loadBackendSettingsForStartup();\n void loadCodexServiceTierState();") + ); assert!(script.contains("fastBlocked")); assert!(script.contains("data-tier=\"unsupported\"")); assert!(script.contains("nextParams.service_tier = override.serviceTier")); @@ -642,11 +654,16 @@ fn injection_script_exposes_fast_service_tier_control() { assert!(script.contains("wireCodexServiceTierBadge")); assert!(script.contains("codexServiceTierBadgePlacement")); assert!(script.contains("codexServiceTierBadgeFooterGroup")); + assert!(script.contains("codexServiceTierLooksLikeModelButton")); + assert!(script.contains("codexServiceTierComposerFromTextInput")); + assert!(script.contains("codexServiceTierModelButtonPlacement")); assert!(script.contains("codexServiceTierFindComposerEl")); assert!(script.contains("codexServiceTierVisibleComposerFooters")); assert!(script.contains("codexServiceTierBestComposerFooter")); assert!(script.contains("codexServiceTierComposerCandidates")); assert!(script.contains("codexServiceTierComposerScore")); + assert!(script.contains("[contenteditable='true']")); + assert!(script.contains("[role='textbox']")); assert!(script.contains("data-codex-service-tier-badge")); assert!(script.contains("codexServiceTierBadgeWired")); assert!(script.contains("setAttribute(\"role\", \"button\")")); @@ -716,6 +733,19 @@ fn injection_script_applies_fast_service_tier_contract() { assert_eq!(cases["turnWithoutModel"]["serviceTier"], "priority"); assert_eq!(cases["turnWithoutModelDiagnosticModel"], "gpt-5.4"); + assert_eq!( + cases["catalogSupportedListFallback"]["serviceTier"], + "priority" + ); + assert_eq!( + cases["catalogSupportedListFallbackDiagnosticModel"], + "gpt-5.5" + ); + + assert_eq!(cases["unknownModelAllowed"]["serviceTier"], "priority"); + assert_eq!(cases["unknownModelAllowedFastBlocked"], false); + assert_eq!(cases["unknownModelAllowedDiagnosticModel"], ""); + assert_eq!( cases["customInheritUnsupported"]["serviceTier"], serde_json::Value::Null @@ -807,6 +837,25 @@ const turnWithoutModel = api.applyServiceTierOverride("turn/start", {{ }}, "conversation-should-not-be-model"); const turnWithoutModelDiagnosticModel = api.diagnostics().at(-1)?.detail?.model; +api.setModelCatalog({{ status: "ok", model: "", default_model: "", models: ["gpt-5.5"] }}); +api.setThreadState({{ mode: "global-fast", defaultMode: "fast", entries: {{}} }}); +const catalogSupportedListFallback = api.applyServiceTierOverride("turn/start", {{ + threadId: "thread-12345678", + service_tier: null, +}}, ""); +const catalogSupportedListFallbackDiagnosticModel = api.diagnostics().at(-1)?.detail?.model; + +api.clearLastSupportedModel(); +api.setModelCatalog({{ status: "loading", model: "", default_model: "", models: [] }}); +api.setThreadState({{ mode: "global-fast", defaultMode: "fast", entries: {{}} }}); +const unknownModelAllowed = api.applyServiceTierOverride("turn/start", {{ + threadId: "thread-12345678", + service_tier: null, +}}, ""); +const unknownModelAllowedDiagnostic = api.diagnostics().at(-1)?.detail || {{}}; +const unknownModelAllowedDiagnosticModel = unknownModelAllowedDiagnostic.model; +const unknownModelAllowedFastBlocked = unknownModelAllowedDiagnostic.fastBlocked; + api.setModelCatalog({{ status: "ok", model: "gpt-4.1", default_model: "gpt-4.1", models: ["gpt-4.1"] }}); api.setThreadState({{ mode: "custom", defaultMode: "inherit", entries: {{}}, draft: {{ mode: "inherit", at: Date.now() }} }}); api.setServiceTierState({{ serviceTier: "priority" }}); @@ -828,6 +877,11 @@ process.stdout.write(JSON.stringify({{ unsupportedModel, turnWithoutModel, turnWithoutModelDiagnosticModel, + catalogSupportedListFallback, + catalogSupportedListFallbackDiagnosticModel, + unknownModelAllowed, + unknownModelAllowedDiagnosticModel, + unknownModelAllowedFastBlocked, customInheritUnsupported, startConversation, }})); @@ -915,6 +969,7 @@ fn injection_script_installs_upstream_branch_dropdown_adapter() { assert!(script.contains("branchMenuInNewWorktreeMode")); assert!(script.contains("branchMenuTriggerIsBranchControl")); assert!(script.contains("actual-upstream-refs-v16")); + assert!(!script.contains("app-server-manager-signals-C1h8B-R-.js")); assert!(script.contains("create and checkout new branch")); assert!(script.contains("if (/^start in")); assert!(script.contains("if (!branchMenuInNewWorktreeMode(trigger))")); diff --git a/crates/codex-plus-core/tests/codex_app_state.rs b/crates/codex-plus-core/tests/codex_app_state.rs new file mode 100644 index 000000000..cd1c8cecf --- /dev/null +++ b/crates/codex-plus-core/tests/codex_app_state.rs @@ -0,0 +1,244 @@ +use codex_plus_core::codex_app_state::{ + capture_app_state_snapshot, sync_app_state_after_provider_switch, +}; +use serde_json::{Value, json}; + +#[test] +fn app_state_sync_restores_safe_state_and_ignores_sensitive_snapshot_keys() { + let temp = tempfile::tempdir().unwrap(); + let home = temp.path(); + let state_path = home.join(".codex-global-state.json"); + std::fs::write( + &state_path, + json!({ + "electron-saved-workspace-roots": ["C:/work/app", "C:\\work\\app\\"], + "project-order": ["C:/work/app"], + "active-workspace-roots": "C:/work/app", + "electron-workspace-root-labels": { + "C:/work/app/": "App" + }, + "electron-avatar-overlay-bounds": { + "x": 20, + "y": 30, + "width": 320, + "height": 240 + }, + "electron-avatar-overlay-open": true, + "electron-main-window-bounds": { + "x": 10, + "y": 10, + "width": 1280, + "height": 800 + }, + "thread-workspace-root-hints": { + "thread-1": "C:/work/app", + "local:thread-2": { + "workspaceRoot": "D:/work/other" + } + }, + "thread-projectless-output-directories": { + "thread-1": "C:/work/app/out" + }, + "thread-writable-roots": { + "thread-1": ["C:/work/app"] + }, + "projectless-thread-ids": ["thread-1", "thread-1"], + "electron-persisted-atom-state": { + "app-shell:right-panel-width:v2:/": 420, + "avatar-overlay-mascot-width-px": 160, + "browser-sidebar-comment-mode-coachmark-dismissed": true, + "composer-auto-context-enabled": false, + "default-service-tier": "priority", + "diff-filter": "all", + "enter-behavior": "cmdAlways", + "first-awake-pet-notification-avatar-ids": ["otter"], + "has-seen-fast-mode-announcement": true, + "has-seen-multi-agent-composer-banner": true, + "plugin-marketplace-unlocked": true, + "sidebar-collapsed-sections-v1": ["cloud"], + "sidebar-project-expanded-v1-codex:C:/work/app": true, + "sidebar-width": 296, + "thread-summary-panel-section-expanded-progress": false, + "thread-client-id-v1:thread-1": "do-not-copy", + "heartbeat-thread-permissions-by-id": { + "thread-1": "do-not-copy" + }, + "prompt-history": ["secret"] + }, + "computer-use-bundled-plugin-auto-install-disabled": true, + "prompt-history": ["secret"], + "provider-token-cache": "secret" + }) + .to_string(), + ) + .unwrap(); + + let snapshot_path = capture_app_state_snapshot(home) + .unwrap() + .expect("snapshot should be created"); + assert!(snapshot_path.is_file()); + + std::fs::write( + &state_path, + json!({ + "electron-saved-workspace-roots": ["D:/fresh/app"], + "active-workspace-roots": "D:/fresh/app", + "thread-workspace-root-hints": { + "thread-3": "D:/fresh/app" + }, + "computer-use-bundled-plugin-auto-install-disabled": true, + "electron-persisted-atom-state": { + "service-tier-default": "standard" + } + }) + .to_string(), + ) + .unwrap(); + + let result = sync_app_state_after_provider_switch(home).unwrap(); + let state: Value = + serde_json::from_str(&std::fs::read_to_string(&state_path).unwrap()).unwrap(); + + assert!(result.changed); + assert!(result.backup_path.as_deref().unwrap().is_dir()); + assert!(result.snapshot_path.as_deref().unwrap().is_file()); + assert_eq!( + state["electron-saved-workspace-roots"], + json!(["D:\\fresh\\app", "C:\\work\\app"]) + ); + assert_eq!( + state["active-workspace-roots"], + json!(["D:\\fresh\\app", "C:\\work\\app"]) + ); + assert_eq!( + state["electron-workspace-root-labels"], + json!({"C:\\work\\app": "App"}) + ); + assert_eq!(state["electron-avatar-overlay-open"], true); + assert_eq!(state["electron-avatar-overlay-bounds"]["width"], 320); + assert_eq!(state["electron-main-window-bounds"]["height"], 800); + assert_eq!( + state["thread-workspace-root-hints"]["thread-1"], + "C:/work/app" + ); + assert_eq!( + state["thread-workspace-root-hints"]["thread-3"], + "D:/fresh/app" + ); + assert_eq!( + state["thread-projectless-output-directories"]["thread-1"], + "C:/work/app/out" + ); + assert_eq!( + state["thread-writable-roots"]["thread-1"], + json!(["C:/work/app"]) + ); + assert_eq!(state["projectless-thread-ids"], json!(["thread-1"])); + assert_eq!( + state["electron-persisted-atom-state"]["default-service-tier"], + "priority" + ); + assert_eq!( + state["electron-persisted-atom-state"]["composer-auto-context-enabled"], + false + ); + assert_eq!( + state["electron-persisted-atom-state"]["enter-behavior"], + "cmdAlways" + ); + assert_eq!( + state["electron-persisted-atom-state"]["avatar-overlay-mascot-width-px"], + 160 + ); + assert_eq!(state["electron-persisted-atom-state"]["sidebar-width"], 296); + assert_eq!( + state["electron-persisted-atom-state"]["app-shell:right-panel-width:v2:/"], + 420 + ); + assert_eq!( + state["electron-persisted-atom-state"]["sidebar-project-expanded-v1-codex:C:/work/app"], + true + ); + assert_eq!( + state["electron-persisted-atom-state"]["has-seen-fast-mode-announcement"], + true + ); + assert_eq!( + state["electron-persisted-atom-state"]["has-seen-multi-agent-composer-banner"], + true + ); + assert_eq!( + state["electron-persisted-atom-state"]["plugin-marketplace-unlocked"], + true + ); + assert_eq!( + state["computer-use-bundled-plugin-auto-install-disabled"], + false + ); + assert!(state.get("prompt-history").is_none()); + assert!(state.get("provider-token-cache").is_none()); + assert!( + state["electron-persisted-atom-state"] + .get("prompt-history") + .is_none() + ); + assert!( + state["electron-persisted-atom-state"] + .get("thread-client-id-v1:thread-1") + .is_none() + ); + assert!( + state["electron-persisted-atom-state"] + .get("heartbeat-thread-permissions-by-id") + .is_none() + ); +} + +#[test] +fn app_state_sync_normalizes_current_state_and_writes_backup_before_change() { + let temp = tempfile::tempdir().unwrap(); + let home = temp.path(); + let state_path = home.join(".codex-global-state.json"); + std::fs::write( + &state_path, + json!({ + "electron-saved-workspace-roots": ["C:/work/app", "C:\\work\\app\\"], + "active-workspace-roots": "C:/work/app/", + "projectless-thread-ids": ["thread-1", "thread-1"], + "computer-use-bundled-plugin-auto-install-disabled": true + }) + .to_string(), + ) + .unwrap(); + + let result = sync_app_state_after_provider_switch(home).unwrap(); + let backup_path = result + .backup_path + .expect("normalization should create backup"); + let state: Value = + serde_json::from_str(&std::fs::read_to_string(&state_path).unwrap()).unwrap(); + let backup: Value = serde_json::from_str( + &std::fs::read_to_string(backup_path.join(".codex-global-state.json")).unwrap(), + ) + .unwrap(); + + assert!(result.changed); + assert_eq!( + state["electron-saved-workspace-roots"], + json!(["C:\\work\\app"]) + ); + assert_eq!(state["active-workspace-roots"], json!("C:\\work\\app")); + assert_eq!(state["projectless-thread-ids"], json!(["thread-1"])); + assert_eq!( + state["computer-use-bundled-plugin-auto-install-disabled"], + false + ); + assert_eq!( + backup["electron-saved-workspace-roots"], + json!(["C:/work/app", "C:\\work\\app\\"]) + ); + assert_eq!( + backup["computer-use-bundled-plugin-auto-install-disabled"], + true + ); +} diff --git a/crates/codex-plus-core/tests/launcher.rs b/crates/codex-plus-core/tests/launcher.rs index d2df5f0b7..20302ba6d 100644 --- a/crates/codex-plus-core/tests/launcher.rs +++ b/crates/codex-plus-core/tests/launcher.rs @@ -217,6 +217,7 @@ fn launcher_does_not_override_codex_app_environment() { assert!(!source.contains(".envs(codex_process_environment())")); assert!(!source.contains("activate_packaged_app_with_environment")); assert!(!source.contains("with_temporary_proxy_environment")); + assert!(source.contains("CODEX_ELECTRON_ENABLE_WINDOWS_COMPUTER_USE")); } #[test] @@ -630,6 +631,7 @@ async fn launch_lifecycle_runs_enabled_maintenance_without_applying_relay_profil "load-settings", "provider-sync", "computer-use-guard", + "builtin-plugin-state:launcher.before_launch", "start-helper:57321", "launch:9229", "computer-use-guard-watchdog", @@ -845,7 +847,7 @@ async fn launch_lifecycle_runs_computer_use_guard_when_enabled() { } #[tokio::test] -async fn launch_lifecycle_skips_computer_use_guard_by_default() { +async fn launch_lifecycle_runs_builtin_plugin_guard_with_plugin_unlock_by_default() { let temp = tempfile::tempdir().unwrap(); let app_dir = temp.path().join("Codex.app"); std::fs::create_dir_all(&app_dir).unwrap(); @@ -866,6 +868,38 @@ async fn launch_lifecycle_skips_computer_use_guard_by_default() { .unwrap(); handle.wait_for_codex_exit().await.unwrap(); + let events = events.lock().unwrap().clone(); + assert!(events.contains(&"computer-use-guard".to_string())); + assert!(events.contains(&"computer-use-guard-watchdog".to_string())); + assert!(events.contains(&"launch:9229".to_string())); +} + +#[tokio::test] +async fn launch_lifecycle_skips_builtin_plugin_guard_when_unlock_and_guard_are_disabled() { + let temp = tempfile::tempdir().unwrap(); + let app_dir = temp.path().join("Codex.app"); + std::fs::create_dir_all(&app_dir).unwrap(); + let status_store = StatusStore::new(temp.path().join("latest-status.json")); + let events = Arc::new(Mutex::new(Vec::::new())); + let hooks = FakeHooks::new(events.clone()).with_settings(BackendSettings { + codex_app_plugin_marketplace_unlock: false, + computer_use_guard_enabled: false, + ..BackendSettings::default() + }); + + let handle = launch_and_inject_with_hooks( + LaunchOptions { + app_dir: Some(app_dir), + debug_port: 9229, + helper_port: 57321, + status_store, + }, + &hooks, + ) + .await + .unwrap(); + handle.wait_for_codex_exit().await.unwrap(); + let events = events.lock().unwrap().clone(); assert!(!events.contains(&"computer-use-guard".to_string())); assert!(!events.contains(&"computer-use-guard-watchdog".to_string())); @@ -929,7 +963,6 @@ async fn launch_lifecycle_skips_active_relay_profile_when_supplier_config_disabl let events = events.lock().unwrap().clone(); assert!(!events.contains(&"apply-relay".to_string())); - assert!(!events.contains(&"computer-use-guard".to_string())); assert!(events.contains(&"launch:9229".to_string())); } @@ -981,7 +1014,6 @@ experimental_bearer_token = "sk-test" let events = events.lock().unwrap().clone(); assert!(!events.contains(&"apply-relay".to_string())); - assert!(!events.contains(&"computer-use-guard".to_string())); assert!(events.contains(&"launch:9229".to_string())); } @@ -1012,8 +1044,10 @@ async fn launch_lifecycle_enters_degraded_mode_and_retries_when_injection_fails( "select-debug:9229", "select-helper:57321", "load-settings", + "computer-use-guard", "start-helper:57321", "launch:9229", + "computer-use-guard-watchdog", "inject:9229:57321", "status:running_degraded", ] @@ -1057,6 +1091,7 @@ async fn launch_lifecycle_cleans_helper_when_launch_fails_after_helper_started() "select-debug:9229", "select-helper:57321", "load-settings", + "computer-use-guard", "start-helper:57321", "launch:9229", "shutdown-helper:57321", @@ -1164,8 +1199,10 @@ async fn launch_lifecycle_cleans_helper_and_codex_when_status_save_fails() { "select-debug:9229", "select-helper:57321", "load-settings", + "computer-use-guard", "start-helper:57321", "launch:9229", + "computer-use-guard-watchdog", "inject:9229:57321", "shutdown-helper:57321", "terminate-packaged:4242", @@ -1252,8 +1289,10 @@ async fn launch_continues_when_plugin_marketplace_config_fails() { "select-helper:57321", "load-settings", "plugin-marketplace", + "computer-use-guard", "start-helper:57321", "launch:9229", + "computer-use-guard-watchdog", "inject:9229:57321", "status:running" ] @@ -1300,6 +1339,7 @@ async fn default_launch_hooks_provider_sync_enabled_returns_explicit_error() { #[derive(Clone)] struct FakeHooks { events: Arc>>, + codex_home: Arc, settings: BackendSettings, launch_result: CodexLaunch, launch_error: Option, @@ -1312,6 +1352,7 @@ impl FakeHooks { fn new(events: Arc>>) -> Self { Self { events, + codex_home: Arc::new(tempfile::tempdir().unwrap()), settings: BackendSettings::default(), launch_result: CodexLaunch::Process { command: vec!["codex".to_string()], @@ -1382,6 +1423,10 @@ impl LaunchHooks for FakeHooks { requested } + fn codex_home(&self) -> PathBuf { + self.codex_home.path().to_path_buf() + } + async fn load_settings(&self) -> anyhow::Result { self.event("load-settings"); Ok(self.settings.clone()) @@ -1408,6 +1453,15 @@ impl LaunchHooks for FakeHooks { Ok(()) } + async fn ensure_builtin_plugin_state_after_provider_switch( + &self, + _settings: &BackendSettings, + source: &str, + ) -> anyhow::Result<()> { + self.event(format!("builtin-plugin-state:{source}")); + Ok(()) + } + async fn ensure_plugin_marketplace_config( &self, _settings: &BackendSettings, diff --git a/crates/codex-plus-core/tests/model_catalog.rs b/crates/codex-plus-core/tests/model_catalog.rs index cf5b15890..17f636e48 100644 --- a/crates/codex-plus-core/tests/model_catalog.rs +++ b/crates/codex-plus-core/tests/model_catalog.rs @@ -12,6 +12,8 @@ use codex_plus_core::settings::{ }; use serde_json::json; +static MODEL_CATALOG_ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(()); + #[tokio::test] async fn model_catalog_fetches_models_from_codex_config_provider() { let temp = tempfile::tempdir().unwrap(); @@ -69,6 +71,7 @@ experimental_bearer_token = "relay-key" #[tokio::test] async fn model_catalog_uses_active_relay_profile_model_list_for_display() { + let _env_lock = MODEL_CATALOG_ENV_LOCK.lock().unwrap(); let temp = tempfile::tempdir().unwrap(); let codex_home = temp.path().join("codex-home"); std::fs::create_dir_all(&codex_home).unwrap(); @@ -124,6 +127,71 @@ async fn model_catalog_uses_active_relay_profile_model_list_for_display() { assert_eq!(result["sources"][0]["type"], "relay_profile_model_list"); } +#[tokio::test] +async fn model_catalog_prefers_live_codex_model_for_active_relay_profile() { + let _env_lock = MODEL_CATALOG_ENV_LOCK.lock().unwrap(); + let temp = tempfile::tempdir().unwrap(); + let codex_home = temp.path().join("codex-home"); + std::fs::create_dir_all(&codex_home).unwrap(); + write_config( + &codex_home, + r#" +model = "gpt-5.5" +model_provider = "custom" + +[model_providers.custom] +name = "Custom" +base_url = "https://example.test/v1" +"#, + ); + let settings_path = temp.path().join("settings.json"); + let previous_codex_home = std::env::var_os("CODEX_HOME"); + let previous_settings_path = + codex_plus_core::paths::set_settings_path_for_tests(Some(settings_path.clone())); + unsafe { + std::env::set_var("CODEX_HOME", &codex_home); + } + + let result = async { + SettingsStore::new(settings_path) + .save(&BackendSettings { + active_relay_id: "relay-a".to_string(), + relay_profiles: vec![RelayProfile { + id: "relay-a".to_string(), + name: "Relay A".to_string(), + model: "gpt-4.1".to_string(), + base_url: "https://example.test/v1".to_string(), + protocol: RelayProtocol::Responses, + relay_mode: RelayMode::MixedApi, + model_list: "gpt-4.1".to_string(), + config_contents: "model = \"gpt-4.1\"\n".to_string(), + ..RelayProfile::default() + }], + ..BackendSettings::default() + }) + .unwrap(); + + read_codex_model_catalog().await + } + .await; + + match previous_codex_home { + Some(value) => unsafe { + std::env::set_var("CODEX_HOME", value); + }, + None => unsafe { + std::env::remove_var("CODEX_HOME"); + }, + } + codex_plus_core::paths::set_settings_path_for_tests(previous_settings_path); + + assert_eq!(result["status"], "ok"); + assert_eq!(result["model"], "gpt-5.5"); + assert_eq!(result["default_model"], "gpt-5.5"); + assert_eq!(result["models"], json!(["gpt-5.5", "gpt-4.1"])); + assert_eq!(result["sources"][0]["type"], "relay_profile_model_list"); +} + #[tokio::test] async fn model_catalog_uses_single_provider_when_root_model_provider_is_absent() { let temp = tempfile::tempdir().unwrap(); diff --git a/crates/codex-plus-core/tests/relay_config.rs b/crates/codex-plus-core/tests/relay_config.rs index 045e990af..434f7c88c 100644 --- a/crates/codex-plus-core/tests/relay_config.rs +++ b/crates/codex-plus-core/tests/relay_config.rs @@ -557,6 +557,60 @@ experimental_bearer_token = "sk-a" assert_eq!(auth, r#"{"OPENAI_API_KEY":"sk-a"}"#); } +#[test] +fn apply_relay_files_preserves_live_desktop_personalization_settings() { + let temp = tempfile::tempdir().unwrap(); + std::fs::write( + temp.path().join("config.toml"), + r#"model = "old" + +[desktop] +composerEnterBehavior = "cmdAlways" +followUpQueueMode = "queue" +selected-avatar-id = "avatar-local" +"#, + ) + .unwrap(); + + apply_relay_files_to_home( + temp.path(), + r#"model_provider = "custom" + +[desktop] +composerEnterBehavior = "enter" +selected-avatar-id = "avatar-from-profile" + +[model_providers.custom] +name = "custom" +wire_api = "responses" +requires_openai_auth = true +base_url = "https://relay-a.example/v1" +experimental_bearer_token = "sk-a" +"#, + r#"{"OPENAI_API_KEY":"sk-a"}"#, + ) + .unwrap(); + + let config = std::fs::read_to_string(temp.path().join("config.toml")).unwrap(); + let parsed: toml::Value = config.parse().unwrap(); + assert_eq!( + parsed["desktop"]["composerEnterBehavior"].as_str(), + Some("cmdAlways") + ); + assert_eq!( + parsed["desktop"]["followUpQueueMode"].as_str(), + Some("queue") + ); + assert_eq!( + parsed["desktop"]["selected-avatar-id"].as_str(), + Some("avatar-local") + ); + assert_eq!( + parsed["model_providers"]["custom"]["base_url"].as_str(), + Some("https://relay-a.example/v1") + ); +} + #[test] fn apply_relay_files_allows_empty_isolated_auth_json() { let temp = tempfile::tempdir().unwrap(); diff --git a/crates/codex-plus-core/tests/relay_switch.rs b/crates/codex-plus-core/tests/relay_switch.rs index 4ec2f7d60..79dbd7c8b 100644 --- a/crates/codex-plus-core/tests/relay_switch.rs +++ b/crates/codex-plus-core/tests/relay_switch.rs @@ -221,6 +221,141 @@ goals = true assert!(returned.api_key.is_empty()); } +#[test] +fn switch_preserves_codex_app_state_and_remote_plugin_marketplace() { + let temp = tempfile::tempdir().unwrap(); + let home = temp.path().join("codex"); + std::fs::create_dir(&home).unwrap(); + std::fs::write( + home.join(".codex-global-state.json"), + serde_json::json!({ + "electron-saved-workspace-roots": ["C:/work/app"], + "thread-workspace-root-hints": { + "thread-1": "C:/work/app" + }, + "thread-projectless-output-directories": { + "thread-1": "C:/work/app/out" + }, + "projectless-thread-ids": ["thread-1"], + "electron-persisted-atom-state": { + "default-service-tier": "priority", + "plugin-marketplace-unlocked": true + }, + "computer-use-bundled-plugin-auto-install-disabled": true + }) + .to_string(), + ) + .unwrap(); + #[cfg(windows)] + { + let marketplace = home + .join(".tmp") + .join("bundled-marketplaces") + .join("openai-bundled"); + std::fs::create_dir_all(marketplace.join(".agents").join("plugins")).unwrap(); + std::fs::write( + marketplace + .join(".agents") + .join("plugins") + .join("marketplace.json"), + "{}", + ) + .unwrap(); + for plugin in ["browser", "chrome", "computer-use", "latex"] { + let plugin_root = marketplace + .join("plugins") + .join(plugin) + .join(".codex-plugin"); + std::fs::create_dir_all(&plugin_root).unwrap(); + std::fs::write(plugin_root.join("plugin.json"), "{}").unwrap(); + } + } + std::fs::write( + home.join("config.toml"), + r#"model_provider = "custom" + +[model_providers.custom] +name = "custom" +wire_api = "responses" +requires_openai_auth = true +base_url = "https://a.example/v1" +"#, + ) + .unwrap(); + std::fs::write(home.join("auth.json"), r#"{"OPENAI_API_KEY":"sk-a"}"#).unwrap(); + let store = SettingsStore::new(temp.path().join("settings.json")); + let original = BackendSettings { + active_relay_id: "a".to_string(), + relay_profiles: vec![ + pure_profile("a", "https://a.example/v1", "sk-a"), + pure_profile("b", "https://b.example/v1", "sk-b"), + ], + codex_app_plugin_marketplace_unlock: true, + computer_use_guard_enabled: true, + ..BackendSettings::default() + }; + store.save(&original).unwrap(); + let next = BackendSettings { + active_relay_id: "b".to_string(), + relay_profiles: original.relay_profiles.clone(), + codex_app_plugin_marketplace_unlock: true, + computer_use_guard_enabled: true, + ..BackendSettings::default() + }; + + switch_relay_profile_in_home(&store, &home, next, "a").unwrap(); + + let state: serde_json::Value = serde_json::from_str( + &std::fs::read_to_string(home.join(".codex-global-state.json")).unwrap(), + ) + .unwrap(); + let config = std::fs::read_to_string(home.join("config.toml")).unwrap(); + assert_eq!( + state["electron-persisted-atom-state"]["default-service-tier"], + "priority" + ); + assert_eq!( + state["electron-persisted-atom-state"]["plugin-marketplace-unlocked"], + true + ); + assert_eq!( + state["thread-workspace-root-hints"]["thread-1"], + "C:/work/app" + ); + assert_eq!( + state["thread-projectless-output-directories"]["thread-1"], + "C:/work/app/out" + ); + assert_eq!( + state["projectless-thread-ids"], + serde_json::json!(["thread-1"]) + ); + assert_eq!( + state["computer-use-bundled-plugin-auto-install-disabled"], + false + ); + assert!(state.get("prompt-history").is_none()); + assert!( + state["electron-persisted-atom-state"] + .get("prompt-history") + .is_none() + ); + assert!( + home.join("backups_state/app-state-sync/latest-safe-state.json") + .is_file() + ); + assert!(config.contains("[marketplaces.openai-curated-remote]")); + #[cfg(windows)] + { + assert!(config.contains("[marketplaces.openai-bundled]")); + assert!(config.contains("[plugins.\"browser@openai-bundled\"]")); + assert!(config.contains("[plugins.\"chrome@openai-bundled\"]")); + assert!(config.contains("[plugins.\"computer-use@openai-bundled\"]")); + assert!(config.contains("computer_use = true")); + assert!(config.contains("sandbox = \"unelevated\"")); + } +} + fn pure_profile(id: &str, base_url: &str, key: &str) -> RelayProfile { RelayProfile { id: id.to_string(), diff --git a/tools/i18n-keys.json b/tools/i18n-keys.json index 47899d750..1abc44c15 100644 --- a/tools/i18n-keys.json +++ b/tools/i18n-keys.json @@ -176,7 +176,7 @@ "启用", "启用 Codex 图片覆盖层", "启用 Codex增强", - "启用 Windows Computer Use Guard", + "强制启用 Windows Computer Use Guard", "启用供应商配置切换", "启用此扩展项", "启用目标功能", @@ -533,7 +533,7 @@ "额外参数", "高级选项,默认关闭;当前实现不主动改写 Zed settings。", "默认中转", - "默认关闭;开启后启动 Codex 时会自动保留官方 Computer Use 插件所需的 config.toml、bundled 插件和 notify 配置。", + "插件市场解锁在完整增强模式下会自动准备 Browser、Chrome、Computer Use 的本地 bundled 状态;此开关用于在其他场景中强制执行同一守护。", "默认启动 Codex 时使用的模型名,请勿带后缀;上下文窗口请在下方「模型列表」中按模型单独配置。", "默认开启;无 VPN 时让 Statsig 初始化快速失败,减少启动时长。需重启 Codex 才生效。", "默认打开策略",