aboutsummaryrefslogtreecommitdiff
path: root/src/platform/windows.rs
diff options
context:
space:
mode:
authorOgulcan Celik <ogulcancelik@gmail.com>2026-07-17 23:55:08 +0300
committerOgulcan Celik <ogulcancelik@gmail.com>2026-07-17 23:59:56 +0300
commite0758c32118f2aa006db3d8fa4b41833fe6e7ead (patch)
tree2b29dd61ebf870db8d0ac95cc0b4515178c834c4 /src/platform/windows.rs
parent040956531f673c8fb7720037494ed4e61b123c6c (diff)
feat: harden named agent cli workflows
refs #1525
Diffstat (limited to 'src/platform/windows.rs')
-rw-r--r--src/platform/windows.rs188
1 files changed, 188 insertions, 0 deletions
diff --git a/src/platform/windows.rs b/src/platform/windows.rs
index 24de21ad..ef41e662 100644
--- a/src/platform/windows.rs
+++ b/src/platform/windows.rs
@@ -62,6 +62,71 @@ fn raw_command_shell(comspec: Option<std::ffi::OsString>) -> std::ffi::OsString
.unwrap_or_else(|| r"C:\Windows\System32\cmd.exe".into())
}
+pub(crate) fn interactive_shell_command(argv: &[String], shell_name: &str) -> Option<String> {
+ let shell_name = shell_name.to_ascii_lowercase();
+ let powershell = shell_name.contains("powershell") || shell_name.contains("pwsh");
+ let script = powershell_agent_script(argv)?;
+ if powershell {
+ Some(script)
+ } else {
+ Some(cmd_encoded_powershell_command(&script))
+ }
+}
+
+fn powershell_agent_script(argv: &[String]) -> Option<String> {
+ let (program, args) = argv.split_first()?;
+ let command_line = args
+ .iter()
+ .map(|arg| quote_windows_command_line_arg(arg))
+ .collect::<Vec<_>>()
+ .join(" ");
+ Some(format!(
+ "$p=Start-Process -FilePath {} -ArgumentList {} -NoNewWindow -Wait -PassThru",
+ super::quote_powershell_arg(program),
+ super::quote_powershell_arg(&command_line),
+ ))
+}
+
+fn quote_windows_command_line_arg(value: &str) -> String {
+ if !value.is_empty()
+ && !value
+ .chars()
+ .any(|ch| matches!(ch, ' ' | '\t' | '\n' | '\x0b' | '"'))
+ {
+ return value.to_string();
+ }
+
+ let mut quoted = String::from("\"");
+ let mut backslashes = 0;
+ for ch in value.chars() {
+ if ch == '\\' {
+ backslashes += 1;
+ continue;
+ }
+ if ch == '"' {
+ quoted.push_str(&"\\".repeat(backslashes * 2 + 1));
+ } else {
+ quoted.push_str(&"\\".repeat(backslashes));
+ }
+ backslashes = 0;
+ quoted.push(ch);
+ }
+ quoted.push_str(&"\\".repeat(backslashes * 2));
+ quoted.push('"');
+ quoted
+}
+
+fn cmd_encoded_powershell_command(script: &str) -> String {
+ use base64::Engine as _;
+
+ let utf16 = script
+ .encode_utf16()
+ .flat_map(u16::to_le_bytes)
+ .collect::<Vec<_>>();
+ let encoded = base64::engine::general_purpose::STANDARD.encode(utf16);
+ format!("powershell.exe -NoLogo -NoProfile -EncodedCommand {encoded}")
+}
+
pub(crate) fn detached_custom_command_process_platform(command: &str) -> std::process::Command {
detached_custom_command_process_with_comspec(command, std::env::var_os("ComSpec"))
}
@@ -155,6 +220,23 @@ pub fn foreground_job(child_pid: u32) -> Option<ForegroundJob> {
select_pane_foreground_job(child_pid, &entries)
}
+pub(crate) fn available_pane_shell(child_pid: u32) -> Option<String> {
+ available_pane_shell_from_snapshot(child_pid, &snapshot_processes())
+}
+
+fn available_pane_shell_from_snapshot(
+ child_pid: u32,
+ entries: &[WindowsProcessEntry],
+) -> Option<String> {
+ let shell = entries.iter().find(|entry| entry.pid == child_pid)?;
+ if !super::is_pane_shell_process_name(&shell.name) {
+ return None;
+ }
+ descendant_entries(child_pid, entries)
+ .is_empty()
+ .then(|| shell.name.clone())
+}
+
pub fn foreground_group_leader_job(process_group_id: u32) -> Option<ForegroundJob> {
let entries = snapshot_processes();
let entry = entries.iter().find(|entry| entry.pid == process_group_id)?;
@@ -670,6 +752,91 @@ mod tests {
AllocConsole, FreeConsole, GetConsoleProcessList, GetConsoleWindow,
};
+ #[test]
+ fn cmd_agent_command_encodes_edge_arguments_without_cmd_expansion() {
+ use base64::Engine as _;
+
+ assert_eq!(super::super::quote_powershell_arg("@options"), "'@options'");
+ let argv = vec![
+ "pi".into(),
+ String::new(),
+ "two words".into(),
+ "100%".into(),
+ "wow!".into(),
+ "a'b".into(),
+ ];
+ let command = super::interactive_shell_command(&argv, "cmd.exe").unwrap();
+ let encoded = command.split_whitespace().last().unwrap();
+ let bytes = base64::engine::general_purpose::STANDARD
+ .decode(encoded)
+ .unwrap();
+ let utf16 = bytes
+ .chunks_exact(2)
+ .map(|chunk| u16::from_le_bytes([chunk[0], chunk[1]]))
+ .collect::<Vec<_>>();
+ assert_eq!(
+ String::from_utf16(&utf16).unwrap(),
+ "$p=Start-Process -FilePath pi -ArgumentList '\"\" \"two words\" 100% wow! a''b' -NoNewWindow -Wait -PassThru"
+ );
+ }
+
+ #[test]
+ fn windows_shells_round_trip_agent_arguments_through_a_real_command() {
+ let base = std::env::temp_dir().join(format!(
+ "herdr-agent-argv-{}-{}",
+ std::process::id(),
+ std::time::SystemTime::now()
+ .duration_since(std::time::UNIX_EPOCH)
+ .unwrap()
+ .as_millis()
+ ));
+ fs::create_dir_all(&base).unwrap();
+ let helper = base.join("pi.cmd");
+ fs::write(
+ &helper,
+ "@echo off\r\n>\"%HERDR_ARGV_CAPTURE%\" (\r\necho(%~1\r\necho(%~2\r\necho(%~3\r\necho(%~4\r\necho(%~5\r\necho(%~6\r\n)\r\n",
+ )
+ .unwrap();
+ let argv = vec![
+ "pi".into(),
+ String::new(),
+ "two words".into(),
+ "100%".into(),
+ "wow!".into(),
+ "a'b".into(),
+ "@options".into(),
+ ];
+ let inherited_path = std::env::var_os("PATH").unwrap_or_default();
+ let path = format!("{};{}", base.display(), inherited_path.to_string_lossy());
+
+ for shell in ["powershell.exe", "cmd.exe"] {
+ let capture = base.join(format!("{shell}.txt"));
+ let command = super::interactive_shell_command(&argv, shell).unwrap();
+ let status = if shell == "cmd.exe" {
+ Command::new("cmd.exe")
+ .args(["/d", "/c", &command])
+ .env("PATH", &path)
+ .env("HERDR_ARGV_CAPTURE", &capture)
+ .status()
+ .unwrap()
+ } else {
+ Command::new("powershell.exe")
+ .args(["-NoLogo", "-NoProfile", "-Command", &command])
+ .env("PATH", &path)
+ .env("HERDR_ARGV_CAPTURE", &capture)
+ .status()
+ .unwrap()
+ };
+ assert!(status.success(), "{shell} command failed");
+ assert_eq!(
+ fs::read_to_string(capture).unwrap().replace("\r\n", "\n"),
+ "\ntwo words\n100%\nwow!\na'b\n@options\n"
+ );
+ }
+
+ let _ = fs::remove_dir_all(base);
+ }
+
const CONSOLE_TEST_CHILD_ENV: &str = "HERDR_TEST_CONSOLE_CHILD_MODE";
const CONSOLE_TEST_PARENT_PID_ENV: &str = "HERDR_TEST_CONSOLE_PARENT_PID";
@@ -1002,6 +1169,27 @@ mod tests {
}
#[test]
+ fn windows_shell_is_available_only_without_descendants() {
+ let shell_only = vec![test_entry(10, 1, "powershell.exe", &["powershell.exe"])];
+ assert_eq!(
+ super::available_pane_shell_from_snapshot(10, &shell_only).as_deref(),
+ Some("powershell.exe")
+ );
+
+ let busy = vec![
+ test_entry(10, 1, "powershell.exe", &["powershell.exe"]),
+ test_entry(20, 10, "git.exe", &["git.exe", "status"]),
+ ];
+ assert_eq!(super::available_pane_shell_from_snapshot(10, &busy), None);
+
+ let replaced = vec![test_entry(10, 1, "vim.exe", &["vim.exe"])];
+ assert_eq!(
+ super::available_pane_shell_from_snapshot(10, &replaced),
+ None
+ );
+ }
+
+ #[test]
fn windows_process_tree_returns_shell_for_multiple_agent_descendants() {
let entries = vec![
test_entry(10, 1, "powershell.exe", &["powershell.exe"]),