Files
galaxy/app/src/remote_server/ssh_transport.rs
T
rkw6086 7c106eecd5 feat: expand Galaxy agent and remote tooling
Add Wormhole remote helpers, provider and agent improvements, filesystem diagnostics, model metadata support, and schema-aware settings IntelliSense.
2026-08-23 13:55:47 -05:00

337 lines
13 KiB
Rust

//! SSH-specific implementation of [`RemoteTransport`].
//!
//! [`SshTransport`] uses an existing SSH ControlMaster socket to check/install
//! the remote server binary and to launch the `remote-server-proxy` process
//! whose stdin/stdout become the protocol channel.
use std::fmt;
use std::future::Future;
use std::path::{Path, PathBuf};
use std::pin::Pin;
use std::sync::Arc;
use anyhow::Result;
use remote_server::auth::RemoteServerAuthContext;
use remote_server::client::RemoteServerClient;
use remote_server::manager::RemoteServerExitStatus;
use remote_server::setup::{
parse_uname_output, remote_server_daemon_dir, PreinstallCheckResult, RemotePlatform,
};
use remote_server::ssh::ssh_args;
use remote_server::transport::{Connection, ControlPath, Error, InstallOutcome, RemoteTransport};
use warpui::r#async::executor;
#[path = "ssh_transport/installation.rs"]
pub(crate) mod installation;
/// SSH transport: connects via a ControlMaster socket.
///
/// `socket_path` is the local Unix socket created by the ControlMaster
/// process (`ssh -N -o ControlMaster=yes -o ControlPath=<path>`). All SSH
/// commands (binary check, install, proxy launch) are multiplexed through
/// this socket without re-authenticating.
#[derive(Clone)]
pub struct SshTransport {
socket_path: PathBuf,
auth_context: Arc<RemoteServerAuthContext>,
/// Whether Warp owns the ControlMaster behind `socket_path`. `false`
/// when the SSH wrapper attached to a master the user already had
/// running, in which case Warp must not run `ssh -O exit` against it
/// on teardown.
warp_owns_control_master: bool,
}
impl fmt::Debug for SshTransport {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("SshTransport")
.field("socket_path", &self.socket_path)
.field("warp_owns_control_master", &self.warp_owns_control_master)
.finish_non_exhaustive()
}
}
impl SshTransport {
pub fn new(
socket_path: PathBuf,
auth_context: Arc<RemoteServerAuthContext>,
warp_owns_control_master: bool,
) -> Self {
Self {
socket_path,
auth_context,
warp_owns_control_master,
}
}
pub fn socket_path(&self) -> &PathBuf {
&self.socket_path
}
pub fn warp_owns_control_master(&self) -> bool {
self.warp_owns_control_master
}
pub fn remote_daemon_socket_path(&self) -> String {
format!(
"{}/{}",
remote_server_daemon_dir(&self.auth_context.remote_server_identity_key()),
remote_server::setup::daemon_socket_name(),
)
}
pub fn remote_daemon_pid_path(&self) -> String {
format!(
"{}/{}",
remote_server_daemon_dir(&self.auth_context.remote_server_identity_key()),
remote_server::setup::daemon_pid_name(),
)
}
fn remote_proxy_command(&self) -> String {
let binary = remote_server::setup::remote_server_binary();
let identity_key = self.auth_context.remote_server_identity_key();
let quoted_identity_key = shell_words::quote(&identity_key);
format!("{binary} remote-server-proxy --identity-key {quoted_identity_key}")
}
}
/// Runs `uname -sm` on the remote host via the ControlMaster socket and
/// parses the output into a [`RemotePlatform`].
async fn detect_remote_platform(socket_path: &Path) -> Result<RemotePlatform, Error> {
let output = remote_server::ssh::run_ssh_command(
socket_path,
"uname -sm",
remote_server::setup::CHECK_TIMEOUT,
)
.await?;
if output.status.success() {
let stdout = String::from_utf8_lossy(&output.stdout);
parse_uname_output(&stdout)
} else {
let code = output.status.code().unwrap_or(-1);
let stderr = String::from_utf8_lossy(&output.stderr);
Err(Error::Other(anyhow::anyhow!(
"uname -sm exited with code {code}: {stderr}"
)))
}
}
impl RemoteTransport for SshTransport {
fn detect_platform(
&self,
) -> Pin<Box<dyn Future<Output = Result<RemotePlatform, Error>> + Send>> {
let socket_path = self.socket_path.clone();
Box::pin(async move { detect_remote_platform(&socket_path).await })
}
fn run_preinstall_check(
&self,
) -> Pin<Box<dyn Future<Output = Result<PreinstallCheckResult, Error>> + Send>> {
let socket_path = self.socket_path.clone();
Box::pin(async move {
match remote_server::ssh::run_ssh_script(
&socket_path,
remote_server::setup::PREINSTALL_CHECK_SCRIPT,
remote_server::setup::CHECK_TIMEOUT,
)
.await
{
Ok(output) if output.status.success() => {
let stdout = String::from_utf8_lossy(&output.stdout);
Ok(PreinstallCheckResult::parse(&stdout))
}
Ok(output) => {
let exit_code = output.status.code().unwrap_or(-1);
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
Err(Error::ScriptFailed { exit_code, stderr })
}
Err(e) => Err(e.into()),
}
})
}
fn check_binary(&self) -> Pin<Box<dyn Future<Output = Result<bool, Error>> + Send>> {
let socket_path = self.socket_path.clone();
Box::pin(async move {
let binary = remote_server::setup::remote_server_binary();
let expected_helper_version = if remote_server::setup::uses_static_linux_helper() {
let platform = detect_remote_platform(&socket_path).await?;
installation::local_helper_version(&platform).await
} else {
None
};
let cmd = match expected_helper_version {
Some(version) => format!(
"{binary} --version >/dev/null && test \"$(cat {binary}.wormhole-version 2>/dev/null)\" = \"{version}\""
),
None => remote_server::setup::binary_check_command(),
};
log::info!("Running binary check: {cmd}");
let output = remote_server::ssh::run_ssh_command(
&socket_path,
&cmd,
remote_server::setup::CHECK_TIMEOUT,
)
.await?;
// `<binary> --version` exits 0 when present, executable, and
// functional. Static helpers additionally compare their bundled
// build marker, where exit 1 means the remote copy is stale.
// Exit 127 means the binary was not found, and 126 means it exists
// but is not executable. Any other non-zero exit (e.g. SSH exit
// 255 for a dead connection, or signal termination) is treated as
// a transport-level failure.
let code = output.status.code();
let stdout = String::from_utf8_lossy(&output.stdout);
log::info!("Binary check result: exit={code:?} stdout={stdout}");
match code {
Some(0) => Ok(true),
Some(1) | Some(126) | Some(127) => Ok(false),
Some(code) => {
let stderr = String::from_utf8_lossy(&output.stderr);
Err(Error::Other(anyhow::anyhow!(
"binary check exited with code {code}: {stderr}"
)))
}
None => Err(Error::Other(anyhow::anyhow!(
"binary check terminated by signal"
))),
}
})
}
fn check_has_old_binary(&self) -> Pin<Box<dyn Future<Output = anyhow::Result<bool>> + Send>> {
let socket_path = self.socket_path.clone();
Box::pin(async move {
// Treat the existence of the remote-server install directory
// itself as evidence of a prior install. If `~/.warp-XX/remote-server`
// exists, something was installed there before, so any mismatch
// with the client's expected binary path should be auto-updated
// rather than surfaced as a first-time install prompt.
let cmd = format!("test -d {}", remote_server::setup::remote_server_dir());
let output = remote_server::ssh::run_ssh_command(
&socket_path,
&cmd,
remote_server::setup::CHECK_TIMEOUT,
)
.await?;
// `test -d` exits 0 when present, 1 when missing.
// Anything else is treated as a check failure.
match output.status.code() {
Some(0) => Ok(true),
Some(1) => Ok(false),
Some(code) => {
let stderr = String::from_utf8_lossy(&output.stderr);
Err(anyhow::anyhow!(
"remote-server dir check exited with code {code}: {stderr}"
))
}
None => Err(anyhow::anyhow!(
"remote-server dir check terminated by signal"
)),
}
})
}
fn install_binary(&self) -> Pin<Box<dyn Future<Output = InstallOutcome> + Send>> {
let socket_path = self.socket_path.clone();
Box::pin(async move { installation::install_binary(&socket_path).await })
}
fn connect(
&self,
executor: Arc<executor::Background>,
) -> Pin<Box<dyn Future<Output = Result<Connection>> + Send>> {
let socket_path = self.socket_path.clone();
let warp_owns_control_master = self.warp_owns_control_master;
let remote_proxy_command = self.remote_proxy_command();
Box::pin(async move {
let mut args = ssh_args(&socket_path);
args.push(remote_proxy_command);
// `kill_on_drop(true)` pairs with ownership of the `Child` being
// returned in the [`Connection`] below: the
// [`RemoteServerManager`] holds the `Child` on its per-session
// state, and dropping that state (on explicit teardown or
// spontaneous disconnect) sends SIGKILL to this ssh process.
let mut child = command::r#async::Command::new("ssh")
.args(&args)
.stdin(std::process::Stdio::piped())
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped())
.kill_on_drop(true)
.spawn()?;
let stdin = child
.stdin
.take()
.ok_or_else(|| anyhow::anyhow!("Failed to capture child stdin"))?;
let stdout = child
.stdout
.take()
.ok_or_else(|| anyhow::anyhow!("Failed to capture child stdout"))?;
let stderr = child
.stderr
.take()
.ok_or_else(|| anyhow::anyhow!("Failed to capture child stderr"))?;
let (client, event_rx, failure_rx, host_response_rx, stderr_tail) =
RemoteServerClient::from_child_streams(stdin, stdout, stderr, &executor);
Ok(Connection {
client,
event_rx,
failure_rx,
host_response_rx,
child,
// Tag the socket with master ownership. Teardown only runs
// `ssh -O exit` against Warp-managed masters; a user-owned
// (external) master must be left running when the Warp
// session exits.
control_path: if warp_owns_control_master {
ControlPath::WarpManaged(socket_path)
} else {
ControlPath::UserOwned(socket_path)
},
stderr_tail,
})
})
}
fn remove_remote_server_binary(
&self,
) -> Pin<Box<dyn Future<Output = anyhow::Result<()>> + Send>> {
let socket_path = self.socket_path.clone();
Box::pin(async move {
let cmd = remote_server::setup::remote_server_removal_command();
log::info!("Removing stale remote server binary: {cmd}");
let output = remote_server::ssh::run_ssh_command(
&socket_path,
&cmd,
remote_server::setup::CHECK_TIMEOUT,
)
.await?;
if output.status.success() {
Ok(())
} else {
let stderr = String::from_utf8_lossy(&output.stderr);
Err(anyhow::anyhow!("Failed to remove binary: {stderr}"))
}
})
}
/// SSH exit code 255 indicates a connection-level error (broken pipe,
/// connection reset, host unreachable) — the ControlMaster's TCP
/// connection is dead. A signal kill also suggests the transport was
/// torn down. In either case, reconnecting through the same
/// ControlMaster is futile.
fn is_reconnectable(&self, exit_status: Option<&RemoteServerExitStatus>) -> bool {
match exit_status {
Some(s) => s.code != Some(255) && !s.signal_killed,
// No exit status available — optimistically allow reconnect.
None => true,
}
}
}
#[cfg(test)]
#[path = "ssh_transport_tests.rs"]
mod tests;