Files
herdr/src/remote/attach.rs
T

4420 lines
149 KiB
Rust

//! Remote thin-client launcher over SSH command stdio.
use super::{args::*, process::wait_with_output_timeout, restart_policy::*, shell_quote};
use base64::Engine as _;
use std::collections::BTreeMap;
use std::fs::{self, File};
use std::io::{self, IsTerminal, Write as _};
use std::path::{Path, PathBuf};
use std::process::{Command, Output, Stdio};
use interprocess::local_socket::traits::Listener as _;
#[cfg(all(test, unix))]
use interprocess::local_socket::traits::Stream as _;
use interprocess::local_socket::ListenerNonblockingMode;
use interprocess::TryClone as _;
use serde::Deserialize;
use std::sync::{
atomic::{AtomicBool, Ordering},
mpsc, Arc,
};
use std::thread::{self, JoinHandle};
use std::time::{Duration, Instant};
const BRIDGE_ACCEPT_POLL: Duration = Duration::from_millis(50);
const BRIDGE_IO_POLL: Duration = Duration::from_millis(1);
const BRIDGE_SOCKET_PERMISSION_MODE: u32 = 0o600;
const REMOTE_SERVER_SHUTDOWN_CONFIRM_TIMEOUT: Duration = Duration::from_secs(5);
const NONINTERACTIVE_SSH_COMMAND_TIMEOUT: Duration = Duration::from_secs(15);
const NONINTERACTIVE_SSH_STDERR_LIMIT: usize = 16 * 1024;
const BRIDGE_FAILURE_REPORT_TIMEOUT: Duration = Duration::from_secs(1);
const REMOTE_SERVER_SHUTDOWN_POLL_INTERVAL: Duration = Duration::from_millis(100);
const CURRENT_PROTOCOL: u32 = crate::protocol::PROTOCOL_VERSION;
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 SSH_CONTROL_SOCKET_NAME: &str = "ctl";
pub(crate) fn run_remote(remote: RemoteLaunch) -> io::Result<()> {
let session_name = crate::session::active_name()
.unwrap_or_else(|| crate::session::DEFAULT_SESSION_NAME.to_string());
let local_socket = local_forward_socket_path(&remote.target, &session_name);
let program = std::env::args()
.next()
.unwrap_or_else(|| "herdr".to_string());
let reattach_command = reattach_command(
&program,
&remote.target,
&session_name,
remote.keybindings,
remote.live_handoff,
);
let manage_ssh_config = crate::config::Config::load()
.config
.remote
.manage_ssh_config;
let require_surface_interest = crate::client::endpoint::EndpointCatalog::load()
.map(|catalog| catalog.contains_enabled_target_session(&remote.target, &session_name))
.unwrap_or(false);
let remote_ssh = RemoteSsh::new(
remote.target.clone(),
manage_ssh_config,
session_name.clone(),
);
let prepared_remote =
prepare_remote_herdr(&remote_ssh, remote.live_handoff, require_surface_interest)?;
ensure_remote_server_ready(
&remote_ssh,
&prepared_remote.remote_herdr,
prepared_remote.stop_after_install_approved,
remote.live_handoff,
require_surface_interest,
)?;
let _bridge = SshStdioBridge::start(
remote.target,
prepared_remote.remote_herdr,
local_socket.clone(),
session_name,
remote_ssh.options(),
false,
)?;
run_client_process(&local_socket, &reattach_command, remote.keybindings)
}
pub(crate) fn prepare_saved_ssh(target: &str, session_name: &str) -> io::Result<()> {
super::validate_remote_target(target)
.map_err(|error| io::Error::new(io::ErrorKind::InvalidInput, error))?;
crate::session::validate_name(session_name)
.map_err(|error| io::Error::new(io::ErrorKind::InvalidInput, error))?;
let manage_ssh_config = crate::config::Config::load()
.config
.remote
.manage_ssh_config;
let ssh = RemoteSsh::new(
target.to_owned(),
manage_ssh_config,
session_name.to_owned(),
);
let prepared = prepare_remote_herdr(&ssh, false, true)?;
ensure_remote_server_ready(
&ssh,
&prepared.remote_herdr,
prepared.stop_after_install_approved,
false,
true,
)?;
// The bridge already owns daemon startup. EOF closes only this temporary attachment,
// leaving the named server running even when no local TUI is open yet.
let command = prepared
.remote_herdr
.executable
.saved_bridge_command(session_name);
let output = ssh.shell_output(&prepared.remote_herdr.platform, &command)?;
if !output.status.success() {
return Err(command_failed("remote server startup failed", &output));
}
match remote_server_status(&ssh, &prepared.remote_herdr, true)? {
RemoteServerStatus::Running {
endpoint_protocol_generation,
surface_interest,
health_check,
detached_server_daemon,
..
} if remote_server_restart_reason(
endpoint_protocol_generation,
detached_server_daemon,
true,
surface_interest,
health_check,
)
.is_none() =>
{
Ok(())
}
_ => Err(io::Error::other(
"remote server is not ready for saved machines",
)),
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct RemotePlatform {
os: &'static str,
arch: &'static str,
}
impl RemotePlatform {
fn from_uname(os: &str, arch: &str) -> Option<Self> {
let os = match os.trim() {
"Linux" => "linux",
"Darwin" => "macos",
_ => return None,
};
let arch = match arch.trim() {
"x86_64" | "amd64" => "x86_64",
"aarch64" | "arm64" => "aarch64",
_ => return None,
};
Some(Self { os, arch })
}
fn local() -> Self {
let os = if cfg!(target_os = "linux") {
"linux"
} else if cfg!(target_os = "macos") {
"macos"
} else if cfg!(target_os = "windows") {
"windows"
} else {
"unknown"
};
let arch = if cfg!(target_arch = "x86_64") {
"x86_64"
} else if cfg!(target_arch = "aarch64") {
"aarch64"
} else {
"unknown"
};
Self { os, arch }
}
fn asset_key(&self) -> String {
format!("{}-{}", self.os, self.arch)
}
fn is_windows(&self) -> bool {
self.os == "windows"
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
enum RemoteExecutable {
PosixShellPath(String),
WindowsPath(String),
}
impl RemoteExecutable {
fn display(&self) -> &str {
match self {
Self::PosixShellPath(path) | Self::WindowsPath(path) => path,
}
}
fn command(&self, args: &[&str]) -> String {
match self {
Self::PosixShellPath(path) => {
let mut command = path.clone();
for arg in args {
command.push(' ');
command.push_str(&shell_quote(arg));
}
command
}
Self::WindowsPath(path) => windows_powershell_script_command(
&windows_powershell_application_script(path, args),
),
}
}
fn session_command(&self, session_name: &str, args: &[&str]) -> String {
self.command(&Self::session_args(session_name, args))
}
fn session_args<'a>(session_name: &'a str, args: &[&'a str]) -> Vec<&'a str> {
let mut session_args = Vec::with_capacity(args.len() + 2);
if session_name != crate::session::DEFAULT_SESSION_NAME {
session_args.extend(["--session", session_name]);
}
session_args.extend_from_slice(args);
session_args
}
fn exists_command(&self) -> String {
match self {
Self::PosixShellPath(path) => format!("test -x {path}"),
Self::WindowsPath(path) => windows_powershell_script_command(&format!(
"if ($null -ne (Get-Command {} -CommandType Application -ErrorAction SilentlyContinue)) {{ exit 0 }}; exit 1",
crate::platform::quote_powershell_arg(path)
)),
}
}
fn status_client_command(&self) -> String {
match self {
Self::PosixShellPath(path) => format!(
"test -x {path} && {}",
self.command(&["status", "client", "--json"])
),
Self::WindowsPath(_) => self.command(&["status", "client", "--json"]),
}
}
fn bridge_command(&self, session_name: &str) -> String {
let args = Self::session_args(session_name, &["remote-client-bridge"]);
match self {
Self::PosixShellPath(_) => {
posix_remote_output_command(&format!("exec {}", self.command(&args)))
}
Self::WindowsPath(path) => {
windows_powershell_streaming_application_command(path, &args)
}
}
}
fn saved_bridge_command(&self, session_name: &str) -> String {
let args = Self::session_args(session_name, &["remote-client-bridge"]);
match self {
Self::PosixShellPath(_) => format!("exec {} </dev/null", self.command(&args)),
Self::WindowsPath(path) => {
windows_powershell_streaming_application_command(path, &args)
}
}
}
fn live_handoff_command(&self, session_name: &str, protocol: u32, version: &str) -> String {
let protocol = protocol.to_string();
match self {
Self::PosixShellPath(path) => format!(
"{} --import-exe {path} --expected-protocol {} --expected-version {}",
self.session_command(session_name, &["server", "live-handoff"]),
shell_quote(&protocol),
shell_quote(version),
),
Self::WindowsPath(path) => self.session_command(
session_name,
&[
"server",
"live-handoff",
"--import-exe",
path,
"--expected-protocol",
&protocol,
"--expected-version",
version,
],
),
}
}
}
#[derive(Debug, Clone)]
pub(super) struct RemoteHerdr {
install_suffix: String,
executable: RemoteExecutable,
platform: RemotePlatform,
}
impl RemoteHerdr {
fn for_platform(platform: RemotePlatform) -> Self {
let (install_suffix, executable) = if platform.is_windows() {
(
String::new(),
RemoteExecutable::WindowsPath("herdr.exe".to_string()),
)
} else {
let install_suffix = ".local/bin/herdr".to_string();
let shell_path = format!("\"$HOME/{install_suffix}\"");
(install_suffix, RemoteExecutable::PosixShellPath(shell_path))
};
Self {
install_suffix,
executable,
platform,
}
}
fn with_shell_path(mut self, shell_path: String) -> Self {
self.executable = RemoteExecutable::PosixShellPath(shell_path);
self
}
}
fn windows_powershell_application_script(path: &str, args: &[&str]) -> String {
let mut script = format!("& {}", crate::platform::quote_powershell_arg(path));
for arg in args {
script.push(' ');
script.push_str(&crate::platform::quote_powershell_arg(arg));
}
script.push_str("; exit $LASTEXITCODE");
script
}
fn windows_powershell_streaming_application_command(path: &str, args: &[&str]) -> String {
let command_line = args
.iter()
.map(|arg| crate::platform::quote_windows_command_line_arg(arg))
.collect::<Vec<_>>()
.join(" ");
windows_powershell_script_command(&format!(
"$process = Start-Process -FilePath {} -ArgumentList {} -NoNewWindow -Wait -PassThru -ErrorAction Stop; exit $process.ExitCode",
crate::platform::quote_powershell_arg(path),
crate::platform::quote_powershell_arg(&command_line),
))
}
fn posix_remote_output_command(command: &str) -> String {
format!("printf '\n%s\n' '{REMOTE_OUTPUT_READY_MARKER}'\n{command}")
}
fn windows_powershell_script_command(script: &str) -> String {
let script = format!(
"[Console]::Out.WriteLine(); [Console]::Out.WriteLine('{REMOTE_OUTPUT_READY_MARKER}'); [Console]::Out.Flush(); {script}"
);
let utf16 = script
.encode_utf16()
.flat_map(u16::to_le_bytes)
.collect::<Vec<_>>();
let encoded = base64::engine::general_purpose::STANDARD.encode(utf16);
format!("powershell.exe -NoLogo -NoProfile -NonInteractive -EncodedCommand {encoded}")
}
#[derive(Debug, Clone, Deserialize)]
#[serde(untagged)]
enum RemoteAssetRef {
Url(String),
Object { url: String, sha256: Option<String> },
}
impl RemoteAssetRef {
fn url(&self) -> &str {
match self {
Self::Url(url) => url,
Self::Object { url, .. } => url,
}
}
fn sha256(&self) -> Option<&str> {
match self {
Self::Url(_) => None,
Self::Object { sha256, .. } => {
sha256.as_deref().filter(|value| !value.trim().is_empty())
}
}
}
}
#[derive(Deserialize)]
struct RemoteUpdateManifest {
version: String,
protocol: Option<u32>,
assets: BTreeMap<String, RemoteAssetRef>,
#[serde(default)]
sha256: BTreeMap<String, String>,
#[serde(default, deserialize_with = "deserialize_remote_manifest_releases")]
releases: BTreeMap<String, RemoteReleaseMetadata>,
}
#[derive(Deserialize)]
struct RemoteReleaseMetadata {
protocol: Option<u32>,
#[serde(default)]
assets: BTreeMap<String, RemoteAssetRef>,
#[serde(default)]
sha256: BTreeMap<String, String>,
}
#[derive(Deserialize)]
struct RemotePreviewManifest {
build_id: String,
protocol: u32,
assets: BTreeMap<String, RemoteAssetRef>,
#[serde(default)]
builds: BTreeMap<String, RemotePreviewBuildMetadata>,
}
#[derive(Deserialize)]
struct RemotePreviewBuildMetadata {
protocol: u32,
assets: BTreeMap<String, RemoteAssetRef>,
}
fn deserialize_remote_manifest_releases<'de, D>(
deserializer: D,
) -> Result<BTreeMap<String, RemoteReleaseMetadata>, D::Error>
where
D: serde::Deserializer<'de>,
{
let value = Option::<serde_json::Value>::deserialize(deserializer)?;
Ok(match value {
Some(serde_json::Value::Object(object)) => object
.into_iter()
.filter_map(|(version, release)| {
serde_json::from_value::<RemoteReleaseMetadata>(release)
.ok()
.map(|metadata| (version, metadata))
})
.collect(),
_ => BTreeMap::new(),
})
}
impl RemoteUpdateManifest {
fn release_for_version(&self, version: &str) -> Option<RemoteManifestReleaseRef<'_>> {
if self.version.trim_start_matches('v') == version {
return Some(RemoteManifestReleaseRef {
protocol: self.protocol,
assets: &self.assets,
sha256: &self.sha256,
});
}
self.releases.get(version).and_then(|release| {
(!release.assets.is_empty()).then_some(RemoteManifestReleaseRef {
protocol: release.protocol,
assets: &release.assets,
sha256: &release.sha256,
})
})
}
}
#[derive(Clone, Copy)]
struct RemoteManifestReleaseRef<'a> {
protocol: Option<u32>,
assets: &'a BTreeMap<String, RemoteAssetRef>,
sha256: &'a BTreeMap<String, String>,
}
fn current_version() -> String {
crate::build_info::version()
}
fn current_channel() -> &'static str {
crate::build_info::channel()
}
struct InstallSource {
path: PathBuf,
temporary_dir: Option<PathBuf>,
}
struct RemoteReleaseAsset {
url: String,
sha256: Option<String>,
}
pub(super) struct PreparedRemoteHerdr {
pub(super) remote_herdr: RemoteHerdr,
stop_after_install_approved: bool,
}
#[derive(Clone)]
pub(super) struct ManagedSshOptions {
config_path: PathBuf,
control_path: Option<PathBuf>,
}
struct ManagedSshConfig {
options: ManagedSshOptions,
}
impl Drop for ManagedSshConfig {
fn drop(&mut self) {
if let Some(dir) = self.options.config_path.parent() {
let _ = fs::remove_dir_all(dir);
}
}
}
pub(super) struct RemoteSsh {
target: String,
session_name: String,
managed_config: Option<ManagedSshConfig>,
noninteractive: bool,
}
impl RemoteSsh {
fn new(target: String, manage_ssh_config: bool, session_name: String) -> Self {
let managed_config = if manage_ssh_config {
write_managed_ssh_config()
.inspect_err(|err| {
tracing::debug!(%err, "could not write managed ssh config; using plain ssh");
})
.ok()
} else {
None
};
Self {
target,
session_name,
managed_config,
noninteractive: false,
}
}
pub(super) fn new_noninteractive(target: String) -> Self {
Self {
target,
session_name: crate::session::DEFAULT_SESSION_NAME.into(),
managed_config: None,
noninteractive: true,
}
}
fn target(&self) -> &str {
&self.target
}
fn destination(&self) -> String {
format!("{} (session {})", self.target, self.session_name)
}
pub(super) fn options(&self) -> Option<&ManagedSshOptions> {
self.managed_config.as_ref().map(|config| &config.options)
}
fn command(&self) -> Command {
let mut command = self.base_command();
if self.noninteractive {
apply_noninteractive_ssh_options(&mut command);
}
command.arg("-T").arg(&self.target);
command
}
fn base_command(&self) -> Command {
let mut command = Command::new("ssh");
apply_managed_ssh_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
.command()
.arg("/bin/sh -s")
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()?;
let write_result = if let Some(mut stdin) = child.stdin.take() {
stdin.write_all(script.as_bytes())
} else {
Err(io::Error::new(
io::ErrorKind::BrokenPipe,
"ssh bootstrap stdin missing",
))
};
let output = if self.noninteractive {
wait_with_output_timeout(child, NONINTERACTIVE_SSH_COMMAND_TIMEOUT)?
} else {
child.wait_with_output()?
};
write_result?;
normalize_remote_output(output)
}
fn framed_user_shell_output(&self, remote_command: &str) -> io::Result<Output> {
let mut command = self.command();
command
// Windows OpenSSH can still read the console with stdin redirected to NUL.
.arg("-n")
.arg(remote_command)
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped());
let output = if self.noninteractive {
wait_with_output_timeout(command.spawn()?, NONINTERACTIVE_SSH_COMMAND_TIMEOUT)
} else {
command.output()
}?;
normalize_remote_output(output)
}
fn posix_user_shell_output(&self, remote_command: &str) -> io::Result<Output> {
self.framed_user_shell_output(&posix_remote_output_command(remote_command))
}
fn shell_output(&self, platform: &RemotePlatform, remote_command: &str) -> io::Result<Output> {
if platform.is_windows() {
self.framed_user_shell_output(remote_command)
} else {
self.sh_output(remote_command)
}
}
fn install_herdr(&self, remote_herdr: &RemoteHerdr, source_path: &Path) -> io::Result<()> {
let output = self.sh_output(&remote_install_prepare_script(remote_herdr))?;
if !output.status.success() {
return Err(command_failed("remote install preparation failed", &output));
}
let (tmp_path, dest_path) = parse_remote_install_paths(&output.stdout)?;
let mut child = self
.command()
.arg(remote_install_stream_command(&tmp_path))
.stdin(Stdio::piped())
.stdout(Stdio::null())
.stderr(Stdio::inherit())
.spawn()
.map_err(|err| {
io::Error::new(err.kind(), format!("failed to start ssh install: {err}"))
})?;
let mut source = File::open(source_path)?;
let copy_result = if let Some(mut stdin) = child.stdin.take() {
io::copy(&mut source, &mut stdin).map(|_| ())
} else {
Err(io::Error::new(
io::ErrorKind::BrokenPipe,
"ssh install stdin missing",
))
};
let status = child.wait()?;
copy_result?;
if status.success() {
let output = self.sh_output(&remote_install_commit_script(&tmp_path, &dest_path))?;
if output.status.success() {
Ok(())
} else {
Err(command_failed("remote install commit failed", &output))
}
} else {
Err(io::Error::other(format!(
"remote install exited with {status}"
)))
}
}
}
fn normalize_remote_output(mut output: Output) -> io::Result<Output> {
normalize_remote_stdout(&mut output.stdout, output.status.success())?;
Ok(output)
}
fn normalize_remote_stdout(stdout: &mut Vec<u8>, command_succeeded: bool) -> io::Result<()> {
let consumed = {
let mut reader = io::Cursor::new(stdout.as_slice());
match discard_remote_output_preamble(&mut reader) {
Ok(()) => reader.position() as usize,
Err(_) if !command_succeeded => return Ok(()),
Err(err) => return Err(err),
}
};
stdout.drain(..consumed);
Ok(())
}
fn remote_install_prepare_script(remote_herdr: &RemoteHerdr) -> String {
format!(
r#"set -eu
dest="$HOME/{install_suffix}"
dir="${{dest%/*}}"
mkdir -p "$dir"
tmp="${{dest}}.tmp.$$"
printf '%s\0%s\0' "$tmp" "$dest"
"#,
install_suffix = remote_herdr.install_suffix
)
}
fn parse_remote_install_paths(stdout: &[u8]) -> io::Result<(String, String)> {
let mut parts = stdout.split(|byte| *byte == 0);
let tmp_path = parts.next().unwrap_or_default();
let dest_path = parts.next().unwrap_or_default();
if tmp_path.is_empty() || dest_path.is_empty() {
return Err(io::Error::other(
"remote install preparation did not return destination paths",
));
}
let tmp_path = String::from_utf8(tmp_path.to_vec()).map_err(|err| {
io::Error::other(format!(
"remote install temporary path is not valid UTF-8: {err}"
))
})?;
let dest_path = String::from_utf8(dest_path.to_vec()).map_err(|err| {
io::Error::other(format!(
"remote install destination path is not valid UTF-8: {err}"
))
})?;
Ok((tmp_path, dest_path))
}
fn remote_install_stream_command(tmp_path: &str) -> String {
format!("tee {}", shell_quote(tmp_path))
}
fn remote_install_commit_script(tmp_path: &str, dest_path: &str) -> String {
format!(
"set -eu\nchmod 755 {tmp_path}\nmv {tmp_path} {dest_path}\n",
tmp_path = shell_quote(tmp_path),
dest_path = shell_quote(dest_path)
)
}
impl Drop for RemoteSsh {
fn drop(&mut self) {
let Some(_options) = self
.managed_config
.as_ref()
.map(|config| &config.options)
.filter(|options| options.control_path.is_some())
else {
return;
};
let _ = self
.base_command()
.arg("-O")
.arg("exit")
.arg("-o")
.arg("BatchMode=yes")
.arg(&self.target)
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null())
.status();
}
}
fn apply_noninteractive_ssh_options(command: &mut Command) {
command
.arg("-o")
.arg("BatchMode=yes")
.arg("-o")
.arg("NumberOfPasswordPrompts=0")
.arg("-o")
.arg("StrictHostKeyChecking=yes")
.arg("-o")
.arg("ConnectTimeout=10")
.arg("-o")
.arg("ConnectionAttempts=1")
.arg("-o")
.arg("ServerAliveInterval=15")
.arg("-o")
.arg("ServerAliveCountMax=4");
}
fn apply_managed_ssh_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("-S")
.arg(control_path)
.arg("-o")
.arg("ControlMaster=auto")
.arg("-o")
.arg("ControlPersist=yes");
}
}
impl InstallSource {
fn persistent(path: PathBuf) -> Self {
Self {
path,
temporary_dir: None,
}
}
fn temporary(path: PathBuf, temporary_dir: PathBuf) -> Self {
Self {
path,
temporary_dir: Some(temporary_dir),
}
}
fn cleanup(&self) {
if let Some(dir) = &self.temporary_dir {
let _ = fs::remove_dir_all(dir);
}
}
}
pub(super) fn prepare_remote_herdr(
ssh: &RemoteSsh,
live_handoff_enabled: bool,
require_surface_interest: bool,
) -> io::Result<PreparedRemoteHerdr> {
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 remote_binary_candidates = remote_binary_candidates(ssh, &remote_herdr)?;
if override_binary.is_none() {
for candidate in &remote_binary_candidates {
if remote_binary_supports_endpoint_requirement(ssh, candidate, require_surface_interest)
.unwrap_or(false)
{
return Ok(PreparedRemoteHerdr {
remote_herdr: candidate.clone(),
stop_after_install_approved: false,
});
}
}
if remote_binary_supports_endpoint_requirement(
ssh,
&remote_herdr,
require_surface_interest,
)? {
return Ok(PreparedRemoteHerdr {
remote_herdr,
stop_after_install_approved: false,
});
}
}
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()
.and_then(|exists| exists.then_some(&remote_herdr))
}) {
stop_after_install_approved = confirm_remote_install_with_running_server(
ssh,
status_probe_herdr,
live_handoff_enabled,
require_surface_interest,
)?;
}
if !stop_after_install_approved {
confirm_remote_install(
&ssh.destination(),
&remote_herdr,
&install_source_description(&remote_herdr.platform, override_binary.as_deref()),
)?;
}
let source = resolve_install_source(&remote_herdr.platform, override_binary)?;
let install_result = ssh.install_herdr(&remote_herdr, &source.path);
source.cleanup();
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",
remote_herdr.executable.display()
)));
}
warn_if_remote_bin_not_on_path(ssh)?;
Ok(PreparedRemoteHerdr {
remote_herdr,
stop_after_install_approved,
})
}
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)? {
return Ok(candidate);
}
}
Err(io::Error::new(
io::ErrorKind::Unsupported,
format!(
"matching Herdr is not ready on {}; run `herdr --remote {}` interactively to install or update it",
ssh.target(),
ssh.target()
),
))
}
fn prepare_windows_remote_herdr(
ssh: &RemoteSsh,
remote_herdr: RemoteHerdr,
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()
),
));
}
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<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)?
};
for candidate in candidates {
let probe =
ssh.framed_user_shell_output(&remote_api_bridge_command(&candidate, session, true))?;
if probe.status.code() == Some(255) {
return Err(command_failed("remote SSH connection failed", &probe));
}
if probe.status.success()
&& String::from_utf8_lossy(&probe.stdout).trim() == "herdr-api-bridge-v1"
{
return Ok(candidate);
}
}
Err(io::Error::new(
io::ErrorKind::Unsupported,
"remote Herdr does not support machine API forwarding; update Herdr on this machine",
))
}
fn detect_remote_platform(ssh: &RemoteSsh) -> io::Result<RemotePlatform> {
let output = ssh.sh_output("uname -s\nuname -m\n")?;
let mut windows_uname_hint = false;
let posix_error = if output.status.success() {
let stdout = String::from_utf8_lossy(&output.stdout);
let mut lines = stdout.lines();
let os = lines.next().unwrap_or_default();
let arch = lines.next().unwrap_or_default();
if let Some(platform) = RemotePlatform::from_uname(os, arch) {
return Ok(platform);
}
windows_uname_hint = looks_like_windows_uname(os);
io::Error::other(format!(
"unsupported remote platform: {} {}",
os.trim(),
arch.trim()
))
} else {
command_failed("remote platform detection failed", &output)
};
if !should_try_windows_probe(
output.status.success(),
output.status.code(),
windows_uname_hint,
) {
return Err(posix_error);
}
let windows_output = match ssh.framed_user_shell_output(&windows_platform_probe_command()) {
Ok(output) if output.status.success() => output,
_ => return Err(posix_error),
};
match parse_windows_platform_probe(&String::from_utf8_lossy(&windows_output.stdout)) {
Ok(Some(platform)) => Ok(platform),
Ok(None) => Err(posix_error),
Err(message) => Err(io::Error::other(message)),
}
}
fn should_try_windows_probe(
success: bool,
exit_code: Option<i32>,
windows_uname_hint: bool,
) -> bool {
(success && windows_uname_hint) || (!success && exit_code.is_some_and(|code| code != 255))
}
fn looks_like_windows_uname(os: &str) -> bool {
let os = os.trim().to_ascii_uppercase();
["MINGW", "MSYS", "CYGWIN"]
.iter()
.any(|prefix| os.starts_with(prefix))
}
fn windows_platform_probe_command() -> String {
windows_powershell_script_command(
"$arch = if ($env:PROCESSOR_ARCHITEW6432) { $env:PROCESSOR_ARCHITEW6432 } else { $env:PROCESSOR_ARCHITECTURE }; [Console]::Out.WriteLine('herdr-windows:' + $arch); exit 0",
)
}
fn parse_windows_platform_probe(stdout: &str) -> Result<Option<RemotePlatform>, String> {
let Some(arch) = stdout
.lines()
.find_map(|line| line.trim().strip_prefix("herdr-windows:"))
else {
return Ok(None);
};
match arch.trim().to_ascii_uppercase().as_str() {
"AMD64" | "X86_64" => Ok(Some(RemotePlatform {
os: "windows",
arch: "x86_64",
})),
arch => Err(format!("unsupported remote platform: Windows {arch}")),
}
}
fn remote_binary_candidates(
ssh: &RemoteSsh,
remote_herdr: &RemoteHerdr,
) -> io::Result<Vec<RemoteHerdr>> {
let mut candidates = Vec::new();
if let Some(path_candidate) = remote_binary_on_path_any(ssh, remote_herdr)? {
push_if_new_remote_binary_candidate(&mut candidates, path_candidate);
}
let output = ssh.sh_output(&known_remote_binary_candidate_script(
&remote_herdr.platform,
))?;
if !output.status.success() {
return Err(command_failed("remote binary discovery failed", &output));
}
let stdout = String::from_utf8_lossy(&output.stdout);
for candidate in remote_herdrs_from_path_discovery(remote_herdr, &stdout) {
push_if_new_remote_binary_candidate(&mut candidates, candidate);
}
Ok(candidates)
}
fn push_if_new_remote_binary_candidate(candidates: &mut Vec<RemoteHerdr>, candidate: RemoteHerdr) {
if !candidates
.iter()
.any(|existing| existing.executable == candidate.executable)
{
candidates.push(candidate);
}
}
fn known_remote_binary_candidate_script(platform: &RemotePlatform) -> String {
let mut script = String::from(
r#"home=${HOME:-}
user=${USER:-}
version="#,
);
script.push_str(&shell_quote(&current_version()));
script.push_str(
r#"
emit() {
path=$1
if [ -n "$path" ] && [ -x "$path" ]; then
printf '%s\n' "$path"
fi
}
if [ -n "$home" ]; then
emit "$home/.local/bin/herdr"
fi
"#,
);
if platform.os == "macos" {
script.push_str(
r#" emit "/opt/homebrew/bin/herdr"
emit "/usr/local/bin/herdr"
"#,
);
} else if platform.os == "linux" {
script.push_str(
r#" emit "/home/linuxbrew/.linuxbrew/bin/herdr"
"#,
);
}
script.push_str(
r#"if [ -n "$home" ]; then
emit "$home/.local/share/mise/installs/herdr/$version/bin/herdr"
emit "$home/.local/share/mise/installs/herdr/$version/herdr"
emit "$home/.local/share/mise/installs/github-ogulcancelik-herdr/$version/herdr"
emit "$home/.nix-profile/bin/herdr"
fi
if [ -n "$user" ]; then
emit "/etc/profiles/per-user/$user/bin/herdr"
fi
emit "/nix/var/nix/profiles/default/bin/herdr"
emit "/run/current-system/sw/bin/herdr"
"#,
);
script
}
fn remote_binary_on_path_any(
ssh: &RemoteSsh,
remote_herdr: &RemoteHerdr,
) -> io::Result<Option<RemoteHerdr>> {
let output = ssh.posix_user_shell_output("command -v herdr")?;
if output.status.success() {
let stdout = String::from_utf8_lossy(&output.stdout);
if let Some(candidate) = remote_herdr_from_path_discovery(remote_herdr, &stdout) {
return Ok(Some(candidate));
}
}
// Non-POSIX login shells such as xonsh reject `command -v`; retry through
// /bin/sh while retaining the login-shell probe for shell-initialized PATHs.
let output = ssh.sh_output("command -v herdr\n")?;
if !output.status.success() {
return Ok(None);
}
let stdout = String::from_utf8_lossy(&output.stdout);
Ok(remote_herdr_from_path_discovery(remote_herdr, &stdout))
}
fn remote_herdrs_from_path_discovery(remote_herdr: &RemoteHerdr, stdout: &str) -> Vec<RemoteHerdr> {
stdout
.lines()
.filter_map(|path| remote_herdr_from_path(remote_herdr, path))
.collect()
}
fn remote_herdr_from_path_discovery(
remote_herdr: &RemoteHerdr,
stdout: &str,
) -> Option<RemoteHerdr> {
stdout
.lines()
.find_map(|path| remote_herdr_from_path(remote_herdr, path))
}
fn remote_herdr_from_path(remote_herdr: &RemoteHerdr, path: &str) -> Option<RemoteHerdr> {
let path = path.trim();
if !path.starts_with('/') {
return None;
}
if is_mise_shim_path(path) {
return None;
}
Some(remote_herdr.clone().with_shell_path(shell_quote(path)))
}
fn is_mise_shim_path(path: &str) -> bool {
path.ends_with("/mise/shims/herdr")
}
fn remote_client_status(
ssh: &RemoteSsh,
remote_herdr: &RemoteHerdr,
) -> io::Result<Option<RemoteClientStatusJson>> {
let command = remote_herdr.executable.status_client_command();
let output = ssh.shell_output(&remote_herdr.platform, &command)?;
if !output.status.success() {
if output.status.code() == Some(255) {
return Err(command_failed("remote SSH connection failed", &output));
}
return Ok(None);
}
Ok(parse_client_status_json(&String::from_utf8_lossy(
&output.stdout,
)))
}
fn remote_binary_supports_endpoint_requirement(
ssh: &RemoteSsh,
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)))
}
fn remote_binary_exists(ssh: &RemoteSsh, remote_herdr: &RemoteHerdr) -> io::Result<bool> {
let command = remote_herdr.executable.exists_command();
Ok(ssh
.shell_output(&remote_herdr.platform, &command)?
.status
.success())
}
fn remote_binary_override_path() -> io::Result<Option<PathBuf>> {
let Some(value) = std::env::var_os(REMOTE_BINARY_ENV_VAR) else {
return Ok(None);
};
if value.is_empty() {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
format!("{REMOTE_BINARY_ENV_VAR} must not be empty"),
));
}
let path = PathBuf::from(value);
let metadata = fs::metadata(&path).map_err(|err| {
io::Error::new(
err.kind(),
format!(
"failed to inspect {REMOTE_BINARY_ENV_VAR} path {}: {err}",
path.display()
),
)
})?;
if !metadata.is_file() {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
format!(
"{REMOTE_BINARY_ENV_VAR} path is not a file: {}",
path.display()
),
));
}
Ok(Some(path))
}
fn install_source_description(platform: &RemotePlatform, override_binary: Option<&Path>) -> String {
install_source_description_for(
platform,
override_binary,
local_binary_can_seed_remote(platform),
)
}
fn install_source_description_for(
platform: &RemotePlatform,
override_binary: Option<&Path>,
local_binary_can_seed_remote: bool,
) -> String {
if let Some(path) = override_binary {
return format!("{REMOTE_BINARY_ENV_VAR} ({})", path.display());
}
if local_binary_can_seed_remote {
"the current local herdr binary".to_string()
} else {
format!(
"the {} {} asset for {}",
current_version(),
current_channel(),
platform.asset_key()
)
}
}
fn resolve_install_source(
platform: &RemotePlatform,
override_binary: Option<PathBuf>,
) -> io::Result<InstallSource> {
if let Some(path) = override_binary {
return Ok(InstallSource::persistent(path));
}
if *platform == RemotePlatform::local() {
let path = std::env::current_exe()?;
if !crate::update::is_package_manager_managed_exe_path(&path) {
return Ok(InstallSource::persistent(path));
}
}
download_release_asset(platform)
}
fn local_binary_can_seed_remote(platform: &RemotePlatform) -> bool {
if *platform != RemotePlatform::local() {
return false;
}
std::env::current_exe()
.map(|path| !crate::update::is_package_manager_managed_exe_path(&path))
.unwrap_or(false)
}
#[derive(Debug, Clone, PartialEq, Eq)]
enum RemoteServerStatus {
Running {
version: Option<String>,
endpoint_protocol_generation: Option<u32>,
surface_interest: bool,
health_check: bool,
live_handoff: bool,
detached_server_daemon: bool,
},
NotRunning,
}
impl RemoteServerStatus {
fn with_endpoint_negotiation(
mut self,
negotiation: &crate::client::endpoint::EndpointNegotiation,
) -> Self {
if let Self::Running {
surface_interest,
health_check,
..
} = &mut self
{
*surface_interest = negotiation.supports_surface_interest();
*health_check = negotiation.supports_health_check();
}
self
}
}
fn ensure_remote_server_ready(
ssh: &RemoteSsh,
remote_herdr: &RemoteHerdr,
stop_after_install_approved: bool,
live_handoff_enabled: bool,
require_surface_interest: bool,
) -> io::Result<()> {
let status = remote_server_status(ssh, remote_herdr, require_surface_interest)?;
let RemoteServerStatus::Running {
version,
endpoint_protocol_generation,
surface_interest,
health_check,
live_handoff,
detached_server_daemon,
} = status
else {
return Ok(());
};
let Some(reason) = remote_server_restart_reason(
endpoint_protocol_generation,
detached_server_daemon,
require_surface_interest,
surface_interest,
health_check,
) else {
return Ok(());
};
if live_handoff_enabled && live_handoff {
match live_handoff_remote_server(ssh, remote_herdr) {
Ok(()) => return Ok(()),
Err(err) => {
eprintln!("remote live handoff failed: {err}");
eprintln!("falling back to remote server restart.");
}
}
}
if stop_after_install_approved {
stop_remote_server(ssh, remote_herdr)?;
return Ok(());
}
if confirm_remote_server_stop(&ssh.destination(), version.as_deref(), reason)? {
stop_remote_server(ssh, remote_herdr)?;
}
Ok(())
}
fn confirm_remote_install_with_running_server(
ssh: &RemoteSsh,
remote_herdr: &RemoteHerdr,
live_handoff_enabled: bool,
require_surface_interest: bool,
) -> io::Result<bool> {
let target = ssh.destination();
let status = match remote_server_status(ssh, remote_herdr, require_surface_interest) {
Ok(status) => status,
Err(err) => {
if !io::stdin().is_terminal() {
return Err(io::Error::other(format!(
"could not inspect the running remote herdr server on {target} before installing: {err}; run from an interactive terminal to approve updating the remote binary"
)));
}
eprintln!(
"could not inspect the running remote herdr server on {target} before installing: {err}"
);
eprint!("continue installing the remote herdr binary? [y/N] ");
io::stderr().flush()?;
let mut answer = String::new();
io::stdin().read_line(&mut answer)?;
let answer = answer.trim().to_ascii_lowercase();
if answer != "y" && answer != "yes" {
return Err(io::Error::new(
io::ErrorKind::Interrupted,
"remote herdr install cancelled",
));
}
return Ok(false);
}
};
let RemoteServerStatus::Running {
version,
endpoint_protocol_generation,
surface_interest,
health_check,
live_handoff,
detached_server_daemon,
} = &status
else {
return Ok(false);
};
let plan = remote_install_running_server_plan(
*endpoint_protocol_generation,
*detached_server_daemon,
*surface_interest,
*health_check,
*live_handoff,
live_handoff_enabled,
require_surface_interest,
);
if plan == RemoteInstallRunningServerPlan::KeepRunning {
if io::stdin().is_terminal() {
eprintln!("remote herdr server on {target} is already compatible:");
eprintln!(" server: v{}", version_label(version.as_deref()));
eprintln!(
"Herdr will install {} without stopping the running remote server.",
current_version()
);
}
return Ok(false);
}
if !io::stdin().is_terminal() {
match plan {
RemoteInstallRunningServerPlan::LiveHandoff => return Ok(false),
RemoteInstallRunningServerPlan::StopRequired(_) => {
return Err(io::Error::other(format!(
"remote herdr server on {target} is running v{}; run from an interactive terminal to approve stopping it for the update",
version_label(version.as_deref())
)));
}
RemoteInstallRunningServerPlan::KeepRunning => return Ok(false),
}
}
if plan == RemoteInstallRunningServerPlan::LiveHandoff {
eprintln!("remote herdr server on {target} is currently running:");
eprintln!(" server: v{}", version_label(version.as_deref()));
eprintln!(
"Herdr will install {} and hand off live pane processes to the prepared server.",
current_version()
);
return Ok(false);
}
eprintln!("remote herdr server on {target} is currently running:");
eprintln!(" server: v{}", version_label(version.as_deref()));
eprintln!(
"To complete the remote update, Herdr must stop the running remote server after installing."
);
eprintln!("This stops active remote pane processes, including shells, agents, dev servers, and tests.");
eprintln!();
eprint!(
"Install {} and stop the remote server now? [y/N] ",
current_version()
);
io::stderr().flush()?;
let mut answer = String::new();
io::stdin().read_line(&mut answer)?;
let answer = answer.trim().to_ascii_lowercase();
if answer != "y" && answer != "yes" {
return Err(io::Error::new(
io::ErrorKind::Interrupted,
"remote herdr install cancelled",
));
}
Ok(true)
}
fn remote_server_status(
ssh: &RemoteSsh,
remote_herdr: &RemoteHerdr,
require_surface_interest: bool,
) -> io::Result<RemoteServerStatus> {
let command = remote_herdr
.executable
.session_command(&ssh.session_name, &["status", "server", "--json"]);
let output = ssh.shell_output(&remote_herdr.platform, &command)?;
if !output.status.success() {
return Err(command_failed("remote server status failed", &output));
}
let stdout = String::from_utf8_lossy(&output.stdout);
let status = parse_remote_server_status_json(stdout.trim())?;
if require_surface_interest
&& matches!(
status,
RemoteServerStatus::Running {
endpoint_protocol_generation: Some(
crate::protocol::endpoint::ENDPOINT_PROTOCOL_GENERATION
),
surface_interest: true,
health_check: true,
..
}
)
{
// Older status helpers omit newer capabilities. Ask the live endpoint rather than
// assuming that the installed binary and the running daemon support the same features.
let negotiation = probe_remote_endpoint(ssh, remote_herdr)?;
return Ok(status.with_endpoint_negotiation(&negotiation));
}
Ok(status)
}
fn probe_remote_endpoint(
ssh: &RemoteSsh,
remote_herdr: &RemoteHerdr,
) -> io::Result<crate::client::endpoint::EndpointNegotiation> {
let path = local_forward_socket_path(ssh.target(), &ssh.session_name);
let bridge = SshStdioBridge::start(
ssh.target.clone(),
remote_herdr.clone(),
path.clone(),
ssh.session_name.clone(),
None,
true,
)?;
let mut stream = crate::ipc::connect_local_stream(&path)?;
// Use the saved client's noninteractive path. This metadata-only attachment never
// acquires a surface or sends pane input.
match crate::client::probe_endpoint_negotiation(&mut stream) {
Ok(negotiation) => Ok(negotiation),
Err(probe_error) => Err(bridge.reported_failure().unwrap_or(probe_error)),
}
}
#[derive(Debug, Deserialize)]
struct RemoteClientStatusJson {
#[serde(default)]
version: Option<String>,
#[serde(default)]
protocol: Option<u32>,
#[serde(default)]
endpoint_protocol_generation: Option<u32>,
#[serde(default)]
endpoint_capabilities: Vec<String>,
}
impl RemoteClientStatusJson {
fn supports_endpoint_requirement(&self, require_surface_interest: bool) -> bool {
self.endpoint_protocol_generation
== Some(crate::protocol::endpoint::ENDPOINT_PROTOCOL_GENERATION)
&& (!require_surface_interest
|| [
crate::protocol::endpoint::SURFACE_INTEREST_CAPABILITY,
crate::protocol::endpoint::PRESENTATION_EFFECTS_FENCE_CAPABILITY,
crate::protocol::endpoint::HEALTH_CHECK_CAPABILITY,
]
.iter()
.all(|required| {
self.endpoint_capabilities
.iter()
.any(|capability| capability == required)
}))
}
}
#[derive(Debug, Deserialize)]
struct RemoteServerStatusJson {
running: bool,
version: Option<String>,
capabilities: Option<RemoteServerCapabilitiesJson>,
}
#[derive(Debug, Deserialize)]
struct RemoteServerCapabilitiesJson {
live_handoff: bool,
#[serde(default)]
detached_server_daemon: bool,
#[serde(default)]
endpoint_protocol_generation: Option<u32>,
#[serde(default)]
surface_interest: bool,
#[serde(default)]
health_check: bool,
}
fn parse_client_status_json(status: &str) -> Option<RemoteClientStatusJson> {
status
.lines()
.rev()
.filter(|line| !line.trim().is_empty())
.filter_map(|line| serde_json::from_str::<RemoteClientStatusJson>(line).ok())
.find(|status| {
status.version.is_some()
|| status.protocol.is_some()
|| status.endpoint_protocol_generation.is_some()
|| !status.endpoint_capabilities.is_empty()
})
}
fn parse_remote_server_status_json(status: &str) -> io::Result<RemoteServerStatus> {
let parsed: RemoteServerStatusJson = serde_json::from_str(status).map_err(|err| {
io::Error::other(format!(
"could not parse remote server status JSON from `{status}`: {err}"
))
})?;
if !parsed.running {
return Ok(RemoteServerStatus::NotRunning);
}
let capabilities = parsed.capabilities;
Ok(RemoteServerStatus::Running {
version: parsed.version,
endpoint_protocol_generation: capabilities
.as_ref()
.and_then(|capabilities| capabilities.endpoint_protocol_generation),
surface_interest: capabilities
.as_ref()
.is_some_and(|capabilities| capabilities.surface_interest),
health_check: capabilities
.as_ref()
.is_some_and(|capabilities| capabilities.health_check),
live_handoff: capabilities
.as_ref()
.is_some_and(|capabilities| capabilities.live_handoff),
detached_server_daemon: capabilities
.as_ref()
.is_some_and(|capabilities| capabilities.detached_server_daemon),
})
}
fn confirm_remote_server_stop(
target: &str,
version: Option<&str>,
reason: RemoteServerRestartReason,
) -> io::Result<bool> {
let required_upgrade = matches!(
reason,
RemoteServerRestartReason::EndpointProtocol
| RemoteServerRestartReason::SurfaceInterest
| RemoteServerRestartReason::HealthCheck
);
if !io::stdin().is_terminal() {
if required_upgrade {
return Err(io::Error::other(format!(
"remote herdr server on {target} needs one final update before this client can attach; run from an interactive terminal to approve updating it"
)));
}
eprintln!(
"remote herdr server on {target} is still running v{}; it will use {} after it restarts.",
version_label(version),
current_version()
);
return Ok(false);
}
eprintln!("remote herdr server on {target} is currently running:");
eprintln!(" server: v{}", version_label(version));
eprintln!(" prepared binary: {}", current_version());
eprintln!();
match reason {
RemoteServerRestartReason::EndpointProtocol => {
eprintln!(
"the remote server predates Herdr's stable endpoint protocol and must update before this client can attach."
);
}
RemoteServerRestartReason::SurfaceInterest => {
eprintln!(
"the remote server must restart before it can join saved SSH endpoint federation."
);
}
RemoteServerRestartReason::HealthCheck => {
eprintln!("the remote server must restart to enable saved SSH endpoint health checks.");
}
RemoteServerRestartReason::DaemonDetach => {
eprintln!(
"the remote server was started by a herdr build that may not survive SSH connection loss. restart it so network drops disconnect only this client."
);
}
}
eprintln!("This stops active remote pane processes, including shells, agents, dev servers, and tests.");
let prompt = if required_upgrade {
"stop and update the remote server, then continue attaching? [y/N] "
} else {
"restart the remote server now? [y/N] "
};
eprint!("{prompt}");
io::stderr().flush()?;
if read_remote_confirmation(&mut io::stdin().lock(), false)? {
return Ok(true);
}
if required_upgrade {
return Err(io::Error::new(
io::ErrorKind::Interrupted,
"remote herdr server stop cancelled",
));
}
Ok(false)
}
fn live_handoff_remote_server(ssh: &RemoteSsh, remote_herdr: &RemoteHerdr) -> io::Result<()> {
let status = remote_client_status(ssh, remote_herdr)?.ok_or_else(|| {
io::Error::other("could not inspect the prepared remote herdr binary before live handoff")
})?;
let protocol = status.protocol.ok_or_else(|| {
io::Error::other("prepared remote herdr did not report its private protocol")
})?;
let version = status
.version
.filter(|version| !version.is_empty())
.ok_or_else(|| io::Error::other("prepared remote herdr did not report its version"))?;
let command =
remote_herdr
.executable
.live_handoff_command(&ssh.session_name, protocol, &version);
let output = ssh.shell_output(&remote_herdr.platform, &command)?;
if !output.status.success() {
return Err(command_failed("remote server live handoff failed", &output));
}
eprintln!(
"handed off the remote herdr server on {}; reconnecting to the prepared server.",
ssh.target()
);
Ok(())
}
fn stop_remote_server(ssh: &RemoteSsh, remote_herdr: &RemoteHerdr) -> io::Result<()> {
let command = remote_herdr
.executable
.session_command(&ssh.session_name, &["server", "stop"]);
let output = ssh.shell_output(&remote_herdr.platform, &command)?;
if !output.status.success() {
return Err(command_failed("remote server stop failed", &output));
}
wait_for_remote_server_shutdown(ssh, remote_herdr)?;
eprintln!(
"stopped the remote herdr server on {}; it will restart when the remote client bridge attaches.",
ssh.target()
);
Ok(())
}
fn wait_for_remote_server_shutdown(ssh: &RemoteSsh, remote_herdr: &RemoteHerdr) -> io::Result<()> {
let deadline = Instant::now() + REMOTE_SERVER_SHUTDOWN_CONFIRM_TIMEOUT;
loop {
if remote_server_status(ssh, remote_herdr, false)? == RemoteServerStatus::NotRunning {
return Ok(());
}
if Instant::now() >= deadline {
return Err(io::Error::new(
io::ErrorKind::TimedOut,
format!(
"shutdown was requested, but the old remote herdr server on {target} is still responding after {} seconds",
REMOTE_SERVER_SHUTDOWN_CONFIRM_TIMEOUT.as_secs(),
target = ssh.target()
),
));
}
thread::sleep(REMOTE_SERVER_SHUTDOWN_POLL_INTERVAL);
}
}
fn version_label(version: Option<&str>) -> &str {
version.unwrap_or("unknown")
}
fn warn_if_remote_bin_not_on_path(ssh: &RemoteSsh) -> io::Result<()> {
let output = ssh.posix_user_shell_output("command -v herdr")?;
if output.status.success()
&& remote_shell_resolves_managed_install(&String::from_utf8_lossy(&output.stdout))
{
return Ok(());
}
eprintln!(
"herdr: installed remote binary to ~/.local/bin/herdr, but the remote shell does not resolve `herdr` to that path"
);
Ok(())
}
fn remote_shell_resolves_managed_install(stdout: &str) -> bool {
stdout
.lines()
.next()
.map(str::trim)
.is_some_and(|path| path.ends_with("/.local/bin/herdr"))
}
fn download_release_asset(platform: &RemotePlatform) -> io::Result<InstallSource> {
let asset_key = platform.asset_key();
let asset = remote_release_asset(&asset_key)?;
let dir = private_download_dir(&asset_key)?;
let path = dir.join("herdr.tmp");
let status = crate::noninteractive_process::curl_command()
.args(["-sfL", "--max-time", "120", "-o"])
.arg(&path)
.arg(&asset.url)
.status()
.map_err(|err| io::Error::new(err.kind(), format!("download failed: {err}")))?;
if !status.success() {
let _ = fs::remove_dir_all(&dir);
return Err(io::Error::other("download failed"));
}
if let Some(expected) = &asset.sha256 {
if let Err(err) = crate::checksum::verify_sha256(&path, expected) {
let _ = fs::remove_dir_all(&dir);
return Err(io::Error::new(
err.kind(),
format!("downloaded remote asset checksum verification failed: {err}"),
));
}
}
Ok(InstallSource::temporary(path, dir))
}
fn fetch_remote_manifest(url: &str) -> io::Result<Vec<u8>> {
let output = crate::noninteractive_process::curl_command()
.args([
"-sfL",
"--retry",
"3",
"--connect-timeout",
"10",
"--max-time",
"20",
url,
])
.output()
.map_err(|err| io::Error::new(err.kind(), format!("curl failed: {err}")))?;
if !output.status.success() {
return Err(command_failed("failed to fetch update manifest", &output));
}
Ok(output.stdout)
}
fn remote_asset_info(asset: &RemoteAssetRef) -> RemoteReleaseAsset {
RemoteReleaseAsset {
url: asset.url().to_string(),
sha256: asset.sha256().map(str::to_string),
}
}
fn preview_assets_for_build<'a>(
manifest: &'a RemotePreviewManifest,
build_id: &str,
) -> io::Result<(u32, &'a BTreeMap<String, RemoteAssetRef>)> {
if manifest.build_id == build_id {
return Ok((manifest.protocol, &manifest.assets));
}
let build = manifest.builds.get(build_id).ok_or_else(|| {
io::Error::other(format!(
"preview manifest no longer includes build {build_id}; run `herdr update` locally or set {REMOTE_BINARY_ENV_VAR}=target/release/herdr"
))
})?;
Ok((build.protocol, &build.assets))
}
fn remote_release_asset(asset_key: &str) -> io::Result<RemoteReleaseAsset> {
if crate::build_info::is_preview() {
let build_id = crate::build_info::build_id().ok_or_else(|| {
io::Error::other("preview client has no build id; set HERDR_REMOTE_BINARY or install Herdr on the remote manually")
})?;
let manifest_bytes = fetch_remote_manifest(PREVIEW_UPDATE_MANIFEST_URL)?;
let manifest: RemotePreviewManifest =
serde_json::from_slice(&manifest_bytes).map_err(|err| {
io::Error::other(format!("failed to parse preview manifest JSON: {err}"))
})?;
let (protocol, assets) = preview_assets_for_build(&manifest, build_id)?;
if protocol != CURRENT_PROTOCOL {
return Err(io::Error::other(format!(
"preview manifest has build {build_id} protocol {protocol}, but this client needs protocol {CURRENT_PROTOCOL}; set {REMOTE_BINARY_ENV_VAR}=target/release/herdr or install a matching Herdr on the remote host manually"
)));
}
return assets.get(asset_key).map(remote_asset_info).ok_or_else(|| {
io::Error::other(format!(
"no {asset_key} binary in the preview manifest for build {build_id}"
))
});
}
let current_version = current_version();
let manifest_bytes = fetch_remote_manifest(STABLE_UPDATE_MANIFEST_URL)?;
let manifest: RemoteUpdateManifest = serde_json::from_slice(&manifest_bytes)
.map_err(|err| io::Error::other(format!("failed to parse update manifest JSON: {err}")))?;
let release = manifest.release_for_version(&current_version).ok_or_else(|| {
io::Error::other(format!(
"release manifest does not include herdr {current_version}; build herdr for {} or install it there manually",
asset_key
))
})?;
if let Some(protocol) = release.protocol {
if protocol != CURRENT_PROTOCOL {
return Err(io::Error::other(format!(
"release manifest has herdr {current_version} protocol {protocol}, but this client needs protocol {CURRENT_PROTOCOL}; set {REMOTE_BINARY_ENV_VAR}=target/release/herdr or install a matching herdr on the remote host manually"
)));
}
}
let asset = release.assets.get(asset_key).ok_or_else(|| {
io::Error::other(format!(
"no {asset_key} binary in the release manifest for herdr {current_version}"
))
})?;
let mut asset = remote_asset_info(asset);
asset.sha256 = asset
.sha256
.or_else(|| release.sha256.get(asset_key).cloned());
if asset.sha256.is_none() {
return Err(io::Error::other(format!(
"release manifest asset {asset_key} is missing a SHA-256 checksum"
)));
}
Ok(asset)
}
fn private_download_dir(asset_key: &str) -> io::Result<PathBuf> {
let base = crate::platform::remote_private_temp_base();
fs::create_dir_all(&base)?;
for attempt in 0..100 {
let dir = base.join(format!(
"herdr-remote-{}-{}-{attempt}",
std::process::id(),
asset_key
));
match crate::platform::create_remote_private_dir(&dir) {
Ok(()) => return Ok(dir),
Err(err) if err.kind() == io::ErrorKind::AlreadyExists => continue,
Err(err) => return Err(err),
}
}
Err(io::Error::new(
io::ErrorKind::AlreadyExists,
"failed to create private herdr remote download directory",
))
}
fn read_remote_confirmation(reader: &mut impl io::BufRead, default: bool) -> io::Result<bool> {
let mut answer = String::new();
if reader.read_line(&mut answer)? == 0 {
return Err(io::Error::new(
io::ErrorKind::Interrupted,
"remote setup cancelled",
));
}
match answer.trim().to_ascii_lowercase().as_str() {
"y" | "yes" => Ok(true),
"n" | "no" => Ok(false),
"" => Ok(default),
_ => Err(io::Error::new(
io::ErrorKind::Interrupted,
"remote setup cancelled: expected yes or no",
)),
}
}
fn confirm_remote_install(
target: &str,
remote_herdr: &RemoteHerdr,
source_description: &str,
) -> io::Result<()> {
if !io::stdin().is_terminal() {
return Err(io::Error::other(format!(
"matching remote herdr {} is not installed at {}; run from an interactive terminal to approve installation",
current_version(),
remote_herdr.executable.display()
)));
}
eprintln!(
"matching herdr {} is not installed on {target} for {}.",
current_version(),
remote_herdr.platform.asset_key()
);
eprint!(
"Install {} to {}? [Y/n] ",
source_description,
remote_herdr.executable.display()
);
io::stderr().flush()?;
if !read_remote_confirmation(&mut io::stdin().lock(), true)? {
return Err(io::Error::new(
io::ErrorKind::Interrupted,
"remote herdr installation cancelled",
));
}
Ok(())
}
pub(super) fn remote_api_bridge_command(
remote_herdr: &RemoteHerdr,
session_name: &str,
check: bool,
) -> String {
let mut args = vec!["--session", session_name, "remote-api-bridge"];
if check {
args.push("--check");
}
match &remote_herdr.executable {
RemoteExecutable::PosixShellPath(_) => {
posix_remote_output_command(&format!("exec {}", remote_herdr.executable.command(&args)))
}
RemoteExecutable::WindowsPath(path) => {
windows_powershell_streaming_application_command(path, &args)
}
}
}
fn reattach_command(
program: &str,
target: &str,
session_name: &str,
keybindings: RemoteKeybindings,
live_handoff: bool,
) -> String {
let program = crate::platform::remote_reattach_program(program);
let target = crate::platform::remote_reattach_argument(target);
let mut command = format!("{program} --remote {target}");
if keybindings != RemoteKeybindings::Local {
command.push_str(" --remote-keybindings ");
command.push_str(keybindings.as_str());
}
if live_handoff {
command.push_str(" --handoff");
}
if session_name != crate::session::DEFAULT_SESSION_NAME {
command.push_str(" --session ");
command.push_str(&crate::platform::remote_reattach_argument(session_name));
}
command
}
fn command_failed(context: &str, output: &Output) -> io::Error {
let stderr = String::from_utf8_lossy(&output.stderr);
let stderr = stderr.trim();
if stderr.is_empty() {
io::Error::other(format!("{context}: {}", output.status))
} else {
io::Error::other(format!("{context}: {stderr}"))
}
}
pub(super) struct SshStdioBridge {
local_socket: PathBuf,
socket_identity: crate::ipc::SocketFileIdentity,
should_stop: Arc<AtomicBool>,
failure_rx: mpsc::Receiver<io::Error>,
thread: Option<JoinHandle<()>>,
}
impl SshStdioBridge {
pub(super) fn start(
target: String,
remote_herdr: RemoteHerdr,
local_socket: PathBuf,
session_name: String,
ssh_options: Option<&ManagedSshOptions>,
noninteractive: bool,
) -> io::Result<Self> {
Self::start_command(
target,
remote_herdr.executable.bridge_command(&session_name),
local_socket,
ssh_options,
noninteractive,
)
}
pub(super) fn start_command(
target: String,
remote_command: String,
local_socket: PathBuf,
ssh_options: Option<&ManagedSshOptions>,
noninteractive: bool,
) -> io::Result<Self> {
crate::ipc::prepare_socket_path(&local_socket, |path| {
format!("remote bridge is already listening at {}", path.display())
})?;
let listener = crate::ipc::bind_private_local_listener(&local_socket)?;
let socket_identity = crate::ipc::socket_file_identity(&local_socket)?;
if let Err(err) =
crate::ipc::restrict_socket_permissions(&local_socket, BRIDGE_SOCKET_PERMISSION_MODE)
{
let _ = crate::ipc::remove_socket_file_if_owned(&local_socket, &socket_identity);
return Err(err);
}
if let Err(err) = listener.set_nonblocking(ListenerNonblockingMode::Accept) {
let _ = crate::ipc::remove_socket_file_if_owned(&local_socket, &socket_identity);
return Err(err);
}
let should_stop = Arc::new(AtomicBool::new(false));
let thread_stop = Arc::clone(&should_stop);
let thread_ssh_options = ssh_options.cloned();
let (failure_tx, failure_rx) = mpsc::sync_channel(1);
let thread = thread::spawn(move || {
while !thread_stop.load(Ordering::Acquire) {
match listener.accept() {
Ok(stream) => {
let stream = match prepare_remote_bridge_stream(stream) {
Ok(stream) => stream,
Err(err) => {
tracing::error!(
error = %err,
"remote bridge failed to prepare client socket"
);
continue;
}
};
if let Err(err) = bridge_connection(
stream,
&target,
&remote_command,
thread_ssh_options.as_ref(),
noninteractive,
&thread_stop,
) {
let _ =
failure_tx.try_send(io::Error::new(err.kind(), err.to_string()));
if noninteractive {
tracing::warn!(error = %err, "saved SSH endpoint bridge failed");
} else {
eprintln!("herdr: remote bridge failed: {err}");
}
}
}
Err(err) if err.kind() == io::ErrorKind::WouldBlock => {
thread::sleep(BRIDGE_ACCEPT_POLL);
}
Err(err) => {
if noninteractive {
tracing::warn!(error = %err, "saved SSH endpoint listener failed");
} else {
eprintln!("herdr: remote bridge listener failed: {err}");
}
break;
}
}
}
});
Ok(Self {
local_socket,
socket_identity,
should_stop,
failure_rx,
thread: Some(thread),
})
}
pub(super) fn reported_failure(&self) -> Option<io::Error> {
self.failure_rx
.recv_timeout(BRIDGE_FAILURE_REPORT_TIMEOUT)
.ok()
}
}
fn prepare_remote_bridge_stream(
mut stream: crate::ipc::LocalStream,
) -> io::Result<crate::ipc::LocalStream> {
crate::ipc::set_local_stream_polling(&mut stream, false)?;
Ok(stream)
}
impl Drop for SshStdioBridge {
fn drop(&mut self) {
self.should_stop.store(true, Ordering::Release);
#[cfg(unix)]
let _ = crate::ipc::remove_socket_file_if_owned(&self.local_socket, &self.socket_identity);
if let Some(thread) = self.thread.take() {
let _ = thread.join();
}
#[cfg(windows)]
let _ = crate::ipc::remove_socket_file_if_owned(&self.local_socket, &self.socket_identity);
}
}
fn ssh_config_quote(path: &str) -> String {
format!("\"{path}\"")
}
fn ssh_config_include_path(path: &Path) -> String {
let path = path.to_string_lossy();
if std::path::MAIN_SEPARATOR == '\\' {
ssh_config_quote(&path.replace('\\', "/"))
} else {
ssh_config_quote(&path)
}
}
/// Builds a temporary ssh config that includes the user's settings first, so
/// OpenSSH's first-value-wins behavior preserves explicit user keepalives.
fn write_managed_ssh_config() -> io::Result<ManagedSshConfig> {
let paths = crate::platform::remote_ssh_config_paths();
let dir = crate::platform::create_remote_ssh_config_dir(SSH_CONTROL_SOCKET_NAME)?;
let path = dir.join("config");
let control_path = paths
.multiplexing
.then(|| dir.join(SSH_CONTROL_SOCKET_NAME));
let mut contents = String::new();
if let Some(user_config) = paths.user_config.filter(|path| path.is_file()) {
contents.push_str(&format!(
"Include {}\n",
ssh_config_include_path(&user_config)
));
}
if let Some(system_config) = paths.system_config.filter(|path| path.is_file()) {
contents.push_str(&format!(
"Include {}\n",
ssh_config_include_path(&system_config)
));
}
contents.push_str("Host *\n");
contents.push_str(" ServerAliveInterval 15\n");
contents.push_str(" ServerAliveCountMax 4\n");
let write_result = (|| {
let mut file = crate::platform::create_remote_ssh_config_file(&path)?;
file.write_all(contents.as_bytes())
})();
if let Err(err) = write_result {
let _ = fs::remove_dir_all(&dir);
return Err(err);
}
Ok(ManagedSshConfig {
options: ManagedSshOptions {
config_path: path,
control_path,
},
})
}
struct BridgeUploadStop {
stopped: AtomicBool,
wake: crate::platform::RemoteBridgeWake,
}
impl BridgeUploadStop {
fn new() -> io::Result<Self> {
Ok(Self {
stopped: AtomicBool::new(false),
wake: crate::platform::RemoteBridgeWake::new()?,
})
}
fn cancel(&self) {
if !self.stopped.swap(true, Ordering::AcqRel) {
if let Err(error) = self.wake.cancel() {
tracing::debug!(%error, "remote bridge read cancellation failed");
}
}
}
fn is_stopped(&self) -> bool {
self.stopped.load(Ordering::Acquire)
}
}
#[cfg(all(test, unix))]
pub(crate) fn bridge_upload_cancellation_for_test(
stream: crate::ipc::LocalStream,
mut writer: impl io::Write + Send + 'static,
) -> impl FnOnce() {
stream.set_nonblocking(true).unwrap();
let stop = Arc::new(BridgeUploadStop::new().unwrap());
let worker_stop = Arc::clone(&stop);
let (done_tx, done_rx) = std::sync::mpsc::channel();
let worker = thread::spawn(move || {
let closed = AtomicBool::new(false);
let result = copy_local_stream_to_writer(
stream,
&mut writer,
&worker_stop,
&AtomicBool::new(false),
&closed,
);
done_tx
.send((result, closed.load(Ordering::Acquire)))
.unwrap();
});
move || {
stop.cancel();
let (result, closed) = done_rx.recv_timeout(Duration::from_secs(3)).unwrap();
worker.join().unwrap();
result.unwrap();
assert!(!closed, "upload cancellation must not report peer EOF");
}
}
fn bridge_connection(
mut stream: crate::ipc::LocalStream,
target: &str,
remote_command: &str,
ssh_options: Option<&ManagedSshOptions>,
noninteractive: bool,
bridge_stop: &Arc<AtomicBool>,
) -> io::Result<()> {
let upload_stop = Arc::new(BridgeUploadStop::new()?);
let mut command = Command::new("ssh");
apply_managed_ssh_options(&mut command, ssh_options);
if noninteractive {
apply_noninteractive_ssh_options(&mut command);
}
command
.arg("-T")
.arg(target)
.arg(remote_command)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(if noninteractive {
Stdio::piped()
} else {
Stdio::inherit()
});
let mut child = command
.spawn()
.map_err(|err| io::Error::new(err.kind(), format!("failed to start ssh bridge: {err}")))?;
let mut child_stdin = match child.stdin.take() {
Some(stdin) => stdin,
None => return terminate_bridge_child(child, "ssh bridge stdin missing"),
};
let child_stdout = match child.stdout.take() {
Some(stdout) => stdout,
None => return terminate_bridge_child(child, "ssh bridge stdout missing"),
};
let stderr_reader = if noninteractive {
let Some(child_stderr) = child.stderr.take() else {
return terminate_bridge_child(child, "ssh bridge stderr missing");
};
Some(thread::spawn(move || capture_ssh_stderr(child_stderr)))
} else {
None
};
let stream_to_child = match stream.try_clone() {
Ok(stream) => stream,
Err(err) => {
let _ = child.kill();
let _ = child.wait();
return Err(err);
}
};
if let Err(err) = crate::ipc::set_local_stream_polling(&mut stream, true) {
let _ = child.kill();
let _ = child.wait();
return Err(err);
}
let mut child_to_stream = stream;
let connection_stop = Arc::new(AtomicBool::new(false));
let upload_failed = Arc::new(AtomicBool::new(false));
let download_done = Arc::new(AtomicBool::new(false));
let client_closed = Arc::new(AtomicBool::new(false));
let upload_cancel = Arc::clone(&upload_stop);
let upload_bridge_stop = Arc::clone(bridge_stop);
let upload_failed_worker = Arc::clone(&upload_failed);
let upload_client_closed = Arc::clone(&client_closed);
let upload = thread::spawn(move || {
let result = copy_local_stream_to_writer(
stream_to_child,
&mut child_stdin,
&upload_cancel,
&upload_bridge_stop,
&upload_client_closed,
);
upload_failed_worker.store(result.is_err(), Ordering::Release);
result
});
let download_stop = Arc::clone(&connection_stop);
let download_bridge_stop = Arc::clone(bridge_stop);
let download_done_worker = Arc::clone(&download_done);
let download_upload_stop = Arc::clone(&upload_stop);
let download = thread::spawn(move || {
let mut child_stdout = io::BufReader::new(child_stdout);
let result = discard_remote_output_preamble(&mut child_stdout).and_then(|()| {
copy_reader_to_local_stream(
&mut child_stdout,
&mut child_to_stream,
&download_stop,
&download_bridge_stop,
)
});
download_done_worker.store(true, Ordering::Release);
download_upload_stop.cancel();
result
});
let mut stopped_at = None;
let (status_result, child_exited) = loop {
match child.try_wait() {
Ok(Some(status)) => {
upload_stop.cancel();
break (Ok(status), true);
}
Ok(None) => {}
Err(err) => {
connection_stop.store(true, Ordering::Release);
upload_stop.cancel();
let _ = child.kill();
let _ = child.wait();
break (Err(err), false);
}
}
if bridge_stop.load(Ordering::Acquire) {
connection_stop.store(true, Ordering::Release);
upload_stop.cancel();
let _ = child.kill();
break (child.wait(), false);
}
if client_closed.load(Ordering::Acquire)
|| upload_failed.load(Ordering::Acquire)
|| download_done.load(Ordering::Acquire)
{
upload_stop.cancel();
let stopped_at = stopped_at.get_or_insert_with(Instant::now);
if stopped_at.elapsed() >= Duration::from_millis(250) {
connection_stop.store(true, Ordering::Release);
let _ = child.kill();
break (child.wait(), false);
}
}
thread::sleep(BRIDGE_ACCEPT_POLL);
};
upload_stop.cancel();
if !child_exited {
connection_stop.store(true, Ordering::Release);
}
let upload_result = upload
.join()
.map_err(|_| io::Error::other("remote bridge upload worker panicked"))?;
let download_result = download
.join()
.map_err(|_| io::Error::other("remote bridge download worker panicked"))?;
let stderr = match stderr_reader {
Some(reader) => reader
.join()
.map_err(|_| io::Error::other("SSH stderr reader panicked"))??,
None => Vec::new(),
};
let status = status_result?;
let stopping = bridge_stop.load(Ordering::Acquire);
let client_closed = client_closed.load(Ordering::Acquire);
if child_exited && !status.success() && !stopping && !client_closed {
return Err(ssh_bridge_exit_error(status, &stderr));
}
if !stopping && !client_closed {
upload_result.map_err(|err| {
io::Error::new(err.kind(), format!("remote bridge upload failed: {err}"))
})?;
download_result.map_err(|err| {
io::Error::new(err.kind(), format!("remote bridge download failed: {err}"))
})?;
}
if status.success() || stopping || client_closed {
Ok(())
} else {
Err(ssh_bridge_exit_error(status, &stderr))
}
}
fn ssh_bridge_exit_error(status: std::process::ExitStatus, stderr: &[u8]) -> io::Error {
let stderr = String::from_utf8_lossy(stderr);
let stderr = stderr.trim();
let message = if stderr.is_empty() {
format!("ssh bridge exited with {status}")
} else {
format!("remote SSH connection failed: {stderr}")
};
io::Error::new(io::ErrorKind::ConnectionAborted, message)
}
fn capture_ssh_stderr(mut stderr: impl io::Read) -> io::Result<Vec<u8>> {
let mut captured = Vec::new();
let mut buffer = [0_u8; 4 * 1024];
loop {
let read = stderr.read(&mut buffer)?;
if read == 0 {
return Ok(captured);
}
let remaining = NONINTERACTIVE_SSH_STDERR_LIMIT.saturating_sub(captured.len());
captured.extend_from_slice(&buffer[..read.min(remaining)]);
}
}
fn discard_remote_output_preamble(reader: &mut impl io::BufRead) -> io::Result<()> {
let marker = REMOTE_OUTPUT_READY_MARKER.as_bytes();
let mut matched = 0;
let mut matching = true;
loop {
let (consumed, ready) = {
let buffer = reader.fill_buf()?;
if buffer.is_empty() {
if matching && matched == marker.len() {
return Ok(());
}
return Err(io::Error::new(
io::ErrorKind::UnexpectedEof,
"remote command exited before producing its output marker",
));
}
let mut consumed = 0;
let mut ready = false;
for &byte in buffer {
consumed += 1;
if byte == b'\n' {
if matching && matched == marker.len() {
ready = true;
break;
}
matched = 0;
matching = true;
} else if matching && matched < marker.len() && byte == marker[matched] {
matched += 1;
} else if matching && (matched != marker.len() || byte != b'\r') {
matching = false;
}
}
(consumed, ready)
};
reader.consume(consumed);
if ready {
return Ok(());
}
}
}
fn terminate_bridge_child(mut child: std::process::Child, message: &'static str) -> io::Result<()> {
let _ = child.kill();
let _ = child.wait();
Err(io::Error::new(io::ErrorKind::BrokenPipe, message))
}
fn copy_reader_to_local_stream<R: io::Read>(
reader: &mut R,
stream: &mut crate::ipc::LocalStream,
connection_stop: &AtomicBool,
bridge_stop: &AtomicBool,
) -> io::Result<u64> {
let mut buffer = [0_u8; 16 * 1024];
let mut total = 0;
loop {
let read = match reader.read(&mut buffer) {
Ok(0) => return Ok(total),
Ok(read) => read,
Err(err) if err.kind() == io::ErrorKind::Interrupted => continue,
Err(err) => return Err(err),
};
let mut written = 0;
while written < read {
if connection_stop.load(Ordering::Acquire) || bridge_stop.load(Ordering::Acquire) {
return Ok(total);
}
let chunk_len = (read - written).min(4 * 1024);
match stream.write(&buffer[written..written + chunk_len]) {
Ok(0) => thread::sleep(BRIDGE_IO_POLL),
Ok(count) => written += count,
Err(err) if err.kind() == io::ErrorKind::Interrupted => continue,
Err(err) if err.kind() == io::ErrorKind::WouldBlock => {
thread::sleep(BRIDGE_IO_POLL);
}
Err(err) => return Err(err),
}
}
stream.flush()?;
total += read as u64;
}
}
fn copy_local_stream_to_writer<W: io::Write>(
mut stream: crate::ipc::LocalStream,
writer: &mut W,
connection_stop: &BridgeUploadStop,
bridge_stop: &AtomicBool,
client_closed: &AtomicBool,
) -> io::Result<u64> {
let mut buffer = [0_u8; 16 * 1024];
let mut total = 0;
while !connection_stop.is_stopped() && !bridge_stop.load(Ordering::Acquire) {
#[cfg(all(test, unix))]
tests::UPLOAD_READ_ATTEMPTS.with(|attempts| {
if let Some(attempts) = attempts.borrow().as_ref() {
attempts.fetch_add(1, Ordering::Relaxed);
}
});
match crate::ipc::poll_local_stream_read_count(&mut stream, &mut buffer)? {
crate::ipc::LocalStreamReadCount::Data(read) => {
writer.write_all(&buffer[..read])?;
writer.flush()?;
total += read as u64;
}
crate::ipc::LocalStreamReadCount::Pending => {
connection_stop.wake.wait(&stream)?;
}
crate::ipc::LocalStreamReadCount::Closed => {
client_closed.store(true, Ordering::Release);
break;
}
}
}
Ok(total)
}
fn run_client_process(
local_socket: &Path,
reattach_command: &str,
keybindings: RemoteKeybindings,
) -> io::Result<()> {
let exe = std::env::current_exe()?;
let status = Command::new(exe)
.arg("client")
.env(
crate::server::socket_paths::CLIENT_SOCKET_PATH_ENV_VAR,
local_socket,
)
.env(REATTACH_COMMAND_ENV_VAR, reattach_command)
.env(REMOTE_KEYBINDINGS_ENV_VAR, keybindings.as_str())
.env_remove(crate::api::SOCKET_PATH_ENV_VAR)
.stdin(Stdio::inherit())
.stdout(Stdio::inherit())
.stderr(Stdio::inherit())
.status()?;
if status.success() {
Ok(())
} else {
Err(io::Error::new(
io::ErrorKind::Interrupted,
format!("remote client exited with {status}"),
))
}
}
fn local_forward_socket_path(target: &str, session_name: &str) -> PathBuf {
let pid = std::process::id();
let target_clean = sanitize_path_component(target);
let session_clean = sanitize_path_component(session_name);
let readable_name = format!("herdr-remote-{pid}-{target_clean}-{session_clean}.sock");
let target_prefix: String = target_clean.chars().take(8).collect();
let hash = short_socket_hash(target, session_name);
let short_name = format!("herdr-r-{pid}-{target_prefix}-{hash}.sock");
crate::platform::remote_bridge_endpoint_path(&readable_name, &short_name)
}
#[cfg(all(test, unix))]
fn fits_unix_socket_path(path: &Path) -> bool {
use std::os::unix::ffi::OsStrExt;
path.as_os_str().as_bytes().len() <= 103
}
fn short_socket_hash(target: &str, session: &str) -> String {
use std::collections::hash_map::DefaultHasher;
use std::hash::{Hash, Hasher};
let mut hasher = DefaultHasher::new();
target.hash(&mut hasher);
0u8.hash(&mut hasher);
session.hash(&mut hasher);
format!("{:016x}", hasher.finish())
}
fn sanitize_path_component(input: &str) -> String {
let sanitized: String = input
.chars()
.map(|ch| {
if ch.is_ascii_alphanumeric() || matches!(ch, '.' | '_' | '-') {
ch
} else {
'-'
}
})
.collect();
sanitized.trim_matches('-').chars().take(32).collect()
}
#[cfg(test)]
mod tests {
use super::*;
#[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) };
}
#[cfg(unix)]
fn upload_test_streams(name: &str) -> (crate::ipc::LocalStream, crate::ipc::LocalStream) {
let socket = local_forward_socket_path(name, "upload-test");
let listener = crate::ipc::bind_private_local_listener(&socket).unwrap();
let client = crate::ipc::connect_local_stream(&socket).unwrap();
let server = listener.accept().unwrap();
server.set_nonblocking(true).unwrap();
drop(listener);
std::fs::remove_file(socket).unwrap();
(client, server)
}
#[cfg(unix)]
#[test]
fn bridge_upload_idle_waits_without_repeated_reads_and_cancels() {
use std::sync::atomic::AtomicUsize;
use std::sync::mpsc;
let (mut client, stream) = upload_test_streams("idle");
let attempts = Arc::new(AtomicUsize::new(0));
let worker_attempts = Arc::clone(&attempts);
let stop = Arc::new(BridgeUploadStop::new().unwrap());
let worker_stop = Arc::clone(&stop);
let (done_tx, done_rx) = mpsc::channel();
let worker = thread::spawn(move || {
UPLOAD_READ_ATTEMPTS.with(|slot| *slot.borrow_mut() = Some(worker_attempts));
let mut output = Vec::new();
let closed = AtomicBool::new(false);
let result = copy_local_stream_to_writer(
stream,
&mut output,
&worker_stop,
&AtomicBool::new(false),
&closed,
);
done_tx
.send((result, output, closed.load(Ordering::Acquire)))
.unwrap();
});
let deadline = Instant::now() + Duration::from_secs(5);
while attempts.load(Ordering::Relaxed) == 0 {
assert!(Instant::now() < deadline, "upload worker did not start");
thread::sleep(Duration::from_millis(1));
}
thread::sleep(Duration::from_millis(100));
let idle_reads = attempts.load(Ordering::Relaxed);
client.write_all(b"pane input").unwrap();
let deadline = Instant::now() + Duration::from_secs(5);
while attempts.load(Ordering::Relaxed) < idle_reads + 2 {
assert!(
Instant::now() < deadline,
"input did not wake the upload worker"
);
thread::sleep(Duration::from_millis(1));
}
thread::sleep(Duration::from_millis(100));
let reads_after_input = attempts.load(Ordering::Relaxed);
stop.cancel();
let (result, output, closed) = done_rx.recv_timeout(Duration::from_secs(2)).unwrap();
worker.join().unwrap();
assert_eq!(result.unwrap(), 10);
assert_eq!(output, b"pane input");
assert!(!closed, "cancellation is not a peer disconnect");
assert_eq!(idle_reads, 1, "idle forwarding must wait, not retry reads");
assert_eq!(
reads_after_input, 3,
"forwarding must sleep again after input"
);
}
#[cfg(unix)]
#[test]
fn bridge_upload_cancel_before_wait_preserves_download() {
use std::io::Read as _;
let (mut client, stream) = upload_test_streams("cancel-before-wait");
let mut download = stream.try_clone().unwrap();
let stop = BridgeUploadStop::new().unwrap();
stop.cancel();
stop.cancel();
let closed = AtomicBool::new(false);
let count = copy_local_stream_to_writer(
stream,
&mut Vec::new(),
&stop,
&AtomicBool::new(false),
&closed,
)
.unwrap();
assert_eq!(count, 0);
assert!(!closed.load(Ordering::Acquire));
download.write_all(b"final frame").unwrap();
let mut output = [0; 11];
client.read_exact(&mut output).unwrap();
assert_eq!(&output, b"final frame");
}
#[cfg(unix)]
#[test]
fn bridge_upload_cancel_between_stop_check_and_wait_is_retained() {
let (_client, stream) = upload_test_streams("cancel-before-poll");
let stop = BridgeUploadStop::new().unwrap();
assert!(!stop.is_stopped());
stop.cancel();
let (done_tx, done_rx) = std::sync::mpsc::channel();
let worker = thread::spawn(move || {
done_tx.send(stop.wake.wait(&stream)).unwrap();
});
done_rx
.recv_timeout(Duration::from_secs(2))
.unwrap()
.unwrap();
worker.join().unwrap();
}
#[cfg(unix)]
#[test]
fn bridge_upload_drains_input_before_peer_eof() {
let (mut client, stream) = upload_test_streams("drain");
let payload = vec![b'x'; 1024 * 1024];
let expected = payload.clone();
let worker = thread::spawn(move || {
let stop = BridgeUploadStop::new().unwrap();
let mut output = Vec::new();
let closed = AtomicBool::new(false);
let count = copy_local_stream_to_writer(
stream,
&mut output,
&stop,
&AtomicBool::new(false),
&closed,
)
.unwrap();
assert!(closed.load(Ordering::Acquire));
assert_eq!(count, output.len() as u64);
output
});
client.write_all(&payload).unwrap();
drop(client);
assert_eq!(worker.join().unwrap(), expected);
}
#[cfg(unix)]
#[test]
fn bridge_socket_is_user_only() {
use std::os::unix::fs::PermissionsExt;
let socket = std::env::temp_dir().join(format!(
"herdr-bridge-permissions-test-{}.sock",
std::process::id()
));
let remote_herdr = RemoteHerdr::for_platform(RemotePlatform {
os: "linux",
arch: "x86_64",
});
let bridge = SshStdioBridge::start(
"example".to_string(),
remote_herdr,
socket.clone(),
"default".to_string(),
None,
false,
)
.expect("start bridge listener");
let mode = std::fs::metadata(&socket).unwrap().permissions().mode() & 0o777;
assert_eq!(mode, BRIDGE_SOCKET_PERMISSION_MODE);
drop(bridge);
let _ = std::fs::remove_file(socket);
}
#[cfg(unix)]
#[test]
fn accepted_bridge_stream_is_reset_to_blocking() {
use std::os::fd::AsRawFd as _;
fn is_nonblocking(stream: &crate::ipc::LocalStream) -> bool {
let fd = match stream {
crate::ipc::LocalStream::UdSocket(stream) => stream.inner().as_raw_fd(),
};
// SAFETY: F_GETFL only reads flags from the live descriptor owned by `stream`.
let flags = unsafe { libc::fcntl(fd, libc::F_GETFL) };
assert!(flags >= 0, "fcntl(F_GETFL): {}", io::Error::last_os_error());
flags & libc::O_NONBLOCK != 0
}
let socket = std::env::temp_dir().join(format!(
"herdr-bridge-blocking-test-{}.sock",
std::process::id()
));
let _ = std::fs::remove_file(&socket);
let listener = crate::ipc::bind_private_local_listener(&socket).expect("bind listener");
let client = crate::ipc::connect_local_stream(&socket).expect("connect client");
let mut server = listener.accept().expect("accept client");
crate::ipc::set_local_stream_polling(&mut server, true)
.expect("force the macOS accepted-stream state");
assert!(is_nonblocking(&server));
let server = prepare_remote_bridge_stream(server).expect("prepare bridge stream");
assert!(!is_nonblocking(&server));
drop(server);
drop(client);
drop(listener);
let _ = std::fs::remove_file(socket);
}
#[cfg(windows)]
#[test]
fn bridge_stream_delivers_large_frame_before_delayed_reply() {
use std::io::Read as _;
use std::sync::mpsc;
use std::time::{SystemTime, UNIX_EPOCH};
fn read_frame(stream: &mut crate::ipc::LocalStream) -> Vec<u8> {
let mut length = [0; 4];
stream.read_exact(&mut length).expect("read frame length");
let length = usize::try_from(u32::from_le_bytes(length)).expect("frame length fits");
let mut body = vec![0; length];
stream.read_exact(&mut body).expect("read frame body");
body
}
fn write_frame(stream: &mut crate::ipc::LocalStream, body: &[u8]) {
let length = u32::try_from(body.len()).expect("frame length fits");
stream
.write_all(&length.to_le_bytes())
.expect("write frame length");
stream.write_all(body).expect("write frame body");
}
let nonce = SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("system clock after epoch")
.as_nanos();
let socket = std::env::temp_dir().join(format!(
"herdr-bridge-large-frame-{}-{nonce}.sock",
std::process::id()
));
let listener = crate::ipc::bind_private_local_listener(&socket).expect("bind listener");
let (large_received_tx, large_received_rx) = mpsc::channel();
let client_socket = socket.clone();
let client = thread::spawn(move || {
let mut stream =
crate::ipc::connect_local_stream(&client_socket).expect("connect client");
assert_eq!(read_frame(&mut stream), b"welcome");
assert_eq!(read_frame(&mut stream), vec![b'x'; 16 * 1024]);
large_received_tx.send(()).expect("signal large frame");
assert_eq!(read_frame(&mut stream), b"pong");
});
let mut server = prepare_remote_bridge_stream(listener.accept().expect("accept client"))
.expect("prepare bridge stream");
crate::ipc::set_local_stream_polling(&mut server, true)
.expect("enable bridge read polling");
write_frame(&mut server, b"welcome");
write_frame(&mut server, &vec![b'x'; 16 * 1024]);
large_received_rx
.recv_timeout(Duration::from_secs(2))
.expect("client received large frame");
write_frame(&mut server, b"pong");
client.join().expect("client thread");
drop(server);
drop(listener);
let _ = std::fs::remove_file(socket);
}
#[test]
fn bridge_drop_while_waiting_for_client_is_bounded() {
let socket = local_forward_socket_path("drop-test", "default");
let remote_herdr = RemoteHerdr::for_platform(RemotePlatform {
os: "linux",
arch: "x86_64",
});
let bridge = SshStdioBridge::start(
"example".to_string(),
remote_herdr,
socket.clone(),
"default".to_string(),
None,
false,
)
.expect("start bridge listener");
let started = Instant::now();
drop(bridge);
assert!(started.elapsed() < Duration::from_secs(1));
assert!(!socket.exists());
}
#[cfg(unix)]
#[test]
fn managed_ssh_config_includes_user_config_then_fallback() {
use std::os::unix::fs::PermissionsExt;
let managed_config = write_managed_ssh_config().expect("write managed config");
let path = managed_config.options.config_path.clone();
let control_path = managed_config
.options
.control_path
.clone()
.expect("Unix managed config has a control path");
let contents = std::fs::read_to_string(&path).expect("read keepalive config");
// herdr's fallback transport settings are present...
assert!(
contents.contains("Host *"),
"config should add a Host * fallback block: {contents}"
);
assert!(
contents.contains("ServerAliveInterval 15"),
"config should set the keepalive interval: {contents}"
);
assert!(
contents.contains("ServerAliveCountMax 4"),
"config should set the keepalive count: {contents}"
);
assert!(!contents.contains("ControlMaster"));
assert!(!contents.contains("ControlPersist"));
assert!(!contents.contains("ControlPath"));
// ...and any user config is Included (quoted) BEFORE it so
// first-value-wins keeps the user's own settings.
if let Some(home) = std::env::var_os("HOME") {
let user_config = PathBuf::from(home).join(".ssh").join("config");
if user_config.is_file() {
let include = format!(
"Include {}",
ssh_config_quote(&user_config.to_string_lossy())
);
let include_at = contents.find(&include).expect("user config Included");
let fallback_at = contents.find("Host *").expect("fallback present");
assert!(
include_at < fallback_at,
"user config must be Included before herdr's fallback: {contents}"
);
}
}
let mode = std::fs::metadata(&path).unwrap().permissions().mode() & 0o777;
assert_eq!(
mode, BRIDGE_SOCKET_PERMISSION_MODE,
"keepalive config must be user-only"
);
// The config lives in a private 0700 dir, not a predictable temp path.
let dir = path.parent().expect("config has a parent dir");
let dir_mode = std::fs::metadata(dir).unwrap().permissions().mode() & 0o777;
assert_eq!(dir_mode, 0o700, "ssh config dir must be user-only");
assert!(
fits_unix_socket_path(&control_path),
"control socket path must fit portable Unix socket limits"
);
drop(managed_config);
}
#[test]
fn ssh_config_quote_wraps_path_with_spaces() {
assert_eq!(
ssh_config_quote("/home/a b/.ssh/config"),
"\"/home/a b/.ssh/config\""
);
}
#[cfg(unix)]
#[test]
fn remote_ssh_command_uses_managed_config_when_present() {
let managed_config = write_managed_ssh_config().expect("write managed config");
let config_path = managed_config.options.config_path.clone();
let control_path = managed_config
.options
.control_path
.clone()
.expect("Unix managed config has a control path");
let ssh = RemoteSsh {
target: "example".to_string(),
session_name: crate::session::DEFAULT_SESSION_NAME.into(),
managed_config: Some(managed_config),
noninteractive: false,
};
let command = ssh.command();
let args = command
.get_args()
.map(|arg| arg.to_string_lossy().into_owned())
.collect::<Vec<_>>();
assert_eq!(
args,
vec![
"-F".to_string(),
config_path.to_string_lossy().into_owned(),
"-S".to_string(),
control_path.to_string_lossy().into_owned(),
"-o".to_string(),
"ControlMaster=auto".to_string(),
"-o".to_string(),
"ControlPersist=yes".to_string(),
"-T".to_string(),
"example".to_string(),
]
);
}
#[cfg(windows)]
#[test]
fn windows_managed_ssh_config_uses_keepalives_without_control_socket() {
let managed_config = write_managed_ssh_config().expect("write managed config");
let config_path = managed_config.options.config_path.clone();
assert!(managed_config.options.control_path.is_none());
let contents = std::fs::read_to_string(&config_path).expect("read managed config");
assert!(contents.contains("ServerAliveInterval 15"));
assert!(contents.contains("ServerAliveCountMax 4"));
let ssh = RemoteSsh {
target: "example".to_string(),
session_name: crate::session::DEFAULT_SESSION_NAME.into(),
managed_config: Some(managed_config),
noninteractive: false,
};
let args = ssh
.command()
.get_args()
.map(|arg| arg.to_string_lossy().into_owned())
.collect::<Vec<_>>();
assert_eq!(
args,
vec![
"-F".to_string(),
config_path.to_string_lossy().into_owned(),
"-T".to_string(),
"example".to_string(),
]
);
}
#[cfg(windows)]
#[test]
fn windows_ssh_config_include_uses_forward_slashes() {
assert_eq!(
ssh_config_include_path(Path::new(r"C:\Users\A B\.ssh\config")),
r#""C:/Users/A B/.ssh/config""#
);
}
#[test]
fn noninteractive_ssh_stderr_capture_is_bounded() {
let stderr = vec![b'x'; NONINTERACTIVE_SSH_STDERR_LIMIT + 4096];
let captured = capture_ssh_stderr(stderr.as_slice()).expect("capture stderr");
assert_eq!(captured.len(), NONINTERACTIVE_SSH_STDERR_LIMIT);
}
#[test]
fn noninteractive_ssh_command_cannot_prompt_or_accept_unknown_hosts() {
let ssh = RemoteSsh::new_noninteractive("example".into());
let args = ssh
.command()
.get_args()
.map(|arg| arg.to_string_lossy().into_owned())
.collect::<Vec<_>>();
for required in [
"BatchMode=yes",
"NumberOfPasswordPrompts=0",
"StrictHostKeyChecking=yes",
"ConnectTimeout=10",
"ConnectionAttempts=1",
"ServerAliveInterval=15",
"ServerAliveCountMax=4",
] {
assert!(args.iter().any(|arg| arg == required), "missing {required}");
}
assert!(!args.iter().any(|arg| arg == "-F"));
assert!(ssh.options().is_none());
}
#[test]
fn remote_setup_approval_requires_input_and_rejects_unrecognized_answers() {
for default in [false, true] {
for input in ["", "maybe\n"] {
assert_eq!(
read_remote_confirmation(&mut input.as_bytes(), default)
.unwrap_err()
.kind(),
io::ErrorKind::Interrupted
);
}
assert_eq!(
read_remote_confirmation(&mut "\n".as_bytes(), default).unwrap(),
default
);
assert!(read_remote_confirmation(&mut "YES\n".as_bytes(), default).unwrap());
assert!(!read_remote_confirmation(&mut "no\n".as_bytes(), default).unwrap());
}
}
#[test]
fn saved_machine_compatibility_uses_capabilities_not_release_or_private_protocol() {
let mut status = RemoteClientStatusJson {
version: Some("0.1.0".into()),
protocol: Some(1),
endpoint_protocol_generation: Some(
crate::protocol::endpoint::ENDPOINT_PROTOCOL_GENERATION,
),
endpoint_capabilities: vec![
crate::protocol::endpoint::SURFACE_INTEREST_CAPABILITY.into(),
crate::protocol::endpoint::PRESENTATION_EFFECTS_FENCE_CAPABILITY.into(),
crate::protocol::endpoint::HEALTH_CHECK_CAPABILITY.into(),
],
};
assert!(status.supports_endpoint_requirement(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));
status.endpoint_capabilities.insert(index, removed);
}
status.endpoint_protocol_generation = None;
assert!(!status.supports_endpoint_requirement(true));
}
#[test]
fn saved_machine_server_commands_are_scoped_to_the_explicit_session() {
let herdr =
RemoteHerdr::for_platform(RemotePlatform::from_uname("Linux", "x86_64").unwrap());
for (args, command) in [
(&["status", "server", "--json"][..], "status server --json"),
(&["server", "stop"][..], "server stop"),
(&["remote-client-bridge"][..], "remote-client-bridge"),
] {
assert_eq!(
herdr.executable.session_command("agents", args),
format!("{} --session agents {command}", herdr.executable.display())
);
assert_eq!(
herdr
.executable
.session_command(crate::session::DEFAULT_SESSION_NAME, args),
format!("{} {command}", herdr.executable.display())
);
}
assert!(herdr
.executable
.live_handoff_command("agents", 19, "0.7.9")
.starts_with(&format!(
"{} --session agents server live-handoff",
herdr.executable.display()
)));
}
#[test]
fn remote_ssh_command_is_plain_without_managed_config() {
let ssh = RemoteSsh {
target: "example".to_string(),
session_name: crate::session::DEFAULT_SESSION_NAME.into(),
managed_config: None,
noninteractive: false,
};
let command = ssh.command();
let args = command
.get_args()
.map(|arg| arg.to_string_lossy().into_owned())
.collect::<Vec<_>>();
assert_eq!(args, vec!["-T".to_string(), "example".to_string()]);
}
#[test]
fn remote_install_stream_command_avoids_shell_c_wrapper() {
let command = remote_install_stream_command("/home/a b/.local/bin/herdr.tmp.123");
assert_eq!(command, "tee '/home/a b/.local/bin/herdr.tmp.123'");
}
#[test]
fn remote_install_prepare_and_commit_scripts_quote_paths() {
let remote_herdr = RemoteHerdr::for_platform(RemotePlatform {
os: "linux",
arch: "x86_64",
});
let prepare = remote_install_prepare_script(&remote_herdr);
assert!(prepare.contains("mkdir -p \"$dir\""));
assert!(prepare.contains("printf '%s\\0%s\\0' \"$tmp\" \"$dest\""));
assert_eq!(
parse_remote_install_paths(b"/home/a b/herdr.tmp.42\0/home/a b/herdr\0").unwrap(),
(
"/home/a b/herdr.tmp.42".to_string(),
"/home/a b/herdr".to_string()
)
);
assert_eq!(
parse_remote_install_paths(b"/home/a b\n/herdr.tmp.42\0/home/a b\n/herdr\0").unwrap(),
(
"/home/a b\n/herdr.tmp.42".to_string(),
"/home/a b\n/herdr".to_string()
)
);
assert_eq!(
remote_install_commit_script("/home/a b/herdr.tmp.42", "/home/a b/herdr"),
"set -eu\nchmod 755 '/home/a b/herdr.tmp.42'\nmv '/home/a b/herdr.tmp.42' '/home/a b/herdr'\n"
);
}
#[test]
fn extract_remote_args_removes_space_form() {
let args = vec![
"herdr".into(),
"--remote".into(),
"dev".into(),
"--help".into(),
];
let (cleaned, remote) = extract_remote_args(&args).unwrap();
assert_eq!(cleaned, vec!["herdr", "--help"]);
let remote = remote.unwrap();
assert_eq!(remote.target, "dev");
assert_eq!(remote.keybindings, RemoteKeybindings::Local);
}
#[test]
fn extract_remote_args_removes_equals_form() {
let args = vec!["herdr".into(), "--remote=user@host".into()];
let (cleaned, remote) = extract_remote_args(&args).unwrap();
assert_eq!(cleaned, vec!["herdr"]);
let remote = remote.unwrap();
assert_eq!(remote.target, "user@host");
assert_eq!(remote.keybindings, RemoteKeybindings::Local);
}
#[test]
fn extract_remote_args_accepts_remote_keybindings_server() {
let args = vec![
"herdr".into(),
"--remote".into(),
"dev".into(),
"--remote-keybindings=server".into(),
];
let (cleaned, remote) = extract_remote_args(&args).unwrap();
assert_eq!(cleaned, vec!["herdr"]);
let remote = remote.unwrap();
assert_eq!(remote.target, "dev");
assert_eq!(remote.keybindings, RemoteKeybindings::Server);
}
#[test]
fn extract_remote_args_accepts_remote_keybindings_space_form() {
let args = vec![
"herdr".into(),
"--remote=dev".into(),
"--remote-keybindings".into(),
"server".into(),
];
let (cleaned, remote) = extract_remote_args(&args).unwrap();
assert_eq!(cleaned, vec!["herdr"]);
assert_eq!(remote.unwrap().keybindings, RemoteKeybindings::Server);
}
#[test]
fn extract_remote_args_accepts_explicit_handoff() {
let args = vec!["herdr".into(), "--remote=dev".into(), "--handoff".into()];
let (cleaned, remote) = extract_remote_args(&args).unwrap();
assert_eq!(cleaned, vec!["herdr"]);
let remote = remote.unwrap();
assert_eq!(remote.target, "dev");
assert!(remote.live_handoff);
}
#[test]
fn extract_remote_args_preserves_child_remote_options_after_separator() {
let args = vec![
"herdr".into(),
"agent".into(),
"start".into(),
"repro".into(),
"--".into(),
"child".into(),
"--remote".into(),
"dev".into(),
"--remote-keybindings=server".into(),
"--handoff".into(),
];
let (cleaned, remote) = extract_remote_args(&args).unwrap();
assert_eq!(cleaned, args);
assert!(remote.is_none());
}
#[test]
fn extract_remote_args_preserves_handoff_without_remote() {
let args = vec!["herdr".into(), "update".into(), "--handoff".into()];
let (cleaned, remote) = extract_remote_args(&args).unwrap();
assert_eq!(cleaned, args);
assert!(remote.is_none());
}
#[test]
fn extract_remote_args_rejects_remote_keybindings_without_remote() {
let args = vec!["herdr".into(), "--remote-keybindings=server".into()];
let err = extract_remote_args(&args).unwrap_err();
assert_eq!(err, "--remote-keybindings requires --remote");
}
#[test]
fn extract_remote_args_rejects_duplicate_remote_keybindings() {
let args = vec![
"herdr".into(),
"--remote=dev".into(),
"--remote-keybindings=local".into(),
"--remote-keybindings=server".into(),
];
let err = extract_remote_args(&args).unwrap_err();
assert_eq!(err, "--remote-keybindings can only be specified once");
}
#[test]
fn extract_remote_args_requires_value() {
let args = vec!["herdr".into(), "--remote".into()];
let err = extract_remote_args(&args).unwrap_err();
assert_eq!(err, "missing value for --remote");
}
#[test]
fn extract_remote_args_rejects_empty_value() {
let args = vec!["herdr".into(), "--remote=".into()];
let err = extract_remote_args(&args).unwrap_err();
assert_eq!(err, "missing value for --remote");
}
#[test]
fn extract_remote_args_rejects_duplicate_values() {
let args = vec![
"herdr".into(),
"--remote=dev".into(),
"--remote=prod".into(),
];
let err = extract_remote_args(&args).unwrap_err();
assert_eq!(err, "--remote can only be specified once");
}
#[test]
fn extract_remote_args_rejects_option_like_target() {
let args = vec!["herdr".into(), "--remote".into(), "-oProxyCommand=x".into()];
let err = extract_remote_args(&args).unwrap_err();
assert_eq!(err, "--remote target must not start with '-'");
}
#[test]
fn sanitize_path_component_removes_shell_sensitive_chars() {
assert_eq!(sanitize_path_component("user@host:22"), "user-host-22");
}
#[test]
fn remote_platform_maps_uname_values() {
assert_eq!(
RemotePlatform::from_uname("Linux", "amd64")
.unwrap()
.asset_key(),
"linux-x86_64"
);
assert_eq!(
RemotePlatform::from_uname("Darwin", "arm64")
.unwrap()
.asset_key(),
"macos-aarch64"
);
assert!(RemotePlatform::from_uname("FreeBSD", "x86_64").is_none());
}
#[test]
fn windows_platform_probe_accepts_only_x86_64() {
assert_eq!(
parse_windows_platform_probe("profile noise\r\nherdr-windows:AMD64\r\n").unwrap(),
Some(RemotePlatform {
os: "windows",
arch: "x86_64",
})
);
assert_eq!(parse_windows_platform_probe("other output").unwrap(), None);
assert_eq!(
parse_windows_platform_probe("herdr-windows:ARM64").unwrap_err(),
"unsupported remote platform: Windows ARM64"
);
}
#[test]
fn windows_probe_preserves_transport_and_unsupported_unix_failures() {
let cases = [
(true, Some(0), false, false),
(true, Some(0), true, true),
(false, Some(255), false, false),
(false, None, false, false),
(false, Some(1), false, true),
];
for (success, exit_code, windows_uname_hint, expected) in cases {
assert_eq!(
should_try_windows_probe(success, exit_code, windows_uname_hint),
expected,
"success={success}, exit_code={exit_code:?}, windows_uname_hint={windows_uname_hint}"
);
}
for (os, expected) in [
("MINGW64_NT-10.0-26100", true),
("MSYS_NT-10.0", true),
("CYGWIN_NT-10.0", true),
("FreeBSD", false),
] {
assert_eq!(looks_like_windows_uname(os), expected, "{os}");
}
}
#[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 = [
(
"platform probe",
windows_platform_probe_command(),
"$arch = if ($env:PROCESSOR_ARCHITEW6432) { $env:PROCESSOR_ARCHITEW6432 } else { $env:PROCESSOR_ARCHITECTURE }; [Console]::Out.WriteLine('herdr-windows:' + $arch); exit 0",
),
(
"PATH lookup",
executable.exists_command(),
"if ($null -ne (Get-Command herdr.exe -CommandType Application -ErrorAction SilentlyContinue)) { exit 0 }; exit 1",
),
(
"client status",
executable.status_client_command(),
"& herdr.exe status client '--json'; exit $LASTEXITCODE",
),
(
"named server status",
executable.session_command("agents", &["status", "server", "--json"]),
"& herdr.exe '--session' agents status server '--json'; exit $LASTEXITCODE",
),
(
"server stop",
executable.session_command("agents", &["server", "stop"]),
"& herdr.exe '--session' agents server stop; exit $LASTEXITCODE",
),
(
"direct bridge",
executable.bridge_command("agents"),
"$process = Start-Process -FilePath herdr.exe -ArgumentList '--session agents remote-client-bridge' -NoNewWindow -Wait -PassThru -ErrorAction Stop; exit $process.ExitCode",
),
(
"API bridge with explicit default session",
remote_api_bridge_command(&RemoteHerdr::for_platform(RemotePlatform { os: "windows", arch: "x86_64" }), "default", false),
"$process = Start-Process -FilePath herdr.exe -ArgumentList '--session default remote-api-bridge' -NoNewWindow -Wait -PassThru -ErrorAction Stop; exit $process.ExitCode",
),
(
"API bridge capability probe",
remote_api_bridge_command(&RemoteHerdr::for_platform(RemotePlatform { os: "windows", arch: "x86_64" }), "agents", true),
"$process = Start-Process -FilePath herdr.exe -ArgumentList '--session agents remote-api-bridge --check' -NoNewWindow -Wait -PassThru -ErrorAction Stop; exit $process.ExitCode",
),
(
"saved bridge with closed stdin",
executable.saved_bridge_command("agents"),
"$process = Start-Process -FilePath herdr.exe -ArgumentList '--session agents remote-client-bridge' -NoNewWindow -Wait -PassThru -ErrorAction Stop; exit $process.ExitCode",
),
];
let preamble = format!(
"[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 = script
.strip_prefix(&preamble)
.unwrap_or_else(|| panic!("{label} lacks shared output marker: {script}"));
assert_eq!(script, expected, "{label}");
}
}
#[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];
input.extend_from_slice(b"\r\nherdr-remote-output-ready:1\r\n");
input.extend_from_slice(&payload);
let mut reader = io::BufReader::with_capacity(17, io::Cursor::new(input));
discard_remote_output_preamble(&mut reader).unwrap();
let mut output = Vec::new();
io::Read::read_to_end(&mut reader, &mut output).unwrap();
assert_eq!(output, payload);
let mut missing = b"profile output without marker".to_vec();
assert!(normalize_remote_stdout(&mut missing, true).is_err());
normalize_remote_stdout(&mut missing, false).unwrap();
assert_eq!(missing, b"profile output without marker");
let mut platform = b"profile output\nherdr-remote-output-ready:1\nLinux\nx86_64\n".to_vec();
normalize_remote_stdout(&mut platform, true).unwrap();
let platform = String::from_utf8(platform).unwrap();
let mut lines = platform.lines();
assert_eq!(
RemotePlatform::from_uname(lines.next().unwrap(), lines.next().unwrap()),
Some(RemotePlatform {
os: "linux",
arch: "x86_64"
})
);
}
#[cfg(unix)]
#[test]
fn reattach_command_includes_remote_and_session() {
assert_eq!(
reattach_command(
"target/release/herdr",
"user@host",
"work",
RemoteKeybindings::Local,
false,
),
"target/release/herdr --remote user@host --session work"
);
assert_eq!(
reattach_command(
"herdr",
"host name",
crate::session::DEFAULT_SESSION_NAME,
RemoteKeybindings::Local,
false,
),
"herdr --remote 'host name'"
);
assert_eq!(
reattach_command(
"herdr",
"host",
crate::session::DEFAULT_SESSION_NAME,
RemoteKeybindings::Server,
false,
),
"herdr --remote host --remote-keybindings server"
);
assert_eq!(
reattach_command(
"herdr",
"host",
crate::session::DEFAULT_SESSION_NAME,
RemoteKeybindings::Local,
true,
),
"herdr --remote host --handoff"
);
}
#[cfg(windows)]
#[test]
fn windows_reattach_command_uses_current_executable() {
let executable = std::env::current_exe().expect("current test executable");
assert_eq!(
reattach_command(
r"C:\Program Files\Herdr\herdr.exe",
"host'name",
"work'name",
RemoteKeybindings::Local,
false,
),
format!(
"& '{}' --remote 'host''name' --session 'work''name'",
executable.display().to_string().replace('\'', "''")
)
);
}
#[test]
fn remote_api_bridge_always_selects_the_saved_session() {
let remote_herdr = RemoteHerdr::for_platform(RemotePlatform {
os: "linux",
arch: "x86_64",
});
for session in ["default", "agents"] {
assert_eq!(
remote_api_bridge_command(&remote_herdr, session, false),
posix_remote_output_command(&format!(
"exec \"$HOME/.local/bin/herdr\" --session {session} remote-api-bridge"
))
);
}
}
#[test]
fn remote_bridge_command_uses_installed_binary() {
let remote_herdr = RemoteHerdr::for_platform(RemotePlatform {
os: "linux",
arch: "x86_64",
});
assert_eq!(
remote_herdr
.executable
.bridge_command(crate::session::DEFAULT_SESSION_NAME),
"printf '\n%s\n' 'herdr-remote-output-ready:1'\nexec \"$HOME/.local/bin/herdr\" remote-client-bridge"
);
assert_eq!(
remote_herdr.executable.saved_bridge_command("agents"),
"exec \"$HOME/.local/bin/herdr\" --session agents remote-client-bridge </dev/null"
);
}
#[test]
fn remote_path_discovery_uses_path_binary() {
let remote_herdr = RemoteHerdr::for_platform(RemotePlatform {
os: "linux",
arch: "x86_64",
});
let remote_herdr = remote_herdr_from_path_discovery(&remote_herdr, "/usr/bin/herdr\n")
.expect("path binary");
assert_eq!(
remote_herdr
.executable
.bridge_command(crate::session::DEFAULT_SESSION_NAME),
"printf '\n%s\n' 'herdr-remote-output-ready:1'\nexec /usr/bin/herdr remote-client-bridge"
);
}
#[test]
fn remote_path_discovery_quotes_discovered_binary() {
let remote_herdr = RemoteHerdr::for_platform(RemotePlatform {
os: "linux",
arch: "x86_64",
});
let remote_herdr =
remote_herdr_from_path_discovery(&remote_herdr, "/opt/herdr bin/herdr\n")
.expect("path binary");
assert_eq!(
remote_herdr
.executable
.bridge_command(crate::session::DEFAULT_SESSION_NAME),
"printf '\n%s\n' 'herdr-remote-output-ready:1'\nexec '/opt/herdr bin/herdr' remote-client-bridge"
);
}
#[test]
fn remote_path_discovery_uses_macos_path_binary() {
let remote_herdr = RemoteHerdr::for_platform(RemotePlatform {
os: "macos",
arch: "aarch64",
});
let remote_herdr =
remote_herdr_from_path_discovery(&remote_herdr, "/opt/homebrew/bin/herdr\n")
.expect("path binary");
assert_eq!(
remote_herdr
.executable
.bridge_command(crate::session::DEFAULT_SESSION_NAME),
"printf '\n%s\n' 'herdr-remote-output-ready:1'\nexec /opt/homebrew/bin/herdr remote-client-bridge"
);
assert_eq!(remote_herdr.platform.asset_key(), "macos-aarch64");
}
#[test]
fn remote_path_discovery_reads_multiple_absolute_paths() {
let remote_herdr = RemoteHerdr::for_platform(RemotePlatform {
os: "linux",
arch: "x86_64",
});
let candidates = remote_herdrs_from_path_discovery(
&remote_herdr,
"/usr/bin/herdr\nbin/herdr\n /opt/herdr bin/herdr\n",
);
assert_eq!(candidates.len(), 2);
assert_eq!(
candidates[0].executable,
RemoteExecutable::PosixShellPath("/usr/bin/herdr".to_string())
);
assert_eq!(
candidates[1].executable,
RemoteExecutable::PosixShellPath("'/opt/herdr bin/herdr'".to_string())
);
}
#[test]
fn remote_path_discovery_ignores_mise_shims() {
let remote_herdr = RemoteHerdr::for_platform(RemotePlatform {
os: "linux",
arch: "x86_64",
});
let candidates = remote_herdrs_from_path_discovery(
&remote_herdr,
"/home/can/.local/share/mise/shims/herdr\n/home/can/.local/share/mise/installs/herdr/0.7.1/bin/herdr\n",
);
assert_eq!(candidates.len(), 1);
assert_eq!(
candidates[0].executable,
RemoteExecutable::PosixShellPath(
"/home/can/.local/share/mise/installs/herdr/0.7.1/bin/herdr".to_string()
)
);
}
#[test]
fn known_remote_binary_candidate_script_includes_mise_and_nix_paths() {
let script = known_remote_binary_candidate_script(&RemotePlatform {
os: "linux",
arch: "x86_64",
});
assert!(script.contains("emit \"$home/.local/bin/herdr\""));
assert!(!script.contains("mise/shims/herdr"));
assert!(script.contains(&format!("version={}", shell_quote(&current_version()))));
assert!(
script.contains("emit \"$home/.local/share/mise/installs/herdr/$version/bin/herdr\"")
);
assert!(script.contains("emit \"$home/.local/share/mise/installs/herdr/$version/herdr\""));
assert!(script.contains(
"emit \"$home/.local/share/mise/installs/github-ogulcancelik-herdr/$version/herdr\""
));
assert!(script.contains("emit \"$home/.nix-profile/bin/herdr\""));
assert!(script.contains("emit \"/etc/profiles/per-user/$user/bin/herdr\""));
assert!(script.contains("emit \"/run/current-system/sw/bin/herdr\""));
assert!(script.contains("emit \"/home/linuxbrew/.linuxbrew/bin/herdr\""));
assert!(!script.contains("emit \"/opt/homebrew/bin/herdr\""));
}
#[test]
fn known_remote_binary_candidate_script_includes_macos_homebrew_paths() {
let script = known_remote_binary_candidate_script(&RemotePlatform {
os: "macos",
arch: "aarch64",
});
assert!(script.contains("emit \"/opt/homebrew/bin/herdr\""));
assert!(script.contains("emit \"/usr/local/bin/herdr\""));
assert!(!script.contains("emit \"/home/linuxbrew/.linuxbrew/bin/herdr\""));
}
#[test]
fn remote_path_discovery_quotes_single_quotes_in_discovered_binary() {
let remote_herdr = RemoteHerdr::for_platform(RemotePlatform {
os: "linux",
arch: "x86_64",
});
let remote_herdr =
remote_herdr_from_path_discovery(&remote_herdr, "/opt/herdr's/bin/herdr\n")
.expect("path binary");
assert_eq!(
remote_herdr
.executable
.bridge_command(crate::session::DEFAULT_SESSION_NAME),
"printf '\n%s\n' 'herdr-remote-output-ready:1'\nexec '/opt/herdr'\\''s/bin/herdr' remote-client-bridge"
);
}
#[test]
fn remote_path_discovery_ignores_relative_paths() {
let remote_herdr = RemoteHerdr::for_platform(RemotePlatform {
os: "linux",
arch: "x86_64",
});
let remote_herdr = remote_herdr_from_path_discovery(&remote_herdr, "bin/herdr\n");
assert!(remote_herdr.is_none());
}
#[test]
fn remote_path_discovery_ignores_empty_output() {
let remote_herdr = RemoteHerdr::for_platform(RemotePlatform {
os: "linux",
arch: "x86_64",
});
let remote_herdr = remote_herdr_from_path_discovery(&remote_herdr, "\n");
assert!(remote_herdr.is_none());
}
#[test]
fn remote_shell_path_warning_accepts_managed_install() {
assert!(remote_shell_resolves_managed_install(
"/home/can/.local/bin/herdr\n"
));
assert!(remote_shell_resolves_managed_install(
"/Users/can/.local/bin/herdr\n"
));
assert!(!remote_shell_resolves_managed_install(
"/usr/local/bin/herdr\n"
));
assert!(!remote_shell_resolves_managed_install(""));
}
#[test]
fn parse_client_status_json_reads_last_json_record() {
let status = parse_client_status_json(
"wrapper output\n{\"version\":\"0.8.0\",\"protocol\":20,\"endpoint_protocol_generation\":1,\"endpoint_capabilities\":[\"surface_interest\",\"health_check\"]}\n{\"wrapper\":true}\n",
)
.unwrap();
assert_eq!(status.version.as_deref(), Some("0.8.0"));
assert_eq!(status.protocol, Some(20));
assert_eq!(status.endpoint_protocol_generation, Some(1));
assert_eq!(
status.endpoint_capabilities,
vec!["surface_interest", "health_check"]
);
assert!(
parse_client_status_json(r#"{"endpoint_protocol_generation":"unknown"}"#).is_none()
);
}
#[test]
fn saved_machine_setup_handoffs_old_server_missing_presentation_fence() {
// 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"]}"#,
)
.unwrap();
let running_binary = parse_client_status_json(
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));
for (live_capabilities, expected) in [
(
running_binary.endpoint_capabilities,
RemoteInstallRunningServerPlan::LiveHandoff,
),
(
installed.endpoint_capabilities,
RemoteInstallRunningServerPlan::KeepRunning,
),
] {
let live_negotiation = crate::client::endpoint::EndpointNegotiation::new(
vec!["client_shell.surface.set".into()],
live_capabilities,
);
let RemoteServerStatus::Running {
endpoint_protocol_generation,
surface_interest,
health_check,
live_handoff,
detached_server_daemon,
..
} = parse_remote_server_status_json(
r#"{"status":"running","running":true,"version":"0.8.2","protocol":22,"capabilities":{"live_handoff":true,"detached_server_daemon":true,"endpoint_protocol_generation":1,"surface_interest":true,"health_check":true}}"#,
)
.unwrap()
.with_endpoint_negotiation(&live_negotiation) else {
panic!("captured server must be running");
};
assert_eq!(
remote_install_running_server_plan(
endpoint_protocol_generation,
detached_server_daemon,
surface_interest,
health_check,
live_handoff,
true,
true,
),
expected,
"setup must follow the running server's negotiated capabilities",
);
}
}
#[test]
fn parse_remote_server_status_json_reads_running_server() {
assert_eq!(
parse_remote_server_status_json(
r#"{"status":"running","running":true,"version":"0.6.0","protocol":8,"capabilities":{"live_handoff":true,"detached_server_daemon":true,"endpoint_protocol_generation":1,"surface_interest":true,"health_check":true}}"#
)
.unwrap(),
RemoteServerStatus::Running {
version: Some("0.6.0".into()),
endpoint_protocol_generation: Some(1),
surface_interest: true,
health_check: true,
live_handoff: true,
detached_server_daemon: true
}
);
}
#[test]
fn parse_remote_server_status_json_treats_missing_capability_as_old_server() {
assert_eq!(
parse_remote_server_status_json(
r#"{"status":"running","running":true,"version":"0.6.0","protocol":8}"#
)
.unwrap(),
RemoteServerStatus::Running {
version: Some("0.6.0".into()),
endpoint_protocol_generation: None,
surface_interest: false,
health_check: false,
live_handoff: false,
detached_server_daemon: false
}
);
}
#[test]
fn parse_remote_server_status_json_reads_stopped_server() {
assert_eq!(
parse_remote_server_status_json(
r#"{"status":"not_running","running":false,"version":null,"protocol":null}"#
)
.unwrap(),
RemoteServerStatus::NotRunning
);
}
#[test]
fn remote_update_manifest_uses_root_assets_for_latest_version() {
let manifest: RemoteUpdateManifest = serde_json::from_str(
r#"{
"version": "1.2.3",
"assets": {
"linux-x86_64": "https://example.com/latest"
},
"sha256": {
"linux-x86_64": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
},
"releases": {
"1.2.3": {
"assets": {
"linux-x86_64": "https://example.com/archive"
}
}
}
}"#,
)
.unwrap();
let release = manifest.release_for_version("1.2.3").unwrap();
assert_eq!(
release.assets.get("linux-x86_64").map(RemoteAssetRef::url),
Some("https://example.com/latest")
);
assert_eq!(
release.sha256.get("linux-x86_64").map(String::as_str),
Some("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa")
);
}
#[test]
fn remote_update_manifest_reads_archived_release_assets() {
let manifest: RemoteUpdateManifest = serde_json::from_str(
r#"{
"version": "1.2.4",
"assets": {
"linux-x86_64": "https://example.com/latest"
},
"releases": {
"1.2.3": {
"notes": "ignored",
"assets": {
"linux-x86_64": "https://example.com/archive"
}
}
}
}"#,
)
.unwrap();
assert_eq!(
manifest
.release_for_version("1.2.3")
.and_then(|release| release.assets.get("linux-x86_64"))
.map(RemoteAssetRef::url),
Some("https://example.com/archive")
);
}
#[test]
fn remote_update_manifest_uses_archived_release_protocol() {
let manifest: RemoteUpdateManifest = serde_json::from_str(
r#"{
"version": "1.2.4",
"protocol": 42,
"assets": {
"linux-x86_64": "https://example.com/latest"
},
"releases": {
"1.2.3": {
"notes": "ignored",
"protocol": 41,
"assets": {
"linux-x86_64": "https://example.com/archive"
}
}
}
}"#,
)
.unwrap();
assert_eq!(
manifest
.release_for_version("1.2.3")
.and_then(|release| release.protocol),
Some(41)
);
}
#[test]
fn remote_update_manifest_does_not_inherit_latest_protocol_for_archived_assets() {
let manifest: RemoteUpdateManifest = serde_json::from_str(
r#"{
"version": "1.2.4",
"protocol": 42,
"assets": {
"linux-x86_64": "https://example.com/latest"
},
"releases": {
"1.2.3": {
"notes": "ignored",
"assets": {
"linux-x86_64": "https://example.com/archive"
}
}
}
}"#,
)
.unwrap();
assert_eq!(
manifest
.release_for_version("1.2.3")
.and_then(|release| release.protocol),
None
);
}
#[test]
fn remote_preview_manifest_falls_back_to_archived_exact_build_assets() {
let manifest: RemotePreviewManifest = serde_json::from_str(
r#"{
"build_id": "2026-06-06-new",
"protocol": 12,
"assets": {
"linux-x86_64": {
"url": "https://example.com/new",
"sha256": "new"
}
},
"builds": {
"2026-06-02-old": {
"protocol": 11,
"assets": {
"linux-x86_64": {
"url": "https://example.com/old",
"sha256": "old"
}
}
}
}
}"#,
)
.unwrap();
let (protocol, assets) =
preview_assets_for_build(&manifest, "2026-06-02-old").expect("archived build");
let asset = assets.get("linux-x86_64").expect("asset");
assert_eq!(protocol, 11);
assert_eq!(asset.url(), "https://example.com/old");
assert_eq!(asset.sha256(), Some("old"));
}
#[test]
fn remote_live_handoff_uses_prepared_binary_identity() {
let remote_herdr = RemoteHerdr::for_platform(RemotePlatform {
os: "linux",
arch: "x86_64",
});
let command = remote_herdr.executable.live_handoff_command(
crate::session::DEFAULT_SESSION_NAME,
19,
"0.7.9",
);
assert!(command.contains("--expected-protocol 19"));
assert!(command.contains("--expected-version 0.7.9"));
assert!(!command.contains(&format!(
"--expected-protocol {CURRENT_PROTOCOL} --expected-version {}",
current_version()
)));
}
#[test]
fn install_source_description_uses_override_binary() {
let platform = RemotePlatform {
os: "linux",
arch: "aarch64",
};
assert_eq!(
install_source_description_for(&platform, Some(Path::new("/tmp/herdr-aarch64")), false),
"HERDR_REMOTE_BINARY (/tmp/herdr-aarch64)"
);
}
#[test]
fn install_source_description_uses_local_binary_when_allowed() {
let platform = RemotePlatform::local();
assert_eq!(
install_source_description_for(&platform, None, true),
"the current local herdr binary"
);
}
#[test]
fn install_source_description_uses_release_asset_when_local_binary_cannot_seed_remote() {
let platform = RemotePlatform::local();
assert_eq!(
install_source_description_for(&platform, None, false),
format!(
"the {} {} asset for {}",
current_version(),
current_channel(),
platform.asset_key()
)
);
}
#[test]
fn resolve_install_source_uses_override_binary_without_temporary_cleanup() {
let platform = RemotePlatform {
os: "linux",
arch: "aarch64",
};
let source = resolve_install_source(&platform, Some(PathBuf::from("/tmp/herdr-aarch64")))
.expect("override source");
assert_eq!(source.path, PathBuf::from("/tmp/herdr-aarch64"));
assert!(source.temporary_dir.is_none());
}
#[cfg(windows)]
#[test]
fn windows_local_forward_endpoint_uses_private_state_dir() {
let path = local_forward_socket_path("user@example.com", "work");
assert!(path.starts_with(crate::platform::remote_private_temp_base()));
assert!(path
.file_name()
.is_some_and(|name| name.to_string_lossy().starts_with("herdr-r-")));
}
#[cfg(unix)]
fn remote_env_lock() -> &'static std::sync::Mutex<()> {
static LOCK: std::sync::OnceLock<std::sync::Mutex<()>> = std::sync::OnceLock::new();
LOCK.get_or_init(|| std::sync::Mutex::new(()))
}
#[cfg(unix)]
fn socket_path_byte_len(path: &Path) -> usize {
use std::os::unix::ffi::OsStrExt;
path.as_os_str().as_bytes().len()
}
#[cfg(unix)]
#[test]
fn local_forward_socket_path_uses_readable_name_when_it_fits() {
let _guard = remote_env_lock().lock().unwrap();
// Short target + session leave plenty of room — keep the human-
// readable form so the socket path stays grep-friendly.
let path = local_forward_socket_path("dev", "default");
let filename = path
.file_name()
.and_then(|s| s.to_str())
.unwrap_or("")
.to_string();
assert!(
filename.starts_with("herdr-remote-"),
"expected readable name, got {filename}"
);
assert!(filename.contains("-dev-default."), "got {filename}");
assert!(
fits_unix_socket_path(&path),
"socket path too long: {} ({} bytes)",
path.display(),
socket_path_byte_len(&path)
);
}
#[cfg(unix)]
#[test]
fn local_forward_socket_path_fits_in_sun_path() {
let _guard = remote_env_lock().lock().unwrap();
// Worst case for the readable form: macOS-style 49-char TMPDIR +
// max-length sanitized components. Should fall back to the hashed
// short name, which fits under TMPDIR.
let target = "longish-host.example.com";
let session = "a-fairly-long-session-name-here";
let path = local_forward_socket_path(target, session);
assert!(
fits_unix_socket_path(&path),
"socket path too long for sun_path: {} ({} bytes)",
path.display(),
socket_path_byte_len(&path)
);
}
#[cfg(unix)]
#[test]
fn local_forward_socket_path_falls_back_to_tmp_when_dir_is_long() {
let _guard = remote_env_lock().lock().unwrap();
// Force a TMPDIR long enough that even the hashed short name cannot
// fit inside it. The fallback should drop to /tmp.
let prior = std::env::var_os("TMPDIR");
let long_dir = std::env::temp_dir().join("a".repeat(80));
let _ = fs::create_dir_all(&long_dir);
std::env::set_var("TMPDIR", &long_dir);
let path = local_forward_socket_path("longish-host.example.com", "default");
let fits = fits_unix_socket_path(&path);
let parent = path.parent().map(Path::to_path_buf);
let filename = path
.file_name()
.and_then(|s| s.to_str())
.unwrap_or("")
.to_string();
match prior {
Some(v) => std::env::set_var("TMPDIR", v),
None => std::env::remove_var("TMPDIR"),
}
let _ = fs::remove_dir_all(&long_dir);
assert!(fits, "fallback path still overflows: {}", path.display());
assert_eq!(parent.as_deref(), Some(Path::new("/tmp")));
assert!(
filename.starts_with("herdr-r-"),
"expected hashed fallback, got {filename}"
);
}
#[test]
fn install_source_cleanup_removes_temporary_directory() {
let dir = std::env::temp_dir().join(format!(
"herdr-install-source-cleanup-test-{}",
std::process::id()
));
let _ = fs::remove_dir_all(&dir);
fs::create_dir(&dir).expect("create temp dir");
let path = dir.join("herdr.tmp");
fs::write(&path, b"test").expect("write temp file");
InstallSource::temporary(path, dir.clone()).cleanup();
assert!(!dir.exists());
}
}