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..424eccd2a --- /dev/null +++ b/src-tauri/src/commands/remote_backend.rs @@ -0,0 +1,79 @@ +//! 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(), + ) +} + +/// `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, goose_path.as_deref()).await +} + +#[tauri::command] +pub async fn remote_backend_disconnect( + app: AppHandle, + registry: State<'_, RemoteBackendRegistry>, + host: String, + expected_generation: Option, +) -> Result<(), RemoteBackendError> { + remote_backend::disconnect_generation(&app, ®istry, &host, expected_generation); + Ok(()) +} + +#[tauri::command] +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, expected_instance_token.as_deref()).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, + goose_path: Option, +) -> Result, RemoteBackendError> { + remote_backend::check_host(&host, goose_path.as_deref()).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..e0b1a49e3 --- /dev/null +++ b/src-tauri/src/services/remote_backend/daemon.rs @@ -0,0 +1,1467 @@ +//! 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, + 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 120s for another account-level mutation and spend +// roughly 80s on five readiness attempts plus cleanup. Keep the outer timeout +// 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)] +#[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, + /// 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)] +#[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, +} + +/// 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 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 + .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, + expected_instance_token: Option<&str>, +) -> Result<(), RemoteBackendError> { + 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(()) + } else { + Err(script_protocol_error( + "remote shutdown finished without confirmation", + &output, + )) + } +} + +pub(crate) async fn check_host( + spec: &RemoteHostSpec, + shell_env: &HashMap, + goose_path: Option<&str>, +) -> Result, RemoteBackendError> { + 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() { + 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), None).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)) +} + +/// 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/args, all of + // which are restricted charsets (uuid, keyword, base64). + let remote_command = remote_command_line(&nonce, mode, arg, goose_arg); + 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::DaemonConflict => { + "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() + } + 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 + ), + }; + 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 + .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); + // `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() +} + +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"), + 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] + 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); + + 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] + 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) { + 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 + .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(); + 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) + .lines() + .filter_map(|line| line.strip_prefix(&prefix).map(str::to_string)) + .collect(); + (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 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) + "\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 +"#, + ) + .unwrap(); + use std::os::unix::fs::PermissionsExt as _; + std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o755)).unwrap(); + 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") + .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(); + // 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")); + } + + /// 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()) + ); + } + + /// 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_an_identical_launch_spec() { + 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], "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()); + 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); + + // 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(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:?}"); + } + + #[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)); + } + + #[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(); + 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 { + 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. + #[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]); + 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(); + 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..53151c078 --- /dev/null +++ b/src-tauri/src/services/remote_backend/error.rs @@ -0,0 +1,222 @@ +//! 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, 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 { + InvalidHost, + SshNotFound, + AuthFailed, + HostKeyUnverified, + HostUnreachable, + GooseNotInstalled, + DaemonConflict, + DaemonChanged, + RemotePortBindFailed, + LocalPortBindFailed, + TunnelClosed, + ReadyTimeout, + RemoteScriptFailed, + Internal, +} + +#[derive(Clone, Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct RemoteBackendError { + pub kind: RemoteBackendErrorKind, + pub message: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub daemon_instance: Option>, +} + +impl RemoteBackendError { + pub fn new(kind: RemoteBackendErrorKind, message: impl Into) -> Self { + 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) + } +} + +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) 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, + } +} + +#[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(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/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..324675c22 --- /dev/null +++ b/src-tauri/src/services/remote_backend/mod.rs @@ -0,0 +1,839 @@ +//! 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. +//! +//! 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. + +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; +// 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; +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, + /// 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)] +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, + /// 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 { + 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, + goose_path: 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 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(), + 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, + generation: shared.generation, + }) + } else { + None + } +} + +/// The slot's cached connection, but only when it was established with the +/// goose binary now being requested. A different override means the bootstrap +/// has to restart the remote daemon, so the cached tunnel is not an answer. +fn cached_connection( + shared: &SlotShared, + requested_goose_path: Option<&str>, +) -> Option { + if shared.goose_path.as_deref() != requested_goose_path { + return None; + } + 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, + 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; + + { + 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); + record_diagnostic(DiagnosticLevel::Info, "connect_start", &slot.key, None); + + 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, + "connect_failed", + &slot.key, + Some(&error.message), + ); + set_state_if_current( + app, + &slot, + expected_generation, + 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`]). +/// `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, RemoteBackendError> { + let shell_env = dir_env::capture_home_interactive_env().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) + })?; + + 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 tunnel_pid = tunnel.child.id(); + let generation = { + let mut shared = slot.shared.lock().expect("slot poisoned"); + advance_generation_if_current(&mut shared, expected_generation).inspect(|_| { + shared.daemon = Some(daemon_info.clone()); + 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); + + 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, + generation, + }; + + spawn_supervisor( + app.clone(), + Arc::clone(slot), + tunnel, + generation, + prior_attempts, + ); + + Ok(Some(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 tunnel_pid = tunnel.child.id(); + let status = tunnel.child.wait().await; + + let is_current = { + 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 + // 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_if_current(&app, &slot, generation, RemoteBackendState::Disconnected); + return; + } + + for attempt in start_attempt..=MAX_RECONNECT_ATTEMPTS { + 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; + + 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, 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, + error.message + ); + last_error = error; + } + } + } + + record_diagnostic( + DiagnosticLevel::Error, + "reconnect_exhausted", + &slot.key, + Some(&last_error.message), + ); + set_state_if_current(&app, &slot, generation, RemoteBackendState::Disconnected); + }); +} + +/// Kill the tunnel; the remote daemon keeps running. +pub fn disconnect(app: &AppHandle, registry: &RemoteBackendRegistry, host_input: &str) { + disconnect_generation(app, registry, host_input, None); +} + +/// Disconnect only when `expected_generation` still owns the host slot. This +/// lets an initializer clean up work superseded while it was awaiting without +/// tearing down a newer connection that won the race. +pub fn disconnect_generation( + app: &AppHandle, + registry: &RemoteBackendRegistry, + host_input: &str, + expected_generation: 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 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); + } + shared.local_port = None; + } + set_state(app, &slot, RemoteBackendState::Disconnected); + record_diagnostic(DiagnosticLevel::Info, "disconnected", &host_key, None); + true +} + +/// Stop the remote daemon, then drop the tunnel. +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)?; + let slot = registry.slot(&spec); + let shell_env = dir_env::capture_home_interactive_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, expected_instance_token).await?; + record_diagnostic(DiagnosticLevel::Info, "daemon_shutdown", &spec.key(), None); + Ok(()) +} + +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, goose_path.as_deref()).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"); + } + + 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 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 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(); + 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..b439a3ee6 --- /dev/null +++ b/src-tauri/src/services/remote_backend/remote_daemon.sh @@ -0,0 +1,587 @@ +#!/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. +# +# 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 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): +# 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, 47 daemon-conflict, 48 daemon-changed. +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" +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" "$*"; } + +b64() { printf %s "$1" | base64 | tr -d '\n'; } +# `-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 +} + +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 +} + +# 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" +} + +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 +} + +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]*) ;; + *) + 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 + 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 +# 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 + 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 + # 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 "$$")" || { + 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 + 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 + reclaim_stale_daemon_lock || true + stale_observations=0 + fi + fi + sleep 0.1 + done + + emit "ERR state-dir" + exit 46 +} + +# 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 f9 <"$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:-}" + 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:-}" + rec_port="${f3:-}" + rec_secret="${f4:-}" + rec_b64version="${f5:--}" + rec_started="${f6:-0}" + rec_b64binary="${f7:-}" + rec_b64launchspec="" + rec_b64identity="" + else + # 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_b64launchspec="" + rec_b64identity="" + fi + case "$rec_pid" in + '' | *[!0-9]*) return 1 ;; + esac + case "$rec_port" in + '' | *[!0-9]*) return 1 ;; + esac + [ -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 +} + +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" + 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` +# 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" ] +} + +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() { + if recorded_process_is_current; then + kill -TERM "$rec_pid" 2>/dev/null || true + i=0 + while [ "$i" -lt 30 ] && recorded_process_is_current; do + sleep 0.1 + i=$((i + 1)) + done + if recorded_process_is_current; then + kill -KILL "$rec_pid" 2>/dev/null || true + fi + fi +} + +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"; 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 + 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 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 + + secret="berd-remote-$(head -c 16 /dev/urandom | od -An -tx1 | tr -d ' \n')" + + attempt=0 + while [ "$attempt" -lt 5 ]; do + attempt=$((attempt + 1)) + port=$(((RANDOM % 40000) + 20000)) + if port_listening "$port"; then + continue + fi + 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_pipe" 2>&1 /dev/null; then + break + fi + if port_listening "$port"; then + started="$(date +%s)" + identity="$(process_identity "$pid")" + if [ -z "$identity" ]; then + terminate_uncommitted_daemon + break + fi + record_tmp="$STATE_DIR/.daemon.record.$$" + 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" + 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 + terminate_uncommitted_daemon + done + emit "ERR port-bind-failed" + exit 43 +} + +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" + emit "STOPPED" +} + +check_host() { + 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 - -" + fi + for tool in claude-agent-acp codex-acp; do + 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 - -" + fi + done + emit "CHECK-DONE" +} + +list_dir() { + if [ "$ARG" = "-" ]; then + emit "ERR bad-path" + exit 44 + fi + target="$(expand_home "$(unb64 "$ARG")")" + 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) + acquire_daemon_lock + ensure_daemon + ;; +shutdown) + acquire_daemon_lock + 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..64ec7f163 --- /dev/null +++ b/src-tauri/src/services/remote_backend/tunnel.rs @@ -0,0 +1,189 @@ +//! 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>, +} + +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, + 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() { + append_to_bounded_tail(&mut tail, &redacted, 8 * 1024); + } + } + }); + } + + 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], "--"); + } + + #[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/app/AppShell.tsx b/src/app/AppShell.tsx index 173e720c1..ea701fc71 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 { useRemoteSessionExperimentReconciliation } from "@/features/chat/hooks/useRemoteSessionExperimentReconciliation"; import { useCompletionNotifications } from "@/shared/hooks/useCompletionNotifications"; import { useHomeSessionStateSync } from "./hooks/useHomeSessionStateSync"; import { useHomeWidgetStore } from "@/features/home/stores/homeWidgetStore"; @@ -219,6 +220,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, @@ -721,6 +726,7 @@ export function AppShell({ const initialActiveView = getInitialAppView(initialSettingsSection); const [activeView, setActiveView] = useState(initialActiveView); const capabilities = useProfileCapabilities(); + useRemoteSessionExperimentReconciliation(); const isAutomationsFeatureEnabled = capabilities.automations; const isBuilderbotSurfaceEnabled = capabilities.builderbot; const isFeedbackEnabled = capabilities.feedback; @@ -1856,12 +1862,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 +2100,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 +2193,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 +2262,28 @@ export function AppShell({ reuseExistingDraft?: boolean; executionTarget?: SessionExecutionTarget; reasoningEffort?: GlobalComposeOptions["reasoningEffort"]; + /** SSH host to run the session's backend on. */ + 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.", + ); + } + // 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=${project?.id ?? "none"})`, + `[perf:newtab] createNewTab start (project=${sessionProject?.id ?? "none"})`, ); const sessionExecutionTarget = await resolveSessionCreationTarget(options); @@ -2266,36 +2300,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 +2352,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 +2375,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 +2586,23 @@ 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.", + ); + } + // 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=${project?.id ?? "none"})`, + `[perf:newtab] createBackgroundDraftChat start (project=${sessionProject?.id ?? "none"})`, ); const sessionExecutionTarget = await resolveSessionCreationTarget(options); @@ -2547,28 +2617,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 +2656,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, @@ -2949,7 +3032,10 @@ export function AppShell({ 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 = @@ -2971,6 +3057,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; @@ -3017,9 +3105,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; @@ -3206,19 +3298,24 @@ export function AppShell({ const chatOptions = { executionTarget: options?.executionTarget, reasoningEffort: options?.reasoningEffort, + remoteHost: options?.remoteHost, + remoteWorkingDir: options?.remoteWorkingDir, }; const shouldDismissCenteredComposer = 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; } @@ -3309,6 +3406,8 @@ export function AppShell({ reuseExistingDraft: false, executionTarget: options?.executionTarget, reasoningEffort: options?.reasoningEffort, + remoteHost: options?.remoteHost, + remoteWorkingDir: options?.remoteWorkingDir, }; const createAndStart = async () => { 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 1efe4a34d..c58b9a9ae 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,20 @@ 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. 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( + "@/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..cadeb606a 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 ?? @@ -325,6 +339,7 @@ export function ConversationComposerCapability({ } attachmentDropTargetRef={attachmentDropTargetRef} onAttachmentDragOverChange={onAttachmentDragOverChange} + attachmentsEnabled={!controller.selectedRemoteHost} initialValue={controller.draftValue} initialAttachments={controller.draftAttachments} onDraftChange={controller.handleDraftChange} @@ -368,6 +383,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..bf3ece7e9 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,189 @@ 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("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", + 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..47d95e529 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"; @@ -109,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 { @@ -371,6 +381,9 @@ export function useChatSessionController({ const catalogLoaded = useProviderCatalogStore((s) => s.loaded); const [pendingPersonaId, setPendingPersonaId] = useState(); const [pendingProjectId, setPendingProjectId] = useState(); + const [pendingRemoteHost, setPendingRemoteHost] = useState(); + const remoteSendInFlightRef = useRef(false); + const [pendingRemoteDir, setPendingRemoteDir] = useState(); const [pendingExecutionTarget, setPendingExecutionTarget] = useState(); const [pendingModelSelection, setPendingModelSelection] = @@ -401,6 +414,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 +782,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 +816,8 @@ export function useChatSessionController({ if (!sessionId) { return false; } - const workingDir = await resolveSessionCwd( + const workingDir = await resolveCwdForSession( + sessionId, nextProject, nextWorkspacePath, ); @@ -785,7 +840,7 @@ export function useChatSessionController({ return true; }, - [project, sessionId, sessionWorkspacePath], + [project, sessionId, sessionWorkspacePath, resolveCwdForSession], ); const prepareCurrentSessionTarget = useCallback( async ( @@ -816,7 +871,8 @@ export function useChatSessionController({ targetToApply.target, ).providerId; if (!wireProviderId) return false; - const workingDir = await resolveSessionCwd( + const workingDir = await resolveCwdForSession( + sessionId, nextProject, nextWorkspacePath, ); @@ -870,7 +926,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 +966,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 +1012,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 +1040,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 +1134,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 +1220,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 +1266,7 @@ export function useChatSessionController({ session?.reasoningEffort, session?.workingDir, sessionId, + resolveCwdForSession, ]); const handlePickerOpenWithReasoningRefresh = useCallback(() => { @@ -1247,7 +1319,8 @@ export function useChatSessionController({ ?.executionTarget, sessionSelection, ); - const workingDir = await resolveSessionCwd( + const workingDir = await resolveCwdForSession( + stateSessionId, project, preparationWorkspacePath, ); @@ -1271,7 +1344,12 @@ export function useChatSessionController({ preparationWorkspacePath, ); }, - [prepareCurrentSessionTarget, project, stateSessionId], + [ + prepareCurrentSessionTarget, + project, + stateSessionId, + resolveCwdForSession, + ], ); const supportsSteering = selectedAgentId === STEERING_SUPPORTED_AGENT_ID; @@ -1429,6 +1507,28 @@ 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) { + const chatStore = useChatStore.getState(); + chatStore.setDraftAttachments( + stateSessionId, + remoteSafeAttachments( + chatStore.draftAttachmentsBySession[stateSessionId], + ) ?? [], + ); + } + }, + [stateSessionId], + ); + + const handleRemoteDirChange = useCallback((dir: string | null) => { + setPendingRemoteDir(dir); + }, []); + const handlePersonaChange = useCallback( (personaId: string | null) => { if (personaId === selectedPersonaId) { @@ -2511,6 +2611,72 @@ 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; + } + // 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), + attachments: remoteSafeAttachments(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), + // The project association is local metadata (grouping, sidebar); + // the workspace itself lives on the remote host. + projectId: effectiveProjectId ?? undefined, + workingDir: remoteDir, + remoteHost, + }); + const firstSend = acceptFirstSend(created.id, payload, { + queueReady: true, + }); + if (!firstSend.accepted) { + return false; + } + setPendingRemoteHost(undefined); + setPendingRemoteDir(undefined); + 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; + } finally { + remoteSendInFlightRef.current = false; + } + })(); + } if (!sessionId) { if (readOnly) { return false; @@ -2721,18 +2887,24 @@ export function useChatSessionController({ handlePersonaChange, onMessageAccepted, onWorkspaceNameRequest, + pendingExecutionTarget, + pendingRemoteDir, + pendingRemoteHost, preselectedWorkspaceStartupName, queue, readOnly, recordDraftPreservingSubmission, + remoteHostSelectionEnabled, session?.agentBuilderOpen, session?.creationState, + session?.executionTarget, session?.intent, sessionId, selectedPersona, selectedPersonaId, stateSessionId, workspaceContextReady, + effectiveProjectId, ], ); @@ -3393,5 +3565,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/hooks/useRemoteSessionExperimentReconciliation.test.tsx b/src/features/chat/hooks/useRemoteSessionExperimentReconciliation.test.tsx new file mode 100644 index 000000000..ac70a8491 --- /dev/null +++ b/src/features/chat/hooks/useRemoteSessionExperimentReconciliation.test.tsx @@ -0,0 +1,44 @@ +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 }), + subscribeToExperimentChanges: () => () => {}, +})); +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..a9053ff4b --- /dev/null +++ b/src/features/chat/hooks/useRemoteSessionExperimentReconciliation.ts @@ -0,0 +1,37 @@ +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(); + 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" + ); + 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__/acpSessionMapping.remoteHost.test.ts b/src/features/chat/lib/__tests__/acpSessionMapping.remoteHost.test.ts new file mode 100644 index 000000000..c04b6d437 --- /dev/null +++ b/src/features/chat/lib/__tests__/acpSessionMapping.remoteHost.test.ts @@ -0,0 +1,111 @@ +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("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" }), + ); + + 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__/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/__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..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[]; @@ -17,12 +21,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,12 +47,21 @@ 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, 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", @@ -49,6 +70,9 @@ export function chatSessionFromAcpInfo(session: AcpSessionInfo): ChatSession { executionTargetSource: executionTarget ? "acp" : undefined, personaId: session.personaId ?? undefined, workingDir: session.workingDir ?? undefined, + // 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, @@ -96,6 +120,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 +131,7 @@ function mergeSessionMetadata( executionTarget, executionTargetSource, personaId, + remoteHost, workspaceAttachments: existing?.workspaceAttachments ?? session.workspaceAttachments, activeWorkspaceId: @@ -134,10 +162,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 +207,7 @@ export function mergeAcpSessionPage( state: Pick, page: AcpSessionsPage, previousCursor: string | null, + context?: AcpSessionMappingContext, ): SessionPageState { const { nextCursor } = page; const repeatedCursor = @@ -192,7 +222,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/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/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/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/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/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/stores/__tests__/chatSessionStore.remoteSessions.test.ts b/src/features/chat/stores/__tests__/chatSessionStore.remoteSessions.test.ts new file mode 100644 index 000000000..e1c4f8ac0 --- /dev/null +++ b/src/features/chat/stores/__tests__/chatSessionStore.remoteSessions.test.ts @@ -0,0 +1,260 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { + REMOTE_SESSIONS_STORAGE_KEY, + readRemoteSessionRecords, + rehydrateRemoteSessions, +} 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("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({ + 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(); + }); + + 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 new file mode 100644 index 000000000..6a3a651f3 --- /dev/null +++ b/src/features/chat/stores/__tests__/remoteSessionPersistence.test.ts @@ -0,0 +1,319 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { + REMOTE_SESSIONS_STORAGE_KEY, + persistRemoteSessionRecord, + readRemoteSessionRecords, + reconcileRemoteSessionsForExperiment, + 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(), + invalidateBackendConnection: vi.fn().mockResolvedValue(undefined), + disconnectRemoteHost: vi.fn().mockResolvedValue(undefined), +})); + +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), +})); + +vi.mock("@/shared/api/remoteHosts", () => ({ + disconnectRemoteHost: (...args: unknown[]) => + mocks.disconnectRemoteHost(...args), +})); + +vi.mock("@/shared/api/acpConnection", () => ({ + invalidateBackendConnection: (...args: unknown[]) => + mocks.invalidateBackendConnection(...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("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" })); + + 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({ sessionId: "ssh:devbox#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", + title: "Remote chat", + remoteHost: "devbox", + workingDir: "/remote/home/damien/project", + 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("rehydrates archived records into session history", async () => { + const archivedAt = "2026-08-02T00:00:00.000Z"; + persistRemoteSessionRecord(makeRecord({ archivedAt })); + + await rehydrateRemoteSessions(); + + expect(mocks.registerSessionBackend).toHaveBeenCalledWith( + "ssh:devbox#remote-1", + "ssh:devbox", + "remote-1", + ); + expect( + useChatSessionStore.getState().getSession("ssh:devbox#remote-1"), + ).toMatchObject({ archivedAt, remoteHost: "devbox" }); + }); + + it("does not clobber a session already in the store", async () => { + persistRemoteSessionRecord( + makeRecord({ + sessionId: "ssh:devbox#remote-1", + title: "Stale title", + }), + ); + useChatSessionStore.setState({ + sessions: [ + { + id: "ssh:devbox#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( + "ssh:devbox#remote-1", + "ssh:devbox", + "remote-1", + ); + expect( + useChatSessionStore.getState().getSession("ssh:devbox#remote-1"), + ).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(mocks.invalidateBackendConnection).toHaveBeenCalledWith( + "ssh:devbox", + ); + 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/chatSessionStore.ts b/src/features/chat/stores/chatSessionStore.ts index 811c297bd..8406673cb 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,19 @@ 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.projectId ? { projectId: session.projectId } : {}), + ...(session.archivedAt ? { archivedAt: session.archivedAt } : {}), + }); +} + function persistWorkspaceMetadataForSession(session: ChatSession): void { persistChatWorkspaceMetadata(session.id, { workspaceAttachments: session.workspaceAttachments ?? [], @@ -521,6 +546,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 +573,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 +594,7 @@ export const useChatSessionStore = create((set, get) => ({ hasReasoningEffort: Boolean(chatSession.reasoningEffort), }); persistWorkspaceMetadataForSession(chatSession); + persistRemoteSessionRecordForSession(chatSession); return chatSession; }, @@ -585,6 +615,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 +630,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 +748,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 +870,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 +947,16 @@ export const useChatSessionStore = create((set, get) => ({ ) { sessionForWorkspacePersistence = merged; } + if ( + merged.remoteHost && + (merged.title !== existing.title || + merged.workingDir !== existing.workingDir || + merged.projectId !== existing.projectId || + merged.archivedAt !== existing.archivedAt || + merged.updatedAt !== existing.updatedAt) + ) { + sessionForRemotePersistence = merged; + } return { sessions: state.sessions.map((session) => session.id === id ? merged : session, @@ -909,6 +966,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 @@ -925,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, @@ -932,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: @@ -942,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) => { @@ -972,6 +1043,7 @@ export const useChatSessionStore = create((set, get) => ({ }; }); removePersistedChatWorkspaceMetadata(id); + removeRemoteSessionRecord(id); useSecurityConfirmationStore.getState().cancelAll(id); releaseWindowedSession(id); }, @@ -1012,6 +1084,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 +1128,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..a8d4ad011 --- /dev/null +++ b/src/features/chat/stores/remoteSessionPersistence.ts @@ -0,0 +1,270 @@ +import { + compositeSessionId, + splitCompositeSessionId, + sshBackendId, +} from "@/shared/api/acpBackendId"; +import { + registerSessionBackend, + unregisterSessionBackend, +} 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 { + /** + * 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; +} + +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 projectId = trimmedString(raw.projectId); + if (projectId) { + record.projectId = projectId; + } + 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 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(); + if (records.length === 0) return; + + const { useChatSessionStore } = await import( + "@/features/chat/stores/chatSessionStore" + ); + const store = useChatSessionStore.getState(); + for (const record of records) { + 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: sessionId, + title: record.title || record.host, + remoteHost: record.host, + projectId: record.projectId, + workingDir: record.workingDir || undefined, + 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, + }); + } +} + +/** + * 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 { invalidateBackendConnection } = await import( + "@/shared/api/acpConnection" + ); + const { disconnectRemoteHost } = await import("@/shared/api/remoteHosts"); + await Promise.allSettled( + [...remoteHosts].flatMap((host) => [ + invalidateBackendConnection(sshBackendId(host)), + disconnectRemoteHost(host), + ]), + ); +} 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..62cf71e43 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; + // 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, @@ -314,14 +329,16 @@ export function ChatInputToolbar({ onAttachFiles?.()} - disabled={disabled} + disabled={disabled || Boolean(selectedRemoteHost)} > {t("toolbar.attachFile")} onAttachFolders?.()} - disabled={disabled} + // Local path attachments are meaningless for a session that + // runs its backend on a remote host. + disabled={disabled || Boolean(selectedRemoteHost)} > {t("toolbar.attachFolder")} @@ -368,6 +385,31 @@ export function ChatInputToolbar({ triggerIconOnly={isCompact} /> ) : null} + + {remoteHostPickerEnabled || selectedRemoteHost ? ( + + ) : null} + + {remoteHostPickerEnabled && selectedRemoteHost ? ( + + ) : null} {/* Right side: actions */} diff --git a/src/features/chat/ui/ChatView.tsx b/src/features/chat/ui/ChatView.tsx index b1ffe2f9e..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 { @@ -37,6 +38,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 +202,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 +419,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 +545,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 +558,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 +603,9 @@ export function ChatView({ }, ); const onTimelineChangeFolder = - !isReadOnly && changeFolderSessionId ? handleChangeFolder : undefined; + !isReadOnly && !sessionIsRemote && changeFolderSessionId + ? handleChangeFolder + : undefined; const shouldShowLoadingIndicator = !controller.isLoadingHistory && @@ -674,6 +692,14 @@ export function ChatView({ composerHandoffActive && "invisible pointer-events-none", )} > + {sessionIsRemote && + effectiveSession?.remoteHost && + !effectiveSession.creationState ? ( + + ) : null} @@ -939,14 +967,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..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 { getClient } from "@/shared/api/acpConnection"; +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"; @@ -380,9 +383,9 @@ 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, + sessionId: getWireSessionId(payload.sessionId), name: `${payload.tool.extensionName}__${name}`, arguments: args ?? {}, }); @@ -409,9 +412,9 @@ 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, + sessionId: getWireSessionId(payload.sessionId), uri, extensionName: payload.tool.extensionName, }); 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/RemoteHostConnectionBanner.tsx b/src/features/chat/ui/RemoteHostConnectionBanner.tsx new file mode 100644 index 000000000..18b5da75b --- /dev/null +++ b/src/features/chat/ui/RemoteHostConnectionBanner.tsx @@ -0,0 +1,108 @@ +import { useCallback, useState } from "react"; +import { Globe2, Loader2 } from "lucide-react"; +import { useTranslation } from "react-i18next"; +import { useRemoteHostStore } from "@/features/remoteHosts/stores/remoteHostStore"; +import { cn } from "@/shared/lib/cn"; +import { Button } from "@/shared/ui/button"; + +interface RemoteHostConnectionBannerProps { + host: string; + /** Session to reload once the tunnel is back, so the transcript resyncs. */ + sessionId: string | null; +} + +/** + * Connection status for the active remote chat, shown above the composer. + * A host with no status entry yet (fresh app run, rehydrated session) is + * effectively disconnected: nothing has dialed the tunnel this run. + * Renders nothing while the host is ready. + */ +export function RemoteHostConnectionBanner({ + host, + sessionId, +}: RemoteHostConnectionBannerProps) { + const { t } = useTranslation("chat"); + const status = useRemoteHostStore((store) => 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/RemoteHostSelector.tsx b/src/features/chat/ui/RemoteHostSelector.tsx new file mode 100644 index 000000000..5b7043d15 --- /dev/null +++ b/src/features/chat/ui/RemoteHostSelector.tsx @@ -0,0 +1,128 @@ +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 manualHosts = useRemoteHostStore((state) => state.manualHosts); + const statusByHost = useRemoteHostStore((state) => state.statusByHost); + 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 = + 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__/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(); 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..2caf063d8 100644 --- a/src/features/chat/ui/__tests__/McpAppView.test.tsx +++ b/src/features/chat/ui/__tests__/McpAppView.test.tsx @@ -60,8 +60,9 @@ vi.mock("@mcp-ui/client", () => ({ }, })); -vi.mock("@/shared/api/acpConnection", () => ({ - getClient: mocks.getClient, +vi.mock("@/shared/api/acpSessionBackends", () => ({ + getClientForSession: mocks.getClient, + getWireSessionId: (sessionId: string) => sessionId, })); 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__/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/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/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/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/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 new file mode 100644 index 000000000..224962fe8 --- /dev/null +++ b/src/features/remoteHosts/stores/remoteHostStore.test.ts @@ -0,0 +1,536 @@ +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 { + getGoosePathForHost, + loadPersistedGoosePaths, + REMOTE_HOST_GOOSE_PATH_STORAGE_KEY, +} from "@/features/remoteHosts/lib/gooseBinaryOverride"; +import { + initRemoteHostStore, + loadPersistedManualHosts, + loadPersistedRecentDirs, + REMOTE_HOST_MANUAL_HOSTS_STORAGE_KEY, + 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, + generation: 1, +}; + +function resetStore(): void { + useRemoteHostStore.setState({ + configHosts: [], + manualHosts: [], + statusByHost: {}, + doctorByHost: {}, + doctorPendingByHost: {}, + doctorErrorByHost: {}, + recentDirsByHost: {}, + goosePathByHost: {}, + }); +} + +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", + }); + }); + + 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", () => { + 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("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(); + 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); + }); +}); + +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 new file mode 100644 index 000000000..0f630896e --- /dev/null +++ b/src/features/remoteHosts/stores/remoteHostStore.ts @@ -0,0 +1,382 @@ +import { create } from "zustand"; +import { + isValidGoosePath, + loadPersistedGoosePaths, + persistGoosePaths, +} from "@/features/remoteHosts/lib/gooseBinaryOverride"; +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"; +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; + 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 + } +} + +/** 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; + doctorErrorByHost: Record; + recentDirsByHost: Record; + /** Per-host goose binary override; absent means the remote login PATH. */ + goosePathByHost: Record; + + // Actions + refreshConfigHosts: () => Promise; + syncBackendSnapshot: () => Promise; + applyStatusEvent: (payload: RemoteBackendStatusPayload) => void; + ensureHostConnected: (host: string) => Promise; + disconnect: (host: string) => Promise; + shutdownHost: (host: string, expectedInstanceToken?: string) => Promise; + 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) => ({ + configHosts: [], + manualHosts: loadPersistedManualHosts(), + statusByHost: {}, + doctorByHost: {}, + doctorPendingByHost: {}, + doctorErrorByHost: {}, + recentDirsByHost: loadPersistedRecentDirs(), + goosePathByHost: loadPersistedGoosePaths(), + + 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) => { + // 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: { + ...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, expectedInstanceToken) => { + if (expectedInstanceToken) { + await shutdownRemoteHost(host, expectedInstanceToken); + } else { + 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 }, + })); + } + }, + + removeManualHost: (host) => { + set((state) => { + if (!state.manualHosts.includes(host)) return state; + const manualHosts = state.manualHosts.filter( + (candidate) => candidate !== host, + ); + persistManualHosts(manualHosts); + return { manualHosts }; + }); + }, + + 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(); + 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); +} + +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. + * 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..e67bbcc8a --- /dev/null +++ b/src/features/remoteHosts/ui/RemoteHostsSettings.tsx @@ -0,0 +1,499 @@ +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"; +import { + ensureRemoteHostStoreInitialized, + 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"; + +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")} + + {/* Which binary actually answered: confirms an override took. */} + {probe.path ? ( + {probe.path} + ) : null} +
  • + ))} +
+ {gooseMissing ? ( +

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

+ ) : null} +
+ ); +} + +/** + * 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, +}: { + host: string; + onRequestShutdown: (target: ShutdownTarget) => 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 isManualHost = useRemoteHostStore((state) => + state.manualHosts.includes(host), + ); + const removeManualHost = useRemoteHostStore( + (state) => state.removeManualHost, + ); + + 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 ( + + {host} + + + } + description={failedMessage} + descriptionClassName="text-destructive" + action={ +
+ {isConnected ? ( + + ) : ( + + )} + {state !== "connecting" ? ( + + ) : null} + + {isManualHost && !isConnected ? ( + + ) : null} +
+ } + details={ +
+ {showDoctor ? ( + + ) : null} + {conflictInstance ? ( +

+ {t("remoteHosts.conflict.instance", { + pid: conflictInstance.pid, + version: conflictInstance.gooseVersion, + binary: + conflictInstance.binary ?? + t("remoteHosts.conflict.pathUnknown"), + })} +

+ ) : null} + +
+ } + /> + ); +} + +/** + * 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 manualHosts = useRemoteHostStore((state) => state.manualHosts); + 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; + // 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(); + } + }, [enabled]); + + // 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 = [...manualHosts, ...Object.keys(statusByHost).sort()].filter( + (host, index, all) => + !configHosts.includes(host) && all.indexOf(host) === index, + ); + return [...configHosts, ...extra]; + }, [configHosts, manualHosts, 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?.host ?? "", + })} + description={t( + shutdownTarget?.reconnect + ? "remoteHosts.takeoverConfirm.description" + : "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 { + if (shutdownTarget.instanceToken) { + await shutdownHost( + shutdownTarget.host, + shutdownTarget.instanceToken, + ); + } else { + await shutdownHost(shutdownTarget.host); + } + if (shutdownTarget.reconnect) { + await ensureHostConnected(shutdownTarget.host); + } + setShutdownTarget(null); + } finally { + setShutdownPending(false); + } + }} + onConfirmError={() => { + // The store keeps the previous status; the row remains actionable. + }} + /> +
+ ); +} + +interface ShutdownTarget { + host: string; + reconnect: boolean; + instanceToken?: string; +} 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..651f42365 --- /dev/null +++ b/src/features/remoteHosts/ui/__tests__/RemoteHostsSettings.test.tsx @@ -0,0 +1,372 @@ +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 () => {}); +const setGoosePath = vi.fn((_host: string, _path: string | null) => true); + +function seedStore(overrides?: Partial>) { + useRemoteHostStore.setState({ ...baseState(), ...overrides }); +} + +function baseState() { + return { + configHosts: [] as string[], + statusByHost: {}, + doctorByHost: {}, + doctorPendingByHost: {}, + doctorErrorByHost: {}, + recentDirsByHost: {}, + goosePathByHost: {} as Record, + ensureHostConnected, + disconnect, + shutdownHost, + runDoctor, + refreshConfigHosts, + syncBackendSnapshot, + setGoosePath, + }; +} + +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("offers a confirmed stop for a persisted disconnected daemon", async () => { + const user = userEvent.setup(); + seedStore({ configHosts: ["alpha"] }); + renderWithProviders(); + + await user.click( + screen.getByRole("button", { + name: enSettings.remoteHosts.actions.shutdown, + }), + ); + await user.click( + await screen.findByRole("button", { + name: enSettings.remoteHosts.shutdownConfirm.confirm, + }), + ); + + expect(shutdownHost).toHaveBeenCalledWith("alpha"); + }); + + it("stops the exact conflicting daemon generation before reconnecting", async () => { + const user = userEvent.setup(); + seedStore({ + configHosts: ["alpha"], + statusByHost: { + alpha: { + state: "failed", + error: { + kind: "daemon-conflict", + message: "incompatible daemon", + daemonInstance: { + pid: 4242, + startedAt: "1756700000", + gooseVersion: "goose 2.0", + binary: "/opt/goose", + instanceToken: "opaque-generation", + }, + }, + }, + }, + }); + renderWithProviders(); + + expect( + screen.getByText(/PID 4242, goose 2\.0, \/opt\/goose/), + ).toBeInTheDocument(); + await user.click( + screen.getByRole("button", { + name: enSettings.remoteHosts.actions.takeover, + }), + ); + expect(shutdownHost).not.toHaveBeenCalled(); + await user.click( + await screen.findByRole("button", { + name: enSettings.remoteHosts.shutdownConfirm.confirm, + }), + ); + + expect(shutdownHost).toHaveBeenCalledWith("alpha", "opaque-generation"); + expect(ensureHostConnected).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(); + }); + + 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(); + 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/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..b279862ee 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, Globe2, MoreHorizontal } from "lucide-react"; import { IconCheck, IconGitBranch } from "@tabler/icons-react"; import { useTranslation } from "react-i18next"; import { @@ -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, @@ -137,6 +138,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 +197,7 @@ export function SidebarChatRow({ id, title, branchName, + remoteHost, activityAt, showTimestamp = true, isActive, @@ -241,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 = @@ -476,6 +484,31 @@ export function SidebarChatRow({ aria-pressed={selectionEnabled ? selected : undefined} > {rowTitleContent} + {remoteHost ? ( + + + ) : null} {isMultiWindowEnabled && isOpenInWindow ? ( + + , + ); +} + 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 +84,36 @@ describe("project unread dot", () => { ).toHaveLength(1); }); }); + +describe("remote project identity", () => { + it("marks the remote chat disconnected when its host has no live tunnel", () => { + const { container } = renderRemoteSection(); + + expect(container.querySelector("[data-sidebar-project-remote]")).toBeNull(); + 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/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/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(() => ({ 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..099ec9d90 100644 --- a/src/shared/api/__tests__/acpApi.test.ts +++ b/src/shared/api/__tests__/acpApi.test.ts @@ -9,6 +9,9 @@ const mocks = vi.hoisted(() => ({ newSession: vi.fn(), setSessionConfigOption: vi.fn(), extMethod: vi.fn(), + clientPrompt: vi.fn(), + clientLoadSession: vi.fn(), + clientCancel: vi.fn(), })); const includeLastMessageSnippetMeta = { @@ -56,6 +59,7 @@ function createConfigOptionsResponse() { vi.mock("../acpConnection", () => ({ getClient: (...args: unknown[]) => mocks.getClient(...args), + getBackendClient: (...args: unknown[]) => mocks.getClient(...args), interceptSessionNotifications: (...args: unknown[]) => mocks.interceptSessionNotifications(...args), })); @@ -928,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 new file mode 100644 index 000000000..d479ebb5d --- /dev/null +++ b/src/shared/api/__tests__/acpBackends.test.ts @@ -0,0 +1,552 @@ +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"; + +interface FakeClient { + initialize: ReturnType; + closed: Promise; + resolveClosed: () => void; +} + +const mocks = vi.hoisted(() => ({ + invoke: vi.fn(), + connectRemoteHost: vi.fn(), + disconnectRemoteHost: vi.fn(), + createWebSocketStream: vi.fn(), + clientCallbackFactories: [] as Array<() => Client>, +})); + +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), + disconnectRemoteHost: (...args: unknown[]) => + mocks.disconnectRemoteHost(...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: () => Client, _stream: unknown) { + mocks.clientCallbackFactories.push(callbacks); + 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(); + 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({ + wsUrl: "ws://remote", + httpBaseUrl: "http://remote", + secretKey: "secret", + localPort: 4242, + generation: 1, + }); + mocks.disconnectRemoteHost.mockResolvedValue(undefined); + 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", + ); + }); + + 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", () => { + 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("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("disposes a remote setup that resolves after experiment invalidation", async () => { + let resolveRemote!: (value: { + wsUrl: string; + httpBaseUrl: string; + secretKey: string; + localPort: number; + generation: number; + }) => void; + mocks.connectRemoteHost.mockReturnValueOnce( + new Promise((resolve) => { + resolveRemote = resolve; + }), + ); + const conn = await importConnection(); + const remoteConnection = conn.getBackendConnection("ssh:dev-box"); + const pendingClient = remoteConnection.getClient(); + + window.localStorage.setItem( + "goose:experimental-features", + JSON.stringify({ + version: 2, + experiments: { "remote-ssh-sessions": { enabled: false } }, + }), + ); + window.dispatchEvent(new Event("goose:experimental-features-change")); + resolveRemote({ + wsUrl: "ws://stale-remote", + httpBaseUrl: "http://stale-remote", + secretKey: "stale", + localPort: 4343, + generation: 7, + }); + + await expect(pendingClient).rejects.toThrow( + "Remote SSH sessions are disabled", + ); + expect(mocks.createWebSocketStream).not.toHaveBeenCalled(); + expect(mocks.disconnectRemoteHost).toHaveBeenCalledWith("dev-box", 7); + expect(remoteConnection.getClientSync()).toBeNull(); + + window.localStorage.setItem( + "goose:experimental-features", + JSON.stringify({ + version: 2, + experiments: { "remote-ssh-sessions": { enabled: true } }, + }), + ); + window.dispatchEvent(new Event("goose:experimental-features-change")); + mocks.connectRemoteHost.mockResolvedValueOnce({ + wsUrl: "ws://fresh-remote", + httpBaseUrl: "http://fresh-remote", + secretKey: "fresh", + localPort: 4444, + generation: 8, + }); + + await expect(remoteConnection.getClient()).resolves.toBeDefined(); + expect(mocks.createWebSocketStream).toHaveBeenCalledWith( + "ws://fresh-remote", + ); + }); + + 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(); + }); + + 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/__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/__tests__/remoteHosts.test.ts b/src/shared/api/__tests__/remoteHosts.test.ts new file mode 100644 index 000000000..c0f0c522c --- /dev/null +++ b/src/shared/api/__tests__/remoteHosts.test.ts @@ -0,0 +1,112 @@ +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, + shutdownRemoteHost, +} 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", + expectedGeneration: null, + }); + }); + + it("scopes initializer cleanup to its remote generation", async () => { + await disconnectRemoteHost("devbox", 7); + + expect(mockedInvoke).toHaveBeenCalledWith("remote_backend_disconnect", { + host: "devbox", + expectedGeneration: 7, + }); + }); + + it("scopes a confirmed takeover to the inspected daemon instance", async () => { + await shutdownRemoteHost("devbox", "instance-token"); + + expect(mockedInvoke).toHaveBeenCalledWith("remote_backend_shutdown", { + host: "devbox", + expectedInstanceToken: "instance-token", + }); + }); +}); 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..ee6561198 100644 --- a/src/shared/api/acpApi.ts +++ b/src/shared/api/acpApi.ts @@ -10,7 +10,22 @@ 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, + compositeSessionId, + type AcpBackendId, +} from "./acpBackendId"; +import { + getBackendClient, + getClient, + interceptSessionNotifications, +} from "./acpConnection"; +import { + getClientForSession, + getSessionBackend, + getWireSessionId, + registerSessionBackend, +} from "./acpSessionBackends"; import { applySessionConfigOptionsSnapshot, readSessionConfigOptionsSnapshots, @@ -135,21 +150,28 @@ function mapSessionInfo(info: SessionInfo): AcpSessionInfo { export async function getSessionInfo( sessionId: string, ): Promise { - const client = await getClient(); - const result = await client.goose.GooseUnstableSessionInfo({ sessionId }); - return mapSessionInfo(result.session as unknown as SessionInfo); + const client = await getClientForSession(sessionId); + 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({ 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 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 @@ -166,19 +188,30 @@ 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 getClient(); - const result = await client.goose.GooseUnstableSessionExport({ sessionId }); + const client = await getClientForSession(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; } 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,9 +233,10 @@ 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, + sessionId: getWireSessionId(sessionId), cwd: workingDir, mcpServers: [], }; @@ -211,8 +245,12 @@ export async function forkSession( } const response = await client.unstable_forkSession(params); + // 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"), @@ -236,10 +274,10 @@ 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, + sessionId: getWireSessionId(sessionId), configId: "model", value: modelId, }); @@ -272,10 +310,10 @@ 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, + sessionId: getWireSessionId(sessionId), configId, value, }); @@ -307,11 +345,11 @@ 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({ - sessionId, + sessionId: getWireSessionId(sessionId), configId: "provider", value: wireProvider, }); @@ -343,13 +381,13 @@ 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. beforeUpdate?.(); await client.goose.GooseUnstableSessionWorkingDirUpdate({ - sessionId, + sessionId: getWireSessionId(sessionId), workingDir, }); } @@ -358,9 +396,9 @@ 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, + sessionId: getWireSessionId(sessionId), mode: "set", text, }); @@ -371,9 +409,9 @@ 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, + sessionId: getWireSessionId(sessionId), mode: "append", key, text, @@ -384,39 +422,48 @@ export async function updateSessionProject( sessionId: string, projectId: string | null, ): Promise { - const client = await getClient(); + const client = await getClientForSession(sessionId); await client.goose.GooseUnstableSessionProjectUpdate({ - sessionId, + sessionId: getWireSessionId(sessionId), projectId, }); } export async function archiveSession(sessionId: string): Promise { - const client = await getClient(); - await client.goose.GooseUnstableSessionArchive({ sessionId }); + const client = await getClientForSession(sessionId); + await client.goose.GooseUnstableSessionArchive({ + sessionId: getWireSessionId(sessionId), + }); } export async function deleteSession(sessionId: string): Promise { - const client = await getClient(); - await client.extMethod("session/delete", { sessionId }); + const client = await getClientForSession(sessionId); + await client.extMethod("session/delete", { + sessionId: getWireSessionId(sessionId), + }); } export async function unarchiveSession(sessionId: string): Promise { - const client = await getClient(); - await client.goose.GooseUnstableSessionUnarchive({ sessionId }); + const client = await getClientForSession(sessionId); + await client.goose.GooseUnstableSessionUnarchive({ + sessionId: getWireSessionId(sessionId), + }); } export async function renameSession( sessionId: string, title: string, ): Promise { - const client = await getClient(); - await client.goose.GooseUnstableSessionRename({ sessionId, title }); + const client = await getClientForSession(sessionId); + await client.goose.GooseUnstableSessionRename({ + sessionId: getWireSessionId(sessionId), + title, + }); } export async function cancelSession(sessionId: string): Promise { - const client = await getClient(); - await client.cancel({ sessionId }); + const client = await getClientForSession(sessionId); + await client.cancel({ sessionId: getWireSessionId(sessionId) }); } export interface NewSessionOptions { @@ -424,6 +471,7 @@ export interface NewSessionOptions { projectId?: string; personaId?: string; hidden?: boolean; + backendId?: AcpBackendId; } export async function newSession( @@ -431,8 +479,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,11 +496,16 @@ export async function newSession( const tCall = performance.now(); const response = await client.newSession(request); - 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( @@ -460,10 +514,10 @@ 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, + sessionId: getWireSessionId(sessionId), cwd: workingDir, mcpServers: [], }); @@ -491,10 +545,10 @@ export async function prompt( onPromptDispatched?: () => void; } = {}, ): Promise { - const client = await getClient(); + const client = await getClientForSession(sessionId); callbacks.onPromptDispatching?.(); const promptPromise = client.prompt({ - sessionId, + sessionId: getWireSessionId(sessionId), prompt: content, _meta: meta, }); @@ -596,10 +650,10 @@ 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, + sessionId: getWireSessionId(sessionId), prompt: content, expectedRunId: runId, ...(meta ? { _meta: meta } : {}), diff --git a/src/shared/api/acpBackendId.ts b/src/shared/api/acpBackendId.ts new file mode 100644 index 000000000..66d69ef03 --- /dev/null +++ b/src/shared/api/acpBackendId.ts @@ -0,0 +1,64 @@ +/** + * 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; +} + +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 0f69884f5..cbf2fc76f 100644 --- a/src/shared/api/acpConnection.ts +++ b/src/shared/api/acpConnection.ts @@ -12,6 +12,17 @@ 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, + remoteHostFromBackendId, + type AcpBackendId, +} from "./acpBackendId"; import { createWebSocketStream } from "./createWebSocketStream"; import { perfLog } from "@/shared/lib/perfLog"; @@ -58,19 +69,32 @@ 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. 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), + }; -function createClientCallbacks(): () => Client { 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", @@ -79,7 +103,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; @@ -92,49 +119,231 @@ 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; +} + +interface ResolvedAcpEndpoint { + wsUrl: string; + /** Tear down only the remote transport generation this endpoint created. */ + dispose?: () => Promise; +} + +type AcpEndpointResolver = () => Promise; + +function invalidatedConnectionError(): Error { + return new Error("ACP connection initialization was invalidated"); +} + +export function createAcpConnection( + resolveEndpoint: AcpEndpointResolver, + backendId: AcpBackendId = LOCAL_BACKEND_ID, + assertAvailable?: () => void, +): AcpConnection { + let clientPromise: Promise | null = null; + let resolvedClient: GooseClient | null = null; + let activeStream: ReturnType | null = null; + let activeEndpointCleanup: (() => Promise) | null = null; + let invalidationGeneration = 0; + const closedListeners = new Set<() => void>(); + const cleanedStreams = new WeakSet(); + + function assertInitializationCurrent(expectedGeneration: number): void { + assertAvailable?.(); + if (invalidationGeneration !== expectedGeneration) { + throw invalidatedConnectionError(); } + } + + function onceAsync(fn: (() => Promise) | undefined) { + if (!fn) return null; + let called = false; + return async () => { + if (called) return; + called = true; + await fn(); + }; + } + + async function cleanupOwnedTransport( + stream: ReturnType | null, + cleanupEndpoint: (() => Promise) | null, + ): Promise { + if (stream && activeStream === stream) { + activeStream = null; + } + if (cleanupEndpoint && activeEndpointCleanup === cleanupEndpoint) { + activeEndpointCleanup = null; + } + const cleanupTasks: Promise[] = []; + if (stream && !cleanedStreams.has(stream)) { + cleanedStreams.add(stream); + cleanupTasks.push(stream.writable.abort()); + } + if (cleanupEndpoint) { + cleanupTasks.push(cleanupEndpoint()); + } + await Promise.allSettled(cleanupTasks); + } + + function monitorConnection( + client: GooseClient, + stream: ReturnType, + ): void { + const clearCurrentConnection = () => { + if (activeStream !== stream) { + return; + } + resolvedClient = null; + clientPromise = null; + activeStream = null; + activeEndpointCleanup = 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 { + invalidationGeneration += 1; + const stream = activeStream; + const cleanupEndpoint = activeEndpointCleanup; + activeStream = null; + activeEndpointCleanup = 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().", + await cleanupOwnedTransport(stream, cleanupEndpoint); + } + + async function initializeConnection( + expectedGeneration: number, + ): Promise { + assertInitializationCurrent(expectedGeneration); + const tStart = performance.now(); + const resolvedEndpoint = await resolveEndpoint(); + const endpoint = + typeof resolvedEndpoint === "string" + ? { wsUrl: resolvedEndpoint } + : resolvedEndpoint; + const cleanupEndpoint = onceAsync(endpoint.dispose); + try { + assertInitializationCurrent(expectedGeneration); + } catch (error) { + await cleanupOwnedTransport(null, cleanupEndpoint); + throw error; + } + perfLog( + `[perf:conn] get_goose_serve_url in ${(performance.now() - tStart).toFixed(1)}ms`, + ); + + const tStream = performance.now(); + const stream = createWebSocketStream(endpoint.wsUrl); + activeStream = stream; + activeEndpointCleanup = cleanupEndpoint; + + const client = new GooseClient(createClientCallbacks(backendId), stream); + perfLog( + `[perf:conn] ws stream + client created in ${(performance.now() - tStream).toFixed(1)}ms`, + ); + + const tInit = performance.now(); + try { + 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); + assertInitializationCurrent(expectedGeneration); + } catch (error) { + await cleanupOwnedTransport(stream, cleanupEndpoint); + throw error; + } + 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 { + assertAvailable?.(); + if (resolvedClient) { + return resolvedClient; + } + + if (!clientPromise) { + perfLog("[perf:conn] getClient() → initializing new ACP connection"); + const expectedGeneration = invalidationGeneration; + const initialization = initializeConnection(expectedGeneration) + .then((client) => { + resolvedClient = client; + return client; + }) + .catch((error) => { + if (clientPromise === initialization) { + clientPromise = null; + } + throw error; + }); + clientPromise = initialization; + } 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 +368,100 @@ 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`, - ); + return await invoke("get_goose_serve_url"); +} - const tStream = performance.now(); - const stream = createWebSocketStream(wsUrl); - activeStream = stream; +const connections = new Map(); - const client = new GooseClient(createClientCallbacks(), stream); - perfLog( - `[perf:conn] ws stream + client created in ${(performance.now() - tStream).toFixed(1)}ms`, - ); +function remoteSessionsEnabled(): boolean { + return getExperiment(REMOTE_SSH_SESSIONS_EXPERIMENT_ID)?.enabled === true; +} - 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, +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); + } + const host = remoteHostFromBackendId(backendId); + if (!host) { + throw new Error(`Unknown ACP backend id: ${backendId}`); + } + return createAcpConnection( + async () => { + const { connectRemoteHost, disconnectRemoteHost } = await import( + "./remoteHosts" + ); + const remote = await connectRemoteHost(host); + return { + wsUrl: remote.wsUrl, + dispose: () => disconnectRemoteHost(host, remote.generation), + }; }, - } satisfies GooseInitializeRequest); - perfLog( - `[perf:conn] client.initialize in ${(performance.now() - tInit).toFixed(1)}ms (total ${(performance.now() - tStart).toFixed(1)}ms)`, + backendId, + assertRemoteSessionsEnabled, ); +} - monitorConnection(client, stream); +export function getBackendConnection(backendId: AcpBackendId): AcpConnection { + if (backendId !== LOCAL_BACKEND_ID) { + assertRemoteSessionsEnabled(); + } + let connection = connections.get(backendId); + if (!connection) { + connection = createBackendConnection(backendId); + connections.set(backendId, connection); + } + return connection; +} - return client; +export function getBackendClient( + backendId: AcpBackendId, +): Promise { + return getBackendConnection(backendId).getClient(); } -export async function getClient(): Promise { - if (resolvedClient) { - return resolvedClient; - } +export async function invalidateBackendConnection( + backendId: AcpBackendId, +): Promise { + await connections.get(backendId)?.invalidate(); +} - 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"); +// 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(); + } } +}); - 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..fef6276ce --- /dev/null +++ b/src/shared/api/acpSessionBackends.ts @@ -0,0 +1,88 @@ +import type { GooseClient } from "@aaif/goose-sdk"; +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 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(); + +export function registerSessionBackend( + sessionId: string, + backendId: AcpBackendId, + wireSessionId?: string, +): void { + 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 { + 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). */ +export function transferSessionBackend( + fromSessionId: string, + toSessionId: string, +): void { + 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 { + 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..bc49001fa --- /dev/null +++ b/src/shared/api/remoteHosts.ts @@ -0,0 +1,182 @@ +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 + * 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", "daemon-conflict", + * or "internal". + */ +export interface RemoteBackendErrorLike { + kind: string; + message: string; + daemonInstance?: { + pid: number; + startedAt: string; + gooseVersion: string; + binary?: string; + instanceToken: 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; + generation: number; +} + +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; + /** Path that answered the probe (an override, or the PATH resolution). */ + path?: 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. + * + * 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, + goosePath: getGoosePathForHost(host) ?? null, + }); +} + +/** Tear down the local tunnel to `host`, leaving the remote daemon running. */ +export async function disconnectRemoteHost( + host: string, + expectedGeneration?: number, +): Promise { + await invoke("remote_backend_disconnect", { + host, + expectedGeneration: expectedGeneration ?? null, + }); +} + +/** Stop the remote daemon on `host` and tear down the tunnel. */ +export async function shutdownRemoteHost( + host: string, + expectedInstanceToken?: string, +): Promise { + await invoke("remote_backend_shutdown", { + host, + expectedInstanceToken: expectedInstanceToken ?? null, + }); +} + +/** 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). + * 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, + goosePath: getGoosePathForHost(host) ?? null, + }); +} + +/** + * 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..103dc0536 100644 --- a/src/shared/i18n/locales/en/chat.json +++ b/src/shared/i18n/locales/en/chat.json @@ -530,6 +530,43 @@ "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" + }, + "banner": { + "disconnected": "Disconnected from {{host}}", + "failed": "Couldn't connect to {{host}}", + "connecting": "Connecting to {{host}}...", + "reconnecting": "Reconnecting to {{host}}...", + "reconnect": "Reconnect" + } + }, "selectModel": "Select model", "selectProject": "Select project", "sendMessage": "Send message", @@ -708,5 +745,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..916912aca 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,61 @@ "title": "Providers", "waitingForSignIn": "Waiting for sign-in..." }, + "remoteHosts": { + "actions": { + "check": "Check host", + "connect": "Connect", + "disconnect": "Disconnect", + "forget": "Forget", + "shutdown": "Stop remote backend", + "takeover": "Stop and reconnect" + }, + "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.", + "conflict": { + "instance": "Conflicting daemon: PID {{pid}}, {{version}}, {{binary}}", + "pathUnknown": "unknown binary" + }, + "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.", + "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", + "description": "This stops the recorded agent daemon on the remote host after verifying its process identity, and ends any remote work running through it.", + "stopping": "Stopping...", + "title": "Stop the remote backend on \"{{host}}\"?" + }, + "takeoverConfirm": { + "description": "This verifies and stops the conflicting daemon shown for this host, then reconnects using your current Goose configuration. If the daemon changed since detection, nothing is stopped." + }, + "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/en/sidebar.json b/src/shared/i18n/locales/en/sidebar.json index d8d3bee11..ab468871c 100644 --- a/src/shared/i18n/locales/en/sidebar.json +++ b/src/shared/i18n/locales/en/sidebar.json @@ -80,7 +80,9 @@ "status": { "chatActive": "Chat active", "pinnedChat": "Pinned chat", - "unreadMessages": "Unread messages" + "unreadMessages": "Unread messages", + "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 896eed292..720104f25 100644 --- a/src/shared/i18n/locales/es/chat.json +++ b/src/shared/i18n/locales/es/chat.json @@ -527,6 +527,43 @@ "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" + }, + "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", "selectProject": "Seleccionar proyecto", "sendMessage": "Enviar mensaje", @@ -704,5 +741,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..faf64a5e5 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,61 @@ "title": "Proveedores", "waitingForSignIn": "Esperando inicio de sesión..." }, + "remoteHosts": { + "actions": { + "check": "Comprobar host", + "connect": "Conectar", + "disconnect": "Desconectar", + "forget": "Olvidar", + "shutdown": "Detener backend remoto", + "takeover": "Detener y reconectar" + }, + "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.", + "conflict": { + "instance": "Daemon en conflicto: PID {{pid}}, {{version}}, {{binary}}", + "pathUnknown": "binario desconocido" + }, + "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.", + "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", + "description": "Esto detiene el daemon del agente registrado en el host remoto después de verificar la identidad del proceso y finaliza cualquier trabajo remoto en curso.", + "stopping": "Deteniendo...", + "title": "¿Detener el backend remoto en \"{{host}}\"?" + }, + "takeoverConfirm": { + "description": "Esto verifica y detiene el daemon en conflicto mostrado para este host y, a continuación, vuelve a conectar con la configuración actual de Goose. Si el daemon cambió desde que se detectó, no se detiene nada." + }, + "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/i18n/locales/es/sidebar.json b/src/shared/i18n/locales/es/sidebar.json index 375186570..e5d6d3294 100644 --- a/src/shared/i18n/locales/es/sidebar.json +++ b/src/shared/i18n/locales/es/sidebar.json @@ -80,7 +80,9 @@ "status": { "chatActive": "Chat activo", "pinnedChat": "Chat fijado", - "unreadMessages": "Mensajes no leídos" + "unreadMessages": "Mensajes no leídos", + "remoteHost": "Chat remoto en {{host}}", + "remoteHostDisconnected": "Chat remoto en {{host}} — desconectado" }, "showLess": "Mostrar menos", "viewMoreChats": "Ver más", 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 {