diff --git a/src-tauri/src/codex_config.rs b/src-tauri/src/codex_config.rs index 116665c7b..19c408552 100644 --- a/src-tauri/src/codex_config.rs +++ b/src-tauri/src/codex_config.rs @@ -2705,6 +2705,16 @@ fn normalize_codex_legacy_openai_reroute(config_text: &str) -> Result Result<(), /// /// 统一会话开关开启时,官方配置在落盘前注入共享的 `custom` 路由 /// (见 `inject_codex_unified_session_bucket`)。 -pub fn write_codex_live_for_provider( +/// A computed Codex live write. All validation (legacy-shape normalization, +/// safety gates, token injection, TOML parsing) happens while building the +/// plan, so callers can preflight a switch — build and discard — before +/// committing any state, then execute the same computation for the real +/// write. Keeping validation and execution in one builder makes it +/// impossible for the two to drift apart. +struct CodexLiveWritePlan { + write_full_auth: bool, + config_text: Option, + remove_auth_file: bool, +} + +fn plan_codex_live_write( category: Option<&str>, auth: &Value, config_text: Option<&str>, -) -> Result<(), AppError> { +) -> Result { if category == Some("official") { let unified_official_config = if crate::settings::unify_codex_session_history() { Some(inject_codex_unified_session_bucket( @@ -3205,11 +3227,11 @@ pub fn write_codex_live_for_provider( // Official cards own auth.json: a material-carrying login is written // in full, a material-less card follows the live login and only // writes config. Official auth never travels through config.toml. - return if codex_auth_has_login_material(auth) { - write_codex_live_atomic(auth, config_text) - } else { - write_codex_live_config_atomic(config_text) - }; + return Ok(CodexLiveWritePlan { + write_full_auth: codex_auth_has_login_material(auth), + config_text: config_text.map(str::to_string), + remove_auth_file: false, + }); } // Third-party switches are config-only. Since Codex 0.149 @@ -3229,13 +3251,22 @@ pub fn write_codex_live_for_provider( // The legacy reroute shape (built-in `openai` provider + top-level // `openai_base_url`) has no provider table to carry the key — rewrite it // into a cc-switch-owned custom table before the safety gates run. + // prepare_codex_provider_live_config normalizes again internally + // (idempotent); the gates need the normalized text here. let normalized = match config_text { Some(text) if carried_key.is_some() => normalize_codex_legacy_openai_reroute(text)?, _ => None, }; let config_text = normalized.as_deref().or(config_text); - match config_text { + // The preservation setting now means exactly one thing: does the + // official login in auth.json survive a third-party switch? Off means + // the file is deleted — a lingering login next to a third-party route is + // the leak shape the gates exist to prevent, and `{}` is not logout, the + // file must go (see clear_stale_codex_live_auth_after_official_switch). + let remove_auth_file = !crate::settings::preserve_codex_official_auth_on_switch(); + + let live_config = match config_text { Some(text) if !text.trim().is_empty() => { // Both safety gates protect the same invariant: the auth Codex // resolves for a third-party route must never come from @@ -3257,26 +3288,46 @@ pub fn write_codex_live_for_provider( "This Codex config has no usable API key, and requires_openai_auth = true (or a top-level openai_base_url) would make Codex fall back to whatever login auth.json holds for a third-party route. Add an API key to the provider or remove the fallback directive", )); } - let live_config = prepare_codex_provider_live_config(auth, text)?; - write_codex_live_config_atomic(Some(&live_config))?; + prepare_codex_provider_live_config(auth, text)? } - other => { - // Empty config: with a key to carry this errs inside - // set_codex_experimental_bearer_token (no table to attach it - // to); without a key the empty config is written as-is. - let live_config = prepare_codex_provider_live_config(auth, other.unwrap_or(""))?; - write_codex_live_config_atomic(Some(&live_config))?; - } - } + // Empty config: with a key to carry this errs inside + // set_codex_experimental_bearer_token (no table to attach it to); + // without a key the empty config is passed through as-is. + other => prepare_codex_provider_live_config(auth, other.unwrap_or(""))?, + }; - // The preservation setting now means exactly one thing: does the - // official login in auth.json survive a third-party switch? Off means - // the file is deleted — a lingering login next to a third-party route is - // the leak shape the gates exist to prevent, and `{}` is not logout, the - // file must go (see clear_stale_codex_live_auth_after_official_switch). + Ok(CodexLiveWritePlan { + write_full_auth: false, + config_text: Some(live_config), + remove_auth_file, + }) +} + +/// Validate a Codex live write without touching the filesystem. Callers use +/// this to fail a provider switch BEFORE committing `current`: a write-layer +/// refusal after `current` moved would let the next switch backfill the old +/// live config into the new provider's DB row. +pub fn preflight_codex_live_write( + category: Option<&str>, + auth: &Value, + config_text: Option<&str>, +) -> Result<(), AppError> { + plan_codex_live_write(category, auth, config_text).map(|_| ()) +} + +pub fn write_codex_live_for_provider( + category: Option<&str>, + auth: &Value, + config_text: Option<&str>, +) -> Result<(), AppError> { + let plan = plan_codex_live_write(category, auth, config_text)?; + if plan.write_full_auth { + return write_codex_live_atomic(auth, plan.config_text.as_deref()); + } + write_codex_live_config_atomic(plan.config_text.as_deref())?; // Config is already committed at this point, so a cleanup failure // degrades to a warning instead of reporting an unswitched state. - if !crate::settings::preserve_codex_official_auth_on_switch() { + if plan.remove_auth_file { remove_codex_live_auth_after_third_party_switch(); } Ok(()) @@ -3298,6 +3349,13 @@ fn remove_codex_live_auth_after_third_party_switch() { /// requests can use a provider-scoped `experimental_bearer_token`, so switching /// providers only needs to update `config.toml`; `auth.json` stays as the user's /// long-lived ChatGPT login cache. +/// +/// This is the single normalize→inject entry point: every caller — provider +/// switches, takeover backup rebuilds (`preserve_codex_auth_in_backup`), and +/// restore (`preserve_codex_oauth_login_on_restore`) — gets the legacy +/// reroute migration, so a pre-0.149 `openai_base_url` shape can never leave +/// its key in a top-level field Codex ignores while auth.json credentials +/// stay live. Idempotent on already-normalized text. pub fn prepare_codex_provider_live_config( auth: &Value, config_text: &str, @@ -3305,10 +3363,12 @@ pub fn prepare_codex_provider_live_config( let token = extract_codex_auth_api_key(auth) .or_else(|| extract_codex_experimental_bearer_token(config_text)); - Ok(match token { - Some(token) => set_codex_experimental_bearer_token(config_text, &token)?, - None => config_text.to_string(), - }) + let Some(token) = token else { + return Ok(config_text.to_string()); + }; + let normalized = normalize_codex_legacy_openai_reroute(config_text)?; + let config_text = normalized.as_deref().unwrap_or(config_text); + set_codex_experimental_bearer_token(config_text, &token) } /// During DB backfill, lift a live `experimental_bearer_token` back into @@ -3389,6 +3449,23 @@ pub fn update_codex_toml_field(toml_str: &str, field: &str, value: &str) -> Resu .map(str::to_string); if let Some(provider_key) = model_provider { + // 0.149 的 validate_reserved_model_provider_ids 对配置里出现 + // `[model_providers.openai]` 等保留 id 表整份报错("Built-in + // providers cannot be overridden"),Codex 直接起不来。内置 + // openai 的改址走它的正统机制——顶层 `openai_base_url`; + // wire_api 由 CLI 内置固定,无需写。其他保留 id(ollama 等) + // 没有等价旋钮,维持既有行为不在此扩大改动。 + if provider_key.eq_ignore_ascii_case("openai") { + if field == "base_url" { + if trimmed.is_empty() { + doc.as_table_mut().remove("openai_base_url"); + } else { + doc["openai_base_url"] = toml_edit::value(trimmed); + } + } + return Ok(doc.to_string()); + } + // Ensure [model_providers] table exists // // 用 as_table_like_mut 而非 as_table_mut:用户把配置写成 inline table @@ -4491,6 +4568,87 @@ openai_base_url = "https://relay.example/v1" } } + #[test] + fn legacy_reroute_normalization_never_overwrites_a_user_cc_switch_table() { + // A user-authored [model_providers.cc-switch] proves nothing about + // ownership — overwriting it would drop their headers/query params + // and backfill the loss into the DB. The shape is left to the safety + // gates instead. + let conflicted = r#"model_provider = "openai" +openai_base_url = "https://relay.example/v1" + +[model_providers.cc-switch] +name = "Mine" +base_url = "https://mine.example/v1" +http_headers = { x-team = "42" } +"#; + assert!( + normalize_codex_legacy_openai_reroute(conflicted) + .expect("normalize") + .is_none(), + "an existing cc-switch table must never be overwritten" + ); + // The gate then refuses the shape with an actionable error. + assert!(codex_config_routes_third_party_without_token_slot( + conflicted + )); + } + + #[test] + fn prepare_normalizes_legacy_reroute_for_every_caller() { + // prepare_codex_provider_live_config is the single normalize→inject + // entry point — takeover backup rebuilds and restore call it directly, + // so the legacy shape must be migrated here, not only in the switch + // path's plan. + let legacy = r#"model_provider = "openai" +model = "gpt-5.4" +openai_base_url = "https://relay.example/v1" +"#; + let prepared = + prepare_codex_provider_live_config(&json!({"OPENAI_API_KEY": "sk-test"}), legacy) + .expect("prepare live config"); + assert!( + !prepared.contains("openai_base_url") + && prepared.contains("[model_providers.cc-switch]"), + "prepare must rewrite the legacy reroute shape; got:\n{prepared}" + ); + assert_eq!( + extract_codex_experimental_bearer_token(&prepared).as_deref(), + Some("sk-test"), + "the key must land in the rewritten provider table" + ); + // No token → nothing to protect, the shape passes through untouched. + let untouched = prepare_codex_provider_live_config(&json!({}), legacy) + .expect("prepare live config without token"); + assert_eq!(untouched, legacy); + } + + #[test] + fn update_toml_field_reroutes_built_in_openai_via_top_level_knob() { + // Codex 0.149 refuses any [model_providers.openai] table outright + // (validate_reserved_model_provider_ids), so rewriting base_url for + // the built-in provider must use the top-level openai_base_url knob. + let input = "model_provider = \"openai\"\nmodel = \"gpt-5.4\"\n"; + let output = update_codex_toml_field(input, "base_url", "http://127.0.0.1:5000/v1") + .expect("update base_url"); + assert!( + !output.contains("[model_providers.openai]") && !output.contains("model_providers"), + "no reserved provider table may be created; got:\n{output}" + ); + assert!( + output.contains("openai_base_url = \"http://127.0.0.1:5000/v1\""), + "the reroute must use the top-level knob; got:\n{output}" + ); + + // Clearing the value removes the knob again. + let cleared = update_codex_toml_field(&output, "base_url", "").expect("clear base_url"); + assert!(!cleared.contains("openai_base_url")); + + // wire_api is fixed by the CLI for built-ins — a no-op, not a table. + let wire = update_codex_toml_field(input, "wire_api", "responses").expect("set wire_api"); + assert!(!wire.contains("model_providers")); + } + #[test] fn bedrock_runtime_is_a_reserved_provider_id() { // `amazon-bedrock-runtime` is reserved by Codex 0.149; treating it as diff --git a/src-tauri/src/services/provider/live.rs b/src-tauri/src/services/provider/live.rs index 036f8a71d..148e37a2c 100644 --- a/src-tauri/src/services/provider/live.rs +++ b/src-tauri/src/services/provider/live.rs @@ -714,6 +714,33 @@ pub(crate) fn write_live_with_common_config_for_state( ) } +/// Validate the target provider's Codex live projection without writing: +/// build the effective settings exactly like the live write would, then run +/// the write-layer plan (legacy normalization, safety gates, token +/// injection, TOML parsing). Called before `current` is committed — a +/// write-layer refusal after `current` moved would let the next switch +/// backfill the old live config into the new provider's DB row. +pub(crate) fn preflight_codex_live_write_for_state( + state: &AppState, + provider: &Provider, +) -> Result<(), AppError> { + let effective = build_effective_provider_for_live_with_codex_oauth_manager( + state.db.as_ref(), + &AppType::Codex, + provider, + &state.codex_oauth_manager, + )?; + let obj = effective + .settings_config + .as_object() + .ok_or_else(|| AppError::Config("Codex 供应商配置必须是 JSON 对象".to_string()))?; + let auth = obj + .get("auth") + .ok_or_else(|| AppError::Config("Codex 供应商配置缺少 'auth' 字段".to_string()))?; + let config_str = obj.get("config").and_then(|v| v.as_str()); + crate::codex_config::preflight_codex_live_write(effective.category.as_deref(), auth, config_str) +} + pub(crate) fn write_live_with_common_config_for_codex_oauth_manager( db: &Database, app_type: &AppType, diff --git a/src-tauri/src/services/provider/mod.rs b/src-tauri/src/services/provider/mod.rs index 9734ebdd7..07f8778e6 100644 --- a/src-tauri/src/services/provider/mod.rs +++ b/src-tauri/src/services/provider/mod.rs @@ -5327,6 +5327,15 @@ impl ProviderService { )); } } else { + // Codex: validate the live projection before committing current — + // the write-layer safety gates can refuse the switch, and a + // refusal after current moved would let the next switch backfill + // the old live config into the new provider's DB row. (The + // managed branch above has its own snapshot rollback instead.) + if matches!(app_type, AppType::Codex) && preflighted_provider.is_none() { + live::preflight_codex_live_write_for_state(state, provider)?; + } + // Additive mode apps skip setting is_current (no such concept). if !app_type.is_additive_mode() { crate::settings::set_current_provider(&app_type, Some(id))?; diff --git a/src-tauri/tests/provider_service.rs b/src-tauri/tests/provider_service.rs index a1e35b727..9ffd78ce9 100644 --- a/src-tauri/tests/provider_service.rs +++ b/src-tauri/tests/provider_service.rs @@ -1107,6 +1107,15 @@ base_url = "https://relay.example/v1" wire_api = "responses" requires_openai_auth = true http_headers = { Authorization = "Bearer explicit-header-token" } +"#; + + let good_config = r#"model_provider = "good" +model = "gpt-5.4" + +[model_providers.good] +name = "Good" +base_url = "https://good.example/v1" +wire_api = "responses" "#; let mut initial_config = MultiAppConfig::default(); @@ -1114,6 +1123,18 @@ http_headers = { Authorization = "Bearer explicit-header-token" } let manager = initial_config .get_manager_mut(&AppType::Codex) .expect("codex manager"); + manager.providers.insert( + "good".to_string(), + Provider::with_id( + "good".to_string(), + "Good".to_string(), + json!({ + "auth": {"OPENAI_API_KEY": "sk-good"}, + "config": good_config + }), + None, + ), + ); manager.providers.insert( "header-auth".to_string(), Provider::with_id( @@ -1130,9 +1151,24 @@ http_headers = { Authorization = "Bearer explicit-header-token" } let state = create_test_state_with_config(&initial_config).expect("create test state"); + ProviderService::switch(&state, AppType::Codex, "good").expect("switch to the good provider"); + ProviderService::switch(&state, AppType::Codex, "header-auth").expect_err( "preservation-on switch must fail when a keyless config falls back to the official auth", ); + + // The refusal happens in the pre-commit preflight: current must not move, + // otherwise the next switch would backfill the good provider's live + // config into the refused card's DB row. + let current = state + .db + .get_current_provider(AppType::Codex.as_str()) + .expect("read current provider"); + assert_eq!( + current.as_deref(), + Some("good"), + "a refused switch must leave current on the previous provider" + ); } #[test]