aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorCan Celik <ogulcancelik@gmail.com>2026-08-01 21:25:20 +0300
committerGitHub <noreply@github.com>2026-08-01 21:25:20 +0300
commitdf2cb2c3a585bdb22bb670f6da8147c7c1ffe982 (patch)
treeefd425e54c5f65388a3ea598640017979203a10d
parent885c9a6b5b48bf1fa89b12783b01ca21ec50d79d (diff)
fix(windows): detect agents across git bash exec boundaries (#2170)
refs #2107
-rw-r--r--docs/next/CHANGELOG.md1
-rw-r--r--src/pane.rs1
-rw-r--r--src/platform/mod.rs7
-rw-r--r--src/platform/windows.rs636
4 files changed, 635 insertions, 10 deletions
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<Mutex<HashMap<u32, CachedProcessRuntimeMarker>>> =
+ LazyLock::new(|| Mutex::new(HashMap::new()));
+static GIT_BASH_PROCESS_CACHE: LazyLock<Mutex<HashMap<u32, CachedGitBashProcess>>> =
+ 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<Vec<u8>> {
@@ -110,6 +129,21 @@ struct ProcessSnapshotCache {
cached: Option<CachedProcessSnapshot>,
}
+#[derive(Debug)]
+struct CachedProcessRuntimeMarker {
+ creation_time: u64,
+ marker: Option<String>,
+ 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<ProcessSnapshotCache> =
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>) -> std::ffi::OsString {
comspec
.filter(|value| !value.is_empty())
@@ -511,19 +560,61 @@ fn select_pane_foreground_job(
shell_pid: u32,
entries: &[WindowsProcessEntry],
) -> Option<ForegroundJob> {
+ 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<String>,
+) -> Option<ForegroundJob> {
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<String> {
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<String> {
+ 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<String> {
+ 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<u64> {
+ 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<Option<String>> {
+ 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<Vec<u16>> {
+ if address.is_null() {
+ return None;
+ }
+
+ let mut memory = MaybeUninit::<MEMORY_BASIC_INFORMATION>::uninit();
+ let queried = unsafe {
+ VirtualQueryEx(
+ process,
+ address,
+ memory.as_mut_ptr(),
+ size_of::<MEMORY_BASIC_INFORMATION>(),
+ )
+ };
+ 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::<u16>() {
+ return None;
+ }
+
+ let max_units = read_len / size_of::<u16>();
+ let chunk_units = PROCESS_ENVIRONMENT_READ_CHUNK_BYTES / size_of::<u16>();
+ 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::<u16>();
+ let mut chunk = vec![0_u16; unit_count];
+ let mut bytes_read = 0;
+ let offset = environment.len().checked_mul(size_of::<u16>())?;
+ let chunk_address = address.checked_add(offset)?;
+ if unsafe {
+ ReadProcessMemory(
+ process,
+ chunk_address as *const c_void,
+ chunk.as_mut_ptr().cast::<c_void>(),
+ chunk_bytes,
+ &mut bytes_read,
+ )
+ } == 0
+ {
+ break;
+ }
+ chunk.truncate(bytes_read / size_of::<u16>());
+ 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<String> {
+ 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<RtlUserProcessParameters> {
let mut basic_info = MaybeUninit::<PROCESS_BASIC_INFORMATION>::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<T: Copy>(process: HANDLE, address: *const c_void) -> Option<T> {
@@ -1880,13 +2254,48 @@ mod tests {
}
#[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![
test_entry(10, 1, "powershell.exe", &["powershell.exe"]),
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);
@@ -1894,6 +2303,157 @@ mod tests {
}
#[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 };
let mut builds = 0;
@@ -2190,6 +2750,62 @@ mod tests {
}
#[test]
+ fn process_environment_variable_parser_reads_case_insensitive_marker() {
+ let environment: Vec<u16> = "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
// composing) and zero for direct English/ASCII input.