diff --git a/src-tauri/src/agents.rs b/src-tauri/src/agents.rs index a6a93b7..6991800 100644 --- a/src-tauri/src/agents.rs +++ b/src-tauri/src/agents.rs @@ -3,6 +3,7 @@ use super::*; mod commands; mod configuration; +mod native_oauth; mod discovery; mod deepseek_harness; mod transactions; @@ -12,6 +13,7 @@ mod launch; mod state; pub(crate) use commands::*; pub(crate) use configuration::*; +pub(crate) use native_oauth::*; pub(crate) use discovery::*; pub(crate) use deepseek_harness::*; pub(crate) use transactions::*; diff --git a/src-tauri/src/agents/configuration.rs b/src-tauri/src/agents/configuration.rs index 09028eb..08b63cc 100644 --- a/src-tauri/src/agents/configuration.rs +++ b/src-tauri/src/agents/configuration.rs @@ -539,6 +539,11 @@ pub(crate) fn write_agent_configuration_file( path: &Path, content: &[u8], ) -> Result<(), String> { + if client == AgentClient::Codex + && matches!(path.file_name().and_then(|name| name.to_str()), Some(CODEX_NATIVE_OAUTH_STATE_FILE | "auth.json")) + { + return write_codex_private_file(path, content); + } if client == AgentClient::DeepSeekHarness { let owner_only = path.file_name().and_then(|name| name.to_str()) == Some(DEEPSEEK_HARNESS_CREDENTIALS_FILE); @@ -2661,9 +2666,12 @@ pub(crate) fn build_codex_auth_update( ) -> Result { let path = codex_configuration_directory(home).join("auth.json"); let after = if oauth_configuration { - validate_codex_oauth_login_at(&path)?; - read_optional_text(&path)? - .ok_or_else(|| "Codex OAuth 登录凭据在应用配置前已被删除,请重新登录".to_string())? + if validate_codex_oauth_login_at(&path).is_ok() { + read_optional_text(&path)? + .ok_or_else(|| "Codex OAuth 登录凭据在应用配置前已被删除,请重新登录".to_string())? + } else { + available_codex_oauth_auth(home)? + } } else { let path_text = read_optional_text(&path)?; let mut root = parse_agent_json_object(path_text.as_deref(), "Codex auth.json")?; diff --git a/src-tauri/src/agents/discovery.rs b/src-tauri/src/agents/discovery.rs index 533954d..10a39b8 100644 --- a/src-tauri/src/agents/discovery.rs +++ b/src-tauri/src/agents/discovery.rs @@ -432,6 +432,7 @@ pub(crate) fn inspect_pi_provider_status( connection_state: agent_connection_state(configured, config_valid, Ok(config_path.is_file() || default_provider_matches)).into(), current_model: current_model.clone(), oauth_configuration: false, + codex_native_oauth: false, modification_enabled: configured, modification_state: modification_state.to_string(), backup_available: false, @@ -745,7 +746,10 @@ pub(crate) fn codex_auth_file_has_api_key(path: &Path, api_key: &str) -> bool { } pub(crate) fn validate_codex_oauth_login(home: &Path) -> Result<(), String> { - validate_codex_oauth_login_at(&codex_configuration_directory(home).join("auth.json")) + if validate_codex_oauth_login_at(&codex_configuration_directory(home).join("auth.json")).is_ok() { + return Ok(()); + } + available_codex_oauth_auth(home).map(|_| ()) } pub(crate) fn validate_codex_oauth_login_at(auth_path: &Path) -> Result<(), String> { @@ -757,11 +761,12 @@ pub(crate) fn validate_codex_oauth_login_at(auth_path: &Path) -> Result<(), Stri } pub(crate) fn clear_codex_config_files(home: &Path) -> Result, String> { - let paths = config_paths("codex",home)?; + let mut paths = config_paths("codex",home)?; + paths.push(codex_configuration_directory(home).join(CODEX_NATIVE_OAUTH_STATE_FILE)); let before = config_images(&paths)?; let mut after = before.clone(); for (path, bytes) in &mut after { - if path.file_name().and_then(|v| v.to_str()).is_some_and(|v| v == "auth.json" || v == "config.toml") { *bytes = None; } + if path.file_name().and_then(|v| v.to_str()).is_some_and(|v| v == "auth.json" || v == "config.toml" || v == CODEX_NATIVE_OAUTH_STATE_FILE) { *bytes = None; } } Ok(commit_config("codex", &paths, &before, &after, "clear", None)?.changed_files) } @@ -884,7 +889,12 @@ pub(crate) fn inspect_agent_config( let paths = agent_config_paths(client, home); let config_exists = paths.iter().any(|path| path.is_file()); let result = config_paths(client.id(), home) - .and_then(|paths| config_images(&paths)) + .and_then(|mut paths| { + if client == AgentClient::Codex && codex_native_oauth_enabled(home)? { + paths.retain(|path| path.file_name().and_then(|name| name.to_str()) != Some(CODEX_MODEL_CATALOG_FILE)); + } + config_images(&paths) + }) .and_then(|images| validate_config_images(&images)) .and_then(|_| inspect_agent_managed_config(client, &paths, port, api_key)).and_then( |(configured, model, oauth_configuration)| { @@ -979,6 +989,7 @@ pub(crate) fn inspect_agent_config( connection_state: agent_connection_state(configured, config_valid, agent_has_connection_evidence(client, &paths)).into(), current_model: current_model.clone(), oauth_configuration, + codex_native_oauth: client == AgentClient::Codex && codex_native_oauth_enabled(home).unwrap_or(false), modification_enabled: configured, modification_state: if !config_valid { "invalid" } else if configured { "applied" } else { "unconfigured" }.into(), backup_available: false, diff --git a/src-tauri/src/agents/native_oauth.rs b/src-tauri/src/agents/native_oauth.rs new file mode 100644 index 0000000..e255174 --- /dev/null +++ b/src-tauri/src/agents/native_oauth.rs @@ -0,0 +1,528 @@ +use super::*; + +#[cfg(test)] +mod tests; + +pub(crate) const CODEX_NATIVE_OAUTH_STATE_FILE: &str = "cpa-native-oauth.json"; +pub(crate) fn write_codex_private_file(path: &Path, content: &[u8]) -> Result<(), String> { + #[cfg(unix)] + { + use std::os::unix::fs::{OpenOptionsExt, PermissionsExt}; + fs::create_dir_all(path.parent().ok_or("Codex 凭据路径无效")?) + .map_err(|_| "创建 Codex 凭据目录失败")?; + let file = fs::OpenOptions::new() + .create(true) + .write(true) + .truncate(false) + .mode(0o600) + .open(path) + .map_err(|_| "无法打开 Codex 凭据文件")?; + file.set_permissions(fs::Permissions::from_mode(0o600)) + .map_err(|_| "设置 Codex 凭据文件权限失败")?; + } + write_bytes_directly(path, content) +} + +const ROUTING_KEYS: &[&str] = &[ + "model_provider", + "model", + "model_catalog_json", + "openai_base_url", + "chatgpt_base_url", + "forced_login_method", + "profile", +]; + +#[derive(Default, Serialize, Deserialize)] +struct NativeOAuthState { + version: u32, + enabled: bool, + cpa_settings: String, + cpa_auth: Option, + native_auth: Option, + native_model: Option, + #[serde(default)] + original_settings: Option, + #[serde(default)] + original_auth: Option, + #[serde(default)] + restore_full_routing: bool, +} + +fn parse_state(bytes: Option<&[u8]>) -> Result { + let Some(bytes) = bytes else { + return Ok(NativeOAuthState { + version: 1, + ..Default::default() + }); + }; + let state: NativeOAuthState = serde_json::from_slice(bytes) + .map_err(|_| "无法读取 Codex 接入方式切换记录,请检查配置文件".to_string())?; + if state.version != 1 { + return Err("不支持的 Codex 接入方式切换记录版本".into()); + } + Ok(state) +} + +fn native_selected(document: &toml_edit::Document, state: &NativeOAuthState) -> bool { + state.enabled + && document + .get("model_provider") + .and_then(toml_edit::Item::as_str) + == Some("openai") + && document + .get("forced_login_method") + .and_then(toml_edit::Item::as_str) + == Some("chatgpt") +} + +pub(crate) fn codex_native_oauth_enabled(home: &Path) -> Result { + let directory = codex_configuration_directory(home); + let state = + parse_state(read_agent_bytes(&directory.join(CODEX_NATIVE_OAUTH_STATE_FILE))?.as_deref())?; + if !state.enabled { + return Ok(false); + } + let content = read_optional_text(&directory.join("config.toml"))?; + let document = parse_codex_document(content.as_deref(), "Codex config.toml")?; + Ok(native_selected(&document, &state)) +} + +pub(crate) fn ensure_codex_cpa_mode(home: &Path) -> Result<(), String> { + if codex_native_oauth_enabled(home)? { + return Err("请点击“更新配置”或“一键接入”恢复 CPA 配置".into()); + } + Ok(()) +} + +// Save only the settings changed by this switch. Other user edits survive both directions. +fn routing_settings(document: &toml_edit::Document) -> toml_edit::Document { + let mut saved = toml_edit::Document::new(); + for key in ROUTING_KEYS { + restore_codex_table_item(saved.as_table_mut(), Some(document.as_table()), key); + } + if let Some(openai) = document + .get("model_providers") + .and_then(toml_edit::Item::as_table_like) + .and_then(|table| table.get("openai")) + { + ensure_toml_child_table(saved.as_table_mut(), "model_providers") + .insert("openai", openai.clone()); + } + saved +} + +fn remove_openai_override(document: &mut toml_edit::Document) { + if let Some(providers) = document + .get_mut("model_providers") + .and_then(toml_edit::Item::as_table_like_mut) + { + providers.remove("openai"); + if providers.is_empty() { + document.remove("model_providers"); + } + } +} + +fn oauth_auth(content: Option<&str>) -> Result, String> { + let Some(content) = content else { + return Ok(None); + }; + let mut root = parse_agent_json_object(Some(content), "Codex auth.json")?; + let has_tokens = root + .get("tokens") + .and_then(serde_json::Value::as_object) + .is_some_and(|tokens| { + ["access_token", "refresh_token"].iter().any(|key| { + tokens + .get(*key) + .and_then(serde_json::Value::as_str) + .is_some_and(|token| !token.trim().is_empty()) + }) + }); + if !has_tokens { + return Ok(None); + } + if root.get("auth_mode").and_then(serde_json::Value::as_str) == Some("chatgpt") + && root + .get("OPENAI_API_KEY") + .is_none_or(serde_json::Value::is_null) + { + return Ok(Some(content.to_string())); + } + root.remove("OPENAI_API_KEY"); + root.insert("auth_mode".into(), serde_json::json!("chatgpt")); + Ok(Some(render_agent_json(root, "Codex auth.json")?)) +} + +fn auth_uses_api_key(content: Option<&str>) -> Result { + let root = parse_agent_json_object(content, "Codex auth.json")?; + Ok( + root.get("auth_mode").and_then(serde_json::Value::as_str) == Some("apikey") + || root + .get("OPENAI_API_KEY") + .and_then(serde_json::Value::as_str) + .is_some_and(|key| !key.trim().is_empty()), + ) +} + +// Restore authentication fields only; user edits to other auth.json fields survive. +fn merge_codex_auth(current: Option<&str>, source: Option<&str>) -> Result, String> { + let mut root = parse_agent_json_object(current, "Codex auth.json")?; + let original = root.clone(); + let source_content = source; + let source = parse_agent_json_object(source, "Codex saved auth.json")?; + for key in ["auth_mode", "OPENAI_API_KEY", "tokens", "last_refresh"] { + root.remove(key); + if let Some(value) = source.get(key) { + root.insert(key.into(), value.clone()); + } + } + if root.is_empty() { + return Ok(None); + } + if root == original { + return Ok(current.map(str::to_string)); + } + if root == source { + return Ok(source_content.map(str::to_string)); + } + Ok(Some(render_agent_json(root, "Codex auth.json")?)) +} + +pub(crate) fn available_codex_oauth_auth(home: &Path) -> Result { + let directory = codex_configuration_directory(home); + let current = read_optional_text(&directory.join("auth.json"))?; + if let Some(auth) = oauth_auth(current.as_deref())? { + return Ok(auth); + } + // A missing/cleared auth file represents logout, not a request to reuse old tokens. + if auth_uses_api_key(current.as_deref())? { + let state = parse_state( + read_agent_bytes(&directory.join(CODEX_NATIVE_OAUTH_STATE_FILE))?.as_deref(), + )?; + if let Some(auth) = oauth_auth(state.native_auth.as_deref())? { + return merge_codex_auth(current.as_deref(), Some(&auth))? + .ok_or_else(|| CODEX_OAUTH_LOGIN_REQUIRED_ERROR.to_string()); + } + } + Err(CODEX_OAUTH_LOGIN_REQUIRED_ERROR.to_string()) +} + +pub(crate) fn close_codex_configuration(home: &Path) -> Result { + let directory = codex_configuration_directory(home); + let paths = vec![ + directory.join(CODEX_NATIVE_OAUTH_STATE_FILE), + directory.join("auth.json"), + directory.join("config.toml"), + ]; + let before = config_images(&paths)?; + let mut state = parse_state(before[0].1.as_deref())?; + let current = parse_codex_document(text(before[2].1.as_deref())?, "Codex config.toml")?; + if current + .get("model_provider") + .and_then(toml_edit::Item::as_str) + != Some(MANAGED_AGENT_PROVIDER_ID) + { + return Ok(action_result( + "unchanged", + false, + None, + Vec::new(), + Vec::new(), + )); + } + let original = state.original_settings.as_deref(); + let restored = build_restored_codex_agent_config(text(before[2].1.as_deref())?, original)?; + let mut document = parse_codex_document(restored.as_deref(), "Codex restored config.toml")?; + if let Some(original) = original { + let saved = parse_codex_document(Some(original), "Codex saved settings")?; + for key in ROUTING_KEYS { + if state.restore_full_routing || *key == "forced_login_method" { + restore_codex_table_item(document.as_table_mut(), Some(saved.as_table()), key); + } + } + if state.restore_full_routing { + remove_openai_override(&mut document); + if let Some(openai) = saved + .get("model_providers") + .and_then(toml_edit::Item::as_table_like) + .and_then(|p| p.get("openai")) + { + ensure_toml_child_table(document.as_table_mut(), "model_providers") + .insert("openai", openai.clone()); + } + } + } else { + document.remove("forced_login_method"); + } + let auth = text(before[1].1.as_deref())?; + let next_auth = if auth_uses_api_key(state.original_auth.as_deref())? { + merge_codex_auth(auth, state.original_auth.as_deref())? + } else { + let oauth = match oauth_auth(auth)? { + Some(current) => Some(current), + None if auth_uses_api_key(auth)? => oauth_auth(state.native_auth.as_deref())?, + None => None, + }; + merge_codex_auth(auth, oauth.as_deref())? + }; + state.native_auth = oauth_auth(next_auth.as_deref())?; + state.enabled = false; + state.original_settings = None; + state.original_auth = None; + state.restore_full_routing = false; + state.cpa_settings.clear(); + state.cpa_auth = None; + let rendered = document.to_string(); + let after = vec![ + ( + paths[0].clone(), + Some(serde_json::to_vec_pretty(&state).map_err(|_| "保存 Codex 切换记录失败")?), + ), + (paths[1].clone(), next_auth.map(String::into_bytes)), + ( + paths[2].clone(), + if rendered.trim().is_empty() { + None + } else { + Some(rendered.into_bytes()) + }, + ), + ]; + let mut result = commit_config("codex", &paths, &before, &after, "close", None)?; + result.enabled = false; + Ok(result) +} + +#[tauri::command] +pub(crate) fn close_codex_config_modification( + app: tauri::AppHandle, +) -> Result { + let home = app.path().home_dir().map_err(|_| "无法获取用户目录")?; + let _guard = AGENT_CONFIG_FILE_LOCK + .lock() + .map_err(|_| "智能体配置文件锁已损坏")?; + let result = close_codex_configuration(&home)?; + app.state::().clear()?; + Ok(result) +} + +fn prepare_native_oauth_switch(home: &Path, enabled: bool) -> Result<(Images, Images), String> { + let directory = codex_configuration_directory(home); + // Persist the recovery record first, then credentials, and activate routing last. + // The shared transaction restores all three if any write or validation fails. + let paths = vec![ + directory.join(CODEX_NATIVE_OAUTH_STATE_FILE), + directory.join("auth.json"), + directory.join("config.toml"), + ]; + let before = config_images(&paths)?; + let mut state = parse_state(before[0].1.as_deref())?; + let auth = text(before[1].1.as_deref())?; + if let Some(auth) = auth { + parse_agent_json_object(Some(auth), "Codex auth.json")?; + } + let mut document = parse_codex_document(text(before[2].1.as_deref())?, "Codex config.toml")?; + let currently_native = native_selected(&document, &state); + if !enabled && !currently_native { + return Ok((before.clone(), before)); + } + let next_auth = if enabled { + if currently_native { + state.native_model = document + .get("model") + .and_then(toml_edit::Item::as_str) + .map(str::to_string); + } else { + state.cpa_settings = routing_settings(&document).to_string(); + state.cpa_auth = auth.map(str::to_string); + } + let next = match oauth_auth(auth)? { + Some(current) => Some(current), + None if !currently_native && auth_uses_api_key(auth)? => { + oauth_auth(state.native_auth.as_deref())? + } + None => None, + }; + for key in ROUTING_KEYS { + document.remove(key); + } + remove_openai_override(&mut document); + document["model_provider"] = toml_edit::value("openai"); + document["forced_login_method"] = toml_edit::value("chatgpt"); + if let Some(model) = state.native_model.as_deref() { + document["model"] = toml_edit::value(model); + } + next + } else { + // Capture refreshed tokens (or a logout) every time, never reuse an older native cache. + state.native_auth = oauth_auth(auth)?; + state.native_model = document + .get("model") + .and_then(toml_edit::Item::as_str) + .map(str::to_string); + let saved = parse_codex_document(Some(&state.cpa_settings), "Codex CPA 接入配置")?; + for key in ROUTING_KEYS { + restore_codex_table_item(document.as_table_mut(), Some(saved.as_table()), key); + } + remove_openai_override(&mut document); + if let Some(openai) = saved + .get("model_providers") + .and_then(toml_edit::Item::as_table_like) + .and_then(|providers| providers.get("openai")) + { + if !document.contains_key("model_providers") { + document["model_providers"] = toml_edit::Item::Table(toml_edit::Table::new()); + } + document["model_providers"] + .as_table_like_mut() + .ok_or("Codex model_providers 格式无效")? + .insert("openai", openai.clone()); + } + if oauth_auth(state.cpa_auth.as_deref())?.is_some() { + // CPA's existing OAuth mode shares the current account and its refreshed tokens. + state.native_auth.clone() + } else { + state.cpa_auth.clone() + } + }; + state.enabled = enabled; + let rendered = document.to_string(); + parse_codex_document(Some(&rendered), "Codex config.toml")?; + let state_bytes = + serde_json::to_vec_pretty(&state).map_err(|_| "保存 Codex 接入方式切换记录失败")?; + let after = vec![ + (paths[0].clone(), Some(state_bytes)), + (paths[1].clone(), next_auth.map(String::into_bytes)), + (paths[2].clone(), Some(rendered.into_bytes())), + ]; + Ok((before, after)) +} + +pub(crate) fn switch_codex_native_oauth( + home: &Path, + enabled: bool, +) -> Result { + let (before, after) = prepare_native_oauth_switch(home, enabled)?; + let paths = before + .iter() + .map(|(path, _)| path.clone()) + .collect::>(); + commit_config("codex", &paths, &before, &after, "native-oauth", None) +} + +// Save credentials and the pre-CPA routing in the same transaction as applying CPA. +// Subsequent model/auth updates retain the original routing until Close is used. +pub(crate) fn apply_codex_cpa_configuration( + home: &Path, + port: u16, + api_key: &str, + model: &str, + options: AgentConfigurationOptions<'_>, +) -> Result { + let (native_before, mut native_after) = prepare_native_oauth_switch(home, false)?; + let mut state = parse_state(native_after[0].1.as_deref())?; + let current_document = + parse_codex_document(text(native_before[2].1.as_deref())?, "Codex config.toml")?; + let auth = text(native_before[1].1.as_deref())?; + if current_document + .get("model_provider") + .and_then(toml_edit::Item::as_str) + != Some(MANAGED_AGENT_PROVIDER_ID) + { + state.restore_full_routing = native_selected( + ¤t_document, + &parse_state(native_before[0].1.as_deref())?, + ); + state.original_settings = Some(routing_settings(¤t_document).to_string()); + state.original_auth = auth.map(str::to_string); + } + if let Some(auth) = oauth_auth(auth)? { + state.native_auth = Some(auth); + } else if !auth_uses_api_key(auth)? { + state.native_auth = None; + } + native_after[0].1 = + Some(serde_json::to_vec_pretty(&state).map_err(|_| "保存 Codex 切换记录失败")?); + let config_paths = config_paths("codex", home)?; + let current = config_images(&config_paths)?; + for image in ¤t { + if native_before + .iter() + .any(|(path, bytes)| path == &image.0 && bytes != &image.1) + { + return Err("配置已被其他程序修改,请刷新后重试".into()); + } + } + let mut restored_config = native_after[2].clone(); + let mut restored_document = + parse_codex_document(text(restored_config.1.as_deref())?, "Codex CPA 配置")?; + if restored_document.contains_key("forced_login_method") { + restored_document["forced_login_method"] = + toml_edit::value(if options.oauth_configuration { + "chatgpt" + } else { + "api" + }); + } + restored_config.1 = Some(restored_document.to_string().into_bytes()); + let mut baseline = current.clone(); + baseline + .iter_mut() + .find(|(path, _)| path == &restored_config.0) + .ok_or("Codex 配置路径不匹配")? + .1 = restored_config.1.clone(); + let catalog = options.codex_catalog.ok_or("无法生成 Codex 模型目录")?; + validate_codex_catalog(catalog, model)?; + let updates = vec![ + AgentFileUpdate { + path: restored_config.0.clone(), + after: build_codex_agent_config_with_oauth( + text(restored_config.1.as_deref())?, + &format!("{}/v1", managed_core_loopback_origin(port)), + api_key, + model, + options.oauth_configuration, + )?, + }, + AgentFileUpdate { + path: codex_model_catalog_path(home), + after: catalog.to_string(), + }, + build_codex_auth_update(home, api_key, options.oauth_configuration)?, + ]; + let updated = prepare_config_updates("codex", &config_paths, &baseline, &updates, false)?; + let mut before = vec![native_before[0].clone()]; + let mut after = vec![native_after[0].clone()]; + // Save refreshed OAuth credentials first and activate the CPA provider last. + for index in [1, 2, 0] { + before.push(current[index].clone()); + after.push(updated[index].clone()); + } + let paths = before + .iter() + .map(|(path, _)| path.clone()) + .collect::>(); + commit_config( + "codex", + &paths, + &before, + &after, + "update", + Some(model.to_string()), + ) +} + +#[tauri::command] +pub(crate) fn restore_codex_official_config( + app: tauri::AppHandle, +) -> Result { + let home = app.path().home_dir().map_err(|_| "无法获取用户目录")?; + let _guard = AGENT_CONFIG_FILE_LOCK + .lock() + .map_err(|_| "智能体配置文件锁已损坏")?; + let result = switch_codex_native_oauth(&home, true)?; + app.state::().clear()?; + Ok(result) +} diff --git a/src-tauri/src/agents/native_oauth/tests.rs b/src-tauri/src/agents/native_oauth/tests.rs new file mode 100644 index 0000000..7c10bb0 --- /dev/null +++ b/src-tauri/src/agents/native_oauth/tests.rs @@ -0,0 +1,555 @@ +use super::*; + +struct TestHome(PathBuf); +impl TestHome { + fn new() -> Self { + let path = std::env::temp_dir().join(format!( + "cpa-native-oauth-{}-{}", + std::process::id(), + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + fs::create_dir_all(path.join(".codex")).unwrap(); + Self(path) + } + fn path(&self, name: &str) -> PathBuf { + self.0.join(".codex").join(name) + } + fn write(&self, name: &str, content: &str) { + fs::write(self.path(name), content).unwrap(); + } + fn read(&self, name: &str) -> String { + fs::read_to_string(self.path(name)).unwrap() + } + fn config(&self) -> toml::Value { + toml::from_str(&self.read("config.toml")).unwrap() + } +} +impl Drop for TestHome { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.0); + } +} + +const CPA_CONFIG: &str = r#"model_provider = "cpa-gui" +model = "cpa-alias" +model_catalog_json = "cpa-gui-model-catalog.json" +openai_base_url = "http://localhost:8317/v1" +chatgpt_base_url = "http://localhost:8317/chatgpt" +forced_login_method = "api" +profile = "legacy-cpa" +# keep this comment +approval_policy = "on-request" +[mcp_servers.example] +command = "example" +[model_providers.cpa-gui] +name = "EasyCLIProxyAPI" +base_url = "http://127.0.0.1:8317/v1" +wire_api = "responses" +experimental_bearer_token = "test-api-key" +[model_providers.openai] +base_url = "http://localhost:8317/v1" +"#; +const OAUTH: &str = r#"{"auth_mode":"chatgpt","tokens":{"access_token":"test-token","refresh_token":"test-refresh"},"last_refresh":"today","custom":"keep"}"#; + +#[test] +fn native_oauth_round_trip_preserves_cpa_and_unrelated_user_edits() { + let home = TestHome::new(); + home.write("config.toml", CPA_CONFIG); + let api = build_codex_api_auth("test-api-key").unwrap(); + home.write("auth.json", &api); + home.write(CODEX_MODEL_CATALOG_FILE, "existing catalog"); + let original = home.config(); + switch_codex_native_oauth(&home.0, true).unwrap(); + assert!(codex_native_oauth_enabled(&home.0).unwrap()); + let native = home.config(); + assert_eq!(native["model_provider"].as_str(), Some("openai")); + assert_eq!(native["forced_login_method"].as_str(), Some("chatgpt")); + for key in [ + "model", + "model_catalog_json", + "openai_base_url", + "chatgpt_base_url", + "profile", + ] { + assert!(native.get(key).is_none(), "{key}"); + } + assert!(native["model_providers"].get("openai").is_none()); + assert_eq!( + native["model_providers"]["cpa-gui"], + original["model_providers"]["cpa-gui"] + ); + assert!(!home.path("auth.json").exists()); + assert!(ensure_codex_cpa_mode(&home.0).is_err()); + assert!( + !sync_codex_model_catalog_if_configured(&home.0, 8317, "test-api-key", &[], "unused") + .unwrap() + ); + home.write("auth.json", OAUTH); + home.write( + "config.toml", + &home.read("config.toml").replace("on-request", "never"), + ); + switch_codex_native_oauth(&home.0, false).unwrap(); + assert!(!codex_native_oauth_enabled(&home.0).unwrap()); + let restored = home.config(); + for key in ROUTING_KEYS { + assert_eq!(restored.get(*key), original.get(*key), "{key}"); + } + assert_eq!(restored["model_providers"], original["model_providers"]); + assert_eq!(restored["approval_policy"].as_str(), Some("never")); + assert!(home.read("config.toml").contains("# keep this comment")); + assert_eq!(home.read("auth.json"), api); + assert_eq!(home.read(CODEX_MODEL_CATALOG_FILE), "existing catalog"); + switch_codex_native_oauth(&home.0, true).unwrap(); + assert_eq!(home.read("auth.json"), OAUTH); +} + +#[test] +fn native_oauth_retains_refreshed_tokens_and_native_model_across_switches() { + let home = TestHome::new(); + home.write("config.toml", CPA_CONFIG); + home.write("auth.json", OAUTH); + switch_codex_native_oauth(&home.0, true).unwrap(); + assert_eq!(home.read("auth.json"), OAUTH); + let refreshed = OAUTH.replace("test-refresh", "rotated-refresh"); + home.write("auth.json", &refreshed); + home.write( + "config.toml", + &("model = \"native-model\"\n".to_string() + &home.read("config.toml")), + ); + switch_codex_native_oauth(&home.0, false).unwrap(); + assert_eq!(home.read("auth.json"), refreshed); + assert_eq!(home.config()["model"].as_str(), Some("cpa-alias")); + switch_codex_native_oauth(&home.0, true).unwrap(); + assert_eq!(home.config()["model"].as_str(), Some("native-model")); + assert_eq!(home.read("auth.json"), refreshed); + fs::remove_file(home.path("auth.json")).unwrap(); + switch_codex_native_oauth(&home.0, false).unwrap(); + switch_codex_native_oauth(&home.0, true).unwrap(); + assert!( + !home.path("auth.json").exists(), + "Never resurrect credentials after the user signs out" + ); +} + +#[test] +fn native_oauth_can_be_enabled_before_first_login_and_is_idempotent() { + let home = TestHome::new(); + assert_eq!( + switch_codex_native_oauth(&home.0, false).unwrap().outcome, + "unchanged" + ); + switch_codex_native_oauth(&home.0, true).unwrap(); + assert!(!home.path("auth.json").exists()); + let paths = [ + home.path("config.toml"), + home.path(CODEX_NATIVE_OAUTH_STATE_FILE), + ]; + let before = config_images(&paths).unwrap(); + assert_eq!( + switch_codex_native_oauth(&home.0, true).unwrap().outcome, + "unchanged" + ); + assert_eq!(config_images(&paths).unwrap(), before); + switch_codex_native_oauth(&home.0, false).unwrap(); + assert!(home.config().as_table().unwrap().is_empty()); +} + +#[test] +fn native_oauth_rejects_malformed_files_without_partial_updates() { + for name in ["config.toml", "auth.json", CODEX_NATIVE_OAUTH_STATE_FILE] { + let home = TestHome::new(); + home.write("config.toml", CPA_CONFIG); + home.write("auth.json", OAUTH); + home.write(name, "malformed ["); + let paths = [ + home.path("config.toml"), + home.path("auth.json"), + home.path(CODEX_NATIVE_OAUTH_STATE_FILE), + ]; + let before = config_images(&paths).unwrap(); + assert!(switch_codex_native_oauth(&home.0, true).is_err()); + assert_eq!(config_images(&paths).unwrap(), before); + } +} + +#[test] +fn native_oauth_does_not_select_api_key_when_tokens_also_exist() { + let auth = OAUTH.replace( + "\"auth_mode\":\"chatgpt\"", + "\"auth_mode\":\"apikey\",\"OPENAI_API_KEY\":\"old-key\"", + ); + let normalized: serde_json::Value = + serde_json::from_str(&oauth_auth(Some(&auth)).unwrap().unwrap()).unwrap(); + assert_eq!(normalized["auth_mode"], "chatgpt"); + assert!(normalized.get("OPENAI_API_KEY").is_none()); + assert_eq!(normalized["tokens"]["refresh_token"], "test-refresh"); +} + +#[test] +fn clearing_codex_removes_native_oauth_recovery_credentials() { + let home = TestHome::new(); + home.write("config.toml", CPA_CONFIG); + home.write("auth.json", OAUTH); + switch_codex_native_oauth(&home.0, true).unwrap(); + clear_codex_config_files(&home.0).unwrap(); + assert!(!home.path(CODEX_NATIVE_OAUTH_STATE_FILE).exists()); + assert!(!home.path("auth.json").exists()); + assert!(!codex_native_oauth_enabled(&home.0).unwrap()); +} + +#[cfg(unix)] +#[test] +fn native_oauth_credential_cache_is_owner_only() { + use std::os::unix::fs::PermissionsExt; + let home = TestHome::new(); + home.write("config.toml", CPA_CONFIG); + home.write("auth.json", OAUTH); + switch_codex_native_oauth(&home.0, true).unwrap(); + assert_eq!( + fs::metadata(home.path(CODEX_NATIVE_OAUTH_STATE_FILE)) + .unwrap() + .permissions() + .mode() + & 0o777, + 0o600 + ); +} + +#[test] +fn native_oauth_handles_inline_provider_tables_without_losing_other_providers() { + let home = TestHome::new(); + home.write("config.toml", "model_provider = \"cpa-gui\"\nmodel_providers = { openai = { base_url = \"http://localhost:8317/v1\" }, other = { base_url = \"http://localhost:9999\" } }\n"); + let original = home.config(); + switch_codex_native_oauth(&home.0, true).unwrap(); + assert!(home.config()["model_providers"].get("openai").is_none()); + assert_eq!( + home.config()["model_providers"]["other"], + original["model_providers"]["other"] + ); + switch_codex_native_oauth(&home.0, false).unwrap(); + assert_eq!(home.config(), original); +} + +#[test] +fn restore_official_configuration_always_removes_the_cpa_catalog_setting() { + let home = TestHome::new(); + home.write("config.toml", CPA_CONFIG); + home.write("auth.json", OAUTH); + switch_codex_native_oauth(&home.0, true).unwrap(); + assert!(home.config().get("model_catalog_json").is_none()); + home.write( + "config.toml", + &format!( + "model_catalog_json = \"{CODEX_MODEL_CATALOG_FILE}\"\nmodel = \"native-choice\"\n{}", + home.read("config.toml") + ), + ); + switch_codex_native_oauth(&home.0, true).unwrap(); + assert!(home.config().get("model_catalog_json").is_none()); + assert_eq!(home.config()["model"].as_str(), Some("native-choice")); + switch_codex_native_oauth(&home.0, false).unwrap(); + assert_eq!(home.config()["model"].as_str(), Some("cpa-alias")); +} + +#[test] +fn update_configuration_reconnects_cpa_and_restores_catalog_after_official_restore() { + for oauth_configuration in [false, true] { + let home = TestHome::new(); + home.write("config.toml", CPA_CONFIG); + home.write("auth.json", OAUTH); + switch_codex_native_oauth(&home.0, true).unwrap(); + let refreshed = OAUTH.replace("test-refresh", "latest-refresh"); + home.write("auth.json", &refreshed); + let catalog = r#"{"models":[{"slug":"new-cpa-model"}]}"#; + let result = apply_agent_configuration_with_oauth( + AgentClient::Codex, + &home.0, + 9321, + "new-cpa-key", + "new-cpa-model", + AgentConfigurationOptions { + models: &[], + codex_catalog: Some(catalog), + oauth_configuration, + claude_code_model_mappings: None, + claude_desktop_model_mappings: None, + }, + ) + .unwrap(); + assert_eq!(result.outcome, "updated"); + assert!(!codex_native_oauth_enabled(&home.0).unwrap()); + let config = home.config(); + assert_eq!(config["model_provider"].as_str(), Some("cpa-gui")); + assert_eq!( + config["model_catalog_json"].as_str(), + Some(CODEX_MODEL_CATALOG_FILE) + ); + assert_eq!(config["model"].as_str(), Some("new-cpa-model")); + assert_eq!( + config["model_providers"]["cpa-gui"]["base_url"].as_str(), + Some("http://127.0.0.1:9321/v1") + ); + assert_eq!( + config["model_providers"]["cpa-gui"]["experimental_bearer_token"].as_str(), + Some("new-cpa-key") + ); + assert_eq!( + config["forced_login_method"].as_str(), + Some(if oauth_configuration { + "chatgpt" + } else { + "api" + }) + ); + validate_codex_catalog_file(&home.path("config.toml"), "new-cpa-model").unwrap(); + if oauth_configuration { + assert_eq!(home.read("auth.json"), refreshed); + } else { + assert!(codex_auth_file_has_api_key( + &home.path("auth.json"), + "new-cpa-key" + )); + } + switch_codex_native_oauth(&home.0, true).unwrap(); + assert_eq!(home.read("auth.json"), refreshed); + assert!(home.config().get("model_catalog_json").is_none()); + } +} + +fn apply_cpa( + home: &TestHome, + oauth_configuration: bool, +) -> Result { + apply_agent_configuration_with_oauth( + AgentClient::Codex, + &home.0, + 8317, + "test-api-key", + "cpa-alias", + AgentConfigurationOptions { + models: &[], + codex_catalog: Some(r#"{"models":[{"slug":"cpa-alias"}]}"#), + oauth_configuration, + claude_code_model_mappings: None, + claude_desktop_model_mappings: None, + }, + ) +} + +#[test] +fn codex_apply_close_restores_original_login_and_preserves_user_edits() { + let home = TestHome::new(); + home.write("config.toml", "model = \"official-model\"\nforced_login_method = \"chatgpt\"\napproval_policy = \"on-request\"\n[mcp_servers.example]\ncommand = \"example\"\n"); + home.write("auth.json", OAUTH); + let original = home.config(); + apply_cpa(&home, false).unwrap(); + assert_eq!(home.config()["model_provider"].as_str(), Some("cpa-gui")); + assert_eq!(home.config()["forced_login_method"].as_str(), Some("api")); + assert!(codex_auth_file_has_api_key( + &home.path("auth.json"), + "test-api-key" + )); + assert!( + validate_codex_oauth_login(&home.0).is_ok(), + "Saved login remains selectable after API mode" + ); + home.write( + "config.toml", + &format!( + "openai_base_url = \"https://example.test/edited\"\n{}", + home.read("config.toml").replace("on-request", "never") + ), + ); + let mut api: serde_json::Value = serde_json::from_str(&home.read("auth.json")).unwrap(); + api["custom"] = serde_json::json!("edited"); + home.write("auth.json", &api.to_string()); + apply_cpa(&home, true).unwrap(); + let refreshed = home + .read("auth.json") + .replace("test-refresh", "new-refresh"); + home.write("auth.json", &refreshed); + apply_cpa(&home, false).unwrap(); + close_codex_configuration(&home.0).unwrap(); + let restored = home.config(); + assert_eq!(restored["model"], original["model"]); + assert_eq!( + restored["forced_login_method"], + original["forced_login_method"] + ); + assert_eq!(restored["approval_policy"].as_str(), Some("never")); + assert_eq!(restored["mcp_servers"], original["mcp_servers"]); + assert_eq!( + restored["openai_base_url"].as_str(), + Some("https://example.test/edited") + ); + assert!(restored.get("model_catalog_json").is_none()); + assert!(restored.get("model_providers").is_none()); + let auth: serde_json::Value = serde_json::from_str(&home.read("auth.json")).unwrap(); + assert_eq!(auth["tokens"]["refresh_token"], "new-refresh"); + assert_eq!(auth["custom"], "edited"); + assert!(auth.get("OPENAI_API_KEY").is_none()); + assert_eq!( + close_codex_configuration(&home.0).unwrap().outcome, + "unchanged" + ); + apply_cpa(&home, false).unwrap(); + switch_codex_native_oauth(&home.0, true).unwrap(); + assert!( + home.read("auth.json").contains("new-refresh"), + "Official restore also uses the saved login" + ); +} + +#[test] +fn codex_close_existing_cpa_without_recovery_record_removes_managed_fields() { + let home = TestHome::new(); + home.write( + "config.toml", + &build_codex_agent_config_with_oauth( + Some("approval_policy = \"never\"\n"), + "http://127.0.0.1:8317/v1", + "test-api-key", + "cpa-alias", + false, + ) + .unwrap(), + ); + home.write("auth.json", &build_codex_api_auth("test-api-key").unwrap()); + close_codex_configuration(&home.0).unwrap(); + assert_eq!(home.config()["approval_policy"].as_str(), Some("never")); + assert!(home.config().get("model_provider").is_none()); + assert!(home.config().get("model_catalog_json").is_none()); + assert!(home.config().get("model_providers").is_none()); + assert!(!home.path("auth.json").exists()); +} + +#[test] +fn codex_close_restores_preexisting_api_provider_and_key() { + let home = TestHome::new(); + home.write("config.toml", "model_provider = \"other\"\nmodel = \"other-model\"\n[model_providers.other]\nbase_url = \"https://example.test/v1\"\n"); + home.write("auth.json", &build_codex_api_auth("original-key").unwrap()); + let original = home.config(); + apply_cpa(&home, false).unwrap(); + close_codex_configuration(&home.0).unwrap(); + assert_eq!(home.config(), original); + assert!(codex_auth_file_has_api_key( + &home.path("auth.json"), + "original-key" + )); +} + +#[test] +fn codex_close_does_not_resurrect_an_explicit_logout() { + let home = TestHome::new(); + home.write("auth.json", OAUTH); + apply_cpa(&home, true).unwrap(); + fs::remove_file(home.path("auth.json")).unwrap(); + assert!(validate_codex_oauth_login(&home.0).is_err()); + apply_cpa(&home, false).unwrap(); + assert!(validate_codex_oauth_login(&home.0).is_err()); + close_codex_configuration(&home.0).unwrap(); + assert!(!home.path("auth.json").exists()); +} + +#[test] +fn codex_official_restore_does_not_reuse_credentials_after_logout() { + let home = TestHome::new(); + home.write("auth.json", OAUTH); + apply_cpa(&home, true).unwrap(); + fs::remove_file(home.path("auth.json")).unwrap(); + switch_codex_native_oauth(&home.0, true).unwrap(); + assert!(!home.path("auth.json").exists()); +} + +#[test] +fn codex_close_after_official_reconnect_restores_official_routing() { + let home = TestHome::new(); + home.write("config.toml", CPA_CONFIG); + home.write("auth.json", OAUTH); + switch_codex_native_oauth(&home.0, true).unwrap(); + apply_cpa(&home, false).unwrap(); + close_codex_configuration(&home.0).unwrap(); + assert_eq!(home.config()["model_provider"].as_str(), Some("openai")); + assert_eq!( + home.config()["forced_login_method"].as_str(), + Some("chatgpt") + ); + for key in [ + "model_catalog_json", + "openai_base_url", + "chatgpt_base_url", + "profile", + ] { + assert!(home.config().get(key).is_none(), "{key}"); + } + assert_eq!(home.read("auth.json"), OAUTH); + assert!(!codex_native_oauth_enabled(&home.0).unwrap()); +} + +#[test] +fn codex_close_rejects_invalid_state_without_changing_any_files() { + for name in ["config.toml", "auth.json", CODEX_NATIVE_OAUTH_STATE_FILE] { + let home = TestHome::new(); + home.write("auth.json", OAUTH); + apply_cpa(&home, false).unwrap(); + home.write(name, "invalid ["); + let paths = vec![ + home.path("config.toml"), + home.path("auth.json"), + home.path(CODEX_NATIVE_OAUTH_STATE_FILE), + ]; + let before = config_images(&paths).unwrap(); + assert!(close_codex_configuration(&home.0).is_err()); + assert_eq!(config_images(&paths).unwrap(), before); + } +} + +#[test] +fn codex_failed_api_apply_preserves_login_and_recovery_record() { + let home = TestHome::new(); + home.write("config.toml", "model = \"official-model\"\n"); + home.write("auth.json", OAUTH); + home.write(CODEX_MODEL_CATALOG_FILE, "malformed catalog"); + let paths = vec![ + home.path("config.toml"), + home.path("auth.json"), + home.path(CODEX_MODEL_CATALOG_FILE), + home.path(CODEX_NATIVE_OAUTH_STATE_FILE), + ]; + let before = config_images(&paths).unwrap(); + assert!(apply_cpa(&home, false).is_err()); + assert_eq!(config_images(&paths).unwrap(), before); +} + +#[test] +fn failed_cpa_update_preserves_official_routing_and_login() { + let home = TestHome::new(); + home.write("config.toml", CPA_CONFIG); + home.write("auth.json", OAUTH); + switch_codex_native_oauth(&home.0, true).unwrap(); + let mut paths = config_paths("codex", &home.0).unwrap(); + paths.push(home.path(CODEX_NATIVE_OAUTH_STATE_FILE)); + let before = config_images(&paths).unwrap(); + let result = apply_agent_configuration_with_oauth( + AgentClient::Codex, + &home.0, + 8317, + "test-api-key", + "unavailable-model", + AgentConfigurationOptions { + models: &[], + codex_catalog: Some(r#"{"models":[{"slug":"other-model"}]}"#), + oauth_configuration: false, + claude_code_model_mappings: None, + claude_desktop_model_mappings: None, + }, + ); + assert!(result.is_err()); + assert_eq!(config_images(&paths).unwrap(), before); + assert!(codex_native_oauth_enabled(&home.0).unwrap()); + assert!(home.config().get("model_catalog_json").is_none()); +} diff --git a/src-tauri/src/agents/state.rs b/src-tauri/src/agents/state.rs index 90f1841..26f18f2 100644 --- a/src-tauri/src/agents/state.rs +++ b/src-tauri/src/agents/state.rs @@ -1351,6 +1351,7 @@ pub(crate) fn sync_codex_model_catalog_if_configured( let _guard = AGENT_CONFIG_FILE_LOCK .lock() .map_err(|_| "智能体配置文件锁已损坏".to_string())?; + if codex_native_oauth_enabled(home)? { return Ok(false); } let before = config_images(&config_paths(client.id(), home)?)?; validate_config_images(&before)?; let (configured, current_model, _) = @@ -1447,6 +1448,9 @@ pub(crate) fn apply_agent_configuration_with_oauth( model: &str, options: AgentConfigurationOptions<'_>, ) -> Result { + if client == AgentClient::Codex { + return apply_codex_cpa_configuration(home, port, api_key, model, options); + } if client == AgentClient::DeepSeekHarness { return apply_deepseek_harness_configuration(home, port, api_key, model, options.models); } diff --git a/src-tauri/src/agents/templates.rs b/src-tauri/src/agents/templates.rs index e14ec0f..803eac3 100644 --- a/src-tauri/src/agents/templates.rs +++ b/src-tauri/src/agents/templates.rs @@ -122,6 +122,7 @@ async fn prepare_template_plan( claude_code_model_mappings: Option, claude_desktop_model_mappings: Option, ) -> Result { + if client == "codex" { ensure_codex_cpa_mode(home)?; } let paths = config_paths(client, home)?; let api_key = effective_agent_api_key(config); let (model, mappings, before, after) = if client == PI_AGENT_ID { diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index 2cc2dd2..19e835b 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -1042,6 +1042,7 @@ struct AgentConfigStatus { connection_state: String, current_model: Option, oauth_configuration: bool, + codex_native_oauth: bool, modification_enabled: bool, modification_state: String, backup_available: bool, @@ -2534,6 +2535,8 @@ fn main() { delete_speed_alias, apply_agent_config, clear_codex_config, + restore_codex_official_config, + close_codex_config_modification, set_agent_config_enabled, update_agent_config, launch_agent, diff --git a/src/i18n/ja.ts b/src/i18n/ja.ts index 958d0f2..40ac241 100644 --- a/src/i18n/ja.ts +++ b/src/i18n/ja.ts @@ -1059,7 +1059,12 @@ export const jaOverrides = { "agents.modify.connectionUnknown": "現在の接続設定:判定できません。設定を確認してください", "agents.modify.description": "選択したモデルと認証方式を書き込み、無関係な設定を保持します。", "agents.modify.authApiKeyHint": "CPA の API を使用して接続し、より安定した接続状態を維持します。", - "agents.modify.authOAuthHint": "CPA 経由で接続し、アカウントのログイン状態を保持して Codex の全サービスを利用できます。事前に Codex でアカウントにログインしてください。", + "agents.nativeOAuth.status": "CPA 未接続", + "agents.nativeOAuth.restore": "接続設定を解除", + "agents.nativeOAuth.restoreHint": "CPA の接続設定を解除し、Codex で ChatGPT にログインできるようにします。Codex を再起動してログインした後、基本設定で OAuth を選び、設定を更新してください。", + "agents.nativeOAuth.activeHint": "現在 CPA に接続していません。ログイン後に OAuth を選んで設定を更新すると、CPA に再接続できます。", + "agents.nativeOAuth.restored": "CPA の接続設定を解除しました。Codex を再起動して ChatGPT にログインし、基本設定で OAuth を選んで設定を更新してください。", + "agents.modify.authOAuthHint": "アカウントのログイン状態を保持して CPA 経由で接続します。未ログインの状態で API から切り替える場合は、設定管理で「接続設定を解除」をクリックし、Codex を再起動して ChatGPT にログインしてから選択してください。", "agents.modify.authHelpLabel": "説明", "agents.modify.authHelpShow": "選び方", "agents.modify.authHelpHide": "説明を閉じる", @@ -1097,16 +1102,19 @@ export const jaOverrides = { 'agents.claudeMapping.context1m': '1M', 'agents.claudeMapping.context1mHint': '1M コンテキストとして設定します。選択した上流モデルが対応していることを確認してください。', 'agents.oauthLoginRequired.title': 'Codex へのログインが必要です', - 'agents.oauthLoginRequired.enableDescription': '有効な Codex のログイン認証情報が見つからないため、OAuth 設定方式を有効にできません。Codex アプリで ChatGPT アカウントにログインしてください。Codex アプリでログインを開始できない場合は、先に', - 'agents.oauthLoginRequired.enableManagementConnector': 'の', - 'agents.oauthLoginRequired.enableClearConfiguration': '設定を消去', - 'agents.oauthLoginRequired.enableDescriptionSuffix': 'を使用してください。', - 'agents.oauthLoginRequired.applyDescription': '有効な Codex のログイン認証情報が見つからないため、OAuth 設定を適用できません。Codex で ChatGPT アカウントにログインしてから、設定を適用してください。', + "agents.oauthLoginRequired.enableDescription": "利用可能な Codex のログイン情報が見つかりません。API から OAuth に切り替えるには、", + "agents.oauthLoginRequired.enableManagementConnector": "で", + "agents.oauthLoginRequired.enableClearConfiguration": "接続設定を解除", + "agents.oauthLoginRequired.enableDescriptionSuffix": "をクリックしてください。Codex を再起動して ChatGPT にログインした後、基本設定で OAuth を選んで設定を更新してください。", + "agents.oauthLoginRequired.applyDescription": "利用可能な Codex のログイン情報が見つかりません。設定管理で「接続設定を解除」をクリックし、Codex を再起動して ChatGPT にログインした後、基本設定で OAuth を選んで設定を更新してください。", 'agents.oauthLoginRequired.launchDescription': '有効なログイン認証情報が見つからないため Codex を起動できません。先に Codex アプリで ChatGPT アカウントにログインしてください。', + "agents.oauthLoginRequired.openManagement": "設定管理を開く", 'agents.oauthLoginRequired.confirm': '了解しました', 'agents.modify.apply': '設定変更を適用', 'agents.modify.update': '設定を更新', 'agents.modify.close': '設定変更を終了', + 'agents.modify.closeHint': 'CPA 設定を解除し、保存されていれば接続前の設定とログイン情報を復元します。', + 'agents.modify.closed': 'CPA 設定を解除しました。Codex を再起動し、必要に応じてログインしてください。', "agents.modify.default": "ezcpa テンプレートを適用", 'agents.modify.clear': '設定を消去', "agents.modify.invalid": "通常の更新は停止しています。設定ファイルを修正するか、「設定管理」の「ezcpa テンプレートを適用」で接続設定を再生成してください。", diff --git a/src/i18n/locales/en.ts b/src/i18n/locales/en.ts index e59b61a..e033c92 100644 --- a/src/i18n/locales/en.ts +++ b/src/i18n/locales/en.ts @@ -1077,7 +1077,12 @@ export const en: Record = { "agents.modify.connectionUnknown": "Current integration: unknown; check configuration", "agents.modify.description": "Write the selected model and authentication settings while preserving unrelated settings.", "agents.modify.authApiKeyHint": "Connect using the CPA API for a more stable connection.", - "agents.modify.authOAuthHint": "Connect through CPA while retaining your account sign-in state and access to the full Codex service. Sign in to your account in Codex first.", + "agents.nativeOAuth.status": "CPA disconnected", + "agents.nativeOAuth.restore": "Clear integration", + "agents.nativeOAuth.restoreHint": "Clear the CPA integration so Codex can use ChatGPT sign-in. Restart Codex and sign in, then return to Basic configuration, select OAuth, and update the configuration.", + "agents.nativeOAuth.activeHint": "CPA is disconnected. After signing in, select OAuth and update the configuration to reconnect through CPA.", + "agents.nativeOAuth.restored": "CPA integration cleared. Restart Codex and sign in to ChatGPT, then return to Basic configuration, select OAuth, and update the configuration.", + "agents.modify.authOAuthHint": "Connect through CPA while retaining your account sign-in. When switching from API mode without sign-in credentials, use Clear integration in Configuration management, restart Codex, and sign in to ChatGPT before selecting this mode.", "agents.modify.authHelpLabel": "Details", "agents.modify.authHelpShow": "How to choose", "agents.modify.authHelpHide": "Hide details", @@ -1097,16 +1102,19 @@ export const en: Record = { "agents.modify.title": "Write {name} Configuration", 'agents.modify.oauthConfiguration': 'OAuth sign-in configuration mode', 'agents.oauthLoginRequired.title': 'Codex sign-in required', - 'agents.oauthLoginRequired.enableDescription': 'No valid Codex sign-in credentials were found, so OAuth configuration mode cannot be enabled. Sign in to your ChatGPT account in the Codex app. If the Codex app cannot trigger sign-in, first open ', - 'agents.oauthLoginRequired.enableManagementConnector': ', then use ', - 'agents.oauthLoginRequired.enableClearConfiguration': 'Clear Configuration', - 'agents.oauthLoginRequired.enableDescriptionSuffix': '.', - 'agents.oauthLoginRequired.applyDescription': 'OAuth configuration cannot be applied because no valid Codex sign-in credentials were found. Sign in to your ChatGPT account in Codex, then apply the configuration.', + "agents.oauthLoginRequired.enableDescription": "No usable Codex sign-in credentials were found. To switch from API mode to OAuth, open ", + "agents.oauthLoginRequired.enableManagementConnector": " and click ", + "agents.oauthLoginRequired.enableClearConfiguration": "Clear integration", + "agents.oauthLoginRequired.enableDescriptionSuffix": ". Restart Codex and sign in to ChatGPT, then return to Basic configuration, select OAuth, and update the configuration.", + "agents.oauthLoginRequired.applyDescription": "No usable Codex sign-in credentials were found. Open Configuration management and click Clear integration. Restart Codex and sign in to ChatGPT, then return to Basic configuration, select OAuth, and update the configuration.", 'agents.oauthLoginRequired.launchDescription': 'Codex cannot be started because no valid sign-in credentials were found. Sign in to your ChatGPT account in the Codex app first.', + "agents.oauthLoginRequired.openManagement": "Open configuration management", 'agents.oauthLoginRequired.confirm': 'Got it', 'agents.modify.apply': 'Apply Configuration Changes', 'agents.modify.update': 'Update Configuration', - 'agents.modify.close': 'Close Configuration Changes', + 'agents.modify.close': 'Close configuration', + 'agents.modify.closeHint': 'Remove CPA configuration and restore the previous settings and sign-in credentials when available.', + 'agents.modify.closed': 'CPA configuration disabled. Restart Codex and sign in if prompted.', "agents.modify.default": "Apply ezcpa template", 'agents.modify.clear': 'Clear Configuration', "agents.modify.invalid": "Normal updates are stopped. Fix the configuration file, or use Apply ezcpa template in Configuration management to regenerate the connection configuration.", diff --git a/src/i18n/locales/zh-CN.ts b/src/i18n/locales/zh-CN.ts index 8235924..384349f 100644 --- a/src/i18n/locales/zh-CN.ts +++ b/src/i18n/locales/zh-CN.ts @@ -1076,7 +1076,12 @@ export const zhCN = { "agents.modify.connectionUnknown": "当前接入:无法判断,请检查配置", "agents.modify.description": "将所选模型和认证方式写入本机配置,保留无关设置。", "agents.modify.authApiKeyHint": "使用 CPA 的API接入,状态更稳定。", - "agents.modify.authOAuthHint": "仍通过 CPA 接入,保留账号登录状态,能使用完整的Codex服务,需在Codex中提前登录账号。", + "agents.nativeOAuth.status": "未接入 CPA", + "agents.nativeOAuth.restore": "清除接入", + "agents.nativeOAuth.restoreHint": "清除 CPA 接入,让 Codex 使用 ChatGPT 登录。请重启 Codex 并登录账号,再回到基础配置选择 OAuth 登录方式并更新配置。", + "agents.nativeOAuth.activeHint": "当前未接入 CPA;登录后选择 OAuth 登录方式,更新配置将重新接入 CPA。", + "agents.nativeOAuth.restored": "已清除 CPA 接入。请重启 Codex 并登录 ChatGPT,再回到基础配置选择 OAuth 登录方式并更新配置。", + "agents.modify.authOAuthHint": "通过 CPA 接入并保留账号登录状态。从 API 切换且未登录时,先在配置管理中点击“清除接入”,重启 Codex 并登录 ChatGPT,再选择此方式。", "agents.modify.authHelpLabel": "说明", "agents.modify.authHelpShow": "如何选择", "agents.modify.authHelpHide": "收起说明", @@ -1096,16 +1101,19 @@ export const zhCN = { "agents.modify.title": "写入 {name} 配置", 'agents.modify.oauthConfiguration': 'OAuth登录配置方式', 'agents.oauthLoginRequired.title': '需要登录 Codex', - 'agents.oauthLoginRequired.enableDescription': '未检测到有效的 Codex 登录凭据,无法开启 OAuth 配置模式。请先在 Codex 应用中登录 ChatGPT 账号,如果 Codex 应用无法触发登录,请先在', - 'agents.oauthLoginRequired.enableManagementConnector': '中', - 'agents.oauthLoginRequired.enableClearConfiguration': '清空配置', - 'agents.oauthLoginRequired.enableDescriptionSuffix': '。', - 'agents.oauthLoginRequired.applyDescription': '无法应用 OAuth 配置:未检测到有效的 Codex 登录凭据。请先在 Codex 中登录 ChatGPT 账号,然后再应用配置。', + "agents.oauthLoginRequired.enableDescription": "未检测到可用的 Codex 登录凭据。从 API 切换到 OAuth 登录方式时,请先在", + "agents.oauthLoginRequired.enableManagementConnector": "中点击", + "agents.oauthLoginRequired.enableClearConfiguration": "清除接入", + "agents.oauthLoginRequired.enableDescriptionSuffix": ",然后重启 Codex 并登录 ChatGPT。登录完成后,返回基础配置选择 OAuth 登录方式并更新配置。", + "agents.oauthLoginRequired.applyDescription": "未检测到可用的 Codex 登录凭据。请前往“配置管理”点击“清除接入”,重启 Codex 并登录 ChatGPT,再返回基础配置选择 OAuth 登录方式并更新配置。", 'agents.oauthLoginRequired.launchDescription': '无法启动 Codex:未检测到有效的登录凭据。请先在 Codex 应用中登录 ChatGPT 账号。', + "agents.oauthLoginRequired.openManagement": "前往配置管理", 'agents.oauthLoginRequired.confirm': '我知道了', 'agents.modify.apply': '应用配置修改', 'agents.modify.update': '更新配置', 'agents.modify.close': '关闭配置修改', + 'agents.modify.closeHint': '撤回 CPA 配置,优先恢复接入前的设置和登录凭据。', + 'agents.modify.closed': '已关闭 CPA 配置,请重启 Codex;如有提示,请登录账号。', "agents.modify.default": "应用 ezcpa 模板", 'agents.modify.clear': '清空配置', "agents.modify.invalid": "普通更新已停止。请修复配置文件,或在“配置管理”中通过“应用 ezcpa 模板”重新生成接入配置。", diff --git a/src/navigation.ts b/src/navigation.ts index d7ffeda..750a588 100644 --- a/src/navigation.ts +++ b/src/navigation.ts @@ -1,4 +1,4 @@ -const alwaysAvailablePages = new Set(['easy', 'home', 'versions', 'config', 'usage-records']); +const alwaysAvailablePages = new Set(['easy', 'home', 'versions', 'config', 'usage-records', 'agents']); export function isAlwaysAvailablePage(pageId: string) { return alwaysAvailablePages.has(pageId); diff --git a/src/pages/AgentControls.tsx b/src/pages/AgentControls.tsx index a5034a0..4a8b778 100644 --- a/src/pages/AgentControls.tsx +++ b/src/pages/AgentControls.tsx @@ -76,12 +76,13 @@ export function AgentConfigurationFeedback({ pending, description, status = '' } export function AgentConfigManagementPanel({ pi, codex, busyAction, canTemplate, canUpdatePi, canUninstallPi, pluginInstalled, pluginVersion, updateLabel, - onBackup, onRestore, onTemplate, onClear, onUpdatePi, onUninstallPi, + onBackup, onRestore, onTemplate, onClear, onUpdatePi, onUninstallPi, canClearIntegration, onClearIntegration, }: { pi: boolean; codex: boolean; busyAction: string | null; canTemplate: boolean; canUpdatePi: boolean; canUninstallPi: boolean; pluginInstalled: boolean; pluginVersion: string | null; updateLabel: string; onBackup: () => void; onRestore: () => void; onTemplate: () => void; onClear: () => void; onUpdatePi: () => void; onUninstallPi: () => void; + canClearIntegration: boolean; onClearIntegration: () => void; }) { const { t } = useI18n(); const busy = busyAction !== null; @@ -101,6 +102,16 @@ export function AgentConfigManagementPanel({ : null} + {codex ?
+

{t('agents.nativeOAuth.restore')}

{t('agents.nativeOAuth.restoreHint')}

+
+ +
+
: null} {codex ?

{t('agents.modify.clear')}

{t('agents.management.clearDescription')}

diff --git a/src/pages/AgentsPage.tsx b/src/pages/AgentsPage.tsx index 2cc1a74..4d672cb 100644 --- a/src/pages/AgentsPage.tsx +++ b/src/pages/AgentsPage.tsx @@ -102,6 +102,7 @@ type AgentConfigStatus = { configurationSynchronized: boolean; currentModel: string | null; oauthConfiguration: boolean; + codexNativeOauth: boolean; modificationEnabled: boolean; modificationState: AgentModificationState; backupAvailable: boolean; @@ -435,6 +436,7 @@ const listStatusText = (status: AgentConfigStatus | undefined) => { : translate(locale, 'agents.list.pluginNotInstalled'); } if (status.modificationState === 'invalid') return translate(locale, 'agents.status.invalid'); + if (status.id === 'codex' && status.codexNativeOauth) return translate(locale, 'agents.nativeOAuth.status'); if (status.modificationState === 'applied') return translate(locale, 'agents.list.modified', { model: status.appliedModel ?? '—' }); return status.version ? translate(locale, 'agents.list.installedVersion', { version: status.version }) @@ -736,7 +738,7 @@ export function AgentsPage({ embedded = false, onConfigurationApplied }: AgentsP const [loading, setLoading] = useState(true); const [modelLoading, setModelLoading] = useState(false); const [busyAction, setBusyAction] = useState< - 'backup' | 'apply' | 'default' | 'clear' | 'install-pi' | 'update-pi' | 'repair-pi' | 'uninstall-pi' | 'oauth-check' | 'directory' | 'launch' | 'launch-cli' | 'launch-app' | 'restart-app' | 'stop-deepseek' | 'restart-deepseek' | null + 'backup' | 'apply' | 'close-config' | 'default' | 'clear' | 'install-pi' | 'update-pi' | 'repair-pi' | 'uninstall-pi' | 'oauth-check' | 'native-oauth' | 'directory' | 'launch' | 'launch-cli' | 'launch-app' | 'restart-app' | 'stop-deepseek' | 'restart-deepseek' | null >(null); const busy = busyAction !== null; const [detectionError, setDetectionError] = useState(''); @@ -864,7 +866,8 @@ export function AgentsPage({ embedded = false, onConfigurationApplied }: AgentsP useEffect(() => { if (loading) return; - const preferredModel = statuses.find((status) => status.id === selected)?.currentModel ?? ''; + const status = statuses.find((status) => status.id === selected); + const preferredModel = status?.currentModel ?? ''; void loadModels(selected, preferredModel); }, [loadModels, loading, selected]); @@ -925,6 +928,7 @@ export function AgentsPage({ embedded = false, onConfigurationApplied }: AgentsP const activeDefinition = agentDefinitions.find((agent) => agent.id === selected) ?? agentDefinitions[0]; const activeStatus = statuses.find((status) => status.id === selected) ?? null; + const nativeOauth = selected === 'codex' && Boolean(activeStatus?.codexNativeOauth); const oauthConfiguration = oauthConfigurationDraft ?? activeStatus?.oauthConfiguration ?? false; @@ -1027,10 +1031,13 @@ export function AgentsPage({ embedded = false, onConfigurationApplied }: AgentsP ]); const connectionState = activeStatus?.connectionState ?? 'invalid'; - const configurationActionLabel = t(connectionState === 'not-configured' ? 'agents.modify.connect' + const appliedModel = activeStatus?.currentModel ?? activeStatus?.appliedModel ?? ''; + const canCloseCodexConfiguration = selected === 'codex' && !nativeOauth + && connectionState === 'configured'; + const configurationActionLabel = t(nativeOauth ? 'agents.modify.update' + : connectionState === 'not-configured' ? selected === 'codex' ? 'agents.modify.apply' : 'agents.modify.connect' : connectionState === 'needs-update' ? 'agents.modify.repair' : 'agents.modify.update'); const configurationWriteBlocked = loading || !activeStatus || connectionState === 'invalid'; - const appliedModel = activeStatus?.currentModel ?? activeStatus?.appliedModel ?? ''; const formValues: AgentFormValues = { model: isClaudeModelMappingClient ? '' : selectedModel, oauthConfiguration: selected === 'codex' && oauthConfiguration, @@ -1108,6 +1115,7 @@ export function AgentsPage({ embedded = false, onConfigurationApplied }: AgentsP : t('agents.model.firstSelection', { count: models.length })); const modificationDescription = activeStatus?.modificationState === 'invalid' ? t('agents.modify.invalid') + : nativeOauth ? t('agents.nativeOAuth.activeHint') : selected === 'zcode' ? t('agents.modify.zcodeRestart') : ''; @@ -1273,6 +1281,43 @@ export function AgentsPage({ embedded = false, onConfigurationApplied }: AgentsP return false; }; + const clearCodexIntegration = async () => { + setBusyAction('native-oauth'); + setConfigurationError(''); + setConfigurationNotice(''); + setClearNotice(''); + try { + await invoke('restore_codex_official_config'); + setModelError(''); + setModelSelectionError(''); + setStatuses((current) => current.map((status) => status.id === 'codex' + ? { ...status, codexNativeOauth: true } : status)); + await reloadStatusesAfterAction(); + setConfigurationNotice(t('agents.nativeOAuth.restored')); + } catch (cause) { + setConfigurationError(String(cause)); + } finally { + setBusyAction(null); + } + }; + + const closeConfigurationChanges = async () => { + setBusyAction('close-config'); + setConfigurationError(''); + setConfigurationNotice(''); + setClearNotice(''); + try { + await invoke('close_codex_config_modification'); + clearPendingChanges(); + await reloadStatusesAfterAction(); + setConfigurationNotice(t('agents.modify.closed')); + } catch (cause) { + setConfigurationError(String(cause)); + } finally { + setBusyAction(null); + } + }; + const changeOauthConfiguration = async (enabled: boolean) => { setConfigurationNotice(''); setClearNotice(''); @@ -1659,6 +1704,12 @@ export function AgentsPage({ embedded = false, onConfigurationApplied }: AgentsP setClearConfirmOpen(false); }; + const openClearIntegrationGuide = () => { + setOauthLoginRequiredAction(null); + setActiveSubpage('management'); + requestAnimationFrame(() => document.getElementById('agent-clear-integration')?.focus()); + }; + const availableSubpages = agentSubpages.filter( (subpage) => (!subpage.clients || subpage.clients.includes(selected)) && (!embedded || subpage.id !== 'sessions'), ); @@ -1687,11 +1738,21 @@ export function AgentsPage({ embedded = false, onConfigurationApplied }: AgentsP ) : null; + const closeConfigurationButton = canCloseCodexConfiguration ? ( + + ) : null; + const configurationFeedback = ; - const configurationErrorMessage = configurationError || modelSelectionError || modelError; + const configurationErrorMessage = configurationError || (!nativeOauth ? modelSelectionError || modelError : ''); return (
@@ -1810,7 +1871,8 @@ export function AgentsPage({ embedded = false, onConfigurationApplied }: AgentsP
{configurationFeedback} -
+
+ {closeConfigurationButton} @@ -2140,12 +2205,14 @@ export function AgentsPage({ embedded = false, onConfigurationApplied }: AgentsP {activeSubpage === 'management' ? (
void createManualBackup()} onRestore={() => setBackupsOpen(true)} onTemplate={() => void openDefaultConfirmation()} onClear={openClearConfirmation} + canClearIntegration={!loading && Boolean(activeStatus?.configValid && activeStatus.supportedPlatform)} + onClearIntegration={() => void clearCodexIntegration()} onUpdatePi={() => void updatePiProvider()} onUninstallPi={() => void uninstallPiProvider()} />
@@ -2470,8 +2537,14 @@ export function AgentsPage({ embedded = false, onConfigurationApplied }: AgentsP

{t('agents.oauthLoginRequired.title')}

{oauthLoginRequiredDescription}

-
- +
+ + {oauthLoginRequiredAction !== 'launch' ? ( + + ) : null}
diff --git a/src/styles.css b/src/styles.css index 7396846..e20c959 100644 --- a/src/styles.css +++ b/src/styles.css @@ -14468,3 +14468,18 @@ body.table-col-resizing * { text-overflow: ellipsis; white-space: nowrap; } + +.agent-codex-save-actions { + flex-wrap: nowrap; +} + +.agent-codex-save-actions .primary-button { + width: 128px; + flex-shrink: 0; +} + +.agent-codex-save-actions button { + width: auto; + min-width: 0; + padding-inline: 12px; +} diff --git a/tests/agent-backups-ui.cjs b/tests/agent-backups-ui.cjs index 910e02f..f646486 100644 --- a/tests/agent-backups-ui.cjs +++ b/tests/agent-backups-ui.cjs @@ -15,10 +15,10 @@ const assert = require('node:assert/strict'); const choose = async () => { await manage(); await page.getByRole('button', { name: '恢复备份', exact: true }).click(); await page.locator('.agent-backup-columns nav button').first().click(); }; for (const query of ['fresh', 'embedded&fresh']) { await open(query); - await page.getByRole('button', { name: '一键接入', exact: true }).click(); + await page.getByRole('button', { name: '应用配置修改', exact: true }).click(); await page.getByText('配置已更新。', { exact: true }).waitFor(); - await page.getByRole('button', { name: '更新配置', exact: true }).click(); - await page.getByText('配置已是最新,无需写入。', { exact: true }).waitFor(); + await page.getByRole('button', { name: '关闭配置修改', exact: true }).waitFor(); + assert.equal((await calls('close_codex_config_modification')).length, 0); assert.equal((await calls('create_agent_config_backup')).length, 0); await backup(); assert.deepEqual((await calls('create_agent_config_backup'))[0].args, { client: 'codex' }); diff --git a/tests/agent-configuration-layout-ui.cjs b/tests/agent-configuration-layout-ui.cjs index a5b2c81..2ebbf39 100644 --- a/tests/agent-configuration-layout-ui.cjs +++ b/tests/agent-configuration-layout-ui.cjs @@ -15,7 +15,7 @@ const base = 'http://127.0.0.1:1421'; await page.waitForFunction(() => window.fixtureCalls.some(call => call.cmd === 'get_agent_models') && !document.querySelector('.agent-model-trigger')?.disabled); }; - const positions = () => page.locator('.agent-save-bar, .agent-save-actions button, .agent-run-controls').evaluateAll(nodes => nodes.map(node => { + const positions = () => page.locator('.agent-save-bar, .agent-save-actions .primary-button, .agent-run-controls').evaluateAll(nodes => nodes.map(node => { const rect = node.getBoundingClientRect(); return [rect.x + scrollX, rect.y + scrollY, rect.width, rect.height]; })); @@ -24,7 +24,7 @@ const base = 'http://127.0.0.1:1421'; assert.ok(geometry[0][3] <= 60, 'The save bar occupies only a button-height row plus padding'); assert.ok(geometry[2][1] - geometry[0][1] - geometry[0][3] <= 20, 'No blank feedback region separates save and run controls'); assert.equal(await page.locator('.agent-shared-feedback').count(), 0, 'Empty state feedback does not reserve space'); - assert.ok(await page.locator('.agent-save-actions button').evaluate(node => node.getBoundingClientRect().width <= 160), 'Save button stays compact in both entry points'); + assert.ok(await page.locator('.agent-save-actions .primary-button').evaluate(node => node.getBoundingClientRect().width <= 160), 'Save button stays compact in both entry points'); return geometry; }; fs.mkdirSync('misc', { recursive: true }); diff --git a/tests/agent-native-oauth-ui.cjs b/tests/agent-native-oauth-ui.cjs new file mode 100644 index 0000000..2dacdc8 --- /dev/null +++ b/tests/agent-native-oauth-ui.cjs @@ -0,0 +1,227 @@ +const { chromium } = require(process.env.PLAYWRIGHT_MODULE || 'playwright'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const base = 'http://127.0.0.1:1421'; +(async () => { + const browser = await chromium.launch({ channel: 'msedge', headless: true, args: ['--no-proxy-server'] }); + try { + const page = await browser.newPage(); + page.setDefaultTimeout(10000); + page.setDefaultNavigationTimeout(30000); + await page.route('**/*', route => route.request().url().startsWith(base + '/') ? route.continue() : route.abort()); + const errors = []; + page.on('pageerror', error => errors.push(String(error))); + const restore = () => page.getByRole('button', { name: '清除接入', exact: true }); + const update = () => page.locator('.agent-save-actions .primary-button'); + const core = () => page.getByRole('tab', { name: '基础配置', exact: true }).click(); + const manage = () => page.getByRole('tab', { name: '配置管理', exact: true }).click(); + const restoreOfficial = async () => { + await manage(); + assert.equal(await page.locator('.agent-management-clear-integration .agent-clear-integration').count(), 1); + await restore().click(); + await page.waitForFunction(() => !document.querySelector('.agent-clear-integration').disabled); + assert.equal(await page.getByRole('tab', { name: '配置管理', exact: true }).getAttribute('aria-selected'), 'true'); + assert.equal(await page.evaluate(() => document.documentElement.scrollWidth > innerWidth), false); + await core(); + }; + const open = async query => { + await page.goto(`${base}/tests/fixtures/agent-backups.html?reset-selections&client=codex&${query}`, { waitUntil: 'domcontentloaded' }); + await update().waitFor(); + await page.waitForFunction(() => { + const button = document.querySelector('.agents-page .management-header button'); + return window.fixtureCalls.some(call => call.cmd === 'get_agent_models') && button && !button.disabled; + }); + }; + const waitMode = async enabled => { + await page.waitForFunction(value => { + const button = document.querySelector('.agent-save-actions .primary-button'); + const status = document.querySelector('.agent-client-list button.active small'); + return button && (status?.textContent === '未接入 CPA') === value; + }, enabled); + }; + const assertButtonOrder = async (applied = true) => { + const positions = await page.locator('.agent-save-actions button').evaluateAll(nodes => nodes.map(node => { + const rect = node.getBoundingClientRect(); + return { x: rect.x, y: rect.y, width: rect.width }; + })); + assert.equal(positions.length, applied ? 2 : 1); + if (applied) { + const [closePosition, updatePosition] = positions; + assert.ok(closePosition.x + closePosition.width <= updatePosition.x + 1, 'Close is left of Update'); + assert.ok(Math.abs(closePosition.y - updatePosition.y) < 1, 'Close and Update share a row'); + } + assert.equal(await restore().count(), 0, 'Official sign-in is only available in configuration management'); + assert.equal(await page.locator('.agent-native-oauth, [role="switch"]').count(), 0, 'The old mode switch is gone'); + }; + fs.mkdirSync('misc', { recursive: true }); + for (const width of [1280, 360]) for (const embedded of [false, true]) { + await page.setViewportSize({ width, height: 900 }); + await open(embedded ? 'embedded' : ''); + await assertButtonOrder(); + await restoreOfficial(); + await waitMode(true); + assert.equal(await page.locator('.agent-close-configuration').count(), 0); + assert.equal(await page.locator('.agent-model-trigger').count(), 1, 'CPA models remain selectable for reconnect'); + await assertButtonOrder(false); + assert.match(await page.locator('.agent-save-feedback').innerText(), /更新配置将重新接入 CPA/); + const calls = await page.evaluate(() => window.fixtureCalls); + assert.equal(calls.filter(call => ['check_codex_oauth_login', 'update_agent_config'].includes(call.cmd)).length, 0); + assert.equal(calls.filter(call => call.cmd === 'restore_codex_official_config').length, 1); + await page.evaluate(() => window.fixtureRemount()); + await waitMode(true); + assert.equal(await page.evaluate(() => document.documentElement.scrollWidth > innerWidth), false); + await manage(); + await restore().waitFor(); + await page.evaluate(() => window.fixtureRemount()); + await restore().waitFor(); + assert.equal(await page.getByRole('tab', { name: '配置管理', exact: true }).getAttribute('aria-selected'), 'true'); + if (width === 1280) await page.screenshot({ animations: 'disabled', path: `misc/restore-official-${embedded ? 'embedded' : 'full'}.png`, fullPage: true }); + await core(); + await page.locator('.agent-model-trigger').click(); + await page.getByRole('option', { name: 'gpt-two' }).click(); + await update().click(); + await waitMode(false); + const updated = (await page.evaluate(() => window.fixtureCalls)).findLast(call => call.cmd === 'update_agent_config'); + assert.equal(updated.args.model, 'gpt-two'); + assert.equal(updated.args.oauthConfiguration, false); + if (!embedded) { + await restoreOfficial(); + await waitMode(true); + await page.getByRole('radio', { name: 'OAuth 登录方式', exact: true }).click(); + await update().click(); + await waitMode(false); + assert.equal((await page.evaluate(() => window.fixtureCalls)).findLast(call => call.cmd === 'update_agent_config').args.oauthConfiguration, true); + } + } + await open('fresh'); + assert.equal(await update().innerText(), '应用配置修改'); + assert.equal(await page.locator('.agent-close-configuration').count(), 0); + await restoreOfficial(); + await waitMode(true); + await update().click(); + await waitMode(false); + await open('no-core'); + await restoreOfficial(); + await waitMode(true); + assert.equal(await update().isDisabled(), true, 'An offline core prevents reconnect, but not Restore'); + await open('fail-native-oauth'); + await restoreOfficial(); + await page.getByRole('alert').filter({ hasText: /模拟切换失败/ }).waitFor(); + await waitMode(false); + await restoreOfficial(); + await waitMode(true); + await open('fail-apply'); + await restoreOfficial(); + await waitMode(true); + await update().click(); + await page.getByRole('alert').filter({ hasText: /模拟配置写入失败/ }).waitFor(); + await waitMode(true); + await update().click(); + await waitMode(false); + + const close = () => page.getByRole('button', { name: '关闭配置修改', exact: true }); + const apply = () => page.getByRole('button', { name: '应用配置修改', exact: true }); + for (const embedded of [false, true]) { + await open(embedded ? 'embedded' : ''); + await close().waitFor(); + assert.equal(await update().innerText(), '更新配置'); + await update().click(); + await page.waitForFunction(() => document.documentElement.dataset.fixtureApplied === '1'); + assert.equal((await page.evaluate(() => window.fixtureCalls)).filter(call => call.cmd === 'close_codex_config_modification').length, 0, 'Update never closes an unchanged configuration'); + await page.locator('.agent-model-trigger').click(); + await page.getByRole('option', { name: 'gpt-two' }).click(); + assert.equal(await close().isEnabled(), true, 'A model draft does not hide or disable Close'); + await close().click(); + await apply().waitFor(); + assert.equal(await close().count(), 0, 'Close is hidden after disconnect'); + assert.equal((await page.evaluate(() => window.fixtureCalls)).filter(call => call.cmd === 'close_codex_config_modification').length, 1); + await page.evaluate(() => window.fixtureRemount()); + await apply().waitFor(); + await apply().click(); + await page.waitForFunction(() => !document.querySelector('.agent-close-configuration').disabled); + if (!embedded) { + await page.getByRole('radio', { name: 'OAuth 登录方式', exact: true }).click(); + await page.getByRole('button', { name: '更新配置', exact: true }).click(); + await page.waitForFunction(() => !document.querySelector('.agent-close-configuration').disabled); + assert.equal((await page.evaluate(() => window.fixtureCalls)).findLast(call => call.cmd === 'update_agent_config').args.oauthConfiguration, true); + await page.getByRole('radio', { name: 'API 密钥', exact: true }).click(); + assert.equal(await close().isEnabled(), true, 'An authentication draft keeps Close available'); + await page.getByRole('button', { name: '更新配置', exact: true }).click(); + await page.waitForFunction(() => !document.querySelector('.agent-close-configuration').disabled); + assert.equal((await page.evaluate(() => window.fixtureCalls)).findLast(call => call.cmd === 'update_agent_config').args.oauthConfiguration, false); + } + } + for (const width of [1280, 360]) for (const embedded of [false, true]) { + await page.setViewportSize({ width, height: 900 }); + await open(`fresh&fail-apply${embedded ? '&embedded' : ''}`); + assert.equal(await close().count(), 0, 'An unapplied configuration has no Close button'); + await assertButtonOrder(false); + await apply().click(); + await page.getByRole('alert').filter({ hasText: /模拟配置写入失败/ }).waitFor(); + assert.equal(await close().count(), 0, 'A failed application does not reveal Close'); + await apply().click(); + await close().waitFor(); + await assertButtonOrder(); + await page.evaluate(() => window.fixtureRemount()); + await close().waitFor(); + await close().click(); + await apply().waitFor(); + assert.equal(await close().count(), 0, 'Closing hides the action again'); + assert.equal(await page.evaluate(() => document.documentElement.scrollWidth > innerWidth), false); + } + await open('no-core'); + await close().waitFor(); + assert.equal(await close().isDisabled(), false, 'Closing is available while CPA is offline'); + await close().click(); + await apply().waitFor(); + await open('fail-close'); + await close().click(); + await page.getByRole('alert').filter({ hasText: /模拟关闭失败/ }).waitFor(); + await close().waitFor(); + await close().click(); + await apply().waitFor(); + for (const width of [1280, 360]) { + await page.setViewportSize({ width, height: 900 }); + await open('no-oauth-login'); + const oauth = () => page.getByRole('radio', { name: 'OAuth 登录方式', exact: true }); + await oauth().click(); + const dialog = page.getByRole('alertdialog'); + await dialog.waitFor(); + assert.match(await dialog.innerText(), /配置管理.*清除接入/); + assert.equal((await dialog.innerText()).includes('清空配置'), false); + assert.equal(await page.getByRole('radio', { name: 'API 密钥', exact: true }).getAttribute('aria-checked'), 'true'); + assert.equal((await page.evaluate(() => window.fixtureCalls)).filter(call => ['restore_codex_official_config', 'clear_codex_config', 'update_agent_config'].includes(call.cmd)).length, 0); + await dialog.getByRole('button', { name: '前往配置管理', exact: true }).click(); + await dialog.waitFor({ state: 'detached' }); + await restore().waitFor(); + await page.waitForFunction(() => document.activeElement?.id === 'agent-clear-integration'); + assert.equal((await page.evaluate(() => window.fixtureCalls)).filter(call => call.cmd === 'restore_codex_official_config').length, 0, 'Guidance navigates without clearing configuration'); + await restore().click(); + await page.getByRole('status').filter({ hasText: /已清除 CPA 接入/ }).waitFor(); + assert.equal((await page.evaluate(() => window.fixtureCalls)).filter(call => call.cmd === 'clear_codex_config').length, 0, 'Clear integration never calls Clear configuration'); + await core(); + await oauth().click(); + await dialog.waitFor(); + await dialog.getByRole('button', { name: '我知道了', exact: true }).click(); + assert.equal(await page.getByRole('tab', { name: '基础配置', exact: true }).getAttribute('aria-selected'), 'true'); + await page.evaluate(() => { window.fixtureOauthLoggedIn = true; }); + await oauth().click(); + await page.waitForFunction(() => document.querySelector('[data-value="oauth"]')?.getAttribute('aria-checked') === 'true'); + assert.equal(await dialog.count(), 0, 'An existing login can select OAuth directly'); + await update().click(); + await close().waitFor(); + assert.equal((await page.evaluate(() => window.fixtureCalls)).findLast(call => call.cmd === 'update_agent_config').args.oauthConfiguration, true); + } + for (const client of ['opencode', 'zcode']) { + await page.goto(`${base}/tests/fixtures/agent-backups.html?reset-selections&client=${client}`, { waitUntil: 'domcontentloaded' }); + await page.getByRole('button', { name: '更新配置', exact: true }).waitFor(); + assert.equal(await close().count(), 0, 'Other clients keep their existing controls'); + assert.equal(await restore().count(), 0); + await manage(); + assert.equal(await restore().count(), 0, 'Other clients have no official Codex login action in management'); + } + + assert.deepEqual(errors, []); + console.log('PASS: Clear integration in management, missing-login guidance and navigation, login then OAuth reconnect, independent Close/Update, failures, and both layouts at 1280/360px.'); + } finally { await browser.close(); } +})().catch(error => { console.error(error); process.exitCode = 1; }); diff --git a/tests/agent-pending-ui.cjs b/tests/agent-pending-ui.cjs index ca1666e..5c734e2 100644 --- a/tests/agent-pending-ui.cjs +++ b/tests/agent-pending-ui.cjs @@ -31,7 +31,7 @@ const assert = require('node:assert/strict'); }; const apply = async () => { await core(); - await page.getByRole('button', { name: /^(更新配置|一键接入)$/ }).click(); + await page.getByRole('button', { name: /^(更新配置|一键接入|应用配置修改)$/ }).click(); await page.getByText(/^(配置已更新。|配置已是最新,无需写入。)$/).waitFor(); await expectPending(false); }; diff --git a/tests/fixtures/agent-backups.tsx b/tests/fixtures/agent-backups.tsx index 6da0342..754900b 100644 --- a/tests/fixtures/agent-backups.tsx +++ b/tests/fixtures/agent-backups.tsx @@ -11,6 +11,7 @@ document.documentElement.dataset.theme = params.get('theme') || 'light'; localStorage.setItem('cpa-gui.agent-selected-client.v1', params.get('client') || 'codex'); const ids = ['claude-code','claude-desktop','codex','opencode','openclaw','hermes','deepseek-harness','zcode','kimi-code','grok-build','pi']; let count=0; let backupCount=0; const backups:any[]=[]; let currentModel=params.has('fresh')?null:'gpt-one'; +let nativeOauth=params.has('native-oauth'); let nativeSwitchCount=0; let codexClosed=false; let closeCount=0; let currentOauth=false; const currentMappings:Record={}; let harnessProvider:Record={}; const harnessConfigurations:Record>={}; @@ -23,6 +24,7 @@ const harnessModels=[ const harnessSnapshot=()=>({revision:String(harnessRevision),provider:harnessProvider,baseUrl:'http://127.0.0.1:8317/v1',defaultModel:currentModel,configured:!!currentModel,models:harnessModels.map(m=>({...m,configuration:harnessConfigurations[m.id]??{}}))}); let harnessStatus={running:params.has('running'),pid:params.has('running')?100:null as number|null,mode:params.has('running')?params.get('harness-mode')||'web':null as string|null}; let appliedCount=0; +(window as any).fixtureOauthLoggedIn=!params.has('no-oauth-login'); const calls:any[]=[];(window as any).fixtureCalls=calls; let embedded=params.has('embedded'); (window as any).fixtureSessionIds=Array.from({length:61},(_,index)=>`session-${index+1}`); @@ -30,8 +32,21 @@ mockIPC(async (cmd,args:any) => { calls.push({cmd,args}); if(cmd==='plugin:event|listen') return 1; if(cmd==='plugin:event|unlisten'||cmd==='set_app_locale') return null; - if(cmd==='get_agent_config_statuses'||cmd==='refresh_agent_config_statuses') return ids.map(id=>({id,name:id,supportedPlatform:true,installed:!params.has('not-installed'),pluginInstalled:!params.has('no-plugin'),launchTargets:params.has('not-installed')?[]:['claude-desktop','zcode'].includes(id)?[{id:'app',label:id,detail:'test desktop'}]:['codex','opencode'].includes(id)&&!params.has('cli-only')?[...(params.has('app-only')?[]:[{id:'cli',label:'CLI',detail:'test CLI'}]),{id:'app',label:'APP',detail:'test desktop'}]:[{id:'cli',label:'CLI',detail:'test CLI'}],version:'1.0',cliVersion:'1.0',appVersion:null,pluginVersion:'1.0',configValid:params.get('state')!=='invalid',connectionState:params.get('state') || (currentModel?'configured':'not-configured'),configured:!!currentModel,configurationSynchronized:!!currentModel,currentModel,oauthConfiguration:id==='codex'&¤tOauth,modificationEnabled:!!currentModel,modificationState:currentModel?'applied':'unconfigured',backupAvailable:false,appliedModel:currentModel,claudeCodeModelMappings:id==='claude-code'?currentMappings[id]??null:null,claudeDesktopModelMappings:id==='claude-desktop'?currentMappings[id]??null:null,warnings:[],error:null})); + if(cmd==='get_agent_config_statuses'||cmd==='refresh_agent_config_statuses') return ids.map(id=>({id,name:id,supportedPlatform:true,installed:!params.has('not-installed'),pluginInstalled:!params.has('no-plugin'),launchTargets:params.has('not-installed')?[]:['claude-desktop','zcode'].includes(id)?[{id:'app',label:id,detail:'test desktop'}]:['codex','opencode'].includes(id)&&!params.has('cli-only')?[...(params.has('app-only')?[]:[{id:'cli',label:'CLI',detail:'test CLI'}]),{id:'app',label:'APP',detail:'test desktop'}]:[{id:'cli',label:'CLI',detail:'test CLI'}],version:'1.0',cliVersion:'1.0',appVersion:null,pluginVersion:'1.0',configValid:params.get('state')!=='invalid',connectionState:params.get('state') || (currentModel?'configured':'not-configured'),configured:!!currentModel,configurationSynchronized:!!currentModel,currentModel,oauthConfiguration:id==='codex'&¤tOauth,codexNativeOauth:id==='codex'&&nativeOauth,modificationEnabled:!!currentModel,modificationState:currentModel?'applied':'unconfigured',backupAvailable:false,appliedModel:currentModel,claudeCodeModelMappings:id==='claude-code'?currentMappings[id]??null:null,claudeDesktopModelMappings:id==='claude-desktop'?currentMappings[id]??null:null,warnings:[],error:null})).map(status=>status.id==='codex'&&(nativeOauth||codexClosed)?{...status,configured:false,connectionState:'not-configured',configurationSynchronized:false,currentModel:null,appliedModel:null,modificationEnabled:false,modificationState:'unconfigured',oauthConfiguration:false}:status); + if(cmd==='close_codex_config_modification') { + closeCount++; + if(params.has('fail-close')&&closeCount===1)throw new Error('模拟关闭失败,已回滚'); + codexClosed=true;nativeOauth=false; + return {outcome:'updated',enabled:false,changedFiles:[],conflictFiles:[]}; + } + if(cmd==='restore_codex_official_config') { + nativeSwitchCount++; + if(params.has('fail-native-oauth')&&nativeSwitchCount===1)throw new Error('模拟切换失败,已回滚'); + nativeOauth=true; return {outcome:'updated'}; + } if(cmd==='get_agent_models') { + if(params.has('no-core'))throw new Error('CPA core is offline'); + if(params.has('defer-models'))await new Promise(resolve=>{(window as any).fixtureFinishModels=resolve;}); if(args.client==='deepseek-harness')return harnessModels.map(m=>({name:m.id,inputModalities:m.defaults.input})); return [{name:'gpt-one'},{name:'gpt-two'}]; } @@ -58,7 +73,7 @@ mockIPC(async (cmd,args:any) => { if(cmd==='uninstall_pi_provider')return null; if(['update_agent_config','repair_pi_provider','install_pi_provider','update_pi_provider'].includes(cmd)) { count++;if(params.has('fail-apply')&&count===1)throw new Error('模拟配置写入失败'); - currentModel=args.model;currentOauth=!!args.oauthConfiguration; + currentModel=args.model;currentOauth=!!args.oauthConfiguration;nativeOauth=false;codexClosed=false; currentMappings[args.client]=args.claudeCodeModelMappings??args.claudeDesktopModelMappings; return {outcome:count>1?'unchanged':'updated',model:currentModel,enabled:true,changedFiles:[],conflictFiles:[]}; } @@ -85,7 +100,10 @@ mockIPC(async (cmd,args:any) => { harnessRevision++;return harnessSnapshot(); } if(cmd==='get_codex_model_catalog_editor') return {models:[],hiddenModels:[],customizations:{}}; - if(cmd==='check_codex_oauth_login')return null; + if(cmd==='check_codex_oauth_login') { + if(!(window as any).fixtureOauthLoggedIn)throw new Error('CODEX_OAUTH_LOGIN_REQUIRED'); + return null; + } throw new Error('Unhandled fixture command: '+cmd); }); let root=createRoot(document.getElementById('root')!); diff --git a/tests/navigationAndUpdate.test.ts b/tests/navigationAndUpdate.test.ts index 33857d1..7a1999f 100644 --- a/tests/navigationAndUpdate.test.ts +++ b/tests/navigationAndUpdate.test.ts @@ -15,6 +15,8 @@ describe('简易模式、首页、配置与版本管理导航', () => { expect(canOpenAppPage('versions', false)).toBe(true); expect(canOpenAppPage('config', false)).toBe(true); expect(canOpenAppPage('usage-records', false)).toBe(true); + expect(canOpenAppPage('agents', false)).toBe(true); + expect(isAlwaysAvailablePage('agents')).toBe(true); expect(canOpenAppPage('quota', false)).toBe(false); });