feat: harden local shell, SFTP, and desktop session reliability
Finder-launched terminals now inherit a login PATH so tools like ollama resolve, destructive SFTP/editor actions require confirmation, and terminal search plus window dragging work again. SSH forwards and RDP sessions keep listeners and survive reconnect without FastPath crashes. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
1 parent
188f3556ed
commit
08696896f9
223 files changed
+13493
-3423
No files matched your search
@@ -1,5 +1,5 @@
|
||||
use std::{
|
||||
collections::HashMap,
|
||||
collections::{HashMap, HashSet},
|
||||
io::{self, Read, Write},
|
||||
net::{Shutdown, TcpStream},
|
||||
sync::{
|
||||
@@ -19,7 +19,7 @@ use crate::domain::ssh::{
|
||||
ShellDataEvent, SshConnectRequest,
|
||||
};
|
||||
|
||||
use super::tunnel::TunnelManager;
|
||||
use super::tunnel::{connection_fingerprint, TunnelManager};
|
||||
|
||||
mod auth;
|
||||
mod host_metrics;
|
||||
@@ -50,13 +50,27 @@ struct Shell {
|
||||
pending_output: Arc<Mutex<Vec<u8>>>,
|
||||
}
|
||||
|
||||
/// Dedicated SSH transport + persistent SFTP channel for one workload.
|
||||
/// Each UI SFTP tab owns a browsing channel and may lazily open a separate
|
||||
/// transfer channel; neither shares the pooled session used by exec/keepalive.
|
||||
struct ManagedSftp {
|
||||
connection_id: String,
|
||||
session: Session,
|
||||
sftp: ssh2::Sftp,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct SshService {
|
||||
connections: Mutex<HashMap<String, Connection>>,
|
||||
shells: Mutex<HashMap<String, Shell>>,
|
||||
sftps: Mutex<HashMap<String, String>>,
|
||||
/// Interactive browsing channel for each SFTP tab.
|
||||
sftps: Mutex<HashMap<String, Arc<Mutex<ManagedSftp>>>>,
|
||||
/// Lazily-created transfer channel, isolated from browsing/file operations.
|
||||
sftp_transfers: Mutex<HashMap<String, Arc<Mutex<ManagedSftp>>>>,
|
||||
/// Per-connection cancel flag for in-flight `ssh_exec` (Agent 停止).
|
||||
active_execs: Mutex<HashMap<String, Arc<AtomicBool>>>,
|
||||
/// Per-SFTP-session cancel for the active upload/download copy loop.
|
||||
sftp_transfer_cancels: Mutex<HashMap<String, Arc<AtomicBool>>>,
|
||||
tunnels: TunnelManager,
|
||||
}
|
||||
|
||||
@@ -65,15 +79,30 @@ impl SshService {
|
||||
validate_config(&config)?;
|
||||
let session = establish_session(&config)?;
|
||||
let id = Uuid::new_v4().to_string();
|
||||
self.connections
|
||||
.lock()
|
||||
.map_err(lock_error)?
|
||||
.insert(id.clone(), Connection { session, config });
|
||||
let fingerprint = connection_fingerprint(&config);
|
||||
let live = {
|
||||
let mut connections = self.connections.lock().map_err(lock_error)?;
|
||||
connections.insert(id.clone(), Connection { session, config });
|
||||
connections.keys().cloned().collect::<HashSet<_>>()
|
||||
};
|
||||
// Reattach listeners that survived a reconnect (same SSH identity,
|
||||
// previous connection id is gone). Runtime is never persisted.
|
||||
self.tunnels.adopt_orphans(&id, &fingerprint, &live)?;
|
||||
Ok(id)
|
||||
}
|
||||
|
||||
pub fn disconnect(&self, app: &AppHandle, connection_id: &str) -> Result<(), String> {
|
||||
self.tunnels.stop_connection(connection_id)?;
|
||||
pub fn disconnect(
|
||||
&self,
|
||||
app: &AppHandle,
|
||||
connection_id: &str,
|
||||
retain_forwards: bool,
|
||||
) -> Result<(), String> {
|
||||
// Dedicated tunnel sessions keep listening across terminal flaps when
|
||||
// retain_forwards is true. Explicit teardown (tab close / panel disconnect)
|
||||
// must still close real Listeners.
|
||||
if !retain_forwards {
|
||||
let _ = self.tunnels.stop_connection(connection_id);
|
||||
}
|
||||
if let Some(flag) = self
|
||||
.active_execs
|
||||
.lock()
|
||||
@@ -97,10 +126,22 @@ impl SshService {
|
||||
let _ = shell.session.disconnect(None, "closed by user", None);
|
||||
}
|
||||
}
|
||||
self.sftps
|
||||
.lock()
|
||||
.map_err(lock_error)?
|
||||
.retain(|_, id| id != connection_id);
|
||||
let sftp_ids = {
|
||||
let sftps = self.sftps.lock().map_err(lock_error)?;
|
||||
sftps
|
||||
.iter()
|
||||
.filter_map(|(id, handle)| {
|
||||
handle
|
||||
.lock()
|
||||
.ok()
|
||||
.filter(|sftp| sftp.connection_id == connection_id)
|
||||
.map(|_| id.clone())
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
};
|
||||
for sftp_id in sftp_ids {
|
||||
let _ = self.close_sftp(&sftp_id);
|
||||
}
|
||||
if let Some(connection) = self
|
||||
.connections
|
||||
.lock()
|
||||
@@ -256,8 +297,8 @@ impl SshService {
|
||||
.map(|_| ())
|
||||
.map_err(|error| format!("SSH 保活失败: {error}"))?;
|
||||
|
||||
// Interactive panes use dedicated SSH sessions so a busy SFTP/exec
|
||||
// channel cannot stall terminal IO. Keep those transports alive too.
|
||||
// Interactive panes / SFTP use dedicated SSH sessions so a busy
|
||||
// SFTP/exec channel cannot stall terminal IO. Keep those alive too.
|
||||
let shell_sessions = self
|
||||
.shells
|
||||
.lock()
|
||||
@@ -271,6 +312,21 @@ impl SshService {
|
||||
.keepalive_send()
|
||||
.map_err(|error| format!("Shell SSH 保活失败: {error}"))?;
|
||||
}
|
||||
let sftp_sessions = {
|
||||
let sftps = self.sftps.lock().map_err(lock_error)?;
|
||||
sftps
|
||||
.values()
|
||||
.filter_map(|handle| {
|
||||
let guard = handle.lock().ok()?;
|
||||
(guard.connection_id == connection_id).then(|| guard.session.clone())
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
};
|
||||
for session in sftp_sessions {
|
||||
session
|
||||
.keepalive_send()
|
||||
.map_err(|error| format!("SFTP SSH 保活失败: {error}"))?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -393,6 +449,7 @@ impl SshService {
|
||||
&self,
|
||||
connection_id: &str,
|
||||
forward_id: Option<String>,
|
||||
local_bind_host: String,
|
||||
local_port: u16,
|
||||
remote_host: String,
|
||||
remote_port: u16,
|
||||
@@ -402,6 +459,7 @@ impl SshService {
|
||||
forward_id,
|
||||
connection_id,
|
||||
config,
|
||||
local_bind_host,
|
||||
local_port,
|
||||
remote_host,
|
||||
remote_port,
|
||||
@@ -423,6 +481,7 @@ impl SshService {
|
||||
&self,
|
||||
connection_id: &str,
|
||||
forward_id: Option<String>,
|
||||
remote_bind_host: String,
|
||||
remote_port: u16,
|
||||
local_host: String,
|
||||
local_port: u16,
|
||||
@@ -432,6 +491,7 @@ impl SshService {
|
||||
forward_id,
|
||||
connection_id,
|
||||
config,
|
||||
remote_bind_host,
|
||||
remote_port,
|
||||
local_host,
|
||||
local_port,
|
||||
@@ -481,7 +541,7 @@ pub(super) fn establish_session(config: &SshConnectRequest) -> Result<Session, S
|
||||
session
|
||||
.handshake()
|
||||
.map_err(|error| format!("SSH 握手失败 ({address}): {error}"))?;
|
||||
session.set_keepalive(config.keep_alive.unwrap_or(true), 30);
|
||||
session.set_keepalive(config.keep_alive.unwrap_or(true), 15);
|
||||
verify_host_key(&session, &config.host, config.port)?;
|
||||
authenticate(&session, config)?;
|
||||
Ok(session)
|
||||
@@ -782,7 +842,10 @@ mod tests {
|
||||
target.seek(SeekFrom::Start(3)).unwrap();
|
||||
let mut progress = Vec::new();
|
||||
|
||||
copy_with_progress(&mut source, &mut target, 3, 6, |value| progress.push(value)).unwrap();
|
||||
copy_with_progress(&mut source, &mut target, 3, 6, None, |value| {
|
||||
progress.push(value)
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(target.into_inner(), b"abcdef");
|
||||
assert_eq!(progress.first(), Some(&3));
|
||||
@@ -835,23 +898,23 @@ mod tests {
|
||||
let remote_path = format!("/tmp/opsxshell-sftp-{probe_id}.txt");
|
||||
let payload = format!("OPSX_SFTP_ROUNDTRIP_{probe_id}\n").into_bytes();
|
||||
fs::write(&local_source, &payload).expect("write local SFTP fixture");
|
||||
let sftp = service.sftp(&sftp_id).expect("SFTP channel");
|
||||
let transfer_result = (|| -> Result<(), String> {
|
||||
upload_file(&sftp, &local_source, &remote_path, |_, _| {})?;
|
||||
download_file(&sftp, &remote_path, &local_download, |_, _| {})?;
|
||||
let transfer_result = service.with_sftp(&sftp_id, |sftp| {
|
||||
upload_file(sftp, &local_source, &remote_path, None, |_, _| {})?;
|
||||
download_file(sftp, &remote_path, &local_download, None, |_, _| {})?;
|
||||
let downloaded = fs::read(&local_download)
|
||||
.map_err(|error| format!("read downloaded probe: {error}"))?;
|
||||
if downloaded != payload {
|
||||
return Err("SFTP round-trip content mismatch".to_string());
|
||||
}
|
||||
let _ = sftp.unlink(Path::new(&remote_path));
|
||||
let _ = sftp.unlink(Path::new(&format!("{remote_path}.opsx-part")));
|
||||
Ok(())
|
||||
})();
|
||||
let _ = sftp.unlink(Path::new(&remote_path));
|
||||
let _ = sftp.unlink(Path::new(&format!("{remote_path}.opsx-part")));
|
||||
});
|
||||
let _ = fs::remove_file(&local_source);
|
||||
let _ = fs::remove_file(&local_download);
|
||||
let _ = fs::remove_file(local_partial_path(&local_download));
|
||||
transfer_result.expect("SFTP upload/download round trip");
|
||||
service.close_sftp(&sftp_id).expect("close SFTP");
|
||||
let connection = service.connection(&connection_id).unwrap();
|
||||
let shell_session = establish_session(&connection.config).expect("shell SSH session");
|
||||
let mut shell = shell_session.channel_session().expect("shell channel");
|
||||
|
||||
Reference in new issue
Block a user