aboutsummaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/cli/machine.rs124
-rw-r--r--src/cli/spec/machine.rs2
-rw-r--r--src/remote/attach.rs269
3 files changed, 320 insertions, 75 deletions
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'];