From 0732039eaf74f2f2a235535b2f3602d3926b12ec Mon Sep 17 00:00:00 2001 From: Can Celik Date: Thu, 30 Jul 2026 23:39:03 +0300 Subject: [PATCH] fix: preserve windows sessions after ssh logout (#2098) * fix: preserve windows sessions after ssh logout refs #2008 * chore: document windows wmi safety refs #2008 --- Cargo.lock | 213 +++++++++++++++++++++++++++++++++ Cargo.toml | 2 + docs/next/CHANGELOG.md | 1 + src/platform/mod.rs | 5 + src/platform/windows.rs | 248 ++++++++++++++++++++++++++++++++++++++- src/server/autodetect.rs | 9 +- 6 files changed, 469 insertions(+), 9 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index abb6a400..42d3debe 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -520,6 +520,94 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" +[[package]] +name = "futures" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a88cf1f829d945f548cf8fec32c61b1f202b6d93b45848602fc02af4b12ad218" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-channel" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "262590f4fe6afeb0bc83be1daa64e52657fe185690a958af7f3ad0e92085c5ae" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2cd50c473c80f6d7c3670a752354b8e569b1a7cbfdc0419ec88e5edad85e0dc7" + +[[package]] +name = "futures-executor" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6754879cc9f2c66f88c6e5c35344bb0bdb0708b0352b1201815667c7eabc7458" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4577ecaa3c4f96589d473f679a71b596316f6641bc350038b962a5daf0085d7a" + +[[package]] +name = "futures-macro" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d6d3cde68c518367be28956066ddfef33813991b77a55005a69dae04bf3b10b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "futures-sink" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e34418ac499d6305c2fb5ad0ed2f6ac998c5f8ca209b4510f7f94242c647e307" + +[[package]] +name = "futures-task" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b231ed28831efb4a61a08580c4bc233ec56bc009f4cd8f52da2c3cb97df0c109" + +[[package]] +name = "futures-util" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a77a90a256fce34da66415271e30f94ee91c57b04b8a2c042d9cf3220179deaa" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "slab", +] + [[package]] name = "generic-array" version = "0.14.7" @@ -609,6 +697,7 @@ dependencies = [ "tracing-subscriber", "unicode-width", "windows-sys", + "wmi", ] [[package]] @@ -1566,6 +1655,12 @@ version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b2aa850e253778c88a04c3d7323b043aeda9d3e30d5971937c1855769763678e" +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + [[package]] name = "smallvec" version = "1.15.1" @@ -2184,12 +2279,107 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" +[[package]] +name = "windows" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "527fadee13e0c05939a6a05d5bd6eec6cd2e3dbd648b9f8e447c6518133d8580" +dependencies = [ + "windows-collections", + "windows-core", + "windows-future", + "windows-numerics", +] + +[[package]] +name = "windows-collections" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b2d95af1a8a14a3c7367e1ed4fc9c20e0a26e79551b1454d72583c97cc6610" +dependencies = [ + "windows-core", +] + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-future" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1d6f90251fe18a279739e78025bd6ddc52a7e22f921070ccdc67dde84c605cb" +dependencies = [ + "windows-core", + "windows-link", + "windows-threading", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "windows-link" version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" +[[package]] +name = "windows-numerics" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e2e40844ac143cdb44aead537bbf727de9b044e107a0f1220392177d15b0f26" +dependencies = [ + "windows-core", + "windows-link", +] + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + [[package]] name = "windows-sys" version = "0.61.2" @@ -2199,6 +2389,15 @@ dependencies = [ "windows-link", ] +[[package]] +name = "windows-threading" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3949bd5b99cafdf1c7ca86b43ca564028dfe27d66958f2470940f73d86d75b37" +dependencies = [ + "windows-link", +] + [[package]] name = "winnow" version = "0.7.15" @@ -2305,6 +2504,20 @@ dependencies = [ "wasmparser", ] +[[package]] +name = "wmi" +version = "0.18.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c81b85c57a57500e56669586496bf2abd5cf082b9d32995251185d105208b64" +dependencies = [ + "futures", + "log", + "serde", + "thiserror 2.0.18", + "windows", + "windows-core", +] + [[package]] name = "zmij" version = "1.0.21" diff --git a/Cargo.toml b/Cargo.toml index db574fad..9ff55610 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -48,9 +48,11 @@ schemars = { version = "1.2.1", features = ["derive"] } portable-pty = { path = "vendor/portable-pty" } [target.'cfg(windows)'.dependencies] +wmi = { version = "0.18.4", default-features = false } windows-sys = { version = "0.61.2", features = [ "Wdk_System_Threading", "Win32_Foundation", + "Win32_Globalization", "Win32_System_DataExchange", "Win32_System_Diagnostics_Debug", "Win32_System_Diagnostics_ToolHelp", diff --git a/docs/next/CHANGELOG.md b/docs/next/CHANGELOG.md index eb9ba325..7a9bac83 100644 --- a/docs/next/CHANGELOG.md +++ b/docs/next/CHANGELOG.md @@ -7,6 +7,7 @@ - Relicensed Herdr from AGPL-3.0-or-later to Apache-2.0. ### Fixed +- Detached Windows servers and pane processes now survive logout from the OpenSSH session that started them. (#2008) - Windows `agent start` now launches agents without native arguments instead of timing out on an invalid empty PowerShell argument list. (#2072) - Headless servers now resume restored agent sessions without waiting for a TUI client to attach. (#2064) - Vibe and other Kitty-keyboard pane applications now receive shifted letters and punctuation when they request associated text. (#2020) diff --git a/src/platform/mod.rs b/src/platform/mod.rs index ba92ca63..613bff1a 100644 --- a/src/platform/mod.rs +++ b/src/platform/mod.rs @@ -59,6 +59,11 @@ pub(crate) const fn capabilities() -> PlatformCapabilities { } } +#[cfg(not(windows))] +pub fn launch_server_daemon_command(command: &mut std::process::Command) -> std::io::Result { + command.spawn().map(|child| child.id()) +} + #[cfg(any(target_os = "linux", target_os = "macos"))] pub fn detach_server_daemon_command(command: &mut std::process::Command) { use std::os::unix::process::CommandExt; diff --git a/src/platform/windows.rs b/src/platform/windows.rs index 48b31c89..35ecfd82 100644 --- a/src/platform/windows.rs +++ b/src/platform/windows.rs @@ -1,6 +1,7 @@ use std::{ + cmp::Ordering, collections::{HashMap, HashSet, VecDeque}, - ffi::c_void, + ffi::{c_void, OsStr}, mem::{size_of, MaybeUninit}, path::PathBuf, ptr::{copy_nonoverlapping, null_mut}, @@ -15,6 +16,7 @@ use windows_sys::{ CloseHandle, GlobalFree, LocalFree, HANDLE, HWND, INVALID_HANDLE_VALUE, NTSTATUS, STATUS_SUCCESS, UNICODE_STRING, }, + Globalization::{CompareStringOrdinal, CSTR_EQUAL, CSTR_GREATER_THAN, CSTR_LESS_THAN}, System::{ Console::GetConsoleWindow, DataExchange::{CloseClipboard, EmptyClipboard, OpenClipboard, SetClipboardData}, @@ -25,7 +27,10 @@ use windows_sys::{ TH32CS_SNAPPROCESS, }, }, - JobObjects::IsProcessInJob, + JobObjects::{ + IsProcessInJob, JobObjectExtendedLimitInformation, QueryInformationJobObject, + JOBOBJECT_EXTENDED_LIMIT_INFORMATION, JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE, + }, Memory::{GlobalAlloc, GlobalLock, GlobalUnlock, GMEM_MOVEABLE}, Ole::CF_UNICODETEXT, Threading::{ @@ -248,6 +253,178 @@ pub(crate) fn configure_background_command_platform(command: &mut std::process:: command.creation_flags(CREATE_NO_WINDOW); } +pub fn launch_server_daemon_command(command: &mut std::process::Command) -> std::io::Result { + if current_job_kills_processes_on_close()? { + launch_server_daemon_with_wmi(command) + } else { + command.spawn().map(|child| child.id()) + } +} + +fn launch_server_daemon_with_wmi(command: &std::process::Command) -> std::io::Result { + // WMI resolves the class from this Rust type name, including CIM casing. + #[allow(non_camel_case_types)] + #[derive(serde::Deserialize)] + struct Win32_Process; + + // WMI serializes this embedded object using the matching CIM class name. + #[allow(non_camel_case_types)] + #[derive(serde::Serialize)] + struct Win32_ProcessStartup { + #[serde(rename = "CreateFlags")] + create_flags: u32, + #[serde(rename = "EnvironmentVariables")] + environment_variables: Vec, + } + + #[derive(serde::Serialize)] + struct CreateInput { + #[serde(rename = "CommandLine")] + command_line: String, + #[serde(rename = "CurrentDirectory")] + current_directory: String, + #[serde(rename = "ProcessStartupInformation")] + process_startup_information: Win32_ProcessStartup, + } + + #[derive(serde::Deserialize)] + struct CreateOutput { + #[serde(rename = "ProcessId")] + process_id: Option, + #[serde(rename = "ReturnValue")] + return_value: u32, + } + + let current_directory = command + .get_current_dir() + .map(std::path::Path::to_path_buf) + .map(Ok) + .unwrap_or_else(std::env::current_dir)?; + let input = CreateInput { + command_line: windows_command_line(command)?, + current_directory: unicode_windows_value( + ¤t_directory.into_os_string(), + "working directory", + )?, + process_startup_information: Win32_ProcessStartup { + create_flags: DETACHED_PROCESS, + environment_variables: effective_command_environment(command)?, + }, + }; + + let connection = wmi::WMIConnection::new() + .map_err(|err| std::io::Error::other(format!("failed to connect to WMI: {err}")))?; + let output: CreateOutput = connection + .exec_class_method::("Create", &input) + .map_err(|err| std::io::Error::other(format!("WMI Win32_Process.Create failed: {err}")))?; + if output.return_value != 0 { + return Err(std::io::Error::other(format!( + "WMI Win32_Process.Create returned error {}", + output.return_value + ))); + } + output.process_id.ok_or_else(|| { + std::io::Error::other("WMI Win32_Process.Create succeeded without a process id") + }) +} + +fn windows_command_line(command: &std::process::Command) -> std::io::Result { + std::iter::once(command.get_program()) + .chain(command.get_args()) + .map(|value| { + unicode_windows_value(value, "server command argument") + .map(|value| quote_windows_command_line_arg(&value)) + }) + .collect::>>() + .map(|parts| parts.join(" ")) +} + +fn effective_command_environment(command: &std::process::Command) -> std::io::Result> { + let mut environment = std::env::vars_os() + .map(|(key, value)| { + Ok(( + unicode_windows_value(&key, "inherited environment variable name")?, + unicode_windows_value(&value, "inherited environment variable value")?, + )) + }) + .collect::>>()?; + for (key, value) in command.get_envs() { + let key = unicode_windows_value(key, "environment variable name")?; + environment.retain(|(inherited, _)| windows_environment_key_cmp(inherited, &key).is_ne()); + if let Some(value) = value { + environment.push(( + key, + unicode_windows_value(value, "environment variable value")?, + )); + } + } + environment.sort_unstable_by(|(left, _), (right, _)| windows_environment_key_cmp(left, right)); + Ok(environment + .into_iter() + .map(|(key, value)| format!("{key}={value}")) + .collect()) +} + +fn windows_environment_key_cmp(left: &str, right: &str) -> Ordering { + let left_wide: Vec = left.encode_utf16().collect(); + let right_wide: Vec = right.encode_utf16().collect(); + // SAFETY: both pointers remain valid for the call and lengths count UTF-16 units. + match unsafe { + CompareStringOrdinal( + left_wide.as_ptr(), + left_wide.len() as i32, + right_wide.as_ptr(), + right_wide.len() as i32, + 1, + ) + } { + CSTR_LESS_THAN => Ordering::Less, + CSTR_EQUAL => Ordering::Equal, + CSTR_GREATER_THAN => Ordering::Greater, + _ => left.cmp(right), + } +} + +fn unicode_windows_value(value: &OsStr, label: &str) -> std::io::Result { + value.to_str().map(str::to_owned).ok_or_else(|| { + std::io::Error::new( + std::io::ErrorKind::InvalidData, + format!("{label} is not valid Unicode"), + ) + }) +} + +fn current_process_is_in_job() -> std::io::Result { + let mut in_job = 0; + // SAFETY: `in_job` is a valid writable BOOL for the duration of the call. + if unsafe { IsProcessInJob(GetCurrentProcess(), null_mut(), &mut in_job) } == 0 { + return Err(std::io::Error::last_os_error()); + } + Ok(in_job != 0) +} + +fn current_job_kills_processes_on_close() -> std::io::Result { + if !current_process_is_in_job()? { + return Ok(false); + } + + let mut limits = JOBOBJECT_EXTENDED_LIMIT_INFORMATION::default(); + // SAFETY: `limits` is writable and its exact buffer size is supplied. + if unsafe { + QueryInformationJobObject( + null_mut(), + JobObjectExtendedLimitInformation, + &mut limits as *mut _ as *mut c_void, + size_of::() as u32, + null_mut(), + ) + } == 0 + { + return Err(std::io::Error::last_os_error()); + } + Ok(limits.BasicLimitInformation.LimitFlags & JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE != 0) +} + pub fn detach_server_daemon_command(command: &mut std::process::Command) { use std::os::windows::process::CommandExt; @@ -259,8 +436,7 @@ pub fn current_process_is_detached_server_daemon() -> bool { return false; } - let mut in_job = 0; - unsafe { IsProcessInJob(GetCurrentProcess(), null_mut(), &mut in_job) != 0 && in_job == 0 } + matches!(current_process_is_in_job(), Ok(false)) } pub fn foreground_job(child_pid: u32) -> Option { @@ -1333,6 +1509,70 @@ mod tests { const CONSOLE_TEST_CHILD_ENV: &str = "HERDR_TEST_CONSOLE_CHILD_MODE"; const CONSOLE_TEST_PARENT_PID_ENV: &str = "HERDR_TEST_CONSOLE_PARENT_PID"; + const WMI_DAEMON_TEST_CHILD_ENV: &str = "HERDR_TEST_WMI_DAEMON_CHILD"; + + #[test] + fn windows_environment_keys_use_unicode_case_insensitive_ordering() { + assert_eq!( + super::windows_environment_key_cmp("hérdr", "HÉRDR"), + std::cmp::Ordering::Equal + ); + } + + #[test] + fn windows_wmi_daemon_preserves_environment_and_working_directory() { + if let Some(capture) = std::env::var_os(WMI_DAEMON_TEST_CHILD_ENV) { + let cwd = std::env::current_dir().expect("WMI daemon test working directory"); + fs::write( + capture, + format!( + "{}\n{}", + cwd.display(), + super::current_process_is_detached_server_daemon() + ), + ) + .expect("write WMI daemon test capture"); + return; + } + + let base = std::env::temp_dir().join(format!( + "herdr-wmi-daemon-test-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_millis() + )); + fs::create_dir_all(&base).unwrap(); + let capture = base.join("capture.txt"); + let test_exe = std::env::current_exe().expect("resolve test executable"); + let mut child = Command::new(test_exe); + child + .arg("windows_wmi_daemon_preserves_environment_and_working_directory") + .current_dir(&base) + .env(WMI_DAEMON_TEST_CHILD_ENV, &capture) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()); + + let pid = super::launch_server_daemon_with_wmi(&child) + .expect("launch detached process through WMI"); + assert_ne!(pid, 0, "WMI returned an invalid process id"); + + let expected = format!("{}\ntrue", base.display()); + let deadline = Instant::now() + Duration::from_secs(10); + loop { + if fs::read_to_string(&capture).is_ok_and(|captured| captured == expected) { + break; + } + assert!( + Instant::now() < deadline, + "WMI daemon child did not write the expected capture" + ); + thread::sleep(Duration::from_millis(50)); + } + let _ = fs::remove_dir_all(base); + } fn console_process_ids() -> Vec { let mut process_ids = vec![0; 8]; diff --git a/src/server/autodetect.rs b/src/server/autodetect.rs index 4b58196e..337c388f 100644 --- a/src/server/autodetect.rs +++ b/src/server/autodetect.rs @@ -198,11 +198,10 @@ pub fn spawn_server_daemon() -> io::Result { let mut command = build_server_daemon_command(exe); - let child = command.spawn().map_err(|err: io::Error| { - io::Error::new(err.kind(), format!("failed to spawn herdr server: {err}")) - })?; - - let pid = child.id(); + let pid = + crate::platform::launch_server_daemon_command(&mut command).map_err(|err: io::Error| { + io::Error::new(err.kind(), format!("failed to spawn herdr server: {err}")) + })?; info!(pid, "server daemon spawned"); Ok(pid)