aboutsummaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
authorJJ Liebig <jonathan.liebig@gmail.com>2026-09-13 23:19:20 +0400
committerGitHub <noreply@github.com>2026-09-13 22:19:20 +0300
commit59167658455442ffd2cd5b72a0fa81bcc4bcdf9e (patch)
treedaaceb865a7540e36936b2f5a5317fee231634a3 /src
parent8be8bff8a56dcea694f249d24db82b9dd3f9bc4c (diff)
feat: install windows remote host packages (#3687)
* feat: install windows remote host packages refs #3651 * docs: clarify windows saved reconnect package behavior refs #3651 * fix: include windows installer in nix sources refs #3651 * fix: preserve paths in windows scp transfers refs #3651 * refactor: isolate windows remote package setup refs #3651
Diffstat (limited to 'src')
-rw-r--r--src/checksum.rs2
-rw-r--r--src/cli/status.rs2
-rw-r--r--src/remote/attach.rs516
-rw-r--r--src/update.rs3
4 files changed, 472 insertions, 51 deletions
diff --git a/src/checksum.rs b/src/checksum.rs
index 4f61a33c..ed84169b 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<String> {
+pub(crate) fn file_sha256(path: &Path) -> io::Result<String> {
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 fa008bdc..99f278cf 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<String>,
}
@@ -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 a597f564..ace72f84 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 {
@@ -586,6 +594,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<Output> {
let script = posix_remote_output_command(script);
let mut child = self
@@ -688,6 +702,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<RemoteHerdr> {
+ 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
+ }
}
// Only interactive setup uses this relay. Background probes retain their
@@ -804,6 +898,72 @@ 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) => {
+ 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}"),
+ }
+}
+
+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<String> {
+ 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<String> {
+ 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
@@ -864,6 +1024,26 @@ 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={}",
+ ssh_config_quote(&control_path.to_string_lossy())
+ ))
+ .arg("-o")
+ .arg("ControlMaster=auto")
+ .arg("-o")
+ .arg("ControlPersist=yes");
+ }
+}
+
impl InstallSource {
fn persistent(path: PathBuf) -> Self {
Self {
@@ -894,7 +1074,12 @@ pub(super) fn prepare_remote_herdr(
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);
+ return prepare_windows_remote_herdr(
+ ssh,
+ remote_herdr,
+ live_handoff_enabled,
+ require_surface_interest,
+ );
}
let override_binary = remote_binary_override_path()?;
let remote_binary_candidates = remote_binary_candidates(ssh, &remote_herdr)?;
@@ -964,10 +1149,6 @@ pub(super) fn prepare_remote_herdr(
pub(super) fn find_installed_remote_herdr(ssh: &RemoteSsh) -> io::Result<RemoteHerdr> {
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)? {
@@ -987,27 +1168,69 @@ pub(super) fn find_installed_remote_herdr(ssh: &RemoteSsh) -> io::Result<RemoteH
fn prepare_windows_remote_herdr(
ssh: &RemoteSsh,
remote_herdr: RemoteHerdr,
+ live_handoff_enabled: bool,
require_surface_interest: bool,
) -> io::Result<PreparedRemoteHerdr> {
- 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()
- ),
- ));
+ let override_package = remote_binary_override_path()?;
+ let custom_package = override_package.is_some();
+ let candidates = remote_binary_candidates(ssh, &remote_herdr)?;
+ if !custom_package {
+ for candidate in &candidates {
+ if remote_binary_supports_endpoint_requirement(
+ ssh,
+ candidate,
+ require_surface_interest,
+ )? {
+ return Ok(PreparedRemoteHerdr {
+ remote_herdr: candidate.clone(),
+ stop_after_install_approved: false,
+ });
+ }
+ }
+ }
+
+ let stop_after_install_approved = if let Some(candidate) = candidates.first() {
+ confirm_remote_install_with_running_server(
+ ssh,
+ candidate,
+ live_handoff_enabled,
+ require_surface_interest,
+ )?
+ } else {
+ false
+ };
+ if !stop_after_install_approved {
+ confirm_remote_install(
+ &ssh.destination(),
+ &remote_herdr,
+ &install_source_description(&remote_herdr.platform, override_package.as_deref()),
+ )?;
}
+ // Windows needs the complete package, including its app-local ConPTY runtime.
+ let source = match override_package {
+ Some(path) => InstallSource::persistent(path),
+ None => download_release_asset(&remote_herdr.platform)?,
+ };
+ let install_result = (|| {
+ let sha256 = crate::checksum::file_sha256(&source.path)?;
+ ssh.install_windows_herdr(
+ &remote_herdr,
+ &source.path,
+ &windows_package_identity(custom_package, &sha256),
+ &sha256,
+ )
+ })();
+ source.cleanup();
+ let remote_herdr = install_result?;
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()
+ "installed remote herdr at {}, but it does not support the required remote hosting capabilities",
+ remote_herdr.executable.display()
)));
}
-
Ok(PreparedRemoteHerdr {
remote_herdr,
- stop_after_install_approved: false,
+ stop_after_install_approved,
})
}
@@ -1017,11 +1240,7 @@ pub(super) fn find_installed_remote_api_herdr(
) -> io::Result<RemoteHerdr> {
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))?;
@@ -1120,6 +1339,17 @@ fn remote_binary_candidates(
ssh: &RemoteSsh,
remote_herdr: &RemoteHerdr,
) -> io::Result<Vec<RemoteHerdr>> {
+ 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)? {
@@ -1140,6 +1370,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<RemoteHerdr> {
+ 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<RemoteHerdr>, candidate: RemoteHerdr) {
if !candidates
.iter()
@@ -1275,8 +1526,11 @@ fn remote_binary_supports_endpoint_requirement(
remote_herdr: &RemoteHerdr,
require_surface_interest: bool,
) -> io::Result<bool> {
- 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<bool> {
@@ -1369,7 +1623,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;
}
@@ -1378,6 +1632,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 {
@@ -1643,12 +1905,19 @@ struct RemoteClientStatusJson {
endpoint_protocol_generation: Option<u32>,
#[serde(default)]
endpoint_capabilities: Vec<String>,
+ #[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,
@@ -2812,6 +3081,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::<Vec<_>>();
+ String::from_utf16(&utf16).expect("UTF-16LE")
+ }
+
#[cfg(unix)]
thread_local! {
pub(super) static UPLOAD_READ_ATTEMPTS: std::cell::RefCell<Option<Arc<std::sync::atomic::AtomicUsize>>> = const { std::cell::RefCell::new(None) };
@@ -3186,7 +3469,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
@@ -3221,6 +3505,25 @@ mod tests {
"example".to_string(),
]
);
+
+ let scp_args = ssh
+ .scp_command()
+ .get_args()
+ .map(|arg| arg.to_string_lossy().into_owned())
+ .collect::<Vec<_>>();
+ 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)]
@@ -3263,6 +3566,15 @@ mod tests {
"example".to_string(),
]
);
+ let scp_args = ssh
+ .scp_command()
+ .get_args()
+ .map(|arg| arg.to_string_lossy().into_owned())
+ .collect::<Vec<_>>();
+ assert_eq!(
+ scp_args,
+ vec!["-F".to_string(), config_path.to_string_lossy().into_owned(),]
+ );
}
#[cfg(windows)]
@@ -3341,6 +3653,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),
@@ -3352,16 +3668,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]
@@ -3409,6 +3726,7 @@ mod tests {
.collect::<Vec<_>>();
assert_eq!(args, vec!["-T".to_string(), "example".to_string()]);
+ assert!(ssh.scp_command().get_args().next().is_none());
}
#[test]
@@ -3730,20 +4048,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::<Vec<_>>();
- String::from_utf16(&utf16).expect("UTF-16LE")
- }
-
let executable = RemoteExecutable::WindowsPath("herdr.exe".to_string());
let commands = [
(
@@ -3797,7 +4101,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}"));
@@ -3806,6 +4110,86 @@ 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(
+ "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'];
let mut input = vec![b'x'; 4 * 1024 * 1024];
@@ -4140,6 +4524,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"]}"#,
@@ -4149,8 +4537,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,
@@ -4197,6 +4585,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!(
parse_remote_server_status_json(
diff --git a/src/update.rs b/src/update.rs
index f836fb80..4c128c57 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 {