mirror of
https://github.com/warpdotdev/warp.git
synced 2026-05-06 15:22:21 +08:00
APP-3805: Auto-update SSH remote-server binary on version skew (#9328)
## Description [APP-3805](https://linear.app/warpdotdev/issue/APP-3805/client-server-version-skew) — handle client/server version skew for the SSH remote-server binary. Previously the remote binary lived at a single unversioned path. When a client updated but the remote still had an older binary, the two could silently go out of sync. This PR does three things: 1. **Version the installed path.** The binary is now written to `{dir}/{binary_name}-{version}`, so any client-server drift becomes a path miss and triggers a reinstall. The unversioned path is kept for `cargo run` / `deploy_remote_server` dev loops. 2. **Check versions on handshake.** `initialize()` now compares `server_version` against the client's release tag. On mismatch, the manager deletes the stale binary, tears the session down, and emits `SessionConnectionFailed` so the failed-banner path fires. 3. **Auto-update without a prompt.** When `check_binary()` misses but the remote already has an install directory, we skip the "Install Warp SSH tools?" modal and install directly with `is_update: true`. The shimmer reads "Updating Warp SSH Extension..." instead of "Installing...". Dev clients without a release tag fall through to the normal prompt so they don't accidentally CDN-install over a locally-deployed binary. ## Testing - `cargo test -p remote_server`, `cargo clippy -D warnings`, `cargo fmt` — all clean. - Manual against `alokedesai@136.107.29.130`: - Fresh install: modal → "Installing Warp SSH Extension..." → Ready. - Reconnect with a newer client tag: handshake mismatch detected, stale binary removed, reconnect reinstalls. - Reconnect with an older client tag while a newer versioned binary is on the remote: auto-update, no modal, "Updating..." shimmer. - Forced install failure on the update path: failed banner renders. ## Server API dependencies None — warp-server already supports `?version=` on `/download/cli` (#10284). --------- Co-authored-by: Oz <oz-agent@warp.dev>
This commit is contained in:
@@ -6,6 +6,8 @@
|
||||
# {channel} — stable | preview | dev
|
||||
# {install_dir} — e.g. ~/.warp/remote-server
|
||||
# {binary_name} — e.g. oz | oz-dev | oz-preview
|
||||
# {version_query} — e.g. &version=v0.2026... (empty when no release tag)
|
||||
# {version_suffix} — e.g. -v0.2026... (empty when no release tag)
|
||||
set -e
|
||||
|
||||
arch=$(uname -m)
|
||||
@@ -29,11 +31,11 @@ mkdir -p "$install_dir"
|
||||
tmpdir=$(mktemp -d "$install_dir/.install.XXXXXX")
|
||||
trap 'rm -rf "$tmpdir"' EXIT
|
||||
|
||||
curl -fSL "{download_base_url}?package=tar&os=$os_name&arch=$arch_name&channel={channel}" \
|
||||
curl -fSL "{download_base_url}?package=tar&os=$os_name&arch=$arch_name&channel={channel}{version_query}" \
|
||||
-o "$tmpdir/oz.tar.gz"
|
||||
tar -xzf "$tmpdir/oz.tar.gz" -C "$tmpdir"
|
||||
|
||||
bin=$(find "$tmpdir" -type f -name 'oz*' ! -name '*.tar.gz' | head -n1)
|
||||
if [ -z "$bin" ]; then echo "no binary found in tarball" >&2; exit 1; fi
|
||||
chmod +x "$bin"
|
||||
mv "$bin" "$install_dir/{binary_name}"
|
||||
mv "$bin" "$install_dir/{binary_name}{version_suffix}"
|
||||
|
||||
@@ -17,7 +17,11 @@ use crate::transport::RemoteTransport;
|
||||
use crate::HostId;
|
||||
use repo_metadata::RepoMetadataUpdate;
|
||||
use serde::Serialize;
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
use warp_core::channel::ChannelState;
|
||||
use warp_core::SessionId;
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
use warpui::r#async::FutureExt as _;
|
||||
use warpui::{Entity, ModelContext, ModelSpawner, SingletonEntity};
|
||||
|
||||
/// Maximum number of reconnection attempts after a spontaneous disconnect.
|
||||
@@ -116,6 +120,27 @@ impl RemoteServerErrorKind {
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns `true` if the client and server are on compatible versions for
|
||||
/// the initialize handshake.
|
||||
///
|
||||
/// Semantics:
|
||||
/// - Both sides carry a non-empty release tag (`Some(_)` client, non-empty
|
||||
/// `server` string): the tags must match exactly. Mismatched releases
|
||||
/// cause the manager to tear the session down and delete the stale
|
||||
/// binary so the next reconnect reinstalls.
|
||||
/// - Both sides are unknown (client `None` and server reports an empty
|
||||
/// string): treat as compatible. This preserves the `cargo run` +
|
||||
/// `script/deploy_remote_server` dev loop, where neither side reports a
|
||||
/// release tag.
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
fn version_is_compatible(client: Option<&str>, server: &str) -> bool {
|
||||
match (client, server.is_empty()) {
|
||||
(Some(c), false) => c == server,
|
||||
(None, true) => true,
|
||||
(Some(_), true) | (None, false) => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Per-session connection state. Encodes which data is available at each
|
||||
/// lifecycle stage so the compiler prevents invalid combinations.
|
||||
///
|
||||
@@ -289,6 +314,14 @@ pub enum RemoteServerManagerEvent {
|
||||
/// The detected remote platform (OS + arch) from `uname -sm`.
|
||||
/// `None` if detection failed or was not attempted.
|
||||
remote_platform: Option<RemotePlatform>,
|
||||
/// `true` if the remote already has an existing install of the
|
||||
/// remote-server binary, detected by probing whether the install
|
||||
/// directory exists (see `RemoteTransport::check_has_old_binary`).
|
||||
/// Combined with `result == Ok(false)`, this tells the controller
|
||||
/// it should auto-install as an update instead of prompting the
|
||||
/// user. `false` when no prior install was detected, or when the
|
||||
/// detection itself failed.
|
||||
has_old_binary: bool,
|
||||
},
|
||||
/// Result of [`RemoteServerManager::install_binary`]. Returns a result where:
|
||||
/// - `Ok(())` means the install succeeded, and
|
||||
@@ -438,9 +471,17 @@ impl RemoteServerManager {
|
||||
let spawner = self.spawner.clone();
|
||||
ctx.background_executor()
|
||||
.spawn(async move {
|
||||
// Run platform detection and binary check concurrently.
|
||||
let (platform_result, check_result) =
|
||||
futures::join!(transport.detect_platform(), transport.check_binary(),);
|
||||
// Run platform detection, binary check, and old-binary
|
||||
// check concurrently. The old-binary check lets the
|
||||
// controller distinguish fresh install (no prior
|
||||
// versioned binary) from update (prior versioned
|
||||
// binary present), so it can skip the install prompt
|
||||
// in the update case.
|
||||
let (platform_result, check_result, old_binary_result) = futures::join!(
|
||||
transport.detect_platform(),
|
||||
transport.check_binary(),
|
||||
transport.check_has_old_binary(),
|
||||
);
|
||||
let platform = match platform_result {
|
||||
Ok(p) => Some(p),
|
||||
Err(e) => {
|
||||
@@ -448,6 +489,16 @@ impl RemoteServerManager {
|
||||
None
|
||||
}
|
||||
};
|
||||
let has_old_binary = match old_binary_result {
|
||||
Ok(has) => has,
|
||||
Err(e) => {
|
||||
log::warn!(
|
||||
"Old-binary detection failed for session {session_id:?}: {e}. \
|
||||
Treating as fresh install."
|
||||
);
|
||||
false
|
||||
}
|
||||
};
|
||||
let _ = spawner
|
||||
.spawn(move |me, ctx| {
|
||||
if let Some(p) = &platform {
|
||||
@@ -465,6 +516,7 @@ impl RemoteServerManager {
|
||||
session_id,
|
||||
result: check_result,
|
||||
remote_platform: platform,
|
||||
has_old_binary,
|
||||
});
|
||||
})
|
||||
.await;
|
||||
@@ -483,6 +535,7 @@ impl RemoteServerManager {
|
||||
&mut self,
|
||||
session_id: SessionId,
|
||||
transport: T,
|
||||
is_update: bool,
|
||||
ctx: &mut ModelContext<Self>,
|
||||
) where
|
||||
T: RemoteTransport + 'static,
|
||||
@@ -494,11 +547,16 @@ impl RemoteServerManager {
|
||||
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
{
|
||||
let setup_state = if is_update {
|
||||
RemoteServerSetupState::Updating
|
||||
} else {
|
||||
RemoteServerSetupState::Installing {
|
||||
progress_percent: None,
|
||||
}
|
||||
};
|
||||
ctx.emit(RemoteServerManagerEvent::SetupStateChanged {
|
||||
session_id,
|
||||
state: RemoteServerSetupState::Installing {
|
||||
progress_percent: None,
|
||||
},
|
||||
state: setup_state,
|
||||
});
|
||||
let spawner = self.spawner.clone();
|
||||
ctx.background_executor()
|
||||
@@ -701,6 +759,35 @@ impl RemoteServerManager {
|
||||
.initialize(auth_token.as_deref())
|
||||
.await
|
||||
.map_err(|e| ConnectAndHandshakeError::Initialize(anyhow::anyhow!("{e:#}")))?;
|
||||
|
||||
// Version compatibility check. If the server reports a different release
|
||||
// tag than the client expects, the binary on disk is stale. Remove it so
|
||||
// the next reconnect (or explicit reconnect by the user) will reinstall.
|
||||
let client_version = ChannelState::app_version();
|
||||
if !version_is_compatible(client_version, &resp.server_version) {
|
||||
log::warn!(
|
||||
"Remote server version mismatch for session {session_id:?}: \
|
||||
client={client_version:?}, server={:?}. Removing stale binary.",
|
||||
resp.server_version
|
||||
);
|
||||
|
||||
const REMOVAL_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(5);
|
||||
|
||||
if let Err(e) = transport
|
||||
.remove_remote_server_binary()
|
||||
.with_timeout(REMOVAL_TIMEOUT)
|
||||
.await
|
||||
.unwrap_or_else(|_| Err(anyhow::anyhow!("timed out after {REMOVAL_TIMEOUT:?}")))
|
||||
{
|
||||
log::warn!("Failed to remove stale remote binary for session {session_id:?}: {e}");
|
||||
}
|
||||
return Err(ConnectAndHandshakeError::Initialize(anyhow::anyhow!(
|
||||
"remote server version mismatch (client: {client_version:?}, \
|
||||
server: {:?}); reconnect to reinstall",
|
||||
resp.server_version
|
||||
)));
|
||||
}
|
||||
|
||||
Ok(HostId::new(resp.host_id))
|
||||
}
|
||||
|
||||
|
||||
@@ -8,8 +8,12 @@ use warp_core::channel::{Channel, ChannelState};
|
||||
pub enum RemoteServerSetupState {
|
||||
/// Checking if the binary exists on remote.
|
||||
Checking,
|
||||
/// Downloading and installing the binary.
|
||||
/// Downloading and installing the binary for the first time on this host.
|
||||
Installing { progress_percent: Option<u8> },
|
||||
/// Replacing an existing install with a differently-versioned binary.
|
||||
/// Rendered as "Updating..." in the UI so the user understands this
|
||||
/// isn't a fresh install.
|
||||
Updating,
|
||||
/// Binary is launched, waiting for InitializeResponse.
|
||||
Initializing,
|
||||
/// Handshake complete. Ready.
|
||||
@@ -34,7 +38,14 @@ impl RemoteServerSetupState {
|
||||
pub fn is_in_progress(&self) -> bool {
|
||||
matches!(
|
||||
self,
|
||||
Self::Checking | Self::Installing { .. } | Self::Initializing
|
||||
Self::Checking | Self::Installing { .. } | Self::Updating | Self::Initializing
|
||||
)
|
||||
}
|
||||
|
||||
pub fn is_connecting(&self) -> bool {
|
||||
matches!(
|
||||
self,
|
||||
Self::Installing { .. } | Self::Updating | Self::Initializing
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -172,16 +183,51 @@ pub fn binary_name() -> &'static str {
|
||||
ChannelState::channel().cli_command_name()
|
||||
}
|
||||
|
||||
/// Returns the full remote binary path.
|
||||
/// Returns the full remote binary path for the current channel and client
|
||||
/// version.
|
||||
///
|
||||
/// The path-versioning rule is keyed strictly off [`Channel`]:
|
||||
///
|
||||
/// - [`Channel::Local`] and [`Channel::Oss`] always use the bare
|
||||
/// `{binary_name}` path. For `Local` this is the slot
|
||||
/// `script/deploy_remote_server` writes to; `Oss` is treated the
|
||||
/// same way because it has no release-pinned CDN artifact and is
|
||||
/// expected to be deployed/managed locally.
|
||||
/// - Every other channel always uses `{binary_name}-{version}`, where
|
||||
/// `version` is the baked-in `GIT_RELEASE_TAG` when present and falls
|
||||
/// back to `CARGO_PKG_VERSION` otherwise. The fallback keeps the path
|
||||
/// deterministic for misconfigured `cargo run --bin {dev,preview,...}`
|
||||
/// builds; the resulting `&version=...` query is expected to 404 against
|
||||
/// `/download/cli` and surface a clean `SetupFailed` rather than silently
|
||||
/// writing to a path that doesn't follow the rule.
|
||||
pub fn remote_server_binary() -> String {
|
||||
format!("{}/{}", remote_server_dir(), binary_name())
|
||||
let dir = remote_server_dir();
|
||||
let name = binary_name();
|
||||
match ChannelState::channel() {
|
||||
Channel::Local | Channel::Oss => format!("{dir}/{name}"),
|
||||
Channel::Stable | Channel::Preview | Channel::Dev | Channel::Integration => {
|
||||
format!("{dir}/{name}-{}", pinned_version())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the shell command to check if the remote server binary exists and
|
||||
/// is executable.
|
||||
pub fn binary_check_command() -> String {
|
||||
let bin = remote_server_binary();
|
||||
format!("test -x {bin}")
|
||||
format!("test -x {}", remote_server_binary())
|
||||
}
|
||||
|
||||
/// Returns the version string used to pin remote-server installs on
|
||||
/// channels that take the versioned path (i.e. everything except
|
||||
/// [`Channel::Local`] and [`Channel::Oss`]). Prefers the baked-in
|
||||
/// `GIT_RELEASE_TAG` from [`ChannelState::app_version`]; falls back to
|
||||
/// `CARGO_PKG_VERSION` so the path / install URL is deterministic even on
|
||||
/// dev `cargo run` builds without a release tag. The `CARGO_PKG_VERSION`
|
||||
/// fallback is not expected to map to a real `/download/cli` artifact —
|
||||
/// it exists to produce a clean install-time failure rather than silently
|
||||
/// fall through to the unversioned (Local/Oss-only) path.
|
||||
fn pinned_version() -> &'static str {
|
||||
ChannelState::app_version().unwrap_or(env!("CARGO_PKG_VERSION"))
|
||||
}
|
||||
|
||||
/// The install script template, loaded from a standalone `.sh` file for
|
||||
@@ -189,20 +235,33 @@ pub fn binary_check_command() -> String {
|
||||
/// [`install_script`].
|
||||
const INSTALL_SCRIPT_TEMPLATE: &str = include_str!("install_remote_server.sh");
|
||||
|
||||
/// Returns the install script that downloads and installs the CLI binary.
|
||||
/// Returns the install script that downloads and installs the CLI binary
|
||||
/// at the current client version.
|
||||
///
|
||||
/// The script detects the remote architecture via `uname -m`, downloads the
|
||||
/// correct Oz CLI tarball from the download URL (with os, arch, package, and
|
||||
/// channel query params), and extracts it to the install directory.
|
||||
///
|
||||
/// All parameters (URL, channel, directory, binary name) are derived
|
||||
/// internally from the current channel configuration.
|
||||
/// The script detects the remote architecture via `uname -m`, downloads
|
||||
/// the correct Oz CLI tarball from the download URL, and installs it at
|
||||
/// the path returned by [`remote_server_binary`] so repeat invocations
|
||||
/// are idempotent. The `version_query` / `version_suffix` substitutions
|
||||
/// follow the same rule as [`remote_server_binary`]: empty on
|
||||
/// [`Channel::Local`] and [`Channel::Oss`] (so the install lands at
|
||||
/// the unversioned path used by `script/deploy_remote_server`); pinned to
|
||||
/// `&version={v}` / `-{v}` on every other channel, where `v` falls back
|
||||
/// to `CARGO_PKG_VERSION` when no release tag is baked in.
|
||||
pub fn install_script() -> String {
|
||||
let (version_query, version_suffix) = match ChannelState::channel() {
|
||||
Channel::Local | Channel::Oss => (String::new(), String::new()),
|
||||
Channel::Stable | Channel::Preview | Channel::Dev | Channel::Integration => {
|
||||
let v = pinned_version();
|
||||
(format!("&version={v}"), format!("-{v}"))
|
||||
}
|
||||
};
|
||||
INSTALL_SCRIPT_TEMPLATE
|
||||
.replace("{download_base_url}", &download_url())
|
||||
.replace("{channel}", download_channel())
|
||||
.replace("{install_dir}", &remote_server_dir())
|
||||
.replace("{binary_name}", binary_name())
|
||||
.replace("{version_query}", &version_query)
|
||||
.replace("{version_suffix}", &version_suffix)
|
||||
}
|
||||
|
||||
/// Construct the download URL from the server root URL.
|
||||
|
||||
@@ -101,8 +101,9 @@ fn state_is_terminal() {
|
||||
.is_terminal());
|
||||
assert!(!RemoteServerSetupState::Checking.is_terminal());
|
||||
assert!(!RemoteServerSetupState::Installing {
|
||||
progress_percent: None
|
||||
progress_percent: None,
|
||||
}
|
||||
.is_terminal());
|
||||
assert!(!RemoteServerSetupState::Updating.is_terminal());
|
||||
assert!(!RemoteServerSetupState::Initializing.is_terminal());
|
||||
}
|
||||
|
||||
@@ -82,6 +82,19 @@ pub trait RemoteTransport: Send + Sync + std::fmt::Debug {
|
||||
&self,
|
||||
) -> Pin<Box<dyn std::future::Future<Output = Result<bool, String>> + Send>>;
|
||||
|
||||
/// Checks whether the remote host already has an existing install
|
||||
/// of the remote server binary.
|
||||
///
|
||||
/// Used by the manager to distinguish a fresh install (no prior
|
||||
/// install on disk, user should be prompted) from an update (prior
|
||||
/// install present, install should happen automatically).
|
||||
///
|
||||
/// Returns `Ok(true)` if a prior install was detected, `Ok(false)`
|
||||
/// if not, and `Err(_)` on SSH failure.
|
||||
fn check_has_old_binary(
|
||||
&self,
|
||||
) -> Pin<Box<dyn std::future::Future<Output = anyhow::Result<bool>> + Send>>;
|
||||
|
||||
/// Installs the remote server binary on the remote host.
|
||||
///
|
||||
/// Pure I/O — does not emit any events. The caller
|
||||
@@ -108,4 +121,18 @@ pub trait RemoteTransport: Send + Sync + std::fmt::Debug {
|
||||
&self,
|
||||
executor: std::sync::Arc<executor::Background>,
|
||||
) -> Pin<Box<dyn std::future::Future<Output = anyhow::Result<Connection>> + Send>>;
|
||||
|
||||
/// Remove the remote server binary, forcing a reinstall on the next
|
||||
/// [`install_binary`] call.
|
||||
///
|
||||
/// Called by the manager after the initialize handshake reports a
|
||||
/// version that disagrees with the client's: the file at the expected
|
||||
/// path is stale/wrong, so we remove it so the next setup sees a miss
|
||||
/// and reinstalls from the CDN instead of looping on the same bad
|
||||
/// binary.
|
||||
///
|
||||
/// [`install_binary`]: RemoteTransport::install_binary
|
||||
fn remove_remote_server_binary(
|
||||
&self,
|
||||
) -> Pin<Box<dyn std::future::Future<Output = anyhow::Result<()>> + Send>>;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user