From df11f6b7ffb2e36e410de318d2162ee8f8441a9a Mon Sep 17 00:00:00 2001 From: damienrj Date: Thu, 27 Aug 2026 18:46:17 -0700 Subject: [PATCH 01/13] feat: remote SSH sessions (experiment) --- src-tauri/src/commands/mod.rs | 1 + src-tauri/src/commands/remote_backend.rs | 70 ++ src-tauri/src/lib.rs | 10 + src-tauri/src/services/acp/goose_serve.rs | 8 +- src-tauri/src/services/diagnostic_log.rs | 2 + src-tauri/src/services/mod.rs | 1 + .../src/services/remote_backend/daemon.rs | 623 ++++++++++++++++++ .../src/services/remote_backend/error.rs | 188 ++++++ src-tauri/src/services/remote_backend/host.rs | 236 +++++++ src-tauri/src/services/remote_backend/mod.rs | 575 ++++++++++++++++ .../services/remote_backend/remote_daemon.sh | 183 +++++ src-tauri/src/services/remote_backend/ssh.rs | 109 +++ .../src/services/remote_backend/ssh_config.rs | 156 +++++ .../src/services/remote_backend/tunnel.rs | 169 +++++ src/app/AppShell.tsx | 162 ++++- src/app/lib/chatRuntimeStartup.ts | 15 + .../berdctl/commands/runtime/sessions.ts | 2 +- .../ConversationComposerCapability.tsx | 27 +- .../chat/hooks/ArtifactPolicyContext.tsx | 78 ++- .../__tests__/ArtifactPolicyContext.test.tsx | 81 +++ .../useChatSessionController.test.ts | 166 ++++- .../__tests__/useGitStateAutoRefresh.test.tsx | 42 ++ src/features/chat/hooks/useChat.ts | 16 +- .../chat/hooks/useChatSessionController.ts | 179 ++++- .../chat/hooks/useGitStateAutoRefresh.ts | 16 +- .../acpSessionMapping.remoteHost.test.ts | 100 +++ .../lib/__tests__/sessionActivation.test.ts | 64 ++ src/features/chat/lib/acpSessionMapping.ts | 30 +- src/features/chat/lib/newChat.test.ts | 88 +++ src/features/chat/lib/newChat.ts | 11 + src/features/chat/lib/queuedSessionSend.ts | 8 +- src/features/chat/lib/remoteSession.ts | 24 + src/features/chat/lib/sessionActivation.ts | 15 + .../lib/sessionFolderRegistration.test.ts | 32 + .../chat/lib/sessionFolderRegistration.ts | 23 + ...essionTargetTransition.integration.test.ts | 1 + .../chat/lib/sessionWorkspaceCleanup.ts | 4 +- .../chatSessionStore.remoteSessions.test.ts | 206 ++++++ .../remoteSessionPersistence.test.ts | 186 ++++++ src/features/chat/stores/chatSessionStore.ts | 71 +- .../chat/stores/remoteSessionPersistence.ts | 160 +++++ src/features/chat/types.ts | 11 + src/features/chat/ui/ArtifactViewer.tsx | 121 +++- src/features/chat/ui/ArtifactViewerPanel.tsx | 16 +- src/features/chat/ui/ChatInput.tsx | 2 + src/features/chat/ui/ChatInputToolbar.tsx | 48 +- src/features/chat/ui/ChatView.tsx | 35 +- src/features/chat/ui/ContextPanel.tsx | 152 +++-- src/features/chat/ui/MarkdownImage.tsx | 38 +- src/features/chat/ui/McpAppView.tsx | 6 +- .../chat/ui/RemoteDirectoryPicker.tsx | 233 +++++++ src/features/chat/ui/RemoteHostSelector.tsx | 119 ++++ .../chat/ui/__tests__/ContextPanel.test.tsx | 49 ++ .../chat/ui/__tests__/McpAppView.test.tsx | 4 +- .../__tests__/RemoteDirectoryPicker.test.tsx | 132 ++++ .../ui/__tests__/RemoteHostSelector.test.tsx | 85 +++ .../chat/ui/__tests__/markdownImage.test.tsx | 38 ++ .../ui/widgets/RemoteWorkspaceSummary.tsx | 100 +++ .../connections/ui/ConnectionsSettings.tsx | 4 + .../__tests__/ExperimentsSettings.test.tsx | 2 + .../experiments/experimentDefinitions.test.ts | 9 + .../experiments/experimentDefinitions.ts | 11 + .../stores/remoteHostStore.test.ts | 361 ++++++++++ .../remoteHosts/stores/remoteHostStore.ts | 265 ++++++++ .../remoteHosts/ui/RemoteHostsSettings.tsx | 355 ++++++++++ .../ui/__tests__/RemoteHostsSettings.test.tsx | 212 ++++++ src/features/search/ui/SearchView.tsx | 8 + .../settings/ui/settingsSearchItems.ts | 7 + src/shared/api/__tests__/acp.test.ts | 29 + src/shared/api/__tests__/acpApi.test.ts | 1 + src/shared/api/__tests__/acpBackends.test.ts | 235 +++++++ .../api/__tests__/acpSessionRegistry.test.ts | 4 +- src/shared/api/acp.ts | 9 + src/shared/api/acpApi.ts | 60 +- src/shared/api/acpBackendId.ts | 25 + src/shared/api/acpConnection.ts | 286 +++++--- src/shared/api/acpSessionBackends.ts | 39 ++ src/shared/api/acpSessionRegistry.ts | 17 +- src/shared/api/remoteHosts.ts | 144 ++++ src/shared/i18n/locales/en/chat.json | 41 ++ src/shared/i18n/locales/en/settings.json | 42 ++ src/shared/i18n/locales/es/chat.json | 41 ++ src/shared/i18n/locales/es/settings.json | 42 ++ src/shared/ui/GlobalComposerPill.tsx | 4 + 84 files changed, 7268 insertions(+), 312 deletions(-) create mode 100644 src-tauri/src/commands/remote_backend.rs create mode 100644 src-tauri/src/services/remote_backend/daemon.rs create mode 100644 src-tauri/src/services/remote_backend/error.rs create mode 100644 src-tauri/src/services/remote_backend/host.rs create mode 100644 src-tauri/src/services/remote_backend/mod.rs create mode 100644 src-tauri/src/services/remote_backend/remote_daemon.sh create mode 100644 src-tauri/src/services/remote_backend/ssh.rs create mode 100644 src-tauri/src/services/remote_backend/ssh_config.rs create mode 100644 src-tauri/src/services/remote_backend/tunnel.rs create mode 100644 src/features/chat/lib/__tests__/acpSessionMapping.remoteHost.test.ts create mode 100644 src/features/chat/lib/remoteSession.ts create mode 100644 src/features/chat/stores/__tests__/chatSessionStore.remoteSessions.test.ts create mode 100644 src/features/chat/stores/__tests__/remoteSessionPersistence.test.ts create mode 100644 src/features/chat/stores/remoteSessionPersistence.ts create mode 100644 src/features/chat/ui/RemoteDirectoryPicker.tsx create mode 100644 src/features/chat/ui/RemoteHostSelector.tsx create mode 100644 src/features/chat/ui/__tests__/RemoteDirectoryPicker.test.tsx create mode 100644 src/features/chat/ui/__tests__/RemoteHostSelector.test.tsx create mode 100644 src/features/chat/ui/widgets/RemoteWorkspaceSummary.tsx create mode 100644 src/features/remoteHosts/stores/remoteHostStore.test.ts create mode 100644 src/features/remoteHosts/stores/remoteHostStore.ts create mode 100644 src/features/remoteHosts/ui/RemoteHostsSettings.tsx create mode 100644 src/features/remoteHosts/ui/__tests__/RemoteHostsSettings.test.tsx create mode 100644 src/shared/api/__tests__/acpBackends.test.ts create mode 100644 src/shared/api/acpBackendId.ts create mode 100644 src/shared/api/acpSessionBackends.ts create mode 100644 src/shared/api/remoteHosts.ts diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index 3cd47ca24..4b88b1b00 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -48,6 +48,7 @@ pub mod pocket_voice; pub mod pr_tracker; pub mod project_icons; pub mod pull_requests; +pub mod remote_backend; pub mod renderer; pub mod runtime_config; pub mod security_threshold; diff --git a/src-tauri/src/commands/remote_backend.rs b/src-tauri/src/commands/remote_backend.rs new file mode 100644 index 000000000..11a90f749 --- /dev/null +++ b/src-tauri/src/commands/remote_backend.rs @@ -0,0 +1,70 @@ +//! Renderer-facing commands for remote SSH backends. +//! +//! Thin wrappers over `services::remote_backend`: every command re-validates +//! the host string against freshly parsed ssh-config aliases before any ssh +//! argv is built, and errors cross the bridge as typed +//! `RemoteBackendError { kind, message }` values the UI can render actionably. + +use tauri::{AppHandle, State}; + +use crate::services::remote_backend::{ + self, daemon::RemoteDirListing, daemon::RemoteToolProbe, RemoteBackendConnection, + RemoteBackendError, RemoteBackendRegistry, RemoteBackendStatus, +}; + +#[tauri::command] +pub async fn list_ssh_config_hosts() -> Result, RemoteBackendError> { + Ok( + tokio::task::spawn_blocking(remote_backend::ssh_config::load_ssh_config_hosts) + .await + .unwrap_or_default(), + ) +} + +#[tauri::command] +pub async fn remote_backend_connect( + app: AppHandle, + registry: State<'_, RemoteBackendRegistry>, + host: String, +) -> Result { + remote_backend::connect(&app, ®istry, &host).await +} + +#[tauri::command] +pub async fn remote_backend_disconnect( + app: AppHandle, + registry: State<'_, RemoteBackendRegistry>, + host: String, +) -> Result<(), RemoteBackendError> { + remote_backend::disconnect(&app, ®istry, &host); + Ok(()) +} + +#[tauri::command] +pub async fn remote_backend_shutdown( + app: AppHandle, + registry: State<'_, RemoteBackendRegistry>, + host: String, +) -> Result<(), RemoteBackendError> { + remote_backend::shutdown(&app, ®istry, &host).await +} + +#[tauri::command] +pub async fn list_remote_backends( + registry: State<'_, RemoteBackendRegistry>, +) -> Result, RemoteBackendError> { + Ok(registry.snapshot()) +} + +#[tauri::command] +pub async fn check_remote_host(host: String) -> Result, RemoteBackendError> { + remote_backend::check_host(&host).await +} + +#[tauri::command] +pub async fn list_remote_dirs( + host: String, + path: String, +) -> Result { + remote_backend::list_remote_dir(&host, &path).await +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index eff35c145..7736446a4 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -230,6 +230,7 @@ pub fn run() { app.manage(commands::terminal::TerminalState::default()); app.manage(commands::window_session::WindowSessionRegistry::default()); app.manage(commands::agent_setup::AgentSetupRegistry::default()); + app.manage(services::remote_backend::RemoteBackendRegistry::default()); app.manage(commands::model_setup::ModelSetupRegistry::default()); app.manage(commands::pocket_voice::PocketVoiceState::default()); app.manage(commands::siri_voice::SiriVoiceState::default()); @@ -549,6 +550,13 @@ pub fn run() { commands::whoami::whoami, commands::acp::get_goose_serve_url, commands::acp::get_goose_serve_host_info, + commands::remote_backend::list_ssh_config_hosts, + commands::remote_backend::remote_backend_connect, + commands::remote_backend::remote_backend_disconnect, + commands::remote_backend::remote_backend_shutdown, + commands::remote_backend::list_remote_backends, + commands::remote_backend::check_remote_host, + commands::remote_backend::list_remote_dirs, commands::project_icons::scan_project_icons, commands::project_icons::read_project_icon, commands::renderer::log_renderer_event, @@ -735,6 +743,8 @@ pub fn run() { .stop_for_app_exit(); app.state::() .stop_for_app_exit(); + app.state::() + .kill_all_tunnels(); services::acp::goose_serve::GooseServeProcess::kill_singleton(); } #[cfg(target_os = "macos")] diff --git a/src-tauri/src/services/acp/goose_serve.rs b/src-tauri/src/services/acp/goose_serve.rs index 76f0946e7..69f145e17 100644 --- a/src-tauri/src/services/acp/goose_serve.rs +++ b/src-tauri/src/services/acp/goose_serve.rs @@ -38,9 +38,9 @@ const GOOSE_SERVE_CONNECT_RETRY_DELAY: Duration = Duration::from_millis(100); const GOOSE_SEARCH_PATHS_ENV: &str = "GOOSE_SEARCH_PATHS"; const LOCALHOST: &str = "127.0.0.1"; #[cfg(target_os = "windows")] -const TAURI_WEBVIEW_ORIGIN: &str = "http://tauri.localhost"; +pub(crate) const TAURI_WEBVIEW_ORIGIN: &str = "http://tauri.localhost"; #[cfg(not(target_os = "windows"))] -const TAURI_WEBVIEW_ORIGIN: &str = "tauri://localhost"; +pub(crate) const TAURI_WEBVIEW_ORIGIN: &str = "tauri://localhost"; const DATABRICKS_HOST_ENV: &str = "DATABRICKS_HOST"; const GOOSE_FAST_MODEL_ENV: &str = "GOOSE_FAST_MODEL"; @@ -360,7 +360,7 @@ impl GooseServeProcess { } } -fn acp_websocket_url(port: u16, secret_key: &str) -> String { +pub(crate) fn acp_websocket_url(port: u16, secret_key: &str) -> String { let mut url = reqwest::Url::parse(&format!("ws://{LOCALHOST}:{port}/acp")) .expect("local ACP WebSocket URL should be valid"); url.query_pairs_mut().append_pair("token", secret_key); @@ -1208,7 +1208,7 @@ fn apply_runtime_goose_provider_env(command: &mut Command, runtime_config: &Runt } } -fn reserve_free_port() -> Result { +pub(crate) fn reserve_free_port() -> Result { let listener = std::net::TcpListener::bind((LOCALHOST, 0)) .map_err(|error| format!("Failed to reserve Goose serve port: {error}"))?; listener diff --git a/src-tauri/src/services/diagnostic_log.rs b/src-tauri/src/services/diagnostic_log.rs index 794f5486d..9155aa21c 100644 --- a/src-tauri/src/services/diagnostic_log.rs +++ b/src-tauri/src/services/diagnostic_log.rs @@ -24,6 +24,7 @@ pub enum DiagnosticCategory { Startup, GooseServe, Renderer, + RemoteBackend, } #[derive(Clone, Debug, Deserialize, PartialEq)] @@ -324,6 +325,7 @@ fn category_name(category: DiagnosticCategory) -> &'static str { DiagnosticCategory::Startup => "startup", DiagnosticCategory::GooseServe => "gooseServe", DiagnosticCategory::Renderer => "renderer", + DiagnosticCategory::RemoteBackend => "remoteBackend", } } diff --git a/src-tauri/src/services/mod.rs b/src-tauri/src/services/mod.rs index 3af3a2807..070c17f2a 100644 --- a/src-tauri/src/services/mod.rs +++ b/src-tauri/src/services/mod.rs @@ -33,6 +33,7 @@ pub(crate) mod managed_acp_tools; pub(crate) mod managed_node; pub mod path_env; pub(crate) mod process; +pub(crate) mod remote_backend; pub mod renderer_monitor; pub mod shell_env; #[cfg(feature = "block-automations")] diff --git a/src-tauri/src/services/remote_backend/daemon.rs b/src-tauri/src/services/remote_backend/daemon.rs new file mode 100644 index 000000000..89eee0be6 --- /dev/null +++ b/src-tauri/src/services/remote_backend/daemon.rs @@ -0,0 +1,623 @@ +//! Drive the remote bootstrap script (`remote_daemon.sh`) over ssh. +//! +//! The script travels over stdin (`bash -s -- []`) so +//! no script text, secret, or user path appears in remote argv. Only stdout +//! lines prefixed with the per-invocation nonce are parsed; anything else is +//! shell rc noise and ignored. + +use std::collections::HashMap; +use std::process::Stdio; +use std::time::Duration; + +use base64::Engine as _; +use serde::Serialize; +use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; + +use super::error::{ + classify_script_exit, classify_ssh_stderr, RemoteBackendError, RemoteBackendErrorKind, +}; +use super::host::RemoteHostSpec; +use super::ssh::{base_ssh_command, push_destination}; +use crate::services::log_redaction::redact_log_line; + +const BOOTSTRAP_SCRIPT: &str = include_str!("remote_daemon.sh"); +const SCRIPT_TIMEOUT: Duration = Duration::from_secs(60); +const MAX_STDERR_BYTES: usize = 16 * 1024; + +#[derive(Clone, Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct RemoteDaemonInfo { + pub pid: u32, + pub port: u16, + #[serde(skip_serializing)] + pub secret: String, + pub goose_version: String, + pub started_at: String, + pub reused: bool, +} + +#[derive(Clone, Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct RemoteToolProbe { + pub binary: String, + pub found: bool, + pub version: Option, +} + +#[derive(Clone, Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct RemoteDirEntry { + pub name: String, + pub is_dir: bool, +} + +#[derive(Clone, Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct RemoteDirListing { + pub resolved_path: String, + pub entries: Vec, +} + +pub(crate) struct ScriptOutput { + /// Protocol lines with the nonce prefix stripped. + pub lines: Vec, + pub stderr: String, + pub exit_code: Option, +} + +pub(crate) async fn ensure_daemon( + spec: &RemoteHostSpec, + shell_env: &HashMap, + extra_serve_args: &[String], +) -> Result { + let arg = if extra_serve_args.is_empty() { + None + } else { + Some(base64::engine::general_purpose::STANDARD.encode(extra_serve_args.join(" "))) + }; + let output = run_remote_script(spec, shell_env, "ensure", arg.as_deref()).await?; + require_success(&output)?; + let ready = output + .lines + .iter() + .find_map(|line| line.strip_prefix("READY ")) + .ok_or_else(|| { + script_protocol_error("remote bootstrap finished without a READY line", &output) + })?; + parse_ready_line(ready) + .ok_or_else(|| script_protocol_error("remote bootstrap READY line was malformed", &output)) +} + +pub(crate) async fn shutdown_daemon( + spec: &RemoteHostSpec, + shell_env: &HashMap, +) -> Result<(), RemoteBackendError> { + let output = run_remote_script(spec, shell_env, "shutdown", None).await?; + require_success(&output)?; + if output.lines.iter().any(|line| line == "STOPPED") { + Ok(()) + } else { + Err(script_protocol_error( + "remote shutdown finished without confirmation", + &output, + )) + } +} + +pub(crate) async fn check_host( + spec: &RemoteHostSpec, + shell_env: &HashMap, +) -> Result, RemoteBackendError> { + let output = run_remote_script(spec, shell_env, "check", None).await?; + require_success(&output)?; + let probes = parse_tool_probes(&output.lines); + if probes.is_empty() { + return Err(script_protocol_error( + "remote check produced no tool reports", + &output, + )); + } + Ok(probes) +} + +pub(crate) async fn list_remote_dir( + spec: &RemoteHostSpec, + shell_env: &HashMap, + path: &str, +) -> Result { + if path.contains(['\n', '\r', '\0']) { + return Err(RemoteBackendError::new( + RemoteBackendErrorKind::RemoteScriptFailed, + "path contains unsupported characters", + )); + } + let arg = base64::engine::general_purpose::STANDARD.encode(path); + let output = run_remote_script(spec, shell_env, "listdir", Some(&arg)).await?; + match output.exit_code { + Some(44) => { + return Err(RemoteBackendError::new( + RemoteBackendErrorKind::RemoteScriptFailed, + "path must be absolute or start with ~", + )) + } + Some(45) => { + return Err(RemoteBackendError::new( + RemoteBackendErrorKind::RemoteScriptFailed, + format!("no such directory on remote host: {path}"), + )) + } + _ => require_success(&output)?, + } + parse_dir_listing(&output.lines) + .ok_or_else(|| script_protocol_error("remote listing was malformed", &output)) +} + +pub(crate) async fn run_remote_script( + spec: &RemoteHostSpec, + shell_env: &HashMap, + mode: &str, + arg: Option<&str>, +) -> Result { + let nonce = format!("berd-{}", uuid::Uuid::new_v4()); + + let mut command = base_ssh_command(spec, shell_env); + push_destination(&mut command, spec); + // The remote command string: constant except the nonce/mode/arg, all of + // which are restricted charsets (uuid, keyword, base64). + let remote_command = match arg { + Some(arg) => format!("bash -s -- {nonce} {mode} {arg}"), + None => format!("bash -s -- {nonce} {mode}"), + }; + command.arg(remote_command); + command + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .kill_on_drop(true); + + let mut child = command.spawn().map_err(|error| { + if error.kind() == std::io::ErrorKind::NotFound { + RemoteBackendError::new( + RemoteBackendErrorKind::SshNotFound, + "ssh was not found on this machine", + ) + } else { + RemoteBackendError::internal(format!("failed to spawn ssh: {error}")) + } + })?; + + let mut stdin = child + .stdin + .take() + .ok_or_else(|| RemoteBackendError::internal("ssh stdin unavailable"))?; + let stdout = child + .stdout + .take() + .ok_or_else(|| RemoteBackendError::internal("ssh stdout unavailable"))?; + let stderr = child + .stderr + .take() + .ok_or_else(|| RemoteBackendError::internal("ssh stderr unavailable"))?; + + let run = async { + stdin + .write_all(BOOTSTRAP_SCRIPT.as_bytes()) + .await + .map_err(|error| { + RemoteBackendError::internal(format!("failed to send bootstrap script: {error}")) + })?; + drop(stdin); + + let nonce_prefix = format!("{nonce} "); + let stdout_task = async { + let mut lines = Vec::new(); + let mut reader = BufReader::new(stdout).lines(); + while let Ok(Some(line)) = reader.next_line().await { + if let Some(protocol) = line.strip_prefix(&nonce_prefix) { + lines.push(protocol.to_string()); + } else if !line.trim().is_empty() { + log::debug!("[remote-backend noise] {}", redact_log_line(&line)); + } + } + lines + }; + let stderr_task = async { + let mut collected = String::new(); + let mut reader = BufReader::new(stderr).lines(); + while let Ok(Some(line)) = reader.next_line().await { + let redacted = redact_log_line(&line); + log::warn!("[remote-backend ssh stderr] {redacted}"); + if collected.len() < MAX_STDERR_BYTES { + collected.push_str(&redacted); + collected.push('\n'); + } + } + collected + }; + + let (lines, stderr_text) = tokio::join!(stdout_task, stderr_task); + let status = child.wait().await.map_err(|error| { + RemoteBackendError::internal(format!("failed to await ssh: {error}")) + })?; + Ok::(ScriptOutput { + lines, + stderr: stderr_text, + exit_code: status.code(), + }) + }; + + match tokio::time::timeout(SCRIPT_TIMEOUT, run).await { + Ok(result) => result, + Err(_) => Err(RemoteBackendError::new( + RemoteBackendErrorKind::ReadyTimeout, + format!( + "ssh to {} timed out after {}s", + spec.destination(), + SCRIPT_TIMEOUT.as_secs() + ), + )), + } +} + +pub(crate) fn require_success(output: &ScriptOutput) -> Result<(), RemoteBackendError> { + match output.exit_code { + Some(0) => Ok(()), + code => { + let kind = code + .and_then(classify_script_exit) + .unwrap_or_else(|| classify_ssh_stderr(&output.stderr)); + let err_word = output + .lines + .iter() + .find_map(|line| line.strip_prefix("ERR ")) + .unwrap_or(""); + let stderr_tail: String = output + .stderr + .lines() + .rev() + .take(3) + .collect::>() + .into_iter() + .rev() + .collect::>() + .join(" | "); + let message = match kind { + RemoteBackendErrorKind::GooseNotInstalled => { + "goose is not installed on the remote host's PATH for ssh sessions".to_string() + } + RemoteBackendErrorKind::RemotePortBindFailed => { + "the remote host could not bind a port for goose serve".to_string() + } + RemoteBackendErrorKind::AuthFailed => { + "ssh authentication failed; make sure key or agent auth works (try `ssh ` in a terminal)" + .to_string() + } + RemoteBackendErrorKind::HostKeyUnverified => { + "the host key is not trusted yet; connect once with `ssh ` in a terminal" + .to_string() + } + _ => format!( + "remote command failed (exit {:?}{}) {}", + code, + if err_word.is_empty() { + String::new() + } else { + format!(", {err_word}") + }, + stderr_tail + ), + }; + Err(RemoteBackendError::new(kind, message)) + } + } +} + +fn script_protocol_error(context: &str, output: &ScriptOutput) -> RemoteBackendError { + let stderr_tail: String = output + .stderr + .lines() + .rev() + .take(3) + .collect::>() + .join(" | "); + RemoteBackendError::new( + RemoteBackendErrorKind::RemoteScriptFailed, + format!("{context}. {stderr_tail}"), + ) +} + +fn decode_b64_field(field: &str) -> Option { + if field == "-" { + return None; + } + let bytes = base64::engine::general_purpose::STANDARD + .decode(field) + .ok()?; + Some(String::from_utf8_lossy(&bytes).into_owned()) +} + +fn parse_ready_line(rest: &str) -> Option { + let mut fields = rest.split_whitespace(); + let pid: u32 = fields.next()?.parse().ok()?; + let port: u16 = fields.next()?.parse().ok()?; + let secret = fields.next()?.to_string(); + let reused = match fields.next()? { + "1" => true, + "0" => false, + _ => return None, + }; + let goose_version = decode_b64_field(fields.next()?).unwrap_or_else(|| "unknown".to_string()); + let started_at = fields.next()?.to_string(); + Some(RemoteDaemonInfo { + pid, + port, + secret, + goose_version, + started_at, + reused, + }) +} + +fn parse_tool_probes(lines: &[String]) -> Vec { + lines + .iter() + .filter_map(|line| { + let rest = line.strip_prefix("TOOL ")?; + let mut fields = rest.split_whitespace(); + let binary = fields.next()?.to_string(); + let found = fields.next()? == "1"; + let version = fields.next().and_then(decode_b64_field); + Some(RemoteToolProbe { + binary, + found, + version, + }) + }) + .collect() +} + +fn parse_dir_listing(lines: &[String]) -> Option { + let resolved_path = lines + .iter() + .find_map(|line| line.strip_prefix("DIR ")) + .and_then(decode_b64_field)?; + let entries = lines + .iter() + .filter_map(|line| { + let rest = line.strip_prefix("E ")?; + let (kind, name) = rest.split_once(' ')?; + let name = decode_b64_field(name)?; + Some(RemoteDirEntry { + name, + is_dir: kind == "D", + }) + }) + .collect(); + Some(RemoteDirListing { + resolved_path, + entries, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn output(lines: &[&str], exit_code: Option, stderr: &str) -> ScriptOutput { + ScriptOutput { + lines: lines.iter().map(|s| s.to_string()).collect(), + stderr: stderr.to_string(), + exit_code, + } + } + + fn b64(value: &str) -> String { + base64::engine::general_purpose::STANDARD.encode(value) + } + + #[test] + fn parses_ready_line() { + let line = format!( + "4242 23456 berd-remote-abc123 0 {} 1756300000", + b64("goose 1.2.3") + ); + let info = parse_ready_line(&line).unwrap(); + assert_eq!(info.pid, 4242); + assert_eq!(info.port, 23456); + assert_eq!(info.secret, "berd-remote-abc123"); + assert!(!info.reused); + assert_eq!(info.goose_version, "goose 1.2.3"); + assert_eq!(info.started_at, "1756300000"); + } + + #[test] + fn rejects_malformed_ready_lines() { + assert!(parse_ready_line("not numbers here at all -").is_none()); + assert!(parse_ready_line("1 2 secret 7 dmVyc2lvbg== 3").is_none()); + assert!(parse_ready_line("").is_none()); + } + + #[test] + fn parses_tool_probes() { + let lines = vec![ + format!("TOOL goose 1 {}", b64("goose 9.9")), + "TOOL claude-agent-acp 0 -".to_string(), + "CHECK-DONE".to_string(), + ]; + let probes = parse_tool_probes(&lines); + assert_eq!(probes.len(), 2); + assert!(probes[0].found); + assert_eq!(probes[0].version.as_deref(), Some("goose 9.9")); + assert!(!probes[1].found); + assert_eq!(probes[1].version, None); + } + + #[test] + fn parses_dir_listing() { + let lines = vec![ + format!("DIR {}", b64("/home/damien/src")), + format!("E D {}", b64("berd")), + format!("E F {}", b64("notes with spaces.md")), + "LIST-DONE".to_string(), + ]; + let listing = parse_dir_listing(&lines).unwrap(); + assert_eq!(listing.resolved_path, "/home/damien/src"); + assert_eq!(listing.entries.len(), 2); + assert!(listing.entries[0].is_dir); + assert_eq!(listing.entries[1].name, "notes with spaces.md"); + } + + #[test] + fn require_success_maps_script_exit_codes() { + let err = require_success(&output(&["ERR goose-not-found"], Some(41), "")).unwrap_err(); + assert_eq!(err.kind, RemoteBackendErrorKind::GooseNotInstalled); + + let err = require_success(&output(&["ERR port-bind-failed"], Some(43), "")).unwrap_err(); + assert_eq!(err.kind, RemoteBackendErrorKind::RemotePortBindFailed); + } + + #[test] + fn require_success_classifies_ssh_stderr() { + let err = require_success(&output( + &[], + Some(255), + "user@host: Permission denied (publickey).", + )) + .unwrap_err(); + assert_eq!(err.kind, RemoteBackendErrorKind::AuthFailed); + } + + #[test] + fn require_success_passes_on_zero() { + assert!(require_success(&output(&["READY"], Some(0), "")).is_ok()); + } + + #[test] + fn secret_is_not_serialized() { + let info = RemoteDaemonInfo { + pid: 1, + port: 2, + secret: "hush".to_string(), + goose_version: "v".to_string(), + started_at: "0".to_string(), + reused: false, + }; + let json = serde_json::to_string(&info).unwrap(); + assert!(!json.contains("hush")); + } + + /// Pin the on-the-wire protocol against the actual script under local bash + /// (failure paths only; starting a real server is out of scope for unit + /// tests). + #[cfg(unix)] + mod script_contract { + use super::*; + use std::process::Command as StdCommand; + + fn run_script( + args: &[&str], + path_override: Option<&str>, + home: &std::path::Path, + ) -> (Vec, Option) { + let nonce = "berd-test-nonce"; + let mut command = StdCommand::new("bash"); + command + .arg("-s") + .arg("--") + .arg(nonce) + .args(args) + .env("HOME", home) + .env("XDG_STATE_HOME", home.join(".state")) + .stdin(std::process::Stdio::piped()) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()); + if let Some(path) = path_override { + command.env("PATH", path); + } + let mut child = command.spawn().expect("spawn bash"); + use std::io::Write as _; + child + .stdin + .take() + .unwrap() + .write_all(BOOTSTRAP_SCRIPT.as_bytes()) + .unwrap(); + let out = child.wait_with_output().expect("script output"); + let prefix = format!("{nonce} "); + let lines = String::from_utf8_lossy(&out.stdout) + .lines() + .filter_map(|line| line.strip_prefix(&prefix).map(str::to_string)) + .collect(); + (lines, out.status.code()) + } + + #[test] + fn ensure_reports_goose_not_found() { + let home = tempfile::tempdir().unwrap(); + // PATH with core utilities but no goose. + let (lines, code) = run_script(&["ensure"], Some("/usr/bin:/bin"), home.path()); + assert_eq!(code, Some(41), "lines: {lines:?}"); + assert!(lines.iter().any(|l| l == "ERR goose-not-found")); + } + + #[test] + fn shutdown_without_record_still_confirms() { + let home = tempfile::tempdir().unwrap(); + let (lines, code) = run_script(&["shutdown"], None, home.path()); + assert_eq!(code, Some(0), "lines: {lines:?}"); + assert!(lines.iter().any(|l| l == "STOPPED")); + } + + #[test] + fn check_reports_tools() { + let home = tempfile::tempdir().unwrap(); + let (lines, code) = run_script(&["check"], Some("/usr/bin:/bin"), home.path()); + assert_eq!(code, Some(0), "lines: {lines:?}"); + let probes = parse_tool_probes(&lines); + assert_eq!(probes.len(), 3); + assert!(probes.iter().all(|p| !p.found)); + assert!(lines.iter().any(|l| l == "CHECK-DONE")); + } + + #[test] + fn listdir_lists_real_directories() { + let home = tempfile::tempdir().unwrap(); + std::fs::create_dir(home.path().join("proj")).unwrap(); + std::fs::write(home.path().join("file with spaces.txt"), "x").unwrap(); + let arg = base64::engine::general_purpose::STANDARD + .encode(home.path().to_string_lossy().as_bytes()); + let (lines, code) = run_script(&["listdir", &arg], None, home.path()); + assert_eq!(code, Some(0), "lines: {lines:?}"); + let listing = parse_dir_listing(&lines).unwrap(); + assert!(listing.entries.iter().any(|e| e.name == "proj" && e.is_dir)); + assert!(listing + .entries + .iter() + .any(|e| e.name == "file with spaces.txt" && !e.is_dir)); + } + + #[test] + fn listdir_rejects_relative_paths() { + let home = tempfile::tempdir().unwrap(); + let arg = base64::engine::general_purpose::STANDARD.encode("relative/path"); + let (lines, code) = run_script(&["listdir", &arg], None, home.path()); + assert_eq!(code, Some(44), "lines: {lines:?}"); + } + + #[test] + fn listdir_reports_missing_directories() { + let home = tempfile::tempdir().unwrap(); + let arg = base64::engine::general_purpose::STANDARD.encode("/definitely/not/here"); + let (lines, code) = run_script(&["listdir", &arg], None, home.path()); + assert_eq!(code, Some(45), "lines: {lines:?}"); + } + + #[test] + fn noise_before_protocol_lines_is_ignored_by_prefix_filter() { + // Simulates shell rc noise: caller filtering only keeps nonce lines. + let home = tempfile::tempdir().unwrap(); + let (lines, _) = run_script(&["shutdown"], None, home.path()); + assert!(lines.iter().all(|l| !l.contains("motd"))); + } + } +} diff --git a/src-tauri/src/services/remote_backend/error.rs b/src-tauri/src/services/remote_backend/error.rs new file mode 100644 index 000000000..2618bf127 --- /dev/null +++ b/src-tauri/src/services/remote_backend/error.rs @@ -0,0 +1,188 @@ +//! Typed errors for the remote SSH backend, shaped for actionable rendering. +//! +//! The renderer switches copy on `kind`; `message` carries the redacted +//! diagnostic detail. Classification is heuristic (OpenSSH reports most +//! failures as exit 255 plus a stderr line), so unknown shapes fall back to +//! broader kinds rather than guessing. + +use serde::Serialize; + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)] +#[serde(rename_all = "kebab-case")] +pub enum RemoteBackendErrorKind { + InvalidHost, + SshNotFound, + AuthFailed, + HostKeyUnverified, + HostUnreachable, + GooseNotInstalled, + RemotePortBindFailed, + LocalPortBindFailed, + TunnelClosed, + ReadyTimeout, + RemoteScriptFailed, + Internal, +} + +#[derive(Clone, Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct RemoteBackendError { + pub kind: RemoteBackendErrorKind, + pub message: String, +} + +impl RemoteBackendError { + pub fn new(kind: RemoteBackendErrorKind, message: impl Into) -> Self { + Self { + kind, + message: message.into(), + } + } + + pub fn internal(message: impl Into) -> Self { + Self::new(RemoteBackendErrorKind::Internal, message) + } +} + +impl std::fmt::Display for RemoteBackendError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{:?}: {}", self.kind, self.message) + } +} + +impl std::error::Error for RemoteBackendError {} + +/// Map an ssh stderr transcript to the most specific error kind we can defend. +/// Order matters: the first matching classification wins, and later entries are +/// deliberately broader. +pub(crate) fn classify_ssh_stderr(stderr: &str) -> RemoteBackendErrorKind { + const CLASSIFICATIONS: &[(&str, RemoteBackendErrorKind)] = &[ + ("Permission denied", RemoteBackendErrorKind::AuthFailed), + ( + "Too many authentication failures", + RemoteBackendErrorKind::AuthFailed, + ), + ( + "Host key verification failed", + RemoteBackendErrorKind::HostKeyUnverified, + ), + ( + "REMOTE HOST IDENTIFICATION HAS CHANGED", + RemoteBackendErrorKind::HostKeyUnverified, + ), + ( + "Could not resolve hostname", + RemoteBackendErrorKind::HostUnreachable, + ), + ( + "Connection timed out", + RemoteBackendErrorKind::HostUnreachable, + ), + ( + "Operation timed out", + RemoteBackendErrorKind::HostUnreachable, + ), + ( + "Connection refused", + RemoteBackendErrorKind::HostUnreachable, + ), + ("No route to host", RemoteBackendErrorKind::HostUnreachable), + ( + "Network is unreachable", + RemoteBackendErrorKind::HostUnreachable, + ), + ( + "Address already in use", + RemoteBackendErrorKind::LocalPortBindFailed, + ), + ( + "cannot listen to port", + RemoteBackendErrorKind::LocalPortBindFailed, + ), + ]; + + for (needle, kind) in CLASSIFICATIONS { + if stderr.contains(needle) { + return *kind; + } + } + RemoteBackendErrorKind::Internal +} + +/// Exit codes the bootstrap script uses to report typed failures. +pub(crate) const EXIT_GOOSE_NOT_FOUND: i32 = 41; +pub(crate) const EXIT_REMOTE_PORT_BIND_FAILED: i32 = 43; + +pub(crate) fn classify_script_exit(code: i32) -> Option { + match code { + EXIT_GOOSE_NOT_FOUND => Some(RemoteBackendErrorKind::GooseNotInstalled), + EXIT_REMOTE_PORT_BIND_FAILED => Some(RemoteBackendErrorKind::RemotePortBindFailed), + _ => None, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn classifies_auth_failures() { + assert_eq!( + classify_ssh_stderr("user@host: Permission denied (publickey)."), + RemoteBackendErrorKind::AuthFailed + ); + } + + #[test] + fn classifies_host_key_failures() { + assert_eq!( + classify_ssh_stderr("Host key verification failed."), + RemoteBackendErrorKind::HostKeyUnverified + ); + } + + #[test] + fn classifies_unreachable_hosts() { + for line in [ + "ssh: Could not resolve hostname nope: nodename nor servname provided", + "ssh: connect to host 10.0.0.9 port 22: Connection timed out", + "ssh: connect to host devbox port 22: Connection refused", + "ssh: connect to host devbox port 22: No route to host", + ] { + assert_eq!( + classify_ssh_stderr(line), + RemoteBackendErrorKind::HostUnreachable, + "line: {line}" + ); + } + } + + #[test] + fn classifies_local_forward_bind_failures() { + assert_eq!( + classify_ssh_stderr("bind [127.0.0.1]:5123: Address already in use"), + RemoteBackendErrorKind::LocalPortBindFailed + ); + } + + #[test] + fn unknown_stderr_falls_back_to_internal() { + assert_eq!( + classify_ssh_stderr("something new and exciting"), + RemoteBackendErrorKind::Internal + ); + } + + #[test] + fn classifies_script_exit_codes() { + assert_eq!( + classify_script_exit(EXIT_GOOSE_NOT_FOUND), + Some(RemoteBackendErrorKind::GooseNotInstalled) + ); + assert_eq!( + classify_script_exit(EXIT_REMOTE_PORT_BIND_FAILED), + Some(RemoteBackendErrorKind::RemotePortBindFailed) + ); + assert_eq!(classify_script_exit(1), None); + } +} diff --git a/src-tauri/src/services/remote_backend/host.rs b/src-tauri/src/services/remote_backend/host.rs new file mode 100644 index 000000000..9fe954db5 --- /dev/null +++ b/src-tauri/src/services/remote_backend/host.rs @@ -0,0 +1,236 @@ +//! Validated SSH destination parsing. +//! +//! Everything the renderer sends is treated as untrusted. A destination is +//! accepted only when it is an exact alias from the user's parsed ssh config, +//! or matches a strict `[user@]host[:port]` shape. The strictness exists to +//! keep option injection (`-oProxyCommand=...`) and shell metacharacters out +//! of the ssh argv; the destination is always passed as its own argv element +//! after `--`, and the port always via `-p`. + +use super::error::{RemoteBackendError, RemoteBackendErrorKind}; + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct RemoteHostSpec { + destination: String, + port: Option, +} + +impl RemoteHostSpec { + /// The `[user@]host` string handed to ssh as the destination argv element. + pub fn destination(&self) -> &str { + &self.destination + } + + /// Explicit port for `-p`, when the input carried one. + pub fn port(&self) -> Option { + self.port + } + + /// Stable registry key for this host as the user expressed it. + pub fn key(&self) -> String { + match self.port { + Some(port) => format!("{}:{port}", self.destination), + None => self.destination.clone(), + } + } + + pub fn parse(input: &str, known_aliases: &[String]) -> Result { + let trimmed = input.trim(); + if trimmed.is_empty() { + return Err(invalid("host must not be empty")); + } + if trimmed.len() > 255 { + return Err(invalid("host is too long")); + } + if trimmed.chars().any(|c| c.is_whitespace() || c.is_control()) { + return Err(invalid( + "host must not contain whitespace or control characters", + )); + } + const FORBIDDEN: &[char] = &['\'', '"', '`', ';', '$', '\\', '=', '|', '&', '<', '>']; + if trimmed.contains(FORBIDDEN) { + return Err(invalid("host contains forbidden characters")); + } + if trimmed.starts_with('-') { + return Err(invalid("host must not start with '-'")); + } + + // An exact ssh-config alias wins verbatim (aliases can contain shapes + // our fallback grammar rejects, e.g. uncommon characters). + if known_aliases.iter().any(|alias| alias == trimmed) { + return Ok(Self { + destination: trimmed.to_string(), + port: None, + }); + } + + let (user, rest) = match trimmed.split_once('@') { + Some((user, rest)) => (Some(user), rest), + None => (None, trimmed), + }; + + if let Some(user) = user { + if user.is_empty() { + return Err(invalid("user must not be empty")); + } + if user.starts_with('-') { + return Err(invalid("user must not start with '-'")); + } + if !user + .chars() + .all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-')) + { + return Err(invalid("user contains unsupported characters")); + } + } + + let (host, port) = split_host_port(rest)?; + if host.is_empty() { + return Err(invalid("host must not be empty")); + } + if host.starts_with('-') { + return Err(invalid("host must not start with '-'")); + } + let host_ok = if host.starts_with('[') && host.ends_with(']') { + // Bracketed IPv6 literal. + host[1..host.len() - 1] + .chars() + .all(|c| c.is_ascii_hexdigit() || c == ':') + } else { + host.chars() + .all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '-')) + }; + if !host_ok { + return Err(invalid("host contains unsupported characters")); + } + + let destination = match user { + Some(user) => format!("{user}@{host}"), + None => host.to_string(), + }; + Ok(Self { destination, port }) + } +} + +fn split_host_port(rest: &str) -> Result<(&str, Option), RemoteBackendError> { + // Bracketed IPv6 may carry a port after the bracket: [::1]:2222. + if let Some(bracket_end) = rest.find(']') { + let host = &rest[..=bracket_end]; + let tail = &rest[bracket_end + 1..]; + if tail.is_empty() { + return Ok((host, None)); + } + let port = tail + .strip_prefix(':') + .ok_or_else(|| invalid("unexpected characters after IPv6 literal"))?; + return Ok((host, Some(parse_port(port)?))); + } + + match rest.split_once(':') { + Some((host, port)) => Ok((host, Some(parse_port(port)?))), + None => Ok((rest, None)), + } +} + +fn parse_port(port: &str) -> Result { + let parsed: u16 = port + .parse() + .map_err(|_| invalid("port must be a number between 1 and 65535"))?; + if parsed == 0 { + return Err(invalid("port must be a number between 1 and 65535")); + } + Ok(parsed) +} + +fn invalid(message: &str) -> RemoteBackendError { + RemoteBackendError::new(RemoteBackendErrorKind::InvalidHost, message) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn aliases(items: &[&str]) -> Vec { + items.iter().map(|s| s.to_string()).collect() + } + + #[test] + fn accepts_known_alias_verbatim() { + let spec = RemoteHostSpec::parse("devbox", &aliases(&["devbox"])).unwrap(); + assert_eq!(spec.destination(), "devbox"); + assert_eq!(spec.port(), None); + } + + #[test] + fn accepts_user_host_and_port() { + let spec = RemoteHostSpec::parse("damien@dev.example.com:2222", &[]).unwrap(); + assert_eq!(spec.destination(), "damien@dev.example.com"); + assert_eq!(spec.port(), Some(2222)); + assert_eq!(spec.key(), "damien@dev.example.com:2222"); + } + + #[test] + fn accepts_bracketed_ipv6_with_port() { + let spec = RemoteHostSpec::parse("[::1]:2222", &[]).unwrap(); + assert_eq!(spec.destination(), "[::1]"); + assert_eq!(spec.port(), Some(2222)); + } + + #[test] + fn rejects_option_injection() { + for input in [ + "-oProxyCommand=evil", + "-v", + "user@-oProxyCommand=evil", + "-@host", + ] { + assert!( + RemoteHostSpec::parse(input, &[]).is_err(), + "should reject {input}" + ); + } + } + + #[test] + fn rejects_shell_metacharacters_and_whitespace() { + for input in [ + "host;rm -rf /", + "host command", + "host\ncommand", + "host$(x)", + "host`x`", + "host|x", + "host&x", + "host>x", + "a=b", + "user name@host", + ] { + assert!( + RemoteHostSpec::parse(input, &[]).is_err(), + "should reject {input:?}" + ); + } + } + + #[test] + fn rejects_bad_ports() { + assert!(RemoteHostSpec::parse("host:0", &[]).is_err()); + assert!(RemoteHostSpec::parse("host:65536", &[]).is_err()); + assert!(RemoteHostSpec::parse("host:abc", &[]).is_err()); + } + + #[test] + fn rejects_empty_and_oversized() { + assert!(RemoteHostSpec::parse("", &[]).is_err()); + assert!(RemoteHostSpec::parse(" ", &[]).is_err()); + let long = "a".repeat(256); + assert!(RemoteHostSpec::parse(&long, &[]).is_err()); + } + + #[test] + fn alias_lookup_does_not_bypass_forbidden_characters() { + // Even a hostile alias list cannot smuggle metacharacters through. + assert!(RemoteHostSpec::parse("evil;alias", &aliases(&["evil;alias"])).is_err()); + assert!(RemoteHostSpec::parse("-evil", &aliases(&["-evil"])).is_err()); + } +} diff --git a/src-tauri/src/services/remote_backend/mod.rs b/src-tauri/src/services/remote_backend/mod.rs new file mode 100644 index 000000000..8309ab6d0 --- /dev/null +++ b/src-tauri/src/services/remote_backend/mod.rs @@ -0,0 +1,575 @@ +//! Remote SSH backends: a detached `goose serve` daemon per host, reached +//! through an SSH local port-forward. +//! +//! Lifecycle model (the daemon is the durable half, the tunnel the cheap one): +//! - `connect` ensures the remote daemon (reusing a healthy one recorded in +//! the remote state dir), reserves a local port, spawns the forwarding ssh, +//! and probes HTTP through it before reporting Ready. +//! - A supervisor watches each tunnel child. Unexpected death triggers up to +//! [`MAX_RECONNECT_ATTEMPTS`] re-establish rounds with exponential backoff; +//! success hands off to a fresh supervisor, exhaustion marks the backend +//! Disconnected. The daemon — and any remote sessions — survive throughout. +//! - `disconnect` kills only the tunnel. `shutdown` also stops the remote +//! daemon. App exit kills all tunnels and leaves daemons running on purpose. +//! +//! Every state transition is emitted as [`REMOTE_BACKEND_STATUS_EVENT`] so the +//! renderer can mirror per-host status without polling. + +pub(crate) mod daemon; +pub(crate) mod error; +pub(crate) mod host; +pub(crate) mod ssh; +pub(crate) mod ssh_config; +pub(crate) mod tunnel; + +use std::collections::HashMap; +use std::sync::{Arc, Mutex}; +use std::time::Duration; + +use serde::Serialize; +use tauri::{AppHandle, Emitter}; + +use crate::services::acp::goose_serve::{ + acp_websocket_url, reserve_free_port, TAURI_WEBVIEW_ORIGIN, +}; +use crate::services::diagnostic_log::{self, DiagnosticCategory, DiagnosticLevel}; +use crate::services::dir_env; +use crate::services::process; + +use daemon::RemoteDaemonInfo; +pub use error::{RemoteBackendError, RemoteBackendErrorKind}; +pub use host::RemoteHostSpec; + +pub const REMOTE_BACKEND_STATUS_EVENT: &str = "berd:remote-backend-status"; + +const MAX_RECONNECT_ATTEMPTS: u32 = 5; +const RECONNECT_BACKOFF_CAP: Duration = Duration::from_secs(30); +/// A reconnect only resets the attempt budget after the tunnel stays up this +/// long. Without the gate, a tunnel that dies seconds after every reconnect +/// would loop forever instead of converging on Disconnected. +const RECONNECT_STABLE_UPTIME: Duration = Duration::from_secs(60); + +#[derive(Clone, Debug, Serialize)] +#[serde( + tag = "state", + rename_all = "camelCase", + rename_all_fields = "camelCase" +)] +pub enum RemoteBackendState { + Connecting, + Ready { + ws_url: String, + http_base_url: String, + local_port: u16, + }, + Reconnecting { + attempt: u32, + error: RemoteBackendError, + }, + Disconnected, + Failed { + error: RemoteBackendError, + }, +} + +#[derive(Clone, Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct RemoteBackendStatus { + pub host: String, + #[serde(flatten)] + pub state: RemoteBackendState, +} + +#[derive(Clone, Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct RemoteBackendConnection { + pub ws_url: String, + pub http_base_url: String, + pub secret_key: String, + pub local_port: u16, + pub goose_version: String, + pub daemon_reused: bool, +} + +#[derive(Default)] +pub struct RemoteBackendRegistry { + slots: Mutex>>, +} + +struct HostSlot { + key: String, + spec: RemoteHostSpec, + /// Serializes establish attempts (user connects and supervisor + /// reconnects) per host. + connect_lock: tokio::sync::Mutex<()>, + shared: Mutex, +} + +struct SlotShared { + state: RemoteBackendState, + /// Monotonic ownership token: each successful establish bumps it, and a + /// supervisor only acts while its own generation is current. Explicit + /// disconnects bump it to strand any racing supervisor. + generation: u64, + daemon: Option, + local_port: Option, + tunnel_pid: Option, +} + +impl RemoteBackendRegistry { + fn slot(&self, spec: &RemoteHostSpec) -> Arc { + let mut slots = self.slots.lock().expect("remote backend registry poisoned"); + Arc::clone(slots.entry(spec.key()).or_insert_with(|| { + Arc::new(HostSlot { + key: spec.key(), + spec: spec.clone(), + connect_lock: tokio::sync::Mutex::new(()), + shared: Mutex::new(SlotShared { + state: RemoteBackendState::Disconnected, + generation: 0, + daemon: None, + local_port: None, + tunnel_pid: None, + }), + }) + })) + } + + fn existing_slot(&self, key: &str) -> Option> { + self.slots + .lock() + .expect("remote backend registry poisoned") + .get(key) + .cloned() + } + + pub fn snapshot(&self) -> Vec { + let slots = self.slots.lock().expect("remote backend registry poisoned"); + slots + .values() + .map(|slot| RemoteBackendStatus { + host: slot.key.clone(), + state: slot.shared.lock().expect("slot poisoned").state.clone(), + }) + .collect() + } + + /// Best-effort synchronous tunnel teardown for app exit. Daemons are left + /// running deliberately: surviving the client is the feature. + pub fn kill_all_tunnels(&self) { + let slots = self.slots.lock().expect("remote backend registry poisoned"); + for slot in slots.values() { + let mut shared = slot.shared.lock().expect("slot poisoned"); + shared.generation += 1; + if let Some(pid) = shared.tunnel_pid.take() { + kill_tunnel_pid(pid); + } + } + } +} + +fn kill_tunnel_pid(pid: u32) { + #[cfg(unix)] + { + if let Some(pid) = process::pid_t_from_u32(pid) { + process::terminate_process(pid); + } + } + #[cfg(windows)] + { + use sysinfo::{Pid, ProcessRefreshKind, ProcessesToUpdate, System}; + let mut system = System::new(); + let pid = Pid::from_u32(pid); + system.refresh_processes_specifics( + ProcessesToUpdate::Some(&[pid]), + true, + ProcessRefreshKind::nothing(), + ); + if let Some(found) = system.process(pid) { + found.kill(); + } + } +} + +fn set_state(app: &AppHandle, slot: &HostSlot, state: RemoteBackendState) { + { + let mut shared = slot.shared.lock().expect("slot poisoned"); + shared.state = state.clone(); + } + emit_status(app, &slot.key, &state); +} + +fn emit_status(app: &AppHandle, host: &str, state: &RemoteBackendState) { + let payload = RemoteBackendStatus { + host: host.to_string(), + state: state.clone(), + }; + if let Err(error) = app.emit(REMOTE_BACKEND_STATUS_EVENT, &payload) { + log::warn!("failed to emit {REMOTE_BACKEND_STATUS_EVENT}: {error}"); + } +} + +fn record_diagnostic(level: DiagnosticLevel, event: &str, host: &str, detail: Option<&str>) { + let mut fields = diagnostic_log::fields([("host", host.into())]); + if let Some(detail) = detail { + fields.insert("detail".to_string(), detail.into()); + } + diagnostic_log::record_event( + level, + DiagnosticCategory::RemoteBackend, + event, + None, + fields, + ); +} + +/// Extra args the remote `goose serve` needs so the packaged app's webview +/// origin passes the server's origin allowlist (mirrors the local spawn). +fn extra_serve_args() -> Vec { + if cfg!(debug_assertions) { + Vec::new() + } else { + vec![ + "--allowed-origin".to_string(), + TAURI_WEBVIEW_ORIGIN.to_string(), + ] + } +} + +fn connection_from_shared(shared: &SlotShared) -> Option { + let daemon = shared.daemon.as_ref()?; + let local_port = shared.local_port?; + if let RemoteBackendState::Ready { + ws_url, + http_base_url, + .. + } = &shared.state + { + Some(RemoteBackendConnection { + ws_url: ws_url.clone(), + http_base_url: http_base_url.clone(), + secret_key: daemon.secret.clone(), + local_port, + goose_version: daemon.goose_version.clone(), + daemon_reused: daemon.reused, + }) + } else { + None + } +} + +pub async fn connect( + app: &AppHandle, + registry: &RemoteBackendRegistry, + host_input: &str, +) -> Result { + let aliases = ssh_config::load_ssh_config_hosts(); + let spec = RemoteHostSpec::parse(host_input, &aliases)?; + let slot = registry.slot(&spec); + + let _guard = slot.connect_lock.lock().await; + + if let Some(existing) = connection_from_shared(&slot.shared.lock().expect("slot poisoned")) { + return Ok(existing); + } + + set_state(app, &slot, RemoteBackendState::Connecting); + record_diagnostic(DiagnosticLevel::Info, "connect_start", &slot.key, None); + + match establish(app, &slot, 0).await { + Ok(connection) => { + record_diagnostic(DiagnosticLevel::Info, "connect_success", &slot.key, None); + Ok(connection) + } + Err(error) => { + record_diagnostic( + DiagnosticLevel::Error, + "connect_failed", + &slot.key, + Some(&error.message), + ); + set_state( + app, + &slot, + RemoteBackendState::Failed { + error: error.clone(), + }, + ); + Err(error) + } + } +} + +/// Establish daemon + tunnel and hand the tunnel to a fresh supervisor. +/// Caller must hold the slot's connect lock. `prior_attempts` carries the +/// reconnect budget already spent into the next supervisor so a flapping +/// tunnel cannot reset it (see [`RECONNECT_STABLE_UPTIME`]). +async fn establish( + app: &AppHandle, + slot: &Arc, + prior_attempts: u32, +) -> Result { + let shell_env = dir_env::capture_home_interactive_env().await; + + let daemon_info = daemon::ensure_daemon(&slot.spec, &shell_env, &extra_serve_args()).await?; + + let local_port = reserve_free_port().map_err(|error| { + RemoteBackendError::new(RemoteBackendErrorKind::LocalPortBindFailed, error) + })?; + + let mut tunnel = tunnel::spawn_tunnel(&slot.spec, &shell_env, local_port, daemon_info.port)?; + tunnel::wait_for_tunnel_ready(local_port, &mut tunnel).await?; + + let ws_url = acp_websocket_url(local_port, &daemon_info.secret); + let http_base_url = format!("http://127.0.0.1:{local_port}"); + let state = RemoteBackendState::Ready { + ws_url: ws_url.clone(), + http_base_url: http_base_url.clone(), + local_port, + }; + + let connection = RemoteBackendConnection { + ws_url, + http_base_url, + secret_key: daemon_info.secret.clone(), + local_port, + goose_version: daemon_info.goose_version.clone(), + daemon_reused: daemon_info.reused, + }; + + let generation = { + let mut shared = slot.shared.lock().expect("slot poisoned"); + shared.generation += 1; + shared.daemon = Some(daemon_info); + shared.local_port = Some(local_port); + shared.tunnel_pid = tunnel.child.id(); + shared.state = state.clone(); + shared.generation + }; + emit_status(app, &slot.key, &state); + + spawn_supervisor( + app.clone(), + Arc::clone(slot), + tunnel, + generation, + prior_attempts, + ); + + Ok(connection) +} + +/// Watch one tunnel child. On unexpected death, try to re-establish (each +/// success spawns the next supervisor); on exhaustion mark Disconnected. +fn spawn_supervisor( + app: AppHandle, + slot: Arc, + mut tunnel: tunnel::TunnelProcess, + generation: u64, + prior_attempts: u32, +) { + tauri::async_runtime::spawn(async move { + let established_at = tokio::time::Instant::now(); + let status = tunnel.child.wait().await; + + let is_current = { + let shared = slot.shared.lock().expect("slot poisoned"); + shared.generation == generation + }; + if !is_current { + // Explicit disconnect, app exit, or a newer establish owns the + // slot now; this watcher is history. + return; + } + + let exit_detail = match status { + Ok(status) => status.to_string(), + Err(error) => error.to_string(), + }; + log::warn!( + "[remote-backend] tunnel to {} closed unexpectedly ({exit_detail}); reconnecting", + slot.key + ); + record_diagnostic( + DiagnosticLevel::Warn, + "tunnel_closed", + &slot.key, + Some(&exit_detail), + ); + + let mut last_error = RemoteBackendError::new( + RemoteBackendErrorKind::TunnelClosed, + format!("ssh tunnel closed ({exit_detail})"), + ); + + // Stability gate: only a tunnel that stayed up earns a fresh budget. + let start_attempt = if established_at.elapsed() >= RECONNECT_STABLE_UPTIME { + 1 + } else { + prior_attempts.saturating_add(1) + }; + if start_attempt > MAX_RECONNECT_ATTEMPTS { + record_diagnostic( + DiagnosticLevel::Error, + "reconnect_exhausted", + &slot.key, + Some(&last_error.message), + ); + set_state(&app, &slot, RemoteBackendState::Disconnected); + return; + } + + for attempt in start_attempt..=MAX_RECONNECT_ATTEMPTS { + set_state( + &app, + &slot, + RemoteBackendState::Reconnecting { + attempt, + error: last_error.clone(), + }, + ); + + let backoff = Duration::from_secs(1 << (attempt - 1)).min(RECONNECT_BACKOFF_CAP); + tokio::time::sleep(backoff).await; + + let _guard = slot.connect_lock.lock().await; + { + let shared = slot.shared.lock().expect("slot poisoned"); + if shared.generation != generation { + // Someone else (user connect, disconnect, exit) took over + // while we were backing off. + return; + } + } + + match establish(&app, &slot, attempt).await { + Ok(_) => { + record_diagnostic(DiagnosticLevel::Info, "reconnect_success", &slot.key, None); + return; + } + Err(error) => { + log::warn!( + "[remote-backend] reconnect attempt {attempt} to {} failed: {}", + slot.key, + error.message + ); + last_error = error; + } + } + } + + record_diagnostic( + DiagnosticLevel::Error, + "reconnect_exhausted", + &slot.key, + Some(&last_error.message), + ); + set_state(&app, &slot, RemoteBackendState::Disconnected); + }); +} + +/// Kill the tunnel; the remote daemon keeps running. +pub fn disconnect(app: &AppHandle, registry: &RemoteBackendRegistry, host_input: &str) { + // Normalize through the parser when possible so `damien@devbox:2222` and + // its parsed key line up; fall back to the raw string for exact keys. + let host_key = RemoteHostSpec::parse(host_input, &ssh_config::load_ssh_config_hosts()) + .map(|spec| spec.key()) + .unwrap_or_else(|_| host_input.trim().to_string()); + let Some(slot) = registry.existing_slot(&host_key) else { + return; + }; + { + let mut shared = slot.shared.lock().expect("slot poisoned"); + shared.generation += 1; + if let Some(pid) = shared.tunnel_pid.take() { + kill_tunnel_pid(pid); + } + shared.local_port = None; + } + set_state(app, &slot, RemoteBackendState::Disconnected); + record_diagnostic(DiagnosticLevel::Info, "disconnected", &host_key, None); +} + +/// Stop the remote daemon, then drop the tunnel. +pub async fn shutdown( + app: &AppHandle, + registry: &RemoteBackendRegistry, + host_input: &str, +) -> Result<(), RemoteBackendError> { + let aliases = ssh_config::load_ssh_config_hosts(); + let spec = RemoteHostSpec::parse(host_input, &aliases)?; + let shell_env = dir_env::capture_home_interactive_env().await; + daemon::shutdown_daemon(&spec, &shell_env).await?; + disconnect(app, registry, &spec.key()); + record_diagnostic(DiagnosticLevel::Info, "daemon_shutdown", &spec.key(), None); + Ok(()) +} + +pub async fn check_host( + host_input: &str, +) -> Result, RemoteBackendError> { + let aliases = ssh_config::load_ssh_config_hosts(); + let spec = RemoteHostSpec::parse(host_input, &aliases)?; + let shell_env = dir_env::capture_home_interactive_env().await; + daemon::check_host(&spec, &shell_env).await +} + +pub async fn list_remote_dir( + host_input: &str, + path: &str, +) -> Result { + let aliases = ssh_config::load_ssh_config_hosts(); + let spec = RemoteHostSpec::parse(host_input, &aliases)?; + let shell_env = dir_env::capture_home_interactive_env().await; + daemon::list_remote_dir(&spec, &shell_env, path).await +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn extra_serve_args_carry_webview_origin_in_release_only() { + let args = extra_serve_args(); + if cfg!(debug_assertions) { + assert!(args.is_empty()); + } else { + assert_eq!(args, vec!["--allowed-origin", TAURI_WEBVIEW_ORIGIN]); + } + } + + #[test] + fn ready_state_serializes_with_camel_case_tag_and_fields() { + let state = RemoteBackendState::Ready { + ws_url: "ws://127.0.0.1:1/acp?token=x".to_string(), + http_base_url: "http://127.0.0.1:1".to_string(), + local_port: 1, + }; + let json = serde_json::to_value(&state).unwrap(); + assert_eq!(json["state"], "ready"); + assert!(json["wsUrl"].is_string()); + assert!(json["httpBaseUrl"].is_string()); + assert_eq!(json["localPort"], 1); + } + + #[test] + fn status_payload_flattens_state() { + let status = RemoteBackendStatus { + host: "devbox".to_string(), + state: RemoteBackendState::Connecting, + }; + let json = serde_json::to_value(&status).unwrap(); + assert_eq!(json["host"], "devbox"); + assert_eq!(json["state"], "connecting"); + } + + #[test] + fn snapshot_reports_registered_slots() { + let registry = RemoteBackendRegistry::default(); + let spec = RemoteHostSpec::parse("devbox", &[]).unwrap(); + let _slot = registry.slot(&spec); + let snapshot = registry.snapshot(); + assert_eq!(snapshot.len(), 1); + assert_eq!(snapshot[0].host, "devbox"); + } +} diff --git a/src-tauri/src/services/remote_backend/remote_daemon.sh b/src-tauri/src/services/remote_backend/remote_daemon.sh new file mode 100644 index 000000000..50daca695 --- /dev/null +++ b/src-tauri/src/services/remote_backend/remote_daemon.sh @@ -0,0 +1,183 @@ +#!/usr/bin/env bash +# Berd remote backend bootstrap. Delivered over ssh stdin as `bash -s -- []` +# so no script text, secret, or user path ever appears in remote argv. +# +# Line protocol: every protocol line starts with the per-invocation nonce so +# shell rc noise on stdout is ignored by the caller. Values that may contain +# arbitrary bytes travel base64-encoded. +# +# Modes: +# ensure [b64 extra `goose serve` args] -> READY +# shutdown -> STOPPED +# check -> TOOL <0|1> ... CHECK-DONE +# listdir -> DIR , E ..., LIST-DONE +# +# Typed exit codes: 41 goose-not-found, 43 port-bind-failed, 44 bad-path, 45 no-such-dir. +set -u + +NONCE="${1:?nonce required}" +MODE="${2:?mode required}" +ARG="${3:--}" + +STATE_DIR="${XDG_STATE_HOME:-$HOME/.local/state}/berd/remote" +RECORD="$STATE_DIR/daemon.record" +LOG="$STATE_DIR/goose-serve.log" + +emit() { printf '%s %s\n' "$NONCE" "$*"; } + +b64() { printf %s "$1" | base64 | tr -d '\n'; } +unb64() { printf %s "$1" | base64 --decode; } + +port_listening() { + (exec 3<>"/dev/tcp/127.0.0.1/$1") 2>/dev/null +} + +read_record() { + # shellcheck disable=SC2034 + IFS=' ' read -r rec_pid rec_port rec_secret rec_b64version rec_started <"$RECORD" 2>/dev/null || return 1 + [ -n "${rec_pid:-}" ] && [ -n "${rec_port:-}" ] && [ -n "${rec_secret:-}" ] +} + +ensure_daemon() { + umask 077 + mkdir -p "$STATE_DIR" || { + emit "ERR state-dir" + exit 46 + } + + if [ -f "$RECORD" ] && read_record; then + if kill -0 "$rec_pid" 2>/dev/null && port_listening "$rec_port"; then + emit "READY $rec_pid $rec_port $rec_secret 1 $rec_b64version $rec_started" + return 0 + fi + rm -f "$RECORD" + fi + + command -v goose >/dev/null 2>&1 || { + emit "ERR goose-not-found" + exit 41 + } + version="$(goose --version 2>/dev/null | head -n 1)" + [ -n "$version" ] || version="unknown" + secret="berd-remote-$(head -c 16 /dev/urandom | od -An -tx1 | tr -d ' \n')" + + extra_args="" + if [ "$ARG" != "-" ]; then + extra_args="$(unb64 "$ARG")" + fi + + attempt=0 + while [ "$attempt" -lt 5 ]; do + attempt=$((attempt + 1)) + port=$(((RANDOM % 40000) + 20000)) + if port_listening "$port"; then + continue + fi + # Detached: nohup + fully redirected stdio survives the ssh session ending + # (no PTY is allocated, and non-interactive bash does not forward SIGHUP). + # shellcheck disable=SC2086 + GOOSE_SERVER__SECRET_KEY="$secret" nohup goose serve --host 127.0.0.1 --port "$port" $extra_args >>"$LOG" 2>&1 /dev/null; then + break + fi + if port_listening "$port"; then + started="$(date +%s)" + printf '%s %s %s %s %s\n' "$pid" "$port" "$secret" "$(b64 "$version")" "$started" >"$RECORD" + emit "READY $pid $port $secret 0 $(b64 "$version") $started" + return 0 + fi + sleep 0.1 + i=$((i + 1)) + done + kill "$pid" 2>/dev/null || true + done + emit "ERR port-bind-failed" + exit 43 +} + +shutdown_daemon() { + if [ -f "$RECORD" ] && read_record; then + if kill -0 "$rec_pid" 2>/dev/null; then + kill -TERM "$rec_pid" 2>/dev/null || true + i=0 + while [ "$i" -lt 30 ] && kill -0 "$rec_pid" 2>/dev/null; do + sleep 0.1 + i=$((i + 1)) + done + if kill -0 "$rec_pid" 2>/dev/null; then + kill -KILL "$rec_pid" 2>/dev/null || true + fi + fi + fi + rm -f "$RECORD" + emit "STOPPED" +} + +check_host() { + if command -v goose >/dev/null 2>&1; then + emit "TOOL goose 1 $(b64 "$(goose --version 2>/dev/null | head -n 1)")" + else + emit "TOOL goose 0 -" + fi + for tool in claude-agent-acp codex-acp; do + if command -v "$tool" >/dev/null 2>&1; then + emit "TOOL $tool 1 -" + else + emit "TOOL $tool 0 -" + fi + done + emit "CHECK-DONE" +} + +list_dir() { + if [ "$ARG" = "-" ]; then + emit "ERR bad-path" + exit 44 + fi + target="$(unb64 "$ARG")" + case "$target" in + "~") target="$HOME" ;; + "~/"*) target="$HOME/${target#\~/}" ;; + esac + case "$target" in + /*) ;; + *) + emit "ERR bad-path" + exit 44 + ;; + esac + cd -- "$target" 2>/dev/null || { + emit "ERR no-such-dir" + exit 45 + } + emit "DIR $(b64 "$(pwd)")" + count=0 + for entry in * .*; do + { [ "$entry" = "." ] || [ "$entry" = ".." ]; } && continue + [ -e "$entry" ] || continue + if [ -d "$entry" ]; then + emit "E D $(b64 "$entry")" + else + emit "E F $(b64 "$entry")" + fi + count=$((count + 1)) + if [ "$count" -ge 2000 ]; then + break + fi + done + emit "LIST-DONE" +} + +case "$MODE" in +ensure) ensure_daemon ;; +shutdown) shutdown_daemon ;; +check) check_host ;; +listdir) list_dir ;; +*) + emit "ERR bad-mode" + exit 40 + ;; +esac diff --git a/src-tauri/src/services/remote_backend/ssh.rs b/src-tauri/src/services/remote_backend/ssh.rs new file mode 100644 index 000000000..9e7b8965d --- /dev/null +++ b/src-tauri/src/services/remote_backend/ssh.rs @@ -0,0 +1,109 @@ +//! Shared ssh command construction. +//! +//! Uses the system `ssh` so the user's `~/.ssh/config` (keys, agent, +//! ProxyJump, jump hosts) applies unchanged. `BatchMode=yes` makes auth +//! failures fail fast with parseable stderr instead of hanging on an +//! interactive prompt a GUI app cannot answer; v1 therefore supports +//! key/agent auth only. + +use std::collections::HashMap; + +use tokio::process::Command; + +use super::host::RemoteHostSpec; +use crate::services::process::apply_no_window_async; + +pub(crate) const CONNECT_TIMEOUT_SECS: u32 = 15; + +/// Options shared by every ssh invocation (exec and tunnel). +fn common_options() -> Vec { + [ + "BatchMode=yes".to_string(), + format!("ConnectTimeout={CONNECT_TIMEOUT_SECS}"), + "ServerAliveInterval=15".to_string(), + "ServerAliveCountMax=3".to_string(), + ] + .into_iter() + .collect() +} + +/// Build `ssh -T -x -a [-p port]` with the interactive-shell env +/// applied. The destination is appended by the caller after any +/// invocation-specific flags, always as `--` + separate argv element. +pub(crate) fn base_ssh_command( + spec: &RemoteHostSpec, + shell_env: &HashMap, +) -> Command { + let mut command = Command::new("ssh"); + // Finder/Dock launches inherit a minimal launchd env; ssh needs the + // user's PATH and SSH_AUTH_SOCK for agent auth (same rationale as the + // goose serve spawn in services/acp/goose_serve.rs). + command.env_clear(); + command.envs(shell_env); + for option in common_options() { + command.arg("-o").arg(option); + } + // No PTY (a PTY would echo stdin and mangle the line protocol), no X11 + // forwarding, no agent forwarding. + command.arg("-T").arg("-x").arg("-a"); + if let Some(port) = spec.port() { + command.arg("-p").arg(port.to_string()); + } + apply_no_window_async(&mut command); + command +} + +pub(crate) fn push_destination(command: &mut Command, spec: &RemoteHostSpec) { + command.arg("--").arg(spec.destination()); +} + +#[cfg(test)] +pub(crate) fn argv_of(command: &Command) -> Vec { + command + .as_std() + .get_args() + .map(|arg| arg.to_string_lossy().into_owned()) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + fn spec(input: &str) -> RemoteHostSpec { + RemoteHostSpec::parse(input, &[]).unwrap() + } + + #[test] + fn base_command_pins_batch_mode_and_timeouts() { + let command = base_ssh_command(&spec("devbox.example.com"), &HashMap::new()); + let argv = argv_of(&command); + let joined = argv.join(" "); + assert!(joined.contains("-o BatchMode=yes"), "argv: {joined}"); + assert!(joined.contains("-o ConnectTimeout=15"), "argv: {joined}"); + assert!( + joined.contains("-o ServerAliveInterval=15"), + "argv: {joined}" + ); + assert!( + joined.contains("-o ServerAliveCountMax=3"), + "argv: {joined}" + ); + assert!(joined.contains("-T"), "argv: {joined}"); + assert!(joined.contains("-x"), "argv: {joined}"); + assert!(joined.contains("-a"), "argv: {joined}"); + } + + #[test] + fn port_travels_via_dash_p_and_destination_after_separator() { + let spec = spec("damien@devbox:2222"); + let mut command = base_ssh_command(&spec, &HashMap::new()); + push_destination(&mut command, &spec); + let argv = argv_of(&command); + let p_index = argv.iter().position(|arg| arg == "-p").unwrap(); + assert_eq!(argv[p_index + 1], "2222"); + let sep_index = argv.iter().position(|arg| arg == "--").unwrap(); + assert_eq!(argv[sep_index + 1], "damien@devbox"); + assert_eq!(sep_index + 2, argv.len()); + } +} diff --git a/src-tauri/src/services/remote_backend/ssh_config.rs b/src-tauri/src/services/remote_backend/ssh_config.rs new file mode 100644 index 000000000..011d62daf --- /dev/null +++ b/src-tauri/src/services/remote_backend/ssh_config.rs @@ -0,0 +1,156 @@ +//! Minimal `~/.ssh/config` reader: concrete `Host` aliases only. +//! +//! This is deliberately not a full ssh_config parser. We only need the list of +//! names a user could type as a destination, so we collect `Host` aliases, +//! skip wildcard/negated patterns, and follow non-glob `Include` directives a +//! few levels deep. Everything else in the file is ignored. + +use std::path::{Path, PathBuf}; + +const MAX_INCLUDE_DEPTH: u8 = 3; + +pub fn load_ssh_config_hosts() -> Vec { + let Some(home) = dirs::home_dir() else { + return Vec::new(); + }; + let config_path = home.join(".ssh").join("config"); + let mut hosts = Vec::new(); + collect_hosts_from_file(&config_path, &home.join(".ssh"), 0, &mut hosts); + hosts +} + +fn collect_hosts_from_file(path: &Path, include_base: &Path, depth: u8, hosts: &mut Vec) { + let Ok(contents) = std::fs::read_to_string(path) else { + return; + }; + collect_hosts(&contents, include_base, depth, hosts); +} + +fn collect_hosts(contents: &str, include_base: &Path, depth: u8, hosts: &mut Vec) { + for line in contents.lines() { + let line = line.trim(); + if line.is_empty() || line.starts_with('#') { + continue; + } + let Some((keyword, value)) = split_directive(line) else { + continue; + }; + if keyword.eq_ignore_ascii_case("host") { + for alias in value.split_whitespace() { + if alias.contains(['*', '?']) || alias.starts_with('!') { + continue; + } + if !hosts.iter().any(|existing| existing == alias) { + hosts.push(alias.to_string()); + } + } + } else if keyword.eq_ignore_ascii_case("include") && depth < MAX_INCLUDE_DEPTH { + for target in value.split_whitespace() { + if target.contains(['*', '?']) { + continue; + } + let target_path = resolve_include_path(target, include_base); + collect_hosts_from_file(&target_path, include_base, depth + 1, hosts); + } + } + } +} + +/// ssh_config accepts `Keyword value`, `Keyword=value`, and `Keyword = value`. +fn split_directive(line: &str) -> Option<(&str, &str)> { + let (keyword, value) = match line.split_once('=') { + Some((keyword, value)) if !keyword.trim().contains(char::is_whitespace) => { + (keyword.trim(), value.trim()) + } + _ => { + let mut parts = line.splitn(2, char::is_whitespace); + (parts.next()?.trim(), parts.next()?.trim()) + } + }; + if keyword.is_empty() || value.is_empty() { + return None; + } + Some((keyword, value)) +} + +fn resolve_include_path(target: &str, include_base: &Path) -> PathBuf { + let expanded = if let Some(rest) = target.strip_prefix("~/") { + dirs::home_dir() + .map(|home| home.join(rest)) + .unwrap_or_else(|| PathBuf::from(target)) + } else { + PathBuf::from(target) + }; + if expanded.is_absolute() { + expanded + } else { + include_base.join(expanded) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn hosts_of(contents: &str) -> Vec { + let mut hosts = Vec::new(); + collect_hosts(contents, Path::new("/nonexistent"), 0, &mut hosts); + hosts + } + + #[test] + fn collects_concrete_aliases() { + let hosts = hosts_of("Host devbox\n HostName dev.example.com\nHost other\n"); + assert_eq!(hosts, vec!["devbox", "other"]); + } + + #[test] + fn skips_wildcards_and_negations() { + let hosts = hosts_of("Host *\nHost dev-*\nHost !prod\nHost real\n"); + assert_eq!(hosts, vec!["real"]); + } + + #[test] + fn handles_multiple_aliases_per_line_and_equals_form() { + let hosts = hosts_of("Host devbox staging\nHost=prod-jump\n"); + assert_eq!(hosts, vec!["devbox", "staging", "prod-jump"]); + } + + #[test] + fn dedupes_preserving_order() { + let hosts = hosts_of("Host a\nHost b a\n"); + assert_eq!(hosts, vec!["a", "b"]); + } + + #[test] + fn ignores_comments_and_unrelated_directives() { + let hosts = hosts_of("# Host commented\nUser damien\nHost real\n Port 22\n"); + assert_eq!(hosts, vec!["real"]); + } + + #[test] + fn keyword_matching_is_case_insensitive() { + let hosts = hosts_of("HOST upper\nhost lower\n"); + assert_eq!(hosts, vec!["upper", "lower"]); + } + + #[test] + fn include_of_missing_file_is_ignored() { + let hosts = hosts_of("Include /definitely/not/a/file\nHost real\n"); + assert_eq!(hosts, vec!["real"]); + } + + #[test] + fn includes_resolve_relative_paths_and_cap_depth() { + let dir = tempfile::tempdir().unwrap(); + std::fs::write(dir.path().join("extra"), "Host included\nInclude loop\n").unwrap(); + std::fs::write(dir.path().join("loop"), "Include loop\nHost looped\n").unwrap(); + + let mut hosts = Vec::new(); + collect_hosts("Include extra\nHost main\n", dir.path(), 0, &mut hosts); + assert!(hosts.contains(&"included".to_string())); + assert!(hosts.contains(&"main".to_string())); + // Depth cap terminates the self-including file without recursion blowup. + assert!(hosts.contains(&"looped".to_string())); + } +} diff --git a/src-tauri/src/services/remote_backend/tunnel.rs b/src-tauri/src/services/remote_backend/tunnel.rs new file mode 100644 index 000000000..974011694 --- /dev/null +++ b/src-tauri/src/services/remote_backend/tunnel.rs @@ -0,0 +1,169 @@ +//! SSH local port-forward to a remote goose daemon. +//! +//! The tunnel is a plain `ssh -N -L` and deliberately owns nothing else: the +//! daemon it points at survives the tunnel, the app, and the network. Tunnel +//! readiness is probed over HTTP *through* the forward — a raw TCP connect is +//! meaningless because ssh's local listener accepts immediately and only then +//! dials the remote target. + +use std::collections::HashMap; +use std::process::Stdio; +use std::sync::{Arc, Mutex}; +use std::time::Duration; + +use tokio::io::{AsyncBufReadExt, BufReader}; +use tokio::process::{Child, Command}; + +use super::error::{classify_ssh_stderr, RemoteBackendError, RemoteBackendErrorKind}; +use super::host::RemoteHostSpec; +use super::ssh::{base_ssh_command, push_destination}; +use crate::services::log_redaction::redact_log_line; + +const TUNNEL_READY_TIMEOUT: Duration = Duration::from_secs(15); +const TUNNEL_PROBE_INTERVAL: Duration = Duration::from_millis(150); + +pub(crate) struct TunnelProcess { + pub child: Child, + /// Rolling tail of redacted stderr, shared with the log-reader task, used + /// to classify unexpected exits. + pub stderr_tail: Arc>, +} + +pub(crate) fn build_tunnel_command( + spec: &RemoteHostSpec, + shell_env: &HashMap, + local_port: u16, + remote_port: u16, +) -> Command { + let mut command = base_ssh_command(spec, shell_env); + command.arg("-o").arg("ExitOnForwardFailure=yes"); + command.arg("-N"); + command + .arg("-L") + .arg(format!("127.0.0.1:{local_port}:127.0.0.1:{remote_port}")); + push_destination(&mut command, spec); + command +} + +pub(crate) fn spawn_tunnel( + spec: &RemoteHostSpec, + shell_env: &HashMap, + local_port: u16, + remote_port: u16, +) -> Result { + let mut command = build_tunnel_command(spec, shell_env, local_port, remote_port); + command + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::piped()) + .kill_on_drop(true); + + let mut child = command.spawn().map_err(|error| { + if error.kind() == std::io::ErrorKind::NotFound { + RemoteBackendError::new( + RemoteBackendErrorKind::SshNotFound, + "ssh was not found on this machine", + ) + } else { + RemoteBackendError::internal(format!("failed to spawn ssh tunnel: {error}")) + } + })?; + + let stderr_tail = Arc::new(Mutex::new(String::new())); + if let Some(stderr) = child.stderr.take() { + let tail = Arc::clone(&stderr_tail); + tauri::async_runtime::spawn(async move { + let mut lines = BufReader::new(stderr).lines(); + while let Ok(Some(line)) = lines.next_line().await { + let redacted = redact_log_line(&line); + log::warn!("[remote-backend tunnel stderr] {redacted}"); + if let Ok(mut tail) = tail.lock() { + tail.push_str(&redacted); + tail.push('\n'); + if tail.len() > 8 * 1024 { + let excess = tail.len() - 8 * 1024; + tail.drain(..excess); + } + } + } + }); + } + + Ok(TunnelProcess { child, stderr_tail }) +} + +/// Probe the forwarded port until the remote goose answers HTTP. Any HTTP +/// response (any status) proves the end-to-end path; connection errors mean +/// not-ready-yet. Fails fast when the tunnel process exits. +pub(crate) async fn wait_for_tunnel_ready( + local_port: u16, + tunnel: &mut TunnelProcess, +) -> Result<(), RemoteBackendError> { + let client = reqwest::Client::builder() + .timeout(Duration::from_secs(2)) + .build() + .map_err(|error| RemoteBackendError::internal(format!("http client: {error}")))?; + let url = format!("http://127.0.0.1:{local_port}/"); + + let deadline = tokio::time::Instant::now() + TUNNEL_READY_TIMEOUT; + loop { + if let Some(status) = tunnel + .child + .try_wait() + .map_err(|error| RemoteBackendError::internal(format!("tunnel wait: {error}")))? + { + let stderr = tunnel + .stderr_tail + .lock() + .map(|tail| tail.clone()) + .unwrap_or_default(); + let kind = match classify_ssh_stderr(&stderr) { + RemoteBackendErrorKind::Internal => RemoteBackendErrorKind::TunnelClosed, + kind => kind, + }; + return Err(RemoteBackendError::new( + kind, + format!( + "ssh tunnel exited ({status}) before the remote backend answered: {}", + stderr.lines().last().unwrap_or_default() + ), + )); + } + + if client.get(&url).send().await.is_ok() { + return Ok(()); + } + + if tokio::time::Instant::now() >= deadline { + return Err(RemoteBackendError::new( + RemoteBackendErrorKind::ReadyTimeout, + "the remote backend did not answer through the tunnel in time", + )); + } + tokio::time::sleep(TUNNEL_PROBE_INTERVAL).await; + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::services::remote_backend::ssh::argv_of; + + #[test] + fn tunnel_argv_binds_loopback_forward_and_no_command() { + let spec = RemoteHostSpec::parse("damien@devbox:2222", &[]).unwrap(); + let command = build_tunnel_command(&spec, &HashMap::new(), 5123, 23456); + let argv = argv_of(&command); + let joined = argv.join(" "); + assert!( + joined.contains("-o ExitOnForwardFailure=yes"), + "argv: {joined}" + ); + assert!(joined.contains("-N"), "argv: {joined}"); + let l_index = argv.iter().position(|arg| arg == "-L").unwrap(); + assert_eq!(argv[l_index + 1], "127.0.0.1:5123:127.0.0.1:23456"); + // Destination is last, after `--`, with no trailing remote command. + assert_eq!(argv.last().unwrap(), "damien@devbox"); + assert_eq!(argv[argv.len() - 2], "--"); + } +} diff --git a/src/app/AppShell.tsx b/src/app/AppShell.tsx index 173e720c1..754c474d0 100644 --- a/src/app/AppShell.tsx +++ b/src/app/AppShell.tsx @@ -219,6 +219,10 @@ import { import { acpCreateSession, acpSetSessionConfigOption } from "@/shared/api/acp"; import { formatAcpErrorMessage } from "@/shared/api/acpErrors"; import { findMissingProjectDirs } from "@/features/projects/lib/missingProjectDirs"; +import { + ensureRemoteHostConnected, + isRemoteSession, +} from "@/features/chat/lib/remoteSession"; import { createSystemNotificationMessage, isSystemNotification, @@ -1856,12 +1860,19 @@ export function AppShell({ ); const creationSelection = gooseServeSelectionFromExecutionTarget(requestedTarget); + // A remote draft's backend session must be created on the SSH host's + // backend, which needs the tunnel up first. Failures here flow into + // the shared creation-failure path below. + if (session.remoteHost) { + await ensureRemoteHostConnected(session.remoteHost); + } return acpCreateSession( creationSelection.providerId ?? requestedTarget.harnessId, resolvedWorkingDir, { projectId, modelId: requestedTarget.modelId, + remoteHost: session.remoteHost, // The draft is already interactive. Construct its provider now so // a selection made while creation is in flight can be applied to // the backend session as soon as it exists. @@ -2087,7 +2098,9 @@ export function AppShell({ .getState() .projects.find((candidate) => candidate.id === projectId) : undefined; - if (project) { + // Local missing-dir checks are meaningless for remote sessions: + // their paths live on the SSH host, not this machine. + if (project && !isRemoteSession(session)) { try { const missing = await findMissingProjectDirs(project); if (missing.length > 0) { @@ -2178,7 +2191,10 @@ export function AppShell({ (candidate) => candidate.creationState === "failed" && candidate.projectId === updatedProject.id && - !candidate.archivedAt, + !candidate.archivedAt && + // Remote sessions never fail on local project folders, so a + // local folder fix is not a retry trigger for them. + !isRemoteSession(candidate), ); if (failedSessions.length === 0) { return; @@ -2244,12 +2260,26 @@ export function AppShell({ reuseExistingDraft?: boolean; executionTarget?: SessionExecutionTarget; reasoningEffort?: GlobalComposeOptions["reasoningEffort"]; + /** SSH host to run the session's backend on; project-less in v1. */ + remoteHost?: string; + /** Remote working directory; required alongside remoteHost. */ + remoteWorkingDir?: string; } = {}, ) => { const shouldActivate = options.activate !== false; + const remoteHost = options.remoteHost?.trim() || undefined; + const remoteWorkingDir = options.remoteWorkingDir?.trim() || undefined; + if (remoteHost && !remoteWorkingDir) { + throw new Error( + "createNewTab requires remoteWorkingDir when remoteHost is set.", + ); + } + // Remote sessions are project-less in v1: local project folders are + // meaningless on the SSH host, and the working dir is chosen remotely. + const sessionProject = remoteHost ? undefined : project; const tStart = performance.now(); perfLog( - `[perf:newtab] createNewTab start (project=${project?.id ?? "none"})`, + `[perf:newtab] createNewTab start (project=${sessionProject?.id ?? "none"})`, ); const sessionExecutionTarget = await resolveSessionCreationTarget(options); @@ -2266,36 +2296,51 @@ export function AppShell({ sessionIdsWithTerminals: getChatSessionIdsWithTerminals(), request: { title, - projectId: project?.id, + projectId: sessionProject?.id, executionTarget: sessionExecutionTarget, reasoningEffortValue: options.reasoningEffort?.value, + remoteHost, }, allowDraftReuse: options.reuseExistingDraft !== false, }); - if ( + // A reused remote draft must also match the freshly chosen remote + // folder; its creation may already be in flight against its own dir. + const reusableDraft = existingDraft && - (chatState.queuedMessageBySession[existingDraft.id]?.length ?? 0) === 0 + (!remoteHost || existingDraft.workingDir === remoteWorkingDir) + ? existingDraft + : undefined; + if ( + reusableDraft && + (chatState.queuedMessageBySession[reusableDraft.id]?.length ?? 0) === 0 ) { if (shouldActivate) { clearSettingsSectionUrl(); - setActiveSession(existingDraft.id); + setActiveSession(reusableDraft.id); setActiveView("chat"); - setChatActiveSession(existingDraft.id); + setChatActiveSession(reusableDraft.id); } perfLog( - `[perf:newtab] ${existingDraft.id.slice(0, 8)} reused draft in ${(performance.now() - tStart).toFixed(1)}ms`, + `[perf:newtab] ${reusableDraft.id.slice(0, 8)} reused draft in ${(performance.now() - tStart).toFixed(1)}ms`, ); - return existingDraft; + return reusableDraft; } if (!shouldActivate) { - const workingDir = await resolveSessionCwd(project); + if (remoteHost && remoteWorkingDir) { + await ensureRemoteHostConnected(remoteHost); + } + const workingDir = + remoteHost && remoteWorkingDir + ? remoteWorkingDir + : await resolveSessionCwd(sessionProject); const session = await createSession({ title, - projectId: project?.id, + projectId: sessionProject?.id, executionTarget: sessionExecutionTarget, workingDir, + remoteHost, }); perfLog( `[perf:newtab] ${session.id.slice(0, 8)} created session in ${(performance.now() - tStart).toFixed(1)}ms`, @@ -2303,12 +2348,18 @@ export function AppShell({ return session; } - const optimisticWorkingDir = getOptimisticSessionCwd(project); + // Remote paths pass through verbatim: local optimistic-cwd resolution + // only knows this machine's filesystem. + const optimisticWorkingDir = + remoteHost && remoteWorkingDir + ? remoteWorkingDir + : getOptimisticSessionCwd(sessionProject); const session = createDraftSession({ title, - projectId: project?.id, + projectId: sessionProject?.id, executionTarget: sessionExecutionTarget, workingDir: optimisticWorkingDir, + remoteHost, }); clearSettingsSectionUrl(); setActiveSession(session.id); @@ -2320,8 +2371,11 @@ export function AppShell({ startDraftSessionCreation({ session, sessionExecutionTarget, - workingDir: resolveSessionCwd(project), - projectId: project?.id, + workingDir: + remoteHost && remoteWorkingDir + ? remoteWorkingDir + : resolveSessionCwd(sessionProject), + projectId: sessionProject?.id, onReady: applyReasoningEffortAfterDraftCreation( session.id, options.reasoningEffort, @@ -2528,11 +2582,21 @@ export function AppShell({ options: { executionTarget?: SessionExecutionTarget; reasoningEffort?: GlobalComposeOptions["reasoningEffort"]; + remoteHost?: string; + remoteWorkingDir?: string; } = {}, ) => { + const remoteHost = options.remoteHost?.trim() || undefined; + const remoteWorkingDir = options.remoteWorkingDir?.trim() || undefined; + if (remoteHost && !remoteWorkingDir) { + throw new Error( + "createBackgroundDraftChat requires remoteWorkingDir when remoteHost is set.", + ); + } + const sessionProject = remoteHost ? undefined : project; const tStart = performance.now(); perfLog( - `[perf:newtab] createBackgroundDraftChat start (project=${project?.id ?? "none"})`, + `[perf:newtab] createBackgroundDraftChat start (project=${sessionProject?.id ?? "none"})`, ); const sessionExecutionTarget = await resolveSessionCreationTarget(options); @@ -2547,28 +2611,38 @@ export function AppShell({ sessionIdsWithTerminals: getChatSessionIdsWithTerminals(), request: { title, - projectId: project?.id, + projectId: sessionProject?.id, executionTarget: sessionExecutionTarget, reasoningEffortValue: options.reasoningEffort?.value, + remoteHost, }, }); - if ( + const reusableDraft = existingDraft && - (chatState.queuedMessageBySession[existingDraft.id]?.length ?? 0) === 0 + (!remoteHost || existingDraft.workingDir === remoteWorkingDir) + ? existingDraft + : undefined; + if ( + reusableDraft && + (chatState.queuedMessageBySession[reusableDraft.id]?.length ?? 0) === 0 ) { perfLog( - `[perf:newtab] ${existingDraft.id.slice(0, 8)} reused background draft in ${(performance.now() - tStart).toFixed(1)}ms`, + `[perf:newtab] ${reusableDraft.id.slice(0, 8)} reused background draft in ${(performance.now() - tStart).toFixed(1)}ms`, ); - return existingDraft; + return reusableDraft; } - const optimisticWorkingDir = getOptimisticSessionCwd(project); + const optimisticWorkingDir = + remoteHost && remoteWorkingDir + ? remoteWorkingDir + : getOptimisticSessionCwd(sessionProject); const session = createDraftSession({ title, - projectId: project?.id, + projectId: sessionProject?.id, executionTarget: sessionExecutionTarget, workingDir: optimisticWorkingDir, + remoteHost, }); perfLog( `[perf:newtab] ${session.id.slice(0, 8)} created background draft in ${(performance.now() - tStart).toFixed(1)}ms`, @@ -2576,8 +2650,11 @@ export function AppShell({ startDraftSessionCreation({ session, sessionExecutionTarget, - workingDir: resolveSessionCwd(project), - projectId: project?.id, + workingDir: + remoteHost && remoteWorkingDir + ? remoteWorkingDir + : resolveSessionCwd(sessionProject), + projectId: sessionProject?.id, onReady: applyReasoningEffortAfterDraftCreation( session.id, options.reasoningEffort, @@ -2946,9 +3023,12 @@ export function AppShell({ onSettled?: (didStart: boolean) => void; }, ) => { - const project = options?.projectId - ? projects.find((candidate) => candidate.id === options.projectId) - : undefined; + // Remote sessions are project-less in v1, so a remote compose never + // routes through the project draft path. + const project = + options?.projectId && !options.remoteHost + ? projects.find((candidate) => candidate.id === options.projectId) + : undefined; const requiresProjectWorkspaceDraftPlan = workspaceRepository.mode === "multi" && Boolean(project?.projectWorkspaces.length); @@ -2971,6 +3051,8 @@ export function AppShell({ const chatOptions = { executionTarget: options?.executionTarget, reasoningEffort: options?.reasoningEffort, + remoteHost: options?.remoteHost, + remoteWorkingDir: options?.remoteWorkingDir, }; const acceptGlobalFirstSend = async (session: ChatSession) => { const sessionId = resolveLiveSessionId(session.id) ?? session.id; @@ -3200,12 +3282,17 @@ export function AppShell({ const handleGlobalComposerExpand = useCallback( (payload: GlobalComposerExpandPayload): Promise => { const options = payload.options; - const project = options?.projectId - ? projects.find((candidate) => candidate.id === options.projectId) - : undefined; + // Remote sessions are project-less in v1, so a remote compose never + // routes through the project draft path. + const project = + options?.projectId && !options.remoteHost + ? projects.find((candidate) => candidate.id === options.projectId) + : undefined; const chatOptions = { executionTarget: options?.executionTarget, reasoningEffort: options?.reasoningEffort, + remoteHost: options?.remoteHost, + remoteWorkingDir: options?.remoteWorkingDir, }; const shouldDismissCenteredComposer = @@ -3301,14 +3388,19 @@ export function AppShell({ }); } const options = payload.options; - const project = options?.projectId - ? projects.find((candidate) => candidate.id === options.projectId) - : undefined; + // Remote sessions are project-less in v1, so a remote compose never + // routes through the project draft path. + const project = + options?.projectId && !options.remoteHost + ? projects.find((candidate) => candidate.id === options.projectId) + : undefined; const chatOptions = { activate: false, reuseExistingDraft: false, executionTarget: options?.executionTarget, reasoningEffort: options?.reasoningEffort, + remoteHost: options?.remoteHost, + remoteWorkingDir: options?.remoteWorkingDir, }; const createAndStart = async () => { diff --git a/src/app/lib/chatRuntimeStartup.ts b/src/app/lib/chatRuntimeStartup.ts index 1efe4a34d..7c0165f5e 100644 --- a/src/app/lib/chatRuntimeStartup.ts +++ b/src/app/lib/chatRuntimeStartup.ts @@ -33,6 +33,8 @@ import { import { checkAllProviderStatus } from "@/features/providers/api/credentials"; import { useDefaultProviderReadinessStore } from "@/features/providers/stores/defaultProviderReadinessStore"; import { useProviderModelCacheStore } from "@/features/providers/stores/providerModelCacheStore"; +import { REMOTE_SSH_SESSIONS_EXPERIMENT_ID } from "@/features/experiments/experimentDefinitions"; +import { getExperiment } from "@/features/experiments/experimentPreferences"; import { useDistroStore } from "@/features/settings/stores/distroStore"; import type { AcpProvider } from "@/shared/api/acp"; import { @@ -330,6 +332,19 @@ async function startChatRuntime( perfLog( `[perf:startup] loadSessions done in ${(performance.now() - t0).toFixed(1)}ms`, ); + // After the local page load so mergeAcpSessionPage cannot race the + // placeholder insert; the merge itself is additive for remoteHost either + // way. + if (getExperiment(REMOTE_SSH_SESSIONS_EXPERIMENT_ID)?.enabled) { + try { + const { rehydrateRemoteSessions } = await import( + "@/features/chat/stores/remoteSessionPersistence" + ); + await rehydrateRemoteSessions(); + } catch (err) { + console.error("Failed to rehydrate remote sessions on startup:", err); + } + } }; applyCuratedProviders(false); diff --git a/src/features/berdctl/commands/runtime/sessions.ts b/src/features/berdctl/commands/runtime/sessions.ts index 42d4b6ed0..b5bb0b9de 100644 --- a/src/features/berdctl/commands/runtime/sessions.ts +++ b/src/features/berdctl/commands/runtime/sessions.ts @@ -64,7 +64,7 @@ async function loadSessionsForBerdctlUntil( for (;;) { const page = await acpListSessionsPage({ cursor }); const fetchedTarget = page.sessions - .map(acpSessionToChatSession) + .map((session) => acpSessionToChatSession(session)) .some(shouldStop); useChatSessionStore.setState((state) => ({ ...mergeAcpSessionPage(state, page, previousCursor), diff --git a/src/features/chat/capabilities/ConversationComposerCapability.tsx b/src/features/chat/capabilities/ConversationComposerCapability.tsx index 67984a7ef..e32abf840 100644 --- a/src/features/chat/capabilities/ConversationComposerCapability.tsx +++ b/src/features/chat/capabilities/ConversationComposerCapability.tsx @@ -1,4 +1,5 @@ import { useCallback, type RefObject } from "react"; +import { useTranslation } from "react-i18next"; import { summarizeProjectWorkspaceStartup } from "@/features/projects/lib/projectChatWorkspaces"; import type { ChatInputControls, @@ -152,6 +153,7 @@ export function ConversationComposerCapability({ attachmentDropTargetRef, onAttachmentDragOverChange, }: ConversationComposerCapabilityProps) { + const { t } = useTranslation("chat"); const { controller, target, @@ -161,6 +163,16 @@ export function ConversationComposerCapability({ onSendQueue, } = binding; const isPendingConversation = target.kind === "pendingConversation"; + // A remote host without a chosen remote folder cannot start a session, so + // the send is blocked with an actionable reason until a folder is picked. + const remoteDirMissing = Boolean( + controller.remoteHostSelectionEnabled && + controller.selectedRemoteHost && + !controller.selectedRemoteDir, + ); + const remoteDirMissingReason = remoteDirMissing + ? t("toolbar.remoteHost.missingDirectory") + : undefined; const readOnlyReason = target.kind === "existingSession" ? target.admission.readOnlyReason @@ -274,9 +286,11 @@ export function ConversationComposerCapability({ controller.projectMetadataPending || controller.isCompactingContext, sendDisabled: isPendingConversation - ? undefined - : admissionBlocked || controller.workspaceSetupInProgress, - sendDisabledReason: admissionBlockingReason, + ? remoteDirMissing || undefined + : admissionBlocked || + controller.workspaceSetupInProgress || + remoteDirMissing, + sendDisabledReason: admissionBlockingReason ?? remoteDirMissingReason, queuedMessage: handoffInProgress ? null : (controller.queue.queuedMessage ?? @@ -368,6 +382,13 @@ export function ConversationComposerCapability({ }, }), }} + remoteHostPicker={{ + enabled: controller.remoteHostSelectionEnabled, + selectedHost: controller.selectedRemoteHost, + onHostChange: controller.handleRemoteHostChange, + selectedDir: controller.selectedRemoteDir, + onDirChange: controller.handleRemoteDirChange, + }} contextUsage={{ contextTokens: controller.tokenState.accumulatedTotal, contextLimit: controller.tokenState.contextLimit, diff --git a/src/features/chat/hooks/ArtifactPolicyContext.tsx b/src/features/chat/hooks/ArtifactPolicyContext.tsx index 2111226a9..65120c93f 100644 --- a/src/features/chat/hooks/ArtifactPolicyContext.tsx +++ b/src/features/chat/hooks/ArtifactPolicyContext.tsx @@ -7,12 +7,16 @@ import { useRef, type ReactNode, } from "react"; +import { useTranslation } from "react-i18next"; +import { toast } from "sonner"; import type { Message, ToolCallLocation, ToolKind, } from "@/shared/types/messages"; import { pathExists } from "@/shared/api/system"; +import { isRemoteSession } from "@/features/chat/lib/remoteSession"; +import { useChatSessionStore } from "@/features/chat/stores/chatSessionStore"; import { useArtifactViewerStore } from "@/features/chat/stores/artifactViewerStore"; import { artifactBasename, @@ -63,6 +67,15 @@ export interface ArtifactPolicyContextValue { * externally. Resolves the path against the session cwd first. */ openInApp: (path: string, filename?: string) => Promise; + /** + * True when this session's backend runs on a remote SSH host, so the + * transcript's file paths refer to that host's filesystem. Local existence + * checks and `convertFileSrc` asset loads would 404; renderers show a + * compact "on " placeholder instead of attempting local file loads. + */ + filesAreRemote: boolean; + /** SSH host carrying this session's files when `filesAreRemote`. */ + remoteHost: string | null; } const DEFAULT_ACTIONS_CONTEXT_VALUE: ArtifactPolicyContextValue = { @@ -70,6 +83,8 @@ const DEFAULT_ACTIONS_CONTEXT_VALUE: ArtifactPolicyContextValue = { pathExists: async () => false, openResolvedPath: async () => {}, openInApp: async () => {}, + filesAreRemote: false, + remoteHost: null, }; const EMPTY_SESSION_ARTIFACTS: readonly SessionArtifact[] = []; @@ -292,7 +307,16 @@ export function ArtifactPolicyProvider({ sessionId?: string | null; children: ReactNode; }) { + const { t } = useTranslation("chat"); const openInViewer = useArtifactViewerStore((s) => s.open); + const remoteHost = useChatSessionStore((s) => { + if (!sessionId) return null; + const session = s.sessions.find((candidate) => candidate.id === sessionId); + return isRemoteSession(session) + ? (session?.remoteHost?.trim() ?? null) + : null; + }); + const filesAreRemote = remoteHost !== null; const normalizedSessionCwd = useMemo( () => sessionCwd?.trim() || null, [sessionCwd], @@ -356,6 +380,9 @@ export function ArtifactPolicyProvider({ const resolveOpenTarget = useCallback( async (path: string): Promise => { + // Remote sessions: the path names a file on the SSH host, so a local + // existence probe is meaningless (and would report a false miss). + if (filesAreRemote) return null; const resolvedPath = resolvePath(path, normalizedSessionCwd); if (await pathExists(resolvedPath)) { return resolvedPath; @@ -363,7 +390,7 @@ export function ArtifactPolicyProvider({ return null; }, - [normalizedSessionCwd], + [filesAreRemote, normalizedSessionCwd], ); const checkPathExists = useCallback( @@ -373,6 +400,13 @@ export function ArtifactPolicyProvider({ const openResolvedPath = useCallback( async (path: string) => { + if (filesAreRemote) { + // Opening externally hands the path to the local OS, which cannot + // reach the remote filesystem. Callers surface or swallow the error. + throw new Error( + t("remoteSessionGuards.openUnavailable", { host: remoteHost }), + ); + } const resolvedTarget = await resolveOpenTarget(path); if (!resolvedTarget) { const cwdMessage = normalizedSessionCwd ?? ""; @@ -388,11 +422,29 @@ export function ArtifactPolicyProvider({ lastOpenAtByPathRef.current.set(key, now); await openPath(resolvedTarget); }, - [resolveOpenTarget, normalizedSessionCwd], + [filesAreRemote, remoteHost, resolveOpenTarget, normalizedSessionCwd, t], ); const openInApp = useCallback( async (path: string, filename?: string) => { + if (filesAreRemote) { + // The viewer renders a compact "on " placeholder for remote + // artifacts, so viewable files still open there (no existence check — + // that would probe the local disk). Everything else has no local + // hand-off; explain instead of silently launching the wrong file. + const resolvedPath = resolvePath(path, normalizedSessionCwd); + if (resolvedPath && sessionId && isViewableArtifact(resolvedPath)) { + openInViewer(sessionId, { + resolvedPath, + filename: filename ?? artifactBasename(resolvedPath), + }); + return; + } + toast.message( + t("remoteSessionGuards.openUnavailable", { host: remoteHost }), + ); + return; + } const resolvedTarget = await resolveOpenTarget(path); // Viewable + resolvable + we know the session: open in the viewer. if (resolvedTarget && sessionId && isViewableArtifact(resolvedTarget)) { @@ -405,7 +457,16 @@ export function ArtifactPolicyProvider({ // Otherwise fall back to opening externally (also handles not-found). await openResolvedPath(path); }, - [resolveOpenTarget, sessionId, openInViewer, openResolvedPath], + [ + filesAreRemote, + normalizedSessionCwd, + remoteHost, + resolveOpenTarget, + sessionId, + openInViewer, + openResolvedPath, + t, + ], ); const actionsValue = useMemo( @@ -414,8 +475,17 @@ export function ArtifactPolicyProvider({ pathExists: checkPathExists, openResolvedPath, openInApp, + filesAreRemote, + remoteHost, }), - [checkPathExists, openResolvedPath, openInApp, resolveMarkdownHref], + [ + checkPathExists, + filesAreRemote, + openResolvedPath, + openInApp, + remoteHost, + resolveMarkdownHref, + ], ); return ( diff --git a/src/features/chat/hooks/__tests__/ArtifactPolicyContext.test.tsx b/src/features/chat/hooks/__tests__/ArtifactPolicyContext.test.tsx index 23e6f411e..e2dea5de6 100644 --- a/src/features/chat/hooks/__tests__/ArtifactPolicyContext.test.tsx +++ b/src/features/chat/hooks/__tests__/ArtifactPolicyContext.test.tsx @@ -2,6 +2,8 @@ import { render, screen } from "@testing-library/react"; import { useEffect } from "react"; import { describe, expect, it, vi } from "vitest"; import type { Message } from "@/shared/types/messages"; +import { useChatSessionStore } from "@/features/chat/stores/chatSessionStore"; +import { useArtifactViewerStore } from "@/features/chat/stores/artifactViewerStore"; import { ArtifactPolicyProvider, collectSessionArtifacts, @@ -584,4 +586,83 @@ describe("ArtifactPolicyContext", () => { expect(screen.getByTestId("link-has-candidate")).toHaveTextContent("true"); expect(screen.getByTestId("link-path")).toHaveTextContent("/tmp/report.md"); }); + + describe("remote sessions", () => { + function seedRemoteSession(sessionId: string) { + mockPathExists.mockClear(); + useChatSessionStore.setState({ + sessions: [ + { + id: sessionId, + title: "Remote", + workingDir: "/home/dev/project", + remoteHost: "devbox", + createdAt: "2026-08-27T00:00:00.000Z", + updatedAt: "2026-08-27T00:00:00.000Z", + messageCount: 1, + }, + ], + }); + } + + function renderRemoteActions(sessionId: string) { + let actions: ArtifactPolicyContextValue | null = null; + render( + + { + actions = value; + }} + /> + , + ); + if (!actions) throw new Error("actions context did not render"); + return actions as ArtifactPolicyContextValue; + } + + it("flags the session's files as remote", () => { + seedRemoteSession("remote-session"); + const actions = renderRemoteActions("remote-session"); + + expect(actions.filesAreRemote).toBe(true); + expect(actions.remoteHost).toBe("devbox"); + }); + + it("never probes the local filesystem for existence", async () => { + seedRemoteSession("remote-session"); + const actions = renderRemoteActions("remote-session"); + + await expect(actions.pathExists("./notes.md")).resolves.toBe(false); + expect(mockPathExists).not.toHaveBeenCalled(); + }); + + it("refuses external opens with a host-naming error", async () => { + seedRemoteSession("remote-session"); + const actions = renderRemoteActions("remote-session"); + + await expect(actions.openResolvedPath("./notes.md")).rejects.toThrow( + /devbox/, + ); + expect(mockPathExists).not.toHaveBeenCalled(); + }); + + it("opens viewable remote artifacts in the viewer without a local existence check", async () => { + seedRemoteSession("remote-session"); + const actions = renderRemoteActions("remote-session"); + + await actions.openInApp("./notes.md"); + + expect(mockPathExists).not.toHaveBeenCalled(); + expect( + useArtifactViewerStore.getState().openBySession["remote-session"], + ).toMatchObject({ + resolvedPath: "/home/dev/project/notes.md", + filename: "notes.md", + }); + }); + }); }); diff --git a/src/features/chat/hooks/__tests__/useChatSessionController.test.ts b/src/features/chat/hooks/__tests__/useChatSessionController.test.ts index 8c5d8b1ce..dead62e43 100644 --- a/src/features/chat/hooks/__tests__/useChatSessionController.test.ts +++ b/src/features/chat/hooks/__tests__/useChatSessionController.test.ts @@ -1,4 +1,6 @@ import { getModelSelectionIntent } from "@/features/chat/model-selection/modelSelectionIntent"; +import { REMOTE_SSH_SESSIONS_EXPERIMENT_ID } from "@/features/experiments/experimentDefinitions"; +import { setExperimentEnabled } from "@/features/experiments/experimentPreferences"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { act, cleanup, renderHook, waitFor } from "@testing-library/react"; import { createElement, type ReactNode } from "react"; @@ -50,6 +52,7 @@ const mockMarkAgentBuilderSessionPreparationFailed = vi.fn(); const mockDeletePersonaSource = vi.fn(); const mockAcpCreateSession = vi.fn(); const mockAcpSessionArchive = vi.fn(); +const mockEnsureRemoteHostConnected = vi.fn(); const mockUseChatRuntime = { chatState: "idle", activeRunId: null as string | null, @@ -125,8 +128,8 @@ vi.mock("sonner", () => ({ toast: { error: (...args: unknown[]) => mockToastError(...args) }, })); -vi.mock("@/shared/api/acpConnection", () => ({ - getClient: async () => ({ +vi.mock("@/shared/api/acpConnection", () => { + const getClient = async () => ({ goose: { GooseUnstableDefaultsRead: (...args: unknown[]) => mockGooseDefaultsRead(...args), @@ -139,8 +142,9 @@ vi.mock("@/shared/api/acpConnection", () => ({ GooseUnstableSessionArchive: (...args: unknown[]) => mockAcpSessionArchive(...args), }, - }), -})); + }); + return { getClient, getBackendClient: getClient }; +}); vi.mock("../useChat", () => ({ useChat: ( @@ -236,6 +240,14 @@ vi.mock("@/features/projects/lib/sessionCwdSelection", () => ({ resolveSessionCwd: (...args: unknown[]) => mockResolveSessionCwd(...args), })); +vi.mock("../../lib/remoteSession", () => ({ + isRemoteSession: ( + session: { remoteHost?: string | null } | null | undefined, + ) => Boolean(session?.remoteHost?.trim()), + ensureRemoteHostConnected: (...args: unknown[]) => + mockEnsureRemoteHostConnected(...args), +})); + vi.mock("../useAgentModelPickerState", () => ({ useAgentModelPickerState: ({ onProviderSelected, @@ -6566,4 +6578,150 @@ describe("useChatSessionController", () => { }); }); }); + + describe("remote SSH host selection", () => { + beforeEach(() => { + mockEnsureRemoteHostConnected.mockReset().mockResolvedValue(undefined); + setExperimentEnabled(REMOTE_SSH_SESSIONS_EXPERIMENT_ID, true); + }); + + it("is disabled while the experiment is off", () => { + setExperimentEnabled(REMOTE_SSH_SESSIONS_EXPERIMENT_ID, false); + const { result } = renderHook(() => + useChatSessionController({ sessionId: "session-1" }), + ); + + expect(result.current.remoteHostSelectionEnabled).toBe(false); + }); + + it("tracks pending host and dir, and clearing the host resets the dir", () => { + const { result } = renderHook(() => + useChatSessionController({ sessionId: "session-1" }), + ); + + expect(result.current.remoteHostSelectionEnabled).toBe(true); + expect(result.current.selectedRemoteHost).toBeNull(); + + act(() => { + result.current.handleRemoteHostChange("devbox"); + }); + expect(result.current.selectedRemoteHost).toBe("devbox"); + // Remote sessions are project-less: picking a host clears the project. + expect(result.current.selectedProjectId).toBeNull(); + expect(result.current.selectedRemoteDir).toBeNull(); + + act(() => { + result.current.handleRemoteDirChange("/home/dev/project"); + }); + expect(result.current.selectedRemoteDir).toBe("/home/dev/project"); + + act(() => { + result.current.handleRemoteHostChange(null); + }); + expect(result.current.selectedRemoteHost).toBeNull(); + expect(result.current.selectedRemoteDir).toBeNull(); + }); + + it("is not offered once the session has started", () => { + useChatStore.setState({ + messagesBySession: { + "session-1": [createUserMessage("already chatting")], + }, + }); + + const { result } = renderHook(() => + useChatSessionController({ sessionId: "session-1" }), + ); + + expect(result.current.remoteHostSelectionEnabled).toBe(false); + }); + + it("blocks a send when a host is selected without a remote directory", async () => { + const { result } = renderHook(() => + useChatSessionController({ sessionId: "session-1" }), + ); + + act(() => { + result.current.handleRemoteHostChange("devbox"); + }); + + let accepted: boolean | Promise = true; + act(() => { + accepted = result.current.handleSend("hello remote"); + }); + expect(await accepted).toBe(false); + expect(mockAcpCreateSession).not.toHaveBeenCalled(); + expect(mockEnsureRemoteHostConnected).not.toHaveBeenCalled(); + }); + + it("routes a send with host and dir into a fresh remote session", async () => { + mockAcpCreateSession.mockResolvedValue({ + sessionId: "remote-session-1", + configOptionsSnapshot: undefined, + }); + + const { result } = renderHook(() => + useChatSessionController({ sessionId: "session-1" }), + ); + + act(() => { + result.current.handleRemoteHostChange("devbox"); + result.current.handleRemoteDirChange("/home/dev/project"); + }); + + let accepted: boolean | Promise = false; + act(() => { + accepted = result.current.handleSend("hello remote"); + }); + await act(async () => { + expect(await accepted).toBe(true); + }); + + expect(mockEnsureRemoteHostConnected).toHaveBeenCalledWith("devbox"); + expect(mockAcpCreateSession).toHaveBeenCalledWith( + expect.any(String), + "/home/dev/project", + expect.objectContaining({ remoteHost: "devbox" }), + ); + const created = useChatSessionStore + .getState() + .getSession("remote-session-1"); + expect(created?.remoteHost).toBe("devbox"); + expect(created?.workingDir).toBe("/home/dev/project"); + // The original (local) session is left untouched. + expect( + useChatSessionStore.getState().getSession("session-1")?.remoteHost, + ).toBeUndefined(); + expect(useChatSessionStore.getState().activeSessionId).toBe( + "remote-session-1", + ); + }); + + it("reports a failed connect and keeps the send unaccepted", async () => { + mockEnsureRemoteHostConnected.mockRejectedValue({ + kind: "host-unreachable", + message: "no route to host", + }); + + const { result } = renderHook(() => + useChatSessionController({ sessionId: "session-1" }), + ); + + act(() => { + result.current.handleRemoteHostChange("devbox"); + result.current.handleRemoteDirChange("/home/dev/project"); + }); + + let accepted: boolean | Promise = true; + act(() => { + accepted = result.current.handleSend("hello remote"); + }); + await act(async () => { + expect(await accepted).toBe(false); + }); + + expect(mockAcpCreateSession).not.toHaveBeenCalled(); + expect(mockToastError).toHaveBeenCalled(); + }); + }); }); diff --git a/src/features/chat/hooks/__tests__/useGitStateAutoRefresh.test.tsx b/src/features/chat/hooks/__tests__/useGitStateAutoRefresh.test.tsx index 3fb8163f9..290013385 100644 --- a/src/features/chat/hooks/__tests__/useGitStateAutoRefresh.test.tsx +++ b/src/features/chat/hooks/__tests__/useGitStateAutoRefresh.test.tsx @@ -187,6 +187,48 @@ describe("useGitStateAutoRefreshOnChatSettled", () => { expect(invalidateSpy).toHaveBeenCalledTimes(2); }); + it("never invalidates git queries for a remote session", () => { + const queryClient = new QueryClient(); + const invalidateSpy = vi.spyOn(queryClient, "invalidateQueries"); + + useChatSessionStore.setState({ + sessions: [ + { + id: "session-1", + title: "Remote", + workingDir: "/home/dev/project", + remoteHost: "devbox", + createdAt: "2026-08-27T00:00:00.000Z", + updatedAt: "2026-08-27T00:00:00.000Z", + messageCount: 1, + }, + ], + }); + useChatStore.getState().setChatState("session-1", "streaming"); + useChatStore.getState().setStreamingMessageId("session-1", "message-1"); + + renderHook( + () => + useGitStateAutoRefreshOnChatSettled({ + sessionId: "session-1", + sessionWorkingDir: "/home/dev/project", + }), + { wrapper: createWrapper(queryClient) }, + ); + + act(() => { + useChatStore.getState().setChatState("session-1", "idle"); + useChatStore.getState().setStreamingMessageId("session-1", null); + }); + act(() => { + vi.advanceTimersByTime(CHAT_GIT_AUTO_REFRESH_DELAY_MS); + }); + + // The path names a directory on the SSH host; a local git-state refetch + // would probe the wrong filesystem, so the settle must not schedule one. + expect(invalidateSpy).not.toHaveBeenCalled(); + }); + it("does not refresh on initial idle render", () => { const queryClient = new QueryClient(); const invalidateSpy = vi.spyOn(queryClient, "invalidateQueries"); diff --git a/src/features/chat/hooks/useChat.ts b/src/features/chat/hooks/useChat.ts index 7c4a52b32..aee7ea014 100644 --- a/src/features/chat/hooks/useChat.ts +++ b/src/features/chat/hooks/useChat.ts @@ -32,6 +32,7 @@ import { flushBufferedStreamingUpdatesForSession, } from "../acp/liveStreamingUpdates"; import { useWorkspaceRepository } from "@/features/workspaces/workspaceRepository"; +import { isRemoteSession } from "../lib/remoteSession"; // TODO: Remove this fallback once goose2 has first-class /-commands. const MANUAL_COMPACT_TRIGGER = "/compact"; @@ -374,12 +375,15 @@ export function useChat( const getWorkingDir = useCallback(() => { const sessionStore = useChatSessionStore.getState(); - return workspaceRepository.chatWorkspaces( - sessionStore.getSession(sessionId), - { - activePath: sessionStore.activeWorkspaceBySession[sessionId]?.path, - }, - ).primary?.path; + const session = sessionStore.getSession(sessionId); + if (isRemoteSession(session)) { + // Remote paths must pass through verbatim: the local workspace + // repository can only resolve directories on this machine. + return session?.workingDir ?? undefined; + } + return workspaceRepository.chatWorkspaces(session, { + activePath: sessionStore.activeWorkspaceBySession[sessionId]?.path, + }).primary?.path; }, [sessionId, workspaceRepository]); const compactConversation = useCallback( diff --git a/src/features/chat/hooks/useChatSessionController.ts b/src/features/chat/hooks/useChatSessionController.ts index 3cbe97a42..05bc1bec6 100644 --- a/src/features/chat/hooks/useChatSessionController.ts +++ b/src/features/chat/hooks/useChatSessionController.ts @@ -20,6 +20,15 @@ import { useChatSessionStore, type ChatSession, } from "../stores/chatSessionStore"; +import { toast } from "sonner"; +import { i18n } from "@/shared/i18n"; +import { REMOTE_SSH_SESSIONS_EXPERIMENT_ID } from "@/features/experiments/experimentDefinitions"; +import { useExperiment } from "@/features/experiments/experimentPreferences"; +import { formatAcpErrorMessage } from "@/shared/api/acpErrors"; +import { + ensureRemoteHostConnected, + isRemoteSession, +} from "../lib/remoteSession"; import { useAgentStore } from "@/features/agents/stores/agentStore"; import { selectPersonas } from "@/features/agents/stores/agentSelectors"; import { useProviderSelection } from "@/features/agents/hooks/useProviderSelection"; @@ -371,6 +380,8 @@ export function useChatSessionController({ const catalogLoaded = useProviderCatalogStore((s) => s.loaded); const [pendingPersonaId, setPendingPersonaId] = useState(); const [pendingProjectId, setPendingProjectId] = useState(); + const [pendingRemoteHost, setPendingRemoteHost] = useState(); + const [pendingRemoteDir, setPendingRemoteDir] = useState(); const [pendingExecutionTarget, setPendingExecutionTarget] = useState(); const [pendingModelSelection, setPendingModelSelection] = @@ -401,6 +412,23 @@ export function useChatSessionController({ const sessionHasStarted = session ? hasSessionStarted(session, sessionLocalMessageCount) : false; + const remoteSshSessionsExperimentEnabled = + useExperiment(REMOTE_SSH_SESSIONS_EXPERIMENT_ID)?.enabled === true; + // A session's backend is fixed once its backend session exists, so remote + // host selection is offered pre-start only (home/draft/pending states); + // started sessions merely display their host. + const remoteHostSelectionEnabled = + remoteSshSessionsExperimentEnabled && !readOnly && !sessionHasStarted; + const selectedRemoteHost = + pendingRemoteHost !== undefined + ? pendingRemoteHost + : (session?.remoteHost ?? null); + const selectedRemoteDir = + pendingRemoteDir !== undefined + ? pendingRemoteDir + : session?.remoteHost + ? (session.workingDir ?? null) + : null; const pendingDraftValue = useChatStore( isHomeSession ? (s) => s.draftsBySession[PENDING_HOME_SESSION_ID] ?? "" @@ -752,6 +780,30 @@ export function useChatSessionController({ [skillProviderId], ); + // Remote sessions carry a workingDir on the remote host's filesystem; + // local path resolution would rewrite it to a local artifacts dir. Pass it + // through verbatim for them (mirrors sessionActivation/queuedSessionSend). + const resolveCwdForSession = useCallback( + async ( + targetSessionId: string | null | undefined, + nextProject: SessionCwdProject, + nextWorkspacePath: string | null | undefined, + ) => { + const liveSession = targetSessionId + ? useChatSessionStore.getState().getSession(targetSessionId) + : undefined; + if ( + liveSession && + isRemoteSession(liveSession) && + liveSession.workingDir + ) { + return liveSession.workingDir; + } + return resolveSessionCwd(nextProject, nextWorkspacePath); + }, + [], + ); + const prepareCurrentSession = useCallback( async ( providerId: string, @@ -762,7 +814,8 @@ export function useChatSessionController({ if (!sessionId) { return false; } - const workingDir = await resolveSessionCwd( + const workingDir = await resolveCwdForSession( + sessionId, nextProject, nextWorkspacePath, ); @@ -785,7 +838,7 @@ export function useChatSessionController({ return true; }, - [project, sessionId, sessionWorkspacePath], + [project, sessionId, sessionWorkspacePath, resolveCwdForSession], ); const prepareCurrentSessionTarget = useCallback( async ( @@ -816,7 +869,8 @@ export function useChatSessionController({ targetToApply.target, ).providerId; if (!wireProviderId) return false; - const workingDir = await resolveSessionCwd( + const workingDir = await resolveCwdForSession( + sessionId, nextProject, nextWorkspacePath, ); @@ -870,7 +924,7 @@ export function useChatSessionController({ delete pendingDefaultReasoningEffortBySessionRef.current[sessionId]; return true; }, - [project, sessionWorkspacePath, sessionId], + [project, sessionWorkspacePath, sessionId, resolveCwdForSession], ); const prepareSelectedProvider = useCallback( (wireProviderId: string, options?: ModelSelectionApplyOptions) => @@ -910,7 +964,8 @@ export function useChatSessionController({ return false; } const target = intent.target; - const workingDir = await resolveSessionCwd( + const workingDir = await resolveCwdForSession( + sessionId, options?.nextProject ?? project, options?.nextWorkspacePath ?? sessionWorkspacePath, ); @@ -955,7 +1010,13 @@ export function useChatSessionController({ delete pendingDefaultReasoningEffortBySessionRef.current[sessionId]; return true; }, - [isHomeSession, project, sessionId, sessionWorkspacePath], + [ + isHomeSession, + project, + sessionId, + sessionWorkspacePath, + resolveCwdForSession, + ], ); // Escape hatch for the "Provider not set" trap. When an in-place provider or @@ -977,7 +1038,8 @@ export function useChatSessionController({ ): Promise => { const store = useChatSessionStore.getState(); const current = sessionId ? store.getSession(sessionId) : undefined; - const workingDir = await resolveSessionCwd( + const workingDir = await resolveCwdForSession( + sessionId, project, activeWorkspace?.path ?? current?.workingDir ?? session?.workingDir, ); @@ -1070,7 +1132,13 @@ export function useChatSessionController({ return true; }, - [activeWorkspace?.path, project, session?.workingDir, sessionId], + [ + activeWorkspace?.path, + project, + session?.workingDir, + sessionId, + resolveCwdForSession, + ], ); const prevProjectIdRef = useRef(session?.projectId); @@ -1150,7 +1218,8 @@ export function useChatSessionController({ reasoningEffortRefreshKeyBySessionRef.current[sessionId] = refreshKey; try { - const workingDir = await resolveSessionCwd( + const workingDir = await resolveCwdForSession( + sessionId, project, activeWorkspace?.path ?? session?.workingDir, ); @@ -1195,6 +1264,7 @@ export function useChatSessionController({ session?.reasoningEffort, session?.workingDir, sessionId, + resolveCwdForSession, ]); const handlePickerOpenWithReasoningRefresh = useCallback(() => { @@ -1247,7 +1317,8 @@ export function useChatSessionController({ ?.executionTarget, sessionSelection, ); - const workingDir = await resolveSessionCwd( + const workingDir = await resolveCwdForSession( + stateSessionId, project, preparationWorkspacePath, ); @@ -1271,7 +1342,12 @@ export function useChatSessionController({ preparationWorkspacePath, ); }, - [prepareCurrentSessionTarget, project, stateSessionId], + [ + prepareCurrentSessionTarget, + project, + stateSessionId, + resolveCwdForSession, + ], ); const supportsSteering = selectedAgentId === STEERING_SUPPORTED_AGENT_ID; @@ -1429,6 +1505,20 @@ export function useChatSessionController({ [sessionId], ); + const handleRemoteHostChange = useCallback((host: string | null) => { + setPendingRemoteHost(host); + // A chosen directory belongs to one host, so any host change resets it. + setPendingRemoteDir(undefined); + if (host) { + // Remote sessions are project-less in v1. + setPendingProjectId(null); + } + }, []); + + const handleRemoteDirChange = useCallback((dir: string | null) => { + setPendingRemoteDir(dir); + }, []); + const handlePersonaChange = useCallback( (personaId: string | null) => { if (personaId === selectedPersonaId) { @@ -2511,6 +2601,63 @@ export function useChatSessionController({ } return accepted; }; + // A remote host picked in this composer routes the send into a fresh + // session on that host's backend. The current session is never mutated: + // a session's backend is fixed at creation, so "switch to remote" means + // "start the chat over there". + if (remoteHostSelectionEnabled && pendingRemoteHost && !readOnly) { + const remoteHost = pendingRemoteHost; + const remoteDir = pendingRemoteDir ?? null; + if (!remoteDir) { + // The composer already blocks send with an inline reason when the + // remote directory is missing; this is a backstop. + return false; + } + const payload = captureSessionSelection({ + text, + persona: personaIntentFromComposer(personaId, personaName), + attachments, + sendOptions, + }); + const remoteExecutionTarget = + pendingExecutionTarget !== undefined + ? (pendingExecutionTarget ?? undefined) + : session?.executionTarget; + return (async () => { + try { + await ensureRemoteHostConnected(remoteHost); + const created = await useChatSessionStore.getState().createSession({ + executionTarget: remoteExecutionTarget, + personaId: + payload.persona.kind === "persona" + ? payload.persona.id + : (selectedPersonaId ?? undefined), + workingDir: remoteDir, + remoteHost, + }); + const firstSend = acceptFirstSend(created.id, payload, { + queueReady: true, + }); + if (!firstSend.accepted) { + return false; + } + setPendingRemoteHost(undefined); + setPendingRemoteDir(undefined); + if (sessionId) { + recordDraftPreservingSubmission(sessionId, text); + } + activateSession(created.id); + onMessageAccepted?.(created.id); + return true; + } catch (error) { + console.error("Failed to start remote session:", error); + toast.error(i18n.t("chat:toolbar.remoteHost.startFailed"), { + description: formatAcpErrorMessage(error), + }); + return false; + } + })(); + } if (!sessionId) { if (readOnly) { return false; @@ -2721,12 +2868,17 @@ export function useChatSessionController({ handlePersonaChange, onMessageAccepted, onWorkspaceNameRequest, + pendingExecutionTarget, + pendingRemoteDir, + pendingRemoteHost, preselectedWorkspaceStartupName, queue, readOnly, recordDraftPreservingSubmission, + remoteHostSelectionEnabled, session?.agentBuilderOpen, session?.creationState, + session?.executionTarget, session?.intent, sessionId, selectedPersona, @@ -3393,5 +3545,10 @@ export function useChatSessionController({ selectedProjectId: effectiveProjectId, availableProjects, handleProjectChange, + remoteHostSelectionEnabled, + selectedRemoteHost, + selectedRemoteDir, + handleRemoteHostChange, + handleRemoteDirChange, }; } diff --git a/src/features/chat/hooks/useGitStateAutoRefresh.ts b/src/features/chat/hooks/useGitStateAutoRefresh.ts index 4bc06f7e5..0a12dfb1a 100644 --- a/src/features/chat/hooks/useGitStateAutoRefresh.ts +++ b/src/features/chat/hooks/useGitStateAutoRefresh.ts @@ -6,6 +6,7 @@ import { gitStateQueryKey, } from "@/shared/lib/gitStateQueryKey"; import { INITIAL_SESSION_CHAT_RUNTIME } from "@/shared/types/chat"; +import { isRemoteSession } from "../lib/remoteSession"; import { isSessionRunning } from "../lib/sessionActivity"; import { useChatSessionStore } from "../stores/chatSessionStore"; import { useChatStore } from "../stores/chatStore"; @@ -44,6 +45,17 @@ export function useGitStateAutoRefreshOnChatSettled({ const activeWorkspacePath = useChatSessionStore((state) => sessionId ? state.activeWorkspaceBySession[sessionId]?.path : undefined, ); + // A remote session's workspace paths live on its SSH host: the local git + // Tauri commands behind these query keys would probe the wrong filesystem, + // so never schedule an invalidation (which would trigger refetches) for one. + const sessionIsRemote = useChatSessionStore((state) => + sessionId + ? isRemoteSession( + state.sessions.find((candidate) => candidate.id === sessionId), + ) + : false, + ); + const refreshEnabled = enabled && !sessionIsRemote; const runtime = useChatStore((state) => sessionId ? state.sessionStateById[sessionId] : undefined, ); @@ -89,7 +101,7 @@ export function useGitStateAutoRefreshOnChatSettled({ ); useEffect(() => { - if (!enabled || !sessionId) { + if (!refreshEnabled || !sessionId) { clearScheduledRefresh(refreshTimeoutRef); lastSessionIdRef.current = sessionId ?? null; wasWorkingRef.current = false; @@ -117,7 +129,7 @@ export function useGitStateAutoRefreshOnChatSettled({ if (gitTargetPath) { scheduleRefresh(gitTargetPath); } - }, [enabled, gitTargetPath, isWorking, scheduleRefresh, sessionId]); + }, [refreshEnabled, gitTargetPath, isWorking, scheduleRefresh, sessionId]); useEffect(() => { return () => clearScheduledRefresh(refreshTimeoutRef); diff --git a/src/features/chat/lib/__tests__/acpSessionMapping.remoteHost.test.ts b/src/features/chat/lib/__tests__/acpSessionMapping.remoteHost.test.ts new file mode 100644 index 000000000..be8cb842e --- /dev/null +++ b/src/features/chat/lib/__tests__/acpSessionMapping.remoteHost.test.ts @@ -0,0 +1,100 @@ +import { beforeEach, describe, expect, it } from "vitest"; +import type { AcpSessionInfo } from "@/shared/api/acp"; +import { + acpSessionToChatSession, + mergeAcpSessionPage, +} from "../acpSessionMapping"; +import type { ChatSession } from "../../stores/chatSessionStore"; + +function makeAcpSession( + overrides: Partial & { sessionId: string }, +): AcpSessionInfo { + const { sessionId, ...rest } = overrides; + return { + sessionId, + title: "ACP Session", + updatedAt: "2026-08-01T00:00:00.000Z", + createdAt: "2026-08-01T00:00:00.000Z", + lastMessageAt: null, + archivedAt: null, + userSetName: false, + messageCount: 1, + subtitle: null, + workingDir: null, + projectId: null, + providerId: null, + modelId: null, + personaId: null, + ...rest, + }; +} + +function makeChatSession(overrides: Partial = {}): ChatSession { + return { + id: "remote-1", + title: "Remote chat", + createdAt: "2026-08-01T00:00:00.000Z", + updatedAt: "2026-08-01T00:00:00.000Z", + messageCount: 1, + ...overrides, + }; +} + +describe("acpSessionMapping remoteHost", () => { + beforeEach(() => { + window.localStorage.clear(); + }); + + it("tags sessions loaded from a remote backend page", () => { + const session = acpSessionToChatSession( + makeAcpSession({ sessionId: "remote-1", workingDir: "/remote/dir" }), + { remoteHost: "devbox" }, + ); + + expect(session.remoteHost).toBe("devbox"); + }); + + it("leaves locally loaded sessions untagged", () => { + const session = acpSessionToChatSession( + makeAcpSession({ sessionId: "local-1" }), + ); + + expect("remoteHost" in session).toBe(false); + }); + + it("keeps an existing remoteHost when the local page reloads the session", () => { + const existing = makeChatSession({ + id: "remote-1", + remoteHost: "devbox", + }); + + const merged = mergeAcpSessionPage( + { sessions: [existing], archiveMutationBySessionId: {} }, + { + sessions: [makeAcpSession({ sessionId: "remote-1" })], + nextCursor: null, + }, + null, + ); + + expect( + merged.sessions.find((session) => session.id === "remote-1")?.remoteHost, + ).toBe("devbox"); + }); + + it("tags sessions merged from a remote page load", () => { + const merged = mergeAcpSessionPage( + { sessions: [], archiveMutationBySessionId: {} }, + { + sessions: [makeAcpSession({ sessionId: "remote-2" })], + nextCursor: null, + }, + null, + { remoteHost: "devbox" }, + ); + + expect( + merged.sessions.find((session) => session.id === "remote-2")?.remoteHost, + ).toBe("devbox"); + }); +}); diff --git a/src/features/chat/lib/__tests__/sessionActivation.test.ts b/src/features/chat/lib/__tests__/sessionActivation.test.ts index 08641cc9f..d1cf05fee 100644 --- a/src/features/chat/lib/__tests__/sessionActivation.test.ts +++ b/src/features/chat/lib/__tests__/sessionActivation.test.ts @@ -35,6 +35,7 @@ const acpLoadSession = vi.hoisted(() => vi.fn()); const acpPrepareSession = vi.hoisted(() => vi.fn()); const resolvePath = vi.hoisted(() => vi.fn()); const checkDirectoriesExist = vi.hoisted(() => vi.fn()); +const ensureRemoteHostConnected = vi.hoisted(() => vi.fn()); vi.mock("@/shared/api/acp", () => ({ acpGetSessionInfo: (...args: unknown[]) => acpGetSessionInfo(...args), @@ -52,6 +53,16 @@ vi.mock("@/features/chat/acp/acpNotificationHandler", () => ({ clearReplayPerf: vi.fn(), })); +vi.mock("@/features/chat/lib/remoteSession", async (importOriginal) => { + const actual = + await importOriginal(); + return { + ...actual, + ensureRemoteHostConnected: (...args: unknown[]) => + ensureRemoteHostConnected(...args), + }; +}); + function makeProject(overrides: Partial = {}): ProjectInfo { return { id: "project-1", @@ -151,6 +162,7 @@ describe("loadSessionMessages", () => { acpGetSessionInfo.mockResolvedValue(null); acpLoadSession.mockResolvedValue(undefined); acpPrepareSession.mockResolvedValue(undefined); + ensureRemoteHostConnected.mockResolvedValue(undefined); resolvePath.mockImplementation(({ parts }: { parts: string[] }) => Promise.resolve({ path: `/resolved${parts[0]}` }), ); @@ -1134,4 +1146,56 @@ describe("loadSessionMessages", () => { expect(messagesFor("s6").map((m) => m.role)).toEqual(["system"]); }); + + describe("remote sessions", () => { + it("passes the remote workingDir through verbatim and skips local checks", async () => { + seedSession({ + id: "s-remote", + remoteHost: "devbox", + workingDir: "/remote/home/damien/project", + }); + + await expect(loadSessionMessages("s-remote")).resolves.toBe(true); + + expect(ensureRemoteHostConnected).toHaveBeenCalledWith("devbox"); + expect(acpLoadSession).toHaveBeenCalledWith( + "s-remote", + "/remote/home/damien/project", + ); + expect(resolvePath).not.toHaveBeenCalled(); + expect(checkDirectoriesExist).not.toHaveBeenCalled(); + }); + + it("does not connect a host for local sessions", async () => { + seedSession({ id: "s-local", workingDir: "/existing/session" }); + + await expect(loadSessionMessages("s-local")).resolves.toBe(true); + + expect(ensureRemoteHostConnected).not.toHaveBeenCalled(); + }); + + it("surfaces a failed host connection as the standard load failure", async () => { + ensureRemoteHostConnected.mockRejectedValue( + new Error("ssh tunnel failed"), + ); + seedSession( + { + id: "s-remote-down", + remoteHost: "devbox", + workingDir: "/remote/project", + }, + { replay: false }, + ); + + await expect(loadSessionMessages("s-remote-down")).resolves.toBe(false); + + expect(acpLoadSession).not.toHaveBeenCalled(); + const error = notificationFromLastMessage("s-remote-down"); + expect(error.notificationType).toBe("error"); + expect(error.text).toBe("ssh tunnel failed"); + expect( + useChatStore.getState().loadingSessionIds.has("s-remote-down"), + ).toBe(false); + }); + }); }); diff --git a/src/features/chat/lib/acpSessionMapping.ts b/src/features/chat/lib/acpSessionMapping.ts index 3845654ae..fb3ec3127 100644 --- a/src/features/chat/lib/acpSessionMapping.ts +++ b/src/features/chat/lib/acpSessionMapping.ts @@ -17,12 +17,20 @@ interface SessionPageState { hasMoreSessions: boolean; } -export function acpSessionToChatSession(session: AcpSessionInfo): ChatSession { +export interface AcpSessionMappingContext { + /** Set when the page/info was loaded from a remote SSH backend. */ + remoteHost?: string; +} + +export function acpSessionToChatSession( + session: AcpSessionInfo, + context?: AcpSessionMappingContext, +): ChatSession { const persistedWorkspaceMetadata = loadPersistedChatWorkspaceMetadata( session.sessionId, ); return withWorkspaceBackfill({ - ...chatSessionFromAcpInfo(session), + ...chatSessionFromAcpInfo(session, context), workspaceAttachments: persistedWorkspaceMetadata?.workspaceAttachments, activeWorkspaceId: persistedWorkspaceMetadata?.activeWorkspaceId, }); @@ -35,7 +43,10 @@ export function acpSessionToChatSession(session: AcpSessionInfo): ChatSession { * workingDir backfill invents attachments for sessions that exist only as * search rows. */ -export function chatSessionFromAcpInfo(session: AcpSessionInfo): ChatSession { +export function chatSessionFromAcpInfo( + session: AcpSessionInfo, + context?: AcpSessionMappingContext, +): ChatSession { const now = new Date().toISOString(); const executionTarget = executionTargetFromGooseServeSession({ providerId: session.providerId ?? undefined, @@ -49,6 +60,9 @@ export function chatSessionFromAcpInfo(session: AcpSessionInfo): ChatSession { executionTargetSource: executionTarget ? "acp" : undefined, personaId: session.personaId ?? undefined, workingDir: session.workingDir ?? undefined, + // Only set the key when the session actually came from a remote backend, + // so spreading a locally loaded row can never wipe an existing tag. + ...(context?.remoteHost ? { remoteHost: context.remoteHost } : {}), createdAt: session.createdAt ?? session.updatedAt ?? now, updatedAt: session.updatedAt ?? now, lastMessageAt: session.lastMessageAt ?? undefined, @@ -96,6 +110,9 @@ function mergeSessionMetadata( ? existing.executionTargetSource : session.executionTargetSource; const personaId = session.personaId ?? existing?.personaId; + // Additive: a refresh from the local page load must not wipe the remote + // tag a rehydrated/remote-loaded session already carries. + const remoteHost = session.remoteHost ?? existing?.remoteHost; byId.set( session.id, withWorkspaceBackfill({ @@ -104,6 +121,7 @@ function mergeSessionMetadata( executionTarget, executionTargetSource, personaId, + remoteHost, workspaceAttachments: existing?.workspaceAttachments ?? session.workspaceAttachments, activeWorkspaceId: @@ -134,10 +152,11 @@ function mergeSessionMetadata( export function mergeAcpSessionInfo( state: Pick, session: AcpSessionInfo, + context?: AcpSessionMappingContext, ): Pick { return mergeSessionMetadata( state.sessions, - [acpSessionToChatSession(session)], + [acpSessionToChatSession(session, context)], state.archiveMutationBySessionId, ); } @@ -178,6 +197,7 @@ export function mergeAcpSessionPage( state: Pick, page: AcpSessionsPage, previousCursor: string | null, + context?: AcpSessionMappingContext, ): SessionPageState { const { nextCursor } = page; const repeatedCursor = @@ -192,7 +212,7 @@ export function mergeAcpSessionPage( const hasMoreSessions = nextCursor != null && !repeatedCursor; const merged = mergeSessionMetadata( state.sessions, - page.sessions.map(acpSessionToChatSession), + page.sessions.map((session) => acpSessionToChatSession(session, context)), state.archiveMutationBySessionId, ); diff --git a/src/features/chat/lib/newChat.test.ts b/src/features/chat/lib/newChat.test.ts index 07b8c2df3..2121296f5 100644 --- a/src/features/chat/lib/newChat.test.ts +++ b/src/features/chat/lib/newChat.test.ts @@ -277,6 +277,94 @@ describe("findExistingDraft", () => { ).toBeUndefined(); }); + it("does not reuse a draft whose remote host differs from the request", () => { + const localDraft = makeSession("local-draft", { + projectId: "alpha", + executionTarget: { harnessId: "goose" }, + }); + const remoteDraft = makeSession("remote-draft", { + projectId: "alpha", + executionTarget: { harnessId: "goose" }, + remoteHost: "devbox", + }); + + expect( + findExistingDraft({ + sessions: [localDraft, remoteDraft], + activeSessionId: null, + draftsBySession: { + "local-draft": "draft text", + "remote-draft": "draft text", + }, + messagesBySession: {}, + request: { + title: "New Chat", + projectId: "alpha", + remoteHost: "otherbox", + }, + }), + ).toBeUndefined(); + + expect( + findExistingDraft({ + sessions: [localDraft, remoteDraft], + activeSessionId: null, + draftsBySession: { + "local-draft": "draft text", + "remote-draft": "draft text", + }, + messagesBySession: {}, + request: { + title: "New Chat", + projectId: "alpha", + remoteHost: "devbox", + }, + }), + ).toEqual(remoteDraft); + }); + + it("treats an absent remote host as local on both sides", () => { + const localDraft = makeSession("local-draft", { + projectId: "alpha", + executionTarget: { harnessId: "goose" }, + }); + const remoteDraft = makeSession("remote-draft", { + projectId: "alpha", + executionTarget: { harnessId: "goose" }, + remoteHost: "devbox", + }); + + expect( + findExistingDraft({ + sessions: [remoteDraft, localDraft], + activeSessionId: null, + draftsBySession: { + "local-draft": "draft text", + "remote-draft": "draft text", + }, + messagesBySession: {}, + request: { + title: "New Chat", + projectId: "alpha", + }, + }), + ).toEqual(localDraft); + + expect( + findExistingDraft({ + sessions: [localDraft], + activeSessionId: null, + draftsBySession: { "local-draft": "draft text" }, + messagesBySession: {}, + request: { + title: "New Chat", + projectId: "alpha", + remoteHost: "", + }, + }), + ).toEqual(localDraft); + }); + it("does not reuse a session with local messages even if messageCount is 0", () => { const session = makeSession("alpha-session", { projectId: "alpha", diff --git a/src/features/chat/lib/newChat.ts b/src/features/chat/lib/newChat.ts index 6870d7f37..42947de06 100644 --- a/src/features/chat/lib/newChat.ts +++ b/src/features/chat/lib/newChat.ts @@ -11,6 +11,7 @@ interface NewChatRequest { projectId?: string; executionTarget?: SessionExecutionTarget; reasoningEffortValue?: string; + remoteHost?: string; } interface FindExistingDraftArgs { @@ -23,12 +24,22 @@ interface FindExistingDraftArgs { allowDraftReuse?: boolean; } +/** Treats undefined, null, and empty/whitespace hosts as "local". */ +function normalizeRemoteHost( + host: string | null | undefined, +): string | undefined { + const trimmed = host?.trim(); + return trimmed ? trimmed : undefined; +} + function isMatchingContext( session: ChatSession, request: Omit, ): boolean { return ( session.projectId === request.projectId && + normalizeRemoteHost(session.remoteHost) === + normalizeRemoteHost(request.remoteHost) && (!request.executionTarget || sameSessionExecutionTarget( session.executionTarget, diff --git a/src/features/chat/lib/queuedSessionSend.ts b/src/features/chat/lib/queuedSessionSend.ts index cd8ba53a5..c090a2eb9 100644 --- a/src/features/chat/lib/queuedSessionSend.ts +++ b/src/features/chat/lib/queuedSessionSend.ts @@ -21,6 +21,7 @@ import { trackChatSessionStarted, } from "@/features/chat/lib/chatTelemetry"; import { QueuedSessionNotReadyError } from "@/features/chat/lib/queuedMessageReadiness"; +import { isRemoteSession } from "@/features/chat/lib/remoteSession"; import { loadSessionMessages } from "@/features/chat/lib/sessionActivation"; import { SessionDispatchContentionError, @@ -206,7 +207,12 @@ export async function prepareExistingSessionForBackgroundSend( ? session.workingDir : (useChatSessionStore.getState().activeWorkspaceBySession[sessionId] ?.path ?? session.workingDir); - const workingDir = await resolveSessionCwd(project, activeWorkspacePath); + // Remote sessions: the cwd names a directory on the SSH host, so the local + // resolve_path/artifact-root machinery must not touch it — pass the stored + // workingDir through verbatim (mirrors sessionActivation's load guard). + const workingDir = isRemoteSession(session) + ? (session.workingDir ?? "") + : await resolveSessionCwd(project, activeWorkspacePath); const liveSessionAtSubmit = useChatSessionStore .getState() .getSession(sessionId); diff --git a/src/features/chat/lib/remoteSession.ts b/src/features/chat/lib/remoteSession.ts new file mode 100644 index 000000000..c21d634c2 --- /dev/null +++ b/src/features/chat/lib/remoteSession.ts @@ -0,0 +1,24 @@ +/** + * A remote session runs its backend on an SSH host instead of the local + * `goose serve` sidecar. Local filesystem checks (missing-dir probes, path + * resolution, artifact cwd fallbacks) are meaningless for its paths, so + * load-path code guards on this predicate and passes the session's + * `workingDir` through verbatim. + */ +export function isRemoteSession( + session: { remoteHost?: string | null } | null | undefined, +): boolean { + return Boolean(session?.remoteHost?.trim()); +} + +/** + * Ensures the SSH backend for `host` is connected before session traffic is + * routed to it. Lazily imports the remote-host store so chat modules do not + * take a static dependency on the remoteHosts feature. + */ +export async function ensureRemoteHostConnected(host: string): Promise { + const { ensureHostConnected } = await import( + "@/features/remoteHosts/stores/remoteHostStore" + ); + await ensureHostConnected(host); +} diff --git a/src/features/chat/lib/sessionActivation.ts b/src/features/chat/lib/sessionActivation.ts index 8ff09f4d4..d7391d2ca 100644 --- a/src/features/chat/lib/sessionActivation.ts +++ b/src/features/chat/lib/sessionActivation.ts @@ -38,6 +38,10 @@ import { hydrateSessionTarget, transitionSessionTarget, } from "@/features/chat/lib/sessionTargetCoordinator"; +import { + ensureRemoteHostConnected, + isRemoteSession, +} from "@/features/chat/lib/remoteSession"; export function clearIdleStreamingMessageAfterReplay( sessionId: string, @@ -78,6 +82,12 @@ async function resolveWorkingDirForSessionLoad( session: ChatSession | undefined, project: ProjectInfo | null, ): Promise { + if (session && isRemoteSession(session)) { + // The session's paths live on the remote host: local existence checks, + // path resolution, and the local artifact-cwd fallback are all + // meaningless there, so pass the stored workingDir through verbatim. + return { workingDir: session.workingDir ?? "" }; + } const activeWorkspace = session?.id != null ? useChatSessionStore.getState().activeWorkspaceBySession[session.id] @@ -380,6 +390,11 @@ async function performSessionMessagesLoad( : null; const { workingDir, missingCwdWarning } = await resolveWorkingDirForSessionLoad(session, project); + if (session && isRemoteSession(session) && session.remoteHost) { + // A failed connect throws into the shared catch below, which surfaces + // the standard session-load-failed notification for this session. + await ensureRemoteHostConnected(session.remoteHost); + } const loadedSelection = await acpLoadSession(sessionId, workingDir); const loadedTarget = loadedSelection ? executionTargetFromGooseServeSession(loadedSelection) diff --git a/src/features/chat/lib/sessionFolderRegistration.test.ts b/src/features/chat/lib/sessionFolderRegistration.test.ts index d49a3c73f..2fe9b79b2 100644 --- a/src/features/chat/lib/sessionFolderRegistration.test.ts +++ b/src/features/chat/lib/sessionFolderRegistration.test.ts @@ -117,6 +117,38 @@ describe("attachSessionFolder", () => { ); }); + it("rejects attach/detach/replace for remote sessions before touching local path checks", async () => { + useChatSessionStore.setState({ + sessions: [{ ...session, remoteHost: "devbox" }], + }); + + await expect( + attachSessionFolder("session-1", "/repo-wt"), + ).rejects.toMatchObject({ + name: "FolderAttachmentError", + message: expect.stringContaining("devbox"), + }); + await expect( + detachSessionFolder("session-1", "/repo"), + ).rejects.toMatchObject({ name: "FolderAttachmentError" }); + await expect( + replaceSessionFolder("session-1", "/repo", "/repo-wt"), + ).rejects.toMatchObject({ name: "FolderAttachmentError" }); + + // The guard fires before any local canonicalization or git probe: those + // Tauri commands act on the local filesystem and the session's paths + // live on the SSH host. + expect( + mocks.canonicalizeAuthorizedWorkspaceDirectory, + ).not.toHaveBeenCalled(); + expect(mocks.resolvePath).not.toHaveBeenCalled(); + expect(mocks.getGitState).not.toHaveBeenCalled(); + expect( + useChatSessionStore.getState().getSession("session-1") + ?.workspaceAttachments ?? [], + ).toHaveLength(0); + }); + it("merges concurrent registrations against current store state", async () => { const first = attachSessionFolder("session-1", "/first"); const second = attachSessionFolder("session-1", "/second"); diff --git a/src/features/chat/lib/sessionFolderRegistration.ts b/src/features/chat/lib/sessionFolderRegistration.ts index c5f16aadf..b985ec012 100644 --- a/src/features/chat/lib/sessionFolderRegistration.ts +++ b/src/features/chat/lib/sessionFolderRegistration.ts @@ -12,6 +12,7 @@ import { isSameWorkspacePathWithHome, workspaceAttachmentIdForPath, } from "@/features/chat/lib/workspaceAttachments"; +import { isRemoteSession } from "@/features/chat/lib/remoteSession"; import { useChatSessionStore } from "@/features/chat/stores/chatSessionStore"; import { getMultiWorkspaceEnabled } from "@/features/workspaces/multiWorkspacePreference"; import { @@ -34,6 +35,25 @@ export class FolderAttachmentError extends Error { } } +/** + * Folder attachment canonicalizes, git-probes, and existence-checks paths on + * the LOCAL filesystem, but a remote session's workspace lives on its SSH + * host — attaching local folders to it is meaningless in v1. Every mutation + * entry point below rejects up front instead of persisting a mixed-machine + * workspace set. + */ +function assertLocalSession( + sessionId: string, + operation: "attach" | "detach" | "replace", +): void { + const session = useChatSessionStore.getState().getSession(sessionId); + if (session && isRemoteSession(session)) { + throw new FolderAttachmentError( + `Cannot ${operation} folders for session "${sessionId}": it runs on remote host "${session.remoteHost}", and local folder attachments are not supported for remote sessions.`, + ); + } +} + async function getAuthorizedWorkspaceRoots( sessionId: string, ): Promise { @@ -182,6 +202,7 @@ export async function attachSessionFolder( requestedPath: string, options: AttachSessionFolderOptions = {}, ): Promise { + assertLocalSession(sessionId, "attach"); const observedIntentGeneration = getSessionWorkspaceIntentGeneration(sessionId); const homeDir = await getHomeDir(); @@ -322,6 +343,7 @@ export async function detachSessionFolder( requestedPath: string, options: DetachSessionFolderOptions = {}, ): Promise { + assertLocalSession(sessionId, "detach"); const homeDir = await getHomeDir(); const path = await canonicalizeDetachablePath( sessionId, @@ -452,6 +474,7 @@ export async function replaceSessionFolder( newRequestedPath: string, options: ReplaceSessionFolderOptions = {}, ): Promise { + assertLocalSession(sessionId, "replace"); const homeDir = await getHomeDir(); const oldPath = await canonicalizeDetachablePath( sessionId, diff --git a/src/features/chat/lib/sessionTargetTransition.integration.test.ts b/src/features/chat/lib/sessionTargetTransition.integration.test.ts index 43b94ebdc..ab5799a71 100644 --- a/src/features/chat/lib/sessionTargetTransition.integration.test.ts +++ b/src/features/chat/lib/sessionTargetTransition.integration.test.ts @@ -14,6 +14,7 @@ vi.mock("@/shared/api/acpApi", () => ({ vi.mock("@/shared/api/acpConnection", () => ({ getClient: () => mockGetClient(), + getBackendClient: () => mockGetClient(), })); const managedRuntimeConfig: RuntimeConfig = { diff --git a/src/features/chat/lib/sessionWorkspaceCleanup.ts b/src/features/chat/lib/sessionWorkspaceCleanup.ts index 32c8a5905..acce006f2 100644 --- a/src/features/chat/lib/sessionWorkspaceCleanup.ts +++ b/src/features/chat/lib/sessionWorkspaceCleanup.ts @@ -100,7 +100,9 @@ export async function loadAllSessionsForWorkspaceCleanup(): Promise< do { const page = await acpListSessionsPage({ cursor }); - sessions.push(...page.sessions.map(acpSessionToChatSession)); + sessions.push( + ...page.sessions.map((session) => acpSessionToChatSession(session)), + ); if (sessions.length > SESSION_PAGE_SAFETY_LIMIT) { throw new Error( "Session pagination exceeded the workspace cleanup limit", diff --git a/src/features/chat/stores/__tests__/chatSessionStore.remoteSessions.test.ts b/src/features/chat/stores/__tests__/chatSessionStore.remoteSessions.test.ts new file mode 100644 index 000000000..384ae23e1 --- /dev/null +++ b/src/features/chat/stores/__tests__/chatSessionStore.remoteSessions.test.ts @@ -0,0 +1,206 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { + REMOTE_SESSIONS_STORAGE_KEY, + readRemoteSessionRecords, +} from "../remoteSessionPersistence"; +import { useChatSessionStore } from "../chatSessionStore"; + +const mocks = vi.hoisted(() => ({ + acpCreateSession: vi.fn(), + acpListSessionsPage: vi.fn(), + registerSessionBackend: vi.fn(), + transferSessionBackend: vi.fn(), + unregisterSessionBackend: vi.fn(), + getSessionBackend: vi.fn(), +})); + +vi.mock("@/shared/api/acp", () => ({ + acpCreateSession: (...args: unknown[]) => mocks.acpCreateSession(...args), + acpListSessionsPage: (...args: unknown[]) => + mocks.acpListSessionsPage(...args), +})); + +vi.mock("@/features/providers/api/credentials", () => ({ + checkAllProviderStatus: vi.fn().mockResolvedValue([]), +})); + +vi.mock("@/shared/profile/buildProfile", () => ({ + getBuildFeatureState: () => ({ + authGate: false, + agentTools: true, + automations: true, + builderbot: true, + byoKeyProviders: false, + telemetry: true, + voiceDictation: true, + managedConnections: true, + securityMl: true, + updater: true, + }), +})); + +vi.mock("@/shared/api/acpApi", () => ({ + archiveSession: vi.fn().mockResolvedValue(undefined), + unarchiveSession: vi.fn().mockResolvedValue(undefined), + renameSession: vi.fn().mockResolvedValue(undefined), + updateSessionProject: vi.fn().mockResolvedValue(undefined), +})); + +vi.mock("@/features/chat/lib/sessionWindowCommands", () => ({ + releaseSession: vi.fn().mockResolvedValue(undefined), +})); + +vi.mock("@/shared/api/acpSessionBackends", () => ({ + registerSessionBackend: (...args: unknown[]) => + mocks.registerSessionBackend(...args), + transferSessionBackend: (...args: unknown[]) => + mocks.transferSessionBackend(...args), + unregisterSessionBackend: (...args: unknown[]) => + mocks.unregisterSessionBackend(...args), + getSessionBackend: (...args: unknown[]) => mocks.getSessionBackend(...args), +})); + +function resetStore() { + useChatSessionStore.setState({ + sessions: [], + activeSessionId: null, + isLoading: false, + isLoadingMoreSessions: false, + hasHydratedSessions: false, + sessionPageCursor: null, + hasMoreSessions: false, + isRightRailOpen: false, + activeWorkspaceBySession: {}, + archiveMutationBySessionId: {}, + }); +} + +describe("chatSessionStore remote sessions", () => { + beforeEach(() => { + window.localStorage.clear(); + resetStore(); + vi.clearAllMocks(); + }); + + it("carries remoteHost from draft creation through promotion", () => { + const store = useChatSessionStore.getState(); + const draft = store.createDraftSession({ + workingDir: "/remote/home/damien/project", + remoteHost: "devbox", + }); + + expect(draft.remoteHost).toBe("devbox"); + expect(mocks.registerSessionBackend).toHaveBeenCalledWith( + draft.id, + "ssh:devbox", + ); + + store.promoteDraftSession(draft.id, "backend-1"); + + expect(mocks.transferSessionBackend).toHaveBeenCalledWith( + draft.id, + "backend-1", + ); + const promoted = useChatSessionStore.getState().getSession("backend-1"); + expect(promoted?.remoteHost).toBe("devbox"); + expect(promoted?.workingDir).toBe("/remote/home/damien/project"); + }); + + it("does not register a backend for local drafts", () => { + const store = useChatSessionStore.getState(); + const draft = store.createDraftSession({ workingDir: "/local/project" }); + + expect(draft.remoteHost).toBeUndefined(); + expect(mocks.registerSessionBackend).not.toHaveBeenCalled(); + + store.promoteDraftSession(draft.id, "backend-local"); + expect(window.localStorage.getItem(REMOTE_SESSIONS_STORAGE_KEY)).toBeNull(); + }); + + it("creates remote sessions on the SSH backend and persists a record", async () => { + mocks.acpCreateSession.mockResolvedValue({ + sessionId: "remote-1", + configOptionsSnapshot: null, + }); + + const session = await useChatSessionStore.getState().createSession({ + workingDir: "/remote/dir", + remoteHost: "devbox", + }); + + expect(session.remoteHost).toBe("devbox"); + // Backend registration happens inside acpCreateSession (the newSession + // call registers the created id); the store's job is to thread the host. + expect(mocks.acpCreateSession).toHaveBeenCalledWith( + expect.any(String), + "/remote/dir", + expect.objectContaining({ remoteHost: "devbox" }), + ); + expect(readRemoteSessionRecords()).toEqual([ + expect.objectContaining({ + sessionId: "remote-1", + host: "devbox", + workingDir: "/remote/dir", + }), + ]); + }); + + it("persists the promoted record under the backend id", () => { + const store = useChatSessionStore.getState(); + const draft = store.createDraftSession({ + workingDir: "/remote/dir", + remoteHost: "devbox", + }); + + store.promoteDraftSession(draft.id, "backend-2", { title: "Remote work" }); + + const records = readRemoteSessionRecords(); + expect(records).toEqual([ + expect.objectContaining({ + sessionId: "backend-2", + host: "devbox", + title: "Remote work", + }), + ]); + }); + + it("updates the record on rename and removes it on delete", () => { + const store = useChatSessionStore.getState(); + const draft = store.createDraftSession({ + workingDir: "/remote/dir", + remoteHost: "devbox", + }); + store.promoteDraftSession(draft.id, "backend-3"); + + useChatSessionStore + .getState() + .patchSession("backend-3", { title: "Renamed" }); + expect(readRemoteSessionRecords()).toEqual([ + expect.objectContaining({ sessionId: "backend-3", title: "Renamed" }), + ]); + + useChatSessionStore.getState().removeSession("backend-3"); + expect(readRemoteSessionRecords()).toEqual([]); + }); + + it("records archivedAt after a successful archive", async () => { + const store = useChatSessionStore.getState(); + const draft = store.createDraftSession({ + workingDir: "/remote/dir", + remoteHost: "devbox", + }); + store.promoteDraftSession(draft.id, "backend-4"); + + await useChatSessionStore.getState().archiveSession("backend-4"); + + expect(readRemoteSessionRecords()).toEqual([ + expect.objectContaining({ + sessionId: "backend-4", + archivedAt: expect.any(String), + }), + ]); + + await useChatSessionStore.getState().unarchiveSession("backend-4"); + expect(readRemoteSessionRecords()[0]?.archivedAt).toBeUndefined(); + }); +}); diff --git a/src/features/chat/stores/__tests__/remoteSessionPersistence.test.ts b/src/features/chat/stores/__tests__/remoteSessionPersistence.test.ts new file mode 100644 index 000000000..e98ea6679 --- /dev/null +++ b/src/features/chat/stores/__tests__/remoteSessionPersistence.test.ts @@ -0,0 +1,186 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { + REMOTE_SESSIONS_STORAGE_KEY, + persistRemoteSessionRecord, + readRemoteSessionRecords, + rehydrateRemoteSessions, + removeRemoteSessionRecord, + type RemoteSessionRecord, +} from "../remoteSessionPersistence"; +import { useChatSessionStore } from "../chatSessionStore"; + +const mocks = vi.hoisted(() => ({ + registerSessionBackend: vi.fn(), + transferSessionBackend: vi.fn(), + unregisterSessionBackend: vi.fn(), + getSessionBackend: vi.fn(), +})); + +vi.mock("@/shared/api/acp", () => ({ + acpCreateSession: vi.fn(), + acpListSessionsPage: vi.fn(), +})); + +vi.mock("@/features/providers/api/credentials", () => ({ + checkAllProviderStatus: vi.fn().mockResolvedValue([]), +})); + +vi.mock("@/shared/api/acpApi", () => ({ + archiveSession: vi.fn().mockResolvedValue(undefined), + unarchiveSession: vi.fn().mockResolvedValue(undefined), + renameSession: vi.fn().mockResolvedValue(undefined), + updateSessionProject: vi.fn().mockResolvedValue(undefined), +})); + +vi.mock("@/features/chat/lib/sessionWindowCommands", () => ({ + releaseSession: vi.fn().mockResolvedValue(undefined), +})); + +vi.mock("@/shared/api/acpSessionBackends", () => ({ + registerSessionBackend: (...args: unknown[]) => + mocks.registerSessionBackend(...args), + transferSessionBackend: (...args: unknown[]) => + mocks.transferSessionBackend(...args), + unregisterSessionBackend: (...args: unknown[]) => + mocks.unregisterSessionBackend(...args), + getSessionBackend: (...args: unknown[]) => mocks.getSessionBackend(...args), +})); + +function makeRecord( + overrides: Partial = {}, +): RemoteSessionRecord { + return { + sessionId: "remote-1", + host: "devbox", + title: "Remote chat", + workingDir: "/remote/home/damien/project", + updatedAt: "2026-08-01T00:00:00.000Z", + ...overrides, + }; +} + +describe("remoteSessionPersistence", () => { + beforeEach(() => { + window.localStorage.clear(); + vi.clearAllMocks(); + useChatSessionStore.setState({ + sessions: [], + activeSessionId: null, + activeWorkspaceBySession: {}, + archiveMutationBySessionId: {}, + }); + }); + + it("round-trips records through localStorage", () => { + persistRemoteSessionRecord(makeRecord()); + persistRemoteSessionRecord( + makeRecord({ sessionId: "remote-2", host: "otherbox" }), + ); + + expect(readRemoteSessionRecords()).toEqual([ + makeRecord(), + makeRecord({ sessionId: "remote-2", host: "otherbox" }), + ]); + + removeRemoteSessionRecord("remote-1"); + expect(readRemoteSessionRecords()).toEqual([ + makeRecord({ sessionId: "remote-2", host: "otherbox" }), + ]); + + removeRemoteSessionRecord("remote-2"); + expect(readRemoteSessionRecords()).toEqual([]); + expect(window.localStorage.getItem(REMOTE_SESSIONS_STORAGE_KEY)).toBeNull(); + }); + + it("upserts by session id", () => { + persistRemoteSessionRecord(makeRecord()); + persistRemoteSessionRecord(makeRecord({ title: "Renamed" })); + + expect(readRemoteSessionRecords()).toEqual([ + makeRecord({ title: "Renamed" }), + ]); + }); + + it("drops malformed stored entries on read", () => { + window.localStorage.setItem( + REMOTE_SESSIONS_STORAGE_KEY, + JSON.stringify({ + "remote-1": { host: "devbox", title: "ok" }, + "remote-bad": { title: "missing host" }, + "remote-worse": "not an object", + }), + ); + + expect(readRemoteSessionRecords()).toEqual([ + expect.objectContaining({ sessionId: "remote-1", host: "devbox" }), + ]); + }); + + it("returns no records for corrupted storage", () => { + window.localStorage.setItem(REMOTE_SESSIONS_STORAGE_KEY, "{not json"); + expect(readRemoteSessionRecords()).toEqual([]); + }); + + describe("rehydrateRemoteSessions", () => { + it("registers backends and seeds sidebar placeholders", async () => { + persistRemoteSessionRecord(makeRecord()); + + await rehydrateRemoteSessions(); + + expect(mocks.registerSessionBackend).toHaveBeenCalledWith( + "remote-1", + "ssh:devbox", + ); + const session = useChatSessionStore.getState().getSession("remote-1"); + expect(session).toMatchObject({ + id: "remote-1", + title: "Remote chat", + remoteHost: "devbox", + workingDir: "/remote/home/damien/project", + clientSessionId: "remote-1", + }); + // Placeholder must be visible in the sidebar before the remote + // transcript loads. + expect(session?.messageCount).toBeGreaterThan(0); + }); + + it("skips archived records", async () => { + persistRemoteSessionRecord( + makeRecord({ archivedAt: "2026-08-02T00:00:00.000Z" }), + ); + + await rehydrateRemoteSessions(); + + expect(mocks.registerSessionBackend).not.toHaveBeenCalled(); + expect( + useChatSessionStore.getState().getSession("remote-1"), + ).toBeUndefined(); + }); + + it("does not clobber a session already in the store", async () => { + persistRemoteSessionRecord(makeRecord({ title: "Stale title" })); + useChatSessionStore.setState({ + sessions: [ + { + id: "remote-1", + title: "Fresh title", + remoteHost: "devbox", + createdAt: "2026-08-03T00:00:00.000Z", + updatedAt: "2026-08-03T00:00:00.000Z", + messageCount: 4, + }, + ], + }); + + await rehydrateRemoteSessions(); + + expect(mocks.registerSessionBackend).toHaveBeenCalledWith( + "remote-1", + "ssh:devbox", + ); + expect( + useChatSessionStore.getState().getSession("remote-1"), + ).toMatchObject({ title: "Fresh title", messageCount: 4 }); + }); + }); +}); diff --git a/src/features/chat/stores/chatSessionStore.ts b/src/features/chat/stores/chatSessionStore.ts index 811c297bd..c80e44089 100644 --- a/src/features/chat/stores/chatSessionStore.ts +++ b/src/features/chat/stores/chatSessionStore.ts @@ -33,6 +33,15 @@ import { persistChatWorkspaceMetadata, removePersistedChatWorkspaceMetadata, } from "./workspaceAttachmentPersistence"; +import { + persistRemoteSessionRecord, + removeRemoteSessionRecord, +} from "./remoteSessionPersistence"; +import { backendIdForSession } from "@/shared/api/acpBackendId"; +import { + registerSessionBackend, + transferSessionBackend, +} from "@/shared/api/acpSessionBackends"; import { materializeSessionExecutionModel, normalizeSessionExecutionTarget, @@ -65,6 +74,8 @@ export interface ChatSession { personaId?: string; reasoningEffort?: ChatSessionReasoningEffortConfig; workingDir?: string | null; + /** SSH host whose backend owns this session; unset means local. */ + remoteHost?: string; workspaceAttachments?: WorkspaceAttachment[]; activeWorkspaceId?: string | null; createdAt: string; @@ -179,6 +190,7 @@ interface CreateSessionOpts { executionTarget?: SessionExecutionTarget; personaId?: string; workingDir?: string; + remoteHost?: string; workspaceAttachments?: WorkspaceAttachment[]; deferProviderSetup?: boolean; } @@ -480,6 +492,18 @@ function releaseWindowedSession(sessionId: string): void { ); } +function persistRemoteSessionRecordForSession(session: ChatSession): void { + if (!session.remoteHost) return; + persistRemoteSessionRecord({ + sessionId: session.id, + host: session.remoteHost, + title: session.title, + workingDir: session.workingDir ?? "", + updatedAt: session.updatedAt, + ...(session.archivedAt ? { archivedAt: session.archivedAt } : {}), + }); +} + function persistWorkspaceMetadataForSession(session: ChatSession): void { persistChatWorkspaceMetadata(session.id, { workspaceAttachments: session.workspaceAttachments ?? [], @@ -521,6 +545,9 @@ export const useChatSessionStore = create((set, get) => ({ modelId: requestedModelId, projectId: opts.projectId, deferProviderSetup: opts.deferProviderSetup ?? requestedModelId == null, + // Creating on the remote backend also registers the session's backend, + // so every later per-session call routes to the same host. + remoteHost: opts.remoteHost, }, ); logReasoningEffortInfo("createSession acp resolved", { @@ -545,6 +572,7 @@ export const useChatSessionStore = create((set, get) => ({ personaId: opts.personaId, reasoningEffort: configOptionsSnapshot?.reasoningEffort ?? undefined, workingDir: opts.workingDir, + ...(opts.remoteHost ? { remoteHost: opts.remoteHost } : {}), workspaceAttachments: opts.workspaceAttachments, createdAt: now, updatedAt: now, @@ -565,6 +593,7 @@ export const useChatSessionStore = create((set, get) => ({ hasReasoningEffort: Boolean(chatSession.reasoningEffort), }); persistWorkspaceMetadataForSession(chatSession); + persistRemoteSessionRecordForSession(chatSession); return chatSession; }, @@ -585,6 +614,7 @@ export const useChatSessionStore = create((set, get) => ({ executionTargetSource: "ui", personaId: opts.personaId, workingDir: opts.workingDir, + ...(opts.remoteHost ? { remoteHost: opts.remoteHost } : {}), workspaceAttachments: opts.workspaceAttachments, createdAt: now, updatedAt: now, @@ -599,6 +629,11 @@ export const useChatSessionStore = create((set, get) => ({ targetAgentDraftState: null, targetAgentDraftSaved: false, }); + if (opts.remoteHost) { + // Register the draft id too, so calls issued before promotion (e.g. the + // creation request itself) route to the remote backend. + registerSessionBackend(id, backendIdForSession(opts)); + } set((state) => ({ sessions: [chatSession, ...state.sessions] })); return chatSession; }, @@ -712,9 +747,19 @@ export const useChatSessionStore = create((set, get) => ({ : remainingWorkspaces, }; }); - if (promotedForPersistence) { + // Typed alias: TS does not track the assignment inside the `set` callback, + // so property access on the captured `let` would be on `never`. + const promoted = promotedForPersistence as ChatSession | null; + if (promoted) { + // No-op for local sessions: the draft id is only registered when the + // draft was created with a remote host. + transferSessionBackend(draftSessionId, backendSessionId); migratePersistedChatWorkspaceMetadata(draftSessionId, backendSessionId); - persistWorkspaceMetadataForSession(promotedForPersistence); + persistWorkspaceMetadataForSession(promoted); + if (promoted.remoteHost) { + removeRemoteSessionRecord(draftSessionId); + persistRemoteSessionRecordForSession(promoted); + } } }, @@ -824,6 +869,7 @@ export const useChatSessionStore = create((set, get) => ({ } const includesReasoningEffort = patchIncludesReasoningEffort(patch); let sessionForWorkspacePersistence: ChatSession | null = null; + let sessionForRemotePersistence: ChatSession | null = null; set((state) => { const existing = state.sessions.find((session) => session.id === id); if (!existing) { @@ -900,6 +946,15 @@ export const useChatSessionStore = create((set, get) => ({ ) { sessionForWorkspacePersistence = merged; } + if ( + merged.remoteHost && + (merged.title !== existing.title || + merged.workingDir !== existing.workingDir || + merged.archivedAt !== existing.archivedAt || + merged.updatedAt !== existing.updatedAt) + ) { + sessionForRemotePersistence = merged; + } return { sessions: state.sessions.map((session) => session.id === id ? merged : session, @@ -909,6 +964,9 @@ export const useChatSessionStore = create((set, get) => ({ if (sessionForWorkspacePersistence) { persistWorkspaceMetadataForSession(sessionForWorkspacePersistence); } + if (sessionForRemotePersistence) { + persistRemoteSessionRecordForSession(sessionForRemotePersistence); + } }, // Update a session's sidebar subtitle in place from raw message text, mirroring @@ -972,6 +1030,7 @@ export const useChatSessionStore = create((set, get) => ({ }; }); removePersistedChatWorkspaceMetadata(id); + removeRemoteSessionRecord(id); useSecurityConfirmationStore.getState().cancelAll(id); releaseWindowedSession(id); }, @@ -1012,6 +1071,10 @@ export const useChatSessionStore = create((set, get) => ({ await acpArchiveSession(session.id); set((state) => recordArchiveMutationSuccess(state, id, mutation)); settleArchiveMutationAndCancelIfArchived(get(), id, operationId); + const archived = get().getSession(id); + if (archived?.remoteHost) { + persistRemoteSessionRecordForSession(archived); + } } catch (error) { // Roll back only the archive flag; navigation/window cleanup is owned by // AppShell's archive transaction. @@ -1052,6 +1115,10 @@ export const useChatSessionStore = create((set, get) => ({ await acpUnarchiveSession(session.id); set((state) => recordArchiveMutationSuccess(state, id, mutation)); settleArchiveMutationAndCancelIfArchived(get(), id, operationId); + const unarchived = get().getSession(id); + if (unarchived?.remoteHost) { + persistRemoteSessionRecordForSession(unarchived); + } } catch (error) { set((state) => rollbackFailedArchiveMutation(state, id, operationId)); settleArchiveMutationAndCancelIfArchived(get(), id, operationId); diff --git a/src/features/chat/stores/remoteSessionPersistence.ts b/src/features/chat/stores/remoteSessionPersistence.ts new file mode 100644 index 000000000..739e18b9b --- /dev/null +++ b/src/features/chat/stores/remoteSessionPersistence.ts @@ -0,0 +1,160 @@ +import { sshBackendId } from "@/shared/api/acpBackendId"; +import { registerSessionBackend } from "@/shared/api/acpSessionBackends"; + +export const REMOTE_SESSIONS_STORAGE_KEY = "goose:remote-sessions:v1"; + +/** + * Locally persisted identity of a session whose backend runs on a remote SSH + * host. The local `goose serve` session list never returns these sessions, so + * this record is what lets the sidebar show them (and route their calls to + * the right backend) across app restarts. + */ +export interface RemoteSessionRecord { + sessionId: string; + host: string; + title: string; + workingDir: string; + updatedAt: string; + archivedAt?: string; +} + +type RemoteSessionRecordsBySessionId = Record; + +function trimmedString(value: unknown): string | null { + return typeof value === "string" && value.trim() ? value.trim() : null; +} + +function normalizeRemoteSessionRecord( + sessionId: string, + value: unknown, +): RemoteSessionRecord | null { + if (!sessionId.trim() || !value || typeof value !== "object") { + return null; + } + + const raw = value as Partial; + const host = trimmedString(raw.host); + if (!host) { + return null; + } + + const record: RemoteSessionRecord = { + sessionId, + host, + title: typeof raw.title === "string" ? raw.title : "", + workingDir: trimmedString(raw.workingDir) ?? "", + updatedAt: trimmedString(raw.updatedAt) ?? new Date(0).toISOString(), + }; + const archivedAt = trimmedString(raw.archivedAt); + if (archivedAt) { + record.archivedAt = archivedAt; + } + return record; +} + +function readAllRemoteSessionRecords(): RemoteSessionRecordsBySessionId { + if (typeof window === "undefined") return {}; + + try { + const stored = window.localStorage.getItem(REMOTE_SESSIONS_STORAGE_KEY); + if (!stored) return {}; + + const parsed = JSON.parse(stored); + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { + return {}; + } + + const bySessionId: RemoteSessionRecordsBySessionId = {}; + for (const [sessionId, value] of Object.entries(parsed)) { + const normalized = normalizeRemoteSessionRecord(sessionId, value); + if (normalized) { + bySessionId[sessionId] = normalized; + } + } + return bySessionId; + } catch { + return {}; + } +} + +function writeAllRemoteSessionRecords( + bySessionId: RemoteSessionRecordsBySessionId, +): void { + if (typeof window === "undefined") return; + + try { + if (Object.keys(bySessionId).length === 0) { + window.localStorage.removeItem(REMOTE_SESSIONS_STORAGE_KEY); + return; + } + + window.localStorage.setItem( + REMOTE_SESSIONS_STORAGE_KEY, + JSON.stringify(bySessionId), + ); + } catch { + // localStorage may be unavailable + } +} + +export function persistRemoteSessionRecord(record: RemoteSessionRecord): void { + const normalized = normalizeRemoteSessionRecord(record.sessionId, record); + if (!normalized) return; + + const bySessionId = readAllRemoteSessionRecords(); + bySessionId[normalized.sessionId] = normalized; + writeAllRemoteSessionRecords(bySessionId); +} + +export function removeRemoteSessionRecord(sessionId: string): void { + const bySessionId = readAllRemoteSessionRecords(); + if (!(sessionId in bySessionId)) return; + + delete bySessionId[sessionId]; + writeAllRemoteSessionRecords(bySessionId); +} + +export function readRemoteSessionRecords(): RemoteSessionRecord[] { + return Object.values(readAllRemoteSessionRecords()); +} + +/** + * Restores remote sessions after an app restart: re-registers each + * non-archived record's session→backend routing and seeds a placeholder + * `ChatSession` so the sidebar can render it before the remote backend is + * contacted. Activation reconciles the placeholder (title, counts, replay) + * through the normal session-load path. + * + * The chat session store is imported lazily to keep this module free of an + * import cycle with `chatSessionStore`, which calls the persistence writers + * above. + */ +export async function rehydrateRemoteSessions(): Promise { + const records = readRemoteSessionRecords().filter( + (record) => !record.archivedAt, + ); + if (records.length === 0) return; + + const { useChatSessionStore } = await import( + "@/features/chat/stores/chatSessionStore" + ); + const store = useChatSessionStore.getState(); + for (const record of records) { + registerSessionBackend(record.sessionId, sshBackendId(record.host)); + if (store.getSession(record.sessionId)) { + continue; + } + store.addSession({ + id: record.sessionId, + title: record.title || record.host, + remoteHost: record.host, + workingDir: record.workingDir || undefined, + clientSessionId: record.sessionId, + createdAt: record.updatedAt, + updatedAt: record.updatedAt, + // Placeholder: the remote message count is unknown until the session + // loads, and 0 would hide the row from the sidebar entirely. + messageCount: 1, + }); + } +} diff --git a/src/features/chat/types.ts b/src/features/chat/types.ts index 290b6e925..4313411a1 100644 --- a/src/features/chat/types.ts +++ b/src/features/chat/types.ts @@ -171,6 +171,16 @@ export interface ChatInputProjectPicker { }) => void; } +export interface ChatInputRemoteHostPicker { + enabled?: boolean; + selectedHost?: string | null; + /** SSH host aliases to offer; defaults to the remote-host store's list. */ + hosts?: string[]; + onHostChange?: (host: string | null) => void; + selectedDir?: string | null; + onDirChange?: (dir: string | null) => void; +} + export interface ChatInputReasoningEffort { config?: ChatSessionReasoningEffortConfig; onChange?: (value: string) => void; @@ -220,6 +230,7 @@ export interface ChatInputProps { agentModelPicker?: ChatInputAgentModelPicker; reasoningEffort?: ChatInputReasoningEffort; projectPicker?: ChatInputProjectPicker; + remoteHostPicker?: ChatInputRemoteHostPicker; contextUsage?: ChatInputContextUsage; controls?: ChatInputControls; /** Called when ↑ should recall the last sent user message. */ diff --git a/src/features/chat/ui/ArtifactViewer.tsx b/src/features/chat/ui/ArtifactViewer.tsx index 5f4012bf9..60cf322a2 100644 --- a/src/features/chat/ui/ArtifactViewer.tsx +++ b/src/features/chat/ui/ArtifactViewer.tsx @@ -50,6 +50,13 @@ const revealLabelKey = interface ArtifactViewerProps { artifact: OpenArtifact; onClose: () => void; + /** + * SSH host carrying the session's files, when the session is remote. The + * viewer then renders a compact placeholder (file name + host chip) instead + * of statting/reading/polling the path on the local filesystem, and hides + * the local-only hand-off actions (open in editor, reveal). + */ + remoteHost?: string | null; } type MarkdownView = "preview" | "raw"; @@ -129,7 +136,11 @@ function sameFingerprint( ); } -export function ArtifactViewer({ artifact, onClose }: ArtifactViewerProps) { +export function ArtifactViewer({ + artifact, + onClose, + remoteHost = null, +}: ArtifactViewerProps) { const { t } = useTranslation(["chat", "common"]); const { openResolvedPath } = useArtifactActionsContext(); const viewMode = useMemo( @@ -221,6 +232,8 @@ export function ArtifactViewer({ artifact, onClose }: ArtifactViewerProps) { // the same path retain last-good content while loading so tool-triggered and // manual refreshes do not flash a spinner or reset the scroll container. useEffect(() => { + // Remote artifacts: no local stat/read — the placeholder body is the view. + if (remoteHost) return; let cancelled = false; const refreshGeneration = ++forcedRefreshGenerationRef.current; // A forced ACP/manual refresh supersedes a poll already in flight. Polls @@ -372,6 +385,7 @@ export function ArtifactViewer({ artifact, onClose }: ArtifactViewerProps) { contentReadRevision, flagDiverged, recordDivergenceStrike, + remoteHost, retryRevision, updateDiskStatus, updateTextState, @@ -383,6 +397,8 @@ export function ArtifactViewer({ artifact, onClose }: ArtifactViewerProps) { // slowing down when the app is not focused and checking immediately when it // returns to the foreground. useEffect(() => { + // No freshness polling for remote artifacts — the file is not local. + if (remoteHost) return; let cancelled = false; let checkInFlight = false; let pollTimerId: number | null = null; @@ -523,6 +539,7 @@ export function ArtifactViewer({ artifact, onClose }: ArtifactViewerProps) { artifact.resolvedPath, artifact.revision, recordDivergenceStrike, + remoteHost, updateDiskStatus, updateTextState, viewMode, @@ -542,7 +559,7 @@ export function ArtifactViewer({ artifact, onClose }: ArtifactViewerProps) { - {viewMode !== "image" ? ( + {remoteHost == null && viewMode !== "image" ? ( - - - - - { - void openResolvedPath(artifact.resolvedPath).catch(() => {}); - }} - > - - {t("artifactViewer.openExternally")} - - { - void revealInFileManager(artifact.resolvedPath).catch( - () => {}, - ); - }} - > - - {t(revealLabelKey)} - - - + {remoteHost == null ? ( + + + + + + { + void openResolvedPath(artifact.resolvedPath).catch( + () => {}, + ); + }} + > + + {t("artifactViewer.openExternally")} + + { + void revealInFileManager(artifact.resolvedPath).catch( + () => {}, + ); + }} + > + + {t(revealLabelKey)} + + + + ) : null} - {diskStatus === "diverged" ? ( + {remoteHost == null && diskStatus === "diverged" ? (
- {viewMode === "image" ? ( + {remoteHost != null ? ( + + ) : viewMode === "image" ? ( +
+
+

+ {t("remoteSessionGuards.viewerUnavailable", { host })} +

+
+ ); +} + function artifactImageSrc(path: string, revision: number): string { const assetSrc = convertFileSrc(path, "asset"); return revision > 0 ? `${assetSrc}?rev=${revision}` : assetSrc; diff --git a/src/features/chat/ui/ArtifactViewerPanel.tsx b/src/features/chat/ui/ArtifactViewerPanel.tsx index 8cd84a960..ed4b176ee 100644 --- a/src/features/chat/ui/ArtifactViewerPanel.tsx +++ b/src/features/chat/ui/ArtifactViewerPanel.tsx @@ -14,6 +14,8 @@ import { useTranslation } from "react-i18next"; import { usePersistedState } from "@/shared/hooks/usePersistedState"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/shared/ui/tooltip"; import { ArtifactViewer } from "./ArtifactViewer"; +import { isRemoteSession } from "../lib/remoteSession"; +import { useChatSessionStore } from "../stores/chatSessionStore"; import { useArtifactViewerStore, useOpenArtifact, @@ -94,6 +96,14 @@ function ViewerPanel({ }) { const { t } = useTranslation("chat"); const close = useArtifactViewerStore((s) => s.close); + // A remote session's artifact paths live on its SSH host; the viewer then + // renders a placeholder instead of statting/reading the local filesystem. + const remoteHost = useChatSessionStore((s) => { + const session = s.sessions.find((candidate) => candidate.id === sessionId); + return isRemoteSession(session) + ? (session?.remoteHost?.trim() ?? null) + : null; + }); const reduceMotion = useReducedMotion(); // False while AnimatePresence is exit-animating this panel. The min-width // floor must lift during enter/exit so the width can actually reach 0; @@ -185,7 +195,11 @@ function ViewerPanel({ {t("artifactViewer.resize")} - close(sessionId)} /> + close(sessionId)} + /> ); diff --git a/src/features/chat/ui/ChatInput.tsx b/src/features/chat/ui/ChatInput.tsx index b9489bf39..752d378c4 100644 --- a/src/features/chat/ui/ChatInput.tsx +++ b/src/features/chat/ui/ChatInput.tsx @@ -290,6 +290,7 @@ export function ChatInput({ agentModelPicker, reasoningEffort, projectPicker, + remoteHostPicker, contextUsage, controls, onRecallLastUserMessage, @@ -1979,6 +1980,7 @@ export function ChatInput({ onProjectChange, onCreateProject, }} + remoteHostPicker={remoteHostPicker} reasoningEffort={reasoningEffort} onRequestComposerFocus={() => textareaRef.current?.focus()} contextUsage={{ diff --git a/src/features/chat/ui/ChatInputToolbar.tsx b/src/features/chat/ui/ChatInputToolbar.tsx index 3050e94f7..dd5c50939 100644 --- a/src/features/chat/ui/ChatInputToolbar.tsx +++ b/src/features/chat/ui/ChatInputToolbar.tsx @@ -36,12 +36,15 @@ import { useProviderCatalogStore } from "@/features/providers/stores/providerCat import { supportsContextCompactionControls } from "../lib/autoCompact"; import { requestOpenSettings } from "@/features/settings/lib/settingsEvents"; import { ProjectInputSelector } from "./ProjectInputSelector"; +import { RemoteDirectoryPicker } from "./RemoteDirectoryPicker"; +import { RemoteHostSelector } from "./RemoteHostSelector"; import type { AgentPickerOption, ChatInputAgentModelPicker, ChatInputContextUsage, ChatInputProjectPicker, ChatInputReasoningEffort, + ChatInputRemoteHostPicker, ChatInputVoiceConversation, } from "../types"; @@ -64,12 +67,19 @@ interface ChatInputToolbarComposerActions { voiceConversation?: ChatInputVoiceConversation; } -type OpenToolbarMenu = "attachments" | "model" | "project" | "context"; +type OpenToolbarMenu = + | "attachments" + | "model" + | "project" + | "remoteHost" + | "remoteDir" + | "context"; interface ChatInputToolbarProps { agentModelPicker: ChatInputAgentModelPicker & { enabled?: boolean }; reasoningEffort?: ChatInputReasoningEffort; projectPicker: ChatInputProjectPicker; + remoteHostPicker?: ChatInputRemoteHostPicker; contextUsage: ChatInputContextUsage; composerActions: ChatInputToolbarComposerActions; onRequestComposerFocus?: () => void; @@ -80,6 +90,7 @@ export function ChatInputToolbar({ agentModelPicker, reasoningEffort, projectPicker, + remoteHostPicker, contextUsage, composerActions, onRequestComposerFocus, @@ -117,6 +128,10 @@ export function ChatInputToolbar({ onProjectChange, onCreateProject, } = projectPicker; + const remoteHostPickerEnabled = remoteHostPicker?.enabled === true; + const selectedRemoteHost = remoteHostPickerEnabled + ? (remoteHostPicker?.selectedHost ?? null) + : null; const { contextTokens = 0, contextLimit = 0, @@ -321,7 +336,9 @@ export function ChatInputToolbar({ onAttachFolders?.()} - disabled={disabled} + // Local folder attachments are meaningless for a session that + // runs its backend on a remote host. + disabled={disabled || Boolean(selectedRemoteHost)} > {t("toolbar.attachFolder")} @@ -366,6 +383,33 @@ export function ChatInputToolbar({ onRequestComposerFocus={onRequestComposerFocus} modal={false} triggerIconOnly={isCompact} + // Remote sessions are project-less in v1; local project folders + // do not exist on the SSH host. + disabled={Boolean(selectedRemoteHost)} + /> + ) : null} + + {remoteHostPickerEnabled ? ( + + ) : null} + + {remoteHostPickerEnabled && selectedRemoteHost ? ( + ) : null} diff --git a/src/features/chat/ui/ChatView.tsx b/src/features/chat/ui/ChatView.tsx index b1ffe2f9e..5a64cc7fc 100644 --- a/src/features/chat/ui/ChatView.tsx +++ b/src/features/chat/ui/ChatView.tsx @@ -37,6 +37,7 @@ import { import { useResizableAgentBuilderRail } from "../hooks/useResizableAgentBuilderRail"; import { useWorkspaceRepository } from "@/features/workspaces/workspaceRepository"; import { useChangeSessionFolder } from "../hooks/useChangeSessionFolder"; +import { isRemoteSession } from "../lib/remoteSession"; import { useChatSessionStore, type ChatSession, @@ -200,6 +201,11 @@ export function ChatView({ const workspaceRepository = useWorkspaceRepository(); const effectiveSession = controller.session ?? activeSession ?? null; const isReadOnly = Boolean(readOnlyStatus); + // A remote session's cwd and artifact paths live on its SSH host: the + // in-chat terminal (a local PTY), local folder pickers, and local file + // auto-open would all act on the wrong machine, so their affordances are + // withheld below (v1 — no ssh terminals or remote file loads). + const sessionIsRemote = isRemoteSession(effectiveSession); // While the viewer panel is open it occupies row width much like the // sidebar occludes the viewport: include its floor allowance in the // compact-mode query so the right rail only docks when rail + viewer + @@ -412,8 +418,14 @@ export function ChatView({ useConfiguredTerminalFallback && terminalFallbackCwd ? terminalFallbackCwd : effectiveSession?.workingDir; - const terminalCwd = - terminalWorkspacePath ?? sessionTerminalCwd ?? projectTerminalCwd ?? null; + // Never hand the terminal a remote session's cwd: the PTY spawns locally, + // so it would either fail or land in an unrelated local directory. + const terminalCwd = sessionIsRemote + ? null + : (terminalWorkspacePath ?? + sessionTerminalCwd ?? + projectTerminalCwd ?? + null); // When a user action closes/collapses the terminal there is nowhere else // meaningful to land focus, so return it to the chat composer. @@ -532,6 +544,9 @@ export function ChatView({ ); useEffect(() => { + // No terminal shortcut for remote sessions — there is no local cwd to + // open a PTY in, and the affordance is hidden everywhere else too. + if (sessionIsRemote) return; const handleKeyDown = (event: KeyboardEvent) => { if (event.isComposing || event.keyCode === 229) return; if (eventMatchesShortcutCommand(event, "view.toggleTerminal")) { @@ -542,7 +557,7 @@ export function ChatView({ window.addEventListener("keydown", handleKeyDown); return () => window.removeEventListener("keydown", handleKeyDown); - }, [handleToggleTerminal]); + }, [handleToggleTerminal, sessionIsRemote]); const handleCloseRightRail = useCallback(() => { if (!effectiveSession?.id || !contextVisible) return; @@ -587,7 +602,9 @@ export function ChatView({ }, ); const onTimelineChangeFolder = - !isReadOnly && changeFolderSessionId ? handleChangeFolder : undefined; + !isReadOnly && !sessionIsRemote && changeFolderSessionId + ? handleChangeFolder + : undefined; const shouldShowLoadingIndicator = !controller.isLoadingHistory && @@ -769,7 +786,9 @@ export function ChatView({ return ( <> @@ -939,14 +958,16 @@ export function ChatView({ terminalOpen={terminal.activeWorkspaceHasTerminal} contextPanelLeftViewportOcclusionPx={chatRowOcclusionPx} onRequestCloseRightRail={handleCloseRightRail} - onToggleTerminal={handleToggleTerminal} + onToggleTerminal={sessionIsRemote ? undefined : handleToggleTerminal} terminalController={terminal} terminalDockPreview={terminalDockPreview} terminalRootRef={terminalRootRef} getTerminalDockTargetForPointer={getTerminalDockTargetForPointer} onTerminalDockPreviewChange={setTerminalDockPreview} onTerminalDockToRegion={handleTerminalDockToRegion} - onOpenTerminalAtPath={handleOpenTerminalAtPath} + onOpenTerminalAtPath={ + sessionIsRemote ? undefined : handleOpenTerminalAtPath + } /> diff --git a/src/features/chat/ui/ContextPanel.tsx b/src/features/chat/ui/ContextPanel.tsx index 855d0aac3..0939ff606 100644 --- a/src/features/chat/ui/ContextPanel.tsx +++ b/src/features/chat/ui/ContextPanel.tsx @@ -64,6 +64,8 @@ import { hasDeferredWorkspaceSend, releaseWorkspaceSendAfterUserEdit, } from "@/features/chat/lib/firstWorkspaceSend"; +import { isRemoteSession } from "@/features/chat/lib/remoteSession"; +import { RemoteWorkspaceSummary } from "./widgets/RemoteWorkspaceSummary"; import { useWorkspaceRepository } from "@/features/workspaces/workspaceRepository"; import { useChangeSessionFolder } from "@/features/chat/hooks/useChangeSessionFolder"; import { supersedePendingSessionWorkspaceActivation } from "@/features/chat/lib/sessionWorkspaceActivation"; @@ -166,12 +168,20 @@ export function ContextPanelWorktreeTracker({ ); const runtime = useChatStore((state) => state.sessionStateById[sessionId]); const setActiveWorkspace = useChatSessionStore((s) => s.setActiveWorkspace); + // Remote sessions keep their worktrees on the SSH host; the local git probe + // and worktree-adoption events below would act on the wrong filesystem. + const sessionIsRemote = useChatSessionStore((s) => + isRemoteSession(s.sessions.find((candidate) => candidate.id === sessionId)), + ); const gitTargetPath = activeContext?.path ?? sessionWorkingDir ?? projectDefaultWorkspaceRoot ?? null; - const { data: gitState } = useGitState(gitTargetPath, Boolean(gitTargetPath)); + const { data: gitState } = useGitState( + gitTargetPath, + Boolean(gitTargetPath) && !sessionIsRemote, + ); const previousWorktreeKeyRef = useRef(null); const pendingCreatedWorktreeRef = useRef(null); const chatRuntime = runtime ?? INITIAL_SESSION_CHAT_RUNTIME; @@ -192,7 +202,7 @@ export function ContextPanelWorktreeTracker({ const eventSourcePathKey = normalizedEventSourcePaths.join("\0"); useEffect(() => { - if (!isWorking) return; + if (sessionIsRemote || !isWorking) return; const eventSourcePathSet = new Set( eventSourcePathKey ? eventSourcePathKey.split("\0") : [], ); @@ -215,7 +225,7 @@ export function ContextPanelWorktreeTracker({ return () => { void unlisten.then((cleanup) => cleanup()); }; - }, [eventSourcePathKey, isWorking]); + }, [eventSourcePathKey, isWorking, sessionIsRemote]); useEffect(() => { const trackingKey = `${sessionId}:${normalizeComparablePath( @@ -298,6 +308,13 @@ export function ContextPanel({ const session = useChatSessionStore((s) => s.sessions.find((candidate) => candidate.id === sessionId), ); + // Everything below the remote guard talks to local-filesystem Tauri + // commands (git probes, changed files, folder pickers, file browsing). A + // remote session's paths live on its SSH host, so those queries stay off + // and the tabs degrade to compact remote summaries instead. + const remoteHost = isRemoteSession(session) + ? (session?.remoteHost?.trim() ?? null) + : null; const allSessions = useChatSessionStore((s) => s.sessions); const homeDir = useHomeDir(); const attachWorkspace = useChatSessionStore((s) => s.attachWorkspace); @@ -343,15 +360,17 @@ export function ContextPanel({ isFetching: fallbackGitIsFetching, } = useGitState( gitTargetPath, - isWorkspaceContextTab && !hasWorkspaceAttachments, + isWorkspaceContextTab && !hasWorkspaceAttachments && remoteHost === null, ); const workspaceGitRuntimes = useWorkspaceGitRuntimes( workspaceAttachments, - isMultiWorkspaceMode && (isWorkspaceContextTab || isAddWorkspaceOpen), + remoteHost === null && + isMultiWorkspaceMode && + (isWorkspaceContextTab || isAddWorkspaceOpen), ); const workspaceChangedFileRuntimes = useWorkspaceChangedFilesRuntimes( workspaceGitRuntimes, - isWorkspaceContextTab, + isWorkspaceContextTab && remoteHost === null, ); const renderedWorkspaceAttachments = useMemo( () => workspaceGitRuntimes.map((runtime) => runtime.workspace), @@ -375,7 +394,7 @@ export function ContextPanel({ isLoading: isFallbackFilesLoading, } = useChangedFiles( gitTargetPath, - isWorkspaceContextTab && !hasWorkspaceAttachments, + isWorkspaceContextTab && !hasWorkspaceAttachments && remoteHost === null, ); const shouldShowChanges = hasWorkspaceAttachments ? workspaceChangedFileRuntimes.length > 0 @@ -1072,7 +1091,12 @@ export function ContextPanel({
- {isMultiWorkspaceMode ? ( + {remoteHost !== null ? ( + + ) : isMultiWorkspaceMode ? ( <>
- {relatedPullRequestsEnabled && ( - toggleSection("pullRequests")} - /> - )} - {shouldShowChanges ? ( - hasWorkspaceAttachments ? ( - - ) : ( - toggleSection("changes")} - /> - ) - ) : isChangesProbeLoading ? ( - - ) : changesProbeError ? ( - ) : ( - + <> + {relatedPullRequestsEnabled && ( + toggleSection("pullRequests")} + /> + )} + {shouldShowChanges ? ( + hasWorkspaceAttachments ? ( + + ) : ( + toggleSection("changes")} + /> + ) + ) : isChangesProbeLoading ? ( + + ) : changesProbeError ? ( + + ) : ( + + )} + )}
- + {remoteHost !== null ? ( +

+ {t("remoteSessionGuards.filesUnavailable", { host: remoteHost })} +

+ ) : ( + + )}
); diff --git a/src/features/chat/ui/MarkdownImage.tsx b/src/features/chat/ui/MarkdownImage.tsx index 17ef9bb7d..a952cf5eb 100644 --- a/src/features/chat/ui/MarkdownImage.tsx +++ b/src/features/chat/ui/MarkdownImage.tsx @@ -1,5 +1,7 @@ import { convertFileSrc } from "@tauri-apps/api/core"; +import { ImageIcon } from "lucide-react"; import { type ComponentProps, memo, useEffect, useState } from "react"; +import { useTranslation } from "react-i18next"; import { useArtifactActionsContext } from "@/features/chat/hooks/ArtifactPolicyContext"; import { ClickableImage } from "./ClickableImage"; @@ -31,14 +33,16 @@ export const MarkdownImage = memo( node: _node, ...rest }: ComponentProps<"img"> & { node?: unknown }) => { - const { resolveMarkdownHref, pathExists } = useArtifactActionsContext(); + const { t } = useTranslation("chat"); + const { resolveMarkdownHref, pathExists, filesAreRemote, remoteHost } = + useArtifactActionsContext(); const [assetSrc, setAssetSrc] = useState(null); const rawSrc = typeof src === "string" ? src : ""; const isLocalCandidate = rawSrc.length > 0 && !isRemoteOrDataSrc(rawSrc); useEffect(() => { - if (!isLocalCandidate) { + if (!isLocalCandidate || filesAreRemote) { setAssetSrc(null); return; } @@ -74,7 +78,35 @@ export const MarkdownImage = memo( return () => { cancelled = true; }; - }, [isLocalCandidate, rawSrc, resolveMarkdownHref, pathExists]); + }, [ + filesAreRemote, + isLocalCandidate, + rawSrc, + resolveMarkdownHref, + pathExists, + ]); + + // A local-looking path in a remote session names a file on the SSH host: + // the asset: scheme would 404 against the local disk. Render a compact + // placeholder (file name + host chip) instead of a broken image. + if (isLocalCandidate && filesAreRemote) { + const resolvedPath = resolveMarkdownHref(rawSrc)?.resolvedPath ?? rawSrc; + const displayName = + resolvedPath.split("/").filter(Boolean).pop() ?? resolvedPath; + return ( + + + ); + } if (assetSrc) { return ; diff --git a/src/features/chat/ui/McpAppView.tsx b/src/features/chat/ui/McpAppView.tsx index 32283e2d3..30ee9256d 100644 --- a/src/features/chat/ui/McpAppView.tsx +++ b/src/features/chat/ui/McpAppView.tsx @@ -6,7 +6,7 @@ import { import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import packageJson from "../../../../package.json"; -import { getClient } from "@/shared/api/acpConnection"; +import { getClientForSession } from "@/shared/api/acpSessionBackends"; import { getGooseServeHostInfo } from "@/shared/api/gooseServeHost"; import { useTheme } from "@/shared/theme/ThemeProvider"; import { createVirtualLayoutStabilityAttributes } from "@/features/chat/transcript/measurement"; @@ -380,7 +380,7 @@ export function McpAppView({ setMcpActivity("nested-tool-request", true, { sourceId }); try { - const client = await getClient(); + const client = await getClientForSession(payload.sessionId); const response = await client.goose.GooseUnstableToolsCall({ sessionId: payload.sessionId, name: `${payload.tool.extensionName}__${name}`, @@ -409,7 +409,7 @@ export function McpAppView({ const sourceId = `resource:${uri}:${mcpRequestSourceCounterRef.current}`; setMcpActivity("host-request", true, { sourceId }); try { - const client = await getClient(); + const client = await getClientForSession(payload.sessionId); const response = await client.goose.GooseUnstableResourcesRead({ sessionId: payload.sessionId, uri, diff --git a/src/features/chat/ui/RemoteDirectoryPicker.tsx b/src/features/chat/ui/RemoteDirectoryPicker.tsx new file mode 100644 index 000000000..840a5fcce --- /dev/null +++ b/src/features/chat/ui/RemoteDirectoryPicker.tsx @@ -0,0 +1,233 @@ +import { useCallback, useEffect, useRef, useState } from "react"; +import { CornerLeftUp, Folder, FolderOpen, History } from "lucide-react"; +import { useTranslation } from "react-i18next"; +import { + isRemoteBackendError, + listRemoteDirs, + type RemoteBackendErrorLike, + type RemoteDirListing, +} from "@/shared/api/remoteHosts"; +import { useRemoteHostStore } from "@/features/remoteHosts/stores/remoteHostStore"; +import { Button } from "@/shared/ui/button"; +import { ComposerActionButton } from "@/shared/ui/composer-action-button"; +import { Input } from "@/shared/ui/input"; +import { Popover, PopoverContent, PopoverTrigger } from "@/shared/ui/popover"; +import { RowButton } from "@/shared/ui/row-button"; + +interface RemoteDirectoryPickerProps { + host: string; + selectedDir?: string | null; + onDirChange?: (dir: string | null) => void; + open?: boolean; + onOpenChange?: (open: boolean) => void; + disabled?: boolean; + triggerIconOnly?: boolean; +} + +function toErrorLike(error: unknown): RemoteBackendErrorLike { + if (isRemoteBackendError(error)) return error; + return { + kind: "internal", + message: error instanceof Error ? error.message : String(error), + }; +} + +/** Last path segment for the trigger label; "/" and "~" name themselves. */ +function directoryBasename(path: string): string { + const trimmed = path.replace(/\/+$/, ""); + if (!trimmed) return "/"; + const index = trimmed.lastIndexOf("/"); + return index >= 0 ? trimmed.slice(index + 1) || "/" : trimmed; +} + +/** Parent of a resolved absolute path, or null at the filesystem root. */ +function parentDirectory(path: string): string | null { + const trimmed = path.replace(/\/+$/, ""); + if (!trimmed.includes("/")) return null; + const parent = trimmed.slice(0, trimmed.lastIndexOf("/")); + return parent || "/"; +} + +export function RemoteDirectoryPicker({ + host, + selectedDir = null, + onDirChange, + open, + onOpenChange, + disabled, + triggerIconOnly = false, +}: RemoteDirectoryPickerProps) { + const { t } = useTranslation("chat"); + const recentDirs = useRemoteHostStore( + (state) => state.recentDirsByHost[host], + ); + const [pathInput, setPathInput] = useState(selectedDir ?? "~"); + const [listing, setListing] = useState(null); + const [error, setError] = useState(null); + const [loading, setLoading] = useState(false); + const requestIdRef = useRef(0); + const selectedDirRef = useRef(selectedDir); + selectedDirRef.current = selectedDir; + + const browse = useCallback( + async (path: string) => { + const requestId = ++requestIdRef.current; + setLoading(true); + setError(null); + try { + const result = await listRemoteDirs(host, path); + if (requestIdRef.current !== requestId) return; + setListing(result); + setPathInput(result.resolvedPath); + } catch (browseError) { + if (requestIdRef.current !== requestId) return; + setError(toErrorLike(browseError)); + } finally { + if (requestIdRef.current === requestId) { + setLoading(false); + } + } + }, + [host], + ); + + useEffect(() => { + if (!open) return; + void browse(selectedDirRef.current ?? "~"); + }, [open, browse]); + + const directories = (listing?.entries ?? []).filter((entry) => entry.isDir); + const parentPath = listing ? parentDirectory(listing.resolvedPath) : null; + const triggerLabel = selectedDir + ? directoryBasename(selectedDir) + : t("toolbar.remoteHost.directory.chooseFolder"); + + const confirmDirectory = () => { + if (!listing) return; + onDirChange?.(listing.resolvedPath); + useRemoteHostStore.getState().recordRecentDir(host, listing.resolvedPath); + onOpenChange?.(false); + }; + + return ( + + + } + title={selectedDir ?? undefined} + className={ + triggerIconOnly + ? "shrink-0" + : "chat-composer-selector-trigger max-w-40 min-w-0" + } + > + {triggerIconOnly ? null : ( + + {triggerLabel} + + )} + + + +
+ {t("toolbar.remoteHost.directory.title", { host })} +
+
{ + event.preventDefault(); + void browse(pathInput); + }} + > + setPathInput(event.target.value)} + placeholder={t("toolbar.remoteHost.directory.pathPlaceholder")} + aria-label={t("toolbar.remoteHost.directory.pathLabel")} + spellCheck={false} + autoCorrect="off" + autoCapitalize="off" + /> +
+ {error ? ( +

+ {error.message} +

+ ) : null} +
+ {parentPath ? ( + + } + label={t("toolbar.remoteHost.directory.parent")} + onClick={() => void browse(parentPath)} + disabled={loading} + /> + ) : null} + {directories.map((entry) => ( + + } + label={entry.name} + onClick={() => + listing && + void browse( + `${listing.resolvedPath.replace(/\/+$/, "")}/${entry.name}`, + ) + } + disabled={loading} + /> + ))} + {!loading && listing && directories.length === 0 ? ( +

+ {t("toolbar.remoteHost.directory.empty")} +

+ ) : null} + {loading ? ( +

+ {t("toolbar.remoteHost.directory.loading")} +

+ ) : null} +
+ {recentDirs?.length ? ( +
+
+ {t("toolbar.remoteHost.directory.recents")} +
+
+ {recentDirs.map((dir) => ( + + } + label={dir} + onClick={() => void browse(dir)} + disabled={loading} + /> + ))} +
+
+ ) : null} +
+ +
+
+
+ ); +} diff --git a/src/features/chat/ui/RemoteHostSelector.tsx b/src/features/chat/ui/RemoteHostSelector.tsx new file mode 100644 index 000000000..bdf59643b --- /dev/null +++ b/src/features/chat/ui/RemoteHostSelector.tsx @@ -0,0 +1,119 @@ +import { Laptop, Server } from "lucide-react"; +import { useTranslation } from "react-i18next"; +import { + ChatInputSelector, + type ChatInputSelectorItem, +} from "./ChatInputSelector"; +import { useRemoteHostStore } from "@/features/remoteHosts/stores/remoteHostStore"; + +const LOCAL_HOST_VALUE = "__local__"; + +interface RemoteHostSelectorProps { + selectedHost?: string | null; + /** SSH host aliases to offer; defaults to the remote-host store's list. */ + hosts?: string[]; + onHostChange?: (host: string | null) => void; + open?: boolean; + onOpenChange?: (open: boolean) => void; + onRequestComposerFocus?: () => void; + triggerIconOnly?: boolean; + disabled?: boolean; + modal?: boolean; +} + +export function RemoteHostSelector({ + selectedHost = null, + hosts, + onHostChange, + open, + onOpenChange, + onRequestComposerFocus, + triggerIconOnly, + disabled, + modal, +}: RemoteHostSelectorProps) { + const { t } = useTranslation("chat"); + const configHosts = useRemoteHostStore((state) => state.configHosts); + const statusByHost = useRemoteHostStore((state) => state.statusByHost); + const availableHosts = hosts ?? configHosts; + // A stale selection (host removed from ~/.ssh/config) still needs a row so + // the trigger label and check mark stay truthful. + const listedHosts = + selectedHost && !availableHosts.includes(selectedHost) + ? [selectedHost, ...availableHosts] + : availableHosts; + + const statusDescription = (host: string): string | undefined => { + const state = statusByHost[host]?.state; + return state ? t(`toolbar.remoteHost.status.${state}`) : undefined; + }; + + const hostItems: ChatInputSelectorItem[] = listedHosts.map((host) => ({ + value: host, + label: host, + description: statusDescription(host), + icon: , + })); + + const handleValueChange = (value: string) => { + onHostChange?.(value === LOCAL_HOST_VALUE ? null : value); + }; + + const handleOpenChange = (nextOpen: boolean) => { + if (nextOpen) { + // The SSH config can change while the app runs; refresh lazily on open. + void useRemoteHostStore.getState().refreshConfigHosts(); + } + onOpenChange?.(nextOpen); + }; + + return ( + + ) : ( + + ) + } + open={open} + onOpenChange={handleOpenChange} + onRequestComposerFocus={onRequestComposerFocus} + triggerIconOnly={triggerIconOnly} + triggerVariant="toolbar" + menuLabel={t("toolbar.remoteHost.chooseHost")} + contentWidth="wide" + disabled={disabled} + modal={modal} + sections={[ + { + items: [ + { + value: LOCAL_HOST_VALUE, + label: t("toolbar.remoteHost.thisComputer"), + description: t("toolbar.remoteHost.thisComputerDescription"), + icon: , + }, + ], + }, + ...(hostItems.length > 0 + ? [ + { + label: t("toolbar.remoteHost.sshHosts"), + items: hostItems, + }, + ] + : []), + ]} + onValueChange={handleValueChange} + /> + ); +} diff --git a/src/features/chat/ui/__tests__/ContextPanel.test.tsx b/src/features/chat/ui/__tests__/ContextPanel.test.tsx index 4811162e6..5739bc815 100644 --- a/src/features/chat/ui/__tests__/ContextPanel.test.tsx +++ b/src/features/chat/ui/__tests__/ContextPanel.test.tsx @@ -3152,4 +3152,53 @@ describe("ContextPanel", () => { await screen.findByText("No uncommitted changes"), ).toBeInTheDocument(); }); + + it("degrades to a remote workspace summary and disables git queries for remote sessions", async () => { + const user = userEvent.setup(); + useChatSessionStore.setState((state) => ({ + sessions: [ + { + id: "remote-session", + title: "Remote chat", + workingDir: "/home/dev/project", + remoteHost: "devbox", + createdAt: "2026-08-27T00:00:00.000Z", + updatedAt: "2026-08-27T00:00:00.000Z", + messageCount: 0, + }, + ...state.sessions, + ], + })); + + renderContextPanel({ + sessionId: "remote-session", + projectWorkingDirs: [], + sessionWorkingDir: "/home/dev/project", + }); + + // Details: no git/branch/worktree widgets — just the folder + host badge. + expect(screen.getByTestId("remote-host-badge")).toHaveTextContent("devbox"); + expect(screen.getByText("Folder on devbox")).toBeInTheDocument(); + expect( + screen.queryByRole("button", { name: /refresh local status/i }), + ).toBeNull(); + + // Every local git query mounts disabled: the paths live on the SSH host. + // (The workspace-runtime mock re-invokes mockUseGitState with no args, so + // only calls that actually pass the enabled flag are meaningful here.) + expect(mockUseGitState.mock.calls.some((call) => call[1] === true)).toBe( + false, + ); + expect( + mockUseWorkspaceGitRuntimes.mock.calls.some((call) => call[1] === true), + ).toBe(false); + + await user.click(screen.getByRole("tab", { name: /changes/i })); + expect( + await screen.findByText(/Changes live on devbox/), + ).toBeInTheDocument(); + + await user.click(screen.getByRole("tab", { name: /files/i })); + expect(await screen.findByText(/Files live on devbox/)).toBeInTheDocument(); + }); }); diff --git a/src/features/chat/ui/__tests__/McpAppView.test.tsx b/src/features/chat/ui/__tests__/McpAppView.test.tsx index 3fd0e311c..b96a48643 100644 --- a/src/features/chat/ui/__tests__/McpAppView.test.tsx +++ b/src/features/chat/ui/__tests__/McpAppView.test.tsx @@ -60,8 +60,8 @@ vi.mock("@mcp-ui/client", () => ({ }, })); -vi.mock("@/shared/api/acpConnection", () => ({ - getClient: mocks.getClient, +vi.mock("@/shared/api/acpSessionBackends", () => ({ + getClientForSession: mocks.getClient, })); vi.mock("@/shared/api/gooseServeHost", () => ({ diff --git a/src/features/chat/ui/__tests__/RemoteDirectoryPicker.test.tsx b/src/features/chat/ui/__tests__/RemoteDirectoryPicker.test.tsx new file mode 100644 index 000000000..8d396bfc0 --- /dev/null +++ b/src/features/chat/ui/__tests__/RemoteDirectoryPicker.test.tsx @@ -0,0 +1,132 @@ +import { render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { RemoteDirectoryPicker } from "../RemoteDirectoryPicker"; +import { + REMOTE_HOST_RECENT_DIRS_STORAGE_KEY, + useRemoteHostStore, +} from "@/features/remoteHosts/stores/remoteHostStore"; + +const mockListRemoteDirs = vi.fn(); + +vi.mock("@/shared/api/remoteHosts", () => ({ + listSshConfigHosts: vi.fn().mockResolvedValue([]), + connectRemoteHost: vi.fn(), + disconnectRemoteHost: vi.fn(), + shutdownRemoteHost: vi.fn(), + listRemoteBackends: vi.fn().mockResolvedValue([]), + checkRemoteHost: vi.fn(), + listRemoteDirs: (...args: unknown[]) => mockListRemoteDirs(...args), + listenRemoteBackendStatus: vi.fn().mockResolvedValue(() => {}), + isRemoteBackendError: (candidate: unknown) => + typeof candidate === "object" && + candidate !== null && + typeof (candidate as { kind?: unknown }).kind === "string" && + typeof (candidate as { message?: unknown }).message === "string", +})); + +describe("RemoteDirectoryPicker", () => { + beforeEach(() => { + window.localStorage.removeItem(REMOTE_HOST_RECENT_DIRS_STORAGE_KEY); + useRemoteHostStore.setState({ recentDirsByHost: {} }); + mockListRemoteDirs.mockReset(); + }); + + it("browses from home, descends into subfolders, and confirms a folder", async () => { + mockListRemoteDirs + .mockResolvedValueOnce({ + resolvedPath: "/home/dev", + entries: [ + { name: "projects", isDir: true }, + { name: "notes.txt", isDir: false }, + ], + }) + .mockResolvedValueOnce({ + resolvedPath: "/home/dev/projects", + entries: [], + }); + const user = userEvent.setup(); + const onDirChange = vi.fn(); + const onOpenChange = vi.fn(); + + render( + , + ); + + await waitFor(() => + expect(mockListRemoteDirs).toHaveBeenCalledWith("devbox", "~"), + ); + // Files are not offered; only directories are. + expect(screen.queryByText("notes.txt")).not.toBeInTheDocument(); + + await user.click(screen.getByRole("button", { name: /projects/i })); + await waitFor(() => + expect(mockListRemoteDirs).toHaveBeenCalledWith( + "devbox", + "/home/dev/projects", + ), + ); + + await user.click(screen.getByRole("button", { name: /use this folder/i })); + expect(onDirChange).toHaveBeenCalledWith("/home/dev/projects"); + expect(onOpenChange).toHaveBeenCalledWith(false); + expect(useRemoteHostStore.getState().recentDirsByHost.devbox).toContain( + "/home/dev/projects", + ); + }); + + it("shows recent folders and browses into one on click", async () => { + useRemoteHostStore.setState({ + recentDirsByHost: { devbox: ["/srv/app"] }, + }); + mockListRemoteDirs + .mockResolvedValueOnce({ resolvedPath: "/home/dev", entries: [] }) + .mockResolvedValueOnce({ resolvedPath: "/srv/app", entries: [] }); + const user = userEvent.setup(); + + render( + , + ); + + expect(await screen.findByText("Recent folders")).toBeInTheDocument(); + await user.click(screen.getByRole("button", { name: "/srv/app" })); + await waitFor(() => + expect(mockListRemoteDirs).toHaveBeenCalledWith("devbox", "/srv/app"), + ); + }); + + it("surfaces listRemoteDirs errors inline", async () => { + mockListRemoteDirs.mockRejectedValueOnce({ + kind: "host-unreachable", + message: "ssh: connect to host devbox port 22: Connection refused", + }); + + render( + , + ); + + expect(await screen.findByText(/connection refused/i)).toBeInTheDocument(); + // Without a successful listing there is nothing to confirm. + expect( + screen.getByRole("button", { name: /use this folder/i }), + ).toBeDisabled(); + }); +}); diff --git a/src/features/chat/ui/__tests__/RemoteHostSelector.test.tsx b/src/features/chat/ui/__tests__/RemoteHostSelector.test.tsx new file mode 100644 index 000000000..33e3879d8 --- /dev/null +++ b/src/features/chat/ui/__tests__/RemoteHostSelector.test.tsx @@ -0,0 +1,85 @@ +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { RemoteHostSelector } from "../RemoteHostSelector"; +import { useRemoteHostStore } from "@/features/remoteHosts/stores/remoteHostStore"; + +const mockListSshConfigHosts = vi.fn(); + +vi.mock("@/shared/api/remoteHosts", () => ({ + listSshConfigHosts: (...args: unknown[]) => mockListSshConfigHosts(...args), + connectRemoteHost: vi.fn(), + disconnectRemoteHost: vi.fn(), + shutdownRemoteHost: vi.fn(), + listRemoteBackends: vi.fn().mockResolvedValue([]), + checkRemoteHost: vi.fn(), + listRemoteDirs: vi.fn(), + listenRemoteBackendStatus: vi.fn().mockResolvedValue(() => {}), + isRemoteBackendError: () => false, +})); + +describe("RemoteHostSelector", () => { + beforeEach(() => { + // Opening the selector refreshes hosts from the SSH config, so the mock + // must agree with the seeded store state. + mockListSshConfigHosts.mockReset().mockResolvedValue(["devbox", "gpu-box"]); + useRemoteHostStore.setState({ + configHosts: ["devbox", "gpu-box"], + statusByHost: { devbox: { state: "ready" } }, + }); + }); + + it("renders the local option and the SSH hosts section", async () => { + const user = userEvent.setup(); + render(); + + await user.click(screen.getByRole("button", { name: /select computer/i })); + + expect( + screen.getByRole("menuitem", { name: /this computer/i }), + ).toBeInTheDocument(); + expect(screen.getByText("SSH hosts")).toBeInTheDocument(); + expect( + screen.getByRole("menuitem", { name: /devbox/i }), + ).toBeInTheDocument(); + expect( + screen.getByRole("menuitem", { name: /gpu-box/i }), + ).toBeInTheDocument(); + // Known backend state shows as an item description. + expect(screen.getByText("Connected")).toBeInTheDocument(); + }); + + it("fires onHostChange with the host, and null for the local option", async () => { + const user = userEvent.setup(); + const onHostChange = vi.fn(); + const { unmount } = render( + , + ); + + await user.click(screen.getByRole("button", { name: /select computer/i })); + await user.click(screen.getByRole("menuitem", { name: /devbox/i })); + expect(onHostChange).toHaveBeenCalledWith("devbox"); + unmount(); + + onHostChange.mockClear(); + render( + , + ); + await user.click(screen.getByRole("button", { name: /select computer/i })); + await user.click(screen.getByRole("menuitem", { name: /this computer/i })); + expect(onHostChange).toHaveBeenCalledWith(null); + }); + + it("still lists a selected host that is missing from the SSH config", async () => { + mockListSshConfigHosts.mockResolvedValue(["gpu-box"]); + useRemoteHostStore.setState({ configHosts: ["gpu-box"] }); + const user = userEvent.setup(); + render(); + + await user.click(screen.getByRole("button", { name: /select computer/i })); + + expect( + screen.getByRole("menuitem", { name: /devbox/i }), + ).toBeInTheDocument(); + }); +}); diff --git a/src/features/chat/ui/__tests__/markdownImage.test.tsx b/src/features/chat/ui/__tests__/markdownImage.test.tsx index 9e440821d..cd615dc1a 100644 --- a/src/features/chat/ui/__tests__/markdownImage.test.tsx +++ b/src/features/chat/ui/__tests__/markdownImage.test.tsx @@ -6,6 +6,8 @@ import { MarkdownImage } from "../MarkdownImage"; const mocks = vi.hoisted(() => ({ resolveMarkdownHref: vi.fn(), pathExists: vi.fn<(path: string) => Promise>(), + filesAreRemote: false, + remoteHost: null as string | null, })); vi.mock("@tauri-apps/api/core", () => ({ @@ -16,6 +18,8 @@ vi.mock("@/features/chat/hooks/ArtifactPolicyContext", () => ({ useArtifactActionsContext: () => ({ resolveMarkdownHref: mocks.resolveMarkdownHref, pathExists: mocks.pathExists, + filesAreRemote: mocks.filesAreRemote, + remoteHost: mocks.remoteHost, }), })); @@ -29,6 +33,8 @@ describe("MarkdownImage", () => { beforeEach(() => { mocks.resolveMarkdownHref.mockReset(); mocks.pathExists.mockReset(); + mocks.filesAreRemote = false; + mocks.remoteHost = null; }); it("renders an eligible local image via the asset: scheme by default", async () => { @@ -118,6 +124,38 @@ describe("MarkdownImage", () => { ); }); + it("renders a compact host placeholder for local paths in a remote session", () => { + mocks.filesAreRemote = true; + mocks.remoteHost = "devbox"; + mocks.resolveMarkdownHref.mockReturnValue({ + rawPath: "./remote.png", + resolvedPath: "/home/dev/project/remote.png", + isWithinSessionCwd: true, + }); + + render(); + + // The file lives on the SSH host: no local existence check, no asset: + // load (which would 404) — a name + host chip stands in for the image. + expect(mocks.pathExists).not.toHaveBeenCalled(); + expect(screen.queryByTestId("clickable-image")).toBeNull(); + const placeholder = screen.getByTestId("remote-image-placeholder"); + expect(placeholder.textContent).toContain("remote artifact"); + expect(placeholder.textContent).toContain("devbox"); + }); + + it("still leaves http(s) images alone in a remote session", () => { + mocks.filesAreRemote = true; + mocks.remoteHost = "devbox"; + + render(); + + expect(screen.queryByTestId("remote-image-placeholder")).toBeNull(); + expect(screen.getByAltText("remote").getAttribute("src")).toBe( + "https://example.com/p.jpg", + ); + }); + it("does not rescue a path resolved outside the session cwd", () => { mocks.resolveMarkdownHref.mockReturnValue({ rawPath: "../../secret.png", diff --git a/src/features/chat/ui/widgets/RemoteWorkspaceSummary.tsx b/src/features/chat/ui/widgets/RemoteWorkspaceSummary.tsx new file mode 100644 index 000000000..f1f457c44 --- /dev/null +++ b/src/features/chat/ui/widgets/RemoteWorkspaceSummary.tsx @@ -0,0 +1,100 @@ +import { useTranslation } from "react-i18next"; +import { Folder, Server } from "lucide-react"; +import { cn } from "@/shared/lib/cn"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@/shared/ui/tooltip"; +import { useRemoteHostStore } from "@/features/remoteHosts/stores/remoteHostStore"; +import type { RemoteBackendState } from "@/shared/api/remoteHosts"; +import { shortenPath } from "./workspacePath"; + +const HOST_STATE_TINT: Record = { + ready: "text-success", + connecting: "text-warning", + reconnecting: "text-warning", + disconnected: "text-muted-foreground", + failed: "text-destructive", +}; + +/** + * Compact "this chat lives on " chip, tinted by the SSH backend's + * connection state with a tooltip naming that state. The v1 host-status + * surface for remote sessions. + */ +export function RemoteHostBadge({ + host, + className, +}: { + host: string; + className?: string; +}) { + const { t } = useTranslation(["chat", "settings"]); + const state = useRemoteHostStore( + (store) => store.statusByHost[host]?.state ?? "disconnected", + ); + + return ( + + + + + + + {t("chat:remoteSessionGuards.hostBadge.tooltip", { + host, + state: t(`settings:remoteHosts.status.${state}`), + })} + + + ); +} + +/** + * Details-tab workspace block for remote sessions. Local git probes, branch + * and worktree controls, folder pickers, and terminal affordances all act on + * the local filesystem, so the remote view degrades to the essentials: the + * remote folder path plus the host badge. + */ +export function RemoteWorkspaceSummary({ + host, + workspacePath, +}: { + host: string; + workspacePath: string | null; +}) { + const { t } = useTranslation("chat"); + + return ( +
+
+
+

+ {t("contextPanel.labels.workspace")} +

+ +
+
+ + + + {workspacePath + ? shortenPath(workspacePath) + : t("contextPanel.empty.folderNotSet")} + + + {t("remoteSessionGuards.workspaceOnHost", { host })} + + +
+
+
+ ); +} diff --git a/src/features/connections/ui/ConnectionsSettings.tsx b/src/features/connections/ui/ConnectionsSettings.tsx index bdb478aed..e64a85bb5 100644 --- a/src/features/connections/ui/ConnectionsSettings.tsx +++ b/src/features/connections/ui/ConnectionsSettings.tsx @@ -26,6 +26,7 @@ import { } from "@/shared/ui/settings-section"; import { SearchBar } from "@/shared/ui/SearchBar"; import { Skeleton } from "@/shared/ui/skeleton"; +import { RemoteHostsSettings } from "@/features/remoteHosts/ui/RemoteHostsSettings"; import { OAuthConnectionCard } from "./ConnectionCards"; import { LocalMcpSection } from "./LocalMcpSection"; @@ -188,6 +189,9 @@ export function ConnectionsSettings({ onAskAgentToAddMcp ? askAgentToAddConnection : undefined } /> + + {/* Renders nothing unless the remote-ssh-sessions experiment is on. */} +
); diff --git a/src/features/experiments/__tests__/ExperimentsSettings.test.tsx b/src/features/experiments/__tests__/ExperimentsSettings.test.tsx index f527bc3a9..68c26af93 100644 --- a/src/features/experiments/__tests__/ExperimentsSettings.test.tsx +++ b/src/features/experiments/__tests__/ExperimentsSettings.test.tsx @@ -9,6 +9,7 @@ import { EXPERIMENT_DEFINITIONS, PROMPT_PINS_EXPERIMENT_ID, RELATED_PULL_REQUESTS_EXPERIMENT_ID, + REMOTE_SSH_SESSIONS_EXPERIMENT_ID, SKILL_DISCOVERY_EXPERIMENT_ID, STARTER_TASKS_EXPERIMENT_ID, TRANSCRIPT_VIRTUAL_RENDERER_EXPERIMENT_ID, @@ -140,6 +141,7 @@ describe("ExperimentsSettings", () => { BERDY_ONBOARDING_EXPERIMENT_ID, RELATED_PULL_REQUESTS_EXPERIMENT_ID, PROMPT_PINS_EXPERIMENT_ID, + REMOTE_SSH_SESSIONS_EXPERIMENT_ID, ]); }); diff --git a/src/features/experiments/experimentDefinitions.test.ts b/src/features/experiments/experimentDefinitions.test.ts index af143faa8..48266b6df 100644 --- a/src/features/experiments/experimentDefinitions.test.ts +++ b/src/features/experiments/experimentDefinitions.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "vitest"; import { EXPERIMENT_DEFINITIONS, + REMOTE_SSH_SESSIONS_EXPERIMENT_ID, VOICE_CONVERSATION_EXPERIMENT_ID, } from "./experimentDefinitions"; @@ -12,4 +13,12 @@ describe("experiment definitions", () => { )?.defaultEnabled, ).toBe(true); }); + + it("keeps Remote SSH sessions manual-enable only and visible in settings", () => { + const definition = EXPERIMENT_DEFINITIONS.find( + (candidate) => candidate.id === REMOTE_SSH_SESSIONS_EXPERIMENT_ID, + ); + expect(definition?.manualEnableOnly).toBe(true); + expect(definition?.settingsVisibility).toBe("all"); + }); }); diff --git a/src/features/experiments/experimentDefinitions.ts b/src/features/experiments/experimentDefinitions.ts index 233279384..54bccb0bb 100644 --- a/src/features/experiments/experimentDefinitions.ts +++ b/src/features/experiments/experimentDefinitions.ts @@ -67,6 +67,8 @@ export const RELATED_PULL_REQUESTS_EXPERIMENT_ID = "related-pull-requests"; export const PROMPT_PINS_EXPERIMENT_ID = "prompt-pins"; +export const REMOTE_SSH_SESSIONS_EXPERIMENT_ID = "remote-ssh-sessions"; + export const EXPERIMENT_DEFINITIONS = [ { id: BUILDERBOT_SURFACE_EXPERIMENT_ID, @@ -126,4 +128,13 @@ export const EXPERIMENT_DEFINITIONS = [ // default, so it follows the global auto-enable preference: on in dev // builds, off in production. }, + { + id: REMOTE_SSH_SESSIONS_EXPERIMENT_ID, + titleKey: "experiments.remoteSshSessions.title", + descriptionKey: "experiments.remoteSshSessions.description", + // Opt-in only: connecting runs Goose on user-configured SSH hosts, so it + // must never turn on via the dev auto-enable default. + manualEnableOnly: true, + settingsVisibility: "all", + }, ] as const satisfies readonly ExperimentDefinition[]; diff --git a/src/features/remoteHosts/stores/remoteHostStore.test.ts b/src/features/remoteHosts/stores/remoteHostStore.test.ts new file mode 100644 index 000000000..6930c4ca2 --- /dev/null +++ b/src/features/remoteHosts/stores/remoteHostStore.test.ts @@ -0,0 +1,361 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { + RemoteBackendConnection, + RemoteToolProbe, +} from "@/shared/api/remoteHosts"; + +const mocks = vi.hoisted(() => ({ + listSshConfigHosts: vi.fn(), + connectRemoteHost: vi.fn(), + disconnectRemoteHost: vi.fn(), + shutdownRemoteHost: vi.fn(), + listRemoteBackends: vi.fn(), + checkRemoteHost: vi.fn(), + listenRemoteBackendStatus: vi.fn(), +})); + +vi.mock("@/shared/api/remoteHosts", async (importOriginal) => { + const actual = + await importOriginal(); + return { ...actual, ...mocks }; +}); + +import { + initRemoteHostStore, + loadPersistedRecentDirs, + REMOTE_HOST_RECENT_DIRS_STORAGE_KEY, + useRemoteHostStore, +} from "./remoteHostStore"; + +const connection: RemoteBackendConnection = { + wsUrl: "ws://127.0.0.1:4001/ws", + httpBaseUrl: "http://127.0.0.1:4001", + secretKey: "secret", + localPort: 4001, + gooseVersion: "1.2.3", + daemonReused: false, +}; + +function resetStore(): void { + useRemoteHostStore.setState({ + configHosts: [], + statusByHost: {}, + doctorByHost: {}, + doctorPendingByHost: {}, + doctorErrorByHost: {}, + recentDirsByHost: {}, + }); +} + +beforeEach(() => { + vi.clearAllMocks(); + window.localStorage.clear(); + resetStore(); +}); + +describe("applyStatusEvent", () => { + it("updates statusByHost from status events", () => { + useRemoteHostStore.getState().applyStatusEvent({ + host: "devbox", + state: "reconnecting", + attempt: 2, + }); + + expect(useRemoteHostStore.getState().statusByHost.devbox).toEqual({ + state: "reconnecting", + attempt: 2, + }); + }); + + it("clears a previous error when a ready event arrives", () => { + useRemoteHostStore.getState().applyStatusEvent({ + host: "devbox", + state: "failed", + error: { kind: "host-unreachable", message: "no route" }, + }); + expect( + useRemoteHostStore.getState().statusByHost.devbox.error, + ).toBeDefined(); + + useRemoteHostStore.getState().applyStatusEvent({ + host: "devbox", + state: "ready", + wsUrl: connection.wsUrl, + localPort: connection.localPort, + }); + + expect(useRemoteHostStore.getState().statusByHost.devbox).toEqual({ + state: "ready", + }); + }); +}); + +describe("syncBackendSnapshot", () => { + it("copies snapshot entries into statusByHost", async () => { + mocks.listRemoteBackends.mockResolvedValue([ + { host: "devbox", state: "ready" }, + { + host: "broken", + state: "failed", + error: { kind: "auth-failed", message: "denied" }, + }, + ]); + + await useRemoteHostStore.getState().syncBackendSnapshot(); + + const { statusByHost } = useRemoteHostStore.getState(); + expect(statusByHost.devbox).toEqual({ state: "ready" }); + expect(statusByHost.broken).toEqual({ + state: "failed", + error: { kind: "auth-failed", message: "denied" }, + }); + }); + + it("keeps the previous statuses when the snapshot fails", async () => { + useRemoteHostStore + .getState() + .applyStatusEvent({ host: "devbox", state: "ready" }); + mocks.listRemoteBackends.mockRejectedValue(new Error("ipc down")); + + await useRemoteHostStore.getState().syncBackendSnapshot(); + + expect(useRemoteHostStore.getState().statusByHost.devbox).toEqual({ + state: "ready", + }); + }); +}); + +describe("refreshConfigHosts", () => { + it("keeps the old list when listing fails", async () => { + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + mocks.listSshConfigHosts.mockResolvedValue(["devbox", "gpu-1"]); + await useRemoteHostStore.getState().refreshConfigHosts(); + expect(useRemoteHostStore.getState().configHosts).toEqual([ + "devbox", + "gpu-1", + ]); + + mocks.listSshConfigHosts.mockRejectedValue(new Error("no config")); + await useRemoteHostStore.getState().refreshConfigHosts(); + + expect(useRemoteHostStore.getState().configHosts).toEqual([ + "devbox", + "gpu-1", + ]); + warn.mockRestore(); + }); +}); + +describe("ensureHostConnected", () => { + it("resolves without invoking connect when the host is already ready", async () => { + useRemoteHostStore + .getState() + .applyStatusEvent({ host: "devbox", state: "ready" }); + + await useRemoteHostStore.getState().ensureHostConnected("devbox"); + + expect(mocks.connectRemoteHost).not.toHaveBeenCalled(); + }); + + it("connects and marks the host ready", async () => { + let resolveConnect: (value: RemoteBackendConnection) => void = () => {}; + mocks.connectRemoteHost.mockImplementation( + () => + new Promise((resolve) => { + resolveConnect = resolve; + }), + ); + + const pending = useRemoteHostStore.getState().ensureHostConnected("devbox"); + expect(useRemoteHostStore.getState().statusByHost.devbox).toEqual({ + state: "connecting", + }); + + resolveConnect(connection); + await pending; + + expect(mocks.connectRemoteHost).toHaveBeenCalledWith("devbox"); + expect(useRemoteHostStore.getState().statusByHost.devbox).toEqual({ + state: "ready", + }); + }); + + it("marks the host failed with the typed error and rethrows", async () => { + const error = { kind: "auth-failed", message: "permission denied" }; + mocks.connectRemoteHost.mockRejectedValue(error); + + await expect( + useRemoteHostStore.getState().ensureHostConnected("devbox"), + ).rejects.toBe(error); + + expect(useRemoteHostStore.getState().statusByHost.devbox).toEqual({ + state: "failed", + error, + }); + }); + + it("wraps non-typed connect errors as internal", async () => { + mocks.connectRemoteHost.mockRejectedValue(new Error("boom")); + + await expect( + useRemoteHostStore.getState().ensureHostConnected("devbox"), + ).rejects.toThrow("boom"); + + expect(useRemoteHostStore.getState().statusByHost.devbox).toEqual({ + state: "failed", + error: { kind: "internal", message: "boom" }, + }); + }); +}); + +describe("disconnect and shutdownHost", () => { + it("marks the host disconnected after disconnect", async () => { + mocks.disconnectRemoteHost.mockResolvedValue(undefined); + useRemoteHostStore + .getState() + .applyStatusEvent({ host: "devbox", state: "ready" }); + + await useRemoteHostStore.getState().disconnect("devbox"); + + expect(mocks.disconnectRemoteHost).toHaveBeenCalledWith("devbox"); + expect(useRemoteHostStore.getState().statusByHost.devbox).toEqual({ + state: "disconnected", + }); + }); + + it("marks the host disconnected after shutdown", async () => { + mocks.shutdownRemoteHost.mockResolvedValue(undefined); + useRemoteHostStore + .getState() + .applyStatusEvent({ host: "devbox", state: "ready" }); + + await useRemoteHostStore.getState().shutdownHost("devbox"); + + expect(mocks.shutdownRemoteHost).toHaveBeenCalledWith("devbox"); + expect(useRemoteHostStore.getState().statusByHost.devbox).toEqual({ + state: "disconnected", + }); + }); +}); + +describe("runDoctor", () => { + const probes: RemoteToolProbe[] = [ + { binary: "goose", found: true, version: "1.2.3" }, + { binary: "claude-agent-acp", found: false }, + ]; + + it("stores probe results and clears the pending flag", async () => { + let resolveCheck: (value: RemoteToolProbe[]) => void = () => {}; + mocks.checkRemoteHost.mockImplementation( + () => + new Promise((resolve) => { + resolveCheck = resolve; + }), + ); + + const pending = useRemoteHostStore.getState().runDoctor("devbox"); + expect(useRemoteHostStore.getState().doctorPendingByHost.devbox).toBe(true); + + resolveCheck(probes); + await pending; + + const state = useRemoteHostStore.getState(); + expect(state.doctorByHost.devbox).toEqual(probes); + expect(state.doctorPendingByHost.devbox).toBe(false); + expect(state.doctorErrorByHost.devbox).toBeUndefined(); + }); + + it("captures the failure per host without throwing", async () => { + mocks.checkRemoteHost.mockRejectedValue({ + kind: "ssh-not-found", + message: "ssh missing", + }); + + await useRemoteHostStore.getState().runDoctor("devbox"); + + const state = useRemoteHostStore.getState(); + expect(state.doctorByHost.devbox).toBeUndefined(); + expect(state.doctorPendingByHost.devbox).toBe(false); + expect(state.doctorErrorByHost.devbox).toEqual({ + kind: "ssh-not-found", + message: "ssh missing", + }); + }); +}); + +describe("recordRecentDir", () => { + it("dedupes, keeps most-recent-first, caps at 8, and persists", () => { + const store = useRemoteHostStore.getState(); + for (let i = 1; i <= 9; i++) { + store.recordRecentDir("devbox", `~/repo-${i}`); + } + store.recordRecentDir("devbox", "~/repo-5"); + + const dirs = useRemoteHostStore.getState().recentDirsByHost.devbox; + expect(dirs).toHaveLength(8); + expect(dirs[0]).toBe("~/repo-5"); + expect(dirs).not.toContain("~/repo-1"); + expect(new Set(dirs).size).toBe(dirs.length); + + const persisted = JSON.parse( + window.localStorage.getItem(REMOTE_HOST_RECENT_DIRS_STORAGE_KEY) ?? "{}", + ); + expect(persisted.devbox).toEqual(dirs); + }); + + it("ignores empty hosts and dirs", () => { + useRemoteHostStore.getState().recordRecentDir("devbox", " "); + useRemoteHostStore.getState().recordRecentDir(" ", "~/repo"); + + expect(useRemoteHostStore.getState().recentDirsByHost).toEqual({}); + }); + + it("rehydrates persisted recents and drops malformed entries", () => { + window.localStorage.setItem( + REMOTE_HOST_RECENT_DIRS_STORAGE_KEY, + JSON.stringify({ + devbox: ["~/a", "~/b", 42, ""], + broken: "not-an-array", + }), + ); + + expect(loadPersistedRecentDirs()).toEqual({ devbox: ["~/a", "~/b"] }); + }); + + it("returns no recents when storage holds invalid JSON", () => { + window.localStorage.setItem(REMOTE_HOST_RECENT_DIRS_STORAGE_KEY, "{nope"); + + expect(loadPersistedRecentDirs()).toEqual({}); + }); +}); + +describe("initRemoteHostStore", () => { + it("subscribes to status events, seeds state, and returns unsubscribe", async () => { + const unlisten = vi.fn(); + let statusHandler: + | ((payload: { host: string; state: string }) => void) + | undefined; + mocks.listenRemoteBackendStatus.mockImplementation((handler) => { + statusHandler = handler; + return Promise.resolve(unlisten); + }); + mocks.listRemoteBackends.mockResolvedValue([ + { host: "devbox", state: "ready" }, + ]); + mocks.listSshConfigHosts.mockResolvedValue(["devbox"]); + + const cleanup = await initRemoteHostStore(); + + expect(useRemoteHostStore.getState().configHosts).toEqual(["devbox"]); + expect(useRemoteHostStore.getState().statusByHost.devbox).toEqual({ + state: "ready", + }); + + statusHandler?.({ host: "devbox", state: "reconnecting" }); + expect(useRemoteHostStore.getState().statusByHost.devbox).toEqual({ + state: "reconnecting", + }); + + expect(cleanup).toBe(unlisten); + }); +}); diff --git a/src/features/remoteHosts/stores/remoteHostStore.ts b/src/features/remoteHosts/stores/remoteHostStore.ts new file mode 100644 index 000000000..04f0e9c8c --- /dev/null +++ b/src/features/remoteHosts/stores/remoteHostStore.ts @@ -0,0 +1,265 @@ +import { create } from "zustand"; +import { + checkRemoteHost, + connectRemoteHost, + disconnectRemoteHost, + isRemoteBackendError, + listenRemoteBackendStatus, + listRemoteBackends, + listSshConfigHosts, + shutdownRemoteHost, + type RemoteBackendErrorLike, + type RemoteBackendState, + type RemoteBackendStatusPayload, + type RemoteToolProbe, +} from "@/shared/api/remoteHosts"; + +export const REMOTE_HOST_RECENT_DIRS_STORAGE_KEY = + "goose:remote-host-recent-dirs"; + +const MAX_RECENT_DIRS_PER_HOST = 8; + +export interface RemoteHostStatus { + state: RemoteBackendState; + attempt?: number; + error?: RemoteBackendErrorLike; +} + +function toRemoteBackendError(error: unknown): RemoteBackendErrorLike { + if (isRemoteBackendError(error)) return error; + return { + kind: "internal", + message: error instanceof Error ? error.message : String(error), + }; +} + +/** Recent remote directories by host, persisted in localStorage. Paths and + * hostnames only — never secrets. */ +export function loadPersistedRecentDirs(): Record { + if (typeof window === "undefined") return {}; + try { + const stored = window.localStorage.getItem( + REMOTE_HOST_RECENT_DIRS_STORAGE_KEY, + ); + if (!stored) return {}; + const parsed = JSON.parse(stored); + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { + return {}; + } + const byHost: Record = {}; + for (const [host, value] of Object.entries(parsed)) { + if (!Array.isArray(value)) continue; + const dirs = value + .filter((dir): dir is string => typeof dir === "string" && dir !== "") + .slice(0, MAX_RECENT_DIRS_PER_HOST); + if (dirs.length > 0) { + byHost[host] = dirs; + } + } + return byHost; + } catch { + return {}; + } +} + +function persistRecentDirs(byHost: Record): void { + if (typeof window === "undefined") return; + try { + window.localStorage.setItem( + REMOTE_HOST_RECENT_DIRS_STORAGE_KEY, + JSON.stringify(byHost), + ); + } catch { + // localStorage may be unavailable + } +} + +export interface RemoteHostStore { + /** Concrete Host aliases from ~/.ssh/config. */ + configHosts: string[]; + statusByHost: Record; + doctorByHost: Record; + doctorPendingByHost: Record; + doctorErrorByHost: Record; + recentDirsByHost: Record; + + // Actions + refreshConfigHosts: () => Promise; + syncBackendSnapshot: () => Promise; + applyStatusEvent: (payload: RemoteBackendStatusPayload) => void; + ensureHostConnected: (host: string) => Promise; + disconnect: (host: string) => Promise; + shutdownHost: (host: string) => Promise; + runDoctor: (host: string) => Promise; + recordRecentDir: (host: string, dir: string) => void; +} + +export const useRemoteHostStore = create((set, get) => ({ + configHosts: [], + statusByHost: {}, + doctorByHost: {}, + doctorPendingByHost: {}, + doctorErrorByHost: {}, + recentDirsByHost: loadPersistedRecentDirs(), + + refreshConfigHosts: async () => { + try { + const configHosts = await listSshConfigHosts(); + set({ configHosts }); + } catch (error) { + // Keep the previous list; the SSH config may be temporarily unreadable. + console.warn("Failed to list SSH config hosts", error); + } + }, + + syncBackendSnapshot: async () => { + try { + const snapshot = await listRemoteBackends(); + set((state) => { + const statusByHost = { ...state.statusByHost }; + for (const entry of snapshot) { + statusByHost[entry.host] = { + state: entry.state, + ...(entry.attempt !== undefined ? { attempt: entry.attempt } : {}), + ...(entry.error ? { error: entry.error } : {}), + }; + } + return { statusByHost }; + }); + } catch (error) { + console.warn("Failed to list remote backends", error); + } + }, + + applyStatusEvent: (payload) => { + set((state) => ({ + statusByHost: { + ...state.statusByHost, + [payload.host]: { + state: payload.state, + ...(payload.attempt !== undefined + ? { attempt: payload.attempt } + : {}), + ...(payload.error ? { error: payload.error } : {}), + }, + }, + })); + }, + + ensureHostConnected: async (host) => { + if (get().statusByHost[host]?.state === "ready") return; + + // Optimistic: the Rust side serializes concurrent connects per host and + // emits status events, but reflect intent immediately in the UI. + set((state) => ({ + statusByHost: { + ...state.statusByHost, + [host]: { state: "connecting" }, + }, + })); + try { + await connectRemoteHost(host); + set((state) => ({ + statusByHost: { + ...state.statusByHost, + [host]: { state: "ready" }, + }, + })); + } catch (error) { + set((state) => ({ + statusByHost: { + ...state.statusByHost, + [host]: { state: "failed", error: toRemoteBackendError(error) }, + }, + })); + throw error; + } + }, + + disconnect: async (host) => { + await disconnectRemoteHost(host); + set((state) => ({ + statusByHost: { + ...state.statusByHost, + [host]: { state: "disconnected" }, + }, + })); + }, + + shutdownHost: async (host) => { + await shutdownRemoteHost(host); + set((state) => ({ + statusByHost: { + ...state.statusByHost, + [host]: { state: "disconnected" }, + }, + })); + }, + + runDoctor: async (host) => { + set((state) => ({ + doctorPendingByHost: { ...state.doctorPendingByHost, [host]: true }, + })); + try { + const probes = await checkRemoteHost(host); + set((state) => ({ + doctorByHost: { ...state.doctorByHost, [host]: probes }, + doctorErrorByHost: { ...state.doctorErrorByHost, [host]: undefined }, + doctorPendingByHost: { ...state.doctorPendingByHost, [host]: false }, + })); + } catch (error) { + set((state) => ({ + doctorErrorByHost: { + ...state.doctorErrorByHost, + [host]: toRemoteBackendError(error), + }, + doctorPendingByHost: { ...state.doctorPendingByHost, [host]: false }, + })); + } + }, + + recordRecentDir: (host, dir) => { + const trimmedHost = host.trim(); + const trimmedDir = dir.trim(); + if (!trimmedHost || !trimmedDir) return; + + set((state) => { + const existing = state.recentDirsByHost[trimmedHost] ?? []; + const dirs = [ + trimmedDir, + ...existing.filter((candidate) => candidate !== trimmedDir), + ].slice(0, MAX_RECENT_DIRS_PER_HOST); + const recentDirsByHost = { + ...state.recentDirsByHost, + [trimmedHost]: dirs, + }; + persistRecentDirs(recentDirsByHost); + return { recentDirsByHost }; + }); + }, +})); + +/** + * Module-level convenience wrapper over the store's `ensureHostConnected` + * action for callers outside React (e.g. session routing in chat). + */ +export function ensureHostConnected(host: string): Promise { + return useRemoteHostStore.getState().ensureHostConnected(host); +} + +/** + * Subscribe to remote backend status events and seed the store from the + * backend snapshot and the SSH config. Returns an unsubscribe function. + * Not wired into app startup here — callers gate it behind the + * remote-ssh-sessions experiment. + */ +export async function initRemoteHostStore(): Promise<() => void> { + const unlisten = await listenRemoteBackendStatus((payload) => { + useRemoteHostStore.getState().applyStatusEvent(payload); + }); + await Promise.all([ + useRemoteHostStore.getState().syncBackendSnapshot(), + useRemoteHostStore.getState().refreshConfigHosts(), + ]); + return unlisten; +} diff --git a/src/features/remoteHosts/ui/RemoteHostsSettings.tsx b/src/features/remoteHosts/ui/RemoteHostsSettings.tsx new file mode 100644 index 000000000..e1aed466a --- /dev/null +++ b/src/features/remoteHosts/ui/RemoteHostsSettings.tsx @@ -0,0 +1,355 @@ +import { useEffect, useMemo, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { REMOTE_SSH_SESSIONS_EXPERIMENT_ID } from "@/features/experiments/experimentDefinitions"; +import { useExperiment } from "@/features/experiments/experimentPreferences"; +import { + initRemoteHostStore, + useRemoteHostStore, + type RemoteHostStatus, +} from "@/features/remoteHosts/stores/remoteHostStore"; +import { + isRemoteBackendError, + type RemoteToolProbe, +} from "@/shared/api/remoteHosts"; +import { cn } from "@/shared/lib/cn"; +import { Badge } from "@/shared/ui/badge"; +import { Button } from "@/shared/ui/button"; +import { ConfirmDialog } from "@/shared/ui/confirm-dialog"; +import { Input } from "@/shared/ui/input"; +import { SettingsRow } from "@/shared/ui/settings-row"; +import { SettingsSection } from "@/shared/ui/settings-section"; + +// One live-status subscription per app lifetime. `initRemoteHostStore` also +// seeds the store (backend snapshot + SSH config hosts), so the first mount +// gets its refresh from init; later mounts re-refresh explicitly below. +let remoteHostStoreInitStarted = false; + +function StatusPill({ status }: { status: RemoteHostStatus | undefined }) { + const { t } = useTranslation("settings"); + const state = status?.state ?? "disconnected"; + + const label = + state === "reconnecting" && status?.attempt !== undefined + ? t("remoteHosts.status.reconnectingAttempt", { attempt: status.attempt }) + : t(`remoteHosts.status.${state}`); + + return ( + + {label} + + ); +} + +function DoctorReport({ + probes, + pending, + errorMessage, +}: { + probes: RemoteToolProbe[] | undefined; + pending: boolean; + errorMessage: string | undefined; +}) { + const { t } = useTranslation("settings"); + + if (pending) { + return ( +

+ {t("remoteHosts.doctor.checking")} +

+ ); + } + if (errorMessage) { + return

{errorMessage}

; + } + if (!probes) return null; + + const gooseMissing = probes.some( + (probe) => probe.binary === "goose" && !probe.found, + ); + + return ( +
+
    + {probes.map((probe) => ( +
  • + {probe.binary} + + {probe.found + ? (probe.version ?? t("remoteHosts.doctor.found")) + : t("remoteHosts.doctor.notFound")} + +
  • + ))} +
+ {gooseMissing ? ( +

+ {t("remoteHosts.doctor.gooseMissing")} +

+ ) : null} +
+ ); +} + +function RemoteHostRow({ + host, + onRequestShutdown, +}: { + host: string; + onRequestShutdown: (host: string) => void; +}) { + const { t } = useTranslation("settings"); + const status = useRemoteHostStore((state) => state.statusByHost[host]); + const probes = useRemoteHostStore((state) => state.doctorByHost[host]); + const doctorPending = useRemoteHostStore( + (state) => state.doctorPendingByHost[host] === true, + ); + const doctorError = useRemoteHostStore( + (state) => state.doctorErrorByHost[host], + ); + const ensureHostConnected = useRemoteHostStore( + (state) => state.ensureHostConnected, + ); + const disconnect = useRemoteHostStore((state) => state.disconnect); + const runDoctor = useRemoteHostStore((state) => state.runDoctor); + + const state = status?.state ?? "disconnected"; + const isConnected = state === "ready" || state === "reconnecting"; + const failedMessage = state === "failed" ? status?.error?.message : undefined; + const showDoctor = doctorPending || doctorError !== undefined || !!probes; + + return ( + + {host} + + + } + description={failedMessage} + descriptionClassName="text-destructive" + action={ +
+ {isConnected ? ( + + ) : ( + + )} + {isConnected ? ( + + ) : null} + +
+ } + details={ + showDoctor ? ( + + ) : undefined + } + /> + ); +} + +/** + * Experiment-gated "Remote SSH hosts" card for the Settings connections + * section: connect Berd to Goose daemons on hosts from `~/.ssh/config` (or a + * free-form `user@host`), with per-host status and a tooling doctor. + */ +export function RemoteHostsSettings() { + const { t } = useTranslation("settings"); + const enabled = + useExperiment(REMOTE_SSH_SESSIONS_EXPERIMENT_ID)?.enabled === true; + + const configHosts = useRemoteHostStore((state) => state.configHosts); + const statusByHost = useRemoteHostStore((state) => state.statusByHost); + const ensureHostConnected = useRemoteHostStore( + (state) => state.ensureHostConnected, + ); + const shutdownHost = useRemoteHostStore((state) => state.shutdownHost); + + const [customHost, setCustomHost] = useState(""); + const [customHostError, setCustomHostError] = useState(null); + const [customHostConnecting, setCustomHostConnecting] = useState(false); + const [shutdownTarget, setShutdownTarget] = useState(null); + const [shutdownPending, setShutdownPending] = useState(false); + + useEffect(() => { + if (!enabled) return; + if (!remoteHostStoreInitStarted) { + remoteHostStoreInitStarted = true; + // Subscribes to live status events and seeds snapshot + config hosts. + void initRemoteHostStore(); + return; + } + const store = useRemoteHostStore.getState(); + void store.refreshConfigHosts(); + void store.syncBackendSnapshot(); + }, [enabled]); + + // Config hosts first, then any ad-hoc hosts we have status for (e.g. from + // the free-form connect below), so manual connections stay visible. + const hosts = useMemo(() => { + const extra = Object.keys(statusByHost) + .filter((host) => !configHosts.includes(host)) + .sort(); + return [...configHosts, ...extra]; + }, [configHosts, statusByHost]); + + if (!enabled) return null; + + const connectCustomHost = async () => { + const trimmed = customHost.trim(); + if (!trimmed) { + setCustomHostError(t("remoteHosts.custom.emptyError")); + return; + } + setCustomHostError(null); + setCustomHostConnecting(true); + try { + await ensureHostConnected(trimmed); + setCustomHost(""); + } catch (error) { + setCustomHostError( + isRemoteBackendError(error) ? error.message : String(error), + ); + } finally { + setCustomHostConnecting(false); + } + }; + + return ( + +

+ {t("remoteHosts.description")} +

+ {hosts.length === 0 ? ( +

+ {t("remoteHosts.empty")} +

+ ) : ( + hosts.map((host) => ( + + )) + )} + ( +
+ setCustomHost(event.target.value)} + onKeyDown={(event) => { + if (event.key === "Enter") { + void connectCustomHost(); + } + }} + /> + +
+ )} + details={ + customHostError ? ( +

{customHostError}

+ ) : undefined + } + /> + { + if (!open) setShutdownTarget(null); + }} + title={t("remoteHosts.shutdownConfirm.title", { + host: shutdownTarget ?? "", + })} + description={t("remoteHosts.shutdownConfirm.description")} + cancelLabel={t("remoteHosts.shutdownConfirm.cancel")} + confirmLabel={t("remoteHosts.shutdownConfirm.confirm")} + loadingLabel={t("remoteHosts.shutdownConfirm.stopping")} + isLoading={shutdownPending} + onConfirm={async () => { + if (shutdownTarget === null) return; + setShutdownPending(true); + try { + await shutdownHost(shutdownTarget); + setShutdownTarget(null); + } finally { + setShutdownPending(false); + } + }} + onConfirmError={() => { + // The store keeps the previous status; the row remains actionable. + }} + /> +
+ ); +} diff --git a/src/features/remoteHosts/ui/__tests__/RemoteHostsSettings.test.tsx b/src/features/remoteHosts/ui/__tests__/RemoteHostsSettings.test.tsx new file mode 100644 index 000000000..6c266161b --- /dev/null +++ b/src/features/remoteHosts/ui/__tests__/RemoteHostsSettings.test.tsx @@ -0,0 +1,212 @@ +import { screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { renderWithProviders } from "@/test/render"; +import enSettings from "@/shared/i18n/locales/en/settings.json"; +import { REMOTE_SSH_SESSIONS_EXPERIMENT_ID } from "@/features/experiments/experimentDefinitions"; +import { + EXPERIMENT_PREFERENCES_STORAGE_KEY, + setExperimentEnabled, +} from "@/features/experiments/experimentPreferences"; +import { useRemoteHostStore } from "@/features/remoteHosts/stores/remoteHostStore"; +import { RemoteHostsSettings } from "../RemoteHostsSettings"; + +const ensureHostConnected = vi.fn(async () => {}); +const disconnect = vi.fn(async () => {}); +const shutdownHost = vi.fn(async () => {}); +const runDoctor = vi.fn(async () => {}); +const refreshConfigHosts = vi.fn(async () => {}); +const syncBackendSnapshot = vi.fn(async () => {}); + +function seedStore(overrides?: Partial>) { + useRemoteHostStore.setState({ ...baseState(), ...overrides }); +} + +function baseState() { + return { + configHosts: [] as string[], + statusByHost: {}, + doctorByHost: {}, + doctorPendingByHost: {}, + doctorErrorByHost: {}, + recentDirsByHost: {}, + ensureHostConnected, + disconnect, + shutdownHost, + runDoctor, + refreshConfigHosts, + syncBackendSnapshot, + }; +} + +describe("RemoteHostsSettings", () => { + beforeEach(() => { + vi.clearAllMocks(); + window.localStorage.removeItem(EXPERIMENT_PREFERENCES_STORAGE_KEY); + // The experiment is manualEnableOnly, so it stays off (even under the + // dev auto-enable default) until each test opts in explicitly. + setExperimentEnabled(REMOTE_SSH_SESSIONS_EXPERIMENT_ID, true); + seedStore(); + }); + + it("renders nothing when the experiment is disabled", () => { + setExperimentEnabled(REMOTE_SSH_SESSIONS_EXPERIMENT_ID, false); + seedStore({ configHosts: ["alpha"] }); + renderWithProviders(); + expect( + screen.queryByText(enSettings.remoteHosts.title), + ).not.toBeInTheDocument(); + expect(screen.queryByText("alpha")).not.toBeInTheDocument(); + }); + + it("renders ssh-config hosts with their statuses", () => { + seedStore({ + configHosts: ["alpha", "beta", "gamma"], + statusByHost: { + alpha: { state: "ready" }, + beta: { + state: "failed", + error: { kind: "auth-failed", message: "Permission denied" }, + }, + gamma: { state: "reconnecting", attempt: 3 }, + }, + }); + renderWithProviders(); + + expect(screen.getByText(enSettings.remoteHosts.title)).toBeInTheDocument(); + expect(screen.getByText("alpha")).toBeInTheDocument(); + expect( + screen.getByText(enSettings.remoteHosts.status.ready), + ).toBeInTheDocument(); + expect( + screen.getByText(enSettings.remoteHosts.status.failed), + ).toBeInTheDocument(); + expect(screen.getByText("Permission denied")).toBeInTheDocument(); + expect(screen.getByText("Reconnecting (attempt 3)...")).toBeInTheDocument(); + }); + + it("shows hosts with backend status that are not in the ssh config", () => { + seedStore({ + configHosts: ["alpha"], + statusByHost: { "user@adhoc": { state: "ready" } }, + }); + renderWithProviders(); + expect(screen.getByText("user@adhoc")).toBeInTheDocument(); + }); + + it("connects a disconnected host via the row's Connect button", async () => { + const user = userEvent.setup(); + seedStore({ configHosts: ["alpha"] }); + renderWithProviders(); + + const [connectButton] = screen.getAllByRole("button", { + name: enSettings.remoteHosts.actions.connect, + }); + await user.click(connectButton); + expect(ensureHostConnected).toHaveBeenCalledWith("alpha"); + }); + + it("offers Disconnect and a confirmed Stop remote backend when connected", async () => { + const user = userEvent.setup(); + seedStore({ + configHosts: ["alpha"], + statusByHost: { alpha: { state: "ready" } }, + }); + renderWithProviders(); + + await user.click( + screen.getByRole("button", { + name: enSettings.remoteHosts.actions.shutdown, + }), + ); + // Confirm dialog interposes before anything stops. + expect(shutdownHost).not.toHaveBeenCalled(); + await user.click( + await screen.findByRole("button", { + name: enSettings.remoteHosts.shutdownConfirm.confirm, + }), + ); + expect(shutdownHost).toHaveBeenCalledWith("alpha"); + + await user.click( + screen.getByRole("button", { + name: enSettings.remoteHosts.actions.disconnect, + }), + ); + expect(disconnect).toHaveBeenCalledWith("alpha"); + }); + + it("runs the doctor and renders the report inline", async () => { + const user = userEvent.setup(); + seedStore({ + configHosts: ["alpha"], + doctorByHost: { + alpha: [ + { binary: "goose", found: false }, + { binary: "claude-agent-acp", found: true, version: "1.2.3" }, + ], + }, + }); + renderWithProviders(); + + await user.click( + screen.getByRole("button", { + name: enSettings.remoteHosts.actions.check, + }), + ); + expect(runDoctor).toHaveBeenCalledWith("alpha"); + + expect(screen.getByText("goose")).toBeInTheDocument(); + expect( + screen.getByText(enSettings.remoteHosts.doctor.notFound), + ).toBeInTheDocument(); + expect(screen.getByText("claude-agent-acp")).toBeInTheDocument(); + expect(screen.getByText("1.2.3")).toBeInTheDocument(); + expect( + screen.getByText(enSettings.remoteHosts.doctor.gooseMissing), + ).toBeInTheDocument(); + }); + + it("connects a free-form user@host and validates empty input", async () => { + const user = userEvent.setup(); + seedStore(); + renderWithProviders(); + + const connectButton = screen.getByRole("button", { + name: enSettings.remoteHosts.actions.connect, + }); + await user.click(connectButton); + expect(ensureHostConnected).not.toHaveBeenCalled(); + expect( + screen.getByText(enSettings.remoteHosts.custom.emptyError), + ).toBeInTheDocument(); + + await user.type( + screen.getByPlaceholderText(enSettings.remoteHosts.custom.placeholder), + "me@example.com", + ); + await user.click(connectButton); + expect(ensureHostConnected).toHaveBeenCalledWith("me@example.com"); + }); + + it("renders the backend error under the free-form row when connect fails", async () => { + const user = userEvent.setup(); + ensureHostConnected.mockRejectedValueOnce({ + kind: "host-unreachable", + message: "Could not reach host", + }); + seedStore(); + renderWithProviders(); + + await user.type( + screen.getByPlaceholderText(enSettings.remoteHosts.custom.placeholder), + "me@example.com", + ); + await user.click( + screen.getByRole("button", { + name: enSettings.remoteHosts.actions.connect, + }), + ); + expect(await screen.findByText("Could not reach host")).toBeInTheDocument(); + }); +}); diff --git a/src/features/search/ui/SearchView.tsx b/src/features/search/ui/SearchView.tsx index 06c39a160..e6ec2ae6f 100644 --- a/src/features/search/ui/SearchView.tsx +++ b/src/features/search/ui/SearchView.tsx @@ -27,6 +27,8 @@ import { } from "@/features/settings/ui/settingsSections"; import { useProfileCapabilities } from "@/shared/profile/capabilities"; import { telemetryConsentEnforced } from "@/shared/telemetry/consent"; +import { REMOTE_SSH_SESSIONS_EXPERIMENT_ID } from "@/features/experiments/experimentDefinitions"; +import { useExperiment } from "@/features/experiments/experimentPreferences"; import { useChatStore } from "@/features/chat/stores/chatStore"; import { selectLocalMessageCountsBySession } from "@/features/chat/stores/chatSelectors"; import { @@ -111,6 +113,8 @@ export function SearchView({ ]); const { formatRelativeTimeToNow } = useLocaleFormatting(); const capabilities = useProfileCapabilities(); + const remoteSshSessionsEnabled = + useExperiment(REMOTE_SSH_SESSIONS_EXPERIMENT_ID)?.enabled === true; const visibleSettingsSections = useMemo( () => getVisibleSettingsSections(capabilities), [capabilities], @@ -204,12 +208,16 @@ export function SearchView({ ...(telemetryConsentEnforced() || !capabilities.telemetry ? ["telemetry"] : []), + // The remote SSH hosts card only renders on the connections page + // while its experiment is on (RemoteHostsSettings returns null). + ...(remoteSshSessionsEnabled ? [] : ["remote-ssh-hosts"]), ], }), [ capabilities.agentTools, capabilities.telemetry, onOpenSettings, + remoteSshSessionsEnabled, t, trimmedDebouncedQuery, visibleSettingsSections, diff --git a/src/features/settings/ui/settingsSearchItems.ts b/src/features/settings/ui/settingsSearchItems.ts index 6e05ad3a6..846e10a07 100644 --- a/src/features/settings/ui/settingsSearchItems.ts +++ b/src/features/settings/ui/settingsSearchItems.ts @@ -114,6 +114,13 @@ export const SETTINGS_SEARCH_ITEMS: readonly SettingsSearchItem[] = [ sectionId: "connections", labelKey: "nav.connections", }, + // Hidden via hiddenItemIds when the remote-ssh-sessions experiment is off, + // mirroring the card itself (RemoteHostsSettings renders null there). + { + id: "remote-ssh-hosts", + sectionId: "connections", + labelKey: "remoteHosts.title", + }, { id: "providers", sectionId: "providers", diff --git a/src/shared/api/__tests__/acp.test.ts b/src/shared/api/__tests__/acp.test.ts index 3c66393c8..8bed6a1d4 100644 --- a/src/shared/api/__tests__/acp.test.ts +++ b/src/shared/api/__tests__/acp.test.ts @@ -837,6 +837,35 @@ describe("acpCreateSession", () => { expect(sessionRegistry.isSessionPrepared("acp-session-1")).toBe(true); }); + it("routes creation to the host's SSH backend when remoteHost is set", async () => { + mockNewSession.mockResolvedValue({ sessionId: "acp-session-remote" }); + + const { acpCreateSession } = await import("../acp"); + + await acpCreateSession("openai", "/home/dev/project", { + modelId: "gpt-4.1", + remoteHost: "devbox", + }); + + expect(mockNewSession).toHaveBeenCalledWith("/home/dev/project", { + providerId: "openai", + projectId: undefined, + personaId: undefined, + backendId: "ssh:devbox", + }); + }); + + it("does not send a backendId for local sessions", async () => { + mockNewSession.mockResolvedValue({ sessionId: "acp-session-local" }); + + const { acpCreateSession } = await import("../acp"); + + await acpCreateSession("openai", "/tmp/project", { modelId: "gpt-4.1" }); + + const [, newSessionOptions] = mockNewSession.mock.calls[0]; + expect(newSessionOptions).not.toHaveProperty("backendId"); + }); + it("sends a concrete provider even when provider setup is deferred", async () => { mockNewSession.mockResolvedValue({ sessionId: "acp-session-1" }); diff --git a/src/shared/api/__tests__/acpApi.test.ts b/src/shared/api/__tests__/acpApi.test.ts index 323d7728d..a0de296f0 100644 --- a/src/shared/api/__tests__/acpApi.test.ts +++ b/src/shared/api/__tests__/acpApi.test.ts @@ -56,6 +56,7 @@ function createConfigOptionsResponse() { vi.mock("../acpConnection", () => ({ getClient: (...args: unknown[]) => mocks.getClient(...args), + getBackendClient: (...args: unknown[]) => mocks.getClient(...args), interceptSessionNotifications: (...args: unknown[]) => mocks.interceptSessionNotifications(...args), })); diff --git a/src/shared/api/__tests__/acpBackends.test.ts b/src/shared/api/__tests__/acpBackends.test.ts new file mode 100644 index 000000000..b570991f5 --- /dev/null +++ b/src/shared/api/__tests__/acpBackends.test.ts @@ -0,0 +1,235 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { + LOCAL_BACKEND_ID, + backendIdForSession, + remoteHostFromBackendId, + sshBackendId, +} from "../acpBackendId"; + +interface FakeClient { + initialize: ReturnType; + closed: Promise; + resolveClosed: () => void; +} + +const mocks = vi.hoisted(() => ({ + invoke: vi.fn(), + connectRemoteHost: vi.fn(), + createWebSocketStream: vi.fn(), +})); + +vi.mock("@tauri-apps/api/core", () => ({ + invoke: (...args: unknown[]) => mocks.invoke(...args), +})); + +vi.mock("../createWebSocketStream", () => ({ + createWebSocketStream: (...args: unknown[]) => + mocks.createWebSocketStream(...args), +})); + +vi.mock("../remoteHosts", () => ({ + connectRemoteHost: (...args: unknown[]) => mocks.connectRemoteHost(...args), +})); + +vi.mock("@agentclientprotocol/sdk", () => ({ + PROTOCOL_VERSION: 1, +})); + +vi.mock("@aaif/goose-sdk", () => ({ + DEFAULT_GOOSE_MCP_HOST_CAPABILITIES: {}, + GooseClient: class { + initialize = vi.fn(async () => {}); + closed: Promise; + resolveClosed!: () => void; + constructor(_callbacks: unknown, _stream: unknown) { + this.closed = new Promise((resolve) => { + this.resolveClosed = resolve; + }); + } + }, +})); + +async function importConnection() { + return import("../acpConnection"); +} + +async function importSessionBackends() { + return import("../acpSessionBackends"); +} + +/** Lets the closed-monitor promise chain run before asserting. */ +function flushClosedMonitor() { + return new Promise((resolve) => setTimeout(resolve, 0)); +} + +beforeEach(() => { + vi.resetModules(); + vi.clearAllMocks(); + mocks.invoke.mockResolvedValue("ws://local"); + mocks.connectRemoteHost.mockResolvedValue({ + wsUrl: "ws://remote", + httpBaseUrl: "http://remote", + secretKey: "secret", + localPort: 4242, + }); + mocks.createWebSocketStream.mockImplementation(() => ({ + writable: { abort: vi.fn().mockResolvedValue(undefined) }, + })); +}); + +describe("acpBackendId", () => { + it("builds ssh backend ids from trimmed hosts", () => { + expect(sshBackendId(" dev-box ")).toBe("ssh:dev-box"); + }); + + it("extracts the remote host, or null for local", () => { + expect(remoteHostFromBackendId(LOCAL_BACKEND_ID)).toBeNull(); + expect(remoteHostFromBackendId("ssh:dev-box")).toBe("dev-box"); + }); + + it("derives a session's backend id from its remote host", () => { + expect(backendIdForSession(null)).toBe("local"); + expect(backendIdForSession(undefined)).toBe("local"); + expect(backendIdForSession({})).toBe("local"); + expect(backendIdForSession({ remoteHost: null })).toBe("local"); + expect(backendIdForSession({ remoteHost: " " })).toBe("local"); + expect(backendIdForSession({ remoteHost: " dev-box " })).toBe( + "ssh:dev-box", + ); + }); +}); + +describe("backend connection registry", () => { + it("memoizes one connection per backend id", async () => { + const conn = await importConnection(); + + expect(conn.getBackendConnection("local")).toBe( + conn.getBackendConnection("local"), + ); + expect(conn.getBackendConnection("ssh:host-a")).toBe( + conn.getBackendConnection("ssh:host-a"), + ); + expect(conn.getBackendConnection("ssh:host-a")).not.toBe( + conn.getBackendConnection("local"), + ); + expect(conn.getBackendConnection("ssh:host-a")).not.toBe( + conn.getBackendConnection("ssh:host-b"), + ); + }); + + it("serves getClient() from the local backend connection", async () => { + const conn = await importConnection(); + + const legacyClient = await conn.getClient(); + const backendClient = await conn.getBackendClient("local"); + + expect(backendClient).toBe(legacyClient); + expect(conn.getClientSync()).toBe(legacyClient); + expect(conn.isClientReady()).toBe(true); + expect(mocks.invoke).toHaveBeenCalledTimes(1); + expect(mocks.invoke).toHaveBeenCalledWith("get_goose_serve_url"); + expect(mocks.connectRemoteHost).not.toHaveBeenCalled(); + }); + + it("dials ssh backends through connectRemoteHost", async () => { + const conn = await importConnection(); + + await conn.getBackendClient("ssh:dev-box"); + + expect(mocks.connectRemoteHost).toHaveBeenCalledWith("dev-box"); + expect(mocks.createWebSocketStream).toHaveBeenCalledWith("ws://remote"); + expect(mocks.invoke).not.toHaveBeenCalled(); + }); + + it("re-runs the ws-url resolver after the connection closes", async () => { + vi.spyOn(console, "warn").mockImplementation(() => {}); + const conn = await importConnection(); + const first = (await conn.getClient()) as unknown as FakeClient; + const onClosed = vi.fn(); + conn.getBackendConnection("local").onClosed(onClosed); + + first.resolveClosed(); + await flushClosedMonitor(); + + expect(onClosed).toHaveBeenCalledOnce(); + expect(conn.isClientReady()).toBe(false); + expect(conn.getClientSync()).toBeNull(); + + const second = await conn.getClient(); + expect(second).not.toBe(first); + expect(mocks.invoke).toHaveBeenCalledTimes(2); + }); + + it("invalidates one backend without clearing the others", async () => { + const conn = await importConnection(); + const localClient = await conn.getClient(); + const remoteClient = await conn.getBackendClient("ssh:dev-box"); + const remoteStream = mocks.createWebSocketStream.mock.results.at(-1) + ?.value as { writable: { abort: ReturnType } }; + + await conn.invalidateBackendConnection("ssh:dev-box"); + + expect(remoteStream.writable.abort).toHaveBeenCalledOnce(); + expect(await conn.getClient()).toBe(localClient); + expect(mocks.invoke).toHaveBeenCalledTimes(1); + + const remoteAgain = await conn.getBackendClient("ssh:dev-box"); + expect(remoteAgain).not.toBe(remoteClient); + expect(mocks.connectRemoteHost).toHaveBeenCalledTimes(2); + }); + + it("delegates invalidateClientConnection to the local backend only", async () => { + const conn = await importConnection(); + const localClient = await conn.getClient(); + const remoteClient = await conn.getBackendClient("ssh:dev-box"); + + await conn.invalidateClientConnection(); + + expect(await conn.getBackendClient("ssh:dev-box")).toBe(remoteClient); + const localAgain = await conn.getClient(); + expect(localAgain).not.toBe(localClient); + expect(mocks.invoke).toHaveBeenCalledTimes(2); + }); +}); + +describe("session backend routing", () => { + it("defaults unregistered sessions to the local backend", async () => { + const conn = await importConnection(); + const sessions = await importSessionBackends(); + + expect(sessions.getSessionBackend("unknown-session")).toBe("local"); + const client = await sessions.getClientForSession("unknown-session"); + expect(client).toBe(await conn.getClient()); + expect(mocks.connectRemoteHost).not.toHaveBeenCalled(); + }); + + it("registers, transfers, and unregisters session backends", async () => { + const sessions = await importSessionBackends(); + + sessions.registerSessionBackend("session-1", "ssh:dev-box"); + expect(sessions.getSessionBackend("session-1")).toBe("ssh:dev-box"); + + sessions.transferSessionBackend("session-1", "session-2"); + expect(sessions.getSessionBackend("session-2")).toBe("ssh:dev-box"); + + // Transferring from an unregistered session is a no-op. + sessions.transferSessionBackend("never-registered", "session-3"); + expect(sessions.getSessionBackend("session-3")).toBe("local"); + + sessions.unregisterSessionBackend("session-1"); + expect(sessions.getSessionBackend("session-1")).toBe("local"); + expect(sessions.getSessionBackend("session-2")).toBe("ssh:dev-box"); + }); + + it("routes getClientForSession through the registered backend", async () => { + const conn = await importConnection(); + const sessions = await importSessionBackends(); + sessions.registerSessionBackend("session-1", "ssh:dev-box"); + + const remoteClient = await sessions.getClientForSession("session-1"); + + expect(mocks.connectRemoteHost).toHaveBeenCalledWith("dev-box"); + expect(remoteClient).toBe(await conn.getBackendClient("ssh:dev-box")); + expect(mocks.invoke).not.toHaveBeenCalled(); + }); +}); diff --git a/src/shared/api/__tests__/acpSessionRegistry.test.ts b/src/shared/api/__tests__/acpSessionRegistry.test.ts index 38c34bb20..4798691f9 100644 --- a/src/shared/api/__tests__/acpSessionRegistry.test.ts +++ b/src/shared/api/__tests__/acpSessionRegistry.test.ts @@ -17,7 +17,8 @@ const noRequestModelContext = (providerId: string) => ({ }); vi.mock("../acpConnection", () => ({ - invalidateClientConnection: (...args: unknown[]) => + getBackendClient: vi.fn(), + invalidateBackendConnection: (...args: unknown[]) => mockInvalidateClientConnection(...args), })); @@ -356,6 +357,7 @@ describe("applySessionModel", () => { await expect(reasoning).rejects.toThrow("ACP operation timed out"); await expect(load).resolves.toMatchObject({ isCurrent: true }); expect(mockInvalidateClientConnection).toHaveBeenCalledOnce(); + expect(mockInvalidateClientConnection).toHaveBeenCalledWith("local"); expect(mockLoadSession).toHaveBeenCalledOnce(); expect(registry.getPreparedProviderId("session-1")).toBe("openai"); } finally { diff --git a/src/shared/api/acp.ts b/src/shared/api/acp.ts index 5edd92628..29925b06d 100644 --- a/src/shared/api/acp.ts +++ b/src/shared/api/acp.ts @@ -6,6 +6,7 @@ import type { AcpSessionsPage, AcpSteerResponse, } from "./acpApi"; +import { sshBackendId } from "./acpBackendId"; import * as sessionRegistry from "./acpSessionRegistry"; import type { AcpSessionExecutionSelection } from "./acpSessionRegistry"; import { @@ -70,6 +71,12 @@ export interface AcpCreateSessionOptions { projectId?: string; modelId?: string | null; deferProviderSetup?: boolean; + /** + * SSH host to create the session on. When set, the session is created on + * that host's remote backend instead of the local `goose serve` sidecar; + * every later per-session call routes by the session's registered backend. + */ + remoteHost?: string; } export interface AcpSessionConfigApplyOptions { @@ -424,10 +431,12 @@ export async function acpCreateSession( options.deferProviderSetup === true && !options.modelId && providerId === "goose"; + const remoteHost = options.remoteHost?.trim(); const response = await directAcp.newSession(workingDir, { providerId: deferProviderSetup ? undefined : providerId, projectId: options.projectId, personaId: options.personaId, + ...(remoteHost ? { backendId: sshBackendId(remoteHost) } : {}), }); const sessionId = response.sessionId; let rollbackSessionRegistration: (() => void) | undefined; diff --git a/src/shared/api/acpApi.ts b/src/shared/api/acpApi.ts index ed2bdcf43..fa8fe9bc4 100644 --- a/src/shared/api/acpApi.ts +++ b/src/shared/api/acpApi.ts @@ -10,7 +10,17 @@ import type { import { messageSnippet } from "@/features/chat/lib/messageSnippet"; import { getCuratedAgentProviders } from "@/features/providers/curatedProviders"; import { toWireProviderId } from "./acpPersonaHandoff"; -import { getClient, interceptSessionNotifications } from "./acpConnection"; +import { LOCAL_BACKEND_ID, type AcpBackendId } from "./acpBackendId"; +import { + getBackendClient, + getClient, + interceptSessionNotifications, +} from "./acpConnection"; +import { + getClientForSession, + getSessionBackend, + registerSessionBackend, +} from "./acpSessionBackends"; import { applySessionConfigOptionsSnapshot, readSessionConfigOptionsSnapshots, @@ -135,7 +145,7 @@ function mapSessionInfo(info: SessionInfo): AcpSessionInfo { export async function getSessionInfo( sessionId: string, ): Promise { - const client = await getClient(); + const client = await getClientForSession(sessionId); const result = await client.goose.GooseUnstableSessionInfo({ sessionId }); return mapSessionInfo(result.session as unknown as SessionInfo); } @@ -143,13 +153,15 @@ export async function getSessionInfo( export async function listSessionsPage({ cursor, query, + backendId, }: { cursor?: string | null; /** Keyword filter for goose's server-side message-content search * (`_meta.query`). Only set when searching; omit for plain listing. */ query?: string | null; + backendId?: AcpBackendId; } = {}): Promise { - const client = await getClient(); + const client = await getBackendClient(backendId ?? LOCAL_BACKEND_ID); const normalizedCursor = cursor?.trim() || null; const normalizedQuery = query?.trim() || null; // ACP session/list only standardizes cwd and cursor filters. Goose project @@ -172,13 +184,14 @@ export async function listSessionsPage({ } export async function exportSession(sessionId: string): Promise { - const client = await getClient(); + const client = await getClientForSession(sessionId); const result = await client.goose.GooseUnstableSessionExport({ sessionId }); // biome-ignore lint/suspicious/noExplicitAny: SDK doesn't expose data field on export result return (result as any).data; } export async function importSession(json: string): Promise { + // App-scoped: imports always land on the local backend. const client = await getClient(); const result = await client.goose.GooseUnstableSessionImport({ input: json, @@ -200,7 +213,8 @@ export async function forkSession( workingDir: string, options: AcpForkSessionOptions = {}, ): Promise { - const client = await getClient(); + const backendId = getSessionBackend(sessionId); + const client = await getBackendClient(backendId); const params: ForkSessionRequest = { sessionId, cwd: workingDir, @@ -211,6 +225,7 @@ export async function forkSession( } const response = await client.unstable_forkSession(params); + registerSessionBackend(response.sessionId, backendId); return { sessionId: response.sessionId, title: null, @@ -236,7 +251,7 @@ export async function setModel( ): Promise { const sid = sessionId.slice(0, 8); const tClient = performance.now(); - const client = await getClient(); + const client = await getClientForSession(sessionId); const tCall = performance.now(); const response = await client.setSessionConfigOption({ sessionId, @@ -272,7 +287,7 @@ export async function setSessionConfigOption( ): Promise { const sid = sessionId.slice(0, 8); const tClient = performance.now(); - const client = await getClient(); + const client = await getClientForSession(sessionId); const tCall = performance.now(); const response = await client.setSessionConfigOption({ sessionId, @@ -307,7 +322,7 @@ export async function setProvider( ): Promise { const sid = sessionId.slice(0, 8); const tClient = performance.now(); - const client = await getClient(); + const client = await getClientForSession(sessionId); const wireProvider = toWireProviderId(providerId); const tCall = performance.now(); const response = await client.setSessionConfigOption({ @@ -343,7 +358,7 @@ export async function updateWorkingDir( workingDir: string, beforeUpdate?: () => void, ): Promise { - const client = await getClient(); + const client = await getClientForSession(sessionId); // Run guards after the asynchronous client lookup and synchronously before // dispatching the mutation. This lets callers close local state races // without exposing the ACP client or duplicating the wire operation. @@ -358,7 +373,7 @@ export async function setSessionSystemPrompt( sessionId: string, text: string, ): Promise { - const client = await getClient(); + const client = await getClientForSession(sessionId); await client.extMethod("_goose/unstable/session/system-prompt/set", { sessionId, mode: "set", @@ -371,7 +386,7 @@ export async function appendSessionSystemPrompt( key: string, text: string, ): Promise { - const client = await getClient(); + const client = await getClientForSession(sessionId); await client.extMethod("_goose/unstable/session/system-prompt/set", { sessionId, mode: "append", @@ -384,7 +399,7 @@ export async function updateSessionProject( sessionId: string, projectId: string | null, ): Promise { - const client = await getClient(); + const client = await getClientForSession(sessionId); await client.goose.GooseUnstableSessionProjectUpdate({ sessionId, projectId, @@ -392,17 +407,17 @@ export async function updateSessionProject( } export async function archiveSession(sessionId: string): Promise { - const client = await getClient(); + const client = await getClientForSession(sessionId); await client.goose.GooseUnstableSessionArchive({ sessionId }); } export async function deleteSession(sessionId: string): Promise { - const client = await getClient(); + const client = await getClientForSession(sessionId); await client.extMethod("session/delete", { sessionId }); } export async function unarchiveSession(sessionId: string): Promise { - const client = await getClient(); + const client = await getClientForSession(sessionId); await client.goose.GooseUnstableSessionUnarchive({ sessionId }); } @@ -410,12 +425,12 @@ export async function renameSession( sessionId: string, title: string, ): Promise { - const client = await getClient(); + const client = await getClientForSession(sessionId); await client.goose.GooseUnstableSessionRename({ sessionId, title }); } export async function cancelSession(sessionId: string): Promise { - const client = await getClient(); + const client = await getClientForSession(sessionId); await client.cancel({ sessionId }); } @@ -424,6 +439,7 @@ export interface NewSessionOptions { projectId?: string; personaId?: string; hidden?: boolean; + backendId?: AcpBackendId; } export async function newSession( @@ -431,8 +447,9 @@ export async function newSession( options: NewSessionOptions = {}, ): Promise { const { providerId, projectId, personaId, hidden } = options; + const backendId = options.backendId ?? LOCAL_BACKEND_ID; const tClient = performance.now(); - const client = await getClient(); + const client = await getBackendClient(backendId); const request: Parameters[0] = { cwd: workingDir, mcpServers: [], @@ -447,6 +464,7 @@ export async function newSession( const tCall = performance.now(); const response = await client.newSession(request); + registerSessionBackend(response.sessionId, backendId); const sid = response.sessionId.slice(0, 8); perfLog( `[perf:api] ${sid} newSession getClient=${(tCall - tClient).toFixed(1)}ms wire=${(performance.now() - tCall).toFixed(1)}ms`, @@ -460,7 +478,7 @@ export async function loadSession( ): Promise { const sid = sessionId.slice(0, 8); const tClient = performance.now(); - const client = await getClient(); + const client = await getClientForSession(sessionId); const tCall = performance.now(); const response = await client.loadSession({ sessionId, @@ -491,7 +509,7 @@ export async function prompt( onPromptDispatched?: () => void; } = {}, ): Promise { - const client = await getClient(); + const client = await getClientForSession(sessionId); callbacks.onPromptDispatching?.(); const promptPromise = client.prompt({ sessionId, @@ -596,7 +614,7 @@ export async function steerSession( expectedRunId: string | null, meta?: Record, ): Promise { - const client = await getClient(); + const client = await getClientForSession(sessionId); const steer = async (runId: string): Promise => { const response = await client.extMethod("_goose/unstable/session/steer", { sessionId, diff --git a/src/shared/api/acpBackendId.ts b/src/shared/api/acpBackendId.ts new file mode 100644 index 000000000..19aac32c0 --- /dev/null +++ b/src/shared/api/acpBackendId.ts @@ -0,0 +1,25 @@ +/** + * Identifies which ACP backend a connection or session belongs to. The local + * `goose serve` sidecar is `"local"`; SSH-remote backends are keyed by host. + */ +export type AcpBackendId = "local" | `ssh:${string}`; + +export const LOCAL_BACKEND_ID: AcpBackendId = "local"; + +const SSH_PREFIX = "ssh:"; + +export function sshBackendId(host: string): AcpBackendId { + return `${SSH_PREFIX}${host.trim()}`; +} + +/** Host component of an SSH backend id, or null for the local backend. */ +export function remoteHostFromBackendId(id: AcpBackendId): string | null { + return id === LOCAL_BACKEND_ID ? null : id.slice(SSH_PREFIX.length); +} + +export function backendIdForSession( + session: { remoteHost?: string | null } | null | undefined, +): AcpBackendId { + const host = session?.remoteHost?.trim(); + return host ? sshBackendId(host) : LOCAL_BACKEND_ID; +} diff --git a/src/shared/api/acpConnection.ts b/src/shared/api/acpConnection.ts index 0f69884f5..a58cc0d1b 100644 --- a/src/shared/api/acpConnection.ts +++ b/src/shared/api/acpConnection.ts @@ -12,6 +12,11 @@ import { type RequestPermissionResponse, } from "@agentclientprotocol/sdk"; import packageJson from "../../../package.json"; +import { + LOCAL_BACKEND_ID, + remoteHostFromBackendId, + type AcpBackendId, +} from "./acpBackendId"; import { createWebSocketStream } from "./createWebSocketStream"; import { perfLog } from "@/shared/lib/perfLog"; @@ -58,10 +63,9 @@ export function setPermissionHandler(handler: PermissionRequestHandler): void { permissionHandler = handler; } -let clientPromise: Promise | null = null; -let resolvedClient: GooseClient | null = null; -let activeStream: ReturnType | null = null; - +// The handler slots above are shared by every backend connection: downstream +// routing is sessionId-keyed, so notifications from any backend flow through +// the same callbacks. function createClientCallbacks(): () => Client { return () => ({ requestPermission: async ( @@ -92,49 +96,148 @@ function createClientCallbacks(): () => Client { }); } -function monitorConnection( - client: GooseClient, - stream: ReturnType, -): void { - const clearCurrentConnection = () => { - if (activeStream !== stream) { - return; - } +export interface AcpConnection { + getClient(): Promise; + getClientSync(): GooseClient | null; + isReady(): boolean; + /** + * Abort the current transport after an ACP request exceeds its liveness + * bound. A timed-out request leaves the connection state unknowable; + * reconnecting is safer than allowing later mutations to race work still + * running remotely. + */ + invalidate(): Promise; + /** Notifies when the active transport closes. Returns an unsubscribe. */ + onClosed(cb: () => void): () => void; +} + +export function createAcpConnection( + resolveWsUrl: () => Promise, +): AcpConnection { + let clientPromise: Promise | null = null; + let resolvedClient: GooseClient | null = null; + let activeStream: ReturnType | null = null; + const closedListeners = new Set<() => void>(); + + function monitorConnection( + client: GooseClient, + stream: ReturnType, + ): void { + const clearCurrentConnection = () => { + if (activeStream !== stream) { + return; + } + resolvedClient = null; + clientPromise = null; + activeStream = null; + for (const listener of closedListeners) { + listener(); + } + }; + client.closed + .then(() => { + console.warn( + "[acp] Connection closed. Will reconnect on next getClient().", + ); + clearCurrentConnection(); + }) + .catch(() => { + console.warn( + "[acp] Connection error. Will reconnect on next getClient().", + ); + clearCurrentConnection(); + }); + } + + async function invalidate(): Promise { + const stream = activeStream; + activeStream = null; resolvedClient = null; clientPromise = null; - activeStream = null; - }; - client.closed - .then(() => { - console.warn( - "[acp] Connection closed. Will reconnect on next getClient().", - ); - clearCurrentConnection(); - }) - .catch(() => { - console.warn( - "[acp] Connection error. Will reconnect on next getClient().", + if (stream) { + await stream.writable.abort(); + } + } + + async function initializeConnection(): Promise { + const tStart = performance.now(); + const wsUrl = await resolveWsUrl(); + perfLog( + `[perf:conn] get_goose_serve_url in ${(performance.now() - tStart).toFixed(1)}ms`, + ); + + const tStream = performance.now(); + const stream = createWebSocketStream(wsUrl); + activeStream = stream; + + const client = new GooseClient(createClientCallbacks(), stream); + perfLog( + `[perf:conn] ws stream + client created in ${(performance.now() - tStream).toFixed(1)}ms`, + ); + + const tInit = performance.now(); + await client.initialize({ + protocolVersion: PROTOCOL_VERSION, + clientCapabilities: { + _meta: { + goose: { + mcpHostCapabilities: DEFAULT_GOOSE_MCP_HOST_CAPABILITIES, + toolCallLabelEnrichment: true, + }, + }, + }, + clientInfo: { + name: packageJson.name, + version: packageJson.version, + }, + } satisfies GooseInitializeRequest); + perfLog( + `[perf:conn] client.initialize in ${(performance.now() - tInit).toFixed(1)}ms (total ${(performance.now() - tStart).toFixed(1)}ms)`, + ); + + monitorConnection(client, stream); + + return client; + } + + async function getClient(): Promise { + if (resolvedClient) { + return resolvedClient; + } + + if (!clientPromise) { + perfLog("[perf:conn] getClient() → initializing new ACP connection"); + clientPromise = initializeConnection() + .then((client) => { + resolvedClient = client; + return client; + }) + .catch((error) => { + clientPromise = null; + throw error; + }); + } else { + perfLog( + "[perf:conn] getClient() awaiting in-flight initializeConnection", ); - clearCurrentConnection(); - }); -} + } -/** - * Abort the current transport after an ACP request exceeds its liveness bound. - * A timed-out request leaves the connection state unknowable; reconnecting is - * safer than allowing later mutations to race work still running remotely. - */ -export async function invalidateClientConnection(): Promise { - const stream = activeStream; - activeStream = null; - resolvedClient = null; - clientPromise = null; - if (stream) { - await stream.writable.abort(); + return clientPromise; } + + return { + getClient, + getClientSync: () => resolvedClient, + isReady: () => resolvedClient !== null, + invalidate, + onClosed: (cb: () => void) => { + closedListeners.add(cb); + return () => closedListeners.delete(cb); + }, + }; } -async function initializeConnection(): Promise { +async function resolveLocalWsUrl(): Promise { // Dev-only: inject a real failure into startup so the WARP probe runs // for real against kgoose. `VITE_DEV_STARTUP_ERROR=warp just dev` lets // us experience the diagnostic UI with whatever real WARP state the @@ -159,73 +262,64 @@ async function initializeConnection(): Promise { } } - const tStart = performance.now(); - const wsUrl: string = await invoke("get_goose_serve_url"); - perfLog( - `[perf:conn] get_goose_serve_url in ${(performance.now() - tStart).toFixed(1)}ms`, - ); - - const tStream = performance.now(); - const stream = createWebSocketStream(wsUrl); - activeStream = stream; - - const client = new GooseClient(createClientCallbacks(), stream); - perfLog( - `[perf:conn] ws stream + client created in ${(performance.now() - tStream).toFixed(1)}ms`, - ); - - const tInit = performance.now(); - await client.initialize({ - protocolVersion: PROTOCOL_VERSION, - clientCapabilities: { - _meta: { - goose: { - mcpHostCapabilities: DEFAULT_GOOSE_MCP_HOST_CAPABILITIES, - toolCallLabelEnrichment: true, - }, - }, - }, - clientInfo: { - name: packageJson.name, - version: packageJson.version, - }, - } satisfies GooseInitializeRequest); - perfLog( - `[perf:conn] client.initialize in ${(performance.now() - tInit).toFixed(1)}ms (total ${(performance.now() - tStart).toFixed(1)}ms)`, - ); + return await invoke("get_goose_serve_url"); +} - monitorConnection(client, stream); +const connections = new Map(); - return client; +function createBackendConnection(backendId: AcpBackendId): AcpConnection { + if (backendId === LOCAL_BACKEND_ID) { + return createAcpConnection(resolveLocalWsUrl); + } + const host = remoteHostFromBackendId(backendId); + if (!host) { + throw new Error(`Unknown ACP backend id: ${backendId}`); + } + return createAcpConnection(async () => { + const { connectRemoteHost } = await import("./remoteHosts"); + const remote = await connectRemoteHost(host); + return remote.wsUrl; + }); } -export async function getClient(): Promise { - if (resolvedClient) { - return resolvedClient; +export function getBackendConnection(backendId: AcpBackendId): AcpConnection { + let connection = connections.get(backendId); + if (!connection) { + connection = createBackendConnection(backendId); + connections.set(backendId, connection); } + return connection; +} - if (!clientPromise) { - perfLog("[perf:conn] getClient() → initializing new ACP connection"); - clientPromise = initializeConnection() - .then((client) => { - resolvedClient = client; - return client; - }) - .catch((error) => { - clientPromise = null; - throw error; - }); - } else { - perfLog("[perf:conn] getClient() awaiting in-flight initializeConnection"); - } +export function getBackendClient( + backendId: AcpBackendId, +): Promise { + return getBackendConnection(backendId).getClient(); +} + +export async function invalidateBackendConnection( + backendId: AcpBackendId, +): Promise { + await connections.get(backendId)?.invalidate(); +} - return clientPromise; +/** + * Abort the current transport after an ACP request exceeds its liveness bound. + * A timed-out request leaves the connection state unknowable; reconnecting is + * safer than allowing later mutations to race work still running remotely. + */ +export async function invalidateClientConnection(): Promise { + await invalidateBackendConnection(LOCAL_BACKEND_ID); +} + +export async function getClient(): Promise { + return getBackendConnection(LOCAL_BACKEND_ID).getClient(); } export function isClientReady(): boolean { - return resolvedClient !== null; + return getBackendConnection(LOCAL_BACKEND_ID).isReady(); } export function getClientSync(): GooseClient | null { - return resolvedClient; + return getBackendConnection(LOCAL_BACKEND_ID).getClientSync(); } diff --git a/src/shared/api/acpSessionBackends.ts b/src/shared/api/acpSessionBackends.ts new file mode 100644 index 000000000..5f32b5cbf --- /dev/null +++ b/src/shared/api/acpSessionBackends.ts @@ -0,0 +1,39 @@ +import type { GooseClient } from "@aaif/goose-sdk"; +import { LOCAL_BACKEND_ID, type AcpBackendId } from "./acpBackendId"; +import { getBackendClient } from "./acpConnection"; + +/** + * Maps ACP session ids to the backend connection that owns them. Sessions + * without an entry belong to the local backend. + */ +const sessionBackends = new Map(); + +export function registerSessionBackend( + sessionId: string, + backendId: AcpBackendId, +): void { + sessionBackends.set(sessionId, backendId); +} + +export function getSessionBackend(sessionId: string): AcpBackendId { + return sessionBackends.get(sessionId) ?? LOCAL_BACKEND_ID; +} + +/** No-op when the source session is unregistered (implicitly local). */ +export function transferSessionBackend( + fromSessionId: string, + toSessionId: string, +): void { + const backendId = sessionBackends.get(fromSessionId); + if (backendId !== undefined) { + sessionBackends.set(toSessionId, backendId); + } +} + +export function unregisterSessionBackend(sessionId: string): void { + sessionBackends.delete(sessionId); +} + +export function getClientForSession(sessionId: string): Promise { + return getBackendClient(getSessionBackend(sessionId)); +} diff --git a/src/shared/api/acpSessionRegistry.ts b/src/shared/api/acpSessionRegistry.ts index 83c6d0c33..63740f82c 100644 --- a/src/shared/api/acpSessionRegistry.ts +++ b/src/shared/api/acpSessionRegistry.ts @@ -1,5 +1,6 @@ import * as acpApi from "./acpApi"; -import { invalidateClientConnection } from "./acpConnection"; +import { invalidateBackendConnection } from "./acpConnection"; +import { getSessionBackend } from "./acpSessionBackends"; import { readSessionExecutionConfigSnapshot, type AcpSessionConfigSnapshotContext, @@ -84,12 +85,14 @@ async function runBoundedSessionMutation( } catch (error) { if (didTimeOut) { prepared.delete(sessionId); - await invalidateClientConnection().catch((invalidationError) => { - console.error( - "Failed to invalidate timed-out ACP connection:", - invalidationError, - ); - }); + await invalidateBackendConnection(getSessionBackend(sessionId)).catch( + (invalidationError) => { + console.error( + "Failed to invalidate timed-out ACP connection:", + invalidationError, + ); + }, + ); } throw error; } finally { diff --git a/src/shared/api/remoteHosts.ts b/src/shared/api/remoteHosts.ts new file mode 100644 index 000000000..926e94bed --- /dev/null +++ b/src/shared/api/remoteHosts.ts @@ -0,0 +1,144 @@ +import { invoke } from "@tauri-apps/api/core"; +import { listen, type UnlistenFn } from "@tauri-apps/api/event"; + +/** + * Error shape thrown by the remote-backend Tauri commands. `kind` is a + * kebab-case discriminator such as "auth-failed", "goose-not-installed", + * "invalid-host", "host-key-unverified", "host-unreachable", "ssh-not-found", + * "ready-timeout", "tunnel-closed", "remote-script-failed", + * "local-port-bind-failed", "remote-port-bind-failed", or "internal". + */ +export interface RemoteBackendErrorLike { + kind: string; + message: string; +} + +export function isRemoteBackendError(x: unknown): x is RemoteBackendErrorLike { + if (!x || typeof x !== "object") return false; + const candidate = x as Partial; + return ( + typeof candidate.kind === "string" && typeof candidate.message === "string" + ); +} + +export interface RemoteBackendConnection { + wsUrl: string; + httpBaseUrl: string; + secretKey: string; + localPort: number; + gooseVersion: string; + daemonReused: boolean; +} + +export type RemoteBackendState = + | "connecting" + | "ready" + | "reconnecting" + | "disconnected" + | "failed"; + +// Mirrors the `berd:remote-backend-status` event emitted by the Rust +// remote-backend manager. `state` discriminates the flattened tagged union. +export const REMOTE_BACKEND_STATUS_EVENT = "berd:remote-backend-status"; + +export interface RemoteBackendStatusPayload { + host: string; + state: RemoteBackendState; + wsUrl?: string; + httpBaseUrl?: string; + localPort?: number; + attempt?: number; + error?: RemoteBackendErrorLike; +} + +/** One entry from the `list_remote_backends` snapshot. */ +export interface RemoteBackendSnapshotEntry { + host: string; + state: RemoteBackendState; + wsUrl?: string; + httpBaseUrl?: string; + localPort?: number; + attempt?: number; + error?: RemoteBackendErrorLike; +} + +/** Result of probing one agent binary on the remote host. */ +export interface RemoteToolProbe { + binary: string; + found: boolean; + version?: string; +} + +export interface RemoteDirEntry { + name: string; + isDir: boolean; +} + +export interface RemoteDirListing { + resolvedPath: string; + entries: RemoteDirEntry[]; +} + +/** Concrete `Host` aliases from `~/.ssh/config` (no wildcards). */ +export async function listSshConfigHosts(): Promise { + return await invoke("list_ssh_config_hosts"); +} + +/** + * Connect (or reuse the connection) to the Goose daemon on `host`. Idempotent + * per host; the Rust side serializes concurrent connects. + */ +export async function connectRemoteHost( + host: string, +): Promise { + return await invoke("remote_backend_connect", { host }); +} + +/** Tear down the local tunnel to `host`, leaving the remote daemon running. */ +export async function disconnectRemoteHost(host: string): Promise { + await invoke("remote_backend_disconnect", { host }); +} + +/** Stop the remote daemon on `host` and tear down the tunnel. */ +export async function shutdownRemoteHost(host: string): Promise { + await invoke("remote_backend_shutdown", { host }); +} + +/** Snapshot of all known remote backends and their current states. */ +export async function listRemoteBackends(): Promise< + RemoteBackendSnapshotEntry[] +> { + return await invoke("list_remote_backends"); +} + +/** Probe `host` for the agent binaries (goose, claude-agent-acp, codex-acp). */ +export async function checkRemoteHost( + host: string, +): Promise { + return await invoke("check_remote_host", { host }); +} + +/** + * List directories on `host`. `path` must be absolute or start with `~`. + */ +export async function listRemoteDirs( + host: string, + path: string, +): Promise { + return await invoke("list_remote_dirs", { host, path }); +} + +// Fires whenever a remote backend transitions state (connecting, ready, +// reconnecting, disconnected, failed). +export function listenRemoteBackendStatus( + handler: (payload: RemoteBackendStatusPayload) => void, +): Promise { + if (!window.__TAURI_INTERNALS__) { + return Promise.resolve(() => {}); + } + + return listen( + REMOTE_BACKEND_STATUS_EVENT, + (event) => handler(event.payload), + ); +} diff --git a/src/shared/i18n/locales/en/chat.json b/src/shared/i18n/locales/en/chat.json index 2b250d4e2..ab4cc5ee3 100644 --- a/src/shared/i18n/locales/en/chat.json +++ b/src/shared/i18n/locales/en/chat.json @@ -530,6 +530,36 @@ "viewMore": "View more", "switchAgent": "Switch agent", "noProject": "No project", + "remoteHost": { + "selectHost": "Select computer", + "chooseHost": "Where should this chat run?", + "thisComputer": "This computer", + "thisComputerDescription": "Run the session locally", + "sshHosts": "SSH hosts", + "localTriggerTitle": "Runs on this computer", + "remoteTriggerTitle": "Runs on {{host}} over SSH", + "missingDirectory": "Choose a folder on the remote host before sending", + "startFailed": "Couldn't start the remote session", + "status": { + "connecting": "Connecting...", + "ready": "Connected", + "reconnecting": "Reconnecting...", + "disconnected": "Disconnected", + "failed": "Connection failed" + }, + "directory": { + "chooseFolder": "Choose folder", + "selectDirectory": "Select remote folder", + "title": "Folder on {{host}}", + "pathLabel": "Remote path", + "pathPlaceholder": "~/ or /absolute/path", + "parent": "Up one level", + "empty": "No subfolders", + "loading": "Loading...", + "recents": "Recent folders", + "use": "Use this folder" + } + }, "selectModel": "Select model", "selectProject": "Select project", "sendMessage": "Send message", @@ -708,5 +738,16 @@ }, "agentBuilder": { "hideChat": "Hide chat" + }, + "remoteSessionGuards": { + "hostBadge": { + "tooltip": "Remote host {{host}}: {{state}}" + }, + "workspaceOnHost": "Folder on {{host}}", + "onHostChip": "on {{host}}", + "changesUnavailable": "Changes live on {{host}} and can't be shown from this machine yet.", + "filesUnavailable": "Files live on {{host}} and can't be browsed from this machine yet.", + "openUnavailable": "This file is on {{host}} and can't be opened from this machine.", + "viewerUnavailable": "This file lives on {{host}}. Viewing remote files isn't supported yet." } } diff --git a/src/shared/i18n/locales/en/settings.json b/src/shared/i18n/locales/en/settings.json index ac1618334..a3ff413c9 100644 --- a/src/shared/i18n/locales/en/settings.json +++ b/src/shared/i18n/locales/en/settings.json @@ -235,6 +235,10 @@ "description": "Pin saved prompts to Home that run with one click, optionally with an attached agent.", "title": "Prompt pins" }, + "remoteSshSessions": { + "description": "Run sessions on a remote machine over SSH using hosts from your SSH config. Berd starts and tunnels to a Goose daemon on the remote host.", + "title": "Remote SSH sessions" + }, "resetToAuto": "Reset to auto", "saveError": "Couldn't save the experiment setting. Try again.", "skillDiscovery": { @@ -690,6 +694,44 @@ "title": "Providers", "waitingForSignIn": "Waiting for sign-in..." }, + "remoteHosts": { + "actions": { + "check": "Check host", + "connect": "Connect", + "disconnect": "Disconnect", + "shutdown": "Stop remote backend" + }, + "custom": { + "description": "Connect to a machine that isn't in your SSH config.", + "emptyError": "Enter a host to connect to.", + "label": "Connect to another host", + "placeholder": "user@host" + }, + "description": "Hosts come from your ~/.ssh/config. Key or agent authentication must already work — check that `ssh ` succeeds in a terminal first.", + "doctor": { + "checking": "Checking host...", + "found": "Found", + "gooseMissing": "Install goose on this host to use it for remote sessions.", + "notFound": "Not found" + }, + "empty": "No hosts found in ~/.ssh/config.", + "shutdownConfirm": { + "cancel": "Cancel", + "confirm": "Stop backend", + "description": "This stops the agent daemon on the remote host and ends any remote work running through it.", + "stopping": "Stopping...", + "title": "Stop the remote backend on \"{{host}}\"?" + }, + "status": { + "connecting": "Connecting...", + "disconnected": "Disconnected", + "failed": "Failed", + "ready": "Connected", + "reconnecting": "Reconnecting...", + "reconnectingAttempt": "Reconnecting (attempt {{attempt}})..." + }, + "title": "Remote SSH hosts" + }, "runtimeConfig": { "clearError": "Couldn't clear fake endpoint response.", "clearSuccess": "Fake endpoint response cleared.", diff --git a/src/shared/i18n/locales/es/chat.json b/src/shared/i18n/locales/es/chat.json index 896eed292..8fe5af80c 100644 --- a/src/shared/i18n/locales/es/chat.json +++ b/src/shared/i18n/locales/es/chat.json @@ -527,6 +527,36 @@ "viewMore": "Ver más", "switchAgent": "Cambiar de agente", "noProject": "Sin proyecto", + "remoteHost": { + "selectHost": "Seleccionar equipo", + "chooseHost": "¿Dónde debería ejecutarse este chat?", + "thisComputer": "Este equipo", + "thisComputerDescription": "Ejecutar la sesión localmente", + "sshHosts": "Hosts SSH", + "localTriggerTitle": "Se ejecuta en este equipo", + "remoteTriggerTitle": "Se ejecuta en {{host}} por SSH", + "missingDirectory": "Elige una carpeta en el host remoto antes de enviar", + "startFailed": "No se pudo iniciar la sesión remota", + "status": { + "connecting": "Conectando...", + "ready": "Conectado", + "reconnecting": "Reconectando...", + "disconnected": "Desconectado", + "failed": "Error de conexión" + }, + "directory": { + "chooseFolder": "Elegir carpeta", + "selectDirectory": "Seleccionar carpeta remota", + "title": "Carpeta en {{host}}", + "pathLabel": "Ruta remota", + "pathPlaceholder": "~/ o /ruta/absoluta", + "parent": "Subir un nivel", + "empty": "Sin subcarpetas", + "loading": "Cargando...", + "recents": "Carpetas recientes", + "use": "Usar esta carpeta" + } + }, "selectModel": "Seleccionar modelo", "selectProject": "Seleccionar proyecto", "sendMessage": "Enviar mensaje", @@ -704,5 +734,16 @@ }, "agentBuilder": { "hideChat": "Ocultar chat" + }, + "remoteSessionGuards": { + "hostBadge": { + "tooltip": "Host remoto {{host}}: {{state}}" + }, + "workspaceOnHost": "Carpeta en {{host}}", + "onHostChip": "en {{host}}", + "changesUnavailable": "Los cambios están en {{host}} y todavía no se pueden mostrar desde esta máquina.", + "filesUnavailable": "Los archivos están en {{host}} y todavía no se pueden explorar desde esta máquina.", + "openUnavailable": "Este archivo está en {{host}} y no se puede abrir desde esta máquina.", + "viewerUnavailable": "Este archivo está en {{host}}. Ver archivos remotos todavía no es compatible." } } diff --git a/src/shared/i18n/locales/es/settings.json b/src/shared/i18n/locales/es/settings.json index 2a7682c56..2b6f34dcd 100644 --- a/src/shared/i18n/locales/es/settings.json +++ b/src/shared/i18n/locales/es/settings.json @@ -235,6 +235,10 @@ "description": "Fija prompts guardados en Inicio que se ejecutan con un clic, opcionalmente con un agente adjunto.", "title": "Pins de prompts" }, + "remoteSshSessions": { + "description": "Ejecuta sesiones en una máquina remota por SSH usando los hosts de tu configuración SSH. Berd inicia un daemon de Goose en el host remoto y crea un túnel hacia él.", + "title": "Sesiones SSH remotas" + }, "resetToAuto": "Restablecer a automático", "saveError": "No se pudo guardar el ajuste del experimento. Inténtalo de nuevo.", "skillDiscovery": { @@ -693,6 +697,44 @@ "title": "Proveedores", "waitingForSignIn": "Esperando inicio de sesión..." }, + "remoteHosts": { + "actions": { + "check": "Comprobar host", + "connect": "Conectar", + "disconnect": "Desconectar", + "shutdown": "Detener backend remoto" + }, + "custom": { + "description": "Conéctate a una máquina que no está en tu configuración SSH.", + "emptyError": "Introduce un host al que conectarte.", + "label": "Conectar a otro host", + "placeholder": "usuario@host" + }, + "description": "Los hosts provienen de tu ~/.ssh/config. La autenticación por clave o agente ya debe funcionar: comprueba primero que `ssh ` funciona en una terminal.", + "doctor": { + "checking": "Comprobando el host...", + "found": "Encontrado", + "gooseMissing": "Instala goose en este host para usarlo en sesiones remotas.", + "notFound": "No encontrado" + }, + "empty": "No se encontraron hosts en ~/.ssh/config.", + "shutdownConfirm": { + "cancel": "Cancelar", + "confirm": "Detener backend", + "description": "Esto detiene el daemon del agente en el host remoto y finaliza cualquier trabajo remoto en curso.", + "stopping": "Deteniendo...", + "title": "¿Detener el backend remoto en \"{{host}}\"?" + }, + "status": { + "connecting": "Conectando...", + "disconnected": "Desconectado", + "failed": "Error", + "ready": "Conectado", + "reconnecting": "Reconectando...", + "reconnectingAttempt": "Reconectando (intento {{attempt}})..." + }, + "title": "Hosts SSH remotos" + }, "runtimeConfig": { "clearError": "No se pudo limpiar la respuesta del endpoint falso.", "clearSuccess": "Respuesta del endpoint falso limpiada.", diff --git a/src/shared/ui/GlobalComposerPill.tsx b/src/shared/ui/GlobalComposerPill.tsx index 15f00dc11..b115ac188 100644 --- a/src/shared/ui/GlobalComposerPill.tsx +++ b/src/shared/ui/GlobalComposerPill.tsx @@ -71,6 +71,10 @@ export interface GlobalComposeOptions { configId: string; value: string; }; + /** SSH host the new chat's backend should run on (remote sessions). */ + remoteHost?: string; + /** Remote working directory; required alongside remoteHost. */ + remoteWorkingDir?: string; } export interface GlobalComposerExpandPayload { From 496c8eb3861beb3b1446d84e2a16900a9bcd3623 Mon Sep 17 00:00:00 2001 From: damienrj Date: Fri, 28 Aug 2026 12:35:57 -0700 Subject: [PATCH 02/13] fix: remote session identity, projects, and host persistence --- src/app/AppShell.tsx | 71 +++--- .../useChatSessionController.test.ts | 39 ++++ .../chat/hooks/useChatSessionController.ts | 21 +- .../remoteSessionPersistence.test.ts | 78 ++++++- src/features/chat/stores/chatSessionStore.ts | 2 + .../chat/stores/remoteSessionPersistence.ts | 39 +++- src/features/chat/ui/ChatInputToolbar.tsx | 12 +- src/features/chat/ui/McpAppView.tsx | 9 +- src/features/chat/ui/RemoteHostSelector.tsx | 11 +- .../chat/ui/__tests__/McpAppView.test.tsx | 1 + .../stores/remoteHostStore.test.ts | 71 ++++++ .../remoteHosts/stores/remoteHostStore.ts | 77 ++++++- .../remoteHosts/ui/RemoteHostsSettings.tsx | 30 ++- .../capabilities/SessionListCapability.tsx | 1 + .../ui/session-list/SidebarChatRow.tsx | 16 +- .../session-list/SidebarFlatChatsSection.tsx | 1 + .../SidebarPinnedItemsSection.tsx | 1 + .../ui/session-list/SidebarProjectSection.tsx | 4 + .../ui/session-list/SidebarRecentsSection.tsx | 1 + src/shared/api/__tests__/acpApi.test.ts | 199 ++++++++++++++++ src/shared/api/__tests__/acpBackends.test.ts | 218 +++++++++++++++++- src/shared/api/acpApi.ts | 90 +++++--- src/shared/api/acpBackendId.ts | 39 ++++ src/shared/api/acpConnection.ts | 33 ++- src/shared/api/acpSessionBackends.ts | 67 +++++- src/shared/i18n/locales/en/settings.json | 1 + src/shared/i18n/locales/en/sidebar.json | 3 +- src/shared/i18n/locales/es/settings.json | 1 + src/shared/i18n/locales/es/sidebar.json | 3 +- 29 files changed, 1015 insertions(+), 124 deletions(-) diff --git a/src/app/AppShell.tsx b/src/app/AppShell.tsx index 754c474d0..036e86c94 100644 --- a/src/app/AppShell.tsx +++ b/src/app/AppShell.tsx @@ -2260,7 +2260,7 @@ export function AppShell({ reuseExistingDraft?: boolean; executionTarget?: SessionExecutionTarget; reasoningEffort?: GlobalComposeOptions["reasoningEffort"]; - /** SSH host to run the session's backend on; project-less in v1. */ + /** SSH host to run the session's backend on. */ remoteHost?: string; /** Remote working directory; required alongside remoteHost. */ remoteWorkingDir?: string; @@ -2274,9 +2274,11 @@ export function AppShell({ "createNewTab requires remoteWorkingDir when remoteHost is set.", ); } - // Remote sessions are project-less in v1: local project folders are - // meaningless on the SSH host, and the working dir is chosen remotely. - const sessionProject = remoteHost ? undefined : project; + // A remote session may belong to a project: the association is local + // grouping metadata. Its working dir still comes from the remote picker, + // never from the project's local folders (all cwd sites below guard on + // remoteHost). + const sessionProject = project; const tStart = performance.now(); perfLog( `[perf:newtab] createNewTab start (project=${sessionProject?.id ?? "none"})`, @@ -2593,7 +2595,9 @@ export function AppShell({ "createBackgroundDraftChat requires remoteWorkingDir when remoteHost is set.", ); } - const sessionProject = remoteHost ? undefined : project; + // Project association is local grouping metadata; cwd sites below guard + // on remoteHost. + const sessionProject = project; const tStart = performance.now(); perfLog( `[perf:newtab] createBackgroundDraftChat start (project=${sessionProject?.id ?? "none"})`, @@ -3023,13 +3027,13 @@ export function AppShell({ onSettled?: (didStart: boolean) => void; }, ) => { - // Remote sessions are project-less in v1, so a remote compose never - // routes through the project draft path. - const project = - options?.projectId && !options.remoteHost - ? projects.find((candidate) => candidate.id === options.projectId) - : undefined; + const project = options?.projectId + ? projects.find((candidate) => candidate.id === options.projectId) + : undefined; + // Project workspace startup (branches/worktrees) is local git machinery; + // a remote compose keeps the project association but skips those plans. const requiresProjectWorkspaceDraftPlan = + !options?.remoteHost && workspaceRepository.mode === "multi" && Boolean(project?.projectWorkspaces.length); const shouldRunComposerHandoff = @@ -3099,9 +3103,13 @@ export function AppShell({ }; const startChat = async () => { - const createChat = project - ? createNewProjectDraft(DEFAULT_CHAT_TITLE, project, chatOptions) - : createNewTab(DEFAULT_CHAT_TITLE, undefined, chatOptions); + // The project-draft route runs local workspace startup; remote project + // chats go through createNewTab, which carries the project association + // and uses the remote working directory verbatim. + const createChat = + project && !options?.remoteHost + ? createNewProjectDraft(DEFAULT_CHAT_TITLE, project, chatOptions) + : createNewTab(DEFAULT_CHAT_TITLE, project, chatOptions); try { const session = await createChat; @@ -3282,12 +3290,9 @@ export function AppShell({ const handleGlobalComposerExpand = useCallback( (payload: GlobalComposerExpandPayload): Promise => { const options = payload.options; - // Remote sessions are project-less in v1, so a remote compose never - // routes through the project draft path. - const project = - options?.projectId && !options.remoteHost - ? projects.find((candidate) => candidate.id === options.projectId) - : undefined; + const project = options?.projectId + ? projects.find((candidate) => candidate.id === options.projectId) + : undefined; const chatOptions = { executionTarget: options?.executionTarget, reasoningEffort: options?.reasoningEffort, @@ -3299,13 +3304,16 @@ export function AppShell({ globalComposerPlacement === "centered"; const openExpandedDraft = async () => { - const session = project - ? await createNewProjectDraft( - DEFAULT_CHAT_TITLE, - project, - chatOptions, - ) - : await createNewTab(DEFAULT_CHAT_TITLE, undefined, chatOptions); + // Remote project chats skip the local workspace-startup draft route + // but keep the project association (see handleGlobalCompose). + const session = + project && !options?.remoteHost + ? await createNewProjectDraft( + DEFAULT_CHAT_TITLE, + project, + chatOptions, + ) + : await createNewTab(DEFAULT_CHAT_TITLE, project, chatOptions); if (!session) { return false; } @@ -3388,12 +3396,9 @@ export function AppShell({ }); } const options = payload.options; - // Remote sessions are project-less in v1, so a remote compose never - // routes through the project draft path. - const project = - options?.projectId && !options.remoteHost - ? projects.find((candidate) => candidate.id === options.projectId) - : undefined; + const project = options?.projectId + ? projects.find((candidate) => candidate.id === options.projectId) + : undefined; const chatOptions = { activate: false, reuseExistingDraft: false, diff --git a/src/features/chat/hooks/__tests__/useChatSessionController.test.ts b/src/features/chat/hooks/__tests__/useChatSessionController.test.ts index dead62e43..bf3ece7e9 100644 --- a/src/features/chat/hooks/__tests__/useChatSessionController.test.ts +++ b/src/features/chat/hooks/__tests__/useChatSessionController.test.ts @@ -6697,6 +6697,45 @@ describe("useChatSessionController", () => { ); }); + it("creates exactly one remote session when send fires twice in flight", async () => { + // ssh connect + session/new take a moment; a re-submit inside that + // window (double Enter, double click) must not create a second session. + let releaseConnect: () => void = () => {}; + mockEnsureRemoteHostConnected.mockImplementation( + () => + new Promise((resolve) => { + releaseConnect = resolve; + }), + ); + mockAcpCreateSession.mockResolvedValue({ + sessionId: "remote-session-1", + configOptionsSnapshot: undefined, + }); + + const { result } = renderHook(() => + useChatSessionController({ sessionId: "session-1" }), + ); + + act(() => { + result.current.handleRemoteHostChange("devbox"); + result.current.handleRemoteDirChange("/home/dev/project"); + }); + + let first: boolean | Promise = false; + let second: boolean | Promise = true; + act(() => { + first = result.current.handleSend("hello remote"); + second = result.current.handleSend("hello remote"); + }); + await act(async () => { + releaseConnect(); + expect(await first).toBe(true); + expect(await second).toBe(false); + }); + + expect(mockAcpCreateSession).toHaveBeenCalledTimes(1); + }); + it("reports a failed connect and keeps the send unaccepted", async () => { mockEnsureRemoteHostConnected.mockRejectedValue({ kind: "host-unreachable", diff --git a/src/features/chat/hooks/useChatSessionController.ts b/src/features/chat/hooks/useChatSessionController.ts index 05bc1bec6..00c636ca2 100644 --- a/src/features/chat/hooks/useChatSessionController.ts +++ b/src/features/chat/hooks/useChatSessionController.ts @@ -381,6 +381,7 @@ export function useChatSessionController({ const [pendingPersonaId, setPendingPersonaId] = useState(); const [pendingProjectId, setPendingProjectId] = useState(); const [pendingRemoteHost, setPendingRemoteHost] = useState(); + const remoteSendInFlightRef = useRef(false); const [pendingRemoteDir, setPendingRemoteDir] = useState(); const [pendingExecutionTarget, setPendingExecutionTarget] = useState(); @@ -1509,10 +1510,6 @@ export function useChatSessionController({ setPendingRemoteHost(host); // A chosen directory belongs to one host, so any host change resets it. setPendingRemoteDir(undefined); - if (host) { - // Remote sessions are project-less in v1. - setPendingProjectId(null); - } }, []); const handleRemoteDirChange = useCallback((dir: string | null) => { @@ -2613,6 +2610,13 @@ export function useChatSessionController({ // remote directory is missing; this is a backstop. return false; } + // Creating the remote session takes a moment (ssh connect + ACP + // session/new); a re-submit in that window must not create a second + // session. + if (remoteSendInFlightRef.current) { + return false; + } + remoteSendInFlightRef.current = true; const payload = captureSessionSelection({ text, persona: personaIntentFromComposer(personaId, personaName), @@ -2632,6 +2636,9 @@ export function useChatSessionController({ payload.persona.kind === "persona" ? payload.persona.id : (selectedPersonaId ?? undefined), + // The project association is local metadata (grouping, sidebar); + // the workspace itself lives on the remote host. + projectId: effectiveProjectId ?? undefined, workingDir: remoteDir, remoteHost, }); @@ -2643,9 +2650,6 @@ export function useChatSessionController({ } setPendingRemoteHost(undefined); setPendingRemoteDir(undefined); - if (sessionId) { - recordDraftPreservingSubmission(sessionId, text); - } activateSession(created.id); onMessageAccepted?.(created.id); return true; @@ -2655,6 +2659,8 @@ export function useChatSessionController({ description: formatAcpErrorMessage(error), }); return false; + } finally { + remoteSendInFlightRef.current = false; } })(); } @@ -2885,6 +2891,7 @@ export function useChatSessionController({ selectedPersonaId, stateSessionId, workspaceContextReady, + effectiveProjectId, ], ); diff --git a/src/features/chat/stores/__tests__/remoteSessionPersistence.test.ts b/src/features/chat/stores/__tests__/remoteSessionPersistence.test.ts index e98ea6679..6aec06f1c 100644 --- a/src/features/chat/stores/__tests__/remoteSessionPersistence.test.ts +++ b/src/features/chat/stores/__tests__/remoteSessionPersistence.test.ts @@ -92,6 +92,22 @@ describe("remoteSessionPersistence", () => { expect(window.localStorage.getItem(REMOTE_SESSIONS_STORAGE_KEY)).toBeNull(); }); + it("round-trips projectId and seeds it on the rehydrated placeholder", async () => { + persistRemoteSessionRecord( + makeRecord({ sessionId: "ssh:devbox#remote-3", projectId: "proj-1" }), + ); + + expect(readRemoteSessionRecords()).toEqual([ + makeRecord({ sessionId: "ssh:devbox#remote-3", projectId: "proj-1" }), + ]); + + await rehydrateRemoteSessions(); + const session = useChatSessionStore + .getState() + .getSession("ssh:devbox#remote-3"); + expect(session?.projectId).toBe("proj-1"); + }); + it("upserts by session id", () => { persistRemoteSessionRecord(makeRecord()); persistRemoteSessionRecord(makeRecord({ title: "Renamed" })); @@ -123,25 +139,60 @@ describe("remoteSessionPersistence", () => { describe("rehydrateRemoteSessions", () => { it("registers backends and seeds sidebar placeholders", async () => { - persistRemoteSessionRecord(makeRecord()); + persistRemoteSessionRecord( + makeRecord({ sessionId: "ssh:devbox#remote-1" }), + ); await rehydrateRemoteSessions(); expect(mocks.registerSessionBackend).toHaveBeenCalledWith( - "remote-1", + "ssh:devbox#remote-1", "ssh:devbox", + "remote-1", ); - const session = useChatSessionStore.getState().getSession("remote-1"); + const session = useChatSessionStore + .getState() + .getSession("ssh:devbox#remote-1"); expect(session).toMatchObject({ - id: "remote-1", + id: "ssh:devbox#remote-1", title: "Remote chat", remoteHost: "devbox", workingDir: "/remote/home/damien/project", - clientSessionId: "remote-1", + clientSessionId: "ssh:devbox#remote-1", }); // Placeholder must be visible in the sidebar before the remote // transcript loads. expect(session?.messageCount).toBeGreaterThan(0); + // The already-composite record is not rewritten. + expect(readRemoteSessionRecords()).toEqual([ + makeRecord({ sessionId: "ssh:devbox#remote-1" }), + ]); + }); + + it("migrates old-format records (bare wire ids) to composite ids", async () => { + persistRemoteSessionRecord(makeRecord({ sessionId: "remote-1" })); + + await rehydrateRemoteSessions(); + + expect(mocks.registerSessionBackend).toHaveBeenCalledWith( + "ssh:devbox#remote-1", + "ssh:devbox", + "remote-1", + ); + const session = useChatSessionStore + .getState() + .getSession("ssh:devbox#remote-1"); + expect(session).toMatchObject({ + id: "ssh:devbox#remote-1", + remoteHost: "devbox", + }); + expect( + useChatSessionStore.getState().getSession("remote-1"), + ).toBeUndefined(); + // The stored record is rewritten under the composite id. + expect(readRemoteSessionRecords()).toEqual([ + makeRecord({ sessionId: "ssh:devbox#remote-1" }), + ]); }); it("skips archived records", async () => { @@ -155,14 +206,22 @@ describe("remoteSessionPersistence", () => { expect( useChatSessionStore.getState().getSession("remote-1"), ).toBeUndefined(); + expect( + useChatSessionStore.getState().getSession("ssh:devbox#remote-1"), + ).toBeUndefined(); }); it("does not clobber a session already in the store", async () => { - persistRemoteSessionRecord(makeRecord({ title: "Stale title" })); + persistRemoteSessionRecord( + makeRecord({ + sessionId: "ssh:devbox#remote-1", + title: "Stale title", + }), + ); useChatSessionStore.setState({ sessions: [ { - id: "remote-1", + id: "ssh:devbox#remote-1", title: "Fresh title", remoteHost: "devbox", createdAt: "2026-08-03T00:00:00.000Z", @@ -175,11 +234,12 @@ describe("remoteSessionPersistence", () => { await rehydrateRemoteSessions(); expect(mocks.registerSessionBackend).toHaveBeenCalledWith( - "remote-1", + "ssh:devbox#remote-1", "ssh:devbox", + "remote-1", ); expect( - useChatSessionStore.getState().getSession("remote-1"), + useChatSessionStore.getState().getSession("ssh:devbox#remote-1"), ).toMatchObject({ title: "Fresh title", messageCount: 4 }); }); }); diff --git a/src/features/chat/stores/chatSessionStore.ts b/src/features/chat/stores/chatSessionStore.ts index c80e44089..11e6d70d5 100644 --- a/src/features/chat/stores/chatSessionStore.ts +++ b/src/features/chat/stores/chatSessionStore.ts @@ -500,6 +500,7 @@ function persistRemoteSessionRecordForSession(session: ChatSession): void { title: session.title, workingDir: session.workingDir ?? "", updatedAt: session.updatedAt, + ...(session.projectId ? { projectId: session.projectId } : {}), ...(session.archivedAt ? { archivedAt: session.archivedAt } : {}), }); } @@ -950,6 +951,7 @@ export const useChatSessionStore = create((set, get) => ({ merged.remoteHost && (merged.title !== existing.title || merged.workingDir !== existing.workingDir || + merged.projectId !== existing.projectId || merged.archivedAt !== existing.archivedAt || merged.updatedAt !== existing.updatedAt) ) { diff --git a/src/features/chat/stores/remoteSessionPersistence.ts b/src/features/chat/stores/remoteSessionPersistence.ts index 739e18b9b..392d79c91 100644 --- a/src/features/chat/stores/remoteSessionPersistence.ts +++ b/src/features/chat/stores/remoteSessionPersistence.ts @@ -1,4 +1,8 @@ -import { sshBackendId } from "@/shared/api/acpBackendId"; +import { + compositeSessionId, + splitCompositeSessionId, + sshBackendId, +} from "@/shared/api/acpBackendId"; import { registerSessionBackend } from "@/shared/api/acpSessionBackends"; export const REMOTE_SESSIONS_STORAGE_KEY = "goose:remote-sessions:v1"; @@ -10,11 +14,18 @@ export const REMOTE_SESSIONS_STORAGE_KEY = "goose:remote-sessions:v1"; * the right backend) across app restarts. */ export interface RemoteSessionRecord { + /** + * Composite renderer-side session id (`ssh:#`). Records + * written before composite ids existed hold the bare wire id; rehydration + * migrates them using `host`. + */ sessionId: string; host: string; title: string; workingDir: string; updatedAt: string; + /** Project the session belongs to; local grouping metadata. */ + projectId?: string; archivedAt?: string; } @@ -45,6 +56,10 @@ function normalizeRemoteSessionRecord( workingDir: trimmedString(raw.workingDir) ?? "", updatedAt: trimmedString(raw.updatedAt) ?? new Date(0).toISOString(), }; + const projectId = trimmedString(raw.projectId); + if (projectId) { + record.projectId = projectId; + } const archivedAt = trimmedString(raw.archivedAt); if (archivedAt) { record.archivedAt = archivedAt; @@ -140,16 +155,30 @@ export async function rehydrateRemoteSessions(): Promise { ); const store = useChatSessionStore.getState(); for (const record of records) { - registerSessionBackend(record.sessionId, sshBackendId(record.host)); - if (store.getSession(record.sessionId)) { + const backendId = sshBackendId(record.host); + const split = splitCompositeSessionId(record.sessionId); + // Old-format records (pre-composite ids) hold the bare wire id; compose + // it so it cannot collide with a same-id local session, and migrate the + // stored record so the composite id is the stable key from here on. + const sessionId = split + ? record.sessionId + : compositeSessionId(backendId, record.sessionId); + const wireSessionId = split?.wireSessionId ?? record.sessionId; + registerSessionBackend(sessionId, backendId, wireSessionId); + if (sessionId !== record.sessionId) { + removeRemoteSessionRecord(record.sessionId); + persistRemoteSessionRecord({ ...record, sessionId }); + } + if (store.getSession(sessionId)) { continue; } store.addSession({ - id: record.sessionId, + id: sessionId, title: record.title || record.host, remoteHost: record.host, + projectId: record.projectId, workingDir: record.workingDir || undefined, - clientSessionId: record.sessionId, + clientSessionId: sessionId, createdAt: record.updatedAt, updatedAt: record.updatedAt, // Placeholder: the remote message count is unknown until the session diff --git a/src/features/chat/ui/ChatInputToolbar.tsx b/src/features/chat/ui/ChatInputToolbar.tsx index dd5c50939..74ce07a59 100644 --- a/src/features/chat/ui/ChatInputToolbar.tsx +++ b/src/features/chat/ui/ChatInputToolbar.tsx @@ -129,9 +129,9 @@ export function ChatInputToolbar({ onCreateProject, } = projectPicker; const remoteHostPickerEnabled = remoteHostPicker?.enabled === true; - const selectedRemoteHost = remoteHostPickerEnabled - ? (remoteHostPicker?.selectedHost ?? null) - : null; + // A started remote session keeps showing its host (selection disabled): + // the composer must always tell the user where their commands will run. + const selectedRemoteHost = remoteHostPicker?.selectedHost ?? null; const { contextTokens = 0, contextLimit = 0, @@ -383,13 +383,10 @@ export function ChatInputToolbar({ onRequestComposerFocus={onRequestComposerFocus} modal={false} triggerIconOnly={isCompact} - // Remote sessions are project-less in v1; local project folders - // do not exist on the SSH host. - disabled={Boolean(selectedRemoteHost)} /> ) : null} - {remoteHostPickerEnabled ? ( + {remoteHostPickerEnabled || selectedRemoteHost ? ( ) : null} diff --git a/src/features/chat/ui/McpAppView.tsx b/src/features/chat/ui/McpAppView.tsx index 30ee9256d..7ec8dcb7d 100644 --- a/src/features/chat/ui/McpAppView.tsx +++ b/src/features/chat/ui/McpAppView.tsx @@ -6,7 +6,10 @@ import { import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import packageJson from "../../../../package.json"; -import { getClientForSession } from "@/shared/api/acpSessionBackends"; +import { + getClientForSession, + getWireSessionId, +} from "@/shared/api/acpSessionBackends"; import { getGooseServeHostInfo } from "@/shared/api/gooseServeHost"; import { useTheme } from "@/shared/theme/ThemeProvider"; import { createVirtualLayoutStabilityAttributes } from "@/features/chat/transcript/measurement"; @@ -382,7 +385,7 @@ export function McpAppView({ try { const client = await getClientForSession(payload.sessionId); const response = await client.goose.GooseUnstableToolsCall({ - sessionId: payload.sessionId, + sessionId: getWireSessionId(payload.sessionId), name: `${payload.tool.extensionName}__${name}`, arguments: args ?? {}, }); @@ -411,7 +414,7 @@ export function McpAppView({ try { const client = await getClientForSession(payload.sessionId); const response = await client.goose.GooseUnstableResourcesRead({ - sessionId: payload.sessionId, + sessionId: getWireSessionId(payload.sessionId), uri, extensionName: payload.tool.extensionName, }); diff --git a/src/features/chat/ui/RemoteHostSelector.tsx b/src/features/chat/ui/RemoteHostSelector.tsx index bdf59643b..5b7043d15 100644 --- a/src/features/chat/ui/RemoteHostSelector.tsx +++ b/src/features/chat/ui/RemoteHostSelector.tsx @@ -34,8 +34,17 @@ export function RemoteHostSelector({ }: RemoteHostSelectorProps) { const { t } = useTranslation("chat"); const configHosts = useRemoteHostStore((state) => state.configHosts); + const manualHosts = useRemoteHostStore((state) => state.manualHosts); const statusByHost = useRemoteHostStore((state) => state.statusByHost); - const availableHosts = hosts ?? configHosts; + const baseHosts = hosts ?? configHosts; + // Hosts added manually (persisted) or connected ad hoc this run don't + // appear in ~/.ssh/config — wildcard-only configs like `Host *.blox` rely + // on this. + const extraHosts = [...manualHosts, ...Object.keys(statusByHost)].filter( + (host, index, all) => + !baseHosts.includes(host) && all.indexOf(host) === index, + ); + const availableHosts = [...baseHosts, ...extraHosts]; // A stale selection (host removed from ~/.ssh/config) still needs a row so // the trigger label and check mark stay truthful. const listedHosts = diff --git a/src/features/chat/ui/__tests__/McpAppView.test.tsx b/src/features/chat/ui/__tests__/McpAppView.test.tsx index b96a48643..2caf063d8 100644 --- a/src/features/chat/ui/__tests__/McpAppView.test.tsx +++ b/src/features/chat/ui/__tests__/McpAppView.test.tsx @@ -62,6 +62,7 @@ vi.mock("@mcp-ui/client", () => ({ vi.mock("@/shared/api/acpSessionBackends", () => ({ getClientForSession: mocks.getClient, + getWireSessionId: (sessionId: string) => sessionId, })); vi.mock("@/shared/api/gooseServeHost", () => ({ diff --git a/src/features/remoteHosts/stores/remoteHostStore.test.ts b/src/features/remoteHosts/stores/remoteHostStore.test.ts index 6930c4ca2..9a854b931 100644 --- a/src/features/remoteHosts/stores/remoteHostStore.test.ts +++ b/src/features/remoteHosts/stores/remoteHostStore.test.ts @@ -22,7 +22,9 @@ vi.mock("@/shared/api/remoteHosts", async (importOriginal) => { import { initRemoteHostStore, + loadPersistedManualHosts, loadPersistedRecentDirs, + REMOTE_HOST_MANUAL_HOSTS_STORAGE_KEY, REMOTE_HOST_RECENT_DIRS_STORAGE_KEY, useRemoteHostStore, } from "./remoteHostStore"; @@ -39,6 +41,7 @@ const connection: RemoteBackendConnection = { function resetStore(): void { useRemoteHostStore.setState({ configHosts: [], + manualHosts: [], statusByHost: {}, doctorByHost: {}, doctorPendingByHost: {}, @@ -359,3 +362,71 @@ describe("initRemoteHostStore", () => { expect(cleanup).toBe(unlisten); }); }); + +describe("manual host persistence", () => { + it("remembers a connected host that is not in the ssh config", async () => { + mocks.connectRemoteHost.mockResolvedValue(connection); + useRemoteHostStore.setState({ configHosts: ["configured"] }); + + await useRemoteHostStore.getState().ensureHostConnected("adhoc.blox"); + + expect(useRemoteHostStore.getState().manualHosts).toEqual(["adhoc.blox"]); + expect(loadPersistedManualHosts()).toEqual(["adhoc.blox"]); + }); + + it("does not record ssh-config hosts as manual", async () => { + mocks.connectRemoteHost.mockResolvedValue(connection); + useRemoteHostStore.setState({ configHosts: ["configured"] }); + + await useRemoteHostStore.getState().ensureHostConnected("configured"); + + expect(useRemoteHostStore.getState().manualHosts).toEqual([]); + expect(loadPersistedManualHosts()).toEqual([]); + }); + + it("does not remember hosts that failed to connect", async () => { + mocks.connectRemoteHost.mockRejectedValue({ + kind: "host-unreachable", + message: "no route", + }); + + await expect( + useRemoteHostStore.getState().ensureHostConnected("nope.blox"), + ).rejects.toBeTruthy(); + + expect(useRemoteHostStore.getState().manualHosts).toEqual([]); + }); + + it("does not duplicate an already remembered host", async () => { + mocks.connectRemoteHost.mockResolvedValue(connection); + useRemoteHostStore.setState({ manualHosts: ["adhoc.blox"] }); + + await useRemoteHostStore.getState().ensureHostConnected("adhoc.blox"); + + expect(useRemoteHostStore.getState().manualHosts).toEqual(["adhoc.blox"]); + }); + + it("forgets a manual host and persists the removal", async () => { + mocks.connectRemoteHost.mockResolvedValue(connection); + await useRemoteHostStore.getState().ensureHostConnected("adhoc.blox"); + + useRemoteHostStore.getState().removeManualHost("adhoc.blox"); + + expect(useRemoteHostStore.getState().manualHosts).toEqual([]); + expect(loadPersistedManualHosts()).toEqual([]); + }); + + it("tolerates corrupted storage when loading manual hosts", () => { + window.localStorage.setItem( + REMOTE_HOST_MANUAL_HOSTS_STORAGE_KEY, + "not-json{", + ); + expect(loadPersistedManualHosts()).toEqual([]); + + window.localStorage.setItem( + REMOTE_HOST_MANUAL_HOSTS_STORAGE_KEY, + JSON.stringify([42, "", "real.host"]), + ); + expect(loadPersistedManualHosts()).toEqual(["real.host"]); + }); +}); diff --git a/src/features/remoteHosts/stores/remoteHostStore.ts b/src/features/remoteHosts/stores/remoteHostStore.ts index 04f0e9c8c..25f9cdb87 100644 --- a/src/features/remoteHosts/stores/remoteHostStore.ts +++ b/src/features/remoteHosts/stores/remoteHostStore.ts @@ -16,8 +16,11 @@ import { export const REMOTE_HOST_RECENT_DIRS_STORAGE_KEY = "goose:remote-host-recent-dirs"; +export const REMOTE_HOST_MANUAL_HOSTS_STORAGE_KEY = + "goose:remote-host-manual-hosts"; const MAX_RECENT_DIRS_PER_HOST = 8; +const MAX_MANUAL_HOSTS = 16; export interface RemoteHostStatus { state: RemoteBackendState; @@ -74,9 +77,45 @@ function persistRecentDirs(byHost: Record): void { } } +/** Hosts the user typed in manually (not in ~/.ssh/config), persisted so + * they survive restarts. Hostnames only — never secrets. */ +export function loadPersistedManualHosts(): string[] { + if (typeof window === "undefined") return []; + try { + const stored = window.localStorage.getItem( + REMOTE_HOST_MANUAL_HOSTS_STORAGE_KEY, + ); + if (!stored) return []; + const parsed = JSON.parse(stored); + if (!Array.isArray(parsed)) return []; + return parsed + .filter( + (host): host is string => + typeof host === "string" && host.trim() !== "", + ) + .slice(0, MAX_MANUAL_HOSTS); + } catch { + return []; + } +} + +function persistManualHosts(hosts: string[]): void { + if (typeof window === "undefined") return; + try { + window.localStorage.setItem( + REMOTE_HOST_MANUAL_HOSTS_STORAGE_KEY, + JSON.stringify(hosts), + ); + } catch { + // localStorage may be unavailable + } +} + export interface RemoteHostStore { /** Concrete Host aliases from ~/.ssh/config. */ configHosts: string[]; + /** Hosts the user added manually (persisted across restarts). */ + manualHosts: string[]; statusByHost: Record; doctorByHost: Record; doctorPendingByHost: Record; @@ -92,10 +131,12 @@ export interface RemoteHostStore { shutdownHost: (host: string) => Promise; runDoctor: (host: string) => Promise; recordRecentDir: (host: string, dir: string) => void; + removeManualHost: (host: string) => void; } export const useRemoteHostStore = create((set, get) => ({ configHosts: [], + manualHosts: loadPersistedManualHosts(), statusByHost: {}, doctorByHost: {}, doctorPendingByHost: {}, @@ -159,12 +200,25 @@ export const useRemoteHostStore = create((set, get) => ({ })); try { await connectRemoteHost(host); - set((state) => ({ - statusByHost: { - ...state.statusByHost, - [host]: { state: "ready" }, - }, - })); + set((state) => { + // A host that connected but isn't in ~/.ssh/config was typed in + // manually; remember it across restarts. + const isKnown = + state.configHosts.includes(host) || state.manualHosts.includes(host); + const manualHosts = isKnown + ? state.manualHosts + : [host, ...state.manualHosts].slice(0, MAX_MANUAL_HOSTS); + if (!isKnown) { + persistManualHosts(manualHosts); + } + return { + manualHosts, + statusByHost: { + ...state.statusByHost, + [host]: { state: "ready" }, + }, + }; + }); } catch (error) { set((state) => ({ statusByHost: { @@ -218,6 +272,17 @@ export const useRemoteHostStore = create((set, get) => ({ } }, + removeManualHost: (host) => { + set((state) => { + if (!state.manualHosts.includes(host)) return state; + const manualHosts = state.manualHosts.filter( + (candidate) => candidate !== host, + ); + persistManualHosts(manualHosts); + return { manualHosts }; + }); + }, + recordRecentDir: (host, dir) => { const trimmedHost = host.trim(); const trimmedDir = dir.trim(); diff --git a/src/features/remoteHosts/ui/RemoteHostsSettings.tsx b/src/features/remoteHosts/ui/RemoteHostsSettings.tsx index e1aed466a..0cc998956 100644 --- a/src/features/remoteHosts/ui/RemoteHostsSettings.tsx +++ b/src/features/remoteHosts/ui/RemoteHostsSettings.tsx @@ -117,6 +117,12 @@ function RemoteHostRow({ ); const disconnect = useRemoteHostStore((state) => state.disconnect); const runDoctor = useRemoteHostStore((state) => state.runDoctor); + const isManualHost = useRemoteHostStore((state) => + state.manualHosts.includes(host), + ); + const removeManualHost = useRemoteHostStore( + (state) => state.removeManualHost, + ); const state = status?.state ?? "disconnected"; const isConnected = state === "ready" || state === "reconnecting"; @@ -185,6 +191,16 @@ function RemoteHostRow({ > {t("remoteHosts.actions.check")} + {isManualHost && !isConnected ? ( + + ) : null} } details={ @@ -211,6 +227,7 @@ export function RemoteHostsSettings() { useExperiment(REMOTE_SSH_SESSIONS_EXPERIMENT_ID)?.enabled === true; const configHosts = useRemoteHostStore((state) => state.configHosts); + const manualHosts = useRemoteHostStore((state) => state.manualHosts); const statusByHost = useRemoteHostStore((state) => state.statusByHost); const ensureHostConnected = useRemoteHostStore( (state) => state.ensureHostConnected, @@ -236,14 +253,15 @@ export function RemoteHostsSettings() { void store.syncBackendSnapshot(); }, [enabled]); - // Config hosts first, then any ad-hoc hosts we have status for (e.g. from - // the free-form connect below), so manual connections stay visible. + // Config hosts first, then manually added hosts (persisted across + // restarts), then any other ad-hoc hosts we have status for this run. const hosts = useMemo(() => { - const extra = Object.keys(statusByHost) - .filter((host) => !configHosts.includes(host)) - .sort(); + const extra = [...manualHosts, ...Object.keys(statusByHost).sort()].filter( + (host, index, all) => + !configHosts.includes(host) && all.indexOf(host) === index, + ); return [...configHosts, ...extra]; - }, [configHosts, statusByHost]); + }, [configHosts, manualHosts, statusByHost]); if (!enabled) return null; diff --git a/src/features/sessions/capabilities/SessionListCapability.tsx b/src/features/sessions/capabilities/SessionListCapability.tsx index 9852867f0..436020440 100644 --- a/src/features/sessions/capabilities/SessionListCapability.tsx +++ b/src/features/sessions/capabilities/SessionListCapability.tsx @@ -187,6 +187,7 @@ function toSessionListItem( id: session.id, title: session.title, branchName, + remoteHost: session.remoteHost, activityAt: sessionActivityAt(session), projectId: session.projectId ?? undefined, updatedAt: session.updatedAt, diff --git a/src/features/sessions/ui/session-list/SidebarChatRow.tsx b/src/features/sessions/ui/session-list/SidebarChatRow.tsx index b360ceb6f..29c38422a 100644 --- a/src/features/sessions/ui/session-list/SidebarChatRow.tsx +++ b/src/features/sessions/ui/session-list/SidebarChatRow.tsx @@ -7,7 +7,7 @@ import { type PointerEvent, } from "react"; import { createPortal } from "react-dom"; -import { ExternalLink, MoreHorizontal } from "lucide-react"; +import { ExternalLink, MoreHorizontal, Server } from "lucide-react"; import { IconCheck, IconGitBranch } from "@tabler/icons-react"; import { useTranslation } from "react-i18next"; import { @@ -137,6 +137,8 @@ interface SidebarChatRowProps { title: string; /** Current Git branch for this chat; only passed when the sidebar branch setting is enabled. */ branchName?: string; + /** SSH host the chat's backend runs on; shows a remote indicator when set. */ + remoteHost?: string; /** Latest visible chat activity. Rendered as a compact relative timestamp on the row's right edge; hidden on hover when the row menu takes its place. */ activityAt?: string | null; showTimestamp?: boolean; @@ -194,6 +196,7 @@ export function SidebarChatRow({ id, title, branchName, + remoteHost, activityAt, showTimestamp = true, isActive, @@ -476,6 +479,17 @@ export function SidebarChatRow({ aria-pressed={selectionEnabled ? selected : undefined} > {rowTitleContent} + {remoteHost ? ( + + + ) : null} {isMultiWindowEnabled && isOpenInWindow ? ( ({ newSession: vi.fn(), setSessionConfigOption: vi.fn(), extMethod: vi.fn(), + clientPrompt: vi.fn(), + clientLoadSession: vi.fn(), + clientCancel: vi.fn(), })); const includeLastMessageSnippetMeta = { @@ -929,3 +932,199 @@ describe("steerSession", () => { ); }); }); + +describe("remote session wire translation", () => { + beforeEach(() => { + vi.clearAllMocks(); + mocks.getClient.mockResolvedValue({ + newSession: mocks.newSession, + prompt: mocks.clientPrompt, + loadSession: mocks.clientLoadSession, + cancel: mocks.clientCancel, + listSessions: mocks.listSessions, + unstable_forkSession: mocks.unstableForkSession, + setSessionConfigOption: mocks.setSessionConfigOption, + extMethod: mocks.extMethod, + }); + setSessionConfigSnapshotHandlers({}); + }); + + it("returns a composite id from a remote newSession and registers the wire id", async () => { + mocks.newSession.mockResolvedValueOnce({ sessionId: "20260828_2" }); + const { newSession } = await import("../acpApi"); + const { getSessionBackend, getWireSessionId, unregisterSessionBackend } = + await import("../acpSessionBackends"); + + const response = await newSession("/remote/dir", { + backendId: "ssh:workstation.blox", + }); + + expect(response.sessionId).toBe("ssh:workstation.blox#20260828_2"); + expect(getSessionBackend("ssh:workstation.blox#20260828_2")).toBe( + "ssh:workstation.blox", + ); + expect(getWireSessionId("ssh:workstation.blox#20260828_2")).toBe( + "20260828_2", + ); + unregisterSessionBackend("ssh:workstation.blox#20260828_2"); + }); + + it("keeps the bare id for a local newSession", async () => { + mocks.newSession.mockResolvedValueOnce({ sessionId: "20260828_2" }); + const { newSession } = await import("../acpApi"); + const { getSessionBackend, unregisterSessionBackend } = await import( + "../acpSessionBackends" + ); + + const response = await newSession("/local/dir"); + + expect(response.sessionId).toBe("20260828_2"); + expect(getSessionBackend("20260828_2")).toBe("local"); + unregisterSessionBackend("20260828_2"); + }); + + it("sends the wire id for a registered remote session and the raw id for local", async () => { + const { prompt, loadSession, cancelSession } = await import("../acpApi"); + const { registerSessionBackend, unregisterSessionBackend } = await import( + "../acpSessionBackends" + ); + registerSessionBackend("ssh:devbox#20260828_2", "ssh:devbox", "20260828_2"); + mocks.clientPrompt.mockResolvedValue({ stopReason: "end_turn" }); + mocks.clientLoadSession.mockResolvedValue({}); + mocks.clientCancel.mockResolvedValue(undefined); + + await prompt("ssh:devbox#20260828_2", [{ type: "text", text: "hi" }]); + expect(mocks.clientPrompt).toHaveBeenCalledWith({ + sessionId: "20260828_2", + prompt: [{ type: "text", text: "hi" }], + _meta: undefined, + }); + + await loadSession("ssh:devbox#20260828_2", "/remote/dir"); + expect(mocks.clientLoadSession).toHaveBeenCalledWith({ + sessionId: "20260828_2", + cwd: "/remote/dir", + mcpServers: [], + }); + + await cancelSession("ssh:devbox#20260828_2"); + expect(mocks.clientCancel).toHaveBeenCalledWith({ + sessionId: "20260828_2", + }); + + await prompt("local-session", [{ type: "text", text: "hi" }]); + expect(mocks.clientPrompt).toHaveBeenLastCalledWith({ + sessionId: "local-session", + prompt: [{ type: "text", text: "hi" }], + _meta: undefined, + }); + + unregisterSessionBackend("ssh:devbox#20260828_2"); + }); + + it("translates config, rename, archive, and steer calls to the wire id", async () => { + const { + setProvider, + setModel, + renameSession, + archiveSession, + steerSession, + } = await import("../acpApi"); + const { registerSessionBackend, unregisterSessionBackend } = await import( + "../acpSessionBackends" + ); + const goose = { + GooseUnstableSessionRename: vi.fn().mockResolvedValue(undefined), + GooseUnstableSessionArchive: vi.fn().mockResolvedValue(undefined), + }; + mocks.getClient.mockResolvedValue({ + setSessionConfigOption: mocks.setSessionConfigOption, + extMethod: mocks.extMethod, + goose, + }); + registerSessionBackend("ssh:devbox#20260828_2", "ssh:devbox", "20260828_2"); + mocks.setSessionConfigOption.mockResolvedValue(undefined); + mocks.extMethod.mockResolvedValue({ runId: "run-1", messageId: "m-1" }); + + await setProvider("ssh:devbox#20260828_2", "goose"); + expect(mocks.setSessionConfigOption).toHaveBeenCalledWith({ + sessionId: "20260828_2", + configId: "provider", + value: "goose", + }); + + await setModel("ssh:devbox#20260828_2", "claude-opus-4-8"); + expect(mocks.setSessionConfigOption).toHaveBeenLastCalledWith({ + sessionId: "20260828_2", + configId: "model", + value: "claude-opus-4-8", + }); + + await renameSession("ssh:devbox#20260828_2", "New title"); + expect(goose.GooseUnstableSessionRename).toHaveBeenCalledWith({ + sessionId: "20260828_2", + title: "New title", + }); + + await archiveSession("ssh:devbox#20260828_2"); + expect(goose.GooseUnstableSessionArchive).toHaveBeenCalledWith({ + sessionId: "20260828_2", + }); + + await steerSession( + "ssh:devbox#20260828_2", + [{ type: "text", text: "steer" }], + "run-1", + ); + expect(mocks.extMethod).toHaveBeenCalledWith( + "_goose/unstable/session/steer", + { + sessionId: "20260828_2", + prompt: [{ type: "text", text: "steer" }], + expectedRunId: "run-1", + }, + ); + + unregisterSessionBackend("ssh:devbox#20260828_2"); + }); + + it("composes remote list results and fork children onto their backend", async () => { + const { listSessionsPage, forkSession } = await import("../acpApi"); + const { + registerSessionBackend, + getWireSessionId, + unregisterSessionBackend, + } = await import("../acpSessionBackends"); + + mocks.listSessions.mockResolvedValueOnce({ + sessions: [{ sessionId: "20260828_2", cwd: "/remote/dir" }], + nextCursor: null, + }); + const page = await listSessionsPage({ backendId: "ssh:devbox" }); + expect(page.sessions[0]?.sessionId).toBe("ssh:devbox#20260828_2"); + + mocks.listSessions.mockResolvedValueOnce({ + sessions: [{ sessionId: "20260828_2", cwd: "/local/dir" }], + nextCursor: null, + }); + const localPage = await listSessionsPage(); + expect(localPage.sessions[0]?.sessionId).toBe("20260828_2"); + + registerSessionBackend("ssh:devbox#20260828_2", "ssh:devbox", "20260828_2"); + mocks.unstableForkSession.mockResolvedValueOnce({ + sessionId: "20260828_3", + _meta: {}, + }); + const fork = await forkSession("ssh:devbox#20260828_2", "/remote/dir"); + expect(mocks.unstableForkSession).toHaveBeenCalledWith({ + sessionId: "20260828_2", + cwd: "/remote/dir", + mcpServers: [], + }); + expect(fork.sessionId).toBe("ssh:devbox#20260828_3"); + expect(getWireSessionId("ssh:devbox#20260828_3")).toBe("20260828_3"); + + unregisterSessionBackend("ssh:devbox#20260828_2"); + unregisterSessionBackend("ssh:devbox#20260828_3"); + }); +}); diff --git a/src/shared/api/__tests__/acpBackends.test.ts b/src/shared/api/__tests__/acpBackends.test.ts index b570991f5..51fcb32c9 100644 --- a/src/shared/api/__tests__/acpBackends.test.ts +++ b/src/shared/api/__tests__/acpBackends.test.ts @@ -1,8 +1,12 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { Client, SessionNotification } from "@agentclientprotocol/sdk"; import { LOCAL_BACKEND_ID, backendIdForSession, + compositeSessionId, + isCompositeSessionId, remoteHostFromBackendId, + splitCompositeSessionId, sshBackendId, } from "../acpBackendId"; @@ -16,6 +20,7 @@ const mocks = vi.hoisted(() => ({ invoke: vi.fn(), connectRemoteHost: vi.fn(), createWebSocketStream: vi.fn(), + clientCallbackFactories: [] as Array<() => Client>, })); vi.mock("@tauri-apps/api/core", () => ({ @@ -41,7 +46,8 @@ vi.mock("@aaif/goose-sdk", () => ({ initialize = vi.fn(async () => {}); closed: Promise; resolveClosed!: () => void; - constructor(_callbacks: unknown, _stream: unknown) { + constructor(callbacks: () => Client, _stream: unknown) { + mocks.clientCallbackFactories.push(callbacks); this.closed = new Promise((resolve) => { this.resolveClosed = resolve; }); @@ -65,6 +71,7 @@ function flushClosedMonitor() { beforeEach(() => { vi.resetModules(); vi.clearAllMocks(); + mocks.clientCallbackFactories.length = 0; mocks.invoke.mockResolvedValue("ws://local"); mocks.connectRemoteHost.mockResolvedValue({ wsUrl: "ws://remote", @@ -97,6 +104,44 @@ describe("acpBackendId", () => { "ssh:dev-box", ); }); + + it("keeps local wire ids unchanged and composes remote ones", () => { + expect(compositeSessionId("local", "20260828_2")).toBe("20260828_2"); + expect(compositeSessionId("ssh:workstation.blox", "20260828_2")).toBe( + "ssh:workstation.blox#20260828_2", + ); + }); + + it("detects composite ids without false positives", () => { + expect(isCompositeSessionId("ssh:workstation.blox#20260828_2")).toBe(true); + expect(isCompositeSessionId("20260828_2")).toBe(false); + // A bare backend id is not a session id. + expect(isCompositeSessionId("ssh:workstation.blox")).toBe(false); + // A local id containing '#' is not composite either. + expect(isCompositeSessionId("weird#local")).toBe(false); + }); + + it("splits composite ids, including hosts containing ':'", () => { + expect(splitCompositeSessionId("ssh:dev-box#20260828_2")).toEqual({ + backendId: "ssh:dev-box", + wireSessionId: "20260828_2", + }); + expect(splitCompositeSessionId("ssh:user@host:2222#20260828_1")).toEqual({ + backendId: "ssh:user@host:2222", + wireSessionId: "20260828_1", + }); + expect(splitCompositeSessionId("20260828_2")).toBeNull(); + expect(splitCompositeSessionId("ssh:no-separator")).toBeNull(); + }); + + it("roundtrips composite ids through split", () => { + const id = compositeSessionId("ssh:user@host:2222", "20260828_1"); + expect(id).toBe("ssh:user@host:2222#20260828_1"); + expect(splitCompositeSessionId(id)).toEqual({ + backendId: "ssh:user@host:2222", + wireSessionId: "20260828_1", + }); + }); }); describe("backend connection registry", () => { @@ -232,4 +277,175 @@ describe("session backend routing", () => { expect(remoteClient).toBe(await conn.getBackendClient("ssh:dev-box")); expect(mocks.invoke).not.toHaveBeenCalled(); }); + + it("returns the registered wire id, defaulting to the session id", async () => { + const sessions = await importSessionBackends(); + + sessions.registerSessionBackend( + "ssh:dev-box#20260828_2", + "ssh:dev-box", + "20260828_2", + ); + expect(sessions.getWireSessionId("ssh:dev-box#20260828_2")).toBe( + "20260828_2", + ); + + sessions.registerSessionBackend("local-session", "local"); + expect(sessions.getWireSessionId("local-session")).toBe("local-session"); + expect(sessions.getWireSessionId("never-registered")).toBe( + "never-registered", + ); + + // Registering a composite id without an explicit wire id derives it from + // the id's shape instead of storing the composite as the wire id. + sessions.registerSessionBackend("ssh:dev-box#20260828_9", "ssh:dev-box"); + expect(sessions.getWireSessionId("ssh:dev-box#20260828_9")).toBe( + "20260828_9", + ); + }); + + it("derives backend and wire id from an unregistered composite id", async () => { + const conn = await importConnection(); + const sessions = await importSessionBackends(); + + // Restart safety: a composite id can be consulted before rehydration + // re-registers it. + expect(sessions.getSessionBackend("ssh:user@host:2222#20260828_1")).toBe( + "ssh:user@host:2222", + ); + expect(sessions.getWireSessionId("ssh:user@host:2222#20260828_1")).toBe( + "20260828_1", + ); + + const client = await sessions.getClientForSession( + "ssh:user@host:2222#20260828_1", + ); + expect(mocks.connectRemoteHost).toHaveBeenCalledWith("user@host:2222"); + expect(client).toBe(await conn.getBackendClient("ssh:user@host:2222")); + }); + + it("transfer keeps the destination's own wire id", async () => { + const sessions = await importSessionBackends(); + + // Draft flow: the draft's uuid is registered on the remote backend, then + // the created composite id (already registered with its wire id) takes + // over. The transfer must not clobber the destination's wire id with the + // draft uuid. + sessions.registerSessionBackend( + "ssh:dev-box#20260828_2", + "ssh:dev-box", + "20260828_2", + ); + sessions.registerSessionBackend("draft-uuid", "ssh:dev-box"); + sessions.transferSessionBackend("draft-uuid", "ssh:dev-box#20260828_2"); + + expect(sessions.getSessionBackend("ssh:dev-box#20260828_2")).toBe( + "ssh:dev-box", + ); + expect(sessions.getWireSessionId("ssh:dev-box#20260828_2")).toBe( + "20260828_2", + ); + + // Unregistered composite destination derives its wire id from its shape. + sessions.transferSessionBackend("draft-uuid", "ssh:dev-box#20260828_3"); + expect(sessions.getWireSessionId("ssh:dev-box#20260828_3")).toBe( + "20260828_3", + ); + }); +}); + +describe("inbound session id translation", () => { + function sessionUpdatePayload(sessionId: string): SessionNotification { + return { + sessionId, + update: { + sessionUpdate: "agent_message_chunk", + content: { type: "text", text: "hello" }, + }, + } as SessionNotification; + } + + async function latestCallbacks(): Promise { + const factory = mocks.clientCallbackFactories.at(-1); + if (!factory) { + throw new Error("no GooseClient constructed"); + } + return factory(); + } + + it("rewrites remote notification session ids to composite ids", async () => { + const conn = await importConnection(); + const received: SessionNotification[] = []; + conn.setNotificationHandler({ + handleSessionNotification: async (notification) => { + received.push(notification); + }, + }); + + await conn.getBackendClient("ssh:dev-box"); + const callbacks = await latestCallbacks(); + await callbacks.sessionUpdate?.(sessionUpdatePayload("20260828_2")); + + expect(received).toHaveLength(1); + expect(received[0]?.sessionId).toBe("ssh:dev-box#20260828_2"); + }); + + it("passes local notifications through byte-identical", async () => { + const conn = await importConnection(); + const received: SessionNotification[] = []; + conn.setNotificationHandler({ + handleSessionNotification: async (notification) => { + received.push(notification); + }, + }); + + await conn.getClient(); + const callbacks = await latestCallbacks(); + const payload = sessionUpdatePayload("20260828_2"); + await callbacks.sessionUpdate?.(payload); + + expect(received).toHaveLength(1); + // Local stays a passthrough: the very same object, not a copy. + expect(received[0]).toBe(payload); + }); + + it("interceptors see the composite id for remote notifications", async () => { + const conn = await importConnection(); + const intercepted: string[] = []; + const stop = conn.interceptSessionNotifications((notification) => { + intercepted.push(notification.sessionId); + return true; + }); + const handler = vi.fn(); + conn.setNotificationHandler({ handleSessionNotification: handler }); + + await conn.getBackendClient("ssh:dev-box"); + const callbacks = await latestCallbacks(); + await callbacks.sessionUpdate?.(sessionUpdatePayload("20260828_2")); + + expect(intercepted).toEqual(["ssh:dev-box#20260828_2"]); + expect(handler).not.toHaveBeenCalled(); + stop(); + }); + + it("rewrites remote permission request session ids to composite ids", async () => { + const conn = await importConnection(); + const seen: string[] = []; + conn.setPermissionHandler(async (request) => { + seen.push(request.sessionId); + return { + outcome: { outcome: "selected", optionId: "approve" }, + }; + }); + + await conn.getBackendClient("ssh:dev-box"); + const callbacks = await latestCallbacks(); + await callbacks.requestPermission?.({ + sessionId: "20260828_2", + options: [], + // biome-ignore lint/suspicious/noExplicitAny: minimal fixture + } as any); + + expect(seen).toEqual(["ssh:dev-box#20260828_2"]); + }); }); diff --git a/src/shared/api/acpApi.ts b/src/shared/api/acpApi.ts index fa8fe9bc4..ee6561198 100644 --- a/src/shared/api/acpApi.ts +++ b/src/shared/api/acpApi.ts @@ -10,7 +10,11 @@ import type { import { messageSnippet } from "@/features/chat/lib/messageSnippet"; import { getCuratedAgentProviders } from "@/features/providers/curatedProviders"; import { toWireProviderId } from "./acpPersonaHandoff"; -import { LOCAL_BACKEND_ID, type AcpBackendId } from "./acpBackendId"; +import { + LOCAL_BACKEND_ID, + compositeSessionId, + type AcpBackendId, +} from "./acpBackendId"; import { getBackendClient, getClient, @@ -19,6 +23,7 @@ import { import { getClientForSession, getSessionBackend, + getWireSessionId, registerSessionBackend, } from "./acpSessionBackends"; import { @@ -146,8 +151,12 @@ export async function getSessionInfo( sessionId: string, ): Promise { const client = await getClientForSession(sessionId); - const result = await client.goose.GooseUnstableSessionInfo({ sessionId }); - return mapSessionInfo(result.session as unknown as SessionInfo); + const result = await client.goose.GooseUnstableSessionInfo({ + sessionId: getWireSessionId(sessionId), + }); + const info = mapSessionInfo(result.session as unknown as SessionInfo); + // The backend echoes its bare wire id; callers hold the composite id. + return { ...info, sessionId }; } export async function listSessionsPage({ @@ -161,7 +170,8 @@ export async function listSessionsPage({ query?: string | null; backendId?: AcpBackendId; } = {}): Promise { - const client = await getBackendClient(backendId ?? LOCAL_BACKEND_ID); + const resolvedBackendId = backendId ?? LOCAL_BACKEND_ID; + const client = await getBackendClient(resolvedBackendId); const normalizedCursor = cursor?.trim() || null; const normalizedQuery = query?.trim() || null; // ACP session/list only standardizes cwd and cursor filters. Goose project @@ -178,14 +188,24 @@ export async function listSessionsPage({ const response = await client.listSessions(params); return { - sessions: response.sessions.map(mapSessionInfo), + // A remote page's wire ids collide with same-id local sessions, so each + // returned id becomes the composite renderer id (local ids pass through). + sessions: response.sessions.map((info) => { + const mapped = mapSessionInfo(info); + return { + ...mapped, + sessionId: compositeSessionId(resolvedBackendId, mapped.sessionId), + }; + }), nextCursor: response.nextCursor?.trim() || null, }; } export async function exportSession(sessionId: string): Promise { const client = await getClientForSession(sessionId); - const result = await client.goose.GooseUnstableSessionExport({ sessionId }); + const result = await client.goose.GooseUnstableSessionExport({ + sessionId: getWireSessionId(sessionId), + }); // biome-ignore lint/suspicious/noExplicitAny: SDK doesn't expose data field on export result return (result as any).data; } @@ -216,7 +236,7 @@ export async function forkSession( const backendId = getSessionBackend(sessionId); const client = await getBackendClient(backendId); const params: ForkSessionRequest = { - sessionId, + sessionId: getWireSessionId(sessionId), cwd: workingDir, mcpServers: [], }; @@ -225,9 +245,12 @@ export async function forkSession( } const response = await client.unstable_forkSession(params); - registerSessionBackend(response.sessionId, backendId); + // The fork lives on the source session's backend; its renderer id is the + // composite of that backend and the returned wire id. + const forkedSessionId = compositeSessionId(backendId, response.sessionId); + registerSessionBackend(forkedSessionId, backendId, response.sessionId); return { - sessionId: response.sessionId, + sessionId: forkedSessionId, title: null, updatedAt: null, createdAt: metaString(response._meta, "createdAt"), @@ -254,7 +277,7 @@ export async function setModel( const client = await getClientForSession(sessionId); const tCall = performance.now(); const response = await client.setSessionConfigOption({ - sessionId, + sessionId: getWireSessionId(sessionId), configId: "model", value: modelId, }); @@ -290,7 +313,7 @@ export async function setSessionConfigOption( const client = await getClientForSession(sessionId); const tCall = performance.now(); const response = await client.setSessionConfigOption({ - sessionId, + sessionId: getWireSessionId(sessionId), configId, value, }); @@ -326,7 +349,7 @@ export async function setProvider( const wireProvider = toWireProviderId(providerId); const tCall = performance.now(); const response = await client.setSessionConfigOption({ - sessionId, + sessionId: getWireSessionId(sessionId), configId: "provider", value: wireProvider, }); @@ -364,7 +387,7 @@ export async function updateWorkingDir( // without exposing the ACP client or duplicating the wire operation. beforeUpdate?.(); await client.goose.GooseUnstableSessionWorkingDirUpdate({ - sessionId, + sessionId: getWireSessionId(sessionId), workingDir, }); } @@ -375,7 +398,7 @@ export async function setSessionSystemPrompt( ): Promise { const client = await getClientForSession(sessionId); await client.extMethod("_goose/unstable/session/system-prompt/set", { - sessionId, + sessionId: getWireSessionId(sessionId), mode: "set", text, }); @@ -388,7 +411,7 @@ export async function appendSessionSystemPrompt( ): Promise { const client = await getClientForSession(sessionId); await client.extMethod("_goose/unstable/session/system-prompt/set", { - sessionId, + sessionId: getWireSessionId(sessionId), mode: "append", key, text, @@ -401,24 +424,30 @@ export async function updateSessionProject( ): Promise { const client = await getClientForSession(sessionId); await client.goose.GooseUnstableSessionProjectUpdate({ - sessionId, + sessionId: getWireSessionId(sessionId), projectId, }); } export async function archiveSession(sessionId: string): Promise { const client = await getClientForSession(sessionId); - await client.goose.GooseUnstableSessionArchive({ sessionId }); + await client.goose.GooseUnstableSessionArchive({ + sessionId: getWireSessionId(sessionId), + }); } export async function deleteSession(sessionId: string): Promise { const client = await getClientForSession(sessionId); - await client.extMethod("session/delete", { sessionId }); + await client.extMethod("session/delete", { + sessionId: getWireSessionId(sessionId), + }); } export async function unarchiveSession(sessionId: string): Promise { const client = await getClientForSession(sessionId); - await client.goose.GooseUnstableSessionUnarchive({ sessionId }); + await client.goose.GooseUnstableSessionUnarchive({ + sessionId: getWireSessionId(sessionId), + }); } export async function renameSession( @@ -426,12 +455,15 @@ export async function renameSession( title: string, ): Promise { const client = await getClientForSession(sessionId); - await client.goose.GooseUnstableSessionRename({ sessionId, title }); + await client.goose.GooseUnstableSessionRename({ + sessionId: getWireSessionId(sessionId), + title, + }); } export async function cancelSession(sessionId: string): Promise { const client = await getClientForSession(sessionId); - await client.cancel({ sessionId }); + await client.cancel({ sessionId: getWireSessionId(sessionId) }); } export interface NewSessionOptions { @@ -464,12 +496,16 @@ export async function newSession( const tCall = performance.now(); const response = await client.newSession(request); - registerSessionBackend(response.sessionId, backendId); - const sid = response.sessionId.slice(0, 8); + // The backend hands back a wire id that is only unique per backend. Callers + // (and every renderer-side store) get the composite id; the registry keeps + // the wire id for outbound translation. + const sessionId = compositeSessionId(backendId, response.sessionId); + registerSessionBackend(sessionId, backendId, response.sessionId); + const sid = sessionId.slice(0, 8); perfLog( `[perf:api] ${sid} newSession getClient=${(tCall - tClient).toFixed(1)}ms wire=${(performance.now() - tCall).toFixed(1)}ms`, ); - return response; + return { ...response, sessionId }; } export async function loadSession( @@ -481,7 +517,7 @@ export async function loadSession( const client = await getClientForSession(sessionId); const tCall = performance.now(); const response = await client.loadSession({ - sessionId, + sessionId: getWireSessionId(sessionId), cwd: workingDir, mcpServers: [], }); @@ -512,7 +548,7 @@ export async function prompt( const client = await getClientForSession(sessionId); callbacks.onPromptDispatching?.(); const promptPromise = client.prompt({ - sessionId, + sessionId: getWireSessionId(sessionId), prompt: content, _meta: meta, }); @@ -617,7 +653,7 @@ export async function steerSession( const client = await getClientForSession(sessionId); const steer = async (runId: string): Promise => { const response = await client.extMethod("_goose/unstable/session/steer", { - sessionId, + sessionId: getWireSessionId(sessionId), prompt: content, expectedRunId: runId, ...(meta ? { _meta: meta } : {}), diff --git a/src/shared/api/acpBackendId.ts b/src/shared/api/acpBackendId.ts index 19aac32c0..66d69ef03 100644 --- a/src/shared/api/acpBackendId.ts +++ b/src/shared/api/acpBackendId.ts @@ -23,3 +23,42 @@ export function backendIdForSession( const host = session?.remoteHost?.trim(); return host ? sshBackendId(host) : LOCAL_BACKEND_ID; } + +const COMPOSITE_SEPARATOR = "#"; + +/** + * Renderer-side session id. Goose session ids ("wire ids", e.g. `20260828_2`) + * are only unique per backend, so a remote session's renderer id embeds its + * backend: `ssh:#`. Local sessions keep their bare wire id. + * `#` cannot appear in a host: the Rust side restricts hosts to + * alphanumerics plus `.-_@:`. + */ +export function compositeSessionId( + backendId: AcpBackendId, + wireSessionId: string, +): string { + return backendId === LOCAL_BACKEND_ID + ? wireSessionId + : `${backendId}${COMPOSITE_SEPARATOR}${wireSessionId}`; +} + +export function isCompositeSessionId(id: string): boolean { + return id.startsWith(SSH_PREFIX) && id.includes(COMPOSITE_SEPARATOR); +} + +/** + * Splits a composite renderer session id into its backend and wire parts, or + * returns null for a bare (local) id. + */ +export function splitCompositeSessionId( + id: string, +): { backendId: AcpBackendId; wireSessionId: string } | null { + if (!isCompositeSessionId(id)) { + return null; + } + const separatorIndex = id.lastIndexOf(COMPOSITE_SEPARATOR); + return { + backendId: id.slice(0, separatorIndex) as AcpBackendId, + wireSessionId: id.slice(separatorIndex + 1), + }; +} diff --git a/src/shared/api/acpConnection.ts b/src/shared/api/acpConnection.ts index a58cc0d1b..ec08eafb7 100644 --- a/src/shared/api/acpConnection.ts +++ b/src/shared/api/acpConnection.ts @@ -14,6 +14,7 @@ import { import packageJson from "../../../package.json"; import { LOCAL_BACKEND_ID, + compositeSessionId, remoteHostFromBackendId, type AcpBackendId, } from "./acpBackendId"; @@ -65,16 +66,30 @@ export function setPermissionHandler(handler: PermissionRequestHandler): void { // The handler slots above are shared by every backend connection: downstream // routing is sessionId-keyed, so notifications from any backend flow through -// the same callbacks. -function createClientCallbacks(): () => Client { +// the same callbacks. Wire session ids are only unique per backend, so each +// connection rewrites inbound payloads to the composite renderer-side id +// before they reach the shared handlers; the local backend stays a +// byte-identical passthrough. +function createClientCallbacks(backendId: AcpBackendId): () => Client { + const toRendererSessionId = ( + payload: T, + ): T => + backendId === LOCAL_BACKEND_ID + ? payload + : { + ...payload, + sessionId: compositeSessionId(backendId, payload.sessionId), + }; + return () => ({ requestPermission: async ( args: RequestPermissionRequest, ): Promise => { + const request = toRendererSessionId(args); if (permissionHandler) { - return permissionHandler(args); + return permissionHandler(request); } - const optionId = args.options?.[0]?.optionId ?? "approve"; + const optionId = request.options?.[0]?.optionId ?? "approve"; return { outcome: { outcome: "selected", @@ -83,7 +98,10 @@ function createClientCallbacks(): () => Client { }; }, - sessionUpdate: async (notification: SessionNotification): Promise => { + sessionUpdate: async ( + wireNotification: SessionNotification, + ): Promise => { + const notification = toRendererSessionId(wireNotification); for (const interceptor of sessionNotificationInterceptors) { if (interceptor(notification)) { return; @@ -113,6 +131,7 @@ export interface AcpConnection { export function createAcpConnection( resolveWsUrl: () => Promise, + backendId: AcpBackendId = LOCAL_BACKEND_ID, ): AcpConnection { let clientPromise: Promise | null = null; let resolvedClient: GooseClient | null = null; @@ -170,7 +189,7 @@ export function createAcpConnection( const stream = createWebSocketStream(wsUrl); activeStream = stream; - const client = new GooseClient(createClientCallbacks(), stream); + const client = new GooseClient(createClientCallbacks(backendId), stream); perfLog( `[perf:conn] ws stream + client created in ${(performance.now() - tStream).toFixed(1)}ms`, ); @@ -279,7 +298,7 @@ function createBackendConnection(backendId: AcpBackendId): AcpConnection { const { connectRemoteHost } = await import("./remoteHosts"); const remote = await connectRemoteHost(host); return remote.wsUrl; - }); + }, backendId); } export function getBackendConnection(backendId: AcpBackendId): AcpConnection { diff --git a/src/shared/api/acpSessionBackends.ts b/src/shared/api/acpSessionBackends.ts index 5f32b5cbf..fef6276ce 100644 --- a/src/shared/api/acpSessionBackends.ts +++ b/src/shared/api/acpSessionBackends.ts @@ -1,22 +1,59 @@ import type { GooseClient } from "@aaif/goose-sdk"; -import { LOCAL_BACKEND_ID, type AcpBackendId } from "./acpBackendId"; +import { + LOCAL_BACKEND_ID, + splitCompositeSessionId, + type AcpBackendId, +} from "./acpBackendId"; import { getBackendClient } from "./acpConnection"; +interface SessionBackendEntry { + backendId: AcpBackendId; + /** Bare ACP session id understood by the owning backend. */ + wireSessionId: string; +} + /** - * Maps ACP session ids to the backend connection that owns them. Sessions - * without an entry belong to the local backend. + * Maps renderer-side session ids to the backend connection that owns them and + * the bare wire id that backend understands. Renderer ids for remote sessions + * are composite (`ssh:#`); local sessions use the wire id + * directly. Sessions without an entry fall back to parsing the composite id, + * then to the local backend. */ -const sessionBackends = new Map(); +const sessionBackends = new Map(); export function registerSessionBackend( sessionId: string, backendId: AcpBackendId, + wireSessionId?: string, ): void { - sessionBackends.set(sessionId, backendId); + sessionBackends.set(sessionId, { + backendId, + // Callers that only hold the renderer id may omit the wire id; a + // composite id carries its own wire id, everything else is already bare. + wireSessionId: + wireSessionId ?? + splitCompositeSessionId(sessionId)?.wireSessionId ?? + sessionId, + }); } export function getSessionBackend(sessionId: string): AcpBackendId { - return sessionBackends.get(sessionId) ?? LOCAL_BACKEND_ID; + const entry = sessionBackends.get(sessionId); + if (entry) { + return entry.backendId; + } + // Restart safety: a composite id can arrive before rehydration re-registers + // it (or after a registry wipe). The backend is recoverable from the prefix. + return splitCompositeSessionId(sessionId)?.backendId ?? LOCAL_BACKEND_ID; +} + +/** Bare ACP id to send over the wire for a renderer-side session id. */ +export function getWireSessionId(sessionId: string): string { + const entry = sessionBackends.get(sessionId); + if (entry) { + return entry.wireSessionId; + } + return splitCompositeSessionId(sessionId)?.wireSessionId ?? sessionId; } /** No-op when the source session is unregistered (implicitly local). */ @@ -24,10 +61,22 @@ export function transferSessionBackend( fromSessionId: string, toSessionId: string, ): void { - const backendId = sessionBackends.get(fromSessionId); - if (backendId !== undefined) { - sessionBackends.set(toSessionId, backendId); + const fromEntry = sessionBackends.get(fromSessionId); + if (fromEntry === undefined) { + return; } + // The destination keeps its own wire id: an already-registered target (e.g. + // a composite id registered by newSession) must not inherit the source's + // wire id, and an unregistered target derives its own from its shape. + const existing = sessionBackends.get(toSessionId); + const wireSessionId = + existing?.wireSessionId ?? + splitCompositeSessionId(toSessionId)?.wireSessionId ?? + toSessionId; + sessionBackends.set(toSessionId, { + backendId: fromEntry.backendId, + wireSessionId, + }); } export function unregisterSessionBackend(sessionId: string): void { diff --git a/src/shared/i18n/locales/en/settings.json b/src/shared/i18n/locales/en/settings.json index a3ff413c9..0e3a877c5 100644 --- a/src/shared/i18n/locales/en/settings.json +++ b/src/shared/i18n/locales/en/settings.json @@ -699,6 +699,7 @@ "check": "Check host", "connect": "Connect", "disconnect": "Disconnect", + "forget": "Forget", "shutdown": "Stop remote backend" }, "custom": { diff --git a/src/shared/i18n/locales/en/sidebar.json b/src/shared/i18n/locales/en/sidebar.json index d8d3bee11..74a278b4b 100644 --- a/src/shared/i18n/locales/en/sidebar.json +++ b/src/shared/i18n/locales/en/sidebar.json @@ -80,7 +80,8 @@ "status": { "chatActive": "Chat active", "pinnedChat": "Pinned chat", - "unreadMessages": "Unread messages" + "unreadMessages": "Unread messages", + "remoteHost": "Remote chat on {{host}}" }, "showLess": "Show less", "viewMoreChats": "View more", diff --git a/src/shared/i18n/locales/es/settings.json b/src/shared/i18n/locales/es/settings.json index 2b6f34dcd..acc020910 100644 --- a/src/shared/i18n/locales/es/settings.json +++ b/src/shared/i18n/locales/es/settings.json @@ -702,6 +702,7 @@ "check": "Comprobar host", "connect": "Conectar", "disconnect": "Desconectar", + "forget": "Olvidar", "shutdown": "Detener backend remoto" }, "custom": { diff --git a/src/shared/i18n/locales/es/sidebar.json b/src/shared/i18n/locales/es/sidebar.json index 375186570..82aec7b38 100644 --- a/src/shared/i18n/locales/es/sidebar.json +++ b/src/shared/i18n/locales/es/sidebar.json @@ -80,7 +80,8 @@ "status": { "chatActive": "Chat activo", "pinnedChat": "Chat fijado", - "unreadMessages": "Mensajes no leídos" + "unreadMessages": "Mensajes no leídos", + "remoteHost": "Chat remoto en {{host}}" }, "showLess": "Mostrar menos", "viewMoreChats": "Ver más", From 798dc7d5757c73f07d24510385edab4347bbaadc Mon Sep 17 00:00:00 2001 From: damienrj Date: Mon, 31 Aug 2026 09:19:30 -0700 Subject: [PATCH 03/13] feat: per-host goose binary override for remote sessions --- src-tauri/src/commands/remote_backend.rs | 13 +- .../src/services/remote_backend/daemon.rs | 445 +++++++++++++++++- src-tauri/src/services/remote_backend/mod.rs | 116 ++++- .../services/remote_backend/remote_daemon.sh | 158 +++++-- .../remoteHosts/lib/gooseBinaryOverride.ts | 72 +++ .../stores/remoteHostStore.test.ts | 90 ++++ .../remoteHosts/stores/remoteHostStore.ts | 33 ++ .../remoteHosts/ui/RemoteHostsSettings.tsx | 104 +++- .../ui/__tests__/RemoteHostsSettings.test.tsx | 99 ++++ src/shared/api/__tests__/remoteHosts.test.ts | 92 ++++ src/shared/api/remoteHosts.ts | 23 +- src/shared/i18n/locales/en/settings.json | 8 + src/shared/i18n/locales/es/settings.json | 8 + 13 files changed, 1188 insertions(+), 73 deletions(-) create mode 100644 src/features/remoteHosts/lib/gooseBinaryOverride.ts create mode 100644 src/shared/api/__tests__/remoteHosts.test.ts diff --git a/src-tauri/src/commands/remote_backend.rs b/src-tauri/src/commands/remote_backend.rs index 11a90f749..fca4924fe 100644 --- a/src-tauri/src/commands/remote_backend.rs +++ b/src-tauri/src/commands/remote_backend.rs @@ -21,13 +21,17 @@ pub async fn list_ssh_config_hosts() -> Result, RemoteBackendError> ) } +/// `goose_path` (`goosePath` on the wire) is the optional per-host goose binary +/// override: absolute or `~/`-prefixed, validated in Rust before any argv is +/// built. `None` keeps the remote login PATH lookup. #[tauri::command] pub async fn remote_backend_connect( app: AppHandle, registry: State<'_, RemoteBackendRegistry>, host: String, + goose_path: Option, ) -> Result { - remote_backend::connect(&app, ®istry, &host).await + remote_backend::connect(&app, ®istry, &host, goose_path.as_deref()).await } #[tauri::command] @@ -57,8 +61,11 @@ pub async fn list_remote_backends( } #[tauri::command] -pub async fn check_remote_host(host: String) -> Result, RemoteBackendError> { - remote_backend::check_host(&host).await +pub async fn check_remote_host( + host: String, + goose_path: Option, +) -> Result, RemoteBackendError> { + remote_backend::check_host(&host, goose_path.as_deref()).await } #[tauri::command] diff --git a/src-tauri/src/services/remote_backend/daemon.rs b/src-tauri/src/services/remote_backend/daemon.rs index 89eee0be6..c7b9286a1 100644 --- a/src-tauri/src/services/remote_backend/daemon.rs +++ b/src-tauri/src/services/remote_backend/daemon.rs @@ -1,9 +1,10 @@ //! Drive the remote bootstrap script (`remote_daemon.sh`) over ssh. //! -//! The script travels over stdin (`bash -s -- []`) so -//! no script text, secret, or user path appears in remote argv. Only stdout -//! lines prefixed with the per-invocation nonce are parsed; anything else is -//! shell rc noise and ignored. +//! The script travels over stdin +//! (`bash -s -- [] []`) so no script text, +//! secret, or user path appears in remote argv. Only stdout lines prefixed with +//! the per-invocation nonce are parsed; anything else is shell rc noise and +//! ignored. use std::collections::HashMap; use std::process::Stdio; @@ -42,6 +43,9 @@ pub struct RemoteToolProbe { pub binary: String, pub found: bool, pub version: Option, + /// Resolved path that answered the probe, so the UI can show which build + /// replied (an override, or whatever the login PATH resolved to). + pub path: Option, } #[derive(Clone, Debug, Serialize)] @@ -65,17 +69,64 @@ pub(crate) struct ScriptOutput { pub exit_code: Option, } +/// Validate and normalize a user-supplied goose binary override before it is +/// ever encoded into the bootstrap argv. Absolute or `~/`-prefixed only: bare +/// relative paths are ambiguous on the remote side, and newline/NUL bytes are +/// rejected so nothing can smuggle extra protocol or argv content. +pub(crate) fn normalize_goose_path(path: &str) -> Result { + let trimmed = path.trim(); + if trimmed.is_empty() { + return Err(RemoteBackendError::new( + RemoteBackendErrorKind::InvalidHost, + "the goose binary path is empty", + )); + } + if trimmed.contains(['\n', '\r', '\0']) { + return Err(RemoteBackendError::new( + RemoteBackendErrorKind::InvalidHost, + "the goose binary path contains unsupported characters", + )); + } + if trimmed != "~" && !trimmed.starts_with('/') && !trimmed.starts_with("~/") { + return Err(RemoteBackendError::new( + RemoteBackendErrorKind::InvalidHost, + "the goose binary path must be absolute or start with ~/", + )); + } + if trimmed == "~" || trimmed.ends_with('/') { + return Err(RemoteBackendError::new( + RemoteBackendErrorKind::InvalidHost, + "the goose binary path must point at a file, not a directory", + )); + } + Ok(trimmed.to_string()) +} + +/// Base64 the (already validated) override so it never travels raw in argv. +fn encode_goose_path(goose_path: Option<&str>) -> Option { + goose_path.map(|path| base64::engine::general_purpose::STANDARD.encode(path)) +} + pub(crate) async fn ensure_daemon( spec: &RemoteHostSpec, shell_env: &HashMap, extra_serve_args: &[String], + goose_path: Option<&str>, ) -> Result { let arg = if extra_serve_args.is_empty() { None } else { Some(base64::engine::general_purpose::STANDARD.encode(extra_serve_args.join(" "))) }; - let output = run_remote_script(spec, shell_env, "ensure", arg.as_deref()).await?; + let goose_arg = encode_goose_path(goose_path); + let output = run_remote_script( + spec, + shell_env, + "ensure", + arg.as_deref(), + goose_arg.as_deref(), + ) + .await?; require_success(&output)?; let ready = output .lines @@ -92,7 +143,7 @@ pub(crate) async fn shutdown_daemon( spec: &RemoteHostSpec, shell_env: &HashMap, ) -> Result<(), RemoteBackendError> { - let output = run_remote_script(spec, shell_env, "shutdown", None).await?; + let output = run_remote_script(spec, shell_env, "shutdown", None, None).await?; require_success(&output)?; if output.lines.iter().any(|line| line == "STOPPED") { Ok(()) @@ -107,8 +158,10 @@ pub(crate) async fn shutdown_daemon( pub(crate) async fn check_host( spec: &RemoteHostSpec, shell_env: &HashMap, + goose_path: Option<&str>, ) -> Result, RemoteBackendError> { - let output = run_remote_script(spec, shell_env, "check", None).await?; + let goose_arg = encode_goose_path(goose_path); + let output = run_remote_script(spec, shell_env, "check", None, goose_arg.as_deref()).await?; require_success(&output)?; let probes = parse_tool_probes(&output.lines); if probes.is_empty() { @@ -132,7 +185,7 @@ pub(crate) async fn list_remote_dir( )); } let arg = base64::engine::general_purpose::STANDARD.encode(path); - let output = run_remote_script(spec, shell_env, "listdir", Some(&arg)).await?; + let output = run_remote_script(spec, shell_env, "listdir", Some(&arg), None).await?; match output.exit_code { Some(44) => { return Err(RemoteBackendError::new( @@ -152,22 +205,41 @@ pub(crate) async fn list_remote_dir( .ok_or_else(|| script_protocol_error("remote listing was malformed", &output)) } +/// Assemble the remote command string. Positional args are fixed +/// (` `), so a goose override without a +/// mode arg still has to send the `-` placeholder in slot 3. +fn remote_command_line( + nonce: &str, + mode: &str, + arg: Option<&str>, + goose_arg: Option<&str>, +) -> String { + let mut line = format!("bash -s -- {nonce} {mode}"); + if arg.is_some() || goose_arg.is_some() { + line.push(' '); + line.push_str(arg.unwrap_or("-")); + } + if let Some(goose_arg) = goose_arg { + line.push(' '); + line.push_str(goose_arg); + } + line +} + pub(crate) async fn run_remote_script( spec: &RemoteHostSpec, shell_env: &HashMap, mode: &str, arg: Option<&str>, + goose_arg: Option<&str>, ) -> Result { let nonce = format!("berd-{}", uuid::Uuid::new_v4()); let mut command = base_ssh_command(spec, shell_env); push_destination(&mut command, spec); - // The remote command string: constant except the nonce/mode/arg, all of + // The remote command string: constant except the nonce/mode/args, all of // which are restricted charsets (uuid, keyword, base64). - let remote_command = match arg { - Some(arg) => format!("bash -s -- {nonce} {mode} {arg}"), - None => format!("bash -s -- {nonce} {mode}"), - }; + let remote_command = remote_command_line(&nonce, mode, arg, goose_arg); command.arg(remote_command); command .stdin(Stdio::piped()) @@ -367,10 +439,14 @@ fn parse_tool_probes(lines: &[String]) -> Vec { let binary = fields.next()?.to_string(); let found = fields.next()? == "1"; let version = fields.next().and_then(decode_b64_field); + // `path` is optional so a daemon script from an older client still + // parses (pre-override hosts emit only three fields). + let path = fields.next().and_then(decode_b64_field); Some(RemoteToolProbe { binary, found, version, + path, }) }) .collect() @@ -440,16 +516,99 @@ mod tests { #[test] fn parses_tool_probes() { let lines = vec![ - format!("TOOL goose 1 {}", b64("goose 9.9")), - "TOOL claude-agent-acp 0 -".to_string(), + format!( + "TOOL goose 1 {} {}", + b64("goose 9.9"), + b64("/opt/goose/bin/goose") + ), + "TOOL claude-agent-acp 0 - -".to_string(), "CHECK-DONE".to_string(), ]; let probes = parse_tool_probes(&lines); assert_eq!(probes.len(), 2); assert!(probes[0].found); assert_eq!(probes[0].version.as_deref(), Some("goose 9.9")); + assert_eq!(probes[0].path.as_deref(), Some("/opt/goose/bin/goose")); assert!(!probes[1].found); assert_eq!(probes[1].version, None); + assert_eq!(probes[1].path, None); + } + + /// A host still running a pre-override bootstrap emits only three fields. + #[test] + fn parses_tool_probes_without_a_path_field() { + let lines = vec![format!("TOOL goose 1 {}", b64("goose 9.9"))]; + let probes = parse_tool_probes(&lines); + assert_eq!(probes.len(), 1); + assert_eq!(probes[0].version.as_deref(), Some("goose 9.9")); + assert_eq!(probes[0].path, None); + } + + #[test] + fn normalize_goose_path_accepts_absolute_and_tilde_paths() { + assert_eq!( + normalize_goose_path(" /opt/goose/bin/goose ").unwrap(), + "/opt/goose/bin/goose" + ); + assert_eq!( + normalize_goose_path("~/src/goose/target/release/goose").unwrap(), + "~/src/goose/target/release/goose" + ); + } + + #[test] + fn normalize_goose_path_rejects_unusable_paths() { + for candidate in [ + "", + " ", + "goose", + "./goose", + "../goose", + "~goose", + "~", + "/opt/goose/", + "/opt/goose\nrm -rf /", + "/opt/goose\rgoose", + "/opt/goose\0goose", + ] { + let error = normalize_goose_path(candidate) + .expect_err(&format!("expected {candidate:?} to be rejected")); + assert_eq!(error.kind, RemoteBackendErrorKind::InvalidHost); + } + } + + #[test] + fn remote_command_line_keeps_positional_slots_stable() { + assert_eq!( + remote_command_line("N", "shutdown", None, None), + "bash -s -- N shutdown" + ); + assert_eq!( + remote_command_line("N", "listdir", Some("cGF0aA=="), None), + "bash -s -- N listdir cGF0aA==" + ); + assert_eq!( + remote_command_line("N", "ensure", Some("YXJncw=="), Some("Ymlu")), + "bash -s -- N ensure YXJncw== Ymlu" + ); + // No mode arg but an override: slot 3 has to carry the placeholder so + // the binary path still lands in slot 4. + assert_eq!( + remote_command_line("N", "check", None, Some("Ymlu")), + "bash -s -- N check - Ymlu" + ); + } + + #[test] + fn goose_path_travels_base64_encoded() { + let encoded = encode_goose_path(Some("~/src/goose/target/release/goose")).unwrap(); + assert!(!encoded.contains("goose"), "raw path leaked: {encoded}"); + assert!(!encoded.contains('~'), "raw path leaked: {encoded}"); + assert_eq!( + decode_b64_field(&encoded).as_deref(), + Some("~/src/goose/target/release/goose") + ); + assert_eq!(encode_goose_path(None), None); } #[test] @@ -551,6 +710,74 @@ mod tests { (lines, out.status.code()) } + fn b64_arg(value: &str) -> String { + base64::engine::general_purpose::STANDARD.encode(value) + } + + /// Fake `goose` that reports a version and, for `serve`, actually binds + /// the requested port so the script's readiness probe succeeds. + fn write_goose_shim( + dir: &std::path::Path, + name: &str, + version: &str, + ) -> std::path::PathBuf { + let path = dir.join(name); + std::fs::write( + &path, + format!( + r#"#!/usr/bin/env bash +if [ "$1" = "--version" ]; then echo "{version}"; exit 0; fi +if [ "$1" = "serve" ]; then + port="" + while [ $# -gt 0 ]; do [ "$1" = "--port" ] && port="$2"; shift; done + exec python3 -c 'import socket,sys,time +s=socket.socket(); s.setsockopt(socket.SOL_SOCKET,socket.SO_REUSEADDR,1) +s.bind(("127.0.0.1",int(sys.argv[1]))); s.listen(5); time.sleep(120)' "$port" +fi +"# + ), + ) + .unwrap(); + use std::os::unix::fs::PermissionsExt as _; + std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o755)).unwrap(); + path + } + + fn read_record_fields(home: &std::path::Path) -> Vec { + let record = std::fs::read_to_string( + home.join(".state") + .join("berd") + .join("remote") + .join("daemon.record"), + ) + .expect("daemon record"); + record + .trim() + .split_whitespace() + .map(str::to_string) + .collect() + } + + /// Safety net so a restarted-away shim never outlives the test run. + fn kill_recorded_pid(fields: &[String]) { + let _ = StdCommand::new("kill") + .arg("-9") + .arg(&fields[1]) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .status(); + } + + fn python3_available() -> bool { + StdCommand::new("python3") + .arg("--version") + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .status() + .map(|status| status.success()) + .unwrap_or(false) + } + #[test] fn ensure_reports_goose_not_found() { let home = tempfile::tempdir().unwrap(); @@ -560,6 +787,194 @@ mod tests { assert!(lines.iter().any(|l| l == "ERR goose-not-found")); } + /// An override that does not resolve must fail with the same typed + /// error as a missing PATH goose, even when PATH does have one. + #[test] + fn ensure_reports_goose_not_found_for_a_missing_override() { + let home = tempfile::tempdir().unwrap(); + let bin = tempfile::tempdir().unwrap(); + write_goose_shim(bin.path(), "goose", "goose 1.0.0"); + let path = format!("{}:/usr/bin:/bin", bin.path().to_string_lossy()); + let missing = b64_arg(&bin.path().join("goose-patched").to_string_lossy()); + + let (lines, code) = run_script(&["ensure", "-", &missing], Some(&path), home.path()); + + assert_eq!(code, Some(41), "lines: {lines:?}"); + assert!(lines.iter().any(|l| l == "ERR goose-not-found")); + } + + /// A directory (or any non-executable) override is not a goose binary. + #[test] + fn ensure_rejects_a_non_executable_override() { + let home = tempfile::tempdir().unwrap(); + let bin = tempfile::tempdir().unwrap(); + std::fs::write(bin.path().join("notes.txt"), "not a binary").unwrap(); + let arg = b64_arg(&bin.path().join("notes.txt").to_string_lossy()); + + let (lines, code) = + run_script(&["ensure", "-", &arg], Some("/usr/bin:/bin"), home.path()); + + assert_eq!(code, Some(41), "lines: {lines:?}"); + assert!(lines.iter().any(|l| l == "ERR goose-not-found")); + } + + #[test] + fn check_probes_the_override_instead_of_path_goose() { + let home = tempfile::tempdir().unwrap(); + let bin = tempfile::tempdir().unwrap(); + write_goose_shim(bin.path(), "goose", "goose 1.0.0-stock"); + let patched = write_goose_shim(bin.path(), "goose-patched", "goose 2.0.0-patched"); + let path = format!("{}:/usr/bin:/bin", bin.path().to_string_lossy()); + let arg = b64_arg(&patched.to_string_lossy()); + + let (lines, code) = run_script(&["check", "-", &arg], Some(&path), home.path()); + + assert_eq!(code, Some(0), "lines: {lines:?}"); + let probes = parse_tool_probes(&lines); + let goose = probes.iter().find(|p| p.binary == "goose").unwrap(); + assert!(goose.found); + assert_eq!(goose.version.as_deref(), Some("goose 2.0.0-patched")); + assert_eq!( + goose.path.as_deref(), + Some(patched.to_string_lossy().as_ref()) + ); + } + + #[test] + fn check_reports_the_resolved_path_goose_when_no_override_is_given() { + let home = tempfile::tempdir().unwrap(); + let bin = tempfile::tempdir().unwrap(); + let stock = write_goose_shim(bin.path(), "goose", "goose 1.0.0-stock"); + let path = format!("{}:/usr/bin:/bin", bin.path().to_string_lossy()); + + let (lines, code) = run_script(&["check"], Some(&path), home.path()); + + assert_eq!(code, Some(0), "lines: {lines:?}"); + let goose = parse_tool_probes(&lines) + .into_iter() + .find(|p| p.binary == "goose") + .unwrap(); + assert_eq!(goose.version.as_deref(), Some("goose 1.0.0-stock")); + assert_eq!( + goose.path.as_deref(), + Some(stock.to_string_lossy().as_ref()) + ); + } + + /// The reuse decision: same recorded binary reuses the live daemon, a + /// different one stops it and starts the requested build instead. + #[test] + fn ensure_reuses_only_a_daemon_started_by_the_requested_binary() { + if !python3_available() { + eprintln!("skipping: python3 unavailable for the goose serve shim"); + return; + } + let home = tempfile::tempdir().unwrap(); + let bin = tempfile::tempdir().unwrap(); + let stock = write_goose_shim(bin.path(), "goose", "goose 1.0.0-stock"); + let patched = write_goose_shim(bin.path(), "goose-patched", "goose 2.0.0-patched"); + let stock_arg = b64_arg(&stock.to_string_lossy()); + let patched_arg = b64_arg(&patched.to_string_lossy()); + + let (lines, code) = run_script(&["ensure", "-", &stock_arg], None, home.path()); + assert_eq!(code, Some(0), "lines: {lines:?}"); + let first = parse_ready_line( + lines + .iter() + .find_map(|l| l.strip_prefix("READY ")) + .expect("READY"), + ) + .unwrap(); + assert!(!first.reused); + let first_fields = read_record_fields(home.path()); + assert_eq!(first_fields[0], "v2", "record fields: {first_fields:?}"); + assert_eq!(first_fields[6], b64_arg(&stock.to_string_lossy())); + + // Same binary: the healthy daemon is handed back untouched. + let (lines, code) = run_script(&["ensure", "-", &stock_arg], None, home.path()); + assert_eq!(code, Some(0), "lines: {lines:?}"); + let reused = parse_ready_line( + lines + .iter() + .find_map(|l| l.strip_prefix("READY ")) + .expect("READY"), + ) + .unwrap(); + assert!(reused.reused); + assert_eq!(reused.pid, first.pid); + assert_eq!(reused.port, first.port); + + // Different binary: restart, new port/secret, patched version. + let (lines, code) = run_script(&["ensure", "-", &patched_arg], None, home.path()); + assert_eq!(code, Some(0), "lines: {lines:?}"); + let restarted = parse_ready_line( + lines + .iter() + .find_map(|l| l.strip_prefix("READY ")) + .expect("READY"), + ) + .unwrap(); + assert!(!restarted.reused); + assert_ne!(restarted.pid, first.pid); + assert_ne!(restarted.secret, first.secret); + assert_eq!(restarted.goose_version, "goose 2.0.0-patched"); + let fields = read_record_fields(home.path()); + assert_eq!(fields[6], b64_arg(&patched.to_string_lossy())); + + let (lines, code) = run_script(&["shutdown"], None, home.path()); + assert_eq!(code, Some(0), "lines: {lines:?}"); + kill_recorded_pid(&first_fields); + } + + /// A record written before the binary field existed cannot prove which + /// build is serving, so it is stale: stop it and start fresh. + #[test] + fn ensure_treats_a_legacy_record_as_not_reusable() { + if !python3_available() { + eprintln!("skipping: python3 unavailable for the goose serve shim"); + return; + } + let home = tempfile::tempdir().unwrap(); + let bin = tempfile::tempdir().unwrap(); + let stock = write_goose_shim(bin.path(), "goose", "goose 1.0.0-stock"); + let stock_arg = b64_arg(&stock.to_string_lossy()); + + let (_, code) = run_script(&["ensure", "-", &stock_arg], None, home.path()); + assert_eq!(code, Some(0)); + let fields = read_record_fields(home.path()); + let record_path = home + .path() + .join(".state") + .join("berd") + .join("remote") + .join("daemon.record"); + // Rewrite as the pre-override (v1) shape: no marker, no binary. + std::fs::write( + &record_path, + format!( + "{} {} {} {} {}\n", + fields[1], fields[2], fields[3], fields[4], fields[5] + ), + ) + .unwrap(); + + let (lines, code) = run_script(&["ensure", "-", &stock_arg], None, home.path()); + assert_eq!(code, Some(0), "lines: {lines:?}"); + let info = parse_ready_line( + lines + .iter() + .find_map(|l| l.strip_prefix("READY ")) + .expect("READY"), + ) + .unwrap(); + assert!(!info.reused, "legacy record must not be reused"); + assert_ne!(info.pid.to_string(), fields[1]); + + let (_, code) = run_script(&["shutdown"], None, home.path()); + assert_eq!(code, Some(0)); + kill_recorded_pid(&fields); + } + #[test] fn shutdown_without_record_still_confirms() { let home = tempfile::tempdir().unwrap(); diff --git a/src-tauri/src/services/remote_backend/mod.rs b/src-tauri/src/services/remote_backend/mod.rs index 8309ab6d0..ff8dbd61e 100644 --- a/src-tauri/src/services/remote_backend/mod.rs +++ b/src-tauri/src/services/remote_backend/mod.rs @@ -12,6 +12,11 @@ //! - `disconnect` kills only the tunnel. `shutdown` also stops the remote //! daemon. App exit kills all tunnels and leaves daemons running on purpose. //! +//! A host may pin an optional goose binary path instead of the remote login +//! PATH lookup. The path is validated here before it reaches the bootstrap, is +//! remembered per slot so reconnects keep using it, and switching it forces a +//! fresh connect because the bootstrap restarts the remote daemon. +//! //! Every state transition is emitted as [`REMOTE_BACKEND_STATUS_EVENT`] so the //! renderer can mirror per-host status without polling. @@ -114,6 +119,10 @@ struct SlotShared { daemon: Option, local_port: Option, tunnel_pid: Option, + /// Validated goose binary override this slot connected with (`None` = the + /// remote login PATH). Supervisor reconnects reuse it, and a connect that + /// asks for a different one must not be served from cache. + goose_path: Option, } impl RemoteBackendRegistry { @@ -130,6 +139,7 @@ impl RemoteBackendRegistry { daemon: None, local_port: None, tunnel_pid: None, + goose_path: None, }), }) })) @@ -258,19 +268,49 @@ fn connection_from_shared(shared: &SlotShared) -> Option, +) -> Option { + if shared.goose_path.as_deref() != requested_goose_path { + return None; + } + connection_from_shared(shared) +} + pub async fn connect( app: &AppHandle, registry: &RemoteBackendRegistry, host_input: &str, + goose_path: Option<&str>, ) -> Result { let aliases = ssh_config::load_ssh_config_hosts(); let spec = RemoteHostSpec::parse(host_input, &aliases)?; + let goose_path = goose_path.map(daemon::normalize_goose_path).transpose()?; let slot = registry.slot(&spec); let _guard = slot.connect_lock.lock().await; - if let Some(existing) = connection_from_shared(&slot.shared.lock().expect("slot poisoned")) { - return Ok(existing); + { + let mut shared = slot.shared.lock().expect("slot poisoned"); + if let Some(existing) = cached_connection(&shared, goose_path.as_deref()) { + return Ok(existing); + } + if shared.goose_path.as_deref() != goose_path.as_deref() { + // A different goose binary was requested: the bootstrap will stop + // the recorded daemon and start a fresh one on a new remote port, + // so the current tunnel (and its supervisor) are already history. + shared.generation += 1; + if let Some(pid) = shared.tunnel_pid.take() { + kill_tunnel_pid(pid); + } + shared.local_port = None; + shared.daemon = None; + shared.goose_path = goose_path; + } } set_state(app, &slot, RemoteBackendState::Connecting); @@ -311,7 +351,21 @@ async fn establish( ) -> Result { let shell_env = dir_env::capture_home_interactive_env().await; - let daemon_info = daemon::ensure_daemon(&slot.spec, &shell_env, &extra_serve_args()).await?; + // Reconnects reuse the binary the slot connected with, so a supervisor + // never silently falls back to the PATH goose. + let goose_path = slot + .shared + .lock() + .expect("slot poisoned") + .goose_path + .clone(); + let daemon_info = daemon::ensure_daemon( + &slot.spec, + &shell_env, + &extra_serve_args(), + goose_path.as_deref(), + ) + .await?; let local_port = reserve_free_port().map_err(|error| { RemoteBackendError::new(RemoteBackendErrorKind::LocalPortBindFailed, error) @@ -507,11 +561,13 @@ pub async fn shutdown( pub async fn check_host( host_input: &str, + goose_path: Option<&str>, ) -> Result, RemoteBackendError> { let aliases = ssh_config::load_ssh_config_hosts(); let spec = RemoteHostSpec::parse(host_input, &aliases)?; + let goose_path = goose_path.map(daemon::normalize_goose_path).transpose()?; let shell_env = dir_env::capture_home_interactive_env().await; - daemon::check_host(&spec, &shell_env).await + daemon::check_host(&spec, &shell_env, goose_path.as_deref()).await } pub async fn list_remote_dir( @@ -563,6 +619,58 @@ mod tests { assert_eq!(json["state"], "connecting"); } + fn ready_shared(goose_path: Option<&str>) -> SlotShared { + SlotShared { + state: RemoteBackendState::Ready { + ws_url: "ws://127.0.0.1:5000/acp?token=x".to_string(), + http_base_url: "http://127.0.0.1:5000".to_string(), + local_port: 5000, + }, + generation: 1, + daemon: Some(RemoteDaemonInfo { + pid: 10, + port: 20, + secret: "hush".to_string(), + goose_version: "goose 1.0.0".to_string(), + started_at: "0".to_string(), + reused: false, + }), + local_port: Some(5000), + tunnel_pid: Some(99), + goose_path: goose_path.map(str::to_string), + } + } + + #[test] + fn cached_connection_is_reused_for_the_same_goose_path() { + assert!(cached_connection(&ready_shared(None), None).is_some()); + assert!(cached_connection( + &ready_shared(Some("/opt/goose/bin/goose")), + Some("/opt/goose/bin/goose") + ) + .is_some()); + } + + #[test] + fn cached_connection_is_refused_for_a_different_goose_path() { + // Adding, removing, or swapping an override all force a fresh connect + // because the bootstrap restarts the remote daemon. + assert!(cached_connection(&ready_shared(None), Some("/opt/goose/bin/goose")).is_none()); + assert!(cached_connection(&ready_shared(Some("/opt/goose/bin/goose")), None).is_none()); + assert!(cached_connection( + &ready_shared(Some("/opt/goose/bin/goose")), + Some("~/src/goose/target/release/goose") + ) + .is_none()); + } + + #[test] + fn cached_connection_is_none_while_not_ready() { + let mut shared = ready_shared(None); + shared.state = RemoteBackendState::Connecting; + assert!(cached_connection(&shared, None).is_none()); + } + #[test] fn snapshot_reports_registered_slots() { let registry = RemoteBackendRegistry::default(); diff --git a/src-tauri/src/services/remote_backend/remote_daemon.sh b/src-tauri/src/services/remote_backend/remote_daemon.sh index 50daca695..69bf99ae5 100644 --- a/src-tauri/src/services/remote_backend/remote_daemon.sh +++ b/src-tauri/src/services/remote_backend/remote_daemon.sh @@ -1,16 +1,31 @@ #!/usr/bin/env bash -# Berd remote backend bootstrap. Delivered over ssh stdin as `bash -s -- []` -# so no script text, secret, or user path ever appears in remote argv. +# Berd remote backend bootstrap. Delivered over ssh stdin as +# `bash -s -- [] []` so no script text, +# secret, or user path ever appears in remote argv. +# +# Positional args: +# $1 nonce per-invocation protocol prefix +# $2 mode ensure | shutdown | check | listdir +# $3 b64arg mode-specific, "-" when absent: extra `goose serve` args +# for `ensure`, the target path for `listdir` +# $4 b64goosepath optional goose binary override ("-" when absent), used by +# `ensure` and `check`; absolute or `~/`-prefixed # # Line protocol: every protocol line starts with the per-invocation nonce so # shell rc noise on stdout is ignored by the caller. Values that may contain # arbitrary bytes travel base64-encoded. # # Modes: -# ensure [b64 extra `goose serve` args] -> READY -# shutdown -> STOPPED -# check -> TOOL <0|1> ... CHECK-DONE -# listdir -> DIR , E ..., LIST-DONE +# ensure [b64 serve args] [b64 goose] -> READY +# shutdown -> STOPPED +# check [-] [b64 goose] -> TOOL <0|1> ... CHECK-DONE +# listdir -> DIR , E ..., LIST-DONE +# +# Daemon record (single line, space separated, field order pinned): +# v2 +# A record without the leading `v2` marker is a pre-override (v1) record: it +# still parses for shutdown, but is never reusable because the binary that +# started it is unknown. # # Typed exit codes: 41 goose-not-found, 43 port-bind-failed, 44 bad-path, 45 no-such-dir. set -u @@ -18,10 +33,12 @@ set -u NONCE="${1:?nonce required}" MODE="${2:?mode required}" ARG="${3:--}" +GOOSE_ARG="${4:--}" STATE_DIR="${XDG_STATE_HOME:-$HOME/.local/state}/berd/remote" RECORD="$STATE_DIR/daemon.record" LOG="$STATE_DIR/goose-serve.log" +RECORD_FORMAT="v2" emit() { printf '%s %s\n' "$NONCE" "$*"; } @@ -32,10 +49,75 @@ port_listening() { (exec 3<>"/dev/tcp/127.0.0.1/$1") 2>/dev/null } +expand_home() { + case "$1" in + "~") printf %s "$HOME" ;; + "~/"*) printf %s "$HOME/${1#\~/}" ;; + *) printf %s "$1" ;; + esac +} + +# Resolves the goose command into $goose_bin: the $4 override when given, +# otherwise the ssh login PATH lookup. Exits 41 when neither answers. +resolve_goose_bin() { + if [ "$GOOSE_ARG" != "-" ]; then + goose_bin="$(expand_home "$(unb64 "$GOOSE_ARG")")" + if [ -z "$goose_bin" ] || [ ! -f "$goose_bin" ] || [ ! -x "$goose_bin" ]; then + emit "ERR goose-not-found" + exit 41 + fi + return 0 + fi + goose_bin="$(command -v goose 2>/dev/null || true)" + if [ -z "$goose_bin" ]; then + emit "ERR goose-not-found" + exit 41 + fi +} + +# Fills rec_* from $RECORD. rec_b64binary is empty for a legacy (v1) record, +# which callers must treat as not reusable. read_record() { # shellcheck disable=SC2034 - IFS=' ' read -r rec_pid rec_port rec_secret rec_b64version rec_started <"$RECORD" 2>/dev/null || return 1 - [ -n "${rec_pid:-}" ] && [ -n "${rec_port:-}" ] && [ -n "${rec_secret:-}" ] + IFS=' ' read -r f1 f2 f3 f4 f5 f6 f7 <"$RECORD" 2>/dev/null || return 1 + if [ "${f1:-}" = "$RECORD_FORMAT" ]; then + rec_pid="${f2:-}" + rec_port="${f3:-}" + rec_secret="${f4:-}" + rec_b64version="${f5:--}" + rec_started="${f6:-0}" + rec_b64binary="${f7:-}" + else + # Pre-override record: no recorded binary, so it can only be shut down. + rec_pid="${f1:-}" + rec_port="${f2:-}" + rec_secret="${f3:-}" + rec_b64version="${f4:--}" + rec_started="${f5:-0}" + rec_b64binary="" + fi + case "$rec_pid" in + '' | *[!0-9]*) return 1 ;; + esac + case "$rec_port" in + '' | *[!0-9]*) return 1 ;; + esac + [ -n "$rec_secret" ] +} + +# Terminates the pid from the last read_record, TERM then KILL. +stop_recorded_daemon() { + if kill -0 "$rec_pid" 2>/dev/null; then + kill -TERM "$rec_pid" 2>/dev/null || true + i=0 + while [ "$i" -lt 30 ] && kill -0 "$rec_pid" 2>/dev/null; do + sleep 0.1 + i=$((i + 1)) + done + if kill -0 "$rec_pid" 2>/dev/null; then + kill -KILL "$rec_pid" 2>/dev/null || true + fi + fi } ensure_daemon() { @@ -45,19 +127,23 @@ ensure_daemon() { exit 46 } + resolve_goose_bin + b64binary="$(b64 "$goose_bin")" + if [ -f "$RECORD" ] && read_record; then if kill -0 "$rec_pid" 2>/dev/null && port_listening "$rec_port"; then - emit "READY $rec_pid $rec_port $rec_secret 1 $rec_b64version $rec_started" - return 0 + if [ -n "$rec_b64binary" ] && [ "$rec_b64binary" = "$b64binary" ]; then + emit "READY $rec_pid $rec_port $rec_secret 1 $rec_b64version $rec_started" + return 0 + fi + # A different (or unknown) goose binary is serving: stop it so the + # requested build is the one that answers. + stop_recorded_daemon fi rm -f "$RECORD" fi - command -v goose >/dev/null 2>&1 || { - emit "ERR goose-not-found" - exit 41 - } - version="$(goose --version 2>/dev/null | head -n 1)" + version="$("$goose_bin" --version 2>/dev/null | head -n 1)" [ -n "$version" ] || version="unknown" secret="berd-remote-$(head -c 16 /dev/urandom | od -An -tx1 | tr -d ' \n')" @@ -76,7 +162,7 @@ ensure_daemon() { # Detached: nohup + fully redirected stdio survives the ssh session ending # (no PTY is allocated, and non-interactive bash does not forward SIGHUP). # shellcheck disable=SC2086 - GOOSE_SERVER__SECRET_KEY="$secret" nohup goose serve --host 127.0.0.1 --port "$port" $extra_args >>"$LOG" 2>&1 >"$LOG" 2>&1 "$RECORD" + printf '%s %s %s %s %s %s %s\n' "$RECORD_FORMAT" "$pid" "$port" "$secret" \ + "$(b64 "$version")" "$started" "$b64binary" >"$RECORD" emit "READY $pid $port $secret 0 $(b64 "$version") $started" return 0 fi @@ -100,33 +187,30 @@ ensure_daemon() { shutdown_daemon() { if [ -f "$RECORD" ] && read_record; then - if kill -0 "$rec_pid" 2>/dev/null; then - kill -TERM "$rec_pid" 2>/dev/null || true - i=0 - while [ "$i" -lt 30 ] && kill -0 "$rec_pid" 2>/dev/null; do - sleep 0.1 - i=$((i + 1)) - done - if kill -0 "$rec_pid" 2>/dev/null; then - kill -KILL "$rec_pid" 2>/dev/null || true - fi - fi + stop_recorded_daemon fi rm -f "$RECORD" emit "STOPPED" } check_host() { - if command -v goose >/dev/null 2>&1; then - emit "TOOL goose 1 $(b64 "$(goose --version 2>/dev/null | head -n 1)")" + if [ "$GOOSE_ARG" != "-" ]; then + probe="$(expand_home "$(unb64 "$GOOSE_ARG")")" + if [ -n "$probe" ] && [ -f "$probe" ] && [ -x "$probe" ]; then + emit "TOOL goose 1 $(b64 "$("$probe" --version 2>/dev/null | head -n 1)") $(b64 "$probe")" + else + emit "TOOL goose 0 - $(b64 "$probe")" + fi + elif probe="$(command -v goose 2>/dev/null)" && [ -n "$probe" ]; then + emit "TOOL goose 1 $(b64 "$("$probe" --version 2>/dev/null | head -n 1)") $(b64 "$probe")" else - emit "TOOL goose 0 -" + emit "TOOL goose 0 - -" fi for tool in claude-agent-acp codex-acp; do - if command -v "$tool" >/dev/null 2>&1; then - emit "TOOL $tool 1 -" + if tool_path="$(command -v "$tool" 2>/dev/null)" && [ -n "$tool_path" ]; then + emit "TOOL $tool 1 - $(b64 "$tool_path")" else - emit "TOOL $tool 0 -" + emit "TOOL $tool 0 - -" fi done emit "CHECK-DONE" @@ -137,11 +221,7 @@ list_dir() { emit "ERR bad-path" exit 44 fi - target="$(unb64 "$ARG")" - case "$target" in - "~") target="$HOME" ;; - "~/"*) target="$HOME/${target#\~/}" ;; - esac + target="$(expand_home "$(unb64 "$ARG")")" case "$target" in /*) ;; *) diff --git a/src/features/remoteHosts/lib/gooseBinaryOverride.ts b/src/features/remoteHosts/lib/gooseBinaryOverride.ts new file mode 100644 index 000000000..8c8b363e4 --- /dev/null +++ b/src/features/remoteHosts/lib/gooseBinaryOverride.ts @@ -0,0 +1,72 @@ +/** + * Per-host goose binary overrides for remote SSH backends. + * + * The remote bootstrap normally resolves a bare `goose` from the ssh login + * PATH. This override lets a host point at a specific build instead (a + * non-PATH install, a pinned version, or a locally patched goose). + * + * Dependency-light on purpose: both the remote-host store and the + * `shared/api/remoteHosts` bridge import it, so it must not pull either in. + * Paths and hostnames only — never secrets. + */ + +export const REMOTE_HOST_GOOSE_PATH_STORAGE_KEY = + "goose:remote-host-goose-path"; + +/** + * Paths the remote script can resolve: absolute, or `~/`-relative to the + * remote home. Bare relative paths are ambiguous remotely, and Rust rejects + * them again before building any argv. + */ +export function isValidGoosePath(path: string): boolean { + const trimmed = path.trim(); + if (trimmed === "") return false; + if (/[\n\r\0]/.test(trimmed)) return false; + if (trimmed.endsWith("/")) return false; + return trimmed.startsWith("/") || trimmed.startsWith("~/"); +} + +/** Host -> goose binary path, tolerating any shape localStorage hands back. */ +export function loadPersistedGoosePaths(): Record { + if (typeof window === "undefined") return {}; + try { + const stored = window.localStorage.getItem( + REMOTE_HOST_GOOSE_PATH_STORAGE_KEY, + ); + if (!stored) return {}; + const parsed = JSON.parse(stored); + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { + return {}; + } + const byHost: Record = {}; + for (const [host, value] of Object.entries(parsed)) { + if (typeof value !== "string") continue; + const path = value.trim(); + if (host.trim() === "" || !isValidGoosePath(path)) continue; + byHost[host] = path; + } + return byHost; + } catch { + return {}; + } +} + +export function persistGoosePaths(byHost: Record): void { + if (typeof window === "undefined") return; + try { + window.localStorage.setItem( + REMOTE_HOST_GOOSE_PATH_STORAGE_KEY, + JSON.stringify(byHost), + ); + } catch { + // localStorage may be unavailable + } +} + +/** + * Current override for `host`, read straight from storage so callers outside + * React (the Tauri bridge) never depend on store hydration order. + */ +export function getGoosePathForHost(host: string): string | undefined { + return loadPersistedGoosePaths()[host.trim()]; +} diff --git a/src/features/remoteHosts/stores/remoteHostStore.test.ts b/src/features/remoteHosts/stores/remoteHostStore.test.ts index 9a854b931..b3bdc35e2 100644 --- a/src/features/remoteHosts/stores/remoteHostStore.test.ts +++ b/src/features/remoteHosts/stores/remoteHostStore.test.ts @@ -20,6 +20,11 @@ vi.mock("@/shared/api/remoteHosts", async (importOriginal) => { return { ...actual, ...mocks }; }); +import { + getGoosePathForHost, + loadPersistedGoosePaths, + REMOTE_HOST_GOOSE_PATH_STORAGE_KEY, +} from "@/features/remoteHosts/lib/gooseBinaryOverride"; import { initRemoteHostStore, loadPersistedManualHosts, @@ -47,6 +52,7 @@ function resetStore(): void { doctorPendingByHost: {}, doctorErrorByHost: {}, recentDirsByHost: {}, + goosePathByHost: {}, }); } @@ -332,6 +338,90 @@ describe("recordRecentDir", () => { }); }); +describe("goose binary override persistence", () => { + it("saves, exposes, and persists a per-host path", () => { + expect( + useRemoteHostStore + .getState() + .setGoosePath("devbox", " ~/src/goose/target/release/goose "), + ).toBe(true); + + expect(useRemoteHostStore.getState().goosePathByHost).toEqual({ + devbox: "~/src/goose/target/release/goose", + }); + expect(loadPersistedGoosePaths()).toEqual({ + devbox: "~/src/goose/target/release/goose", + }); + expect(getGoosePathForHost("devbox")).toBe( + "~/src/goose/target/release/goose", + ); + expect(getGoosePathForHost("other")).toBeUndefined(); + }); + + it("clears an override and persists the removal", () => { + const store = useRemoteHostStore.getState(); + store.setGoosePath("devbox", "/opt/goose/bin/goose"); + store.setGoosePath("gpu-1", "/opt/goose/bin/goose"); + + expect(store.setGoosePath("devbox", null)).toBe(true); + + expect(useRemoteHostStore.getState().goosePathByHost).toEqual({ + "gpu-1": "/opt/goose/bin/goose", + }); + expect(loadPersistedGoosePaths()).toEqual({ + "gpu-1": "/opt/goose/bin/goose", + }); + }); + + it("rejects paths the remote script could not resolve", () => { + const store = useRemoteHostStore.getState(); + for (const candidate of [ + "", + " ", + "goose", + "./goose", + "~goose", + "/opt/goose/", + "/opt/goose\ngoose", + ]) { + expect(store.setGoosePath("devbox", candidate)).toBe(false); + } + expect(store.setGoosePath(" ", "/opt/goose/bin/goose")).toBe(false); + + expect(useRemoteHostStore.getState().goosePathByHost).toEqual({}); + expect( + window.localStorage.getItem(REMOTE_HOST_GOOSE_PATH_STORAGE_KEY), + ).toBe(null); + }); + + it("tolerates corrupted storage and drops unusable entries", () => { + window.localStorage.setItem( + REMOTE_HOST_GOOSE_PATH_STORAGE_KEY, + "not-json{", + ); + expect(loadPersistedGoosePaths()).toEqual({}); + + window.localStorage.setItem( + REMOTE_HOST_GOOSE_PATH_STORAGE_KEY, + JSON.stringify(["/opt/goose/bin/goose"]), + ); + expect(loadPersistedGoosePaths()).toEqual({}); + + window.localStorage.setItem( + REMOTE_HOST_GOOSE_PATH_STORAGE_KEY, + JSON.stringify({ + devbox: "/opt/goose/bin/goose", + relative: "goose", + numeric: 42, + "": "/opt/goose/bin/goose", + }), + ); + expect(loadPersistedGoosePaths()).toEqual({ + devbox: "/opt/goose/bin/goose", + }); + }); +}); + describe("initRemoteHostStore", () => { it("subscribes to status events, seeds state, and returns unsubscribe", async () => { const unlisten = vi.fn(); diff --git a/src/features/remoteHosts/stores/remoteHostStore.ts b/src/features/remoteHosts/stores/remoteHostStore.ts index 25f9cdb87..372eb1aa7 100644 --- a/src/features/remoteHosts/stores/remoteHostStore.ts +++ b/src/features/remoteHosts/stores/remoteHostStore.ts @@ -1,4 +1,9 @@ import { create } from "zustand"; +import { + isValidGoosePath, + loadPersistedGoosePaths, + persistGoosePaths, +} from "@/features/remoteHosts/lib/gooseBinaryOverride"; import { checkRemoteHost, connectRemoteHost, @@ -121,6 +126,8 @@ export interface RemoteHostStore { doctorPendingByHost: Record; doctorErrorByHost: Record; recentDirsByHost: Record; + /** Per-host goose binary override; absent means the remote login PATH. */ + goosePathByHost: Record; // Actions refreshConfigHosts: () => Promise; @@ -132,6 +139,12 @@ export interface RemoteHostStore { runDoctor: (host: string) => Promise; recordRecentDir: (host: string, dir: string) => void; removeManualHost: (host: string) => void; + /** + * Set (or clear, with `null`) the goose binary a host's remote backend + * should run. Returns false for a path the remote script could not resolve. + * Takes effect on the next connect, which restarts the remote daemon. + */ + setGoosePath: (host: string, path: string | null) => boolean; } export const useRemoteHostStore = create((set, get) => ({ @@ -142,6 +155,7 @@ export const useRemoteHostStore = create((set, get) => ({ doctorPendingByHost: {}, doctorErrorByHost: {}, recentDirsByHost: loadPersistedRecentDirs(), + goosePathByHost: loadPersistedGoosePaths(), refreshConfigHosts: async () => { try { @@ -283,6 +297,25 @@ export const useRemoteHostStore = create((set, get) => ({ }); }, + setGoosePath: (host, path) => { + const trimmedHost = host.trim(); + if (!trimmedHost) return false; + const trimmedPath = path?.trim() ?? ""; + if (path !== null && !isValidGoosePath(trimmedPath)) return false; + + set((state) => { + const goosePathByHost = { ...state.goosePathByHost }; + if (path === null) { + delete goosePathByHost[trimmedHost]; + } else { + goosePathByHost[trimmedHost] = trimmedPath; + } + persistGoosePaths(goosePathByHost); + return { goosePathByHost }; + }); + return true; + }, + recordRecentDir: (host, dir) => { const trimmedHost = host.trim(); const trimmedDir = dir.trim(); diff --git a/src/features/remoteHosts/ui/RemoteHostsSettings.tsx b/src/features/remoteHosts/ui/RemoteHostsSettings.tsx index 0cc998956..23eb6a900 100644 --- a/src/features/remoteHosts/ui/RemoteHostsSettings.tsx +++ b/src/features/remoteHosts/ui/RemoteHostsSettings.tsx @@ -1,4 +1,4 @@ -import { useEffect, useMemo, useState } from "react"; +import { useEffect, useId, useMemo, useState } from "react"; import { useTranslation } from "react-i18next"; import { REMOTE_SSH_SESSIONS_EXPERIMENT_ID } from "@/features/experiments/experimentDefinitions"; import { useExperiment } from "@/features/experiments/experimentPreferences"; @@ -76,7 +76,7 @@ function DoctorReport({ {probes.map((probe) => (
  • {probe.binary} @@ -84,6 +84,10 @@ function DoctorReport({ ? (probe.version ?? t("remoteHosts.doctor.found")) : t("remoteHosts.doctor.notFound")} + {/* Which binary actually answered: confirms an override took. */} + {probe.path ? ( + {probe.path} + ) : null}
  • ))} @@ -96,6 +100,85 @@ function DoctorReport({ ); } +/** + * Optional per-host goose binary path. Empty means "resolve `goose` from the + * ssh login PATH" (the default); a saved path is what the remote daemon runs + * from the next connect on. + */ +function GooseBinaryOverride({ host }: { host: string }) { + const { t } = useTranslation("settings"); + const inputId = useId(); + const savedPath = useRemoteHostStore((state) => state.goosePathByHost[host]); + const setGoosePath = useRemoteHostStore((state) => state.setGoosePath); + + const [draft, setDraft] = useState(savedPath ?? ""); + const [error, setError] = useState(null); + + // Follow the persisted value when it changes elsewhere (other settings + // mount, another window) instead of stranding a stale draft. + useEffect(() => { + setDraft(savedPath ?? ""); + setError(null); + }, [savedPath]); + + const save = () => { + if (!setGoosePath(host, draft)) { + setError(t("remoteHosts.gooseBinary.invalidError")); + return; + } + setError(null); + }; + + const clear = () => { + setGoosePath(host, null); + setDraft(""); + setError(null); + }; + + return ( +
    + +
    + { + setDraft(event.target.value); + setError(null); + }} + onKeyDown={(event) => { + if (event.key === "Enter") { + event.preventDefault(); + save(); + } + }} + /> + + +
    + {error ?

    {error}

    : null} +

    + {t("remoteHosts.gooseBinary.hint")} +

    +
    + ); +} + function RemoteHostRow({ host, onRequestShutdown, @@ -204,13 +287,16 @@ function RemoteHostRow({ } details={ - showDoctor ? ( - - ) : undefined +
    + {showDoctor ? ( + + ) : null} + +
    } /> ); diff --git a/src/features/remoteHosts/ui/__tests__/RemoteHostsSettings.test.tsx b/src/features/remoteHosts/ui/__tests__/RemoteHostsSettings.test.tsx index 6c266161b..ae66ab05d 100644 --- a/src/features/remoteHosts/ui/__tests__/RemoteHostsSettings.test.tsx +++ b/src/features/remoteHosts/ui/__tests__/RemoteHostsSettings.test.tsx @@ -17,6 +17,7 @@ const shutdownHost = vi.fn(async () => {}); const runDoctor = vi.fn(async () => {}); const refreshConfigHosts = vi.fn(async () => {}); const syncBackendSnapshot = vi.fn(async () => {}); +const setGoosePath = vi.fn((_host: string, _path: string | null) => true); function seedStore(overrides?: Partial>) { useRemoteHostStore.setState({ ...baseState(), ...overrides }); @@ -30,12 +31,14 @@ function baseState() { doctorPendingByHost: {}, doctorErrorByHost: {}, recentDirsByHost: {}, + goosePathByHost: {} as Record, ensureHostConnected, disconnect, shutdownHost, runDoctor, refreshConfigHosts, syncBackendSnapshot, + setGoosePath, }; } @@ -167,6 +170,102 @@ describe("RemoteHostsSettings", () => { ).toBeInTheDocument(); }); + describe("goose binary override", () => { + it("renders the persisted path for the host", () => { + seedStore({ + configHosts: ["alpha"], + goosePathByHost: { alpha: "~/src/goose/target/release/goose" }, + }); + renderWithProviders(); + + expect( + screen.getByLabelText(enSettings.remoteHosts.gooseBinary.label), + ).toHaveValue("~/src/goose/target/release/goose"); + expect( + screen.getByText(enSettings.remoteHosts.gooseBinary.hint), + ).toBeInTheDocument(); + }); + + it("saves a typed path through the store", async () => { + const user = userEvent.setup(); + seedStore({ configHosts: ["alpha"] }); + renderWithProviders(); + + await user.type( + screen.getByLabelText(enSettings.remoteHosts.gooseBinary.label), + "/opt/goose/bin/goose", + ); + await user.click( + screen.getByRole("button", { + name: enSettings.remoteHosts.gooseBinary.save, + }), + ); + + expect(setGoosePath).toHaveBeenCalledWith( + "alpha", + "/opt/goose/bin/goose", + ); + }); + + it("shows an error for a path the remote script cannot resolve", async () => { + const user = userEvent.setup(); + setGoosePath.mockReturnValueOnce(false); + seedStore({ configHosts: ["alpha"] }); + renderWithProviders(); + + await user.type( + screen.getByLabelText(enSettings.remoteHosts.gooseBinary.label), + "goose", + ); + await user.click( + screen.getByRole("button", { + name: enSettings.remoteHosts.gooseBinary.save, + }), + ); + + expect( + screen.getByText(enSettings.remoteHosts.gooseBinary.invalidError), + ).toBeInTheDocument(); + }); + + it("clears a saved path", async () => { + const user = userEvent.setup(); + seedStore({ + configHosts: ["alpha"], + goosePathByHost: { alpha: "/opt/goose/bin/goose" }, + }); + renderWithProviders(); + + await user.click( + screen.getByRole("button", { + name: enSettings.remoteHosts.gooseBinary.clear, + }), + ); + + expect(setGoosePath).toHaveBeenCalledWith("alpha", null); + }); + + it("reports which binary answered the doctor check", async () => { + seedStore({ + configHosts: ["alpha"], + doctorByHost: { + alpha: [ + { + binary: "goose", + found: true, + version: "goose 2.0.0-patched", + path: "/opt/goose/bin/goose", + }, + ], + }, + }); + renderWithProviders(); + + expect(screen.getByText("goose 2.0.0-patched")).toBeInTheDocument(); + expect(screen.getByText("/opt/goose/bin/goose")).toBeInTheDocument(); + }); + }); + it("connects a free-form user@host and validates empty input", async () => { const user = userEvent.setup(); seedStore(); diff --git a/src/shared/api/__tests__/remoteHosts.test.ts b/src/shared/api/__tests__/remoteHosts.test.ts new file mode 100644 index 000000000..7fb0addd3 --- /dev/null +++ b/src/shared/api/__tests__/remoteHosts.test.ts @@ -0,0 +1,92 @@ +import { invoke } from "@tauri-apps/api/core"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { REMOTE_HOST_GOOSE_PATH_STORAGE_KEY } from "@/features/remoteHosts/lib/gooseBinaryOverride"; +import { + checkRemoteHost, + connectRemoteHost, + disconnectRemoteHost, +} from "@/shared/api/remoteHosts"; + +vi.mock("@tauri-apps/api/core", () => ({ + invoke: vi.fn(), +})); + +const mockedInvoke = vi.mocked(invoke); + +function persistOverride(byHost: Record): void { + window.localStorage.setItem( + REMOTE_HOST_GOOSE_PATH_STORAGE_KEY, + JSON.stringify(byHost), + ); +} + +describe("remote host goose binary override", () => { + beforeEach(() => { + vi.clearAllMocks(); + window.localStorage.clear(); + mockedInvoke.mockResolvedValue(undefined); + }); + + it("sends the persisted override as goosePath on connect", async () => { + persistOverride({ devbox: "~/src/goose/target/release/goose" }); + + await connectRemoteHost("devbox"); + + expect(mockedInvoke).toHaveBeenCalledWith("remote_backend_connect", { + host: "devbox", + goosePath: "~/src/goose/target/release/goose", + }); + }); + + it("sends a null goosePath for hosts without an override", async () => { + persistOverride({ other: "/opt/goose/bin/goose" }); + + await connectRemoteHost("devbox"); + + expect(mockedInvoke).toHaveBeenCalledWith("remote_backend_connect", { + host: "devbox", + goosePath: null, + }); + }); + + it("probes the override binary in the doctor check", async () => { + persistOverride({ devbox: "/opt/goose/bin/goose" }); + mockedInvoke.mockResolvedValue([ + { + binary: "goose", + found: true, + version: "goose 2.0.0", + path: "/opt/goose/bin/goose", + }, + ]); + + const probes = await checkRemoteHost("devbox"); + + expect(mockedInvoke).toHaveBeenCalledWith("check_remote_host", { + host: "devbox", + goosePath: "/opt/goose/bin/goose", + }); + expect(probes[0].path).toBe("/opt/goose/bin/goose"); + }); + + it("ignores unusable persisted paths instead of forwarding them", async () => { + persistOverride({ devbox: "goose" }); + + await connectRemoteHost("devbox"); + + expect(mockedInvoke).toHaveBeenCalledWith("remote_backend_connect", { + host: "devbox", + goosePath: null, + }); + }); + + it("leaves other remote-host commands untouched", async () => { + persistOverride({ devbox: "/opt/goose/bin/goose" }); + + await disconnectRemoteHost("devbox"); + + expect(mockedInvoke).toHaveBeenCalledWith("remote_backend_disconnect", { + host: "devbox", + }); + }); +}); diff --git a/src/shared/api/remoteHosts.ts b/src/shared/api/remoteHosts.ts index 926e94bed..174e8a691 100644 --- a/src/shared/api/remoteHosts.ts +++ b/src/shared/api/remoteHosts.ts @@ -1,5 +1,6 @@ import { invoke } from "@tauri-apps/api/core"; import { listen, type UnlistenFn } from "@tauri-apps/api/event"; +import { getGoosePathForHost } from "@/features/remoteHosts/lib/gooseBinaryOverride"; /** * Error shape thrown by the remote-backend Tauri commands. `kind` is a @@ -67,6 +68,8 @@ export interface RemoteToolProbe { binary: string; found: boolean; version?: string; + /** Path that answered the probe (an override, or the PATH resolution). */ + path?: string; } export interface RemoteDirEntry { @@ -87,11 +90,18 @@ export async function listSshConfigHosts(): Promise { /** * Connect (or reuse the connection) to the Goose daemon on `host`. Idempotent * per host; the Rust side serializes concurrent connects. + * + * The persisted per-host goose binary override is looked up here rather than + * threaded through callers, so every connect path (settings, session routing, + * reconnects) agrees on which binary the host should run. */ export async function connectRemoteHost( host: string, ): Promise { - return await invoke("remote_backend_connect", { host }); + return await invoke("remote_backend_connect", { + host, + goosePath: getGoosePathForHost(host) ?? null, + }); } /** Tear down the local tunnel to `host`, leaving the remote daemon running. */ @@ -111,11 +121,18 @@ export async function listRemoteBackends(): Promise< return await invoke("list_remote_backends"); } -/** Probe `host` for the agent binaries (goose, claude-agent-acp, codex-acp). */ +/** + * Probe `host` for the agent binaries (goose, claude-agent-acp, codex-acp). + * When the host has a goose binary override, that binary is probed instead of + * the PATH lookup so the report shows the build Berd would actually run. + */ export async function checkRemoteHost( host: string, ): Promise { - return await invoke("check_remote_host", { host }); + return await invoke("check_remote_host", { + host, + goosePath: getGoosePathForHost(host) ?? null, + }); } /** diff --git a/src/shared/i18n/locales/en/settings.json b/src/shared/i18n/locales/en/settings.json index 0e3a877c5..e1caf05d2 100644 --- a/src/shared/i18n/locales/en/settings.json +++ b/src/shared/i18n/locales/en/settings.json @@ -716,6 +716,14 @@ "notFound": "Not found" }, "empty": "No hosts found in ~/.ssh/config.", + "gooseBinary": { + "clear": "Clear", + "hint": "Overrides the PATH lookup for goose on this host. Changing it restarts the remote backend on the next connect.", + "invalidError": "Enter an absolute path, or one starting with ~/.", + "label": "Goose binary path (optional)", + "placeholder": "~/src/goose/target/release/goose", + "save": "Save" + }, "shutdownConfirm": { "cancel": "Cancel", "confirm": "Stop backend", diff --git a/src/shared/i18n/locales/es/settings.json b/src/shared/i18n/locales/es/settings.json index acc020910..f2c5ffd1d 100644 --- a/src/shared/i18n/locales/es/settings.json +++ b/src/shared/i18n/locales/es/settings.json @@ -719,6 +719,14 @@ "notFound": "No encontrado" }, "empty": "No se encontraron hosts en ~/.ssh/config.", + "gooseBinary": { + "clear": "Borrar", + "hint": "Sustituye la búsqueda de goose en el PATH de este host. Al cambiarla, el backend remoto se reinicia en la próxima conexión.", + "invalidError": "Introduce una ruta absoluta o que empiece por ~/.", + "label": "Ruta del binario de goose (opcional)", + "placeholder": "~/src/goose/target/release/goose", + "save": "Guardar" + }, "shutdownConfirm": { "cancel": "Cancelar", "confirm": "Detener backend", From 0e5f7a09c1fd9bb30fa2626d36b823bd07776bf4 Mon Sep 17 00:00:00 2001 From: damienrj Date: Mon, 31 Aug 2026 10:26:19 -0700 Subject: [PATCH 04/13] fix: gate unix-only process import for Windows build --- src-tauri/src/services/remote_backend/mod.rs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src-tauri/src/services/remote_backend/mod.rs b/src-tauri/src/services/remote_backend/mod.rs index ff8dbd61e..b9feb39fb 100644 --- a/src-tauri/src/services/remote_backend/mod.rs +++ b/src-tauri/src/services/remote_backend/mod.rs @@ -39,6 +39,9 @@ use crate::services::acp::goose_serve::{ }; use crate::services::diagnostic_log::{self, DiagnosticCategory, DiagnosticLevel}; use crate::services::dir_env; +// Only the unix tunnel-kill path goes through the shared process helpers; the +// Windows branch talks to sysinfo directly. +#[cfg(unix)] use crate::services::process; use daemon::RemoteDaemonInfo; From 1cc39c28ddbe3611451b14cbad5c6dcb86fe8d1c Mon Sep 17 00:00:00 2001 From: damienrj Date: Mon, 31 Aug 2026 11:06:32 -0700 Subject: [PATCH 05/13] fix: address remote session review findings --- .../src/services/remote_backend/daemon.rs | 45 +++++++++- .../services/remote_backend/remote_daemon.sh | 84 ++++++++++++++----- .../acpSessionMapping.remoteHost.test.ts | 11 +++ src/features/chat/lib/acpSessionMapping.ts | 16 +++- .../chatSessionStore.remoteSessions.test.ts | 54 ++++++++++++ .../remoteSessionPersistence.test.ts | 18 ++-- src/features/chat/stores/chatSessionStore.ts | 13 ++- .../chat/stores/remoteSessionPersistence.ts | 9 +- 8 files changed, 210 insertions(+), 40 deletions(-) diff --git a/src-tauri/src/services/remote_backend/daemon.rs b/src-tauri/src/services/remote_backend/daemon.rs index c7b9286a1..13498b810 100644 --- a/src-tauri/src/services/remote_backend/daemon.rs +++ b/src-tauri/src/services/remote_backend/daemon.rs @@ -887,8 +887,9 @@ fi .unwrap(); assert!(!first.reused); let first_fields = read_record_fields(home.path()); - assert_eq!(first_fields[0], "v2", "record fields: {first_fields:?}"); + assert_eq!(first_fields[0], "v3", "record fields: {first_fields:?}"); assert_eq!(first_fields[6], b64_arg(&stock.to_string_lossy())); + assert!(!first_fields[7].is_empty()); // Same binary: the healthy daemon is handed back untouched. let (lines, code) = run_script(&["ensure", "-", &stock_arg], None, home.path()); @@ -926,8 +927,9 @@ fi kill_recorded_pid(&first_fields); } - /// A record written before the binary field existed cannot prove which - /// build is serving, so it is stale: stop it and start fresh. + /// A record written before process identities existed cannot prove PID + /// ownership, so it is discarded without signaling and a fresh daemon + /// is started. #[test] fn ensure_treats_a_legacy_record_as_not_reusable() { if !python3_available() { @@ -969,12 +971,49 @@ fi .unwrap(); assert!(!info.reused, "legacy record must not be reused"); assert_ne!(info.pid.to_string(), fields[1]); + assert!( + StdCommand::new("kill") + .arg("-0") + .arg(&fields[1]) + .status() + .unwrap() + .success(), + "an unverifiable legacy PID must not be signaled" + ); let (_, code) = run_script(&["shutdown"], None, home.path()); assert_eq!(code, Some(0)); kill_recorded_pid(&fields); } + #[test] + fn shutdown_does_not_signal_a_pid_with_the_wrong_identity() { + let home = tempfile::tempdir().unwrap(); + let state_dir = home.path().join(".state").join("berd").join("remote"); + std::fs::create_dir_all(&state_dir).unwrap(); + let mut unrelated = StdCommand::new("sleep").arg("120").spawn().unwrap(); + std::fs::write( + state_dir.join("daemon.record"), + format!( + "v3 {} 54321 secret - 0 - {}\n", + unrelated.id(), + b64_arg("not-the-recorded-process") + ), + ) + .unwrap(); + + let (lines, code) = run_script(&["shutdown"], None, home.path()); + + assert_eq!(code, Some(0), "lines: {lines:?}"); + assert!(lines.iter().any(|line| line == "STOPPED")); + assert!( + unrelated.try_wait().unwrap().is_none(), + "shutdown must leave a mismatched PID alive" + ); + unrelated.kill().unwrap(); + unrelated.wait().unwrap(); + } + #[test] fn shutdown_without_record_still_confirms() { let home = tempfile::tempdir().unwrap(); diff --git a/src-tauri/src/services/remote_backend/remote_daemon.sh b/src-tauri/src/services/remote_backend/remote_daemon.sh index 69bf99ae5..df1f5c8f0 100644 --- a/src-tauri/src/services/remote_backend/remote_daemon.sh +++ b/src-tauri/src/services/remote_backend/remote_daemon.sh @@ -22,10 +22,10 @@ # listdir -> DIR , E ..., LIST-DONE # # Daemon record (single line, space separated, field order pinned): -# v2 -# A record without the leading `v2` marker is a pre-override (v1) record: it -# still parses for shutdown, but is never reusable because the binary that -# started it is unknown. +# v3 +# The identity is an OS process-start token captured after readiness. Older +# records still parse, but cannot prove PID ownership and are never reused or +# signaled. # # Typed exit codes: 41 goose-not-found, 43 port-bind-failed, 44 bad-path, 45 no-such-dir. set -u @@ -38,7 +38,7 @@ GOOSE_ARG="${4:--}" STATE_DIR="${XDG_STATE_HOME:-$HOME/.local/state}/berd/remote" RECORD="$STATE_DIR/daemon.record" LOG="$STATE_DIR/goose-serve.log" -RECORD_FORMAT="v2" +RECORD_FORMAT="v3" emit() { printf '%s %s\n' "$NONCE" "$*"; } @@ -75,11 +75,31 @@ resolve_goose_bin() { fi } -# Fills rec_* from $RECORD. rec_b64binary is empty for a legacy (v1) record, -# which callers must treat as not reusable. +# Returns an OS-provided process identity that remains stable for the lifetime +# of a PID. Linux exposes an exact boot-relative start tick; BSD/macOS ps +# exposes the process start timestamp and command. The value is persisted and +# must match before Berd ever signals the recorded PID. +process_identity() { + identity_pid="$1" + if [ -r "/proc/$identity_pid/stat" ]; then + # After removing pid + parenthesized comm, process start time is field 20 + # of the remainder (field 22 in proc_pid_stat(5)). + identity_start="$(sed 's/^.*) //' "/proc/$identity_pid/stat" 2>/dev/null | awk '{print $20}')" + [ -n "$identity_start" ] || return 1 + printf 'proc:%s' "$identity_start" + return 0 + fi + + identity_ps="$(ps -p "$identity_pid" -o lstart= -o command= 2>/dev/null)" || return 1 + [ -n "$identity_ps" ] || return 1 + printf 'ps:%s' "$identity_ps" +} + +# Fills rec_* from $RECORD. Records before v3 have no process identity and +# therefore cannot authorize reuse or termination. read_record() { # shellcheck disable=SC2034 - IFS=' ' read -r f1 f2 f3 f4 f5 f6 f7 <"$RECORD" 2>/dev/null || return 1 + IFS=' ' read -r f1 f2 f3 f4 f5 f6 f7 f8 <"$RECORD" 2>/dev/null || return 1 if [ "${f1:-}" = "$RECORD_FORMAT" ]; then rec_pid="${f2:-}" rec_port="${f3:-}" @@ -87,14 +107,24 @@ read_record() { rec_b64version="${f5:--}" rec_started="${f6:-0}" rec_b64binary="${f7:-}" + rec_b64identity="${f8:-}" + elif [ "${f1:-}" = "v2" ]; then + rec_pid="${f2:-}" + rec_port="${f3:-}" + rec_secret="${f4:-}" + rec_b64version="${f5:--}" + rec_started="${f6:-0}" + rec_b64binary="${f7:-}" + rec_b64identity="" else - # Pre-override record: no recorded binary, so it can only be shut down. + # Pre-override record: no recorded binary or process identity. rec_pid="${f1:-}" rec_port="${f2:-}" rec_secret="${f3:-}" rec_b64version="${f4:--}" rec_started="${f5:-0}" rec_b64binary="" + rec_b64identity="" fi case "$rec_pid" in '' | *[!0-9]*) return 1 ;; @@ -105,16 +135,26 @@ read_record() { [ -n "$rec_secret" ] } -# Terminates the pid from the last read_record, TERM then KILL. +# Confirms the PID is still the exact process that wrote this record. `kill -0` +# alone is insufficient because a stale PID can be reused by another process. +recorded_process_is_current() { + [ -n "$rec_b64identity" ] || return 1 + kill -0 "$rec_pid" 2>/dev/null || return 1 + current_identity="$(process_identity "$rec_pid")" || return 1 + [ "$(b64 "$current_identity")" = "$rec_b64identity" ] +} + +# Terminates the pid from the last read_record, TERM then KILL, while rechecking +# ownership so a PID recycled during shutdown is never signaled. stop_recorded_daemon() { - if kill -0 "$rec_pid" 2>/dev/null; then + if recorded_process_is_current; then kill -TERM "$rec_pid" 2>/dev/null || true i=0 - while [ "$i" -lt 30 ] && kill -0 "$rec_pid" 2>/dev/null; do + while [ "$i" -lt 30 ] && recorded_process_is_current; do sleep 0.1 i=$((i + 1)) done - if kill -0 "$rec_pid" 2>/dev/null; then + if recorded_process_is_current; then kill -KILL "$rec_pid" 2>/dev/null || true fi fi @@ -131,13 +171,14 @@ ensure_daemon() { b64binary="$(b64 "$goose_bin")" if [ -f "$RECORD" ] && read_record; then - if kill -0 "$rec_pid" 2>/dev/null && port_listening "$rec_port"; then - if [ -n "$rec_b64binary" ] && [ "$rec_b64binary" = "$b64binary" ]; then + if recorded_process_is_current; then + if port_listening "$rec_port" && [ "$rec_b64binary" = "$b64binary" ]; then emit "READY $rec_pid $rec_port $rec_secret 1 $rec_b64version $rec_started" return 0 fi - # A different (or unknown) goose binary is serving: stop it so the - # requested build is the one that answers. + # A known daemon is unhealthy or uses another binary: stop it so the + # requested build is the one that answers. Unverifiable old records are + # only discarded; their PIDs are never signaled. stop_recorded_daemon fi rm -f "$RECORD" @@ -171,8 +212,13 @@ ensure_daemon() { fi if port_listening "$port"; then started="$(date +%s)" - printf '%s %s %s %s %s %s %s\n' "$RECORD_FORMAT" "$pid" "$port" "$secret" \ - "$(b64 "$version")" "$started" "$b64binary" >"$RECORD" + identity="$(process_identity "$pid")" + if [ -z "$identity" ]; then + kill "$pid" 2>/dev/null || true + break + fi + printf '%s %s %s %s %s %s %s %s\n' "$RECORD_FORMAT" "$pid" "$port" "$secret" \ + "$(b64 "$version")" "$started" "$b64binary" "$(b64 "$identity")" >"$RECORD" emit "READY $pid $port $secret 0 $(b64 "$version") $started" return 0 fi diff --git a/src/features/chat/lib/__tests__/acpSessionMapping.remoteHost.test.ts b/src/features/chat/lib/__tests__/acpSessionMapping.remoteHost.test.ts index be8cb842e..c04b6d437 100644 --- a/src/features/chat/lib/__tests__/acpSessionMapping.remoteHost.test.ts +++ b/src/features/chat/lib/__tests__/acpSessionMapping.remoteHost.test.ts @@ -54,6 +54,17 @@ describe("acpSessionMapping remoteHost", () => { expect(session.remoteHost).toBe("devbox"); }); + it("infers the remote host from a composite session id", () => { + const session = acpSessionToChatSession( + makeAcpSession({ + sessionId: "ssh:devbox#fork-1", + workingDir: "/remote/dir", + }), + ); + + expect(session.remoteHost).toBe("devbox"); + }); + it("leaves locally loaded sessions untagged", () => { const session = acpSessionToChatSession( makeAcpSession({ sessionId: "local-1" }), diff --git a/src/features/chat/lib/acpSessionMapping.ts b/src/features/chat/lib/acpSessionMapping.ts index fb3ec3127..ea7e40b9f 100644 --- a/src/features/chat/lib/acpSessionMapping.ts +++ b/src/features/chat/lib/acpSessionMapping.ts @@ -9,6 +9,10 @@ import { normalizeAcpTitle } from "@/features/chat/lib/sessionTitle"; import { withWorkspaceBackfill } from "@/features/chat/lib/workspaceAttachments"; import { loadPersistedChatWorkspaceMetadata } from "@/features/chat/stores/workspaceAttachmentPersistence"; import { executionTargetFromGooseServeSession } from "@/features/chat/lib/gooseServeExecutionTarget"; +import { + remoteHostFromBackendId, + splitCompositeSessionId, +} from "@/shared/api/acpBackendId"; interface SessionPageState { sessions: ChatSession[]; @@ -52,6 +56,12 @@ export function chatSessionFromAcpInfo( providerId: session.providerId ?? undefined, modelId: session.modelId ?? undefined, }); + const compositeBackend = splitCompositeSessionId( + session.sessionId, + )?.backendId; + const remoteHost = + context?.remoteHost ?? + (compositeBackend ? remoteHostFromBackendId(compositeBackend) : null); return { id: session.sessionId, title: normalizeAcpTitle(session.title) ?? "Untitled", @@ -60,9 +70,9 @@ export function chatSessionFromAcpInfo( executionTargetSource: executionTarget ? "acp" : undefined, personaId: session.personaId ?? undefined, workingDir: session.workingDir ?? undefined, - // Only set the key when the session actually came from a remote backend, - // so spreading a locally loaded row can never wipe an existing tag. - ...(context?.remoteHost ? { remoteHost: context.remoteHost } : {}), + // Only set the key when context or the composite id proves that the + // session came from a remote backend, so a local row cannot gain a tag. + ...(remoteHost ? { remoteHost } : {}), createdAt: session.createdAt ?? session.updatedAt ?? now, updatedAt: session.updatedAt ?? now, lastMessageAt: session.lastMessageAt ?? undefined, diff --git a/src/features/chat/stores/__tests__/chatSessionStore.remoteSessions.test.ts b/src/features/chat/stores/__tests__/chatSessionStore.remoteSessions.test.ts index 384ae23e1..e1c4f8ac0 100644 --- a/src/features/chat/stores/__tests__/chatSessionStore.remoteSessions.test.ts +++ b/src/features/chat/stores/__tests__/chatSessionStore.remoteSessions.test.ts @@ -2,6 +2,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import { REMOTE_SESSIONS_STORAGE_KEY, readRemoteSessionRecords, + rehydrateRemoteSessions, } from "../remoteSessionPersistence"; import { useChatSessionStore } from "../chatSessionStore"; @@ -164,6 +165,27 @@ describe("chatSessionStore remote sessions", () => { ]); }); + it("persists remote sessions inserted by fork flows", () => { + useChatSessionStore.getState().addSession({ + id: "ssh:devbox#fork-1", + title: "Remote fork", + remoteHost: "devbox", + workingDir: "/remote/dir", + createdAt: "2026-08-31T00:00:00.000Z", + updatedAt: "2026-08-31T00:00:00.000Z", + messageCount: 3, + }); + + expect(readRemoteSessionRecords()).toEqual([ + expect.objectContaining({ + sessionId: "ssh:devbox#fork-1", + host: "devbox", + title: "Remote fork", + workingDir: "/remote/dir", + }), + ]); + }); + it("updates the record on rename and removes it on delete", () => { const store = useChatSessionStore.getState(); const draft = store.createDraftSession({ @@ -203,4 +225,36 @@ describe("chatSessionStore remote sessions", () => { await useChatSessionStore.getState().unarchiveSession("backend-4"); expect(readRemoteSessionRecords()[0]?.archivedAt).toBeUndefined(); }); + + it("rehydrates archived remote sessions into history after restart", async () => { + const store = useChatSessionStore.getState(); + const draft = store.createDraftSession({ + workingDir: "/remote/dir", + remoteHost: "devbox", + }); + store.promoteDraftSession(draft.id, "ssh:devbox#backend-5", { + title: "Archived remote work", + }); + await useChatSessionStore.getState().archiveSession("ssh:devbox#backend-5"); + const archivedAt = readRemoteSessionRecords()[0]?.archivedAt; + + resetStore(); + vi.clearAllMocks(); + await rehydrateRemoteSessions(); + + expect(mocks.registerSessionBackend).toHaveBeenCalledWith( + "ssh:devbox#backend-5", + "ssh:devbox", + "backend-5", + ); + expect( + useChatSessionStore.getState().getSession("ssh:devbox#backend-5"), + ).toEqual( + expect.objectContaining({ + title: "Archived remote work", + remoteHost: "devbox", + archivedAt, + }), + ); + }); }); diff --git a/src/features/chat/stores/__tests__/remoteSessionPersistence.test.ts b/src/features/chat/stores/__tests__/remoteSessionPersistence.test.ts index 6aec06f1c..d4873ef65 100644 --- a/src/features/chat/stores/__tests__/remoteSessionPersistence.test.ts +++ b/src/features/chat/stores/__tests__/remoteSessionPersistence.test.ts @@ -195,20 +195,20 @@ describe("remoteSessionPersistence", () => { ]); }); - it("skips archived records", async () => { - persistRemoteSessionRecord( - makeRecord({ archivedAt: "2026-08-02T00:00:00.000Z" }), - ); + it("rehydrates archived records into session history", async () => { + const archivedAt = "2026-08-02T00:00:00.000Z"; + persistRemoteSessionRecord(makeRecord({ archivedAt })); await rehydrateRemoteSessions(); - expect(mocks.registerSessionBackend).not.toHaveBeenCalled(); - expect( - useChatSessionStore.getState().getSession("remote-1"), - ).toBeUndefined(); + expect(mocks.registerSessionBackend).toHaveBeenCalledWith( + "ssh:devbox#remote-1", + "ssh:devbox", + "remote-1", + ); expect( useChatSessionStore.getState().getSession("ssh:devbox#remote-1"), - ).toBeUndefined(); + ).toMatchObject({ archivedAt, remoteHost: "devbox" }); }); it("does not clobber a session already in the store", async () => { diff --git a/src/features/chat/stores/chatSessionStore.ts b/src/features/chat/stores/chatSessionStore.ts index 11e6d70d5..8406673cb 100644 --- a/src/features/chat/stores/chatSessionStore.ts +++ b/src/features/chat/stores/chatSessionStore.ts @@ -985,6 +985,7 @@ export const useChatSessionStore = create((set, get) => ({ }, addSession: (session) => { + let sessionForRemotePersistence: ChatSession | null = null; set((state) => { const existing = state.sessions.findIndex( (candidate) => candidate.id === session.id, @@ -992,7 +993,7 @@ export const useChatSessionStore = create((set, get) => ({ const backfilledSession = withWorkspaceBackfill(session); if (existing >= 0) { const updated = [...state.sessions]; - updated[existing] = withWorkspaceBackfill({ + const merged = withWorkspaceBackfill({ ...updated[existing], ...backfilledSession, workspaceAttachments: @@ -1002,10 +1003,20 @@ export const useChatSessionStore = create((set, get) => ({ updated[existing].activeWorkspaceId ?? backfilledSession.activeWorkspaceId, }); + updated[existing] = merged; + if (merged.remoteHost) { + sessionForRemotePersistence = merged; + } return { sessions: updated }; } + if (backfilledSession.remoteHost) { + sessionForRemotePersistence = backfilledSession; + } return { sessions: [backfilledSession, ...state.sessions] }; }); + if (sessionForRemotePersistence) { + persistRemoteSessionRecordForSession(sessionForRemotePersistence); + } }, removeSession: (id) => { diff --git a/src/features/chat/stores/remoteSessionPersistence.ts b/src/features/chat/stores/remoteSessionPersistence.ts index 392d79c91..442077e64 100644 --- a/src/features/chat/stores/remoteSessionPersistence.ts +++ b/src/features/chat/stores/remoteSessionPersistence.ts @@ -134,8 +134,8 @@ export function readRemoteSessionRecords(): RemoteSessionRecord[] { } /** - * Restores remote sessions after an app restart: re-registers each - * non-archived record's session→backend routing and seeds a placeholder + * Restores remote sessions after an app restart: re-registers each record's + * session→backend routing and seeds a placeholder * `ChatSession` so the sidebar can render it before the remote backend is * contacted. Activation reconciles the placeholder (title, counts, replay) * through the normal session-load path. @@ -145,9 +145,7 @@ export function readRemoteSessionRecords(): RemoteSessionRecord[] { * above. */ export async function rehydrateRemoteSessions(): Promise { - const records = readRemoteSessionRecords().filter( - (record) => !record.archivedAt, - ); + const records = readRemoteSessionRecords(); if (records.length === 0) return; const { useChatSessionStore } = await import( @@ -181,6 +179,7 @@ export async function rehydrateRemoteSessions(): Promise { clientSessionId: sessionId, createdAt: record.updatedAt, updatedAt: record.updatedAt, + archivedAt: record.archivedAt, // Placeholder: the remote message count is unknown until the session // loads, and 0 would hide the row from the sidebar entirely. messageCount: 1, From 127e098da3c5961e8a388a67d131e2c908d64bea Mon Sep 17 00:00:00 2001 From: damienrj Date: Mon, 31 Aug 2026 11:39:45 -0700 Subject: [PATCH 06/13] fix: address remote session review feedback --- src-tauri/src/services/remote_backend/mod.rs | 104 ++++++++++++++---- .../services/remote_backend/remote_daemon.sh | 4 +- src/app/AppShell.tsx | 31 +++++- src/app/lib/chatRuntimeStartup.ts | 3 +- .../remoteSessionPersistence.test.ts | 64 +++++++++++ .../chat/stores/remoteSessionPersistence.ts | 78 ++++++++++++- .../ui/session-list/SidebarChatRow.tsx | 6 +- .../ui/session-list/SidebarProjectSection.tsx | 47 ++++++-- .../__tests__/SidebarProjectUnread.test.tsx | 35 ++++++ 9 files changed, 334 insertions(+), 38 deletions(-) diff --git a/src-tauri/src/services/remote_backend/mod.rs b/src-tauri/src/services/remote_backend/mod.rs index b9feb39fb..b5327f1d0 100644 --- a/src-tauri/src/services/remote_backend/mod.rs +++ b/src-tauri/src/services/remote_backend/mod.rs @@ -284,6 +284,24 @@ fn cached_connection( connection_from_shared(shared) } +fn advance_generation_if_current(shared: &mut SlotShared, expected: u64) -> Option { + if shared.generation != expected { + return None; + } + shared.generation += 1; + Some(shared.generation) +} + +fn clear_tunnel_pid_if_current(shared: &mut SlotShared, generation: u64, pid: Option) -> bool { + if shared.generation != generation { + return false; + } + if shared.tunnel_pid == pid { + shared.tunnel_pid = None; + } + true +} + pub async fn connect( app: &AppHandle, registry: &RemoteBackendRegistry, @@ -319,11 +337,15 @@ pub async fn connect( set_state(app, &slot, RemoteBackendState::Connecting); record_diagnostic(DiagnosticLevel::Info, "connect_start", &slot.key, None); - match establish(app, &slot, 0).await { - Ok(connection) => { + let expected_generation = slot.shared.lock().expect("slot poisoned").generation; + match establish(app, &slot, expected_generation, 0).await { + Ok(Some(connection)) => { record_diagnostic(DiagnosticLevel::Info, "connect_success", &slot.key, None); Ok(connection) } + Ok(None) => Err(RemoteBackendError::internal( + "remote connection attempt was superseded", + )), Err(error) => { record_diagnostic( DiagnosticLevel::Error, @@ -331,13 +353,15 @@ pub async fn connect( &slot.key, Some(&error.message), ); - set_state( - app, - &slot, - RemoteBackendState::Failed { - error: error.clone(), - }, - ); + if slot.shared.lock().expect("slot poisoned").generation == expected_generation { + set_state( + app, + &slot, + RemoteBackendState::Failed { + error: error.clone(), + }, + ); + } Err(error) } } @@ -347,11 +371,15 @@ pub async fn connect( /// Caller must hold the slot's connect lock. `prior_attempts` carries the /// reconnect budget already spent into the next supervisor so a flapping /// tunnel cannot reset it (see [`RECONNECT_STABLE_UPTIME`]). +/// `expected_generation` is the ownership token observed before asynchronous +/// setup began. `Ok(None)` means a disconnect or newer connection superseded +/// this attempt before it could publish Ready. async fn establish( app: &AppHandle, slot: &Arc, + expected_generation: u64, prior_attempts: u32, -) -> Result { +) -> Result, RemoteBackendError> { let shell_env = dir_env::capture_home_interactive_env().await; // Reconnects reuse the binary the slot connected with, so a supervisor @@ -394,14 +422,20 @@ async fn establish( daemon_reused: daemon_info.reused, }; + let tunnel_pid = tunnel.child.id(); let generation = { let mut shared = slot.shared.lock().expect("slot poisoned"); - shared.generation += 1; - shared.daemon = Some(daemon_info); - shared.local_port = Some(local_port); - shared.tunnel_pid = tunnel.child.id(); - shared.state = state.clone(); - shared.generation + advance_generation_if_current(&mut shared, expected_generation).inspect(|_| { + shared.daemon = Some(daemon_info); + shared.local_port = Some(local_port); + shared.tunnel_pid = tunnel_pid; + shared.state = state.clone(); + }) + }; + let Some(generation) = generation else { + let _ = tunnel.child.start_kill(); + let _ = tunnel.child.wait().await; + return Ok(None); }; emit_status(app, &slot.key, &state); @@ -413,7 +447,7 @@ async fn establish( prior_attempts, ); - Ok(connection) + Ok(Some(connection)) } /// Watch one tunnel child. On unexpected death, try to re-establish (each @@ -427,11 +461,12 @@ fn spawn_supervisor( ) { tauri::async_runtime::spawn(async move { let established_at = tokio::time::Instant::now(); + let tunnel_pid = tunnel.child.id(); let status = tunnel.child.wait().await; let is_current = { - let shared = slot.shared.lock().expect("slot poisoned"); - shared.generation == generation + let mut shared = slot.shared.lock().expect("slot poisoned"); + clear_tunnel_pid_if_current(&mut shared, generation, tunnel_pid) }; if !is_current { // Explicit disconnect, app exit, or a newer establish owns the @@ -499,12 +534,16 @@ fn spawn_supervisor( } } - match establish(&app, &slot, attempt).await { - Ok(_) => { + match establish(&app, &slot, generation, attempt).await { + Ok(Some(_)) => { record_diagnostic(DiagnosticLevel::Info, "reconnect_success", &slot.key, None); return; } + Ok(None) => return, Err(error) => { + if slot.shared.lock().expect("slot poisoned").generation != generation { + return; + } log::warn!( "[remote-backend] reconnect attempt {attempt} to {} failed: {}", slot.key, @@ -674,6 +713,29 @@ mod tests { assert!(cached_connection(&shared, None).is_none()); } + #[test] + fn stale_establish_cannot_advance_generation() { + let mut shared = ready_shared(None); + shared.generation = 4; + + assert_eq!(advance_generation_if_current(&mut shared, 3), None); + assert_eq!(shared.generation, 4); + assert_eq!(advance_generation_if_current(&mut shared, 4), Some(5)); + assert_eq!(shared.generation, 5); + } + + #[test] + fn exited_tunnel_pid_is_cleared_only_by_its_current_supervisor() { + let mut shared = ready_shared(None); + + assert!(clear_tunnel_pid_if_current(&mut shared, 1, Some(99))); + assert_eq!(shared.tunnel_pid, None); + + shared.tunnel_pid = Some(100); + assert!(!clear_tunnel_pid_if_current(&mut shared, 0, Some(100))); + assert_eq!(shared.tunnel_pid, Some(100)); + } + #[test] fn snapshot_reports_registered_slots() { let registry = RemoteBackendRegistry::default(); diff --git a/src-tauri/src/services/remote_backend/remote_daemon.sh b/src-tauri/src/services/remote_backend/remote_daemon.sh index df1f5c8f0..add8bb844 100644 --- a/src-tauri/src/services/remote_backend/remote_daemon.sh +++ b/src-tauri/src/services/remote_backend/remote_daemon.sh @@ -43,7 +43,9 @@ RECORD_FORMAT="v3" emit() { printf '%s %s\n' "$NONCE" "$*"; } b64() { printf %s "$1" | base64 | tr -d '\n'; } -unb64() { printf %s "$1" | base64 --decode; } +# `-d` is supported by both GNU coreutils and macOS/BSD base64; GNU's +# `--decode` long option is not available on macOS remotes. +unb64() { printf %s "$1" | base64 -d; } port_listening() { (exec 3<>"/dev/tcp/127.0.0.1/$1") 2>/dev/null diff --git a/src/app/AppShell.tsx b/src/app/AppShell.tsx index 036e86c94..8bd098f3e 100644 --- a/src/app/AppShell.tsx +++ b/src/app/AppShell.tsx @@ -97,6 +97,7 @@ import { selectProjects } from "@/features/projects/stores/projectSelectors"; import { findExistingDraft } from "@/features/chat/lib/newChat"; import { DEFAULT_CHAT_TITLE } from "@/features/chat/lib/sessionTitle"; import { useAppStartup } from "./hooks/useAppStartup"; +import { runChatRuntimeStartup } from "./lib/chatRuntimeStartup"; import { useCompletionNotifications } from "@/shared/hooks/useCompletionNotifications"; import { useHomeSessionStateSync } from "./hooks/useHomeSessionStateSync"; import { useHomeWidgetStore } from "@/features/home/stores/homeWidgetStore"; @@ -285,7 +286,10 @@ import { requestStarterWidgetPicker, STARTER_WIDGET_ADDED_EVENT, } from "@/features/home/onboarding/starterWidgetTask"; -import { STARTER_TASKS_EXPERIMENT_ID } from "@/features/experiments/experimentDefinitions"; +import { + REMOTE_SSH_SESSIONS_EXPERIMENT_ID, + STARTER_TASKS_EXPERIMENT_ID, +} from "@/features/experiments/experimentDefinitions"; import { recordAssistiveMomentRetired, recordAssistiveMomentShown, @@ -725,9 +729,34 @@ export function AppShell({ const initialActiveView = getInitialAppView(initialSettingsSection); const [activeView, setActiveView] = useState(initialActiveView); const capabilities = useProfileCapabilities(); + const remoteSshSessionsExperimentEnabled = + useExperiment(REMOTE_SSH_SESSIONS_EXPERIMENT_ID)?.enabled === true; const isAutomationsFeatureEnabled = capabilities.automations; const isBuilderbotSurfaceEnabled = capabilities.builderbot; const isFeedbackEnabled = capabilities.feedback; + const remoteSessionReconciliationRef = useRef>( + Promise.resolve(), + ); + useEffect(() => { + remoteSessionReconciliationRef.current = + remoteSessionReconciliationRef.current + .catch(() => undefined) + .then(async () => { + await runChatRuntimeStartup(); + const { reconcileRemoteSessionsForExperiment } = await import( + "@/features/chat/stores/remoteSessionPersistence" + ); + await reconcileRemoteSessionsForExperiment( + remoteSshSessionsExperimentEnabled, + ); + }) + .catch((error) => { + console.error( + "Failed to reconcile remote-session experiment:", + error, + ); + }); + }, [remoteSshSessionsExperimentEnabled]); const sessionWindowSupport = useSessionWindowSupport(); const isMultiWindowEnabled = sessionWindowSupport.supported; const stopVoiceConversation = useVoiceConversationStore( diff --git a/src/app/lib/chatRuntimeStartup.ts b/src/app/lib/chatRuntimeStartup.ts index 7c0165f5e..675fd6e77 100644 --- a/src/app/lib/chatRuntimeStartup.ts +++ b/src/app/lib/chatRuntimeStartup.ts @@ -334,7 +334,8 @@ async function startChatRuntime( ); // After the local page load so mergeAcpSessionPage cannot race the // placeholder insert; the merge itself is additive for remoteHost either - // way. + // way. This also rehydrates secondary session windows, which do not mount + // AppShell's live experiment reconciliation effect. if (getExperiment(REMOTE_SSH_SESSIONS_EXPERIMENT_ID)?.enabled) { try { const { rehydrateRemoteSessions } = await import( diff --git a/src/features/chat/stores/__tests__/remoteSessionPersistence.test.ts b/src/features/chat/stores/__tests__/remoteSessionPersistence.test.ts index d4873ef65..9970b5846 100644 --- a/src/features/chat/stores/__tests__/remoteSessionPersistence.test.ts +++ b/src/features/chat/stores/__tests__/remoteSessionPersistence.test.ts @@ -3,6 +3,7 @@ import { REMOTE_SESSIONS_STORAGE_KEY, persistRemoteSessionRecord, readRemoteSessionRecords, + reconcileRemoteSessionsForExperiment, rehydrateRemoteSessions, removeRemoteSessionRecord, type RemoteSessionRecord, @@ -14,6 +15,7 @@ const mocks = vi.hoisted(() => ({ transferSessionBackend: vi.fn(), unregisterSessionBackend: vi.fn(), getSessionBackend: vi.fn(), + disconnectRemoteHost: vi.fn().mockResolvedValue(undefined), })); vi.mock("@/shared/api/acp", () => ({ @@ -46,6 +48,11 @@ vi.mock("@/shared/api/acpSessionBackends", () => ({ getSessionBackend: (...args: unknown[]) => mocks.getSessionBackend(...args), })); +vi.mock("@/shared/api/remoteHosts", () => ({ + disconnectRemoteHost: (...args: unknown[]) => + mocks.disconnectRemoteHost(...args), +})); + function makeRecord( overrides: Partial = {}, ): RemoteSessionRecord { @@ -243,4 +250,61 @@ describe("remoteSessionPersistence", () => { ).toMatchObject({ title: "Fresh title", messageCount: 4 }); }); }); + + describe("reconcileRemoteSessionsForExperiment", () => { + it("hides remote sessions and disconnects hosts without deleting persistence", async () => { + const record = makeRecord({ sessionId: "ssh:devbox#remote-1" }); + persistRemoteSessionRecord(record); + useChatSessionStore.setState({ + sessions: [ + { + id: "local-1", + title: "Local chat", + createdAt: record.updatedAt, + updatedAt: record.updatedAt, + messageCount: 1, + }, + { + id: record.sessionId, + title: record.title, + remoteHost: record.host, + createdAt: record.updatedAt, + updatedAt: record.updatedAt, + messageCount: 1, + }, + ], + activeSessionId: record.sessionId, + activeWorkspaceBySession: { + [record.sessionId]: { path: record.workingDir, branch: null }, + }, + }); + + await reconcileRemoteSessionsForExperiment(false); + + expect(useChatSessionStore.getState().sessions).toEqual([ + expect.objectContaining({ id: "local-1" }), + ]); + expect(useChatSessionStore.getState().activeSessionId).toBeNull(); + expect( + useChatSessionStore.getState().activeWorkspaceBySession, + ).not.toHaveProperty(record.sessionId); + expect(mocks.unregisterSessionBackend).toHaveBeenCalledWith( + record.sessionId, + ); + expect(mocks.disconnectRemoteHost).toHaveBeenCalledWith(record.host); + expect(readRemoteSessionRecords()).toEqual([record]); + }); + + it("rehydrates persisted sessions immediately when enabled", async () => { + persistRemoteSessionRecord( + makeRecord({ sessionId: "ssh:devbox#remote-1" }), + ); + + await reconcileRemoteSessionsForExperiment(true); + + expect( + useChatSessionStore.getState().getSession("ssh:devbox#remote-1"), + ).toMatchObject({ remoteHost: "devbox" }); + }); + }); }); diff --git a/src/features/chat/stores/remoteSessionPersistence.ts b/src/features/chat/stores/remoteSessionPersistence.ts index 442077e64..4c00caa74 100644 --- a/src/features/chat/stores/remoteSessionPersistence.ts +++ b/src/features/chat/stores/remoteSessionPersistence.ts @@ -3,7 +3,10 @@ import { splitCompositeSessionId, sshBackendId, } from "@/shared/api/acpBackendId"; -import { registerSessionBackend } from "@/shared/api/acpSessionBackends"; +import { + registerSessionBackend, + unregisterSessionBackend, +} from "@/shared/api/acpSessionBackends"; export const REMOTE_SESSIONS_STORAGE_KEY = "goose:remote-sessions:v1"; @@ -186,3 +189,76 @@ export async function rehydrateRemoteSessions(): Promise { }); } } + +/** + * Apply the remote-session experiment at runtime without deleting the user's + * persisted remote-session identities. Disabling hides those sessions and + * disconnects their SSH tunnels; re-enabling restores the placeholders and + * backend routing immediately. + */ +export async function reconcileRemoteSessionsForExperiment( + enabled: boolean, +): Promise { + if (enabled) { + await rehydrateRemoteSessions(); + return; + } + + const records = readRemoteSessionRecords(); + const { useChatSessionStore } = await import( + "@/features/chat/stores/chatSessionStore" + ); + const state = useChatSessionStore.getState(); + const remoteSessionIds = new Set( + state.sessions + .filter((session) => Boolean(session.remoteHost)) + .map((session) => session.id), + ); + const remoteHosts = new Set( + state.sessions + .map((session) => session.remoteHost?.trim()) + .filter((host): host is string => Boolean(host)), + ); + + for (const record of records) { + const backendId = sshBackendId(record.host); + const split = splitCompositeSessionId(record.sessionId); + remoteSessionIds.add( + split + ? record.sessionId + : compositeSessionId(backendId, record.sessionId), + ); + remoteHosts.add(record.host); + } + + useChatSessionStore.setState((current) => { + const activeWorkspaceBySession = { ...current.activeWorkspaceBySession }; + const archiveMutationBySessionId = { + ...current.archiveMutationBySessionId, + }; + for (const sessionId of remoteSessionIds) { + delete activeWorkspaceBySession[sessionId]; + delete archiveMutationBySessionId[sessionId]; + } + return { + sessions: current.sessions.filter( + (session) => !remoteSessionIds.has(session.id), + ), + activeSessionId: + current.activeSessionId && remoteSessionIds.has(current.activeSessionId) + ? null + : current.activeSessionId, + activeWorkspaceBySession, + archiveMutationBySessionId, + }; + }); + + for (const sessionId of remoteSessionIds) { + unregisterSessionBackend(sessionId); + } + + const { disconnectRemoteHost } = await import("@/shared/api/remoteHosts"); + await Promise.allSettled( + [...remoteHosts].map((host) => disconnectRemoteHost(host)), + ); +} diff --git a/src/features/sessions/ui/session-list/SidebarChatRow.tsx b/src/features/sessions/ui/session-list/SidebarChatRow.tsx index 29c38422a..87990cf28 100644 --- a/src/features/sessions/ui/session-list/SidebarChatRow.tsx +++ b/src/features/sessions/ui/session-list/SidebarChatRow.tsx @@ -7,7 +7,7 @@ import { type PointerEvent, } from "react"; import { createPortal } from "react-dom"; -import { ExternalLink, MoreHorizontal, Server } from "lucide-react"; +import { ExternalLink, Globe2, MoreHorizontal } from "lucide-react"; import { IconCheck, IconGitBranch } from "@tabler/icons-react"; import { useTranslation } from "react-i18next"; import { @@ -482,12 +482,12 @@ export function SidebarChatRow({ {remoteHost ? ( - ) : null} {isMultiWindowEnabled && isOpenInWindow ? ( diff --git a/src/features/sessions/ui/session-list/SidebarProjectSection.tsx b/src/features/sessions/ui/session-list/SidebarProjectSection.tsx index 5546fcd3e..f32f127ea 100644 --- a/src/features/sessions/ui/session-list/SidebarProjectSection.tsx +++ b/src/features/sessions/ui/session-list/SidebarProjectSection.tsx @@ -4,6 +4,7 @@ import { IconChevronDown, IconChevronRight, IconEdit, + IconWorld, } from "@tabler/icons-react"; import type { AppView } from "@/app/AppShell"; import { usePinToHomeWidget } from "@/features/home/hooks/usePinToHomeWidget"; @@ -46,6 +47,33 @@ const PROJECT_CHAT_DISCLOSURE_CLASS = cn( SIDEBAR_GROUP_LABEL_TEXT_CLASS, ); +function SidebarProjectIcon({ + project, + remote, +}: { + project: ProjectInfo; + remote: boolean; +}) { + return ( + + + {remote ? ( + + + + ) : null} + + ); +} + export interface SidebarSessionItem { id: string; title: string; @@ -157,6 +185,9 @@ export function SidebarProjectSection({ const [menuOpen, setMenuOpen] = useState(false); const [contextMenuOpen, setContextMenuOpen] = useState(false); const projectHasUnread = projectChats.some((session) => session.hasUnread); + const projectHasRemoteChats = projectChats.some((session) => + Boolean(session.remoteHost), + ); const projectHasChats = projectChats.length > 0; const projectCanExpand = projectHasChats || emptyState != null; // When collapsed, surface unread on the project identity because its chat @@ -366,11 +397,9 @@ export function SidebarProjectSection({ {showExpansionChevron && projectCanExpand ? ( <> - {isExpanded ? ( @@ -380,11 +409,9 @@ export function SidebarProjectSection({ )} ) : ( - )} diff --git a/src/features/sessions/ui/session-list/__tests__/SidebarProjectUnread.test.tsx b/src/features/sessions/ui/session-list/__tests__/SidebarProjectUnread.test.tsx index 2ba315884..f3717625c 100644 --- a/src/features/sessions/ui/session-list/__tests__/SidebarProjectUnread.test.tsx +++ b/src/features/sessions/ui/session-list/__tests__/SidebarProjectUnread.test.tsx @@ -41,6 +41,28 @@ function renderSection(isExpanded: boolean) { ); } +function renderRemoteSection() { + return render( + + + , + ); +} + describe("project unread dot", () => { it("swaps the project icon for the unread dot when collapsed and a chat is unread", () => { const { container } = renderSection(false); @@ -61,3 +83,16 @@ describe("project unread dot", () => { ).toHaveLength(1); }); }); + +describe("remote project identity", () => { + it("badges the project icon and uses a remote globe on its chat", () => { + const { container } = renderRemoteSection(); + + expect( + container.querySelector("[data-sidebar-project-remote]"), + ).toBeInTheDocument(); + expect( + container.querySelector("[data-sidebar-chat-remote-host]"), + ).toHaveAttribute("title", "Remote chat on blox"); + }); +}); From 4e8e5d197e9e0ed6466f0505a255410c973c8164 Mon Sep 17 00:00:00 2001 From: damienrj Date: Mon, 31 Aug 2026 12:18:26 -0700 Subject: [PATCH 07/13] refine: keep remote indicator on chats --- .../ui/session-list/SidebarProjectSection.tsx | 47 ++++--------------- .../__tests__/SidebarProjectUnread.test.tsx | 6 +-- 2 files changed, 12 insertions(+), 41 deletions(-) diff --git a/src/features/sessions/ui/session-list/SidebarProjectSection.tsx b/src/features/sessions/ui/session-list/SidebarProjectSection.tsx index f32f127ea..5546fcd3e 100644 --- a/src/features/sessions/ui/session-list/SidebarProjectSection.tsx +++ b/src/features/sessions/ui/session-list/SidebarProjectSection.tsx @@ -4,7 +4,6 @@ import { IconChevronDown, IconChevronRight, IconEdit, - IconWorld, } from "@tabler/icons-react"; import type { AppView } from "@/app/AppShell"; import { usePinToHomeWidget } from "@/features/home/hooks/usePinToHomeWidget"; @@ -47,33 +46,6 @@ const PROJECT_CHAT_DISCLOSURE_CLASS = cn( SIDEBAR_GROUP_LABEL_TEXT_CLASS, ); -function SidebarProjectIcon({ - project, - remote, -}: { - project: ProjectInfo; - remote: boolean; -}) { - return ( - - - {remote ? ( - - - - ) : null} - - ); -} - export interface SidebarSessionItem { id: string; title: string; @@ -185,9 +157,6 @@ export function SidebarProjectSection({ const [menuOpen, setMenuOpen] = useState(false); const [contextMenuOpen, setContextMenuOpen] = useState(false); const projectHasUnread = projectChats.some((session) => session.hasUnread); - const projectHasRemoteChats = projectChats.some((session) => - Boolean(session.remoteHost), - ); const projectHasChats = projectChats.length > 0; const projectCanExpand = projectHasChats || emptyState != null; // When collapsed, surface unread on the project identity because its chat @@ -397,9 +366,11 @@ export function SidebarProjectSection({ {showExpansionChevron && projectCanExpand ? ( <> - {isExpanded ? ( @@ -409,9 +380,11 @@ export function SidebarProjectSection({ )} ) : ( - )} diff --git a/src/features/sessions/ui/session-list/__tests__/SidebarProjectUnread.test.tsx b/src/features/sessions/ui/session-list/__tests__/SidebarProjectUnread.test.tsx index f3717625c..3c5f28935 100644 --- a/src/features/sessions/ui/session-list/__tests__/SidebarProjectUnread.test.tsx +++ b/src/features/sessions/ui/session-list/__tests__/SidebarProjectUnread.test.tsx @@ -85,12 +85,10 @@ describe("project unread dot", () => { }); describe("remote project identity", () => { - it("badges the project icon and uses a remote globe on its chat", () => { + it("uses a remote globe on the chat without badging the project", () => { const { container } = renderRemoteSection(); - expect( - container.querySelector("[data-sidebar-project-remote]"), - ).toBeInTheDocument(); + expect(container.querySelector("[data-sidebar-project-remote]")).toBeNull(); expect( container.querySelector("[data-sidebar-chat-remote-host]"), ).toHaveAttribute("title", "Remote chat on blox"); From 6928c06e08df2343eadad47d8161c72a9b1fd903 Mon Sep 17 00:00:00 2001 From: damienrj Date: Mon, 31 Aug 2026 12:40:58 -0700 Subject: [PATCH 08/13] fix: close remaining remote session races --- src-tauri/src/services/remote_backend/mod.rs | 97 ++++++++++++++++--- src/app/AppShell.tsx | 33 +------ src/app/SessionWindowApp.tsx | 9 ++ src/app/__tests__/SessionWindowApp.test.tsx | 32 ++++++ src/app/lib/chatRuntimeStartup.ts | 4 +- .../ConversationComposerCapability.tsx | 1 + .../chat/hooks/useChatSessionController.ts | 25 +++-- ...teSessionExperimentReconciliation.test.tsx | 43 ++++++++ ...seRemoteSessionExperimentReconciliation.ts | 28 ++++++ .../chat/lib/__tests__/attachments.test.ts | 37 ++++++- src/features/chat/lib/attachments.ts | 15 +++ src/features/chat/lib/sendCore.test.ts | 54 +++++++++++ src/features/chat/lib/sendCore.ts | 17 +++- src/features/chat/lib/steerCore.ts | 20 +++- src/features/chat/ui/ChatInputToolbar.tsx | 4 +- .../chat/ui/__tests__/ChatInput.test.tsx | 40 ++++++++ 16 files changed, 394 insertions(+), 65 deletions(-) create mode 100644 src/features/chat/hooks/useRemoteSessionExperimentReconciliation.test.tsx create mode 100644 src/features/chat/hooks/useRemoteSessionExperimentReconciliation.ts diff --git a/src-tauri/src/services/remote_backend/mod.rs b/src-tauri/src/services/remote_backend/mod.rs index b5327f1d0..c28d65c3e 100644 --- a/src-tauri/src/services/remote_backend/mod.rs +++ b/src-tauri/src/services/remote_backend/mod.rs @@ -212,6 +212,35 @@ fn set_state(app: &AppHandle, slot: &HostSlot, state: RemoteBackendState) { emit_status(app, &slot.key, &state); } +fn update_state_if_current( + shared: &mut SlotShared, + generation: u64, + state: RemoteBackendState, +) -> bool { + if shared.generation != generation { + return false; + } + shared.state = state; + true +} + +/// Publish a supervisor-owned transition only while its generation still owns +/// the slot. Emitting while holding the same mutex keeps a later Disconnect +/// event ordered after this one instead of allowing a stale event to overtake it. +fn set_state_if_current( + app: &AppHandle, + slot: &HostSlot, + generation: u64, + state: RemoteBackendState, +) -> bool { + let mut shared = slot.shared.lock().expect("slot poisoned"); + if !update_state_if_current(&mut shared, generation, state.clone()) { + return false; + } + emit_status(app, &slot.key, &state); + true +} + fn emit_status(app: &AppHandle, host: &str, state: &RemoteBackendState) { let payload = RemoteBackendStatus { host: host.to_string(), @@ -353,15 +382,14 @@ pub async fn connect( &slot.key, Some(&error.message), ); - if slot.shared.lock().expect("slot poisoned").generation == expected_generation { - set_state( - app, - &slot, - RemoteBackendState::Failed { - error: error.clone(), - }, - ); - } + set_state_if_current( + app, + &slot, + expected_generation, + RemoteBackendState::Failed { + error: error.clone(), + }, + ); Err(error) } } @@ -507,19 +535,22 @@ fn spawn_supervisor( &slot.key, Some(&last_error.message), ); - set_state(&app, &slot, RemoteBackendState::Disconnected); + set_state_if_current(&app, &slot, generation, RemoteBackendState::Disconnected); return; } for attempt in start_attempt..=MAX_RECONNECT_ATTEMPTS { - set_state( + if !set_state_if_current( &app, &slot, + generation, RemoteBackendState::Reconnecting { attempt, error: last_error.clone(), }, - ); + ) { + return; + } let backoff = Duration::from_secs(1 << (attempt - 1)).min(RECONNECT_BACKOFF_CAP); tokio::time::sleep(backoff).await; @@ -560,7 +591,7 @@ fn spawn_supervisor( &slot.key, Some(&last_error.message), ); - set_state(&app, &slot, RemoteBackendState::Disconnected); + set_state_if_current(&app, &slot, generation, RemoteBackendState::Disconnected); }); } @@ -594,9 +625,16 @@ pub async fn shutdown( ) -> Result<(), RemoteBackendError> { let aliases = ssh_config::load_ssh_config_hosts(); let spec = RemoteHostSpec::parse(host_input, &aliases)?; + let slot = registry.slot(&spec); let shell_env = dir_env::capture_home_interactive_env().await; - daemon::shutdown_daemon(&spec, &shell_env).await?; + + // Wait out any in-flight establish, then invalidate its supervisor and + // drop its tunnel before touching the daemon. A new connect cannot start + // until shutdown releases this lock, so ensure_daemon cannot recreate the + // daemon after shutdown_daemon stops it. + let _guard = slot.connect_lock.lock().await; disconnect(app, registry, &spec.key()); + daemon::shutdown_daemon(&spec, &shell_env).await?; record_diagnostic(DiagnosticLevel::Info, "daemon_shutdown", &spec.key(), None); Ok(()) } @@ -736,6 +774,37 @@ mod tests { assert_eq!(shared.tunnel_pid, Some(100)); } + #[test] + fn reconnecting_state_is_rejected_after_generation_changes() { + let mut shared = ready_shared(None); + shared.generation = 3; + + assert!(update_state_if_current( + &mut shared, + 3, + RemoteBackendState::Reconnecting { + attempt: 1, + error: RemoteBackendError::internal("closed"), + }, + )); + assert!(matches!( + shared.state, + RemoteBackendState::Reconnecting { attempt: 1, .. } + )); + + shared.generation = 4; + shared.state = RemoteBackendState::Disconnected; + assert!(!update_state_if_current( + &mut shared, + 3, + RemoteBackendState::Reconnecting { + attempt: 2, + error: RemoteBackendError::internal("closed again"), + }, + )); + assert!(matches!(shared.state, RemoteBackendState::Disconnected)); + } + #[test] fn snapshot_reports_registered_slots() { let registry = RemoteBackendRegistry::default(); diff --git a/src/app/AppShell.tsx b/src/app/AppShell.tsx index 8bd098f3e..ea701fc71 100644 --- a/src/app/AppShell.tsx +++ b/src/app/AppShell.tsx @@ -97,7 +97,7 @@ import { selectProjects } from "@/features/projects/stores/projectSelectors"; import { findExistingDraft } from "@/features/chat/lib/newChat"; import { DEFAULT_CHAT_TITLE } from "@/features/chat/lib/sessionTitle"; import { useAppStartup } from "./hooks/useAppStartup"; -import { runChatRuntimeStartup } from "./lib/chatRuntimeStartup"; +import { useRemoteSessionExperimentReconciliation } from "@/features/chat/hooks/useRemoteSessionExperimentReconciliation"; import { useCompletionNotifications } from "@/shared/hooks/useCompletionNotifications"; import { useHomeSessionStateSync } from "./hooks/useHomeSessionStateSync"; import { useHomeWidgetStore } from "@/features/home/stores/homeWidgetStore"; @@ -286,10 +286,7 @@ import { requestStarterWidgetPicker, STARTER_WIDGET_ADDED_EVENT, } from "@/features/home/onboarding/starterWidgetTask"; -import { - REMOTE_SSH_SESSIONS_EXPERIMENT_ID, - STARTER_TASKS_EXPERIMENT_ID, -} from "@/features/experiments/experimentDefinitions"; +import { STARTER_TASKS_EXPERIMENT_ID } from "@/features/experiments/experimentDefinitions"; import { recordAssistiveMomentRetired, recordAssistiveMomentShown, @@ -729,34 +726,10 @@ export function AppShell({ const initialActiveView = getInitialAppView(initialSettingsSection); const [activeView, setActiveView] = useState(initialActiveView); const capabilities = useProfileCapabilities(); - const remoteSshSessionsExperimentEnabled = - useExperiment(REMOTE_SSH_SESSIONS_EXPERIMENT_ID)?.enabled === true; + useRemoteSessionExperimentReconciliation(); const isAutomationsFeatureEnabled = capabilities.automations; const isBuilderbotSurfaceEnabled = capabilities.builderbot; const isFeedbackEnabled = capabilities.feedback; - const remoteSessionReconciliationRef = useRef>( - Promise.resolve(), - ); - useEffect(() => { - remoteSessionReconciliationRef.current = - remoteSessionReconciliationRef.current - .catch(() => undefined) - .then(async () => { - await runChatRuntimeStartup(); - const { reconcileRemoteSessionsForExperiment } = await import( - "@/features/chat/stores/remoteSessionPersistence" - ); - await reconcileRemoteSessionsForExperiment( - remoteSshSessionsExperimentEnabled, - ); - }) - .catch((error) => { - console.error( - "Failed to reconcile remote-session experiment:", - error, - ); - }); - }, [remoteSshSessionsExperimentEnabled]); const sessionWindowSupport = useSessionWindowSupport(); const isMultiWindowEnabled = sessionWindowSupport.supported; const stopVoiceConversation = useVoiceConversationStore( diff --git a/src/app/SessionWindowApp.tsx b/src/app/SessionWindowApp.tsx index 69d52c7b8..4f6764286 100644 --- a/src/app/SessionWindowApp.tsx +++ b/src/app/SessionWindowApp.tsx @@ -39,6 +39,7 @@ import { useBerdctlQueuedMessageDrain } from "@/features/berdctl/bridge/useBerdc import { ChatView } from "@/features/chat/ui/ChatView"; import { BackgroundQueuedMessageDrain } from "@/features/chat/ui/BackgroundQueuedMessageDrain"; import { useWorkspaceNameRequestQueue } from "@/features/chat/hooks/useWorkspaceNameRequestQueue"; +import { useRemoteSessionExperimentReconciliation } from "@/features/chat/hooks/useRemoteSessionExperimentReconciliation"; import { ProjectWorkspaceStartupNameDialog } from "@/features/projects/ui/ProjectWorkspaceStartupNameDialog"; import { Button } from "@/shared/ui/button"; import { SecurityConfirmationFallback } from "@/features/security/ui/SecurityConfirmationPanel"; @@ -145,6 +146,7 @@ export function SessionWindowApp({ currentWindowLabel: currentWindowLabelOverride, }: SessionWindowAppProps) { const { t } = useTranslation("chat"); + const remoteSessionsEnabled = useRemoteSessionExperimentReconciliation(); const [phase, setPhase] = useState("loading"); useBerdctlQueuedMessageDrain(sessionId, phase === "ready"); const [session, setSession] = useState(null); @@ -161,6 +163,13 @@ export function SessionWindowApp({ const isRightRailOpen = useChatSessionStore((s) => s.isRightRailOpen); const setRightRailOpen = useChatSessionStore((s) => s.setRightRailOpen); + useEffect(() => { + if (!remoteSessionsEnabled && session?.remoteHost) { + setSession(null); + setPhase("missing"); + } + }, [remoteSessionsEnabled, session?.remoteHost]); + const loadOwnedSession = useCallback( async (options: { force?: boolean } = {}) => { activateSession(sessionId); diff --git a/src/app/__tests__/SessionWindowApp.test.tsx b/src/app/__tests__/SessionWindowApp.test.tsx index 1c9f395d8..a579c9676 100644 --- a/src/app/__tests__/SessionWindowApp.test.tsx +++ b/src/app/__tests__/SessionWindowApp.test.tsx @@ -39,6 +39,7 @@ const mocks = vi.hoisted(() => ({ buildFeatures: { securityMl: true, }, + remoteSessionsEnabled: true, setVoiceConversationForegroundSession: vi.fn().mockResolvedValue(undefined), })); @@ -46,6 +47,13 @@ vi.mock("@/app/lib/chatRuntimeStartup", () => ({ runChatRuntimeStartup: vi.fn().mockResolvedValue(undefined), })); +vi.mock( + "@/features/chat/hooks/useRemoteSessionExperimentReconciliation", + () => ({ + useRemoteSessionExperimentReconciliation: () => mocks.remoteSessionsEnabled, + }), +); + vi.mock("@/shared/profile/buildProfile", () => ({ getBuildFeatureState: () => mocks.buildFeatures, })); @@ -201,6 +209,7 @@ describe("SessionWindowApp", () => { vi.useRealTimers(); handoffListeners.available = undefined; handoffListeners.searchTarget = undefined; + mocks.remoteSessionsEnabled = true; useSessionWindowStore.getState().setSnapshot([]); useChatStore.setState({ messagesBySession: {}, @@ -236,6 +245,29 @@ describe("SessionWindowApp", () => { ).toBeInTheDocument(); }); + it("stops rendering a remote session when the experiment is disabled", async () => { + useChatSessionStore.setState({ + sessions: [{ ...session, remoteHost: "devbox" }], + activeSessionId: null, + hasHydratedSessions: true, + }); + const view = renderSessionWindow(); + await screen.findByTestId("chat-view"); + + mocks.remoteSessionsEnabled = false; + view.rerender( + , + ); + + expect( + await screen.findByText(/can.t find this session/i), + ).toBeInTheDocument(); + expect(screen.queryByTestId("chat-view")).not.toBeInTheDocument(); + }); + it("applies message search targets sent from the main window", async () => { seedSession(); renderSessionWindow(); diff --git a/src/app/lib/chatRuntimeStartup.ts b/src/app/lib/chatRuntimeStartup.ts index 675fd6e77..c58b9a9ae 100644 --- a/src/app/lib/chatRuntimeStartup.ts +++ b/src/app/lib/chatRuntimeStartup.ts @@ -334,8 +334,8 @@ async function startChatRuntime( ); // After the local page load so mergeAcpSessionPage cannot race the // placeholder insert; the merge itself is additive for remoteHost either - // way. This also rehydrates secondary session windows, which do not mount - // AppShell's live experiment reconciliation effect. + // way. The per-window experiment reconciliation hook also waits on this + // startup latch before applying later runtime changes. if (getExperiment(REMOTE_SSH_SESSIONS_EXPERIMENT_ID)?.enabled) { try { const { rehydrateRemoteSessions } = await import( diff --git a/src/features/chat/capabilities/ConversationComposerCapability.tsx b/src/features/chat/capabilities/ConversationComposerCapability.tsx index e32abf840..cadeb606a 100644 --- a/src/features/chat/capabilities/ConversationComposerCapability.tsx +++ b/src/features/chat/capabilities/ConversationComposerCapability.tsx @@ -339,6 +339,7 @@ export function ConversationComposerCapability({ } attachmentDropTargetRef={attachmentDropTargetRef} onAttachmentDragOverChange={onAttachmentDragOverChange} + attachmentsEnabled={!controller.selectedRemoteHost} initialValue={controller.draftValue} initialAttachments={controller.draftAttachments} onDraftChange={controller.handleDraftChange} diff --git a/src/features/chat/hooks/useChatSessionController.ts b/src/features/chat/hooks/useChatSessionController.ts index 00c636ca2..47d95e529 100644 --- a/src/features/chat/hooks/useChatSessionController.ts +++ b/src/features/chat/hooks/useChatSessionController.ts @@ -118,6 +118,7 @@ import { type RecreateSessionForProvider, } from "../model-selection/strandedProviderRecovery"; import { perfLog } from "@/shared/lib/perfLog"; +import { remoteSafeAttachments } from "@/features/chat/lib/attachments"; import type { BerdChatChatSourceSurface } from "@/shared/telemetry/events"; import { isFirstCommittedUserMessage } from "../lib/chatFirstMessage"; import { @@ -1506,11 +1507,23 @@ export function useChatSessionController({ [sessionId], ); - const handleRemoteHostChange = useCallback((host: string | null) => { - setPendingRemoteHost(host); - // A chosen directory belongs to one host, so any host change resets it. - setPendingRemoteDir(undefined); - }, []); + const handleRemoteHostChange = useCallback( + (host: string | null) => { + setPendingRemoteHost(host); + // A chosen directory belongs to one host, so any host change resets it. + setPendingRemoteDir(undefined); + if (host) { + const chatStore = useChatStore.getState(); + chatStore.setDraftAttachments( + stateSessionId, + remoteSafeAttachments( + chatStore.draftAttachmentsBySession[stateSessionId], + ) ?? [], + ); + } + }, + [stateSessionId], + ); const handleRemoteDirChange = useCallback((dir: string | null) => { setPendingRemoteDir(dir); @@ -2620,7 +2633,7 @@ export function useChatSessionController({ const payload = captureSessionSelection({ text, persona: personaIntentFromComposer(personaId, personaName), - attachments, + attachments: remoteSafeAttachments(attachments), sendOptions, }); const remoteExecutionTarget = diff --git a/src/features/chat/hooks/useRemoteSessionExperimentReconciliation.test.tsx b/src/features/chat/hooks/useRemoteSessionExperimentReconciliation.test.tsx new file mode 100644 index 000000000..b616f05a7 --- /dev/null +++ b/src/features/chat/hooks/useRemoteSessionExperimentReconciliation.test.tsx @@ -0,0 +1,43 @@ +import { renderHook, waitFor } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + enabled: true, + reconcile: vi.fn().mockResolvedValue(undefined), + startup: vi.fn().mockResolvedValue(undefined), +})); + +vi.mock("@/app/lib/chatRuntimeStartup", () => ({ + runChatRuntimeStartup: mocks.startup, +})); +vi.mock("@/features/experiments/experimentPreferences", () => ({ + useExperiment: () => ({ enabled: mocks.enabled }), +})); +vi.mock("@/features/chat/stores/remoteSessionPersistence", () => ({ + reconcileRemoteSessionsForExperiment: mocks.reconcile, +})); + +import { useRemoteSessionExperimentReconciliation } from "./useRemoteSessionExperimentReconciliation"; + +describe("useRemoteSessionExperimentReconciliation", () => { + beforeEach(() => { + mocks.enabled = true; + mocks.reconcile.mockClear(); + mocks.startup.mockClear(); + }); + + it("reconciles both enabled and disabled runtime states after startup", async () => { + const { rerender } = renderHook(() => + useRemoteSessionExperimentReconciliation(), + ); + + await waitFor(() => expect(mocks.reconcile).toHaveBeenCalledWith(true)); + expect(mocks.startup).toHaveBeenCalledTimes(1); + + mocks.enabled = false; + rerender(); + + await waitFor(() => expect(mocks.reconcile).toHaveBeenCalledWith(false)); + expect(mocks.startup).toHaveBeenCalledTimes(2); + }); +}); diff --git a/src/features/chat/hooks/useRemoteSessionExperimentReconciliation.ts b/src/features/chat/hooks/useRemoteSessionExperimentReconciliation.ts new file mode 100644 index 000000000..c4c9b3521 --- /dev/null +++ b/src/features/chat/hooks/useRemoteSessionExperimentReconciliation.ts @@ -0,0 +1,28 @@ +import { useEffect, useRef } from "react"; +import { runChatRuntimeStartup } from "@/app/lib/chatRuntimeStartup"; +import { REMOTE_SSH_SESSIONS_EXPERIMENT_ID } from "@/features/experiments/experimentDefinitions"; +import { useExperiment } from "@/features/experiments/experimentPreferences"; + +/** Keep remote-session state aligned with the user-local experiment in every window. */ +export function useRemoteSessionExperimentReconciliation(): boolean { + const enabled = + useExperiment(REMOTE_SSH_SESSIONS_EXPERIMENT_ID)?.enabled === true; + const reconciliationRef = useRef>(Promise.resolve()); + + useEffect(() => { + reconciliationRef.current = reconciliationRef.current + .catch(() => undefined) + .then(async () => { + await runChatRuntimeStartup(); + const { reconcileRemoteSessionsForExperiment } = await import( + "@/features/chat/stores/remoteSessionPersistence" + ); + await reconcileRemoteSessionsForExperiment(enabled); + }) + .catch((error) => { + console.error("Failed to reconcile remote-session experiment:", error); + }); + }, [enabled]); + + return enabled; +} diff --git a/src/features/chat/lib/__tests__/attachments.test.ts b/src/features/chat/lib/__tests__/attachments.test.ts index c207e34b5..5b8054189 100644 --- a/src/features/chat/lib/__tests__/attachments.test.ts +++ b/src/features/chat/lib/__tests__/attachments.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { appendAttachmentPaths } from "../attachments"; +import { appendAttachmentPaths, remoteSafeAttachments } from "../attachments"; import type { ChatAttachmentDraft } from "@/shared/types/messages"; describe("appendAttachmentPaths", () => { @@ -88,3 +88,38 @@ describe("appendAttachmentPaths", () => { expect(appendAttachmentPaths("hello", attachments)).toBe("hello"); }); }); + +describe("remoteSafeAttachments", () => { + it("drops path-only attachments and strips the local path from content-backed images", () => { + const attachments: ChatAttachmentDraft[] = [ + { + id: "file", + kind: "file", + name: "notes.md", + path: "/Users/me/notes.md", + }, + { + id: "directory", + kind: "directory", + name: "repo", + path: "/Users/me/repo", + }, + { + id: "image", + kind: "image", + name: "diagram.png", + path: "/Users/me/diagram.png", + mimeType: "image/png", + base64: "abc", + previewUrl: "asset://diagram.png", + }, + ]; + + const safe = remoteSafeAttachments(attachments); + + expect(safe).toEqual([ + expect.objectContaining({ id: "image", path: undefined }), + ]); + expect(appendAttachmentPaths("review", safe)).toBe("review"); + }); +}); diff --git a/src/features/chat/lib/attachments.ts b/src/features/chat/lib/attachments.ts index 0724ef2ad..59a662b59 100644 --- a/src/features/chat/lib/attachments.ts +++ b/src/features/chat/lib/attachments.ts @@ -1,8 +1,23 @@ import type { ChatAttachmentDraft, + ChatImageAttachmentDraft, MessageAttachment, } from "@/shared/types/messages"; +/** + * Remote ACP backends cannot resolve paths from the local machine. Keep only + * images whose bytes travel in the prompt, and strip their local path so it is + * never appended to the remote prompt or persisted as agent-readable context. + */ +export function remoteSafeAttachments( + attachments: ChatAttachmentDraft[] | undefined, +): ChatImageAttachmentDraft[] | undefined { + const images = (attachments ?? []).flatMap((attachment) => + attachment.kind === "image" ? [{ ...attachment, path: undefined }] : [], + ); + return images.length > 0 ? images : undefined; +} + export function appendAttachmentPaths( text: string, attachments: ChatAttachmentDraft[] | undefined, diff --git a/src/features/chat/lib/sendCore.test.ts b/src/features/chat/lib/sendCore.test.ts index 167bf5476..cfb7ddd38 100644 --- a/src/features/chat/lib/sendCore.test.ts +++ b/src/features/chat/lib/sendCore.test.ts @@ -1,5 +1,6 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import { useChatStore } from "@/features/chat/stores/chatStore"; +import { useChatSessionStore } from "@/features/chat/stores/chatSessionStore"; import type { SessionChatRuntime } from "@/shared/types/chat"; import { QueuedMessageOwnershipLostError } from "./preCommitSendRejection"; import { dispatchPrompt } from "./sendCore"; @@ -23,6 +24,7 @@ describe("dispatchPrompt pre-commit rejection", () => { activeSessionId: null, isConnected: false, }); + useChatSessionStore.setState({ sessions: [], activeSessionId: null }); }); it("preserves the complete newer-owner runtime on ownership loss", async () => { @@ -62,4 +64,56 @@ describe("dispatchPrompt pre-commit rejection", () => { newerOwnerRuntime, ); }); + + it("never sends local attachment paths to a remote session", async () => { + useChatSessionStore.setState({ + sessions: [ + { + id: "session-1", + title: "Remote chat", + createdAt: "2026-08-31T00:00:00.000Z", + updatedAt: "2026-08-31T00:00:00.000Z", + messageCount: 0, + remoteHost: "devbox", + }, + ], + activeSessionId: "session-1", + }); + mocks.acpSendMessage.mockImplementationOnce( + ( + _sessionId: string, + _prompt: string, + options: { onPromptDispatching(): void }, + ) => { + options.onPromptDispatching(); + return Promise.resolve(); + }, + ); + + await dispatchPrompt("session-1", "review", { + attachments: [ + { + id: "file", + kind: "file", + name: "notes.md", + path: "/Users/me/notes.md", + }, + { + id: "image", + kind: "image", + name: "diagram.png", + path: "/Users/me/diagram.png", + mimeType: "image/png", + base64: "abc", + previewUrl: "asset://diagram.png", + }, + ], + }); + + expect(mocks.acpSendMessage).toHaveBeenCalledWith( + "session-1", + "review", + expect.objectContaining({ images: [["abc", "image/png"]] }), + ); + }); }); diff --git a/src/features/chat/lib/sendCore.ts b/src/features/chat/lib/sendCore.ts index a19fd8421..a490b916d 100644 --- a/src/features/chat/lib/sendCore.ts +++ b/src/features/chat/lib/sendCore.ts @@ -4,6 +4,7 @@ import { appendAttachmentPaths, buildAcpImages, buildMessageAttachments, + remoteSafeAttachments, } from "@/features/chat/lib/attachments"; import { getSessionTitleFromDraft, @@ -211,13 +212,19 @@ export async function dispatchPrompt( systemPrompt, userMessageMetadata, } = opts; - const images = buildAcpImages(attachments); + const sessionRunsRemotely = Boolean( + useChatSessionStore.getState().getSession(sessionId)?.remoteHost, + ); + const dispatchAttachments = sessionRunsRemotely + ? remoteSafeAttachments(attachments) + : attachments; + const images = buildAcpImages(dispatchAttachments); // Reject oversized attachment payloads before committing anything: an // overflowing ACP WebSocket message silently kills the shared connection // and every open chat with it (BOT-1463). Failing here keeps the draft in // the composer so the user can remove attachments and retry. - const attachmentBytes = promptAttachmentBytes(attachments); + const attachmentBytes = promptAttachmentBytes(dispatchAttachments); if (attachmentBytes > MAX_PROMPT_ATTACHMENT_BYTES) { const errorMessage = formatAttachmentsTooLargeMessage(attachmentBytes); useChatStore.getState().setError(sessionId, errorMessage); @@ -251,7 +258,7 @@ export async function dispatchPrompt( beforeUserMessageCommitted?.(); const userMessage = createUserMessage( displayText ?? text, - buildMessageAttachments(attachments), + buildMessageAttachments(dispatchAttachments), chips, ); if (persona) { @@ -286,7 +293,7 @@ export async function dispatchPrompt( const session = sessionStore.getSession(sessionId); if (session && isDefaultChatTitle(session.title)) { sessionStore.patchSession(sessionId, { - title: getSessionTitleFromDraft(text, attachments), + title: getSessionTitleFromDraft(text, dispatchAttachments), updatedAt: new Date().toISOString(), }); } else { @@ -302,7 +309,7 @@ export async function dispatchPrompt( const promptWithPaths = appendAttachmentPaths( background ? text : text.trim(), - attachments, + dispatchAttachments, ); const acpPrompt = promptWithPaths || (images?.length ? " " : promptWithPaths); diff --git a/src/features/chat/lib/steerCore.ts b/src/features/chat/lib/steerCore.ts index 2dd1a930e..e4de2198c 100644 --- a/src/features/chat/lib/steerCore.ts +++ b/src/features/chat/lib/steerCore.ts @@ -18,6 +18,7 @@ import { appendAttachmentPaths, buildAcpImages, buildMessageAttachments, + remoteSafeAttachments, } from "./attachments"; import { isSessionRunning } from "./sessionActivity"; import { getSessionPromptOwner } from "./sessionPromptOwnership"; @@ -37,8 +38,14 @@ export async function steerPromptInSession( sendOptions?: ChatSendOptions, options: { throwOnError?: boolean } = {}, ): Promise { - const images = buildAcpImages(attachments); - const hasAttachments = (attachments?.length ?? 0) > 0; + const sessionRunsRemotely = Boolean( + useChatSessionStore.getState().getSession(sessionId)?.remoteHost, + ); + const dispatchAttachments = sessionRunsRemotely + ? remoteSafeAttachments(attachments) + : attachments; + const images = buildAcpImages(dispatchAttachments); + const hasAttachments = (dispatchAttachments?.length ?? 0) > 0; const activeRunId = useChatStore .getState() .getSessionRuntime(sessionId).activeRunId; @@ -53,7 +60,7 @@ export async function steerPromptInSession( // (berdctl, queued steers) reach here directly. An oversized ACP message // silently kills the shared WebSocket and every open chat (BOT-1463), so // reject before committing anything. - const attachmentBytes = promptAttachmentBytes(attachments); + const attachmentBytes = promptAttachmentBytes(dispatchAttachments); if (attachmentBytes > MAX_PROMPT_ATTACHMENT_BYTES) { const errorMessage = formatAttachmentsTooLargeMessage(attachmentBytes); useChatStore @@ -70,7 +77,7 @@ export async function steerPromptInSession( const userMessage = createUserMessage( sendOptions?.displayText ?? text, - buildMessageAttachments(attachments), + buildMessageAttachments(dispatchAttachments), sendOptions?.chips, ); userMessage.metadata = { @@ -90,7 +97,10 @@ export async function steerPromptInSession( } } - const promptWithPaths = appendAttachmentPaths(text.trim(), attachments); + const promptWithPaths = appendAttachmentPaths( + text.trim(), + dispatchAttachments, + ); const acpPrompt = promptWithPaths || (images?.length ? " " : promptWithPaths); const chatStore = useChatStore.getState(); chatStore.addMessage(sessionId, userMessage); diff --git a/src/features/chat/ui/ChatInputToolbar.tsx b/src/features/chat/ui/ChatInputToolbar.tsx index 74ce07a59..62cf71e43 100644 --- a/src/features/chat/ui/ChatInputToolbar.tsx +++ b/src/features/chat/ui/ChatInputToolbar.tsx @@ -329,14 +329,14 @@ export function ChatInputToolbar({ onAttachFiles?.()} - disabled={disabled} + disabled={disabled || Boolean(selectedRemoteHost)} > {t("toolbar.attachFile")} onAttachFolders?.()} - // Local folder attachments are meaningless for a session that + // Local path attachments are meaningless for a session that // runs its backend on a remote host. disabled={disabled || Boolean(selectedRemoteHost)} > diff --git a/src/features/chat/ui/__tests__/ChatInput.test.tsx b/src/features/chat/ui/__tests__/ChatInput.test.tsx index ee54a378a..f9003d30e 100644 --- a/src/features/chat/ui/__tests__/ChatInput.test.tsx +++ b/src/features/chat/ui/__tests__/ChatInput.test.tsx @@ -4065,6 +4065,46 @@ describe("ChatInput", () => { expect(screen.queryByText("Attach file")).not.toBeInTheDocument(); }); + it("disables local path attachment actions for a remote host", async () => { + const user = userEvent.setup(); + + render( + , + ); + + await user.click(screen.getByRole("button", { name: /attach/i })); + + expect(screen.getByRole("menuitem", { name: "File" })).toHaveAttribute( + "data-disabled", + ); + expect(screen.getByRole("menuitem", { name: "Folder" })).toHaveAttribute( + "data-disabled", + ); + }); + it("keeps only one dropdown menu open when switching between attach and project", async () => { const user = userEvent.setup(); From e75e313747427dbd476f2fa9ba86ae5eddd98c03 Mon Sep 17 00:00:00 2001 From: damienrj Date: Mon, 31 Aug 2026 14:08:43 -0700 Subject: [PATCH 09/13] feat: disconnected state and reconnect for remote chats --- ...seRemoteSessionExperimentReconciliation.ts | 9 ++ src/features/chat/ui/ChatView.tsx | 9 ++ .../chat/ui/RemoteHostConnectionBanner.tsx | 108 ++++++++++++++++ .../RemoteHostConnectionBanner.test.tsx | 118 ++++++++++++++++++ .../remoteHosts/stores/remoteHostStore.ts | 15 +++ .../remoteHosts/ui/RemoteHostsSettings.tsx | 22 ++-- .../ui/session-list/SidebarChatRow.tsx | 25 +++- .../__tests__/SidebarProjectUnread.test.tsx | 31 ++++- src/shared/i18n/locales/en/chat.json | 7 ++ src/shared/i18n/locales/en/sidebar.json | 3 +- src/shared/i18n/locales/es/chat.json | 7 ++ src/shared/i18n/locales/es/sidebar.json | 3 +- 12 files changed, 334 insertions(+), 23 deletions(-) create mode 100644 src/features/chat/ui/RemoteHostConnectionBanner.tsx create mode 100644 src/features/chat/ui/__tests__/RemoteHostConnectionBanner.test.tsx diff --git a/src/features/chat/hooks/useRemoteSessionExperimentReconciliation.ts b/src/features/chat/hooks/useRemoteSessionExperimentReconciliation.ts index c4c9b3521..a9053ff4b 100644 --- a/src/features/chat/hooks/useRemoteSessionExperimentReconciliation.ts +++ b/src/features/chat/hooks/useRemoteSessionExperimentReconciliation.ts @@ -14,6 +14,15 @@ export function useRemoteSessionExperimentReconciliation(): boolean { .catch(() => undefined) .then(async () => { await runChatRuntimeStartup(); + if (enabled) { + // Live host statuses feed the sidebar indicator and the chat + // disconnected banner, so the subscription must not wait for the + // settings card to mount. + const { ensureRemoteHostStoreInitialized } = await import( + "@/features/remoteHosts/stores/remoteHostStore" + ); + ensureRemoteHostStoreInitialized(); + } const { reconcileRemoteSessionsForExperiment } = await import( "@/features/chat/stores/remoteSessionPersistence" ); diff --git a/src/features/chat/ui/ChatView.tsx b/src/features/chat/ui/ChatView.tsx index 5a64cc7fc..8df4f6359 100644 --- a/src/features/chat/ui/ChatView.tsx +++ b/src/features/chat/ui/ChatView.tsx @@ -11,6 +11,7 @@ import { IconLayoutSidebarLeftCollapse } from "@tabler/icons-react"; import { useTranslation } from "react-i18next"; import { ChatSearchBar } from "./ChatSearchBar"; import { ChatTranscriptSurface } from "./ChatTranscriptSurface"; +import { RemoteHostConnectionBanner } from "./RemoteHostConnectionBanner"; import { LoadingBerd } from "./LoadingBerd"; import { ChatRightRail } from "./ChatRightRail"; import { @@ -691,6 +692,14 @@ export function ChatView({ composerHandoffActive && "invisible pointer-events-none", )} > + {sessionIsRemote && + effectiveSession?.remoteHost && + !effectiveSession.creationState ? ( + + ) : null} store.statusByHost[host]); + const ensureHostConnected = useRemoteHostStore( + (store) => store.ensureHostConnected, + ); + const [reconnectPending, setReconnectPending] = useState(false); + + const handleReconnect = useCallback(async () => { + setReconnectPending(true); + try { + await ensureHostConnected(host); + if (sessionId) { + // Replays the remote transcript so anything that happened while the + // tunnel was down (the daemon keeps running) shows up. + const { loadSessionMessages } = await import( + "@/features/chat/lib/sessionActivation" + ); + await loadSessionMessages(sessionId); + } + } catch { + // ensureHostConnected already recorded the failure in statusByHost; + // the banner re-renders into the failed state with a retry button. + } finally { + setReconnectPending(false); + } + }, [ensureHostConnected, host, sessionId]); + + const state = status?.state ?? "disconnected"; + if (state === "ready") return null; + + const busy = + reconnectPending || state === "connecting" || state === "reconnecting"; + const label = busy + ? state === "reconnecting" + ? t("toolbar.remoteHost.banner.reconnecting", { host }) + : t("toolbar.remoteHost.banner.connecting", { host }) + : state === "failed" + ? t("toolbar.remoteHost.banner.failed", { host }) + : t("toolbar.remoteHost.banner.disconnected", { host }); + const detail = !busy && state === "failed" ? status?.error?.message : null; + + return ( +
    + {busy ? ( +
    + ); +} diff --git a/src/features/chat/ui/__tests__/RemoteHostConnectionBanner.test.tsx b/src/features/chat/ui/__tests__/RemoteHostConnectionBanner.test.tsx new file mode 100644 index 000000000..aab2712b5 --- /dev/null +++ b/src/features/chat/ui/__tests__/RemoteHostConnectionBanner.test.tsx @@ -0,0 +1,118 @@ +import { render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { useRemoteHostStore } from "@/features/remoteHosts/stores/remoteHostStore"; +import { RemoteHostConnectionBanner } from "../RemoteHostConnectionBanner"; + +const loadSessionMessages = vi.hoisted(() => vi.fn(async () => true)); + +vi.mock("@/features/chat/lib/sessionActivation", () => ({ + loadSessionMessages, +})); + +const HOST = "blox"; + +function setHostState( + state: "connecting" | "ready" | "reconnecting" | "disconnected" | "failed", + error?: { kind: string; message: string }, +) { + useRemoteHostStore.setState((current) => ({ + statusByHost: { + ...current.statusByHost, + [HOST]: { state, ...(error ? { error } : {}) }, + }, + })); +} + +function clearHostState() { + useRemoteHostStore.setState((current) => { + const statusByHost = { ...current.statusByHost }; + delete statusByHost[HOST]; + return { statusByHost }; + }); +} + +describe("RemoteHostConnectionBanner", () => { + afterEach(() => { + clearHostState(); + loadSessionMessages.mockClear(); + }); + + it("treats a host with no status entry as disconnected", () => { + render(); + + expect(screen.getByText("Disconnected from blox")).toBeInTheDocument(); + expect( + screen.getByRole("button", { name: "Reconnect" }), + ).toBeInTheDocument(); + }); + + it("renders nothing while the host is ready", () => { + setHostState("ready"); + const { container } = render( + , + ); + + expect(container.querySelector("[data-remote-host-banner]")).toBeNull(); + }); + + it("shows automatic reconnect progress without a button", () => { + setHostState("reconnecting"); + render(); + + expect(screen.getByText("Reconnecting to blox...")).toBeInTheDocument(); + expect(screen.queryByRole("button", { name: "Reconnect" })).toBeNull(); + }); + + it("shows the failure detail and offers a retry", () => { + setHostState("failed", { kind: "hostUnreachable", message: "no route" }); + render(); + + expect(screen.getByText("Couldn't connect to blox")).toBeInTheDocument(); + expect(screen.getByText("no route")).toBeInTheDocument(); + expect( + screen.getByRole("button", { name: "Reconnect" }), + ).toBeInTheDocument(); + }); + + it("reconnects the host and reloads the session transcript", async () => { + const ensureHostConnected = vi.fn(async () => { + setHostState("ready"); + }); + const original = useRemoteHostStore.getState().ensureHostConnected; + useRemoteHostStore.setState({ ensureHostConnected }); + try { + render(); + + await userEvent.click(screen.getByRole("button", { name: "Reconnect" })); + + expect(ensureHostConnected).toHaveBeenCalledWith(HOST); + await waitFor(() => { + expect(loadSessionMessages).toHaveBeenCalledWith("s1"); + }); + } finally { + useRemoteHostStore.setState({ ensureHostConnected: original }); + } + }); + + it("does not reload the transcript when reconnecting fails", async () => { + const ensureHostConnected = vi.fn(async () => { + setHostState("failed", { kind: "hostUnreachable", message: "no route" }); + throw new Error("no route"); + }); + const original = useRemoteHostStore.getState().ensureHostConnected; + useRemoteHostStore.setState({ ensureHostConnected }); + try { + render(); + + await userEvent.click(screen.getByRole("button", { name: "Reconnect" })); + + await waitFor(() => { + expect(screen.getByText("Couldn't connect to blox")).toBeVisible(); + }); + expect(loadSessionMessages).not.toHaveBeenCalled(); + } finally { + useRemoteHostStore.setState({ ensureHostConnected: original }); + } + }); +}); diff --git a/src/features/remoteHosts/stores/remoteHostStore.ts b/src/features/remoteHosts/stores/remoteHostStore.ts index 372eb1aa7..0b8cc6396 100644 --- a/src/features/remoteHosts/stores/remoteHostStore.ts +++ b/src/features/remoteHosts/stores/remoteHostStore.ts @@ -345,6 +345,21 @@ export function ensureHostConnected(host: string): Promise { return useRemoteHostStore.getState().ensureHostConnected(host); } +let remoteHostStoreInitStarted = false; + +/** + * Start the live-status subscription and store seeding once per app lifetime. + * Returns true when this call started it, false when it was already running — + * callers that want fresher data on later invocations refresh explicitly. + * Callers gate this behind the remote-ssh-sessions experiment. + */ +export function ensureRemoteHostStoreInitialized(): boolean { + if (remoteHostStoreInitStarted) return false; + remoteHostStoreInitStarted = true; + void initRemoteHostStore(); + return true; +} + /** * Subscribe to remote backend status events and seed the store from the * backend snapshot and the SSH config. Returns an unsubscribe function. diff --git a/src/features/remoteHosts/ui/RemoteHostsSettings.tsx b/src/features/remoteHosts/ui/RemoteHostsSettings.tsx index 23eb6a900..900245d5e 100644 --- a/src/features/remoteHosts/ui/RemoteHostsSettings.tsx +++ b/src/features/remoteHosts/ui/RemoteHostsSettings.tsx @@ -3,7 +3,7 @@ import { useTranslation } from "react-i18next"; import { REMOTE_SSH_SESSIONS_EXPERIMENT_ID } from "@/features/experiments/experimentDefinitions"; import { useExperiment } from "@/features/experiments/experimentPreferences"; import { - initRemoteHostStore, + ensureRemoteHostStoreInitialized, useRemoteHostStore, type RemoteHostStatus, } from "@/features/remoteHosts/stores/remoteHostStore"; @@ -19,11 +19,6 @@ import { Input } from "@/shared/ui/input"; import { SettingsRow } from "@/shared/ui/settings-row"; import { SettingsSection } from "@/shared/ui/settings-section"; -// One live-status subscription per app lifetime. `initRemoteHostStore` also -// seeds the store (backend snapshot + SSH config hosts), so the first mount -// gets its refresh from init; later mounts re-refresh explicitly below. -let remoteHostStoreInitStarted = false; - function StatusPill({ status }: { status: RemoteHostStatus | undefined }) { const { t } = useTranslation("settings"); const state = status?.state ?? "disconnected"; @@ -328,15 +323,14 @@ export function RemoteHostsSettings() { useEffect(() => { if (!enabled) return; - if (!remoteHostStoreInitStarted) { - remoteHostStoreInitStarted = true; - // Subscribes to live status events and seeds snapshot + config hosts. - void initRemoteHostStore(); - return; + // Init subscribes to live status events and seeds snapshot + config + // hosts; when it already ran (startup or an earlier mount), re-refresh so + // this mount shows current data. + if (!ensureRemoteHostStoreInitialized()) { + const store = useRemoteHostStore.getState(); + void store.refreshConfigHosts(); + void store.syncBackendSnapshot(); } - const store = useRemoteHostStore.getState(); - void store.refreshConfigHosts(); - void store.syncBackendSnapshot(); }, [enabled]); // Config hosts first, then manually added hosts (persisted across diff --git a/src/features/sessions/ui/session-list/SidebarChatRow.tsx b/src/features/sessions/ui/session-list/SidebarChatRow.tsx index 87990cf28..b279862ee 100644 --- a/src/features/sessions/ui/session-list/SidebarChatRow.tsx +++ b/src/features/sessions/ui/session-list/SidebarChatRow.tsx @@ -21,6 +21,7 @@ import { } from "@/features/chat/lib/sessionWindowCommands"; import { useSessionWindowSupport } from "@/features/chat/hooks/useSessionWindowSupport"; import { useSessionWindowStore } from "@/features/chat/stores/sessionWindowStore"; +import { useRemoteHostStore } from "@/features/remoteHosts/stores/remoteHostStore"; import { exportSessionAction } from "@/features/sessions/lib/exportSessionAction"; import { isMultiSelectModifier, @@ -244,6 +245,10 @@ export function SidebarChatRow({ onMarkSelectedUnread, }: SidebarChatRowProps) { const { t } = useTranslation(["sidebar", "common"]); + const remoteHostConnected = + useRemoteHostStore((store) => + remoteHost ? store.statusByHost[remoteHost]?.state : undefined, + ) === "ready"; const workingIndicatorAnimationPreference = useWorkingIndicatorAnimationPreference(); const animateRunningState = @@ -482,10 +487,24 @@ export function SidebarChatRow({ {remoteHost ? ( diff --git a/src/features/sessions/ui/session-list/__tests__/SidebarProjectUnread.test.tsx b/src/features/sessions/ui/session-list/__tests__/SidebarProjectUnread.test.tsx index 3c5f28935..65168fa83 100644 --- a/src/features/sessions/ui/session-list/__tests__/SidebarProjectUnread.test.tsx +++ b/src/features/sessions/ui/session-list/__tests__/SidebarProjectUnread.test.tsx @@ -1,6 +1,7 @@ import { render } from "@testing-library/react"; import { describe, expect, it, vi } from "vitest"; import type { ProjectInfo } from "@/features/projects/api/projects"; +import { useRemoteHostStore } from "@/features/remoteHosts/stores/remoteHostStore"; import { SidebarChatDragProvider } from "../SidebarChatDragContext"; import { SidebarProjectSection } from "../SidebarProjectSection"; @@ -85,12 +86,34 @@ describe("project unread dot", () => { }); describe("remote project identity", () => { - it("uses a remote globe on the chat without badging the project", () => { + it("marks the remote chat disconnected when its host has no live tunnel", () => { const { container } = renderRemoteSection(); expect(container.querySelector("[data-sidebar-project-remote]")).toBeNull(); - expect( - container.querySelector("[data-sidebar-chat-remote-host]"), - ).toHaveAttribute("title", "Remote chat on blox"); + const glyph = container.querySelector("[data-sidebar-chat-remote-host]"); + expect(glyph).toHaveAttribute( + "title", + "Remote chat on blox — disconnected", + ); + expect(glyph).toHaveAttribute("data-remote-host-connected", "false"); + }); + + it("uses the connected label once the host tunnel is ready", () => { + useRemoteHostStore.setState((state) => ({ + statusByHost: { ...state.statusByHost, blox: { state: "ready" } }, + })); + try { + const { container } = renderRemoteSection(); + + const glyph = container.querySelector("[data-sidebar-chat-remote-host]"); + expect(glyph).toHaveAttribute("title", "Remote chat on blox"); + expect(glyph).toHaveAttribute("data-remote-host-connected", "true"); + } finally { + useRemoteHostStore.setState((state) => { + const statusByHost = { ...state.statusByHost }; + delete statusByHost.blox; + return { statusByHost }; + }); + } }); }); diff --git a/src/shared/i18n/locales/en/chat.json b/src/shared/i18n/locales/en/chat.json index ab4cc5ee3..103dc0536 100644 --- a/src/shared/i18n/locales/en/chat.json +++ b/src/shared/i18n/locales/en/chat.json @@ -558,6 +558,13 @@ "loading": "Loading...", "recents": "Recent folders", "use": "Use this folder" + }, + "banner": { + "disconnected": "Disconnected from {{host}}", + "failed": "Couldn't connect to {{host}}", + "connecting": "Connecting to {{host}}...", + "reconnecting": "Reconnecting to {{host}}...", + "reconnect": "Reconnect" } }, "selectModel": "Select model", diff --git a/src/shared/i18n/locales/en/sidebar.json b/src/shared/i18n/locales/en/sidebar.json index 74a278b4b..ab468871c 100644 --- a/src/shared/i18n/locales/en/sidebar.json +++ b/src/shared/i18n/locales/en/sidebar.json @@ -81,7 +81,8 @@ "chatActive": "Chat active", "pinnedChat": "Pinned chat", "unreadMessages": "Unread messages", - "remoteHost": "Remote chat on {{host}}" + "remoteHost": "Remote chat on {{host}}", + "remoteHostDisconnected": "Remote chat on {{host}} — disconnected" }, "showLess": "Show less", "viewMoreChats": "View more", diff --git a/src/shared/i18n/locales/es/chat.json b/src/shared/i18n/locales/es/chat.json index 8fe5af80c..720104f25 100644 --- a/src/shared/i18n/locales/es/chat.json +++ b/src/shared/i18n/locales/es/chat.json @@ -555,6 +555,13 @@ "loading": "Cargando...", "recents": "Carpetas recientes", "use": "Usar esta carpeta" + }, + "banner": { + "disconnected": "Desconectado de {{host}}", + "failed": "No se pudo conectar con {{host}}", + "connecting": "Conectando con {{host}}...", + "reconnecting": "Reconectando con {{host}}...", + "reconnect": "Reconectar" } }, "selectModel": "Seleccionar modelo", diff --git a/src/shared/i18n/locales/es/sidebar.json b/src/shared/i18n/locales/es/sidebar.json index 82aec7b38..e5d6d3294 100644 --- a/src/shared/i18n/locales/es/sidebar.json +++ b/src/shared/i18n/locales/es/sidebar.json @@ -81,7 +81,8 @@ "chatActive": "Chat activo", "pinnedChat": "Chat fijado", "unreadMessages": "Mensajes no leídos", - "remoteHost": "Chat remoto en {{host}}" + "remoteHost": "Chat remoto en {{host}}", + "remoteHostDisconnected": "Chat remoto en {{host}} — desconectado" }, "showLess": "Mostrar menos", "viewMoreChats": "Ver más", From 2107048b2213947dea2c1951e8dbfa3a4062b255 Mon Sep 17 00:00:00 2001 From: damienrj Date: Mon, 31 Aug 2026 19:03:12 -0700 Subject: [PATCH 10/13] fix: address remote session review feedback --- .../src/services/remote_backend/daemon.rs | 65 +++++++++++ .../services/remote_backend/remote_daemon.sh | 108 ++++++++++++++++-- .../src/services/remote_backend/tunnel.rs | 32 +++++- .../remoteSessionPersistence.test.ts | 9 ++ .../chat/stores/remoteSessionPersistence.ts | 8 +- .../experiments/experimentPreferences.ts | 2 +- src/shared/api/__tests__/acpBackends.test.ts | 34 ++++++ src/shared/api/acpConnection.ts | 50 +++++++- 8 files changed, 285 insertions(+), 23 deletions(-) diff --git a/src-tauri/src/services/remote_backend/daemon.rs b/src-tauri/src/services/remote_backend/daemon.rs index 13498b810..8b7c10d4e 100644 --- a/src-tauri/src/services/remote_backend/daemon.rs +++ b/src-tauri/src/services/remote_backend/daemon.rs @@ -678,6 +678,14 @@ mod tests { path_override: Option<&str>, home: &std::path::Path, ) -> (Vec, Option) { + collect_script(spawn_script(args, path_override, home)) + } + + fn spawn_script( + args: &[&str], + path_override: Option<&str>, + home: &std::path::Path, + ) -> std::process::Child { let nonce = "berd-test-nonce"; let mut command = StdCommand::new("bash"); command @@ -701,6 +709,11 @@ mod tests { .unwrap() .write_all(BOOTSTRAP_SCRIPT.as_bytes()) .unwrap(); + child + } + + fn collect_script(child: std::process::Child) -> (Vec, Option) { + let nonce = "berd-test-nonce"; let out = child.wait_with_output().expect("script output"); let prefix = format!("{nonce} "); let lines = String::from_utf8_lossy(&out.stdout) @@ -927,6 +940,58 @@ fi kill_recorded_pid(&first_fields); } + #[test] + fn concurrent_ensure_calls_share_one_recorded_daemon() { + if !python3_available() { + eprintln!("skipping: python3 unavailable for the goose serve shim"); + return; + } + let home = tempfile::tempdir().unwrap(); + let bin = tempfile::tempdir().unwrap(); + let goose = write_goose_shim(bin.path(), "goose", "goose 1.0.0"); + let goose_arg = b64_arg(&goose.to_string_lossy()); + + let first = spawn_script(&["ensure", "-", &goose_arg], None, home.path()); + let second = spawn_script(&["ensure", "-", &goose_arg], None, home.path()); + let (first_lines, first_code) = collect_script(first); + let (second_lines, second_code) = collect_script(second); + + assert_eq!(first_code, Some(0), "lines: {first_lines:?}"); + assert_eq!(second_code, Some(0), "lines: {second_lines:?}"); + let first_ready = parse_ready_line( + first_lines + .iter() + .find_map(|line| line.strip_prefix("READY ")) + .expect("first READY"), + ) + .unwrap(); + let second_ready = parse_ready_line( + second_lines + .iter() + .find_map(|line| line.strip_prefix("READY ")) + .expect("second READY"), + ) + .unwrap(); + assert_eq!(first_ready.pid, second_ready.pid); + assert_eq!(first_ready.port, second_ready.port); + assert_ne!(first_ready.reused, second_ready.reused); + + let state_dir = home.path().join(".state").join("berd").join("remote"); + let entries = std::fs::read_dir(&state_dir) + .unwrap() + .map(|entry| entry.unwrap().file_name()) + .collect::>(); + assert!( + entries + .iter() + .all(|name| !name.to_string_lossy().starts_with(".daemon.record.")), + "atomic record temp was left behind: {entries:?}" + ); + + let (_, code) = run_script(&["shutdown"], None, home.path()); + assert_eq!(code, Some(0)); + } + /// A record written before process identities existed cannot prove PID /// ownership, so it is discarded without signaling and a fresh daemon /// is started. diff --git a/src-tauri/src/services/remote_backend/remote_daemon.sh b/src-tauri/src/services/remote_backend/remote_daemon.sh index add8bb844..34777fce5 100644 --- a/src-tauri/src/services/remote_backend/remote_daemon.sh +++ b/src-tauri/src/services/remote_backend/remote_daemon.sh @@ -38,6 +38,8 @@ GOOSE_ARG="${4:--}" STATE_DIR="${XDG_STATE_HOME:-$HOME/.local/state}/berd/remote" RECORD="$STATE_DIR/daemon.record" LOG="$STATE_DIR/goose-serve.log" +LOCK_DIR="$STATE_DIR/daemon.lock" +LOCK_OWNER="$LOCK_DIR/owner" RECORD_FORMAT="v3" emit() { printf '%s %s\n' "$NONCE" "$*"; } @@ -97,6 +99,85 @@ process_identity() { printf 'ps:%s' "$identity_ps" } +lock_owner_is_current() { + IFS=' ' read -r lock_pid lock_b64identity lock_extra <"$LOCK_OWNER" 2>/dev/null || return 1 + case "$lock_pid" in + '' | *[!0-9]*) return 1 ;; + esac + [ -n "$lock_b64identity" ] || return 1 + [ -z "${lock_extra:-}" ] || return 1 + kill -0 "$lock_pid" 2>/dev/null || return 1 + lock_identity="$(process_identity "$lock_pid")" || return 1 + [ "$(b64 "$lock_identity")" = "$lock_b64identity" ] +} + +release_daemon_lock() { + [ "${lock_held:-0}" = "1" ] || return 0 + if IFS= read -r current_lock_owner <"$LOCK_OWNER" 2>/dev/null && + [ "$current_lock_owner" = "$our_lock_owner" ]; then + rm -f "$LOCK_OWNER" + rmdir "$LOCK_DIR" 2>/dev/null || true + fi + lock_held=0 +} + +# `mkdir` is the portable cross-process atomic primitive available on both +# Linux and macOS remotes. The lock covers every daemon.record read/mutation, +# including shutdown from another Berd client using the same remote account. +acquire_daemon_lock() { + umask 077 + mkdir -p "$STATE_DIR" || { + emit "ERR state-dir" + exit 46 + } + + lock_held=0 + stale_observations=0 + lock_attempt=0 + while [ "$lock_attempt" -lt 1200 ]; do + lock_attempt=$((lock_attempt + 1)) + if mkdir "$LOCK_DIR" 2>/dev/null; then + our_identity="$(process_identity "$$")" || { + rmdir "$LOCK_DIR" 2>/dev/null || true + emit "ERR state-dir" + exit 46 + } + our_lock_owner="$$ $(b64 "$our_identity")" + lock_owner_tmp="$LOCK_DIR/.owner.$$" + if ! printf '%s\n' "$our_lock_owner" >"$lock_owner_tmp" || + ! mv -f "$lock_owner_tmp" "$LOCK_OWNER"; then + rm -f "$lock_owner_tmp" + rmdir "$LOCK_DIR" 2>/dev/null || true + emit "ERR state-dir" + exit 46 + fi + lock_held=1 + trap release_daemon_lock EXIT + trap 'exit 129' HUP + trap 'exit 130' INT + trap 'exit 143' TERM + return 0 + fi + + if lock_owner_is_current; then + stale_observations=0 + else + stale_observations=$((stale_observations + 1)) + # Allow the mkdir winner time to publish its owner before treating a + # missing/malformed owner as stale after an interrupted invocation. + if [ "$stale_observations" -ge 20 ]; then + rm -f "$LOCK_OWNER" + rmdir "$LOCK_DIR" 2>/dev/null || true + stale_observations=0 + fi + fi + sleep 0.1 + done + + emit "ERR state-dir" + exit 46 +} + # Fills rec_* from $RECORD. Records before v3 have no process identity and # therefore cannot authorize reuse or termination. read_record() { @@ -163,12 +244,6 @@ stop_recorded_daemon() { } ensure_daemon() { - umask 077 - mkdir -p "$STATE_DIR" || { - emit "ERR state-dir" - exit 46 - } - resolve_goose_bin b64binary="$(b64 "$goose_bin")" @@ -219,8 +294,15 @@ ensure_daemon() { kill "$pid" 2>/dev/null || true break fi - printf '%s %s %s %s %s %s %s %s\n' "$RECORD_FORMAT" "$pid" "$port" "$secret" \ - "$(b64 "$version")" "$started" "$b64binary" "$(b64 "$identity")" >"$RECORD" + record_tmp="$STATE_DIR/.daemon.record.$$" + if ! printf '%s %s %s %s %s %s %s %s\n' "$RECORD_FORMAT" "$pid" "$port" "$secret" \ + "$(b64 "$version")" "$started" "$b64binary" "$(b64 "$identity")" >"$record_tmp" || + ! mv -f "$record_tmp" "$RECORD"; then + rm -f "$record_tmp" + kill "$pid" 2>/dev/null || true + emit "ERR state-dir" + exit 46 + fi emit "READY $pid $port $secret 0 $(b64 "$version") $started" return 0 fi @@ -300,8 +382,14 @@ list_dir() { } case "$MODE" in -ensure) ensure_daemon ;; -shutdown) shutdown_daemon ;; +ensure) + acquire_daemon_lock + ensure_daemon + ;; +shutdown) + acquire_daemon_lock + shutdown_daemon + ;; check) check_host ;; listdir) list_dir ;; *) diff --git a/src-tauri/src/services/remote_backend/tunnel.rs b/src-tauri/src/services/remote_backend/tunnel.rs index 974011694..64ec7f163 100644 --- a/src-tauri/src/services/remote_backend/tunnel.rs +++ b/src-tauri/src/services/remote_backend/tunnel.rs @@ -29,6 +29,22 @@ pub(crate) struct TunnelProcess { pub stderr_tail: Arc>, } +fn append_to_bounded_tail(tail: &mut String, line: &str, max_bytes: usize) { + tail.push_str(line); + tail.push('\n'); + if tail.len() <= max_bytes { + return; + } + + let excess = tail.len() - max_bytes; + let drain_end = tail + .char_indices() + .map(|(index, _)| index) + .find(|index| *index >= excess) + .unwrap_or(tail.len()); + tail.drain(..drain_end); +} + pub(crate) fn build_tunnel_command( spec: &RemoteHostSpec, shell_env: &HashMap, @@ -78,12 +94,7 @@ pub(crate) fn spawn_tunnel( let redacted = redact_log_line(&line); log::warn!("[remote-backend tunnel stderr] {redacted}"); if let Ok(mut tail) = tail.lock() { - tail.push_str(&redacted); - tail.push('\n'); - if tail.len() > 8 * 1024 { - let excess = tail.len() - 8 * 1024; - tail.drain(..excess); - } + append_to_bounded_tail(&mut tail, &redacted, 8 * 1024); } } }); @@ -166,4 +177,13 @@ mod tests { assert_eq!(argv.last().unwrap(), "damien@devbox"); assert_eq!(argv[argv.len() - 2], "--"); } + + #[test] + fn stderr_tail_truncation_respects_utf8_boundaries() { + let mut tail = String::new(); + append_to_bounded_tail(&mut tail, &format!("é{}", "a".repeat(8_190)), 8 * 1024); + + assert!(tail.len() <= 8 * 1024); + assert_eq!(tail, format!("{}\n", "a".repeat(8_190))); + } } diff --git a/src/features/chat/stores/__tests__/remoteSessionPersistence.test.ts b/src/features/chat/stores/__tests__/remoteSessionPersistence.test.ts index 9970b5846..6a3a651f3 100644 --- a/src/features/chat/stores/__tests__/remoteSessionPersistence.test.ts +++ b/src/features/chat/stores/__tests__/remoteSessionPersistence.test.ts @@ -15,6 +15,7 @@ const mocks = vi.hoisted(() => ({ transferSessionBackend: vi.fn(), unregisterSessionBackend: vi.fn(), getSessionBackend: vi.fn(), + invalidateBackendConnection: vi.fn().mockResolvedValue(undefined), disconnectRemoteHost: vi.fn().mockResolvedValue(undefined), })); @@ -53,6 +54,11 @@ vi.mock("@/shared/api/remoteHosts", () => ({ mocks.disconnectRemoteHost(...args), })); +vi.mock("@/shared/api/acpConnection", () => ({ + invalidateBackendConnection: (...args: unknown[]) => + mocks.invalidateBackendConnection(...args), +})); + function makeRecord( overrides: Partial = {}, ): RemoteSessionRecord { @@ -292,6 +298,9 @@ describe("remoteSessionPersistence", () => { record.sessionId, ); expect(mocks.disconnectRemoteHost).toHaveBeenCalledWith(record.host); + expect(mocks.invalidateBackendConnection).toHaveBeenCalledWith( + "ssh:devbox", + ); expect(readRemoteSessionRecords()).toEqual([record]); }); diff --git a/src/features/chat/stores/remoteSessionPersistence.ts b/src/features/chat/stores/remoteSessionPersistence.ts index 4c00caa74..a8d4ad011 100644 --- a/src/features/chat/stores/remoteSessionPersistence.ts +++ b/src/features/chat/stores/remoteSessionPersistence.ts @@ -257,8 +257,14 @@ export async function reconcileRemoteSessionsForExperiment( unregisterSessionBackend(sessionId); } + const { invalidateBackendConnection } = await import( + "@/shared/api/acpConnection" + ); const { disconnectRemoteHost } = await import("@/shared/api/remoteHosts"); await Promise.allSettled( - [...remoteHosts].map((host) => disconnectRemoteHost(host)), + [...remoteHosts].flatMap((host) => [ + invalidateBackendConnection(sshBackendId(host)), + disconnectRemoteHost(host), + ]), ); } diff --git a/src/features/experiments/experimentPreferences.ts b/src/features/experiments/experimentPreferences.ts index 632348af6..776b0a797 100644 --- a/src/features/experiments/experimentPreferences.ts +++ b/src/features/experiments/experimentPreferences.ts @@ -528,7 +528,7 @@ function getExperimentSnapshot(id: string, registry: ExperimentRegistry) { return snapshot; } -function subscribeToExperimentChanges(onStoreChange: () => void) { +export function subscribeToExperimentChanges(onStoreChange: () => void) { if (typeof window === "undefined") return () => {}; const handleStorage = (event: StorageEvent) => { diff --git a/src/shared/api/__tests__/acpBackends.test.ts b/src/shared/api/__tests__/acpBackends.test.ts index 51fcb32c9..0d5624b14 100644 --- a/src/shared/api/__tests__/acpBackends.test.ts +++ b/src/shared/api/__tests__/acpBackends.test.ts @@ -71,6 +71,13 @@ function flushClosedMonitor() { beforeEach(() => { vi.resetModules(); vi.clearAllMocks(); + window.localStorage.setItem( + "goose:experimental-features", + JSON.stringify({ + version: 2, + experiments: { "remote-ssh-sessions": { enabled: true } }, + }), + ); mocks.clientCallbackFactories.length = 0; mocks.invoke.mockResolvedValue("ws://local"); mocks.connectRemoteHost.mockResolvedValue({ @@ -186,6 +193,33 @@ describe("backend connection registry", () => { expect(mocks.invoke).not.toHaveBeenCalled(); }); + it("blocks and invalidates ssh transports when the experiment is disabled", async () => { + const conn = await importConnection(); + const remoteConnection = conn.getBackendConnection("ssh:dev-box"); + await remoteConnection.getClient(); + const remoteStream = mocks.createWebSocketStream.mock.results.at(-1) + ?.value as { writable: { abort: ReturnType } }; + + window.localStorage.setItem( + "goose:experimental-features", + JSON.stringify({ + version: 2, + experiments: { "remote-ssh-sessions": { enabled: false } }, + }), + ); + window.dispatchEvent(new Event("goose:experimental-features-change")); + await Promise.resolve(); + + expect(remoteStream.writable.abort).toHaveBeenCalledOnce(); + expect(() => conn.getBackendConnection("ssh:dev-box")).toThrow( + "Remote SSH sessions are disabled", + ); + await expect(remoteConnection.getClient()).rejects.toThrow( + "Remote SSH sessions are disabled", + ); + expect(mocks.connectRemoteHost).toHaveBeenCalledTimes(1); + }); + it("re-runs the ws-url resolver after the connection closes", async () => { vi.spyOn(console, "warn").mockImplementation(() => {}); const conn = await importConnection(); diff --git a/src/shared/api/acpConnection.ts b/src/shared/api/acpConnection.ts index ec08eafb7..35e7114c2 100644 --- a/src/shared/api/acpConnection.ts +++ b/src/shared/api/acpConnection.ts @@ -12,6 +12,11 @@ import { type RequestPermissionResponse, } from "@agentclientprotocol/sdk"; import packageJson from "../../../package.json"; +import { REMOTE_SSH_SESSIONS_EXPERIMENT_ID } from "@/features/experiments/experimentDefinitions"; +import { + getExperiment, + subscribeToExperimentChanges, +} from "@/features/experiments/experimentPreferences"; import { LOCAL_BACKEND_ID, compositeSessionId, @@ -132,6 +137,7 @@ export interface AcpConnection { export function createAcpConnection( resolveWsUrl: () => Promise, backendId: AcpBackendId = LOCAL_BACKEND_ID, + assertAvailable?: () => void, ): AcpConnection { let clientPromise: Promise | null = null; let resolvedClient: GooseClient | null = null; @@ -179,6 +185,7 @@ export function createAcpConnection( } async function initializeConnection(): Promise { + assertAvailable?.(); const tStart = performance.now(); const wsUrl = await resolveWsUrl(); perfLog( @@ -210,6 +217,7 @@ export function createAcpConnection( version: packageJson.version, }, } satisfies GooseInitializeRequest); + assertAvailable?.(); perfLog( `[perf:conn] client.initialize in ${(performance.now() - tInit).toFixed(1)}ms (total ${(performance.now() - tStart).toFixed(1)}ms)`, ); @@ -220,6 +228,7 @@ export function createAcpConnection( } async function getClient(): Promise { + assertAvailable?.(); if (resolvedClient) { return resolvedClient; } @@ -286,6 +295,18 @@ async function resolveLocalWsUrl(): Promise { const connections = new Map(); +function remoteSessionsEnabled(): boolean { + return getExperiment(REMOTE_SSH_SESSIONS_EXPERIMENT_ID)?.enabled === true; +} + +function assertRemoteSessionsEnabled(): void { + if (!remoteSessionsEnabled()) { + throw new Error( + "Remote SSH sessions are disabled in Experimental Features", + ); + } +} + function createBackendConnection(backendId: AcpBackendId): AcpConnection { if (backendId === LOCAL_BACKEND_ID) { return createAcpConnection(resolveLocalWsUrl); @@ -294,14 +315,21 @@ function createBackendConnection(backendId: AcpBackendId): AcpConnection { if (!host) { throw new Error(`Unknown ACP backend id: ${backendId}`); } - return createAcpConnection(async () => { - const { connectRemoteHost } = await import("./remoteHosts"); - const remote = await connectRemoteHost(host); - return remote.wsUrl; - }, backendId); + return createAcpConnection( + async () => { + const { connectRemoteHost } = await import("./remoteHosts"); + const remote = await connectRemoteHost(host); + return remote.wsUrl; + }, + backendId, + assertRemoteSessionsEnabled, + ); } export function getBackendConnection(backendId: AcpBackendId): AcpConnection { + if (backendId !== LOCAL_BACKEND_ID) { + assertRemoteSessionsEnabled(); + } let connection = connections.get(backendId); if (!connection) { connection = createBackendConnection(backendId); @@ -322,6 +350,18 @@ export async function invalidateBackendConnection( await connections.get(backendId)?.invalidate(); } +// Renderer reconciliation removes remote sessions and tunnels, while this +// lower-level subscription makes the transport itself authoritative. It also +// closes clients already cached by non-React callers in this window. +subscribeToExperimentChanges(() => { + if (remoteSessionsEnabled()) return; + for (const [backendId, connection] of connections) { + if (backendId !== LOCAL_BACKEND_ID) { + void connection.invalidate(); + } + } +}); + /** * Abort the current transport after an ACP request exceeds its liveness bound. * A timed-out request leaves the connection state unknowable; reconnecting is From b91de46a481ac4a631fc80a68c28a64c0f85e801 Mon Sep 17 00:00:00 2001 From: damienrj Date: Mon, 31 Aug 2026 19:19:09 -0700 Subject: [PATCH 11/13] fix: harden remote daemon lifecycle --- .../src/services/remote_backend/daemon.rs | 163 ++++++++++++++--- .../src/services/remote_backend/error.rs | 7 + .../services/remote_backend/remote_daemon.sh | 170 ++++++++++++++---- src/shared/api/remoteHosts.ts | 3 +- 4 files changed, 289 insertions(+), 54 deletions(-) diff --git a/src-tauri/src/services/remote_backend/daemon.rs b/src-tauri/src/services/remote_backend/daemon.rs index 8b7c10d4e..5b4cfc0ab 100644 --- a/src-tauri/src/services/remote_backend/daemon.rs +++ b/src-tauri/src/services/remote_backend/daemon.rs @@ -22,7 +22,10 @@ use super::ssh::{base_ssh_command, push_destination}; use crate::services::log_redaction::redact_log_line; const BOOTSTRAP_SCRIPT: &str = include_str!("remote_daemon.sh"); -const SCRIPT_TIMEOUT: Duration = Duration::from_secs(60); +// The remote script can wait 45s for another account-level mutation and spend +// roughly 80s on five readiness attempts plus cleanup. Keep the outer timeout +// above that bounded lifetime so cancellation cannot strand an unrecorded PID. +const SCRIPT_TIMEOUT: Duration = Duration::from_secs(180); const MAX_STDERR_BYTES: usize = 16 * 1024; #[derive(Clone, Debug, Serialize)] @@ -360,6 +363,10 @@ pub(crate) fn require_success(output: &ScriptOutput) -> Result<(), RemoteBackend RemoteBackendErrorKind::RemotePortBindFailed => { "the remote host could not bind a port for goose serve".to_string() } + RemoteBackendErrorKind::DaemonConflict => { + "another Berd client is using this remote account with an incompatible Goose launch configuration" + .to_string() + } RemoteBackendErrorKind::AuthFailed => { "ssh authentication failed; make sure key or agent auth works (try `ssh ` in a terminal)" .to_string() @@ -633,6 +640,9 @@ mod tests { let err = require_success(&output(&["ERR port-bind-failed"], Some(43), "")).unwrap_err(); assert_eq!(err.kind, RemoteBackendErrorKind::RemotePortBindFailed); + + let err = require_success(&output(&["ERR daemon-conflict"], Some(47), "")).unwrap_err(); + assert_eq!(err.kind, RemoteBackendErrorKind::DaemonConflict); } #[test] @@ -756,6 +766,46 @@ fi path } + fn write_stalling_goose_shim(dir: &std::path::Path) -> std::path::PathBuf { + let path = dir.join("goose-stalling"); + std::fs::write( + &path, + r#"#!/usr/bin/env bash +if [ "$1" = "--version" ]; then echo "goose stalling"; exit 0; fi +if [ "$1" = "serve" ]; then + echo $$ > "$HOME/uncommitted-goose.pid" + exec sleep 120 +fi +"#, + ) + .unwrap(); + use std::os::unix::fs::PermissionsExt as _; + std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o755)).unwrap(); + path + } + + fn write_noisy_goose_shim(dir: &std::path::Path) -> std::path::PathBuf { + let path = dir.join("goose-noisy"); + std::fs::write( + &path, + r#"#!/usr/bin/env bash +if [ "$1" = "--version" ]; then echo "goose noisy"; exit 0; fi +if [ "$1" = "serve" ]; then + port="" + while [ $# -gt 0 ]; do [ "$1" = "--port" ] && port="$2"; shift; done + exec python3 -c 'import socket,sys,time +sys.stderr.write("x" * (5 * 1024 * 1024) + "\n"); sys.stderr.flush() +s=socket.socket(); s.setsockopt(socket.SOL_SOCKET,socket.SO_REUSEADDR,1) +s.bind(("127.0.0.1",int(sys.argv[1]))); s.listen(5); time.sleep(120)' "$port" +fi +"#, + ) + .unwrap(); + use std::os::unix::fs::PermissionsExt as _; + std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o755)).unwrap(); + path + } + fn read_record_fields(home: &std::path::Path) -> Vec { let record = std::fs::read_to_string( home.join(".state") @@ -874,10 +924,10 @@ fi ); } - /// The reuse decision: same recorded binary reuses the live daemon, a - /// different one stops it and starts the requested build instead. + /// A daemon is reused only for the exact launch contract. Other Berd + /// clients get a conflict instead of evicting each other's daemon. #[test] - fn ensure_reuses_only_a_daemon_started_by_the_requested_binary() { + fn ensure_reuses_only_an_identical_launch_spec() { if !python3_available() { eprintln!("skipping: python3 unavailable for the goose serve shim"); return; @@ -900,9 +950,10 @@ fi .unwrap(); assert!(!first.reused); let first_fields = read_record_fields(home.path()); - assert_eq!(first_fields[0], "v3", "record fields: {first_fields:?}"); + assert_eq!(first_fields[0], "v4", "record fields: {first_fields:?}"); assert_eq!(first_fields[6], b64_arg(&stock.to_string_lossy())); assert!(!first_fields[7].is_empty()); + assert!(!first_fields[8].is_empty()); // Same binary: the healthy daemon is handed back untouched. let (lines, code) = run_script(&["ensure", "-", &stock_arg], None, home.path()); @@ -918,26 +969,23 @@ fi assert_eq!(reused.pid, first.pid); assert_eq!(reused.port, first.port); - // Different binary: restart, new port/secret, patched version. + // A different allowed-origin contract cannot silently inherit the + // first daemon, even when it uses the same Goose binary. + let windows_origins = b64_arg("--allowed-origins http://tauri.localhost"); + let (lines, code) = + run_script(&["ensure", &windows_origins, &stock_arg], None, home.path()); + assert_eq!(code, Some(47), "lines: {lines:?}"); + assert!(lines.iter().any(|line| line == "ERR daemon-conflict")); + + // A different binary is also an ownership conflict, not a daemon + // eviction loop between clients. let (lines, code) = run_script(&["ensure", "-", &patched_arg], None, home.path()); - assert_eq!(code, Some(0), "lines: {lines:?}"); - let restarted = parse_ready_line( - lines - .iter() - .find_map(|l| l.strip_prefix("READY ")) - .expect("READY"), - ) - .unwrap(); - assert!(!restarted.reused); - assert_ne!(restarted.pid, first.pid); - assert_ne!(restarted.secret, first.secret); - assert_eq!(restarted.goose_version, "goose 2.0.0-patched"); - let fields = read_record_fields(home.path()); - assert_eq!(fields[6], b64_arg(&patched.to_string_lossy())); + assert_eq!(code, Some(47), "lines: {lines:?}"); + assert!(lines.iter().any(|line| line == "ERR daemon-conflict")); + assert_eq!(read_record_fields(home.path()), first_fields); let (lines, code) = run_script(&["shutdown"], None, home.path()); assert_eq!(code, Some(0), "lines: {lines:?}"); - kill_recorded_pid(&first_fields); } #[test] @@ -992,6 +1040,79 @@ fi assert_eq!(code, Some(0)); } + #[test] + fn terminating_bootstrap_cleans_up_an_uncommitted_daemon() { + let home = tempfile::tempdir().unwrap(); + let bin = tempfile::tempdir().unwrap(); + let goose = write_stalling_goose_shim(bin.path()); + let goose_arg = b64_arg(&goose.to_string_lossy()); + let bootstrap = spawn_script(&["ensure", "-", &goose_arg], None, home.path()); + let pid_path = home.path().join("uncommitted-goose.pid"); + + for _ in 0..100 { + if pid_path.exists() { + break; + } + std::thread::sleep(Duration::from_millis(20)); + } + let daemon_pid = std::fs::read_to_string(&pid_path) + .expect("stalling goose should publish its pid") + .trim() + .to_string(); + assert!(StdCommand::new("kill") + .arg("-TERM") + .arg(bootstrap.id().to_string()) + .status() + .unwrap() + .success()); + let (_, code) = collect_script(bootstrap); + assert_eq!(code, Some(143)); + assert!( + !StdCommand::new("kill") + .arg("-0") + .arg(&daemon_pid) + .status() + .unwrap() + .success(), + "uncommitted daemon {daemon_pid} survived bootstrap termination" + ); + assert!(!home + .path() + .join(".state/berd/remote/daemon.record") + .exists()); + } + + #[test] + fn daemon_log_retains_a_bounded_tail() { + if !python3_available() { + eprintln!("skipping: python3 unavailable for the goose serve shim"); + return; + } + let home = tempfile::tempdir().unwrap(); + let bin = tempfile::tempdir().unwrap(); + let goose = write_noisy_goose_shim(bin.path()); + let goose_arg = b64_arg(&goose.to_string_lossy()); + + let (lines, code) = run_script(&["ensure", "-", &goose_arg], None, home.path()); + assert_eq!(code, Some(0), "lines: {lines:?}"); + let log_path = home.path().join(".state/berd/remote/goose-serve.log"); + let mut log_len = 0; + for _ in 0..100 { + log_len = std::fs::metadata(&log_path) + .map(|metadata| metadata.len()) + .unwrap_or(0); + if log_len > 0 && log_len <= 4 * 1024 * 1024 { + break; + } + std::thread::sleep(Duration::from_millis(20)); + } + assert!(log_len > 0); + assert!(log_len <= 4 * 1024 * 1024, "log grew to {log_len} bytes"); + + let (_, code) = run_script(&["shutdown"], None, home.path()); + assert_eq!(code, Some(0)); + } + /// A record written before process identities existed cannot prove PID /// ownership, so it is discarded without signaling and a fresh daemon /// is started. diff --git a/src-tauri/src/services/remote_backend/error.rs b/src-tauri/src/services/remote_backend/error.rs index 2618bf127..ce56d802b 100644 --- a/src-tauri/src/services/remote_backend/error.rs +++ b/src-tauri/src/services/remote_backend/error.rs @@ -16,6 +16,7 @@ pub enum RemoteBackendErrorKind { HostKeyUnverified, HostUnreachable, GooseNotInstalled, + DaemonConflict, RemotePortBindFailed, LocalPortBindFailed, TunnelClosed, @@ -112,11 +113,13 @@ pub(crate) fn classify_ssh_stderr(stderr: &str) -> RemoteBackendErrorKind { /// Exit codes the bootstrap script uses to report typed failures. pub(crate) const EXIT_GOOSE_NOT_FOUND: i32 = 41; pub(crate) const EXIT_REMOTE_PORT_BIND_FAILED: i32 = 43; +pub(crate) const EXIT_DAEMON_CONFLICT: i32 = 47; pub(crate) fn classify_script_exit(code: i32) -> Option { match code { EXIT_GOOSE_NOT_FOUND => Some(RemoteBackendErrorKind::GooseNotInstalled), EXIT_REMOTE_PORT_BIND_FAILED => Some(RemoteBackendErrorKind::RemotePortBindFailed), + EXIT_DAEMON_CONFLICT => Some(RemoteBackendErrorKind::DaemonConflict), _ => None, } } @@ -183,6 +186,10 @@ mod tests { classify_script_exit(EXIT_REMOTE_PORT_BIND_FAILED), Some(RemoteBackendErrorKind::RemotePortBindFailed) ); + assert_eq!( + classify_script_exit(EXIT_DAEMON_CONFLICT), + Some(RemoteBackendErrorKind::DaemonConflict) + ); assert_eq!(classify_script_exit(1), None); } } diff --git a/src-tauri/src/services/remote_backend/remote_daemon.sh b/src-tauri/src/services/remote_backend/remote_daemon.sh index 34777fce5..9aa3a870b 100644 --- a/src-tauri/src/services/remote_backend/remote_daemon.sh +++ b/src-tauri/src/services/remote_backend/remote_daemon.sh @@ -22,12 +22,13 @@ # listdir -> DIR , E ..., LIST-DONE # # Daemon record (single line, space separated, field order pinned): -# v3 -# The identity is an OS process-start token captured after readiness. Older -# records still parse, but cannot prove PID ownership and are never reused or -# signaled. +# v4 +# The launch spec covers the binary, serve args, and bootstrap protocol. The +# identity is an OS process-start token captured after readiness. Older records +# still parse for safe migration, but are never reused. # -# Typed exit codes: 41 goose-not-found, 43 port-bind-failed, 44 bad-path, 45 no-such-dir. +# Typed exit codes: 41 goose-not-found, 43 port-bind-failed, 44 bad-path, +# 45 no-such-dir, 47 daemon-conflict. set -u NONCE="${1:?nonce required}" @@ -40,7 +41,9 @@ RECORD="$STATE_DIR/daemon.record" LOG="$STATE_DIR/goose-serve.log" LOCK_DIR="$STATE_DIR/daemon.lock" LOCK_OWNER="$LOCK_DIR/owner" -RECORD_FORMAT="v3" +RECORD_FORMAT="v4" +LOG_MAX_BYTES=$((4 * 1024 * 1024)) +LOG_RETAIN_BYTES=$((2 * 1024 * 1024)) emit() { printf '%s %s\n' "$NONCE" "$*"; } @@ -121,6 +124,42 @@ release_daemon_lock() { lock_held=0 } +terminate_uncommitted_daemon() { + case "${uncommitted_pid:-}" in + '' | *[!0-9]*) ;; + *) + cleanup_pid="$uncommitted_pid" + if kill -0 "$cleanup_pid" 2>/dev/null; then + kill -TERM "$cleanup_pid" 2>/dev/null || true + cleanup_i=0 + while [ "$cleanup_i" -lt 10 ] && kill -0 "$cleanup_pid" 2>/dev/null; do + sleep 0.1 + cleanup_i=$((cleanup_i + 1)) + done + if kill -0 "$cleanup_pid" 2>/dev/null; then + kill -KILL "$cleanup_pid" 2>/dev/null || true + fi + fi + wait "$cleanup_pid" 2>/dev/null || true + ;; + esac + uncommitted_pid="" + if [ -n "${uncommitted_logger_pid:-}" ]; then + kill -TERM "$uncommitted_logger_pid" 2>/dev/null || true + wait "$uncommitted_logger_pid" 2>/dev/null || true + uncommitted_logger_pid="" + fi + if [ -n "${uncommitted_log_pipe:-}" ]; then + rm -f "$uncommitted_log_pipe" + uncommitted_log_pipe="" + fi +} + +cleanup_daemon_mutation() { + terminate_uncommitted_daemon + release_daemon_lock +} + # `mkdir` is the portable cross-process atomic primitive available on both # Linux and macOS remotes. The lock covers every daemon.record read/mutation, # including shutdown from another Berd client using the same remote account. @@ -134,7 +173,7 @@ acquire_daemon_lock() { lock_held=0 stale_observations=0 lock_attempt=0 - while [ "$lock_attempt" -lt 1200 ]; do + while [ "$lock_attempt" -lt 450 ]; do lock_attempt=$((lock_attempt + 1)) if mkdir "$LOCK_DIR" 2>/dev/null; then our_identity="$(process_identity "$$")" || { @@ -152,7 +191,10 @@ acquire_daemon_lock() { exit 46 fi lock_held=1 - trap release_daemon_lock EXIT + uncommitted_pid="" + uncommitted_logger_pid="" + uncommitted_log_pipe="" + trap cleanup_daemon_mutation EXIT trap 'exit 129' HUP trap 'exit 130' INT trap 'exit 143' TERM @@ -178,11 +220,12 @@ acquire_daemon_lock() { exit 46 } -# Fills rec_* from $RECORD. Records before v3 have no process identity and -# therefore cannot authorize reuse or termination. +# Fills rec_* from $RECORD. v3 records retain a process identity so Berd can +# stop them during migration, but only v4 records carry a reusable launch spec. +# Older records cannot authorize reuse or termination. read_record() { # shellcheck disable=SC2034 - IFS=' ' read -r f1 f2 f3 f4 f5 f6 f7 f8 <"$RECORD" 2>/dev/null || return 1 + IFS=' ' read -r f1 f2 f3 f4 f5 f6 f7 f8 f9 <"$RECORD" 2>/dev/null || return 1 if [ "${f1:-}" = "$RECORD_FORMAT" ]; then rec_pid="${f2:-}" rec_port="${f3:-}" @@ -190,6 +233,16 @@ read_record() { rec_b64version="${f5:--}" rec_started="${f6:-0}" rec_b64binary="${f7:-}" + rec_b64launchspec="${f8:-}" + rec_b64identity="${f9:-}" + elif [ "${f1:-}" = "v3" ]; then + rec_pid="${f2:-}" + rec_port="${f3:-}" + rec_secret="${f4:-}" + rec_b64version="${f5:--}" + rec_started="${f6:-0}" + rec_b64binary="${f7:-}" + rec_b64launchspec="" rec_b64identity="${f8:-}" elif [ "${f1:-}" = "v2" ]; then rec_pid="${f2:-}" @@ -198,6 +251,7 @@ read_record() { rec_b64version="${f5:--}" rec_started="${f6:-0}" rec_b64binary="${f7:-}" + rec_b64launchspec="" rec_b64identity="" else # Pre-override record: no recorded binary or process identity. @@ -207,6 +261,7 @@ read_record() { rec_b64version="${f4:--}" rec_started="${f5:-0}" rec_b64binary="" + rec_b64launchspec="" rec_b64identity="" fi case "$rec_pid" in @@ -218,6 +273,32 @@ read_record() { [ -n "$rec_secret" ] } +trim_log_if_needed() { + log_path="$1" + [ -f "$log_path" ] || return 0 + log_bytes="$(wc -c <"$log_path" 2>/dev/null | tr -d ' ')" + case "$log_bytes" in + '' | *[!0-9]*) return 0 ;; + esac + [ "$log_bytes" -le "$LOG_MAX_BYTES" ] && return 0 + log_tmp="$STATE_DIR/.goose-serve.log.$$" + if tail -c "$LOG_RETAIN_BYTES" "$log_path" >"$log_tmp" 2>/dev/null; then + mv -f "$log_tmp" "$log_path" + else + rm -f "$log_tmp" + fi +} + +# Consume the daemon's pipe for its whole lifetime while keeping only a bounded +# tail on disk. Each append reopens the path so atomic trims take effect. +bounded_log_writer() { + writer_log="$1" + while IFS= read -r writer_line || [ -n "$writer_line" ]; do + printf '%s\n' "$writer_line" >>"$writer_log" + trim_log_if_needed "$writer_log" + done +} + # Confirms the PID is still the exact process that wrote this record. `kill -0` # alone is insufficient because a stale PID can be reused by another process. recorded_process_is_current() { @@ -247,29 +328,38 @@ ensure_daemon() { resolve_goose_bin b64binary="$(b64 "$goose_bin")" + extra_args="" + if [ "$ARG" != "-" ]; then + extra_args="$(unb64 "$ARG")" + fi + version="$("$goose_bin" --version 2>/dev/null | head -n 1)" + [ -n "$version" ] || version="unknown" + launch_spec="$(printf 'remote-daemon-v1\n%s\n%s\n%s' "$goose_bin" "$version" "$extra_args")" + b64launchspec="$(b64 "$launch_spec")" + if [ -f "$RECORD" ] && read_record; then if recorded_process_is_current; then - if port_listening "$rec_port" && [ "$rec_b64binary" = "$b64binary" ]; then - emit "READY $rec_pid $rec_port $rec_secret 1 $rec_b64version $rec_started" - return 0 + if port_listening "$rec_port"; then + if [ -n "$rec_b64launchspec" ] && + [ "$rec_b64launchspec" != "$b64launchspec" ]; then + emit "ERR daemon-conflict" + exit 47 + fi + if [ "$rec_b64launchspec" = "$b64launchspec" ]; then + emit "READY $rec_pid $rec_port $rec_secret 1 $rec_b64version $rec_started" + return 0 + fi fi - # A known daemon is unhealthy or uses another binary: stop it so the - # requested build is the one that answers. Unverifiable old records are - # only discarded; their PIDs are never signaled. + # A known daemon is unhealthy, or predates launch-spec identity. Stop it + # so a v4 record can be committed. Unverifiable old records are only + # discarded; their PIDs are never signaled. stop_recorded_daemon fi rm -f "$RECORD" fi - version="$("$goose_bin" --version 2>/dev/null | head -n 1)" - [ -n "$version" ] || version="unknown" secret="berd-remote-$(head -c 16 /dev/urandom | od -An -tx1 | tr -d ' \n')" - extra_args="" - if [ "$ARG" != "-" ]; then - extra_args="$(unb64 "$ARG")" - fi - attempt=0 while [ "$attempt" -lt 5 ]; do attempt=$((attempt + 1)) @@ -277,11 +367,23 @@ ensure_daemon() { if port_listening "$port"; then continue fi - # Detached: nohup + fully redirected stdio survives the ssh session ending - # (no PTY is allocated, and non-interactive bash does not forward SIGHUP). + trim_log_if_needed "$LOG" + log_pipe="$STATE_DIR/.goose-serve.log.pipe.$$" + rm -f "$log_pipe" + if ! mkfifo "$log_pipe"; then + emit "ERR state-dir" + exit 46 + fi + uncommitted_log_pipe="$log_pipe" + bounded_log_writer "$LOG" <"$log_pipe" >/dev/null 2>&1 & + logger_pid=$! + uncommitted_logger_pid="$logger_pid" + # Detached: nohup writes into a FIFO consumed by the bounded logger. The + # logger has no protocol-stream descriptors and exits when Goose closes. # shellcheck disable=SC2086 - GOOSE_SERVER__SECRET_KEY="$secret" nohup "$goose_bin" serve --host 127.0.0.1 --port "$port" $extra_args >>"$LOG" 2>&1 "$log_pipe" 2>&1 /dev/null; then @@ -291,25 +393,29 @@ ensure_daemon() { started="$(date +%s)" identity="$(process_identity "$pid")" if [ -z "$identity" ]; then - kill "$pid" 2>/dev/null || true + terminate_uncommitted_daemon break fi record_tmp="$STATE_DIR/.daemon.record.$$" - if ! printf '%s %s %s %s %s %s %s %s\n' "$RECORD_FORMAT" "$pid" "$port" "$secret" \ - "$(b64 "$version")" "$started" "$b64binary" "$(b64 "$identity")" >"$record_tmp" || + if ! printf '%s %s %s %s %s %s %s %s %s\n' "$RECORD_FORMAT" "$pid" "$port" "$secret" \ + "$(b64 "$version")" "$started" "$b64binary" "$b64launchspec" "$(b64 "$identity")" >"$record_tmp" || ! mv -f "$record_tmp" "$RECORD"; then rm -f "$record_tmp" - kill "$pid" 2>/dev/null || true + terminate_uncommitted_daemon emit "ERR state-dir" exit 46 fi + rm -f "$log_pipe" + uncommitted_pid="" + uncommitted_logger_pid="" + uncommitted_log_pipe="" emit "READY $pid $port $secret 0 $(b64 "$version") $started" return 0 fi sleep 0.1 i=$((i + 1)) done - kill "$pid" 2>/dev/null || true + terminate_uncommitted_daemon done emit "ERR port-bind-failed" exit 43 diff --git a/src/shared/api/remoteHosts.ts b/src/shared/api/remoteHosts.ts index 174e8a691..5b2537aa6 100644 --- a/src/shared/api/remoteHosts.ts +++ b/src/shared/api/remoteHosts.ts @@ -7,7 +7,8 @@ import { getGoosePathForHost } from "@/features/remoteHosts/lib/gooseBinaryOverr * kebab-case discriminator such as "auth-failed", "goose-not-installed", * "invalid-host", "host-key-unverified", "host-unreachable", "ssh-not-found", * "ready-timeout", "tunnel-closed", "remote-script-failed", - * "local-port-bind-failed", "remote-port-bind-failed", or "internal". + * "local-port-bind-failed", "remote-port-bind-failed", "daemon-conflict", + * or "internal". */ export interface RemoteBackendErrorLike { kind: string; From 087bc1d539ccc41503604c7e728c774985ac948a Mon Sep 17 00:00:00 2001 From: damienrj Date: Mon, 31 Aug 2026 20:33:35 -0700 Subject: [PATCH 12/13] test: update experiment preference mocks --- .../chat/hooks/useRemoteSessionExperimentReconciliation.test.tsx | 1 + src/features/home/ui/WidgetCanvas.chatLifecycle.test.tsx | 1 + src/features/home/ui/WidgetCanvas.test.tsx | 1 + src/features/home/widgets/ChatPinWidget.test.tsx | 1 + src/features/skills/ui/__tests__/SkillsView.test.tsx | 1 + 5 files changed, 5 insertions(+) diff --git a/src/features/chat/hooks/useRemoteSessionExperimentReconciliation.test.tsx b/src/features/chat/hooks/useRemoteSessionExperimentReconciliation.test.tsx index b616f05a7..ac70a8491 100644 --- a/src/features/chat/hooks/useRemoteSessionExperimentReconciliation.test.tsx +++ b/src/features/chat/hooks/useRemoteSessionExperimentReconciliation.test.tsx @@ -12,6 +12,7 @@ vi.mock("@/app/lib/chatRuntimeStartup", () => ({ })); vi.mock("@/features/experiments/experimentPreferences", () => ({ useExperiment: () => ({ enabled: mocks.enabled }), + subscribeToExperimentChanges: () => () => {}, })); vi.mock("@/features/chat/stores/remoteSessionPersistence", () => ({ reconcileRemoteSessionsForExperiment: mocks.reconcile, diff --git a/src/features/home/ui/WidgetCanvas.chatLifecycle.test.tsx b/src/features/home/ui/WidgetCanvas.chatLifecycle.test.tsx index 82f882ef7..ca7cbd426 100644 --- a/src/features/home/ui/WidgetCanvas.chatLifecycle.test.tsx +++ b/src/features/home/ui/WidgetCanvas.chatLifecycle.test.tsx @@ -55,6 +55,7 @@ const messages: Message[] = [ vi.mock("@/features/experiments/experimentPreferences", () => ({ useExperiment: () => ({ enabled: true }), + subscribeToExperimentChanges: () => () => {}, })); vi.mock("@/features/chat/hooks/useChatTranscriptReadModel", () => ({ diff --git a/src/features/home/ui/WidgetCanvas.test.tsx b/src/features/home/ui/WidgetCanvas.test.tsx index 6473f8f8b..1fea113f5 100644 --- a/src/features/home/ui/WidgetCanvas.test.tsx +++ b/src/features/home/ui/WidgetCanvas.test.tsx @@ -148,6 +148,7 @@ vi.mock("@/features/chat/stores/chatStore", () => ({ vi.mock("@/features/experiments/experimentPreferences", () => ({ useExperiment: () => ({ enabled: true }), + subscribeToExperimentChanges: () => () => {}, })); vi.mock("../widgets/ChatCanvasCard", () => ({ diff --git a/src/features/home/widgets/ChatPinWidget.test.tsx b/src/features/home/widgets/ChatPinWidget.test.tsx index cbbd2593b..db323b07c 100644 --- a/src/features/home/widgets/ChatPinWidget.test.tsx +++ b/src/features/home/widgets/ChatPinWidget.test.tsx @@ -12,6 +12,7 @@ const { mockUseExperiment } = vi.hoisted(() => ({ vi.mock("@/features/experiments/experimentPreferences", () => ({ useExperiment: mockUseExperiment, + subscribeToExperimentChanges: () => () => {}, })); vi.mock("./ChatCanvasCard", () => ({ diff --git a/src/features/skills/ui/__tests__/SkillsView.test.tsx b/src/features/skills/ui/__tests__/SkillsView.test.tsx index 643f33fc9..c3cfb8e1c 100644 --- a/src/features/skills/ui/__tests__/SkillsView.test.tsx +++ b/src/features/skills/ui/__tests__/SkillsView.test.tsx @@ -138,6 +138,7 @@ const mockDiscoveryExperimentEnabled = vi.hoisted(() => ({ value: false })); const mockUseExperiment = vi.hoisted(() => vi.fn()); vi.mock("@/features/experiments/experimentPreferences", () => ({ useExperiment: mockUseExperiment, + subscribeToExperimentChanges: () => () => {}, })); const mockRemoteSkillsState = vi.hoisted(() => ({ From a9a02619abf24aacf5bdb413b1f27f471f758256 Mon Sep 17 00:00:00 2001 From: damienrj Date: Tue, 1 Sep 2026 09:32:36 -0700 Subject: [PATCH 13/13] fix: harden remote lifecycle recovery --- src-tauri/src/commands/remote_backend.rs | 6 +- .../src/services/remote_backend/daemon.rs | 224 +++++++++++++++++- .../src/services/remote_backend/error.rs | 27 +++ src-tauri/src/services/remote_backend/mod.rs | 46 +++- .../services/remote_backend/remote_daemon.sh | 114 +++++++-- .../stores/remoteHostStore.test.ts | 14 ++ .../remoteHosts/stores/remoteHostStore.ts | 10 +- .../remoteHosts/ui/RemoteHostsSettings.tsx | 62 ++++- .../ui/__tests__/RemoteHostsSettings.test.tsx | 61 +++++ src/shared/api/__tests__/acpBackends.test.ts | 67 ++++++ src/shared/api/__tests__/remoteHosts.test.ts | 20 ++ src/shared/api/acpConnection.ts | 135 +++++++++-- src/shared/api/remoteHosts.ts | 28 ++- src/shared/i18n/locales/en/settings.json | 12 +- src/shared/i18n/locales/es/settings.json | 12 +- 15 files changed, 753 insertions(+), 85 deletions(-) diff --git a/src-tauri/src/commands/remote_backend.rs b/src-tauri/src/commands/remote_backend.rs index fca4924fe..424eccd2a 100644 --- a/src-tauri/src/commands/remote_backend.rs +++ b/src-tauri/src/commands/remote_backend.rs @@ -39,8 +39,9 @@ pub async fn remote_backend_disconnect( app: AppHandle, registry: State<'_, RemoteBackendRegistry>, host: String, + expected_generation: Option, ) -> Result<(), RemoteBackendError> { - remote_backend::disconnect(&app, ®istry, &host); + remote_backend::disconnect_generation(&app, ®istry, &host, expected_generation); Ok(()) } @@ -49,8 +50,9 @@ pub async fn remote_backend_shutdown( app: AppHandle, registry: State<'_, RemoteBackendRegistry>, host: String, + expected_instance_token: Option, ) -> Result<(), RemoteBackendError> { - remote_backend::shutdown(&app, ®istry, &host).await + remote_backend::shutdown(&app, ®istry, &host, expected_instance_token.as_deref()).await } #[tauri::command] diff --git a/src-tauri/src/services/remote_backend/daemon.rs b/src-tauri/src/services/remote_backend/daemon.rs index 5b4cfc0ab..e0b1a49e3 100644 --- a/src-tauri/src/services/remote_backend/daemon.rs +++ b/src-tauri/src/services/remote_backend/daemon.rs @@ -16,16 +16,18 @@ use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; use super::error::{ classify_script_exit, classify_ssh_stderr, RemoteBackendError, RemoteBackendErrorKind, + RemoteDaemonInstance, }; use super::host::RemoteHostSpec; use super::ssh::{base_ssh_command, push_destination}; use crate::services::log_redaction::redact_log_line; const BOOTSTRAP_SCRIPT: &str = include_str!("remote_daemon.sh"); -// The remote script can wait 45s for another account-level mutation and spend +// The remote script can wait 120s for another account-level mutation and spend // roughly 80s on five readiness attempts plus cleanup. Keep the outer timeout -// above that bounded lifetime so cancellation cannot strand an unrecorded PID. -const SCRIPT_TIMEOUT: Duration = Duration::from_secs(180); +// above their combined bounded lifetime so cancellation cannot strand an +// unrecorded PID. +const SCRIPT_TIMEOUT: Duration = Duration::from_secs(300); const MAX_STDERR_BYTES: usize = 16 * 1024; #[derive(Clone, Debug, Serialize)] @@ -145,8 +147,18 @@ pub(crate) async fn ensure_daemon( pub(crate) async fn shutdown_daemon( spec: &RemoteHostSpec, shell_env: &HashMap, + expected_instance_token: Option<&str>, ) -> Result<(), RemoteBackendError> { - let output = run_remote_script(spec, shell_env, "shutdown", None, None).await?; + let expected_instance_arg = expected_instance_token + .map(|token| base64::engine::general_purpose::STANDARD.encode(token)); + let output = run_remote_script( + spec, + shell_env, + "shutdown", + expected_instance_arg.as_deref(), + None, + ) + .await?; require_success(&output)?; if output.lines.iter().any(|line| line == "STOPPED") { Ok(()) @@ -367,6 +379,10 @@ pub(crate) fn require_success(output: &ScriptOutput) -> Result<(), RemoteBackend "another Berd client is using this remote account with an incompatible Goose launch configuration" .to_string() } + RemoteBackendErrorKind::DaemonChanged => { + "the remote daemon changed after confirmation; inspect it and try again" + .to_string() + } RemoteBackendErrorKind::AuthFailed => { "ssh authentication failed; make sure key or agent auth works (try `ssh ` in a terminal)" .to_string() @@ -386,11 +402,39 @@ pub(crate) fn require_success(output: &ScriptOutput) -> Result<(), RemoteBackend stderr_tail ), }; - Err(RemoteBackendError::new(kind, message)) + let error = RemoteBackendError::new(kind, message); + if kind == RemoteBackendErrorKind::DaemonConflict { + if let Some(instance) = parse_conflict_instance(&output.lines) { + return Err(error.with_daemon_instance(instance)); + } + } + Err(error) } } } +fn parse_conflict_instance(lines: &[String]) -> Option { + let rest = lines + .iter() + .find_map(|line| line.strip_prefix("CONFLICT "))?; + let mut fields = rest.split_whitespace(); + let pid = fields.next()?.parse().ok()?; + let started_at = fields.next()?.to_string(); + let goose_version = decode_b64_field(fields.next()?).unwrap_or_else(|| "unknown".to_string()); + let binary = decode_b64_field(fields.next()?); + let instance_token = fields.next()?.to_string(); + if fields.next().is_some() { + return None; + } + Some(RemoteDaemonInstance { + pid, + started_at, + goose_version, + binary, + instance_token, + }) +} + fn script_protocol_error(context: &str, output: &ScriptOutput) -> RemoteBackendError { let stderr_tail: String = output .stderr @@ -643,6 +687,26 @@ mod tests { let err = require_success(&output(&["ERR daemon-conflict"], Some(47), "")).unwrap_err(); assert_eq!(err.kind, RemoteBackendErrorKind::DaemonConflict); + + let err = require_success(&output(&["ERR daemon-changed"], Some(48), "")).unwrap_err(); + assert_eq!(err.kind, RemoteBackendErrorKind::DaemonChanged); + } + + #[test] + fn daemon_conflict_carries_exact_instance_metadata() { + let line = format!( + "CONFLICT 4242 1756700000 {} {} opaque-token", + b64("goose 2.0"), + b64("/opt/goose") + ); + let err = + require_success(&output(&[&line, "ERR daemon-conflict"], Some(47), "")).unwrap_err(); + let instance = err.daemon_instance.expect("conflict instance"); + assert_eq!(instance.pid, 4242); + assert_eq!(instance.started_at, "1756700000"); + assert_eq!(instance.goose_version, "goose 2.0"); + assert_eq!(instance.binary.as_deref(), Some("/opt/goose")); + assert_eq!(instance.instance_token, "opaque-token"); } #[test] @@ -794,7 +858,7 @@ if [ "$1" = "serve" ]; then port="" while [ $# -gt 0 ]; do [ "$1" = "--port" ] && port="$2"; shift; done exec python3 -c 'import socket,sys,time -sys.stderr.write("x" * (5 * 1024 * 1024) + "\n"); sys.stderr.flush() +sys.stderr.write("x" * (5 * 1024 * 1024) + "\nBOUNDED-TAIL\n"); sys.stderr.flush() s=socket.socket(); s.setsockopt(socket.SOL_SOCKET,socket.SO_REUSEADDR,1) s.bind(("127.0.0.1",int(sys.argv[1]))); s.listen(5); time.sleep(120)' "$port" fi @@ -806,6 +870,31 @@ fi path } + fn write_no_newline_goose_shim(dir: &std::path::Path) -> std::path::PathBuf { + let path = dir.join("goose-no-newline"); + std::fs::write( + &path, + r#"#!/usr/bin/env bash +if [ "$1" = "--version" ]; then echo "goose no-newline"; exit 0; fi +if [ "$1" = "serve" ]; then + port="" + while [ $# -gt 0 ]; do [ "$1" = "--port" ] && port="$2"; shift; done + exec python3 -c 'import os,socket,sys,time +s=socket.socket(); s.setsockopt(socket.SOL_SOCKET,socket.SO_REUSEADDR,1) +s.bind(("127.0.0.1",int(sys.argv[1]))); s.listen(5) +for _ in range(80): + sys.stderr.write("z" * 65536); sys.stderr.flush(); time.sleep(0.005) +open(os.path.join(os.environ["HOME"],"no-newline-complete"),"w").close() +time.sleep(120)' "$port" +fi +"#, + ) + .unwrap(); + use std::os::unix::fs::PermissionsExt as _; + std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o755)).unwrap(); + path + } + fn read_record_fields(home: &std::path::Path) -> Vec { let record = std::fs::read_to_string( home.join(".state") @@ -1040,6 +1129,48 @@ fi assert_eq!(code, Some(0)); } + #[test] + fn recovers_a_crash_during_lock_owner_publication() { + let home = tempfile::tempdir().unwrap(); + let lock_dir = home.path().join(".state/berd/remote/daemon.lock"); + std::fs::create_dir_all(&lock_dir).unwrap(); + std::fs::write(lock_dir.join(".owner.99999"), "partial owner").unwrap(); + + let (lines, code) = run_script(&["shutdown"], None, home.path()); + + assert_eq!(code, Some(0), "lines: {lines:?}"); + assert!(lines.iter().any(|line| line == "STOPPED")); + assert!(!lock_dir.exists(), "partial lock generation survived"); + } + + #[test] + fn synchronized_reclaimers_do_not_remove_a_successor_lock() { + let home = tempfile::tempdir().unwrap(); + let state_dir = home.path().join(".state/berd/remote"); + let lock_dir = state_dir.join("daemon.lock"); + std::fs::create_dir_all(&lock_dir).unwrap(); + std::fs::write(lock_dir.join("owner"), "999999 invalid-identity").unwrap(); + + let reclaimers = (0..6) + .map(|_| spawn_script(&["shutdown"], None, home.path())) + .collect::>(); + for child in reclaimers { + let (lines, code) = collect_script(child); + assert_eq!(code, Some(0), "lines: {lines:?}"); + } + + assert!(!lock_dir.exists(), "lock remained after all shutdowns"); + let leftovers = std::fs::read_dir(&state_dir) + .unwrap() + .map(|entry| entry.unwrap().file_name().to_string_lossy().into_owned()) + .filter(|name| name.contains("daemon.lock")) + .collect::>(); + assert!( + leftovers.is_empty(), + "lock artifacts remained: {leftovers:?}" + ); + } + #[test] fn terminating_bootstrap_cleans_up_an_uncommitted_daemon() { let home = tempfile::tempdir().unwrap(); @@ -1098,21 +1229,94 @@ fi let log_path = home.path().join(".state/berd/remote/goose-serve.log"); let mut log_len = 0; for _ in 0..100 { - log_len = std::fs::metadata(&log_path) - .map(|metadata| metadata.len()) - .unwrap_or(0); - if log_len > 0 && log_len <= 4 * 1024 * 1024 { + let contents = std::fs::read(&log_path).unwrap_or_default(); + log_len = contents.len() as u64; + assert!( + log_len <= 4 * 1024 * 1024, + "log transiently grew to {log_len} bytes" + ); + if contents.ends_with(b"BOUNDED-TAIL\n") { break; } std::thread::sleep(Duration::from_millis(20)); } assert!(log_len > 0); assert!(log_len <= 4 * 1024 * 1024, "log grew to {log_len} bytes"); + assert!( + std::fs::read(&log_path) + .unwrap_or_default() + .ends_with(b"BOUNDED-TAIL\n"), + "bounded writer did not retain the final record tail" + ); + + let (_, code) = run_script(&["shutdown"], None, home.path()); + assert_eq!(code, Some(0)); + } + + #[test] + fn daemon_log_stays_bounded_while_no_newline_producer_is_open() { + if !python3_available() { + eprintln!("skipping: python3 unavailable for the goose serve shim"); + return; + } + let home = tempfile::tempdir().unwrap(); + let bin = tempfile::tempdir().unwrap(); + let goose = write_no_newline_goose_shim(bin.path()); + let goose_arg = b64_arg(&goose.to_string_lossy()); + + let (lines, code) = run_script(&["ensure", "-", &goose_arg], None, home.path()); + assert_eq!(code, Some(0), "lines: {lines:?}"); + let log_path = home.path().join(".state/berd/remote/goose-serve.log"); + let complete = home.path().join("no-newline-complete"); + for _ in 0..1500 { + let log_len = std::fs::metadata(&log_path) + .map(|metadata| metadata.len()) + .unwrap_or(0); + assert!( + log_len <= 4 * 1024 * 1024, + "no-newline log transiently grew to {log_len} bytes" + ); + if complete.exists() { + break; + } + std::thread::sleep(Duration::from_millis(10)); + } + assert!( + complete.exists(), + "producer did not finish its no-newline burst" + ); + assert!(std::fs::metadata(&log_path).unwrap().len() <= 4 * 1024 * 1024); let (_, code) = run_script(&["shutdown"], None, home.path()); assert_eq!(code, Some(0)); } + #[test] + fn shutdown_refuses_a_changed_daemon_generation() { + if !python3_available() { + eprintln!("skipping: python3 unavailable for the goose serve shim"); + return; + } + let home = tempfile::tempdir().unwrap(); + let bin = tempfile::tempdir().unwrap(); + let goose = write_goose_shim(bin.path(), "goose", "goose 1.0.0"); + let goose_arg = b64_arg(&goose.to_string_lossy()); + let (_, code) = run_script(&["ensure", "-", &goose_arg], None, home.path()); + assert_eq!(code, Some(0)); + let fields = read_record_fields(home.path()); + let expected_token = b64_arg(&format!("{} {}", fields[1], fields[8])); + + let wrong_token_arg = b64_arg("wrong-token"); + let (lines, code) = run_script(&["shutdown", &wrong_token_arg], None, home.path()); + assert_eq!(code, Some(48), "lines: {lines:?}"); + assert!(lines.iter().any(|line| line == "ERR daemon-changed")); + assert_eq!(read_record_fields(home.path()), fields); + + let expected_token_arg = b64_arg(&expected_token); + let (lines, code) = run_script(&["shutdown", &expected_token_arg], None, home.path()); + assert_eq!(code, Some(0), "lines: {lines:?}"); + } + /// A record written before process identities existed cannot prove PID /// ownership, so it is discarded without signaling and a fresh daemon /// is started. diff --git a/src-tauri/src/services/remote_backend/error.rs b/src-tauri/src/services/remote_backend/error.rs index ce56d802b..53151c078 100644 --- a/src-tauri/src/services/remote_backend/error.rs +++ b/src-tauri/src/services/remote_backend/error.rs @@ -7,6 +7,18 @@ use serde::Serialize; +#[derive(Clone, Debug, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct RemoteDaemonInstance { + pub pid: u32, + pub started_at: String, + pub goose_version: String, + pub binary: Option, + /// Opaque generation token required for a conflict takeover. It prevents + /// a delayed confirmation from stopping a replacement daemon. + pub instance_token: String, +} + #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)] #[serde(rename_all = "kebab-case")] pub enum RemoteBackendErrorKind { @@ -17,6 +29,7 @@ pub enum RemoteBackendErrorKind { HostUnreachable, GooseNotInstalled, DaemonConflict, + DaemonChanged, RemotePortBindFailed, LocalPortBindFailed, TunnelClosed, @@ -30,6 +43,8 @@ pub enum RemoteBackendErrorKind { pub struct RemoteBackendError { pub kind: RemoteBackendErrorKind, pub message: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub daemon_instance: Option>, } impl RemoteBackendError { @@ -37,9 +52,15 @@ impl RemoteBackendError { Self { kind, message: message.into(), + daemon_instance: None, } } + pub fn with_daemon_instance(mut self, daemon_instance: RemoteDaemonInstance) -> Self { + self.daemon_instance = Some(Box::new(daemon_instance)); + self + } + pub fn internal(message: impl Into) -> Self { Self::new(RemoteBackendErrorKind::Internal, message) } @@ -114,12 +135,14 @@ pub(crate) fn classify_ssh_stderr(stderr: &str) -> RemoteBackendErrorKind { pub(crate) const EXIT_GOOSE_NOT_FOUND: i32 = 41; pub(crate) const EXIT_REMOTE_PORT_BIND_FAILED: i32 = 43; pub(crate) const EXIT_DAEMON_CONFLICT: i32 = 47; +pub(crate) const EXIT_DAEMON_CHANGED: i32 = 48; pub(crate) fn classify_script_exit(code: i32) -> Option { match code { EXIT_GOOSE_NOT_FOUND => Some(RemoteBackendErrorKind::GooseNotInstalled), EXIT_REMOTE_PORT_BIND_FAILED => Some(RemoteBackendErrorKind::RemotePortBindFailed), EXIT_DAEMON_CONFLICT => Some(RemoteBackendErrorKind::DaemonConflict), + EXIT_DAEMON_CHANGED => Some(RemoteBackendErrorKind::DaemonChanged), _ => None, } } @@ -190,6 +213,10 @@ mod tests { classify_script_exit(EXIT_DAEMON_CONFLICT), Some(RemoteBackendErrorKind::DaemonConflict) ); + assert_eq!( + classify_script_exit(EXIT_DAEMON_CHANGED), + Some(RemoteBackendErrorKind::DaemonChanged) + ); assert_eq!(classify_script_exit(1), None); } } diff --git a/src-tauri/src/services/remote_backend/mod.rs b/src-tauri/src/services/remote_backend/mod.rs index c28d65c3e..324675c22 100644 --- a/src-tauri/src/services/remote_backend/mod.rs +++ b/src-tauri/src/services/remote_backend/mod.rs @@ -97,6 +97,9 @@ pub struct RemoteBackendConnection { pub local_port: u16, pub goose_version: String, pub daemon_reused: bool, + /// Slot generation that owns this tunnel. Callers use it to invalidate + /// only the connection they established, never a newer replacement. + pub generation: u64, } #[derive(Default)] @@ -294,6 +297,7 @@ fn connection_from_shared(shared: &SlotShared) -> Option, +) -> bool { // Normalize through the parser when possible so `damien@devbox:2222` and // its parsed key line up; fall back to the raw string for exact keys. let host_key = RemoteHostSpec::parse(host_input, &ssh_config::load_ssh_config_hosts()) .map(|spec| spec.key()) .unwrap_or_else(|_| host_input.trim().to_string()); let Some(slot) = registry.existing_slot(&host_key) else { - return; + return false; }; { let mut shared = slot.shared.lock().expect("slot poisoned"); + if expected_generation.is_some_and(|expected| shared.generation != expected) { + return false; + } shared.generation += 1; if let Some(pid) = shared.tunnel_pid.take() { kill_tunnel_pid(pid); @@ -615,6 +635,7 @@ pub fn disconnect(app: &AppHandle, registry: &RemoteBackendRegistry, host_input: } set_state(app, &slot, RemoteBackendState::Disconnected); record_diagnostic(DiagnosticLevel::Info, "disconnected", &host_key, None); + true } /// Stop the remote daemon, then drop the tunnel. @@ -622,6 +643,7 @@ pub async fn shutdown( app: &AppHandle, registry: &RemoteBackendRegistry, host_input: &str, + expected_instance_token: Option<&str>, ) -> Result<(), RemoteBackendError> { let aliases = ssh_config::load_ssh_config_hosts(); let spec = RemoteHostSpec::parse(host_input, &aliases)?; @@ -634,7 +656,7 @@ pub async fn shutdown( // daemon after shutdown_daemon stops it. let _guard = slot.connect_lock.lock().await; disconnect(app, registry, &spec.key()); - daemon::shutdown_daemon(&spec, &shell_env).await?; + daemon::shutdown_daemon(&spec, &shell_env, expected_instance_token).await?; record_diagnostic(DiagnosticLevel::Info, "daemon_shutdown", &spec.key(), None); Ok(()) } diff --git a/src-tauri/src/services/remote_backend/remote_daemon.sh b/src-tauri/src/services/remote_backend/remote_daemon.sh index 9aa3a870b..b439a3ee6 100644 --- a/src-tauri/src/services/remote_backend/remote_daemon.sh +++ b/src-tauri/src/services/remote_backend/remote_daemon.sh @@ -28,7 +28,7 @@ # still parse for safe migration, but are never reused. # # Typed exit codes: 41 goose-not-found, 43 port-bind-failed, 44 bad-path, -# 45 no-such-dir, 47 daemon-conflict. +# 45 no-such-dir, 47 daemon-conflict, 48 daemon-changed. set -u NONCE="${1:?nonce required}" @@ -41,9 +41,11 @@ RECORD="$STATE_DIR/daemon.record" LOG="$STATE_DIR/goose-serve.log" LOCK_DIR="$STATE_DIR/daemon.lock" LOCK_OWNER="$LOCK_DIR/owner" +LOCK_RECLAIM_DIR="$STATE_DIR/daemon.lock.reclaim" RECORD_FORMAT="v4" LOG_MAX_BYTES=$((4 * 1024 * 1024)) LOG_RETAIN_BYTES=$((2 * 1024 * 1024)) +LOG_WRITE_CHUNK_BYTES=$((64 * 1024)) emit() { printf '%s %s\n' "$NONCE" "$*"; } @@ -124,6 +126,12 @@ release_daemon_lock() { lock_held=0 } +release_reclaim_lock() { + [ "${reclaim_held:-0}" = "1" ] || return 0 + rmdir "$LOCK_RECLAIM_DIR" 2>/dev/null || true + reclaim_held=0 +} + terminate_uncommitted_daemon() { case "${uncommitted_pid:-}" in '' | *[!0-9]*) ;; @@ -158,6 +166,31 @@ terminate_uncommitted_daemon() { cleanup_daemon_mutation() { terminate_uncommitted_daemon release_daemon_lock + release_reclaim_lock +} + +# Claim one stale lock generation before deleting it. The separate reclaim +# mutex serializes observers: after its final owner re-check, no peer can +# remove the old directory and let a new generation appear before this process +# atomically renames it. Once renamed, cleanup is confined to the claimed path, +# so partial `.owner.*` publications are removed without touching a successor. +reclaim_stale_daemon_lock() { + if ! mkdir "$LOCK_RECLAIM_DIR" 2>/dev/null; then + return 1 + fi + reclaim_held=1 + + if lock_owner_is_current; then + release_reclaim_lock + return 1 + fi + + claimed_lock="$STATE_DIR/.daemon.lock.reclaimed.$$.$lock_attempt" + if mv "$LOCK_DIR" "$claimed_lock" 2>/dev/null; then + rm -rf -- "$claimed_lock" + fi + release_reclaim_lock + return 0 } # `mkdir` is the portable cross-process atomic primitive available on both @@ -171,9 +204,20 @@ acquire_daemon_lock() { } lock_held=0 + reclaim_held=0 + uncommitted_pid="" + uncommitted_logger_pid="" + uncommitted_log_pipe="" + trap cleanup_daemon_mutation EXIT + trap 'exit 129' HUP + trap 'exit 130' INT + trap 'exit 143' TERM stale_observations=0 lock_attempt=0 - while [ "$lock_attempt" -lt 450 ]; do + # A lock holder can spend about 80 seconds across five readiness attempts + # and bounded cleanup. Wait longer than that critical section so a healthy + # concurrent ensure cannot be mistaken for a wedged state-dir operation. + while [ "$lock_attempt" -lt 1200 ]; do lock_attempt=$((lock_attempt + 1)) if mkdir "$LOCK_DIR" 2>/dev/null; then our_identity="$(process_identity "$$")" || { @@ -191,13 +235,6 @@ acquire_daemon_lock() { exit 46 fi lock_held=1 - uncommitted_pid="" - uncommitted_logger_pid="" - uncommitted_log_pipe="" - trap cleanup_daemon_mutation EXIT - trap 'exit 129' HUP - trap 'exit 130' INT - trap 'exit 143' TERM return 0 fi @@ -208,8 +245,7 @@ acquire_daemon_lock() { # Allow the mkdir winner time to publish its owner before treating a # missing/malformed owner as stale after an interrupted invocation. if [ "$stale_observations" -ge 20 ]; then - rm -f "$LOCK_OWNER" - rmdir "$LOCK_DIR" 2>/dev/null || true + reclaim_stale_daemon_lock || true stale_observations=0 fi fi @@ -289,14 +325,45 @@ trim_log_if_needed() { fi } -# Consume the daemon's pipe for its whole lifetime while keeping only a bounded -# tail on disk. Each append reopens the path so atomic trims take effect. +prepare_log_for_append() { + append_log="$1" + append_bytes="$2" + [ -f "$append_log" ] || return 0 + current_bytes="$(wc -c <"$append_log" 2>/dev/null | tr -d ' ')" + case "$current_bytes" in + '' | *[!0-9]*) return 0 ;; + esac + if [ $((current_bytes + append_bytes)) -gt "$LOG_MAX_BYTES" ]; then + log_tmp="$STATE_DIR/.goose-serve.log.$$" + if tail -c "$LOG_RETAIN_BYTES" "$append_log" >"$log_tmp" 2>/dev/null; then + mv -f "$log_tmp" "$append_log" + else + rm -f "$log_tmp" + fi + fi +} + +# Consume fixed-size byte chunks rather than newline records. This bounds shell +# memory for a huge line and continues rotating a producer that never emits a +# newline. Each append reopens the path so atomic trims take effect. bounded_log_writer() { writer_log="$1" - while IFS= read -r writer_line || [ -n "$writer_line" ]; do - printf '%s\n' "$writer_line" >>"$writer_log" - trim_log_if_needed "$writer_log" + writer_chunk="$STATE_DIR/.goose-serve.log.chunk.$$" + while :; do + rm -f "$writer_chunk" + dd bs="$LOG_WRITE_CHUNK_BYTES" count=1 of="$writer_chunk" 2>/dev/null || true + writer_bytes="$(wc -c <"$writer_chunk" 2>/dev/null | tr -d ' ')" + case "$writer_bytes" in + '' | *[!0-9]*) writer_bytes=0 ;; + esac + if [ "$writer_bytes" -eq 0 ]; then + rm -f "$writer_chunk" + break + fi + prepare_log_for_append "$writer_log" "$writer_bytes" + cat "$writer_chunk" >>"$writer_log" done + rm -f "$writer_chunk" } # Confirms the PID is still the exact process that wrote this record. `kill -0` @@ -308,6 +375,10 @@ recorded_process_is_current() { [ "$(b64 "$current_identity")" = "$rec_b64identity" ] } +record_instance_token() { + b64 "$rec_pid $rec_b64identity" +} + # Terminates the pid from the last read_record, TERM then KILL, while rechecking # ownership so a PID recycled during shutdown is never signaled. stop_recorded_daemon() { @@ -342,6 +413,7 @@ ensure_daemon() { if port_listening "$rec_port"; then if [ -n "$rec_b64launchspec" ] && [ "$rec_b64launchspec" != "$b64launchspec" ]; then + emit "CONFLICT $rec_pid $rec_started $rec_b64version $rec_b64binary $(record_instance_token)" emit "ERR daemon-conflict" exit 47 fi @@ -423,6 +495,16 @@ ensure_daemon() { shutdown_daemon() { if [ -f "$RECORD" ] && read_record; then + if [ "$ARG" != "-" ]; then + expected_instance_token="$(unb64 "$ARG")" || { + emit "ERR daemon-changed" + exit 48 + } + if [ "$expected_instance_token" != "$(record_instance_token)" ]; then + emit "ERR daemon-changed" + exit 48 + fi + fi stop_recorded_daemon fi rm -f "$RECORD" diff --git a/src/features/remoteHosts/stores/remoteHostStore.test.ts b/src/features/remoteHosts/stores/remoteHostStore.test.ts index b3bdc35e2..224962fe8 100644 --- a/src/features/remoteHosts/stores/remoteHostStore.test.ts +++ b/src/features/remoteHosts/stores/remoteHostStore.test.ts @@ -41,6 +41,7 @@ const connection: RemoteBackendConnection = { localPort: 4001, gooseVersion: "1.2.3", daemonReused: false, + generation: 1, }; function resetStore(): void { @@ -245,6 +246,19 @@ describe("disconnect and shutdownHost", () => { state: "disconnected", }); }); + + it("passes a conflict generation token to shutdown", async () => { + mocks.shutdownRemoteHost.mockResolvedValue(undefined); + + await useRemoteHostStore + .getState() + .shutdownHost("devbox", "opaque-generation"); + + expect(mocks.shutdownRemoteHost).toHaveBeenCalledWith( + "devbox", + "opaque-generation", + ); + }); }); describe("runDoctor", () => { diff --git a/src/features/remoteHosts/stores/remoteHostStore.ts b/src/features/remoteHosts/stores/remoteHostStore.ts index 0b8cc6396..0f630896e 100644 --- a/src/features/remoteHosts/stores/remoteHostStore.ts +++ b/src/features/remoteHosts/stores/remoteHostStore.ts @@ -135,7 +135,7 @@ export interface RemoteHostStore { applyStatusEvent: (payload: RemoteBackendStatusPayload) => void; ensureHostConnected: (host: string) => Promise; disconnect: (host: string) => Promise; - shutdownHost: (host: string) => Promise; + shutdownHost: (host: string, expectedInstanceToken?: string) => Promise; runDoctor: (host: string) => Promise; recordRecentDir: (host: string, dir: string) => void; removeManualHost: (host: string) => void; @@ -254,8 +254,12 @@ export const useRemoteHostStore = create((set, get) => ({ })); }, - shutdownHost: async (host) => { - await shutdownRemoteHost(host); + shutdownHost: async (host, expectedInstanceToken) => { + if (expectedInstanceToken) { + await shutdownRemoteHost(host, expectedInstanceToken); + } else { + await shutdownRemoteHost(host); + } set((state) => ({ statusByHost: { ...state.statusByHost, diff --git a/src/features/remoteHosts/ui/RemoteHostsSettings.tsx b/src/features/remoteHosts/ui/RemoteHostsSettings.tsx index 900245d5e..e67bbcc8a 100644 --- a/src/features/remoteHosts/ui/RemoteHostsSettings.tsx +++ b/src/features/remoteHosts/ui/RemoteHostsSettings.tsx @@ -179,7 +179,7 @@ function RemoteHostRow({ onRequestShutdown, }: { host: string; - onRequestShutdown: (host: string) => void; + onRequestShutdown: (target: ShutdownTarget) => void; }) { const { t } = useTranslation("settings"); const status = useRemoteHostStore((state) => state.statusByHost[host]); @@ -205,6 +205,11 @@ function RemoteHostRow({ const state = status?.state ?? "disconnected"; const isConnected = state === "ready" || state === "reconnecting"; const failedMessage = state === "failed" ? status?.error?.message : undefined; + const conflictInstance = + status?.error?.kind === "daemon-conflict" + ? status.error.daemonInstance + : undefined; + const isConflict = conflictInstance !== undefined; const showDoctor = doctorPending || doctorError !== undefined || !!probes; return ( @@ -247,15 +252,23 @@ function RemoteHostRow({ : t("remoteHosts.actions.connect")} )} - {isConnected ? ( + {state !== "connecting" ? ( ) : null}