Initial public release of Warp.
Repo-Sync-Origin: warpdotdev/warp-internal@12af1d983b
This commit is contained in:
@@ -0,0 +1,120 @@
|
||||
# Remote Server SSH: Binary Installation and Initialization
|
||||
|
||||
## Summary
|
||||
|
||||
Replace the current SSH wrapper's ControlMaster-based `RemoteCommandExecutor` with a persistent remote server binary (`warp-remote-server`) that runs on the remote machine. After the remote shell sends `InitShell`, Warp checks for the binary at `~/.warp/warp-remote-server`, installs it if missing, launches it, and performs a protobuf Initialize handshake over stdin/stdout. The session is fully bootstrapped only after both the remote server is initialized and the shell `Bootstrapped` hook has been received.
|
||||
|
||||
This is gated behind a new feature flag.
|
||||
|
||||
## Problem
|
||||
|
||||
The current SSH wrapper flow relies on SSH ControlMaster sockets to execute commands on the remote machine. This approach has limitations:
|
||||
- ControlMaster connections can be unreliable and produce hard-to-debug errors.
|
||||
- Every generator/completion command opens a new SSH channel through the control socket.
|
||||
- There is no persistent process on the remote side to maintain state, cache results, or provide richer capabilities.
|
||||
|
||||
A persistent remote binary enables future capabilities (file watching, indexing, richer completions) and provides a more reliable command execution channel.
|
||||
|
||||
## Goals
|
||||
|
||||
- Install the `warp-remote-server` binary on the remote machine automatically when it is not present.
|
||||
- Detect the remote OS (Linux or macOS) and architecture (x86_64 or aarch64) to download the correct binary.
|
||||
- Show clear, stage-specific status messages in the Warp input during installation and initialization.
|
||||
- Perform a protobuf-based Initialize handshake with the remote binary before marking the session as ready.
|
||||
- Gate the entire flow behind a feature flag so the existing ControlMaster flow remains the default.
|
||||
- Require both remote server initialization and shell bootstrap completion before the session accepts input.
|
||||
|
||||
## Non-goals
|
||||
|
||||
- Windows remote hosts (Linux and macOS only for now).
|
||||
- Replacing all `RemoteCommandExecutor` functionality with the remote server in this iteration.
|
||||
- Auto-updating the remote binary when a newer version is available.
|
||||
- Handling SSH connections that require interactive password entry for the binary installation step (assumes ControlMaster socket is already established).
|
||||
- Supporting remote hosts without `curl` or `wget`.
|
||||
|
||||
## Figma
|
||||
|
||||
Figma: none provided.
|
||||
|
||||
## User experience
|
||||
|
||||
### Feature flag
|
||||
|
||||
The new flow is gated behind a feature flag (e.g. `RemoteServerSSH`). When the flag is disabled, the existing ControlMaster-based flow is used unchanged. When enabled, the new flow described below applies to all SSH wrapper sessions.
|
||||
|
||||
### Status messages
|
||||
|
||||
Throughout the flow, the Warp input prompt area displays a status message (bold, in the same style as "Starting shell..."). The messages are:
|
||||
|
||||
1. **"Starting shell..."** — shown immediately after `InitShell` (same as today).
|
||||
2. **"Installing Warp SSH tools... (X%)"** — shown while the binary is being downloaded and installed on the remote machine. Replaces "Starting shell..." once installation begins. The percentage reflects download progress reported by `curl`/`wget`. If progress cannot be determined, show **"Installing Warp SSH tools..."** without a percentage.
|
||||
3. **"Initializing..."** — shown after the binary is launched and the Initialize handshake is in progress.
|
||||
4. Once the Initialize handshake succeeds AND the `Bootstrapped` hook is received (in either order), the prompt transitions to the normal working directory display.
|
||||
|
||||
### Installation flow (after `InitShell`)
|
||||
|
||||
After `InitShell` is received and the pending session info is created:
|
||||
|
||||
1. **Check for existing binary.** Run a command over the existing SSH ControlMaster socket to check if `~/.warp/warp-remote-server` exists and is executable on the remote machine (e.g. `test -x ~/.warp/warp-remote-server && ~/.warp/warp-remote-server --version`).
|
||||
|
||||
2. **If the binary is not present or not functional:**
|
||||
a. Detect the remote OS and architecture by running `uname -sm` over SSH and parsing the output:
|
||||
- OS: `Darwin` → macOS, `Linux` → Linux
|
||||
- Arch: `x86_64` → x86_64, `arm64`/`aarch64`/`armv8l` → aarch64
|
||||
b. Download the Oz CLI tarball from the Warp server's `/download/cli` endpoint, using the detected OS and architecture. The endpoint accepts query parameters `os` (`macos` or `linux`), `arch` (`x86_64` or `aarch64`), `package` (`tar`), and `channel` (matching the current client channel). The endpoint returns a 302 redirect to the releases CDN (e.g. `https://releases.warp.dev/{channel}/{version}/cli/{os}/{arch}/warp-{channel}-{os}-{arch}.tar.gz`). The download is performed on the remote machine using `curl -fL` (preferred) or `wget` (fallback) via the SSH ControlMaster socket.
|
||||
c. Extract the Oz CLI binary from the tarball to `~/.warp/warp-remote-server` and set executable permissions (`chmod 755`).
|
||||
d. During this process, the input prompt shows **"Installing Warp SSH tools... (X%)"** with download progress when available, or **"Installing Warp SSH tools..."** without percentage if progress reporting is unavailable.
|
||||
|
||||
3. **If the binary is already present and functional**, skip installation.
|
||||
|
||||
### Launch and initialization flow
|
||||
|
||||
After the binary is confirmed present:
|
||||
|
||||
1. Launch `~/.warp/warp-remote-server` on the remote machine over the SSH ControlMaster socket. The process's stdin/stdout are used for communication.
|
||||
2. Send a `ClientMessage` containing an `Initialize` message (protobuf, length-prefixed as defined in `remote_server.proto`).
|
||||
3. Wait for a `ServerMessage` containing an `InitializeResponse`.
|
||||
4. During this phase, the input prompt shows **"Initializing..."**.
|
||||
|
||||
### Session readiness
|
||||
|
||||
The session is considered fully bootstrapped and ready to accept user input only when **both** of the following conditions are met:
|
||||
- The remote server has responded with `InitializeResponse`.
|
||||
- The shell `Bootstrapped` DCS hook has been received.
|
||||
|
||||
These two events may arrive in either order. The session must wait for both before transitioning to the fully bootstrapped state.
|
||||
|
||||
### Error handling
|
||||
|
||||
- **Installation failure (download fails, extraction fails, unsupported platform):** The input prompt should show an error message (e.g. "Failed to install Warp SSH tools"). The session should fall back to the existing ControlMaster-based `RemoteCommandExecutor` so the user can still use the SSH session with reduced functionality. Log the error for diagnostics.
|
||||
- **Binary launch failure:** Same fallback behavior. Show a brief error message, then proceed with ControlMaster-based execution.
|
||||
- **Initialize handshake timeout:** If no `InitializeResponse` is received within 10 seconds, fall back to ControlMaster-based execution with a logged warning.
|
||||
- **Unsupported OS/arch from `uname`:** Fall back to ControlMaster-based execution. Log the unrecognized platform string.
|
||||
|
||||
### Exiting SSH
|
||||
|
||||
When the SSH session ends (user types `exit` or the connection drops), the remote server process should be terminated. No special cleanup of `~/.warp/warp-remote-server` is needed — the binary remains installed for future sessions.
|
||||
|
||||
## Success criteria
|
||||
|
||||
1. When the feature flag is enabled and a user SSHs into a Linux or macOS remote host that does not have the binary installed, the binary is automatically downloaded and installed at `~/.warp/warp-remote-server` without user intervention.
|
||||
2. The correct binary variant is downloaded based on the remote host's OS and architecture (linux-x86_64, linux-aarch64, darwin-x86_64, darwin-aarch64).
|
||||
3. During installation, the input prompt displays "Installing Warp SSH tools..." instead of "Starting shell...".
|
||||
4. After installation (or if the binary was already present), the remote server binary is launched, the Initialize handshake completes, and the input prompt shows "Initializing..." during this phase.
|
||||
5. The session does not transition to the fully bootstrapped state until both the Initialize handshake and the `Bootstrapped` DCS hook have been received.
|
||||
6. On subsequent SSH connections to the same host, the binary is already present and the installation step is skipped entirely.
|
||||
7. If any step fails (download, launch, handshake), the session falls back to the existing ControlMaster-based `RemoteCommandExecutor` and the user can still use the session.
|
||||
8. When the feature flag is disabled, the existing SSH flow is completely unchanged.
|
||||
|
||||
## Validation
|
||||
|
||||
- **Manual testing:** SSH into a fresh Linux VM and a fresh macOS remote. Verify the binary is downloaded, installed, launched, and the Initialize handshake completes. Verify the prompt messages transition correctly: "Starting shell..." → "Installing Warp SSH tools..." → "Initializing..." → working directory.
|
||||
- **Subsequent connection test:** SSH into the same host again. Verify installation is skipped and the flow goes directly to launch + Initialize.
|
||||
- **Architecture coverage:** Test on at least one x86_64 and one aarch64 remote host.
|
||||
- **Error path testing:** Test with a remote host that has no `curl` or `wget`, or where the download URL is unreachable. Verify fallback to ControlMaster-based execution.
|
||||
- **Feature flag off:** Verify the entire new flow is inactive and the existing SSH behavior is unchanged.
|
||||
- **Race condition:** Verify correct behavior when `InitializeResponse` arrives before `Bootstrapped`, and vice versa.
|
||||
|
||||
## Open questions
|
||||
|
||||
None at this time.
|
||||
@@ -0,0 +1,181 @@
|
||||
# Remote Server SSH: Technical Spec
|
||||
|
||||
## Problem
|
||||
|
||||
The current SSH wrapper flow creates a `RemoteCommandExecutor` that runs every generator/completion command by opening a new SSH channel through a ControlMaster socket. This is unreliable and stateless. We want to replace it with a persistent remote server binary (`~/.warp/remote-server/oz`) on the remote machine that communicates over stdin/stdout using length-prefixed protobuf messages. The binary is the Oz CLI, installed from the `/download/cli` endpoint if not already present.
|
||||
|
||||
The challenge is that this introduces two independent async conditions that must both complete before the session is ready: (1) the shell `Bootstrapped` DCS hook, and (2) the remote server `InitializeResponse`. Today the bootstrap path is fully synchronous — `Bootstrapped` DCS immediately triggers `initialize_bootstrapped_session()`. We need to gate that call on both conditions without breaking non-SSH or flag-off flows.
|
||||
|
||||
## Relevant code
|
||||
|
||||
- `warp_core/src/features.rs` — `FeatureFlag` enum and `DOGFOOD_FLAGS` array
|
||||
- `app/src/terminal/model/terminal_model.rs:2808-2846` — `bootstrapped()` handler that takes `pending_session_info` and emits `HandlerEvent::Bootstrapped`
|
||||
- `app/src/terminal/model/terminal_model.rs:2848-2860` — `pre_interactive_ssh_session()` and `ssh()` handlers
|
||||
- `app/src/terminal/model/terminal_model.rs:2862-2908` — `init_shell()` handler
|
||||
- `app/src/terminal/model_events.rs:89-110` — `ModelEventDispatcher` handler for `HandlerEvent::Bootstrapped` that synchronously calls `sessions.initialize_bootstrapped_session()`
|
||||
- `app/src/terminal/model/session.rs:220-332` — `Sessions::initialize_bootstrapped_session()` which creates the command executor and emits `SessionBootstrapped`
|
||||
- `app/src/terminal/model/session.rs:410-414` — `IsLegacySSHSession` enum
|
||||
- `app/src/terminal/model/session.rs:448-542` — `SessionInfo` struct and `create_pending()`
|
||||
- `app/src/terminal/model/session/command_executor.rs:190-296` — executor selection logic in `new_command_executor_for_local_tty_session()`
|
||||
- `app/src/terminal/model/session/command_executor/remote_command_executor.rs` — current `RemoteCommandExecutor` using ControlMaster
|
||||
- `app/src/terminal/prompt_render_helper.rs:231-254` — `prompt_working_dir()` and `bootstrapping_shell_message()` that drive the status text
|
||||
- `app/src/terminal/view.rs:10255-10267` — `ModelEvent::PreInteractiveSSHSession` (no-op) and `ModelEvent::SSH` handlers
|
||||
- `app/src/terminal/view.rs:10860-11024` — `handle_session_bootstrapped()`
|
||||
- `remote_server/proto/remote_server.proto` — protobuf definitions for `Initialize`/`InitializeResponse`
|
||||
- `remote_server/src/lib.rs` — generated proto bindings
|
||||
- `app/assets/bundled/bootstrap/bash_body.sh` and `zsh_body.sh` — shell-side SSH wrapper functions
|
||||
|
||||
## Current state
|
||||
|
||||
### DCS hook sequence for SSH wrapper flow
|
||||
|
||||
1. **`PreInteractiveSSHSession`** — no-op marker
|
||||
2. **`SSH`** — carries `socket_path` (ControlMaster) and `remote_shell`. Stored in `pending_legacy_ssh_session`.
|
||||
3. **`InitShell`** — creates `SessionInfo::create_pending()`, consuming `pending_legacy_ssh_session` to populate `IsLegacySSHSession::Yes { socket_path }`. Calls `reinit_shell()` to reset the block list. Warp input becomes visible with "Starting shell...".
|
||||
4. **`Bootstrapped`** — `terminal_model.bootstrapped()` merges pending session info, emits `HandlerEvent::Bootstrapped`. The `ModelEventDispatcher` synchronously calls `sessions.initialize_bootstrapped_session()`, which creates the `RemoteCommandExecutor` from the socket path, stores the session, and emits `SessionBootstrapped`. The view shows the normal prompt.
|
||||
|
||||
### Key constraint
|
||||
|
||||
`initialize_bootstrapped_session()` is called synchronously inside the `ModelEventDispatcher` event handler for `HandlerEvent::Bootstrapped` (model_events.rs:98-106). This is the only place session initialization happens. All downstream logic (view, history, telemetry) flows from the `SessionBootstrapped` event it emits.
|
||||
|
||||
## Proposed changes
|
||||
|
||||
### 1. New feature flag
|
||||
|
||||
Add `RemoteServerSSH` to the `FeatureFlag` enum in `warp_core/src/features.rs`. Add it to `DOGFOOD_FLAGS` for initial rollout.
|
||||
|
||||
### 2. Remote server setup state machine
|
||||
|
||||
Create a new module `app/src/terminal/ssh/remote_server_setup.rs` (or similar location alongside the existing `app/src/terminal/ssh/` module) that encapsulates the install → launch → initialize flow.
|
||||
|
||||
```
|
||||
enum RemoteServerSetupState {
|
||||
/// Checking if the binary exists on remote.
|
||||
Checking,
|
||||
/// Downloading and installing the binary.
|
||||
Installing { progress_percent: Option<u8> },
|
||||
/// Binary is launched, waiting for InitializeResponse.
|
||||
Initializing,
|
||||
/// Handshake complete. Ready.
|
||||
Ready,
|
||||
/// Something failed. Fall back to ControlMaster.
|
||||
Failed { error: String },
|
||||
}
|
||||
```
|
||||
|
||||
The setup runs as an async task, using the existing SSH ControlMaster socket (from `IsLegacySSHSession::Yes { socket_path }`) to execute remote commands. The steps are:
|
||||
|
||||
1. **Check**: `ssh -o ControlPath={socket} placeholder@placeholder 'test -x ~/.warp/remote-server/oz && ~/.warp/remote-server/oz --version'`
|
||||
2. **Install** (if check fails): pipe the following script into `bash -s` over the control socket SSH connection (mirroring how `RemoteCommandExecutor` runs commands today):
|
||||
|
||||
```sh
|
||||
set -e
|
||||
arch=$(uname -m)
|
||||
case "$arch" in
|
||||
x86_64) pkg=oz-linux-x86_64.tar.gz ;;
|
||||
aarch64|arm64) pkg=oz-linux-aarch64.tar.gz ;;
|
||||
*) echo "unsupported arch: $arch" >&2; exit 2 ;;
|
||||
esac
|
||||
mkdir -p "$HOME/.warp/remote-server"
|
||||
curl -fSL "$WARP_GET_URL?package=$pkg" -o "$HOME/.warp/remote-server/oz.tar.gz"
|
||||
tar -xzf "$HOME/.warp/remote-server/oz.tar.gz" -C "$HOME/.warp/remote-server"
|
||||
chmod +x "$HOME/.warp/remote-server/oz"
|
||||
```
|
||||
|
||||
`$WARP_GET_URL` is substituted at runtime from the configured server root URL (`SERVER_ROOT_URL` env var or its compiled-in default), pointing at `/download/cli`. Exit code 2 is mapped to `ErrorReason::UnsupportedPlatform`. The script is shipped as a constant `&str` in the install module. Parse `curl` stderr for download progress if available.
|
||||
3. **Launch**: `ssh -o ControlPath={socket} placeholder@placeholder '~/.warp/remote-server/oz'` — keep the SSH channel open, forwarding stdin/stdout.
|
||||
4. **Initialize**: Send a `ClientMessage { request_id, initialize: Initialize {} }` (length-prefixed protobuf) to the process's stdin. Read a `ServerMessage { initialize_response }` from stdout. Timeout after 10 seconds.
|
||||
|
||||
The async task communicates state changes back to the UI via an event channel.
|
||||
|
||||
### 3. New event: `RemoteServerReady`
|
||||
|
||||
Add a new `Event` variant:
|
||||
|
||||
```
|
||||
Event::RemoteServerReady {
|
||||
session_id: SessionId,
|
||||
result: Result<(), RemoteServerSetupError>,
|
||||
}
|
||||
```
|
||||
|
||||
This is sent by the async setup task when it completes (success or failure). The `ModelEventDispatcher` receives this alongside the existing `HandlerEvent::Bootstrapped`.
|
||||
|
||||
### 4. Gate session initialization on both conditions
|
||||
|
||||
This is the core change. In `ModelEventDispatcher` (model_events.rs), when the feature flag is enabled and the session is a legacy SSH session:
|
||||
|
||||
- When `HandlerEvent::Bootstrapped` arrives: store the `BootstrappedEvent` payload in a new field `pending_bootstrapped_event: Option<BootstrappedEvent>` on `ModelEventDispatcher`. Do NOT call `initialize_bootstrapped_session()` yet.
|
||||
- When `Event::RemoteServerReady` arrives: store the result.
|
||||
- After either event, check if both are present. If so, call `initialize_bootstrapped_session()` as normal.
|
||||
- If the remote server setup failed, call `initialize_bootstrapped_session()` anyway (fallback to ControlMaster executor).
|
||||
|
||||
When the flag is disabled, or the session is not a legacy SSH session, the existing synchronous path is unchanged.
|
||||
|
||||
### 5. Dynamic bootstrapping message
|
||||
|
||||
Extend `bootstrapping_shell_message()` in `prompt_render_helper.rs` to show stage-specific messages. The `Sessions` model (or a new model) needs to expose the current `RemoteServerSetupState` for the pending session. The render helper checks this state:
|
||||
|
||||
- `RemoteServerSetupState::Checking` → "Starting shell..." (unchanged)
|
||||
- `RemoteServerSetupState::Installing { progress_percent: Some(p) }` → "Installing Warp SSH tools... ({p}%)"
|
||||
- `RemoteServerSetupState::Installing { progress_percent: None }` → "Installing Warp SSH tools..."
|
||||
- `RemoteServerSetupState::Initializing` → "Initializing..."
|
||||
- No remote server state (flag off, non-SSH) → existing behavior
|
||||
|
||||
The state is stored on `Sessions` keyed by `SessionId` and updated via events from the async setup task. The prompt re-renders on each state change via `ctx.notify()`.
|
||||
|
||||
### 6. Command executor selection
|
||||
|
||||
In `new_command_executor_for_local_tty_session()` (command_executor.rs:245-258), when the flag is enabled and the remote server is ready, create a new `RemoteServerCommandExecutor` instead of `RemoteCommandExecutor`. This new executor sends commands to the running remote server process via stdin/stdout protobuf messages instead of opening SSH channels.
|
||||
|
||||
For the initial iteration, the new executor can wrap the same interface but communicate through the persistent process. If the remote server setup failed, the existing `RemoteCommandExecutor` (ControlMaster) is used as fallback.
|
||||
|
||||
### 7. Cleanup on SSH exit
|
||||
|
||||
The remote server process is spawned as an SSH channel via ControlMaster. When the SSH session exits:
|
||||
- The ControlMaster connection is torn down, which kills the remote process.
|
||||
- The `Arc<Session>` holding the executor is dropped via Rust's RAII.
|
||||
- No explicit cleanup is needed.
|
||||
|
||||
## End-to-end flow
|
||||
|
||||
1. User runs `ssh user@host`.
|
||||
2. `PreInteractiveSSHSession` DCS → no-op.
|
||||
3. `SSH` DCS → stores socket path in `pending_legacy_ssh_session`.
|
||||
4. `InitShell` DCS → creates `SessionInfo::create_pending()` with `IsLegacySSHSession::Yes { socket_path }`. Warp input appears with "Starting shell...". **If `RemoteServerSSH` flag is enabled**: kicks off the async remote server setup task using the socket path from the pending session info.
|
||||
5. Setup task transitions: Checking → Installing (if needed) → Initializing. Each state change updates the `Sessions` model and triggers a prompt re-render.
|
||||
6. `Bootstrapped` DCS → `ModelEventDispatcher` stores the `BootstrappedEvent` instead of immediately initializing the session.
|
||||
7. Setup task sends `Event::RemoteServerReady { session_id, Ok(()) }`.
|
||||
8. `ModelEventDispatcher` sees both conditions met, calls `initialize_bootstrapped_session()` which creates the `RemoteServerCommandExecutor`.
|
||||
9. `SessionBootstrapped` event fires. View transitions to normal prompt with working directory.
|
||||
|
||||
If `Bootstrapped` arrives after `RemoteServerReady`, step 6 triggers immediate initialization. The order doesn't matter.
|
||||
|
||||
## Risks and mitigations
|
||||
|
||||
**Risk: Setup task blocks on slow network.** The download could take a long time on a slow connection. Mitigation: the shell bootstrap runs in parallel, so only the delta matters. Show progress percentage to set expectations. Timeout the entire setup after a generous limit (e.g. 60 seconds) and fall back.
|
||||
|
||||
**Risk: ControlMaster socket gone before setup completes.** If the SSH session drops during setup, the task will fail. Mitigation: the task uses the existing error handling — any SSH command failure transitions to `Failed` state, and the session falls back.
|
||||
|
||||
**Risk: Remote host has no curl or wget.** Mitigation: try `curl` first, then `wget`. If neither exists, transition to `Failed` and fall back to ControlMaster.
|
||||
|
||||
**Risk: Race between `Bootstrapped` and `RemoteServerReady` events.** Mitigation: the `ModelEventDispatcher` stores whichever arrives first and processes initialization when both are present. This is a simple two-flag check, not a complex synchronization problem.
|
||||
|
||||
**Risk: Regression for non-SSH sessions.** Mitigation: the gating logic in `ModelEventDispatcher` only applies when the feature flag is enabled AND `is_legacy_ssh_session` is `Yes`. All other sessions use the existing synchronous path.
|
||||
|
||||
## Testing and validation
|
||||
|
||||
- **Unit tests**: Test `RemoteServerSetupState` transitions, `uname` output parsing, download URL construction.
|
||||
- **Unit tests**: Test the two-condition gate in `ModelEventDispatcher` — verify `initialize_bootstrapped_session` is called only when both conditions are met, in both arrival orders.
|
||||
- **Unit tests**: Test fallback behavior when setup fails — verify `RemoteCommandExecutor` is created instead of `RemoteServerCommandExecutor`.
|
||||
- **Integration test**: SSH into a Docker container (using the existing SSH testing setup in the repo). Verify binary installation, launch, and Initialize handshake.
|
||||
- **Manual testing**: SSH into fresh Linux x86_64 and aarch64 hosts. Verify prompt message transitions. SSH again to verify installation is skipped.
|
||||
- **Feature flag off**: Verify no behavioral change with the flag disabled.
|
||||
|
||||
## Follow-ups
|
||||
|
||||
- **Version checking**: Compare installed binary version to client version and re-install on mismatch.
|
||||
- **Auto-update**: Silently update the binary when a newer version is available.
|
||||
- **Richer remote server capabilities**: File watching, codebase indexing, cached completions via the persistent process.
|
||||
- **Replace ControlMaster entirely**: Once the remote server is stable, remove the ControlMaster-based `RemoteCommandExecutor` code path.
|
||||
- **Progress reporting**: Improve download progress by parsing curl/wget output more reliably.
|
||||
Reference in New Issue
Block a user