diff --git a/apps/codex-plus-launcher/src/main.rs b/apps/codex-plus-launcher/src/main.rs index 54c5cf42d..b80a71ec5 100644 --- a/apps/codex-plus-launcher/src/main.rs +++ b/apps/codex-plus-launcher/src/main.rs @@ -986,6 +986,15 @@ async fn activate_existing_codex_app(options: &LaunchOptions) -> anyhow::Result< // 于是「切换登录方式后 model_provider 没跟着切换」「历史会话没被自动修复」, // 只有管理工具的「重启」才生效(issue #2080)。失败不阻断激活,只记日志。 run_activation_provider_sync(&hooks, &settings).await; + if let Err(error) = codex_plus_core::relay_config::ensure_managed_session_provider_in_home( + &codex_plus_core::relay_config::default_codex_home_dir(), + &settings, + ) { + let _ = codex_plus_core::diagnostic_log::append_diagnostic_log( + "launcher.managed_session_provider_repair_failed", + json!({ "message": error.to_string(), "phase": "activate_existing" }), + ); + } let launch_result = hooks .launch_codex( &app_dir, @@ -1114,8 +1123,11 @@ impl LaunchHooks for LauncherHooks { } } - fn cleanup_unsupported_config(&self) -> anyhow::Result<()> { - self.core.cleanup_unsupported_config() + fn cleanup_unsupported_config( + &self, + settings: &codex_plus_core::settings::BackendSettings, + ) -> anyhow::Result<()> { + self.core.cleanup_unsupported_config(settings) } async fn run_provider_sync(&self) -> anyhow::Result<()> { diff --git a/crates/codex-plus-core/src/launcher.rs b/crates/codex-plus-core/src/launcher.rs index 9c6e99da2..44aae7ce0 100644 --- a/crates/codex-plus-core/src/launcher.rs +++ b/crates/codex-plus-core/src/launcher.rs @@ -399,7 +399,7 @@ pub trait LaunchHooks: Send + Sync { async fn load_settings(&self) -> anyhow::Result; async fn start_native_browser_compatibility(&self, _settings: &BackendSettings) {} async fn stop_native_browser_compatibility(&self) {} - fn cleanup_unsupported_config(&self) -> anyhow::Result<()> { + fn cleanup_unsupported_config(&self, _settings: &BackendSettings) -> anyhow::Result<()> { Ok(()) } async fn run_provider_sync(&self) -> anyhow::Result<()>; @@ -847,7 +847,7 @@ where hooks.start_native_browser_compatibility(&settings).await; let home = crate::relay_config::default_codex_home_dir(); timeline.mark("cleanup_unsupported_config", 10); - hooks.cleanup_unsupported_config()?; + hooks.cleanup_unsupported_config(&settings)?; crate::relay_config::ensure_windows_sandbox_usable_for_current_user(&home)?; if settings.provider_sync_enabled { timeline.mark("provider_sync", 15); @@ -1221,9 +1221,17 @@ impl LaunchHooks for DefaultLaunchHooks { SettingsStore::default().load() } - fn cleanup_unsupported_config(&self) -> anyhow::Result<()> { + fn cleanup_unsupported_config(&self, settings: &BackendSettings) -> anyhow::Result<()> { let home = crate::relay_config::default_codex_home_dir(); crate::relay_config::cleanup_unsupported_approval_policies_in_home(&home)?; + if let Err(error) = + crate::relay_config::ensure_managed_session_provider_in_home(&home, settings) + { + let _ = crate::diagnostic_log::append_diagnostic_log( + "launcher.managed_session_provider_repair_failed", + serde_json::json!({ "message": error.to_string() }), + ); + } Ok(()) } diff --git a/crates/codex-plus-core/src/relay_config.rs b/crates/codex-plus-core/src/relay_config.rs index aff6d843e..3b9cdd8c4 100644 --- a/crates/codex-plus-core/src/relay_config.rs +++ b/crates/codex-plus-core/src/relay_config.rs @@ -12,6 +12,14 @@ use crate::settings::{ }; const RELAY_PROVIDER: &str = "custom"; +const MANAGED_SESSION_PROVIDER: &str = "codex_plus_managed"; + +#[derive(Clone, Copy)] +enum ManagedSessionProviderScan { + IndexOnly, + LegacyRollouts, +} + const LEGACY_RELAY_PROVIDERS: &[&str] = &["CodexPlusPlus", "CodexPP"]; const CC_SWITCH_MODEL_CATALOG_FILENAME: &str = "cc-switch-model-catalog.json"; const CHAT_UPSTREAM_BASE_URL_KEY: &str = "codex_plus_chat_base_url"; @@ -400,10 +408,6 @@ pub fn ensure_active_protocol_proxy_config_in_home( changed = true; } - if !transport_uses_proxy && !openai_identity_uses_proxy && !changed { - return Ok(false); - } - if transport_uses_proxy { let provider = doc .get_mut("model_providers") @@ -438,6 +442,10 @@ pub fn ensure_active_protocol_proxy_config_in_home( changed |= before != after; } + if settings.active_relay_id != crate::gateway::apply::CODEX_GATEWAY_RELAY_ID { + changed |= sync_managed_session_provider(&mut doc, false); + } + if !changed { return Ok(false); } @@ -448,6 +456,232 @@ pub fn ensure_active_protocol_proxy_config_in_home( Ok(true) } +// 新版桌面端在主进程 RPC 内恢复会话,不一定经过渲染层的请求补丁。 +// 为旧会话保留可解析的 provider,传输始终跟随当前生效配置,不保留旧代理端口或 Key。 +fn sync_managed_session_provider(doc: &mut DocumentMut, needed: bool) -> bool { + if !needed + && doc + .get("model_providers") + .and_then(|providers| providers.get(MANAGED_SESSION_PROVIDER)) + .is_none() + { + return false; + } + let active = match doc.get("model_provider") { + None => "openai", + Some(item) => match item.as_str().map(str::trim).filter(|id| !id.is_empty()) { + Some(id) => id, + None => return remove_managed_session_provider(doc), + }, + } + .to_string(); + if active == MANAGED_SESSION_PROVIDER { + return false; + } + if doc + .get("model_providers") + .is_some_and(|providers| !providers.is_table_like()) + { + return false; + } + let mut provider = doc + .get("model_providers") + .and_then(|providers| providers.get(&active)) + .filter(|provider| provider.is_table_like()) + .cloned(); + if active == "openai" { + // 内置 openai 不接受同名表覆盖;别名也不能继承该表里的残留凭据。 + let mut builtin = Table::new(); + builtin["name"] = toml_edit::value("OpenAI"); + builtin["wire_api"] = toml_edit::value("responses"); + builtin["requires_openai_auth"] = toml_edit::value(true); + provider = Some(Item::Table(builtin)); + } + let Some(mut provider) = provider else { + return remove_managed_session_provider(doc); + }; + if let Some(inline) = provider.as_inline_table().cloned() { + provider = Item::Table(inline.into_table()); + } + // openai_base_url 是内置 OpenAI provider 的实际传输覆盖。 + if active == "openai" { + if let Some(url) = doc.get(OPENAI_BASE_URL_KEY).and_then(Item::as_str) { + provider["base_url"] = toml_edit::value(url); + } + } + if doc + .get("model_providers") + .and_then(|providers| providers.get(MANAGED_SESSION_PROVIDER)) + .is_some_and(|existing| existing.to_string() == provider.to_string()) + { + return false; + } + if doc.get("model_providers").is_none() { + doc["model_providers"] = toml_edit::table(); + } else if let Some(inline) = doc["model_providers"].as_inline_table().cloned() { + doc["model_providers"] = Item::Table(inline.into_table()); + } + doc["model_providers"][MANAGED_SESSION_PROVIDER] = provider; + true +} + +fn remove_managed_session_provider(doc: &mut DocumentMut) -> bool { + doc.get_mut("model_providers") + .and_then(Item::as_table_like_mut) + .and_then(|providers| providers.remove(MANAGED_SESSION_PROVIDER)) + .is_some() +} + +fn managed_session_provider_needed(home: &Path, scan: ManagedSessionProviderScan) -> bool { + let home = home.canonicalize().unwrap_or_else(|_| home.to_path_buf()); + crate::codex_sqlite::codex_session_db_paths_from_home(&home) + .iter() + .any(|path| managed_session_provider_needed_in_db(path, &home, scan)) +} + +fn managed_session_provider_needed_in_db( + path: &Path, + home: &Path, + scan: ManagedSessionProviderScan, +) -> bool { + let Ok(connection) = + rusqlite::Connection::open_with_flags(path, rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY) + else { + return false; + }; + if connection + .query_row( + "SELECT EXISTS(SELECT 1 FROM threads WHERE model_provider = ?1)", + [MANAGED_SESSION_PROVIDER], + |row| row.get::<_, bool>(0), + ) + .unwrap_or(false) + { + return true; + } + + if matches!(scan, ManagedSessionProviderScan::IndexOnly) { + return false; + } + + // 旧版修复流程可能已经把 state_5.sqlite 中的 provider 改成 custom, + // 但原始 rollout 的 session_meta 仍保留 codex_plus_managed。原生 + // thread/resume 读取的是这份历史身份,不能只依赖当前索引中的 provider。 + let mut statement = match connection.prepare( + "SELECT rollout_path FROM threads WHERE rollout_path IS NOT NULL AND rollout_path <> ''", + ) { + Ok(statement) => statement, + Err(_) => return false, + }; + let paths = statement.query_map([], |row| row.get::<_, String>(0)); + let Ok(paths) = paths else { + return false; + }; + paths.flatten().any(|raw| { + managed_rollout_path(home, Path::new(&raw)) + .is_some_and(|path| rollout_declares_managed_provider(&path)) + }) +} + +fn managed_rollout_path(home: &Path, raw: &Path) -> Option { + let home = home.canonicalize().ok()?; + let raw = raw.to_string_lossy(); + let raw = raw.strip_prefix(r"\\?\").unwrap_or(&raw); + let path = Path::new(raw); + let path = if path.is_absolute() { + path.to_path_buf() + } else { + home.join(path) + }; + let path = path.canonicalize().ok()?; + if !path_is_within(&home, &path) { + return None; + } + Some(path) +} + +fn path_is_within(root: &Path, candidate: &Path) -> bool { + if candidate.strip_prefix(root).is_ok() { + return true; + } + #[cfg(windows)] + { + fn normalized(path: &Path) -> String { + let text = path.to_string_lossy().replace('/', "\\"); + let text = text.strip_prefix(r"\\?\").unwrap_or(&text); + text.trim_end_matches('\\').to_ascii_lowercase() + } + let root = normalized(root); + let candidate = normalized(candidate); + candidate == root || candidate.starts_with(&(root + "\\")) + } + #[cfg(not(windows))] + { + false + } +} + +fn rollout_declares_managed_provider(path: &Path) -> bool { + let Ok(file) = std::fs::File::open(path) else { + return false; + }; + use std::io::BufRead; + std::io::BufReader::new(file) + .lines() + .take(8) + .flatten() + .find_map(|line| { + let value = serde_json::from_str::(&line).ok()?; + (value.get("type").and_then(Value::as_str) == Some("session_meta")) + .then(|| { + value + .get("payload") + .and_then(|payload| payload.get("model_provider")) + .and_then(Value::as_str) + .map(|provider| provider == MANAGED_SESSION_PROVIDER) + }) + .flatten() + }) + .unwrap_or(false) +} + +pub fn ensure_managed_session_provider_in_home( + home: &Path, + settings: &BackendSettings, +) -> anyhow::Result { + if !settings.relay_profiles_enabled + || settings.active_relay_id == crate::gateway::apply::CODEX_GATEWAY_RELAY_ID + { + return Ok(false); + } + repair_managed_session_provider_in_home(home, ManagedSessionProviderScan::IndexOnly) +} + +fn repair_managed_session_provider_in_home( + home: &Path, + scan: ManagedSessionProviderScan, +) -> anyhow::Result { + let path = home.join("config.toml"); + let text = read_optional_text(&path)?; + let mut doc = parse_toml_document(&text)?; + let has_managed = doc + .get("model_providers") + .and_then(|providers| providers.get(MANAGED_SESSION_PROVIDER)) + .is_some(); + if !sync_managed_session_provider( + &mut doc, + !has_managed && managed_session_provider_needed(home, scan), + ) { + return Ok(false); + } + if read_optional_text(&path)? != text { + anyhow::bail!("Codex 配置已被其他窗口更新,请重试"); + } + create_live_backup(home, Some(text.as_bytes()), None)?; + crate::settings::atomic_write(&path, ensure_trailing_newline(doc.to_string()).as_bytes())?; + Ok(true) +} + pub fn apply_relay_config_to_home( home: &Path, base_url: &str, @@ -851,6 +1085,16 @@ pub fn apply_relay_profile_to_home_with_switch_rules_and_proxy_port( let previous_model = live_root_model(home); let result = apply_relay_profile_to_home_inner(home, profile, common_config_contents, proxy_port)?; + if profile.id != crate::gateway::apply::CODEX_GATEWAY_RELAY_ID { + if let Err(error) = + repair_managed_session_provider_in_home(home, ManagedSessionProviderScan::LegacyRollouts) + { + let _ = crate::diagnostic_log::append_diagnostic_log( + "relay_config.managed_session_provider_repair_failed", + serde_json::json!({ "message": format!("{error:#}") }), + ); + } + } // issue #2081:默认模型变了就记一笔,旧会话迁移留到下次启动前执行(opt-in)。 let new_model = live_root_model(home); if let Err(error) = crate::codex_sqlite::record_pending_thread_model_migration( @@ -2341,7 +2585,13 @@ fn write_codex_live_atomic( let config_text = match config_text { Some(config_text) => { let config_text = preserve_live_app_settings(home, config_text)?; - Some(preserve_live_marketplace_configs(home, &config_text)?) + let config_text = preserve_live_marketplace_configs(home, &config_text)?; + let mut doc = parse_toml_document(&config_text)?; + let live = read_optional_text(&config_path)?; + let had_managed = parse_toml_document(&live) + .is_ok_and(|live| live.get("model_providers").and_then(|providers| providers.get(MANAGED_SESSION_PROVIDER)).is_some()); + sync_managed_session_provider(&mut doc, had_managed); + Some(ensure_trailing_newline(doc.to_string())) } None => None, }; @@ -5860,6 +6110,8 @@ fn complete_relay_profile_config_with_proxy_port( provider["experimental_bearer_token"] = toml_edit::value(api_key.trim()); } + sync_managed_session_provider(&mut doc, profile.relay_mode == RelayMode::Aggregate); + Ok(move_model_providers_before_profiles( &ensure_trailing_newline(doc.to_string()), )) @@ -6467,6 +6719,346 @@ fn account_label_from_jwt(token: &str) -> Option { mod tests { use super::*; + #[test] + fn managed_session_provider_follows_effective_transport_without_selecting_it() { + let mut doc = parse_toml_document( + r#" +model_provider = "relay" +[model_providers.relay] +name = "Relay" +base_url = "http://127.0.0.1:64000/v1" +wire_api = "responses" +requires_openai_auth = false +experimental_bearer_token = "synthetic-new" +http_headers = { "X-Test" = "test" } +[model_providers.codex_plus_managed] +name = "custom" +base_url = "http://127.0.0.1:12345/v1" +experimental_bearer_token = "synthetic-old" +"#, + ) + .unwrap(); + assert!(sync_managed_session_provider(&mut doc, false)); + assert_eq!(doc["model_provider"].as_str(), Some("relay")); + assert_eq!( + doc["model_providers"][MANAGED_SESSION_PROVIDER].to_string(), + doc["model_providers"]["relay"].to_string() + ); + assert!(!sync_managed_session_provider(&mut doc, true)); + } + + #[test] + fn managed_session_provider_official_switch_drops_old_credentials() { + let mut doc = parse_toml_document( + r#" +openai_base_url = "http://127.0.0.1:64001/v1" +[model_providers.codex_plus_managed] +name = "custom" +experimental_bearer_token = "synthetic-old" +env_key = "OLD_KEY" +http_headers = { "Authorization" = "old" } +[model_providers.openai] +experimental_bearer_token = "unused-token" +env_key = "UNUSED_KEY" +http_headers = { "Authorization" = "unused" } +"#, + ) + .unwrap(); + assert!(sync_managed_session_provider(&mut doc, false)); + let provider = &doc["model_providers"][MANAGED_SESSION_PROVIDER]; + assert_eq!(provider["requires_openai_auth"].as_bool(), Some(true)); + assert_eq!( + provider["base_url"].as_str(), + Some("http://127.0.0.1:64001/v1") + ); + assert!(provider.get("experimental_bearer_token").is_none()); + assert!(provider.get("env_key").is_none()); + assert!(provider.get("http_headers").is_none()); + assert!(doc.get("model_provider").is_none()); + } + + #[test] + fn managed_session_provider_does_not_guess_unknown_provider_or_touch_unrelated_homes() { + for text in [ + "model_provider = 'unknown'", + "model_provider = 'openai'", + "model_providers = 1", + ] { + let mut doc = parse_toml_document(text).unwrap(); + let original = doc.to_string(); + assert!(!sync_managed_session_provider(&mut doc, false)); + assert_eq!(doc.to_string(), original); + } + let mut doc = parse_toml_document("model_provider = 'unknown'").unwrap(); + assert!(!sync_managed_session_provider(&mut doc, true)); + for invalid in ["model_provider = ''", "model_provider = 7"] { + let mut doc = parse_toml_document(invalid).unwrap(); + let original = doc.to_string(); + assert!(!sync_managed_session_provider(&mut doc, true)); + assert_eq!(doc.to_string(), original); + } + } + + #[test] + fn managed_session_provider_removes_stale_alias_when_active_provider_is_unresolved() { + for active in ["'missing'", "''", "7", "'broken'"] { + for providers in [ + "[model_providers]\nbroken = 7\n[model_providers.unrelated]\nname = 'Keep'\n[model_providers.codex_plus_managed]\nexperimental_bearer_token = 'synthetic-old'\n", + "model_providers = { broken = 7, unrelated = { name = 'Keep' }, codex_plus_managed = { experimental_bearer_token = 'synthetic-old' } }\n", + ] { + let mut doc = parse_toml_document(&format!("model_provider = {active}\n{providers}")).unwrap(); + let original_active = doc["model_provider"].to_string(); + assert!(sync_managed_session_provider(&mut doc, true)); + assert!(doc["model_providers"].get(MANAGED_SESSION_PROVIDER).is_none()); + assert_eq!(doc["model_providers"]["unrelated"]["name"].as_str(), Some("Keep")); + assert_eq!(doc["model_provider"].to_string(), original_active); + assert!(!doc.to_string().contains("synthetic-old")); + assert!(!sync_managed_session_provider(&mut doc, true)); + } + } + } + + #[test] + fn managed_session_provider_supports_inline_provider_tables() { + for text in [ + "model_provider = 'relay'\nmodel_providers = { relay = { name = 'Relay', wire_api = 'responses' }, codex_plus_managed = { name = 'old' } }", + "model_provider = 'relay'\n[model_providers]\nrelay = { name = 'Relay', wire_api = 'responses' }\ncodex_plus_managed = { name = 'old' }", + ] { + let mut doc = parse_toml_document(text).unwrap(); + assert!(sync_managed_session_provider(&mut doc, false)); + let written = parse_toml_document(&doc.to_string()).unwrap(); + assert_eq!( + written["model_providers"][MANAGED_SESSION_PROVIDER]["name"].as_str(), + Some("Relay") + ); + assert!(!sync_managed_session_provider(&mut doc, false)); + } + } + + #[test] + fn managed_session_provider_repairs_native_resume_from_catalog_and_is_idempotent() { + let home = tempfile::tempdir().unwrap(); + let db = rusqlite::Connection::open(home.path().join("state_5.sqlite")).unwrap(); + db.execute_batch("CREATE TABLE threads (id TEXT, model_provider TEXT); INSERT INTO threads VALUES ('legacy', 'codex_plus_managed');").unwrap(); + std::fs::write( + home.path().join("config.toml"), + r#" +model_provider = "relay" +[model_providers.relay] +name = "Relay" +wire_api = "responses" +base_url = "http://127.0.0.1:64002/v1" +requires_openai_auth = false +"#, + ) + .unwrap(); + let settings = BackendSettings::default(); + assert!(ensure_managed_session_provider_in_home(home.path(), &settings).unwrap()); + let text = std::fs::read_to_string(home.path().join("config.toml")).unwrap(); + assert!(text.contains("[model_providers.codex_plus_managed]")); + assert!(!ensure_managed_session_provider_in_home(home.path(), &settings).unwrap()); + assert_eq!( + db.query_row("SELECT model_provider FROM threads", [], |row| row + .get::<_, String>(0)) + .unwrap(), + MANAGED_SESSION_PROVIDER + ); + // 切换供应商时会重建 provider 表,兼容项必须由新配置重新生成。 + apply_pure_api_config_to_home(home.path(), "https://next.invalid/v1", "synthetic-key") + .unwrap(); + let switched = + parse_toml_document(&std::fs::read_to_string(home.path().join("config.toml")).unwrap()) + .unwrap(); + assert_eq!( + switched["model_providers"][MANAGED_SESSION_PROVIDER]["base_url"].as_str(), + Some("https://next.invalid/v1") + ); + } + + #[test] + fn managed_session_provider_repair_keeps_legacy_rollout_identity_after_index_provider_migration() { + let home = tempfile::tempdir().unwrap(); + let rollout = home.path().join("sessions").join("legacy.jsonl"); + std::fs::create_dir_all(rollout.parent().unwrap()).unwrap(); + std::fs::write( + &rollout, + r#"{"type":"session_meta","payload":{"model_provider":"codex_plus_managed"}} +"#, + ) + .unwrap(); + let db_path = home.path().join("state_5.sqlite"); + let db = rusqlite::Connection::open(&db_path).unwrap(); + db.execute( + "CREATE TABLE threads (rollout_path TEXT, model_provider TEXT)", + [], + ) + .unwrap(); + db.execute( + "INSERT INTO threads VALUES (?1, 'custom')", + [format!(r"\\?\{}", rollout.display())], + ) + .unwrap(); + drop(db); + + assert!(!managed_session_provider_needed_in_db(&db_path, home.path(), ManagedSessionProviderScan::IndexOnly)); + assert!(managed_session_provider_needed_in_db(&db_path, home.path(), ManagedSessionProviderScan::LegacyRollouts)); + std::fs::write( + home.path().join("config.toml"), + r#"model_provider = "custom" +[model_providers.custom] +name = "Relay" +wire_api = "responses" +base_url = "http://127.0.0.1:64003/v1" +requires_openai_auth = false +"#, + ) + .unwrap(); + let settings = BackendSettings::default(); + assert!(!ensure_managed_session_provider_in_home(home.path(), &settings).unwrap()); + assert!(!ensure_active_protocol_proxy_config_in_home(home.path(), &settings).unwrap()); + apply_pure_api_config_to_home(home.path(), "https://next.invalid/v1", "synthetic-key") + .unwrap(); + let before_switch = std::fs::read_to_string(home.path().join("config.toml")).unwrap(); + assert!(!before_switch.contains("[model_providers.codex_plus_managed]")); + let profile = RelayProfile { + relay_mode: RelayMode::PureApi, + base_url: "https://next.invalid/v1".to_string(), + api_key: "synthetic-key".to_string(), + ..RelayProfile::default() + }; + apply_relay_profile_to_home_with_switch_rules(home.path(), &profile, "").unwrap(); + assert!( + std::fs::read_to_string(home.path().join("config.toml")) + .unwrap() + .contains("[model_providers.codex_plus_managed]") + ); + } + + #[cfg(windows)] + #[test] + fn managed_session_provider_accepts_short_home_aliases() { + use std::os::windows::ffi::{OsStrExt, OsStringExt}; + use windows::Win32::Storage::FileSystem::GetShortPathNameW; + + let home = tempfile::Builder::new() + .prefix("Codex legacy home ") + .tempdir() + .unwrap(); + let canonical_home = home.path().canonicalize().unwrap(); + let input: Vec = canonical_home + .as_os_str() + .encode_wide() + .chain(Some(0)) + .collect(); + let mut output = vec![0_u16; 32768]; + let length = unsafe { + GetShortPathNameW(windows::core::PCWSTR(input.as_ptr()), Some(&mut output)) + } as usize; + assert!(length > 0 && length < output.len()); + let short_home = std::path::PathBuf::from(std::ffi::OsString::from_wide(&output[..length])); + if short_home == canonical_home { + eprintln!("当前文件系统未提供 8.3 别名,跳过该别名场景"); + return; + } + let rollout = home.path().join("legacy.jsonl"); + std::fs::write(&rollout, "{}").unwrap(); + let expected = rollout.canonicalize().unwrap(); + assert_eq!( + managed_rollout_path(&short_home, Path::new("legacy.jsonl")), + Some(expected.clone()) + ); + assert_eq!( + managed_rollout_path(&short_home, &rollout), + Some(expected) + ); + } + + #[cfg(unix)] + #[test] + fn managed_session_provider_accepts_symlinked_home_and_rejects_external_rollouts() { + let temp = tempfile::tempdir().unwrap(); + let home = temp.path().join("home"); + let alias = temp.path().join("alias"); + std::fs::create_dir(&home).unwrap(); + std::os::unix::fs::symlink(&home, &alias).unwrap(); + let rollout = home.join("legacy.jsonl"); + std::fs::write(&rollout, "{}").unwrap(); + let expected = rollout.canonicalize().unwrap(); + assert_eq!(managed_rollout_path(&alias, &rollout), Some(expected.clone())); + assert_eq!(managed_rollout_path(&home, &alias.join("legacy.jsonl")), Some(expected.clone())); + assert_eq!(managed_rollout_path(&alias, Path::new("legacy.jsonl")), Some(expected)); + let external = temp.path().join("external.jsonl"); + std::fs::write(&external, "{}").unwrap(); + std::os::unix::fs::symlink(&external, home.join("escape.jsonl")).unwrap(); + assert!(managed_rollout_path(&alias, Path::new("escape.jsonl")).is_none()); + } + + #[cfg(target_os = "macos")] + #[test] + fn managed_session_provider_accepts_var_and_private_var_home_spellings() { + let home = tempfile::Builder::new().prefix("managed-resume-").tempdir_in("/var/tmp").unwrap(); + let rollout = home.path().join("legacy.jsonl"); + std::fs::write(&rollout, "{}").unwrap(); + let canonical_home = home.path().canonicalize().unwrap(); + let canonical_rollout = rollout.canonicalize().unwrap(); + assert_eq!(managed_rollout_path(home.path(), &canonical_rollout), Some(canonical_rollout.clone())); + assert_eq!(managed_rollout_path(&canonical_home, &rollout), Some(canonical_rollout)); + } + + #[test] + fn managed_session_provider_repair_respects_disabled_profiles_and_gateway_mode() { + for (enabled, active_id) in [(false, "relay"), (true, crate::gateway::apply::CODEX_GATEWAY_RELAY_ID)] { + let home = tempfile::tempdir().unwrap(); + let settings = BackendSettings { + relay_profiles_enabled: enabled, + active_relay_id: active_id.to_string(), + ..BackendSettings::default() + }; + let config = "model_provider = 'relay'\n[model_providers.relay]\nname = 'Relay'\n[model_providers.codex_plus_managed]\nname = 'Leave alone'\n"; + for text in [config, "invalid toml = ["] { + std::fs::write(home.path().join("config.toml"), text).unwrap(); + assert!(!ensure_managed_session_provider_in_home(home.path(), &settings).unwrap()); + assert_eq!(std::fs::read_to_string(home.path().join("config.toml")).unwrap(), text); + assert_eq!(std::fs::read_dir(home.path()).unwrap().count(), 1); + } + } + } + + #[test] + fn managed_session_provider_ignores_rollouts_outside_home_and_unrelated_metadata() { + let temp = tempfile::tempdir().unwrap(); + let home = temp.path().join("home"); + let neighbor = temp.path().join("home-other"); + std::fs::create_dir_all(&home).unwrap(); + std::fs::create_dir_all(&neighbor).unwrap(); + let outside = neighbor.join("legacy.jsonl"); + std::fs::write( + &outside, + r#"{"type":"session_meta","payload":{"model_provider":"codex_plus_managed"}}"#, + ) + .unwrap(); + let local = home.join("local.jsonl"); + std::fs::write( + &local, + r#"{"type":"session_meta","payload":{"model_provider":"custom"}}"#, + ) + .unwrap(); + let db_path = home.join("state_5.sqlite"); + let db = rusqlite::Connection::open(&db_path).unwrap(); + db.execute_batch("CREATE TABLE threads (model_provider TEXT, rollout_path TEXT);") + .unwrap(); + for path in [&outside, &local, &home.join("missing.jsonl")] { + db.execute( + "INSERT INTO threads VALUES ('custom', ?1)", + [path.to_string_lossy().as_ref()], + ) + .unwrap(); + } + assert!(!managed_session_provider_needed_in_db(&db_path, &home, ManagedSessionProviderScan::LegacyRollouts)); + assert!(managed_rollout_path(&home, Path::new("local.jsonl")).is_some()); + assert!(managed_rollout_path(&home, &outside).is_none()); + } + #[test] fn live_config_detects_managed_protocol_proxy_for_active_provider_only() { let home = tempfile::tempdir().unwrap(); diff --git a/crates/codex-plus-core/tests/all/main.rs b/crates/codex-plus-core/tests/all/main.rs index c03be91df..eb5f2412a 100644 --- a/crates/codex-plus-core/tests/all/main.rs +++ b/crates/codex-plus-core/tests/all/main.rs @@ -59,6 +59,7 @@ mod live_settings_migration; mod local_compaction; mod local_compaction_settings; mod local_config_provider; +mod managed_session_provider; mod map_unlisted_models_proxy; mod map_unlisted_models_settings; mod model_catalog; diff --git a/crates/codex-plus-core/tests/all/managed_session_provider.rs b/crates/codex-plus-core/tests/all/managed_session_provider.rs new file mode 100644 index 000000000..f373dd669 --- /dev/null +++ b/crates/codex-plus-core/tests/all/managed_session_provider.rs @@ -0,0 +1,367 @@ +use std::ffi::{OsStr, OsString}; +use std::fs; +use std::io::{BufRead, Write}; +use std::path::{Path, PathBuf}; +use std::process::{Child, ChildStdin, Command, Stdio}; +use std::sync::mpsc::{Receiver, channel}; +use std::time::{Duration, Instant}; + +use codex_plus_core::relay_config::ensure_managed_session_provider_in_home; +use codex_plus_core::settings::BackendSettings; +use serde_json::{Value, json}; + +const THREAD_ID: &str = "77777777-7777-4777-8777-777777777777"; +const TURN_ID: &str = "managed-history-turn"; +const USER_TEXT: &str = "Synthetic historical question"; +const ASSISTANT_TEXT: &str = "Synthetic recorded answer"; + +#[test] +#[ignore = "requires CODEX_PLUS_TEST_CODEX_BIN"] +fn native_official_resume_requires_managed_alias_and_preserves_history_after_restart() { + let _state = crate::common::global_state_lock(); + let binary = std::env::var_os("CODEX_PLUS_TEST_CODEX_BIN") + .expect("set CODEX_PLUS_TEST_CODEX_BIN to the native Codex executable"); + assert!( + Path::new(&binary).is_file(), + "native Codex executable is missing" + ); + let home = tempfile::tempdir().unwrap(); + let rollout = fixture(home.path()); + let original = fs::read(&rollout).unwrap(); + let params = json!({"threadId":THREAD_ID,"path":rollout,"excludeTurns":false}); + + { + let mut server = NativeServer::start(&binary, home.path(), "before-repair"); + let response = server.response("thread/resume", params.clone()); + let error = response["error"]["message"] + .as_str() + .expect("native resume must fail before repairing the missing provider"); + let error = error.to_ascii_lowercase(); + assert!( + error.contains("model provider") + && error.contains("codex_plus_managed") + && error.contains("not found"), + "unexpected native resume error: {response}" + ); + } + + let index = home.path().join("repair-index"); + fs::create_dir_all(&index).unwrap(); + let connection = rusqlite::Connection::open(index.join("state_5.sqlite")).unwrap(); + connection + .execute_batch("CREATE TABLE threads (model_provider TEXT); INSERT INTO threads VALUES ('codex_plus_managed');") + .unwrap(); + drop(connection); + { + let _sqlite_home = SqliteHomeGuard::set(&index); + assert!( + ensure_managed_session_provider_in_home(home.path(), &BackendSettings::default()) + .unwrap() + ); + } + let config = fs::read_to_string(home.path().join("config.toml")).unwrap(); + let doc = config.parse::().unwrap(); + assert_eq!(doc["model_provider"].as_str(), Some("openai")); + let provider = &doc["model_providers"]["codex_plus_managed"]; + assert_eq!(provider["requires_openai_auth"].as_bool(), Some(true)); + assert!(provider.get("base_url").is_none()); + assert!(provider.get("experimental_bearer_token").is_none()); + + for phase in ["after-repair", "after-restart"] { + let mut server = NativeServer::start(&binary, home.path(), phase); + let resume_params = if phase == "after-repair" { + params.clone() + } else { + json!({"threadId":THREAD_ID,"excludeTurns":false}) + }; + let resumed = server.request("thread/resume", resume_params); + assert_eq!(resumed["thread"]["id"], THREAD_ID); + let read = server.request( + "thread/read", + json!({"threadId":THREAD_ID,"includeTurns":true}), + ); + assert_eq!(read["thread"]["id"], THREAD_ID); + let turns = read["thread"]["turns"].as_array().unwrap(); + assert_eq!(turns.len(), 1, "{phase}: {read}"); + assert_eq!(turns[0]["id"], TURN_ID); + assert_eq!(turns[0]["status"], "completed"); + let items = turns[0]["items"].as_array().unwrap(); + assert_eq!(items.len(), 2, "{phase}: {read}"); + assert_eq!(items[0]["type"], "userMessage"); + assert_eq!(items[0]["content"][0]["text"], USER_TEXT); + assert_eq!(items[1]["type"], "agentMessage"); + assert_eq!(items[1]["text"], ASSISTANT_TEXT); + drop(server); + assert!(!home.path().join("auth.json").exists()); + } + let persisted = fs::read_to_string(&rollout).unwrap(); + let metadata: Value = + serde_json::from_slice(original.split(|byte| *byte == b'\n').next().unwrap()).unwrap(); + let restored_metadata: Value = serde_json::from_str(persisted.lines().next().unwrap()).unwrap(); + assert_eq!(restored_metadata, metadata); + assert!(!home.path().join("auth.json").exists()); +} + +fn fixture(home: &Path) -> PathBuf { + fs::write( + home.join("config.toml"), + "model = 'gpt-5'\nmodel_provider = 'openai'\ncli_auth_credentials_store = 'file'\nmcp_oauth_credentials_store = 'file'\napproval_policy = 'never'\nsandbox_mode = 'read-only'\n[features]\nshell_snapshot = false\n", + ) + .unwrap(); + let rows = [ + json!({"type":"session_meta","payload":{"id":THREAD_ID,"session_id":THREAD_ID,"timestamp":"2026-10-30T00:00:00.000Z","cwd":home,"history_mode":"paginated","model_provider":"codex_plus_managed","originator":"codex_plus_managed_resume_test","cli_version":"0.162.0-alpha.17.2","source":"vscode","base_instructions":{"text":"Synthetic history only."}}}), + json!({"type":"event_msg","payload":{"type":"task_started","turn_id":TURN_ID}}), + json!({"type":"response_item","payload":{"type":"message","role":"user","content":[{"type":"input_text","text":USER_TEXT}],"internal_chat_message_metadata_passthrough":{"turn_id":TURN_ID}}}), + json!({"type":"event_msg","payload":{"type":"item_completed","thread_id":THREAD_ID,"turn_id":TURN_ID,"item":{"type":"UserMessage","id":"managed-user","content":[{"type":"text","text":USER_TEXT,"text_elements":[]}]}}}), + json!({"type":"response_item","payload":{"type":"message","role":"assistant","content":[{"type":"output_text","text":ASSISTANT_TEXT}],"internal_chat_message_metadata_passthrough":{"turn_id":TURN_ID}}}), + json!({"type":"event_msg","payload":{"type":"item_completed","thread_id":THREAD_ID,"turn_id":TURN_ID,"item":{"type":"AgentMessage","id":"managed-agent","content":[{"type":"Text","text":ASSISTANT_TEXT}]}}}), + json!({"type":"event_msg","payload":{"type":"task_complete","turn_id":TURN_ID}}), + ]; + let content: String = rows + .into_iter() + .enumerate() + .map(|(ordinal, mut row)| { + row["ordinal"] = json!(ordinal); + row["timestamp"] = json!("2026-10-30T00:00:00.000Z"); + format!("{row}\n") + }) + .collect(); + let sessions = home.join("sessions/2026/10/30"); + fs::create_dir_all(&sessions).unwrap(); + let rollout = sessions.join(format!("rollout-2026-10-30T00-00-00-{THREAD_ID}.jsonl")); + fs::write(&rollout, content).unwrap(); + rollout +} + +struct SqliteHomeGuard(Option); + +impl SqliteHomeGuard { + fn set(home: &Path) -> Self { + let previous = std::env::var_os("CODEX_SQLITE_HOME"); + unsafe { std::env::set_var("CODEX_SQLITE_HOME", home) }; + Self(previous) + } +} + +impl Drop for SqliteHomeGuard { + fn drop(&mut self) { + unsafe { + match &self.0 { + Some(previous) => std::env::set_var("CODEX_SQLITE_HOME", previous), + None => std::env::remove_var("CODEX_SQLITE_HOME"), + } + } + } +} + +struct NativeServer { + child: Child, + stdin: Option, + messages: Receiver, + reader: Option>, + next_id: u64, + log: PathBuf, + #[cfg(windows)] + job: WindowsJob, +} + +impl NativeServer { + fn start(binary: &OsStr, home: &Path, phase: &str) -> Self { + #[cfg(windows)] + use std::os::windows::process::CommandExt; + + let mut command = Command::new(binary); + command + .args(["app-server", "--listen", "stdio://"]) + .env_clear(); + for directory in ["appdata", "localappdata", "user", "tmp", "sqlite", "xdg"] { + fs::create_dir_all(home.join(directory)).unwrap(); + } + for (key, directory) in [ + ("APPDATA", "appdata"), + ("LOCALAPPDATA", "localappdata"), + ("HOME", "user"), + ("USERPROFILE", "user"), + ("TEMP", "tmp"), + ("TMP", "tmp"), + ("TMPDIR", "tmp"), + ("XDG_CONFIG_HOME", "xdg"), + ("XDG_DATA_HOME", "xdg"), + ("XDG_CACHE_HOME", "xdg"), + ("CODEX_SQLITE_HOME", "sqlite"), + ] { + command.env(key, home.join(directory)); + } + command.env("CODEX_HOME", home); + #[cfg(windows)] + { + let system_root = std::env::var_os("SystemRoot").expect("SystemRoot is required"); + command + .env("PATH", Path::new(&system_root).join("System32")) + .env("WINDIR", &system_root) + .env("SystemRoot", system_root) + .creation_flags(0x08000000); + } + #[cfg(unix)] + { + use std::os::unix::process::CommandExt; + command.env("PATH", "/usr/bin:/bin").process_group(0); + } + let log = home.join(format!("{phase}.log")); + let mut child = command + .current_dir(home) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(fs::File::create(&log).unwrap()) + .spawn() + .unwrap(); + #[cfg(windows)] + let job = match WindowsJob::attach(&child) { + Ok(job) => job, + Err(error) => { + let _ = Command::new( + Path::new(&std::env::var_os("SystemRoot").unwrap()) + .join("System32/taskkill.exe"), + ) + .args(["/PID", &child.id().to_string(), "/T", "/F"]) + .creation_flags(0x08000000) + .output(); + let _ = child.kill(); + let _ = child.wait(); + panic!("cannot isolate native app-server process tree: {error}"); + } + }; + let stdin = child.stdin.take().unwrap(); + let stdout = child.stdout.take().unwrap(); + let (sender, messages) = channel(); + let reader = std::thread::spawn(move || { + for line in std::io::BufReader::new(stdout) + .lines() + .map_while(Result::ok) + { + if let Ok(message) = serde_json::from_str::(&line) { + if sender.send(message).is_err() { + break; + } + } + } + }); + let mut server = Self { + child, + stdin: Some(stdin), + messages, + reader: Some(reader), + next_id: 0, + log, + #[cfg(windows)] + job, + }; + server.request("initialize", json!({"clientInfo":{"name":"codex_plus_managed_resume_test","version":"0.1.0"},"capabilities":{"experimentalApi":true}})); + server.send(json!({"method":"initialized","params":{}})); + server + } + + fn send(&mut self, value: Value) { + let stdin = self.stdin.as_mut().unwrap(); + writeln!(stdin, "{value}").unwrap(); + stdin.flush().unwrap(); + } + + fn response(&mut self, method: &str, params: Value) -> Value { + assert!(matches!( + method, + "initialize" | "thread/resume" | "thread/read" + )); + self.next_id += 1; + self.send(json!({"id":self.next_id,"method":method,"params":params})); + let deadline = Instant::now() + Duration::from_secs(20); + loop { + let response = self + .messages + .recv_timeout(deadline.saturating_duration_since(Instant::now())) + .unwrap_or_else(|error| { + panic!( + "{method}: {error}; {}", + fs::read_to_string(&self.log).unwrap_or_default() + ) + }); + if response["id"].as_u64() == Some(self.next_id) { + return response; + } + } + } + + fn request(&mut self, method: &str, params: Value) -> Value { + let response = self.response(method, params); + assert!(response.get("error").is_none(), "{method}: {response}"); + response["result"].clone() + } +} + +impl Drop for NativeServer { + fn drop(&mut self) { + #[cfg(windows)] + self.job.terminate(); + #[cfg(unix)] + { + unsafe extern "C" { + fn kill(pid: i32, signal: i32) -> i32; + } + unsafe { kill(-i32::try_from(self.child.id()).unwrap(), 9) }; + } + self.stdin.take(); + let _ = self.child.kill(); + let _ = self.child.wait(); + if let Some(reader) = self.reader.take() { + let _ = reader.join(); + } + } +} + +#[cfg(windows)] +struct WindowsJob(*mut std::ffi::c_void); + +#[cfg(windows)] +#[link(name = "kernel32")] +unsafe extern "system" { + fn CreateJobObjectW( + attributes: *const std::ffi::c_void, + name: *const u16, + ) -> *mut std::ffi::c_void; + fn AssignProcessToJobObject(job: *mut std::ffi::c_void, process: *mut std::ffi::c_void) -> i32; + fn TerminateJobObject(job: *mut std::ffi::c_void, exit_code: u32) -> i32; + fn CloseHandle(handle: *mut std::ffi::c_void) -> i32; +} + +#[cfg(windows)] +impl WindowsJob { + fn attach(child: &Child) -> std::io::Result { + use std::os::windows::io::AsRawHandle; + let handle = unsafe { CreateJobObjectW(std::ptr::null(), std::ptr::null()) }; + if handle.is_null() { + return Err(std::io::Error::last_os_error()); + } + let job = Self(handle); + if unsafe { AssignProcessToJobObject(job.0, child.as_raw_handle()) } == 0 { + return Err(std::io::Error::last_os_error()); + } + Ok(job) + } + + fn terminate(&self) { + if unsafe { TerminateJobObject(self.0, 1) } == 0 { + eprintln!( + "cannot terminate owned native app-server job: {}", + std::io::Error::last_os_error() + ); + } + } +} + +#[cfg(windows)] +impl Drop for WindowsJob { + fn drop(&mut self) { + self.terminate(); + unsafe { CloseHandle(self.0) }; + } +} diff --git a/crates/codex-plus-core/tests/all/relay_config.rs b/crates/codex-plus-core/tests/all/relay_config.rs index 07e9c2812..16b3ba758 100644 --- a/crates/codex-plus-core/tests/all/relay_config.rs +++ b/crates/codex-plus-core/tests/all/relay_config.rs @@ -1645,6 +1645,67 @@ fn apply_aggregate_relay_points_codex_to_local_responses_proxy_without_snapshot( assert!(updated.contains("requires_openai_auth = false")); assert!(updated.contains(r#"base_url = "http://127.0.0.1:57321/v1""#)); assert!(updated.contains(r#"experimental_bearer_token = "codex-plus-aggregate""#)); + let doc = updated.parse::().unwrap(); + assert_eq!(doc["model_provider"].as_str(), Some("custom")); + assert_eq!( + doc["model_providers"]["codex_plus_managed"].to_string(), + doc["model_providers"]["custom"].to_string(), + "聚合会话的内部 provider 必须能由原生恢复入口直接解析" + ); +} + +#[test] +fn managed_session_provider_official_native_resume_config_preserves_login_and_transport() { + for official_http in [false, true] { + let temp = tempfile::tempdir().unwrap(); + let home = temp.path(); + let rollout = home.join("legacy.jsonl"); + let metadata = r#"{"type":"session_meta","payload":{"model_provider":"codex_plus_managed"}}"#; + std::fs::write(&rollout, metadata).unwrap(); + let db = rusqlite::Connection::open(home.join("state_5.sqlite")).unwrap(); + db.execute_batch("CREATE TABLE threads (model_provider TEXT); INSERT INTO threads VALUES ('codex_plus_managed');").unwrap(); + let login = r#"{"auth_mode":"chatgpt","tokens":{"access_token":"synthetic-official-login"}}"#; + std::fs::write(home.join("auth.json"), login).unwrap(); + std::fs::write(home.join("config.toml"), r#" +model_provider = "custom" +[model_providers.custom] +name = "Relay" +wire_api = "responses" +base_url = "https://old.invalid/v1" +experimental_bearer_token = "synthetic-old-key" +[model_providers.codex_plus_managed] +experimental_bearer_token = "synthetic-old-key" +"#).unwrap(); + let settings = BackendSettings::default(); + let snapshot = if official_http { + "model_provider = 'openai-http'\n[model_providers.openai-http]\nname = 'OpenAI (HTTP)'\nwire_api = 'responses'\nrequires_openai_auth = true\nsupports_websockets = false\n" + } else { + "" + }; + if official_http { + apply_relay_config_file_to_home(home, snapshot).unwrap(); + } + clear_relay_config_to_home_with_auth(home, Some(login)).unwrap(); + for _ in 0..2 { + codex_plus_core::relay_config::ensure_managed_session_provider_in_home(home, &settings).unwrap(); + let text = std::fs::read_to_string(home.join("config.toml")).unwrap(); + let doc = text.parse::().unwrap(); + assert_eq!(doc.get("model_provider").and_then(toml_edit::Item::as_str), official_http.then_some("openai-http")); + let provider = &doc["model_providers"]["codex_plus_managed"]; + assert_eq!(provider["requires_openai_auth"].as_bool(), Some(true)); + assert_eq!(provider["wire_api"].as_str(), Some("responses")); + assert_eq!(provider.get("supports_websockets").and_then(toml_edit::Item::as_bool), official_http.then_some(false)); + for credential in ["base_url", "experimental_bearer_token", "env_key", "http_headers"] { + assert!(provider.get(credential).is_none()); + } + assert!(!text.contains("synthetic-old-key")); + assert!(!text.contains("old.invalid")); + assert_eq!(std::fs::read_to_string(home.join("auth.json")).unwrap(), login); + assert_eq!(std::fs::read_to_string(&rollout).unwrap(), metadata); + assert_eq!(db.query_row("SELECT model_provider FROM threads", [], |row| row.get::<_, String>(0)).unwrap(), "codex_plus_managed"); + clear_relay_config_to_home_with_auth(home, Some(login)).unwrap(); + } + } } #[test] @@ -7900,6 +7961,46 @@ base_url = "https://responses.example.test/v1" } } +#[test] +fn managed_provider_repair_failure_after_apply_does_not_report_switch_failure() { + let _state = crate::common::global_state_lock(); + let temp = tempfile::tempdir().unwrap(); + let home = temp.path(); + let log_path = home.join("codex-plus.log"); + codex_plus_core::diagnostic_log::set_diagnostic_log_path_for_tests(Some(log_path.clone())); + let db = rusqlite::Connection::open(home.join("state_5.sqlite")).unwrap(); + db.execute_batch( + "CREATE TABLE threads (model_provider TEXT); \ + INSERT INTO threads VALUES ('codex_plus_managed');", + ) + .unwrap(); + drop(db); + std::fs::write(home.join("backups"), "blocks only the post-write backup").unwrap(); + + let applied = apply_relay_profile_to_home_with_switch_rules_and_proxy_port( + home, + &responses_profile_with_model("gpt-5.4"), + "", + 57321, + ); + codex_plus_core::diagnostic_log::set_diagnostic_log_path_for_tests(None); + + let config = std::fs::read_to_string(home.join("config.toml")).unwrap(); + assert_eq!( + config + .parse::() + .unwrap()["model"] + .as_str(), + Some("gpt-5.4"), + ); + assert!(applied.is_ok(), "the profile has already been written: {applied:?}"); + assert!( + std::fs::read_to_string(log_path) + .unwrap() + .contains("relay_config.managed_session_provider_repair_failed") + ); +} + /// issue #2081:切换默认模型时只记录待迁移项,不碰 sqlite; /// 下次启动前(opt-in)才把仍停在旧默认的会话迁到新默认,手选模型保持不变。 #[test] diff --git a/docs/managed-session-provider.md b/docs/managed-session-provider.md new file mode 100644 index 000000000..0e8e7d110 --- /dev/null +++ b/docs/managed-session-provider.md @@ -0,0 +1,35 @@ +# 历史聚合会话的供应商兼容配置 + +历史聚合会话可能在 rollout 中保存 `codex_plus_managed`,而当前供应商已经切换。 +主进程直接执行原生恢复时不一定经过渲染层补丁,因此配置中需要一个可解析的同名供应商。 +兼容表复制当前生效传输,不选择为默认供应商,不保留旧代理地址或凭据。 +活动 `model_provider` 无法解析时移除旧兼容表;不会猜测连接目标。 + +启动与激活已有实例时,仅在供应商接管开启且当前不是网关供应商时修复。 +已有兼容表直接更新;缺少时只查 SQLite 索引,不读取 rollout 文件。 +通用配置写入和协议代理端口修复只维护已有兼容表,不扫描历史。 +只有显式应用或切换供应商后、仍缺少兼容表时,才检查索引引用的 rollout 前八行。 +因此旧索引已经改名、仅 rollout 保留内部身份的场景需要重新应用供应商;启动不承担迁移扫描。 +没有引入持久缓存;无命中的显式供应商应用可以再次扫描,已有兼容表则跳过。 + +`prepareCodexManagedSessionConnection` 负责经过渲染层的请求:聚合时注入会话连接快照, +离开聚合后把旧会话恢复请求改为当前供应商。兼容配置覆盖未经过该入口的主进程原生恢复。 +`provider_sync` 继续按自己的开关与目标迁移历史索引及其负责的元数据;兼容配置修复只读历史, +不会替代或自动启用供应商同步。 + +官方登录使用 `requires_openai_auth = true`,不继承第三方凭据;显式官方 HTTP 传输继续保留。 +回归测试仅在临时目录使用合成登录内容,解析写出的配置并检查认证文件、历史身份与传输选项。 +这些测试不发起真实登录或模型请求,不等同于原生客户端端到端恢复验收。 + +可选的 `managed_session_provider::native_official_resume_requires_managed_alias_and_preserves_history_after_restart` +使用真实 CLI app-server 和合成历史。设置 `CODEX_PLUS_TEST_CODEX_BIN` 为原生 Codex 可执行文件后运行: + +```sh +cargo test --offline -p codex-plus-core --test all managed_session_provider:: -- --ignored --test-threads=1 +``` + +用例先检查缺失 provider 的原生恢复错误,再调用生产配置修复函数,验证恢复后的历史及重启后的按 ID 恢复。 +修复检测使用独立的合成 SQLite 索引;app-server 自己使用另一临时 SQLite 目录,由原生程序创建完整数据库。 +子进程清空继承环境并隔离 HOME、CODEX_HOME、CODEX_SQLITE_HOME、APPDATA 等目录,凭据存储限定为临时文件。 +仅允许 initialize、thread/resume、thread/read 请求,不发起 turn/start;退出和断言失败均清理本次进程树。 +该用例验证原生恢复与历史读取,不验证真实官方登录、鉴权或生成。