aboutsummaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
authorJJ Liebig <jonathan.liebig@gmail.com>2026-09-05 21:56:17 +0200
committerGitHub <noreply@github.com>2026-09-05 21:56:17 +0200
commit6f57f2f62c5d2591647262caf15bc3c962b9c9f8 (patch)
treeaeece6f488a6ec55e15369151fed74f0137a69bb /src
parent50ce4ec2ddb8ec621a77d8773aa6df6d9e7441ff (diff)
fix: resolve Windows plugin pane launch paths (#3666)
* fix: resolve Windows plugin pane launch paths refs #3024 * test: compare resolved Windows plugin probe paths refs #3024 * test: compare resolved Windows plugin working directories refs #3024 * test: wait for Windows plugin fixture completion refs #3024
Diffstat (limited to 'src')
-rw-r--r--src/app/api/plugins/manifest.rs8
-rw-r--r--src/app/api/plugins/mod.rs187
-rw-r--r--src/platform/mod.rs9
-rw-r--r--src/platform/windows.rs107
-rw-r--r--src/plugin_command.rs60
5 files changed, 336 insertions, 35 deletions
diff --git a/src/app/api/plugins/manifest.rs b/src/app/api/plugins/manifest.rs
index 657f8257..fb650065 100644
--- a/src/app/api/plugins/manifest.rs
+++ b/src/app/api/plugins/manifest.rs
@@ -128,15 +128,13 @@ pub(crate) fn load_plugin_manifest(
let manifest_path = manifest_path
.canonicalize()
.map_err(|err| ("plugin_manifest_not_found", err.to_string()))?;
- let plugin_root = manifest_path
- .parent()
- .ok_or_else(|| {
+ let plugin_root =
+ crate::platform::plugin_runtime_path(manifest_path.parent().ok_or_else(|| {
(
"invalid_plugin_manifest_path",
"manifest path has no parent directory".to_string(),
)
- })?
- .to_path_buf();
+ })?);
let content = std::fs::read_to_string(&manifest_path)
.map_err(|err| ("plugin_manifest_read_failed", err.to_string()))?;
let raw: RawPluginManifest = toml::from_str(&content)
diff --git a/src/app/api/plugins/mod.rs b/src/app/api/plugins/mod.rs
index 056315c3..f4b6329b 100644
--- a/src/app/api/plugins/mod.rs
+++ b/src/app/api/plugins/mod.rs
@@ -440,7 +440,7 @@ impl App {
let Some(entrypoint) = normalize_action_id(&params.entrypoint) else {
return encode_error(id, "invalid_plugin_entrypoint", "invalid entrypoint id");
};
- let Some(pane) = plugin
+ let Some(mut pane) = plugin
.panes
.iter()
.find(|pane| pane.id == entrypoint)
@@ -458,6 +458,12 @@ impl App {
) {
return encode_error(id, code, message);
}
+ pane.command[0] = crate::plugin_command::program_for_cwd(
+ &pane.command[0],
+ std::path::Path::new(&plugin.plugin_root),
+ )
+ .display()
+ .to_string();
let placement = params.placement.unwrap_or(pane.placement);
if placement != PluginPanePlacement::Popup
&& (params.width.is_some() || params.height.is_some())
@@ -987,7 +993,16 @@ platforms = ["linux", "macos", "windows"]
assert_eq!(plugin.plugin_id, "example.worktree-bootstrap");
assert_eq!(plugin.name, "Worktree Bootstrap");
assert_eq!(plugin.version, "0.1.0");
- assert_eq!(plugin.plugin_root, canonical_path_string(&root));
+ assert_eq!(
+ plugin.manifest_path,
+ canonical_path_string(&root.join("herdr-plugin.toml"))
+ );
+ assert_eq!(
+ plugin.plugin_root,
+ crate::platform::plugin_runtime_path(&root.canonicalize().unwrap())
+ .display()
+ .to_string()
+ );
assert!(plugin.enabled);
assert_eq!(plugin.build.len(), 1);
assert_eq!(plugin.build[0].command, ["bun", "install"]);
@@ -1466,6 +1481,174 @@ platforms = ["linux", "macos"]
let _ = std::fs::remove_dir_all(root);
}
+ #[cfg(windows)]
+ #[tokio::test]
+ async fn windows_plugin_pane_commands_resolve_from_plugin_root_with_cwd_override() {
+ use std::os::windows::ffi::OsStrExt;
+
+ let mut app = test_app();
+ app.state.workspaces = vec![crate::workspace::Workspace::test_new("plugin-pane-paths")];
+ app.state.ensure_test_terminals();
+ app.state.active = Some(0);
+ app.state.selected = 0;
+ app.state.mode = crate::app::Mode::Terminal;
+
+ let root = unique_temp_path("plugin-pane-paths");
+ let child_cwd = root.join("child-cwd");
+ std::fs::create_dir_all(&child_cwd).unwrap();
+ let cleanup_root = root.canonicalize().unwrap();
+ let tool = root.join("tool.exe");
+ let long_where_relative = format!("bin/{}/where.exe", "x".repeat(220));
+ let long_where = root.join(&long_where_relative);
+ std::fs::create_dir_all(long_where.parent().unwrap()).unwrap();
+ let shell = std::env::var_os("ComSpec").expect("ComSpec");
+ std::fs::copy(&shell, &tool).unwrap();
+ let where_exe = std::path::PathBuf::from(std::env::var_os("SystemRoot").unwrap())
+ .join("System32/where.exe");
+ std::fs::copy(where_exe, &long_where).unwrap();
+ let where_probe = "herdr-long-path-probe.exe";
+ std::fs::write(child_cwd.join(where_probe), b"").unwrap();
+ assert!(
+ root.as_os_str().encode_wide().count()
+ < windows_sys::Win32::Foundation::MAX_PATH as usize
+ );
+ assert!(
+ long_where.as_os_str().encode_wide().count()
+ >= windows_sys::Win32::Foundation::MAX_PATH as usize
+ );
+ let script = "@echo off\r\n(echo %1&cd)>capture-%1.tmp\r\nmove /y capture-%1.tmp capture-%1.txt >nul\r\n";
+ std::fs::write(root.join("slot.cmd"), script).unwrap();
+ std::fs::write(child_cwd.join("slot.cmd"), script).unwrap();
+ write_manifest_content(
+ &root,
+ &format!(
+ r#"
+id = "example.pane-paths"
+name = "Pane Paths"
+version = "0.1.0"
+min_herdr_version = "0.7.0"
+platforms = ["windows"]
+
+[[panes]]
+id = "explicit"
+title = "Explicit"
+command = ["./tool.exe", "/d", "/c", "slot.cmd", "explicit"]
+
+[[panes]]
+id = "bare"
+title = "Bare"
+command = ["tool.exe", "/d", "/c", "slot.cmd", "bare"]
+
+[[panes]]
+id = "path"
+title = "Path"
+command = ["cmd.exe", "/d", "/c", "slot.cmd", "path"]
+
+[[panes]]
+id = "absolute"
+title = "Absolute"
+command = ['{}', "/d", "/c", "slot.cmd", "absolute"]
+
+[[panes]]
+id = "long"
+title = "Long executable"
+command = ['./{}', "{}"]
+
+[[panes]]
+id = "default"
+title = "Default cwd"
+command = ["cmd.exe", "/d", "/c", "slot.cmd", "default"]
+"#,
+ tool.display(),
+ long_where_relative,
+ where_probe
+ ),
+ );
+ link_manifest(&mut app, &root);
+
+ for (entrypoint, cwd) in [
+ ("explicit", Some(&child_cwd)),
+ ("bare", Some(&child_cwd)),
+ ("path", Some(&child_cwd)),
+ ("absolute", Some(&child_cwd)),
+ ("long", Some(&child_cwd)),
+ ("default", None),
+ ] {
+ let expected_cwd = cwd.unwrap_or(&root);
+ let open = app.handle_api_request(Request {
+ id: format!("pane-open-{entrypoint}"),
+ method: Method::PluginPaneOpen(PluginPaneOpenParams {
+ plugin_id: "example.pane-paths".into(),
+ entrypoint: entrypoint.into(),
+ placement: Some(PluginPanePlacement::Split),
+ width: None,
+ height: None,
+ workspace_id: None,
+ target_pane_id: None,
+ direction: None,
+ cwd: cwd.map(|path| path.display().to_string()),
+ focus: false,
+ env: std::collections::HashMap::new(),
+ }),
+ });
+ let ResponseResult::PluginPaneOpened { plugin_pane } = response_result(&open) else {
+ panic!("failed to open {entrypoint} plugin pane: {open}");
+ };
+ if entrypoint == "long" {
+ let Some((ws_idx, pane_id)) = app.parse_pane_id(&plugin_pane.pane.pane_id) else {
+ panic!("opened pane id should parse");
+ };
+ let runtime = app
+ .state
+ .runtime_for_pane_in_workspace(&app.terminal_runtimes, ws_idx, pane_id)
+ .expect("long executable pane runtime");
+ let expected = child_cwd.join(where_probe).canonicalize().unwrap();
+ let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
+ loop {
+ let text = runtime.recent_unwrapped_text(20);
+ if text.lines().any(|line| {
+ std::fs::canonicalize(line.trim()).is_ok_and(|path| path == expected)
+ }) {
+ break;
+ }
+ assert!(
+ std::time::Instant::now() < deadline,
+ "long where.exe output missing from pane: {text:?}"
+ );
+ std::thread::sleep(std::time::Duration::from_millis(10));
+ }
+ continue;
+ }
+ let capture = read_capture_when_ready(
+ &expected_cwd.join(format!("capture-{entrypoint}.txt")),
+ || {},
+ );
+ let mut lines = capture.lines();
+ assert_eq!(lines.next(), Some(entrypoint));
+ let actual_cwd = std::fs::canonicalize(lines.next().expect("captured cwd")).unwrap();
+ assert_eq!(actual_cwd, expected_cwd.canonicalize().unwrap());
+ }
+
+ for (_, runtime) in app.terminal_runtimes.drain() {
+ runtime.shutdown();
+ }
+ // PTY actor shutdown is queued, so its Windows handles can outlive `shutdown()`.
+ let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
+ loop {
+ match std::fs::remove_dir_all(&cleanup_root) {
+ Ok(()) => break,
+ Err(error) => {
+ assert!(
+ std::time::Instant::now() < deadline,
+ "failed to remove {} after runtime shutdown: {error}",
+ cleanup_root.display()
+ );
+ std::thread::sleep(std::time::Duration::from_millis(10));
+ }
+ }
+ }
+ }
+
#[cfg(unix)]
#[tokio::test]
async fn plugin_pane_open_uses_plugin_root_title_env_and_target_context() {
diff --git a/src/platform/mod.rs b/src/platform/mod.rs
index 402b695d..4b49b9f7 100644
--- a/src/platform/mod.rs
+++ b/src/platform/mod.rs
@@ -43,6 +43,15 @@ pub(crate) fn prepare_paste_text_for_pty(text: String) -> String {
prepare_paste_text_for_pty_platform(text)
}
+pub(crate) fn plugin_runtime_path(path: &std::path::Path) -> std::path::PathBuf {
+ plugin_runtime_path_platform(path)
+}
+
+#[cfg(not(windows))]
+fn plugin_runtime_path_platform(path: &std::path::Path) -> std::path::PathBuf {
+ path.to_path_buf()
+}
+
#[cfg(not(windows))]
fn prepare_paste_text_for_pty_platform(text: String) -> String {
text
diff --git a/src/platform/windows.rs b/src/platform/windows.rs
index 10fbb6f0..1c5c4afb 100644
--- a/src/platform/windows.rs
+++ b/src/platform/windows.rs
@@ -30,7 +30,7 @@ use windows_sys::{
Win32::{
Foundation::{
CloseHandle, GlobalFree, LocalFree, FILETIME, HANDLE, HWND, INVALID_HANDLE_VALUE,
- NTSTATUS, STATUS_SUCCESS, UNICODE_STRING,
+ MAX_PATH, NTSTATUS, STATUS_SUCCESS, UNICODE_STRING,
},
Globalization::{CompareStringOrdinal, CSTR_EQUAL, CSTR_GREATER_THAN, CSTR_LESS_THAN},
Security::SECURITY_ATTRIBUTES,
@@ -105,6 +105,44 @@ pub(crate) fn prepare_paste_text_for_pty_platform(text: String) -> String {
text.replace("\r\n", "\n").replace('\n', "\r\n")
}
+pub(crate) fn plugin_runtime_path_platform(path: &std::path::Path) -> PathBuf {
+ use std::os::windows::ffi::OsStrExt;
+
+ let Some(candidate) = standard_windows_path(path) else {
+ return path.to_path_buf();
+ };
+ // Rust can canonicalize a long standard path by adding its own verbatim prefix, but native
+ // process consumers still need the original prefix when the plugin root exceeds MAX_PATH.
+ if candidate.join("").as_os_str().encode_wide().count() >= MAX_PATH as usize {
+ return path.to_path_buf();
+ }
+ match candidate.canonicalize() {
+ Ok(canonical) if canonical == path => candidate,
+ _ => path.to_path_buf(),
+ }
+}
+
+fn standard_windows_path(path: &std::path::Path) -> Option<PathBuf> {
+ use std::path::{Component, Prefix};
+
+ let mut components = path.components();
+ let Component::Prefix(prefix) = components.next()? else {
+ return None;
+ };
+ let mut candidate = match prefix.kind() {
+ Prefix::VerbatimDisk(drive) => PathBuf::from(format!("{}:", char::from(drive))),
+ Prefix::VerbatimUNC(server, share) => {
+ let mut candidate = PathBuf::from(r"\\");
+ candidate.push(server);
+ candidate.push(share);
+ candidate
+ }
+ _ => return None,
+ };
+ candidate.push(components.as_path());
+ Some(candidate)
+}
+
/// Resolves against the current foreground layout because asynchronous console
/// records do not retain the layout that was active when the key was pressed.
pub(crate) fn resolve_base_printable_key(vk: u16, scan: u16) -> Option<char> {
@@ -2570,6 +2608,73 @@ mod tests {
};
#[test]
+ fn windows_standard_plugin_runtime_paths_drop_only_disk_and_unc_verbatim_prefixes() {
+ assert_eq!(
+ super::standard_windows_path(std::path::Path::new(r"\\?\C:\plugins\example")),
+ Some(std::path::PathBuf::from(r"C:\plugins\example"))
+ );
+ assert_eq!(
+ super::standard_windows_path(std::path::Path::new(
+ r"\\?\UNC\server\share\plugins\example"
+ )),
+ Some(std::path::PathBuf::from(r"\\server\share\plugins\example"))
+ );
+ assert_eq!(
+ super::standard_windows_path(std::path::Path::new(
+ r"\\?\Volume{01234567-89ab-cdef-0123-456789abcdef}\plugins"
+ )),
+ None
+ );
+ }
+
+ #[test]
+ fn windows_plugin_runtime_path_keeps_extended_path_when_normal_form_is_not_equivalent() {
+ let path = std::path::PathBuf::from(format!(
+ r"\\?\C:\herdr-missing-plugin-runtime-path-{}",
+ std::process::id()
+ ));
+ assert_eq!(super::plugin_runtime_path_platform(&path), path);
+ }
+
+ #[test]
+ fn windows_plugin_runtime_path_keeps_verbatim_root_beyond_max_path() {
+ use std::os::windows::ffi::OsStrExt;
+
+ let base = std::env::temp_dir().join(format!(
+ "herdr-plugin-runtime-path-limit-test-{}",
+ std::process::id()
+ ));
+ fs::create_dir_all(&base).expect("create test base");
+ let extended_base = base.canonicalize().expect("canonicalize test base");
+ let normal_base = super::standard_windows_path(&extended_base)
+ .expect("test base has a standard drive path");
+ let normal_base_len = normal_base.as_os_str().encode_wide().count();
+ let root_at_length = |length| {
+ let component_len = length - normal_base_len - 1;
+ let path = extended_base.join("é".repeat(component_len));
+ fs::create_dir(&path).expect("create length-boundary test root");
+ path.canonicalize()
+ .expect("canonicalize length-boundary test root")
+ };
+
+ let at_limit = root_at_length(windows_sys::Win32::Foundation::MAX_PATH as usize - 2);
+ let at_limit_normal =
+ super::standard_windows_path(&at_limit).expect("convert root at MAX_PATH boundary");
+ assert_eq!(
+ super::plugin_runtime_path_platform(&at_limit),
+ at_limit_normal
+ );
+
+ let beyond_limit = root_at_length(windows_sys::Win32::Foundation::MAX_PATH as usize - 1);
+ assert_eq!(
+ super::plugin_runtime_path_platform(&beyond_limit),
+ beyond_limit
+ );
+
+ fs::remove_dir_all(base).expect("remove test directory");
+ }
+
+ #[test]
fn paste_text_uses_windows_line_endings() {
assert_eq!(
super::prepare_paste_text_for_pty_platform("one\ntwo\r\nthree\rfour".to_owned()),
diff --git a/src/plugin_command.rs b/src/plugin_command.rs
index 6b639f0d..48f37c57 100644
--- a/src/plugin_command.rs
+++ b/src/plugin_command.rs
@@ -1,24 +1,28 @@
-use std::ffi::{OsStr, OsString};
-use std::path::Path;
-#[cfg(windows)]
-use std::path::PathBuf;
+use std::ffi::OsStr;
+use std::path::{Path, PathBuf};
use std::process::Command;
pub(crate) fn command_for_argv_in_dir(program: &str, args: &[String], cwd: &Path) -> Command {
let program = program_for_cwd(program, cwd);
- let mut command = command_for_program(&program);
+ let mut command = command_for_program(program.as_os_str());
command.args(args).current_dir(cwd);
command
}
-fn program_for_cwd(program: &str, cwd: &Path) -> OsString {
+pub(crate) fn program_for_cwd(program: &str, cwd: &Path) -> PathBuf {
let path = Path::new(program);
let has_separator = program.contains('/') || (cfg!(windows) && program.contains('\\'));
- if path.is_relative() && has_separator {
+ if path.is_relative() && (has_separator || (cfg!(windows) && cwd.join(path).is_file())) {
let relative = path.strip_prefix(Path::new(".")).unwrap_or(path);
- cwd.join(relative).into_os_string()
+ #[cfg(windows)]
+ if is_windows_batch_file_name(path.as_os_str()) {
+ return cwd.join(relative);
+ }
+ #[cfg(windows)]
+ let cwd = cwd.canonicalize().unwrap_or_else(|_| cwd.to_path_buf());
+ cwd.join(relative)
} else {
- path.as_os_str().to_os_string()
+ path.to_path_buf()
}
}
@@ -139,11 +143,8 @@ mod tests {
fn resolves_explicit_relative_program_against_working_directory() {
let cwd = Path::new("plugin-root");
- assert_eq!(
- program_for_cwd("./bin/tool", cwd),
- cwd.join("bin/tool").into_os_string()
- );
- assert_eq!(program_for_cwd("tool", cwd), OsString::from("tool"));
+ assert_eq!(program_for_cwd("./bin/tool", cwd), cwd.join("bin/tool"));
+ assert_eq!(program_for_cwd("tool", cwd), PathBuf::from("tool"));
}
#[test]
@@ -157,24 +158,29 @@ mod tests {
#[cfg(windows)]
#[test]
fn windows_batch_command_captures_output() {
- let path = std::env::temp_dir().join(format!(
- "herdr-plugin-command-output-{}.cmd",
+ let root = std::env::temp_dir().join(format!(
+ "herdr plugin command output {}",
std::process::id()
));
+ std::fs::create_dir_all(&root).expect("create batch fixture directory");
+ let path = root.join("capture.cmd");
std::fs::write(&path, "@echo off\r\necho plugin-%1\r\n").expect("write batch fixture");
- let cwd = path.parent().expect("batch fixture parent");
-
- let output =
- command_for_argv_in_dir(&path.display().to_string(), &["ready".to_string()], cwd)
+ for program in [
+ path.display().to_string(),
+ "./capture.cmd".to_string(),
+ "capture.cmd".to_string(),
+ ] {
+ let output = command_for_argv_in_dir(&program, &["ready".to_string()], &root)
.output()
.expect("run batch fixture");
- let _ = std::fs::remove_file(&path);
-
- assert!(output.status.success(), "{output:?}");
- assert_eq!(
- String::from_utf8_lossy(&output.stdout).trim(),
- "plugin-ready"
- );
+ assert!(output.status.success(), "{program}: {output:?}");
+ assert_eq!(
+ String::from_utf8_lossy(&output.stdout).trim(),
+ "plugin-ready",
+ "{program}"
+ );
+ }
+ std::fs::remove_dir_all(root).expect("remove batch fixture directory");
}
#[cfg(windows)]