diff --git a/apps/codex-plus-launcher/src/main.rs b/apps/codex-plus-launcher/src/main.rs index 7c4bbd2d1..d22c92ebd 100644 --- a/apps/codex-plus-launcher/src/main.rs +++ b/apps/codex-plus-launcher/src/main.rs @@ -54,7 +54,11 @@ impl StartupReply { if result.is_ok() { let mut state = self.state.lock().unwrap_or_else(|error| error.into_inner()); state.ready = true; - record_session_probe(&state.options, state.status_started_at_ms); + record_session_probe( + &state.options, + state.status_started_at_ms, + state.launch_extra_args.clone(), + ); } self.reply.send(result) } @@ -66,6 +70,7 @@ struct SessionState { options: LaunchOptions, requested_at_ms: u64, status_started_at_ms: u64, + launch_extra_args: Option>, } /// 本进程 launcher 会话的只读快照,供自动接管的判据使用。 @@ -82,6 +87,10 @@ pub struct SessionSnapshot { /// `latest-status.json` 里的状态(`running` / `running_degraded` / …)。 pub status: Option, pub debug_port: Option, + /// 已就绪会话实际使用的应用目录,不从旧状态文件推测。 + pub app_dir: Option, + /// 本会话启动时实际使用的附加参数;接管已有实例时可能未知。 + pub launch_extra_args: Option>, /// 该会话的状态代际,用来判断是不是同一次启动。 pub started_at_ms: Option, } @@ -96,18 +105,24 @@ struct SessionProbe { helper_port: u16, app_dir: Option, started_at_ms: u64, + launch_extra_args: Option>, } fn session_probe_slot() -> &'static Mutex> { SESSION_PROBE.get_or_init(|| Mutex::new(None)) } -fn record_session_probe(options: &LaunchOptions, started_at_ms: u64) { +fn record_session_probe( + options: &LaunchOptions, + started_at_ms: u64, + launch_extra_args: Option>, +) { let probe = SessionProbe { debug_port: options.debug_port, helper_port: options.helper_port, app_dir: options.app_dir.clone(), started_at_ms, + launch_extra_args, }; *session_probe_slot() .lock() @@ -185,6 +200,7 @@ impl EmbeddedSession { options: options.clone(), requested_at_ms, status_started_at_ms: requested_at_ms, + launch_extra_args: None, })); let ready = StartupReply { reply: ready, @@ -417,6 +433,10 @@ pub fn session_snapshot() -> SessionSnapshot { .as_ref() .map(|probe| probe.debug_port) .or_else(|| latest.as_ref().and_then(|latest| latest.debug_port)), + app_dir: probe.as_ref().and_then(|probe| probe.app_dir.clone()), + launch_extra_args: probe + .as_ref() + .and_then(|probe| probe.launch_extra_args.clone()), started_at_ms: probe .as_ref() .map(|probe| probe.started_at_ms) @@ -461,11 +481,11 @@ async fn runtime_commands( if let Some(current) = &session { let current_options = { let state = current.state.lock().unwrap_or_else(|error| error.into_inner()); - state.ready.then(|| (state.options.clone(), state.status_started_at_ms)) + state.ready.then(|| (state.options.clone(), state.status_started_at_ms, state.launch_extra_args.clone())) }; let result = match current_options { - Some((current_options, started_at_ms)) => { - record_session_probe(¤t_options, started_at_ms); + Some((current_options, started_at_ms, launch_extra_args)) => { + record_session_probe(¤t_options, started_at_ms, launch_extra_args); if attach_only { Ok(()) } else { activate(current_options, started_at_ms).await } } None => Err(StartupBusy.into()), @@ -1378,6 +1398,12 @@ impl LaunchHooks for LauncherHooks { ), }); } + if let Some(state) = &self.session_state { + state + .lock() + .unwrap_or_else(|error| error.into_inner()) + .launch_extra_args = Some(extra_args.to_vec()); + } self.core .launch_codex(app_dir, debug_port, settings, extra_args, instance) .await @@ -2432,6 +2458,44 @@ mod tests { #[test] fn embedded_runtime_deduplicates_start_and_waits_for_cleanup_before_restarting() { use std::sync::atomic::{AtomicUsize, Ordering}; + let child_root = std::env::var_os("CODEX_PLUS_RUNTIME_PROBE_TEST_ROOT"); + if child_root.is_none() { + let fixture = tempfile::tempdir().unwrap(); + let mut command = std::process::Command::new(std::env::current_exe().unwrap()); + command + .args([ + "--exact", + "tests::embedded_runtime_deduplicates_start_and_waits_for_cleanup_before_restarting", + "--nocapture", + ]) + .env("CODEX_PLUS_RUNTIME_PROBE_TEST_ROOT", fixture.path()); + for (variable, directory) in [ + ("CODEX_PLUS_STATE_DIR", "state"), + ("CODEX_HOME", "home"), + ("CODEX_SQLITE_HOME", "sqlite"), + ("APPDATA", "appdata"), + ("XDG_CONFIG_HOME", "xdg"), + ] { + let directory = fixture.path().join(directory); + std::fs::create_dir(&directory).unwrap(); + command.env(variable, directory); + } + #[cfg(windows)] + { + use std::os::windows::process::CommandExt; + command.creation_flags(codex_plus_core::windows_create_no_window()); + } + // 其他 runtime 用例也会清空进程级快照;在单独测试进程内验证该快照。 + let output = command.output().unwrap(); + assert!( + output.status.success(), + "{}\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + assert_eq!(std::fs::read(fixture.path().join("completed")).unwrap(), b"ok"); + return; + } let launched = Arc::new(AtomicUsize::new(0)); let cleaned = Arc::new(AtomicUsize::new(0)); let activations = Arc::new(AtomicUsize::new(0)); @@ -2459,6 +2523,8 @@ mod tests { std::future::pending::<()>().await; }); tokio::task::yield_now().await; + ready.state.lock().unwrap().launch_extra_args = + Some(vec!["--user-data-dir=C:\\test\\started-instance".to_string()]); ready.send(Ok(())).unwrap(); let _ = stop.await; tokio::time::sleep(std::time::Duration::from_millis(5)).await; @@ -2481,6 +2547,10 @@ mod tests { test_start_command(LaunchOptions::default(), reply) }) .unwrap(); + assert_eq!( + session_probe_slot().lock().unwrap().as_ref().unwrap().launch_extra_args, + Some(vec!["--user-data-dir=C:\\test\\started-instance".to_string()]) + ); send_test_command(&worker, |reply| { test_start_command( LaunchOptions { @@ -2491,6 +2561,10 @@ mod tests { ) }) .unwrap(); + assert_eq!( + session_probe_slot().lock().unwrap().as_ref().unwrap().launch_extra_args, + Some(vec!["--user-data-dir=C:\\test\\started-instance".to_string()]) + ); assert_eq!(launched.load(Ordering::SeqCst), 1); assert_eq!(activations.load(Ordering::SeqCst), 1); send_test_command(&worker, RuntimeCommand::Stop).unwrap(); @@ -2505,6 +2579,7 @@ mod tests { worker.thread.join().unwrap(); assert_eq!(cleaned.load(Ordering::SeqCst), 2); assert_eq!(connections_dropped.load(Ordering::SeqCst), 2); + std::fs::write(PathBuf::from(child_root.unwrap()).join("completed"), b"ok").unwrap(); } #[test] diff --git a/apps/codex-plus-manager/src-tauri/src/commands.rs b/apps/codex-plus-manager/src-tauri/src/commands.rs index ffb072c88..7564b8e6c 100644 --- a/apps/codex-plus-manager/src-tauri/src/commands.rs +++ b/apps/codex-plus-manager/src-tauri/src/commands.rs @@ -2903,8 +2903,12 @@ enum RestartDisposition { StopAndRestart, } -fn restart_disposition(sync_active_relay: bool, app_running: bool) -> RestartDisposition { - if !sync_active_relay && !app_running { +fn restart_disposition( + sync_active_relay: bool, + app_running: bool, + is_windows: bool, +) -> RestartDisposition { + if !is_windows && !sync_active_relay && !app_running { RestartDisposition::LaunchOnly } else { RestartDisposition::StopAndRestart @@ -2917,15 +2921,24 @@ fn restart_codex_plus_blocking(request: LaunchRequest) -> CommandResult { Err(message) => return failed(message, json!({})), }; let request = normalize_launch_request(request); + 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 + }), + ); // 按本实例的调试端口判定是否在跑。 // 早先用 `find_codex_processes_for_debug_port`,但它在非 macOS 上返回全部 // Codex 进程:Windows 上只要有多开实例在跑,就会把「主 Codex 没开」误判成 // 「已运行」,进而走进重启分支去停进程。 let app_running = codex_plus_core::cdp::endpoint_available(request.debug_port); - if restart_disposition(request.sync_active_relay, app_running) == RestartDisposition::LaunchOnly + if restart_disposition(request.sync_active_relay, app_running, cfg!(windows)) + == RestartDisposition::LaunchOnly { - // 调试端口不可连不代表 Codex 没在跑:Windows 商店版主 Codex 可能开着却没带端口 - // (issue #2764)。走重启专用的启动,由 launcher 识别并结束它后再启动。 + // Windows 始终先执行停止流程;其他平台保留原来的直接启动分支。 return spawn_codex_plus_launch_with( request, "Codex 未运行,已按直接启动方式在后台唤起。", @@ -2971,14 +2984,19 @@ fn restart_codex_plus_blocking(request: LaunchRequest) -> CommandResult { 10, )); let native_browser_shutdown = match stop_codex_plus_for_restart( - || stop_main_codex_process(request.debug_port), + || stop_main_codex_process(&request).map(|_| ()), codex_plus_launcher::stop, wait_native_browser_shutdown, ) { Ok(shutdown) => shutdown, Err(error) => { + let message = restart_stop_failure_message(&error); + let _ = codex_plus_core::diagnostic_log::append_diagnostic_log( + "manager.restart_stop_failed", + json!({"debug_port": request.debug_port, "message": message}), + ); return failed( - &restart_stop_failure_message(&error), + &message, json!({"debugPort": request.debug_port, "helperPort": request.helper_port}), ); } @@ -3001,7 +3019,7 @@ fn restart_codex_plus_blocking(request: LaunchRequest) -> CommandResult { } 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_starting", json!({ "debug_port": request.debug_port, "helper_port": request.helper_port, @@ -3027,11 +3045,11 @@ fn restart_codex_plus_blocking(request: LaunchRequest) -> CommandResult { } let mut restart_message = "Codex 已请求重启,启动任务正在后台运行。".to_string(); match restart_codex_plus_after_stop(&request, &home, settings.as_ref(), |request| { - spawn_after_provider_guard_release( - provider_guard, - request, - start_embedded_launcher_for_restart, - ) + #[cfg(windows)] + let start = start_embedded_launcher; + #[cfg(not(windows))] + let start = start_embedded_launcher_for_restart; + spawn_after_provider_guard_release(provider_guard, request, start) }) { Ok(warnings) => { for warning in warnings { @@ -3071,24 +3089,64 @@ fn restart_codex_plus_blocking(request: LaunchRequest) -> CommandResult { } } -/// 结束主 Codex 进程并确认调试端口已关闭。重启和「停止 Codex」共用。 -fn stop_main_codex_process(debug_port: u16) -> anyhow::Result<()> { - // 主实例在 Windows 上是 AUMID 激活的,命令行未必带调试端口; - // 这里允许「按端口没匹配到就退回全局结束」,否则会卡在 - // 「Codex 进程尚未退出」。多开页的结束走的是不允许退回的那个版本。 - // macOS 上按端口没匹配到时,退回结束没带调试端口的主进程(外部启动的 Codex)。 +/// 结束主 Codex 进程并确认退出。重启和「停止 Codex」共用。 +fn stop_main_codex_process(request: &LaunchRequest) -> anyhow::Result { #[cfg(windows)] - codex_plus_core::watcher::stop_codex_processes_for_debug_port_with_fallback(debug_port, true); + { + let session = codex_plus_launcher::session_snapshot(); + let needs_settings = (session.launch_extra_args.is_none() && !session.owned) + || (session.app_dir.is_none() && request.app_path.trim().is_empty()); + let settings = needs_settings + .then(|| SettingsStore::default().load()) + .transpose()?; + // 设置可能在启动后被编辑;停止时沿用该会话真正使用过的参数。 + let launch_extra_args = session + .launch_extra_args + .as_deref() + .or_else(|| { + (!session.owned).then(|| { + settings + .as_ref() + .map(|settings| settings.codex_extra_args.as_slice()) + .unwrap_or_default() + }) + }); + let owned_debug_port = session.owned.then_some(session.debug_port).flatten(); + let app_dir = session.app_dir.or_else(|| { + codex_plus_core::app_paths::resolve_codex_app_dir_with_saved( + (!request.app_path.trim().is_empty()) + .then(|| Path::new(request.app_path.trim())), + settings + .as_ref() + .map(|settings| settings.codex_app_path.as_str()), + ) + }); + let app_dir = app_dir.ok_or_else(|| anyhow::anyhow!("无法核实 Codex 应用路径"))?; + let executable = codex_plus_core::app_paths::build_codex_executable(&app_dir); + return codex_plus_core::watcher::stop_windows_main_codex_processes_with_args_and_wait( + &executable, + owned_debug_port, + launch_extra_args, + ); + } + #[cfg(not(windows))] + let was_running = codex_plus_core::cdp::endpoint_available(request.debug_port) + || main_codex_running_without_debug_port(); + #[cfg(not(windows))] + let debug_port = request.debug_port; #[cfg(target_os = "macos")] codex_plus_core::watcher::stop_main_codex_processes_and_wait(debug_port); #[cfg(not(any(windows, target_os = "macos")))] codex_plus_core::watcher::stop_codex_processes_for_debug_port_and_wait(debug_port); + #[cfg(not(windows))] anyhow::ensure!( !codex_plus_core::cdp::endpoint_available(debug_port), "Codex 进程尚未退出" ); + #[cfg(not(windows))] anyhow::ensure!(!main_codex_running_without_debug_port(), "Codex 进程尚未退出"); - Ok(()) + #[cfg(not(windows))] + Ok(usize::from(was_running)) } /// 主 Codex 开着却没带调试端口:从 Dock / Finder 等外部启动,调试端口探测不到它。 @@ -3121,7 +3179,16 @@ fn stop_codex_plus_blocking(request: LaunchRequest) -> CommandResult { Err(message) => return failed(message, json!({})), }; let request = normalize_launch_request(request); - if !codex_plus_core::cdp::endpoint_available(request.debug_port) + let _ = codex_plus_core::diagnostic_log::append_diagnostic_log( + "manager.stop_requested", + json!({ + "debug_port": request.debug_port, + "helper_port": request.helper_port, + "app_path": request.app_path.trim() + }), + ); + if !cfg!(windows) + && !codex_plus_core::cdp::endpoint_available(request.debug_port) && !main_codex_running_without_debug_port() { return ok("Codex 没有在运行。", json!({})); @@ -3141,23 +3208,41 @@ fn stop_codex_plus_blocking(request: LaunchRequest) -> CommandResult { "user_stopping", 10, )); + let mut stopped_count = None; match stop_codex_plus_for_restart( - || stop_main_codex_process(request.debug_port), + || { + let count = stop_main_codex_process(&request)?; + stopped_count = Some(count); + Ok(()) + }, codex_plus_launcher::stop, wait_native_browser_shutdown, ) { Ok(_) => { + let stopped_count = stopped_count.unwrap_or_default(); + let message = if stopped_count == 0 { + "Codex 没有在运行。" + } else { + "Codex 已停止。" + }; let _ = StatusStore::default().save_latest(&requested_launch_status_with_phase( &request, "stopped", - "Codex 已停止。", + message, current_timestamp_ms(), "user_stopped", 100, )); - ok("Codex 已停止。", json!({})) + ok(message, json!({ "stoppedCount": stopped_count })) + } + Err(error) => { + let message = stop_failure_message(&error); + let _ = codex_plus_core::diagnostic_log::append_diagnostic_log( + "manager.stop_failed", + json!({"debug_port": request.debug_port, "message": message}), + ); + failed(&message, json!({})) } - Err(error) => failed(&stop_failure_message(&error), json!({})), } } @@ -11441,12 +11526,18 @@ base_url = "https://example.invalid/v1" #[test] fn restart_absent_app_uses_launch_but_explicit_sync_still_restarts() { assert_eq!( - restart_disposition(false, false), + restart_disposition(false, false, false), RestartDisposition::LaunchOnly ); for (sync, running) in [(true, false), (false, true), (true, true)] { assert_eq!( - restart_disposition(sync, running), + restart_disposition(sync, running, false), + RestartDisposition::StopAndRestart + ); + } + for (sync, running) in [(false, false), (true, false), (false, true), (true, true)] { + assert_eq!( + restart_disposition(sync, running, true), RestartDisposition::StopAndRestart ); } diff --git a/crates/codex-plus-core/src/packaged_launch.rs b/crates/codex-plus-core/src/packaged_launch.rs index c858ece12..da5085190 100644 --- a/crates/codex-plus-core/src/packaged_launch.rs +++ b/crates/codex-plus-core/src/packaged_launch.rs @@ -650,11 +650,11 @@ pub fn existing_main_instance_blocks_launch_message( .collect::>() .join(", "); format!( - "检测到 Codex 已在运行,但没有开启调试端口 {debug_port}(主进程 PID {ids})。\ - Codex 同时只允许一个主实例,Codex++ 再启动的实例会把请求交给它后立即退出,无法注入。\ - 请先完全退出 Codex:关掉窗口后若托盘里还有 Codex 图标,右键选择退出;\ - 或在任务管理器里结束所有 {executable_name} 进程。然后再点「启动 Codex」。\ - 也可以点「重启 Codex」,由 Codex++ 结束它并重新启动。" + "检测到同包 Codex 进程({executable_name},PID {ids}),其命令行未声明本次调试端口 \ + {debug_port}:可能未开启调试,也可能使用了其他端口或属于隔离实例。\ + 如果它与本次启动共用用户数据目录,新进程可能将请求交给已有实例后退出,\ + 导致本次端口无法注入。关闭窗口或托盘图标消失并不代表主进程已退出。\ + 可以在 Codex++ 中点击「重启 Codex」尝试恢复;无法确认目标身份时会保留进程并提示原因。" ) } @@ -2431,13 +2431,27 @@ mod tests { } #[test] - fn blocking_instance_message_tells_the_user_how_to_quit_codex() { + fn blocking_instance_message_distinguishes_the_requested_port_from_other_debug_ports() { let message = existing_main_instance_blocks_launch_message(9229, "ChatGPT.exe", &[28480, 31144]); assert!(message.contains("9229")); assert!(message.contains("28480, 31144")); assert!(message.contains("ChatGPT.exe")); - assert!(message.contains("托盘")); + assert!(message.contains("命令行未声明本次调试端口")); + assert!(message.contains("可能未开启调试")); + assert!(message.contains("也可能使用了其他端口")); + assert!(!message.contains("没有开启调试端口")); + } + + #[test] + fn blocking_instance_message_recommends_a_verified_restart_instead_of_bulk_process_termination() { + let message = existing_main_instance_blocks_launch_message(52106, "ChatGPT.exe", &[32424]); + assert!(message.contains("如果它与本次启动共用用户数据目录")); + assert!(message.contains("可能将请求交给已有实例")); + assert!(message.contains("托盘图标消失并不代表主进程已退出")); assert!(message.contains("重启 Codex")); + assert!(message.contains("无法确认目标身份时会保留进程")); + assert!(!message.contains("结束所有")); + assert!(!message.contains("任务管理器")); } #[test] diff --git a/crates/codex-plus-core/src/watcher.rs b/crates/codex-plus-core/src/watcher.rs index 2f84d9249..9d5a95d83 100644 --- a/crates/codex-plus-core/src/watcher.rs +++ b/crates/codex-plus-core/src/watcher.rs @@ -6,6 +6,13 @@ use std::time::Duration; #[cfg(windows)] pub use crate::windows_integration::WindowsProcessInfo; +#[cfg(any(windows, test))] +mod windows_main_stop; +#[cfg(windows)] +pub use windows_main_stop::{ + stop_windows_main_codex_processes_and_wait, + stop_windows_main_codex_processes_with_args_and_wait, +}; pub const WATCHER_INTERVAL_SECONDS: f64 = 3.0; pub const CDP_PROBE_TIMEOUT_SECONDS: f64 = 0.5; @@ -22,6 +29,17 @@ pub const WATCHER_STARTUP_SHORTCUT_NAME: &str = "CodexPlusPlusWatcher.lnk"; /// macOS 开机启动用的 LaunchAgent 标签;plist 文件名即 `<标签>.plist`。 pub const MACOS_WATCHER_LABEL: &str = "com.bigpizzav3.codexplusplus.watcher"; +/// Windows Chromium 接受多种 switch 前缀,比较名称时还须忽略 ASCII 大小写。 +pub fn windows_codex_switch_name(argument: &str) -> Option<&str> { + let argument = argument.trim(); + let switch = argument + .strip_prefix("--") + .or_else(|| argument.strip_prefix('-')) + .or_else(|| argument.strip_prefix('/'))?; + let name = switch.split('=').next()?; + (!name.is_empty()).then_some(name) +} + #[derive(Debug, Clone, PartialEq, Eq)] pub struct WatcherInstallPlan { pub run_value_name: String, diff --git a/crates/codex-plus-core/src/watcher/windows_main_stop.rs b/crates/codex-plus-core/src/watcher/windows_main_stop.rs new file mode 100644 index 000000000..a0aa163fd --- /dev/null +++ b/crates/codex-plus-core/src/watcher/windows_main_stop.rs @@ -0,0 +1,1523 @@ +use std::path::{Path, PathBuf}; +use std::time::{Duration, Instant}; + +use anyhow::{Context, Result}; + +use crate::windows_integration::{StopProcessEnvironment, StopProcessOwner}; + +#[derive(Clone, PartialEq, Eq)] +struct ProcessFacts { + birth: u64, + image: PathBuf, + arguments: Vec, + owner: StopProcessOwner, + environment: StopProcessEnvironment, +} + +struct StopContext { + executable: String, + user_data: Option, + debug_port_only: bool, + listener_identity: Option<(u32, u64)>, + owner: StopProcessOwner, + app_data: String, + owned_debug_port: Option, +} + +trait HeldMainProcess { + fn id(&self) -> u32; + fn alive(&self) -> Result; + /// `Some(false)` 表示已确认不是目标主进程;argv 无法解析时返回 `None`, + /// 让调用方先核对归属和 APPDATA 后再决定是否需要 fail closed。 + fn possible_target(&self, context: &StopContext) -> Result>; + fn facts(&self) -> Result; + fn request_close(&self) -> Result; + fn terminate(&self) -> Result<()>; +} + +/// 停止已经核验的 Windows Codex 主进程,并等待原句柄确认退出。 +/// +/// 使用调用管理器的环境上下文;附加参数有自定义目录时使用 with_args 入口。 +/// 不使用调试端口兜底,不遍历或结束子进程,不重新按 PID 终止进程。 +#[cfg(windows)] +pub fn stop_windows_main_codex_processes_and_wait( + expected_executable: &Path, + owned_debug_port: Option, +) -> Result { + stop_windows_main_codex_processes_with_args_and_wait( + expected_executable, + owned_debug_port, + Some(&[]), + ) +} + +/// 按实际启动参数和管理器环境核验用户数据上下文,兼容自定义目录。 +/// +/// 双方须属于同一用户、登录及 Windows 会话,APPDATA 相同且没有 agent 隔离覆盖。 +/// 自定义 userData 只允许精确目录匹配;默认上下文仍排除所有隔离实例。 +/// 接管会话的启动参数未知时,只允许停止实际监听者的原进程身份,不猜测默认目录。 +/// 已就绪会话的端口只用于拒绝漏停,不用于扩大停止范围。 +#[cfg(windows)] +pub fn stop_windows_main_codex_processes_with_args_and_wait( + expected_executable: &Path, + owned_debug_port: Option, + launch_extra_args: Option<&[String]>, +) -> Result { + use crate::windows_integration::{VerifiedStopProcess, bounded_process_snapshot}; + + let expected_name = expected_executable + .file_name() + .and_then(|name| name.to_str()) + .filter(|name| crate::app_paths::is_windows_desktop_app_executable_name(name)) + .ok_or_else(|| anyhow::anyhow!("缺少可信的 Codex 桌面应用入口"))?; + let manager = VerifiedStopProcess::capture(std::process::id(), false)? + .ok_or_else(|| anyhow::anyhow!("无法持有当前管理器的进程身份"))?; + let mut context = stop_context( + expected_executable, + launch_extra_args, + manager.stop_owner()?, + manager.stop_environment()?, + owned_debug_port, + )?; + // 接管会话没有目录快照时,绑定真实监听者的持有身份,不按相同端口参数批量匹配。 + let listener = if let Some(port) = owned_debug_port { + let listener = + crate::windows_integration::capture_debug_listener(port, expected_executable)?; + if let Some(listener) = listener.as_ref() { + if context.debug_port_only { + context.listener_identity = Some((listener.process_id, listener.birth_id)); + } + let held = VerifiedStopProcess::capture(listener.process_id, false)? + .ok_or_else(|| anyhow::anyhow!("核验时已接管的 Codex 监听进程消失"))?; + let facts = held.facts()?; + anyhow::ensure!( + held.alive()? && facts.birth == listener.birth_id, + "核验时已接管的 Codex 监听进程身份发生变化" + ); + ensure_listener_matches(&facts, &context)?; + } else { + anyhow::ensure!( + !context.debug_port_only + && crate::windows_integration::loopback_listener_process_ids(port)?.is_empty(), + "无法核实已接管调试端口的唯一 Codex 监听者" + ); + } + listener + } else { + None + }; + let candidates = || -> Result> { + let mut candidates = Vec::new(); + for process in bounded_process_snapshot(65_536)? { + if !process.exe_file.eq_ignore_ascii_case(expected_name) { + continue; + } + if matches!( + crate::windows_integration::process_session_id(process.process_id)?, + Some(session_id) if session_id == context.owner.session_id + ) { + candidates.push(process.process_id); + } + } + Ok(candidates) + }; + let stopped = capture_and_stop( + candidates()?, + VerifiedStopProcess::capture, + &context, + Duration::from_millis(super::RESTART_STOP_WAIT_TIMEOUT_MS), + || std::thread::sleep(Duration::from_millis(super::RESTART_STOP_WAIT_INTERVAL_MS)), + )?; + ensure_no_live_targets(candidates()?, VerifiedStopProcess::capture, &context)?; + if let Some(listener) = listener { + anyhow::ensure!(!listener.is_alive()?, "已接管的 Codex 原监听进程尚未退出"); + } + if let Some(port) = owned_debug_port { + anyhow::ensure!( + crate::windows_integration::loopback_listener_process_ids(port)?.is_empty(), + "停止后调试端口出现其他监听者,未继续启动" + ); + } + Ok(stopped) +} + +fn ensure_listener_matches(facts: &ProcessFacts, context: &StopContext) -> Result<()> { + anyhow::ensure!( + target_matches(facts, context)?, + "已接管的 Codex 监听进程与待停止的用户数据上下文不一致,未结束任何进程" + ); + Ok(()) +} + +fn stop_context( + executable: &Path, + extra_args: Option<&[String]>, + owner: StopProcessOwner, + environment: StopProcessEnvironment, + owned_debug_port: Option, +) -> Result { + anyhow::ensure!( + environment + .agent_run_id + .as_deref() + .is_none_or(str::is_empty), + "管理器存在 agent 隔离覆盖,无法按已知主实例启动约定核验" + ); + anyhow::ensure!( + extra_args.is_some() || owned_debug_port.is_some_and(|port| port != 0), + "会话启动参数未知且缺少已接管调试端口,无法确认停止目标" + ); + let arguments = parsed_launch_arguments(&crate::settings::normalize_codex_extra_args( + extra_args.unwrap_or_default(), + ))?; + anyhow::ensure!(!arguments.child, "主实例启动参数不能声明 Chromium 子进程"); + // launcher 按显式参数覆盖子进程的环境目录;不以管理器继承的旧值拒绝该配置。 + let user_data = match arguments.user_data { + Some(path) => Some(path), + None => environment + .user_data + .as_deref() + .filter(|value| !value.is_empty()) + .map(normalized_path_text) + .transpose()?, + }; + Ok(StopContext { + executable: normalized_path(executable)?, + user_data, + debug_port_only: extra_args.is_none(), + listener_identity: None, + owner, + app_data: normalized_path_text( + environment + .app_data + .as_deref() + .ok_or_else(|| anyhow::anyhow!("无法核实管理器 APPDATA"))?, + )?, + owned_debug_port, + }) +} + +fn effective_user_data( + argument: Option, + environment: &StopProcessEnvironment, +) -> Result> { + let environment = environment + .user_data + .as_deref() + .filter(|value| !value.is_empty()) + .map(normalized_path_text) + .transpose()?; + if let (Some(argument), Some(environment)) = (&argument, &environment) { + anyhow::ensure!( + argument == environment, + "Codex 命令行与环境中的用户数据目录冲突" + ); + } + Ok(argument.or(environment)) +} + +#[cfg(windows)] +impl HeldMainProcess for crate::windows_integration::VerifiedStopProcess { + fn id(&self) -> u32 { + self.process_id + } + + fn alive(&self) -> Result { + self.is_alive() + } + + fn possible_target(&self, context: &StopContext) -> Result> { + let image = normalized_path(&self.image()?)?; + if !image_matches(&context.executable, &image) { + return Ok(Some(false)); + } + let arguments = self.arguments()?; + Ok(match parsed_arguments(&arguments, &image) { + Ok(arguments) => Some(!arguments.child), + Err(_) => None, + }) + } + + fn facts(&self) -> Result { + Ok(ProcessFacts { + birth: self.birth_id, + image: self.image()?, + arguments: self.arguments()?, + owner: self.stop_owner()?, + environment: self.stop_environment()?, + }) + } + + fn terminate(&self) -> Result<()> { + crate::windows_integration::VerifiedStopProcess::terminate(self) + } + + fn request_close(&self) -> Result { + self.request_main_close() + } +} + +fn capture_and_stop( + ids: impl IntoIterator, + mut capture: impl FnMut(u32, bool) -> Result>, + context: &StopContext, + timeout: Duration, + mut wait: impl FnMut(), +) -> Result { + let mut held = Vec::new(); + let mut seen = std::collections::HashSet::new(); + let mut skipped_context = None; + // 所有候选均持有句柄、完成核验后才允许首次终止;后续候选失败不会造成部分预先停止。 + for id in ids { + anyhow::ensure!(id != 0 && seen.insert(id), "主实例快照包含无效或重复 PID"); + let Some(process) = + capture(id, false).with_context(|| format!("无法核验 Codex 候选 PID {id}"))? + else { + continue; + }; + let Some(facts) = candidate_facts(&process, context)? else { + continue; + }; + match classify_target(&facts, context)? { + TargetClassification::Irrelevant | TargetClassification::Child => continue, + TargetClassification::Mismatch(reason) => { + if process.alive()? { + reject_skipped_owned_session(&facts, context)?; + skipped_context.get_or_insert(reason); + } + continue; + } + TargetClassification::Main => {} + } + let Some(stoppable) = capture(id, true) + .with_context(|| format!("无法取得 Codex 主进程 PID {id} 的停止权限"))? + else { + anyhow::ensure!(!process.alive()?, "无法取得仍存活的 Codex 主进程句柄"); + continue; + }; + if !process.alive()? { + continue; + } + let current = stoppable + .facts() + .context("无法核验持有停止权限的 Codex 句柄")?; + anyhow::ensure!( + stoppable.alive()? + && current == facts + && matches!( + classify_target(¤t, context)?, + TargetClassification::Main + ), + "获取停止权限时 Codex PID {id} 的实例身份发生变化" + ); + held.push((stoppable, current)); + } + if held.is_empty() { + if let Some(reason) = skipped_context { + anyhow::bail!( + "检测到 Codex 主进程,但其{}与当前停止上下文不一致,未结束任何进程", + reason + ); + } + } + anyhow::ensure!( + !context.debug_port_only || held.len() == 1, + "无法核实已接管调试端口对应的 Codex 主进程,未按保存设置猜测停止目标" + ); + revalidate_held(&held, context)?; + let mut closing = Vec::new(); + for (process, _) in &held { + if process.request_close()? { + closing.push(process); + } + } + if !closing.is_empty() { + let grace_deadline = Instant::now() + timeout.min(Duration::from_millis(1_500)); + loop { + let mut pending = false; + for process in &closing { + pending |= process.alive()?; + } + if !pending || Instant::now() >= grace_deadline { + break; + } + wait(); + } + } + // WM_CLOSE 是正常退出机会,不是退出证明;强制前再次核验所有仍存活的原句柄。 + revalidate_held(&held, context)?; + let mut failure = None; + for (process, _) in &held { + if let Err(error) = process.terminate() { + failure.get_or_insert_with(|| { + error.context(format!("停止 Codex PID {} 失败", process.id())) + }); + } + } + let deadline = Instant::now() + timeout; + loop { + let mut alive = Vec::new(); + for (process, _) in &held { + match process.alive() { + Ok(true) => alive.push(process.id()), + Ok(false) => {} + Err(error) => { + failure.get_or_insert_with(|| error.context("无法确认原 Codex 句柄退出")); + } + } + } + if alive.is_empty() { + return match failure { + Some(error) => Err(error), + None => Ok(held.len()), + }; + } + if Instant::now() >= deadline { + if let Some(error) = failure { + return Err(error).with_context(|| { + format!("Codex 原主进程在等待结束后仍存活(PID {alive:?});未继续启动") + }); + } + anyhow::bail!("Codex 原主进程在等待结束后仍存活(PID {alive:?});未继续启动"); + } + wait(); + } +} + +fn candidate_facts( + process: &P, + context: &StopContext, +) -> Result> { + if context + .listener_identity + .is_some_and(|(id, _)| process.id() != id) + { + return Ok(None); + } + if !process.alive()? { + return Ok(None); + } + match process.possible_target(context)? { + Some(false) => return Ok(None), + Some(true) | None => {} + } + match process.facts() { + Ok(facts) => { + anyhow::ensure!( + context + .listener_identity + .is_none_or(|(_, birth)| facts.birth == birth), + "已接管的 Codex 监听进程创建身份发生变化" + ); + let image = normalized_path(&facts.image)?; + if !image_matches(&context.executable, &image) { + return Ok(None); + } + if facts.owner != context.owner { + return Ok(None); + } + Ok(Some(facts)) + } + Err(_) if !process.alive()? => Ok(None), + Err(error) => Err(error).context("无法完整核验 Codex 主实例"), + } +} + +fn reject_skipped_owned_session(facts: &ProcessFacts, context: &StopContext) -> Result<()> { + if facts.owner == context.owner + && context.owned_debug_port.is_some() + && parsed_arguments(&facts.arguments, &normalized_path(&facts.image)?)?.debug_port + == context.owned_debug_port + { + anyhow::bail!("当前后台会话的 Codex 进程与待停止的用户数据上下文不一致,无法确认停止"); + } + Ok(()) +} + +fn ensure_no_live_targets( + ids: impl IntoIterator, + mut capture: impl FnMut(u32, bool) -> Result>, + context: &StopContext, +) -> Result<()> { + for id in ids { + let Some(process) = + capture(id, false).with_context(|| format!("无法复核 Codex 候选 PID {id}"))? + else { + continue; + }; + let Some(facts) = candidate_facts(&process, context)? else { + continue; + }; + if !process.alive()? { + continue; + } + if target_matches(&facts, context)? { + anyhow::bail!("停止后检测到新的或仍存活的 Codex 主进程 PID {id},未确认退出"); + } + reject_skipped_owned_session(&facts, context)?; + } + Ok(()) +} + +fn revalidate_held( + held: &[(P, ProcessFacts)], + context: &StopContext, +) -> Result<()> { + for (process, previous) in held { + if !process.alive()? { + continue; + } + let current = match process.facts() { + Ok(facts) => facts, + Err(_) if !process.alive()? => continue, + Err(error) => return Err(error).context("停止前无法再次核验 Codex 主实例"), + }; + anyhow::ensure!( + current == *previous && target_matches(¤t, context)?, + "停止前 Codex PID {} 的实例身份发生变化", + process.id() + ); + } + Ok(()) +} + +fn target_matches(facts: &ProcessFacts, context: &StopContext) -> Result { + Ok(matches!( + classify_target(facts, context)?, + TargetClassification::Main + )) +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum TargetClassification { + Irrelevant, + Child, + Mismatch(&'static str), + Main, +} + +fn classify_target(facts: &ProcessFacts, context: &StopContext) -> Result { + anyhow::ensure!(facts.birth != 0, "缺少 Codex 进程创建身份"); + let image = normalized_path(&facts.image)?; + if !image_matches(&context.executable, &image) || facts.owner != context.owner { + return Ok(TargetClassification::Irrelevant); + } + let environment = &facts.environment; + let app_data = environment + .app_data + .as_deref() + .ok_or_else(|| anyhow::anyhow!("无法核实目标 Codex APPDATA"))?; + if normalized_path_text(app_data)? != context.app_data { + return Ok(TargetClassification::Mismatch("APPDATA")); + } + let arguments = parsed_arguments(&facts.arguments, &image)?; + if arguments.child { + return Ok(TargetClassification::Child); + } + let effective = effective_user_data(arguments.user_data, environment)?; + if environment + .agent_run_id + .as_deref() + .is_some_and(|value| !value.is_empty()) + { + return Ok(TargetClassification::Mismatch("agent 隔离标记")); + } + if context.debug_port_only { + // MSIX 激活的命令行可以不带调试参数;授权来自已持有的真实监听者身份。 + return Ok(if context.listener_identity.is_some() { + TargetClassification::Main + } else { + TargetClassification::Mismatch("已接管调试端口的监听身份") + }); + } + Ok(if effective == context.user_data { + TargetClassification::Main + } else { + TargetClassification::Mismatch("用户数据目录") + }) +} + +struct ParsedArguments { + child: bool, + user_data: Option, + debug_port: Option, +} + +fn parsed_arguments(arguments: &[String], image: &str) -> Result { + let first = arguments + .first() + .ok_or_else(|| anyhow::anyhow!("Codex 命令行缺少 argv[0]"))?; + anyhow::ensure!( + normalized_path_text(first)? == image, + "Codex argv[0] 与实际映像不一致" + ); + parsed_launch_arguments(&arguments[1..]) +} + +fn parsed_launch_arguments(arguments: &[String]) -> Result { + let mut child = None; + let mut user_data = None; + let mut debug_port = None; + let mut index = 0; + while index < arguments.len() { + let argument = &arguments[index]; + anyhow::ensure!( + argument.trim() != "--", + "无法安全识别含参数终止标记的 Codex 命令行" + ); + let key = super::windows_codex_switch_name(argument); + if key.is_some_and(|key| { + key.eq_ignore_ascii_case("type") + || key.eq_ignore_ascii_case("user-data-dir") + || key.eq_ignore_ascii_case("remote-debugging-port") + }) { + let key = key.unwrap(); + let (_, value) = argument + .trim() + .split_once('=') + .ok_or_else(|| anyhow::anyhow!("Codex 身份参数必须使用 --key=value 形式"))?; + anyhow::ensure!(!value.is_empty(), "Codex 身份参数为空"); + if key.eq_ignore_ascii_case("type") { + anyhow::ensure!( + child.replace(value.to_owned()).is_none(), + "Codex --type 参数重复" + ); + } else if key.eq_ignore_ascii_case("user-data-dir") { + anyhow::ensure!( + user_data.replace(normalized_path_text(value)?).is_none(), + "Codex --user-data-dir 参数重复" + ); + } else { + let port = value + .parse::() + .ok() + .filter(|port| *port != 0) + .ok_or_else(|| anyhow::anyhow!("Codex 调试端口参数无效"))?; + anyhow::ensure!(debug_port.replace(port).is_none(), "Codex 调试端口参数重复"); + } + } + index += 1; + } + Ok(ParsedArguments { + child: child.is_some(), + user_data, + debug_port, + }) +} + +fn normalized_path(path: &Path) -> Result { + normalized_path_text( + path.to_str() + .ok_or_else(|| anyhow::anyhow!("停止核验路径不是有效 Unicode"))?, + ) +} + +fn normalized_path_text(path: &str) -> Result { + let mut path = path.replace('/', "\\").to_lowercase(); + if let Some(rest) = path.strip_prefix("\\\\?\\unc\\") { + path = format!("\\\\{rest}"); + } else if let Some(rest) = path.strip_prefix("\\\\?\\") { + path = rest.to_owned(); + } + let drive_rooted = path.len() >= 3 + && path.as_bytes()[0].is_ascii_alphabetic() + && path.as_bytes()[1..3] == *b":\\"; + let body = if drive_rooted { + &path[3..] + } else if let Some(body) = path.strip_prefix("\\\\") { + anyhow::ensure!( + body.split('\\').filter(|part| !part.is_empty()).count() >= 2, + "停止核验需要完整 UNC 路径" + ); + body + } else { + anyhow::bail!("停止核验需要绝对 Windows 路径") + }; + anyhow::ensure!( + !body.contains(['\0', ':', '"', '*', '?']) + && body + .split('\\') + .all(|part| { part != "." && part != ".." && !part.ends_with([' ', '.']) }), + "停止核验路径包含无法安全归一化的部分" + ); + while path.ends_with('\\') && path.len() > 3 { + path.pop(); + } + Ok(path) +} + +fn image_matches(expected: &str, actual: &str) -> bool { + if expected == actual { + return true; + } + let Some((expected_family, expected_entry)) = packaged_entry(expected) else { + return false; + }; + let Some((actual_family, actual_entry)) = packaged_entry(actual) else { + return false; + }; + expected_family == actual_family && expected_entry == actual_entry +} + +fn packaged_entry(path: &str) -> Option<((String, String, String), &str)> { + let (root, after) = path.split_once("\\windowsapps\\")?; + let (package, entry) = after.split_once('\\')?; + let segments = package.split('_').collect::>(); + if segments.len() != 5 + || segments[0].is_empty() + || segments[4].is_empty() + || !matches!(segments[2], "x64" | "x86" | "arm64" | "neutral") + || segments[1].split('.').count() != 4 + || segments[1] + .split('.') + .any(|part| part.parse::().is_err()) + || !entry.starts_with("app\\") + || entry[4..].contains('\\') + { + return None; + } + Some(( + ( + root.to_owned(), + segments[0].to_owned(), + segments[4].to_owned(), + ), + entry, + )) +} + +#[cfg(test)] +mod tests { + use std::cell::{Cell, RefCell}; + use std::rc::Rc; + + use super::*; + + const IMAGE: &str = + r"C:\Program Files\WindowsApps\OpenAI.Codex_1.2.3.4_x64__publisher\app\ChatGPT.exe"; + const APP_DATA: &str = r"C:\Users\test\AppData\Roaming"; + + fn context(user_data: Option<&str>) -> StopContext { + StopContext { + executable: normalized_path_text(IMAGE).unwrap(), + user_data: user_data.map(|path| normalized_path_text(path).unwrap()), + debug_port_only: false, + listener_identity: None, + owner: StopProcessOwner { + user_sid: vec![1, 2, 3], + authentication_id: (10, 0), + session_id: 1, + }, + app_data: normalized_path_text(APP_DATA).unwrap(), + owned_debug_port: None, + } + } + + fn facts(arguments: &[&str]) -> ProcessFacts { + ProcessFacts { + birth: 100, + image: PathBuf::from(IMAGE), + arguments: std::iter::once(IMAGE) + .chain(arguments.iter().copied()) + .map(str::to_owned) + .collect(), + owner: context(None).owner, + environment: StopProcessEnvironment { + app_data: Some(APP_DATA.to_owned()), + user_data: None, + agent_run_id: None, + }, + } + } + + #[derive(Clone)] + struct FakeProcess(Rc); + + struct FakeState { + id: u32, + facts: Vec, + reads: Cell, + terminated: Cell, + stubborn: bool, + read_failure: bool, + stop_failure: bool, + graceful: bool, + window_present: bool, + close_requests: Cell, + events: Rc>>, + } + + impl HeldMainProcess for FakeProcess { + fn id(&self) -> u32 { + self.0.id + } + + fn alive(&self) -> Result { + Ok(!self.0.terminated.get() || self.0.stubborn) + } + + fn possible_target(&self, context: &StopContext) -> Result> { + let facts = &self.0.facts[0]; + let image = normalized_path(&facts.image)?; + if !image_matches(&context.executable, &image) { + return Ok(Some(false)); + } + Ok(match parsed_arguments(&facts.arguments, &image) { + Ok(arguments) => Some(!arguments.child), + Err(_) => None, + }) + } + + fn facts(&self) -> Result { + anyhow::ensure!(!self.0.read_failure, "模拟环境读取失败"); + let reads = self.0.reads.get(); + self.0.reads.set(reads + 1); + Ok(self.0.facts[reads.min(self.0.facts.len() - 1)].clone()) + } + + fn terminate(&self) -> Result<()> { + if self.0.terminated.get() && !self.0.stubborn { + return Ok(()); + } + self.0.events.borrow_mut().push(self.id()); + anyhow::ensure!(!self.0.stop_failure, "模拟终止失败"); + self.0.terminated.set(true); + Ok(()) + } + + fn request_close(&self) -> Result { + self.0.close_requests.set(self.0.close_requests.get() + 1); + if self.0.graceful { + self.0.terminated.set(true); + } + Ok(self.0.graceful || self.0.window_present) + } + } + + fn fake(id: u32, facts: Vec, events: &Rc>>) -> FakeProcess { + FakeProcess(Rc::new(FakeState { + id, + facts, + reads: Cell::new(0), + terminated: Cell::new(false), + stubborn: false, + read_failure: false, + stop_failure: false, + graceful: false, + window_present: false, + close_requests: Cell::new(0), + events: events.clone(), + })) + } + + fn stop(processes: Vec, context: &StopContext) -> Result { + capture_and_stop( + processes + .iter() + .map(HeldMainProcess::id) + .collect::>(), + |id, _| Ok(processes.iter().find(|process| process.id() == id).cloned()), + context, + Duration::ZERO, + || panic!("零超时测试不应等待"), + ) + } + + #[test] + fn stops_only_verified_main_handles() { + let events = Rc::new(RefCell::new(Vec::new())); + let main = fake(1, vec![facts(&["--remote-debugging-port=52106"])], &events); + let mut child = fake(2, vec![facts(&["--type=renderer"])], &events); + Rc::get_mut(&mut child.0).unwrap().read_failure = true; + assert_eq!(stop(vec![main.clone(), child], &context(None)).unwrap(), 1); + assert_eq!(*events.borrow(), vec![1]); + assert_eq!(main.0.reads.get(), 4); + } + + #[test] + fn graceful_window_close_avoids_forced_termination() { + let events = Rc::new(RefCell::new(Vec::new())); + let mut main = fake(1, vec![facts(&[])], &events); + Rc::get_mut(&mut main.0).unwrap().graceful = true; + assert_eq!(stop(vec![main.clone()], &context(None)).unwrap(), 1); + assert_eq!(main.0.close_requests.get(), 1); + assert_eq!(main.0.reads.get(), 3); + assert!(events.borrow().is_empty()); + } + + #[test] + fn identity_change_after_close_prevents_force() { + let events = Rc::new(RefCell::new(Vec::new())); + let initial = facts(&[]); + let mut changed = initial.clone(); + changed.environment.user_data = Some(r"C:\isolated".to_owned()); + let main = fake( + 1, + vec![initial.clone(), initial.clone(), initial, changed], + &events, + ); + assert!(stop(vec![main.clone()], &context(None)).is_err()); + assert_eq!(main.0.close_requests.get(), 1); + assert!(events.borrow().is_empty()); + } + + #[test] + fn window_that_does_not_exit_reaches_bounded_force_after_revalidation() { + let events = Rc::new(RefCell::new(Vec::new())); + let mut main = fake(1, vec![facts(&[])], &events); + Rc::get_mut(&mut main.0).unwrap().window_present = true; + assert_eq!(stop(vec![main.clone()], &context(None)).unwrap(), 1); + assert_eq!(main.0.close_requests.get(), 1); + assert_eq!(main.0.reads.get(), 4); + assert_eq!(*events.borrow(), vec![1]); + } + + #[test] + fn default_context_preserves_argv_environment_and_agent_isolation() { + let events = Rc::new(RefCell::new(Vec::new())); + let argument = facts(&["--user-data-dir=C:\\test\\isolated"]); + let mut environment = facts(&[]); + environment.environment.user_data = Some(r"C:\test\isolated".to_owned()); + let mut agent = facts(&[]); + agent.environment.agent_run_id = Some("isolated-agent".to_owned()); + let processes = [argument, environment, agent] + .into_iter() + .enumerate() + .map(|(index, facts)| fake(index as u32 + 1, vec![facts], &events)) + .collect(); + assert!(stop(processes, &context(None)).is_err()); + assert!(events.borrow().is_empty()); + } + + #[test] + fn no_live_processes_are_idempotent() { + let events = Rc::new(RefCell::new(Vec::::new())); + assert_eq!(stop(Vec::new(), &context(None)).unwrap(), 0); + assert!(events.borrow().is_empty()); + } + + #[test] + fn malformed_arguments_from_another_owner_are_ignored() { + let events = Rc::new(RefCell::new(Vec::new())); + let mut unrelated = facts(&["--user-data-dir"]); + unrelated.owner.user_sid.push(9); + assert_eq!( + stop(vec![fake(1, vec![unrelated], &events)], &context(None)).unwrap(), + 0 + ); + assert!(events.borrow().is_empty()); + } + + #[test] + fn windows_switch_variants_preserve_isolated_and_child_processes() { + for argument in [ + "--USER-DATA-DIR=C:\\isolated", + "-user-data-dir=C:\\isolated", + "/User-Data-Dir=C:\\isolated", + " --user-data-dir=C:\\isolated ", + ] { + assert!(!target_matches(&facts(&[argument]), &context(None)).unwrap()); + } + for argument in ["--TYPE=renderer", "-type=renderer", "/Type=renderer"] { + assert!(!target_matches(&facts(&[argument]), &context(None)).unwrap()); + } + assert!( + target_matches( + &facts(&["--user-data-dir=C:\\one", "/USER-DATA-DIR=C:\\one"]), + &context(None) + ) + .is_err() + ); + } + + #[test] + fn skipped_owned_session_prevents_stopping_other_targets() { + let events = Rc::new(RefCell::new(Vec::new())); + let main = fake(1, vec![facts(&[])], &events); + let isolated = fake( + 2, + vec![facts(&[ + "/REMOTE-DEBUGGING-PORT=9329", + "--user-data-dir=C:\\isolated", + ])], + &events, + ); + let mut expected = context(None); + expected.owned_debug_port = Some(9329); + assert!(stop(vec![main, isolated], &expected).is_err()); + assert!(events.borrow().is_empty()); + } + + #[test] + fn post_stop_snapshot_rejects_a_new_matching_main_process() { + let events = Rc::new(RefCell::new(Vec::new())); + let replacement = fake(3, vec![facts(&[])], &events); + let result = + ensure_no_live_targets([3], |_, _| Ok(Some(replacement.clone())), &context(None)); + assert!(result.is_err()); + assert!(events.borrow().is_empty()); + } + + #[test] + fn explicit_context_requires_a_proven_exact_directory() { + let expected = context(Some(r"C:\test\isolated")); + let mut environment = facts(&[]); + environment.environment.user_data = Some(r"C:\test\isolated".to_owned()); + assert!(target_matches(&environment, &expected).unwrap()); + assert!( + target_matches(&facts(&["--user-data-dir=C:\\test\\isolated"]), &expected).unwrap() + ); + assert!(!target_matches(&facts(&[]), &expected).unwrap()); + assert!(!target_matches(&facts(&["--user-data-dir=C:\\test\\other"]), &expected).unwrap()); + } + + #[test] + fn custom_launch_context_supports_arguments_environment_and_override_precedence() { + let environment = facts(&[]).environment; + for arguments in [ + vec![r"--user-data-dir=C:\test\custom"], + vec![r"/USER-DATA-DIR=C:\test\custom"], + vec![r"-user-data-dir=C:\test\custom"], + ] { + let arguments = arguments.into_iter().map(str::to_owned).collect::>(); + let mut inherited = environment.clone(); + inherited.user_data = Some(r"C:\inherited".to_owned()); + let expected = stop_context( + Path::new(IMAGE), + Some(&arguments), + context(None).owner, + inherited, + None, + ) + .unwrap(); + assert_eq!(expected.user_data.as_deref(), Some(r"c:\test\custom")); + assert!( + target_matches(&facts(&[r"--user-data-dir=C:\test\custom"]), &expected).unwrap() + ); + assert!(!target_matches(&facts(&[]), &expected).unwrap()); + } + let mut inherited = environment.clone(); + inherited.user_data = Some(r"C:\test\environment".to_owned()); + let expected = stop_context( + Path::new(IMAGE), + Some(&[]), + context(None).owner, + inherited.clone(), + None, + ) + .unwrap(); + assert_eq!(expected.user_data.as_deref(), Some(r"c:\test\environment")); + let mut candidate = facts(&[]); + candidate.environment = inherited; + assert!(target_matches(&candidate, &expected).unwrap()); + candidate + .arguments + .push(r"--user-data-dir=C:\other".to_owned()); + assert!(target_matches(&candidate, &expected).is_err()); + let mut agent = environment; + agent.agent_run_id = Some("agent".to_owned()); + assert!( + stop_context( + Path::new(IMAGE), + Some(&[]), + context(None).owner, + agent, + None + ) + .is_err() + ); + } + + #[test] + fn custom_stop_preserves_default_other_directory_and_agent_instances() { + let events = Rc::new(RefCell::new(Vec::new())); + let selected = fake( + 1, + vec![facts(&[r"--user-data-dir=C:\test\custom"])], + &events, + ); + let default = fake(2, vec![facts(&[])], &events); + let other = fake(3, vec![facts(&[r"--user-data-dir=C:\test\other"])], &events); + let mut agent_facts = facts(&[r"--user-data-dir=C:\test\custom"]); + agent_facts.environment.agent_run_id = Some("agent".to_owned()); + let agent = fake(4, vec![agent_facts], &events); + assert_eq!( + stop( + vec![selected, default.clone(), other.clone(), agent.clone()], + &context(Some(r"C:\test\custom")), + ) + .unwrap(), + 1 + ); + assert_eq!(*events.borrow(), [1]); + assert!(default.alive().unwrap() && other.alive().unwrap() && agent.alive().unwrap()); + } + + #[test] + fn attached_session_requires_a_verified_port_instead_of_current_settings() { + let events = Rc::new(RefCell::new(Vec::new())); + let selected = fake( + 1, + vec![facts(&[ + r"--user-data-dir=C:\started", + "--remote-debugging-port=52106", + ])], + &events, + ); + let other = fake( + 2, + vec![facts(&[r"--user-data-dir=C:\changed-settings"])], + &events, + ); + let mut expected = stop_context( + Path::new(IMAGE), + None, + context(None).owner, + facts(&[]).environment, + Some(52106), + ) + .unwrap(); + expected.listener_identity = Some((1, 100)); + let same_port = fake( + 3, + vec![facts(&[ + r"--user-data-dir=C:\other", + "--remote-debugging-port=52106", + ])], + &events, + ); + assert_eq!( + stop(vec![selected, other.clone(), same_port.clone()], &expected).unwrap(), + 1 + ); + assert!(other.alive().unwrap()); + assert!(same_port.alive().unwrap()); + assert_eq!(*events.borrow(), [1]); + assert!(stop(vec![other], &expected).is_err()); + assert!( + stop_context( + Path::new(IMAGE), + None, + context(None).owner, + facts(&[]).environment, + None, + ) + .is_err() + ); + } + + #[test] + fn attached_listener_identity_allows_msix_arguments_without_a_debug_flag() { + let events = Rc::new(RefCell::new(Vec::new())); + let main = fake(1, vec![facts(&[])], &events); + let other = fake(2, vec![facts(&[])], &events); + let mut expected = context(None); + expected.debug_port_only = true; + expected.owned_debug_port = Some(52106); + expected.listener_identity = Some((1, 100)); + assert_eq!(stop(vec![main, other.clone()], &expected).unwrap(), 1); + assert!(other.alive().unwrap()); + assert_eq!(*events.borrow(), [1]); + } + + #[test] + fn known_scope_rejects_a_listener_using_another_directory_without_a_debug_flag() { + let expected = context(Some(r"C:\custom")); + assert!(ensure_listener_matches(&facts(&[]), &expected).is_err()); + assert!( + ensure_listener_matches(&facts(&[r"--user-data-dir=C:\custom"]), &expected,).is_ok() + ); + } + + #[test] + fn same_basename_in_another_image_or_package_is_preserved() { + let expected = context(None); + let mut candidate = facts(&[]); + candidate.image = PathBuf::from(r"C:\other\ChatGPT.exe"); + assert!(!target_matches(&candidate, &expected).unwrap()); + candidate.image = PathBuf::from(IMAGE.replace("OpenAI.Codex_", "OpenAI.ChatGPT_")); + assert!(!target_matches(&candidate, &expected).unwrap()); + candidate.image = PathBuf::from(IMAGE.replace("__publisher", "__other")); + assert!(!target_matches(&candidate, &expected).unwrap()); + } + + #[test] + fn package_update_retains_the_same_family_and_entry_requirement() { + let current = normalized_path_text(IMAGE).unwrap(); + let old = normalized_path_text(&IMAGE.replace("1.2.3.4", "1.2.2.0")).unwrap(); + assert!(image_matches(¤t, &old)); + assert!(!image_matches( + ¤t, + &old.replace("\\app\\", "\\resources\\") + )); + assert!(!image_matches( + ¤t, + &old.replace("chatgpt.exe", "codex.exe") + )); + assert!(!image_matches( + ¤t, + &old.replace("c:\\program files\\", "c:\\other\\") + )); + } + + #[test] + fn owner_and_appdata_mismatch_are_preserved_but_missing_appdata_is_an_error() { + let expected = context(None); + let mut candidate = facts(&[]); + candidate.owner.authentication_id.0 += 1; + assert!(!target_matches(&candidate, &expected).unwrap()); + candidate.owner = expected.owner.clone(); + candidate.owner.session_id += 1; + assert!(!target_matches(&candidate, &expected).unwrap()); + candidate.owner = expected.owner.clone(); + candidate.owner.user_sid.push(4); + assert!(!target_matches(&candidate, &expected).unwrap()); + candidate.owner = expected.owner.clone(); + candidate.environment.app_data = Some(r"C:\other\AppData".to_owned()); + assert!(!target_matches(&candidate, &expected).unwrap()); + candidate.environment.app_data = None; + assert!(target_matches(&candidate, &expected).is_err()); + } + + #[test] + fn ambiguous_or_malformed_identity_arguments_fail_closed() { + let cases = [ + vec!["--user-data-dir"], + vec!["--user-data-dir", r"C:\one"], + vec!["--remote-debugging-port", "52106"], + vec!["--type", "renderer"], + vec!["--user-data-dir="], + vec!["--user-data-dir=relative"], + vec!["--user-data-dir=C:\\one", "--user-data-dir=C:\\one"], + vec!["--type=renderer", "--type=renderer"], + vec!["--remote-debugging-port=no"], + vec![ + "--remote-debugging-port=52106", + "--remote-debugging-port=52106", + ], + vec!["--"], + ]; + for arguments in cases { + assert!( + target_matches(&facts(&arguments), &context(None)).is_err(), + "{arguments:?}" + ); + } + let mut conflict = facts(&["--user-data-dir=C:\\one"]); + conflict.environment.user_data = Some(r"C:\two".to_owned()); + assert!(target_matches(&conflict, &context(None)).is_err()); + } + + #[test] + fn paths_with_spaces_use_the_existing_windows_argv_boundaries() { + let candidate = facts(&[r"--user-data-dir=C:\a path\an instance"]); + assert!(target_matches(&candidate, &context(Some(r"C:\a path\an instance"))).unwrap()); + assert_eq!( + normalized_path_text(r"\\?\C:\A Path\Instance\").unwrap(), + r"c:\a path\instance" + ); + for path in [r"C:relative", r"C:\a\..\b", r"C:\a.", r"C:\a ", r"\\server"] { + assert!(normalized_path_text(path).is_err(), "{path}"); + } + } + + #[test] + fn later_capture_or_validation_failure_prevents_every_termination() { + let events = Rc::new(RefCell::new(Vec::new())); + let main = fake(1, vec![facts(&[])], &events); + let bad = fake(2, vec![facts(&["--user-data-dir="])], &events); + assert!(stop(vec![main.clone(), bad], &context(None)).is_err()); + assert!(events.borrow().is_empty()); + let result = capture_and_stop( + vec![1, 2], + |id, _| { + if id == 1 { + Ok(Some(main.clone())) + } else { + anyhow::bail!("模拟 ACCESS_DENIED") + } + }, + &context(None), + Duration::ZERO, + || {}, + ); + assert!(result.is_err()); + assert!(events.borrow().is_empty()); + } + + #[test] + fn only_verified_main_candidates_request_stop_rights() { + let events = Rc::new(RefCell::new(Vec::new())); + let requests = RefCell::new(Vec::new()); + let main = fake(1, vec![facts(&[])], &events); + let child = fake(2, vec![facts(&["--type=renderer"])], &events); + let processes = [main, child]; + let result = capture_and_stop( + [1, 2], + |id, terminate| { + requests.borrow_mut().push((id, terminate)); + Ok(processes.iter().find(|process| process.id() == id).cloned()) + }, + &context(None), + Duration::ZERO, + || {}, + ); + assert_eq!(result.unwrap(), 1); + assert_eq!(*requests.borrow(), vec![(1, false), (1, true), (2, false)]); + assert_eq!(*events.borrow(), vec![1]); + } + + #[test] + fn changed_identity_when_opening_stop_handle_prevents_all_actions() { + let events = Rc::new(RefCell::new(Vec::new())); + let initial = fake(1, vec![facts(&[])], &events); + let mut replacement_facts = facts(&[]); + replacement_facts.birth += 1; + let replacement = fake(1, vec![replacement_facts], &events); + let result = capture_and_stop( + [1], + |_, terminate| { + Ok(Some(if terminate { + replacement.clone() + } else { + initial.clone() + })) + }, + &context(None), + Duration::ZERO, + || {}, + ); + assert!(result.is_err()); + assert_eq!(initial.0.close_requests.get(), 0); + assert_eq!(replacement.0.close_requests.get(), 0); + assert!(events.borrow().is_empty()); + } + + #[test] + fn unreadable_or_changed_identity_prevents_every_termination() { + let events = Rc::new(RefCell::new(Vec::new())); + let main = fake(1, vec![facts(&[])], &events); + let mut unknown = fake(2, vec![facts(&[])], &events); + Rc::get_mut(&mut unknown.0).unwrap().read_failure = true; + assert!(stop(vec![main.clone(), unknown], &context(None)).is_err()); + let initial = facts(&[]); + let mut changed = initial.clone(); + changed.environment.user_data = Some(r"C:\isolated".to_owned()); + let changing = fake(3, vec![initial, changed], &events); + assert!(stop(vec![main, changing], &context(None)).is_err()); + assert!(events.borrow().is_empty()); + } + + #[test] + fn zero_creation_identity_prevents_termination() { + let events = Rc::new(RefCell::new(Vec::new())); + let mut candidate = facts(&[]); + candidate.birth = 0; + assert!(stop(vec![fake(1, vec![candidate], &events)], &context(None)).is_err()); + assert!(events.borrow().is_empty()); + } + + #[test] + fn termination_failure_and_surviving_original_handle_are_reported() { + let events = Rc::new(RefCell::new(Vec::new())); + let mut failure = fake(1, vec![facts(&[])], &events); + Rc::get_mut(&mut failure.0).unwrap().stop_failure = true; + assert!(stop(vec![failure], &context(None)).is_err()); + let mut stubborn = fake(2, vec![facts(&[])], &events); + Rc::get_mut(&mut stubborn.0).unwrap().stubborn = true; + assert!(stop(vec![stubborn], &context(None)).is_err()); + assert_eq!(*events.borrow(), vec![1, 2]); + } + + #[cfg(windows)] + struct OwnedTestChild(std::process::Child); + + #[cfg(windows)] + impl Drop for OwnedTestChild { + fn drop(&mut self) { + let _ = self.0.kill(); + let _ = self.0.wait(); + } + } + + #[cfg(windows)] + #[test] + fn owned_process_stop_preserves_an_isolated_sibling() { + use std::os::windows::process::CommandExt; + use std::process::{Command, Stdio}; + + use crate::windows_integration::{CREATE_NO_WINDOW, VerifiedStopProcess}; + + let fixture = tempfile::tempdir().unwrap(); + let app_data = fixture.path().join("appdata"); + let user_data = fixture.path().join("isolated"); + let executable = std::env::current_exe().unwrap(); + let spawn = |isolated: bool| { + let mut command = Command::new(&executable); + command + .args([ + "--exact", + "windows_integration::tests::stop_identity_child_process", + "--ignored", + "--nocapture", + ]) + .env_clear() + .env("CODEX_PLUS_STOP_IDENTITY_TEST_CHILD", "owned-helper") + .env("APPDATA", &app_data) + .stdin(Stdio::piped()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .creation_flags(CREATE_NO_WINDOW); + if isolated { + command.env("CODEX_ELECTRON_USER_DATA_PATH", &user_data); + } + OwnedTestChild(command.spawn().unwrap()) + }; + let mut main = spawn(false); + let mut isolated = spawn(true); + assert!(main.0.try_wait().unwrap().is_none()); + assert!(isolated.0.try_wait().unwrap().is_none()); + let owner = VerifiedStopProcess::capture(std::process::id(), false) + .unwrap() + .unwrap() + .stop_owner() + .unwrap(); + let context = StopContext { + executable: normalized_path(&executable).unwrap(), + user_data: None, + debug_port_only: false, + listener_identity: None, + owner, + app_data: normalized_path(&app_data).unwrap(), + owned_debug_port: None, + }; + let count = capture_and_stop( + [main.0.id(), isolated.0.id()], + VerifiedStopProcess::capture, + &context, + Duration::from_secs(5), + || std::thread::sleep(Duration::from_millis(10)), + ) + .unwrap(); + assert_eq!(count, 1); + assert!(main.0.try_wait().unwrap().is_some()); + assert!(isolated.0.try_wait().unwrap().is_none()); + drop(isolated.0.stdin.take()); + } + + #[cfg(windows)] + #[test] + fn public_stop_api_stops_only_its_selected_main() { + use std::os::windows::process::CommandExt; + use std::process::{Command, Stdio}; + + for mode in ["default", "environment"] { + let fixture = tempfile::tempdir().unwrap(); + let app_data = fixture.path().join("appdata"); + std::fs::create_dir(&app_data).unwrap(); + // 原名 driver 不会进入 ChatGPT.exe 快照;仅子进程持有临时环境。 + let mut command = Command::new(std::env::current_exe().unwrap()); + command + .args([ + "--exact", + "watcher::windows_main_stop::tests::public_stop_api_driver", + "--ignored", + "--nocapture", + ]) + .env_clear() + .env("CODEX_PLUS_MAIN_STOP_API_TEST_DRIVER", mode) + .env("APPDATA", &app_data) + .stdin(Stdio::null()) + .creation_flags(crate::windows_integration::CREATE_NO_WINDOW); + if mode == "environment" { + command.env("CODEX_ELECTRON_USER_DATA_PATH", app_data.join("selected")); + } + let mut driver = OwnedTestChild(command.spawn().unwrap()); + assert!(driver.0.wait().unwrap().success(), "{mode}"); + assert_eq!( + std::fs::read(app_data.join("stop-api-completed")).unwrap(), + b"ok" + ); + } + } + + #[cfg(windows)] + #[test] + #[ignore = "仅由 public_stop_api_stops_only_its_selected_main 启动"] + fn public_stop_api_driver() { + use std::os::windows::process::CommandExt; + use std::process::{Command, Stdio}; + + let mode = std::env::var("CODEX_PLUS_MAIN_STOP_API_TEST_DRIVER").unwrap_or_default(); + if !matches!(mode.as_str(), "default" | "environment") { + return; + } + let original = std::env::current_exe().unwrap(); + assert!(!crate::app_paths::is_windows_desktop_app_executable_name( + original.file_name().unwrap().to_str().unwrap() + )); + let app_data = PathBuf::from(std::env::var_os("APPDATA").unwrap()); + assert!(app_data.is_absolute()); + let fixture = tempfile::tempdir_in(&app_data).unwrap(); + let executable = fixture.path().join("ChatGPT.exe"); + std::fs::copy(&original, &executable).unwrap(); + let selected = app_data.join("selected"); + let spawn = |user_data: Option<&Path>| { + let mut command = Command::new(&executable); + command + .args([ + "--exact", + "windows_integration::tests::stop_identity_child_process", + "--ignored", + "--nocapture", + ]) + .env_clear() + .env("CODEX_PLUS_STOP_IDENTITY_TEST_CHILD", "owned-helper") + .env("APPDATA", &app_data) + .stdin(Stdio::piped()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .creation_flags(crate::windows_integration::CREATE_NO_WINDOW); + if let Some(user_data) = user_data { + command.env("CODEX_ELECTRON_USER_DATA_PATH", user_data); + } + OwnedTestChild(command.spawn().unwrap()) + }; + let mut main = spawn(None); + let mut isolated = spawn(Some(&selected)); + let mut other = spawn(Some(&fixture.path().join("other"))); + assert!(main.0.try_wait().unwrap().is_none()); + assert!(isolated.0.try_wait().unwrap().is_none()); + assert_eq!( + stop_windows_main_codex_processes_and_wait(&executable, None).unwrap(), + 1 + ); + assert_eq!(main.0.try_wait().unwrap().is_some(), mode == "default"); + assert_eq!( + isolated.0.try_wait().unwrap().is_some(), + mode == "environment" + ); + assert!(other.0.try_wait().unwrap().is_none()); + assert!( + stop_windows_main_codex_processes_and_wait(&executable, None).is_err(), + "{mode}: a live Codex main process exists under another context" + ); + if mode == "default" { + let arguments = vec![format!("--user-data-dir={}", selected.display())]; + assert_eq!( + stop_windows_main_codex_processes_with_args_and_wait( + &executable, + None, + Some(&arguments), + ) + .unwrap(), + 1 + ); + assert!(isolated.0.try_wait().unwrap().is_some()); + } else { + assert!(main.0.try_wait().unwrap().is_none()); + } + assert!(other.0.try_wait().unwrap().is_none()); + std::fs::write(app_data.join("stop-api-completed"), b"ok").unwrap(); + } +} diff --git a/crates/codex-plus-core/src/windows_integration.rs b/crates/codex-plus-core/src/windows_integration.rs index 46bf82d7c..9840b9a40 100644 --- a/crates/codex-plus-core/src/windows_integration.rs +++ b/crates/codex-plus-core/src/windows_integration.rs @@ -630,6 +630,67 @@ pub(crate) struct VerifiedStopProcess { handle: std::os::windows::io::OwnedHandle, } +#[cfg(any(windows, test))] +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct StopProcessEnvironment { + pub app_data: Option, + pub user_data: Option, + pub agent_run_id: Option, +} + +#[cfg(any(windows, test))] +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct StopProcessOwner { + pub user_sid: Vec, + pub authentication_id: (u32, i32), + pub session_id: u32, +} + +/// 只保留停止核验需要的键;完整解析失败时不输出目标的其他环境数据。 +#[cfg(any(windows, test))] +fn stop_process_environment_from_block(block: &[u16]) -> anyhow::Result { + let end = block + .windows(2) + .position(|pair| pair == [0, 0]) + .ok_or_else(|| anyhow::anyhow!("进程环境缺少完整结束标记"))?; + anyhow::ensure!(end + 2 == block.len(), "进程环境结束标记后仍有数据"); + let mut result = StopProcessEnvironment { + app_data: None, + user_data: None, + agent_run_id: None, + }; + if end == 0 { + return Ok(result); + } + for entry in block[..end].split(|unit| *unit == 0) { + let entry = String::from_utf16(entry) + .map_err(|_| anyhow::anyhow!("进程环境包含无效 UTF-16"))?; + // Windows 的 =C:=... 是合法的隐藏驱动器环境变量。 + let separator = entry + .char_indices() + .skip(usize::from(entry.starts_with('='))) + .find(|(_, ch)| *ch == '=') + .map(|(index, _)| index) + .ok_or_else(|| anyhow::anyhow!("进程环境包含无法解析的条目"))?; + let (key, value) = entry.split_at(separator); + anyhow::ensure!(!key.is_empty(), "进程环境包含空键"); + let target = if key.eq_ignore_ascii_case("APPDATA") { + Some(&mut result.app_data) + } else if key.eq_ignore_ascii_case("CODEX_ELECTRON_USER_DATA_PATH") { + Some(&mut result.user_data) + } else if key.eq_ignore_ascii_case("CODEX_ELECTRON_AGENT_RUN_ID") { + Some(&mut result.agent_run_id) + } else { + None + }; + if let Some(target) = target { + anyhow::ensure!(target.is_none(), "停止核验所需环境键重复"); + *target = Some(value[1..].to_owned()); + } + } + Ok(result) +} + #[cfg(windows)] impl VerifiedStopProcess { pub fn capture(process_id: u32, terminate: bool) -> anyhow::Result> { @@ -685,6 +746,73 @@ impl VerifiedStopProcess { .ok_or_else(|| anyhow::anyhow!("无法读取多开进程参数")) } + pub fn stop_environment(&self) -> anyhow::Result { + let address = process_environment_address(self.native())?; + let mut block = Vec::new(); + // 每次最多读一个较小块;跨不可读页时退回单个 UTF-16 单元。 + const MAX_ENVIRONMENT_UNITS: usize = 512 * 1024; + while block.len() < MAX_ENVIRONMENT_UNITS { + let offset = block + .len() + .checked_mul(2) + .and_then(|offset| address.checked_add(offset)) + .ok_or_else(|| anyhow::anyhow!("进程环境地址溢出"))?; + let mut chunk = [0_u16; 256]; + let count = (MAX_ENVIRONMENT_UNITS - block.len()).min(chunk.len()); + let read = read_process_memory_bytes( + self.native(), + offset, + chunk.as_mut_ptr().cast(), + count * 2, + ); + let units = if read == Some(count * 2) { + count + } else { + chunk[0] = read_process_memory::(self.native(), offset) + .ok_or_else(|| anyhow::anyhow!("无法完整读取进程环境"))?; + 1 + }; + for unit in chunk.into_iter().take(units) { + let complete = unit == 0 && block.last() == Some(&0); + block.push(unit); + if complete { + anyhow::ensure!( + process_environment_address(self.native())? == address, + "读取期间进程环境地址发生变化" + ); + return stop_process_environment_from_block(&block); + } + } + } + anyhow::bail!("进程环境超过安全读取上限") + } + + pub fn stop_owner(&self) -> anyhow::Result { + process_stop_owner(self.native()) + } + + pub fn request_main_close(&self) -> anyhow::Result { + if !self.is_alive()? { + return Ok(false); + } + let Some(hwnd) = visible_window_for_process(self.process_id) else { + return Ok(false); + }; + if !self.is_alive()? { + return Ok(false); + } + let mut window_process_id = 0; + unsafe { GetWindowThreadProcessId(hwnd, Some(&mut window_process_id)) }; + anyhow::ensure!( + window_process_id == self.process_id, + "关闭前 Codex 窗口归属发生变化" + ); + // HWND 不随进程句柄固定,故在投递前最后复核 owner,绝不按标题/类名关闭别的窗口。 + unsafe { PostMessageW(hwnd, WM_CLOSE, WPARAM(0), LPARAM(0)) } + .context("无法请求已核验 Codex 窗口关闭")?; + Ok(true) + } + pub fn parent_id(&self) -> anyhow::Result { let mut info: ProcessBasicInformation = unsafe { std::mem::zeroed() }; let mut returned = 0_u32; @@ -781,6 +909,10 @@ const X64_COMMAND_LINE_OFFSET: usize = 0x70; const X86_PROCESS_PARAMETERS_OFFSET: usize = 0x10; #[cfg(windows)] const X86_COMMAND_LINE_OFFSET: usize = 0x40; +#[cfg(windows)] +const X64_ENVIRONMENT_OFFSET: usize = 0x80; +#[cfg(windows)] +const X86_ENVIRONMENT_OFFSET: usize = 0x48; /// Windows 命令行上限 32767 个字符;`UNICODE_STRING.length` 是字节数。 #[cfg(windows)] @@ -824,9 +956,185 @@ unsafe extern "system" { ) -> i32; } +#[cfg(windows)] +#[link(name = "advapi32")] +unsafe extern "system" { + fn OpenProcessToken(process: HANDLE, access: u32, token: *mut HANDLE) -> i32; + fn GetTokenInformation( + token: HANDLE, + information_class: u32, + information: *mut core::ffi::c_void, + information_length: u32, + return_length: *mut u32, + ) -> i32; +} + +#[cfg(windows)] +#[repr(C)] +#[derive(Default)] +struct StopLuid { + low: u32, + high: i32, +} + +#[cfg(windows)] +#[repr(C)] +#[derive(Default)] +struct StopTokenStatistics { + token_id: StopLuid, + authentication_id: StopLuid, + expiration_time: i64, + token_type: i32, + impersonation_level: i32, + dynamic_charged: u32, + dynamic_available: u32, + group_count: u32, + privilege_count: u32, + modified_id: StopLuid, +} + +#[cfg(windows)] +fn process_stop_owner(process: HANDLE) -> anyhow::Result { + use std::os::windows::io::FromRawHandle; + let mut token = HANDLE::default(); + anyhow::ensure!( + unsafe { OpenProcessToken(process, 0x0008, &mut token) } != 0, + "无法读取进程登录身份:{}", + std::io::Error::last_os_error() + ); + let _token_guard = unsafe { std::os::windows::io::OwnedHandle::from_raw_handle(token.0) }; + let mut statistics = StopTokenStatistics::default(); + let mut returned = 0; + anyhow::ensure!( + unsafe { + GetTokenInformation( + token, + 10, + (&mut statistics as *mut StopTokenStatistics).cast(), + std::mem::size_of::() as u32, + &mut returned, + ) + } != 0 + && returned as usize == std::mem::size_of::(), + "无法完整读取进程登录统计" + ); + let mut session_id = 0_u32; + anyhow::ensure!( + unsafe { + GetTokenInformation( + token, + 12, + (&mut session_id as *mut u32).cast(), + std::mem::size_of::() as u32, + &mut returned, + ) + } != 0 + && returned as usize == std::mem::size_of::(), + "无法完整读取进程 Windows 会话" + ); + let mut required = 0; + let query = unsafe { GetTokenInformation(token, 1, std::ptr::null_mut(), 0, &mut required) }; + anyhow::ensure!( + query == 0 + && std::io::Error::last_os_error().raw_os_error() == Some(122) + && (std::mem::size_of::()..=65536).contains(&(required as usize)), + "无法确定进程用户身份大小" + ); + let mut user = vec![0_u8; required as usize]; + anyhow::ensure!( + unsafe { + GetTokenInformation( + token, + 1, + user.as_mut_ptr().cast(), + required, + &mut returned, + ) + } != 0 + && returned <= required, + "无法完整读取进程用户身份" + ); + user.truncate(returned as usize); + anyhow::ensure!(user.len() >= std::mem::size_of::(), "用户身份结构不完整"); + let sid = unsafe { std::ptr::read_unaligned(user.as_ptr().cast::()) }; + let start = sid + .checked_sub(user.as_ptr() as usize) + .ok_or_else(|| anyhow::anyhow!("用户 SID 地址不在结果内"))?; + let header = user + .get(start..) + .filter(|tail| tail.len() >= 8 && tail[0] == 1 && tail[1] <= 15) + .ok_or_else(|| anyhow::anyhow!("用户 SID 结构不完整"))?; + let length = 8 + usize::from(header[1]) * 4; + let user_sid = header + .get(..length) + .ok_or_else(|| anyhow::anyhow!("用户 SID 长度不完整"))? + .to_vec(); + Ok(StopProcessOwner { + user_sid, + authentication_id: ( + statistics.authentication_id.low, + statistics.authentication_id.high, + ), + session_id, + }) +} + +#[cfg(windows)] +fn process_environment_address(handle: HANDLE) -> anyhow::Result { + let mut peb32 = 0_usize; + let mut returned = 0_u32; + let status = unsafe { + NtQueryInformationProcess( + handle, + PROCESS_WOW64_INFORMATION_CLASS, + (&mut peb32 as *mut usize).cast(), + std::mem::size_of::() as u32, + &mut returned, + ) + }; + anyhow::ensure!( + status >= 0 && returned as usize == std::mem::size_of::(), + "无法核验目标进程位数" + ); + let (peb, parameters_offset, environment_offset, pointer_is_32_bit) = + if peb32 != 0 { + (peb32, X86_PROCESS_PARAMETERS_OFFSET, X86_ENVIRONMENT_OFFSET, true) + } else { + anyhow::ensure!( + std::mem::size_of::() == 8, + "当前架构不支持安全读取目标进程环境" + ); + ( + query_peb_base_address(handle) + .ok_or_else(|| anyhow::anyhow!("无法读取进程 PEB"))?, + X64_PROCESS_PARAMETERS_OFFSET, + X64_ENVIRONMENT_OFFSET, + false, + ) + }; + let read_pointer = |address| { + if pointer_is_32_bit { + read_process_memory::(handle, address).map(|value| value as usize) + } else { + read_process_memory::(handle, address) + } + .filter(|address| *address != 0) + }; + let parameters = peb + .checked_add(parameters_offset) + .and_then(read_pointer) + .ok_or_else(|| anyhow::anyhow!("无法读取进程参数地址"))?; + parameters + .checked_add(environment_offset) + .and_then(read_pointer) + .ok_or_else(|| anyhow::anyhow!("无法读取进程环境地址")) +} + #[cfg(windows)] #[link(name = "kernel32")] unsafe extern "system" { + fn ProcessIdToSessionId(process_id: u32, session_id: *mut u32) -> BOOL; + fn ReadProcessMemory( process_handle: HANDLE, base_address: *const core::ffi::c_void, @@ -836,6 +1144,25 @@ unsafe extern "system" { ) -> i32; } +/// 读取进程所属的 Windows 会话;已经退出的 PID 返回 `None`。 +/// +/// 该查询不需要打开进程句柄,停止主实例前先用它排除其他交互式会话, +/// 避免无关用户的 `ChatGPT.exe` 因句柄权限或坏 argv 阻断当前实例。 +#[cfg(windows)] +pub(crate) fn process_session_id(process_id: u32) -> anyhow::Result> { + anyhow::ensure!(process_id != 0, "拒绝查询无效进程 PID"); + let mut session_id = 0_u32; + if unsafe { ProcessIdToSessionId(process_id, &mut session_id).as_bool() } { + return Ok(Some(session_id)); + } + let error = std::io::Error::last_os_error(); + // ERROR_INVALID_PARAMETER:PID 已经退出或不再存在。 + if error.raw_os_error() == Some(87) { + return Ok(None); + } + Err(error).context("无法读取进程 Windows 会话") +} + #[cfg(windows)] fn query_peb_base_address(handle: HANDLE) -> Option { let mut information: ProcessBasicInformation = unsafe { std::mem::zeroed() }; @@ -1509,6 +1836,45 @@ impl Drop for RegistryKeyGuard { } } +#[cfg(test)] +mod stop_environment_tests { + use super::stop_process_environment_from_block; + + fn block(text: &str) -> Vec { + text.encode_utf16().collect() + } + + #[test] + fn returns_only_needed_environment_keys() { + let environment = stop_process_environment_from_block(&block( + "=C:=C:\\work\0OTHER=hidden\0APPDATA=C:\\Users\\test\\AppData\\Roaming\0CODEX_ELECTRON_USER_DATA_PATH=C:\\isolated\0CODEX_ELECTRON_AGENT_RUN_ID=agent\0\0", + )) + .unwrap(); + assert_eq!( + environment.app_data.as_deref(), + Some(r"C:\Users\test\AppData\Roaming") + ); + assert_eq!(environment.user_data.as_deref(), Some(r"C:\isolated")); + assert_eq!(environment.agent_run_id.as_deref(), Some("agent")); + } + + #[test] + fn missing_truncated_malformed_and_duplicate_environment_fail_closed() { + for text in [ + "APPDATA=C:\\test\0", + "APPDATA=C:\\test\0\0trailing", + "malformed\0\0", + "APPDATA=C:\\test\0appdata=C:\\test\0\0", + "CODEX_ELECTRON_USER_DATA_PATH=C:\\one\0codex_electron_user_data_path=C:\\one\0\0", + ] { + assert!(stop_process_environment_from_block(&block(text)).is_err()); + } + assert!(stop_process_environment_from_block(&[0xd800, 0, 0]).is_err()); + let empty = stop_process_environment_from_block(&[0, 0]).unwrap(); + assert!(empty.app_data.is_none()); + } +} + #[cfg(all(test, windows))] mod tests { use super::*; @@ -1542,4 +1908,103 @@ mod tests { "命令行里应包含自身可执行文件名:{command_line}" ); } + + #[test] + fn windows_argv_keeps_quoted_directory_values_together() { + let arguments = command_line_arguments( + r#""C:\Program Files\Codex\ChatGPT.exe" --user-data-dir "C:\a path\instance" --remote-debugging-port=52106"#, + ) + .unwrap(); + assert_eq!( + arguments, + [ + r"C:\Program Files\Codex\ChatGPT.exe", + "--user-data-dir", + r"C:\a path\instance", + "--remote-debugging-port=52106", + ] + ); + } + + #[test] + #[ignore = "仅由 owned_child_environment_and_owner_are_read_from_held_handles 启动"] + fn stop_identity_child_process() { + if std::env::var_os("CODEX_PLUS_STOP_IDENTITY_TEST_CHILD") + != Some(OsString::from("owned-helper")) + { + return; + } + use std::io::Read; + let mut bytes = Vec::new(); + std::io::stdin().read_to_end(&mut bytes).unwrap(); + } + + #[test] + fn owned_child_environment_and_owner_are_read_from_held_handles() { + use std::os::windows::process::CommandExt; + use std::process::{Child, Command, Stdio}; + use std::time::{Duration, Instant}; + + struct OwnedTestChild(Child); + impl Drop for OwnedTestChild { + fn drop(&mut self) { + // Child 的原始进程句柄只指向本测试明确创建的 helper。 + let _ = self.0.kill(); + let _ = self.0.wait(); + } + } + + let fixture_root = std::env::temp_dir().join("codex-stop-identity-fixture"); + let app_data = fixture_root.join("app-data"); + let user_data = fixture_root.join("user-data"); + let mut child = OwnedTestChild( + Command::new(std::env::current_exe().unwrap()) + .args([ + "--exact", + "windows_integration::tests::stop_identity_child_process", + "--ignored", + "--nocapture", + ]) + .env_clear() + .env("CODEX_PLUS_STOP_IDENTITY_TEST_CHILD", "owned-helper") + .env("APPDATA", &app_data) + .env("CODEX_ELECTRON_USER_DATA_PATH", &user_data) + .env("CODEX_ELECTRON_AGENT_RUN_ID", "owned-agent") + .stdin(Stdio::piped()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .creation_flags(CREATE_NO_WINDOW) + .spawn() + .unwrap(), + ); + let held = VerifiedStopProcess::capture(child.0.id(), false) + .unwrap() + .unwrap(); + assert!(held.is_alive().unwrap()); + assert_eq!(child_process_birth_id(&child.0), Some(held.birth_id)); + let environment = held.stop_environment().unwrap(); + assert_eq!( + environment.app_data.as_deref(), + app_data.to_str() + ); + assert_eq!( + environment.user_data.as_deref(), + user_data.to_str() + ); + assert_eq!(environment.agent_run_id.as_deref(), Some("owned-agent")); + let manager = VerifiedStopProcess::capture(std::process::id(), false) + .unwrap() + .unwrap(); + assert_eq!(held.stop_owner().unwrap(), manager.stop_owner().unwrap()); + drop(child.0.stdin.take()); + let deadline = Instant::now() + Duration::from_secs(5); + loop { + if child.0.try_wait().unwrap().is_some() { + break; + } + assert!(Instant::now() < deadline, "本次测试 helper 未在宽限内退出"); + std::thread::sleep(Duration::from_millis(10)); + } + assert!(!held.is_alive().unwrap()); + } }