Files
galaxy/specs/APP-4069/TECH.md
T

41 KiB
Raw Blame History

APP-4069 — SSH Initialization UX

Linear: APP-4069 — Initialization UX

1. Problem

When a user SSHes into a remote host, we want to introduce a choice block for users to choose between (1) installing and connecting to the remote server (2) falling back to the existing warpify behaviour.

To do so, we'll need to block the current bootstrap and connect server flow. Today, the client has a race where: PtyController writes the legacy bootstrap script to the PTY synchronously on InitShell, while TerminalView in parallel kicks off an async background task in RemoteServerManager that checks for the remote-server binary, installs it if missing, and initializes the server. Because the bootstrap is written before the check completes, we cannot defer or cancel warpification based on the check result — by the time we know whether the remote server is available, the legacy warpification has already taken effect. This spec resolves the race by deferring the bootstrap write under the control of the remote-server setup outcome, and introduces a two-option choice block that appears only when the binary is missing:

  • Yes, install — flush the bootstrap, install the binary on the remote, launch + handshake the remote server. Session is fully warpified via the remote-server path.
  • No, skip — flush the stashed bootstrap (so the shell is properly initialized) but do not call connect_session. The session falls back to ControlMaster warpification without engaging the remote-server path.

This spec covers the following two sections:

  • Part 1 — Blocking and wiring. A new per-pane RemoteServerController owns the state machine that defers the bootstrap, checks the binary via RemoteServerManager, and flushes at the right moment.
  • Part 2 — Rendering. How RemoteServerController signals TerminalView via ModelEventDispatcher to render the two-option dialog, and how the view forwards the user's decision back.

2. Relevant code

  • crates/remote_server/src/manager.rs:168284RemoteServerManager::connect_session orchestrates check → install → launch → handshake as a single background task.
  • crates/remote_server/src/manager.rs:599667ensure_binary_installed free function; where we split the check phase from the install phase.
  • app/src/terminal/writeable_pty/pty_controller.rs:122210PtyController::new constructor and subscription to ModelEventDispatcher, where InitShell is handled today.
  • app/src/terminal/writeable_pty/pty_controller.rs:393509initialize_shell + write_bootstrap_script_to_shell; the target of the deferred write.
  • app/src/terminal/view.rs:1081310836 — current view-level InitShell handler that calls RemoteServerManager::connect_session. This entire block gets deleted.
  • app/src/terminal/view.rs:41284160 — current bridge that forwards RemoteServerManagerEvent::SetupReady / SessionConnectionFailed into the terminal event stream. Per §4.2.1, the SetupReady arm is replaced with a SessionConnected arm; SetupFailed arm is renamed to SessionConnectionFailed, and the SetupStateChanged arm is unchanged.
  • app/src/terminal/model_events.rs:4155, 433562ModelEventDispatcher struct and ModelEvent enum, where new variants and methods for dialog signaling are added.
  • app/src/terminal/model_events.rs:93104 — emission site where HandlerEvent::InitShell is converted into a ModelEvent; the single place where the SSH-vs-regular branch is introduced (§4.1.1).
  • app/src/terminal/model_events.rs:105160, 340369 — existing Bootstrapped ↔ RemoteServerReady stash-and-wait gate; unchanged.
  • app/src/terminal/writeable_pty/terminal_manager_util.rs:120init_pty_controller_model where both PtyController and the new RemoteServerController are constructed side-by-side.
  • app/src/terminal/model/session.rs:511514IsLegacySSHSession enum used to distinguish sessions we can engage.
  • crates/warp_features/src/lib.rs:806FeatureFlag::SshRemoteServer.
  • app/src/ai/blocklist/inline_action/ask_user_question_view.rs — existing block-based dialog we draw visual inspiration from; HeaderConfig + NumberShortcutButtons are the reusable primitives.

3. Current state

Two subscribers react synchronously to ModelEvent::Handler(AnsiHandlerEvent::InitShell):

  • PtyController (in pty_controller.rs:126129) calls initialize_shell, which unconditionally writes the bootstrap script to the PTY via write_bootstrap_script_to_shell.
  • TerminalView (in view.rs:1081310836) spawns RemoteServerManager::connect_session, which runs check + install + launch + handshake in a single background task. Because both subscribers fire on the same tick but the view's work is async, the bootstrap is already written by the time the check result is known. This is the core race. RemoteServerManager::connect_session today is monolithic: it emits SetupStateChanged(Checking) → runs the check → on "not installed" emits SetupStateChanged(Installing) and runs the install → on success emits SetupReady and proceeds to launch + handshake. There is no way to observe the binary-presence result without also triggering the install. ModelEventDispatcher already has a stash-and-wait gate that waits for both Bootstrapped (from the remote shell sourcing the bootstrap script) and RemoteServerReady (forwarded today from RemoteServerManager::SetupReady) before calling complete_bootstrapped_session. The gate logic itself is unchanged, but its success-signal source moves: today SetupReady fires after the install decision but before start_remote_server and client.initialize() have run, so it is optimistic — launch or handshake can still fail after the gate has already resolved with ready=true (because Bootstrapped typically arrives while the handshake is still in flight), at which point the session has been committed to the warpified path against a manager that has no connected client. §4.2.1 sources the gate's success signal from SessionConnected (emitted only after handshake succeeds at manager.rs:535) to fix this.

4. Proposed changes

Part 1: Blocking and wiring

4.1 RemoteServerController — per-pane orchestrator

A new per-pane Entity model, RemoteServerController, owns the block-and-bootstrap state machine. PtyController stays a byte writer: it exposes initialize_shell as pub(crate) so the controller can flush the deferred bootstrap. TerminalView no longer initiates anything — it only renders the dialog (Part 2) and forwards clicks to the controller. Construction. Constructed alongside PtyController in init_pty_controller_model (app/src/terminal/writeable_pty/terminal_manager_util.rs:120). Generic over T: EventLoopSender so it can hold WeakModelHandle<PtyController<T>>. Dropped with the PTY (no special Drop behaviour — by the time the controller is dropped, the PTY is gone, so there is nothing to initialize). Fields.

/// Per-SSH-init state machine. Encoding the state as an enum makes invalid
/// transitions unrepresentable and ensures the `SessionInfo` stash cannot be
/// accessed after it has been consumed.
enum SshInitState {
    Idle,
    /// Stash held, `check_binary` in flight.
    AwaitingCheck { session_info: SessionInfo },
    /// Stash held, dialog showing.
    AwaitingUserChoice { session_info: SessionInfo },
    /// Stash already flushed at Install-click time, `install_binary` in flight.
    /// `session_id` and `socket_path` retained for event-matching and connect.
    AwaitingInstall { session_id: SessionId, socket_path: PathBuf },
}

pub struct RemoteServerController<T: EventLoopSender> {
    pty_controller: WeakModelHandle<PtyController<T>>,
    model_event_dispatcher: ModelHandle<ModelEventDispatcher>,
    state: SshInitState,
}

State machine. The controller is always in exactly one of these states for a given SSH init:

  • Idle — no stash.
  • AwaitingCheck — stash held, check_binary in flight.
  • AwaitingUserChoice — stash held, dialog showing.
  • AwaitingInstall — stash already flushed, install_binary in flight. socket_path is stashed here for use in connect_session after install completes.

Transitions:

  • Idle → AwaitingCheck on SshInitShellRequested — stash info, call mgr.check_binary.
  • AwaitingCheck → Idle on BinaryCheckComplete{result: Ok(true)} — flush, call mgr.connect_session.
  • AwaitingCheck → AwaitingUserChoice on BinaryCheckComplete{result: Ok(false)}request_remote_server_block.
  • AwaitingCheck → Idle on BinaryCheckComplete{result: Err(_)} — flush, mark_remote_server_skipped (check failed — fall back to ControlMaster rather than offering install against an unreachable host).
  • AwaitingUserChoice → AwaitingInstall on Install click — flush, call mgr.install_binary.
  • AwaitingUserChoice → Idle on Skip click — flush, mark_remote_server_skipped.
  • AwaitingInstall → Idle on BinaryInstallComplete{result: Ok(())} — call mgr.connect_session (socket path read from AwaitingInstall state).
  • AwaitingInstall → Idle on BinaryInstallComplete{result: Err(_)}mark_remote_server_skipped.

Flush contract:

  • Every exit from AwaitingCheck / AwaitingUserChoice calls flush_stashed_bootstrap
  • Every AwaitingInstall entry already flushed at click time.
  • The bootstrap and remote-server setup run in parallel; the existing ModelEventDispatcher two-condition gate coordinates Bootstrapped × RemoteServerReady / SessionConnectionFailed.
  • Within a single PTY, overlapping SSH InitShell events cannot occur: the first session's remote shell is blocked waiting for the deferred bootstrap, so no second InitShell DCS can arrive until the stash is consumed. A debug_assert! guards this invariant; as a defensive fallback in release builds, the old session's bootstrap is flushed and its dispatcher gate released via mark_remote_server_skipped before the new session takes over.
impl<T: EventLoopSender> RemoteServerController<T> {
    pub fn new(
        pty_controller: WeakModelHandle<PtyController<T>>,
        model_event_dispatcher: ModelHandle<ModelEventDispatcher>,
        ctx: &mut ModelContext<Self>,
    ) -> Self {
        ctx.subscribe_to_model(&model_event_dispatcher, |me, event, ctx| {
            if let ModelEvent::SshInitShell { pending_session_info } = event {
                me.on_ssh_init_shell_requested(
                    pending_session_info.as_ref().clone(), ctx,
                );
            }
        });
        let mgr = RemoteServerManager::handle(ctx);
        ctx.subscribe_to_model(&mgr, |me, event, ctx| match event {
            RemoteServerManagerEvent::BinaryCheckComplete { session_id, result, socket_path } =>
                me.on_binary_check_complete(*session_id, result.clone(), socket_path.clone(), ctx),
            RemoteServerManagerEvent::BinaryInstallComplete { session_id, result } =>
                me.on_binary_install_complete(*session_id, result.clone(), ctx),
            _ => {}
        });
        Self {
            pty_controller, model_event_dispatcher,
            state: SshInitState::Idle,
        }
    }
}

Methods:

/// Exclusive flush primitive. Extracts the `SessionInfo` from whatever
/// stash-holding state we're in (`AwaitingCheck` or `AwaitingUserChoice`)
/// and writes the bootstrap script to the PTY.
fn flush_stashed_bootstrap(&mut self, session_info: SessionInfo, ctx: &mut ModelContext<Self>) {
    if let Some(pty) = self.pty_controller.upgrade(ctx) {
        pty.update(ctx, |pty, ctx| pty.initialize_shell(&session_info, ctx));
    } else {
        log::warn!("PtyController dropped before bootstrap could be flushed");
    }
}

// Idle -> AwaitingCheck
fn on_ssh_init_shell_requested(&mut self, info: SessionInfo, ctx: &mut ModelContext<Self>) {
    let IsLegacySSHSession::Yes { socket_path } = &info.is_legacy_ssh_session else {
        return;
    };
    let session_id = info.session_id;
    let socket_path = socket_path.clone();
    debug_assert!(matches!(self.state, SshInitState::Idle));
    // Defensive fallback: if an overlapping init arrives, flush the old
    // session's bootstrap so its shell doesn't hang and release its gate.
    match std::mem::replace(&mut self.state, SshInitState::Idle) {
        SshInitState::Idle => {}
        SshInitState::AwaitingCheck { session_info: old_info }
        | SshInitState::AwaitingUserChoice { session_info: old_info } => {
            let old_session_id = old_info.session_id;
            self.flush_stashed_bootstrap(old_info, ctx);
            self.model_event_dispatcher.update(ctx, |d, ctx| {
                d.mark_remote_server_skipped(old_session_id, ctx);
            });
        }
        SshInitState::AwaitingInstall { session_id: old_session_id, .. } => {
            // Stash already flushed at Install-click time; release the gate.
            self.model_event_dispatcher.update(ctx, |d, ctx| {
                d.mark_remote_server_skipped(old_session_id, ctx);
            });
        }
    }
    self.state = SshInitState::AwaitingCheck { session_info: info };
    RemoteServerManager::handle(ctx).update(ctx, |mgr, ctx| {
        mgr.check_binary(session_id, socket_path, ctx);
    });
}

// AwaitingCheck -> { Idle (installed / check-failed) | AwaitingUserChoice (missing) }
fn on_binary_check_complete(
    &mut self, session_id: SessionId, result: Result<bool, String>, socket_path: PathBuf,
    ctx: &mut ModelContext<Self>,
) {
    // Guard: reject events that don't match the current AwaitingCheck state.
    let SshInitState::AwaitingCheck { ref session_info } = self.state else {
        log::warn!("BinaryCheckComplete for {session_id:?} but state is not AwaitingCheck");
        return;
    };
    if session_info.session_id != session_id {
        log::warn!("BinaryCheckComplete session {session_id:?} != expected {:?}", session_info.session_id);
        return;
    }
    // Take ownership of session_info out of the state.
    let SshInitState::AwaitingCheck { session_info } = std::mem::replace(&mut self.state, SshInitState::Idle) else {
        unreachable!("just matched AwaitingCheck above");
    };
    match result {
        Ok(true) => {
            self.flush_stashed_bootstrap(session_info, ctx);
            RemoteServerManager::handle(ctx).update(ctx, |mgr, ctx| {
                mgr.connect_session(session_id, socket_path, ctx);
            });
        }
        Ok(false) => {
            self.state = SshInitState::AwaitingUserChoice { session_info };
            self.model_event_dispatcher.update(ctx, |d, ctx| {
                d.request_remote_server_block(session_id, ctx);
            });
        }
        Err(e) => {
            // Check failed — remote is unreachable or SSH is broken. Fall back
            // to ControlMaster rather than offering install against a host we
            // can't reach.
            log::warn!("Binary check failed for session {session_id:?}: {e}");
            self.flush_stashed_bootstrap(session_info, ctx);
            self.model_event_dispatcher.update(ctx, |d, ctx| {
                d.mark_remote_server_skipped(session_id, ctx);
            });
        }
    }
}

// AwaitingUserChoice -> AwaitingInstall
pub fn handle_ssh_remote_server_install(
    &mut self, session_id: SessionId, ctx: &mut ModelContext<Self>,
) {
    // Extract session_info from AwaitingUserChoice to get the socket path.
    let SshInitState::AwaitingUserChoice { ref session_info } = self.state else {
        log::warn!("Install clicked but state is not AwaitingUserChoice for {session_id:?}");
        return;
    };
    let IsLegacySSHSession::Yes { socket_path } = &session_info.is_legacy_ssh_session else {
        return;
    };
    let socket_path = socket_path.clone();
    let SshInitState::AwaitingUserChoice { session_info } = std::mem::replace(&mut self.state, SshInitState::AwaitingInstall { session_id, socket_path: socket_path.clone() }) else {
        unreachable!("just matched AwaitingUserChoice above");
    };
    self.flush_stashed_bootstrap(session_info, ctx);
    RemoteServerManager::handle(ctx).update(ctx, |mgr, ctx| {
        mgr.install_binary(session_id, socket_path, ctx);
    });
}

// AwaitingUserChoice -> Idle (explicit Skip)
pub fn handle_ssh_remote_server_skip(
    &mut self, session_id: SessionId, ctx: &mut ModelContext<Self>,
) {
    let SshInitState::AwaitingUserChoice { session_info } = std::mem::replace(&mut self.state, SshInitState::Idle) else {
        log::warn!("Skip clicked but state is not AwaitingUserChoice for {session_id:?}");
        return;
    };
    self.flush_stashed_bootstrap(session_info, ctx);
    self.model_event_dispatcher.update(ctx, |d, ctx| {
        d.mark_remote_server_skipped(session_id, ctx);
    });
}

// AwaitingInstall -> Idle
fn on_binary_install_complete(
    &mut self, session_id: SessionId, result: Result<(), String>,
    ctx: &mut ModelContext<Self>,
) {
    // Guard: reject stale events from a previous session's install.
    let SshInitState::AwaitingInstall { session_id: expected, .. } = &self.state else {
        log::warn!("BinaryInstallComplete for {session_id:?} but state is not AwaitingInstall");
        return;
    };
    if *expected != session_id {
        log::warn!("BinaryInstallComplete session {session_id:?} != expected {expected:?}");
        return;
    }
    let SshInitState::AwaitingInstall { socket_path, .. } =
        std::mem::replace(&mut self.state, SshInitState::Idle)
    else {
        unreachable!("just matched AwaitingInstall above");
    };
    match result {
        Ok(()) => {
            RemoteServerManager::handle(ctx).update(ctx, |mgr, ctx| {
                mgr.connect_session(session_id, socket_path, ctx);
            });
        }
        Err(e) => {
            log::warn!("Install failed for session {session_id:?}: {e}");
            self.model_event_dispatcher.update(ctx, |d, ctx| {
                d.mark_remote_server_skipped(session_id, ctx);
            });
        }
    }
}

The prompt view dispatches SshRemoteServerChoiceViewAction::Install / ::Skip (see §4.5) which TerminalView forwards to the corresponding RemoteServerController methods (see §4.4). PtyController change (minimal). initialize_shell is promoted to pub(crate). PtyController gains no new fields, subscriptions, or manager knowledge. A debug_assert!(self.bootstrap_file.is_none()) at the top of initialize_shell catches accidental double-calls during development.

4.1.1 Event-split at the emission site

ModelEventDispatcher::handle_terminal_model_event (app/src/terminal/model_events.rs:95104) is the single place where Event::Handler(HandlerEvent::InitShell) is converted into a ModelEvent. That branch is extended to emit a distinct variant when the session is legacy SSH and the feature flag is on:

Event::Handler(HandlerEvent::InitShell { pending_session_info }) => {
    self.sessions.update(ctx, |sessions, ctx| {
        sessions.register_pending_session(pending_session_info.as_ref(), ctx);
    });
    let is_legacy_ssh = matches!(
        pending_session_info.is_legacy_ssh_session,
        IsLegacySSHSession::Yes { .. }
    );
    if FeatureFlag::SshRemoteServer.is_enabled() && is_legacy_ssh {
        ModelEvent::SshInitShell { pending_session_info }
    } else {
        ModelEvent::Handler(AnsiHandlerEvent::InitShell { pending_session_info })
    }
}

A new ModelEvent variant:

pub enum ModelEvent {
    // ... existing variants ...
    SshInitShell { pending_session_info: Box<SessionInfo> },
}

Rationale: every subscriber only sees events it owns. PtyController keeps its synchronous InitShell handler and never runs for deferred SSH sessions. RemoteServerController subscribes only to SshInitShell and never races PtyController. Double-flush is prevented by construction — no subscription ordering dependency, no shared event. Centralizing the branch at this single emission site prevents drift: there is exactly one place to keep in sync, and any new code path that wants to emit InitShell is forced to go through this helper.

4.2 RemoteServerManager — three public phases; gate on SessionConnected

The current ensure_binary_installed is split into two public entry points on RemoteServerManagercheck_binary and install_binary — and connect_session drops its install responsibility entirely. Each phase emits a distinct terminal event so the caller (RemoteServerController) can decide independently what to do between phases and so install-phase failure can be surfaced differently from launch/handshake failure without having to infer the phase from session state. The three phases and their terminal events:

  • check_binary(session_id, socket_path) → emits SetupStateChanged(Checking), then BinaryCheckComplete { result: Result<bool, String> } where Ok(true) = installed, Ok(false) = definitively not installed, Err(_) = check itself failed (e.g. SSH timeout/unreachable).
  • install_binary(session_id, socket_path) → emits SetupStateChanged(Installing), then BinaryInstallComplete { result: Result<(), String> }. Does not emit SessionConnectionFailed on install failure — install-phase failure is signaled exclusively via BinaryInstallComplete { result: Err(_) }, leaving SessionConnectionFailed scoped to launch/handshake failure.
  • connect_session(session_id, socket_path) → emits SessionConnecting, then either SessionConnected (on handshake success) or SessionConnectionFailed (on launch or handshake failure). Assumes the binary is present; callers that need to install first call install_binary and wait for BinaryInstallComplete { result: Ok(()) }. connect_session is also tightened so launch (start_remote_server) and handshake (client.initialize()) failures both emit SessionConnectionFailed instead of only calling mark_session_disconnected. Under the new gating scheme (§4.2.1) SessionConnectionFailed is the sole signal that releases the gate with ready=false on a launch/handshake failure, so it must fire on every terminal failure in those phases. New event variants on RemoteServerManagerEvent:
pub enum RemoteServerManagerEvent {
    // ... existing variants ...
    /// Result of check_binary. `Ok(true)` = binary is installed and
    /// executable, `Ok(false)` = definitively not installed, `Err(_)` =
    /// the check itself failed (SSH error, timeout). Callers can
    /// distinguish "not installed" from "check failed" to avoid offering
    /// install when the remote is unreachable. The error type is `String`
    /// rather than `anyhow::Error` because the event derives `Clone` and
    /// `anyhow::Error` is not `Clone`.
    /// Carries `socket_path` because `flush_stashed_bootstrap` consumes the
    /// `SessionInfo` stash (via `take()`), so the controller can no longer
    /// extract the path from the stash by the time it needs to call
    /// `connect_session`. The event is the only source of the path after flush.
    BinaryCheckComplete {
        session_id: SessionId,
        result: Result<bool, String>,
        socket_path: PathBuf,
    },
    /// Result of install_binary. `Ok(())` = install succeeded; `Err(_)`
    /// carries the failure reason (SSH error, timeout, script error).
    /// Scoped to the install phase only so callers can render a
    /// phase-specific error without inferring from session state.
    /// Does not carry `socket_path` — the controller stashes it in
    /// `AwaitingInstall` at Install-click time and reads it back on
    /// completion, avoiding a round-trip through the event.
    BinaryInstallComplete {
        session_id: SessionId,
        result: Result<(), String>,
    },
}

Public methods:

impl RemoteServerManager {
    /// Pure check. Emits SetupStateChanged(Checking) then BinaryCheckComplete.
    /// Does NOT mutate self.sessions — safe to call concurrently with other
    /// session-state transitions (including `deregister_session`). A
    /// `BinaryCheckComplete` that arrives for an already-torn-down session is
    /// a no-op downstream because `RemoteServerController::on_binary_check_complete`
    /// guards on the state being `AwaitingCheck` with a matching session ID.
    ///
    /// On SSH timeout or error, `run_ssh_command` returns `Err` and the
    /// normal code path emits `BinaryCheckComplete { result: Err(_) }`,
    /// so `AwaitingCheck` is time-bounded by `CHECK_TIMEOUT`.
    pub fn check_binary(
        &self,
        session_id: SessionId,
        socket_path: PathBuf,
        ctx: &mut ModelContext<Self>,
    ) {
        ctx.emit(RemoteServerManagerEvent::SetupStateChanged {
            session_id,
            state: RemoteServerSetupState::Checking,
        });
        let spawner = self.spawner.clone();
        ctx.background_executor().spawn(async move {
            let result = match run_ssh_command(&socket_path, &binary_check_command(), CHECK_TIMEOUT).await {
                Ok(output) => match output.status.code() {
                    Some(0) => Ok(true),
                    Some(1) => Ok(false),
                    Some(code) => {
                        let stderr = String::from_utf8_lossy(&output.stderr);
                        Err(format!("binary check exited with code {code}: {stderr}"))
                    }
                    None => Err("binary check terminated by signal".into()),
                },
                Err(e) => Err(e.to_string()),
            };
            let _ = spawner.spawn(move |_, ctx| {
                ctx.emit(RemoteServerManagerEvent::BinaryCheckComplete {
                    session_id, result, socket_path,
                });
            }).await;
        }).detach();
    }
    /// Pure install. Emits SetupStateChanged(Installing) then
    /// BinaryInstallComplete. Does NOT mutate self.sessions — install is a
    /// side-effect on the remote host, decoupled from the session's
    /// connection lifecycle. Does NOT emit SessionConnectionFailed; install failure is
    /// signaled exclusively via BinaryInstallComplete { result: Err(_) },
    /// leaving SessionConnectionFailed scoped to launch/handshake failure.
    ///
    /// On SSH timeout or error, `run_ssh_script` returns `Err` and the
    /// normal code path emits `BinaryInstallComplete { result: Err(_) }`,
    /// so `AwaitingInstall` is time-bounded by `INSTALL_TIMEOUT`.
    pub fn install_binary(
        &self,
        session_id: SessionId,
        socket_path: PathBuf,
        ctx: &mut ModelContext<Self>,
    ) {
        ctx.emit(RemoteServerManagerEvent::SetupStateChanged {
            session_id,
            state: RemoteServerSetupState::Installing { progress_percent: None },
        });
        let spawner = self.spawner.clone();
        ctx.background_executor().spawn(async move {
            let result = match run_ssh_script(&socket_path, &install_script(), INSTALL_TIMEOUT).await {
                Ok(output) if output.status.success() => Ok(()),
                Ok(output) => {
                    let code = output.status.code().unwrap_or(-1);
                    let stderr = String::from_utf8_lossy(&output.stderr);
                    Err(format!("install script failed (exit {code}): {stderr}"))
                }
                Err(e) => Err(e.to_string()),
            };
            let _ = spawner.spawn(move |_, ctx| {
                ctx.emit(RemoteServerManagerEvent::BinaryInstallComplete {
                    session_id, result,
                });
            }).await;
        }).detach();
    }
    /// Launch + handshake only. Assumes the binary is already present on
    /// the remote host; callers that need to install first should call
    /// `install_binary` and wait for `BinaryInstallComplete { result: Ok(()) }`
    /// before calling this. Emits SessionConnecting, then either
    /// SessionConnected (on handshake success) or SessionConnectionFailed (on launch or
    /// handshake failure).
    pub fn connect_session(
        &mut self,
        session_id: SessionId,
        socket_path: PathBuf,
        ctx: &mut ModelContext<Self>,
    ) {
        self.sessions.insert(session_id, RemoteSessionState::Connecting);
        ctx.emit(RemoteServerManagerEvent::SessionConnecting { session_id });
        let spawner = self.spawner.clone();
        ctx.background_executor().spawn(async move {
            // Launch + handshake tail. Any failure here emits SessionConnectionFailed
            // so the gate can release, not just mark_session_disconnected.
            match start_remote_server(&socket_path).await {
                Ok((stdin, stdout, stderr)) => {
                    // create RemoteServerClient, initialize handshake.
                    // On handshake Err: emit SessionConnectionFailed + mark_session_disconnected.
                }
                Err(_) => {
                    let _ = spawner.spawn(move |me, ctx| {
                        ctx.emit(RemoteServerManagerEvent::SessionConnectionFailed { session_id });
                        me.mark_session_disconnected(session_id, ctx);
                    }).await;
                }
            }
        }).detach();
    }
}

4.2.1 Gate release signal: SessionConnected instead of SetupReady

As described in §3, SetupReady is optimistic — it fires before launch/handshake. We fix this by sourcing the gate's success signal from SessionConnected instead (mark_session_connected at manager.rs:535), which fires only after client.initialize() succeeds. By that point the RemoteServerClient is wired up and client_for_session(session_id) returns Some. Bridge change in view.rs:41294161: replace the SetupReady arm with a SessionConnected arm forwarded as Event::RemoteServerReady. The SetupFailed arm is renamed to SessionConnectionFailed, and the SetupStateChanged arm is unchanged.

// Before:
RemoteServerManagerEvent::SetupReady { session_id } => {
    me.model.lock().event_proxy.send_terminal_event(
        Event::RemoteServerReady { session_id: *session_id },
    );
}
// After:
RemoteServerManagerEvent::SessionConnected { session_id, host_id: _ } => {
    me.model.lock().event_proxy.send_terminal_event(
        Event::RemoteServerReady { session_id: *session_id },
    );
}

SessionConnected and SessionConnectionFailed are mutually exclusive per session. The specific emission branches are disjoint:

  • SessionConnectedmark_session_connected, which runs only on the Ok(client.initialize()) branch in connect_session.
  • SessionConnectionFailedstart_remote_server Err branch / handshake Err branch. Install failure is not in this set — it emits BinaryInstallComplete { result: Err(_) } instead, which RemoteServerController routes through mark_remote_server_skipped to release the gate with ready=false. (deregister_session is currently dead code with no callers; if it gains callers in the future, it should also emit SessionConnectionFailed for sessions in Connecting/Initializing state to prevent the gate from hanging.)

Part 2: Rendering

4.3 ModelEventDispatcher — dialog signaling primitives

Only "show" crosses the model→view boundary. Dismissal is owned by TerminalView (on click or SessionDeregistered). New ModelEvent variant:

pub enum ModelEvent {
    // ... existing variants ...
    RemoteServerBlockRequested { session_id: SessionId },
}

New public methods on ModelEventDispatcher:

impl ModelEventDispatcher {
    pub fn request_remote_server_block(
        &mut self, session_id: SessionId, ctx: &mut ModelContext<Self>,
    ) { ctx.emit(ModelEvent::RemoteServerBlockRequested { session_id }); }
    /// Resolves the remote-server condition as failed for Skip, so the
    /// two-condition gate can release and ControlMaster fallback engages.
    pub fn mark_remote_server_skipped(
        &mut self, session_id: SessionId, ctx: &mut ModelContext<Self>,
    ) { self.handle_remote_server_setup_result(session_id, false, ctx); }
}

4.4 TerminalView — dialog render + choice forwarding

Delete the RemoteServerManager::connect_session call in the InitShell handler (view.rs:1081610836). The view no longer initiates the check; that responsibility has moved to RemoteServerController. Add one match arm for the new ModelEvent variant that shows the SshRemoteServerChoiceView block (see §4.5). Dismissal is handled inline by the click handlers below, not via a match arm:

ModelEvent::RemoteServerBlockRequested { session_id } => {
    self.show_ssh_remote_server_choice_block(session_id, ctx);
}

New view methods:

  • show_ssh_remote_server_choice_block(session_id, ctx) — creates a new inline block containing the SshRemoteServerChoiceView and tracks its handle by SessionId.
  • remove_ssh_remote_server_choice_block(session_id, ctx) — removes the inline block. TerminalView gains a handle to RemoteServerController (constructed alongside PtyController per §4.1) and subscribes to the prompt view's typed actions. Each handler removes the prompt view's block directly (dialog dismissal is a view-only concern) and then forwards to the corresponding RemoteServerController method:
fn on_ssh_remote_server_install(
    &mut self,
    session_id: SessionId,
    ctx: &mut ViewContext<Self>,
) {
    self.remove_ssh_remote_server_choice_block(session_id, ctx);
    self.remote_server_controller.update(ctx, |ctrl, ctx| {
        ctrl.handle_ssh_remote_server_install(session_id, ctx);
    });
}
fn on_ssh_remote_server_skip(
    &mut self,
    session_id: SessionId,
    ctx: &mut ViewContext<Self>,
) {
    self.remove_ssh_remote_server_choice_block(session_id, ctx);
    self.remote_server_controller.update(ctx, |ctrl, ctx| {
        ctrl.handle_ssh_remote_server_skip(session_id, ctx);
    });
}

The existing view-level forwarding subscription on RemoteServerManager (view.rs:41294161) is updated per §4.2.1: its SetupReady arm is replaced with a SessionConnected arm. A SessionDeregistered arm is added to dismiss any stale dialog so it doesn't linger when the SSH session goes away (e.g. network drop, user Ctrl-C, exit):

RemoteServerManagerEvent::SessionDeregistered { session_id } => {
    me.remove_ssh_remote_server_choice_block(*session_id, ctx);
}

The SetupFailed arm is renamed to SessionConnectionFailed, and the SetupStateChanged arm is unchanged. The subscription continues to feed ModelEventDispatcher's two-condition gate via Event::RemoteServerReady / Event::RemoteServerFailed.

4.5 SshRemoteServerChoiceView — the two-option dialog view

New file: app/src/terminal/view/ssh_remote_server_choice_view.rs (final location TBD alongside existing inline block views). A standalone View composed of existing block primitives. We don't extract a shared ChoiceBlockView abstraction: this is the only two-option choice block in the blocklist today, and HeaderConfig + NumberShortcutButtons compose cleanly into the view without further factoring. If a second consumer with similar structure arrives, the abstraction can be lifted at that point. Structure:

  • HeaderHeaderConfig from inline_action_header with title "Install Warp remote server?" and a suitable icon.
  • Descriptionrender_text_with_markdown_support (the same helper AskUserQuestionView uses) briefly explaining that Warp can install a helper binary on the remote host to enable remote-server features.
  • Two numbered buttons via NumberShortcutButtons:
    • 1. "Install Warp's SSH extension"
    • 2. "Continue without installing"
  • Session association — constructor takes a SessionId used in click callbacks. The view is a pure renderer of the two-button prompt and owns no internal state beyond the session ID; dismissal is performed by TerminalView removing the block on click. Action enum on the prompt view:
#[derive(Clone, Debug)]
pub enum SshRemoteServerChoiceViewAction {
    Install,
    Skip,
}

Click handlers dispatch SshRemoteServerChoiceViewAction::Install / ::Skip via dispatch_typed_action. TerminalView subscribes to the prompt view and maps each action to handle_ssh_remote_server_install / handle_ssh_remote_server_skip. The dialog does not own any state about the stash, the socket path, or the manager — it is a pure renderer + user-input forwarder. 1 / 2 keyboard shortcuts come for free from NumberShortcutButtons. No AI action model coupling. The view matches AskUserQuestionView's visual style (header + description + numbered buttons + rounded-corner border) so the block feels at home in the blocklist.

5. End-to-end flow

Happy path (binary present — no dialog)

sequenceDiagram
    participant Shell as Remote shell
    participant TM as TerminalModel
    participant MED as ModelEventDispatcher
    participant Ctrl as RemoteServerController
    participant Pty as PtyController
    participant Mgr as RemoteServerManager
    participant View as TerminalView
    Shell->>TM: InitShell DCS + SSHValue(socket_path)
    TM->>MED: Event::InitShell
    MED->>Ctrl: ModelEvent::SshInitShell
    Note over Ctrl: stash SessionInfo<br/>(AwaitingCheck)
    Ctrl->>Mgr: check_binary(session_id, socket_path)
    Mgr->>Ctrl: BinaryCheckComplete { result: Ok(true) }
    Ctrl->>Pty: initialize_shell(info) → write bootstrap
    Ctrl->>Mgr: connect_session(session_id, socket_path)
    Mgr->>Mgr: launch + handshake
    Mgr->>View: SessionConnected (bridge forwards as Event::RemoteServerReady, see §4.2.1)
    Shell->>TM: Bootstrapped DCS
    MED->>MED: complete_bootstrapped_session<br/>(existing two-condition gate)

Install path (binary missing → user picks Install)

sequenceDiagram
    participant User
    participant Shell as Remote shell
    participant TM as TerminalModel
    participant MED as ModelEventDispatcher
    participant Ctrl as RemoteServerController
    participant Pty as PtyController
    participant Mgr as RemoteServerManager
    participant View as TerminalView
    Shell->>TM: InitShell DCS + SSHValue(socket_path)
    MED->>Ctrl: ModelEvent::SshInitShell
    Note over Ctrl: stash SessionInfo<br/>(AwaitingCheck)
    Ctrl->>Mgr: check_binary
    Mgr->>Ctrl: BinaryCheckComplete { result: Ok(false) }
    Ctrl->>MED: request_remote_server_block
    Note over Ctrl: (AwaitingUserChoice)
    MED->>View: ModelEvent::RemoteServerBlockRequested
    View->>View: render SshRemoteServerChoiceView
    User->>View: clicks "Install"
    View->>View: remove_ssh_remote_server_choice_block
    View->>Ctrl: handle_ssh_remote_server_install
    Ctrl->>Pty: initialize_shell(info) → write bootstrap
    Note over Ctrl: (AwaitingInstall)
    Ctrl->>Mgr: install_binary(session_id, socket_path)
    Note over Shell,Mgr: bootstrap sourcing and install run in parallel
    Mgr->>Mgr: install
    Mgr->>Ctrl: BinaryInstallComplete { result: Ok(()) }
    Ctrl->>Mgr: connect_session(session_id, socket_path)
    Mgr->>Mgr: launch + handshake
    Mgr->>View: SessionConnected (bridge forwards as Event::RemoteServerReady, see §4.2.1)
    Shell->>TM: Bootstrapped DCS
    MED->>MED: complete_bootstrapped_session<br/>(both conditions met)

Skip path (binary missing → user picks Skip)

sequenceDiagram
    participant User
    participant Shell as Remote shell
    participant View as TerminalView
    participant Ctrl as RemoteServerController
    participant Pty as PtyController
    participant TM as TerminalModel
    participant MED as ModelEventDispatcher
    Note over Ctrl,View: already showed the dialog<br/>(see Install path above through DialogRequested)
    User->>View: clicks "Skip"
    View->>View: remove_ssh_remote_server_choice_block
    View->>Ctrl: handle_ssh_remote_server_skip
    Ctrl->>Pty: initialize_shell(info) → write bootstrap
    Ctrl->>MED: mark_remote_server_skipped
    MED->>MED: remote_server_ready = Some(false)
    Shell->>TM: Bootstrapped DCS<br/>(shell sources the flushed bootstrap)
    TM->>MED: Event::Bootstrapped
    MED->>MED: release two-condition gate<br/>complete_bootstrapped_session(ready=false)<br/>→ ControlMaster fallback engages

Failure path (install failure after Install was picked)

sequenceDiagram
    participant Shell as Remote shell
    participant Ctrl as RemoteServerController
    participant Mgr as RemoteServerManager
    participant TM as TerminalModel
    participant MED as ModelEventDispatcher
    Note over Mgr: install_binary fails (SSH timeout or error).<br/>Bootstrap was already flushed at Install click time.
    Mgr->>Ctrl: BinaryInstallComplete { result: Err(_) }
    Ctrl->>MED: mark_remote_server_skipped
    MED->>MED: remote_server_ready = Some(false)
    Shell->>TM: Bootstrapped DCS<br/>(shell sources the flushed bootstrap)
    TM->>MED: Event::Bootstrapped
    MED->>MED: release two-condition gate<br/>complete_bootstrapped_session(ready=false)<br/>→ ControlMaster fallback engages

Failure path (launch or handshake failure after Install was picked)

sequenceDiagram
    participant Shell as Remote shell
    participant Ctrl as RemoteServerController
    participant Mgr as RemoteServerManager
    participant View as TerminalView
    participant TM as TerminalModel
    participant MED as ModelEventDispatcher
    Note over Mgr: BinaryInstallComplete { result: Ok(()) } already delivered.<br/>Ctrl called connect_session; launch or handshake fails.
    Mgr->>Ctrl: SessionConnectionFailed
    Mgr->>View: SessionConnectionFailed (existing bridge at view.rs:4151)
    View->>TM: Event::RemoteServerFailed<br/>(remote_server_ready = Some(false))
    Shell->>TM: Bootstrapped DCS<br/>(shell sources the flushed bootstrap)
    TM->>MED: Event::Bootstrapped
    MED->>MED: release two-condition gate<br/>complete_bootstrapped_session(ready=false)<br/>→ ControlMaster fallback engages