From 4b72909354867d8770f7be3fde44370c7a23f6a8 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 30 Sep 2026 07:46:13 +0000 Subject: [PATCH 1/2] feat(remote): cut off all managed endpoints on sign-out Signing out only cleared the relay token, so pooled managed SSH connections and their PTYs kept accepting exec and PTY traffic. SshConnectionPool::cut_off_managed tears down every pooled managed connection and refuses new managed connections until a signed-in token arrives. The remote_cut_off_managed command also closes the PTYs for those endpoints and emits remote://cutoff (session_ended) for each one. User-managed endpoints are untouched. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01MbYSPHBRmDYwPJpHG33Y4t --- src-tauri/src/commands/remote_control.rs | 24 +++++ src-tauri/src/core/remote_ssh_transport.rs | 108 ++++++++++++++++++++- src-tauri/src/lib.rs | 1 + 3 files changed, 132 insertions(+), 1 deletion(-) diff --git a/src-tauri/src/commands/remote_control.rs b/src-tauri/src/commands/remote_control.rs index 2e4138c93..53fad6f0d 100644 --- a/src-tauri/src/commands/remote_control.rs +++ b/src-tauri/src/commands/remote_control.rs @@ -205,6 +205,30 @@ pub async fn remote_force_cutoff( Ok(()) } +/// Cuts off every managed endpoint on sign-out (PRD "Hard cutoff on +/// revocation or expiry": the Supabase session ended). Tears down pooled +/// managed connections and their PTY sessions, emits `remote://cutoff` for +/// each, and refuses new managed connections until the user signs in again. +#[tauri::command] +pub async fn remote_cut_off_managed( + app: tauri::AppHandle, + state: State<'_, RemoteExecState>, + pty_state: State<'_, crate::commands::remote_pty_commands::RemotePtyState>, +) -> Result<(), String> { + let reason = CutoffReasonDto::SessionEnded; + for endpoint_id in state.0.cut_off_managed(reason.into()).await { + pty_state.0.close_all_for_endpoint(&endpoint_id).await; + let _ = app.emit( + REMOTE_CUTOFF_EVENT, + RemoteCutoffEvent { + endpoint_id, + reason, + }, + ); + } + Ok(()) +} + /// Clears a previously forced cutoff after the user reauthenticates and a /// fresh certificate is issued through the normal registration and issuance /// flow (PRD "The user regains access only by reauthenticating and obtaining diff --git a/src-tauri/src/core/remote_ssh_transport.rs b/src-tauri/src/core/remote_ssh_transport.rs index 5d00cd2cc..f344a4c47 100644 --- a/src-tauri/src/core/remote_ssh_transport.rs +++ b/src-tauri/src/core/remote_ssh_transport.rs @@ -688,6 +688,8 @@ pub struct PoolKey { host_key_fingerprints: Vec, /// Relay URL for relayed endpoints; `None` for direct TCP. relay_url: Option, + /// Whether this is a Treq-managed instance, which sign-out cuts off. + managed: bool, } impl PoolKey { @@ -715,10 +717,18 @@ impl PoolKey { SshTransport::Direct => None, SshTransport::Relay { url } => Some(url.clone()), }, + managed: is_managed(endpoint), } } } +fn is_managed(endpoint: &SshEndpoint) -> bool { + matches!( + endpoint.source, + crate::core::remote_control_plane::SshEndpointSource::Managed { .. } + ) +} + struct PooledConnection { handle: Arc>>, last_used: Instant, @@ -747,6 +757,9 @@ pub struct SshConnectionPool { /// connection, so a refreshed token needs no reconnect, and it is never /// logged or included in an error. relay_access_token: std::sync::RwLock>, + /// Set by [`Self::cut_off_managed`] on sign-out: every managed endpoint is + /// refused, pooled or not, until a signed-in user's token arrives. + managed_cutoff: std::sync::RwLock>, } impl Default for SshConnectionPool { @@ -765,6 +778,7 @@ impl SshConnectionPool { cutoffs: AsyncMutex::new(HashMap::new()), device_key_provider: None, relay_access_token: std::sync::RwLock::new(None), + managed_cutoff: std::sync::RwLock::new(None), } } @@ -772,6 +786,12 @@ impl SshConnectionPool { /// connections. See the field doc on `relay_access_token`. pub fn set_relay_access_token(&self, token: Option) { let token = token.filter(|token| !token.is_empty()); + if token.is_some() { + *self + .managed_cutoff + .write() + .unwrap_or_else(|poisoned| poisoned.into_inner()) = None; + } match self.relay_access_token.write() { Ok(mut guard) => *guard = token, Err(poisoned) => *poisoned.into_inner() = token, @@ -828,6 +848,32 @@ impl SshConnectionPool { tracing::warn!(endpoint_id = %endpoint_id, reason = %reason, "ssh endpoint credential cut off"); } + /// Cuts off every managed endpoint when the user signs out: tears down + /// each pooled managed connection (see [`Self::force_cutoff`]) and refuses + /// new managed connections until [`Self::set_relay_access_token`] receives + /// a token again. Returns the endpoint ids whose connections were torn + /// down. User-managed endpoints are untouched. + pub async fn cut_off_managed(&self, reason: CutoffReason) -> Vec { + *self + .managed_cutoff + .write() + .unwrap_or_else(|poisoned| poisoned.into_inner()) = Some(reason); + let mut endpoint_ids: Vec = { + let connections = self.connections.lock().await; + connections + .keys() + .filter(|key| key.managed) + .map(|key| key.endpoint_id.clone()) + .collect() + }; + endpoint_ids.sort(); + endpoint_ids.dedup(); + for endpoint_id in &endpoint_ids { + self.force_cutoff(endpoint_id, reason).await; + } + endpoint_ids + } + /// Clears a previously forced cutoff, e.g. after the user reauthenticates /// and a fresh certificate is issued through the normal registration and /// issuance flow. Does not itself reconnect; the next call reconnects with @@ -857,7 +903,15 @@ impl SshConnectionPool { &self, endpoint: &SshEndpoint, ) -> Result>>, SshTransportError> { - if let Some(reason) = self.cutoff_reason(&endpoint.id).await { + let managed_cutoff = if is_managed(endpoint) { + *self + .managed_cutoff + .read() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + } else { + None + }; + if let Some(reason) = managed_cutoff.or(self.cutoff_reason(&endpoint.id).await) { return Err(SshTransportError::CredentialCutOff { endpoint_id: endpoint.id.clone(), reason, @@ -2651,6 +2705,58 @@ mod tests { assert!(output.success()); } + #[tokio::test] + async fn cut_off_managed_tears_down_managed_endpoints_until_sign_in() { + let (addr, host_key) = start_mock_server(0).await; + let temp_dir = tempfile::tempdir().unwrap(); + let key_reference = write_client_key(temp_dir.path(), &test_host_key()); + let user_endpoint = test_endpoint(addr, &host_key, key_reference); + let managed = SshEndpoint { + id: "managed-endpoint".to_string(), + source: SshEndpointSource::Managed { + provider: "fly_sprites".to_string(), + generation: 1, + }, + ..user_endpoint.clone() + }; + let pool = SshConnectionPool::new(); + let cancellation = CancellationToken::new(); + let run = |endpoint: &SshEndpoint| { + let endpoint = endpoint.clone(); + let (pool, cancellation) = (&pool, &cancellation); + async move { + let args = ["repo".to_string(), "inspect".to_string()]; + exec_command(pool, &endpoint, &args, ExecLimits::default(), cancellation).await + } + }; + run(&managed).await.unwrap(); + run(&user_endpoint).await.unwrap(); + + let cut = pool.cut_off_managed(CutoffReason::SessionEnded).await; + + assert_eq!(cut, vec![managed.id.clone()]); + assert_eq!(pool.pooled_connection_count().await, 1); + assert!(matches!( + run(&managed).await, + Err(SshTransportError::CredentialCutOff { + reason: CutoffReason::SessionEnded, + .. + }) + )); + assert!(run(&user_endpoint).await.is_ok()); + + // Signing in again lifts the blanket block; the torn-down endpoint + // still waits for its own reauthentication. + let fresh = SshEndpoint { + id: "other-managed".to_string(), + ..managed.clone() + }; + assert!(run(&fresh).await.is_err()); + pool.set_relay_access_token(Some("token".to_string())); + assert!(run(&fresh).await.is_ok()); + assert!(run(&managed).await.is_err()); + } + // -- Retrying after network loss (PRD "Structured command protocol" > // "Retrying after network loss") --------------------------------------- diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 1233a12d1..642e782db 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -875,6 +875,7 @@ pub fn run() { commands::remote_transport_metrics, commands::remote_force_cutoff, commands::remote_clear_cutoff, + commands::remote_cut_off_managed, commands::remote_set_relay_access_token, commands::remote_cutoff_reason, commands::set_window_repo_path, From 27548199edb45bff1b7b1b8cc692ffbe3de11679 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 30 Sep 2026 08:11:16 +0000 Subject: [PATCH 2/2] fix(remote): add the sign-out cutoff invoke wrapper with its command The repo requires every Tauri command to ship with its api wrapper and a caller, so remoteCutOffManaged and the authStore.signOut call move here from the follow-up PR. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01MbYSPHBRmDYwPJpHG33Y4t --- src/lib/api-extra.ts | 8 ++++++++ src/stores/authStore.ts | 8 ++++++++ 2 files changed, 16 insertions(+) diff --git a/src/lib/api-extra.ts b/src/lib/api-extra.ts index b7c031e95..39c00cdf9 100644 --- a/src/lib/api-extra.ts +++ b/src/lib/api-extra.ts @@ -842,6 +842,14 @@ export const remoteForceCutoff = ( reason: string, ): Promise => invoke("remote_force_cutoff", { endpointId, reason }); +/** + * Cuts off every managed endpoint on sign-out: tears down pooled managed + * connections and PTYs, emits `remote://cutoff` for each, and refuses new + * managed connections until a signed-in token reaches the native pool. + */ +export const remoteCutOffManaged = (): Promise => + invoke("remote_cut_off_managed"); + /** * Clears a previously forced cutoff after the user reauthenticates and a * fresh certificate is issued through the normal registration and issuance diff --git a/src/stores/authStore.ts b/src/stores/authStore.ts index d48d081dd..07c55d216 100644 --- a/src/stores/authStore.ts +++ b/src/stores/authStore.ts @@ -1,6 +1,7 @@ import type { Session, User } from "@supabase/supabase-js"; import { create } from "zustand"; import { getSetting, setSetting } from "../lib/api"; +import { remoteCutOffManaged } from "../lib/api-extra"; import { SUPABASE_ANON_KEY, SUPABASE_URL, @@ -55,6 +56,13 @@ export const useAuthStore = create((set, get) => ({ await openUrl(`${WEB_URL}/sign-in?source=desktop`); }, signOut: async () => { + // Signing out ends the authorization behind managed SSH access, so + // open managed connections and PTYs are cut off, not left running. + try { + await remoteCutOffManaged(); + } catch { + // No native pool (e.g. a web preview); nothing to cut off. + } await supabase.auth.signOut(); set({ user: null, session: null, subscription: null }); await persistSession(null);