From b459c5706e4cafa34bc243e7b27778b4b4714a31 Mon Sep 17 00:00:00 2001 From: Jonathan Liebig Date: Sat, 5 Sep 2026 21:58:41 +0200 Subject: [PATCH 1/4] feat: install windows remote host packages refs #3651 --- .../src/content/docs/persistence-remote.mdx | 6 +- .../website/src/content/docs/windows-beta.mdx | 4 +- src/checksum.rs | 2 +- src/cli/status.rs | 2 + src/remote/attach.rs | 492 +++++++++++++++--- src/update.rs | 3 +- tests/cli/sessions.rs | 2 + 7 files changed, 427 insertions(+), 84 deletions(-) diff --git a/docs/next/website/src/content/docs/persistence-remote.mdx b/docs/next/website/src/content/docs/persistence-remote.mdx index bdfe5177c4..6f3cb695fc 100644 --- a/docs/next/website/src/content/docs/persistence-remote.mdx +++ b/docs/next/website/src/content/docs/persistence-remote.mdx @@ -67,7 +67,7 @@ Then attach with: herdr --remote workbox ``` -Remote attach and saved SSH machines support Linux, macOS, and Windows local clients connecting to Linux or macOS hosts on x86_64 and aarch64, or Windows hosts on x86_64. Local and remote versions do not need to match once both support the stable endpoint generation. On Linux and macOS hosts, Herdr prefers a compatible `herdr` already on the remote `PATH`, then checks common direct, Homebrew, mise, and Nix profile install paths. If no compatible binary exists, interactive runs prompt to install one to `~/.local/bin/herdr`; non-interactive runs fail instead of modifying the host. If `~/.local/bin` is not on the remote `PATH`, Herdr warns after install. Windows hosts must already have a compatible Herdr package with remote host support on `PATH`; remote attach does not install or update it. +Remote attach and saved SSH machines support Linux, macOS, and Windows local clients connecting to Linux or macOS hosts on x86_64 and aarch64, or Windows hosts on x86_64. Local and remote versions do not need to match once both support the stable endpoint generation. On Linux and macOS hosts, Herdr prefers a compatible `herdr` already on the remote `PATH`, then checks common direct, Homebrew, mise, and Nix profile install paths. On Windows hosts, Herdr checks `PATH` and the active managed release. If no compatible binary exists, interactive runs prompt to install or update one; non-interactive runs fail instead of modifying the host. Linux and macOS installs use `~/.local/bin/herdr`; Windows installs use the complete Windows package and its app-local ConPTY runtime. If `~/.local/bin` is not on the remote `PATH`, Herdr warns after install. By default, `herdr --remote` runs remote setup and the bridge through a temporary SSH config that includes your SSH config first, then adds fallback keepalive settings. Existing user keepalive settings win. Linux and macOS clients also use a private per-attach control socket for connection reuse; Windows OpenSSH does not. Set `[remote].manage_ssh_config = false` to use plain `ssh` without Herdr's generated config or control socket. @@ -88,9 +88,9 @@ herdr --remote workbox --handoff If you SSH into the server first and run `herdr` there, Herdr runs entirely on the server and cannot access your local desktop clipboard beyond normal terminal text paste. -For Linux and macOS hosts, Herdr can copy the current local binary when the platforms match. For Homebrew, mise, and Nix installs, or when the platforms differ, it downloads the matching release asset for the current client version from `https://herdr.dev/latest.json`. +For Linux and macOS hosts, Herdr can copy the current local binary when the platforms match. For Homebrew, mise, and Nix installs, when the platforms differ, or for Windows hosts, it downloads the matching release asset for the current client version from `https://herdr.dev/latest.json`. -For local builds or custom binaries targeting Linux or macOS hosts, set `HERDR_REMOTE_BINARY` to a local file path before running remote attach. +For local builds or custom packages, set `HERDR_REMOTE_BINARY` to a local file path before running remote attach. Use a bare Herdr executable for Linux or macOS targets and a complete `herdr-windows-x86_64.zip` package for Windows targets. ```bash HERDR_REMOTE_BINARY=target/release/herdr herdr --remote workbox diff --git a/docs/next/website/src/content/docs/windows-beta.mdx b/docs/next/website/src/content/docs/windows-beta.mdx index 8f34dfb9f5..2876ce908d 100644 --- a/docs/next/website/src/content/docs/windows-beta.mdx +++ b/docs/next/website/src/content/docs/windows-beta.mdx @@ -32,7 +32,7 @@ For internal testing, `HERDR_MANIFEST_URL` can point the installer at a custom m | Local persistent sessions | supported | | Native panes through ConPTY | supported | | Windows Terminal / PowerShell app attach | supported | -| `herdr --remote` and saved SSH machines to Linux/macOS/Windows hosts | supported; Windows requires a compatible package with remote host support on `PATH` | +| `herdr --remote` and saved SSH machines to Linux/macOS/Windows hosts | supported; Windows packages are installed or updated after confirmation when needed | | Remote clipboard images and image-file drops | supported | | `cmd.exe` panes | supported | | Native keyboard and mouse input | supported | @@ -130,7 +130,7 @@ From Windows Terminal, use the same remote command as Linux and macOS: herdr --remote workbox ``` -The target host can run Linux, macOS, or Windows. Windows hosts must already have a compatible Herdr package with remote host support on `PATH`; remote attach does not install or update it. Herdr uses the installed Windows OpenSSH client and your SSH configuration. Windows OpenSSH does not use Herdr's Unix control-socket reuse, so key authentication through Windows `ssh-agent` is recommended to avoid repeated prompts during remote setup. +The target host can run Linux, macOS, or Windows. On Windows hosts, remote attach reuses a compatible package from `PATH` or the active managed release. It prompts before installing or updating the complete package when needed. Herdr uses the installed Windows OpenSSH client and your SSH configuration. Windows OpenSSH does not use Herdr's Unix control-socket reuse, so key authentication through Windows `ssh-agent` is recommended to avoid repeated prompts during remote setup. Windows updates run through the Windows installer and update the active versioned release path. New terminals and reconnected SSH sessions receive that path; start Herdr there to use the updated client. Compatible running servers keep their panes alive. Restart a server later only when you need server-side changes from the release. Live handoff is Unix-only. diff --git a/src/checksum.rs b/src/checksum.rs index 4f61a33cb2..ed84169b07 100644 --- a/src/checksum.rs +++ b/src/checksum.rs @@ -25,7 +25,7 @@ pub(crate) fn verify_sha256(path: &Path, expected: &str) -> io::Result<()> { Ok(()) } -fn file_sha256(path: &Path) -> io::Result { +pub(crate) fn file_sha256(path: &Path) -> io::Result { let mut file = File::open(path)?; let mut hasher = Sha256::new(); let mut buffer = [0u8; 64 * 1024]; diff --git a/src/cli/status.rs b/src/cli/status.rs index fa008bdca5..99f278cf63 100644 --- a/src/cli/status.rs +++ b/src/cli/status.rs @@ -252,6 +252,7 @@ struct ClientStatusJson { protocol: u32, endpoint_protocol_generation: u32, endpoint_capabilities: Vec<&'static str>, + remote_host_bridge: bool, binary: String, session: Option, } @@ -297,6 +298,7 @@ fn client_status_json() -> ClientStatusJson { crate::protocol::endpoint::PRESENTATION_EFFECTS_FENCE_CAPABILITY, crate::protocol::endpoint::HEALTH_CHECK_CAPABILITY, ], + remote_host_bridge: true, binary: current_exe_label(), session: crate::session::active_name(), } diff --git a/src/remote/attach.rs b/src/remote/attach.rs index 042676d03e..1ac99010d5 100644 --- a/src/remote/attach.rs +++ b/src/remote/attach.rs @@ -34,6 +34,9 @@ const STABLE_UPDATE_MANIFEST_URL: &str = "https://herdr.dev/latest.json"; const PREVIEW_UPDATE_MANIFEST_URL: &str = "https://herdr.dev/preview.json"; const REMOTE_BINARY_ENV_VAR: &str = "HERDR_REMOTE_BINARY"; const REMOTE_OUTPUT_READY_MARKER: &str = "herdr-remote-output-ready:1"; +const WINDOWS_REMOTE_PATH_MARKER: &str = "herdr-remote-path:1:"; +const WINDOWS_REMOTE_INSTALL_DIR_MARKER: &str = "herdr-remote-install-dir:1:"; +const WINDOWS_REMOTE_INSTALL_RESULT_MARKER: &str = "herdr-remote-install-result:1:"; const SSH_CONTROL_SOCKET_NAME: &str = "ctl"; pub(crate) fn run_remote(remote: RemoteLaunch) -> io::Result<()> { let session_name = crate::session::active_name() @@ -332,6 +335,11 @@ impl RemoteHerdr { self.executable = RemoteExecutable::PosixShellPath(shell_path); self } + + fn with_windows_path(mut self, path: String) -> Self { + self.executable = RemoteExecutable::WindowsPath(path); + self + } } fn windows_powershell_application_script(path: &str, args: &[&str]) -> String { @@ -584,6 +592,12 @@ impl RemoteSsh { command } + fn scp_command(&self) -> Command { + let mut command = Command::new("scp"); + apply_managed_scp_options(&mut command, self.options()); + command + } + fn sh_output(&self, script: &str) -> io::Result { let script = posix_remote_output_command(script); let mut child = self @@ -683,6 +697,86 @@ impl RemoteSsh { ))) } } + + fn copy_windows_file(&self, source_path: &Path, remote_path: &str) -> io::Result<()> { + let status = self + .scp_command() + .arg(source_path) + .arg(windows_scp_target(&self.target, remote_path)) + .status() + .map_err(|err| io::Error::new(err.kind(), format!("failed to start scp: {err}")))?; + if status.success() { + Ok(()) + } else { + Err(io::Error::other(format!("scp exited with {status}"))) + } + } + + fn install_windows_herdr( + &self, + remote_herdr: &RemoteHerdr, + source_path: &Path, + identity: &str, + sha256: &str, + ) -> io::Result { + let installer_dir = private_download_dir("windows-installer")?; + let local_installer_path = installer_dir.join("install.ps1"); + if let Err(err) = fs::write(&local_installer_path, crate::update::WINDOWS_INSTALLER) { + let _ = fs::remove_dir_all(&installer_dir); + return Err(err); + } + + let result = (|| { + let output = self.framed_user_shell_output(&windows_remote_install_prepare_command())?; + if !output.status.success() { + return Err(command_failed("remote install preparation failed", &output)); + } + let remote_dir = parse_windows_remote_path( + &String::from_utf8_lossy(&output.stdout), + WINDOWS_REMOTE_INSTALL_DIR_MARKER, + )?; + let install_result = (|| { + self.copy_windows_file( + &local_installer_path, + &format!(r"{remote_dir}\install.ps1"), + )?; + self.copy_windows_file( + source_path, + &format!(r"{remote_dir}\herdr-windows-x86_64.zip"), + )?; + + let output = self.framed_user_shell_output(&windows_remote_install_command( + &remote_dir, + identity, + sha256, + ))?; + if !output.status.success() { + return Err(command_failed("remote Windows install failed", &output)); + } + parse_windows_remote_path( + &String::from_utf8_lossy(&output.stdout), + WINDOWS_REMOTE_INSTALL_RESULT_MARKER, + ) + .map(|path| remote_herdr.clone().with_windows_path(path)) + })(); + + let cleanup = + self.framed_user_shell_output(&windows_remote_install_cleanup_command(&remote_dir)); + match install_result { + Err(err) => Err(err), + Ok(installed) => { + let output = cleanup?; + if output.status.success() { + Ok(installed) + } else { + Err(command_failed("remote install cleanup failed", &output)) + } + } + } + })(); + let _ = fs::remove_dir_all(installer_dir); + result + } } fn normalize_remote_output(mut output: Output) -> io::Result { @@ -750,6 +844,62 @@ fn remote_install_commit_script(tmp_path: &str, dest_path: &str) -> String { ) } +fn windows_remote_install_prepare_command() -> String { + windows_powershell_script_command(&format!( + "$remoteInstallDir = Join-Path ([System.IO.Path]::GetTempPath()) ('herdr-remote-' + [System.Guid]::NewGuid().ToString('N')); [System.IO.Directory]::CreateDirectory($remoteInstallDir) | Out-Null; $encodedInstallDir = [System.Convert]::ToBase64String([System.Text.Encoding]::UTF8.GetBytes($remoteInstallDir)); [Console]::Out.WriteLine('{WINDOWS_REMOTE_INSTALL_DIR_MARKER}' + $encodedInstallDir); exit 0" + )) +} + +fn windows_scp_target(target: &str, remote_path: &str) -> String { + let remote_path = remote_path.replace('\\', "/"); + match target.strip_prefix("ssh://") { + Some(authority) => format!("scp://{authority}/{remote_path}"), + None => format!("{target}:{remote_path}"), + } +} + +fn windows_remote_install_command(remote_dir: &str, identity: &str, sha256: &str) -> String { + let installer = crate::platform::quote_powershell_arg(&format!(r"{remote_dir}\install.ps1")); + let package = + crate::platform::quote_powershell_arg(&format!(r"{remote_dir}\herdr-windows-x86_64.zip")); + windows_powershell_script_command(&format!( + r#"$herdrInstaller = {installer}; $herdrPackage = {package}; & powershell.exe -NoLogo -NoProfile -NonInteractive -ExecutionPolicy Bypass -File $herdrInstaller -Channel {channel} -LocalPackagePath $herdrPackage -LocalPackageFormat zip -LocalPackageIdentity {identity} -LocalPackageSha256 {sha256}; if ($LASTEXITCODE -ne 0) {{ exit $LASTEXITCODE }}; $herdrHome = if ([string]::IsNullOrWhiteSpace($env:HERDR_HOME)) {{ Join-Path $env:USERPROFILE '.herdr' }} else {{ $env:HERDR_HOME }}; $activeJunction = Join-Path $herdrHome 'packages\standalone\current'; $activeTarget = [string](Get-Item -LiteralPath $activeJunction -Force -ErrorAction Stop).Target; if ([string]::IsNullOrWhiteSpace($activeTarget)) {{ throw 'Herdr installer did not activate a concrete release.' }}; $installedHerdr = Join-Path $activeTarget 'herdr.exe'; if (-not (Test-Path -LiteralPath $installedHerdr -PathType Leaf)) {{ throw 'Herdr installer result does not contain herdr.exe.' }}; $encodedResult = [System.Convert]::ToBase64String([System.Text.Encoding]::UTF8.GetBytes([System.IO.Path]::GetFullPath($installedHerdr))); [Console]::Out.WriteLine('{WINDOWS_REMOTE_INSTALL_RESULT_MARKER}' + $encodedResult); exit 0"#, + channel = crate::platform::quote_powershell_arg(current_channel()), + identity = crate::platform::quote_powershell_arg(identity), + sha256 = crate::platform::quote_powershell_arg(sha256), + )) +} + +fn windows_remote_install_cleanup_command(remote_dir: &str) -> String { + windows_powershell_script_command(&format!( + "Remove-Item -LiteralPath {} -Recurse -Force -ErrorAction Stop; exit 0", + crate::platform::quote_powershell_arg(remote_dir) + )) +} + +fn parse_windows_remote_path(stdout: &str, marker: &str) -> io::Result { + let encoded = stdout + .lines() + .rev() + .find_map(|line| line.trim().strip_prefix(marker)) + .ok_or_else(|| io::Error::other(format!("remote command did not return {marker}")))?; + decode_windows_remote_path(encoded) +} + +fn decode_windows_remote_path(encoded: &str) -> io::Result { + let bytes = base64::engine::general_purpose::STANDARD + .decode(encoded) + .map_err(|err| { + io::Error::other(format!("remote path result is not valid base64: {err}")) + })?; + let path = String::from_utf8(bytes) + .map_err(|err| io::Error::other(format!("remote path result is not valid UTF-8: {err}")))?; + if path.is_empty() { + return Err(io::Error::other("remote path result is empty")); + } + Ok(path) +} + impl Drop for RemoteSsh { fn drop(&mut self) { let Some(_options) = self @@ -810,6 +960,23 @@ fn apply_managed_ssh_options(command: &mut Command, options: Option<&ManagedSshO } } +fn apply_managed_scp_options(command: &mut Command, options: Option<&ManagedSshOptions>) { + let Some(options) = options else { + return; + }; + + command.arg("-F").arg(&options.config_path); + if let Some(control_path) = &options.control_path { + command + .arg("-o") + .arg(format!("ControlPath={}", control_path.to_string_lossy())) + .arg("-o") + .arg("ControlMaster=auto") + .arg("-o") + .arg("ControlPersist=yes"); + } +} + impl InstallSource { fn persistent(path: PathBuf) -> Self { Self { @@ -839,10 +1006,8 @@ pub(super) fn prepare_remote_herdr( ) -> io::Result { let platform = detect_remote_platform(ssh)?; let remote_herdr = RemoteHerdr::for_platform(platform); - if remote_herdr.platform.is_windows() { - return prepare_windows_remote_herdr(ssh, remote_herdr, require_surface_interest); - } let override_binary = remote_binary_override_path()?; + let custom_package = override_binary.is_some(); let remote_binary_candidates = remote_binary_candidates(ssh, &remote_herdr)?; if override_binary.is_none() { @@ -856,11 +1021,13 @@ pub(super) fn prepare_remote_herdr( }); } } - if remote_binary_supports_endpoint_requirement( - ssh, - &remote_herdr, - require_surface_interest, - )? { + if !remote_herdr.platform.is_windows() + && remote_binary_supports_endpoint_requirement( + ssh, + &remote_herdr, + require_surface_interest, + )? + { return Ok(PreparedRemoteHerdr { remote_herdr, stop_after_install_approved: false, @@ -870,8 +1037,9 @@ pub(super) fn prepare_remote_herdr( let mut stop_after_install_approved = false; if let Some(status_probe_herdr) = remote_binary_candidates.first().or_else(|| { - remote_binary_exists(ssh, &remote_herdr) - .ok() + (!remote_herdr.platform.is_windows()) + .then(|| remote_binary_exists(ssh, &remote_herdr).ok()) + .flatten() .and_then(|exists| exists.then_some(&remote_herdr)) }) { stop_after_install_approved = confirm_remote_install_with_running_server( @@ -889,17 +1057,32 @@ pub(super) fn prepare_remote_herdr( )?; } let source = resolve_install_source(&remote_herdr.platform, override_binary)?; - let install_result = ssh.install_herdr(&remote_herdr, &source.path); + let install_result = (|| { + if remote_herdr.platform.is_windows() { + let sha256 = crate::checksum::file_sha256(&source.path)?; + ssh.install_windows_herdr( + &remote_herdr, + &source.path, + &windows_package_identity(custom_package, &sha256), + &sha256, + ) + } else { + ssh.install_herdr(&remote_herdr, &source.path)?; + Ok(remote_herdr.clone()) + } + })(); source.cleanup(); - install_result?; + let remote_herdr = install_result?; if !remote_binary_supports_endpoint_requirement(ssh, &remote_herdr, require_surface_interest)? { return Err(io::Error::other(format!( - "installed remote herdr at {}, but it does not support saved SSH endpoint federation", + "installed remote herdr at {}, but it does not support the required remote hosting capabilities", remote_herdr.executable.display() ))); } - warn_if_remote_bin_not_on_path(ssh)?; + if !remote_herdr.platform.is_windows() { + warn_if_remote_bin_not_on_path(ssh)?; + } Ok(PreparedRemoteHerdr { remote_herdr, @@ -910,10 +1093,6 @@ pub(super) fn prepare_remote_herdr( pub(super) fn find_installed_remote_herdr(ssh: &RemoteSsh) -> io::Result { let platform = detect_remote_platform(ssh)?; let remote_herdr = RemoteHerdr::for_platform(platform); - if remote_herdr.platform.is_windows() { - return prepare_windows_remote_herdr(ssh, remote_herdr, true) - .map(|prepared| prepared.remote_herdr); - } let candidates = remote_binary_candidates(ssh, &remote_herdr)?; for candidate in candidates { if remote_binary_supports_endpoint_requirement(ssh, &candidate, true)? { @@ -930,44 +1109,13 @@ pub(super) fn find_installed_remote_herdr(ssh: &RemoteSsh) -> io::Result io::Result { - if !remote_binary_exists(ssh, &remote_herdr)? { - return Err(io::Error::new( - io::ErrorKind::NotFound, - format!( - "herdr.exe is not installed or is not on PATH on Windows host {}; install a compatible Windows package with remote host support and retry", - ssh.target() - ), - )); - } - if !remote_binary_supports_endpoint_requirement(ssh, &remote_herdr, require_surface_interest)? { - return Err(io::Error::other(format!( - "herdr.exe on Windows host {} does not support saved SSH endpoint federation; install a compatible Windows package with remote host support and retry", - ssh.target() - ))); - } - - Ok(PreparedRemoteHerdr { - remote_herdr, - stop_after_install_approved: false, - }) -} - pub(super) fn find_installed_remote_api_herdr( ssh: &RemoteSsh, session: &str, ) -> io::Result { let platform = detect_remote_platform(ssh)?; let remote_herdr = RemoteHerdr::for_platform(platform); - let candidates = if remote_herdr.platform.is_windows() { - vec![remote_herdr] - } else { - remote_binary_candidates(ssh, &remote_herdr)? - }; + let candidates = remote_binary_candidates(ssh, &remote_herdr)?; for candidate in candidates { let probe = ssh.framed_user_shell_output(&remote_api_bridge_command(&candidate, session, true))?; @@ -1066,6 +1214,17 @@ fn remote_binary_candidates( ssh: &RemoteSsh, remote_herdr: &RemoteHerdr, ) -> io::Result> { + if remote_herdr.platform.is_windows() { + let output = ssh.framed_user_shell_output(&windows_remote_binary_candidate_command())?; + if !output.status.success() { + return Err(command_failed("remote binary discovery failed", &output)); + } + return Ok(windows_remote_binary_candidates( + remote_herdr, + &String::from_utf8_lossy(&output.stdout), + )); + } + let mut candidates = Vec::new(); if let Some(path_candidate) = remote_binary_on_path_any(ssh, remote_herdr)? { @@ -1086,6 +1245,27 @@ fn remote_binary_candidates( Ok(candidates) } +fn windows_remote_binary_candidate_command() -> String { + windows_powershell_script_command(&format!( + r#"function Emit-HerdrPath([string]$CandidatePath) {{ if ([string]::IsNullOrWhiteSpace($CandidatePath) -or -not (Test-Path -LiteralPath $CandidatePath -PathType Leaf)) {{ return }}; $candidateFullPath = [System.IO.Path]::GetFullPath($CandidatePath); $encodedCandidate = [System.Convert]::ToBase64String([System.Text.Encoding]::UTF8.GetBytes($candidateFullPath)); [Console]::Out.WriteLine('{WINDOWS_REMOTE_PATH_MARKER}' + $encodedCandidate) }}; $pathCommand = Get-Command herdr.exe -CommandType Application -ErrorAction SilentlyContinue | Select-Object -First 1; if ($null -ne $pathCommand) {{ Emit-HerdrPath $pathCommand.Source }}; $herdrHome = if ([string]::IsNullOrWhiteSpace($env:HERDR_HOME)) {{ Join-Path $env:USERPROFILE '.herdr' }} else {{ $env:HERDR_HOME }}; $activeJunction = Get-Item -LiteralPath (Join-Path $herdrHome 'packages\standalone\current') -Force -ErrorAction SilentlyContinue; if ($null -ne $activeJunction -and -not [string]::IsNullOrWhiteSpace([string]$activeJunction.Target)) {{ Emit-HerdrPath (Join-Path ([string]$activeJunction.Target) 'herdr.exe') }}; exit 0"# + )) +} + +fn windows_remote_binary_candidates(remote_herdr: &RemoteHerdr, stdout: &str) -> Vec { + let mut candidates = Vec::new(); + for path in stdout.lines().filter_map(|line| { + line.trim() + .strip_prefix(WINDOWS_REMOTE_PATH_MARKER) + .and_then(|encoded| decode_windows_remote_path(encoded).ok()) + }) { + push_if_new_remote_binary_candidate( + &mut candidates, + remote_herdr.clone().with_windows_path(path), + ); + } + candidates +} + fn push_if_new_remote_binary_candidate(candidates: &mut Vec, candidate: RemoteHerdr) { if !candidates .iter() @@ -1221,8 +1401,11 @@ fn remote_binary_supports_endpoint_requirement( remote_herdr: &RemoteHerdr, require_surface_interest: bool, ) -> io::Result { - Ok(remote_client_status(ssh, remote_herdr)? - .is_some_and(|status| status.supports_endpoint_requirement(require_surface_interest))) + Ok( + remote_client_status(ssh, remote_herdr)?.is_some_and(|status| { + status.supports_endpoint_requirement(&remote_herdr.platform, require_surface_interest) + }), + ) } fn remote_binary_exists(ssh: &RemoteSsh, remote_herdr: &RemoteHerdr) -> io::Result { @@ -1304,7 +1487,7 @@ fn resolve_install_source( return Ok(InstallSource::persistent(path)); } - if *platform == RemotePlatform::local() { + if !platform.is_windows() && *platform == RemotePlatform::local() { let path = std::env::current_exe()?; if !crate::update::is_package_manager_managed_exe_path(&path) { return Ok(InstallSource::persistent(path)); @@ -1315,7 +1498,7 @@ fn resolve_install_source( } fn local_binary_can_seed_remote(platform: &RemotePlatform) -> bool { - if *platform != RemotePlatform::local() { + if platform.is_windows() || *platform != RemotePlatform::local() { return false; } @@ -1324,6 +1507,14 @@ fn local_binary_can_seed_remote(platform: &RemotePlatform) -> bool { .unwrap_or(false) } +fn windows_package_identity(custom: bool, sha256: &str) -> String { + if custom { + format!("{}-custom.{sha256}", current_version()) + } else { + current_version() + } +} + #[derive(Debug, Clone, PartialEq, Eq)] enum RemoteServerStatus { Running { @@ -1589,12 +1780,19 @@ struct RemoteClientStatusJson { endpoint_protocol_generation: Option, #[serde(default)] endpoint_capabilities: Vec, + #[serde(default)] + remote_host_bridge: bool, } impl RemoteClientStatusJson { - fn supports_endpoint_requirement(&self, require_surface_interest: bool) -> bool { + fn supports_endpoint_requirement( + &self, + platform: &RemotePlatform, + require_surface_interest: bool, + ) -> bool { self.endpoint_protocol_generation == Some(crate::protocol::endpoint::ENDPOINT_PROTOCOL_GENERATION) + && (!platform.is_windows() || self.remote_host_bridge) && (!require_surface_interest || [ crate::protocol::endpoint::SURFACE_INTEREST_CAPABILITY, @@ -2740,6 +2938,20 @@ fn sanitize_path_component(input: &str) -> String { mod tests { use super::*; + fn decode_windows_command(command: &str) -> String { + let encoded = command + .strip_prefix("powershell.exe -NoLogo -NoProfile -NonInteractive -EncodedCommand ") + .expect("encoded PowerShell command"); + let bytes = base64::engine::general_purpose::STANDARD + .decode(encoded) + .expect("base64"); + let utf16 = bytes + .chunks_exact(2) + .map(|pair| u16::from_le_bytes([pair[0], pair[1]])) + .collect::>(); + String::from_utf16(&utf16).expect("UTF-16LE") + } + #[cfg(unix)] thread_local! { pub(super) static UPLOAD_READ_ATTEMPTS: std::cell::RefCell>> = const { std::cell::RefCell::new(None) }; @@ -3149,6 +3361,25 @@ mod tests { "example".to_string(), ] ); + + let scp_args = ssh + .scp_command() + .get_args() + .map(|arg| arg.to_string_lossy().into_owned()) + .collect::>(); + assert_eq!( + scp_args, + vec![ + "-F".to_string(), + config_path.to_string_lossy().into_owned(), + "-o".to_string(), + format!("ControlPath={}", control_path.to_string_lossy()), + "-o".to_string(), + "ControlMaster=auto".to_string(), + "-o".to_string(), + "ControlPersist=yes".to_string(), + ] + ); } #[cfg(windows)] @@ -3181,6 +3412,15 @@ mod tests { "example".to_string(), ] ); + let scp_args = ssh + .scp_command() + .get_args() + .map(|arg| arg.to_string_lossy().into_owned()) + .collect::>(); + assert_eq!( + scp_args, + vec!["-F".to_string(), config_path.to_string_lossy().into_owned(),] + ); } #[cfg(windows)] @@ -3244,6 +3484,10 @@ mod tests { #[test] fn saved_machine_compatibility_uses_capabilities_not_release_or_private_protocol() { + let linux = RemotePlatform { + os: "linux", + arch: "x86_64", + }; let mut status = RemoteClientStatusJson { version: Some("0.1.0".into()), protocol: Some(1), @@ -3255,16 +3499,17 @@ mod tests { crate::protocol::endpoint::PRESENTATION_EFFECTS_FENCE_CAPABILITY.into(), crate::protocol::endpoint::HEALTH_CHECK_CAPABILITY.into(), ], + remote_host_bridge: false, }; - assert!(status.supports_endpoint_requirement(true)); + assert!(status.supports_endpoint_requirement(&linux, true)); for index in 0..status.endpoint_capabilities.len() { let removed = status.endpoint_capabilities.remove(index); - assert!(!status.supports_endpoint_requirement(true)); - assert!(status.supports_endpoint_requirement(false)); + assert!(!status.supports_endpoint_requirement(&linux, true)); + assert!(status.supports_endpoint_requirement(&linux, false)); status.endpoint_capabilities.insert(index, removed); } status.endpoint_protocol_generation = None; - assert!(!status.supports_endpoint_requirement(true)); + assert!(!status.supports_endpoint_requirement(&linux, true)); } #[test] @@ -3312,6 +3557,7 @@ mod tests { .collect::>(); assert_eq!(args, vec!["-T".to_string(), "example".to_string()]); + assert!(ssh.scp_command().get_args().next().is_none()); } #[test] @@ -3565,20 +3811,6 @@ mod tests { #[test] fn windows_remote_commands_use_one_encoded_powershell_grammar() { - fn decode(command: &str) -> String { - let encoded = command - .strip_prefix("powershell.exe -NoLogo -NoProfile -NonInteractive -EncodedCommand ") - .expect("encoded PowerShell command"); - let bytes = base64::engine::general_purpose::STANDARD - .decode(encoded) - .expect("base64"); - let utf16 = bytes - .chunks_exact(2) - .map(|pair| u16::from_le_bytes([pair[0], pair[1]])) - .collect::>(); - String::from_utf16(&utf16).expect("UTF-16LE") - } - let executable = RemoteExecutable::WindowsPath("herdr.exe".to_string()); let commands = [ ( @@ -3632,7 +3864,7 @@ mod tests { "[Console]::Out.WriteLine(); [Console]::Out.WriteLine('{REMOTE_OUTPUT_READY_MARKER}'); [Console]::Out.Flush(); " ); for (label, command, expected) in commands { - let script = decode(&command); + let script = decode_windows_command(&command); let script = script .strip_prefix(&preamble) .unwrap_or_else(|| panic!("{label} lacks shared output marker: {script}")); @@ -3640,6 +3872,78 @@ mod tests { } } + #[test] + fn windows_install_commands_copy_zip_and_return_concrete_path() { + let remote_dir = r"C:\Temp\Herdr O'Brien\测试"; + assert_eq!( + windows_scp_target( + "user@example", + &format!(r"{remote_dir}\herdr-windows-x86_64.zip") + ), + "user@example:C:/Temp/Herdr O'Brien/测试/herdr-windows-x86_64.zip" + ); + assert_eq!( + windows_scp_target("ssh://user@example:2222", r"C:\Temp\install.ps1"), + "scp://user@example:2222/C:/Temp/install.ps1" + ); + + let command = decode_windows_command(&windows_remote_install_command( + remote_dir, + "0.9.0-custom.digest", + &"a".repeat(64), + )); + assert!(command.contains("-LocalPackageFormat zip")); + assert!(command.contains("-LocalPackageIdentity 0.9.0-custom.digest")); + assert!(command.contains(".Target")); + assert!(command.contains(WINDOWS_REMOTE_INSTALL_RESULT_MARKER)); + + let installed = r"C:\Users\test\测试\.herdr\packages\standalone\releases\0.9.0\herdr.exe"; + let encoded = base64::engine::general_purpose::STANDARD.encode(installed); + assert_eq!( + parse_windows_remote_path( + &format!("installer chatter\n{WINDOWS_REMOTE_INSTALL_RESULT_MARKER}{encoded}\n"), + WINDOWS_REMOTE_INSTALL_RESULT_MARKER + ) + .unwrap(), + installed + ); + assert!(parse_windows_remote_path( + "installer chatter", + WINDOWS_REMOTE_INSTALL_RESULT_MARKER + ) + .is_err()); + } + + #[test] + fn windows_candidate_discovery_prefers_path_then_active_release() { + let remote_herdr = RemoteHerdr::for_platform(RemotePlatform { + os: "windows", + arch: "x86_64", + }); + let path = r"C:\stale\herdr.exe"; + let active = r"C:\Users\test\.herdr\packages\standalone\releases\current\herdr.exe"; + let stdout = format!( + "{WINDOWS_REMOTE_PATH_MARKER}{}\nnoise\n{WINDOWS_REMOTE_PATH_MARKER}{}\n{WINDOWS_REMOTE_PATH_MARKER}{}\n", + base64::engine::general_purpose::STANDARD.encode(path), + base64::engine::general_purpose::STANDARD.encode(active), + base64::engine::general_purpose::STANDARD.encode(active), + ); + let candidates = windows_remote_binary_candidates(&remote_herdr, &stdout); + assert_eq!(candidates.len(), 2); + assert_eq!( + candidates[0].executable, + RemoteExecutable::WindowsPath(path.to_string()) + ); + assert_eq!( + candidates[1].executable, + RemoteExecutable::WindowsPath(active.to_string()) + ); + + let command = decode_windows_command(&windows_remote_binary_candidate_command()); + assert!(command.contains("Get-Command herdr.exe")); + assert!(command.contains("$activeJunction.Target")); + } + #[test] fn remote_output_framing_discards_any_banner_and_preserves_binary() { let payload = [0, 1, 2, 0xff, b'\n']; @@ -3975,6 +4279,10 @@ mod tests { #[test] fn saved_machine_setup_handoffs_old_server_missing_presentation_fence() { + let linux = RemotePlatform { + os: "linux", + arch: "x86_64", + }; // Captured from Rohan after installing a new binary while the old daemon stayed alive. let installed = parse_client_status_json( r#"{"version":"0.8.2","protocol":22,"endpoint_protocol_generation":1,"endpoint_capabilities":["surface_interest","presentation_effects_fence","health_check"]}"#, @@ -3984,8 +4292,8 @@ mod tests { r#"{"version":"0.8.2","protocol":22,"endpoint_protocol_generation":1,"endpoint_capabilities":["surface_interest","health_check"]}"#, ) .unwrap(); - assert!(installed.supports_endpoint_requirement(true)); - assert!(!running_binary.supports_endpoint_requirement(true)); + assert!(installed.supports_endpoint_requirement(&linux, true)); + assert!(!running_binary.supports_endpoint_requirement(&linux, true)); for (live_capabilities, expected) in [ ( running_binary.endpoint_capabilities, @@ -4031,6 +4339,38 @@ mod tests { } } + #[test] + fn windows_reuse_requires_explicit_remote_host_bridge() { + let windows = RemotePlatform { + os: "windows", + arch: "x86_64", + }; + let linux = RemotePlatform { + os: "linux", + arch: "x86_64", + }; + let old = + parse_client_status_json(r#"{"version":"0.8.2","endpoint_protocol_generation":1}"#) + .unwrap(); + let current = parse_client_status_json( + r#"{"version":"0.9.0","endpoint_protocol_generation":1,"remote_host_bridge":true}"#, + ) + .unwrap(); + + assert!(!old.supports_endpoint_requirement(&windows, false)); + assert!(current.supports_endpoint_requirement(&windows, false)); + assert!(old.supports_endpoint_requirement(&linux, false)); + assert!(!local_binary_can_seed_remote(&windows)); + assert_eq!( + windows_package_identity(true, &"a".repeat(64)), + format!("{}-custom.{}", current_version(), "a".repeat(64)) + ); + assert_eq!( + windows_package_identity(false, "ignored"), + current_version() + ); + } + #[test] fn parse_remote_server_status_json_reads_running_server() { assert_eq!( diff --git a/src/update.rs b/src/update.rs index f836fb80b3..4c128c5782 100644 --- a/src/update.rs +++ b/src/update.rs @@ -698,8 +698,7 @@ fn install_downloaded_update(mut update: DownloadedUpdate) -> Result<(), String> Ok(()) } -#[cfg(windows)] -const WINDOWS_INSTALLER: &str = include_str!("../distribution/install.ps1"); +pub(crate) const WINDOWS_INSTALLER: &str = include_str!("../distribution/install.ps1"); #[cfg(windows)] struct DownloadedWindowsUpdate { diff --git a/tests/cli/sessions.rs b/tests/cli/sessions.rs index 5d87f73b9f..5a88769812 100644 --- a/tests/cli/sessions.rs +++ b/tests/cli/sessions.rs @@ -458,6 +458,7 @@ fn status_commands_report_client_and_server_versions() { assert_eq!(full_json["client"]["version"], env!("CARGO_PKG_VERSION")); assert_eq!(full_json["client"]["protocol"], 22); assert_eq!(full_json["client"]["endpoint_protocol_generation"], 1); + assert_eq!(full_json["client"]["remote_host_bridge"], true); assert_eq!(full_json["server"]["status"], "running"); assert_eq!(full_json["server"]["running"], true); assert_eq!(full_json["server"]["compatible"], true); @@ -482,6 +483,7 @@ fn status_commands_report_client_and_server_versions() { assert_eq!(client_json["version"], env!("CARGO_PKG_VERSION")); assert_eq!(client_json["protocol"], 22); assert_eq!(client_json["endpoint_protocol_generation"], 1); + assert_eq!(client_json["remote_host_bridge"], true); assert!(client_json["binary"] .as_str() .is_some_and(|path| !path.is_empty())); From 7654e08e94500a4ec049148192ddce0cdc0e1b4e Mon Sep 17 00:00:00 2001 From: Jonathan Liebig Date: Mon, 7 Sep 2026 15:14:48 +0200 Subject: [PATCH 2/4] docs: clarify windows saved reconnect package behavior refs #3651 --- docs/next/website/src/content/docs/windows-beta.mdx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/next/website/src/content/docs/windows-beta.mdx b/docs/next/website/src/content/docs/windows-beta.mdx index 2876ce908d..888080c810 100644 --- a/docs/next/website/src/content/docs/windows-beta.mdx +++ b/docs/next/website/src/content/docs/windows-beta.mdx @@ -32,7 +32,7 @@ For internal testing, `HERDR_MANIFEST_URL` can point the installer at a custom m | Local persistent sessions | supported | | Native panes through ConPTY | supported | | Windows Terminal / PowerShell app attach | supported | -| `herdr --remote` and saved SSH machines to Linux/macOS/Windows hosts | supported; Windows packages are installed or updated after confirmation when needed | +| `herdr --remote` and saved SSH machines to Linux/macOS/Windows hosts | supported; interactive attach and saved-machine setup can install or update Windows packages after confirmation; background reconnect only discovers installed packages | | Remote clipboard images and image-file drops | supported | | `cmd.exe` panes | supported | | Native keyboard and mouse input | supported | @@ -130,7 +130,7 @@ From Windows Terminal, use the same remote command as Linux and macOS: herdr --remote workbox ``` -The target host can run Linux, macOS, or Windows. On Windows hosts, remote attach reuses a compatible package from `PATH` or the active managed release. It prompts before installing or updating the complete package when needed. Herdr uses the installed Windows OpenSSH client and your SSH configuration. Windows OpenSSH does not use Herdr's Unix control-socket reuse, so key authentication through Windows `ssh-agent` is recommended to avoid repeated prompts during remote setup. +The target host can run Linux, macOS, or Windows. On Windows hosts, remote attach reuses a compatible package from `PATH` or the active managed release. Interactive direct attach and saved-machine setup prompt before installing or updating the complete package when needed. Background saved reconnect only discovers installed packages and cannot prompt for installation or updates. Herdr uses the installed Windows OpenSSH client and your SSH configuration. Windows OpenSSH does not use Herdr's Unix control-socket reuse, so key authentication through Windows `ssh-agent` is recommended to avoid repeated prompts during remote setup. Windows updates run through the Windows installer and update the active versioned release path. New terminals and reconnected SSH sessions receive that path; start Herdr there to use the updated client. Compatible running servers keep their panes alive. Restart a server later only when you need server-side changes from the release. Live handoff is Unix-only. From 74b7ba22fc9eb141782a793abde9e1b47f524dc8 Mon Sep 17 00:00:00 2001 From: Jonathan Liebig Date: Mon, 7 Sep 2026 16:02:35 +0200 Subject: [PATCH 3/4] fix: include windows installer in nix sources refs #3651 --- nix/package.nix | 1 + 1 file changed, 1 insertion(+) diff --git a/nix/package.nix b/nix/package.nix index 48de7085be..2a506c3a35 100644 --- a/nix/package.nix +++ b/nix/package.nix @@ -40,6 +40,7 @@ rustPlatform.buildRustPackage { fileset = lib.fileset.intersection (lib.fileset.fromSource (lib.sources.cleanSource ./..)) ( lib.fileset.unions [ ../assets + ../distribution/install.ps1 ../docs/next/api/herdr-api.schema.json ../src ../vendor/libghostty-vt From 7de2f580646f250d3f271a36bbd2008f8e73eb01 Mon Sep 17 00:00:00 2001 From: Jonathan Liebig Date: Mon, 7 Sep 2026 20:37:48 +0200 Subject: [PATCH 4/4] fix: preserve paths in windows scp transfers refs #3651 --- src/remote/attach.rs | 30 ++++++++++++++++++++++++++---- 1 file changed, 26 insertions(+), 4 deletions(-) diff --git a/src/remote/attach.rs b/src/remote/attach.rs index 1ac99010d5..fb0ba7f845 100644 --- a/src/remote/attach.rs +++ b/src/remote/attach.rs @@ -853,7 +853,17 @@ fn windows_remote_install_prepare_command() -> String { fn windows_scp_target(target: &str, remote_path: &str) -> String { let remote_path = remote_path.replace('\\', "/"); match target.strip_prefix("ssh://") { - Some(authority) => format!("scp://{authority}/{remote_path}"), + Some(authority) => { + let mut encoded = String::new(); + for byte in remote_path.bytes() { + if byte.is_ascii_alphanumeric() || b"-_.~/:".contains(&byte) { + encoded.push(char::from(byte)); + } else { + encoded.push_str(&format!("%{byte:02X}")); + } + } + format!("scp://{authority}/{encoded}") + } None => format!("{target}:{remote_path}"), } } @@ -969,7 +979,10 @@ fn apply_managed_scp_options(command: &mut Command, options: Option<&ManagedSshO if let Some(control_path) = &options.control_path { command .arg("-o") - .arg(format!("ControlPath={}", control_path.to_string_lossy())) + .arg(format!( + "ControlPath={}", + ssh_config_quote(&control_path.to_string_lossy()) + )) .arg("-o") .arg("ControlMaster=auto") .arg("-o") @@ -3326,7 +3339,8 @@ mod tests { #[cfg(unix)] #[test] fn remote_ssh_command_uses_managed_config_when_present() { - let managed_config = write_managed_ssh_config().expect("write managed config"); + let mut managed_config = write_managed_ssh_config().expect("write managed config"); + managed_config.options.control_path = Some(PathBuf::from("/tmp/herdr test/control")); let config_path = managed_config.options.config_path.clone(); let control_path = managed_config .options @@ -3373,7 +3387,7 @@ mod tests { "-F".to_string(), config_path.to_string_lossy().into_owned(), "-o".to_string(), - format!("ControlPath={}", control_path.to_string_lossy()), + format!("ControlPath=\"{}\"", control_path.to_string_lossy()), "-o".to_string(), "ControlMaster=auto".to_string(), "-o".to_string(), @@ -3874,6 +3888,14 @@ mod tests { #[test] fn windows_install_commands_copy_zip_and_return_concrete_path() { + assert_eq!( + windows_scp_target("ssh://user@example:2222", r"C:\Temp\A+B%20 C\ü\install.ps1"), + "scp://user@example:2222/C:/Temp/A%2BB%2520%20C/%C3%BC/install.ps1" + ); + assert_eq!( + windows_scp_target("example", r"C:\Temp\A+B%20 C\install.ps1"), + "example:C:/Temp/A+B%20 C/install.ps1" + ); let remote_dir = r"C:\Temp\Herdr O'Brien\测试"; assert_eq!( windows_scp_target(