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:
Aloke Desai
2026-04-30 09:59:20 -05:00
committed by GitHub
parent 6898ac27fb
commit b19866a4a8
11 changed files with 499 additions and 64 deletions

View File

@@ -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}"

View File

@@ -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))
}

View File

@@ -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.

View File

@@ -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());
}

View File

@@ -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>>;
}