From 95c3002587bc3118ab2509531cc1a3383225a798 Mon Sep 17 00:00:00 2001 From: a328153009-droid <319430327+a328153009-droid@users.noreply.github.com> Date: Sat, 5 Sep 2026 15:59:58 +0800 Subject: [PATCH] fix: resolve historical repair targets from live provider config --- Cargo.lock | 1 + apps/codex-plus-launcher/src/main.rs | 34 +- .../src-tauri/src/commands.rs | 85 ++++- apps/codex-plus-manager/src/App.tsx | 47 ++- .../src/provider-sync-target.test.ts | 39 +++ .../src/provider-sync-target.ts | 23 ++ crates/codex-plus-data/Cargo.toml | 1 + crates/codex-plus-data/src/lib.rs | 2 +- crates/codex-plus-data/src/provider_sync.rs | 316 ++++++++++++++++-- crates/codex-plus-data/tests/provider_sync.rs | 303 +++++++++++++++-- 10 files changed, 769 insertions(+), 82 deletions(-) create mode 100644 apps/codex-plus-manager/src/provider-sync-target.test.ts create mode 100644 apps/codex-plus-manager/src/provider-sync-target.ts diff --git a/Cargo.lock b/Cargo.lock index 272f033d2..d593b9bd1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -461,6 +461,7 @@ dependencies = [ "sha2", "tempfile", "thiserror 2.0.18", + "toml 0.8.2", "uuid", ] diff --git a/apps/codex-plus-launcher/src/main.rs b/apps/codex-plus-launcher/src/main.rs index e5fa16d8d..47019c732 100644 --- a/apps/codex-plus-launcher/src/main.rs +++ b/apps/codex-plus-launcher/src/main.rs @@ -355,10 +355,10 @@ impl LaunchHooks for LauncherHooks { } async fn run_provider_sync(&self) -> anyhow::Result<()> { - let _ = tokio::task::spawn_blocking(|| codex_plus_data::run_provider_sync(None)) + let result = tokio::task::spawn_blocking(|| codex_plus_data::run_provider_sync(None)) .await .map_err(|error| anyhow::anyhow!("provider sync task failed: {error}"))?; - Ok(()) + require_completed_provider_sync(&result.status, &result.message) } fn has_pending_remote_control_session_recoveries(&self) -> bool { @@ -607,6 +607,16 @@ impl LaunchHooks for LauncherHooks { } } +fn require_completed_provider_sync( + status: &codex_plus_data::ProviderSyncStatus, + message: &str, +) -> anyhow::Result<()> { + if *status == codex_plus_data::ProviderSyncStatus::Synced { + return Ok(()); + } + anyhow::bail!("provider sync did not complete ({status:?}): {message}") +} + #[derive(Debug, Clone)] struct LauncherDataService { db_path: PathBuf, @@ -1136,6 +1146,26 @@ mod tests { assert_eq!(options.helper_port, LaunchOptions::default().helper_port); } + #[test] + fn launcher_accepts_only_a_completed_provider_sync() { + assert!( + require_completed_provider_sync( + &codex_plus_data::ProviderSyncStatus::Synced, + "Provider sync complete", + ) + .is_ok() + ); + + for status in [ + codex_plus_data::ProviderSyncStatus::Disabled, + codex_plus_data::ProviderSyncStatus::Skipped, + ] { + let error = require_completed_provider_sync(&status, "target is unresolved") + .expect_err("an incomplete provider sync must stop launch"); + assert!(error.to_string().contains("target is unresolved")); + } + } + #[test] fn launcher_uses_single_instance_guard_before_launching() { let source = include_str!("main.rs"); diff --git a/apps/codex-plus-manager/src-tauri/src/commands.rs b/apps/codex-plus-manager/src-tauri/src/commands.rs index a2127b524..0412fe856 100644 --- a/apps/codex-plus-manager/src-tauri/src/commands.rs +++ b/apps/codex-plus-manager/src-tauri/src/commands.rs @@ -2944,7 +2944,7 @@ pub async fn load_provider_sync_targets() -> CommandResult { } }) .collect::>(); - merge_manual_provider_sync_targets(&mut targets, &manual, &settings); + merge_manual_provider_sync_targets(&mut targets, &manual, &settings, None); ok( "Provider 同步目标已加载。", serde_json::to_value(targets).unwrap_or_else(|_| json!({})), @@ -2958,6 +2958,7 @@ fn merge_manual_provider_sync_targets( targets: &mut codex_plus_data::ProviderSyncTargetList, manual: &[String], settings: &BackendSettings, + codex_home: Option<&Path>, ) { for id in manual { if let Some(existing) = targets.targets.iter_mut().find(|target| target.id == *id) { @@ -2973,6 +2974,11 @@ fn merge_manual_provider_sync_targets( existing.is_manual = settings.provider_sync_manual_providers.contains(id); existing.is_saved = settings.provider_sync_saved_providers.contains(id); } else { + let (is_resolvable, unavailable_reason) = + match codex_plus_data::validate_provider_sync_target(codex_home, Some(id)) { + Ok(_) => (true, None), + Err(reason) => (false, Some(reason)), + }; targets .targets .push(codex_plus_data::ProviderSyncTargetOption { @@ -2981,6 +2987,8 @@ fn merge_manual_provider_sync_targets( is_current_provider: *id == targets.current_provider, is_manual: settings.provider_sync_manual_providers.contains(id), is_saved: settings.provider_sync_saved_providers.contains(id), + is_resolvable, + unavailable_reason, }); } } @@ -3056,6 +3064,21 @@ pub async fn sync_providers_now(target_provider: Option) -> CommandResul let target_provider = target_provider .map(|value| value.trim().to_string()) .filter(|value| !value.is_empty()); + let target_for_validation = target_provider.clone(); + match tauri::async_runtime::spawn_blocking(move || { + codex_plus_data::validate_provider_sync_target(None, target_for_validation.as_deref()) + }) + .await + { + // Keep None as "use current" so the sync reads the live selection again. + Ok(Ok(_)) => {} + Ok(Err(message)) => return provider_sync_preflight_failure(&message), + Err(error) => { + return provider_sync_preflight_failure(&format!( + "provider target validation task failed: {error}" + )); + } + }; let target_for_settings = target_provider.clone(); let home = codex_plus_core::relay_config::default_codex_home_dir(); prepare_codex_app_state_before_provider_switch(&home, "manager.sync_providers_now.before"); @@ -3100,6 +3123,17 @@ pub async fn sync_providers_now(target_provider: Option) -> CommandResul } } +fn provider_sync_preflight_failure(message: &str) -> CommandResult { + failed( + &format!("供应商同步未执行:{message}"), + json!({ + "syncStatus": "skipped", + "targetProvider": "", + "syncMessage": message, + }), + ) +} + fn is_success_sync_status(status: &codex_plus_data::ProviderSyncStatus) -> bool { matches!(status, codex_plus_data::ProviderSyncStatus::Synced) } @@ -6227,6 +6261,55 @@ mod tests { assert_eq!(result.payload["syncStatus"], "skipped"); } + #[test] + fn provider_sync_preflight_failure_is_structured_as_skipped() { + let result = provider_sync_preflight_failure("target is not resolvable"); + + assert_eq!(result.status, "failed"); + assert_eq!(result.payload["syncStatus"], "skipped"); + assert_eq!(result.payload["targetProvider"], ""); + assert_eq!(result.payload["syncMessage"], "target is not resolvable"); + } + + #[test] + fn manual_provider_sync_targets_keep_unresolvable_history_visible() { + let temp = tempfile::tempdir().unwrap(); + std::fs::write( + temp.path().join("config.toml"), + r#"model_provider = "relay-live" + +[model_providers.relay-live] +base_url = "https://example.invalid/v1" +"#, + ) + .unwrap(); + let mut settings = BackendSettings::default(); + settings.provider_sync_manual_providers = + vec!["relay-live".to_string(), "relay-history".to_string()]; + let manual = settings.provider_sync_manual_providers.clone(); + let mut targets = codex_plus_data::ProviderSyncTargetList { + current_provider: "relay-live".to_string(), + targets: Vec::new(), + }; + + merge_manual_provider_sync_targets(&mut targets, &manual, &settings, Some(temp.path())); + + let live = targets + .targets + .iter() + .find(|target| target.id == "relay-live") + .unwrap(); + assert!(live.is_resolvable); + assert!(live.unavailable_reason.is_none()); + let history = targets + .targets + .iter() + .find(|target| target.id == "relay-history") + .unwrap(); + assert!(!history.is_resolvable); + assert!(history.unavailable_reason.is_some()); + } + #[test] fn provider_sync_synced_is_reported_as_command_success() { let result = provider_sync_command_result(provider_sync_result_for_test( diff --git a/apps/codex-plus-manager/src/App.tsx b/apps/codex-plus-manager/src/App.tsx index b9ea427df..3c9812674 100644 --- a/apps/codex-plus-manager/src/App.tsx +++ b/apps/codex-plus-manager/src/App.tsx @@ -111,6 +111,7 @@ import { } from "./model-windows"; import { relayAuthForLiveDraft, shouldBackfillRelayProfileBeforeSwitch } from "./relay-live-files"; import { resolveProviderSyncCompletion } from "./provider-sync-flow"; +import { isProviderSyncTargetSelectable, preferredProviderSyncTarget } from "./provider-sync-target"; import { resolveLaunchStatus } from "./launch-status"; import { defaultDreamSkinTheme, @@ -708,6 +709,8 @@ type ProviderSyncTargetOption = { isCurrentProvider: boolean; isManual: boolean; isSaved: boolean; + isResolvable: boolean; + unavailableReason: string | null; }; type ProviderSyncTargetsPayload = { @@ -840,7 +843,8 @@ const providerSyncSourceLabels: Record = { function providerSyncTargetLabel(target: ProviderSyncTargetOption): string { const labels = target.sources.map((source) => providerSyncSourceLabels[source]).filter(Boolean); const current = target.isCurrentProvider ? [t("当前")] : []; - return [...labels, ...current].join(" / ") || t("发现"); + const unavailable = isProviderSyncTargetSelectable(target) ? [] : [t("供应商切换不可用")]; + return [...labels, ...current, ...unavailable].join(" / ") || t("发现"); } function syncMarketInstalledState(current: ScriptMarketResult | null, userScripts: UserScriptInventory): ScriptMarketResult | null { @@ -2403,12 +2407,8 @@ export function App() { setProviderSyncTargets(result); const targets = result.targets ?? []; const saved = settingsForm.providerSyncLastSelectedProvider; - const preferred = - targets.find((target) => target.id === saved)?.id || - targets.find((target) => target.isCurrentProvider)?.id || - targets[0]?.id || - "openai"; - setSelectedProviderSyncTarget((current) => (targets.some((target) => target.id === current) ? current : preferred)); + const preferred = preferredProviderSyncTarget(targets, result.currentProvider, saved); + setSelectedProviderSyncTarget(preferred); if (!silent && !isSuccessStatus(result.status)) showNotice(t("Provider 同步目标"), result.message, result.status); } return result; @@ -3184,7 +3184,6 @@ export function App() { refreshProviderSyncTargets, setProviderSyncTarget: (provider: string) => { setSelectedProviderSyncTarget(provider); - setSettingsForm((current) => ({ ...current, providerSyncLastSelectedProvider: provider })); }, setLaunchMode: async (launchMode: LaunchMode) => { await saveLaunchMode(launchMode); @@ -5909,6 +5908,13 @@ function SessionsScreen({ const selectedSessions = useMemo(() => items.filter((session) => selectedSessionIds.has(session.id)), [items, selectedSessionIds]); const selectedCount = selectedSessions.length; const allSelected = items.length > 0 && selectedCount === items.length; + const providerTargets = providerSyncTargets?.targets ?? []; + const selectedProviderTarget = providerTargets.find( + (target) => target.id === selectedProviderSyncTarget, + ); + const canRepairProviderSessions = selectedProviderTarget + ? isProviderSyncTargetSelectable(selectedProviderTarget) + : false; useEffect(() => { const itemIds = new Set(items.map((session) => session.id)); @@ -5981,15 +5987,22 @@ function SessionsScreen({
actions.setProviderSyncTarget(value)} options={ - (providerSyncTargets?.targets ?? []).length - ? (providerSyncTargets?.targets ?? []).map((target) => ({ - value: target.id, - label: `${target.id}${t("(")}${providerSyncTargetLabel(target)}${t(")")}`, - })) + providerTargets.length + ? [ + ...(!selectedProviderSyncTarget + ? [{ value: "", label: t("当前配置 provider"), disabled: true }] + : []), + ...providerTargets.map((target) => ({ + value: target.id, + label: `${target.id}${t("(")}${providerSyncTargetLabel(target)}${t(")")}`, + disabled: !isProviderSyncTargetSelectable(target), + title: target.unavailableReason ?? undefined, + })), + ] : [{ value: "", label: t("当前配置 provider"), disabled: true }] } /> @@ -6017,7 +6030,11 @@ function SessionsScreen({ {t("导入文件")} - diff --git a/apps/codex-plus-manager/src/provider-sync-target.test.ts b/apps/codex-plus-manager/src/provider-sync-target.test.ts new file mode 100644 index 000000000..470e3a3e4 --- /dev/null +++ b/apps/codex-plus-manager/src/provider-sync-target.test.ts @@ -0,0 +1,39 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + isProviderSyncTargetSelectable, + preferredProviderSyncTarget, +} from "./provider-sync-target.ts"; + +test("the exact current provider takes priority over a saved provider", () => { + const targets = [ + { id: "relay-alpha", isCurrentProvider: true, isResolvable: true }, + { id: "relay-beta", isCurrentProvider: false, isResolvable: true }, + ]; + + assert.equal(preferredProviderSyncTarget(targets, "relay-alpha", "relay-beta"), "relay-alpha"); +}); + +test("a resolvable saved provider is used when the current provider is unavailable", () => { + const targets = [ + { id: "relay-alpha", isCurrentProvider: true, isResolvable: false }, + { id: "relay-beta", isCurrentProvider: false, isResolvable: true }, + ]; + + assert.equal(preferredProviderSyncTarget(targets, "relay-alpha", "relay-beta"), "relay-beta"); +}); + +test("history-only and legacy targets fail closed", () => { + const historyOnly = { id: "relay-history", isCurrentProvider: false, isResolvable: false }; + const legacy = { id: "relay-legacy", isCurrentProvider: false }; + + assert.equal(isProviderSyncTargetSelectable(historyOnly), false); + assert.equal( + isProviderSyncTargetSelectable( + legacy as unknown as Parameters[0], + ), + false, + ); + assert.equal(preferredProviderSyncTarget([historyOnly], "relay-history", "relay-history"), ""); +}); diff --git a/apps/codex-plus-manager/src/provider-sync-target.ts b/apps/codex-plus-manager/src/provider-sync-target.ts new file mode 100644 index 000000000..2e653e87e --- /dev/null +++ b/apps/codex-plus-manager/src/provider-sync-target.ts @@ -0,0 +1,23 @@ +export type ProviderSyncSelectableTarget = { + id: string; + isCurrentProvider: boolean; + isResolvable: boolean; +}; + +export function isProviderSyncTargetSelectable(target: ProviderSyncSelectableTarget): boolean { + return target.isResolvable === true; +} + +export function preferredProviderSyncTarget( + targets: readonly ProviderSyncSelectableTarget[], + currentProvider: string, + savedProvider: string, +): string { + return ( + targets.find((target) => target.id === currentProvider && isProviderSyncTargetSelectable(target))?.id ?? + targets.find((target) => target.isCurrentProvider && isProviderSyncTargetSelectable(target))?.id ?? + targets.find((target) => target.id === savedProvider && isProviderSyncTargetSelectable(target))?.id ?? + targets.find(isProviderSyncTargetSelectable)?.id ?? + "" + ); +} diff --git a/crates/codex-plus-data/Cargo.toml b/crates/codex-plus-data/Cargo.toml index 6ad7f59ed..6217a8248 100644 --- a/crates/codex-plus-data/Cargo.toml +++ b/crates/codex-plus-data/Cargo.toml @@ -16,6 +16,7 @@ serde.workspace = true serde_json = { workspace = true, features = ["preserve_order"] } sha2.workspace = true thiserror.workspace = true +toml.workspace = true uuid.workspace = true [dev-dependencies] diff --git a/crates/codex-plus-data/src/lib.rs b/crates/codex-plus-data/src/lib.rs index 0c63033a3..ff731363e 100644 --- a/crates/codex-plus-data/src/lib.rs +++ b/crates/codex-plus-data/src/lib.rs @@ -15,6 +15,6 @@ pub use provider_sync::{ run_provider_sync_with_target, run_remote_control_session_catalog_recovery_for_thread_with_target, run_remote_control_session_finalization_for_thread_with_target, - try_acquire_provider_sync_lifecycle_guard, + try_acquire_provider_sync_lifecycle_guard, validate_provider_sync_target, }; pub use storage::{LocalSession, SQLiteStorageAdapter, delete_local_from_paths}; diff --git a/crates/codex-plus-data/src/provider_sync.rs b/crates/codex-plus-data/src/provider_sync.rs index de2fdcd2b..b7d6d3464 100644 --- a/crates/codex-plus-data/src/provider_sync.rs +++ b/crates/codex-plus-data/src/provider_sync.rs @@ -296,6 +296,10 @@ pub struct ProviderSyncTargetOption { pub is_current_provider: bool, pub is_manual: bool, pub is_saved: bool, + #[serde(default)] + pub is_resolvable: bool, + #[serde(default)] + pub unavailable_reason: Option, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] @@ -771,6 +775,23 @@ pub fn run_provider_sync_with_target( let home = codex_home .map(Path::to_path_buf) .unwrap_or_else(default_codex_home_dir); + run_provider_sync_with_target_in_home( + home, + explicit_target_provider, + require_stopped_app, + || {}, + ) +} + +fn run_provider_sync_with_target_in_home( + home: PathBuf, + explicit_target_provider: Option<&str>, + require_stopped_app: bool, + before_first_write: BeforeFirstWrite, +) -> ProviderSyncResult +where + BeforeFirstWrite: FnOnce(), +{ if !home.exists() { return result( ProviderSyncStatus::Skipped, @@ -781,20 +802,22 @@ pub fn run_provider_sync_with_target( 0, ); } - let target_provider = - match resolve_target_provider(&home.join("config.toml"), explicit_target_provider) { - Ok(provider) => provider, + let config_path = home.join("config.toml"); + let target_snapshot = + match resolve_provider_sync_target_snapshot(&config_path, explicit_target_provider) { + Ok(snapshot) => snapshot, Err(message) => { return result( ProviderSyncStatus::Skipped, message, - DEFAULT_PROVIDER, + &safe_explicit_target_provider(explicit_target_provider), None, 0, 0, ); } }; + let target_provider = target_snapshot.target_provider.clone(); if require_stopped_app { let running_processes = codex_plus_core::watcher::find_session_index_cleanup_blocking_processes(); @@ -894,6 +917,12 @@ pub fn run_provider_sync_with_target( && catalog_repair_count == 0 && global_state_update_count == 0 { + revalidate_provider_sync_target_snapshot( + &config_path, + explicit_target_provider, + &target_snapshot, + ) + .map_err(anyhow::Error::msg)?; let mut synced = result( ProviderSyncStatus::Synced, "Provider sync already up to date", @@ -909,6 +938,13 @@ pub fn run_provider_sync_with_target( provider_sync_message_with_audit(&synced.message, &synced.repair_audit); return Ok(synced); } + before_first_write(); + revalidate_provider_sync_target_snapshot( + &config_path, + explicit_target_provider, + &target_snapshot, + ) + .map_err(anyhow::Error::msg)?; let backup_dir = create_backup(&home, &target_provider, &rewrite_changes)?; let applied = apply_session_changes(&rewrite_changes)?; let apply_result = (|| -> anyhow::Result<(SqliteUpdateCounts, usize)> { @@ -1115,11 +1151,67 @@ fn collect_files_recursive(root: &Path, files: &mut Vec) -> anyhow::Res Ok(()) } +#[derive(Debug, Clone)] +struct LiveProviderConfig { + current_provider: Option, + configured_provider_ids: HashSet, + provider_table_ids: HashSet, +} + +impl LiveProviderConfig { + fn is_provider_resolvable(&self, provider: &str) -> bool { + is_valid_explicit_provider_id(provider) + && (provider == DEFAULT_PROVIDER || self.provider_table_ids.contains(provider)) + } + + fn validate_current_provider(&self) -> Result<(), String> { + let Some(current_provider) = self.current_provider.as_deref() else { + return Err( + "Current provider identity is missing or invalid in live config.toml".to_string(), + ); + }; + if !self.is_provider_resolvable(current_provider) { + return Err( + "Current provider is not resolvable from an exact live model provider table" + .to_string(), + ); + } + Ok(()) + } +} + +/// Secret-free provider identity inputs used to detect a config switch while history is scanned. +#[derive(Debug, Clone, PartialEq, Eq)] +struct ProviderSyncTargetSnapshot { + target_provider: String, + current_provider: Option, +} + +/// Resolves a repair target from live config without modifying configuration or history. +pub fn validate_provider_sync_target( + codex_home: Option<&Path>, + explicit_target_provider: Option<&str>, +) -> Result { + let home = codex_home + .map(Path::to_path_buf) + .unwrap_or_else(default_codex_home_dir); + if !home.exists() { + return Err(format!("Codex home not found: {}", home.to_string_lossy())); + } + resolve_provider_sync_target_snapshot(&home.join("config.toml"), explicit_target_provider) + .map(|snapshot| snapshot.target_provider) +} + pub fn load_provider_sync_targets(codex_home: Option<&Path>) -> ProviderSyncTargetList { let home = codex_home .map(Path::to_path_buf) .unwrap_or_else(default_codex_home_dir); - let current_provider = read_current_provider(&home.join("config.toml")); + let config_path = home.join("config.toml"); + let live_config = load_live_provider_config(&config_path); + let current_provider = match &live_config { + Ok(config) => config.current_provider.clone().unwrap_or_default(), + Err(_) => read_current_provider(&config_path), + }; let mut sources: HashMap> = HashMap::new(); fn add_sources( @@ -1137,7 +1229,10 @@ pub fn load_provider_sync_targets(codex_home: Option<&Path>) -> ProviderSyncTarg add_sources( &mut sources, - list_configured_provider_ids(&home.join("config.toml")), + live_config + .as_ref() + .map(|config| sorted_provider_ids(config.configured_provider_ids.clone())) + .unwrap_or_else(|_| list_configured_provider_ids(&config_path)), ProviderSyncTargetSource::Config, ); add_sources( @@ -1159,10 +1254,14 @@ pub fn load_provider_sync_targets(codex_home: Option<&Path>) -> ProviderSyncTarg .map(|(id, source_set)| { let mut source_list = source_set.into_iter().collect::>(); source_list.sort(); + let (is_resolvable, unavailable_reason) = + provider_resolution_for_discovery(&live_config, &id); ProviderSyncTargetOption { is_current_provider: id == current_provider, is_manual: source_list.contains(&ProviderSyncTargetSource::Manual), is_saved: false, + is_resolvable, + unavailable_reason, id, sources: source_list, } @@ -1183,38 +1282,139 @@ pub fn load_provider_sync_targets(codex_home: Option<&Path>) -> ProviderSyncTarg fn read_current_provider(path: &Path) -> String { let Ok(text) = fs::read_to_string(path) else { - return DEFAULT_PROVIDER.to_string(); + return String::new(); }; let provider = root_toml_string_value(&text, "model_provider").unwrap_or_default(); - if provider.trim().is_empty() { - DEFAULT_PROVIDER.to_string() - } else { - provider - } + let provider = provider.trim(); + is_valid_explicit_provider_id(provider) + .then(|| provider.to_string()) + .unwrap_or_default() } -fn resolve_target_provider( +fn resolve_provider_sync_target_snapshot( config_path: &Path, explicit_target_provider: Option<&str>, -) -> Result { - if let Some(raw) = explicit_target_provider { - let trimmed = raw.trim(); - if trimmed.is_empty() { - return Ok(read_current_provider(config_path)); - } +) -> Result { + let explicit_target_provider = explicit_target_provider + .map(str::trim) + .filter(|provider| !provider.is_empty()); + if let Some(trimmed) = explicit_target_provider { if !is_valid_explicit_provider_id(trimmed) { - return Err(format!("Invalid provider sync target: {trimmed:?}")); + return Err("Invalid provider sync target identity".to_string()); } - return Ok(trimmed.to_string()); } - Ok(read_current_provider(config_path)) + + let live_config = load_live_provider_config(config_path)?; + let target_provider = match explicit_target_provider { + Some(provider) => provider, + None => { + live_config.validate_current_provider()?; + live_config.current_provider.as_deref().ok_or_else(|| { + "Current provider identity is missing or invalid in live config.toml".to_string() + })? + } + }; + if !live_config.is_provider_resolvable(target_provider) { + return Err(format!( + "Provider sync target {target_provider:?} is not resolvable from an exact live model provider table" + )); + } + + Ok(ProviderSyncTargetSnapshot { + target_provider: target_provider.to_string(), + current_provider: live_config.current_provider, + }) +} + +fn safe_explicit_target_provider(explicit_target_provider: Option<&str>) -> String { + explicit_target_provider + .map(str::trim) + .filter(|provider| is_valid_explicit_provider_id(provider)) + .unwrap_or_default() + .to_string() +} + +fn revalidate_provider_sync_target_snapshot( + config_path: &Path, + explicit_target_provider: Option<&str>, + expected: &ProviderSyncTargetSnapshot, +) -> Result<(), String> { + let current = resolve_provider_sync_target_snapshot(config_path, explicit_target_provider) + .map_err(|_| { + "Live provider configuration changed or became unavailable during provider sync; retry" + .to_string() + })?; + if ¤t != expected { + return Err( + "Live provider configuration changed or became unavailable during provider sync; retry" + .to_string(), + ); + } + Ok(()) } fn is_valid_explicit_provider_id(value: &str) -> bool { - !value.is_empty() - && value - .chars() - .all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '_' | '-' | '.')) + !value.is_empty() && value.trim() == value && !value.chars().any(char::is_control) +} + +fn load_live_provider_config(path: &Path) -> Result { + let text = match fs::read_to_string(path) { + Ok(text) => text, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + return Err("Live config.toml was not found".to_string()); + } + Err(_) => return Err("Live config.toml could not be read safely".to_string()), + }; + let root = toml::from_str::(&text) + .map_err(|_| "Live config.toml could not be parsed safely".to_string())?; + let current_provider = match root.get("model_provider") { + None => Some(DEFAULT_PROVIDER.to_string()), + Some(value) => value + .as_str() + .filter(|provider| is_valid_explicit_provider_id(provider)) + .map(ToString::to_string), + }; + + let mut configured_provider_ids = HashSet::from([DEFAULT_PROVIDER.to_string()]); + let mut provider_table_ids = HashSet::new(); + if let Some(value) = root.get("model_providers") { + let providers = value + .as_table() + .ok_or_else(|| "Live model provider mappings are invalid".to_string())?; + for (provider, mapping) in providers { + configured_provider_ids.insert(provider.clone()); + if mapping.is_table() { + provider_table_ids.insert(provider.clone()); + } + } + } + + Ok(LiveProviderConfig { + current_provider, + configured_provider_ids, + provider_table_ids, + }) +} + +fn provider_resolution_for_discovery( + live_config: &Result, + provider: &str, +) -> (bool, Option) { + let config = match live_config { + Ok(config) => config, + Err(message) => return (false, Some(message.clone())), + }; + if config.is_provider_resolvable(provider) { + (true, None) + } else { + ( + false, + Some( + "Provider is visible in history but has no exact live model provider table" + .to_string(), + ), + ) + } } fn list_configured_provider_ids(path: &Path) -> Vec { @@ -4058,6 +4258,70 @@ fn now_secs() -> u64 { .as_secs() } +#[cfg(test)] +mod provider_target_snapshot_tests { + use super::*; + + fn write_config(home: &Path, current: &str, providers: &[&str]) { + let mut text = format!("model_provider = {current:?}\n"); + for provider in providers { + text.push_str(&format!( + "\n[model_providers.{provider:?}]\nname = {provider:?}\n" + )); + } + fs::write(home.join("config.toml"), text).unwrap(); + } + + #[test] + fn current_provider_change_at_first_write_boundary_leaves_history_untouched() { + let temp = tempfile::tempdir().unwrap(); + let home = temp.path().join(".codex"); + let rollout = home.join("sessions/rollout-race.jsonl"); + fs::create_dir_all(rollout.parent().unwrap()).unwrap(); + write_config(&home, "relay-alpha", &["relay-alpha"]); + let original_rollout = format!( + "{}\n{}\n", + json!({ + "type": "session_meta", + "payload": { + "id": "thread-1", + "model_provider": "openai", + "cwd": "C:/workspace" + } + }), + json!({"type": "event_msg", "payload": {"type": "user_message"}}), + ); + fs::write(&rollout, &original_rollout).unwrap(); + let config_path = home.join("config.toml"); + + let result = run_provider_sync_with_target_in_home(home.clone(), None, false, || { + write_config(&home, "relay-beta", &["relay-beta"]); + }); + + assert_eq!(result.status, ProviderSyncStatus::Skipped); + assert!(result.message.contains("configuration changed")); + assert!(result.backup_dir.is_none()); + assert_eq!(fs::read_to_string(rollout).unwrap(), original_rollout); + assert!(!home.join("backups_state/provider-sync").exists()); + assert!(!home.join("tmp/provider-sync.lock").exists()); + assert!(config_path.exists()); + } + + #[test] + fn adding_an_unrelated_provider_table_does_not_change_target_identity() { + let temp = tempfile::tempdir().unwrap(); + let home = temp.path().join(".codex"); + fs::create_dir(&home).unwrap(); + write_config(&home, "relay-alpha", &["relay-alpha"]); + let config_path = home.join("config.toml"); + let snapshot = resolve_provider_sync_target_snapshot(&config_path, None).unwrap(); + + write_config(&home, "relay-alpha", &["relay-alpha", "relay-beta"]); + + revalidate_provider_sync_target_snapshot(&config_path, None, &snapshot).unwrap(); + } +} + #[cfg(test)] mod non_root_agent_tests { use super::*; diff --git a/crates/codex-plus-data/tests/provider_sync.rs b/crates/codex-plus-data/tests/provider_sync.rs index 7b4ff59a5..b511b2a5c 100644 --- a/crates/codex-plus-data/tests/provider_sync.rs +++ b/crates/codex-plus-data/tests/provider_sync.rs @@ -4,7 +4,7 @@ use codex_plus_data::{ remote_control_session_recovery_candidate_exists, run_provider_sync, run_provider_sync_with_target, run_remote_control_session_catalog_recovery_for_thread_with_target, - run_remote_control_session_finalization_for_thread_with_target, + run_remote_control_session_finalization_for_thread_with_target, validate_provider_sync_target, }; use rusqlite::Connection; use serde_json::json; @@ -56,6 +56,29 @@ fn write_rollout(path: &Path, provider: &str, thread_id: &str, cwd: &str) { fs::write(path, format!("{first}\n{event}\n")).unwrap(); } +fn write_provider_config(home: &Path, current_provider: &str) { + write_provider_config_with_tables(home, current_provider, &[]); +} + +fn write_provider_config_with_tables( + home: &Path, + current_provider: &str, + additional_providers: &[&str], +) { + let mut providers = vec![current_provider]; + providers.extend_from_slice(additional_providers); + providers.sort_unstable(); + providers.dedup(); + + let mut config = format!("model_provider = {current_provider:?}\n"); + for provider in providers { + config.push_str(&format!( + "\n[model_providers.{provider:?}]\nname = {provider:?}\n" + )); + } + fs::write(home.join("config.toml"), config).unwrap(); +} + fn write_catalog_rollout(path: &Path) { fs::create_dir_all(path.parent().unwrap()).unwrap(); fs::write(path, "").unwrap(); @@ -337,7 +360,7 @@ fn provider_sync_targets_default_to_codex_home_env() { let tmp = tempdir().unwrap(); let home = tmp.path().join("custom-codex-home"); fs::create_dir_all(&home).unwrap(); - fs::write(home.join("config.toml"), "model_provider = \"custom\"\n").unwrap(); + write_provider_config(&home, "custom"); let _guard = CodexHomeEnvGuard::set(&home); let targets = load_provider_sync_targets(None); @@ -407,7 +430,16 @@ name = "apigather" .find(|target| target.id == "custom") .unwrap(); assert!(custom.is_current_provider); + assert!(custom.is_resolvable); + assert!(custom.unavailable_reason.is_none()); assert!(custom.sources.contains(&ProviderSyncTargetSource::Config)); + let apigather = targets + .targets + .iter() + .find(|target| target.id == "apigather") + .unwrap(); + assert!(apigather.is_resolvable); + assert!(apigather.unavailable_reason.is_none()); let openai = targets .targets .iter() @@ -416,12 +448,208 @@ name = "apigather" assert!(openai.sources.contains(&ProviderSyncTargetSource::Config)); assert!(openai.sources.contains(&ProviderSyncTargetSource::Rollout)); assert!(openai.sources.contains(&ProviderSyncTargetSource::Sqlite)); + assert!(openai.is_resolvable); + assert!(openai.unavailable_reason.is_none()); let legacy = targets .targets .iter() .find(|target| target.id == "legacy-provider") .unwrap(); assert_eq!(legacy.sources, vec![ProviderSyncTargetSource::Rollout]); + assert!(!legacy.is_resolvable); + assert!(legacy.unavailable_reason.is_some()); + let sqlite_only = targets + .targets + .iter() + .find(|target| target.id == "sqlite-provider") + .unwrap(); + assert!(!sqlite_only.is_resolvable); + assert!(sqlite_only.unavailable_reason.is_some()); +} + +#[test] +fn provider_sync_resolves_exact_generic_provider_ids_case_sensitively() { + let tmp = tempdir().unwrap(); + let home = tmp.path().join(".codex"); + fs::create_dir(&home).unwrap(); + write_provider_config_with_tables( + &home, + "relay-alpha", + &["relay-beta", "relay.alpha", "relay/prod", "中转甲"], + ); + write_rollout( + &home.join("sessions/rollout-history-only.jsonl"), + "RELAY-ALPHA", + "thread-history", + "C:/workspace", + ); + + assert_eq!( + validate_provider_sync_target(Some(&home), None).unwrap(), + "relay-alpha" + ); + assert_eq!( + validate_provider_sync_target(Some(&home), Some("relay-beta")).unwrap(), + "relay-beta" + ); + assert_eq!( + validate_provider_sync_target(Some(&home), Some("relay.alpha")).unwrap(), + "relay.alpha" + ); + assert!(validate_provider_sync_target(Some(&home), Some("RELAY-ALPHA")).is_err()); + + let targets = load_provider_sync_targets(Some(&home)); + let current = targets.targets.first().unwrap(); + assert_eq!(current.id, "relay-alpha"); + assert!(current.is_current_provider); + assert!(current.is_resolvable); + for provider in ["relay-beta", "relay.alpha", "relay/prod", "中转甲"] { + assert_eq!( + validate_provider_sync_target(Some(&home), Some(provider)).unwrap(), + provider + ); + let target = targets + .targets + .iter() + .find(|target| target.id == provider) + .unwrap(); + assert!(target.is_resolvable, "{provider}"); + assert!(target.unavailable_reason.is_none(), "{provider}"); + } + let history_only = targets + .targets + .iter() + .find(|target| target.id == "RELAY-ALPHA") + .unwrap(); + assert!(!history_only.is_resolvable); + assert!(history_only.unavailable_reason.is_some()); +} + +#[test] +fn provider_sync_requires_an_exact_live_table_before_writing_history() { + let tmp = tempdir().unwrap(); + let home = tmp.path().join(".codex"); + fs::create_dir(&home).unwrap(); + fs::write( + home.join("config.toml"), + "model_provider = \"relay-alpha\"\n\n[model_providers]\nrelay-beta = \"not-a-table\"\n", + ) + .unwrap(); + let rollout = home.join("sessions/rollout-exact-target.jsonl"); + write_rollout(&rollout, "openai", "thread-1", "C:/workspace"); + let original_rollout = fs::read_to_string(&rollout).unwrap(); + + assert!(validate_provider_sync_target(Some(&home), None).is_err()); + assert!(validate_provider_sync_target(Some(&home), Some("relay-beta")).is_err()); + let result = run_provider_sync(Some(&home)); + + assert_eq!(result.status, ProviderSyncStatus::Skipped); + assert!(result.message.contains("not resolvable")); + assert!(result.backup_dir.is_none()); + assert_eq!(fs::read_to_string(rollout).unwrap(), original_rollout); + assert!(!home.join("backups_state/provider-sync").exists()); +} + +#[test] +fn provider_sync_treats_openai_as_builtin_in_a_valid_live_config() { + let tmp = tempdir().unwrap(); + let home = tmp.path().join(".codex"); + fs::create_dir(&home).unwrap(); + fs::write(home.join("config.toml"), "# use the built-in provider\n").unwrap(); + + assert_eq!( + validate_provider_sync_target(Some(&home), None).unwrap(), + "openai" + ); + let targets = load_provider_sync_targets(Some(&home)); + let openai = targets + .targets + .iter() + .find(|target| target.id == "openai") + .unwrap(); + assert!(openai.is_current_provider); + assert!(openai.is_resolvable); +} + +#[test] +fn provider_sync_follows_external_switches_without_changing_config_or_auth() { + let tmp = tempdir().unwrap(); + let home = tmp.path().join(".codex"); + fs::create_dir(&home).unwrap(); + let rollout = home.join("sessions/rollout-switch.jsonl"); + write_rollout(&rollout, "openai", "thread-1", "C:/workspace"); + let original_body = fs::read_to_string(&rollout) + .unwrap() + .split_once('\n') + .unwrap() + .1 + .to_string(); + let db_path = home.join("state_5.sqlite"); + create_state_db(&db_path); + let auth = b"{\"OPENAI_API_KEY\":\"fixture-only\"}\n"; + fs::write(home.join("auth.json"), auth).unwrap(); + + for provider in ["relay-alpha", "relay-beta", "relay-alpha", "openai"] { + // Model an external switcher replacing config before repair starts. + if provider == "openai" { + fs::write(home.join("config.toml"), "# built-in provider\n").unwrap(); + } else { + write_provider_config(&home, provider); + } + let config = fs::read(home.join("config.toml")).unwrap(); + let result = run_provider_sync(Some(&home)); + assert_eq!( + result.status, + ProviderSyncStatus::Synced, + "{}", + result.message + ); + assert_eq!(result.target_provider, provider); + assert_eq!(fs::read(home.join("config.toml")).unwrap(), config); + assert_eq!(fs::read(home.join("auth.json")).unwrap(), auth); + let text = fs::read_to_string(&rollout).unwrap(); + let (meta, body) = text.split_once('\n').unwrap(); + assert_eq!(body, original_body); + let meta: serde_json::Value = serde_json::from_str(meta).unwrap(); + assert_eq!(meta["payload"]["model_provider"], provider); + let db = Connection::open(&db_path).unwrap(); + let actual: String = db + .query_row( + "SELECT model_provider FROM threads WHERE id = 'thread-1'", + [], + |row| row.get(0), + ) + .unwrap(); + assert_eq!(actual, provider); + } +} + +#[test] +fn provider_sync_rejects_missing_or_malformed_config_without_writes_or_secret_errors() { + let tmp = tempdir().unwrap(); + let home = tmp.path().join(".codex"); + fs::create_dir(&home).unwrap(); + let rollout = home.join("sessions/rollout-invalid-config.jsonl"); + write_rollout(&rollout, "relay-alpha", "thread-1", "C:/workspace"); + let original = fs::read(&rollout).unwrap(); + let missing = run_provider_sync(Some(&home)); + assert_eq!(missing.status, ProviderSyncStatus::Skipped); + assert_eq!(load_provider_sync_targets(Some(&home)).current_provider, ""); + let config = "model_provider = \"relay-alpha\"\nsecret = \"fixture-private-token\"\nbroken = ["; + fs::write(home.join("config.toml"), config).unwrap(); + let result = run_provider_sync(Some(&home)); + assert_eq!(result.status, ProviderSyncStatus::Skipped); + assert!(!result.message.contains("fixture-private-token")); + assert!( + load_provider_sync_targets(Some(&home)) + .targets + .iter() + .all(|target| !target.is_resolvable) + ); + assert_eq!(fs::read(&rollout).unwrap(), original); + assert_eq!(fs::read_to_string(home.join("config.toml")).unwrap(), config); + assert!(!home.join("backups_state/provider-sync").exists()); + assert!(!home.join("tmp/provider-sync.lock").exists()); } #[test] @@ -477,7 +705,7 @@ fn provider_sync_rewrites_all_session_meta_model_providers() { let tmp = tempdir().unwrap(); let home = tmp.path().join(".codex"); fs::create_dir(&home).unwrap(); - fs::write(home.join("config.toml"), "model_provider = \"apigather\"\n").unwrap(); + write_provider_config(&home, "apigather"); let rollout = home.join("sessions/2026/rollout-multi-meta.jsonl"); write_rollout_with_providers( &rollout, @@ -513,7 +741,7 @@ fn provider_sync_ignores_spawned_subagent_threads() { let tmp = tempdir().unwrap(); let home = tmp.path().join(".codex"); fs::create_dir(&home).unwrap(); - fs::write(home.join("config.toml"), "model_provider = \"apigather\"\n").unwrap(); + write_provider_config(&home, "apigather"); let parent_rollout = home.join("sessions/2026/rollout-parent.jsonl"); let child_rollout = home.join("sessions/2026/rollout-child.jsonl"); write_rollout(&parent_rollout, "openai", "parent", "C:/workspace"); @@ -579,7 +807,7 @@ fn provider_sync_preserves_marked_subagents_and_explicit_user_priority() { let tmp = tempdir().unwrap(); let home = tmp.path().join(".codex"); fs::create_dir(&home).unwrap(); - fs::write(home.join("config.toml"), "model_provider = \"apigather\"\n").unwrap(); + write_provider_config(&home, "apigather"); let structured_rollout = home.join("sessions/2026/rollout-structured-child.jsonl"); let rollout_child = home.join("sessions/2026/rollout-source-child.jsonl"); @@ -713,7 +941,7 @@ fn provider_sync_target_discovery_reads_all_session_meta_providers() { let tmp = tempdir().unwrap(); let home = tmp.path().join(".codex"); fs::create_dir(&home).unwrap(); - fs::write(home.join("config.toml"), "model_provider = \"custom\"\n").unwrap(); + write_provider_config(&home, "custom"); write_rollout_with_providers( &home.join("sessions/2026/rollout-multi-meta.jsonl"), &["openai", "ccx", "CodexPlusPlus"], @@ -738,7 +966,7 @@ fn provider_sync_updates_rollout_sqlite_visibility_and_creates_backup() { let tmp = tempdir().unwrap(); let home = tmp.path().join(".codex"); fs::create_dir(&home).unwrap(); - fs::write(home.join("config.toml"), "model_provider = \"apigather\"\n").unwrap(); + write_provider_config(&home, "apigather"); let rollout = home.join("sessions/2026/rollout-abc.jsonl"); write_rollout(&rollout, "openai", "thread-1", "C:/workspace"); create_state_db(&home.join("state_5.sqlite")); @@ -790,7 +1018,7 @@ fn provider_sync_updates_new_codex_sqlite_directory_db() { let home = tmp.path().join(".codex"); let sqlite_dir = home.join("sqlite"); fs::create_dir_all(&sqlite_dir).unwrap(); - fs::write(home.join("config.toml"), "model_provider = \"apigather\"\n").unwrap(); + write_provider_config(&home, "apigather"); let rollout = home.join("sessions/2026/rollout-abc.jsonl"); write_rollout(&rollout, "openai", "thread-1", "C:/workspace"); let db_path = sqlite_dir.join("codex-dev.db"); @@ -828,7 +1056,7 @@ fn provider_sync_updates_and_discovers_local_thread_catalog() { let home = tmp.path().join(".codex"); let sqlite_dir = home.join("sqlite"); fs::create_dir_all(&sqlite_dir).unwrap(); - fs::write(home.join("config.toml"), "model_provider = \"apigather\"\n").unwrap(); + write_provider_config(&home, "apigather"); let db_path = sqlite_dir.join("codex-dev.db"); create_local_thread_catalog_db(&db_path, &[("thread-1", "openai"), ("thread-2", "custom")]); @@ -865,7 +1093,7 @@ fn provider_sync_repairs_missing_local_thread_catalog_rows_from_threads() { let home = tmp.path().join(".codex"); let sqlite_dir = home.join("sqlite"); fs::create_dir_all(&sqlite_dir).unwrap(); - fs::write(home.join("config.toml"), "model_provider = \"apigather\"\n").unwrap(); + write_provider_config(&home, "apigather"); let state_db = home.join("state_5.sqlite"); let db = Connection::open(&state_db).unwrap(); db.execute( @@ -952,7 +1180,7 @@ fn provider_sync_audits_catalog_only_sessions_without_claiming_recovery() { let home = tmp.path().join(".codex"); let sqlite_dir = home.join("sqlite"); fs::create_dir_all(&sqlite_dir).unwrap(); - fs::write(home.join("config.toml"), "model_provider = \"apigather\"\n").unwrap(); + write_provider_config(&home, "apigather"); let state_db = home.join("state_5.sqlite"); create_state_db_with_providers(&state_db, &[("canonical", "openai", 0)]); @@ -1009,7 +1237,7 @@ fn provider_sync_continues_when_repair_audit_backup_root_is_not_directory() { let home = tmp.path().join(".codex"); let sqlite_dir = home.join("sqlite"); fs::create_dir_all(&sqlite_dir).unwrap(); - fs::write(home.join("config.toml"), "model_provider = \"apigather\"\n").unwrap(); + write_provider_config(&home, "apigather"); create_state_db_with_providers(&home.join("state_5.sqlite"), &[]); create_local_thread_catalog_db( &sqlite_dir.join("codex-dev.db"), @@ -1041,7 +1269,7 @@ fn provider_sync_repair_audit_skips_cyclic_backup_symlinks() { let home = tmp.path().join(".codex"); let sqlite_dir = home.join("sqlite"); fs::create_dir_all(&sqlite_dir).unwrap(); - fs::write(home.join("config.toml"), "model_provider = \"apigather\"\n").unwrap(); + write_provider_config(&home, "apigather"); create_state_db_with_providers(&home.join("state_5.sqlite"), &[]); create_local_thread_catalog_db( &sqlite_dir.join("codex-dev.db"), @@ -1065,7 +1293,7 @@ fn provider_sync_catalogs_user_threads_but_skips_subagents() { let home = tmp.path().join(".codex"); let sqlite_dir = home.join("sqlite"); fs::create_dir_all(&sqlite_dir).unwrap(); - fs::write(home.join("config.toml"), "model_provider = \"apigather\"\n").unwrap(); + write_provider_config(&home, "apigather"); let state_db = home.join("state_5.sqlite"); let db = Connection::open(&state_db).unwrap(); @@ -1235,7 +1463,7 @@ fn provider_sync_prunes_existing_local_subagent_catalog_rows() { let home = tmp.path().join(".codex"); let sqlite_dir = home.join("sqlite"); fs::create_dir_all(&sqlite_dir).unwrap(); - fs::write(home.join("config.toml"), "model_provider = \"apigather\"\n").unwrap(); + write_provider_config(&home, "apigather"); let state_db = home.join("state_5.sqlite"); let db = Connection::open(&state_db).unwrap(); @@ -1414,7 +1642,7 @@ fn provider_sync_prunes_archived_and_ineligible_catalog_rows() { let home = tmp.path().join(".codex"); let sqlite_dir = home.join("sqlite"); fs::create_dir_all(&sqlite_dir).unwrap(); - fs::write(home.join("config.toml"), "model_provider = \"apigather\"\n").unwrap(); + write_provider_config(&home, "apigather"); let rollout_dir = home.join("sessions"); let state_db = home.join("state_5.sqlite"); @@ -1620,7 +1848,7 @@ fn remote_control_catalog_recovery_for_thread_does_not_touch_other_candidates() let home = tmp.path().join(".codex"); let sqlite_dir = home.join("sqlite"); fs::create_dir_all(&sqlite_dir).unwrap(); - fs::write(home.join("config.toml"), "model_provider = \"custom\"\n").unwrap(); + write_provider_config(&home, "custom"); let state_db = home.join("state_5.sqlite"); let db = Connection::open(&state_db).unwrap(); @@ -1704,7 +1932,7 @@ fn remote_control_catalog_recovery_does_not_insert_subagent() { let home = tmp.path().join(".codex"); let sqlite_dir = home.join("sqlite"); fs::create_dir_all(&sqlite_dir).unwrap(); - fs::write(home.join("config.toml"), "model_provider = \"custom\"\n").unwrap(); + write_provider_config(&home, "custom"); let state_db = home.join("state_5.sqlite"); let db = Connection::open(&state_db).unwrap(); @@ -1797,7 +2025,7 @@ fn remote_control_catalog_recovery_for_thread_only_repairs_the_local_catalog_hos let home = tmp.path().join(".codex"); let sqlite_dir = home.join("sqlite"); fs::create_dir_all(&sqlite_dir).unwrap(); - fs::write(home.join("config.toml"), "model_provider = \"custom\"\n").unwrap(); + write_provider_config(&home, "custom"); let rollout = home.join("sessions/rollout-mobile.jsonl"); write_rollout(&rollout, "openai", "mobile", "C:/workspace"); create_state_db_with_providers(&home.join("state_5.sqlite"), &[("mobile", "openai", 0)]); @@ -2072,7 +2300,7 @@ fn remote_control_finalization_defers_when_rollout_changes_after_collection() { let home = tmp.path().join(".codex"); let sqlite_dir = home.join("sqlite"); fs::create_dir_all(&sqlite_dir).unwrap(); - fs::write(home.join("config.toml"), "model_provider = \"custom\"\n").unwrap(); + write_provider_config(&home, "custom"); let rollout = home.join("sessions/rollout-mobile.jsonl"); write_rollout(&rollout, "openai", "mobile", "C:/workspace"); let state_db = home.join("state_5.sqlite"); @@ -2158,7 +2386,7 @@ fn remote_control_finalization_retries_after_catalog_only_partial_commit() { let home = tmp.path().join(".codex"); let sqlite_dir = home.join("sqlite"); fs::create_dir_all(&sqlite_dir).unwrap(); - fs::write(home.join("config.toml"), "model_provider = \"custom\"\n").unwrap(); + write_provider_config(&home, "custom"); let rollout = home.join("sessions/rollout-mobile.jsonl"); write_rollout(&rollout, "openai", "mobile", "C:/workspace"); let state_db = home.join("state_5.sqlite"); @@ -2236,7 +2464,7 @@ fn provider_sync_backup_metadata_contains_reference_fields_and_managed_marker() let tmp = tempdir().unwrap(); let home = tmp.path().join(".codex"); fs::create_dir(&home).unwrap(); - fs::write(home.join("config.toml"), "model_provider = \"apigather\"\n").unwrap(); + write_provider_config(&home, "apigather"); write_rollout( &home.join("sessions/rollout-backup.jsonl"), "openai", @@ -2272,7 +2500,8 @@ fn provider_sync_explicit_target_overrides_config_without_switching_config() { let tmp = tempdir().unwrap(); let home = tmp.path().join(".codex"); fs::create_dir(&home).unwrap(); - fs::write(home.join("config.toml"), "model_provider = \"apigather\"\n").unwrap(); + write_provider_config_with_tables(&home, "apigather", &["custom"]); + let original_config = fs::read_to_string(home.join("config.toml")).unwrap(); let rollout = home.join("sessions/2026/rollout-target.jsonl"); write_rollout(&rollout, "openai", "thread-1", "C:/workspace"); create_state_db(&home.join("state_5.sqlite")); @@ -2283,7 +2512,7 @@ fn provider_sync_explicit_target_overrides_config_without_switching_config() { assert_eq!(result.target_provider, "custom"); assert_eq!( fs::read_to_string(home.join("config.toml")).unwrap(), - "model_provider = \"apigather\"\n" + original_config ); let first: serde_json::Value = serde_json::from_str( fs::read_to_string(&rollout) @@ -2310,7 +2539,7 @@ fn provider_sync_rejects_invalid_explicit_target_before_writes() { let tmp = tempdir().unwrap(); let home = tmp.path().join(".codex"); fs::create_dir(&home).unwrap(); - fs::write(home.join("config.toml"), "model_provider = \"apigather\"\n").unwrap(); + write_provider_config(&home, "apigather"); let rollout = home.join("sessions/rollout-invalid-target.jsonl"); write_rollout(&rollout, "openai", "thread-1", "C:/workspace"); let original = fs::read_to_string(&rollout).unwrap(); @@ -2328,7 +2557,7 @@ fn provider_sync_repairs_sqlite_when_rollout_provider_matches_and_normalizes_pat let tmp = tempdir().unwrap(); let home = tmp.path().join(".codex"); fs::create_dir(&home).unwrap(); - fs::write(home.join("config.toml"), "model_provider = \"apigather\"\n").unwrap(); + write_provider_config(&home, "apigather"); write_rollout( &home.join("archived_sessions/rollout-current.jsonl"), "apigather", @@ -2383,7 +2612,7 @@ fn provider_sync_does_not_restore_cwd_for_projectless_threads() { let tmp = tempdir().unwrap(); let home = tmp.path().join(".codex"); fs::create_dir(&home).unwrap(); - fs::write(home.join("config.toml"), "model_provider = \"apigather\"\n").unwrap(); + write_provider_config(&home, "apigather"); write_rollout( &home.join("sessions/rollout-projectless.jsonl"), "apigather", @@ -2418,7 +2647,7 @@ fn provider_sync_normalizes_open_in_target_preferences_per_path() { let tmp = tempdir().unwrap(); let home = tmp.path().join(".codex"); fs::create_dir(&home).unwrap(); - fs::write(home.join("config.toml"), "model_provider = \"apigather\"\n").unwrap(); + write_provider_config(&home, "apigather"); write_rollout( &home.join("sessions/rollout-current.jsonl"), "apigather", @@ -2461,7 +2690,7 @@ fn provider_sync_restores_rollout_first_line_when_later_step_fails() { let tmp = tempdir().unwrap(); let home = tmp.path().join(".codex"); fs::create_dir(&home).unwrap(); - fs::write(home.join("config.toml"), "model_provider = \"apigather\"\n").unwrap(); + write_provider_config(&home, "apigather"); let rollout = home.join("sessions/rollout-needs-rewrite.jsonl"); write_rollout(&rollout, "openai", "thread-1", "C:/workspace"); let original_first_line = fs::read_to_string(&rollout) @@ -2506,7 +2735,7 @@ fn provider_sync_rolls_back_sqlite_provider_update_when_later_update_fails() { let tmp = tempdir().unwrap(); let home = tmp.path().join(".codex"); fs::create_dir(&home).unwrap(); - fs::write(home.join("config.toml"), "model_provider = \"apigather\"\n").unwrap(); + write_provider_config(&home, "apigather"); write_rollout( &home.join("sessions/rollout-current.jsonl"), "apigather", @@ -2556,7 +2785,7 @@ fn provider_sync_restores_global_state_when_later_step_fails() { let tmp = tempdir().unwrap(); let home = tmp.path().join(".codex"); fs::create_dir(&home).unwrap(); - fs::write(home.join("config.toml"), "model_provider = \"apigather\"\n").unwrap(); + write_provider_config(&home, "apigather"); write_rollout( &home.join("sessions/rollout-current.jsonl"), "apigather", @@ -2594,7 +2823,7 @@ fn provider_sync_skips_when_home_missing_or_lock_exists_and_prunes_backups() { let home = tmp.path().join(".codex"); fs::create_dir(&home).unwrap(); fs::create_dir_all(home.join("tmp/provider-sync.lock")).unwrap(); - fs::write(home.join("config.toml"), "model_provider = \"apigather\"\n").unwrap(); + write_provider_config(&home, "apigather"); let result = run_provider_sync(Some(&home)); assert_eq!(result.status, ProviderSyncStatus::Skipped); assert!(result.message.to_lowercase().contains("lock")); @@ -2632,7 +2861,7 @@ fn provider_sync_recovers_lock_owned_by_dead_process() { let home = tmp.path().join(".codex"); let lock_dir = home.join("tmp/provider-sync.lock"); fs::create_dir_all(&lock_dir).unwrap(); - fs::write(home.join("config.toml"), "model_provider = \"apigather\"\n").unwrap(); + write_provider_config(&home, "apigather"); fs::write( lock_dir.join("owner.json"), json!({"pid": u32::MAX, "startedAt": 1234}).to_string(), @@ -2660,7 +2889,7 @@ fn provider_sync_preserves_lock_owned_by_live_process() { let home = tmp.path().join(".codex"); let lock_dir = home.join("tmp/provider-sync.lock"); fs::create_dir_all(&lock_dir).unwrap(); - fs::write(home.join("config.toml"), "model_provider = \"apigather\"\n").unwrap(); + write_provider_config(&home, "apigather"); fs::write( lock_dir.join("owner.json"), json!({"pid": std::process::id(), "startedAt": 1234}).to_string(), @@ -2680,7 +2909,7 @@ fn provider_sync_preserves_lock_with_malformed_owner() { let home = tmp.path().join(".codex"); let lock_dir = home.join("tmp/provider-sync.lock"); fs::create_dir_all(&lock_dir).unwrap(); - fs::write(home.join("config.toml"), "model_provider = \"apigather\"\n").unwrap(); + write_provider_config(&home, "apigather"); fs::write(lock_dir.join("owner.json"), "{not-json").unwrap(); let result = run_provider_sync(Some(&home)); @@ -2695,7 +2924,7 @@ fn provider_sync_preserves_rollout_mtime() { let tmp = tempdir().unwrap(); let home = tmp.path().join(".codex"); fs::create_dir(&home).unwrap(); - fs::write(home.join("config.toml"), "model_provider = \"apigather\"\n").unwrap(); + write_provider_config(&home, "apigather"); let rollout = home.join("sessions/2026/rollout-mtime.jsonl"); write_rollout(&rollout, "openai", "thread-1", "C:/workspace"); @@ -2728,7 +2957,7 @@ fn provider_sync_never_prunes_unconfirmed_or_delayed_index_entries() { let tmp = tempdir().unwrap(); let home = tmp.path().join(".codex"); fs::create_dir(&home).unwrap(); - fs::write(home.join("config.toml"), "model_provider = \"custom\"\n").unwrap(); + write_provider_config(&home, "custom"); let stale_id = "019f4e36-490e-7ae0-8e78-a8b3ab33a428"; let original_index = format!("{}\n", session_index_line(stale_id, "可能仍在云端同步")); fs::write(home.join("session_index.jsonl"), &original_index).unwrap();