Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5,535 changes: 5,535 additions & 0 deletions BitFun-Installer/src-tauri/Cargo.lock

Large diffs are not rendered by default.

2,358 changes: 2,358 additions & 0 deletions BitFun-Installer/src-tauri/gen/schemas/macOS-schema.json

Large diffs are not rendered by default.

15 changes: 14 additions & 1 deletion src/apps/desktop/src/api/app_state.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,8 @@ use bitfun_core::miniapp::{
initialize_global_miniapp_manager, seed_builtin_miniapps, JsWorkerPool, MiniAppManager,
};
use bitfun_core::service::remote_ssh::{
init_remote_workspace_manager, RemoteFileService, RemoteTerminalManager, SSHConnectionManager,
global_port_forward_manager, init_remote_workspace_manager, PortForwardManager,
RemoteFileService, RemoteTerminalManager, SSHConnectionManager,
};
use bitfun_core::service::{announcement, config, filesystem, mcp, search, token_usage, workspace};
use bitfun_core::util::errors::*;
Expand Down Expand Up @@ -84,6 +85,10 @@ pub struct AppState {
pub ssh_manager: Arc<RwLock<Option<SSHConnectionManager>>>,
pub remote_file_service: Arc<RwLock<Option<RemoteFileService>>>,
pub remote_terminal_manager: Arc<RwLock<Option<RemoteTerminalManager>>>,
/// Local (`-L`) port forwards. Unlike the services above this needs no
/// `Option`: it is useful before any connection exists, and its own entry
/// points report a missing SSH manager.
pub port_forward_manager: PortForwardManager,
pub remote_workspace: Arc<RwLock<Option<RemoteWorkspace>>>,
pub active_searches: Arc<Mutex<HashMap<String, Arc<AtomicBool>>>>,
/// Cancellation flags for active file transfers (download/upload), keyed by transfer_id.
Expand Down Expand Up @@ -274,6 +279,13 @@ impl AppState {
let manager_for_fs = Arc::new(tokio::sync::RwLock::new(Some(manager_arc.as_ref().clone())));
let fs = RemoteFileService::new(manager_for_fs.clone());
let tm = RemoteTerminalManager::new(manager_arc.as_ref().clone());
// Share the process-wide registry rather than owning a private one: the
// Agent tool reaches forwards through the same global, and a mapping
// made in the UI has to be visible to the Agent and vice versa.
let port_forward_manager = global_port_forward_manager();
port_forward_manager
.set_ssh_manager(manager_arc.as_ref().clone())
.await;

// Clone for storing in AppState
let fs_for_state = fs.clone();
Expand Down Expand Up @@ -316,6 +328,7 @@ impl AppState {
ssh_manager,
remote_file_service,
remote_terminal_manager,
port_forward_manager,
remote_workspace,
active_searches: Arc::new(Mutex::new(HashMap::new())),
active_transfers: Arc::new(Mutex::new(HashMap::new())),
Expand Down
16 changes: 16 additions & 0 deletions src/apps/desktop/src/api/remote_workspace_policy.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1884,6 +1884,14 @@ pub const REMOTE_WORKSPACE_COMMAND_POLICIES: &[(&str, RemoteWorkspacePolicy)] =
"ssh_list_docker_containers",
RemoteWorkspacePolicy::WorkspaceAgnostic,
),
(
"ssh_list_port_forwards",
RemoteWorkspacePolicy::WorkspaceAgnostic,
),
(
"ssh_list_remote_listening_ports",
RemoteWorkspacePolicy::WorkspaceAgnostic,
),
(
"ssh_list_saved_connections",
RemoteWorkspacePolicy::WorkspaceAgnostic,
Expand All @@ -1892,6 +1900,14 @@ pub const REMOTE_WORKSPACE_COMMAND_POLICIES: &[(&str, RemoteWorkspacePolicy)] =
"ssh_save_connection",
RemoteWorkspacePolicy::WorkspaceAgnostic,
),
(
"ssh_start_port_forward",
RemoteWorkspacePolicy::WorkspaceAgnostic,
),
(
"ssh_stop_port_forward",
RemoteWorkspacePolicy::WorkspaceAgnostic,
),
(
"ssh_test_connection",
RemoteWorkspacePolicy::WorkspaceAgnostic,
Expand Down
81 changes: 80 additions & 1 deletion src/apps/desktop/src/api/ssh_api.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,8 @@ use crate::api::app_state::SSHServiceError;
use crate::startup_trace::DesktopStartupTrace;
use crate::AppState;
use bitfun_core::service::remote_ssh::{
ConnectionTestReport, DockerContainerInfo, RemoteTreeNode, SSHAuthMethod, SSHConfigEntry,
list_remote_listening_ports, ConnectionTestReport, DockerContainerInfo, PortForward,
PortForwardRequest, RemoteListeningPort, RemoteTreeNode, SSHAuthMethod, SSHConfigEntry,
SSHConfigLookupResult, SSHConnectionConfig, SSHConnectionManager, SSHConnectionResult,
SavedConnection, ServerInfo,
};
Expand Down Expand Up @@ -188,6 +189,13 @@ pub async fn ssh_disconnect(
connection_id: String,
) -> Result<(), String> {
let manager = state.get_ssh_manager_async().await?;
// Tear the forwards down first. They are listeners whose only meaning is
// this session, and leaving one running would let a later connection on it
// reconnect the host the user just closed.
state
.port_forward_manager
.stop_for_connection(&connection_id)
.await;
manager
.disconnect(&connection_id)
.await
Expand All @@ -197,6 +205,7 @@ pub async fn ssh_disconnect(
#[tauri::command]
pub async fn ssh_disconnect_all(state: State<'_, AppState>) -> Result<(), String> {
let manager = state.get_ssh_manager_async().await?;
state.port_forward_manager.stop_all().await;
manager.disconnect_all().await;
Ok(())
}
Expand Down Expand Up @@ -1120,6 +1129,76 @@ pub async fn remote_get_workspace_info(
Ok(workspace)
}

// === Port Forwarding ===

/// Start a local (`-L`) forward and return the mapping that was established.
///
/// The returned `localPort` is not always the requested one: an unavailable
/// port is replaced rather than refused, and `requestedLocalPort` carries what
/// was asked for so the UI can say so.
#[tauri::command]
pub async fn ssh_start_port_forward(
state: State<'_, AppState>,
request: PortForwardRequest,
) -> Result<PortForward, String> {
log::info!(
"ssh_start_port_forward: connection={}, remote={}:{}, requested local port={:?}",
request.connection_id,
request.effective_remote_host(),
request.remote_port,
request.preferred_local_port()
);
state
.port_forward_manager
.start_local_forward(&request)
.await
.map_err(|error| format!("{error:#}"))
}

#[tauri::command]
pub async fn ssh_stop_port_forward(
state: State<'_, AppState>,
forward_id: String,
) -> Result<(), String> {
state
.port_forward_manager
.stop_forward(&forward_id)
.await
.map_err(|error| format!("{error:#}"))
}

/// List forwards, optionally narrowed to one connection.
#[tauri::command]
pub async fn ssh_list_port_forwards(
state: State<'_, AppState>,
connection_id: Option<String>,
) -> Result<Vec<PortForward>, String> {
Ok(match connection_id {
Some(connection_id) => {
state
.port_forward_manager
.list_forwards_for_connection(&connection_id)
.await
}
None => state.port_forward_manager.list_forwards().await,
})
}

/// List the TCP ports currently accepting connections on the remote host.
///
/// Discovery only. Nothing is forwarded as a result of calling this; it exists
/// so users can pick a real port instead of guessing one.
#[tauri::command]
pub async fn ssh_list_remote_listening_ports(
state: State<'_, AppState>,
connection_id: String,
) -> Result<Vec<RemoteListeningPort>, String> {
let manager = state.get_ssh_manager_async().await?;
list_remote_listening_ports(&manager, &connection_id)
.await
.map_err(|error| format!("{error:#}"))
}

#[cfg(test)]
mod tests {
use super::{
Expand Down
5 changes: 5 additions & 0 deletions src/apps/desktop/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1886,6 +1886,11 @@ pub async fn run() {
api::ssh_api::remote_close_workspace,
api::ssh_api::remote_remove_workspace,
api::ssh_api::remote_get_workspace_info,
// Remote port forwarding (local `-L` mappings, user-driven)
api::ssh_api::ssh_start_port_forward,
api::ssh_api::ssh_stop_port_forward,
api::ssh_api::ssh_list_port_forwards,
api::ssh_api::ssh_list_remote_listening_ports,
// Detached task dispatch (controller-side SSH transport)
api::dispatch_api::dispatch_list_targets,
api::dispatch_api::dispatch_probe_target,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,10 @@ impl ClawMode {
"ExecCommand".to_string(),
"WriteStdin".to_string(),
"ExecControl".to_string(),
// The companion to ExecCommand for remote work: a server
// started on an SSH host is unreachable from the user's
// machine until a forward exists.
"PortForward".to_string(),
"Grep".to_string(),
"Glob".to_string(),
"WebSearch".to_string(),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,10 @@ impl CoworkMode {
"ExecCommand".to_string(),
"WriteStdin".to_string(),
"ExecControl".to_string(),
// The companion to ExecCommand for remote work: a server
// started on an SSH host is unreachable from the user's
// machine until a forward exists.
"PortForward".to_string(),
"WebSearch".to_string(),
"WebFetch".to_string(),
"ControlHub".to_string(),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,10 @@ impl DeepResearchMode {
"ExecCommand".to_string(),
"WriteStdin".to_string(),
"ExecControl".to_string(),
// The companion to ExecCommand for remote work: a server
// started on an SSH host is unreachable from the user's
// machine until a forward exists.
"PortForward".to_string(),
"ControlHub".to_string(),
// Standing research ("re-check these sources every morning")
// belongs on a schedule, and ControlHub's `wait` points here
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,10 @@ impl TeamMode {
"ExecCommand".to_string(),
"WriteStdin".to_string(),
"ExecControl".to_string(),
// The companion to ExecCommand for remote work: a server
// started on an SSH host is unreachable from the user's
// machine until a forward exists.
"PortForward".to_string(),
"Grep".to_string(),
"Glob".to_string(),
"WebSearch".to_string(),
Expand Down
5 changes: 5 additions & 0 deletions src/crates/assembly/core/src/agentic/agents/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,11 @@ pub fn shared_coding_mode_tools() -> Vec<String> {
"ExecCommand".to_string(),
"WriteStdin".to_string(),
"ExecControl".to_string(),
// The companion to ExecCommand for remote work: a server started on
// an SSH host is unreachable from the user's machine until a forward
// exists, and an Agent that cannot see this tool reinvents it with
// hand-written `ssh -L` instructions the user has to run themselves.
"PortForward".to_string(),
"Grep".to_string(),
"Glob".to_string(),
"WebSearch".to_string(),
Expand Down
30 changes: 30 additions & 0 deletions src/crates/assembly/core/src/agentic/agents/registry/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -367,6 +367,36 @@ fn every_builtin_mode_with_control_hub_can_also_schedule_with_cron() {
}
}

#[test]
fn builtin_modes_carrying_the_environment_tools_can_forward_a_port_they_opened() {
// The allowlist is what the deferred catalog is built from, so a tool
// missing here is invisible to the model however it is registered — the
// Agent then reinvents the feature as hand-written `ssh -L` instructions
// for the user to run.
//
// Cron is the marker for "carries the environment-control set" rather than
// ExecCommand, which even the deliberately narrow modes have: `minimal` is
// a fixed six-tool manifest and `Ultra` is a planner whose workers do the
// running, and neither carries Git or Cron either.
for spec in builtin_agent_specs()
.into_iter()
.filter(|spec| spec.category == AgentCategory::Mode)
{
let mode = (spec.factory)();
let default_tools = mode.default_tools();
let runs_commands = default_tools.iter().any(|tool| tool == "ExecCommand");
let schedules = default_tools.iter().any(|tool| tool == "Cron");
if !(runs_commands && schedules) {
continue;
}
assert!(
default_tools.iter().any(|tool| tool == "PortForward"),
"builtin mode {} can run commands but cannot forward a port it opened",
mode.id()
);
}
}

#[test]
fn agent_list_and_delete_are_exposed_only_to_swarm_planners() {
for spec in builtin_agent_specs() {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ Notes:
| `SessionMessage` | Deferred | None | - |
| `SessionHistory` | Deferred | None | - |
| `Cron` | Deferred | None | - |
| `PortForward` | Deferred | None | - |
| `WebSearch` | Deferred | `DeepResearch` | Direct |
| `WebFetch` | Deferred | `DeepResearch` | Direct |
| `ListMCPResources` | Deferred | None | - |
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,8 @@ pub mod page_publish_tool;
#[cfg(feature = "tools-miniapp")]
pub mod playbook_tool;
#[cfg(feature = "tools-git")]
pub mod port_forward_tool;
#[cfg(feature = "tools-git")]
pub mod review_platform_tool;
pub mod session_control_tool;
pub mod session_history_tool;
Expand All @@ -71,7 +73,6 @@ pub mod util;
pub mod view_image_tool;
#[cfg(feature = "tools-browser-web")]
pub mod web;
#[cfg(feature = "tools-git")]
pub mod worktree_tool;

#[deprecated(note = "GetToolSpecTool is owned by the product tool runtime boundary")]
Expand Down Expand Up @@ -128,6 +129,8 @@ pub use page_publish_tool::PagePublishTool;
#[cfg(feature = "tools-miniapp")]
pub use playbook_tool::PlaybookTool;
#[cfg(feature = "tools-git")]
pub use port_forward_tool::PortForwardTool;
#[cfg(feature = "tools-git")]
pub use review_platform_tool::ReviewPlatformTool;
pub use session_control_tool::SessionControlTool;
pub use session_history_tool::SessionHistoryTool;
Expand All @@ -143,5 +146,4 @@ pub use todo_write_tool::TodoWriteTool;
pub use view_image_tool::ViewImageTool;
#[cfg(feature = "tools-browser-web")]
pub use web::{WebFetchTool, WebSearchTool};
#[cfg(feature = "tools-git")]
pub use worktree_tool::WorktreeTool;
Loading