Skip to content

Commit 8ca1453

Browse files
bobleerbowen628claude
authored
feat: add SSH remote workspace support (#206)
* feat: add SSH remote workspace support - SSH connection management with saved connections, key/agent/password auth - Remote file system access via SFTP (read, write, edit, delete, rename) - Remote terminal sessions over SSH PTY - Multi-workspace registry: path-keyed HashMap replaces single global state, allowing multiple concurrent remote workspaces without path conflicts - Per-workspace connection status indicator (green/yellow/red dot) in sidebar - Auto-reconnect on startup: retries up to 5 times with 10s intervals, keeps workspace in sidebar with error state if reconnection fails - Session list re-initialized after SSH reconnect to fix timing race where sessions loaded before workspace was registered in state manager - Snapshot/rollback skipped for remote workspaces (no local .bitfun dir); rollback commands return empty success instead of "directory not found" error - Agentic tools (file read/write/edit, bash, glob, grep) routed through SSH when workspace path matches a registered remote workspace Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix: vendor OpenSSL to fix Windows CI build failure russh-keys uses the openssl crate which requires a system OpenSSL installation. On Windows runners there is none, so the build fails. Adding openssl with the vendored feature compiles OpenSSL from source (same approach already used by git2 via vendored-openssl). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix: improve SSH error diagnostics, algorithm compatibility, and Windows build - Make HandlerError carry actual error message (was unit struct, discarding all info) - Implement disconnected() callback to capture real SSH disconnect reason - Add DH_G14_SHA1/DH_G1_SHA1 KEX and SSH_RSA host key for legacy server compatibility - Improve error message when server closes connection before sending SSH banner - Fix win32job missing from bitfun-core Windows target dependencies - Fix type annotation needed for MutexGuard in process_manager.rs - Fix RemoteFileBrowser cancel button using missing i18n key (common.cancel → actions.cancel) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix: remove unreconnectable remote workspaces on startup instead of showing error Password-auth workspaces cannot auto-reconnect (BitFun does not persist passwords). Any workspace that fails reconnection (password, key, or agent) is now silently removed from the sidebar instead of being shown with a permanent red error state. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix: suppress FlowChat init error for disconnected remote workspaces on startup Before initializing FlowChatManager, check that a remote workspace's SSH connection is actually live. If not (e.g. password-auth workspace that cannot auto-reconnect), log a warning and skip initialization instead of showing an error notification to the user. --------- Co-authored-by: bowen628 <bowen628@noreply.gitcode.com> Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 405c76a commit 8ca1453

82 files changed

Lines changed: 9478 additions & 456 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

Cargo.toml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,9 @@ toml = "0.8"
8080
# Git
8181
git2 = { version = "0.18", default-features = false, features = ["https", "vendored-libgit2", "vendored-openssl"] }
8282

83+
# OpenSSL — vendored so no system OpenSSL is needed (required by russh-keys on Windows)
84+
openssl = { version = "0.10", features = ["vendored"] }
85+
8386
# Terminal
8487
portable-pty = "0.8"
8588
vte = "0.15.0"

src/apps/desktop/Cargo.toml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ serde_json = { workspace = true }
1919

2020
[dependencies]
2121
# Internal crates
22-
bitfun-core = { path = "../../crates/core" }
22+
bitfun-core = { path = "../../crates/core", features = ["ssh-remote"] }
2323
bitfun-transport = { path = "../../crates/transport", features = ["tauri-adapter"] }
2424

2525
# Tauri
@@ -42,6 +42,7 @@ similar = { workspace = true }
4242
ignore = { workspace = true }
4343
urlencoding = { workspace = true }
4444
reqwest = { workspace = true }
45+
thiserror = "1.0"
4546

4647
[target.'cfg(windows)'.dependencies]
4748
win32job = { workspace = true }

src/apps/desktop/src/api/agentic_api.rs

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@
22
33
use log::warn;
44
use serde::{Deserialize, Serialize};
5-
use std::path::PathBuf;
65
use std::sync::Arc;
76
use tauri::{AppHandle, State};
87

@@ -14,6 +13,7 @@ use bitfun_core::agentic::coordination::{
1413
use bitfun_core::agentic::core::*;
1514
use bitfun_core::agentic::image_analysis::ImageContextData;
1615
use bitfun_core::agentic::tools::image_context::get_image_context;
16+
use bitfun_core::service::remote_ssh::workspace_state::get_effective_session_path;
1717

1818
#[derive(Debug, Deserialize)]
1919
#[serde(rename_all = "camelCase")]
@@ -435,8 +435,9 @@ pub async fn delete_session(
435435
coordinator: State<'_, Arc<ConversationCoordinator>>,
436436
request: DeleteSessionRequest,
437437
) -> Result<(), String> {
438+
let effective_path = get_effective_session_path(&request.workspace_path).await;
438439
coordinator
439-
.delete_session(&PathBuf::from(request.workspace_path), &request.session_id)
440+
.delete_session(&effective_path, &request.session_id)
440441
.await
441442
.map_err(|e| format!("Failed to delete session: {}", e))
442443
}
@@ -446,8 +447,9 @@ pub async fn restore_session(
446447
coordinator: State<'_, Arc<ConversationCoordinator>>,
447448
request: RestoreSessionRequest,
448449
) -> Result<SessionResponse, String> {
450+
let effective_path = get_effective_session_path(&request.workspace_path).await;
449451
let session = coordinator
450-
.restore_session(&PathBuf::from(request.workspace_path), &request.session_id)
452+
.restore_session(&effective_path, &request.session_id)
451453
.await
452454
.map_err(|e| format!("Failed to restore session: {}", e))?;
453455

@@ -459,8 +461,10 @@ pub async fn list_sessions(
459461
coordinator: State<'_, Arc<ConversationCoordinator>>,
460462
request: ListSessionsRequest,
461463
) -> Result<Vec<SessionResponse>, String> {
464+
// Map remote workspace path to local session storage path
465+
let effective_path = get_effective_session_path(&request.workspace_path).await;
462466
let summaries = coordinator
463-
.list_sessions(&PathBuf::from(request.workspace_path))
467+
.list_sessions(&effective_path)
464468
.await
465469
.map_err(|e| format!("Failed to list sessions: {}", e))?;
466470

src/apps/desktop/src/api/app_state.rs

Lines changed: 203 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,13 +5,28 @@ use bitfun_core::agentic::{agents, tools};
55
use bitfun_core::infrastructure::ai::{AIClient, AIClientFactory};
66
use bitfun_core::miniapp::{initialize_global_miniapp_manager, JsWorkerPool, MiniAppManager};
77
use bitfun_core::service::{ai_rules, config, filesystem, mcp, token_usage, workspace};
8+
use bitfun_core::service::remote_ssh::{
9+
init_remote_workspace_manager, SSHConnectionManager, RemoteFileService, RemoteTerminalManager,
10+
};
811
use bitfun_core::util::errors::*;
912

1013
use serde::{Deserialize, Serialize};
1114
use std::collections::HashMap;
1215
use std::sync::Arc;
16+
use thiserror::Error;
1317
use tokio::sync::RwLock;
1418

19+
/// Errors that can occur when accessing SSH remote services
20+
#[derive(Error, Debug)]
21+
pub enum SSHServiceError {
22+
#[error("SSH manager not initialized")]
23+
ManagerNotInitialized,
24+
#[error("Remote file service not initialized")]
25+
FileServiceNotInitialized,
26+
#[error("Remote terminal manager not initialized")]
27+
TerminalManagerNotInitialized,
28+
}
29+
1530
#[derive(Debug, Clone, Serialize, Deserialize)]
1631
pub struct HealthStatus {
1732
pub status: String,
@@ -28,6 +43,15 @@ pub struct AppStatistics {
2843
pub uptime_seconds: u64,
2944
}
3045

46+
/// Remote workspace information
47+
#[derive(Debug, Clone, Serialize, Deserialize)]
48+
#[serde(rename_all = "camelCase")]
49+
pub struct RemoteWorkspace {
50+
pub connection_id: String,
51+
pub connection_name: String,
52+
pub remote_path: String,
53+
}
54+
3155
pub struct AppState {
3256
pub ai_client: Arc<RwLock<Option<AIClient>>>,
3357
pub ai_client_factory: Arc<AIClientFactory>,
@@ -46,6 +70,11 @@ pub struct AppState {
4670
pub js_worker_pool: Option<Arc<JsWorkerPool>>,
4771
pub statistics: Arc<RwLock<AppStatistics>>,
4872
pub start_time: std::time::Instant,
73+
// SSH Remote connection state
74+
pub ssh_manager: Arc<RwLock<Option<SSHConnectionManager>>>,
75+
pub remote_file_service: Arc<RwLock<Option<RemoteFileService>>>,
76+
pub remote_terminal_manager: Arc<RwLock<Option<RemoteTerminalManager>>>,
77+
pub remote_workspace: Arc<RwLock<Option<RemoteWorkspace>>>,
4978
}
5079

5180
impl AppState {
@@ -143,6 +172,74 @@ impl AppState {
143172
}
144173
}
145174

175+
// Initialize SSH Remote services synchronously so they're ready before app starts
176+
let ssh_data_dir = dirs::data_local_dir()
177+
.unwrap_or_else(|| std::path::PathBuf::from("."))
178+
.join("BitFun")
179+
.join("ssh");
180+
let ssh_manager = Arc::new(RwLock::new(None));
181+
let ssh_manager_clone = ssh_manager.clone();
182+
let remote_file_service = Arc::new(RwLock::new(None));
183+
let remote_file_service_clone = remote_file_service.clone();
184+
let remote_terminal_manager = Arc::new(RwLock::new(None));
185+
let remote_terminal_manager_clone = remote_terminal_manager.clone();
186+
// Create remote_workspace before spawn so we can pass it in
187+
let remote_workspace = Arc::new(RwLock::new(None));
188+
let remote_workspace_clone = remote_workspace.clone();
189+
190+
// Initialize SSH services synchronously (not spawned) so they're ready before app starts
191+
let manager = SSHConnectionManager::new(ssh_data_dir.clone());
192+
if let Err(e) = manager.load_saved_connections().await {
193+
log::error!("Failed to load saved SSH connections: {}", e);
194+
} else {
195+
log::info!("SSH connections loaded successfully");
196+
}
197+
if let Err(e) = manager.load_known_hosts().await {
198+
log::error!("Failed to load known hosts: {}", e);
199+
}
200+
201+
// Load persisted remote workspaces (may be multiple)
202+
match manager.load_remote_workspace().await {
203+
Ok(_) => {
204+
let workspaces = manager.get_remote_workspaces().await;
205+
if !workspaces.is_empty() {
206+
log::info!("Loaded {} persisted remote workspace(s)", workspaces.len());
207+
// Use the first one for the legacy single-workspace field
208+
let first = &workspaces[0];
209+
let app_workspace = RemoteWorkspace {
210+
connection_id: first.connection_id.clone(),
211+
remote_path: first.remote_path.clone(),
212+
connection_name: first.connection_name.clone(),
213+
};
214+
*remote_workspace_clone.write().await = Some(app_workspace);
215+
}
216+
}
217+
Err(e) => {
218+
log::warn!("Failed to load remote workspace: {}", e);
219+
}
220+
}
221+
222+
let manager_arc = Arc::new(manager);
223+
let manager_for_fs = Arc::new(tokio::sync::RwLock::new(Some(manager_arc.as_ref().clone())));
224+
let fs = RemoteFileService::new(manager_for_fs.clone());
225+
let tm = RemoteTerminalManager::new(manager_arc.as_ref().clone());
226+
227+
// Clone for storing in AppState
228+
let fs_for_state = fs.clone();
229+
let tm_for_state = tm.clone();
230+
231+
*ssh_manager_clone.write().await = Some((*manager_arc).clone());
232+
*remote_file_service_clone.write().await = Some(fs_for_state);
233+
*remote_terminal_manager_clone.write().await = Some(tm_for_state);
234+
235+
// Note: We do NOT activate the global remote workspace state here because
236+
// there is no live SSH connection yet. The persisted workspace info is loaded
237+
// into self.remote_workspace so the frontend can query it via remote_get_workspace_info
238+
// and drive the reconnection flow. The global state will be activated when the
239+
// frontend successfully reconnects and calls remote_open_workspace → set_remote_workspace.
240+
241+
log::info!("SSH Remote services initialized with SFTP, PTY, and known hosts support");
242+
146243
let app_state = Self {
147244
ai_client,
148245
ai_client_factory,
@@ -161,6 +258,11 @@ impl AppState {
161258
js_worker_pool,
162259
statistics,
163260
start_time,
261+
// SSH Remote connection state
262+
ssh_manager,
263+
remote_file_service,
264+
remote_terminal_manager,
265+
remote_workspace,
164266
};
165267

166268
log::info!("AppState initialized successfully");
@@ -207,4 +309,104 @@ impl AppState {
207309
.map(|tool| tool.name().to_string())
208310
.collect()
209311
}
210-
}
312+
313+
// SSH Remote connection methods
314+
315+
/// Get SSH connection manager synchronously (must be called within async context)
316+
pub async fn get_ssh_manager_async(&self) -> Result<SSHConnectionManager, SSHServiceError> {
317+
self.ssh_manager.read().await.clone()
318+
.ok_or(SSHServiceError::ManagerNotInitialized)
319+
}
320+
321+
/// Get remote file service synchronously (must be called within async context)
322+
pub async fn get_remote_file_service_async(&self) -> Result<RemoteFileService, SSHServiceError> {
323+
self.remote_file_service.read().await.clone()
324+
.ok_or(SSHServiceError::FileServiceNotInitialized)
325+
}
326+
327+
/// Get remote terminal manager synchronously (must be called within async context)
328+
pub async fn get_remote_terminal_manager_async(&self) -> Result<RemoteTerminalManager, SSHServiceError> {
329+
self.remote_terminal_manager.read().await.clone()
330+
.ok_or(SSHServiceError::TerminalManagerNotInitialized)
331+
}
332+
333+
/// Set current remote workspace
334+
pub async fn set_remote_workspace(&self, workspace: RemoteWorkspace) -> Result<(), SSHServiceError> {
335+
// Update local state
336+
*self.remote_workspace.write().await = Some(workspace.clone());
337+
338+
// Persist to SSHConnectionManager for restoration on restart
339+
if let Ok(manager) = self.get_ssh_manager_async().await {
340+
let core_workspace = bitfun_core::service::remote_ssh::RemoteWorkspace {
341+
connection_id: workspace.connection_id.clone(),
342+
remote_path: workspace.remote_path.clone(),
343+
connection_name: workspace.connection_name.clone(),
344+
};
345+
if let Err(e) = manager.set_remote_workspace(core_workspace).await {
346+
log::warn!("Failed to persist remote workspace: {}", e);
347+
}
348+
}
349+
350+
// Register in the global workspace registry
351+
let state_manager = init_remote_workspace_manager();
352+
353+
// Ensure shared services are set (idempotent if already set)
354+
let manager = self.get_ssh_manager_async().await?;
355+
let fs = self.get_remote_file_service_async().await?;
356+
let terminal = self.get_remote_terminal_manager_async().await?;
357+
358+
state_manager.set_ssh_manager(manager.clone()).await;
359+
state_manager.set_file_service(fs.clone()).await;
360+
state_manager.set_terminal_manager(terminal.clone()).await;
361+
362+
// Register this workspace (does not overwrite other workspaces)
363+
log::info!("register_remote_workspace: connection_id={}, remote_path={}, connection_name={}",
364+
workspace.connection_id, workspace.remote_path, workspace.connection_name);
365+
state_manager.register_remote_workspace(
366+
workspace.remote_path.clone(),
367+
workspace.connection_id.clone(),
368+
workspace.connection_name.clone(),
369+
).await;
370+
log::info!("Remote workspace registered: {} on {}",
371+
workspace.remote_path, workspace.connection_name);
372+
Ok(())
373+
}
374+
375+
/// Get current remote workspace
376+
pub async fn get_remote_workspace_async(&self) -> Option<RemoteWorkspace> {
377+
self.remote_workspace.read().await.clone()
378+
}
379+
380+
/// Clear current remote workspace
381+
pub async fn clear_remote_workspace(&self) {
382+
// Get the remote_path before clearing so we can unregister the specific workspace
383+
let remote_path = {
384+
let guard = self.remote_workspace.read().await;
385+
guard.as_ref().map(|w| w.remote_path.clone())
386+
};
387+
388+
// Clear local state
389+
*self.remote_workspace.write().await = None;
390+
391+
// Remove this specific workspace from persistence (not all of them)
392+
if let Some(path) = &remote_path {
393+
if let Ok(manager) = self.get_ssh_manager_async().await {
394+
if let Err(e) = manager.remove_remote_workspace(path).await {
395+
log::warn!("Failed to remove persisted remote workspace: {}", e);
396+
}
397+
}
398+
399+
// Unregister from the global registry
400+
if let Some(state_manager) = bitfun_core::service::remote_ssh::get_remote_workspace_manager() {
401+
state_manager.unregister_remote_workspace(path).await;
402+
}
403+
}
404+
405+
log::info!("Remote workspace unregistered: {:?}", remote_path);
406+
}
407+
408+
/// Check if currently in a remote workspace
409+
pub async fn is_remote_workspace(&self) -> bool {
410+
self.remote_workspace.read().await.is_some()
411+
}
412+
}

0 commit comments

Comments
 (0)