aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorOgulcan Celik <ogulcancelik@gmail.com>2026-07-05 20:18:24 +0300
committerOgulcan Celik <ogulcancelik@gmail.com>2026-07-05 20:19:13 +0300
commit220b99a9fabf10fad83a570f641ad39737d85875 (patch)
treea27a3b791f110d091ca6ef9b194bb6c1fb4e2b55
parent2bc1724c2dae72184a5d2ec070e30c70dc519f9b (diff)
fix: support custom command keybindings on windows
refs #1041
-rw-r--r--AGENTS.md2
-rw-r--r--docs/next/CHANGELOG.md1
-rw-r--r--docs/next/website/src/content/docs/configuration.mdx4
-rw-r--r--scripts/test_vendor_portable_pty.py13
-rw-r--r--src/app/input/navigate.rs6
-rw-r--r--src/main.rs1
-rw-r--r--src/pane.rs4
-rw-r--r--src/platform/fallback.rs17
-rw-r--r--src/platform/linux.rs17
-rw-r--r--src/platform/macos.rs17
-rw-r--r--src/platform/mod.rs31
-rw-r--r--src/platform/windows.rs107
-rw-r--r--vendor/patches/portable-pty/0002-windows-raw-command-tail.patch116
-rw-r--r--vendor/portable-pty.patches.md34
-rw-r--r--vendor/portable-pty/src/cmdbuilder.rs49
15 files changed, 410 insertions, 9 deletions
diff --git a/AGENTS.md b/AGENTS.md
index c56804c7..7fc7a08a 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -28,7 +28,7 @@ These instructions are layered.
- **State is separated from runtime.** `AppState` is pure data, testable without PTYs or async. `PaneState` is separate from `PaneRuntime`. Workspace logic doesn't need real terminals.
- **Render is pure.** `compute_view()` handles geometry and mutations. `render()` takes `&AppState` and only draws. Never mutate state during render.
- **No god objects.** If a module is doing too many things, split it. `app/` is already split into state, actions, and input. Keep it that way.
-- **Platform code is isolated.** OS-specific behavior lives in `src/platform/`. Core modules don't have `#[cfg(target_os)]`.
+- **Platform code is isolated.** OS-specific behavior lives in the matching `src/platform/<os>.rs` file, with only shared traits, types, wrappers, and testable contracts in `src/platform/mod.rs`. Core modules don't have `#[cfg(target_os)]`.
- **Detection is decoupled.** The detector reads a screen snapshot, never touches the parser or viewport state.
- **Screen detection is evidence-based.** When changing `src/detect/manifests/`, first capture the relevant bottom-buffer state with `herdr agent read <pane> --source detection --format text` and, when styling or alternate screen behavior matters, `--format ansi`. Decide which visible controls are invariant, which are alternatives, and encode them as explicit AND/OR gates. Do not match whole-pane incidental text, and do not use the user-visible viewport for agent status because users can scroll it.
- **UI patterns should be reused.** Herdr is a mouse-first TUI. New dialogs, onboarding, settings, and post-update flows should follow the existing UI/UX language and interaction patterns instead of inventing one-off screens. Prefer reusing existing modal/screen structure, affordances, and close actions so the app feels consistent.
diff --git a/docs/next/CHANGELOG.md b/docs/next/CHANGELOG.md
index cf684726..22d74383 100644
--- a/docs/next/CHANGELOG.md
+++ b/docs/next/CHANGELOG.md
@@ -19,6 +19,7 @@
### Fixed
- `herdr server stop` now waits until both server sockets are unreachable before returning, avoiding an immediate first-start failure when restarting right after replacing the binary.
- Unix local Herdr clients no longer treat empty bracketed paste as a clipboard-image bridge; `herdr --remote` keeps using it for local-desktop image paste over SSH. (#986)
+- Custom command keybindings now run through `cmd.exe /d /c` on Windows instead of `/bin/sh`, so `type = "pane"` and `type = "shell"` bindings can launch native Windows commands. (#1041)
- Plain PageUp/PageDown now reach primary-screen pager apps such as `less -X` and Git diff when they enter application cursor mode, while shell transcripts still use Herdr pane scrollback. (#953)
- `prefix+e` scrollback editor panes now open on Windows without trying to run `/bin/sh`; Windows uses `VISUAL`, then `EDITOR`, then `notepad.exe` as the fallback editor. (#914)
- `herdr pane split --current` now resolves to the calling Herdr pane instead of the UI-focused pane when run inside a pane. (#902)
diff --git a/docs/next/website/src/content/docs/configuration.mdx b/docs/next/website/src/content/docs/configuration.mdx
index b17107df..8f411cba 100644
--- a/docs/next/website/src/content/docs/configuration.mdx
+++ b/docs/next/website/src/content/docs/configuration.mdx
@@ -71,7 +71,7 @@ Set the executable Herdr uses for newly created interactive panes:
default_shell = "nu"
```
-When unset or empty, Herdr uses `$SHELL`, then `/bin/sh`. This is an executable name or path, not a shell command line. Existing panes keep their current shell until they are recreated. Command panes still run through `/bin/sh -c`; detached custom command keybindings use Herdr's existing `/bin/sh -lc` path.
+When unset or empty, Herdr uses `$SHELL`, then `/bin/sh` on Unix and PowerShell on Windows. This is an executable name or path, not a shell command line. Existing panes keep their current shell until they are recreated. Custom command keybinding strings run through `/bin/sh -c` for pane commands and `/bin/sh -lc` for detached commands on Unix; on Windows they run through `cmd.exe /d /c`.
Set how Herdr starts newly created interactive pane shells:
@@ -248,6 +248,8 @@ An optional `description` can be provided. When specified, this description is d
Custom commands receive `HERDR_SOCKET_PATH`, `HERDR_BIN_PATH`, `HERDR_ACTIVE_WORKSPACE_ID`, `HERDR_ACTIVE_TAB_ID`, `HERDR_ACTIVE_PANE_ID`, and `HERDR_ACTIVE_PANE_CWD` when those values are available. Shell commands run from the focused pane's working directory when Herdr can detect it.
+On Windows, custom command strings use `cmd.exe /d /c`, so environment variables use `%HERDR_BIN_PATH%` syntax. To run PowerShell syntax, invoke it explicitly, for example `powershell.exe -NoProfile -Command "..."`.
+
## Theme
Choose a built-in theme:
diff --git a/scripts/test_vendor_portable_pty.py b/scripts/test_vendor_portable_pty.py
index 8a6d4ca1..87537d41 100644
--- a/scripts/test_vendor_portable_pty.py
+++ b/scripts/test_vendor_portable_pty.py
@@ -71,6 +71,19 @@ class VendorPortablePtyTests(unittest.TestCase):
]
self.assertEqual(missing, [])
+ def test_listed_local_vendor_patches_exist(self) -> None:
+ project_root = Path(__file__).resolve().parent.parent
+ index = project_root / "vendor" / "portable-pty.patches.md"
+ text = index.read_text()
+ listed = [
+ line.split("`", 2)[1]
+ for line in text.splitlines()
+ if line.startswith("patch: `vendor/patches/portable-pty/")
+ ]
+
+ missing = [path for path in listed if not (project_root / path).exists()]
+ self.assertEqual(missing, [])
+
def test_local_vendor_patches_are_applied_to_vendored_tree(self) -> None:
project_root = Path(__file__).resolve().parent.parent
patch_dir = project_root / "vendor" / "patches" / "portable-pty"
diff --git a/src/app/input/navigate.rs b/src/app/input/navigate.rs
index b278daac..f4719ace 100644
--- a/src/app/input/navigate.rs
+++ b/src/app/input/navigate.rs
@@ -1,7 +1,7 @@
use std::{
fs, io,
io::Write,
- process::{Command, Stdio},
+ process::Stdio,
time::{SystemTime, UNIX_EPOCH},
};
@@ -771,10 +771,8 @@ impl App {
&self,
binding: &crate::config::CustomCommandKeybind,
) -> std::io::Result<()> {
- let mut command = Command::new("/bin/sh");
+ let mut command = crate::platform::detached_custom_command_process(&binding.command);
command
- .arg("-lc")
- .arg(&binding.command)
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null());
diff --git a/src/main.rs b/src/main.rs
index 04ff4894..3b4bed1c 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -220,6 +220,7 @@ const DEFAULT_CONFIG: &str = r##"# herdr configuration
# Custom commands use the same binding syntax.
# type = "shell" runs detached in the background.
# type = "pane" opens a temporary pane and closes it when the command exits.
+# On Windows, command strings run through cmd.exe /d /c.
# [[keys.command]]
# key = "prefix+alt+g"
# type = "pane"
diff --git a/src/pane.rs b/src/pane.rs
index 6094af83..a6edaaa7 100644
--- a/src/pane.rs
+++ b/src/pane.rs
@@ -1585,9 +1585,7 @@ impl PaneRuntime {
render_notify: Arc<Notify>,
render_dirty: Arc<AtomicBool>,
) -> std::io::Result<Self> {
- let mut cmd = CommandBuilder::new("/bin/sh");
- cmd.arg("-c");
- cmd.arg(command);
+ let mut cmd = crate::platform::pane_custom_command_pty_builder(command);
cmd.cwd(cwd);
apply_pane_terminal_env(&mut cmd);
apply_pane_launch_env(&mut cmd, launch_env);
diff --git a/src/platform/fallback.rs b/src/platform/fallback.rs
index e9134a8b..382ccf5e 100644
--- a/src/platform/fallback.rs
+++ b/src/platform/fallback.rs
@@ -6,6 +6,23 @@ use super::{ClipboardImage, ForegroundJob, Signal};
/// Unsupported platform stub.
pub fn raise_server_nofile_limit() {}
+fn raw_command_argv(command: &str, flag: &str) -> Vec<std::ffi::OsString> {
+ vec!["/bin/sh".into(), flag.into(), command.into()]
+}
+
+pub(crate) fn detached_custom_command_process_platform(command: &str) -> std::process::Command {
+ let argv = raw_command_argv(command, "-lc");
+ let mut command = std::process::Command::new(&argv[0]);
+ command.args(&argv[1..]);
+ command
+}
+
+pub(crate) fn pane_custom_command_pty_builder_platform(
+ command: &str,
+) -> portable_pty::CommandBuilder {
+ portable_pty::CommandBuilder::from_argv(raw_command_argv(command, "-c"))
+}
+
/// Unsupported platform stub.
pub(crate) fn scrollback_editor_argv(_path: &std::path::Path) -> std::io::Result<Vec<String>> {
Err(std::io::Error::new(
diff --git a/src/platform/linux.rs b/src/platform/linux.rs
index 4e5574d0..be0c97b9 100644
--- a/src/platform/linux.rs
+++ b/src/platform/linux.rs
@@ -39,6 +39,23 @@ static FOREGROUND_MEMBERS_CACHE: Mutex<ForegroundMembersCache> =
pub fn raise_server_nofile_limit() {}
+fn raw_command_argv(command: &str, flag: &str) -> Vec<std::ffi::OsString> {
+ vec!["/bin/sh".into(), flag.into(), command.into()]
+}
+
+pub(crate) fn detached_custom_command_process_platform(command: &str) -> std::process::Command {
+ let argv = raw_command_argv(command, "-lc");
+ let mut command = std::process::Command::new(&argv[0]);
+ command.args(&argv[1..]);
+ command
+}
+
+pub(crate) fn pane_custom_command_pty_builder_platform(
+ command: &str,
+) -> portable_pty::CommandBuilder {
+ portable_pty::CommandBuilder::from_argv(raw_command_argv(command, "-c"))
+}
+
pub(crate) fn scrollback_editor_argv(path: &std::path::Path) -> std::io::Result<Vec<String>> {
let quoted_path = shell_quote(&path.display().to_string());
let command = format!(
diff --git a/src/platform/macos.rs b/src/platform/macos.rs
index e72d32fe..f2441f06 100644
--- a/src/platform/macos.rs
+++ b/src/platform/macos.rs
@@ -16,6 +16,23 @@ const PROC_PGRP_ONLY: u32 = 2;
const SERVER_NOFILE_LIMIT_TARGET: libc::rlim_t = 8192;
const CF_STRING_ENCODING_UTF8: u32 = 0x0800_0100;
+fn raw_command_argv(command: &str, flag: &str) -> Vec<std::ffi::OsString> {
+ vec!["/bin/sh".into(), flag.into(), command.into()]
+}
+
+pub(crate) fn detached_custom_command_process_platform(command: &str) -> std::process::Command {
+ let argv = raw_command_argv(command, "-lc");
+ let mut command = std::process::Command::new(&argv[0]);
+ command.args(&argv[1..]);
+ command
+}
+
+pub(crate) fn pane_custom_command_pty_builder_platform(
+ command: &str,
+) -> portable_pty::CommandBuilder {
+ portable_pty::CommandBuilder::from_argv(raw_command_argv(command, "-c"))
+}
+
pub(crate) fn scrollback_editor_argv(path: &Path) -> std::io::Result<Vec<String>> {
let quoted_path = shell_quote(&path.display().to_string());
let command = format!(
diff --git a/src/platform/mod.rs b/src/platform/mod.rs
index 889c4ce9..e396966b 100644
--- a/src/platform/mod.rs
+++ b/src/platform/mod.rs
@@ -25,6 +25,14 @@ pub enum Signal {
Kill,
}
+pub(crate) fn detached_custom_command_process(command: &str) -> std::process::Command {
+ detached_custom_command_process_platform(command)
+}
+
+pub(crate) fn pane_custom_command_pty_builder(command: &str) -> portable_pty::CommandBuilder {
+ pane_custom_command_pty_builder_platform(command)
+}
+
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) struct PlatformCapabilities {
pub(crate) live_handoff: bool,
@@ -199,6 +207,29 @@ mod tests {
use super::*;
#[test]
+ fn detached_custom_command_preserves_unix_login_shell_flag() {
+ let cmd = detached_custom_command_process("echo hello");
+ assert_eq!(cmd.get_program(), std::ffi::OsStr::new("/bin/sh"));
+ assert_eq!(
+ cmd.get_args().collect::<Vec<_>>(),
+ [
+ std::ffi::OsStr::new("-lc"),
+ std::ffi::OsStr::new("echo hello")
+ ]
+ );
+ }
+
+ #[test]
+ fn pane_custom_command_builder_preserves_unix_shell_flag() {
+ let expected: Vec<std::ffi::OsString> =
+ vec!["/bin/sh".into(), "-c".into(), "echo hello".into()];
+ assert_eq!(
+ pane_custom_command_pty_builder("echo hello").get_argv(),
+ &expected
+ );
+ }
+
+ #[test]
fn read_limited_reader_returns_complete_data_under_limit() {
let input = std::io::Cursor::new(b"image".to_vec());
assert_eq!(
diff --git a/src/platform/windows.rs b/src/platform/windows.rs
index 50e0e905..cc486ace 100644
--- a/src/platform/windows.rs
+++ b/src/platform/windows.rs
@@ -46,6 +46,44 @@ struct WindowsProcessEntry {
pub fn raise_server_nofile_limit() {}
+fn raw_command_shell(comspec: Option<std::ffi::OsString>) -> std::ffi::OsString {
+ comspec
+ .filter(|value| !value.is_empty())
+ .unwrap_or_else(|| r"C:\Windows\System32\cmd.exe".into())
+}
+
+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"))
+}
+
+fn detached_custom_command_process_with_comspec(
+ command: &str,
+ comspec: Option<std::ffi::OsString>,
+) -> std::process::Command {
+ use std::os::windows::process::CommandExt;
+
+ let mut process = std::process::Command::new(raw_command_shell(comspec));
+ process.arg("/d").arg("/c").raw_arg(command);
+ process
+}
+
+pub(crate) fn pane_custom_command_pty_builder_platform(
+ command: &str,
+) -> portable_pty::CommandBuilder {
+ pane_custom_command_pty_builder_with_comspec(command, std::env::var_os("ComSpec"))
+}
+
+fn pane_custom_command_pty_builder_with_comspec(
+ command: &str,
+ comspec: Option<std::ffi::OsString>,
+) -> portable_pty::CommandBuilder {
+ let mut builder = portable_pty::CommandBuilder::new(raw_command_shell(comspec));
+ builder.arg("/d");
+ builder.arg("/c");
+ builder.raw_arg(command);
+ builder
+}
+
pub(crate) fn scrollback_editor_argv(path: &std::path::Path) -> std::io::Result<Vec<String>> {
let editor = std::env::var("VISUAL")
.ok()
@@ -537,6 +575,75 @@ mod tests {
time::{Duration, Instant},
};
+ fn argv_strings(argv: &[std::ffi::OsString]) -> Vec<String> {
+ argv.into_iter()
+ .map(|arg| arg.to_string_lossy().into_owned())
+ .collect()
+ }
+
+ #[test]
+ fn pane_custom_command_uses_cmd() {
+ let builder = super::pane_custom_command_pty_builder_with_comspec(
+ "echo hello",
+ Some(r"C:\Windows\System32\cmd.exe".into()),
+ );
+
+ assert_eq!(
+ argv_strings(builder.get_argv()),
+ [r"C:\Windows\System32\cmd.exe", "/d", "/c"]
+ );
+ }
+
+ #[test]
+ fn detached_custom_command_uses_cmd() {
+ let expected_shell = std::env::var_os("ComSpec")
+ .filter(|value| !value.is_empty())
+ .unwrap_or_else(|| r"C:\Windows\System32\cmd.exe".into())
+ .to_string_lossy()
+ .into_owned();
+
+ let process = super::detached_custom_command_process_platform("echo hello");
+
+ assert_eq!(process.get_program().to_string_lossy(), expected_shell);
+ assert_eq!(
+ process
+ .get_args()
+ .map(|arg| arg.to_string_lossy().into_owned())
+ .collect::<Vec<_>>(),
+ ["/d", "/c", "echo hello"]
+ );
+ }
+
+ #[test]
+ fn custom_command_falls_back_when_comspec_is_empty() {
+ let builder =
+ super::pane_custom_command_pty_builder_with_comspec("echo hello", Some("".into()));
+
+ assert_eq!(
+ argv_strings(builder.get_argv()),
+ [r"C:\Windows\System32\cmd.exe", "/d", "/c"]
+ );
+ }
+
+ #[test]
+ fn detached_custom_command_preserves_quoted_command_tail() {
+ let path = std::env::temp_dir().join(format!(
+ "herdr-raw-command-quotes-{}.txt",
+ std::process::id()
+ ));
+ let command = format!(r#"echo "hi" > "{}""#, path.display());
+
+ let status = super::detached_custom_command_process_platform(&command)
+ .status()
+ .expect("spawn raw command");
+
+ assert!(status.success(), "{status:?}");
+ let content = std::fs::read_to_string(&path).expect("read command output");
+ let _ = std::fs::remove_file(&path);
+ assert!(content.contains(r#""hi""#), "{content:?}");
+ assert!(!content.contains(r#"\"hi\""#), "{content:?}");
+ }
+
#[test]
fn windows_process_cwd_reads_child_launch_directory() {
let cwd = std::env::temp_dir().join(format!("herdr-cwd-test-{}", std::process::id()));
diff --git a/vendor/patches/portable-pty/0002-windows-raw-command-tail.patch b/vendor/patches/portable-pty/0002-windows-raw-command-tail.patch
new file mode 100644
index 00000000..33e09804
--- /dev/null
+++ b/vendor/patches/portable-pty/0002-windows-raw-command-tail.patch
@@ -0,0 +1,116 @@
+From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001
+From: herdr maintainers <maintainers@herdr.dev>
+Date: Sun, 5 Jul 2026 00:00:00 +0000
+Subject: [PATCH] expose Windows raw command tails
+
+Herdr launches user-authored custom command strings through the platform shell.
+On Windows, cmd.exe expects the text after /c to remain shell syntax, but
+portable-pty quotes every argv item with ArgvQuote. That escapes embedded
+quotes in the command tail and changes cmd.exe parsing.
+
+Herdr issue: https://github.com/ogulcancelik/herdr/issues/1041
+Vendored base: portable-pty 0.9.0
+---
+ vendor/portable-pty/src/cmdbuilder.rs | 40 +++++++++++++++++++++++++++
+ 1 file changed, 40 insertions(+)
+
+diff --git a/vendor/portable-pty/src/cmdbuilder.rs b/vendor/portable-pty/src/cmdbuilder.rs
+index 0000000..0000000 100644
+--- a/vendor/portable-pty/src/cmdbuilder.rs
++++ b/vendor/portable-pty/src/cmdbuilder.rs
+@@ -202,6 +202,8 @@ fn get_base_env() -> BTreeMap<OsString, EnvEntry> {
+ #[cfg_attr(feature = "serde_support", derive(Serialize, Deserialize))]
+ pub struct CommandBuilder {
+ args: Vec<OsString>,
++ #[cfg(windows)]
++ raw_args: Vec<OsString>,
+ envs: BTreeMap<OsString, EnvEntry>,
+ cwd: Option<OsString>,
+ #[cfg(unix)]
+@@ -215,6 +217,8 @@ impl CommandBuilder {
+ pub fn new<S: AsRef<OsStr>>(program: S) -> Self {
+ Self {
+ args: vec![program.as_ref().to_owned()],
++ #[cfg(windows)]
++ raw_args: Vec::new(),
+ envs: get_base_env(),
+ cwd: None,
+ #[cfg(unix)]
+@@ -227,6 +231,8 @@ impl CommandBuilder {
+ pub fn from_argv(args: Vec<OsString>) -> Self {
+ Self {
+ args,
++ #[cfg(windows)]
++ raw_args: Vec::new(),
+ envs: get_base_env(),
+ cwd: None,
+ #[cfg(unix)]
+@@ -254,6 +260,8 @@ impl CommandBuilder {
+ pub fn new_default_prog() -> Self {
+ Self {
+ args: vec![],
++ #[cfg(windows)]
++ raw_args: Vec::new(),
+ envs: get_base_env(),
+ cwd: None,
+ #[cfg(unix)]
+@@ -287,6 +295,15 @@ impl CommandBuilder {
+ }
+ }
+
++ /// Append raw shell text to the Windows command line without ArgvQuote escaping.
++ #[cfg(windows)]
++ pub fn raw_arg<S: AsRef<OsStr>>(&mut self, arg: S) {
++ if self.is_default_prog() {
++ panic!("attempted to add args to a default_prog builder");
++ }
++ self.raw_args.push(arg.as_ref().to_owned());
++ }
++
+ pub fn get_argv(&self) -> &Vec<OsString> {
+ &self.args
+ }
+@@ -692,6 +709,15 @@ impl CommandBuilder {
+ );
+ Self::append_quoted(arg, &mut cmdline);
+ }
++ for arg in &self.raw_args {
++ cmdline.push(' ' as u16);
++ anyhow::ensure!(
++ !arg.encode_wide().any(|c| c == 0),
++ "invalid encoding for raw command line argument {:?}",
++ arg
++ );
++ cmdline.extend(arg.encode_wide());
++ }
+ // Ensure that the command line is nul terminated too!
+ cmdline.push(0);
+ Ok((exe, cmdline))
+@@ -820,4 +846,27 @@ mod tests {
+ cmd.env_remove("cARGO_pKG_aUTHORS");
+ assert!(cmd.get_env("CARGO_PKG_AUTHORS").is_none());
+ }
++
++ #[cfg(windows)]
++ #[test]
++ fn raw_arg_appends_unescaped_windows_command_tail() {
++ use std::os::windows::ffi::OsStringExt;
++
++ let mut cmd = CommandBuilder::new("cmd.exe");
++ cmd.arg("/d");
++ cmd.arg("/c");
++ cmd.raw_arg(r#"echo "hi""#);
++
++ let (_exe, cmdline) = cmd.cmdline().expect("command line");
++ let nul = cmdline
++ .iter()
++ .position(|ch| *ch == 0)
++ .expect("command line should be nul terminated");
++ let command = OsString::from_wide(&cmdline[..nul])
++ .to_string_lossy()
++ .into_owned();
++
++ assert!(command.ends_with(r#"/d /c echo "hi""#), "{}", command);
++ assert!(!command.contains(r#"\"hi\""#), "{}", command);
++ }
+ }
diff --git a/vendor/portable-pty.patches.md b/vendor/portable-pty.patches.md
index 61645310..7e6d2743 100644
--- a/vendor/portable-pty.patches.md
+++ b/vendor/portable-pty.patches.md
@@ -39,3 +39,37 @@ python3 -m unittest scripts.test_vendor_portable_pty
On Windows, also verify that pane creation succeeds when `PATH` contains a
directory with `conpty.dll`.
+
+## 0002 expose Windows raw command tails
+
+status: active
+
+patch: `vendor/patches/portable-pty/0002-windows-raw-command-tail.patch`
+
+herdr issue: https://github.com/ogulcancelik/herdr/issues/1041
+
+upstream discussion: none
+
+upstream pr: none
+
+vendored base: `portable-pty 0.9.0`
+
+local files:
+
+- `vendor/portable-pty/src/cmdbuilder.rs`
+
+reason: Herdr needs to launch `cmd.exe /d /c` with the user-authored command
+tail parsed as shell text. `portable-pty` represents commands as argv and
+ArgvQuote escapes embedded quotes, which changes how `cmd.exe` parses the raw
+command string.
+
+remove when: upstream `portable-pty` exposes Windows raw command-line tail
+support or Herdr replaces this launch path.
+
+verification:
+
+```sh
+python3 -m unittest scripts.test_vendor_portable_pty
+```
+
+On Windows, also run `cargo test raw_arg_appends_unescaped_windows_command_tail`.
diff --git a/vendor/portable-pty/src/cmdbuilder.rs b/vendor/portable-pty/src/cmdbuilder.rs
index b4ce50a8..385fe81c 100644
--- a/vendor/portable-pty/src/cmdbuilder.rs
+++ b/vendor/portable-pty/src/cmdbuilder.rs
@@ -202,6 +202,8 @@ fn get_base_env() -> BTreeMap<OsString, EnvEntry> {
#[cfg_attr(feature = "serde_support", derive(Serialize, Deserialize))]
pub struct CommandBuilder {
args: Vec<OsString>,
+ #[cfg(windows)]
+ raw_args: Vec<OsString>,
envs: BTreeMap<OsString, EnvEntry>,
cwd: Option<OsString>,
#[cfg(unix)]
@@ -215,6 +217,8 @@ impl CommandBuilder {
pub fn new<S: AsRef<OsStr>>(program: S) -> Self {
Self {
args: vec![program.as_ref().to_owned()],
+ #[cfg(windows)]
+ raw_args: Vec::new(),
envs: get_base_env(),
cwd: None,
#[cfg(unix)]
@@ -227,6 +231,8 @@ impl CommandBuilder {
pub fn from_argv(args: Vec<OsString>) -> Self {
Self {
args,
+ #[cfg(windows)]
+ raw_args: Vec::new(),
envs: get_base_env(),
cwd: None,
#[cfg(unix)]
@@ -254,6 +260,8 @@ impl CommandBuilder {
pub fn new_default_prog() -> Self {
Self {
args: vec![],
+ #[cfg(windows)]
+ raw_args: Vec::new(),
envs: get_base_env(),
cwd: None,
#[cfg(unix)]
@@ -287,6 +295,15 @@ impl CommandBuilder {
}
}
+ /// Append raw shell text to the Windows command line without ArgvQuote escaping.
+ #[cfg(windows)]
+ pub fn raw_arg<S: AsRef<OsStr>>(&mut self, arg: S) {
+ if self.is_default_prog() {
+ panic!("attempted to add args to a default_prog builder");
+ }
+ self.raw_args.push(arg.as_ref().to_owned());
+ }
+
pub fn get_argv(&self) -> &Vec<OsString> {
&self.args
}
@@ -692,6 +709,15 @@ impl CommandBuilder {
);
Self::append_quoted(arg, &mut cmdline);
}
+ for arg in &self.raw_args {
+ cmdline.push(' ' as u16);
+ anyhow::ensure!(
+ !arg.encode_wide().any(|c| c == 0),
+ "invalid encoding for raw command line argument {:?}",
+ arg
+ );
+ cmdline.extend(arg.encode_wide());
+ }
// Ensure that the command line is nul terminated too!
cmdline.push(0);
Ok((exe, cmdline))
@@ -820,4 +846,27 @@ mod tests {
cmd.env_remove("cARGO_pKG_aUTHORS");
assert!(cmd.get_env("CARGO_PKG_AUTHORS").is_none());
}
+
+ #[cfg(windows)]
+ #[test]
+ fn raw_arg_appends_unescaped_windows_command_tail() {
+ use std::os::windows::ffi::OsStringExt;
+
+ let mut cmd = CommandBuilder::new("cmd.exe");
+ cmd.arg("/d");
+ cmd.arg("/c");
+ cmd.raw_arg(r#"echo "hi""#);
+
+ let (_exe, cmdline) = cmd.cmdline().expect("command line");
+ let nul = cmdline
+ .iter()
+ .position(|ch| *ch == 0)
+ .expect("command line should be nul terminated");
+ let command = OsString::from_wide(&cmdline[..nul])
+ .to_string_lossy()
+ .into_owned();
+
+ assert!(command.ends_with(r#"/d /c echo "hi""#), "{}", command);
+ assert!(!command.contains(r#"\"hi\""#), "{}", command);
+ }
}