diff --git a/Cargo.lock b/Cargo.lock index 2a744529b..096c059e6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -454,6 +454,8 @@ dependencies = [ "base64 0.22.1", "chrono", "codex-plus-core", + "fs2", + "libc", "rusqlite", "serde", "serde_json", diff --git a/apps/codex-plus-launcher/src/main.rs b/apps/codex-plus-launcher/src/main.rs index d018be40f..1bbd20f57 100644 --- a/apps/codex-plus-launcher/src/main.rs +++ b/apps/codex-plus-launcher/src/main.rs @@ -73,11 +73,7 @@ async fn main() -> Result<()> { Ok(()) } -async fn launcher_main( - args: Vec, - helper_only: bool, - options: LaunchOptions, -) -> Result<()> { +async fn launcher_main(args: Vec, helper_only: bool, options: LaunchOptions) -> Result<()> { if helper_only { let hooks = LauncherHooks::default(); hooks.start_helper(options.helper_port).await?; @@ -132,13 +128,15 @@ fn acquire_single_instance_guard_with_retry( } Ok(Some(guard)) } - Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => { - log_launcher_already_running(debug_port); - Ok(None) - } - Err(error) if error.kind() == std::io::ErrorKind::AddrInUse => { + Err(error) + if matches!( + error.kind(), + std::io::ErrorKind::WouldBlock | std::io::ErrorKind::AddrInUse + ) => + { log_launcher_already_running(debug_port); - if allow_stale_recovery && should_recover_stale_launcher(debug_port) { + let stale = allow_stale_recovery && should_recover_stale_launcher(debug_port); + if should_retry_stale_launcher_guard(error.kind(), allow_stale_recovery, stale) { codex_plus_core::watcher::stop_launcher_processes(); std::thread::sleep(std::time::Duration::from_millis(250)); return acquire_single_instance_guard_with_retry(debug_port, false); @@ -156,6 +154,19 @@ fn acquire_single_instance_guard_with_retry( } } +fn should_retry_stale_launcher_guard( + error_kind: std::io::ErrorKind, + allow_stale_recovery: bool, + stale_launcher: bool, +) -> bool { + allow_stale_recovery + && stale_launcher + && matches!( + error_kind, + std::io::ErrorKind::WouldBlock | std::io::ErrorKind::AddrInUse + ) +} + fn try_acquire_single_instance_guard() -> std::io::Result { codex_plus_core::ports::acquire_resilient_loopback_port_guard( @@ -513,6 +524,15 @@ impl LaunchHooks for LauncherHooks { self.core.apply_active_relay_profile(settings).await } + async fn ensure_active_protocol_proxy_config( + &self, + settings: &codex_plus_core::settings::BackendSettings, + ) -> anyhow::Result<()> { + self.core + .ensure_active_protocol_proxy_config(settings) + .await + } + async fn ensure_plugin_marketplace_config( &self, settings: &codex_plus_core::settings::BackendSettings, @@ -1125,6 +1145,30 @@ mod tests { assert!(source.contains("status: \"failed\".to_string()")); } + #[test] + fn stale_launcher_recovery_covers_port_and_fallback_lock_conflicts() { + assert!(should_retry_stale_launcher_guard( + std::io::ErrorKind::WouldBlock, + true, + true + )); + assert!(should_retry_stale_launcher_guard( + std::io::ErrorKind::AddrInUse, + true, + true + )); + assert!(!should_retry_stale_launcher_guard( + std::io::ErrorKind::WouldBlock, + false, + true + )); + assert!(!should_retry_stale_launcher_guard( + std::io::ErrorKind::PermissionDenied, + true, + true + )); + } + #[test] fn existing_launcher_path_drains_pending_remote_control_recovery_before_activation() { let source = include_str!("main.rs"); @@ -1197,6 +1241,7 @@ mod tests { #[test] fn launcher_hooks_forward_runtime_watchdog_and_marketplace_methods() { let source = include_str!("main.rs"); + let compact_source = source.split_whitespace().collect::(); assert!(source.contains("async fn start_bridge_watchdog")); assert!(source.contains("self.watchdog_bridge_context()?")); @@ -1204,6 +1249,11 @@ mod tests { assert!(source.contains("inject_with_context(debug_port, helper_port, ctx, runtime)")); assert!(source.contains("async fn ensure_plugin_marketplace_config")); assert!(source.contains("self.core.ensure_plugin_marketplace_config(settings).await")); + assert!(source.contains("async fn ensure_active_protocol_proxy_config")); + assert!( + compact_source + .contains("self.core.ensure_active_protocol_proxy_config(settings).await") + ); } #[tokio::test] diff --git a/apps/codex-plus-manager/src-tauri/src/commands.rs b/apps/codex-plus-manager/src-tauri/src/commands.rs index 33e13265c..7a1405ea2 100644 --- a/apps/codex-plus-manager/src-tauri/src/commands.rs +++ b/apps/codex-plus-manager/src-tauri/src/commands.rs @@ -4,7 +4,7 @@ use std::io::{Read, Seek, SeekFrom}; use std::path::{Path, PathBuf}; use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::{Arc, Mutex, OnceLock}; -use std::time::{SystemTime, UNIX_EPOCH}; +use std::time::{Instant, SystemTime, UNIX_EPOCH}; use codex_plus_core::install::SILENT_BINARY; use codex_plus_core::models::{DeleteResult, SessionRef}; @@ -629,8 +629,93 @@ pub fn launch_codex_plus(request: LaunchRequest) -> CommandResult { } #[tauri::command] -pub fn restart_codex_plus(request: LaunchRequest) -> CommandResult { - let Ok(_guard) = relay_switch_mutex().lock() else { +pub async fn restart_codex_plus(request: LaunchRequest) -> CommandResult { + let error_payload = request.clone(); + match tauri::async_runtime::spawn_blocking(move || restart_codex_plus_blocking(request)).await { + Ok(result) => result, + Err(error) => failed( + &format!("重启 Codex++ 后台任务失败:{error}"), + json!({ + "debugPort": error_payload.debug_port, + "helperPort": error_payload.helper_port, + "syncActiveRelay": error_payload.sync_active_relay + }), + ), + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum RestartDisposition { + LaunchOnly, + StopAndRestart, +} + +fn restart_disposition(sync_active_relay: bool, target_app_running: bool) -> RestartDisposition { + if !sync_active_relay && !target_app_running { + RestartDisposition::LaunchOnly + } else { + RestartDisposition::StopAndRestart + } +} + +fn restart_codex_plus_blocking(request: LaunchRequest) -> CommandResult { + let restart_started = Instant::now(); + let _restart_guard = match try_acquire_restart_guard() { + Ok(guard) => guard, + Err(message) => return failed(message, json!({})), + }; + let target_app_running = !codex_plus_core::watcher::find_codex_processes().is_empty(); + let disposition = restart_disposition(request.sync_active_relay, target_app_running); + let _ = codex_plus_core::diagnostic_log::append_diagnostic_log( + "manager.restart_requested", + json!({ + "debug_port": request.debug_port, + "helper_port": request.helper_port, + "app_path": request.app_path.trim(), + "sync_active_relay": request.sync_active_relay, + "target_app_running": target_app_running, + "disposition": match disposition { + RestartDisposition::LaunchOnly => "launch_only", + RestartDisposition::StopAndRestart => "stop_and_restart", + }, + }), + ); + if disposition == RestartDisposition::LaunchOnly { + let _ = codex_plus_core::diagnostic_log::append_diagnostic_log( + "manager.restart_as_launch", + json!({ + "debug_port": request.debug_port, + "elapsed_ms": restart_started.elapsed().as_millis(), + }), + ); + return spawn_codex_plus_launch( + request, + "未发现正在运行的目标 Codex App,已按启动方式在后台尝试唤起。", + ); + } + + let provider_guard_started = Instant::now(); + let provider_sync_guard = match ensure_provider_sync_is_idle_before_stop() { + Ok(guard) => guard, + Err(message) => { + return failed( + &message, + json!({ + "debugPort": request.debug_port, + "helperPort": request.helper_port, + "syncActiveRelay": request.sync_active_relay + }), + ); + } + }; + let _ = codex_plus_core::diagnostic_log::append_diagnostic_log( + "manager.restart_provider_guard_acquired", + json!({ + "phase_elapsed_ms": provider_guard_started.elapsed().as_millis(), + "total_elapsed_ms": restart_started.elapsed().as_millis(), + }), + ); + let Ok(_relay_guard) = relay_switch_mutex().lock() else { return failed("供应商切换锁已损坏,请重启管理器后再试。", json!({})); }; let settings = if request.sync_active_relay { @@ -649,28 +734,32 @@ pub fn restart_codex_plus(request: LaunchRequest) -> CommandResult { } else { None }; - if let Err(message) = ensure_provider_sync_is_idle_before_stop() { - return failed( - &message, - json!({ - "debugPort": request.debug_port, - "helperPort": request.helper_port, - "syncActiveRelay": request.sync_active_relay - }), - ); + if let Err(message) = codex_plus_core::watcher::stop_launcher_processes_and_wait() { + return failed(&format!("重启 Codex++ 已安全中止:{message}"), json!({})); } - codex_plus_core::watcher::stop_launcher_processes_and_wait(); - codex_plus_core::watcher::stop_codex_processes_for_debug_port_and_wait(request.debug_port); - let home = codex_plus_core::relay_config::default_codex_home_dir(); let _ = codex_plus_core::diagnostic_log::append_diagnostic_log( - "manager.restart_requested", + "manager.restart_launchers_stopped", + json!({ "total_elapsed_ms": restart_started.elapsed().as_millis() }), + ); + let targeted_stop_outcome = + match codex_plus_core::watcher::stop_codex_processes_for_restart_and_wait() { + Ok(outcome) => outcome, + Err(message) => { + return failed(&format!("重启 Codex++ 已安全中止:{message}"), json!({})); + } + }; + let targeted_stop_outcome = match targeted_stop_outcome { + codex_plus_core::watcher::CodexStopOutcome::Stopped => "stopped", + codex_plus_core::watcher::CodexStopOutcome::AlreadyAbsent => "already_absent", + }; + let _ = codex_plus_core::diagnostic_log::append_diagnostic_log( + "manager.restart_codex_stopped", json!({ - "debug_port": request.debug_port, - "helper_port": request.helper_port, - "app_path": request.app_path.trim(), - "sync_active_relay": request.sync_active_relay + "total_elapsed_ms": restart_started.elapsed().as_millis(), + "outcome": targeted_stop_outcome, }), ); + let home = codex_plus_core::relay_config::default_codex_home_dir(); let launch_started_at_ms = current_timestamp_ms(); if let Err(error) = save_requested_launch_status( &request, @@ -687,17 +776,31 @@ pub fn restart_codex_plus(request: LaunchRequest) -> CommandResult { }), ); } - match restart_codex_plus_after_stop(&request, &home, settings.as_ref(), spawn_silent_launcher) { - Ok(()) => CommandResult { - status: "accepted".to_string(), - message: "Codex 已请求重启,启动任务正在后台运行。".to_string(), - payload: json!({ - "debugPort": request.debug_port, - "helperPort": request.helper_port, - "syncActiveRelay": request.sync_active_relay, - "launchStartedAtMs": launch_started_at_ms - }), - }, + let spawn_after_guard_release = move |request: &LaunchRequest| { + spawn_after_provider_sync_guard_release(provider_sync_guard, request, spawn_silent_launcher) + }; + match restart_codex_plus_after_stop( + &request, + &home, + settings.as_ref(), + spawn_after_guard_release, + ) { + Ok(()) => { + let _ = codex_plus_core::diagnostic_log::append_diagnostic_log( + "manager.restart_launcher_spawned", + json!({ "total_elapsed_ms": restart_started.elapsed().as_millis() }), + ); + CommandResult { + status: "accepted".to_string(), + message: "Codex 已请求重启,启动任务正在后台运行。".to_string(), + payload: json!({ + "debugPort": request.debug_port, + "helperPort": request.helper_port, + "syncActiveRelay": request.sync_active_relay, + "launchStartedAtMs": launch_started_at_ms + }), + } + } Err(error) => { let message = format!("重启 Codex++ 失败:{error}"); let _ = @@ -748,6 +851,20 @@ where Ok(()) } +fn spawn_after_provider_sync_guard_release( + guard: codex_plus_data::ProviderSyncLifecycleGuard, + request: &LaunchRequest, + spawn: F, +) -> anyhow::Result<()> +where + F: FnOnce(&LaunchRequest) -> anyhow::Result<()>, +{ + guard + .release() + .map_err(|error| anyhow::anyhow!("释放历史会话同步保护锁失败,未启动 Codex++:{error}"))?; + spawn(request) +} + #[derive(Debug)] struct RelayLiveSnapshot { config: Option>, @@ -5507,6 +5624,21 @@ fn relay_switch_mutex() -> &'static Mutex<()> { RELAY_SWITCH_LOCK.get_or_init(|| Mutex::new(())) } +fn restart_mutex() -> &'static Mutex<()> { + static RESTART_LOCK: OnceLock> = OnceLock::new(); + RESTART_LOCK.get_or_init(|| Mutex::new(())) +} + +fn try_acquire_restart_guard() -> Result, &'static str> { + match restart_mutex().try_lock() { + Ok(guard) => Ok(guard), + Err(std::sync::TryLockError::WouldBlock) => { + Err("已有 Codex++ 重启任务正在进行,请稍后再试。") + } + Err(std::sync::TryLockError::Poisoned(_)) => Err("重启任务锁已损坏,请重启管理器后再试。"), + } +} + fn empty_context_entries() -> codex_plus_core::relay_config::CodexContextEntries { codex_plus_core::relay_config::CodexContextEntries { mcp_servers: Vec::new(), @@ -5910,61 +6042,33 @@ fn failed(message: &str, payload: T) -> CommandResult { } } -/// provider sync 正在进行时,最多等它这么久再考虑放弃重启。 -const PROVIDER_SYNC_WAIT_TIMEOUT_MS: u64 = 30_000; -const PROVIDER_SYNC_WAIT_INTERVAL_MS: u64 = 200; - -/// 等待正在执行的 provider sync 结束。 -/// -/// launcher 在同步期间持有 `~/.codex/tmp/provider-sync.lock`,而这一步之后调用方会 -/// `TerminateProcess` 强杀 launcher。被强杀的进程来不及 `release_lock()`,会留下残留锁, -/// 使后续启动全部跳过同步,用户侧表现为历史会话消失或「修复 0 个会话」(issue #1901)。 -/// 因此这里先等同步自然结束;等不到就拒绝本次重启,而不是把它打断。 -fn wait_for_idle_provider_sync( - inspect: impl Fn() -> codex_plus_data::ProviderSyncLockState, - sleep: impl Fn(u64), - timeout_ms: u64, -) -> Result<(), codex_plus_data::ProviderSyncLockState> { - use codex_plus_data::ProviderSyncLockState; - - let mut waited_ms = 0; - loop { - // Stale 锁的持有者已经退出,下一次 acquire_lock 会自动回收它,不必等。 - match inspect() { - ProviderSyncLockState::Free | ProviderSyncLockState::Stale { .. } => return Ok(()), - state => { - if waited_ms >= timeout_ms { - return Err(state); - } - } - } - sleep(PROVIDER_SYNC_WAIT_INTERVAL_MS); - waited_ms += PROVIDER_SYNC_WAIT_INTERVAL_MS; - } -} - -/// 在强杀 launcher 前放行或拦截本次重启,并把判定结果写进诊断日志。 -fn ensure_provider_sync_is_idle_before_stop() -> Result<(), String> { - let outcome = wait_for_idle_provider_sync( - || codex_plus_data::inspect_provider_sync_lock(None), - |ms| std::thread::sleep(std::time::Duration::from_millis(ms)), - PROVIDER_SYNC_WAIT_TIMEOUT_MS, - ); - match outcome { - Ok(()) => Ok(()), - Err(state) => { +/// 原子取得 provider-sync 生命周期 guard;同步正忙时立即拒绝重启。 +fn ensure_provider_sync_is_idle_before_stop() +-> Result { + match codex_plus_data::try_acquire_provider_sync_lifecycle_guard(None) { + Ok(guard) => Ok(guard), + Err(error) + if matches!( + error.kind(), + std::io::ErrorKind::AlreadyExists | std::io::ErrorKind::WouldBlock + ) => + { + let state = codex_plus_data::inspect_provider_sync_lock(None); let _ = codex_plus_core::diagnostic_log::append_diagnostic_log( "manager.restart_blocked_by_provider_sync", - json!({ - "state": state, - "waited_ms": PROVIDER_SYNC_WAIT_TIMEOUT_MS, - }), + json!({ "state": state }), ); Err(format!( - "历史会话同步正在进行中(已等待 {} 秒)。为避免中断同步导致会话丢失,本次重启未执行;请等待同步完成后重试。", - PROVIDER_SYNC_WAIT_TIMEOUT_MS / 1000 + "历史会话同步正在进行中。为避免中断同步导致会话丢失,本次重启未执行;请稍后重试。" )) } + Err(error) => { + let _ = codex_plus_core::diagnostic_log::append_diagnostic_log( + "manager.restart_provider_sync_guard_failed", + json!({ "message": error.to_string() }), + ); + Err(format!("无法取得历史会话同步保护锁,未执行重启:{error}")) + } } } @@ -6142,83 +6246,161 @@ mod tests { } #[test] - fn restart_does_not_wait_when_no_provider_sync_is_running() { - let slept = std::cell::Cell::new(0); + fn ordinary_restart_uses_launch_path_when_target_app_is_absent() { + assert_eq!( + restart_disposition(false, false), + RestartDisposition::LaunchOnly + ); + } + + #[test] + fn ordinary_restart_stops_and_restarts_when_target_app_is_running() { + assert_eq!( + restart_disposition(false, true), + RestartDisposition::StopAndRestart + ); + } - let outcome = wait_for_idle_provider_sync( - || codex_plus_data::ProviderSyncLockState::Free, - |ms| slept.set(slept.get() + ms), - PROVIDER_SYNC_WAIT_TIMEOUT_MS, + #[test] + fn active_relay_restart_keeps_full_restart_when_target_app_is_absent() { + assert_eq!( + restart_disposition(true, false), + RestartDisposition::StopAndRestart ); + } - assert!(outcome.is_ok()); - assert_eq!(slept.get(), 0); + #[test] + fn restart_as_launch_decision_precedes_provider_sync_guard_wait() { + let source = include_str!("commands.rs"); + let async_start = source + .find("pub async fn restart_codex_plus") + .expect("async restart command"); + let start = source + .find("fn restart_codex_plus_blocking") + .expect("restart command"); + let async_body = &source[async_start..start]; + let end = source[start..] + .find("fn restart_codex_plus_after_stop") + .map(|offset| start + offset) + .expect("restart helper boundary"); + let body = &source[start..end]; + let launch_only = body + .find("if disposition == RestartDisposition::LaunchOnly") + .expect("restart-as-launch branch"); + let provider_guard = body + .find("ensure_provider_sync_is_idle_before_stop") + .expect("provider sync guard"); + + assert!(async_body.contains("spawn_blocking")); + assert!(body.contains("codex_plus_core::watcher::find_codex_processes()")); + assert!(!body.contains("codex_plus_core::cdp")); + assert!(launch_only < provider_guard); + assert!(body[launch_only..provider_guard].contains("spawn_codex_plus_launch")); } #[test] - fn restart_does_not_wait_on_a_lock_whose_owner_already_exited() { - let slept = std::cell::Cell::new(0); + fn full_restart_stops_native_app_before_live_sync() { + let source = include_str!("commands.rs"); + let start = source + .find("fn restart_codex_plus_blocking") + .expect("restart command"); + let end = source[start..] + .find("fn restart_codex_plus_after_stop") + .map(|offset| start + offset) + .expect("restart helper boundary"); + let body = &source[start..end]; + let stop_launcher = body + .find("stop_launcher_processes_and_wait") + .expect("launcher stop"); + let stop_app = body + .find("stop_codex_processes_for_restart_and_wait") + .expect("native app stop"); + let live_sync = body + .find("restart_codex_plus_after_stop") + .unwrap_or(body.len()); + + assert!(stop_launcher < stop_app); + assert!(stop_app < live_sync); + assert!(body[stop_app..live_sync].contains("return failed")); + } - let outcome = wait_for_idle_provider_sync( - || codex_plus_data::ProviderSyncLockState::Stale { pid: Some(4321) }, - |ms| slept.set(slept.get() + ms), - PROVIDER_SYNC_WAIT_TIMEOUT_MS, - ); + #[test] + fn restart_provider_sync_guard_is_fail_fast() { + let source = include_str!("commands.rs"); + let start = source + .find("fn ensure_provider_sync_is_idle_before_stop") + .expect("provider sync guard"); + let end = source[start..] + .find("fn default_debug_port") + .map(|offset| start + offset) + .expect("provider sync guard boundary"); + let body = &source[start..end]; - assert!(outcome.is_ok()); - assert_eq!(slept.get(), 0); + assert!(body.contains("try_acquire_provider_sync_lifecycle_guard")); + assert!(!body.contains("sleep")); + assert!(!body.contains("WAIT_TIMEOUT")); + assert!(!body.contains("wait_for_idle_provider_sync")); } #[test] - fn restart_proceeds_once_an_in_flight_provider_sync_releases_the_lock() { - let polls = std::cell::Cell::new(0); - - let outcome = wait_for_idle_provider_sync( - || { - polls.set(polls.get() + 1); - if polls.get() < 3 { - codex_plus_data::ProviderSyncLockState::Held { - pid: 4321, - started_at: 1234, - } - } else { - codex_plus_data::ProviderSyncLockState::Free - } - }, - |_| {}, - PROVIDER_SYNC_WAIT_TIMEOUT_MS, - ); + fn concurrent_restart_is_rejected_by_single_flight_guard() { + let first = try_acquire_restart_guard().unwrap(); - assert!(outcome.is_ok()); - assert_eq!(polls.get(), 3); + let second = try_acquire_restart_guard(); + + assert!(matches!(second, Err(message) if message.contains("正在进行"))); + drop(first); + assert!(try_acquire_restart_guard().is_ok()); } - /// issue #1901:同步一直不结束时宁可拒绝重启,也不能强杀持锁的 launcher。 #[test] - fn restart_is_refused_while_a_provider_sync_keeps_holding_the_lock() { - let held = codex_plus_data::ProviderSyncLockState::Held { - pid: 4321, - started_at: 1234, - }; + fn restart_releases_provider_guard_before_spawning() { + let temp = tempfile::tempdir().unwrap(); + let guard = + codex_plus_data::try_acquire_provider_sync_lifecycle_guard(Some(temp.path())).unwrap(); + let spawned = std::cell::Cell::new(false); - let outcome = - wait_for_idle_provider_sync(|| held.clone(), |_| {}, PROVIDER_SYNC_WAIT_TIMEOUT_MS); + spawn_after_provider_sync_guard_release(guard, &launch_request(false), |_| { + assert_eq!( + codex_plus_data::inspect_provider_sync_lock(Some(temp.path())), + codex_plus_data::ProviderSyncLockState::Free + ); + let replacement = + codex_plus_data::try_acquire_provider_sync_lifecycle_guard(Some(temp.path()))?; + replacement.release()?; + spawned.set(true); + Ok(()) + }) + .unwrap(); - assert_eq!(outcome, Err(held)); + assert!(spawned.get()); } #[test] - fn restart_is_refused_while_the_lock_owner_cannot_be_determined() { - let outcome = wait_for_idle_provider_sync( - || codex_plus_data::ProviderSyncLockState::Indeterminate, - |_| {}, - PROVIDER_SYNC_WAIT_TIMEOUT_MS, - ); + fn restart_does_not_spawn_when_provider_guard_release_fails() { + let temp = tempfile::tempdir().unwrap(); + let guard = + codex_plus_data::try_acquire_provider_sync_lifecycle_guard(Some(temp.path())).unwrap(); + std::fs::write( + temp.path().join("tmp/provider-sync.lock/owner.json"), + json!({ + "pid": std::process::id(), + "startedAt": 1, + "lockId": "replacement-owner", + }) + .to_string(), + ) + .unwrap(); + let spawned = std::cell::Cell::new(false); - assert_eq!( - outcome, - Err(codex_plus_data::ProviderSyncLockState::Indeterminate) - ); + let error = spawn_after_provider_sync_guard_release(guard, &launch_request(false), |_| { + spawned.set(true); + Ok(()) + }) + .unwrap_err(); + + assert!(!spawned.get()); + assert!(error.to_string().contains("未启动 Codex++")); } #[test] diff --git a/assets/inject/renderer-inject.js b/assets/inject/renderer-inject.js index 9a2ac85f8..4ad23642b 100644 --- a/assets/inject/renderer-inject.js +++ b/assets/inject/renderer-inject.js @@ -104,7 +104,6 @@ const installationKey = `2:${enabled ? "on" : "off"}:${locale}`; if (window.__codexPlusForceChineseLocaleInstalled === installationKey) return; window.__codexPlusForceChineseLocaleInstalled = installationKey; - const languages = [locale, "zh", "en-US", "en"]; const managedLocaleStorageKey = "codexPlus.forceChineseLocale.managed.v1"; const localeReloadStorageKey = "codexPlus.forceChineseLocale.reload.v1"; @@ -241,26 +240,6 @@ syncOfficialLocaleSetting().catch(() => {}); if (!enabled) return; - const defineNavigatorGetter = (name, value) => { - try { - Object.defineProperty(Navigator.prototype, name, { - configurable: true, - get: () => value, - }); - } catch { - try { - Object.defineProperty(navigator, name, { - configurable: true, - get: () => value, - }); - } catch { - } - } - }; - - defineNavigatorGetter("language", locale); - defineNavigatorGetter("languages", languages); - const patchI18nConfig = (dynamicConfig) => { if (!dynamicConfig || typeof dynamicConfig !== "object") return dynamicConfig; const value = dynamicConfig.value && typeof dynamicConfig.value === "object" ? dynamicConfig.value : {}; @@ -6749,7 +6728,7 @@ const nextValue = { ...value, available_models: availableModels, - default_model: names[0] || value.default_model, + default_model: value.default_model || names[0], }; if (!changed && nextValue.default_model === value.default_model) return config; try { diff --git a/crates/codex-plus-core/src/assets.rs b/crates/codex-plus-core/src/assets.rs index 0c5807f82..7197c9f29 100644 --- a/crates/codex-plus-core/src/assets.rs +++ b/crates/codex-plus-core/src/assets.rs @@ -116,6 +116,7 @@ fn dream_skin_target_runtime_script(settings: &BackendSettings, include_art: boo } let (engine, renderer, base_css) = dream_skin_target_assets(settings); + let base_css = preserve_host_typography(base_css); let managed_css = managed_dream_skin_css(settings); let css = format!("{base_css}\n{managed_css}"); let theme = serde_json::to_string(&settings.codex_app_dream_skin_theme_config) @@ -192,6 +193,27 @@ fn dream_skin_target_runtime_script(settings: &BackendSettings, include_art: boo payload } +fn preserve_host_typography(css: &str) -> String { + let mut output = String::with_capacity(css.len()); + let mut host_body_rule = false; + for line in css.split_inclusive('\n') { + let trimmed = line.trim(); + if matches!( + trimmed, + "html.codex-dream-skin body {" | "html.codex-glass-vision-skin body {" + ) { + host_body_rule = true; + } + if !(host_body_rule && trimmed.starts_with("font-family:")) { + output.push_str(line); + } + if host_body_rule && trimmed == "}" { + host_body_rule = false; + } + } + output +} + fn managed_dream_skin_css(settings: &BackendSettings) -> String { let image_path = settings.codex_app_dream_skin_image_path.trim(); if image_path.is_empty() @@ -468,6 +490,9 @@ pub fn dream_skin_art_content_signature(settings: &BackendSettings) -> String { pub fn dream_skin_runtime_content_signature(settings: &BackendSettings) -> String { let (engine, _, css) = dream_skin_target_assets(settings); + let css = preserve_host_typography(css); + let managed_css = managed_dream_skin_css(settings); + let css = format!("{css}\n{managed_css}"); let theme = serde_json::to_string(&settings.codex_app_dream_skin_theme_config) .expect("dream skin target theme should serialize"); let style_revision = dream_skin_content_signature(css.as_bytes()); @@ -740,6 +765,50 @@ mod tests { assert_eq!(config["fitMode"].as_str(), Some("fill")); } + #[test] + fn dream_skin_preserves_host_body_typography() { + let css = concat!( + "html.codex-dream-skin body {\n", + " color: white;\n", + " font-family: Example, sans-serif !important;\n", + "}\n", + "html.codex-dream-skin button {\n", + " font-family: inherit !important;\n", + "}\n", + ); + + let preserved = preserve_host_typography(css); + + assert!(!preserved.contains("font-family: Example")); + assert!(preserved.contains("font-family: inherit !important")); + } + + #[test] + fn bundled_dream_skins_drop_only_the_host_body_font_rule() { + for css in [ + DREAM_TARGET_CSS, + CIDALA_TARGET_CSS, + CODEX_SNOW_CSS, + GLASS_VISION_CSS, + ] { + let preserved = preserve_host_typography(css); + + assert_eq!(css.lines().count(), preserved.lines().count() + 1); + } + } + + #[test] + fn dream_skin_runtime_signature_matches_the_filtered_payload() { + let settings = BackendSettings { + codex_app_dream_skin_enabled: true, + ..BackendSettings::default() + }; + let signature = dream_skin_runtime_content_signature(&settings); + let script = dream_skin_live_update_script(&settings, false); + + assert!(script.contains(&serde_json::to_string(&signature).unwrap())); + } + #[test] fn local_plugin_marketplaces_includes_api_marketplace_snapshot() { let temp = tempfile::tempdir().unwrap(); diff --git a/crates/codex-plus-core/src/cdp.rs b/crates/codex-plus-core/src/cdp.rs index 047d7d942..5a1d17035 100644 --- a/crates/codex-plus-core/src/cdp.rs +++ b/crates/codex-plus-core/src/cdp.rs @@ -43,7 +43,7 @@ impl CdpBrowserIdentity { } } -/// Returns whether the requested loopback port exposes a CDP target list. +/// Returns whether the requested loopback port exposes the main Codex App CDP target. pub(crate) fn endpoint_available(debug_port: u16) -> bool { [ SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), debug_port), @@ -104,12 +104,8 @@ fn response_contains_codex_target(response: &[u8], debug_port: u16) -> bool { return false; }; targets.iter().any(|target| { - is_primary_codex_page_target(target) - && target - .url - .trim() - .to_ascii_lowercase() - .starts_with("app://-/") + is_injectable_page_target(target) + && is_codex_app_page_target(target) && target .web_socket_debugger_url .as_deref() @@ -379,10 +375,10 @@ mod endpoint_tests { } #[test] - fn endpoint_available_accepts_devtools_target_response() { + fn endpoint_available_accepts_the_current_chatgpt_titled_app_main_target() { let (port, server) = serve_once(|port| { format!( - r#"[{{"id":"codex","type":"page","title":"Codex","url":"app://-/index.html","webSocketDebuggerUrl":"ws://127.0.0.1:{port}/devtools/page/1"}}]"# + r#"[{{"id":"codex","type":"page","title":"ChatGPT","url":"app://-/index.html","webSocketDebuggerUrl":"ws://127.0.0.1:{port}/devtools/page/1"}}]"# ) }); @@ -423,14 +419,26 @@ mod endpoint_tests { } #[test] - fn endpoint_available_rejects_quick_chat_only_target() { + fn endpoint_available_accepts_quick_chat_app_target() { let (port, server) = serve_once(|port| { format!( r#"[{{"id":"quick-chat","type":"page","title":"Codex","url":"app://-/index.html?initialRoute=%2Fchatgpt%2Fquick-chat-prewarm","webSocketDebuggerUrl":"ws://127.0.0.1:{port}/devtools/page/1"}}]"# ) }); - assert!(!endpoint_available(port)); + assert!(endpoint_available(port)); + server.join().unwrap(); + } + + #[test] + fn endpoint_available_accepts_avatar_overlay_app_target() { + let (port, server) = serve_once(|port| { + format!( + r#"[{{"id":"avatar","type":"page","title":"ChatGPT","url":"app://-/index.html?initialRoute=%2Favatar-overlay","webSocketDebuggerUrl":"ws://127.0.0.1:{port}/devtools/page/1"}}]"# + ) + }); + + assert!(endpoint_available(port)); server.join().unwrap(); } } diff --git a/crates/codex-plus-core/src/launcher.rs b/crates/codex-plus-core/src/launcher.rs index cda4a140e..f00b2bd61 100644 --- a/crates/codex-plus-core/src/launcher.rs +++ b/crates/codex-plus-core/src/launcher.rs @@ -171,6 +171,12 @@ pub trait LaunchHooks: Send + Sync { async fn apply_active_relay_profile(&self, _settings: &BackendSettings) -> anyhow::Result<()> { Ok(()) } + async fn ensure_active_protocol_proxy_config( + &self, + _settings: &BackendSettings, + ) -> anyhow::Result<()> { + Ok(()) + } async fn ensure_plugin_marketplace_config( &self, _settings: &BackendSettings, @@ -387,6 +393,7 @@ where let protocol_proxy_enabled = relay_protocol_proxy_enabled(&settings) || remote_control_provider_proxy_enabled(&settings); if protocol_proxy_enabled { + hooks.ensure_active_protocol_proxy_config(&settings).await?; helper_port = crate::protocol_proxy::DEFAULT_PROTOCOL_PROXY_PORT; } if settings.enhancements_enabled || protocol_proxy_enabled { @@ -685,6 +692,15 @@ impl LaunchHooks for DefaultLaunchHooks { Ok(()) } + async fn ensure_active_protocol_proxy_config( + &self, + settings: &BackendSettings, + ) -> anyhow::Result<()> { + let home = crate::relay_config::default_codex_home_dir(); + crate::relay_config::ensure_active_protocol_proxy_config_in_home(&home, settings)?; + Ok(()) + } + async fn ensure_plugin_marketplace_config( &self, settings: &BackendSettings, diff --git a/crates/codex-plus-core/src/relay_config.rs b/crates/codex-plus-core/src/relay_config.rs index 63dbf72da..27eb47779 100644 --- a/crates/codex-plus-core/src/relay_config.rs +++ b/crates/codex-plus-core/src/relay_config.rs @@ -6,7 +6,7 @@ use std::path::{Path, PathBuf}; use std::time::{SystemTime, UNIX_EPOCH}; use toml_edit::{DocumentMut, Item, Table, TableLike}; -use crate::settings::{RelayProfile, RelayProtocol, RelaySessionProvider}; +use crate::settings::{BackendSettings, RelayProfile, RelayProtocol, RelaySessionProvider}; const RELAY_PROVIDER: &str = "custom"; /// 我们代管的 config.toml 上下文表。 @@ -297,6 +297,77 @@ pub fn responses_proxy_configured_in_home(home: &Path) -> bool { ) } +pub fn ensure_active_protocol_proxy_config_in_home( + home: &Path, + settings: &BackendSettings, +) -> anyhow::Result { + let profile = settings.active_relay_profile(); + let transport_uses_proxy = settings.active_relay_transport_uses_protocol_proxy(); + let openai_identity_uses_proxy = settings.active_relay_session_provider() + == RelaySessionProvider::Openai + || (profile.relay_mode == crate::settings::RelayMode::Official + && profile.official_mix_api_key); + if !transport_uses_proxy && !openai_identity_uses_proxy { + return Ok(false); + } + + let config_path = home.join("config.toml"); + let existing = std::fs::read_to_string(&config_path) + .with_context(|| format!("读取 {} 失败", config_path.display()))?; + let mut doc = parse_toml_document(&existing)?; + let managed = managed_openai_base_url(); + let mut changed = false; + + if transport_uses_proxy { + let session_provider_id = active_session_provider_id(&doc); + let transport_provider_id = if session_provider_id == "openai" { + RELAY_PROVIDER.to_string() + } else { + active_or_default_provider_id(&doc) + }; + let provider = doc + .get_mut("model_providers") + .and_then(Item::as_table_mut) + .and_then(|providers| providers.get_mut(&transport_provider_id)) + .and_then(Item::as_table_mut) + .ok_or_else(|| { + anyhow::anyhow!("活动协议代理需要现有 model_providers.{transport_provider_id} 配置") + })?; + let current = provider + .get("base_url") + .and_then(Item::as_str) + .map(str::trim); + if current != Some(managed.as_str()) { + provider["base_url"] = toml_edit::value(managed.as_str()); + changed = true; + } + } + + if openai_identity_uses_proxy { + let before = doc + .get(OPENAI_BASE_URL_KEY) + .and_then(Item::as_str) + .map(str::trim) + .map(ToString::to_string); + update_remote_control_openai_base_url(&mut doc, true); + let after = doc + .get(OPENAI_BASE_URL_KEY) + .and_then(Item::as_str) + .map(str::trim) + .map(ToString::to_string); + changed |= before != after; + } + + if !changed { + return Ok(false); + } + crate::settings::atomic_write( + &config_path, + ensure_trailing_newline(doc.to_string()).as_bytes(), + )?; + Ok(true) +} + pub fn apply_relay_config_to_home( home: &Path, base_url: &str, diff --git a/crates/codex-plus-core/src/settings.rs b/crates/codex-plus-core/src/settings.rs index 80c59105e..9bb20af34 100644 --- a/crates/codex-plus-core/src/settings.rs +++ b/crates/codex-plus-core/src/settings.rs @@ -759,11 +759,15 @@ impl BackendSettings { } } - pub fn active_relay_uses_protocol_proxy(&self) -> bool { + pub fn active_relay_transport_uses_protocol_proxy(&self) -> bool { self.active_aggregate_relay_profile().is_some() || self.active_relay_profile().protocol == RelayProtocol::ChatCompletions || self.active_relay_profile().has_model_routes() || self.active_relay_profile().uses_no_auth() + } + + pub fn active_relay_uses_protocol_proxy(&self) -> bool { + self.active_relay_transport_uses_protocol_proxy() || self.active_relay_session_provider() == RelaySessionProvider::Openai } } @@ -1685,7 +1689,7 @@ pub fn atomic_write(path: &Path, bytes: &[u8]) -> anyhow::Result<()> { let temp_path = temp_path_for(path); fs::write(&temp_path, bytes) .with_context(|| format!("failed to write temp file {}", temp_path.display()))?; - if let Err(error) = replace_file(&temp_path, path) { + if let Err(error) = atomic_replace_file(&temp_path, path) { let _ = fs::remove_file(&temp_path); return Err(error).with_context(|| { format!( @@ -1699,13 +1703,13 @@ pub fn atomic_write(path: &Path, bytes: &[u8]) -> anyhow::Result<()> { } #[cfg(not(windows))] -fn replace_file(source: &Path, target: &Path) -> anyhow::Result<()> { +pub fn atomic_replace_file(source: &Path, target: &Path) -> anyhow::Result<()> { fs::rename(source, target)?; Ok(()) } #[cfg(windows)] -fn replace_file(source: &Path, target: &Path) -> anyhow::Result<()> { +pub fn atomic_replace_file(source: &Path, target: &Path) -> anyhow::Result<()> { use std::os::windows::ffi::OsStrExt; use windows::Win32::Storage::FileSystem::{ MOVEFILE_REPLACE_EXISTING, MOVEFILE_WRITE_THROUGH, MoveFileExW, @@ -1732,6 +1736,130 @@ fn replace_file(source: &Path, target: &Path) -> anyhow::Result<()> { Ok(()) } +#[cfg(not(windows))] +pub fn atomic_replace_file_with_backup( + replacement: &Path, + target: &Path, + backup: &Path, +) -> anyhow::Result<()> { + if backup.exists() { + anyhow::bail!( + "atomic replacement backup already exists: {}", + backup.display() + ); + } + fs::rename(target, backup)?; + if let Err(error) = fs::hard_link(replacement, target) { + if !target.exists() { + let _ = fs::rename(backup, target); + } + return Err(error.into()); + } + fs::remove_file(replacement)?; + Ok(()) +} + +#[cfg(windows)] +pub fn directory_instance_identity(path: &Path) -> anyhow::Result { + use std::os::windows::fs::OpenOptionsExt; + use windows::Win32::Storage::FileSystem::FILE_FLAG_BACKUP_SEMANTICS; + + let directory = fs::OpenOptions::new() + .read(true) + .custom_flags(FILE_FLAG_BACKUP_SEMANTICS.0) + .open(path)?; + file_instance_identity(&directory) +} + +#[cfg(windows)] +pub fn file_instance_identity(file: &fs::File) -> anyhow::Result { + use std::os::windows::io::AsRawHandle; + use windows::Win32::Foundation::HANDLE; + use windows::Win32::Storage::FileSystem::{ + BY_HANDLE_FILE_INFORMATION, GetFileInformationByHandle, + }; + + let mut information = BY_HANDLE_FILE_INFORMATION::default(); + unsafe { + GetFileInformationByHandle(HANDLE(file.as_raw_handle()), &mut information)?; + } + Ok(format!( + "windows:{}:{:08x}{:08x}", + information.dwVolumeSerialNumber, information.nFileIndexHigh, information.nFileIndexLow + )) +} + +#[cfg(unix)] +pub fn directory_instance_identity(path: &Path) -> anyhow::Result { + file_instance_identity(&fs::File::open(path)?) +} + +#[cfg(unix)] +pub fn file_instance_identity(file: &fs::File) -> anyhow::Result { + use std::os::unix::fs::MetadataExt; + + let metadata = file.metadata()?; + Ok(format!("unix:{}:{}", metadata.dev(), metadata.ino())) +} + +#[cfg(not(any(windows, unix)))] +pub fn directory_instance_identity(path: &Path) -> anyhow::Result { + file_instance_identity(&fs::File::open(path)?) +} + +#[cfg(not(any(windows, unix)))] +pub fn file_instance_identity(file: &fs::File) -> anyhow::Result { + let metadata = file.metadata()?; + let modified = metadata + .modified()? + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default(); + Ok(format!( + "generic:{}:{}:{}", + metadata.len(), + modified.as_secs(), + modified.subsec_nanos() + )) +} + +#[cfg(windows)] +pub fn atomic_replace_file_with_backup( + replacement: &Path, + target: &Path, + backup: &Path, +) -> anyhow::Result<()> { + use std::os::windows::ffi::OsStrExt; + use windows::Win32::Storage::FileSystem::{REPLACE_FILE_FLAGS, ReplaceFileW}; + use windows::core::PCWSTR; + + let replacement = replacement + .as_os_str() + .encode_wide() + .chain(std::iter::once(0)) + .collect::>(); + let target = target + .as_os_str() + .encode_wide() + .chain(std::iter::once(0)) + .collect::>(); + let backup = backup + .as_os_str() + .encode_wide() + .chain(std::iter::once(0)) + .collect::>(); + unsafe { + ReplaceFileW( + PCWSTR(target.as_ptr()), + PCWSTR(replacement.as_ptr()), + PCWSTR(backup.as_ptr()), + REPLACE_FILE_FLAGS(0), + None, + None, + )?; + } + Ok(()) +} + fn temp_path_for(path: &Path) -> PathBuf { let mut temp_path = path.to_path_buf(); let extension = path.extension().and_then(|value| value.to_str()); @@ -1773,6 +1901,34 @@ mod tests { std::fs::remove_dir_all(dir).unwrap(); } + #[test] + fn atomic_replace_file_with_backup_preserves_the_displaced_file() { + let dir = temp_dir(); + let target = dir.join("target.json"); + let replacement = dir.join("replacement.json"); + let backup = dir.join("displaced.json"); + std::fs::write(&target, b"old").unwrap(); + std::fs::write(&replacement, b"new").unwrap(); + + atomic_replace_file_with_backup(&replacement, &target, &backup).unwrap(); + + assert_eq!(std::fs::read(&target).unwrap(), b"new"); + assert_eq!(std::fs::read(&backup).unwrap(), b"old"); + assert!(!replacement.exists()); + std::fs::remove_dir_all(dir).unwrap(); + } + + #[test] + fn directory_instance_identity_is_stable_for_the_same_directory() { + let dir = temp_dir(); + + let first = directory_instance_identity(&dir).unwrap(); + let second = directory_instance_identity(&dir).unwrap(); + + assert_eq!(first, second); + std::fs::remove_dir_all(dir).unwrap(); + } + #[test] fn settings_default_matches_expected_behavior() { let settings = BackendSettings::default(); diff --git a/crates/codex-plus-core/src/watcher.rs b/crates/codex-plus-core/src/watcher.rs index e674cff94..d4a018ede 100644 --- a/crates/codex-plus-core/src/watcher.rs +++ b/crates/codex-plus-core/src/watcher.rs @@ -16,6 +16,31 @@ const RESTART_STOP_WAIT_INTERVAL_MS: u64 = 100; pub const WATCHER_RUN_NAME: &str = "CodexPlusPlusWatcher"; pub const WATCHER_RUN_KEY: &str = r"Software\Microsoft\Windows\CurrentVersion\Run"; pub const WATCHER_STARTUP_SHORTCUT_NAME: &str = "CodexPlusPlusWatcher.lnk"; +#[cfg(any(test, target_os = "macos"))] +const LEGACY_MACOS_LAUNCHER_BINARY: &str = "CodexPlusPlus"; + +#[cfg(any(test, target_os = "macos"))] +fn macos_launcher_binary_names() -> [&'static str; 2] { + [crate::install::SILENT_BINARY, LEGACY_MACOS_LAUNCHER_BINARY] +} + +#[cfg(any(test, target_os = "macos"))] +fn macos_launcher_process_ids_from_pgrep_outputs( + outputs: impl IntoIterator>, +) -> Vec { + let mut process_ids = outputs + .into_iter() + .flat_map(|output| { + String::from_utf8_lossy(&output) + .lines() + .filter_map(|value| value.trim().parse::().ok()) + .collect::>() + }) + .collect::>(); + process_ids.sort_unstable(); + process_ids.dedup(); + process_ids +} #[derive(Debug, Clone, PartialEq, Eq)] pub struct WatcherInstallPlan { @@ -153,22 +178,177 @@ pub fn process_ids_still_running( .collect() } +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ProcessInstanceState { + NotRunning, + Running { + started_at_secs: Option, + birth_id: Option, + }, + Unknown, +} + #[cfg(windows)] -pub fn process_id_is_running(process_id: u32) -> Option { +pub fn inspect_process_instance(process_id: u32) -> ProcessInstanceState { if process_id == 0 { - return Some(false); + return ProcessInstanceState::NotRunning; } let processes = crate::windows_integration::enumerate_processes(); if processes.is_empty() { - return None; + return ProcessInstanceState::Unknown; } - Some( - processes - .iter() - .any(|process| process.process_id == process_id), + if !processes + .iter() + .any(|process| process.process_id == process_id) + { + return ProcessInstanceState::NotRunning; + } + let birth_id = crate::windows_integration::process_birth_id(process_id); + ProcessInstanceState::Running { + started_at_secs: birth_id + .and_then(crate::windows_integration::process_started_at_secs_from_birth_id), + birth_id: birth_id.map(|birth_id| birth_id.to_string()), + } +} + +#[cfg(any(target_os = "linux", target_os = "macos"))] +pub fn inspect_process_instance(process_id: u32) -> ProcessInstanceState { + match process_id_is_running(process_id) { + Some(false) => ProcessInstanceState::NotRunning, + Some(true) => { + let (started_at_secs, birth_id) = unix_process_identity(process_id); + ProcessInstanceState::Running { + started_at_secs, + birth_id, + } + } + None => ProcessInstanceState::Unknown, + } +} + +#[cfg(not(any(windows, target_os = "linux", target_os = "macos")))] +pub fn inspect_process_instance(process_id: u32) -> ProcessInstanceState { + if process_id == 0 { + ProcessInstanceState::NotRunning + } else { + ProcessInstanceState::Unknown + } +} + +#[cfg(any(target_os = "linux", target_os = "macos"))] +fn unix_process_identity(process_id: u32) -> (Option, Option) { + let process_id_arg = process_id.to_string(); + let output = std::process::Command::new("ps") + .args([ + "-p", + process_id_arg.as_str(), + "-o", + "etime=", + "-o", + "lstart=", + ]) + .env("LC_ALL", "C") + .output(); + let Ok(output) = output else { + return (None, None); + }; + if !output.status.success() { + return (None, None); + } + let text = String::from_utf8_lossy(&output.stdout); + let text = text.trim(); + let Some(split_at) = text.find(char::is_whitespace) else { + return (None, None); + }; + let elapsed = parse_ps_elapsed_seconds(&text[..split_at]); + let birth_id = text[split_at..].trim(); + let started_at_secs = elapsed.and_then(|elapsed| { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .ok() + .map(|now| now.as_secs().saturating_sub(elapsed)) + }); + ( + started_at_secs, + (!birth_id.is_empty()).then(|| birth_id.to_string()), ) } +#[cfg(any(target_os = "linux", target_os = "macos", test))] +fn parse_ps_elapsed_seconds(value: &str) -> Option { + let (days, time) = if let Some((days, time)) = value.split_once('-') { + (days.parse().ok()?, time) + } else { + (0, value) + }; + let parts = time + .split(':') + .map(str::parse::) + .collect::, _>>() + .ok()?; + let (hours, minutes, seconds) = match parts.as_slice() { + [minutes, seconds] => (0, *minutes, *seconds), + [hours, minutes, seconds] => (*hours, *minutes, *seconds), + _ => return None, + }; + Some(days * 86_400 + hours * 3_600 + minutes * 60 + seconds) +} + +#[cfg(test)] +mod process_identity_tests { + use super::*; + + #[test] + fn parses_ps_elapsed_time_formats() { + assert_eq!(parse_ps_elapsed_seconds("03:04"), Some(184)); + assert_eq!(parse_ps_elapsed_seconds("02:03:04"), Some(7_384)); + assert_eq!(parse_ps_elapsed_seconds("2-02:03:04"), Some(180_184)); + assert_eq!(parse_ps_elapsed_seconds("invalid"), None); + } + + #[test] + fn macos_launcher_names_preserve_current_and_legacy_binaries() { + assert_eq!( + macos_launcher_binary_names(), + ["codex-plus-plus", "CodexPlusPlus"] + ); + } + + #[test] + fn macos_launcher_process_ids_merge_names_and_deduplicate() { + let process_ids = macos_launcher_process_ids_from_pgrep_outputs([ + b"20\n10\n".to_vec(), + b"30\n20\ninvalid\n".to_vec(), + ]); + + assert_eq!(process_ids, vec![10, 20, 30]); + } + + #[cfg(windows)] + #[test] + fn current_windows_process_has_a_stable_birth_identity() { + let ProcessInstanceState::Running { + started_at_secs, + birth_id, + } = inspect_process_instance(std::process::id()) + else { + panic!("current process should be visible"); + }; + + assert!(started_at_secs.is_some()); + assert!(birth_id.is_some()); + } +} + +#[cfg(windows)] +pub fn process_id_is_running(process_id: u32) -> Option { + match inspect_process_instance(process_id) { + ProcessInstanceState::NotRunning => Some(false), + ProcessInstanceState::Running { .. } => Some(true), + ProcessInstanceState::Unknown => None, + } +} + #[cfg(target_os = "linux")] pub fn process_id_is_running(process_id: u32) -> Option { if process_id == 0 { @@ -382,7 +562,7 @@ pub fn stop_launcher_processes() { pub fn stop_launcher_processes() {} #[cfg(windows)] -pub fn stop_launcher_processes_and_wait() { +pub fn stop_launcher_processes_and_wait() -> Result<(), String> { let processes = crate::windows_integration::enumerate_processes(); let killable = filter_killable_launcher_processes( processes.iter().map(|process| { @@ -394,25 +574,35 @@ pub fn stop_launcher_processes_and_wait() { }), std::process::id(), ); - terminate_and_wait_for_exit( + if terminate_and_wait_for_exit( killable, RESTART_STOP_WAIT_TIMEOUT_MS, RESTART_STOP_WAIT_INTERVAL_MS, - ); + ) { + Ok(()) + } else { + Err("等待旧 Codex++ Launcher 退出超时,已中止重启。".to_string()) + } } #[cfg(target_os = "macos")] -pub fn stop_launcher_processes_and_wait() { - terminate_macos_processes_and_wait( +pub fn stop_launcher_processes_and_wait() -> Result<(), String> { + if terminate_macos_processes_and_wait( find_launcher_processes(), || find_launcher_processes(), RESTART_STOP_WAIT_TIMEOUT_MS, RESTART_STOP_WAIT_INTERVAL_MS, - ); + ) { + Ok(()) + } else { + Err("等待旧 Codex++ Launcher 退出超时,已中止重启。".to_string()) + } } #[cfg(not(any(windows, target_os = "macos")))] -pub fn stop_launcher_processes_and_wait() {} +pub fn stop_launcher_processes_and_wait() -> Result<(), String> { + Ok(()) +} #[cfg(windows)] pub fn stop_codex_processes() { @@ -431,41 +621,54 @@ pub fn stop_codex_processes() { } } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum CodexStopOutcome { + Stopped, + AlreadyAbsent, +} + #[cfg(windows)] -pub fn stop_codex_processes_and_wait() { - terminate_and_wait_for_exit( - find_codex_processes(), +pub fn stop_codex_processes_for_restart_and_wait() -> Result { + let process_ids = find_codex_processes(); + if process_ids.is_empty() { + return Ok(CodexStopOutcome::AlreadyAbsent); + } + if terminate_and_wait_for_exit( + process_ids, RESTART_STOP_WAIT_TIMEOUT_MS, RESTART_STOP_WAIT_INTERVAL_MS, - ); + ) { + Ok(CodexStopOutcome::Stopped) + } else { + Err("等待 Codex App 退出超时,已中止重启。".to_string()) + } } #[cfg(target_os = "macos")] -pub fn stop_codex_processes_and_wait() { - terminate_macos_processes_and_wait( - find_codex_processes(), +pub fn stop_codex_processes_for_restart_and_wait() -> Result { + let process_ids = find_codex_processes(); + if process_ids.is_empty() { + return Ok(CodexStopOutcome::AlreadyAbsent); + } + if terminate_macos_processes_and_wait( + process_ids, || find_codex_processes(), RESTART_STOP_WAIT_TIMEOUT_MS, RESTART_STOP_WAIT_INTERVAL_MS, - ); + ) { + Ok(CodexStopOutcome::Stopped) + } else { + Err("等待 Codex App 退出超时,已中止重启。".to_string()) + } } #[cfg(not(any(windows, target_os = "macos")))] -pub fn stop_codex_processes_and_wait() {} - -#[cfg(target_os = "macos")] -pub fn stop_codex_processes_for_debug_port_and_wait(debug_port: u16) { - terminate_macos_processes_and_wait( - find_macos_codex_processes_for_debug_port(debug_port), - || find_macos_codex_processes_for_debug_port(debug_port), - RESTART_STOP_WAIT_TIMEOUT_MS, - RESTART_STOP_WAIT_INTERVAL_MS, - ); +pub fn stop_codex_processes_for_restart_and_wait() -> Result { + Ok(CodexStopOutcome::AlreadyAbsent) } -#[cfg(not(target_os = "macos"))] -pub fn stop_codex_processes_for_debug_port_and_wait(_debug_port: u16) { - stop_codex_processes_and_wait(); +pub fn stop_codex_processes_and_wait() { + let _ = stop_codex_processes_for_restart_and_wait(); } #[cfg(target_os = "macos")] @@ -474,11 +677,12 @@ fn terminate_macos_processes_and_wait( mut find_processes: F, timeout_ms: u64, interval_ms: u64, -) where +) -> bool +where F: FnMut() -> Vec, { if process_ids.is_empty() { - return; + return true; } for process_id in &process_ids { let _ = terminate_macos_process(*process_id); @@ -497,7 +701,7 @@ fn terminate_macos_processes_and_wait( }), ); } - break; + return remaining.is_empty(); } std::thread::sleep(Duration::from_millis(interval_ms)); } @@ -515,61 +719,23 @@ fn terminate_macos_process(process_id: u32) -> std::io::Result<()> { #[cfg(target_os = "macos")] fn find_launcher_processes() -> Vec { - std::process::Command::new("pgrep") - .args(["-x", crate::install::SILENT_BINARY]) - .output() - .ok() - .into_iter() - .flat_map(|output| { - String::from_utf8_lossy(&output.stdout) - .lines() - .filter_map(|value| value.trim().parse::().ok()) - .collect::>() - }) - .collect() -} - -#[cfg(target_os = "macos")] -fn find_macos_codex_processes_for_debug_port(debug_port: u16) -> Vec { - let Ok(output) = std::process::Command::new("ps") - .args(["-axo", "pid=,args="]) - .output() - else { - return Vec::new(); - }; - macos_codex_process_ids_for_debug_port( - String::from_utf8_lossy(&output.stdout).lines(), - debug_port, + macos_launcher_process_ids_from_pgrep_outputs( + macos_launcher_binary_names() + .into_iter() + .filter_map(|binary| { + std::process::Command::new("pgrep") + .args(["-x", binary]) + .output() + .ok() + .map(|output| output.stdout) + }), ) } -#[cfg(target_os = "macos")] -fn macos_codex_process_ids_for_debug_port<'a>( - process_lines: impl IntoIterator, - debug_port: u16, -) -> Vec { - let debug_flag = format!("remote-debugging-port={debug_port}"); - let mut ids = process_lines - .into_iter() - .filter_map(|line| { - let trimmed = line.trim_start(); - let (pid, args) = trimmed.split_once(char::is_whitespace)?; - let process_id = pid.parse::().ok()?; - let is_desktop_main = (args.contains(".app/Contents/MacOS/ChatGPT") - || args.contains(".app/Contents/MacOS/Codex")) - && !args.contains("/Helpers/"); - (is_desktop_main && args.contains(&debug_flag)).then_some(process_id) - }) - .collect::>(); - ids.sort_unstable(); - ids.dedup(); - ids -} - #[cfg(windows)] -fn terminate_and_wait_for_exit(process_ids: Vec, timeout_ms: u64, interval_ms: u64) { +fn terminate_and_wait_for_exit(process_ids: Vec, timeout_ms: u64, interval_ms: u64) -> bool { if process_ids.is_empty() { - return; + return true; } for process_id in &process_ids { let _ = crate::windows_integration::terminate_process(*process_id); @@ -590,7 +756,7 @@ fn terminate_and_wait_for_exit(process_ids: Vec, timeout_ms: u64, interval_ }), ); } - break; + return remaining.is_empty(); } std::thread::sleep(Duration::from_millis(interval_ms)); } diff --git a/crates/codex-plus-core/src/windows_integration.rs b/crates/codex-plus-core/src/windows_integration.rs index f5d3cdd4b..17921d8b8 100644 --- a/crates/codex-plus-core/src/windows_integration.rs +++ b/crates/codex-plus-core/src/windows_integration.rs @@ -12,7 +12,9 @@ use std::sync::OnceLock; #[cfg(windows)] use anyhow::Context; #[cfg(windows)] -use windows::Win32::Foundation::{BOOL, CloseHandle, HANDLE, HWND, LPARAM, MAX_PATH, WPARAM}; +use windows::Win32::Foundation::{ + BOOL, CloseHandle, FILETIME, HANDLE, HWND, LPARAM, MAX_PATH, WPARAM, +}; #[cfg(windows)] use windows::Win32::System::Com::{ CLSCTX_INPROC_SERVER, COINIT_APARTMENTTHREADED, CoCreateInstance, CoInitializeEx, @@ -30,8 +32,8 @@ use windows::Win32::System::Registry::{ }; #[cfg(windows)] use windows::Win32::System::Threading::{ - OpenProcess, PROCESS_QUERY_LIMITED_INFORMATION, PROCESS_TERMINATE, QueryFullProcessImageNameW, - TerminateProcess, + GetProcessTimes, OpenProcess, PROCESS_QUERY_LIMITED_INFORMATION, PROCESS_TERMINATE, + QueryFullProcessImageNameW, TerminateProcess, }; #[cfg(windows)] use windows::Win32::UI::Shell::PropertiesSystem::{IPropertyStore, SHGetPropertyStoreForWindow}; @@ -345,6 +347,40 @@ pub fn terminate_process(process_id: u32) -> bool { unsafe { TerminateProcess(handle, 0) }.is_ok() } +#[cfg(windows)] +pub fn process_birth_id(process_id: u32) -> Option { + let handle = unsafe { OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, false, process_id).ok()? }; + if handle.is_invalid() { + return None; + } + let _guard = HandleGuard(handle); + let mut creation_time = FILETIME::default(); + let mut exit_time = FILETIME::default(); + let mut kernel_time = FILETIME::default(); + let mut user_time = FILETIME::default(); + unsafe { + GetProcessTimes( + handle, + &mut creation_time, + &mut exit_time, + &mut kernel_time, + &mut user_time, + ) + .ok()?; + } + Some(((creation_time.dwHighDateTime as u64) << 32) | creation_time.dwLowDateTime as u64) +} + +#[cfg(windows)] +pub fn process_started_at_secs_from_birth_id(birth_id: u64) -> Option { + const WINDOWS_TO_UNIX_EPOCH_100NS: u64 = 116_444_736_000_000_000; + const TICKS_PER_SECOND: u64 = 10_000_000; + + birth_id + .checked_sub(WINDOWS_TO_UNIX_EPOCH_100NS) + .map(|unix_ticks| unix_ticks / TICKS_PER_SECOND) +} + #[cfg(windows)] pub fn activate_process_window(process_id: u32) -> bool { let Some(hwnd) = process_window(process_id, false) else { diff --git a/crates/codex-plus-core/tests/cdp_bridge.rs b/crates/codex-plus-core/tests/cdp_bridge.rs index e18fda660..01201dbe8 100644 --- a/crates/codex-plus-core/tests/cdp_bridge.rs +++ b/crates/codex-plus-core/tests/cdp_bridge.rs @@ -1673,10 +1673,53 @@ fn injection_script_unlocks_custom_model_catalog() { assert!(!script.contains("function patchObjectGraphForModels")); assert!(!script.contains("window.dispatchEvent = function patchedCodexPlusDispatchEvent")); assert!(script.contains("String(name) === \"107580212\"")); + assert!(script.contains("default_model: value.default_model || names[0]")); + assert!(!script.contains("default_model: names[0] || value.default_model")); assert!(script.contains("window.addEventListener(\"codex-message-from-view\"")); assert!(!script.contains("querySelectorAll(\"button, [role='menu']")); } +#[test] +fn model_whitelist_preserves_the_app_default_model() { + let script = assets::injection_script(57321); + let start = script + .find("function patchStatsigModelDynamicConfig(config)") + .expect("Statsig model patch should exist"); + let end = script[start..] + .find("\n function statsigClients()") + .map(|offset| start + offset) + .expect("Statsig model patch should have a stable end marker"); + let function_source = &script[start..end]; + let temp = tempfile::tempdir().expect("temp dir should be created"); + let harness_path = temp.path().join("model-default-harness.cjs"); + std::fs::write( + &harness_path, + format!( + r#" +const codexPlusModelNames = () => ["supplier-default", "extra-model"]; +{function_source} +const config = {{ value: {{ available_models: ["native-model"], default_model: "thread-model" }} }}; +const patched = patchStatsigModelDynamicConfig(config); +if (patched.value.default_model !== "thread-model") process.exit(2); +if (!patched.value.available_models.includes("supplier-default")) process.exit(3); +if (!patched.value.available_models.includes("extra-model")) process.exit(4); +"# + ), + ) + .expect("model default harness should be written"); + + let output = Command::new("node") + .arg(&harness_path) + .output() + .expect("node should run model default harness"); + assert!( + output.status.success(), + "node harness failed\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); +} + #[test] fn injection_script_exposes_fast_service_tier_control() { let script = assets::injection_script(57321); @@ -1971,6 +2014,7 @@ fn injection_script_applies_fast_service_tier_contract() { assert_eq!(cases["refreshedCustomProviderOverride"], "refreshed_vendor"); assert_eq!(cases["refreshedOpenAiProvider"], "openai"); assert_eq!(cases["refreshedPureApiResumeProvider"], "custom"); + assert_eq!(cases["refreshedPureApiResumeModel"], "gpt-5.6-terra"); assert_eq!(cases["failedRefreshProviderUnchanged"], "openai"); assert_eq!(cases["missingActiveProviderUnchanged"], true); assert_eq!(cases["missingActiveRecoveryUnscheduled"], true); @@ -2495,8 +2539,10 @@ window.__codexSessionDeleteBridge = async (path) => {{ await appServerClient.sendRequest("thread/resume", {{ threadId: "thread-mobile-pure-api-refresh", modelProvider: "openai", + model: "gpt-5.6-terra", }}, {{ signal: "pure-api-switch" }}); const refreshedPureApiResumeProvider = appServerCalls.at(-1)?.params?.modelProvider || ""; +const refreshedPureApiResumeModel = appServerCalls.at(-1)?.params?.model || ""; delete window.__codexSessionDeleteBridge; api.setBackendSettings({{ relayProfilesEnabled: true, @@ -2609,6 +2655,7 @@ process.stdout.write(JSON.stringify({{ refreshedCustomProviderOverride, refreshedOpenAiProvider, refreshedPureApiResumeProvider, + refreshedPureApiResumeModel, failedRefreshProviderUnchanged, missingActiveProviderUnchanged, missingActiveRecoveryUnscheduled, diff --git a/crates/codex-plus-core/tests/force_chinese_locale_settings.rs b/crates/codex-plus-core/tests/force_chinese_locale_settings.rs index 08ba6339c..2a8fea948 100644 --- a/crates/codex-plus-core/tests/force_chinese_locale_settings.rs +++ b/crates/codex-plus-core/tests/force_chinese_locale_settings.rs @@ -90,6 +90,8 @@ fn injection_script_includes_force_chinese_locale_global_and_patch() { assert!(script.contains("window.location.reload()")); assert!(script.contains("codexPlus.forceChineseLocale.managed.v1")); assert!(!script.contains("setItem(\"localeOverride\"")); + assert!(!script.contains("defineNavigatorGetter")); + assert!(!script.contains("Object.defineProperty(Navigator.prototype")); settings.codex_app_force_chinese_locale = false; let script = injection_script_with_settings(0, &settings); diff --git a/crates/codex-plus-core/tests/launcher.rs b/crates/codex-plus-core/tests/launcher.rs index ec369852c..011d1a44d 100644 --- a/crates/codex-plus-core/tests/launcher.rs +++ b/crates/codex-plus-core/tests/launcher.rs @@ -1663,7 +1663,17 @@ async fn launch_starts_helper_when_model_routing_is_enabled() { let before_stop = events.lock().unwrap().clone(); assert!(before_stop.contains(&"select-helper:58000".to_string())); + assert!(before_stop.contains(&"ensure-protocol-proxy-config".to_string())); assert!(before_stop.contains(&"start-helper:57321".to_string())); + let ensure = before_stop + .iter() + .position(|event| event == "ensure-protocol-proxy-config") + .unwrap(); + let start = before_stop + .iter() + .position(|event| event == "start-helper:57321") + .unwrap(); + assert!(ensure < start); assert!(!before_stop.contains(&"inject:9229:57321".to_string())); handle.wait_for_codex_exit().await.unwrap(); @@ -2004,6 +2014,14 @@ impl LaunchHooks for FakeHooks { Ok(()) } + async fn ensure_active_protocol_proxy_config( + &self, + _settings: &BackendSettings, + ) -> anyhow::Result<()> { + self.event("ensure-protocol-proxy-config"); + Ok(()) + } + async fn ensure_plugin_marketplace_config( &self, _settings: &BackendSettings, diff --git a/crates/codex-plus-core/tests/relay_config.rs b/crates/codex-plus-core/tests/relay_config.rs index fd6738ee6..2be9b5450 100644 --- a/crates/codex-plus-core/tests/relay_config.rs +++ b/crates/codex-plus-core/tests/relay_config.rs @@ -6,14 +6,16 @@ use codex_plus_core::relay_config::{ backfill_relay_profile_from_home, backfill_relay_profile_from_home_with_common, chatgpt_auth_status_from_home, cleanup_unsupported_approval_policies_in_home, clear_relay_config_to_home, clear_relay_config_to_home_with_auth, - delete_context_entry_from_common_config, extract_common_config_from_config, - list_context_entries_from_common_config, normalize_relay_profile_for_storage, - prepare_common_config_for_apply, relay_config_status_from_home, relay_profile_api_key, - sanitize_common_config_contents, set_codex_goals_feature_in_home, - strip_common_config_from_config, sync_live_config_context_entries, - upsert_context_entry_in_common_config, + delete_context_entry_from_common_config, ensure_active_protocol_proxy_config_in_home, + extract_common_config_from_config, list_context_entries_from_common_config, + normalize_relay_profile_for_storage, prepare_common_config_for_apply, + relay_config_status_from_home, relay_profile_api_key, sanitize_common_config_contents, + set_codex_goals_feature_in_home, strip_common_config_from_config, + sync_live_config_context_entries, upsert_context_entry_in_common_config, +}; +use codex_plus_core::settings::{ + BackendSettings, RelayMode, RelayModelRoute, RelayProfile, RelayProtocol, }; -use codex_plus_core::settings::{RelayMode, RelayModelRoute, RelayProfile, RelayProtocol}; fn write_remote_plugin_marketplace_snapshot(home: &std::path::Path) { let root = home.join(".tmp").join("plugins-remote"); @@ -749,6 +751,193 @@ base_url = "https://responses.example.test/v1" assert_eq!(backfilled.model_routes, profile.model_routes); } +#[test] +fn launcher_repairs_only_the_live_model_route_proxy_endpoint() { + let temp = tempfile::tempdir().unwrap(); + let config_path = temp.path().join("config.toml"); + std::fs::write( + &config_path, + r#"model = "gpt-5.6-sol" +model_provider = "relay-source" +custom_setting = "preserve-me" + +[model_providers.relay-source] +name = "Source" +wire_api = "responses" +base_url = "https://source.example.test/v1" +experimental_bearer_token = "sk-preserve" + +[plugins.example] +enabled = true +"#, + ) + .unwrap(); + let settings = BackendSettings { + active_relay_id: "source".to_string(), + relay_profiles: vec![ + RelayProfile { + id: "source".to_string(), + config_contents: r#"model_provider = "relay-source" + +[model_providers.relay-source] +base_url = "https://source.example.test/v1" +"# + .to_string(), + model_routes: vec![RelayModelRoute { + model: "gpt-5.6-luna".to_string(), + target_relay_id: "target".to_string(), + target_model: String::new(), + }], + ..RelayProfile::default() + }, + RelayProfile { + id: "target".to_string(), + ..RelayProfile::default() + }, + ], + ..BackendSettings::default() + }; + + assert!(ensure_active_protocol_proxy_config_in_home(temp.path(), &settings).unwrap()); + let updated = std::fs::read_to_string(&config_path).unwrap(); + assert!(updated.contains(r#"base_url = "http://127.0.0.1:57321/v1""#)); + assert!(updated.contains(r#"experimental_bearer_token = "sk-preserve""#)); + assert!(updated.contains(r#"custom_setting = "preserve-me""#)); + assert!(updated.contains("[plugins.example]")); + assert!(!ensure_active_protocol_proxy_config_in_home(temp.path(), &settings).unwrap()); +} + +#[test] +fn launcher_does_not_rewrite_pure_responses_profiles_without_proxy_features() { + let temp = tempfile::tempdir().unwrap(); + let config_path = temp.path().join("config.toml"); + let original = r#"model_provider = "custom" + +[model_providers.custom] +base_url = "https://responses.example.test/v1" +"#; + std::fs::write(&config_path, original).unwrap(); + let settings = BackendSettings { + relay_profiles: vec![RelayProfile { + base_url: "https://responses.example.test/v1".to_string(), + protocol: RelayProtocol::Responses, + ..RelayProfile::default() + }], + ..BackendSettings::default() + }; + + assert!(!ensure_active_protocol_proxy_config_in_home(temp.path(), &settings).unwrap()); + assert_eq!(std::fs::read_to_string(config_path).unwrap(), original); +} + +#[test] +fn launcher_repairs_no_auth_transport_without_rewriting_managed_credentials() { + let temp = tempfile::tempdir().unwrap(); + let config_path = temp.path().join("config.toml"); + std::fs::write( + &config_path, + r#"model_provider = "custom" + +[model_providers.custom] +base_url = "https://no-auth.example.test/v1" +experimental_bearer_token = "codex-plus-no-auth" +custom_setting = "preserve-me" +"#, + ) + .unwrap(); + let settings = BackendSettings { + relay_profiles: vec![RelayProfile { + relay_mode: RelayMode::PureApi, + no_auth: true, + base_url: "https://no-auth.example.test/v1".to_string(), + protocol: RelayProtocol::Responses, + ..RelayProfile::default() + }], + ..BackendSettings::default() + }; + + assert!(ensure_active_protocol_proxy_config_in_home(temp.path(), &settings).unwrap()); + let updated = std::fs::read_to_string(&config_path).unwrap(); + assert!(updated.contains(r#"base_url = "http://127.0.0.1:57321/v1""#)); + assert!(updated.contains(r#"experimental_bearer_token = "codex-plus-no-auth""#)); + assert!(updated.contains(r#"custom_setting = "preserve-me""#)); + assert!(!ensure_active_protocol_proxy_config_in_home(temp.path(), &settings).unwrap()); +} + +#[test] +fn launcher_repairs_route_transport_and_openai_identity_endpoints_together() { + let temp = tempfile::tempdir().unwrap(); + let config_path = temp.path().join("config.toml"); + std::fs::write( + &config_path, + r#"model_provider = "openai" + +[model_providers.custom] +base_url = "https://source.example.test/v1" +experimental_bearer_token = "sk-preserve" +"#, + ) + .unwrap(); + let settings = BackendSettings { + active_relay_id: "source".to_string(), + relay_profiles: vec![ + RelayProfile { + id: "source".to_string(), + config_contents: "model_provider = \"openai\"\n".to_string(), + model_routes: vec![RelayModelRoute { + model: "gpt-5.6-luna".to_string(), + target_relay_id: "target".to_string(), + target_model: String::new(), + }], + ..RelayProfile::default() + }, + RelayProfile { + id: "target".to_string(), + ..RelayProfile::default() + }, + ], + ..BackendSettings::default() + }; + + assert!(ensure_active_protocol_proxy_config_in_home(temp.path(), &settings).unwrap()); + let updated = std::fs::read_to_string(config_path).unwrap(); + assert!(updated.contains(r#"openai_base_url = "http://127.0.0.1:57321/v1""#)); + assert!(updated.contains("[model_providers.custom]")); + assert!(updated.contains(r#"base_url = "http://127.0.0.1:57321/v1""#)); + assert!(updated.contains(r#"experimental_bearer_token = "sk-preserve""#)); +} + +#[test] +fn launcher_official_mix_repairs_only_managed_openai_endpoint() { + let temp = tempfile::tempdir().unwrap(); + let config_path = temp.path().join("config.toml"); + std::fs::write( + &config_path, + r#"model_provider = "custom" + +[model_providers.custom] +base_url = "https://responses.example.test/v1" +"#, + ) + .unwrap(); + let settings = BackendSettings { + active_relay_id: "official-mix".to_string(), + relay_profiles: vec![RelayProfile { + id: "official-mix".to_string(), + relay_mode: RelayMode::Official, + official_mix_api_key: true, + protocol: RelayProtocol::Responses, + ..RelayProfile::default() + }], + ..BackendSettings::default() + }; + + assert!(ensure_active_protocol_proxy_config_in_home(temp.path(), &settings).unwrap()); + let updated = std::fs::read_to_string(config_path).unwrap(); + assert!(updated.contains(r#"openai_base_url = "http://127.0.0.1:57321/v1""#)); + assert!(updated.contains(r#"base_url = "https://responses.example.test/v1""#)); +} + #[test] fn apply_aggregate_relay_points_codex_to_local_responses_proxy_without_snapshot() { let temp = tempfile::tempdir().unwrap(); diff --git a/crates/codex-plus-data/Cargo.toml b/crates/codex-plus-data/Cargo.toml index c0abc389b..9f7b2f121 100644 --- a/crates/codex-plus-data/Cargo.toml +++ b/crates/codex-plus-data/Cargo.toml @@ -10,6 +10,7 @@ anyhow.workspace = true base64.workspace = true chrono = { version = "0.4", default-features = false, features = ["clock"] } codex-plus-core = { path = "../codex-plus-core" } +fs2.workspace = true rusqlite.workspace = true serde.workspace = true serde_json = { workspace = true, features = ["preserve_order"] } @@ -17,5 +18,8 @@ sha2.workspace = true thiserror.workspace = true uuid.workspace = true +[target.'cfg(unix)'.dependencies] +libc = "0.2" + [dev-dependencies] tempfile.workspace = true diff --git a/crates/codex-plus-data/src/lib.rs b/crates/codex-plus-data/src/lib.rs index 86b62e0a4..0c63033a3 100644 --- a/crates/codex-plus-data/src/lib.rs +++ b/crates/codex-plus-data/src/lib.rs @@ -6,8 +6,8 @@ pub mod storage; pub use backup::BackupStore; pub use markdown::{MarkdownExportService, export_markdown_from_paths}; pub use provider_sync::{ - ProviderSyncAudit, ProviderSyncLockState, ProviderSyncResult, ProviderSyncStatus, - ProviderSyncTargetList, ProviderSyncTargetOption, ProviderSyncTargetSource, + ProviderSyncAudit, ProviderSyncLifecycleGuard, ProviderSyncLockState, ProviderSyncResult, + ProviderSyncStatus, ProviderSyncTargetList, ProviderSyncTargetOption, ProviderSyncTargetSource, SessionIndexCleanupApplyError, SessionIndexCleanupCandidate, SessionIndexCleanupPreview, SessionIndexCleanupResult, apply_session_index_cleanup, inspect_provider_sync_lock, load_provider_sync_targets, preview_session_index_cleanup, @@ -15,5 +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, }; 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 a2f1aa766..8e897ace7 100644 --- a/crates/codex-plus-data/src/provider_sync.rs +++ b/crates/codex-plus-data/src/provider_sync.rs @@ -1,10 +1,11 @@ +use fs2::FileExt; use rusqlite::{Connection, OptionalExtension, params_from_iter, types::Value as SqlValue}; use serde::{Deserialize, Serialize}; use serde_json::{Map, Value, json}; use sha2::{Digest, Sha256}; use std::collections::{HashMap, HashSet}; use std::fs::{self, File, OpenOptions}; -use std::io::{Read, Seek, SeekFrom, Write}; +use std::io::{BufRead, BufReader, BufWriter, Read, Seek, SeekFrom, Write}; use std::path::{Path, PathBuf}; use std::time::{SystemTime, UNIX_EPOCH}; @@ -12,17 +13,44 @@ const DEFAULT_PROVIDER: &str = "openai"; const SESSION_DIRS: [&str; 2] = ["sessions", "archived_sessions"]; const BACKUP_KEEP_COUNT: usize = 5; const REMOTE_CONTROL_CREATION_WINDOW_SECS: i64 = 15 * 60; +const SESSION_TRANSACTION_FILE: &str = "session-transaction.json"; +const SESSION_TRANSACTION_NAMESPACE: &str = "provider-sync-rollout-transaction"; +const SESSION_TRANSACTION_IN_PROGRESS: &str = "in_progress"; +const SESSION_TRANSACTION_COMMITTED: &str = "committed"; +const SESSION_TRANSACTION_ROLLED_BACK: &str = "rolled_back"; +const PROVIDER_SYNC_SCAN_STATE_FILE: &str = "rollout-scan-state.json"; +const PROVIDER_SYNC_SCAN_STATE_NAMESPACE: &str = "provider-sync-rollout-scan-state"; +const PROVIDER_SYNC_SCAN_STATE_VERSION: u32 = 1; +const PROVIDER_SYNC_SCAN_STATE_MAX_BYTES: u64 = 32 * 1024 * 1024; +const PROVIDER_SYNC_SCAN_STATE_MAX_ENTRIES: usize = 100_000; +const PROVIDER_SYNC_SCAN_RULES_V1: &str = concat!( + "provider-sync-rollout-scan/v1;", + "utf8-jsonl;session_meta-payload-id-cwd-model_provider;", + "user_message-or-user_input;encrypted_content-byte-marker;", + "non-root-agent-source;missing-provider-sentinel" +); /// `create_lock` 先建目录再写 `owner.json`,两步之间被强杀会留下没有 owner 的锁目录。 /// 该窗口只有几毫秒,因此超过这个时长仍缺 owner 的锁一定是中断残留,可以安全回收; /// 反过来说,宽限期内的无主锁必须保留,否则会把正在建锁的同伴进程挤掉。 const LOCK_INTERRUPTED_GRACE_SECS: u64 = 60; +/// Legacy owner files do not record the OS process creation time. A live PID whose process began +/// well after the lock was created is a reused PID, not the original lock owner. +const LEGACY_PID_REUSE_TOLERANCE_SECS: u64 = 5 * 60; +const LEGACY_PID_REUSE_MIN_LOCK_AGE_SECS: u64 = 24 * 60 * 60; +const PROCESS_START_MATCH_TOLERANCE_SECS: u64 = 5; #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] struct ProviderSyncLockOwner { pid: u32, started_at: u64, + #[serde(default)] + process_started_at: Option, + #[serde(default)] + process_birth_id: Option, + #[serde(default)] + lock_id: Option, } /// provider sync 锁的可观测状态。管理器在强杀 launcher 前用它判断 @@ -41,6 +69,55 @@ pub enum ProviderSyncLockState { Indeterminate, } +#[derive(Debug)] +pub struct ProviderSyncLifecycleGuard { + lock_dir: PathBuf, + lock_file: File, + lock_id: String, + directory_released: bool, + file_unlocked: bool, +} + +impl ProviderSyncLifecycleGuard { + /// Releases both compatibility and OS ownership before a caller starts a successor process. + /// A mismatched owner is an ABA conflict and must block the successor instead of deleting it. + pub fn release(mut self) -> std::io::Result<()> { + if !release_owned_lock(&self.lock_dir, &self.lock_id)? { + return Err(std::io::Error::new( + std::io::ErrorKind::Other, + "provider-sync lock ownership changed before release", + )); + } + self.directory_released = true; + FileExt::unlock(&self.lock_file)?; + self.file_unlocked = true; + Ok(()) + } +} + +impl Drop for ProviderSyncLifecycleGuard { + fn drop(&mut self) { + if !self.directory_released { + let _ = release_owned_lock(&self.lock_dir, &self.lock_id); + } + if !self.file_unlocked { + let _ = FileExt::unlock(&self.lock_file); + } + } +} + +/// Atomically reserves provider-sync lifecycle ownership for a restart or a real sync. +/// The OS file lock is released automatically if the process exits; the legacy directory remains +/// present while held so older launchers also stay out of the critical section. +pub fn try_acquire_provider_sync_lifecycle_guard( + codex_home: Option<&Path>, +) -> std::io::Result { + let home = codex_home + .map(Path::to_path_buf) + .unwrap_or_else(default_codex_home_dir); + acquire_lock_inner(&home.join("tmp/provider-sync.lock"), false) +} + /// 读取 provider sync 锁的当前状态,不获取也不修改它。 pub fn inspect_provider_sync_lock(codex_home: Option<&Path>) -> ProviderSyncLockState { let home = codex_home @@ -56,7 +133,7 @@ fn inspect_lock(path: &Path) -> ProviderSyncLockState { classify_lock( read_lock_owner(path).as_ref(), lock_dir_age_secs(path), - codex_plus_core::watcher::process_id_is_running, + codex_plus_core::watcher::inspect_process_instance, ) } @@ -64,7 +141,7 @@ fn inspect_lock(path: &Path) -> ProviderSyncLockState { fn classify_lock( owner: Option<&ProviderSyncLockOwner>, age_secs: Option, - process_alive: impl Fn(u32) -> Option, + inspect_process: impl Fn(u32) -> codex_plus_core::watcher::ProcessInstanceState, ) -> ProviderSyncLockState { let Some(owner) = owner else { // owner.json 缺失或损坏。持有者只在建锁的几毫秒内处于这个状态, @@ -75,18 +152,64 @@ fn classify_lock( ProviderSyncLockState::Indeterminate }; }; - match process_alive(owner.pid) { - Some(false) => ProviderSyncLockState::Stale { + use codex_plus_core::watcher::ProcessInstanceState; + match inspect_process(owner.pid) { + ProcessInstanceState::NotRunning => ProviderSyncLockState::Stale { pid: Some(owner.pid), }, - // `None` 表示进程枚举失败,无法证明持有者已死;按「仍在持有」保守处理。 - _ => ProviderSyncLockState::Held { + ProcessInstanceState::Running { + started_at_secs, + birth_id: current_birth_id, + } => { + let birth_mismatch = owner + .process_birth_id + .as_deref() + .zip(current_birth_id.as_deref()) + .is_some_and(|(expected, current)| expected != current); + let recorded_start_mismatch = owner.process_birth_id.is_none() + && owner.process_started_at.zip(started_at_secs).is_some_and( + |(expected, current)| { + expected.abs_diff(current) > PROCESS_START_MATCH_TOLERANCE_SECS + }, + ); + let legacy_pid_reuse = owner.process_birth_id.is_none() + && owner.process_started_at.is_none() + && age_secs.is_some_and(|age| age >= LEGACY_PID_REUSE_MIN_LOCK_AGE_SECS) + && started_at_secs.is_some_and(|current| { + current + > owner + .started_at + .saturating_add(LEGACY_PID_REUSE_TOLERANCE_SECS) + }); + if birth_mismatch || recorded_start_mismatch || legacy_pid_reuse { + ProviderSyncLockState::Stale { + pid: Some(owner.pid), + } + } else { + ProviderSyncLockState::Held { + pid: owner.pid, + started_at: owner.started_at, + } + } + } + // Unknown process identity cannot prove that the owner is gone. Preserve the lock. + ProcessInstanceState::Unknown => ProviderSyncLockState::Held { pid: owner.pid, started_at: owner.started_at, }, } } +fn current_process_identity() -> (Option, Option) { + match codex_plus_core::watcher::inspect_process_instance(std::process::id()) { + codex_plus_core::watcher::ProcessInstanceState::Running { + started_at_secs, + birth_id, + } => (started_at_secs, birth_id), + _ => (None, None), + } +} + fn read_lock_owner(path: &Path) -> Option { serde_json::from_slice::(&fs::read(path.join("owner.json")).ok()?).ok() } @@ -201,25 +324,62 @@ pub struct ProviderSyncTargetList { #[derive(Debug, Clone)] struct SessionChange { path: PathBuf, - original_text: String, - next_text: String, - original_session_meta_lines: Vec, + original_sha256: String, + original_size: u64, thread_id: Option, cwd: Option, has_user_event: bool, rewrite_needed: bool, original_mtime: Option, + rewrite_mode: SessionRewriteMode, +} + +#[derive(Debug, Clone)] +enum SessionRewriteMode { + AllProviders, + SourceProvider { source_provider: String }, } #[derive(Debug, Default)] struct RolloutRewrite { - next_text: String, rewrite_needed: bool, thread_id: Option, cwd: Option, - providers: Vec, - original_session_meta_lines: Vec, + providers: HashSet, + session_meta_count: usize, + has_user_event: bool, + has_encrypted_content: bool, + marks_non_root_agent: bool, + original_sha256: String, + original_size: u64, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +struct ProviderSyncRolloutScanState { + relative_path: String, + size: u64, + modified_secs: Option, + modified_nanos: Option, + file_identity: String, + sha256: String, + thread_id: Option, + cwd: Option, + has_user_event: bool, + has_encrypted_content: bool, + marks_non_root_agent: bool, session_meta_count: usize, + providers: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +struct ProviderSyncRolloutScanStateManifest { + version: u32, + namespace: String, + rules_sha256: String, + rollout_roots: HashMap, + entries: Vec, } #[derive(Debug, Default)] @@ -228,6 +388,7 @@ struct SessionChanges { skipped_locked_rollout_files: Vec, encrypted_content_counts: HashMap, subagent_thread_ids: HashSet, + scan_state_entries: Vec, } #[derive(Debug, Default)] @@ -238,10 +399,94 @@ struct ProviderSyncThreadKinds { #[derive(Debug, Default)] struct AppliedSessionChanges { - changes: Vec, + changed_files: usize, skipped_locked_rollout_files: Vec, } +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +struct SessionTransactionEntry { + relative_path: String, + original_sha256: String, + next_sha256: String, + original_size: u64, + next_size: u64, + session_meta_backup_sha256: String, + original_mtime_secs: Option, + original_mtime_nanos: Option, + #[serde(default)] + external_sha256: Option, + #[serde(default)] + external_size: Option, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +enum SessionTransactionMode { + Full, + Remote, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +enum SessionTransactionPhase { + RolloutsApplying, + RolloutsApplied, + DownstreamStarted, + CommitDecided, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +struct SessionTransactionRootEvidence { + canonical_path: String, + identity: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +struct ProviderSyncBackupFileEvidence { + size: u64, + sha256: String, +} + +struct PreparedProviderSyncBackupFile { + file: File, + modified: Option, + evidence: ProviderSyncBackupFileEvidence, +} + +struct PreparedProviderSyncBackupSet { + files: HashMap, + snapshot_dir: PathBuf, +} + +impl Drop for PreparedProviderSyncBackupSet { + fn drop(&mut self) { + self.files.clear(); + let _ = fs::remove_dir_all(&self.snapshot_dir); + } +} + +struct ProviderSyncDirectoryEvidence { + canonical_path: PathBuf, + identity: String, + guard: File, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +struct SessionTransactionManifest { + version: u32, + namespace: String, + status: String, + transaction_id: String, + mode: SessionTransactionMode, + phase: SessionTransactionPhase, + rollout_roots: HashMap, + entries: Vec, +} + #[derive(Debug, Clone)] struct SessionIndexPlan { path: PathBuf, @@ -410,6 +655,7 @@ pub fn run_remote_control_session_catalog_recovery_for_thread_with_target( thread_id: &str, target_provider: &str, ) -> ProviderSyncResult { + let require_stopped_app = codex_home.is_none(); let thread_id = thread_id.trim(); if thread_id.is_empty() || thread_id.len() > 128 { return result( @@ -436,10 +682,37 @@ pub fn run_remote_control_session_catalog_recovery_for_thread_with_target( .map(Path::to_path_buf) .unwrap_or_else(default_codex_home_dir); let lock_dir = home.join("tmp/provider-sync.lock"); - if acquire_lock(&lock_dir).is_err() { + let _lock_guard = match acquire_lock(&lock_dir) { + Ok(guard) => guard, + Err(_) => { + return result( + ProviderSyncStatus::Skipped, + format!("Provider sync lock exists: {}", lock_dir.to_string_lossy()), + target_provider, + None, + 0, + 0, + ); + } + }; + if require_stopped_app { + let running_processes = + codex_plus_core::watcher::find_session_index_cleanup_blocking_processes(); + if !running_processes.is_empty() { + return result( + ProviderSyncStatus::Skipped, + "Remote Control session catalog recovery requires Codex App / ChatGPT to be stopped", + target_provider, + None, + 0, + 0, + ); + } + } + if let Err(error) = recover_interrupted_session_transactions(&home) { return result( ProviderSyncStatus::Skipped, - format!("Provider sync lock exists: {}", lock_dir.to_string_lossy()), + format!("Remote Control session catalog recovery skipped: {error}"), target_provider, None, 0, @@ -453,7 +726,6 @@ pub fn run_remote_control_session_catalog_recovery_for_thread_with_target( target_provider, &thread_ids, ); - let _ = release_lock(&lock_dir); recovery.unwrap_or_else(|error| { result( ProviderSyncStatus::Skipped, @@ -471,6 +743,7 @@ pub fn run_remote_control_session_finalization_for_thread_with_target( thread_id: &str, target_provider: &str, ) -> ProviderSyncResult { + let require_stopped_app = codex_home.is_none(); let thread_id = thread_id.trim(); let target_provider = target_provider.trim(); if thread_id.is_empty() @@ -491,17 +764,35 @@ pub fn run_remote_control_session_finalization_for_thread_with_target( .map(Path::to_path_buf) .unwrap_or_else(default_codex_home_dir); let lock_dir = home.join("tmp/provider-sync.lock"); - if acquire_lock(&lock_dir).is_err() { - return result( - ProviderSyncStatus::Skipped, - format!("Provider sync lock exists: {}", lock_dir.to_string_lossy()), - target_provider, - None, - 0, - 0, - ); + let _lock_guard = match acquire_lock(&lock_dir) { + Ok(guard) => guard, + Err(_) => { + return result( + ProviderSyncStatus::Skipped, + format!("Provider sync lock exists: {}", lock_dir.to_string_lossy()), + target_provider, + None, + 0, + 0, + ); + } + }; + if require_stopped_app { + let running_processes = + codex_plus_core::watcher::find_session_index_cleanup_blocking_processes(); + if !running_processes.is_empty() { + return result( + ProviderSyncStatus::Skipped, + "Remote Control session finalization requires Codex App / ChatGPT to be stopped", + target_provider, + None, + 0, + 0, + ); + } } let recovery = (|| -> anyhow::Result { + recover_interrupted_session_transactions(&home)?; let sqlite_paths = provider_sync_db_paths(&home); let rollout_path = match remote_control_rollout_for_thread( &home, @@ -553,20 +844,29 @@ pub fn run_remote_control_session_finalization_for_thread_with_target( .filter(|change| change.rewrite_needed) .cloned() .collect::>(); - let backup_dir = create_backup(&home, target_provider, &rewrite_changes)?; - let applied = apply_session_changes(&rewrite_changes)?; + let backup_dir = create_backup( + &home, + target_provider, + SessionTransactionMode::Remote, + &rewrite_changes, + )?; + let applied = apply_session_changes(&home, &backup_dir, target_provider, &rewrite_changes)?; + set_session_transaction_phase(&backup_dir, SessionTransactionPhase::RolloutsApplied)?; if !rollout_file_matches_provider(&rollout_path, thread_id, target_provider)? { + rollback_session_transaction(&home, &backup_dir)?; let mut deferred = result( ProviderSyncStatus::Skipped, "Remote Control session finalization deferred for a changed or locked rollout", target_provider, Some(backup_dir), - applied.changes.len(), + applied.changed_files, 0, ); deferred.skipped_locked_rollout_files = applied.skipped_locked_rollout_files; return Ok(deferred); } + set_session_transaction_phase(&backup_dir, SessionTransactionPhase::CommitDecided)?; + commit_session_transaction(&backup_dir)?; let thread_ids = HashSet::from([thread_id.to_string()]); let catalog_repairs = repair_missing_local_thread_catalog_rows_for_threads( &home, @@ -587,7 +887,7 @@ pub fn run_remote_control_session_finalization_for_thread_with_target( "Remote Control session finalization complete", target_provider, Some(backup_dir), - applied.changes.len(), + applied.changed_files, sqlite_updates.total(), ); synced.sqlite_provider_rows_updated = sqlite_updates.provider_rows; @@ -595,7 +895,6 @@ pub fn run_remote_control_session_finalization_for_thread_with_target( synced.sqlite_catalog_rows_removed = sqlite_updates.catalog_remove_rows; Ok(synced) })(); - let _ = release_lock(&lock_dir); recovery.unwrap_or_else(|error| { result( ProviderSyncStatus::Skipped, @@ -706,17 +1005,35 @@ pub fn run_provider_sync_with_target( } } let lock_dir = home.join("tmp/provider-sync.lock"); - if acquire_lock(&lock_dir).is_err() { - return result( - ProviderSyncStatus::Skipped, - format!("Provider sync lock exists: {}", lock_dir.to_string_lossy()), - &target_provider, - None, - 0, - 0, - ); + let _lock_guard = match acquire_lock(&lock_dir) { + Ok(guard) => guard, + Err(_) => { + return result( + ProviderSyncStatus::Skipped, + format!("Provider sync lock exists: {}", lock_dir.to_string_lossy()), + &target_provider, + None, + 0, + 0, + ); + } + }; + if require_stopped_app { + let running_processes = + codex_plus_core::watcher::find_session_index_cleanup_blocking_processes(); + if !running_processes.is_empty() { + return result( + ProviderSyncStatus::Skipped, + "Codex App / ChatGPT started before provider-sync recovery", + &target_provider, + None, + 0, + 0, + ); + } } let sync_result = (|| -> anyhow::Result { + recover_interrupted_session_transactions(&home)?; let sqlite_paths = provider_sync_db_paths(&home); let thread_kinds = sqlite_provider_sync_thread_kinds(&sqlite_paths)?; let repair_audit = match audit_provider_sync_state(&home, &sqlite_paths) { @@ -780,6 +1097,16 @@ pub fn run_provider_sync_with_target( && catalog_repair_count == 0 && global_state_update_count == 0 { + if require_stopped_app { + let running_processes = + codex_plus_core::watcher::find_session_index_cleanup_blocking_processes(); + if !running_processes.is_empty() { + anyhow::bail!( + "Codex App / ChatGPT started while provider sync was scanning rollouts" + ); + } + } + persist_provider_sync_scan_state_best_effort(&home, &collected.scan_state_entries); let mut synced = result( ProviderSyncStatus::Synced, "Provider sync already up to date", @@ -795,8 +1122,25 @@ pub fn run_provider_sync_with_target( provider_sync_message_with_audit(&synced.message, &synced.repair_audit); return Ok(synced); } - let backup_dir = create_backup(&home, &target_provider, &rewrite_changes)?; - let applied = apply_session_changes(&rewrite_changes)?; + if require_stopped_app { + let running_processes = + codex_plus_core::watcher::find_session_index_cleanup_blocking_processes(); + if !running_processes.is_empty() { + anyhow::bail!( + "Codex App / ChatGPT started while provider sync was scanning rollouts" + ); + } + } + let backup_dir = create_backup( + &home, + &target_provider, + SessionTransactionMode::Full, + &rewrite_changes, + )?; + let applied = + apply_session_changes(&home, &backup_dir, &target_provider, &rewrite_changes)?; + set_session_transaction_phase(&backup_dir, SessionTransactionPhase::RolloutsApplied)?; + set_session_transaction_phase(&backup_dir, SessionTransactionPhase::DownstreamStarted)?; let apply_result = (|| -> anyhow::Result<(SqliteUpdateCounts, usize)> { let sqlite_updates = apply_sqlite_update_for_paths( &sqlite_paths, @@ -812,22 +1156,37 @@ pub fn run_provider_sync_with_target( sqlite_updates.catalog_remove_rows = catalog_repairs.removed_rows; let updated_workspace_roots = apply_global_state_update(&home.join(".codex-global-state.json"))?; - prune_backups(&home)?; Ok((sqlite_updates, updated_workspace_roots)) })(); let (sqlite_updates, updated_workspace_roots) = match apply_result { Ok(counts) => counts, Err(err) => { - let _ = restore_session_changes(&applied.changes); + restore_provider_sync_downstream_backup(&home, &backup_dir).map_err( + |restore_error| { + anyhow::anyhow!( + "provider-sync downstream update failed ({err}); backup restore also failed: {restore_error}" + ) + }, + )?; + rollback_session_transaction(&home, &backup_dir)?; return Err(err); } }; + set_session_transaction_phase(&backup_dir, SessionTransactionPhase::CommitDecided)?; + commit_session_transaction(&backup_dir)?; + persist_committed_provider_sync_scan_state_best_effort( + &home, + &backup_dir, + &target_provider, + collected.scan_state_entries.clone(), + ); + prune_backups(&home)?; let mut synced = result( ProviderSyncStatus::Synced, "Provider sync complete", &target_provider, Some(backup_dir), - applied.changes.len(), + applied.changed_files, sqlite_updates.total(), ); synced.skipped_locked_rollout_files = collected.skipped_locked_rollout_files; @@ -847,7 +1206,6 @@ pub fn run_provider_sync_with_target( synced.message = provider_sync_message_with_audit(&synced.message, &synced.repair_audit); Ok(synced) })(); - let _ = release_lock(&lock_dir); sync_result.unwrap_or_else(|err| { result( ProviderSyncStatus::Skipped, @@ -1180,16 +1538,42 @@ fn toml_string_value(raw: &str) -> Option { None } -fn acquire_lock(path: &Path) -> std::io::Result<()> { +fn acquire_lock(path: &Path) -> std::io::Result { + acquire_lock_inner(path, true) +} + +fn acquire_lock_inner(path: &Path, log_busy: bool) -> std::io::Result { fs::create_dir_all(path.parent().unwrap_or_else(|| Path::new(".")))?; - match create_lock(path) { - Ok(()) => Ok(()), + let lifecycle_path = path.with_file_name("provider-sync.lifecycle.lock"); + let lock_file = OpenOptions::new() + .read(true) + .write(true) + .create(true) + .open(lifecycle_path)?; + if let Err(error) = lock_file.try_lock_exclusive() { + let error = normalize_lock_contention_error(error); + if log_busy { + log_lock_busy(path); + } + return Err(error); + } + let lock_id = uuid::Uuid::new_v4().to_string(); + match create_lock(path, &lock_id) { + Ok(()) => Ok(ProviderSyncLifecycleGuard { + lock_dir: path.to_path_buf(), + lock_file, + lock_id, + directory_released: false, + file_unlocked: false, + }), Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => { let Some((owner, isolated_path)) = isolate_stale_lock(path) else { - log_lock_busy(path); + if log_busy { + log_lock_busy(path); + } return Err(error); }; - match create_lock(path) { + match create_lock(path, &lock_id) { Ok(()) => { let quarantine_cleanup_failed = fs::remove_dir_all(&isolated_path).is_err(); let _ = codex_plus_core::diagnostic_log::append_diagnostic_log( @@ -1197,12 +1581,21 @@ fn acquire_lock(path: &Path) -> std::io::Result<()> { json!({ "owner_pid": owner.as_ref().map(|owner| owner.pid), "owner_started_at": owner.as_ref().map(|owner| owner.started_at), + "owner_process_started_at": owner + .as_ref() + .and_then(|owner| owner.process_started_at), // owner 缺失说明持有者是在建锁中途被强杀的(issue #1901) "interrupted": owner.is_none(), "quarantine_cleanup_failed": quarantine_cleanup_failed, }), ); - Ok(()) + Ok(ProviderSyncLifecycleGuard { + lock_dir: path.to_path_buf(), + lock_file, + lock_id, + directory_released: false, + file_unlocked: false, + }) } Err(retry_error) => { let _ = fs::remove_dir_all(isolated_path); @@ -1214,6 +1607,14 @@ fn acquire_lock(path: &Path) -> std::io::Result<()> { } } +fn normalize_lock_contention_error(error: std::io::Error) -> std::io::Error { + #[cfg(windows)] + if error.raw_os_error() == Some(33) { + return std::io::Error::new(std::io::ErrorKind::WouldBlock, error); + } + error +} + /// 锁没能拿到时留下现场,用于区分「另一个同步真的在跑」和「残留锁把同步永久卡死」。 fn log_lock_busy(path: &Path) { let state = inspect_lock(path); @@ -1227,11 +1628,19 @@ fn log_lock_busy(path: &Path) { ); } -fn create_lock(path: &Path) -> std::io::Result<()> { +fn create_lock(path: &Path, lock_id: &str) -> std::io::Result<()> { fs::create_dir(path)?; + let (process_started_at, process_birth_id) = current_process_identity(); let write_result = fs::write( path.join("owner.json"), - json!({"pid": std::process::id(), "startedAt": now_secs()}).to_string(), + json!({ + "pid": std::process::id(), + "startedAt": now_secs(), + "processStartedAt": process_started_at, + "processBirthId": process_birth_id, + "lockId": lock_id, + }) + .to_string(), ); if let Err(error) = write_result { let _ = fs::remove_dir_all(path); @@ -1240,18 +1649,28 @@ fn create_lock(path: &Path) -> std::io::Result<()> { Ok(()) } -/// 把一把可以证明已经失效的锁挪到隔离路径,让调用方重新建锁。 +/// 在已经持有 OS 生命周期锁时,把可回收的兼容目录挪到隔离路径,让调用方重新建锁。 /// -/// 两种可回收的形态: +/// 三种可回收的形态: +/// - owner.json 带 `lockId`,证明目录来自新版协议;OS 锁既然已取得,该目录必为孤儿; /// - owner.json 可读且持有进程已退出(正常的崩溃残留); /// - owner.json 缺失/损坏,且锁目录存在时间已超过 [`LOCK_INTERRUPTED_GRACE_SECS`] /// ——持有者在 `create_lock` 中途被强杀,不会再有人来补写 owner(issue #1901)。 /// /// 其余情况一律保留锁:宁可跳过一次同步,也不能抢走仍在写入的进程的锁。 fn isolate_stale_lock(path: &Path) -> Option<(Option, PathBuf)> { - let owner = match inspect_lock(path) { - ProviderSyncLockState::Stale { .. } => read_lock_owner(path), - _ => return None, + let parsed_owner = read_lock_owner(path); + let owner = if parsed_owner + .as_ref() + .and_then(|owner| owner.lock_id.as_ref()) + .is_some() + { + parsed_owner + } else { + match inspect_lock(path) { + ProviderSyncLockState::Stale { .. } => parsed_owner, + _ => return None, + } }; let file_name = path.file_name()?.to_string_lossy(); let owner_tag = owner @@ -1265,87 +1684,519 @@ fn isolate_stale_lock(path: &Path) -> Option<(Option, Pat Some((owner, isolated_path)) } -fn release_lock(path: &Path) -> std::io::Result<()> { - if path.exists() { +fn release_owned_lock(path: &Path, lock_id: &str) -> std::io::Result { + if !path.exists() { + return Ok(true); + } + if read_lock_owner(path) + .and_then(|owner| owner.lock_id) + .is_some_and(|owner_lock_id| owner_lock_id == lock_id) + { fs::remove_dir_all(path)?; + return Ok(true); } - Ok(()) + Ok(false) } -fn collect_session_changes( +fn provider_sync_scan_rules_sha256() -> String { + let mut hasher = Sha256::new(); + hasher.update(PROVIDER_SYNC_SCAN_RULES_V1.as_bytes()); + hasher.update([0]); + hasher.update(env!("CARGO_PKG_VERSION").as_bytes()); + format!("{:x}", hasher.finalize()) +} + +fn provider_sync_scan_state_path(home: &Path) -> PathBuf { + home.join("backups_state/provider-sync") + .join(PROVIDER_SYNC_SCAN_STATE_FILE) +} + +fn load_provider_sync_scan_state( home: &Path, - target_provider: &str, - excluded_thread_ids: &HashSet, - explicit_user_thread_ids: &HashSet, -) -> anyhow::Result { - let mut collected = SessionChanges::default(); - for path in rollout_files(home)? { - let text = match fs::read_to_string(&path) { - Ok(text) => text, - Err(error) if is_locked_io_error(&error) => { - collected.skipped_locked_rollout_files.push(path); - continue; - } - Err(error) => return Err(error.into()), - }; - let rewrite = rewrite_rollout_session_meta_providers(&text, target_provider)?; - if rewrite.session_meta_count == 0 { - continue; - } - let is_explicit_user = rewrite - .thread_id - .as_ref() - .is_some_and(|thread_id| explicit_user_thread_ids.contains(thread_id)); - if rollout_session_meta_marks_non_root_agent(&text) { - if let Some(thread_id) = &rewrite.thread_id { - collected.subagent_thread_ids.insert(thread_id.clone()); - } - continue; - } - if !is_explicit_user - && rewrite +) -> anyhow::Result> { + let path = provider_sync_scan_state_path(home); + let path_metadata = match fs::symlink_metadata(&path) { + Ok(metadata) => metadata, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(HashMap::new()), + Err(error) => return Err(error.into()), + }; + if !path_metadata.is_file() { + anyhow::bail!("provider-sync rollout scan state is not a bounded regular file"); + } + if let Some(parent) = path.parent() { + ensure_path_components_not_reparse(home, parent)?; + } + ensure_not_reparse_or_symlink(&path)?; + let mut file = File::open(&path)?; + let metadata = file.metadata()?; + if !metadata.is_file() || metadata.len() > PROVIDER_SYNC_SCAN_STATE_MAX_BYTES { + anyhow::bail!("provider-sync rollout scan state is not a bounded regular file"); + } + let mut bytes = Vec::with_capacity(metadata.len() as usize); + std::io::Read::take(&mut file, PROVIDER_SYNC_SCAN_STATE_MAX_BYTES + 1) + .read_to_end(&mut bytes)?; + if bytes.len() as u64 > PROVIDER_SYNC_SCAN_STATE_MAX_BYTES { + anyhow::bail!("provider-sync rollout scan state exceeded the read limit"); + } + let manifest: ProviderSyncRolloutScanStateManifest = serde_json::from_slice(&bytes)?; + if manifest.version != PROVIDER_SYNC_SCAN_STATE_VERSION + || manifest.namespace != PROVIDER_SYNC_SCAN_STATE_NAMESPACE + || manifest.rules_sha256 != provider_sync_scan_rules_sha256() + || manifest.rollout_roots != session_transaction_rollout_roots(home)? + || manifest.entries.len() > PROVIDER_SYNC_SCAN_STATE_MAX_ENTRIES + { + anyhow::bail!("unsupported provider-sync rollout scan state"); + } + let mut entries = HashMap::new(); + for entry in manifest.entries { + validated_rollout_relative_path(&entry.relative_path)?; + if entry.relative_path.len() > 32 * 1024 + || entry.file_identity.is_empty() + || entry.file_identity.len() > 512 + || entry.sha256.len() != 64 + || !entry.sha256.bytes().all(|byte| byte.is_ascii_hexdigit()) + || entry.modified_secs.is_some() != entry.modified_nanos.is_some() + || entry .thread_id .as_ref() - .is_some_and(|thread_id| excluded_thread_ids.contains(thread_id)) + .is_some_and(|value| value.len() > 512) + || entry + .cwd + .as_ref() + .is_some_and(|value| value.len() > 32 * 1024) + || entry.providers.len() > 1024 + || entry.providers.iter().any(|value| value.len() > 512) + || !entry.providers.windows(2).all(|pair| pair[0] < pair[1]) + || entries.insert(entry.relative_path.clone(), entry).is_some() { - continue; - } - let has_user_event = text.contains("\"user_message\"") || text.contains("\"user_input\""); - if text.contains("encrypted_content") { - for provider in &rewrite.providers { - *collected - .encrypted_content_counts - .entry(provider.clone()) - .or_insert(0) += 1; - } + anyhow::bail!("invalid provider-sync rollout scan state entry"); } - let original_mtime = fs::metadata(&path).and_then(|m| m.modified()).ok(); - collected.changes.push(SessionChange { - path, - original_text: text, - next_text: rewrite.next_text, - original_session_meta_lines: rewrite.original_session_meta_lines, - thread_id: rewrite.thread_id, - cwd: rewrite.cwd, - has_user_event, - rewrite_needed: rewrite.rewrite_needed, - original_mtime, - }); } - Ok(collected) + Ok(entries) } -fn rollout_session_meta_marks_non_root_agent(text: &str) -> bool { - text.lines().any(|line| { - let Ok(record) = serde_json::from_str::(line) else { - return false; - }; - record.get("type").and_then(Value::as_str) == Some("session_meta") - && record - .get("payload") - .and_then(|payload| payload.get("source")) - .is_some_and(source_value_marks_non_root_agent) - }) +fn load_provider_sync_scan_state_best_effort( + home: &Path, +) -> HashMap { + match load_provider_sync_scan_state(home) { + Ok(entries) => entries, + Err(error) => { + let _ = codex_plus_core::diagnostic_log::append_diagnostic_log( + "provider_sync.scan_state_ignored", + json!({ + "error": error.to_string(), + "path": provider_sync_scan_state_path(home).to_string_lossy(), + }), + ); + HashMap::new() + } + } +} + +fn persist_provider_sync_scan_state( + home: &Path, + entries: &[ProviderSyncRolloutScanState], +) -> anyhow::Result<()> { + if entries.len() > PROVIDER_SYNC_SCAN_STATE_MAX_ENTRIES { + anyhow::bail!("provider-sync rollout scan state has too many entries"); + } + let mut entries = entries.to_vec(); + entries.sort_by(|left, right| left.relative_path.cmp(&right.relative_path)); + let path = provider_sync_scan_state_path(home); + if let Some(parent) = path.parent() { + create_validated_directory_path(home, parent)?; + } + match fs::symlink_metadata(&path) { + Ok(_) => ensure_not_reparse_or_symlink(&path)?, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(error) => return Err(error.into()), + } + let bytes = serde_json::to_vec_pretty(&ProviderSyncRolloutScanStateManifest { + version: PROVIDER_SYNC_SCAN_STATE_VERSION, + namespace: PROVIDER_SYNC_SCAN_STATE_NAMESPACE.to_string(), + rules_sha256: provider_sync_scan_rules_sha256(), + rollout_roots: session_transaction_rollout_roots(home)?, + entries, + })?; + if bytes.len() as u64 > PROVIDER_SYNC_SCAN_STATE_MAX_BYTES { + anyhow::bail!("provider-sync rollout scan state is too large"); + } + codex_plus_core::settings::atomic_write(&path, &bytes) +} + +fn persist_provider_sync_scan_state_best_effort( + home: &Path, + entries: &[ProviderSyncRolloutScanState], +) { + if let Err(error) = persist_provider_sync_scan_state(home, entries) { + let _ = codex_plus_core::diagnostic_log::append_diagnostic_log( + "provider_sync.scan_state_write_failed", + json!({ + "error": error.to_string(), + "path": provider_sync_scan_state_path(home).to_string_lossy(), + }), + ); + } +} + +fn invalidate_provider_sync_scan_state(home: &Path) -> anyhow::Result<()> { + let path = provider_sync_scan_state_path(home); + match fs::symlink_metadata(&path) { + Ok(_) => {} + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(()), + Err(error) => return Err(error.into()), + } + if let Some(parent) = path.parent() { + ensure_path_components_not_reparse(home, parent)?; + } + ensure_not_reparse_or_symlink(&path)?; + match fs::remove_file(path) { + Ok(()) => Ok(()), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()), + Err(error) => Err(error.into()), + } +} + +fn committed_provider_sync_scan_state( + home: &Path, + backup_dir: &Path, + target_provider: &str, + mut entries: Vec, +) -> anyhow::Result> { + let transaction = read_session_transaction(backup_dir)?; + if transaction.status != SESSION_TRANSACTION_COMMITTED + || transaction.mode != SessionTransactionMode::Full + { + anyhow::bail!("provider-sync rollout scan state requires a committed full transaction"); + } + let committed = transaction + .entries + .iter() + .map(|entry| (entry.relative_path.as_str(), entry)) + .collect::>(); + let mut next_entries = Vec::with_capacity(entries.len()); + for mut state in entries.drain(..) { + let relative = validated_rollout_relative_path(&state.relative_path)?; + let path = home.join(relative); + if let Some(entry) = committed.get(state.relative_path.as_str()) { + state.size = entry.next_size; + state.sha256 = entry.next_sha256.clone(); + if state.session_meta_count > 0 { + state.providers = vec![target_provider.to_string()]; + } + let Ok(file) = File::open(&path) else { + continue; + }; + let Ok(metadata) = file.metadata() else { + continue; + }; + let Ok(file_identity) = codex_plus_core::settings::file_instance_identity(&file) else { + continue; + }; + let modified = metadata.modified().ok(); + if !metadata.is_file() || metadata.len() != entry.next_size || modified.is_none() { + continue; + } + state.file_identity = file_identity; + (state.modified_secs, state.modified_nanos) = system_time_parts(modified); + next_entries.push(state); + } else { + let providers = state.providers.iter().cloned().collect::>(); + let still_needs_rewrite = state.session_meta_count > 0 + && !state.marks_non_root_agent + && rewrite_needed_for_providers( + &providers, + target_provider, + &SessionRewriteMode::AllProviders, + ); + if !still_needs_rewrite && provider_sync_scan_state_matches(&path, &state) { + next_entries.push(state); + } + } + } + Ok(next_entries) +} + +fn persist_committed_provider_sync_scan_state_best_effort( + home: &Path, + backup_dir: &Path, + target_provider: &str, + entries: Vec, +) { + match committed_provider_sync_scan_state(home, backup_dir, target_provider, entries) { + Ok(entries) => persist_provider_sync_scan_state_best_effort(home, &entries), + Err(error) => { + let _ = codex_plus_core::diagnostic_log::append_diagnostic_log( + "provider_sync.scan_state_commit_failed", + json!({ + "error": error.to_string(), + "backup_dir": backup_dir.to_string_lossy(), + }), + ); + } + } +} + +fn provider_sync_scan_state_matches(path: &Path, state: &ProviderSyncRolloutScanState) -> bool { + let Ok(file) = File::open(path) else { + return false; + }; + let Ok(metadata) = file.metadata() else { + return false; + }; + let Ok(file_identity) = codex_plus_core::settings::file_instance_identity(&file) else { + return false; + }; + let (modified_secs, modified_nanos) = system_time_parts(metadata.modified().ok()); + state.modified_secs.is_some() + && metadata.is_file() + && metadata.len() == state.size + && file_identity == state.file_identity + && modified_secs == state.modified_secs + && modified_nanos == state.modified_nanos +} + +fn rewrite_needed_for_providers( + providers: &HashSet, + target_provider: &str, + rewrite_mode: &SessionRewriteMode, +) -> bool { + match rewrite_mode { + SessionRewriteMode::AllProviders => { + providers.iter().any(|provider| provider != target_provider) + } + SessionRewriteMode::SourceProvider { source_provider } => providers + .iter() + .any(|provider| provider == "(missing)" || provider == source_provider), + } +} + +fn rollout_rewrite_from_scan_state( + state: &ProviderSyncRolloutScanState, + target_provider: &str, + rewrite_mode: &SessionRewriteMode, +) -> RolloutRewrite { + let providers = state.providers.iter().cloned().collect::>(); + RolloutRewrite { + rewrite_needed: rewrite_needed_for_providers(&providers, target_provider, rewrite_mode), + thread_id: state.thread_id.clone(), + cwd: state.cwd.clone(), + providers, + session_meta_count: state.session_meta_count, + has_user_event: state.has_user_event, + has_encrypted_content: state.has_encrypted_content, + marks_non_root_agent: state.marks_non_root_agent, + original_sha256: state.sha256.clone(), + original_size: state.size, + } +} + +fn provider_sync_cached_state_can_skip_body( + state: &ProviderSyncRolloutScanState, + target_provider: &str, + excluded_thread_ids: &HashSet, + explicit_user_thread_ids: &HashSet, +) -> bool { + if state.session_meta_count == 0 || state.marks_non_root_agent { + return true; + } + let is_explicit_user = state + .thread_id + .as_ref() + .is_some_and(|thread_id| explicit_user_thread_ids.contains(thread_id)); + if !is_explicit_user + && state + .thread_id + .as_ref() + .is_some_and(|thread_id| excluded_thread_ids.contains(thread_id)) + { + return true; + } + let providers = state.providers.iter().cloned().collect::>(); + !rewrite_needed_for_providers( + &providers, + target_provider, + &SessionRewriteMode::AllProviders, + ) +} + +fn scan_rollout_for_provider_sync_state( + home: &Path, + path: &Path, + target_provider: &str, + rewrite_mode: &SessionRewriteMode, +) -> std::io::Result<( + RolloutRewrite, + Option, + Option, +)> { + let mut file = File::open(path)?; + let before = file.metadata()?; + let before_modified = before.modified().ok(); + let before_identity = codex_plus_core::settings::file_instance_identity(&file).ok(); + let rewrite = + scan_rollout_session_meta_providers_from_file(&mut file, target_provider, rewrite_mode)?; + let after = file.metadata()?; + let after_modified = after.modified().ok(); + let after_identity = codex_plus_core::settings::file_instance_identity(&file).ok(); + let current = File::open(path)?; + let current_metadata = current.metadata()?; + let current_modified = current_metadata.modified().ok(); + let current_identity = codex_plus_core::settings::file_instance_identity(¤t).ok(); + let stable = before.len() == after.len() + && after.len() == rewrite.original_size + && after.len() == current_metadata.len() + && before_modified.is_some() + && before_modified == after_modified + && after_modified == current_modified + && before_identity.is_some() + && before_identity == after_identity + && after_identity == current_identity; + let mut providers = rewrite.providers.iter().cloned().collect::>(); + providers.sort(); + let relative_path = rollout_relative_path(home, path) + .map_err(|error| std::io::Error::new(std::io::ErrorKind::InvalidData, error))?; + let (modified_secs, modified_nanos) = system_time_parts(after_modified); + let state = stable.then(|| ProviderSyncRolloutScanState { + relative_path, + size: rewrite.original_size, + modified_secs, + modified_nanos, + file_identity: after_identity.expect("stable rollout scan has file identity"), + sha256: rewrite.original_sha256.clone(), + thread_id: rewrite.thread_id.clone(), + cwd: rewrite.cwd.clone(), + has_user_event: rewrite.has_user_event, + has_encrypted_content: rewrite.has_encrypted_content, + marks_non_root_agent: rewrite.marks_non_root_agent, + session_meta_count: rewrite.session_meta_count, + providers, + }); + Ok((rewrite, after_modified, state)) +} + +fn collect_session_changes( + home: &Path, + target_provider: &str, + excluded_thread_ids: &HashSet, + explicit_user_thread_ids: &HashSet, +) -> anyhow::Result { + collect_session_changes_with_scanner( + home, + target_provider, + excluded_thread_ids, + explicit_user_thread_ids, + load_provider_sync_scan_state_best_effort(home), + scan_rollout_for_provider_sync_state, + ) +} + +fn collect_session_changes_with_scanner( + home: &Path, + target_provider: &str, + excluded_thread_ids: &HashSet, + explicit_user_thread_ids: &HashSet, + mut scan_state: HashMap, + mut scanner: F, +) -> anyhow::Result +where + F: FnMut( + &Path, + &Path, + &str, + &SessionRewriteMode, + ) -> std::io::Result<( + RolloutRewrite, + Option, + Option, + )>, +{ + let mut collected = SessionChanges::default(); + for path in rollout_files(home)? { + let relative_path = rollout_relative_path(home, &path)?; + let cached = scan_state + .remove(&relative_path) + .filter(|state| provider_sync_scan_state_matches(&path, state)) + .filter(|state| { + provider_sync_cached_state_can_skip_body( + state, + target_provider, + excluded_thread_ids, + explicit_user_thread_ids, + ) + }); + let (rewrite, original_mtime, scan_state_entry) = match cached { + Some(state) => { + let original_mtime = fs::metadata(&path).and_then(|value| value.modified()).ok(); + ( + rollout_rewrite_from_scan_state( + &state, + target_provider, + &SessionRewriteMode::AllProviders, + ), + original_mtime, + Some(state), + ) + } + None => match scanner( + home, + &path, + target_provider, + &SessionRewriteMode::AllProviders, + ) { + Ok(scanned) => scanned, + Err(error) if is_locked_io_error(&error) => { + collected.skipped_locked_rollout_files.push(path); + continue; + } + Err(error) => return Err(error.into()), + }, + }; + if let Some(scan_state_entry) = scan_state_entry { + collected.scan_state_entries.push(scan_state_entry); + } + if rewrite.session_meta_count == 0 { + continue; + } + let is_explicit_user = rewrite + .thread_id + .as_ref() + .is_some_and(|thread_id| explicit_user_thread_ids.contains(thread_id)); + if rewrite.marks_non_root_agent { + if let Some(thread_id) = &rewrite.thread_id { + collected.subagent_thread_ids.insert(thread_id.clone()); + } + continue; + } + if !is_explicit_user + && rewrite + .thread_id + .as_ref() + .is_some_and(|thread_id| excluded_thread_ids.contains(thread_id)) + { + continue; + } + if rewrite.has_encrypted_content { + for provider in &rewrite.providers { + *collected + .encrypted_content_counts + .entry(provider.clone()) + .or_insert(0) += 1; + } + } + collected.changes.push(SessionChange { + path, + original_sha256: rewrite.original_sha256, + original_size: rewrite.original_size, + thread_id: rewrite.thread_id, + cwd: rewrite.cwd, + has_user_event: rewrite.has_user_event, + rewrite_needed: rewrite.rewrite_needed, + original_mtime, + rewrite_mode: SessionRewriteMode::AllProviders, + }); + } + Ok(collected) } fn remote_control_rollout_for_thread( @@ -1460,12 +2311,18 @@ fn resolve_active_rollout_path(home: &Path, value: &str) -> Option { fn rollout_provider_state_for_path( path: &Path, ) -> anyhow::Result)>> { - let text = match fs::read_to_string(path) { - Ok(text) => text, + let rewrite = match scan_rollout_session_meta_providers( + path, + DEFAULT_PROVIDER, + &SessionRewriteMode::AllProviders, + ) { + Ok(rewrite) => rewrite, Err(error) if is_locked_io_error(&error) => return Ok(None), Err(error) => return Err(error.into()), }; - Ok(rollout_thread_provider_state(&text)) + Ok(rewrite + .thread_id + .map(|thread_id| (thread_id, rewrite.providers.into_iter().collect()))) } fn collect_session_change_for_path( @@ -1475,8 +2332,11 @@ fn collect_session_change_for_path( thread_id: &str, ) -> anyhow::Result { let mut collected = SessionChanges::default(); - let text = match fs::read_to_string(path) { - Ok(text) => text, + let rewrite_mode = SessionRewriteMode::SourceProvider { + source_provider: source_provider.to_string(), + }; + let rewrite = match scan_rollout_session_meta_providers(path, target_provider, &rewrite_mode) { + Ok(rewrite) => rewrite, Err(error) if is_locked_io_error(&error) => { collected .skipped_locked_rollout_files @@ -1485,17 +2345,10 @@ fn collect_session_change_for_path( } Err(error) => return Err(error.into()), }; - let rewrite = rewrite_rollout_session_meta_providers_for_threads( - &text, - target_provider, - source_provider, - &HashSet::from([thread_id.to_string()]), - )?; if rewrite.session_meta_count == 0 || rewrite.thread_id.as_deref() != Some(thread_id) { return Ok(collected); } - let has_user_event = text.contains("\"user_message\"") || text.contains("\"user_input\""); - if text.contains("encrypted_content") { + if rewrite.has_encrypted_content { for provider in &rewrite.providers { *collected .encrypted_content_counts @@ -1508,14 +2361,14 @@ fn collect_session_change_for_path( .ok(); collected.changes.push(SessionChange { path: path.to_path_buf(), - original_text: text, - next_text: rewrite.next_text, - original_session_meta_lines: rewrite.original_session_meta_lines, + original_sha256: rewrite.original_sha256, + original_size: rewrite.original_size, thread_id: rewrite.thread_id, cwd: rewrite.cwd, - has_user_event, + has_user_event: rewrite.has_user_event, rewrite_needed: rewrite.rewrite_needed, original_mtime, + rewrite_mode, }); Ok(collected) } @@ -1533,139 +2386,114 @@ fn rollout_file_matches_provider( && providers.iter().all(|provider| provider == target_provider)) } -fn rewrite_rollout_session_meta_providers( - text: &str, +fn scan_rollout_session_meta_providers( + path: &Path, target_provider: &str, -) -> anyhow::Result { - let mut rewrite = RolloutRewrite::default(); - for segment in text.split_inclusive('\n') { - let (line, line_ending) = split_line_ending(segment); - let mut next_line = line.to_string(); - if !line.trim().is_empty() { - if let Ok(mut record) = serde_json::from_str::(line) { - if record.get("type").and_then(Value::as_str) == Some("session_meta") { - let Some(payload) = record.get_mut("payload").and_then(Value::as_object_mut) - else { - rewrite.next_text.push_str(&next_line); - rewrite.next_text.push_str(line_ending); - continue; - }; - rewrite.session_meta_count += 1; - rewrite.original_session_meta_lines.push(line.to_string()); - if rewrite.thread_id.is_none() { - rewrite.thread_id = payload - .get("id") - .and_then(Value::as_str) - .map(ToString::to_string); - } - if rewrite.cwd.is_none() { - rewrite.cwd = payload - .get("cwd") - .and_then(Value::as_str) - .and_then(to_desktop_workspace_path); - } - let provider = payload - .get("model_provider") - .and_then(Value::as_str) - .unwrap_or("(missing)") - .to_string(); - rewrite.providers.push(provider); - if payload.get("model_provider").and_then(Value::as_str) - != Some(target_provider) - { - payload.insert("model_provider".to_string(), json!(target_provider)); - next_line = serde_json::to_string(&record)?; - rewrite.rewrite_needed = true; - } - } - } - } - rewrite.next_text.push_str(&next_line); - rewrite.next_text.push_str(line_ending); - } - Ok(rewrite) + rewrite_mode: &SessionRewriteMode, +) -> std::io::Result { + let mut file = File::open(path)?; + scan_rollout_session_meta_providers_from_file(&mut file, target_provider, rewrite_mode) } -fn rewrite_rollout_session_meta_providers_for_threads( - text: &str, +fn scan_rollout_session_meta_providers_from_file( + file: &mut File, target_provider: &str, - source_provider: &str, - thread_ids: &HashSet, -) -> anyhow::Result { - let rollout_thread_id = text.lines().find_map(|line| { - let record = serde_json::from_str::(line).ok()?; + rewrite_mode: &SessionRewriteMode, +) -> std::io::Result { + let mut rewrite = RolloutRewrite::default(); + file.seek(SeekFrom::Start(0))?; + let mut reader = BufReader::new(file); + let mut line = Vec::new(); + let mut hasher = Sha256::new(); + loop { + line.clear(); + let read = reader.read_until(b'\n', &mut line)?; + if read == 0 { + break; + } + rewrite.original_size += read as u64; + hasher.update(&line); + rewrite.has_user_event |= + contains_bytes(&line, b"\"user_message\"") || contains_bytes(&line, b"\"user_input\""); + rewrite.has_encrypted_content |= contains_bytes(&line, b"encrypted_content"); + let (line_bytes, _) = split_line_ending_bytes(&line); + let line_text = std::str::from_utf8(line_bytes) + .map_err(|error| std::io::Error::new(std::io::ErrorKind::InvalidData, error))?; + if !contains_bytes(line_bytes, b"\"session_meta\"") { + continue; + } + let Ok(mut record) = serde_json::from_str::(line_text) else { + continue; + }; if record.get("type").and_then(Value::as_str) != Some("session_meta") { - return None; + continue; } - record - .get("payload")? - .get("id")? - .as_str() - .map(ToString::to_string) - }); - if rollout_thread_id - .as_ref() - .is_none_or(|thread_id| !thread_ids.contains(thread_id)) - { - return Ok(RolloutRewrite { - next_text: text.to_string(), - ..RolloutRewrite::default() - }); - } - - let mut rewrite = RolloutRewrite { - thread_id: rollout_thread_id, - ..RolloutRewrite::default() - }; - for segment in text.split_inclusive('\n') { - let (line, line_ending) = split_line_ending(segment); - let mut next_line = line.to_string(); - if !line.trim().is_empty() { - if let Ok(mut record) = serde_json::from_str::(line) { - if record.get("type").and_then(Value::as_str) == Some("session_meta") { - let Some(payload) = record.get_mut("payload").and_then(Value::as_object_mut) - else { - rewrite.next_text.push_str(&next_line); - rewrite.next_text.push_str(line_ending); - continue; - }; - rewrite.session_meta_count += 1; - rewrite.original_session_meta_lines.push(line.to_string()); - if rewrite.cwd.is_none() { - rewrite.cwd = payload - .get("cwd") - .and_then(Value::as_str) - .and_then(to_desktop_workspace_path); - } - let provider = payload - .get("model_provider") - .and_then(Value::as_str) - .map(ToString::to_string); - rewrite - .providers - .push(provider.clone().unwrap_or_else(|| "(missing)".to_string())); - if provider - .as_deref() - .is_none_or(|provider| provider == source_provider) - { - payload.insert("model_provider".to_string(), json!(target_provider)); - next_line = serde_json::to_string(&record)?; - rewrite.rewrite_needed = true; - } - } - } + rewrite.marks_non_root_agent |= record + .get("payload") + .and_then(|payload| payload.get("source")) + .is_some_and(source_value_marks_non_root_agent); + let Some(payload) = record.get_mut("payload").and_then(Value::as_object_mut) else { + continue; + }; + rewrite.session_meta_count += 1; + if rewrite.thread_id.is_none() { + rewrite.thread_id = payload + .get("id") + .and_then(Value::as_str) + .map(ToString::to_string); + } + if rewrite.cwd.is_none() { + rewrite.cwd = payload + .get("cwd") + .and_then(Value::as_str) + .and_then(to_desktop_workspace_path); } - rewrite.next_text.push_str(&next_line); - rewrite.next_text.push_str(line_ending); + let provider = payload + .get("model_provider") + .and_then(Value::as_str) + .map(ToString::to_string); + rewrite + .providers + .insert(provider.clone().unwrap_or_else(|| "(missing)".to_string())); + rewrite.rewrite_needed |= match rewrite_mode { + SessionRewriteMode::AllProviders => provider.as_deref() != Some(target_provider), + SessionRewriteMode::SourceProvider { source_provider } => provider + .as_deref() + .is_none_or(|provider| provider == source_provider), + }; } + rewrite.original_sha256 = format!("{:x}", hasher.finalize()); Ok(rewrite) } +fn contains_bytes(haystack: &[u8], needle: &[u8]) -> bool { + !needle.is_empty() + && haystack + .windows(needle.len()) + .any(|window| window == needle) +} + +fn split_line_ending_bytes(line: &[u8]) -> (&[u8], &[u8]) { + if line.ends_with(b"\r\n") { + (&line[..line.len() - 2], &line[line.len() - 2..]) + } else if line.ends_with(b"\n") { + (&line[..line.len() - 1], &line[line.len() - 1..]) + } else { + (line, &[]) + } +} + fn rollout_files(home: &Path) -> anyhow::Result> { let mut files = Vec::new(); + let canonical_home = fs::canonicalize(home)?; for dirname in SESSION_DIRS { let root = home.join(dirname); if root.exists() { + ensure_not_reparse_or_symlink(&root)?; + let canonical_root = fs::canonicalize(&root)?; + if !canonical_root.starts_with(&canonical_home) { + anyhow::bail!("provider-sync rollout root resolves outside Codex home"); + } collect_rollout_files(&root, &mut files)?; } } @@ -1686,28 +2514,17 @@ fn collect_live_thread_ids( { ids.insert(id); } - let text = match fs::read_to_string(&path) { - Ok(text) => text, + let rewrite = match scan_rollout_session_meta_providers( + &path, + DEFAULT_PROVIDER, + &SessionRewriteMode::AllProviders, + ) { + Ok(rewrite) => rewrite, Err(error) if is_locked_io_error(&error) => continue, Err(error) => return Err(error.into()), }; - for segment in text.split_inclusive('\n') { - let (line, _) = split_line_ending(segment); - let Ok(record) = serde_json::from_str::(line) else { - continue; - }; - if record.get("type").and_then(Value::as_str) != Some("session_meta") { - continue; - } - if let Some(id) = record - .get("payload") - .and_then(Value::as_object) - .and_then(|payload| payload.get("id")) - .and_then(Value::as_str) - .filter(|id| !id.trim().is_empty()) - { - ids.insert(id.to_string()); - } + if let Some(id) = rewrite.thread_id.filter(|id| !id.trim().is_empty()) { + ids.insert(id); } } for path in sqlite_paths { @@ -1923,7 +2740,7 @@ pub fn apply_session_index_cleanup( .map(Path::to_path_buf) .unwrap_or_else(default_codex_home_dir); let lock_dir = home.join("tmp/provider-sync.lock"); - acquire_lock(&lock_dir).map_err(|error| cleanup_apply_error(error, None))?; + let _lock_guard = acquire_lock(&lock_dir).map_err(|error| cleanup_apply_error(error, None))?; let result = (|| { let sqlite_paths = codex_plus_core::codex_sqlite::codex_thread_reference_db_paths_from_home(&home); @@ -1993,7 +2810,6 @@ pub fn apply_session_index_cleanup( backup_dir: Some(backup_dir), }) })(); - let _ = release_lock(&lock_dir); result } @@ -2024,10 +2840,7 @@ pub fn session_index_lines_for_thread( /// /// Best-effort: returns `Ok(0)` without writing when the file is missing or /// changed since it was read, so a delete flow never clobbers fresh entries. -pub fn remove_session_index_entry( - codex_home: &Path, - thread_id: &str, -) -> anyhow::Result { +pub fn remove_session_index_entry(codex_home: &Path, thread_id: &str) -> anyhow::Result { let path = codex_home.join("session_index.jsonl"); if !path.exists() { return Ok(0); @@ -2057,10 +2870,7 @@ pub fn remove_session_index_entry( /// Lines whose `id` already exists are skipped. Returns the number of /// appended lines. Best-effort: returns `Ok(0)` without writing when the /// file changed since it was read. -pub fn restore_session_index_entries( - codex_home: &Path, - lines: &[String], -) -> anyhow::Result { +pub fn restore_session_index_entries(codex_home: &Path, lines: &[String]) -> anyhow::Result { if lines.is_empty() { return Ok(0); } @@ -2138,29 +2948,18 @@ fn cleanup_apply_error( fn rollout_provider_ids(home: &Path) -> anyhow::Result> { let mut ids = HashSet::new(); for path in rollout_files(home)? { - let text = match fs::read_to_string(&path) { - Ok(text) => text, + let rewrite = match scan_rollout_session_meta_providers( + &path, + DEFAULT_PROVIDER, + &SessionRewriteMode::AllProviders, + ) { + Ok(rewrite) => rewrite, Err(error) if is_locked_io_error(&error) => continue, Err(error) => return Err(error.into()), }; - for segment in text.split_inclusive('\n') { - let (line, _) = split_line_ending(segment); - let Ok(record) = serde_json::from_str::(line) else { - continue; - }; - if record.get("type").and_then(Value::as_str) != Some("session_meta") { - continue; - } - let Some(provider) = record - .get("payload") - .and_then(Value::as_object) - .and_then(|payload| payload.get("model_provider")) - .and_then(Value::as_str) - else { - continue; - }; - if is_valid_provider_id_for_discovery(provider) { - ids.insert(provider.to_string()); + for provider in rewrite.providers { + if is_valid_provider_id_for_discovery(&provider) { + ids.insert(provider); } } } @@ -2169,13 +2968,17 @@ fn rollout_provider_ids(home: &Path) -> anyhow::Result> { fn collect_rollout_files(root: &Path, files: &mut Vec) -> anyhow::Result<()> { for entry in fs::read_dir(root)? { - let path = entry?.path(); - if path.is_dir() { + let entry = entry?; + let path = entry.path(); + ensure_not_reparse_or_symlink(&path)?; + let file_type = entry.file_type()?; + if file_type.is_dir() { collect_rollout_files(&path, files)?; - } else if path - .file_name() - .and_then(|name| name.to_str()) - .is_some_and(|name| name.starts_with("rollout-") && name.ends_with(".jsonl")) + } else if file_type.is_file() + && path + .file_name() + .and_then(|name| name.to_str()) + .is_some_and(|name| name.starts_with("rollout-") && name.ends_with(".jsonl")) { files.push(path); } @@ -2237,6 +3040,7 @@ fn build_encrypted_content_warning( fn create_backup( home: &Path, target_provider: &str, + mode: SessionTransactionMode, changes: &[SessionChange], ) -> anyhow::Result { let backup_root = home.join("backups_state/provider-sync"); @@ -2247,6 +3051,8 @@ fn create_backup( backup_dir = backup_root.join(format!("{}-{suffix}", timestamp_name())); } fs::create_dir_all(&backup_dir)?; + let mut global_state_files = Vec::new(); + let mut backup_files = HashMap::new(); for name in [ "config.toml", ".codex-global-state.json", @@ -2254,7 +3060,16 @@ fn create_backup( ] { let source = home.join(name); if source.exists() { - fs::copy(&source, backup_dir.join(name))?; + let target = backup_dir.join(name); + fs::copy(&source, &target)?; + if name != "config.toml" { + global_state_files.push(name); + let (sha256, size) = file_sha256_and_size(&target)?; + backup_files.insert( + name.to_string(), + ProviderSyncBackupFileEvidence { size, sha256 }, + ); + } } } let db_dir = backup_dir.join("db"); @@ -2264,27 +3079,30 @@ fn create_backup( if !source.exists() { continue; } - let relative = codex_plus_core::codex_sqlite::relative_to_codex_home(home, &source); + let relative = source.strip_prefix(home).map_err(|_| { + anyhow::anyhow!( + "provider-sync database is outside Codex home: {}", + source.display() + ) + })?; + validated_backup_relative_path(relative)?; let target = db_dir.join(&relative); if let Some(parent) = target.parent() { fs::create_dir_all(parent)?; } fs::copy(&source, &target)?; - db_files.push(relative.to_string_lossy().replace('\\', "/")); + let relative = relative.to_string_lossy().replace('\\', "/"); + let (sha256, size) = file_sha256_and_size(&target)?; + backup_files.insert( + format!("db/{relative}"), + ProviderSyncBackupFileEvidence { size, sha256 }, + ); + db_files.push(relative); } } - let manifest = changes - .iter() - .map(|change| { - json!({ - "path": change.path.to_string_lossy(), - "originalSessionMetaLines": change.original_session_meta_lines, - }) - }) - .collect::>(); fs::write( backup_dir.join("session-meta-backup.json"), - serde_json::to_string_pretty(&manifest)?, + serde_json::to_string_pretty(&Vec::::new())?, )?; fs::write( backup_dir.join("metadata.json"), @@ -2295,10 +3113,25 @@ fn create_backup( "targetProvider": target_provider, "createdAt": chrono::Utc::now().to_rfc3339(), "dbFiles": db_files, + "globalStateFiles": global_state_files, + "backupFiles": backup_files, "changedSessionFiles": changes.len(), "managedBy": "Codex++ provider sync" }))?, )?; + write_session_transaction( + &backup_dir, + &SessionTransactionManifest { + version: 1, + namespace: SESSION_TRANSACTION_NAMESPACE.to_string(), + status: SESSION_TRANSACTION_IN_PROGRESS.to_string(), + transaction_id: uuid::Uuid::new_v4().simple().to_string(), + mode, + phase: SessionTransactionPhase::RolloutsApplying, + rollout_roots: session_transaction_rollout_roots(home)?, + entries: Vec::new(), + }, + )?; Ok(backup_dir) } @@ -2332,75 +3165,1567 @@ fn create_session_index_cleanup_backup( Ok(backup_dir) } -fn apply_session_changes(changes: &[SessionChange]) -> anyhow::Result { - let mut applied = AppliedSessionChanges::default(); - for change in changes { - match replace_session_text_if_unchanged( - &change.path, - &change.original_text, - &change.next_text, - ) { - Ok(true) => {} - Ok(false) => { +fn apply_session_changes( + home: &Path, + backup_dir: &Path, + target_provider: &str, + changes: &[SessionChange], +) -> anyhow::Result { + let apply_result = (|| -> anyhow::Result { + if !changes.is_empty() { + invalidate_provider_sync_scan_state(home)?; + } + let mut applied = AppliedSessionChanges::default(); + let mut transaction = read_session_transaction(backup_dir)?; + for change in changes { + let entry_index = transaction.entries.len(); + let relative_path = rollout_relative_path(home, &change.path)?; + let target_path = validated_rollout_transaction_path( + home, + &relative_path, + &transaction.rollout_roots, + )?; + let staged_file_name = format!( + ".{}.provider-sync-{}-{}.tmp", + target_path + .file_name() + .and_then(|name| name.to_str()) + .unwrap_or("rollout"), + transaction.transaction_id, + entry_index + ); + let staged_path = target_path + .parent() + .ok_or_else(|| anyhow::anyhow!("rollout has no parent: {}", target_path.display()))? + .join(&staged_file_name); + let session_meta_backup_path = + backup_dir.join(format!("session-meta/{entry_index}.jsonl")); + let mut source = match open_session_file_for_update(&target_path) { + Ok(source) => source, + Err(error) if is_locked_io_error(&error) => { + applied + .skipped_locked_rollout_files + .push(change.path.clone()); + continue; + } + Err(error) => return Err(error.into()), + }; + if source.try_lock().is_err() { applied .skipped_locked_rollout_files .push(change.path.clone()); continue; } - Err(error) if is_locked_io_error(&error) => { + let staged = match stage_next_session_file( + &mut source, + &staged_path, + &session_meta_backup_path, + target_provider, + &change.rewrite_mode, + ) { + Ok(staged) => staged, + Err(error) => { + let _ = fs::remove_file(&staged_path); + let _ = fs::remove_file(&session_meta_backup_path); + return Err(error.into()); + } + }; + if staged.original_sha256 != change.original_sha256 + || staged.original_size != change.original_size + { + drop(source); + let _ = fs::remove_file(&staged_path); + let _ = fs::remove_file(&session_meta_backup_path); applied .skipped_locked_rollout_files .push(change.path.clone()); continue; } - Err(error) => return Err(error.into()), + let (original_mtime_secs, original_mtime_nanos) = + system_time_parts(change.original_mtime); + transaction.entries.push(SessionTransactionEntry { + relative_path: relative_path.clone(), + original_sha256: staged.original_sha256, + next_sha256: staged.next_sha256.clone(), + original_size: staged.original_size, + next_size: staged.next_size, + session_meta_backup_sha256: staged.session_meta_backup_sha256, + original_mtime_secs, + original_mtime_nanos, + external_sha256: None, + external_size: None, + }); + if let Err(error) = write_session_transaction(backup_dir, &transaction) { + transaction.entries.pop(); + drop(source); + let _ = fs::remove_file(&staged_path); + let _ = fs::remove_file(&session_meta_backup_path); + let _ = rollback_session_transaction(home, backup_dir); + return Err(error); + } + if let Err(error) = write_session_meta_backup_manifest(backup_dir, &transaction) { + drop(source); + let _ = fs::remove_file(&staged_path); + let _ = rollback_session_transaction(home, backup_dir); + return Err(error); + } + drop(source); + let replacement_target = validated_rollout_transaction_path( + home, + &relative_path, + &transaction.rollout_roots, + )?; + if replacement_target != target_path { + anyhow::bail!("provider-sync rollout path changed before replacement"); + } + let displaced_path = + transaction_displaced_path(&target_path, &transaction.transaction_id, entry_index)?; + if let Err(error) = codex_plus_core::settings::atomic_replace_file_with_backup( + &staged_path, + &target_path, + &displaced_path, + ) { + let _ = fs::remove_file(&staged_path); + if is_locked_io_error_from_anyhow(&error) { + transaction.entries.pop(); + write_session_transaction(backup_dir, &transaction)?; + write_session_meta_backup_manifest(backup_dir, &transaction)?; + let _ = fs::remove_file(&session_meta_backup_path); + applied + .skipped_locked_rollout_files + .push(change.path.clone()); + continue; + } + let _ = rollback_session_transaction(home, backup_dir); + return Err(error); + } + let (displaced_sha256, displaced_size) = file_sha256_and_size(&displaced_path)?; + if displaced_sha256 != change.original_sha256 || displaced_size != change.original_size + { + let entry = transaction + .entries + .last_mut() + .ok_or_else(|| anyhow::anyhow!("provider-sync transaction entry missing"))?; + entry.external_sha256 = Some(displaced_sha256); + entry.external_size = Some(displaced_size); + write_session_transaction(backup_dir, &transaction)?; + restore_displaced_session_file( + &target_path, + &displaced_path, + &transaction.transaction_id, + entry_index, + )?; + anyhow::bail!( + "rollout changed between provider-sync staging and replacement: {}", + target_path.display() + ); + } + restore_file_mtime(&target_path, change.original_mtime); + let (persisted_sha256, persisted_size) = file_sha256_and_size(&target_path)?; + if persisted_sha256 != staged.next_sha256 || persisted_size != staged.next_size { + let _ = rollback_session_transaction(home, backup_dir); + anyhow::bail!( + "rollout changed while provider metadata was being replaced: {}", + target_path.display() + ); + } + fs::remove_file(displaced_path)?; + applied.changed_files += 1; } - restore_file_mtime(&change.path, change.original_mtime); - applied.changes.push(change.clone()); + Ok(applied) + })(); + if apply_result.is_err() { + let _ = rollback_session_transaction(home, backup_dir); } - Ok(applied) + apply_result } -fn restore_session_changes(changes: &[SessionChange]) -> anyhow::Result<()> { - for change in changes { - if replace_session_text_if_unchanged( - &change.path, - &change.next_text, - &change.original_text, - )? { - restore_file_mtime(&change.path, change.original_mtime); +#[derive(Debug)] +struct StagedSessionFile { + original_sha256: String, + next_sha256: String, + original_size: u64, + next_size: u64, + session_meta_backup_sha256: String, +} + +fn stage_next_session_file( + source: &mut File, + staged_path: &Path, + session_meta_backup_path: &Path, + target_provider: &str, + rewrite_mode: &SessionRewriteMode, +) -> std::io::Result { + let staged_file = OpenOptions::new() + .create_new(true) + .write(true) + .open(staged_path)?; + if let Some(parent) = session_meta_backup_path.parent() { + fs::create_dir_all(parent)?; + } + let session_meta_backup_file = OpenOptions::new() + .create_new(true) + .write(true) + .open(session_meta_backup_path)?; + let mut reader = BufReader::new(source); + let mut writer = BufWriter::new(staged_file); + let mut session_meta_writer = BufWriter::new(session_meta_backup_file); + let mut line = Vec::new(); + let mut original_hasher = Sha256::new(); + let mut next_hasher = Sha256::new(); + let mut session_meta_hasher = Sha256::new(); + let mut original_size = 0_u64; + let mut next_size = 0_u64; + loop { + line.clear(); + let read = reader.read_until(b'\n', &mut line)?; + if read == 0 { + break; + } + original_size += read as u64; + original_hasher.update(&line); + let (line_bytes, line_ending) = split_line_ending_bytes(&line); + let line_text = std::str::from_utf8(line_bytes) + .map_err(|error| std::io::Error::new(std::io::ErrorKind::InvalidData, error))?; + if session_meta_line_has_payload_object(line_text) { + let backup_line = serde_json::to_vec(line_text) + .map_err(|error| std::io::Error::new(std::io::ErrorKind::InvalidData, error))?; + session_meta_writer.write_all(&backup_line)?; + session_meta_writer.write_all(b"\n")?; + session_meta_hasher.update(&backup_line); + session_meta_hasher.update(b"\n"); + } + if let Some(next_line) = + rewritten_session_meta_line(line_text, target_provider, rewrite_mode)? + { + writer.write_all(&next_line)?; + writer.write_all(line_ending)?; + next_hasher.update(&next_line); + next_hasher.update(line_ending); + next_size += (next_line.len() + line_ending.len()) as u64; + } else { + writer.write_all(&line)?; + next_hasher.update(&line); + next_size += line.len() as u64; + } + } + writer.flush()?; + writer.get_ref().sync_all()?; + session_meta_writer.flush()?; + session_meta_writer.get_ref().sync_all()?; + Ok(StagedSessionFile { + original_sha256: format!("{:x}", original_hasher.finalize()), + next_sha256: format!("{:x}", next_hasher.finalize()), + original_size, + next_size, + session_meta_backup_sha256: format!("{:x}", session_meta_hasher.finalize()), + }) +} + +fn write_session_meta_backup_manifest( + backup_dir: &Path, + transaction: &SessionTransactionManifest, +) -> anyhow::Result<()> { + let manifest = transaction + .entries + .iter() + .enumerate() + .map(|(index, entry)| { + json!({ + "path": entry.relative_path, + "originalSha256": entry.original_sha256, + "originalSize": entry.original_size, + "sessionMetaBackup": format!("session-meta/{index}.jsonl"), + }) + }) + .collect::>(); + codex_plus_core::settings::atomic_write( + &backup_dir.join("session-meta-backup.json"), + &serde_json::to_vec_pretty(&manifest)?, + ) +} + +fn session_meta_line_has_payload_object(line: &str) -> bool { + line.contains("\"session_meta\"") + && serde_json::from_str::(line).is_ok_and(|record| { + record.get("type").and_then(Value::as_str) == Some("session_meta") + && record.get("payload").and_then(Value::as_object).is_some() + }) +} + +fn rewritten_session_meta_line( + line: &str, + target_provider: &str, + rewrite_mode: &SessionRewriteMode, +) -> std::io::Result>> { + if !line.contains("\"session_meta\"") { + return Ok(None); + } + let Ok(mut record) = serde_json::from_str::(line) else { + return Ok(None); + }; + if record.get("type").and_then(Value::as_str) != Some("session_meta") { + return Ok(None); + } + let Some(payload) = record.get_mut("payload").and_then(Value::as_object_mut) else { + return Ok(None); + }; + let provider = payload.get("model_provider").and_then(Value::as_str); + let rewrite = match rewrite_mode { + SessionRewriteMode::AllProviders => provider != Some(target_provider), + SessionRewriteMode::SourceProvider { source_provider } => { + provider.is_none_or(|provider| provider == source_provider) } + }; + if !rewrite { + return Ok(None); } + payload.insert("model_provider".to_string(), json!(target_provider)); + serde_json::to_vec(&record) + .map(Some) + .map_err(std::io::Error::other) +} + +fn transaction_displaced_path( + path: &Path, + transaction_id: &str, + entry_index: usize, +) -> anyhow::Result { + Ok(path + .parent() + .ok_or_else(|| anyhow::anyhow!("rollout has no parent: {}", path.display()))? + .join(format!( + ".{}.provider-sync-displaced-{}-{}.tmp", + path.file_name() + .and_then(|name| name.to_str()) + .unwrap_or("rollout"), + transaction_id, + entry_index + ))) +} + +fn restore_displaced_session_file( + path: &Path, + displaced_path: &Path, + transaction_id: &str, + entry_index: usize, +) -> anyhow::Result<()> { + if !path.exists() { + return codex_plus_core::settings::atomic_replace_file(displaced_path, path); + } + let rejected_path = path + .parent() + .ok_or_else(|| anyhow::anyhow!("rollout has no parent: {}", path.display()))? + .join(format!( + ".{}.provider-sync-rejected-{}-{}.tmp", + path.file_name() + .and_then(|name| name.to_str()) + .unwrap_or("rollout"), + transaction_id, + entry_index + )); + codex_plus_core::settings::atomic_replace_file_with_backup( + displaced_path, + path, + &rejected_path, + )?; + fs::remove_file(rejected_path)?; Ok(()) } -fn replace_session_text_if_unchanged( +fn rollback_session_transaction(home: &Path, backup_dir: &Path) -> anyhow::Result<()> { + let mut transaction = read_session_transaction(backup_dir)?; + if transaction.status == SESSION_TRANSACTION_COMMITTED + || transaction.status == SESSION_TRANSACTION_ROLLED_BACK + { + return Ok(()); + } + if transaction.status != SESSION_TRANSACTION_IN_PROGRESS { + anyhow::bail!("unknown provider-sync rollout transaction status"); + } + for entry_index in (0..transaction.entries.len()).rev() { + let entry = transaction.entries[entry_index].clone(); + let path = validated_rollout_transaction_path( + home, + &entry.relative_path, + &transaction.rollout_roots, + )?; + let displaced_path = + transaction_displaced_path(&path, &transaction.transaction_id, entry_index)?; + if let (Some(external_sha256), Some(external_size)) = + (entry.external_sha256.as_deref(), entry.external_size) + { + if path.exists() { + let (current_sha256, current_size) = file_sha256_and_size(&path)?; + if current_sha256 == external_sha256 && current_size == external_size { + if displaced_path.exists() { + let (displaced_sha256, displaced_size) = + file_sha256_and_size(&displaced_path)?; + let displaced_is_original = displaced_sha256 == entry.original_sha256 + && displaced_size == entry.original_size; + let displaced_is_external = + displaced_sha256 == external_sha256 && displaced_size == external_size; + if !displaced_is_original && !displaced_is_external { + anyhow::bail!( + "provider-sync displaced file changed after external restore: {}", + displaced_path.display() + ); + } + fs::remove_file(&displaced_path)?; + } + continue; + } + if current_sha256 != entry.next_sha256 || current_size != entry.next_size { + anyhow::bail!( + "rollout changed after external restore decision: {}", + path.display() + ); + } + } + if displaced_path.exists() { + let (displaced_sha256, displaced_size) = file_sha256_and_size(&displaced_path)?; + if displaced_sha256 != external_sha256 || displaced_size != external_size { + anyhow::bail!( + "provider-sync displaced external version changed: {}", + displaced_path.display() + ); + } + restore_displaced_session_file( + &path, + &displaced_path, + &transaction.transaction_id, + entry_index, + )?; + continue; + } + anyhow::bail!( + "provider-sync external version is missing after restore decision: {}", + path.display() + ); + } + if displaced_path.exists() { + let (displaced_sha256, displaced_size) = file_sha256_and_size(&displaced_path)?; + if !path.exists() { + restore_displaced_session_file( + &path, + &displaced_path, + &transaction.transaction_id, + entry_index, + )?; + if displaced_sha256 == entry.original_sha256 + && displaced_size == entry.original_size + { + restore_file_mtime_parts( + &path, + entry.original_mtime_secs, + entry.original_mtime_nanos, + ); + } + continue; + } + let (current_sha256, current_size) = file_sha256_and_size(&path)?; + if displaced_sha256 == entry.original_sha256 && displaced_size == entry.original_size { + if current_sha256 == entry.original_sha256 && current_size == entry.original_size { + fs::remove_file(&displaced_path)?; + continue; + } + if current_sha256 != entry.next_sha256 || current_size != entry.next_size { + transaction.entries[entry_index].external_sha256 = Some(current_sha256); + transaction.entries[entry_index].external_size = Some(current_size); + write_session_transaction(backup_dir, &transaction)?; + fs::remove_file(&displaced_path)?; + continue; + } + restore_displaced_session_file( + &path, + &displaced_path, + &transaction.transaction_id, + entry_index, + )?; + restore_file_mtime_parts( + &path, + entry.original_mtime_secs, + entry.original_mtime_nanos, + ); + continue; + } + if current_sha256 != entry.next_sha256 || current_size != entry.next_size { + anyhow::bail!( + "rollout and displaced file both changed; refusing recovery: {}", + path.display() + ); + } + transaction.entries[entry_index].external_sha256 = Some(displaced_sha256); + transaction.entries[entry_index].external_size = Some(displaced_size); + write_session_transaction(backup_dir, &transaction)?; + restore_displaced_session_file( + &path, + &displaced_path, + &transaction.transaction_id, + entry_index, + )?; + continue; + } + let (current_sha256, current_size) = file_sha256_and_size(&path)?; + if current_sha256 == entry.original_sha256 && current_size == entry.original_size { + continue; + } + if current_sha256 != entry.next_sha256 || current_size != entry.next_size { + anyhow::bail!( + "rollout changed after provider-sync replacement; refusing rollback: {}", + path.display() + ); + } + let restore_name = format!( + ".{}.provider-sync-restore-{}.tmp", + path.file_name() + .and_then(|name| name.to_str()) + .unwrap_or("rollout"), + transaction.transaction_id + ); + let restore_path = path + .parent() + .ok_or_else(|| anyhow::anyhow!("rollout has no parent: {}", path.display()))? + .join(restore_name); + let restored = match stage_original_session_file( + &path, + &restore_path, + backup_dir, + entry_index, + &entry, + ) { + Ok(restored) => restored, + Err(error) => { + let _ = fs::remove_file(&restore_path); + return Err(error); + } + }; + if restored.0 != entry.original_sha256 || restored.1 != entry.original_size { + let _ = fs::remove_file(&restore_path); + anyhow::bail!("provider-sync rollback hash mismatch: {}", path.display()); + } + codex_plus_core::settings::atomic_replace_file(&restore_path, &path)?; + restore_file_mtime_parts(&path, entry.original_mtime_secs, entry.original_mtime_nanos); + } + cleanup_transaction_staged_files(home, &transaction.transaction_id)?; + transaction.status = SESSION_TRANSACTION_ROLLED_BACK.to_string(); + write_session_transaction(backup_dir, &transaction) +} + +fn stage_original_session_file( path: &Path, - expected_text: &str, - next_text: &str, -) -> std::io::Result { - let mut file = open_session_file_for_update(path)?; - file.try_lock()?; - let mut current_text = String::new(); - file.read_to_string(&mut current_text)?; - if current_text != expected_text { - return Ok(false); + staged_path: &Path, + backup_dir: &Path, + entry_index: usize, + entry: &SessionTransactionEntry, +) -> anyhow::Result<(String, u64)> { + let mut source = open_session_file_for_update(path)?; + source.try_lock()?; + let staged_file = OpenOptions::new() + .create_new(true) + .write(true) + .open(staged_path)?; + let mut reader = BufReader::new(&mut source); + let mut writer = BufWriter::new(staged_file); + let canonical_backup_dir = fs::canonicalize(backup_dir)?; + let session_meta_backup_path = backup_dir.join(format!("session-meta/{entry_index}.jsonl")); + let canonical_session_meta_backup_path = fs::canonicalize(&session_meta_backup_path)?; + if !canonical_session_meta_backup_path.starts_with(&canonical_backup_dir) { + anyhow::bail!("provider-sync session-meta backup resolves outside its backup directory"); + } + if !fs::symlink_metadata(&session_meta_backup_path)? + .file_type() + .is_file() + { + anyhow::bail!("provider-sync session-meta backup is not a regular file"); + } + let mut session_meta_reader = BufReader::new(File::open(&session_meta_backup_path)?); + let mut line = Vec::new(); + let mut session_meta_line = Vec::new(); + let mut hasher = Sha256::new(); + let mut session_meta_hasher = Sha256::new(); + let mut size = 0_u64; + loop { + line.clear(); + let read = reader.read_until(b'\n', &mut line)?; + if read == 0 { + break; + } + let (line_bytes, line_ending) = split_line_ending_bytes(&line); + let line_text = std::str::from_utf8(line_bytes)?; + let is_session_meta = if line_text.contains("\"session_meta\"") { + serde_json::from_str::(line_text).is_ok_and(|record| { + record.get("type").and_then(Value::as_str) == Some("session_meta") + && record.get("payload").and_then(Value::as_object).is_some() + }) + } else { + false + }; + if is_session_meta { + session_meta_line.clear(); + if session_meta_reader.read_until(b'\n', &mut session_meta_line)? == 0 { + anyhow::bail!("provider-sync rollback metadata is incomplete"); + } + session_meta_hasher.update(&session_meta_line); + let backup_line = session_meta_line + .strip_suffix(b"\n") + .unwrap_or(&session_meta_line); + let original_line: String = serde_json::from_slice(backup_line)?; + writer.write_all(original_line.as_bytes())?; + writer.write_all(line_ending)?; + hasher.update(original_line.as_bytes()); + hasher.update(line_ending); + size += (original_line.len() + line_ending.len()) as u64; + } else { + writer.write_all(&line)?; + hasher.update(&line); + size += line.len() as u64; + } + } + session_meta_line.clear(); + if session_meta_reader.read_until(b'\n', &mut session_meta_line)? != 0 { + anyhow::bail!("provider-sync rollback metadata count mismatch"); + } + let session_meta_backup_sha256 = format!("{:x}", session_meta_hasher.finalize()); + if session_meta_backup_sha256 != entry.session_meta_backup_sha256 { + anyhow::bail!("provider-sync session-meta backup hash mismatch"); + } + writer.flush()?; + writer.get_ref().sync_all()?; + Ok((format!("{:x}", hasher.finalize()), size)) +} + +fn commit_session_transaction(backup_dir: &Path) -> anyhow::Result<()> { + let mut transaction = read_session_transaction(backup_dir)?; + if transaction.phase != SessionTransactionPhase::CommitDecided { + anyhow::bail!("provider-sync rollout transaction has no commit decision"); + } + transaction.status = SESSION_TRANSACTION_COMMITTED.to_string(); + write_session_transaction(backup_dir, &transaction) +} + +fn set_session_transaction_phase( + backup_dir: &Path, + phase: SessionTransactionPhase, +) -> anyhow::Result<()> { + let mut transaction = read_session_transaction(backup_dir)?; + if transaction.status != SESSION_TRANSACTION_IN_PROGRESS { + anyhow::bail!("provider-sync rollout transaction is no longer active"); + } + let valid = matches!( + (transaction.phase, phase), + ( + SessionTransactionPhase::RolloutsApplying, + SessionTransactionPhase::RolloutsApplied + ) | ( + SessionTransactionPhase::RolloutsApplied, + SessionTransactionPhase::DownstreamStarted + ) | ( + SessionTransactionPhase::RolloutsApplied, + SessionTransactionPhase::CommitDecided + ) | ( + SessionTransactionPhase::DownstreamStarted, + SessionTransactionPhase::CommitDecided + ) + ); + if !valid { + anyhow::bail!("invalid provider-sync rollout transaction phase transition"); } + transaction.phase = phase; + write_session_transaction(backup_dir, &transaction) +} + +fn recover_interrupted_session_transactions(home: &Path) -> anyhow::Result<()> { + let backup_root = home.join("backups_state/provider-sync"); + let entries = match fs::read_dir(&backup_root) { + Ok(entries) => entries, + Err(error) + if matches!( + error.kind(), + std::io::ErrorKind::NotFound | std::io::ErrorKind::NotADirectory + ) => + { + return Ok(()); + } + Err(error) => return Err(error.into()), + }; + let mut backup_dirs = Vec::new(); + for entry in entries { + let entry = entry?; + if entry.file_type()?.is_dir() && entry.path().join(SESSION_TRANSACTION_FILE).is_file() { + backup_dirs.push(entry.path()); + } + } + backup_dirs.sort(); + for backup_dir in backup_dirs { + let transaction = read_session_transaction(&backup_dir)?; + if transaction.status != SESSION_TRANSACTION_IN_PROGRESS { + continue; + } + match (transaction.mode, transaction.phase) { + (_, SessionTransactionPhase::CommitDecided) => { + commit_session_transaction(&backup_dir)?; + } + (SessionTransactionMode::Full, SessionTransactionPhase::DownstreamStarted) => { + restore_provider_sync_downstream_backup(home, &backup_dir)?; + rollback_session_transaction(home, &backup_dir)?; + } + _ => rollback_session_transaction(home, &backup_dir)?, + } + } + Ok(()) +} + +fn restore_provider_sync_downstream_backup(home: &Path, backup_dir: &Path) -> anyhow::Result<()> { + let transaction = read_session_transaction(backup_dir)?; + if transaction.mode != SessionTransactionMode::Full { + anyhow::bail!("remote provider-sync transactions do not own downstream state"); + } + let metadata: Value = serde_json::from_slice(&fs::read(backup_dir.join("metadata.json"))?)?; + let db_files = metadata + .get("dbFiles") + .and_then(Value::as_array) + .ok_or_else(|| anyhow::anyhow!("provider-sync backup is missing dbFiles"))? + .iter() + .map(|value| { + value + .as_str() + .ok_or_else(|| anyhow::anyhow!("provider-sync backup has an invalid dbFiles entry")) + .and_then(|value| validated_backup_relative_path(Path::new(value))) + }) + .collect::>>()?; + let allowed_db_files = provider_sync_db_paths(home) + .into_iter() + .flat_map(|db_path| codex_plus_core::codex_sqlite::codex_sqlite_sidecar_paths(&db_path)) + .map(|path| { + let relative = path.strip_prefix(home).map_err(|_| { + anyhow::anyhow!( + "provider-sync database is outside Codex home: {}", + path.display() + ) + })?; + Ok(validated_backup_relative_path(relative)? + .to_string_lossy() + .replace('\\', "/")) + }) + .collect::>>()?; + let db_file_set = db_files + .iter() + .map(|path| path.to_string_lossy().replace('\\', "/")) + .collect::>(); + if !db_file_set.is_subset(&allowed_db_files) { + anyhow::bail!("provider-sync backup dbFiles contains an unexpected path"); + } + let actual_backup_db_files = collect_backup_relative_files(&backup_dir.join("db"))?; + if db_file_set != actual_backup_db_files { + anyhow::bail!("provider-sync backup dbFiles does not match the backup directory"); + } + + let global_state_files = metadata + .get("globalStateFiles") + .and_then(Value::as_array) + .ok_or_else(|| anyhow::anyhow!("provider-sync backup is missing globalStateFiles"))? + .iter() + .map(|value| { + value.as_str().ok_or_else(|| { + anyhow::anyhow!("provider-sync backup has an invalid globalStateFiles entry") + }) + }) + .collect::>>()?; + let allowed_global_state_files = + HashSet::from([".codex-global-state.json", ".codex-global-state.json.bak"]); + if !global_state_files.is_subset(&allowed_global_state_files) { + anyhow::bail!("provider-sync backup globalStateFiles contains an unexpected path"); + } + let actual_global_state_files = allowed_global_state_files + .iter() + .copied() + .filter(|name| { + fs::symlink_metadata(backup_dir.join(name)) + .is_ok_and(|metadata| metadata.file_type().is_file()) + }) + .collect::>(); + if global_state_files != actual_global_state_files { + anyhow::bail!("provider-sync backup globalStateFiles does not match the backup directory"); + } + + let backup_files: HashMap = serde_json::from_value( + metadata + .get("backupFiles") + .cloned() + .ok_or_else(|| anyhow::anyhow!("provider-sync backup is missing backupFiles"))?, + )?; + let expected_backup_files = db_file_set + .iter() + .map(|relative| format!("db/{relative}")) + .chain(global_state_files.iter().map(|name| (*name).to_string())) + .collect::>(); + let recorded_backup_files = backup_files.keys().cloned().collect::>(); + if recorded_backup_files != expected_backup_files { + anyhow::bail!("provider-sync backupFiles does not match the recorded backup files"); + } + let mut prepared_backup_files = + prepare_provider_sync_backup_files(backup_dir, &backup_files, &transaction.transaction_id)?; + let target_parents = prepare_provider_sync_target_parents(home, &db_files)?; + + for db_path in provider_sync_db_paths(home) { + for (index, current) in codex_plus_core::codex_sqlite::codex_sqlite_sidecar_paths(&db_path) + .into_iter() + .enumerate() + { + let Ok(relative) = current.strip_prefix(home) else { + anyhow::bail!( + "provider-sync database is outside Codex home: {}", + current.display() + ); + }; + let relative = validated_backup_relative_path(relative)?; + let key = relative.to_string_lossy().replace('\\', "/"); + if index > 0 && current.exists() && !db_file_set.contains(&key) { + let parent_evidence = + validate_provider_sync_target_parent(home, ¤t, &target_parents)?; + ensure_not_reparse_or_symlink(¤t)?; + remove_provider_sync_target_file(¤t, parent_evidence)?; + } + } + } + for relative in db_files { + let key = format!("db/{}", relative.to_string_lossy().replace('\\', "/")); + let prepared = prepared_backup_files + .files + .get_mut(&key) + .ok_or_else(|| anyhow::anyhow!("provider-sync backup file evidence is missing"))?; + let target = home.join(&relative); + restore_file_from_provider_sync_backup( + home, + &target, + &transaction.transaction_id, + prepared, + &target_parents, + )?; + } + for name in [".codex-global-state.json", ".codex-global-state.json.bak"] { + let target = home.join(name); + if global_state_files.contains(name) { + let prepared = prepared_backup_files + .files + .get_mut(name) + .ok_or_else(|| anyhow::anyhow!("provider-sync backup file evidence is missing"))?; + restore_file_from_provider_sync_backup( + home, + &target, + &transaction.transaction_id, + prepared, + &target_parents, + )?; + } else if target.exists() { + let parent_evidence = + validate_provider_sync_target_parent(home, &target, &target_parents)?; + ensure_not_reparse_or_symlink(&target)?; + remove_provider_sync_target_file(&target, parent_evidence)?; + } + } + Ok(()) +} + +fn prepare_provider_sync_backup_files( + backup_dir: &Path, + backup_files: &HashMap, + transaction_id: &str, +) -> anyhow::Result { + let canonical_backup_dir = fs::canonicalize(backup_dir)?; + let snapshot_dir = std::env::temp_dir().join(format!( + "codex-plus-provider-sync-{transaction_id}-{}", + uuid::Uuid::new_v4().simple() + )); + fs::create_dir(&snapshot_dir)?; + ensure_not_reparse_or_symlink(&snapshot_dir)?; + let mut prepared = PreparedProviderSyncBackupSet { + files: HashMap::new(), + snapshot_dir, + }; + for (index, (relative, evidence)) in backup_files.iter().enumerate() { + if evidence.sha256.len() != 64 + || !evidence.sha256.bytes().all(|byte| byte.is_ascii_hexdigit()) + { + anyhow::bail!("provider-sync backupFiles contains an invalid hash"); + } + let relative_path = validated_backup_relative_path(Path::new(relative))?; + let source = backup_dir.join(relative_path); + ensure_not_reparse_or_symlink(&source)?; + if !fs::symlink_metadata(&source)?.file_type().is_file() { + anyhow::bail!("provider-sync backupFiles contains a non-file entry"); + } + let canonical_source = fs::canonicalize(&source)?; + if !canonical_source.starts_with(&canonical_backup_dir) { + anyhow::bail!("provider-sync backup file resolves outside its backup directory"); + } + let mut source_file = File::open(&source)?; + let modified = source_file.metadata()?.modified().ok(); + let snapshot_path = prepared.snapshot_dir.join(format!("{index}.snapshot")); + let mut snapshot = OpenOptions::new() + .create_new(true) + .read(true) + .write(true) + .open(&snapshot_path)?; + let mut hasher = Sha256::new(); + let mut size = 0_u64; + let mut buffer = [0_u8; 64 * 1024]; + loop { + let read = source_file.read(&mut buffer)?; + if read == 0 { + break; + } + snapshot.write_all(&buffer[..read])?; + hasher.update(&buffer[..read]); + size += read as u64; + } + snapshot.sync_all()?; + let sha256 = format!("{:x}", hasher.finalize()); + if sha256 != evidence.sha256 || size != evidence.size { + anyhow::bail!("provider-sync backup file hash or size mismatch"); + } + snapshot.seek(SeekFrom::Start(0))?; + snapshot.lock_exclusive()?; + #[cfg(unix)] + fs::remove_file(&snapshot_path)?; + prepared.files.insert( + relative.clone(), + PreparedProviderSyncBackupFile { + file: snapshot, + modified, + evidence: evidence.clone(), + }, + ); + } + Ok(prepared) +} + +fn file_handle_sha256_and_size(file: &mut File) -> std::io::Result<(String, u64)> { + let mut hasher = Sha256::new(); + let mut size = 0_u64; + let mut buffer = [0_u8; 64 * 1024]; + loop { + let read = file.read(&mut buffer)?; + if read == 0 { + break; + } + hasher.update(&buffer[..read]); + size += read as u64; + } + Ok((format!("{:x}", hasher.finalize()), size)) +} + +fn prepare_provider_sync_target_parents( + home: &Path, + db_files: &[PathBuf], +) -> anyhow::Result> { + let mut parents = HashSet::from([home.to_path_buf()]); + for relative in db_files { + let target = home.join(relative); + let parent = target + .parent() + .ok_or_else(|| anyhow::anyhow!("backup restore target has no parent"))?; + parents.insert(parent.to_path_buf()); + } + let canonical_home = fs::canonicalize(home)?; + let mut evidence = HashMap::new(); + for parent in parents { + create_validated_directory_path(home, &parent)?; + let canonical_path = fs::canonicalize(&parent)?; + if !canonical_path.starts_with(&canonical_home) { + anyhow::bail!("provider-sync backup target resolves outside Codex home"); + } + let guard = open_directory_mutation_guard(&parent)?; + let guarded_identity = codex_plus_core::settings::file_instance_identity(&guard)?; + if fs::canonicalize(&parent)? != canonical_path + || codex_plus_core::settings::directory_instance_identity(&parent)? != guarded_identity + { + anyhow::bail!("provider-sync backup target parent changed while being guarded"); + } + evidence.insert( + parent.clone(), + ProviderSyncDirectoryEvidence { + canonical_path, + identity: guarded_identity, + guard, + }, + ); + } + Ok(evidence) +} + +fn create_validated_directory_path(home: &Path, target: &Path) -> anyhow::Result<()> { + let relative = target + .strip_prefix(home) + .map_err(|_| anyhow::anyhow!("provider-sync target parent is outside Codex home"))?; + let mut current = home.to_path_buf(); + for component in relative.components() { + let std::path::Component::Normal(name) = component else { + anyhow::bail!("provider-sync target parent contains an invalid component"); + }; + current.push(name); + if current.exists() { + ensure_not_reparse_or_symlink(¤t)?; + } else { + fs::create_dir(¤t)?; + ensure_not_reparse_or_symlink(¤t)?; + } + } + Ok(()) +} + +fn validate_provider_sync_target_parent<'a>( + home: &Path, + target: &Path, + target_parents: &'a HashMap, +) -> anyhow::Result<&'a ProviderSyncDirectoryEvidence> { + let parent = target + .parent() + .ok_or_else(|| anyhow::anyhow!("backup restore target has no parent"))?; + let expected = target_parents + .get(parent) + .ok_or_else(|| anyhow::anyhow!("provider-sync backup target parent was not recorded"))?; + ensure_path_components_not_reparse(home, parent)?; + let canonical_parent = fs::canonicalize(parent)?; + if canonical_parent != expected.canonical_path + || codex_plus_core::settings::directory_instance_identity(parent)? != expected.identity + { + anyhow::bail!("provider-sync backup target parent changed during restore"); + } + Ok(expected) +} + +#[cfg(windows)] +fn open_directory_mutation_guard(path: &Path) -> std::io::Result { + use std::os::windows::fs::OpenOptionsExt; + + const FILE_SHARE_READ: u32 = 0x0000_0001; + const FILE_SHARE_WRITE: u32 = 0x0000_0002; + const FILE_FLAG_BACKUP_SEMANTICS: u32 = 0x0200_0000; + OpenOptions::new() + .read(true) + .share_mode(FILE_SHARE_READ | FILE_SHARE_WRITE) + .custom_flags(FILE_FLAG_BACKUP_SEMANTICS) + .open(path) +} + +#[cfg(not(windows))] +fn open_directory_mutation_guard(path: &Path) -> std::io::Result { + File::open(path) +} + +fn restore_file_from_provider_sync_backup( + home: &Path, + target: &Path, + transaction_id: &str, + prepared: &mut PreparedProviderSyncBackupFile, + target_parents: &HashMap, +) -> anyhow::Result<()> { + let parent_evidence = validate_provider_sync_target_parent(home, target, target_parents)?; + if target.exists() { + ensure_not_reparse_or_symlink(target)?; + } + let parent = target + .parent() + .ok_or_else(|| anyhow::anyhow!("backup restore target has no parent"))?; + let temp = parent.join(format!( + ".{}.provider-sync-downstream-{}.tmp", + target + .file_name() + .and_then(|name| name.to_str()) + .unwrap_or("state"), + transaction_id + )); + let restore_result = (|| -> anyhow::Result<()> { + if let Err(error) = remove_provider_sync_target_file(&temp, parent_evidence) + && error.kind() != std::io::ErrorKind::NotFound + { + return Err(error.into()); + } + let mut temp_file = create_provider_sync_target_file(&temp, parent_evidence)?; + prepared.file.seek(SeekFrom::Start(0))?; + std::io::copy(&mut prepared.file, &mut temp_file)?; + temp_file.sync_all()?; + temp_file.seek(SeekFrom::Start(0))?; + let (sha256, size) = file_handle_sha256_and_size(&mut temp_file)?; + if sha256 != prepared.evidence.sha256 || size != prepared.evidence.size { + anyhow::bail!("provider-sync backup changed during restore"); + } + drop(temp_file); + let parent_evidence = validate_provider_sync_target_parent(home, target, target_parents)?; + if target.exists() { + ensure_not_reparse_or_symlink(target)?; + } + atomic_replace_provider_sync_target(&temp, target, parent_evidence)?; + restore_file_mtime(target, prepared.modified); + Ok(()) + })(); + if restore_result.is_err() { + let _ = remove_provider_sync_target_file(&temp, parent_evidence); + } + restore_result +} + +#[cfg(unix)] +fn create_provider_sync_target_file( + path: &Path, + parent: &ProviderSyncDirectoryEvidence, +) -> std::io::Result { + use std::ffi::CString; + use std::os::fd::{AsRawFd, FromRawFd}; + use std::os::unix::ffi::OsStrExt; + + let name = path.file_name().ok_or_else(|| { + std::io::Error::new(std::io::ErrorKind::InvalidInput, "missing file name") + })?; + let name = CString::new(name.as_bytes()) + .map_err(|_| std::io::Error::new(std::io::ErrorKind::InvalidInput, "invalid file name"))?; + let fd = unsafe { + libc::openat( + parent.guard.as_raw_fd(), + name.as_ptr(), + libc::O_CREAT | libc::O_EXCL | libc::O_RDWR | libc::O_CLOEXEC, + 0o600, + ) + }; + if fd < 0 { + return Err(std::io::Error::last_os_error()); + } + Ok(unsafe { File::from_raw_fd(fd) }) +} + +#[cfg(not(unix))] +fn create_provider_sync_target_file( + path: &Path, + parent: &ProviderSyncDirectoryEvidence, +) -> std::io::Result { + let _directory_guard = &parent.guard; + OpenOptions::new() + .create_new(true) + .read(true) + .write(true) + .open(path) +} + +#[cfg(unix)] +fn remove_provider_sync_target_file( + path: &Path, + parent: &ProviderSyncDirectoryEvidence, +) -> std::io::Result<()> { + use std::ffi::CString; + use std::os::fd::AsRawFd; + use std::os::unix::ffi::OsStrExt; + + let name = path.file_name().ok_or_else(|| { + std::io::Error::new(std::io::ErrorKind::InvalidInput, "missing file name") + })?; + let name = CString::new(name.as_bytes()) + .map_err(|_| std::io::Error::new(std::io::ErrorKind::InvalidInput, "invalid file name"))?; + if unsafe { libc::unlinkat(parent.guard.as_raw_fd(), name.as_ptr(), 0) } != 0 { + return Err(std::io::Error::last_os_error()); + } + Ok(()) +} + +#[cfg(not(unix))] +fn remove_provider_sync_target_file( + path: &Path, + parent: &ProviderSyncDirectoryEvidence, +) -> std::io::Result<()> { + let _directory_guard = &parent.guard; + fs::remove_file(path) +} + +#[cfg(unix)] +fn atomic_replace_provider_sync_target( + replacement: &Path, + target: &Path, + parent: &ProviderSyncDirectoryEvidence, +) -> std::io::Result<()> { + use std::ffi::CString; + use std::os::fd::AsRawFd; + use std::os::unix::ffi::OsStrExt; + + let replacement = replacement.file_name().ok_or_else(|| { + std::io::Error::new(std::io::ErrorKind::InvalidInput, "missing replacement name") + })?; + let target = target.file_name().ok_or_else(|| { + std::io::Error::new(std::io::ErrorKind::InvalidInput, "missing target name") + })?; + let replacement = CString::new(replacement.as_bytes()).map_err(|_| { + std::io::Error::new(std::io::ErrorKind::InvalidInput, "invalid replacement name") + })?; + let target = CString::new(target.as_bytes()).map_err(|_| { + std::io::Error::new(std::io::ErrorKind::InvalidInput, "invalid target name") + })?; + if unsafe { + libc::renameat( + parent.guard.as_raw_fd(), + replacement.as_ptr(), + parent.guard.as_raw_fd(), + target.as_ptr(), + ) + } != 0 + { + return Err(std::io::Error::last_os_error()); + } + Ok(()) +} + +#[cfg(not(unix))] +fn atomic_replace_provider_sync_target( + replacement: &Path, + target: &Path, + parent: &ProviderSyncDirectoryEvidence, +) -> anyhow::Result<()> { + let _directory_guard = &parent.guard; + codex_plus_core::settings::atomic_replace_file(replacement, target) +} + +fn validated_backup_relative_path(path: &Path) -> anyhow::Result { + if path.is_absolute() + || path + .components() + .any(|component| !matches!(component, std::path::Component::Normal(_))) + { + anyhow::bail!("invalid provider-sync backup path"); + } + Ok(path.to_path_buf()) +} + +fn collect_backup_relative_files(root: &Path) -> anyhow::Result> { + if !root.exists() { + return Ok(HashSet::new()); + } + let canonical_root = fs::canonicalize(root)?; + let mut files = HashSet::new(); + collect_backup_relative_files_in(root, &canonical_root, &mut files)?; + Ok(files) +} + +fn collect_backup_relative_files_in( + root: &Path, + canonical_root: &Path, + files: &mut HashSet, +) -> anyhow::Result<()> { + for entry in fs::read_dir(root)? { + let entry = entry?; + let path = entry.path(); + let file_type = entry.file_type()?; + if file_type.is_dir() { + collect_backup_relative_files_in(&path, canonical_root, files)?; + continue; + } + if !file_type.is_file() { + anyhow::bail!("provider-sync backup db directory contains a non-file entry"); + } + let canonical_path = fs::canonicalize(&path)?; + let relative = canonical_path.strip_prefix(canonical_root).map_err(|_| { + anyhow::anyhow!("provider-sync backup db file resolves outside its directory") + })?; + files.insert( + validated_backup_relative_path(relative)? + .to_string_lossy() + .replace('\\', "/"), + ); + } + Ok(()) +} + +fn read_session_transaction(backup_dir: &Path) -> anyhow::Result { + let transaction: SessionTransactionManifest = + serde_json::from_slice(&fs::read(backup_dir.join(SESSION_TRANSACTION_FILE))?)?; + if transaction.version != 1 || transaction.namespace != SESSION_TRANSACTION_NAMESPACE { + anyhow::bail!("unsupported provider-sync rollout transaction manifest"); + } + if transaction.transaction_id.len() != 32 + || !transaction + .transaction_id + .bytes() + .all(|byte| byte.is_ascii_hexdigit()) + { + anyhow::bail!("invalid provider-sync rollout transaction id"); + } + for (name, root) in &transaction.rollout_roots { + if !SESSION_DIRS.contains(&name.as_str()) + || !Path::new(&root.canonical_path).is_absolute() + || root.identity.trim().is_empty() + { + anyhow::bail!("invalid provider-sync rollout root evidence"); + } + } + for entry in &transaction.entries { + for hash in [ + Some(entry.original_sha256.as_str()), + Some(entry.next_sha256.as_str()), + Some(entry.session_meta_backup_sha256.as_str()), + entry.external_sha256.as_deref(), + ] + .into_iter() + .flatten() + { + if hash.len() != 64 || !hash.bytes().all(|byte| byte.is_ascii_hexdigit()) { + anyhow::bail!("invalid provider-sync rollout transaction hash"); + } + } + if entry.external_sha256.is_some() != entry.external_size.is_some() { + anyhow::bail!("incomplete provider-sync external restore decision"); + } + } + Ok(transaction) +} + +fn write_session_transaction( + backup_dir: &Path, + transaction: &SessionTransactionManifest, +) -> anyhow::Result<()> { + codex_plus_core::settings::atomic_write( + &backup_dir.join(SESSION_TRANSACTION_FILE), + &serde_json::to_vec_pretty(transaction)?, + ) +} + +fn rollout_relative_path(home: &Path, path: &Path) -> anyhow::Result { + let canonical_home = fs::canonicalize(home)?; + let relative = path + .strip_prefix(home) + .or_else(|_| path.strip_prefix(&canonical_home)) + .map_err(|_| anyhow::anyhow!("rollout is outside Codex home: {}", path.display()))?; + validated_rollout_relative_path(&relative.to_string_lossy().replace('\\', "/"))?; + Ok(relative.to_string_lossy().replace('\\', "/")) +} + +fn validated_rollout_relative_path(value: &str) -> anyhow::Result { + let path = PathBuf::from(value); + if path.is_absolute() + || path + .components() + .any(|component| !matches!(component, std::path::Component::Normal(_))) + { + anyhow::bail!("invalid provider-sync rollout transaction path"); + } + let Some(first) = path.components().next() else { + anyhow::bail!("empty provider-sync rollout transaction path"); + }; + let std::path::Component::Normal(first) = first else { + anyhow::bail!("invalid provider-sync rollout transaction root"); + }; + if !SESSION_DIRS + .iter() + .any(|root| first == std::ffi::OsStr::new(root)) + { + anyhow::bail!("provider-sync transaction path is outside rollout roots"); + } + Ok(path) +} + +fn session_transaction_rollout_roots( + home: &Path, +) -> anyhow::Result> { + let canonical_home = fs::canonicalize(home)?; + let mut roots = HashMap::new(); + for dirname in SESSION_DIRS { + let root = home.join(dirname); + if !root.exists() { + continue; + } + let canonical_root = fs::canonicalize(&root)?; + if !canonical_root.starts_with(&canonical_home) { + anyhow::bail!("provider-sync rollout root resolves outside Codex home"); + } + roots.insert( + dirname.to_string(), + SessionTransactionRootEvidence { + canonical_path: canonical_root.to_string_lossy().to_string(), + identity: rollout_root_identity(&root)?, + }, + ); + } + Ok(roots) +} + +fn rollout_root_identity(path: &Path) -> anyhow::Result { + ensure_not_reparse_or_symlink(path)?; + codex_plus_core::settings::directory_instance_identity(path) +} + +fn ensure_not_reparse_or_symlink(path: &Path) -> anyhow::Result<()> { + let metadata = fs::symlink_metadata(path)?; + + #[cfg(windows)] + { + use std::os::windows::fs::MetadataExt; + + const FILE_ATTRIBUTE_REPARSE_POINT: u32 = 0x400; + if metadata.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT != 0 { + anyhow::bail!( + "provider-sync path cannot be a reparse point: {}", + path.display() + ); + } + } + #[cfg(not(windows))] + if metadata.file_type().is_symlink() { + anyhow::bail!("provider-sync path cannot be a symlink: {}", path.display()); + } + Ok(()) +} + +fn ensure_path_components_not_reparse(root: &Path, target: &Path) -> anyhow::Result<()> { + let relative = target + .strip_prefix(root) + .map_err(|_| anyhow::anyhow!("provider-sync path is outside its validated root"))?; + let mut current = root.to_path_buf(); + for component in relative.components() { + let std::path::Component::Normal(name) = component else { + anyhow::bail!("provider-sync path contains an invalid component"); + }; + current.push(name); + ensure_not_reparse_or_symlink(¤t)?; + } + Ok(()) +} + +fn validated_rollout_transaction_path( + home: &Path, + value: &str, + rollout_roots: &HashMap, +) -> anyhow::Result { + let relative = validated_rollout_relative_path(value)?; + let root_name = relative + .components() + .next() + .and_then(|component| match component { + std::path::Component::Normal(name) => Some(name.to_os_string()), + _ => None, + }) + .ok_or_else(|| anyhow::anyhow!("provider-sync rollout path has no root"))?; + let root_name_text = root_name.to_string_lossy(); + let expected_root = rollout_roots + .get(root_name_text.as_ref()) + .ok_or_else(|| anyhow::anyhow!("provider-sync rollout root was not recorded"))?; + let path = home.join(&relative); + let parent = path + .parent() + .ok_or_else(|| anyhow::anyhow!("provider-sync rollout path has no parent"))?; + let canonical_home = fs::canonicalize(home)?; + let root = home.join(&root_name); + let canonical_root = fs::canonicalize(&root)?; + if !canonical_root.starts_with(&canonical_home) { + anyhow::bail!("provider-sync rollout root resolves outside Codex home"); + } + if canonical_root != PathBuf::from(&expected_root.canonical_path) + || rollout_root_identity(&root)? != expected_root.identity + { + anyhow::bail!("provider-sync rollout root identity changed after backup"); + } + let canonical_parent = fs::canonicalize(parent)?; + if !canonical_parent.starts_with(&canonical_root) { + anyhow::bail!("provider-sync rollout path resolves outside rollout roots"); + } + ensure_path_components_not_reparse(&root, parent)?; + if path.exists() && !fs::canonicalize(&path)?.starts_with(&canonical_root) { + anyhow::bail!("provider-sync rollout path resolves outside Codex home"); + } + if path.exists() { + ensure_path_components_not_reparse(&root, &path)?; + } + Ok(path) +} + +fn cleanup_transaction_staged_files(home: &Path, transaction_id: &str) -> std::io::Result<()> { + for dirname in SESSION_DIRS { + cleanup_transaction_staged_files_in(&home.join(dirname), transaction_id)?; + } + Ok(()) +} + +fn cleanup_transaction_staged_files_in(root: &Path, transaction_id: &str) -> std::io::Result<()> { + let entries = match fs::read_dir(root) { + Ok(entries) => entries, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(()), + Err(error) => return Err(error), + }; + for entry in entries { + let entry = entry?; + let path = entry.path(); + let file_type = entry.file_type()?; + if file_type.is_dir() { + cleanup_transaction_staged_files_in(&path, transaction_id)?; + continue; + } + if !file_type.is_file() { + continue; + } + let should_remove = path + .file_name() + .and_then(|name| name.to_str()) + .is_some_and(|name| provider_sync_temp_name_belongs(name, transaction_id)); + if should_remove { + fs::remove_file(path)?; + } + } + Ok(()) +} + +fn provider_sync_temp_name_belongs(name: &str, transaction_id: &str) -> bool { + let Some(body) = name + .strip_prefix('.') + .and_then(|name| name.strip_suffix(".tmp")) + else { + return false; + }; + for kind in ["", "displaced-", "rejected-"] { + let marker = format!(".provider-sync-{kind}{transaction_id}-"); + if let Some((rollout_name, index)) = body.rsplit_once(&marker) { + return !rollout_name.is_empty() + && !index.is_empty() + && index.bytes().all(|byte| byte.is_ascii_digit()); + } + } + let marker = format!(".provider-sync-restore-{transaction_id}"); + body.strip_suffix(&marker) + .is_some_and(|rollout_name| !rollout_name.is_empty()) +} + +fn file_sha256_and_size(path: &Path) -> std::io::Result<(String, u64)> { + let mut file = File::open(path)?; + let mut buffer = [0_u8; 64 * 1024]; + let mut hasher = Sha256::new(); + let mut size = 0_u64; + loop { + let read = file.read(&mut buffer)?; + if read == 0 { + break; + } + hasher.update(&buffer[..read]); + size += read as u64; + } + Ok((format!("{:x}", hasher.finalize()), size)) +} + +fn system_time_parts(time: Option) -> (Option, Option) { + let Some(time) = time else { + return (None, None); + }; + let Ok(duration) = time.duration_since(UNIX_EPOCH) else { + return (None, None); + }; + (Some(duration.as_secs()), Some(duration.subsec_nanos())) +} - file.seek(SeekFrom::Start(0))?; - file.set_len(0)?; - file.write_all(next_text.as_bytes())?; - file.flush()?; +fn restore_file_mtime_parts(path: &Path, secs: Option, nanos: Option) { + let Some(secs) = secs else { return }; + let time = UNIX_EPOCH + std::time::Duration::new(secs, nanos.unwrap_or_default()); + restore_file_mtime(path, Some(time)); +} - file.seek(SeekFrom::Start(0))?; - let mut persisted_text = String::new(); - file.read_to_string(&mut persisted_text)?; - if persisted_text != next_text { - return Err(std::io::Error::other( - "rollout changed while provider metadata was being written", - )); - } - Ok(true) +fn is_locked_io_error_from_anyhow(error: &anyhow::Error) -> bool { + error + .chain() + .find_map(|cause| cause.downcast_ref::()) + .is_some_and(is_locked_io_error) } fn open_session_file_for_update(path: &Path) -> std::io::Result { @@ -2464,9 +4789,7 @@ fn sqlite_provider_ids(path: &Path) -> anyhow::Result> { Ok(sorted_provider_ids(ids)) } -fn sqlite_provider_sync_thread_kinds( - paths: &[PathBuf], -) -> anyhow::Result { +fn sqlite_provider_sync_thread_kinds(paths: &[PathBuf]) -> anyhow::Result { let mut kinds = ProviderSyncThreadKinds::default(); for path in paths { if !path.exists() { @@ -2629,38 +4952,6 @@ fn remote_control_catalog_recovery_thread_ids( Ok(known_thread_ids) } -fn rollout_thread_provider_state(text: &str) -> Option<(String, HashSet)> { - let mut thread_id = None; - let mut providers = HashSet::new(); - for segment in text.split_inclusive('\n') { - let (line, _) = split_line_ending(segment); - let Ok(record) = serde_json::from_str::(line) else { - continue; - }; - if record.get("type").and_then(Value::as_str) != Some("session_meta") { - continue; - } - let Some(payload) = record.get("payload").and_then(Value::as_object) else { - continue; - }; - if thread_id.is_none() { - thread_id = payload - .get("id") - .and_then(Value::as_str) - .filter(|id| !id.trim().is_empty()) - .map(ToString::to_string); - } - providers.insert( - payload - .get("model_provider") - .and_then(Value::as_str) - .unwrap_or("(missing)") - .to_string(), - ); - } - thread_id.map(|thread_id| (thread_id, providers)) -} - fn provider_update_thread_ids( db: &Connection, table: &str, @@ -2697,14 +4988,9 @@ fn count_sqlite_updates( let catalog_columns = table_columns(&db, "local_thread_catalog")?; let mut total = 0; if columns.contains("id") && columns.contains("model_provider") { - total += provider_update_thread_ids( - &db, - "threads", - "id", - target_provider, - excluded_thread_ids, - )? - .len(); + total += + provider_update_thread_ids(&db, "threads", "id", target_provider, excluded_thread_ids)? + .len(); } if catalog_columns.contains("thread_id") && catalog_columns.contains("model_provider") { total += provider_update_thread_ids( @@ -2782,13 +5068,9 @@ fn apply_sqlite_update( let tx = db.transaction()?; let mut counts = SqliteUpdateCounts::default(); if columns.contains("id") && columns.contains("model_provider") { - for thread_id in provider_update_thread_ids( - &tx, - "threads", - "id", - target_provider, - excluded_thread_ids, - )? { + for thread_id in + provider_update_thread_ids(&tx, "threads", "id", target_provider, excluded_thread_ids)? + { counts.provider_rows += tx.execute( "UPDATE threads SET model_provider = ?1 WHERE id = ?2 AND COALESCE(model_provider, '') <> ?1", (target_provider, thread_id), @@ -3037,9 +5319,7 @@ fn repair_missing_local_thread_catalog_rows_filtered( update_full_sync_state: bool, ) -> anyhow::Result { let plan = collect_catalog_repair_plan(home, paths, target_provider, thread_ids)?; - if plan.threads.is_empty() - && (!update_full_sync_state || !plan.has_cleanup_candidates()) - { + if plan.threads.is_empty() && (!update_full_sync_state || !plan.has_cleanup_candidates()) { return Ok(CatalogRepairCounts::default()); } let mut total = CatalogRepairCounts::default(); @@ -3339,8 +5619,7 @@ fn collect_catalog_marked_non_root_thread_ids( if thread_source_is_user(thread_source.as_deref()) { continue; } - if source_marks_non_root_agent(&source_kind) || spawned_child_ids.contains(&thread_id) - { + if source_marks_non_root_agent(&source_kind) || spawned_child_ids.contains(&thread_id) { thread_ids_by_path .entry(path.clone()) .or_default() @@ -3364,8 +5643,7 @@ fn is_catalog_non_root_agent( if thread_source_is_user(thread.thread_source.as_deref()) { return false; } - source_marks_non_root_agent(&thread.source_kind) - || spawned_child_ids.contains(&thread.id) + source_marks_non_root_agent(&thread.source_kind) || spawned_child_ids.contains(&thread.id) } fn thread_source_is_user(thread_source: Option<&str>) -> bool { @@ -3376,8 +5654,7 @@ fn thread_source_is_user(thread_source: Option<&str>) -> bool { fn thread_source_marks_non_root(thread_source: Option<&str>) -> bool { thread_source.map(str::trim).is_some_and(|value| { - value.eq_ignore_ascii_case("subagent") - || value.eq_ignore_ascii_case("memory_consolidation") + value.eq_ignore_ascii_case("subagent") || value.eq_ignore_ascii_case("memory_consolidation") }) } @@ -3878,6 +6155,489 @@ fn now_secs() -> u64 { .as_secs() } +#[cfg(test)] +mod bounded_memory_tests { + use super::*; + use std::cell::Cell; + use std::io::{BufWriter, Write}; + use tempfile::tempdir; + + #[test] + fn collection_retains_metadata_instead_of_rollout_payloads() { + let tmp = tempdir().unwrap(); + let home = tmp.path().join(".codex"); + fs::create_dir_all(home.join("sessions")).unwrap(); + let chunk = vec![b'A'; 64 * 1024]; + for index in 0..8 { + let path = home.join(format!("sessions/rollout-{index}.jsonl")); + let mut writer = BufWriter::new(File::create(path).unwrap()); + writeln!( + writer, + "{}", + json!({ + "type": "session_meta", + "payload": { + "id": format!("thread-{index}"), + "model_provider": "openai", + "cwd": "C:/workspace" + } + }) + ) + .unwrap(); + writer + .write_all(b"{\"type\":\"event_msg\",\"payload\":{\"blob\":\"") + .unwrap(); + for _ in 0..16 { + writer.write_all(&chunk).unwrap(); + } + writer.write_all(b"\"}}\n").unwrap(); + writer.flush().unwrap(); + } + + let collected = + collect_session_changes(&home, "custom", &HashSet::new(), &HashSet::new()).unwrap(); + + assert_eq!(collected.changes.len(), 8); + let retained_bytes = collected + .changes + .iter() + .map(|change| { + change.original_sha256.len() + + change.thread_id.as_ref().map_or(0, String::len) + + change.cwd.as_ref().map_or(0, String::len) + }) + .sum::(); + assert!( + retained_bytes < 64 * 1024, + "retained {retained_bytes} bytes" + ); + } + + #[test] + fn repeated_session_meta_providers_are_retained_once() { + let tmp = tempdir().unwrap(); + let rollout = tmp.path().join("rollout-many-meta.jsonl"); + let mut writer = BufWriter::new(File::create(&rollout).unwrap()); + for index in 0..10_000 { + writeln!( + writer, + "{}", + json!({ + "type": "session_meta", + "payload": { + "id": format!("thread-{index}"), + "model_provider": "openai" + } + }) + ) + .unwrap(); + } + writer.flush().unwrap(); + + let rewrite = scan_rollout_session_meta_providers( + &rollout, + "custom", + &SessionRewriteMode::AllProviders, + ) + .unwrap(); + + assert_eq!(rewrite.session_meta_count, 10_000); + assert_eq!(rewrite.providers, HashSet::from(["openai".to_string()])); + } + + #[test] + fn unchanged_rollout_reuses_committed_scan_state_without_reading_the_body() { + let tmp = tempdir().unwrap(); + let home = tmp.path().join(".codex"); + let rollout = home.join("sessions/rollout-cached.jsonl"); + fs::create_dir_all(rollout.parent().unwrap()).unwrap(); + fs::write( + &rollout, + format!( + "{}\n{}\n", + json!({ + "type": "session_meta", + "payload": { + "id": "thread-cached", + "model_provider": "openai", + "cwd": "C:/cached" + } + }), + json!({"type": "event_msg", "payload": {"type": "user_message"}}) + ), + ) + .unwrap(); + let (_, _, state) = scan_rollout_for_provider_sync_state( + &home, + &rollout, + "openai", + &SessionRewriteMode::AllProviders, + ) + .unwrap(); + let state = state.unwrap(); + persist_provider_sync_scan_state(&home, std::slice::from_ref(&state)).unwrap(); + + let collected = collect_session_changes_with_scanner( + &home, + "openai", + &HashSet::new(), + &HashSet::new(), + load_provider_sync_scan_state(&home).unwrap(), + |_, _, _, _| panic!("unchanged rollout body should not be opened"), + ) + .unwrap(); + + assert_eq!(collected.changes.len(), 1); + assert!(!collected.changes[0].rewrite_needed); + assert!(collected.changes[0].has_user_event); + assert_eq!(collected.changes[0].cwd.as_deref(), Some("C:/cached")); + assert_eq!(collected.scan_state_entries, vec![state]); + } + + #[test] + fn changed_target_or_missing_mtime_forces_a_full_rollout_scan() { + let tmp = tempdir().unwrap(); + let home = tmp.path().join(".codex"); + let rollout = home.join("sessions/rollout-rescan.jsonl"); + fs::create_dir_all(rollout.parent().unwrap()).unwrap(); + fs::write( + &rollout, + format!( + "{}\n", + json!({ + "type": "session_meta", + "payload": { + "id": "thread-rescan", + "model_provider": "openai" + } + }) + ), + ) + .unwrap(); + let (_, _, state) = scan_rollout_for_provider_sync_state( + &home, + &rollout, + "openai", + &SessionRewriteMode::AllProviders, + ) + .unwrap(); + let state = state.unwrap(); + let scans = Cell::new(0); + let collected = collect_session_changes_with_scanner( + &home, + "custom", + &HashSet::new(), + &HashSet::new(), + HashMap::from([(state.relative_path.clone(), state.clone())]), + |home, path, target, mode| { + scans.set(scans.get() + 1); + scan_rollout_for_provider_sync_state(home, path, target, mode) + }, + ) + .unwrap(); + assert_eq!(scans.get(), 1); + assert!(collected.changes[0].rewrite_needed); + + let mut legacy = state; + legacy.modified_secs = None; + legacy.modified_nanos = None; + let scans = Cell::new(0); + collect_session_changes_with_scanner( + &home, + "openai", + &HashSet::new(), + &HashSet::new(), + HashMap::from([(legacy.relative_path.clone(), legacy)]), + |home, path, target, mode| { + scans.set(scans.get() + 1); + scan_rollout_for_provider_sync_state(home, path, target, mode) + }, + ) + .unwrap(); + assert_eq!(scans.get(), 1); + } + + #[test] + fn same_path_size_and_mtime_still_reject_a_replaced_file_instance() { + let tmp = tempdir().unwrap(); + let home = tmp.path().join(".codex"); + let rollout = home.join("sessions/rollout-replaced.jsonl"); + fs::create_dir_all(rollout.parent().unwrap()).unwrap(); + let bytes = format!( + "{}\n", + json!({ + "type": "session_meta", + "payload": {"id": "thread-replaced", "model_provider": "openai"} + }) + ); + fs::write(&rollout, &bytes).unwrap(); + let (_, _, state) = scan_rollout_for_provider_sync_state( + &home, + &rollout, + "openai", + &SessionRewriteMode::AllProviders, + ) + .unwrap(); + let state = state.unwrap(); + let original_mtime = fs::metadata(&rollout).unwrap().modified().unwrap(); + let original_identity = state.file_identity.clone(); + let replacement = rollout.with_extension("replacement"); + fs::write(&replacement, &bytes).unwrap(); + fs::File::options() + .write(true) + .open(&replacement) + .unwrap() + .set_times(std::fs::FileTimes::new().set_modified(original_mtime)) + .unwrap(); + fs::remove_file(&rollout).unwrap(); + fs::rename(&replacement, &rollout).unwrap(); + let replacement_file = File::open(&rollout).unwrap(); + assert_ne!( + codex_plus_core::settings::file_instance_identity(&replacement_file).unwrap(), + original_identity + ); + assert_eq!(fs::metadata(&rollout).unwrap().len(), state.size); + assert_eq!( + system_time_parts(fs::metadata(&rollout).unwrap().modified().ok()), + (state.modified_secs, state.modified_nanos) + ); + assert!(!provider_sync_scan_state_matches(&rollout, &state)); + } + + #[test] + fn scan_rule_digest_or_corrupt_state_invalidates_the_entire_cache() { + let tmp = tempdir().unwrap(); + let home = tmp.path().join(".codex"); + let path = provider_sync_scan_state_path(&home); + fs::create_dir_all(path.parent().unwrap()).unwrap(); + fs::write( + &path, + serde_json::to_vec_pretty(&ProviderSyncRolloutScanStateManifest { + version: PROVIDER_SYNC_SCAN_STATE_VERSION, + namespace: PROVIDER_SYNC_SCAN_STATE_NAMESPACE.to_string(), + rules_sha256: "0".repeat(64), + rollout_roots: session_transaction_rollout_roots(&home).unwrap(), + entries: Vec::new(), + }) + .unwrap(), + ) + .unwrap(); + assert!(load_provider_sync_scan_state(&home).is_err()); + + fs::write(&path, b"not-json").unwrap(); + assert!(load_provider_sync_scan_state(&home).is_err()); + assert!(load_provider_sync_scan_state_best_effort(&home).is_empty()); + + let oversized = File::create(&path).unwrap(); + oversized + .set_len(PROVIDER_SYNC_SCAN_STATE_MAX_BYTES + 1) + .unwrap(); + drop(oversized); + assert!(load_provider_sync_scan_state(&home).is_err()); + } + + #[test] + fn replacing_a_rollout_root_invalidates_the_entire_cache() { + let tmp = tempdir().unwrap(); + let home = tmp.path().join(".codex"); + let sessions = home.join("sessions"); + let rollout = sessions.join("rollout-root.jsonl"); + fs::create_dir_all(&sessions).unwrap(); + fs::write( + &rollout, + format!( + "{}\n", + json!({ + "type": "session_meta", + "payload": {"id": "thread-root", "model_provider": "openai"} + }) + ), + ) + .unwrap(); + let (_, _, state) = scan_rollout_for_provider_sync_state( + &home, + &rollout, + "openai", + &SessionRewriteMode::AllProviders, + ) + .unwrap(); + persist_provider_sync_scan_state(&home, &[state.unwrap()]).unwrap(); + + fs::rename(&sessions, home.join("sessions-old")).unwrap(); + fs::create_dir(&sessions).unwrap(); + + assert!(load_provider_sync_scan_state(&home).is_err()); + } + + #[cfg(unix)] + #[test] + fn scan_state_parent_creation_rejects_a_symlink_before_touching_its_target() { + use std::os::unix::fs::symlink; + + let tmp = tempdir().unwrap(); + let home = tmp.path().join(".codex"); + let outside = tmp.path().join("outside"); + fs::create_dir(&home).unwrap(); + fs::create_dir(&outside).unwrap(); + symlink(&outside, home.join("backups_state")).unwrap(); + + assert!(persist_provider_sync_scan_state(&home, &[]).is_err()); + assert!(!outside.join("provider-sync").exists()); + } + + #[cfg(windows)] + #[test] + fn scan_state_parent_creation_rejects_a_junction_before_touching_its_target() { + use std::process::Command; + + let tmp = tempdir().unwrap(); + let home = tmp.path().join(".codex"); + let outside = tmp.path().join("outside"); + fs::create_dir(&home).unwrap(); + fs::create_dir(&outside).unwrap(); + let link = home.join("backups_state"); + let output = Command::new("powershell") + .args([ + "-NoProfile", + "-NonInteractive", + "-Command", + "New-Item -ItemType Junction -Path $env:CODEXPP_TEST_LINK -Target $env:CODEXPP_TEST_TARGET | Out-Null", + ]) + .env("CODEXPP_TEST_LINK", &link) + .env("CODEXPP_TEST_TARGET", &outside) + .output() + .unwrap(); + assert!( + output.status.success(), + "junction creation failed: {}", + String::from_utf8_lossy(&output.stderr) + ); + + assert!(persist_provider_sync_scan_state(&home, &[]).is_err()); + assert!(!outside.join("provider-sync").exists()); + fs::remove_dir(link).unwrap(); + } + + #[test] + fn prepared_backup_handle_keeps_the_verified_file_instance() { + let tmp = tempdir().unwrap(); + let backup_dir = tmp.path().join("backup"); + let source = backup_dir.join("db/state_5.sqlite"); + fs::create_dir_all(source.parent().unwrap()).unwrap(); + fs::write(&source, b"verified-backup").unwrap(); + let (sha256, size) = file_sha256_and_size(&source).unwrap(); + let evidence = HashMap::from([( + "db/state_5.sqlite".to_string(), + ProviderSyncBackupFileEvidence { size, sha256 }, + )]); + let mut prepared = prepare_provider_sync_backup_files( + &backup_dir, + &evidence, + "0123456789abcdef0123456789abcdef", + ) + .unwrap(); + + fs::write(&source, b"replacement-path-content").unwrap(); + let prepared = prepared.files.get_mut("db/state_5.sqlite").unwrap(); + prepared.file.seek(SeekFrom::Start(0)).unwrap(); + let mut bytes = Vec::new(); + prepared.file.read_to_end(&mut bytes).unwrap(); + + assert_eq!(bytes, b"verified-backup"); + assert_eq!(fs::read(source).unwrap(), b"replacement-path-content"); + } + + #[cfg(unix)] + #[test] + fn rollout_discovery_rejects_nested_symlinks() { + use std::os::unix::fs::symlink; + + let tmp = tempdir().unwrap(); + let home = tmp.path().join(".codex"); + let outside = tmp.path().join("outside"); + fs::create_dir_all(home.join("sessions")).unwrap(); + fs::create_dir(&outside).unwrap(); + symlink(&outside, home.join("sessions/link")).unwrap(); + + let error = rollout_files(&home).unwrap_err(); + + assert!(error.to_string().contains("symlink")); + } + + #[cfg(windows)] + #[test] + fn rollout_discovery_rejects_nested_junctions() { + use std::process::Command; + + let tmp = tempdir().unwrap(); + let home = tmp.path().join(".codex"); + let outside = tmp.path().join("outside"); + fs::create_dir_all(home.join("sessions")).unwrap(); + fs::create_dir(&outside).unwrap(); + let link = home.join("sessions/link"); + let output = Command::new("powershell") + .args([ + "-NoProfile", + "-NonInteractive", + "-Command", + "New-Item -ItemType Junction -Path $env:CODEXPP_TEST_LINK -Target $env:CODEXPP_TEST_TARGET | Out-Null", + ]) + .env("CODEXPP_TEST_LINK", &link) + .env("CODEXPP_TEST_TARGET", &outside) + .output() + .unwrap(); + assert!( + output.status.success(), + "junction creation failed: {}", + String::from_utf8_lossy(&output.stderr) + ); + + let error = rollout_files(&home).unwrap_err(); + + assert!(error.to_string().contains("reparse point")); + fs::remove_dir(link).unwrap(); + } + + #[cfg(windows)] + #[test] + fn downstream_directory_guard_blocks_parent_replacement() { + let tmp = tempdir().unwrap(); + let home = tmp.path().join(".codex"); + let moved = tmp.path().join("moved-home"); + fs::create_dir(&home).unwrap(); + let guards = prepare_provider_sync_target_parents(&home, &[]).unwrap(); + + assert!(fs::rename(&home, &moved).is_err()); + drop(guards); + fs::rename(&home, &moved).unwrap(); + } + + #[test] + fn orphan_cleanup_requires_an_exact_transaction_temp_name() { + let transaction_id = "0123456789abcdef0123456789abcdef"; + assert!(provider_sync_temp_name_belongs( + ".rollout-a.jsonl.provider-sync-0123456789abcdef0123456789abcdef-4.tmp", + transaction_id + )); + assert!(provider_sync_temp_name_belongs( + ".rollout-a.jsonl.provider-sync-displaced-0123456789abcdef0123456789abcdef-4.tmp", + transaction_id + )); + assert!(provider_sync_temp_name_belongs( + ".rollout-a.jsonl.provider-sync-restore-0123456789abcdef0123456789abcdef.tmp", + transaction_id + )); + assert!(!provider_sync_temp_name_belongs( + ".user.provider-sync-note-0123456789abcdef0123456789abcdef.tmp", + transaction_id + )); + assert!(!provider_sync_temp_name_belongs( + ".rollout-a.jsonl.provider-sync-0123456789abcdef0123456789abcdef-x.tmp", + transaction_id + )); + } +} + #[cfg(test)] mod non_root_agent_tests { use super::*; @@ -3888,7 +6648,9 @@ mod non_root_agent_tests { #[test] fn structured_subagent_markers_still_identify_child_threads() { - assert!(marks_non_root(r#"{"subagent":{"thread_spawn":{"depth":1}}}"#)); + assert!(marks_non_root( + r#"{"subagent":{"thread_spawn":{"depth":1}}}"# + )); assert!(marks_non_root(r#"{"sub_agent":{"other":"review"}}"#)); assert!(marks_non_root(r#"{"internal":true}"#)); } @@ -3921,17 +6683,28 @@ mod non_root_agent_tests { #[cfg(test)] mod lock_state_tests { use super::*; + use codex_plus_core::watcher::ProcessInstanceState; fn owner(pid: u32) -> ProviderSyncLockOwner { ProviderSyncLockOwner { pid, started_at: 1234, + process_started_at: Some(1200), + process_birth_id: Some("birth-1200".to_string()), + lock_id: Some("lock-1".to_string()), + } + } + + fn running(started_at_secs: Option) -> ProcessInstanceState { + ProcessInstanceState::Running { + started_at_secs, + birth_id: started_at_secs.map(|started_at| format!("birth-{started_at}")), } } #[test] fn live_owner_counts_as_held() { - let state = classify_lock(Some(&owner(42)), Some(0), |_| Some(true)); + let state = classify_lock(Some(&owner(42)), Some(0), |_| running(Some(1200))); assert_eq!( state, @@ -3944,14 +6717,112 @@ mod lock_state_tests { #[test] fn dead_owner_counts_as_stale() { - let state = classify_lock(Some(&owner(42)), Some(0), |_| Some(false)); + let state = classify_lock(Some(&owner(42)), Some(0), |_| { + ProcessInstanceState::NotRunning + }); + + assert_eq!(state, ProviderSyncLockState::Stale { pid: Some(42) }); + } + + #[test] + fn reused_pid_with_a_different_process_start_is_stale() { + let state = classify_lock(Some(&owner(42)), Some(9_999), |_| running(Some(5000))); + + assert_eq!(state, ProviderSyncLockState::Stale { pid: Some(42) }); + } + + #[test] + fn matching_birth_id_tolerates_approximate_unix_start_time_drift() { + let state = classify_lock(Some(&owner(42)), Some(0), |_| { + ProcessInstanceState::Running { + started_at_secs: Some(1201), + birth_id: Some("birth-1200".to_string()), + } + }); + + assert_eq!( + state, + ProviderSyncLockState::Held { + pid: 42, + started_at: 1234 + } + ); + } + + #[test] + fn legacy_owner_with_a_much_newer_process_is_stale() { + let legacy_owner = ProviderSyncLockOwner { + process_started_at: None, + process_birth_id: None, + lock_id: None, + ..owner(42) + }; + let state = classify_lock( + Some(&legacy_owner), + Some(LEGACY_PID_REUSE_MIN_LOCK_AGE_SECS), + |_| { + running(Some( + legacy_owner.started_at + LEGACY_PID_REUSE_TOLERANCE_SECS + 1, + )) + }, + ); assert_eq!(state, ProviderSyncLockState::Stale { pid: Some(42) }); } #[test] - fn unknown_liveness_is_treated_as_held_rather_than_stolen() { - let state = classify_lock(Some(&owner(42)), Some(9_999), |_| None); + fn legacy_owner_keeps_a_process_started_before_the_lock() { + let legacy_owner = ProviderSyncLockOwner { + process_started_at: None, + process_birth_id: None, + lock_id: None, + ..owner(42) + }; + let state = classify_lock(Some(&legacy_owner), Some(9_999), |_| { + running(Some(legacy_owner.started_at - 1)) + }); + + assert_eq!( + state, + ProviderSyncLockState::Held { + pid: 42, + started_at: 1234 + } + ); + } + + #[test] + fn recent_legacy_lock_remains_held_even_if_wall_clock_evidence_looks_newer() { + let legacy_owner = ProviderSyncLockOwner { + process_started_at: None, + process_birth_id: None, + lock_id: None, + ..owner(42) + }; + let state = classify_lock( + Some(&legacy_owner), + Some(LEGACY_PID_REUSE_MIN_LOCK_AGE_SECS - 1), + |_| { + running(Some( + legacy_owner.started_at + LEGACY_PID_REUSE_TOLERANCE_SECS + 1, + )) + }, + ); + + assert_eq!( + state, + ProviderSyncLockState::Held { + pid: 42, + started_at: 1234 + } + ); + } + + #[test] + fn unknown_process_identity_is_treated_as_held_rather_than_stolen() { + let state = classify_lock(Some(&owner(42)), Some(9_999), |_| { + ProcessInstanceState::Unknown + }); assert_eq!( state, @@ -3964,22 +6835,121 @@ mod lock_state_tests { #[test] fn aged_lock_without_owner_is_recoverable_interrupted_leftover() { - let state = classify_lock(None, Some(LOCK_INTERRUPTED_GRACE_SECS), |_| Some(true)); + let state = classify_lock(None, Some(LOCK_INTERRUPTED_GRACE_SECS), |_| { + running(Some(1200)) + }); assert_eq!(state, ProviderSyncLockState::Stale { pid: None }); } #[test] fn fresh_lock_without_owner_is_left_alone_for_the_process_still_creating_it() { - let state = classify_lock(None, Some(LOCK_INTERRUPTED_GRACE_SECS - 1), |_| Some(true)); + let state = classify_lock(None, Some(LOCK_INTERRUPTED_GRACE_SECS - 1), |_| { + running(Some(1200)) + }); assert_eq!(state, ProviderSyncLockState::Indeterminate); } #[test] fn unreadable_lock_age_is_left_alone() { - let state = classify_lock(None, None, |_| Some(true)); + let state = classify_lock(None, None, |_| running(Some(1200))); assert_eq!(state, ProviderSyncLockState::Indeterminate); } + + #[test] + fn legacy_owner_json_remains_compatible() { + let owner: ProviderSyncLockOwner = + serde_json::from_str(r#"{"pid":42,"startedAt":1234}"#).unwrap(); + + assert_eq!(owner.pid, 42); + assert_eq!(owner.started_at, 1234); + assert_eq!(owner.process_started_at, None); + assert_eq!(owner.process_birth_id, None); + assert_eq!(owner.lock_id, None); + } + + #[test] + fn lifecycle_guard_serializes_and_releases_the_legacy_directory() { + let temp = tempfile::tempdir().unwrap(); + let lock_dir = temp.path().join("tmp/provider-sync.lock"); + let first = acquire_lock_inner(&lock_dir, false).unwrap(); + + assert!(lock_dir.join("owner.json").is_file()); + let error = acquire_lock_inner(&lock_dir, false).unwrap_err(); + assert!( + matches!( + error.kind(), + std::io::ErrorKind::AlreadyExists | std::io::ErrorKind::WouldBlock + ), + "unexpected lock contention error: {error:?}; raw={:?}", + error.raw_os_error() + ); + + drop(first); + assert!(!lock_dir.exists()); + let second = acquire_lock_inner(&lock_dir, false).unwrap(); + drop(second); + assert!(!lock_dir.exists()); + } + + #[test] + fn a_guard_cannot_remove_a_directory_owned_by_another_lock_id() { + let temp = tempfile::tempdir().unwrap(); + let lock_dir = temp.path().join("tmp/provider-sync.lock"); + let guard = acquire_lock_inner(&lock_dir, false).unwrap(); + + assert!(!release_owned_lock(&lock_dir, "not-the-owner").unwrap()); + assert!(lock_dir.join("owner.json").is_file()); + + drop(guard); + assert!(!lock_dir.exists()); + } + + #[test] + fn explicit_release_rejects_changed_directory_ownership() { + let temp = tempfile::tempdir().unwrap(); + let lock_dir = temp.path().join("tmp/provider-sync.lock"); + let guard = acquire_lock_inner(&lock_dir, false).unwrap(); + fs::write( + lock_dir.join("owner.json"), + json!({ + "pid": std::process::id(), + "startedAt": now_secs(), + "lockId": "replacement-owner", + }) + .to_string(), + ) + .unwrap(); + + let error = guard.release().unwrap_err(); + + assert_eq!(error.kind(), std::io::ErrorKind::Other); + assert!(lock_dir.exists()); + } + + #[test] + fn os_lock_authoritatively_recovers_an_orphaned_new_protocol_directory() { + let temp = tempfile::tempdir().unwrap(); + let lock_dir = temp.path().join("tmp/provider-sync.lock"); + let guard = acquire_lock_inner(&lock_dir, false).unwrap(); + fs::write( + lock_dir.join("owner.json"), + json!({ + "pid": std::process::id(), + "startedAt": now_secs(), + "lockId": "orphaned-owner", + }) + .to_string(), + ) + .unwrap(); + drop(guard); + assert!(lock_dir.exists()); + + let recovered = acquire_lock_inner(&lock_dir, false).unwrap(); + recovered.release().unwrap(); + + assert!(!lock_dir.exists()); + } } diff --git a/crates/codex-plus-data/tests/provider_sync.rs b/crates/codex-plus-data/tests/provider_sync.rs index b61f9e567..a1ec29dec 100644 --- a/crates/codex-plus-data/tests/provider_sync.rs +++ b/crates/codex-plus-data/tests/provider_sync.rs @@ -6,10 +6,13 @@ use codex_plus_data::{ run_remote_control_session_catalog_recovery_for_thread_with_target, run_remote_control_session_finalization_for_thread_with_target, }; +use fs2::FileExt; use rusqlite::Connection; use serde_json::json; +use sha2::{Digest, Sha256}; use std::ffi::OsString; use std::fs; +use std::io::{BufWriter, Write}; use std::path::Path; use std::sync::Mutex; use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; @@ -17,6 +20,61 @@ use tempfile::tempdir; static CODEX_HOME_ENV_LOCK: Mutex<()> = Mutex::new(()); +#[cfg(windows)] +fn current_process_peak_working_set_bytes() -> Option { + use std::ffi::c_void; + + #[repr(C)] + struct ProcessMemoryCounters { + cb: u32, + page_fault_count: u32, + peak_working_set_size: usize, + working_set_size: usize, + quota_peak_paged_pool_usage: usize, + quota_paged_pool_usage: usize, + quota_peak_non_paged_pool_usage: usize, + quota_non_paged_pool_usage: usize, + pagefile_usage: usize, + peak_pagefile_usage: usize, + } + + #[link(name = "kernel32")] + unsafe extern "system" { + fn GetCurrentProcess() -> *mut c_void; + fn K32GetProcessMemoryInfo( + process: *mut c_void, + counters: *mut ProcessMemoryCounters, + size: u32, + ) -> i32; + } + + let mut counters = ProcessMemoryCounters { + cb: std::mem::size_of::() as u32, + page_fault_count: 0, + peak_working_set_size: 0, + working_set_size: 0, + quota_peak_paged_pool_usage: 0, + quota_paged_pool_usage: 0, + quota_peak_non_paged_pool_usage: 0, + quota_non_paged_pool_usage: 0, + pagefile_usage: 0, + peak_pagefile_usage: 0, + }; + let success = unsafe { + K32GetProcessMemoryInfo( + GetCurrentProcess(), + &mut counters, + std::mem::size_of::() as u32, + ) + }; + (success != 0).then_some(counters.peak_working_set_size as u64) +} + +#[cfg(not(windows))] +fn current_process_peak_working_set_bytes() -> Option { + None +} + struct CodexHomeEnvGuard { previous: Option, } @@ -56,6 +114,37 @@ fn write_rollout(path: &Path, provider: &str, thread_id: &str, cwd: &str) { fs::write(path, format!("{first}\n{event}\n")).unwrap(); } +fn write_large_rollout(path: &Path, provider: &str, thread_id: &str, payload_bytes: usize) { + fs::create_dir_all(path.parent().unwrap()).unwrap(); + let file = fs::File::create(path).unwrap(); + let mut writer = BufWriter::new(file); + writeln!( + writer, + "{}", + json!({ + "type": "session_meta", + "payload": { + "id": thread_id, + "model_provider": provider, + "cwd": "C:/workspace" + } + }) + ) + .unwrap(); + writer + .write_all(b"{\"type\":\"event_msg\",\"payload\":{\"blob\":\"PAYLOAD_SENTINEL_") + .unwrap(); + let chunk = vec![b'A'; 64 * 1024]; + let mut remaining = payload_bytes; + while remaining > 0 { + let write = remaining.min(chunk.len()); + writer.write_all(&chunk[..write]).unwrap(); + remaining -= write; + } + writer.write_all(b"\"}}\n").unwrap(); + writer.flush().unwrap(); +} + fn write_catalog_rollout(path: &Path) { fs::create_dir_all(path.parent().unwrap()).unwrap(); fs::write(path, "").unwrap(); @@ -1901,6 +1990,10 @@ fn remote_control_finalization_uses_only_recorded_rollout_and_preserves_full_syn let other_rollout = home.join("sessions/rollout-other.jsonl"); write_rollout(&target_rollout, "openai", "mobile", "C:/workspace"); write_rollout(&other_rollout, "openai", "other", "C:/workspace"); + let seeded = run_provider_sync_with_target(Some(&home), Some("openai")); + assert_eq!(seeded.status, ProviderSyncStatus::Synced, "{}", seeded.message); + let scan_state_path = home.join("backups_state/provider-sync/rollout-scan-state.json"); + assert!(scan_state_path.is_file()); create_remote_control_state_db( &home.join("state_5.sqlite"), &[ @@ -1936,6 +2029,7 @@ fn remote_control_finalization_uses_only_recorded_rollout_and_preserves_full_syn assert_eq!(result.status, ProviderSyncStatus::Synced); assert_eq!(result.changed_session_files, 1); assert_eq!(result.sqlite_catalog_rows_inserted, 1); + assert!(!scan_state_path.exists()); let target_first: serde_json::Value = serde_json::from_str( fs::read_to_string(&target_rollout) .unwrap() @@ -2464,6 +2558,10 @@ fn provider_sync_restores_rollout_first_line_when_later_step_fails() { fs::write(home.join("config.toml"), "model_provider = \"apigather\"\n").unwrap(); let rollout = home.join("sessions/rollout-needs-rewrite.jsonl"); write_rollout(&rollout, "openai", "thread-1", "C:/workspace"); + let seeded = run_provider_sync_with_target(Some(&home), Some("openai")); + assert_eq!(seeded.status, ProviderSyncStatus::Synced, "{}", seeded.message); + let scan_state_path = home.join("backups_state/provider-sync/rollout-scan-state.json"); + assert!(scan_state_path.is_file()); let original_first_line = fs::read_to_string(&rollout) .unwrap() .lines() @@ -2499,6 +2597,7 @@ fn provider_sync_restores_rollout_first_line_when_later_step_fails() { .unwrap() .to_string(); assert_eq!(restored_first_line, original_first_line); + assert!(!scan_state_path.exists()); } #[test] @@ -2651,7 +2750,7 @@ fn provider_sync_recovers_lock_owned_by_dead_process() { .unwrap() .contains("provider_sync.stale_lock_recovered") ); - assert!(fs::read_dir(home.join("tmp")).unwrap().next().is_none()); + assert!(home.join("tmp/provider-sync.lifecycle.lock").is_file()); } #[test] @@ -2723,6 +2822,740 @@ fn provider_sync_preserves_rollout_mtime() { ); } +#[test] +fn provider_sync_commits_scan_state_for_the_rewritten_file_instance() { + 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(); + let rollout = home.join("sessions/rollout-state.jsonl"); + write_rollout(&rollout, "openai", "thread-state", "C:/workspace"); + + let first = run_provider_sync(Some(&home)); + + assert_eq!(first.status, ProviderSyncStatus::Synced, "{}", first.message); + assert_eq!(first.changed_session_files, 1); + let state_path = home.join("backups_state/provider-sync/rollout-scan-state.json"); + let state: serde_json::Value = + serde_json::from_slice(&fs::read(&state_path).unwrap()).unwrap(); + let entries = state["entries"].as_array().unwrap(); + assert_eq!(entries.len(), 1); + assert_eq!(entries[0]["providers"], json!(["custom"])); + assert!(entries[0]["fileIdentity"].as_str().unwrap().contains(':')); + let bytes = fs::read(&rollout).unwrap(); + assert_eq!(entries[0]["size"], bytes.len() as u64); + assert_eq!( + entries[0]["sha256"], + format!("{:x}", Sha256::digest(&bytes)) + ); + + let second = run_provider_sync(Some(&home)); + + assert_eq!(second.status, ProviderSyncStatus::Synced, "{}", second.message); + assert_eq!(second.changed_session_files, 0); + assert!(second.backup_dir.is_none()); + assert!(second.message.contains("already up to date")); +} + +#[test] +fn provider_sync_cache_write_failure_does_not_fail_an_up_to_date_sync() { + let tmp = tempdir().unwrap(); + let home = tmp.path().join(".codex"); + fs::create_dir_all(home.join("sessions")).unwrap(); + fs::write(home.join("config.toml"), "model_provider = \"openai\"\n").unwrap(); + write_rollout( + &home.join("sessions/rollout-cache-write.jsonl"), + "openai", + "thread-cache-write", + "C:/workspace", + ); + fs::create_dir_all(home.join("backups_state")).unwrap(); + fs::write(home.join("backups_state/provider-sync"), b"block-cache-directory").unwrap(); + + let result = run_provider_sync(Some(&home)); + + assert_eq!(result.status, ProviderSyncStatus::Synced, "{}", result.message); + assert_eq!(result.changed_session_files, 0); + assert!(result.message.contains("already up to date")); +} + +#[test] +fn provider_sync_refuses_rollout_mutation_when_scan_state_cannot_be_invalidated() { + 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(); + let rollout = home.join("sessions/rollout-invalidation.jsonl"); + write_rollout( + &rollout, + "openai", + "thread-invalidation", + "C:/workspace", + ); + let scan_state_path = home.join("backups_state/provider-sync/rollout-scan-state.json"); + fs::create_dir_all(&scan_state_path).unwrap(); + + let result = run_provider_sync(Some(&home)); + + assert_eq!(result.status, ProviderSyncStatus::Skipped); + let first: serde_json::Value = serde_json::from_str( + fs::read_to_string(&rollout).unwrap().lines().next().unwrap(), + ) + .unwrap(); + assert_eq!(first["payload"]["model_provider"], "openai"); + let backup_root = home.join("backups_state/provider-sync"); + let transaction = fs::read_dir(&backup_root) + .unwrap() + .filter_map(Result::ok) + .map(|entry| entry.path().join("session-transaction.json")) + .find(|path| path.is_file()) + .unwrap(); + let transaction: serde_json::Value = + serde_json::from_slice(&fs::read(transaction).unwrap()).unwrap(); + assert_eq!(transaction["status"], "rolled_back"); +} + +#[test] +fn provider_sync_streams_large_payloads_without_copying_them_into_the_journal() { + 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(); + for index in 0..6 { + write_large_rollout( + &home.join(format!("sessions/rollout-large-{index}.jsonl")), + "openai", + &format!("large-{index}"), + 2 * 1024 * 1024, + ); + } + + let result = run_provider_sync(Some(&home)); + + assert_eq!( + result.status, + ProviderSyncStatus::Synced, + "{}", + result.message + ); + assert_eq!(result.changed_session_files, 6); + let backup_dir = result.backup_dir.unwrap(); + let transaction = fs::read(backup_dir.join("session-transaction.json")).unwrap(); + let session_meta = fs::read(backup_dir.join("session-meta-backup.json")).unwrap(); + assert!(transaction.len() < 64 * 1024); + assert!(session_meta.len() < 64 * 1024); + assert!( + !transaction + .windows(16) + .any(|window| window == b"PAYLOAD_SENTINEL") + ); + assert!( + !session_meta + .windows(16) + .any(|window| window == b"PAYLOAD_SENTINEL") + ); + let session_meta_manifest: serde_json::Value = serde_json::from_slice(&session_meta).unwrap(); + let entries = session_meta_manifest.as_array().unwrap(); + assert_eq!(entries.len(), 6); + for entry in entries { + let relative = entry["sessionMetaBackup"].as_str().unwrap(); + assert!(relative.ends_with(".jsonl")); + let backup = fs::read(backup_dir.join(relative)).unwrap(); + assert!(backup.len() < 64 * 1024); + assert!( + !backup + .windows(16) + .any(|window| window == b"PAYLOAD_SENTINEL") + ); + } +} + +#[test] +fn provider_sync_manifest_indexes_only_rollouts_that_enter_the_transaction() { + 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(); + let locked_rollout = home.join("sessions/rollout-a-locked.jsonl"); + let applied_rollout = home.join("sessions/rollout-b-applied.jsonl"); + write_rollout(&locked_rollout, "openai", "locked", "C:/locked"); + write_rollout(&applied_rollout, "openai", "applied", "C:/applied"); + let locked = fs::OpenOptions::new() + .read(true) + .write(true) + .open(&locked_rollout) + .unwrap(); + locked.lock_exclusive().unwrap(); + + let result = run_provider_sync(Some(&home)); + + locked.unlock().unwrap(); + assert_eq!(result.status, ProviderSyncStatus::Synced, "{}", result.message); + assert_eq!(result.changed_session_files, 1); + assert_eq!(result.skipped_locked_rollout_files, vec![locked_rollout]); + let backup_dir = result.backup_dir.unwrap(); + let transaction: serde_json::Value = serde_json::from_slice( + &fs::read(backup_dir.join("session-transaction.json")).unwrap(), + ) + .unwrap(); + assert_eq!(transaction["entries"].as_array().unwrap().len(), 1); + assert!(transaction["entries"][0]["relativePath"] + .as_str() + .unwrap() + .ends_with("rollout-b-applied.jsonl")); + let manifest: serde_json::Value = serde_json::from_slice( + &fs::read(backup_dir.join("session-meta-backup.json")).unwrap(), + ) + .unwrap(); + assert_eq!(manifest.as_array().unwrap().len(), 1); + assert!(manifest[0]["path"] + .as_str() + .unwrap() + .ends_with("rollout-b-applied.jsonl")); + assert_eq!(manifest[0]["sessionMetaBackup"], "session-meta/0.jsonl"); + let scan_state: serde_json::Value = serde_json::from_slice( + &fs::read(home.join("backups_state/provider-sync/rollout-scan-state.json")).unwrap(), + ) + .unwrap(); + let entries = scan_state["entries"].as_array().unwrap(); + assert_eq!(entries.len(), 1); + assert!( + entries[0]["relativePath"] + .as_str() + .unwrap() + .ends_with("rollout-b-applied.jsonl") + ); + assert_eq!(entries[0]["providers"], json!(["custom"])); +} + +#[test] +#[ignore = "synthetic provider-sync throughput and peak-memory benchmark"] +fn provider_sync_bounded_memory_benchmark() { + 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(); + let file_count = 48_usize; + let payload_bytes = 4 * 1024 * 1024; + for index in 0..file_count { + write_large_rollout( + &home.join(format!("sessions/rollout-benchmark-{index}.jsonl")), + "openai", + &format!("benchmark-{index}"), + payload_bytes, + ); + } + + let started = Instant::now(); + let result = run_provider_sync(Some(&home)); + let peak_working_set = current_process_peak_working_set_bytes(); + + assert_eq!(result.status, ProviderSyncStatus::Synced, "{}", result.message); + assert_eq!(result.changed_session_files, file_count); + eprintln!( + "provider-sync streamed {} files / {} MiB in {:?}; peak working set: {:?} MiB", + file_count, + file_count * payload_bytes / (1024 * 1024), + started.elapsed(), + peak_working_set.map(|bytes| bytes / (1024 * 1024)) + ); + if let Some(peak_working_set) = peak_working_set { + assert!(peak_working_set < 256 * 1024 * 1024); + } +} + +#[test] +#[ignore = "synthetic unchanged-rollout prefilter benchmark"] +fn provider_sync_unchanged_rollout_prefilter_benchmark() { + 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(); + let file_count = 118_usize; + let payload_bytes = 1024 * 1024; + for index in 0..file_count { + write_large_rollout( + &home.join(format!("sessions/rollout-prefilter-{index}.jsonl")), + "custom", + &format!("prefilter-{index}"), + payload_bytes, + ); + } + + let full_started = Instant::now(); + let first = run_provider_sync(Some(&home)); + let full_elapsed = full_started.elapsed(); + let unchanged_started = Instant::now(); + let second = run_provider_sync(Some(&home)); + let unchanged_elapsed = unchanged_started.elapsed(); + + assert_eq!(first.status, ProviderSyncStatus::Synced, "{}", first.message); + assert_eq!(second.status, ProviderSyncStatus::Synced, "{}", second.message); + assert_eq!(first.changed_session_files, 0); + assert_eq!(second.changed_session_files, 0); + assert!(first.backup_dir.is_none()); + assert!(second.backup_dir.is_none()); + eprintln!( + "provider-sync prefilter {} files / {} MiB: full {:?}, unchanged {:?}", + file_count, + file_count * payload_bytes / (1024 * 1024), + full_elapsed, + unchanged_elapsed + ); +} + +#[test] +fn provider_sync_recovers_an_interrupted_rollout_transaction_before_resuming() { + 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(); + let rollout = home.join("sessions/rollout-interrupted.jsonl"); + write_rollout(&rollout, "openai", "thread-1", "C:/workspace"); + + let first = run_provider_sync(Some(&home)); + assert_eq!(first.status, ProviderSyncStatus::Synced); + let first_backup = first.backup_dir.unwrap(); + let transaction_path = first_backup.join("session-transaction.json"); + let mut transaction: serde_json::Value = + serde_json::from_slice(&fs::read(&transaction_path).unwrap()).unwrap(); + transaction["status"] = json!("in_progress"); + transaction["phase"] = json!("rollouts_applied"); + let orphan_stage = rollout.parent().unwrap().join(format!( + ".rollout-interrupted.jsonl.provider-sync-{}-99.tmp", + transaction["transactionId"].as_str().unwrap() + )); + fs::write(&orphan_stage, b"orphaned stage").unwrap(); + fs::write( + &transaction_path, + serde_json::to_vec_pretty(&transaction).unwrap(), + ) + .unwrap(); + + let second = run_provider_sync(Some(&home)); + + assert_eq!( + second.status, + ProviderSyncStatus::Synced, + "{}", + second.message + ); + assert_eq!(second.changed_session_files, 1); + let recovered: serde_json::Value = + serde_json::from_slice(&fs::read(&transaction_path).unwrap()).unwrap(); + assert_eq!(recovered["status"], "rolled_back"); + assert!(!orphan_stage.exists()); + let first_line: serde_json::Value = serde_json::from_str( + fs::read_to_string(&rollout) + .unwrap() + .lines() + .next() + .unwrap(), + ) + .unwrap(); + assert_eq!(first_line["payload"]["model_provider"], "custom"); +} + +#[test] +fn provider_sync_restores_downstream_backup_when_crash_precedes_commit_decision() { + 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(); + let rollout = home.join("sessions/rollout-downstream.jsonl"); + write_rollout(&rollout, "openai", "thread-1", "C:/workspace"); + let state_db = home.join("state_5.sqlite"); + create_state_db(&state_db); + + let first = run_provider_sync(Some(&home)); + assert_eq!(first.status, ProviderSyncStatus::Synced); + let backup_dir = first.backup_dir.unwrap(); + let backed_up_provider: String = Connection::open(backup_dir.join("db/state_5.sqlite")) + .unwrap() + .query_row( + "SELECT model_provider FROM threads WHERE id = 'thread-1'", + [], + |row| row.get(0), + ) + .unwrap(); + assert_eq!(backed_up_provider, "old-provider"); + let transaction_path = backup_dir.join("session-transaction.json"); + let mut transaction: serde_json::Value = + serde_json::from_slice(&fs::read(&transaction_path).unwrap()).unwrap(); + transaction["status"] = json!("in_progress"); + transaction["phase"] = json!("downstream_started"); + fs::write( + &transaction_path, + serde_json::to_vec_pretty(&transaction).unwrap(), + ) + .unwrap(); + let recovery = run_remote_control_session_catalog_recovery_for_thread_with_target( + Some(&home), + "thread-1", + "custom", + ); + assert_eq!(recovery.status, ProviderSyncStatus::Synced, "{}", recovery.message); + let restored_provider: String = Connection::open(&state_db) + .unwrap() + .query_row( + "SELECT model_provider FROM threads WHERE id = 'thread-1'", + [], + |row| row.get(0), + ) + .unwrap(); + assert_eq!(restored_provider, "old-provider"); + let recovered: serde_json::Value = + serde_json::from_slice(&fs::read(&transaction_path).unwrap()).unwrap(); + assert_eq!(recovered["status"], "rolled_back"); + let first_line: serde_json::Value = serde_json::from_str( + fs::read_to_string(&rollout).unwrap().lines().next().unwrap(), + ) + .unwrap(); + assert_eq!(first_line["payload"]["model_provider"], "openai"); +} + +#[test] +fn provider_sync_rejects_a_tampered_downstream_backup_manifest() { + 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(); + let rollout = home.join("sessions/rollout-tampered-backup.jsonl"); + write_rollout(&rollout, "openai", "thread-1", "C:/workspace"); + create_state_db(&home.join("state_5.sqlite")); + + let first = run_provider_sync(Some(&home)); + assert_eq!(first.status, ProviderSyncStatus::Synced); + let backup_dir = first.backup_dir.unwrap(); + let transaction_path = backup_dir.join("session-transaction.json"); + let mut transaction: serde_json::Value = + serde_json::from_slice(&fs::read(&transaction_path).unwrap()).unwrap(); + transaction["status"] = json!("in_progress"); + transaction["phase"] = json!("downstream_started"); + fs::write( + &transaction_path, + serde_json::to_vec_pretty(&transaction).unwrap(), + ) + .unwrap(); + let metadata_path = backup_dir.join("metadata.json"); + let mut metadata: serde_json::Value = + serde_json::from_slice(&fs::read(&metadata_path).unwrap()).unwrap(); + metadata["dbFiles"] + .as_array_mut() + .unwrap() + .push(json!("config.toml")); + fs::write(&metadata_path, serde_json::to_vec_pretty(&metadata).unwrap()).unwrap(); + + let recovery = run_remote_control_session_catalog_recovery_for_thread_with_target( + Some(&home), + "thread-1", + "custom", + ); + + assert_eq!(recovery.status, ProviderSyncStatus::Skipped); + assert!(recovery.message.contains("unexpected path")); + let transaction: serde_json::Value = + serde_json::from_slice(&fs::read(&transaction_path).unwrap()).unwrap(); + assert_eq!(transaction["status"], "in_progress"); + let first_line: serde_json::Value = serde_json::from_str( + fs::read_to_string(&rollout).unwrap().lines().next().unwrap(), + ) + .unwrap(); + assert_eq!(first_line["payload"]["model_provider"], "custom"); +} + +#[test] +fn provider_sync_preflights_all_downstream_backup_hashes_before_live_mutation() { + 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(); + fs::write( + home.join(".codex-global-state.json"), + serde_json::to_vec(&json!({"modelProviderId": "openai"})).unwrap(), + ) + .unwrap(); + let rollout = home.join("sessions/rollout-tampered-backup-bytes.jsonl"); + write_rollout(&rollout, "openai", "thread-1", "C:/workspace"); + let state_db = home.join("state_5.sqlite"); + create_state_db(&state_db); + + let first = run_provider_sync(Some(&home)); + assert_eq!(first.status, ProviderSyncStatus::Synced); + let backup_dir = first.backup_dir.unwrap(); + let live_db_before = fs::read(&state_db).unwrap(); + let global_state = home.join(".codex-global-state.json"); + let live_global_before = fs::read(&global_state).unwrap(); + let live_wal = home.join("state_5.sqlite-wal"); + fs::write(&live_wal, b"live-wal-must-survive-preflight").unwrap(); + + let transaction_path = backup_dir.join("session-transaction.json"); + let mut transaction: serde_json::Value = + serde_json::from_slice(&fs::read(&transaction_path).unwrap()).unwrap(); + transaction["status"] = json!("in_progress"); + transaction["phase"] = json!("downstream_started"); + fs::write( + &transaction_path, + serde_json::to_vec_pretty(&transaction).unwrap(), + ) + .unwrap(); + let backup_db = backup_dir.join("db/state_5.sqlite"); + let mut tampered = fs::read(&backup_db).unwrap(); + tampered.extend_from_slice(b"tampered"); + fs::write(&backup_db, tampered).unwrap(); + + let recovery = run_remote_control_session_catalog_recovery_for_thread_with_target( + Some(&home), + "thread-1", + "custom", + ); + + assert_eq!(recovery.status, ProviderSyncStatus::Skipped); + assert!(recovery.message.contains("hash or size mismatch")); + assert_eq!(fs::read(&state_db).unwrap(), live_db_before); + assert_eq!(fs::read(&global_state).unwrap(), live_global_before); + assert_eq!( + fs::read(&live_wal).unwrap(), + b"live-wal-must-survive-preflight" + ); + let transaction: serde_json::Value = + serde_json::from_slice(&fs::read(&transaction_path).unwrap()).unwrap(); + assert_eq!(transaction["status"], "in_progress"); +} + +#[test] +fn provider_sync_finishes_commit_decision_without_rolling_back() { + 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(); + let rollout = home.join("sessions/rollout-commit-decided.jsonl"); + write_rollout(&rollout, "openai", "thread-1", "C:/workspace"); + + let first = run_provider_sync(Some(&home)); + assert_eq!(first.status, ProviderSyncStatus::Synced); + let backup_dir = first.backup_dir.unwrap(); + let transaction_path = backup_dir.join("session-transaction.json"); + let mut transaction: serde_json::Value = + serde_json::from_slice(&fs::read(&transaction_path).unwrap()).unwrap(); + transaction["status"] = json!("in_progress"); + transaction["phase"] = json!("commit_decided"); + fs::write( + &transaction_path, + serde_json::to_vec_pretty(&transaction).unwrap(), + ) + .unwrap(); + + let second = run_provider_sync(Some(&home)); + + assert_eq!(second.status, ProviderSyncStatus::Synced, "{}", second.message); + assert_eq!(second.changed_session_files, 0); + assert!(second.backup_dir.is_none()); + let recovered: serde_json::Value = + serde_json::from_slice(&fs::read(&transaction_path).unwrap()).unwrap(); + assert_eq!(recovered["status"], "committed"); + let first_line: serde_json::Value = serde_json::from_str( + fs::read_to_string(&rollout).unwrap().lines().next().unwrap(), + ) + .unwrap(); + assert_eq!(first_line["payload"]["model_provider"], "custom"); +} + +#[test] +fn provider_sync_recovers_a_displaced_original_left_by_atomic_replacement() { + 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(); + let rollout = home.join("sessions/rollout-displaced.jsonl"); + write_rollout(&rollout, "openai", "thread-1", "C:/workspace"); + let original = fs::read(&rollout).unwrap(); + + let first = run_provider_sync(Some(&home)); + assert_eq!(first.status, ProviderSyncStatus::Synced); + let backup_dir = first.backup_dir.unwrap(); + let transaction_path = backup_dir.join("session-transaction.json"); + let mut transaction: serde_json::Value = + serde_json::from_slice(&fs::read(&transaction_path).unwrap()).unwrap(); + transaction["status"] = json!("in_progress"); + transaction["phase"] = json!("rollouts_applied"); + fs::write( + &transaction_path, + serde_json::to_vec_pretty(&transaction).unwrap(), + ) + .unwrap(); + let displaced = rollout.parent().unwrap().join(format!( + ".rollout-displaced.jsonl.provider-sync-displaced-{}-0.tmp", + transaction["transactionId"].as_str().unwrap() + )); + fs::write(&displaced, original).unwrap(); + + let second = run_provider_sync(Some(&home)); + + assert_eq!(second.status, ProviderSyncStatus::Synced, "{}", second.message); + assert_eq!(second.changed_session_files, 1); + assert!(!displaced.exists()); + let recovered: serde_json::Value = + serde_json::from_slice(&fs::read(&transaction_path).unwrap()).unwrap(); + assert_eq!(recovered["status"], "rolled_back"); + let first_line: serde_json::Value = serde_json::from_str( + fs::read_to_string(&rollout).unwrap().lines().next().unwrap(), + ) + .unwrap(); + assert_eq!(first_line["payload"]["model_provider"], "custom"); +} + +#[test] +fn provider_sync_accepts_recorded_external_restore_after_a_second_crash() { + 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(); + let rollout = home.join("sessions/rollout-external-restore.jsonl"); + write_rollout(&rollout, "openai", "thread-1", "C:/workspace"); + let original = fs::read(&rollout).unwrap(); + let mut external = original.clone(); + external.extend_from_slice( + format!( + "{}\n", + json!({"type": "event_msg", "payload": {"external_restore": true}}) + ) + .as_bytes(), + ); + + let first = run_provider_sync(Some(&home)); + assert_eq!(first.status, ProviderSyncStatus::Synced); + let backup_dir = first.backup_dir.unwrap(); + let transaction_path = backup_dir.join("session-transaction.json"); + let mut transaction: serde_json::Value = + serde_json::from_slice(&fs::read(&transaction_path).unwrap()).unwrap(); + transaction["status"] = json!("in_progress"); + transaction["phase"] = json!("rollouts_applied"); + transaction["entries"][0]["externalSha256"] = + json!(format!("{:x}", Sha256::digest(&external))); + transaction["entries"][0]["externalSize"] = json!(external.len()); + fs::write( + &transaction_path, + serde_json::to_vec_pretty(&transaction).unwrap(), + ) + .unwrap(); + fs::write(&rollout, &external).unwrap(); + let displaced = rollout.parent().unwrap().join(format!( + ".rollout-external-restore.jsonl.provider-sync-displaced-{}-0.tmp", + transaction["transactionId"].as_str().unwrap() + )); + fs::write(&displaced, original).unwrap(); + + let second = run_provider_sync(Some(&home)); + + assert_eq!(second.status, ProviderSyncStatus::Synced, "{}", second.message); + assert_eq!(second.changed_session_files, 1); + let recovered: serde_json::Value = + serde_json::from_slice(&fs::read(&transaction_path).unwrap()).unwrap(); + assert_eq!(recovered["status"], "rolled_back"); + assert!(!displaced.exists()); + let current = fs::read_to_string(&rollout).unwrap(); + assert!(current.contains("\"external_restore\":true")); + let first_line: serde_json::Value = + serde_json::from_str(current.lines().next().unwrap()).unwrap(); + assert_eq!(first_line["payload"]["model_provider"], "custom"); +} + +#[test] +fn provider_sync_restores_a_recorded_external_displaced_file_after_a_second_crash() { + 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(); + let rollout = home.join("sessions/rollout-external-displaced.jsonl"); + write_rollout(&rollout, "openai", "thread-1", "C:/workspace"); + let mut external = fs::read(&rollout).unwrap(); + external.extend_from_slice( + format!( + "{}\n", + json!({"type": "event_msg", "payload": {"external_displaced": true}}) + ) + .as_bytes(), + ); + + let first = run_provider_sync(Some(&home)); + assert_eq!(first.status, ProviderSyncStatus::Synced); + let backup_dir = first.backup_dir.unwrap(); + let transaction_path = backup_dir.join("session-transaction.json"); + let mut transaction: serde_json::Value = + serde_json::from_slice(&fs::read(&transaction_path).unwrap()).unwrap(); + transaction["status"] = json!("in_progress"); + transaction["phase"] = json!("rollouts_applied"); + transaction["entries"][0]["externalSha256"] = + json!(format!("{:x}", Sha256::digest(&external))); + transaction["entries"][0]["externalSize"] = json!(external.len()); + fs::write( + &transaction_path, + serde_json::to_vec_pretty(&transaction).unwrap(), + ) + .unwrap(); + let displaced = rollout.parent().unwrap().join(format!( + ".rollout-external-displaced.jsonl.provider-sync-displaced-{}-0.tmp", + transaction["transactionId"].as_str().unwrap() + )); + fs::write(&displaced, &external).unwrap(); + + let second = run_provider_sync(Some(&home)); + + assert_eq!(second.status, ProviderSyncStatus::Synced, "{}", second.message); + assert_eq!(second.changed_session_files, 1); + assert!(!displaced.exists()); + let recovered: serde_json::Value = + serde_json::from_slice(&fs::read(&transaction_path).unwrap()).unwrap(); + assert_eq!(recovered["status"], "rolled_back"); + let current = fs::read_to_string(&rollout).unwrap(); + assert!(current.contains("\"external_displaced\":true")); + let first_line: serde_json::Value = + serde_json::from_str(current.lines().next().unwrap()).unwrap(); + assert_eq!(first_line["payload"]["model_provider"], "custom"); +} + +#[test] +fn provider_sync_refuses_to_recover_over_externally_changed_rollout() { + 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(); + let rollout = home.join("sessions/rollout-conflict.jsonl"); + write_rollout(&rollout, "openai", "thread-1", "C:/workspace"); + + let first = run_provider_sync(Some(&home)); + assert_eq!(first.status, ProviderSyncStatus::Synced); + let backup_dir = first.backup_dir.unwrap(); + let transaction_path = backup_dir.join("session-transaction.json"); + let mut transaction: serde_json::Value = + serde_json::from_slice(&fs::read(&transaction_path).unwrap()).unwrap(); + transaction["status"] = json!("in_progress"); + transaction["phase"] = json!("rollouts_applied"); + fs::write( + &transaction_path, + serde_json::to_vec_pretty(&transaction).unwrap(), + ) + .unwrap(); + let mut file = fs::OpenOptions::new().append(true).open(&rollout).unwrap(); + writeln!( + file, + "{}", + json!({"type": "event_msg", "payload": {"external": true}}) + ) + .unwrap(); + + let second = run_provider_sync(Some(&home)); + + assert_eq!(second.status, ProviderSyncStatus::Skipped); + assert!(second.message.contains("refusing rollback")); + let current = fs::read_to_string(&rollout).unwrap(); + assert!(current.contains("\"external\":true")); + let first_line: serde_json::Value = + serde_json::from_str(current.lines().next().unwrap()).unwrap(); + assert_eq!(first_line["payload"]["model_provider"], "custom"); +} + #[test] fn provider_sync_never_prunes_unconfirmed_or_delayed_index_entries() { let tmp = tempdir().unwrap();