17 KiB
TECH.md — Client/Server Version Skew for Remote Server
Linear: APP-3805
1. Problem
The Warp remote server binary is installed at a single, unversioned path per channel (e.g. ~/.warp/remote-server/oz). The existence check is test -x {bin} and we never inspect the binary's version before talking to it. When the client auto-updates to a new version, it happily reuses the old remote server binary, which can drift arbitrarily far from the protocol/behaviour the client expects. The InitializeResponse already carries server_version, but the client ignores it.
We need a version-gated install flow: connecting from a client at version V always ends up talking to a server binary also at V. Any local cargo run workflow (where the client has no GIT_RELEASE_TAG) keeps working with script/deploy_remote_server: a deployed binary at the unversioned path always wins, and when one is missing the client falls back to installing latest-for-channel at the same unversioned path so the dev loop self-heals.
2. Requirements
R1. Exact version match. A connected client at version V must only communicate with a remote daemon spawned from a binary at the same version V. No silent skew.
R2. Automatic reinstall on drift. When the installed binary is the wrong version (or missing), the client reinstalls the correct version as part of the connect flow. No manual rm -rf step.
R3. Cheap happy path. When the correct binary is already installed, connecting should require no extra downloads and no extra SSH round-trips beyond today's single test -x check.
R4. Local dev workflow preserved. Channel::Local clients (the default cargo run) must keep working with script/deploy_remote_server. When a deployed binary exists at the unversioned path, the client uses it without re-downloading from the CDN. When no binary exists, the client auto-installs latest-for-channel at the same unversioned path so a stale install directory self-heals. The unversioned filename is reserved for Channel::Local and Channel::Oss (which has no release-pinned CDN artifact and is treated identically); every other channel always uses a versioned filename, so versioned-channel builds can never silently overwrite a script/deploy_remote_server-owned slot.
R5. Clear failure surface. If install or version validation fails, the user sees an actionable setup-failed state rather than a confusing protocol error.
R6. Defense-in-depth. A hand-placed or half-written binary at the expected path shouldn't be trusted blindly — the version reported at the handshake must also be verified.
R7. No regression in storage behaviour. We don't introduce unbounded disk growth, and we don't break existing installs at the current unversioned path during rollout.
3. Current connect flow
The install + handshake path this spec mutates spans four files. The sequence below shows today's behaviour; §4 slots version-aware steps into the marked decision points.
sequenceDiagram
participant UI as Client UI
participant Mgr as RemoteServerManager
participant Tx as SshTransport
participant Remote as Remote host
participant Daemon as oz daemon
UI->>Mgr: connect_session
Mgr->>Tx: setup(session_id)
Note over Tx: today: path = {dir}/{binary_name}<br/>(no version in filename)
Tx->>Remote: ssh: test -x {path}
alt binary missing
Tx->>Remote: ssh: install_remote_server.sh<br/>(curl → tar → mv → {path})
end
Tx-->>Mgr: SetupReady
Mgr->>Tx: connect()
Tx->>Remote: ssh: `{path} remote-server-proxy`
Remote->>Daemon: spawn (if not already running)
Mgr->>Daemon: Initialize
Daemon-->>Mgr: InitializeResponse { server_version, host_id }
Note over Mgr: today: server_version is ignored
Mgr->>Mgr: mark_session_connected
Key pieces of today's behaviour the diagram elides:
setup::remote_server_binary()resolves the path purely from the channel; there is no version in the filename.install_remote_server.shpulls from{server_root_url}/download/cli?package=tar&os=...&arch=...&channel={channel}with no version pin. Thewarp-server/download/cliendpoint already accepts aversion=query parameter that pins the artifact to an exact release (and falls back to latest-for-channel when omitted), so this spec only needs client-side changes.ChannelState::app_version()returnsoption_env!("GIT_RELEASE_TAG")—Some("v0.…")on release builds,Noneoncargo run. This is the signal we'll thread through in §4.script/deploy_remote_serveris the developer escape hatch: itrsyncs a locally-built binary into~/.warp-local/remote-server/oz-localand assumes the client won't try to download on top of it.
4. Proposed solution
Encode the client's expected version into the installed binary's filename, so version drift turns into a missing-file miss that naturally re-triggers the existing install flow. Layer a handshake-level version check on top as a safety net, and reserve the unversioned filename for Channel::Local and Channel::Oss.
4.1 Channel-keyed binary paths
In crates/remote_server/src/setup.rs, the path resolution is keyed strictly off [Channel]:
remote_server_binary():- When
ChannelState::channel()isChannel::LocalorChannel::Oss→{dir}/{binary_name}(the unversioneddeploy_remote_serverslot;Osshas no release-pinned CDN artifact and follows the same convention). - For every other channel (
Stable,Preview,Dev,Integration) →{dir}/{binary_name}-{v}, wherev = ChannelState::app_version().unwrap_or(env!("CARGO_PKG_VERSION")).
- When
binary_check_command()keeps the singletest -x {remote_server_binary()}contract. Because the version is part of the filename on every versioned channel, any drift resolves to a miss on the existing code path and re-runsinstall_script. TheCARGO_PKG_VERSIONfallback is intentionally not expected to point at a real release artifact; see §4.4 for the failure shape on versioned-channel + no-tag builds.
4.2 Install script pins the exact version (or latest, for Local/Oss)
warp-server's /download/cli already honours a version= query parameter (pins the redirect to the exact versioned artifact when present; falls back to latest-for-channel when absent), so no server-side change is needed.
In install_remote_server.sh, add {version_query} and {version_suffix} placeholders used in two places:
- Download URL query string:
...&channel={channel}{version_query}(e.g.&version=v0.…, or empty). - Final install path:
mv "$bin" "$install_dir/{binary_name}{version_suffix}"(e.g.-v0.…, or empty). Insetup.rs::install_script(), substitute based onChannelState::channel(): Channel::LocalandChannel::Oss→ both substitutions are empty strings. These channels download latest-for-channel and install at the unversioned{dir}/{binary_name}path thatscript/deploy_remote_serveralso uses.- Every other channel →
version_query = "&version={v}",version_suffix = "-{v}", wherevresolves viaapp_version().unwrap_or(CARGO_PKG_VERSION). Release-tagged clients pin the exact published version; versioned-channel builds without a release tag pinCARGO_PKG_VERSION, which deliberately doesn't map to a real/download/cliartifact. Unknown versions (rolled-back releases, or theCARGO_PKG_VERSIONversioned-channel fallback) surface as a GCS 404 on the redirected URL, whichcurl -fSLturns into a non-zero exit andSetupFailedfor the user.
4.3 Handshake version validation
In crates/remote_server/src/manager.rs::connect_session, immediately after client.initialize().await returns Ok(resp):
- Let
client_v = ChannelState::app_version()andserver_v = resp.server_version. - If both are
Some(or server returned a non-empty string) and they differ → log the skew, delete the versioned binary on the remote host (see below), then emitRemoteServerManagerEvent::SessionDisconnectedand callmark_session_disconnected. - If both sides are unknown (client
Noneand server reports an empty string) → accept and log a warning. This is thecargo run(Local/Oss) +script/deploy_remote_serverdev loop where neither side carries a release tag. - Any other shape (one side has a version, the other doesn't) → treated as incompatible. This is defense-in-depth: in normal operation the path-based check (§4.1) would already have triggered a reinstall to bring both sides into agreement, so a mixed shape at the handshake means something unusual (hand-placed binary, partial download) and we prefer to tear the session down and reinstall.
Why delete the binary on mismatch? The handshake check only fires when filename-based detection (§4.1) said we had the right version but the running daemon disagrees. That means the file at the versioned path is wrong — partial download, hand-placed, or corrupted. If we only disconnect, the next reconnect will re-run ensure_binary_installed, see test -x {path} succeed (the file still exists), skip install, respawn the daemon, and hit the same mismatch — a reconnect loop. Deleting the binary forces the next ensure_binary_installed to miss and reinstall, breaking the loop in a single extra SSH command (ssh rm -f {path}).
Factor the comparison into a pure helper (fn version_is_compatible(client: Option<&str>, server: &str) -> bool) so it's unit-testable without wiring up a client.
4.4 Unversioned channels (Local / Oss) and versioned-channel-without-tag
Channel::Local (the default cargo run) and Channel::Oss take the unversioned branch of remote_server_binary() / install_script() (see §4.1, §4.2). Concretely:
- If
script/deploy_remote_serverhas put a binary at the unversioned path,binary_check_commandsucceeds and we connect without touching the CDN. - If the unversioned binary is missing but the install directory exists (e.g. a previous install ran here), the controller's auto-update branch fires and
install_scriptruns with emptyversion_query/version_suffix— i.e. it pulls latest-for-channel and installs at the same unversioned path. Futuredeploy_remote_serverruns simply overwrite this in place. - If the install directory is also missing (truly fresh host), we fall through to the normal install-mode path (
AlwaysAsk→ user modal, etc.).script/deploy_remote_serveris not touched; its target path (~/.warp-local/remote-server/oz-localon the local channel) is exactly what theChannel::Localbranch ofremote_server_binary()returns. Versioned-channel builds without a release tag (e.g.cargo run --bin dev,--bin preview) are deliberately unsupported for SSH remote-server installs. TheCARGO_PKG_VERSIONfallback (§4.1) keeps the path deterministic but the resulting&version=CARGO_PKG_VERSIONquery 404s against/download/cli, surfacing a cleanBinaryInstallComplete::Err(_)and the existing failed-banner path. Developers who need to test against a real channel should either build withGIT_RELEASE_TAGset or usecargo run(Local) +script/deploy_remote_server.
4.5 Rollout / backwards compatibility
The first release with this change will, for every user, look for a versioned path that does not yet exist on their remote host. That's fine — it falls through to the normal install path and overwrites into the new versioned filename. The legacy unversioned binary ({dir}/{binary_name}) is simply orphaned on disk. We accept that (see §4.6) and can sweep it later.
4.6 Deliberate non-goals
- Cleanup of old versioned binaries. Accept accumulation for v1; easy to add later as a post-install step that keeps the current + previous version.
- Upload-over-SSH fallback when the remote can't reach the CDN. Reasonable future work, not required here.
- Protocol change to send client version in
Initialize. We only readserver_versionoff the response; the reverse direction can be added if/when the server wants to reject proactively.
5. How the solution maps to the requirements
- R1 (exact match). Versioned filename (§4.1) + handshake check (§4.3) give us two independent enforcements of exact match.
- R2 (automatic reinstall). A new version turns into a path miss (§4.1), which re-enters the existing
install_scriptpath (§4.2). No manual step. - R3 (cheap happy path). Still a single
test -xSSH command on the hot path; handshake check is a string compare on a response we already receive. - R4 (local dev workflow).
Channel::LocalandChannel::Ossshort-circuit to the unversioned path for both the install location and the existence check (§4.1). A deployed binary always wins viatest -x; if missing but the install directory exists, the install script runs with emptyversion_query/version_suffixand pulls latest-for-channel to the same unversioned path (§4.2, §4.4).deploy_remote_serveris unchanged and overwrites in place. Versioned-channel builds without a release tag fail cleanly via theCARGO_PKG_VERSION404 path so they can never silently overwrite a Local/Oss-owned slot. - R5 (clear failure). Install errors (CDN 404, network failure, etc.) propagate via
BinaryInstallComplete { result: Err(_) }and the controller surfaces them through the existingshow_ssh_remote_server_failed_bannerpath. Handshake version mismatches reach the same banner viaSessionConnectionFailed { phase: Initialize }(§4.3). - R6 (defense-in-depth). Handshake check catches stale or hand-placed binaries even when the filename is right (§4.3).
- R7 (no regression). Existing unversioned install is simply orphaned after the first upgrade; no code assumes its absence or presence (§4.5).
6. Testing and validation
Covers each requirement with a concrete check:
- Unit tests in
crates/remote_server/src/setup_tests.rs(uses the existingtest-utilChannelState::set_app_versionhook):remote_server_binary()returns a{name}-{version}path on every versioned channel (Stable,Preview,Dev,Integration), whetherapp_version()isSomeor falls back toCARGO_PKG_VERSION. (R1, R2)remote_server_binary()returns the bare{name}path onChannel::LocalandChannel::Oss, regardless ofapp_version(). (R4)install_script()substitutes{version_query}/{version_suffix}into both the URL and the install path on every versioned channel. (R1, R2)install_script()substitutes empty strings for both placeholders onChannel::LocalandChannel::Oss, so the script downloads latest-for-channel and installs at the unversioned path. (R4)
- Unit tests for
version_is_compatiblecovering:- Matching
Some("v…")on both sides → compatible. (R1) - Differing
Some/Some→ incompatible. (R1, R6) - Both unknown (client
None, server"") → compatible with warning. (R4) - Mixed shape (
Some/empty,None/non-empty) → incompatible. (R6)
- Matching
- Manual validation (captured in the PR description):
- Connect once at version V; confirm the binary lands at
{dir}/{name}-{V}and the session comes up. (R1, R2, R3) - Bump the client to version V+1 (or stub the version in
ChannelState::set_app_version), reconnect; confirm a new install runs and the connection succeeds. (R1, R2) - Corrupt the versioned binary (e.g. replace its contents with a stub) so the handshake reports a different version; confirm the session is torn down with
SessionDisconnectedand a user-visible error. (R6) cargo run(Local) against a host where the install directory exists but the unversioned binary is missing; confirm the controller auto-installs latest-for-channel at the unversioned path and the session comes up. (R4)- After running
script/deploy_remote_server, reconnect withcargo run(Local) and confirmtest -xsucceeds and we connect without re-downloading. (R4)
- Connect once at version V; confirm the binary lands at
- Existing remote-server manager tests continue to pass unchanged (no behavioural change for the
None/""path).
7. Risks and mitigations
- Churn on every release. Each stable/preview/dev release will pull a fresh binary on every host the user connects to the first time. Expected; bandwidth impact is minor.
- Orphaned binaries accumulate. See §4.6; acceptable for v1, cleanup is a straightforward follow-up.
- Repeat handshake mismatches. Addressed by deleting the offending binary on mismatch (§4.3) so the next reconnect reinstalls rather than looping. Worth explicit test coverage (§6).
8. Open questions
- Should
Initialize(client → server) also carry the client's expected version so the server can reject proactively, or is client-side enforcement enough? Leaning "enough" for now. - Do we want to surface the "version-skew detected, retrying" state as a distinct UI affordance, or is the standard reconnect flow sufficient? Current plan: reconnect-only, revisit if it surfaces as a usability issue.