aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorJJ Liebig <jonathan.liebig@gmail.com>2026-09-27 20:46:47 +0200
committerGitHub <noreply@github.com>2026-09-27 20:46:47 +0200
commit0b9c8ead0073221aaf4f1b0a5e01f0c44a61ad0e (patch)
tree23328023ea8a9e595f298ca8db8c4411151087c0
parent21d71a0308c3df7053f9fc281ad4943d1c89d1a3 (diff)
feat: simplify ssh machine setup discovery (#4204)
-rw-r--r--docs/next/website/src/content/docs/cli-reference.mdx2
-rw-r--r--docs/next/website/src/content/docs/connecting-machines.mdx8
-rw-r--r--src/cli/machine.rs124
-rw-r--r--src/cli/spec/machine.rs2
-rw-r--r--src/remote/attach.rs269
-rw-r--r--tests/machine_setup.rs233
-rw-r--r--tests/remote_attach.rs40
7 files changed, 577 insertions, 101 deletions
diff --git a/docs/next/website/src/content/docs/cli-reference.mdx b/docs/next/website/src/content/docs/cli-reference.mdx
index 1b6f7019..31fe26a9 100644
--- a/docs/next/website/src/content/docs/cli-reference.mdx
+++ b/docs/next/website/src/content/docs/cli-reference.mdx
@@ -59,7 +59,7 @@ herdr machine enable <profile-id>
herdr machine remove <profile-id>
```
-By default, `machine add` uses the remote default session. Add `--remote-session <name>` only for a named session. `machine list` accepts optional `--json` for scripts. See [Connecting machines](/docs/connecting-machines/) for the full setup and connection guide.
+In an interactive terminal, `machine add` discovers running sessions and lets you choose when several are running. No running sessions, or no Herdr installation, uses `default`; failed queries against an installed binary require an explicit choice. Add `--remote-session <name>` to skip discovery. Non-interactive commands use `default` unless that flag is supplied. `machine list` accepts optional `--json` for scripts. See [Connecting machines](/docs/connecting-machines/) for the full setup and connection guide.
`machine add` checks the remote installation's capabilities, installs or updates with approval only when needed, and starts the requested session's background server before saving. Compatible release versions do not need to match. Run setup in an interactive terminal when installation or restart approval is required; failed or cancelled setup does not save a profile. Replacing a running server requires explicit approval with a default answer of No, and stops its pane processes. `machine add` never enables experimental handoff implicitly.
diff --git a/docs/next/website/src/content/docs/connecting-machines.mdx b/docs/next/website/src/content/docs/connecting-machines.mdx
index 2ce18b06..47bc19a5 100644
--- a/docs/next/website/src/content/docs/connecting-machines.mdx
+++ b/docs/next/website/src/content/docs/connecting-machines.mdx
@@ -35,9 +35,11 @@ Run setup in an interactive terminal so Herdr can ask before installing or repla
herdr machine add workbox
```
-The sidebar shows the machine as `workbox`, the SSH host without any `user@` prefix. Add `--label "Build machine"` to choose a different name. This uses the remote default session. A machine profile targets one remote session; it does not combine every session on the host.
+In an interactive terminal, Herdr discovers running sessions: it selects the only running session, or lets you choose with Up/Down and Enter when several are running. Esc or Ctrl+C cancels. If a successful query finds no running sessions, setup uses `default`. If no Herdr installation is found, setup offers to install it for `default`. If an installed binary cannot report its sessions, setup reports the error; pass `--remote-session <name>` to select a session explicitly.
-To use a named session instead, add the optional `--remote-session` flag:
+The sidebar shows the default session as `workbox`, the SSH host without any `user@` prefix. Add `--label "Build machine"` to choose a different name. A machine profile targets one remote session; it does not combine every session on the host. Non-interactive commands use `default` unless `--remote-session` is supplied.
+
+To skip discovery and use a specific session, add `--remote-session`:
```bash
herdr machine add workbox --remote-session agents
@@ -45,6 +47,8 @@ herdr machine add workbox --remote-session agents
Without `--label`, this machine shows as `workbox/agents`. If the default name is already taken, `machine add` asks you to pass `--label`.
+On Windows servers, setup also checks executables used by Herdr processes owned by the SSH user. It validates their capabilities before reuse, so a compatible running build can be used even when SSH's `PATH` points to an older installation.
+
Herdr checks both the installed binary and the running server. It starts the requested background server before saving the profile. Compatible client and server versions do not have to match. Missing or incompatible installations go through an approval-based setup. When the running server needs replacement, setup asks before stopping it and its pane processes, then starts the compatible server. The default answer is No. If installation and replacement are both needed, one confirmation covers them. `machine add` does not use experimental live handoff. Cancelling or failing setup leaves the profile unsaved.
Run `herdr` to open the UI. If a local client is already open, added and enabled machines normally appear within a second and connect in the background without changing your selection. An in-progress machine switch finishes before profile changes are applied. The remote server keeps running after setup exits.
diff --git a/src/cli/machine.rs b/src/cli/machine.rs
index 52828f75..2459805a 100644
--- a/src/cli/machine.rs
+++ b/src/cli/machine.rs
@@ -1,3 +1,8 @@
+use std::io::IsTerminal as _;
+
+use crossterm::event::{Event, KeyCode, KeyEventKind, KeyModifiers};
+use crossterm::style::{Attribute, SetAttribute};
+use crossterm::{cursor, execute, terminal};
use serde::Serialize;
use crate::client::endpoint::{EndpointCatalog, ProfileId, MAX_LABEL_BYTES};
@@ -206,7 +211,7 @@ fn reconnect(args: &[String]) -> std::io::Result<i32> {
struct AddArgs {
target: String,
label: Option<String>,
- session: String,
+ session: Option<String>,
}
fn parse_add_args(args: &[String]) -> Result<AddArgs, String> {
@@ -249,7 +254,6 @@ fn parse_add_args(args: &[String]) -> Result<AddArgs, String> {
"usage: herdr machine add <ssh-target> [--label <label>] [--remote-session <name>]"
.to_owned()
})?;
- let session = session.unwrap_or_else(|| crate::session::DEFAULT_SESSION_NAME.to_owned());
Ok(AddArgs {
target,
label,
@@ -304,6 +308,27 @@ fn add(args: &[String]) -> std::io::Result<i32> {
return Ok(2);
}
};
+ let mut setup = None;
+ let session =
+ if session.is_none() && std::io::stdin().is_terminal() && std::io::stderr().is_terminal() {
+ let discovered = (|| {
+ let connection = crate::remote::SavedSshSetup::connect(&target)?;
+ let sessions = connection.running_sessions()?;
+ let session = select_remote_session(&sessions, &target)?;
+ setup = Some(connection);
+ Ok::<_, std::io::Error>(session)
+ })();
+ match discovered {
+ Ok(session) => session,
+ Err(error) => {
+ eprintln!("error: {error}; machine was not saved");
+ crate::remote::print_saved_ssh_error_hint(&error, &target);
+ return Ok(1);
+ }
+ }
+ } else {
+ session.unwrap_or_else(|| crate::session::DEFAULT_SESSION_NAME.to_owned())
+ };
let label_is_default = label.is_none();
let label = label.unwrap_or_else(|| default_label(&target, &session));
let mut catalog = load_catalog()?;
@@ -320,7 +345,11 @@ fn add(args: &[String]) -> std::io::Result<i32> {
return Ok(2);
}
}
- let metadata = match crate::remote::prepare_saved_ssh(&target, &session) {
+ let metadata = match setup
+ .map(Ok)
+ .unwrap_or_else(|| crate::remote::SavedSshSetup::connect(&target))
+ .and_then(|setup| setup.prepare(&session))
+ {
Ok(metadata) => metadata,
Err(error) => {
eprintln!("error: {error}; machine was not saved");
@@ -361,6 +390,83 @@ fn add(args: &[String]) -> std::io::Result<i32> {
Ok(0)
}
+fn select_remote_session(sessions: &[String], target: &str) -> std::io::Result<String> {
+ match sessions {
+ [] => return Ok(crate::session::DEFAULT_SESSION_NAME.to_owned()),
+ [session] => return Ok(session.clone()),
+ _ => {}
+ }
+
+ let _raw_mode = RawModeGuard::enable()?;
+ let mut output = std::io::stderr();
+ let mut selected = 0;
+ render_remote_session_picker(&mut output, target, sessions, selected, false)?;
+ loop {
+ let Event::Key(key) = crossterm::event::read()? else {
+ continue;
+ };
+ if !matches!(key.kind, KeyEventKind::Press | KeyEventKind::Repeat) {
+ continue;
+ }
+ match key.code {
+ KeyCode::Up => selected = selected.checked_sub(1).unwrap_or(sessions.len() - 1),
+ KeyCode::Down => selected = (selected + 1) % sessions.len(),
+ KeyCode::Enter => return Ok(sessions[selected].clone()),
+ KeyCode::Esc => break,
+ KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => break,
+ _ => continue,
+ }
+ render_remote_session_picker(&mut output, target, sessions, selected, true)?;
+ }
+ Err(std::io::Error::new(
+ std::io::ErrorKind::Interrupted,
+ "remote session selection cancelled",
+ ))
+}
+
+struct RawModeGuard;
+
+impl RawModeGuard {
+ fn enable() -> std::io::Result<Self> {
+ terminal::enable_raw_mode()?;
+ Ok(Self)
+ }
+}
+
+impl Drop for RawModeGuard {
+ fn drop(&mut self) {
+ let _ = terminal::disable_raw_mode();
+ }
+}
+
+fn render_remote_session_picker(
+ output: &mut impl std::io::Write,
+ target: &str,
+ sessions: &[String],
+ selected: usize,
+ redraw: bool,
+) -> std::io::Result<()> {
+ if redraw {
+ execute!(output, cursor::MoveUp((sessions.len() + 2) as u16))?;
+ }
+ execute!(output, terminal::Clear(terminal::ClearType::CurrentLine))?;
+ write!(output, "Running sessions on {target}:\r\n")?;
+ for (index, session) in sessions.iter().enumerate() {
+ execute!(output, terminal::Clear(terminal::ClearType::CurrentLine))?;
+ if index == selected {
+ execute!(output, SetAttribute(Attribute::Bold))?;
+ write!(output, "> {session}")?;
+ execute!(output, SetAttribute(Attribute::Reset))?;
+ write!(output, "\r\n")?;
+ } else {
+ write!(output, " {session}\r\n")?;
+ }
+ }
+ execute!(output, terminal::Clear(terminal::ClearType::CurrentLine))?;
+ write!(output, "↑/↓ select · Enter confirm · Esc cancel\r\n")?;
+ output.flush()
+}
+
fn rename(args: &[String]) -> std::io::Result<i32> {
let args = super::expand_equals_args(args, &["--label"]);
let [raw_id, flag, label] = args.as_slice() else {
@@ -480,8 +586,8 @@ mod tests {
#[test]
fn add_parser_preserves_values_across_argument_orders() {
for (args, session) in [
- (vec!["--label", "coder", "workstation.coder"], "default"),
- (vec!["workstation.coder", "--label", "coder"], "default"),
+ (vec!["--label", "coder", "workstation.coder"], None),
+ (vec!["workstation.coder", "--label", "coder"], None),
(
vec![
"--remote-session",
@@ -490,7 +596,7 @@ mod tests {
"--label",
"coder",
],
- "agents",
+ Some("agents"),
),
(
vec![
@@ -498,7 +604,7 @@ mod tests {
"--remote-session=agents",
"workstation.coder",
],
- "agents",
+ Some("agents"),
),
] {
let args = args.into_iter().map(str::to_owned).collect::<Vec<_>>();
@@ -507,7 +613,7 @@ mod tests {
AddArgs {
target: "workstation.coder".into(),
label: Some("coder".into()),
- session: session.into(),
+ session: session.map(str::to_owned),
},
"{args:?}"
);
@@ -518,7 +624,7 @@ mod tests {
fn add_parser_leaves_label_unset_without_flag() {
let parsed = parse_add_args(&["workstation.coder".to_owned()]).unwrap();
assert_eq!(parsed.label, None);
- assert_eq!(parsed.session, "default");
+ assert_eq!(parsed.session, None);
}
#[test]
diff --git a/src/cli/spec/machine.rs b/src/cli/spec/machine.rs
index 19a34088..dc4eefa5 100644
--- a/src/cli/spec/machine.rs
+++ b/src/cli/spec/machine.rs
@@ -36,7 +36,7 @@ pub(super) fn command() -> Command {
)
.arg(
option("remote-session", "NAME")
- .help("Set the explicit Herdr session on the remote machine"),
+ .help("Select a session explicitly (default without an interactive terminal)"),
),
)
.subcommand(
diff --git a/src/remote/attach.rs b/src/remote/attach.rs
index a4a00dea..82caeb9a 100644
--- a/src/remote/attach.rs
+++ b/src/remote/attach.rs
@@ -119,72 +119,145 @@ pub(crate) fn check_saved_ssh(target: &str, session: &str) -> io::Result<()> {
}
}
-pub(crate) fn prepare_saved_ssh(
- target: &str,
- session_name: &str,
-) -> io::Result<Option<crate::client::endpoint::SshMachineMetadata>> {
- 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,
- )?;
+pub(crate) struct SavedSshSetup {
+ ssh: RemoteSsh,
+ remote_herdr: RemoteHerdr,
+ candidates: Vec<RemoteHerdr>,
+}
+
+impl SavedSshSetup {
+ pub(crate) fn connect(target: &str) -> io::Result<Self> {
+ super::validate_remote_target(target)
+ .map_err(|error| io::Error::new(io::ErrorKind::InvalidInput, error))?;
+ let manage = crate::config::Config::load()
+ .config
+ .remote
+ .manage_ssh_config;
+ let ssh = RemoteSsh::new(
+ target.to_owned(),
+ manage,
+ crate::session::DEFAULT_SESSION_NAME.to_owned(),
+ );
+ let remote_herdr = RemoteHerdr::for_platform(detect_remote_platform(&ssh)?);
+ let candidates = remote_binary_candidates(&ssh, &remote_herdr)?;
+ Ok(Self {
+ ssh,
+ remote_herdr,
+ candidates,
+ })
+ }
- // 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));
+ pub(crate) fn running_sessions(&self) -> io::Result<Vec<String>> {
+ // A fresh host must still reach the normal installation approval.
+ if self.candidates.is_empty() {
+ return Ok(Vec::new());
+ }
+ let mut failure = String::new();
+ for candidate in &self.candidates {
+ let output = self.ssh.shell_output(
+ &candidate.platform,
+ &candidate.executable.command(&["session", "list", "--json"]),
+ )?;
+ if output.status.code() == Some(255) {
+ return Err(command_failed("remote SSH connection failed", &output));
+ }
+ if !output.status.success() {
+ failure = command_failed("remote session query failed", &output).to_string();
+ continue;
+ }
+ match serde_json::from_slice::<RemoteSessionListJson>(&output.stdout) {
+ Ok(list) => {
+ return Ok(list
+ .sessions
+ .into_iter()
+ .filter(|session| {
+ session.running && crate::session::validate_name(&session.name).is_ok()
+ })
+ .map(|session| session.name)
+ .collect())
+ }
+ Err(error) => failure = format!("invalid remote session list: {error}"),
+ }
+ }
+ Err(io::Error::other(format!(
+ "could not discover remote Herdr sessions: {failure}; specify --remote-session to continue"
+ )))
}
- 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,
+
+ pub(crate) fn prepare(
+ mut self,
+ session_name: &str,
+ ) -> io::Result<Option<crate::client::endpoint::SshMachineMetadata>> {
+ crate::session::validate_name(session_name)
+ .map_err(|error| io::Error::new(io::ErrorKind::InvalidInput, error))?;
+ self.ssh.session_name = session_name.to_owned();
+ let Self {
+ ssh,
+ remote_herdr,
+ candidates,
+ } = self;
+ let prepared =
+ prepare_discovered_remote_herdr(&ssh, remote_herdr, &candidates, false, true)?;
+ ensure_remote_server_ready(
+ &ssh,
+ &prepared.remote_herdr,
+ prepared.stop_after_install_approved,
+ false,
true,
- surface_interest,
- health_check,
- )
- .is_none() =>
- {
- Ok(prepared.remote_herdr.machine_metadata().or_else(|| {
- discover_remote_api_metadata(&ssh, session_name)
- .inspect_err(
- |error| tracing::debug!(%error, "could not capture SSH setup metadata"),
- )
- .ok()
- }))
+ )?;
+
+ // 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(prepared.remote_herdr.machine_metadata().or_else(|| {
+ discover_remote_api_metadata(&ssh, session_name)
+ .inspect_err(
+ |error| tracing::debug!(%error, "could not capture SSH setup metadata"),
+ )
+ .ok()
+ }))
+ }
+ _ => Err(io::Error::other(
+ "remote server is not ready for saved machines",
+ )),
}
- _ => Err(io::Error::other(
- "remote server is not ready for saved machines",
- )),
}
}
+#[derive(Deserialize)]
+struct RemoteSessionListJson {
+ sessions: Vec<RemoteSessionJson>,
+}
+
+#[derive(Deserialize)]
+struct RemoteSessionJson {
+ name: String,
+ running: bool,
+}
+
#[derive(Debug, Clone, PartialEq, Eq)]
struct RemotePlatform {
os: &'static str,
@@ -1201,19 +1274,36 @@ pub(super) fn prepare_remote_herdr(
) -> io::Result<PreparedRemoteHerdr> {
let platform = detect_remote_platform(ssh)?;
let remote_herdr = RemoteHerdr::for_platform(platform);
+ let candidates = remote_binary_candidates(ssh, &remote_herdr)?;
+ prepare_discovered_remote_herdr(
+ ssh,
+ remote_herdr,
+ &candidates,
+ live_handoff_enabled,
+ require_surface_interest,
+ )
+}
+
+fn prepare_discovered_remote_herdr(
+ ssh: &RemoteSsh,
+ remote_herdr: RemoteHerdr,
+ candidates: &[RemoteHerdr],
+ live_handoff_enabled: bool,
+ require_surface_interest: bool,
+) -> io::Result<PreparedRemoteHerdr> {
if remote_herdr.platform.is_windows() {
return prepare_windows_remote_herdr(
ssh,
remote_herdr,
+ candidates,
live_handoff_enabled,
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 {
+ for candidate in candidates {
if remote_binary_supports_endpoint_requirement(ssh, candidate, require_surface_interest)
.unwrap_or(false)
{
@@ -1236,7 +1326,7 @@ pub(super) fn prepare_remote_herdr(
}
let mut stop_after_install_approved = false;
- if let Some(status_probe_herdr) = remote_binary_candidates.first().or_else(|| {
+ if let Some(status_probe_herdr) = candidates.first().or_else(|| {
remote_binary_exists(ssh, &remote_herdr)
.ok()
.and_then(|exists| exists.then_some(&remote_herdr))
@@ -1299,14 +1389,14 @@ pub(super) fn find_installed_remote_herdr(ssh: &RemoteSsh) -> io::Result<RemoteH
fn prepare_windows_remote_herdr(
ssh: &RemoteSsh,
remote_herdr: RemoteHerdr,
+ candidates: &[RemoteHerdr],
live_handoff_enabled: bool,
require_surface_interest: bool,
) -> io::Result<PreparedRemoteHerdr> {
let override_package = remote_binary_override_path()?;
let custom_package = override_package.is_some();
- let candidates = remote_binary_candidates(ssh, &remote_herdr)?;
if !custom_package {
- for candidate in &candidates {
+ for candidate in candidates {
if remote_binary_supports_endpoint_requirement(
ssh,
candidate,
@@ -1524,7 +1614,7 @@ fn remote_binary_candidates(
fn windows_remote_binary_candidate_command() -> String {
windows_powershell_script_command(&format!(
- r#"function Emit-HerdrPath([string]$CandidatePath) {{ if ([string]::IsNullOrWhiteSpace($CandidatePath) -or -not (Test-Path -LiteralPath $CandidatePath -PathType Leaf)) {{ return }}; $candidateFullPath = [System.IO.Path]::GetFullPath($CandidatePath); $encodedCandidate = [System.Convert]::ToBase64String([System.Text.Encoding]::UTF8.GetBytes($candidateFullPath)); [Console]::Out.WriteLine('{WINDOWS_REMOTE_PATH_MARKER}' + $encodedCandidate) }}; $pathCommand = Get-Command herdr.exe -CommandType Application -ErrorAction SilentlyContinue | Select-Object -First 1; if ($null -ne $pathCommand) {{ Emit-HerdrPath $pathCommand.Source }}; $herdrHome = if ([string]::IsNullOrWhiteSpace($env:HERDR_HOME)) {{ Join-Path $env:USERPROFILE '.herdr' }} else {{ $env:HERDR_HOME }}; $activeJunction = Get-Item -LiteralPath (Join-Path $herdrHome 'packages\standalone\current') -Force -ErrorAction SilentlyContinue; if ($null -ne $activeJunction -and -not [string]::IsNullOrWhiteSpace([string]$activeJunction.Target)) {{ Emit-HerdrPath (Join-Path ([string]$activeJunction.Target) 'herdr.exe') }}; exit 0"#
+ r#"function Emit-HerdrPath([string]$CandidatePath) {{ if ([string]::IsNullOrWhiteSpace($CandidatePath) -or -not (Test-Path -LiteralPath $CandidatePath -PathType Leaf)) {{ return }}; $candidateFullPath = [System.IO.Path]::GetFullPath($CandidatePath); $encodedCandidate = [System.Convert]::ToBase64String([System.Text.Encoding]::UTF8.GetBytes($candidateFullPath)); [Console]::Out.WriteLine('{WINDOWS_REMOTE_PATH_MARKER}' + $encodedCandidate) }}; $pathCommand = Get-Command herdr.exe -CommandType Application -ErrorAction SilentlyContinue | Select-Object -First 1; if ($null -ne $pathCommand) {{ Emit-HerdrPath $pathCommand.Source }}; $userSid = [System.Security.Principal.WindowsIdentity]::GetCurrent().User.Value; Get-CimInstance Win32_Process -Filter "Name = 'herdr.exe' OR Name = 'herdr-dev.exe'" -ErrorAction SilentlyContinue | ForEach-Object {{ $owner = Invoke-CimMethod -InputObject $_ -MethodName GetOwnerSid -ErrorAction SilentlyContinue; if ($owner.ReturnValue -eq 0 -and $owner.Sid -eq $userSid) {{ Emit-HerdrPath $_.ExecutablePath }} }}; $herdrHome = if ([string]::IsNullOrWhiteSpace($env:HERDR_HOME)) {{ Join-Path $env:USERPROFILE '.herdr' }} else {{ $env:HERDR_HOME }}; $activeJunction = Get-Item -LiteralPath (Join-Path $herdrHome 'packages\standalone\current') -Force -ErrorAction SilentlyContinue; if ($null -ne $activeJunction -and -not [string]::IsNullOrWhiteSpace([string]$activeJunction.Target)) {{ Emit-HerdrPath (Join-Path ([string]$activeJunction.Target) 'herdr.exe') }}; exit 0"#
))
}
@@ -4614,6 +4704,55 @@ mod tests {
assert!(command.contains("$activeJunction.Target"));
}
+ #[cfg(windows)]
+ #[test]
+ fn windows_process_candidates_require_a_verified_current_user_owner() {
+ // Supply ownership results without needing another Windows account.
+ // Execute the real discovery script, including its path validation.
+ let fixture = r#"
+function Get-Command {}
+function Get-Item {}
+function Get-CimInstance {
+ $sid = [System.Security.Principal.WindowsIdentity]::GetCurrent().User.Value
+ [pscustomobject]@{ ExecutablePath=$env:HERDR_TEST_EXE; OwnerSid=$sid; OwnerResult=0 }
+ [pscustomobject]@{ ExecutablePath="$env:SystemRoot\System32\cmd.exe"; OwnerSid='S-1-0-0'; OwnerResult=0 }
+ [pscustomobject]@{ ExecutablePath="$PSHOME\powershell.exe"; OwnerSid=$sid; OwnerResult=2 }
+}
+function Invoke-CimMethod {
+ param($InputObject)
+ [pscustomobject]@{ ReturnValue=$InputObject.OwnerResult; Sid=$InputObject.OwnerSid }
+}
+function Get-Process {
+ Get-CimInstance | ForEach-Object { [pscustomobject]@{ Path=$_.ExecutablePath } }
+}
+"#;
+ let executable = std::env::current_exe().unwrap();
+ let script = format!(
+ "{fixture}\n{}",
+ decode_windows_command(&windows_remote_binary_candidate_command())
+ );
+ let encoded = windows_powershell_script_command(&script);
+ let mut command = Command::new("powershell.exe");
+ command
+ .args(["-NoProfile", "-NonInteractive", "-EncodedCommand"])
+ .arg(encoded.split_whitespace().last().unwrap())
+ .env("HERDR_TEST_EXE", &executable);
+ crate::platform::configure_background_command(&mut command);
+ let output = command.output().unwrap();
+ assert!(
+ output.status.success(),
+ "{}",
+ String::from_utf8_lossy(&output.stderr)
+ );
+ let paths = String::from_utf8(output.stdout)
+ .unwrap()
+ .lines()
+ .filter_map(|line| line.strip_prefix(WINDOWS_REMOTE_PATH_MARKER))
+ .map(|path| decode_windows_remote_path(path).unwrap())
+ .collect::<Vec<_>>();
+ assert_eq!(paths, [executable.to_string_lossy().into_owned()]);
+ }
+
#[test]
fn remote_output_framing_discards_any_banner_and_preserves_binary() {
let payload = [0, 1, 2, 0xff, b'\n'];
diff --git a/tests/machine_setup.rs b/tests/machine_setup.rs
index 3b577f4e..649ba7c0 100644
--- a/tests/machine_setup.rs
+++ b/tests/machine_setup.rs
@@ -21,7 +21,7 @@ if [ "$FAKE_STRICT_HOST_KEY_FAILURE" = yes ] && [ "$strict_host_key_check" = yes
exit 255
fi
if [ "$last" = 'command -v herdr' ]; then
- echo /home/remote/.local/bin/herdr
+ if [ "$FAKE_INSTALLED" != missing ]; then echo /home/remote/.local/bin/herdr; fi
exit 0
fi
case "$last" in
@@ -33,8 +33,10 @@ case "$last" in
esac
printf '\n%s\n' 'herdr-remote-output-ready:1'
case "$script" in
- *'uname -s'*) uname -s; uname -m ;;
- *'version='*) echo /home/remote/.local/bin/herdr ;;
+ *'uname -s'*) echo platform >>"$FAKE_ROOT/probes"; uname -s; uname -m ;;
+ *'version='*)
+ echo candidates >>"$FAKE_ROOT/probes"
+ if [ "$FAKE_INSTALLED" != missing ]; then echo /home/remote/.local/bin/herdr; fi ;;
*'status client --json'*)
if [ "$FAKE_INSTALLED" = new ] || [ -f "$FAKE_ROOT/installed" ]; then
printf '%s\n' "$FAKE_CLIENT_STATUS"
@@ -51,10 +53,17 @@ case "$script" in
fi ;;
*'server live-handoff'*) echo handoff >>"$FAKE_ROOT/actions"; exit 1 ;;
*'server stop'*) echo stop >>"$FAKE_ROOT/actions"; touch "$FAKE_ROOT/stopped" ;;
- *'remote-client-bridge'*) echo start >>"$FAKE_ROOT/actions"; echo 'test startup failure' >&2; exit 1 ;;
+ *'session list --json'*)
+ echo sessions >>"$FAKE_ROOT/probes"
+ if [ "$FAKE_SESSIONS" = failed ]; then echo 'session query denied' >&2; exit 1; fi
+ printf '%s\n' "$FAKE_SESSIONS" ;;
+ *'remote-client-bridge'*)
+ printf '%s\n' "$script" >"$FAKE_ROOT/start-command"
+ echo start >>"$FAKE_ROOT/actions"; echo 'test startup failure' >&2; exit 1 ;;
*'mkdir -p'*) printf '/fake/tmp\000/fake/herdr\000' ;;
*'chmod 755'*) echo install >>"$FAKE_ROOT/actions"; touch "$FAKE_ROOT/installed" ;;
- *'command -v herdr'*) echo /home/remote/.local/bin/herdr ;;
+ *'command -v herdr'*) if [ "$FAKE_INSTALLED" != missing ]; then echo /home/remote/.local/bin/herdr; fi ;;
+ *'test -x '*) exit 1 ;;
*) echo "unexpected fake SSH script: $script" >&2; exit 1 ;;
esac
"#;
@@ -64,6 +73,16 @@ struct SetupResult {
actions: String,
prompts: usize,
success: bool,
+ probes: String,
+ start_command: String,
+}
+
+#[derive(Default)]
+struct SetupOptions<'a> {
+ sessions: Option<&'a str>,
+ selection: &'a [u8],
+ explicit_session: Option<&'a str>,
+ noninteractive: bool,
}
fn setup(installed: &str, answer: &str, handoff: bool) -> SetupResult {
@@ -76,13 +95,33 @@ fn setup_with_strict_host_key_failure(
handoff: bool,
strict_host_key_failure: bool,
) -> SetupResult {
+ setup_options(
+ installed,
+ answer,
+ handoff,
+ strict_host_key_failure,
+ SetupOptions::default(),
+ )
+}
+
+fn setup_options(
+ installed: &str,
+ answer: &str,
+ handoff: bool,
+ strict_host_key_failure: bool,
+ options: SetupOptions<'_>,
+) -> SetupResult {
let root = std::path::PathBuf::from(format!(
- "/var/tmp/herdr-machine-setup-{}-{}-{}-{}-{}",
+ "/var/tmp/herdr-machine-setup-{}-{}-{}-{}-{}-{}",
std::process::id(),
installed,
answer.trim().is_empty(),
handoff,
- strict_host_key_failure
+ strict_host_key_failure,
+ SystemTime::now()
+ .duration_since(UNIX_EPOCH)
+ .unwrap()
+ .as_nanos()
));
let app = if cfg!(debug_assertions) {
"herdr-dev"
@@ -110,6 +149,9 @@ fn setup_with_strict_host_key_failure(
command.args(["--remote", "fake-host", "--handoff"]);
} else {
command.args(["machine", "add", "fake-host", "--label", "VPS"]);
+ if let Some(session) = options.explicit_session {
+ command.args(["--remote-session", session]);
+ }
}
command.env(
"PATH",
@@ -122,6 +164,12 @@ fn setup_with_strict_host_key_failure(
command.env("FAKE_ROOT", &root);
command.env("FAKE_INSTALLED", installed);
command.env(
+ "FAKE_SESSIONS",
+ options
+ .sessions
+ .unwrap_or(r#"{"sessions":[{"name":"default","running":true}]}"#),
+ );
+ command.env(
"FAKE_STRICT_HOST_KEY_FAILURE",
if strict_host_key_failure { "yes" } else { "no" },
);
@@ -139,6 +187,18 @@ fn setup_with_strict_host_key_failure(
] {
command.env_remove(name);
}
+ if options.noninteractive {
+ // A redirected stdin makes machine add non-interactive while retaining
+ // the same isolated SSH fixture and captured terminal diagnostics.
+ let mut shell = CommandBuilder::new("/bin/sh");
+ shell.args(["-c", "exec \"$@\" </dev/null", "sh"]);
+ shell.args(command.get_argv());
+ shell.env_clear();
+ for (key, value) in command.iter_full_env_as_str() {
+ shell.env(key, value);
+ }
+ command = shell;
+ }
let mut child = pair.slave.spawn_command(command).unwrap();
drop(pair.slave);
let mut reader = pair.master.try_clone_reader().unwrap();
@@ -155,10 +215,16 @@ fn setup_with_strict_host_key_failure(
let mut output = String::new();
let mut prompts = 0;
let mut timed_out = false;
+ let mut picked = false;
loop {
match rx.recv_timeout(Duration::from_secs(20)) {
Ok(bytes) => {
output.push_str(&String::from_utf8_lossy(&bytes));
+ if !picked && output.contains("Esc cancel") {
+ writer.write_all(options.selection).unwrap();
+ writer.flush().unwrap();
+ picked = true;
+ }
let count = output.matches("[y/N]").count() + output.matches("[Y/n]").count();
if count > prompts {
writer
@@ -185,6 +251,8 @@ fn setup_with_strict_host_key_failure(
drop(pair.master);
reading.join().unwrap();
let actions = fs::read_to_string(root.join("actions")).unwrap_or_default();
+ let probes = fs::read_to_string(root.join("probes")).unwrap_or_default();
+ let start_command = fs::read_to_string(root.join("start-command")).unwrap_or_default();
// Every scenario either cancels or reaches the intentionally failed startup.
let saved = root
.join("state")
@@ -202,6 +270,8 @@ fn setup_with_strict_host_key_failure(
actions,
prompts,
success: status.success(),
+ probes,
+ start_command,
}
}
@@ -259,6 +329,155 @@ fn machine_add_accepts_help_argument_order() {
}
#[test]
+fn machine_add_fresh_host_offers_installation_and_cancellation_saves_nothing() {
+ let result = setup("missing", "n\n", false);
+ assert_eq!(result.probes, "platform\ncandidates\n", "{}", result.output);
+ assert_eq!(result.prompts, 1, "{}", result.output);
+ assert!(
+ result.output.contains("installation cancelled"),
+ "{}",
+ result.output
+ );
+ assert!(result.actions.is_empty(), "{}", result.output);
+}
+
+#[test]
+fn machine_add_discovers_sessions_once_and_prepares_the_selected_session() {
+ for (sessions, selection, expected) in [
+ (r#"{"sessions":[]}"#, &b""[..], "default"),
+ (
+ r#"{"extra":1,"sessions":[{"name":"stopped","running":false},{"name":"bad/name","running":true},{"name":"agents","running":true,"future":true}]}"#,
+ &b""[..],
+ "agents",
+ ),
+ (
+ r#"{"sessions":[{"name":"default","running":true},{"name":"agents","running":true}]}"#,
+ &b"\x1b[B\r"[..],
+ "agents",
+ ),
+ ] {
+ let result = setup_options(
+ "new",
+ "y\n",
+ false,
+ false,
+ SetupOptions {
+ sessions: Some(sessions),
+ selection,
+ ..Default::default()
+ },
+ );
+ assert_eq!(
+ result.probes, "platform\ncandidates\nsessions\n",
+ "{}",
+ result.output
+ );
+ assert_eq!(result.actions, "stop\nstart\n", "{}", result.output);
+ if expected == "default" {
+ assert!(
+ !result.start_command.contains("--session"),
+ "{}",
+ result.start_command
+ );
+ } else {
+ assert!(
+ result.start_command.contains("--session agents"),
+ "{}",
+ result.start_command
+ );
+ }
+ if !selection.is_empty() {
+ let picker = result
+ .output
+ .split("Running sessions on fake-host:")
+ .nth(1)
+ .unwrap();
+ assert!(
+ picker.starts_with("\r\n"),
+ "picker did not return to column zero: {picker:?}"
+ );
+ assert!(picker.contains("default\u{1b}[0m\r\n"), "{picker:?}");
+ assert!(picker.contains(" agents\r\n"), "{picker:?}");
+ }
+ }
+}
+
+#[test]
+fn machine_add_failed_discovery_and_cancel_never_prepare_or_save() {
+ for (sessions, selection, expected) in [
+ ("failed", &b""[..], "session query denied"),
+ ("not json", &b""[..], "--remote-session"),
+ (
+ r#"{"sessions":[{"name":"agents"}]}"#,
+ &b""[..],
+ "--remote-session",
+ ),
+ (
+ r#"{"sessions":[{"name":"default","running":true},{"name":"agents","running":true}]}"#,
+ &b"\x1b"[..],
+ "selection cancelled",
+ ),
+ ] {
+ let result = setup_options(
+ "new",
+ "y\n",
+ false,
+ false,
+ SetupOptions {
+ sessions: Some(sessions),
+ selection,
+ ..Default::default()
+ },
+ );
+ assert!(!result.success, "{}", result.output);
+ assert!(result.actions.is_empty(), "{}", result.output);
+ assert!(result.output.contains(expected), "{}", result.output);
+ assert!(
+ result.output.contains("machine was not saved"),
+ "{}",
+ result.output
+ );
+ }
+}
+
+#[test]
+fn machine_add_skips_discovery_for_scripts_and_explicit_sessions() {
+ for (noninteractive, explicit_session) in [
+ (true, None),
+ (true, Some("agents")),
+ (false, Some("agents")),
+ ] {
+ let result = setup_options(
+ "new",
+ "y\n",
+ false,
+ false,
+ SetupOptions {
+ noninteractive,
+ explicit_session,
+ sessions: Some("failed"),
+ ..Default::default()
+ },
+ );
+ assert_eq!(result.probes, "platform\ncandidates\n", "{}", result.output);
+ assert!(
+ result.output.contains(if explicit_session.is_some() {
+ "session agents"
+ } else {
+ "session default"
+ }),
+ "{}",
+ result.output
+ );
+ if noninteractive {
+ assert!(result.actions.is_empty(), "{}", result.output);
+ } else {
+ assert_eq!(result.actions, "stop\nstart\n", "{}", result.output);
+ }
+ }
+}
+
+#[test]
fn machine_add_reports_strict_host_key_failure() {
let result = setup_with_strict_host_key_failure("new", "", false, true);
assert!(!result.success, "{}", result.output);
diff --git a/tests/remote_attach.rs b/tests/remote_attach.rs
index b99e4335..2af61c77 100644
--- a/tests/remote_attach.rs
+++ b/tests/remote_attach.rs
@@ -1,7 +1,7 @@
#![cfg(unix)]
use std::fs;
-use std::io::{BufRead, BufReader};
+use std::io::Read;
use std::os::unix::fs::PermissionsExt;
use std::os::unix::process::CommandExt;
use std::path::{Path, PathBuf};
@@ -77,7 +77,7 @@ fn check_authentication_output(framed_shell: bool) {
r#"#!/bin/sh
authenticate() {{
: > "$FAKE_SSH_STARTED"
- printf '%s\n%s\n' '{CHECK_NOTICE}' '{CHECK_URL}' >&2
+ printf '/bin/sh: expected probe noise\n%s\n%s' '{CHECK_NOTICE}' '{CHECK_URL}' >&2
while [ ! -e "$FAKE_SSH_APPROVED" ]; do
/bin/sleep 0.01
done
@@ -94,7 +94,7 @@ if [ "$FAKE_SSH_FRAMED" = 1 ] && [ ! -e "$FAKE_SSH_STARTED" ]; then
fi
/bin/cat >/dev/null
: > "$FAKE_SSH_ADVANCED"
-printf '%s\n' '{LATER_FAILURE}' >&2
+printf '\n%s\n' '{LATER_FAILURE}' >&2
exit 255
"#
),
@@ -128,22 +128,25 @@ exit 255
cleanup.child = Some(child);
let child = cleanup.child.as_mut().expect("registered child");
- let stderr = child.stderr.take().expect("remote attach stderr");
+ let mut stderr = child.stderr.take().expect("remote attach stderr");
let (line_tx, line_rx) = mpsc::channel();
cleanup.reader = Some(thread::spawn(move || {
- for line in BufReader::new(stderr).lines() {
- if line_tx
- .send(line.expect("read remote attach stderr"))
- .is_err()
- {
+ let mut buffer = [0; 4096];
+ while let Ok(len) = stderr.read(&mut buffer) {
+ if len == 0 || line_tx.send(buffer[..len].to_vec()).is_err() {
break;
}
}
}));
wait_for_file(&started_path, Duration::from_secs(2));
- let notice = line_rx.recv_timeout(Duration::from_secs(2));
- let url = line_rx.recv_timeout(Duration::from_secs(2));
+ let mut before_approval = Vec::new();
+ while !String::from_utf8_lossy(&before_approval).contains(CHECK_URL) {
+ match line_rx.recv_timeout(Duration::from_secs(2)) {
+ Ok(bytes) => before_approval.extend(bytes),
+ Err(_) => break,
+ }
+ }
fs::write(&approval_path, b"approved").expect("release fake ssh approval");
wait_for_file(&advanced_path, Duration::from_secs(2));
@@ -155,16 +158,21 @@ exit 255
.expect("registered stderr reader")
.join()
.expect("join stderr reader");
- let later_lines = line_rx.try_iter().collect::<Vec<_>>();
+ let later_bytes = line_rx.try_iter().flatten().collect::<Vec<_>>();
+ let later_lines = String::from_utf8_lossy(&later_bytes);
- assert_eq!(notice.as_deref(), Ok(CHECK_NOTICE));
- assert_eq!(url.as_deref(), Ok(CHECK_URL));
+ let before_approval = String::from_utf8_lossy(&before_approval);
+ assert!(before_approval.contains(CHECK_NOTICE), "{before_approval}");
+ assert!(
+ before_approval.contains(CHECK_URL),
+ "partial prompt was hidden: {before_approval}"
+ );
assert!(
- later_lines.iter().any(|line| line == LATER_FAILURE),
+ later_lines.contains(LATER_FAILURE),
"later SSH stderr should also be visible: {later_lines:?}"
);
assert!(
- later_lines.iter().any(|line| {
+ later_lines.lines().any(|line| {
line.contains("error: remote binary discovery failed") && line.contains(LATER_FAILURE)
}),
"failed SSH stderr should remain in the contextual error: {later_lines:?}"