From 60da507292eee5386510fef1114f9be720a95e58 Mon Sep 17 00:00:00 2001 From: Yuimi_chaya <124485273+Yuimi-chaya@users.noreply.github.com> Date: Mon, 6 Jul 2026 01:30:54 +0800 Subject: [PATCH 1/4] =?UTF-8?q?feat:=20sync=20Codex=20App=20state=20across?= =?UTF-8?q?=20provider=20profiles=20/=20=E5=90=8C=E6=AD=A5=E4=BE=9B?= =?UTF-8?q?=E5=BA=94=E5=95=86=E5=88=87=E6=8D=A2=E5=90=8E=E7=9A=84=20Codex?= =?UTF-8?q?=20App=20=E7=8A=B6=E6=80=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 为什么 / Why: Codex++ 切换供应商时已经能保留会话,但 Codex App 的 persisted state 和 desktop settings 会丢失,导致工作区、输入习惯、布局和个性化体验回到默认值。 Provider switching already preserves conversations, but Codex App persisted state and desktop settings can be lost, resetting workspace roots, input behavior, layout, and personalization. 改动 / Changes: - Add a safe allowlist-based .codex-global-state.json snapshot/merge module. - Preserve safe workspace, project hint, service-tier, onboarding, layout, and personalization state. - Preserve the live [desktop] table when writing provider config.toml, so settings such as Enter/Ctrl+Enter behavior and selected avatar are not overwritten by stale profile config. - Back up global state before writing merged state. 安全边界 / Safety: The sync deliberately excludes auth, API keys, prompt history, provider token cache, and permission maps. 同步刻意排除 auth、API Key、prompt history、provider token cache 和权限映射。 --- crates/codex-plus-core/src/codex_app_state.rs | 631 ++++++++++++++++++ crates/codex-plus-core/src/lib.rs | 1 + crates/codex-plus-core/src/relay_config.rs | 27 + .../codex-plus-core/tests/codex_app_state.rs | 244 +++++++ crates/codex-plus-core/tests/relay_config.rs | 54 ++ 5 files changed, 957 insertions(+) create mode 100644 crates/codex-plus-core/src/codex_app_state.rs create mode 100644 crates/codex-plus-core/tests/codex_app_state.rs 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..0ff335b39 --- /dev/null +++ b/crates/codex-plus-core/src/codex_app_state.rs @@ -0,0 +1,631 @@ +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(), + }), + ); + } + } +} + +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/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/relay_config.rs b/crates/codex-plus-core/src/relay_config.rs index 6217661b6..73b8d0361 100644 --- a/crates/codex-plus-core/src/relay_config.rs +++ b/crates/codex-plus-core/src/relay_config.rs @@ -1075,6 +1075,12 @@ fn write_codex_live_atomic( #[cfg(windows)] let config_text = guarded_config_text.as_deref(); + let config_text = match config_text { + Some(config_text) => Some(preserve_live_desktop_settings(home, config_text)?), + None => None, + }; + let config_text = config_text.as_deref(); + let config_text = match config_text { Some(config_text) => Some( crate::plugin_marketplace::preserve_openai_curated_remote_marketplace_config( @@ -1349,6 +1355,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/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/relay_config.rs b/crates/codex-plus-core/tests/relay_config.rs index c5e61c02b..29de5cb7d 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(); From 792fbdadb030c4a6787286e6e20c723983cedc6c Mon Sep 17 00:00:00 2001 From: Yuimi_chaya <124485273+Yuimi-chaya@users.noreply.github.com> Date: Mon, 6 Jul 2026 01:30:59 +0800 Subject: [PATCH 2/4] =?UTF-8?q?fix:=20preserve=20provider-switch=20app=20s?= =?UTF-8?q?tate=20and=20bundled=20plugin=20guard=20/=20=E4=BF=9D=E7=95=99?= =?UTF-8?q?=E4=BE=9B=E5=BA=94=E5=95=86=E5=88=87=E6=8D=A2=E7=8A=B6=E6=80=81?= =?UTF-8?q?=E5=B9=B6=E5=8A=A0=E5=9B=BA=E5=86=85=E7=BD=AE=E6=8F=92=E4=BB=B6?= =?UTF-8?q?=E5=AE=88=E6=8A=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 为什么 / Why: Codex++ 的主要使用场景是自定义 URL + API key。切换供应商后,如果工作区状态、特殊插件本地缓存或 Computer Use 运行时配置被重置,用户会被迫重新安装工作区或开 VPN 进入一次官方环境。 Codex++ is commonly used with custom URLs and API keys. After switching providers, losing workspace state, special plugin cache, or Computer Use runtime config forces users to reinstall workspace state or open the official environment through VPN once. 改动 / Changes: - Wire app-state capture/merge into launcher, relay switch, manager apply/clear paths. - Make builtin plugin guard active when full enhancements and plugin marketplace unlock are enabled, while keeping the force switch for other cases. - Preserve Browser, Chrome, Computer Use bundled plugin config and local openai-bundled marketplace state. - Ensure js_repl, local_shell, computer_use, Windows unelevated sandbox, notify config, and runtime export compatibility. - Set CODEX_ELECTRON_ENABLE_WINDOWS_COMPUTER_USE=1 for guarded Windows launches. - Update manager UI/i18n so the old Computer Use switch is described as a force-enable guard. 安全边界 / Safety: This is a local config/cache/runtime guard. It does not claim to bypass every possible organization or region gate inside Codex App ASAR. 这是本地 config/cache/runtime 兜底,不声称能 100% 绕过 Codex App ASAR 内部所有组织或地区 gate。 --- apps/codex-plus-launcher/src/main.rs | 14 ++ .../src-tauri/src/commands.rs | 206 +++++++++++++++++- apps/codex-plus-manager/src/App.tsx | 4 +- apps/codex-plus-manager/src/i18n-en.ts | 6 +- crates/codex-plus-core/src/codex_app_state.rs | 27 ++- .../codex-plus-core/src/computer_use_guard.rs | 83 +++++++ crates/codex-plus-core/src/launcher.rs | 87 +++++++- crates/codex-plus-core/src/relay_switch.rs | 44 +++- crates/codex-plus-core/src/settings.rs | 43 ++++ crates/codex-plus-core/tests/launcher.rs | 60 ++++- crates/codex-plus-core/tests/relay_switch.rs | 135 ++++++++++++ tools/i18n-keys.json | 4 +- 12 files changed, 687 insertions(+), 26 deletions(-) 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/crates/codex-plus-core/src/codex_app_state.rs b/crates/codex-plus-core/src/codex_app_state.rs index 0ff335b39..bdcbd11c0 100644 --- a/crates/codex-plus-core/src/codex_app_state.rs +++ b/crates/codex-plus-core/src/codex_app_state.rs @@ -177,7 +177,32 @@ pub fn sync_app_state_after_provider_switch_nonfatal(home: &Path, source: &str) } } } - + +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() { 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 75a851c2c..5829e5d03 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(), }), ); @@ -730,6 +795,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}"))?; @@ -778,7 +847,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/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/launcher.rs b/crates/codex-plus-core/tests/launcher.rs index c6a0a4a03..aad4ccc96 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] @@ -622,6 +623,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", @@ -837,7 +839,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(); @@ -858,6 +860,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())); @@ -921,7 +955,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())); } @@ -973,7 +1006,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())); } @@ -1004,8 +1036,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", ] @@ -1049,6 +1083,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", @@ -1156,8 +1191,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", @@ -1244,8 +1281,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" ] @@ -1292,6 +1331,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, @@ -1304,6 +1344,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()], @@ -1374,6 +1415,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()) @@ -1400,6 +1445,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/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 才生效。", "默认打开策略", From c2100b0beefcb34543b41d3a7939127195bc3fc9 Mon Sep 17 00:00:00 2001 From: Yuimi_chaya <124485273+Yuimi-chaya@users.noreply.github.com> Date: Mon, 6 Jul 2026 01:31:02 +0800 Subject: [PATCH 3/4] =?UTF-8?q?fix:=20prefer=20live=20relay=20model=20in?= =?UTF-8?q?=20catalog=20/=20=E6=A8=A1=E5=9E=8B=E7=9B=AE=E5=BD=95=E4=BC=98?= =?UTF-8?q?=E5=85=88=E4=BD=BF=E7=94=A8=E5=BD=93=E5=89=8D=20live=20?= =?UTF-8?q?=E4=BE=9B=E5=BA=94=E5=95=86=E6=A8=A1=E5=9E=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 为什么 / Why: Fast/service-tier 判断依赖当前模型。如果 active relay profile 的 model list 或 profile model 是旧值,而 live ~/.codex/config.toml 已经选择了 gpt-5.5,Codex++ 会误以为当前模型未读取或不支持。 Fast/service-tier decisions depend on the current model. If the active relay profile model list is stale while live ~/.codex/config.toml already selects gpt-5.5, Codex++ may think the current model is unread or unsupported. 改动 / Changes: - Read the live Codex config model for the active relay profile catalog response. - Put the effective current model first, then merge profile model and model list entries. - Strip Codex++ model suffixes before catalog candidate dedupe. - Add tests for live config winning over stale relay profile values. --- crates/codex-plus-core/src/model_catalog.rs | 62 +++++++++++++---- crates/codex-plus-core/tests/model_catalog.rs | 68 +++++++++++++++++++ 2 files changed, 116 insertions(+), 14 deletions(-) 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/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(); From ea442101b968002ca1614146772cbaf4a7fecf30 Mon Sep 17 00:00:00 2001 From: Yuimi_chaya <124485273+Yuimi-chaya@users.noreply.github.com> Date: Mon, 6 Jul 2026 01:32:44 +0800 Subject: [PATCH 4/4] =?UTF-8?q?fix:=20stabilize=20Fast=20injection=20and?= =?UTF-8?q?=20Codex=20asset=20compatibility=20/=20=E7=A8=B3=E5=AE=9A=20Fas?= =?UTF-8?q?t=20=E6=B3=A8=E5=85=A5=E5=B9=B6=E5=85=BC=E5=AE=B9=E6=96=B0?= =?UTF-8?q?=E7=89=88=20Codex=20=E8=B5=84=E4=BA=A7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 为什么 / Why: 用户实际遇到过界面已选 gpt-5.5 且请求可用,但 Codex++ 仍提示“当前模型未读取”或隐藏 Fast 入口。新版 Codex Desktop 还移除了旧 app-server-manager-signals-* asset,并调整了 composer DOM,导致注入报错和右下角 Fast badge 缺失。 Users have seen gpt-5.5 selected and working while Codex++ still reports the current model as unread or hides Fast. Newer Codex Desktop builds also removed the old app-server-manager-signals-* asset and changed composer DOM, causing injection errors and missing composer Fast badge. 改动 / Changes: - Treat unknown current model as pending, not unsupported; only known non-gpt-5.4 / gpt-5.5 models block Fast. - Resolve model from request params, visible UI, backend catalog, and last-known supported model. - Keep service_tier="priority" for pending supported paths instead of silently downgrading. - Load old Codex app assets optionally and log skipped diagnostics when they are absent. - Remove the hard-coded old app-server-manager asset import. - Add composer placement fallbacks based on model buttons, textarea, contenteditable, and textbox nodes. --- assets/inject/renderer-inject.js | 273 +++++++++++++++++++-- crates/codex-plus-core/tests/cdp_bridge.rs | 55 +++++ 2 files changed, 301 insertions(+), 27 deletions(-) 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/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))"));