diff --git a/src-tauri/src/cli/codex_temp_launch.rs b/src-tauri/src/cli/codex_temp_launch.rs index e036d4b8..776f0e48 100644 --- a/src-tauri/src/cli/codex_temp_launch.rs +++ b/src-tauri/src/cli/codex_temp_launch.rs @@ -12,6 +12,8 @@ use serde_json::Value; #[derive(Debug, Clone)] pub(crate) struct PreparedCodexLaunch { pub(crate) executable: PathBuf, + pub(crate) cc_switch_executable: PathBuf, + pub(crate) provider_id: String, pub(crate) codex_home: PathBuf, } @@ -37,9 +39,18 @@ where Resolve: FnOnce() -> Result, { let executable = resolve_codex_binary()?; + let cc_switch_executable = std::env::current_exe().map_err(|err| { + AppError::localized( + "codex.temp_launch_current_exe_failed", + format!("解析当前 cc-switch 可执行文件路径失败: {err}"), + format!("Failed to resolve current cc-switch executable path: {err}"), + ) + })?; let codex_home = write_temp_codex_home(temp_dir, provider)?; Ok(PreparedCodexLaunch { executable, + cc_switch_executable, + provider_id: provider.id.clone(), codex_home, }) } @@ -76,11 +87,13 @@ pub(crate) fn build_handoff_command( ) -> std::process::Command { let mut command = std::process::Command::new("/bin/sh"); command.arg("-c").arg( - "codex_home=\"$1\"; codex_bin=\"$2\"; shift 2; exit_status=0; cleanup() { rm -rf -- \"$codex_home\"; cleanup_status=$?; if [ \"$cleanup_status\" -ne 0 ]; then printf '%s\\n' \"cc-switch: failed to remove temporary Codex home: $codex_home\" >&2; if [ \"$exit_status\" -eq 0 ]; then exit_status=$cleanup_status; fi; fi; }; on_signal() { exit_status=\"$1\"; trap - INT TERM HUP; cleanup; exit \"$exit_status\"; }; trap 'on_signal 130' INT; trap 'on_signal 143' TERM; trap 'on_signal 129' HUP; export CODEX_HOME=\"$codex_home\"; \"$codex_bin\" \"$@\"; exit_status=$?; cleanup; exit \"$exit_status\"", + "codex_home=\"$1\"; codex_bin=\"$2\"; cc_switch_bin=\"$3\"; provider_id=\"$4\"; shift 4; exit_status=0; persist() { \"$cc_switch_bin\" internal capture-codex-temp \"$provider_id\" \"$codex_home\"; persist_status=$?; if [ \"$persist_status\" -ne 0 ]; then printf '%s\\n' \"cc-switch: 持久化供应商 $provider_id 的临时 Codex 登录状态失败(failed to persist temporary Codex login state)\" >&2; if [ \"$exit_status\" -eq 0 ]; then exit_status=$persist_status; fi; fi; }; cleanup() { rm -rf -- \"$codex_home\"; cleanup_status=$?; if [ \"$cleanup_status\" -ne 0 ]; then printf '%s\\n' \"cc-switch: failed to remove temporary Codex home: $codex_home\" >&2; if [ \"$exit_status\" -eq 0 ]; then exit_status=$cleanup_status; fi; fi; }; on_signal() { exit_status=\"$1\"; trap - INT TERM HUP; persist; cleanup; exit \"$exit_status\"; }; trap 'on_signal 130' INT; trap 'on_signal 143' TERM; trap 'on_signal 129' HUP; export CODEX_HOME=\"$codex_home\"; \"$codex_bin\" \"$@\"; exit_status=$?; persist; cleanup; exit \"$exit_status\"", ); command.arg("cc-switch-codex-handoff"); command.arg(&prepared.codex_home); command.arg(&prepared.executable); + command.arg(&prepared.cc_switch_executable); + command.arg(&prepared.provider_id); command.args(native_args); command } @@ -321,6 +334,8 @@ mod tests { fn unix_handoff_command_exports_codex_home_and_cleans_up_temp_dir() { let prepared = PreparedCodexLaunch { executable: PathBuf::from("/usr/local/bin/codex"), + cc_switch_executable: PathBuf::from("/usr/local/bin/cc-switch"), + provider_id: "demo".to_string(), codex_home: PathBuf::from("/tmp/cc-switch-codex-home"), }; let native_args = vec![OsString::from("--model"), OsString::from("gpt-5.4")]; @@ -334,11 +349,13 @@ mod tests { vec![ OsString::from("-c"), OsString::from( - "codex_home=\"$1\"; codex_bin=\"$2\"; shift 2; exit_status=0; cleanup() { rm -rf -- \"$codex_home\"; cleanup_status=$?; if [ \"$cleanup_status\" -ne 0 ]; then printf '%s\\n' \"cc-switch: failed to remove temporary Codex home: $codex_home\" >&2; if [ \"$exit_status\" -eq 0 ]; then exit_status=$cleanup_status; fi; fi; }; on_signal() { exit_status=\"$1\"; trap - INT TERM HUP; cleanup; exit \"$exit_status\"; }; trap 'on_signal 130' INT; trap 'on_signal 143' TERM; trap 'on_signal 129' HUP; export CODEX_HOME=\"$codex_home\"; \"$codex_bin\" \"$@\"; exit_status=$?; cleanup; exit \"$exit_status\"" + "codex_home=\"$1\"; codex_bin=\"$2\"; cc_switch_bin=\"$3\"; provider_id=\"$4\"; shift 4; exit_status=0; persist() { \"$cc_switch_bin\" internal capture-codex-temp \"$provider_id\" \"$codex_home\"; persist_status=$?; if [ \"$persist_status\" -ne 0 ]; then printf '%s\\n' \"cc-switch: 持久化供应商 $provider_id 的临时 Codex 登录状态失败(failed to persist temporary Codex login state)\" >&2; if [ \"$exit_status\" -eq 0 ]; then exit_status=$persist_status; fi; fi; }; cleanup() { rm -rf -- \"$codex_home\"; cleanup_status=$?; if [ \"$cleanup_status\" -ne 0 ]; then printf '%s\\n' \"cc-switch: failed to remove temporary Codex home: $codex_home\" >&2; if [ \"$exit_status\" -eq 0 ]; then exit_status=$cleanup_status; fi; fi; }; on_signal() { exit_status=\"$1\"; trap - INT TERM HUP; persist; cleanup; exit \"$exit_status\"; }; trap 'on_signal 130' INT; trap 'on_signal 143' TERM; trap 'on_signal 129' HUP; export CODEX_HOME=\"$codex_home\"; \"$codex_bin\" \"$@\"; exit_status=$?; persist; cleanup; exit \"$exit_status\"" ), OsString::from("cc-switch-codex-handoff"), OsString::from("/tmp/cc-switch-codex-home"), OsString::from("/usr/local/bin/codex"), + OsString::from("/usr/local/bin/cc-switch"), + OsString::from("demo"), OsString::from("--model"), OsString::from("gpt-5.4"), ] @@ -361,6 +378,8 @@ mod tests { let prepared = PreparedCodexLaunch { executable, + cc_switch_executable: PathBuf::from("/bin/true"), + provider_id: "demo".to_string(), codex_home: codex_home.clone(), }; let mut command = build_handoff_command(&prepared, &[]); @@ -394,6 +413,8 @@ mod tests { let executable = write_test_executable(&temp_dir, "codex-stub.sh", "exit 0"); let prepared = PreparedCodexLaunch { executable, + cc_switch_executable: PathBuf::from("/bin/true"), + provider_id: "demo".to_string(), codex_home: PathBuf::from("."), }; diff --git a/src-tauri/src/cli/commands/internal.rs b/src-tauri/src/cli/commands/internal.rs new file mode 100644 index 00000000..fb8c8886 --- /dev/null +++ b/src-tauri/src/cli/commands/internal.rs @@ -0,0 +1,28 @@ +use std::path::PathBuf; + +use clap::Subcommand; + +use crate::error::AppError; +use crate::services::ProviderService; +use crate::store::AppState; + +#[derive(Subcommand)] +pub enum InternalCommand { + /// Persist Codex files written during `cc-switch start codex`. + CaptureCodexTemp { + provider_id: String, + codex_home: PathBuf, + }, +} + +pub fn execute(cmd: InternalCommand) -> Result<(), AppError> { + match cmd { + InternalCommand::CaptureCodexTemp { + provider_id, + codex_home, + } => { + let state = AppState::try_new()?; + ProviderService::capture_codex_temp_launch_snapshot(&state, &provider_id, &codex_home) + } + } +} diff --git a/src-tauri/src/cli/commands/mod.rs b/src-tauri/src/cli/commands/mod.rs index a7fb1549..84e959c7 100644 --- a/src-tauri/src/cli/commands/mod.rs +++ b/src-tauri/src/cli/commands/mod.rs @@ -3,6 +3,7 @@ pub mod config; mod config_common; pub mod config_webdav; pub mod env; +pub mod internal; pub mod mcp; pub mod prompts; pub mod provider; diff --git a/src-tauri/src/cli/mod.rs b/src-tauri/src/cli/mod.rs index 12cf8b1e..8e9af79a 100644 --- a/src-tauri/src/cli/mod.rs +++ b/src-tauri/src/cli/mod.rs @@ -78,6 +78,9 @@ pub enum Commands { /// Generate, install, inspect, or uninstall shell completions Completions(commands::completions::CompletionsCommand), + + #[command(name = "internal", hide = true, subcommand)] + Internal(commands::internal::InternalCommand), } /// Generate shell completions diff --git a/src-tauri/src/codex_config.rs b/src-tauri/src/codex_config.rs index 0163e4d2..42f821b5 100644 --- a/src-tauri/src/codex_config.rs +++ b/src-tauri/src/codex_config.rs @@ -75,6 +75,13 @@ pub fn delete_codex_provider_config( pub fn write_codex_live_atomic( auth: &Value, config_text_opt: Option<&str>, +) -> Result<(), AppError> { + write_codex_live_atomic_optional_auth(Some(auth), config_text_opt) +} + +pub fn write_codex_live_atomic_optional_auth( + auth: Option<&Value>, + config_text_opt: Option<&str>, ) -> Result<(), AppError> { let auth_path = get_codex_auth_path(); let config_path = get_codex_config_path(); @@ -105,7 +112,11 @@ pub fn write_codex_live_atomic( } // 第一步:写 auth.json - write_json_file(&auth_path, auth)?; + if let Some(auth) = auth { + write_json_file(&auth_path, auth)?; + } else { + delete_file(&auth_path)?; + } // 第二步:写 config.toml(失败则回滚 auth.json) if let Err(e) = write_text_file(&config_path, &cfg_text) { @@ -431,6 +442,13 @@ pub fn restore_codex_settings_config_model_provider_for_backfill( pub fn write_codex_live_atomic_with_stable_provider( auth: &Value, config_text_opt: Option<&str>, +) -> Result<(), AppError> { + write_codex_live_atomic_optional_auth_with_stable_provider(Some(auth), config_text_opt) +} + +pub fn write_codex_live_atomic_optional_auth_with_stable_provider( + auth: Option<&Value>, + config_text_opt: Option<&str>, ) -> Result<(), AppError> { match config_text_opt { Some(config_text) => { @@ -442,9 +460,9 @@ pub fn write_codex_live_atomic_with_stable_provider( .get("config") .and_then(|value| value.as_str()) .unwrap_or(config_text); - write_codex_live_atomic(auth, Some(config_text)) + write_codex_live_atomic_optional_auth(auth, Some(config_text)) } - None => write_codex_live_atomic(auth, None), + None => write_codex_live_atomic_optional_auth(auth, None), } } diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index 978df67c..aa7c7df8 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -43,12 +43,15 @@ fn run(cli: Cli) -> Result<(), AppError> { Some(Commands::Env(cmd)) => cc_switch_lib::cli::commands::env::execute(cmd, cli.app), Some(Commands::Update(cmd)) => cc_switch_lib::cli::commands::update::execute(cmd), Some(Commands::Completions(cmd)) => cc_switch_lib::cli::commands::completions::execute(cmd), + Some(Commands::Internal(cmd)) => cc_switch_lib::cli::commands::internal::execute(cmd), } } fn command_requires_startup_state(command: &Option) -> bool { match command { - Some(Commands::Completions(_)) | Some(Commands::Update(_)) => false, + Some(Commands::Completions(_)) + | Some(Commands::Update(_)) + | Some(Commands::Internal(_)) => false, _ => true, } } @@ -107,6 +110,13 @@ mod tests { let completions_status = Cli::parse_from(["cc-switch", "completions", "status"]); let completions_uninstall = Cli::parse_from(["cc-switch", "completions", "uninstall", "--shell", "bash"]); + let internal_capture = Cli::parse_from([ + "cc-switch", + "internal", + "capture-codex-temp", + "official", + "/tmp/codex-home", + ]); let provider = Cli::parse_from(["cc-switch", "provider", "list"]); assert!(!command_requires_startup_state(&update.command)); @@ -120,6 +130,7 @@ mod tests { assert!(!command_requires_startup_state( &completions_uninstall.command )); + assert!(!command_requires_startup_state(&internal_capture.command)); assert!(command_requires_startup_state(&provider.command)); } @@ -135,6 +146,24 @@ mod tests { .expect("update should not touch startup state"); } + #[test] + #[serial] + fn internal_commands_bypass_future_schema_database_gate() { + let temp = tempfile::tempdir().expect("create temp dir"); + seed_future_schema_database(temp.path()); + let _guard = ConfigDirEnvGuard::set(temp.path()); + + let cli = Cli::parse_from([ + "cc-switch", + "internal", + "capture-codex-temp", + "official", + "/tmp/codex-home", + ]); + initialize_startup_state_if_needed(&cli.command) + .expect("internal commands should not touch startup state"); + } + #[test] #[serial] fn provider_commands_still_fail_on_future_schema_database() { diff --git a/src-tauri/src/services/provider/codex.rs b/src-tauri/src/services/provider/codex.rs index 262e0981..334bdd1e 100644 --- a/src-tauri/src/services/provider/codex.rs +++ b/src-tauri/src/services/provider/codex.rs @@ -1,6 +1,77 @@ use super::*; +use std::fs; +use std::path::Path; impl ProviderService { + pub(crate) fn capture_codex_temp_launch_snapshot( + state: &AppState, + provider_id: &str, + codex_home: &Path, + ) -> Result<(), AppError> { + let (provider, common_snippet) = { + let guard = state.config.read().map_err(AppError::from)?; + let provider = guard + .get_manager(&AppType::Codex) + .and_then(|manager| manager.providers.get(provider_id)) + .cloned() + .ok_or_else(|| { + AppError::localized( + "provider.not_found", + format!("供应商不存在: {provider_id}"), + format!("Provider not found: {provider_id}"), + ) + })?; + (provider, guard.common_config_snippets.codex.clone()) + }; + + let config_path = codex_home.join("config.toml"); + let cfg_text = if config_path.exists() { + fs::read_to_string(&config_path).map_err(|err| AppError::io(&config_path, err))? + } else { + provider + .settings_config + .get("config") + .and_then(Value::as_str) + .unwrap_or_default() + .to_string() + }; + crate::codex_config::validate_config_toml(&cfg_text)?; + let cfg_text_for_storage = Self::strip_codex_mcp_servers_from_snapshot_config(&cfg_text)?; + + let auth_path = codex_home.join("auth.json"); + let auth = if auth_path.exists() { + read_json_file::(&auth_path)? + } else { + Value::Object(serde_json::Map::new()) + }; + + let mut raw_settings = serde_json::Map::new(); + raw_settings.insert("auth".to_string(), auth); + raw_settings.insert("config".to_string(), Value::String(cfg_text_for_storage)); + + let mut settings_to_store = Self::normalize_settings_config_for_storage( + &AppType::Codex, + &provider, + Value::Object(raw_settings), + common_snippet.as_deref(), + )?; + Self::restore_codex_model_provider_for_storage_best_effort( + &provider, + &mut settings_to_store, + ); + + { + let mut guard = state.config.write().map_err(AppError::from)?; + if let Some(manager) = guard.get_manager_mut(&AppType::Codex) { + if let Some(target) = manager.providers.get_mut(provider_id) { + target.settings_config = settings_to_store; + } + } + } + + state.save() + } + pub(super) fn extract_codex_common_config_from_config_toml( config_toml: &str, ) -> Result { @@ -347,7 +418,6 @@ impl ProviderService { let auth = settings .get("auth") .ok_or_else(|| AppError::Config("Codex 供应商配置缺少 'auth' 字段".to_string()))?; - let cfg_text = settings .get("config") .and_then(Value::as_str) @@ -355,7 +425,17 @@ impl ProviderService { AppError::Config("Codex 供应商配置缺少 'config' 字段或不是字符串".to_string()) })?; - crate::codex_config::write_codex_live_atomic_with_stable_provider(auth, Some(cfg_text))?; + let auth_to_write = if Self::is_codex_official_provider(provider) + && auth.as_object().is_some_and(|auth| auth.is_empty()) + { + None + } else { + Some(auth) + }; + crate::codex_config::write_codex_live_atomic_optional_auth_with_stable_provider( + auth_to_write, + Some(cfg_text), + )?; Ok(()) } diff --git a/src-tauri/src/services/provider/tests.rs b/src-tauri/src/services/provider/tests.rs index 3b9d9613..59413741 100644 --- a/src-tauri/src/services/provider/tests.rs +++ b/src-tauri/src/services/provider/tests.rs @@ -61,6 +61,106 @@ fn with_common_enabled(mut provider: Provider) -> Provider { provider } +#[test] +fn capture_codex_temp_launch_snapshot_persists_auth_and_config() { + let mut config = MultiAppConfig::default(); + config.ensure_app(&AppType::Codex); + { + let manager = config + .get_manager_mut(&AppType::Codex) + .expect("codex manager"); + manager.current = "official".to_string(); + manager.providers.insert( + "official".to_string(), + Provider::with_id( + "official".to_string(), + "OpenAI Official".to_string(), + codex_settings("model_reasoning_effort = \"medium\"\n"), + None, + ), + ); + } + let state = state_from_config(config); + let temp = TempDir::new().expect("create temp codex home"); + std::fs::write( + temp.path().join("auth.json"), + r#"{"tokens":{"access_token":"new-access","refresh_token":"new-refresh"}}"#, + ) + .expect("write auth"); + std::fs::write( + temp.path().join("config.toml"), + "model_reasoning_effort = \"high\"\n[mcp_servers.temp]\ncommand = \"npx\"\n", + ) + .expect("write config"); + + ProviderService::capture_codex_temp_launch_snapshot(&state, "official", temp.path()) + .expect("capture temp launch snapshot"); + + let providers = ProviderService::list(&state, AppType::Codex).expect("list providers"); + let provider = providers.get("official").expect("provider should remain"); + assert_eq!( + provider + .settings_config + .get("auth") + .and_then(|value| value.pointer("/tokens/refresh_token")) + .and_then(Value::as_str), + Some("new-refresh") + ); + let stored_config = provider + .settings_config + .get("config") + .and_then(Value::as_str) + .expect("stored config"); + assert!(stored_config.contains("model_reasoning_effort = \"high\"")); + assert!( + !stored_config.contains("mcp_servers"), + "runtime MCP tables should not be backfilled into provider snapshots" + ); +} + +#[test] +fn capture_codex_temp_launch_snapshot_clears_auth_when_auth_file_is_missing() { + let mut config = MultiAppConfig::default(); + config.ensure_app(&AppType::Codex); + { + let manager = config + .get_manager_mut(&AppType::Codex) + .expect("codex manager"); + manager.current = "official".to_string(); + manager.providers.insert( + "official".to_string(), + Provider::with_id( + "official".to_string(), + "OpenAI Official".to_string(), + codex_settings("model_reasoning_effort = \"medium\"\n"), + None, + ), + ); + } + let state = state_from_config(config); + let temp = TempDir::new().expect("create temp codex home"); + std::fs::write( + temp.path().join("config.toml"), + "model_reasoning_effort = \"high\"\n", + ) + .expect("write config"); + + ProviderService::capture_codex_temp_launch_snapshot(&state, "official", temp.path()) + .expect("capture temp launch snapshot"); + + let providers = ProviderService::list(&state, AppType::Codex).expect("list providers"); + let provider = providers.get("official").expect("provider should remain"); + let auth = provider + .settings_config + .get("auth") + .and_then(Value::as_object) + .expect("stored auth should remain explicit"); + assert!( + auth.is_empty(), + "missing temporary auth.json should clear the saved auth snapshot" + ); +} + fn setup_switched_codex_state_with_managed_mcp() -> (TempDir, EnvGuard, AppState) { let temp_home = TempDir::new().expect("create temp home"); let env = EnvGuard::set_home(temp_home.path()); @@ -485,6 +585,68 @@ fn codex_switch_overwrites_existing_auth_json_for_openai_official_provider() { ); } +#[test] +#[serial] +fn codex_switch_removes_empty_auth_json_for_openai_official_provider() { + let temp_home = TempDir::new().expect("create temp home"); + let _env = EnvGuard::set_home(temp_home.path()); + std::fs::create_dir_all(crate::codex_config::get_codex_config_dir()) + .expect("create ~/.codex (initialized)"); + + let auth_path = crate::codex_config::get_codex_auth_path(); + crate::config::write_json_file(&auth_path, &json!({ "OPENAI_API_KEY": "sk-existing" })) + .expect("write auth.json"); + + let mut config = MultiAppConfig::default(); + config.ensure_app(&AppType::Codex); + { + let manager = config + .get_manager_mut(&AppType::Codex) + .expect("codex manager"); + manager.current = "p1".to_string(); + manager.providers.insert( + "p1".to_string(), + Provider::with_id( + "p1".to_string(), + "Third Party".to_string(), + json!({ + "auth": { "OPENAI_API_KEY": "sk-third-party" }, + "config": "model_provider = \"thirdparty\"\nmodel = \"gpt-5.2-codex\"\n\n[model_providers.thirdparty]\nbase_url = \"https://third-party.example/v1\"\nwire_api = \"responses\"\nrequires_openai_auth = true\n", + }), + None, + ), + ); + + let mut official = Provider::with_id( + "codex-official".to_string(), + "OpenAI Official".to_string(), + json!({ + "auth": {}, + "config": "", + }), + None, + ); + official.category = Some("official".to_string()); + official.meta = Some(crate::provider::ProviderMeta { + codex_official: Some(true), + ..Default::default() + }); + manager + .providers + .insert("codex-official".to_string(), official); + } + + let state = state_from_config(config); + + ProviderService::switch(&state, AppType::Codex, "codex-official") + .expect("switch to official should succeed without saved auth"); + + assert!( + !auth_path.exists(), + "empty official auth snapshot should remove live auth.json so Codex can prompt login" + ); +} + #[test] #[serial] fn codex_switch_preserves_base_url_and_wire_api_across_multiple_switches() {