diff --git a/docs/next/CHANGELOG.md b/docs/next/CHANGELOG.md index 1bf5bbed..545ee411 100644 --- a/docs/next/CHANGELOG.md +++ b/docs/next/CHANGELOG.md @@ -12,6 +12,7 @@ - Relicensed Herdr from AGPL-3.0-or-later to Apache-2.0. ### Fixed +- Windows agent detection now follows Git Bash-launched agents across emulated `exec` process boundaries. (#2107) - 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) diff --git a/src/pane.rs b/src/pane.rs index 4a10f8e4..c3e91f7f 100644 --- a/src/pane.rs +++ b/src/pane.rs @@ -116,6 +116,7 @@ fn apply_pane_launch_env(cmd: &mut CommandBuilder, launch_env: &PaneLaunchEnv) { } cmd.env(crate::HERDR_ENV_VAR, crate::HERDR_ENV_VALUE); crate::integration::apply_pane_base_env(cmd); + crate::platform::apply_pane_runtime_marker(cmd); match &launch_env.identity { PaneLaunchIdentity::Inherit => {} PaneLaunchIdentity::Managed { diff --git a/src/platform/mod.rs b/src/platform/mod.rs index 613bff1a..38d797d1 100644 --- a/src/platform/mod.rs +++ b/src/platform/mod.rs @@ -35,6 +35,13 @@ pub(crate) fn pane_custom_command_pty_builder(command: &str) -> portable_pty::Co pane_custom_command_pty_builder_platform(command) } +pub(crate) fn apply_pane_runtime_marker(command: &mut portable_pty::CommandBuilder) { + apply_pane_runtime_marker_platform(command); +} + +#[cfg(not(windows))] +fn apply_pane_runtime_marker_platform(_command: &mut portable_pty::CommandBuilder) {} + pub(crate) fn configure_background_command(command: &mut std::process::Command) { configure_background_command_platform(command); } diff --git a/src/platform/windows.rs b/src/platform/windows.rs index f384ea02..55e0fd0b 100644 --- a/src/platform/windows.rs +++ b/src/platform/windows.rs @@ -5,16 +5,19 @@ use std::{ mem::{size_of, MaybeUninit}, path::PathBuf, ptr::{copy_nonoverlapping, null_mut}, - sync::{Arc, Mutex}, - time::{Duration, Instant}, + sync::{ + atomic::{AtomicU64, Ordering as AtomicOrdering}, + Arc, LazyLock, Mutex, + }, + time::{Duration, Instant, SystemTime, UNIX_EPOCH}, }; use windows_sys::{ Wdk::System::Threading::{NtQueryInformationProcess, ProcessBasicInformation}, Win32::{ Foundation::{ - CloseHandle, GlobalFree, LocalFree, HANDLE, HWND, INVALID_HANDLE_VALUE, NTSTATUS, - STATUS_SUCCESS, UNICODE_STRING, + CloseHandle, GlobalFree, LocalFree, FILETIME, HANDLE, HWND, INVALID_HANDLE_VALUE, + NTSTATUS, STATUS_SUCCESS, UNICODE_STRING, }, Globalization::{CompareStringOrdinal, CSTR_EQUAL, CSTR_GREATER_THAN, CSTR_LESS_THAN}, System::{ @@ -31,11 +34,15 @@ use windows_sys::{ IsProcessInJob, JobObjectExtendedLimitInformation, QueryInformationJobObject, JOBOBJECT_EXTENDED_LIMIT_INFORMATION, JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE, }, - Memory::{GlobalAlloc, GlobalLock, GlobalUnlock, GMEM_MOVEABLE}, + Memory::{ + GlobalAlloc, GlobalLock, GlobalUnlock, VirtualQueryEx, GMEM_MOVEABLE, + MEMORY_BASIC_INFORMATION, + }, Ole::CF_UNICODETEXT, Threading::{ - GetCurrentProcess, GetExitCodeProcess, OpenProcess, TerminateProcess, - CREATE_NO_WINDOW, DETACHED_PROCESS, PROCESS_BASIC_INFORMATION, + GetCurrentProcess, GetExitCodeProcess, GetProcessTimes, OpenProcess, + QueryFullProcessImageNameW, TerminateProcess, CREATE_NO_WINDOW, DETACHED_PROCESS, + PROCESS_BASIC_INFORMATION, PROCESS_QUERY_INFORMATION, PROCESS_QUERY_LIMITED_INFORMATION, PROCESS_VM_READ, }, }, @@ -63,6 +70,18 @@ use super::{ClipboardImage, ForegroundJob, Signal}; const STILL_ACTIVE: u32 = 259; const FOREGROUND_PROCESS_SNAPSHOT_CACHE_TTL: Duration = Duration::from_millis(250); +const PANE_RUNTIME_MARKER_ENV_VAR: &str = "HERDR_PANE_RUNTIME_ID"; +const MAX_PROCESS_ENVIRONMENT_BYTES: usize = 256 * 1024; +const PROCESS_ENVIRONMENT_READ_CHUNK_BYTES: usize = 16 * 1024; +const PROCESS_RUNTIME_MARKER_CACHE_CAPACITY: usize = 1_024; +const PROCESS_RUNTIME_MARKER_CACHE_RETENTION: Duration = Duration::from_secs(60); +const PROCESS_RUNTIME_MARKER_NEGATIVE_TTL: Duration = Duration::from_secs(1); + +static NEXT_PANE_RUNTIME_MARKER: AtomicU64 = AtomicU64::new(1); +static PROCESS_RUNTIME_MARKER_CACHE: LazyLock>> = + LazyLock::new(|| Mutex::new(HashMap::new())); +static GIT_BASH_PROCESS_CACHE: LazyLock>> = + LazyLock::new(|| Mutex::new(HashMap::new())); /// Encode native or targeted semantic Win32 input for a compatible ConPTY destination. pub(crate) fn encode_windows_conpty_fallback(key: &crate::input::TerminalKey) -> Option> { @@ -110,6 +129,21 @@ struct ProcessSnapshotCache { cached: Option, } +#[derive(Debug)] +struct CachedProcessRuntimeMarker { + creation_time: u64, + marker: Option, + cached_at: Instant, + last_used: Instant, +} + +#[derive(Debug)] +struct CachedGitBashProcess { + creation_time: u64, + is_git_bash: bool, + last_used: Instant, +} + static FOREGROUND_PROCESS_SNAPSHOT_CACHE: Mutex = Mutex::new(ProcessSnapshotCache { cached: None }); @@ -129,6 +163,21 @@ struct WindowsProcessEntry { pub fn raise_server_nofile_limit() {} +pub(crate) fn apply_pane_runtime_marker_platform(command: &mut portable_pty::CommandBuilder) { + if command_uses_git_bash(command) { + command.env(PANE_RUNTIME_MARKER_ENV_VAR, next_pane_runtime_marker()); + } +} + +fn next_pane_runtime_marker() -> String { + let timestamp = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|duration| duration.as_nanos()) + .unwrap_or(0); + let counter = NEXT_PANE_RUNTIME_MARKER.fetch_add(1, AtomicOrdering::Relaxed); + format!("{:x}-{timestamp:x}-{counter:x}", std::process::id()) +} + fn raw_command_shell(comspec: Option) -> std::ffi::OsString { comspec .filter(|value| !value.is_empty()) @@ -510,20 +559,62 @@ pub fn process_cwd(pid: u32) -> Option { fn select_pane_foreground_job( shell_pid: u32, entries: &[WindowsProcessEntry], +) -> Option { + select_pane_foreground_job_with_runtime_inspection( + shell_pid, + entries, + |shell| process_is_git_bash(shell.pid), + |entry| process_runtime_marker(entry.pid), + ) +} + +fn select_pane_foreground_job_with_runtime_inspection( + shell_pid: u32, + entries: &[WindowsProcessEntry], + shell_is_git_bash: impl FnOnce(&WindowsProcessEntry) -> bool, + mut runtime_marker: impl FnMut(&WindowsProcessEntry) -> Option, ) -> Option { let shell = entries.iter().find(|entry| entry.pid == shell_pid)?; let descendants = descendant_entries(shell_pid, entries); let mut candidates = Vec::new(); for entry in std::iter::once(shell).chain(descendants) { - if crate::detect::identify_agent_in_job(&foreground_job_from_entry(entry)).is_some() { + if process_entry_identifies_agent(entry) { candidates.push(entry); } } - let selected = select_topmost_agent_chain_candidate(&candidates, entries).unwrap_or(shell); + if let Some(selected) = select_topmost_agent_chain_candidate(&candidates, entries) { + return Some(foreground_job_from_entry(selected)); + } + if !candidates.is_empty() || !shell_is_git_bash(shell) { + return Some(foreground_job_from_entry(shell)); + } + + let escaped_candidates: Vec<_> = entries + .iter() + .filter(|entry| process_entry_identifies_agent(entry)) + .collect(); + if escaped_candidates.is_empty() { + return Some(foreground_job_from_entry(shell)); + } + + let Some(shell_runtime_marker) = runtime_marker(shell).filter(|marker| !marker.is_empty()) + else { + return Some(foreground_job_from_entry(shell)); + }; + let matching_candidates: Vec<_> = escaped_candidates + .into_iter() + .filter(|entry| runtime_marker(entry).as_deref() == Some(shell_runtime_marker.as_str())) + .collect(); + let selected = + select_topmost_agent_chain_candidate(&matching_candidates, entries).unwrap_or(shell); Some(foreground_job_from_entry(selected)) } +fn process_entry_identifies_agent(entry: &WindowsProcessEntry) -> bool { + crate::detect::identify_agent_in_job(&foreground_job_from_entry(entry)).is_some() +} + fn foreground_job_from_entry(entry: &WindowsProcessEntry) -> ForegroundJob { ForegroundJob { process_group_id: entry.pid, @@ -679,6 +770,288 @@ fn process_command_line(pid: u32) -> Option { read_unicode_string(process.0, parameters.command_line) } +fn process_is_git_bash(pid: u32) -> bool { + let Some(process) = ProcessHandle::open(pid, PROCESS_QUERY_LIMITED_INFORMATION) else { + return false; + }; + let Some(creation_time) = process_creation_time(process.0) else { + return false; + }; + { + let mut cache = GIT_BASH_PROCESS_CACHE + .lock() + .unwrap_or_else(|err| err.into_inner()); + if let Some(cached) = cache.get_mut(&pid) { + if cached.creation_time == creation_time { + cached.last_used = Instant::now(); + return cached.is_git_bash; + } + } + } + + let is_git_bash = process_executable_path(process.0) + .as_deref() + .is_some_and(|path| is_git_bash_executable_path(std::path::Path::new(path))); + let mut cache = GIT_BASH_PROCESS_CACHE + .lock() + .unwrap_or_else(|err| err.into_inner()); + if cache.len() >= PROCESS_RUNTIME_MARKER_CACHE_CAPACITY { + cache.retain(|_, cached| { + cached.last_used.elapsed() < PROCESS_RUNTIME_MARKER_CACHE_RETENTION + }); + if cache.len() >= PROCESS_RUNTIME_MARKER_CACHE_CAPACITY { + cache.clear(); + } + } + cache.insert( + pid, + CachedGitBashProcess { + creation_time, + is_git_bash, + last_used: Instant::now(), + }, + ); + is_git_bash +} + +fn process_executable_path(process: HANDLE) -> Option { + let mut path = vec![0_u16; 32_768]; + let mut len = path.len() as u32; + if unsafe { QueryFullProcessImageNameW(process, 0, path.as_mut_ptr(), &mut len) } == 0 { + return None; + } + String::from_utf16(&path[..len as usize]).ok() +} + +fn command_uses_git_bash(command: &portable_pty::CommandBuilder) -> bool { + let Some(program) = command.get_argv().first() else { + return false; + }; + let path = std::path::Path::new(program); + if path.is_absolute() { + return is_git_bash_executable_path(path); + } + if program.to_string_lossy().contains(['/', '\\']) { + return false; + } + + let Some(file_name) = path.file_name().and_then(OsStr::to_str) else { + return false; + }; + let candidate_name = if file_name.eq_ignore_ascii_case("bash") { + "bash.exe" + } else if file_name.eq_ignore_ascii_case("bash.exe") { + file_name + } else { + return false; + }; + let search_path = command + .get_env("PATH") + .map(OsStr::to_os_string) + .or_else(|| std::env::var_os("PATH")); + search_path.is_some_and(|search_path| { + std::env::split_paths(&search_path) + .map(|directory| directory.join(candidate_name)) + .find(|candidate| candidate.is_file()) + .is_some_and(|candidate| is_git_bash_executable_path(&candidate)) + }) +} + +fn is_git_bash_executable_path(path: &std::path::Path) -> bool { + let Some(file_name) = path.file_name().and_then(OsStr::to_str) else { + return false; + }; + if !file_name.eq_ignore_ascii_case("bash.exe") || !path.is_absolute() || !path.is_file() { + return false; + } + + let Some(bin_dir) = path.parent() else { + return false; + }; + if !bin_dir + .file_name() + .and_then(OsStr::to_str) + .is_some_and(|name| name.eq_ignore_ascii_case("bin")) + { + return false; + } + + let Some(mut root) = bin_dir.parent() else { + return false; + }; + if root + .file_name() + .and_then(OsStr::to_str) + .is_some_and(|name| name.eq_ignore_ascii_case("usr")) + { + let Some(parent) = root.parent() else { + return false; + }; + root = parent; + } + + root.join("usr").join("bin").join("msys-2.0.dll").is_file() + && root.join("cmd").join("git.exe").is_file() +} + +fn process_runtime_marker(pid: u32) -> Option { + let process = ProcessHandle::open(pid, PROCESS_QUERY_INFORMATION | PROCESS_VM_READ)?; + let creation_time = process_creation_time(process.0)?; + { + let mut cache = PROCESS_RUNTIME_MARKER_CACHE + .lock() + .unwrap_or_else(|err| err.into_inner()); + if let Some(cached) = cache.get_mut(&pid) { + if cached.creation_time == creation_time + && (cached.marker.is_some() + || cached.cached_at.elapsed() < PROCESS_RUNTIME_MARKER_NEGATIVE_TTL) + { + cached.last_used = Instant::now(); + return cached.marker.clone(); + } + } + } + + let marker = process_runtime_marker_from_handle(process.0)?; + let mut cache = PROCESS_RUNTIME_MARKER_CACHE + .lock() + .unwrap_or_else(|err| err.into_inner()); + if cache.len() >= PROCESS_RUNTIME_MARKER_CACHE_CAPACITY { + cache.retain(|_, cached| { + cached.last_used.elapsed() < PROCESS_RUNTIME_MARKER_CACHE_RETENTION + }); + if cache.len() >= PROCESS_RUNTIME_MARKER_CACHE_CAPACITY { + cache.clear(); + } + } + cache.insert( + pid, + CachedProcessRuntimeMarker { + creation_time, + marker: marker.clone(), + cached_at: Instant::now(), + last_used: Instant::now(), + }, + ); + marker +} + +fn process_creation_time(process: HANDLE) -> Option { + let mut creation_time = FILETIME::default(); + let mut exit_time = FILETIME::default(); + let mut kernel_time = FILETIME::default(); + let mut user_time = FILETIME::default(); + if unsafe { + GetProcessTimes( + process, + &mut creation_time, + &mut exit_time, + &mut kernel_time, + &mut user_time, + ) + } == 0 + { + return None; + } + Some((u64::from(creation_time.dwHighDateTime) << 32) | u64::from(creation_time.dwLowDateTime)) +} + +fn process_runtime_marker_from_handle(process: HANDLE) -> Option> { + let parameters = read_process_parameters(process)?; + let environment = read_process_environment(process, parameters.environment)?; + Some(environment_variable_from_utf16( + &environment, + PANE_RUNTIME_MARKER_ENV_VAR, + )) +} + +fn read_process_environment(process: HANDLE, address: *const c_void) -> Option> { + if address.is_null() { + return None; + } + + let mut memory = MaybeUninit::::uninit(); + let queried = unsafe { + VirtualQueryEx( + process, + address, + memory.as_mut_ptr(), + size_of::(), + ) + }; + if queried == 0 { + return None; + } + let memory = unsafe { memory.assume_init() }; + let address = address as usize; + let base = memory.BaseAddress as usize; + let offset = address.checked_sub(base)?; + let available = memory.RegionSize.checked_sub(offset)?; + let read_len = available.min(MAX_PROCESS_ENVIRONMENT_BYTES); + if read_len < size_of::() { + return None; + } + + let max_units = read_len / size_of::(); + let chunk_units = PROCESS_ENVIRONMENT_READ_CHUNK_BYTES / size_of::(); + let mut environment = Vec::new(); + while environment.len() < max_units { + let unit_count = (max_units - environment.len()).min(chunk_units); + let chunk_bytes = unit_count * size_of::(); + let mut chunk = vec![0_u16; unit_count]; + let mut bytes_read = 0; + let offset = environment.len().checked_mul(size_of::())?; + let chunk_address = address.checked_add(offset)?; + if unsafe { + ReadProcessMemory( + process, + chunk_address as *const c_void, + chunk.as_mut_ptr().cast::(), + chunk_bytes, + &mut bytes_read, + ) + } == 0 + { + break; + } + chunk.truncate(bytes_read / size_of::()); + if chunk.is_empty() { + break; + } + environment.extend_from_slice(&chunk); + if let Some(end) = environment + .windows(2) + .position(|pair| pair == [0, 0]) + .map(|index| index + 2) + { + environment.truncate(end); + return Some(environment); + } + if bytes_read < chunk_bytes { + break; + } + } + None +} + +fn environment_variable_from_utf16(environment: &[u16], name: &str) -> Option { + for variable in environment.split(|unit| *unit == 0) { + if variable.is_empty() { + break; + } + let Some(separator) = variable.iter().position(|unit| *unit == u16::from(b'=')) else { + continue; + }; + let Ok(variable_name) = String::from_utf16(&variable[..separator]) else { + continue; + }; + if variable_name.eq_ignore_ascii_case(name) { + return String::from_utf16(&variable[separator + 1..]).ok(); + } + } + None +} + fn read_process_parameters(process: HANDLE) -> Option { let mut basic_info = MaybeUninit::::uninit(); let status = unsafe { @@ -1049,6 +1422,7 @@ struct RtlUserProcessParameters { dll_path: UNICODE_STRING, image_path_name: UNICODE_STRING, command_line: UNICODE_STRING, + environment: *mut c_void, } fn read_process_value(process: HANDLE, address: *const c_void) -> Option { @@ -1879,6 +2253,35 @@ mod tests { assert_eq!(observed.as_deref(), Some(cwd.as_path())); } + #[test] + fn windows_process_environment_reads_runtime_marker() { + let shell = + std::env::var_os("ComSpec").unwrap_or_else(|| r"C:\Windows\System32\cmd.exe".into()); + let mut child = Command::new(shell) + .args(["/D", "/Q", "/C", "ping -n 11 127.0.0.1 > NUL"]) + .env(super::PANE_RUNTIME_MARKER_ENV_VAR, "pane-test") + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn() + .expect("spawn cmd"); + + let deadline = Instant::now() + Duration::from_secs(5); + let mut observed = None; + while Instant::now() < deadline { + observed = super::process_runtime_marker(child.id()); + if observed.as_deref() == Some("pane-test") { + break; + } + thread::sleep(Duration::from_millis(100)); + } + + let _ = child.kill(); + let _ = child.wait(); + + assert_eq!(observed.as_deref(), Some("pane-test")); + } + #[test] fn windows_process_tree_selects_direct_agent_descendant() { let entries = vec![ @@ -1886,13 +2289,170 @@ mod tests { test_entry(20, 10, "codex.exe", &["codex.exe"]), ]; - let job = super::select_pane_foreground_job(10, &entries).unwrap(); + let job = super::select_pane_foreground_job_with_runtime_inspection( + 10, + &entries, + |_| panic!("Git Bash fallback must not run after normal detection succeeds"), + |_| panic!("runtime marker must not be read after normal detection succeeds"), + ) + .unwrap(); assert_eq!(job.process_group_id, 20); assert_eq!(job.processes.len(), 1); assert_eq!(job.processes[0].name, "codex.exe"); } + #[test] + fn windows_process_tree_recovers_git_bash_exec_chain_from_runtime_marker() { + let entries = vec![ + test_entry(10, 1, "bash.exe", &[r"C:\Program Files\Git\bin\bash.exe"]), + test_entry( + 11, + 10, + "bash.exe", + &[r"C:\Program Files\Git\usr\bin\bash.exe"], + ), + test_entry( + 20, + 99, + "sh.exe", + &[r"C:\Program Files\Git\usr\bin\sh.exe", "/c/npm/codex"], + ), + test_entry( + 30, + 20, + "node.exe", + &[ + r"C:\Program Files\nodejs\node.exe", + r"C:\Users\user\AppData\Roaming\npm\node_modules\@openai\codex\bin\codex.js", + ], + ), + test_entry( + 40, + 30, + "codex.exe", + &[r"C:\npm\node_modules\@openai\codex\bin\codex.exe"], + ), + ]; + let mut inspected = Vec::new(); + + let job = super::select_pane_foreground_job_with_runtime_inspection( + 10, + &entries, + |_| true, + |entry| { + inspected.push(entry.pid); + Some("pane-a".to_string()) + }, + ) + .unwrap(); + + assert_eq!(job.process_group_id, 20); + assert_eq!(job.processes[0].name, "sh.exe"); + assert_eq!(inspected, vec![10, 20, 30, 40]); + } + + #[test] + fn windows_process_tree_skips_runtime_inspection_for_non_git_bash_shell() { + let entries = vec![ + test_entry(10, 1, "powershell.exe", &["powershell.exe"]), + test_entry(20, 99, "codex.exe", &["codex.exe"]), + ]; + + let job = super::select_pane_foreground_job_with_runtime_inspection( + 10, + &entries, + |_| false, + |_| panic!("runtime marker must not be read for non-Git-Bash panes"), + ) + .unwrap(); + + assert_eq!(job.process_group_id, 10); + } + + #[test] + fn windows_process_tree_skips_runtime_inspection_without_agent_candidate() { + let entries = vec![ + test_entry(10, 1, "bash.exe", &[r"C:\Program Files\Git\bin\bash.exe"]), + test_entry(20, 99, "git.exe", &["git.exe", "status"]), + ]; + + let job = super::select_pane_foreground_job_with_runtime_inspection( + 10, + &entries, + |_| true, + |_| panic!("runtime marker must not be read without an agent candidate"), + ) + .unwrap(); + + assert_eq!(job.process_group_id, 10); + } + + #[test] + fn windows_process_tree_rejects_missing_or_empty_shell_runtime_marker() { + let entries = vec![ + test_entry(10, 1, "bash.exe", &[r"C:\Program Files\Git\bin\bash.exe"]), + test_entry(20, 99, "codex.exe", &["codex.exe"]), + ]; + + for shell_marker in [None, Some(String::new())] { + let job = super::select_pane_foreground_job_with_runtime_inspection( + 10, + &entries, + |_| true, + |entry| { + if entry.pid == 10 { + shell_marker.clone() + } else { + Some("pane-a".to_string()) + } + }, + ) + .unwrap(); + + assert_eq!(job.process_group_id, 10); + } + } + + #[test] + fn windows_process_tree_rejects_runtime_marker_from_another_pane() { + let entries = vec![ + test_entry(10, 1, "bash.exe", &[r"C:\Program Files\Git\bin\bash.exe"]), + test_entry(20, 99, "codex.exe", &["codex.exe"]), + ]; + + let job = super::select_pane_foreground_job_with_runtime_inspection( + 10, + &entries, + |_| true, + |entry| Some(if entry.pid == 10 { "pane-a" } else { "pane-b" }.to_string()), + ) + .unwrap(); + + assert_eq!(job.process_group_id, 10); + assert_eq!(job.processes[0].name, "bash.exe"); + } + + #[test] + fn windows_process_tree_rejects_ambiguous_runtime_marker_candidates() { + let entries = vec![ + test_entry(10, 1, "bash.exe", &[r"C:\Program Files\Git\bin\bash.exe"]), + test_entry(20, 99, "codex.exe", &["codex.exe"]), + test_entry(30, 98, "claude.exe", &["claude.exe"]), + ]; + + let job = super::select_pane_foreground_job_with_runtime_inspection( + 10, + &entries, + |_| true, + |_| Some("pane-a".to_string()), + ) + .unwrap(); + + assert_eq!(job.process_group_id, 10); + assert_eq!(job.processes[0].name, "bash.exe"); + } + #[test] fn windows_foreground_process_snapshot_is_shared_within_ttl() { let mut cache = super::ProcessSnapshotCache { cached: None }; @@ -2189,6 +2749,62 @@ mod tests { } } + #[test] + fn process_environment_variable_parser_reads_case_insensitive_marker() { + let environment: Vec = "PATH=C:\\Windows\0herdr_pane_runtime_id=pane-a\0\0" + .encode_utf16() + .collect(); + + assert_eq!( + super::environment_variable_from_utf16( + &environment, + super::PANE_RUNTIME_MARKER_ENV_VAR, + ) + .as_deref(), + Some("pane-a") + ); + } + + #[test] + fn pane_runtime_markers_are_distinct() { + let first = super::next_pane_runtime_marker(); + let second = super::next_pane_runtime_marker(); + + assert_ne!(first, second); + } + + #[test] + fn pane_runtime_marker_is_added_only_to_git_bash_environment() { + let root = std::env::temp_dir().join(format!( + "herdr-git-bash-test-{}", + super::next_pane_runtime_marker() + )); + fs::create_dir_all(root.join("bin")).expect("create Git Bash bin fixture"); + fs::create_dir_all(root.join("usr").join("bin")).expect("create Git Bash usr/bin fixture"); + fs::create_dir_all(root.join("cmd")).expect("create Git Bash cmd fixture"); + fs::write(root.join("bin").join("bash.exe"), []).expect("create Bash fixture"); + fs::write(root.join("usr").join("bin").join("msys-2.0.dll"), []) + .expect("create MSYS runtime fixture"); + fs::write(root.join("cmd").join("git.exe"), []).expect("create Git fixture"); + + let mut git_bash = portable_pty::CommandBuilder::new(root.join("bin").join("bash.exe")); + super::apply_pane_runtime_marker_platform(&mut git_bash); + let mut path_resolved_git_bash = portable_pty::CommandBuilder::new("bash.exe"); + path_resolved_git_bash.env("PATH", root.join("bin")); + super::apply_pane_runtime_marker_platform(&mut path_resolved_git_bash); + let mut cmd = portable_pty::CommandBuilder::new("cmd.exe"); + super::apply_pane_runtime_marker_platform(&mut cmd); + + assert!(git_bash + .get_env(super::PANE_RUNTIME_MARKER_ENV_VAR) + .is_some_and(|value| !value.is_empty())); + assert!(path_resolved_git_bash + .get_env(super::PANE_RUNTIME_MARKER_ENV_VAR) + .is_some_and(|value| !value.is_empty())); + assert!(cmd.get_env(super::PANE_RUNTIME_MARKER_ENV_VAR).is_none()); + fs::remove_dir_all(root).expect("remove Git Bash fixture"); + } + #[test] fn ime_open_reflects_open_status() { // IMC_GETOPENSTATUS returns nonzero when the IME is open (Hangul