diff --git a/src/apps/desktop/src/api/acp_client_api.rs b/src/apps/desktop/src/api/acp_client_api.rs index fb7997f5d..d16b2cf2b 100644 --- a/src/apps/desktop/src/api/acp_client_api.rs +++ b/src/apps/desktop/src/api/acp_client_api.rs @@ -14,6 +14,8 @@ use tauri::{AppHandle, Emitter, State}; #[serde(rename_all = "camelCase")] pub struct AcpClientIdRequest { pub client_id: String, + #[serde(default)] + pub remote_connection_id: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -146,7 +148,7 @@ pub async fn install_acp_client_cli( .as_ref() .ok_or_else(|| "ACP client service not initialized".to_string())?; service - .install_client_cli(&request.client_id) + .install_client_cli(&request.client_id, request.remote_connection_id.as_deref()) .await .map_err(|e| e.to_string()) } diff --git a/src/crates/acp/src/client/builtin_clients.rs b/src/crates/acp/src/client/builtin_clients.rs index 4a4cea539..375114621 100644 --- a/src/crates/acp/src/client/builtin_clients.rs +++ b/src/crates/acp/src/client/builtin_clients.rs @@ -54,10 +54,6 @@ pub(crate) fn builtin_acp_client_preset( .find(|preset| preset.id == client_id) } -pub(crate) fn supported_remote_acp_clients() -> String { - builtin_client_ids().collect::>().join(", ") -} - pub(crate) fn default_config_for_builtin_client(client_id: &str) -> Option { let preset = builtin_acp_client_preset(client_id)?; Some(AcpClientConfig { @@ -75,65 +71,10 @@ pub(crate) fn default_config_for_builtin_client(client_id: &str) -> Option Option { - let preset = builtin_acp_client_preset(client_id)?; - Some(render_shell_command(preset.command, preset.args)) -} - -pub(crate) fn remote_command_for_builtin_client_in_workspace( - client_id: &str, - workspace_path: &str, -) -> Option { - let command = remote_command_for_builtin_client(client_id)?; - let workspace_path = workspace_path.trim(); - if workspace_path.is_empty() { - return Some(command); - } - Some(format!( - "cd {} && {}", - shell_escape(workspace_path), - command - )) -} - -fn render_shell_command(command: &str, args: &[&str]) -> String { - std::iter::once(command) - .chain(args.iter().copied()) - .map(shell_escape) - .collect::>() - .join(" ") -} - -fn shell_escape(value: &str) -> String { - if value.chars().all(|ch| { - ch.is_ascii_alphanumeric() || matches!(ch, '/' | '.' | '-' | '_' | ':' | '=' | '@') - }) { - value.to_string() - } else { - format!("'{}'", value.replace('\'', "'\\''")) - } -} - #[cfg(test)] mod tests { use super::*; - #[test] - fn builds_remote_builtin_command_for_opencode() { - assert_eq!( - remote_command_for_builtin_client("opencode").as_deref(), - Some("opencode acp") - ); - } - - #[test] - fn builds_remote_builtin_command_for_npx_adapter() { - assert_eq!( - remote_command_for_builtin_client("codex").as_deref(), - Some("npx --yes @zed-industries/codex-acp@latest") - ); - } - #[test] fn returns_default_config_for_builtin_client() { let config = default_config_for_builtin_client("claude-code").expect("builtin config"); @@ -144,17 +85,4 @@ mod tests { vec!["--yes", "@zed-industries/claude-code-acp@latest"] ); } - - #[test] - fn shell_escape_quotes_spaces() { - assert_eq!(shell_escape("hello world"), "'hello world'"); - } - - #[test] - fn builds_remote_builtin_command_in_workspace() { - assert_eq!( - remote_command_for_builtin_client_in_workspace("opencode", "/tmp/my repo").as_deref(), - Some("cd '/tmp/my repo' && opencode acp") - ); - } } diff --git a/src/crates/acp/src/client/manager.rs b/src/crates/acp/src/client/manager.rs index e3c9be926..f82a3fef2 100644 --- a/src/crates/acp/src/client/manager.rs +++ b/src/crates/acp/src/client/manager.rs @@ -33,21 +33,18 @@ use tokio::process::{Child, Command}; use tokio::sync::{oneshot, Mutex, RwLock}; use tokio_util::compat::{TokioAsyncReadCompatExt, TokioAsyncWriteCompatExt}; -use super::builtin_clients::{ - builtin_acp_client_preset, builtin_client_ids, default_config_for_builtin_client, - remote_command_for_builtin_client, remote_command_for_builtin_client_in_workspace, - supported_remote_acp_clients, -}; +use super::builtin_clients::{builtin_client_ids, default_config_for_builtin_client}; use super::config::{ AcpClientConfig, AcpClientConfigFile, AcpClientInfo, AcpClientPermissionMode, AcpClientRequirementProbe, AcpClientStatus, RemoteAcpClientRequirementSnapshot, }; use super::remote_capability_store::RemoteAcpCapabilityStore; use super::remote_session::{preferred_resume_strategies, AcpRemoteSessionStrategy}; +use super::remote_shell::{remote_user_shell_command, render_remote_env_assignments, shell_escape}; use super::requirements::{ acp_requirement_spec, apply_command_environment, install_npm_cli_package, - predownload_npm_adapter, probe_executable, probe_npm_adapter, probe_remote_executable, - probe_remote_npx_adapter, resolve_configured_command, + install_remote_npm_cli_package, predownload_npm_adapter, probe_executable, probe_npm_adapter, + probe_remote_executable, probe_remote_npx_adapter, resolve_configured_command, }; use super::session_options::{model_config_id, session_options_from_state, AcpSessionOptions}; use super::session_persistence::AcpSessionPersistence; @@ -362,21 +359,35 @@ impl AcpClientService { BitFunError::service("SSH manager is not available for remote ACP".to_string()) })?; - let mut ids = builtin_client_ids() - .map(ToString::to_string) - .collect::>(); + let config_file = self.load_config_file().await?; + let mut ids = config_file.acp_clients.keys().cloned().collect::>(); + for id in builtin_client_ids() { + if !ids.iter().any(|candidate| candidate == id) { + ids.push(id.to_string()); + } + } ids.sort(); let mut probes = Vec::with_capacity(ids.len()); for id in ids { - let spec = acp_requirement_spec(&id, None); - let tool = - probe_remote_executable(&ssh_manager, remote_connection_id, spec.tool_command) - .await; + let config = resolve_config_for_client(&config_file, &id, Some(remote_connection_id)); + let spec = acp_requirement_spec(&id, config.as_ref()); + let tool = probe_remote_executable( + &ssh_manager, + remote_connection_id, + spec.tool_command, + config.as_ref().map(|config| &config.env), + ) + .await; let adapter = match spec.adapter { Some(adapter) => Some( - probe_remote_npx_adapter(&ssh_manager, remote_connection_id, adapter.package) - .await, + probe_remote_npx_adapter( + &ssh_manager, + remote_connection_id, + adapter.package, + config.as_ref().map(|config| &config.env), + ) + .await, ), None => None, }; @@ -432,9 +443,17 @@ impl AcpClientService { predownload_npm_adapter(adapter.package, adapter.bin).await } - pub async fn install_client_cli(self: &Arc, client_id: &str) -> BitFunResult<()> { - let configs = self.load_configs().await?; - let spec = acp_requirement_spec(client_id, configs.get(client_id)); + pub async fn install_client_cli( + self: &Arc, + client_id: &str, + remote_connection_id: Option<&str>, + ) -> BitFunResult<()> { + let remote_connection_id = remote_connection_id + .map(str::trim) + .filter(|value| !value.is_empty()); + let config_file = self.load_config_file().await?; + let config = resolve_config_for_client(&config_file, client_id, remote_connection_id); + let spec = acp_requirement_spec(client_id, config.as_ref()); let package = spec.install_package.ok_or_else(|| { BitFunError::config(format!( "ACP client '{}' does not have a known CLI installer", @@ -442,7 +461,17 @@ impl AcpClientService { )) })?; - install_npm_cli_package(package).await + if let Some(remote_connection_id) = remote_connection_id { + let remote_manager = get_remote_workspace_manager().ok_or_else(|| { + BitFunError::service("Remote workspace manager is not initialized".to_string()) + })?; + let ssh_manager = remote_manager.get_ssh_manager().await.ok_or_else(|| { + BitFunError::service("SSH manager is not available for remote ACP".to_string()) + })?; + install_remote_npm_cli_package(&ssh_manager, remote_connection_id, package).await + } else { + install_npm_cli_package(package).await + } } pub async fn start_client_for_session( @@ -769,6 +798,7 @@ impl AcpClientService { self.config_service .set_config(CONFIG_PATH, canonical_value) .await?; + self.remote_capability_store.clear().await?; self.initialize_all().await } @@ -1365,7 +1395,11 @@ impl AcpClientService { } async fn load_configs(&self) -> BitFunResult> { - Ok(parse_config_value(self.load_config_value().await?)?.acp_clients) + Ok(self.load_config_file().await?.acp_clients) + } + + async fn load_config_file(&self) -> BitFunResult { + parse_config_value(self.load_config_value().await?) } async fn load_config_value(&self) -> BitFunResult { @@ -1533,7 +1567,7 @@ impl AcpClientService { )> { match remote_connection_id { Some(remote_connection_id) => self - .start_remote_transport(client_id, workspace_path, remote_connection_id) + .start_remote_transport(client_id, config, workspace_path, remote_connection_id) .await .map(|transport| (transport, None)), None => self @@ -1546,22 +1580,11 @@ impl AcpClientService { async fn start_remote_transport( &self, client_id: &str, + config: &AcpClientConfig, workspace_path: Option<&str>, remote_connection_id: &str, ) -> BitFunResult> { - let command = workspace_path - .map(str::trim) - .filter(|value| !value.is_empty()) - .and_then(|workspace_path| { - remote_command_for_builtin_client_in_workspace(client_id, workspace_path) - }) - .or_else(|| remote_command_for_builtin_client(client_id)) - .ok_or_else(|| { - BitFunError::config(format!( - "Remote ACP currently supports only built-in clients: {}", - supported_remote_acp_clients() - )) - })?; + let command = render_remote_client_command(config, workspace_path)?; let remote_manager = get_remote_workspace_manager().ok_or_else(|| { BitFunError::service("Remote workspace manager is not initialized".to_string()) })?; @@ -1595,25 +1618,19 @@ impl AcpClientService { .map(str::trim) .filter(|value| !value.is_empty()) .map(ToOwned::to_owned); - let remote_builtin = remote_connection_id - .as_deref() - .and_then(|_| builtin_acp_client_preset(client_id)) - .is_some(); - let mut config = self - .load_configs() - .await? - .remove(client_id) - .or_else(|| { - if remote_connection_id.is_some() { - default_config_for_builtin_client(client_id) - } else { - None - } - }) - .ok_or_else(|| BitFunError::NotFound(format!("ACP client not found: {}", client_id)))?; - - if remote_builtin { - config.enabled = true; + let is_remote = remote_connection_id.is_some(); + let config_file = self.load_config_file().await?; + let config = + resolve_config_for_client(&config_file, client_id, remote_connection_id.as_deref()) + .ok_or_else(|| { + BitFunError::NotFound(format!("ACP client not found: {}", client_id)) + })?; + + if config.command.trim().is_empty() { + return Err(BitFunError::config(format!( + "ACP client command is empty: {}", + client_id + ))); } if !config.enabled { return Err(BitFunError::config(format!( @@ -1622,7 +1639,7 @@ impl AcpClientService { ))); } - if remote_connection_id.is_some() { + if is_remote { ensure_remote_client_supported(client_id, workspace_path)?; } @@ -1633,8 +1650,20 @@ impl AcpClientService { } } -fn ensure_remote_client_supported( +fn resolve_config_for_client( + config_file: &AcpClientConfigFile, client_id: &str, + remote_connection_id: Option<&str>, +) -> Option { + config_file + .acp_clients + .get(client_id) + .cloned() + .or_else(|| remote_connection_id.and_then(|_| default_config_for_builtin_client(client_id))) +} + +fn ensure_remote_client_supported( + _client_id: &str, workspace_path: Option<&str>, ) -> BitFunResult<()> { if workspace_path @@ -1646,14 +1675,41 @@ fn ensure_remote_client_supported( )); } - if builtin_acp_client_preset(client_id).is_none() { - return Err(BitFunError::config(format!( - "Remote ACP currently supports only built-in clients: {}", - supported_remote_acp_clients() - ))); + Ok(()) +} + +fn render_remote_client_command( + config: &AcpClientConfig, + workspace_path: Option<&str>, +) -> BitFunResult { + let command = config.command.trim(); + if command.is_empty() { + return Err(BitFunError::config( + "ACP client command is empty".to_string(), + )); } - Ok(()) + let mut command_parts = Vec::new(); + command_parts.push(shell_escape(command)); + command_parts.extend(config.args.iter().map(|arg| shell_escape(arg))); + + let mut parts = Vec::new(); + parts.push("exec".to_string()); + let env_assignments = render_remote_env_assignments(&config.env); + if !env_assignments.is_empty() { + parts.push("env".to_string()); + parts.extend(env_assignments); + } + parts.extend(command_parts); + + let command = parts.join(" "); + let workspace_path = workspace_path.map(str::trim).unwrap_or_default(); + let body = if workspace_path.is_empty() { + command + } else { + format!("cd {} && {}", shell_escape(workspace_path), command) + }; + Ok(remote_user_shell_command(&body)) } fn current_unix_timestamp_ms() -> u64 { @@ -2065,4 +2121,59 @@ mod tests { "ACP startup timed out: client 'codex' exceeded 60s during initialize and was terminated. Please try again after the client is ready." ); } + + #[test] + fn renders_remote_client_command_from_config() { + let config = AcpClientConfig { + name: Some("Custom".to_string()), + command: "custom-acp".to_string(), + args: vec!["--stdio".to_string(), "with space".to_string()], + env: HashMap::from([ + ("PATH".to_string(), "/remote/bin:/usr/bin".to_string()), + ("INVALID-NAME".to_string(), "ignored".to_string()), + ]), + enabled: true, + readonly: false, + permission_mode: AcpClientPermissionMode::Ask, + }; + + let command = render_remote_client_command(&config, Some("/srv/my repo")).expect("command"); + assert!(command.starts_with("bash -lc ")); + assert!(command.contains(".nvm/nvm.sh")); + assert!(command.contains( + "cd '\\''/srv/my repo'\\'' && exec env PATH=/remote/bin:/usr/bin custom-acp --stdio '\\''with space'\\''" + )); + } + + #[test] + fn resolves_remote_client_config_from_global_config() { + let config_file = AcpClientConfigFile { + acp_clients: HashMap::from([( + "codex".to_string(), + AcpClientConfig { + name: Some("Codex".to_string()), + command: "npx".to_string(), + args: vec![ + "--yes".to_string(), + "@zed-industries/codex-acp@latest".to_string(), + ], + env: HashMap::from([("BASE".to_string(), "1".to_string())]), + enabled: true, + readonly: false, + permission_mode: AcpClientPermissionMode::Ask, + }, + )]), + }; + + let resolved = resolve_config_for_client(&config_file, "codex", Some("huawei-server")) + .expect("config"); + + assert_eq!(resolved.command, "npx"); + assert_eq!( + resolved.args, + vec!["--yes", "@zed-industries/codex-acp@latest"] + ); + assert_eq!(resolved.env.get("BASE").map(String::as_str), Some("1")); + assert!(resolved.enabled); + } } diff --git a/src/crates/acp/src/client/mod.rs b/src/crates/acp/src/client/mod.rs index af6a6891f..58959c3e9 100644 --- a/src/crates/acp/src/client/mod.rs +++ b/src/crates/acp/src/client/mod.rs @@ -3,6 +3,7 @@ mod config; mod manager; mod remote_capability_store; mod remote_session; +mod remote_shell; mod requirements; mod session_options; mod session_persistence; diff --git a/src/crates/acp/src/client/remote_capability_store.rs b/src/crates/acp/src/client/remote_capability_store.rs index 759b71c41..2c9d894d3 100644 --- a/src/crates/acp/src/client/remote_capability_store.rs +++ b/src/crates/acp/src/client/remote_capability_store.rs @@ -69,6 +69,14 @@ impl RemoteAcpCapabilityStore { self.persist(entries).await } + pub(crate) async fn clear(&self) -> BitFunResult<()> { + { + let mut guard = self.snapshots.write().await; + guard.clear(); + } + self.persist(Vec::new()).await + } + async fn persist( &self, snapshots: Vec, diff --git a/src/crates/acp/src/client/remote_shell.rs b/src/crates/acp/src/client/remote_shell.rs new file mode 100644 index 000000000..c3c1162bd --- /dev/null +++ b/src/crates/acp/src/client/remote_shell.rs @@ -0,0 +1,84 @@ +use std::collections::HashMap; + +const REMOTE_ENV_BOOTSTRAP: &str = r#"__bitfun_home="${HOME:-}" +if [ -n "$__bitfun_home" ]; then + if [ -s "$__bitfun_home/.nvm/nvm.sh" ]; then + . "$__bitfun_home/.nvm/nvm.sh" >/dev/null 2>&1 + fi + for __bitfun_dir in "$__bitfun_home/.local/bin" "$__bitfun_home/.cargo/bin" "$__bitfun_home/.npm-global/bin"; do + if [ -d "$__bitfun_dir" ]; then + PATH="$__bitfun_dir:$PATH" + fi + done +fi +export PATH"#; + +pub(super) fn remote_user_shell_command(body: &str) -> String { + format!( + "bash -lc {}", + shell_escape(&format!("{REMOTE_ENV_BOOTSTRAP}\n{body}")) + ) +} + +pub(super) fn render_remote_env_assignments(env: &HashMap) -> Vec { + let mut entries = env + .iter() + .filter(|(key, _)| is_shell_env_key(key)) + .collect::>(); + entries.sort_by(|(left, _), (right, _)| left.cmp(right)); + entries + .into_iter() + .map(|(key, value)| format!("{key}={}", shell_escape(value))) + .collect() +} + +pub(super) fn shell_escape(value: &str) -> String { + if value.chars().all(|ch| { + ch.is_ascii_alphanumeric() || matches!(ch, '/' | '.' | '-' | '_' | ':' | '=' | '@') + }) { + value.to_string() + } else { + format!("'{}'", value.replace('\'', "'\\''")) + } +} + +fn is_shell_env_key(key: &str) -> bool { + let mut chars = key.chars(); + let Some(first) = chars.next() else { + return false; + }; + (first == '_' || first.is_ascii_alphabetic()) + && chars.all(|ch| ch == '_' || ch.is_ascii_alphanumeric()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn remote_env_assignments_use_valid_keys_in_stable_order() { + let env = HashMap::from([ + ("INVALID-NAME".to_string(), "ignored".to_string()), + ("PATH".to_string(), "/remote/bin:/usr/bin".to_string()), + ("ACP_HOME".to_string(), "/tmp/acp home".to_string()), + ]); + + assert_eq!( + render_remote_env_assignments(&env), + vec![ + "ACP_HOME='/tmp/acp home'".to_string(), + "PATH=/remote/bin:/usr/bin".to_string(), + ] + ); + } + + #[test] + fn remote_user_shell_command_loads_common_user_toolchains() { + let command = remote_user_shell_command("command -v codex"); + + assert!(command.starts_with("bash -lc ")); + assert!(command.contains(".nvm/nvm.sh")); + assert!(command.contains(".local/bin")); + assert!(command.contains("command -v codex")); + } +} diff --git a/src/crates/acp/src/client/requirements.rs b/src/crates/acp/src/client/requirements.rs index ca87ad556..8789628a2 100644 --- a/src/crates/acp/src/client/requirements.rs +++ b/src/crates/acp/src/client/requirements.rs @@ -4,12 +4,13 @@ use std::ffi::{OsStr, OsString}; use std::path::{Path, PathBuf}; use std::time::Duration; -use bitfun_core::service::remote_ssh::SSHConnectionManager; +use bitfun_core::service::remote_ssh::{SSHCommandOptions, SSHConnectionManager}; use bitfun_core::util::errors::{BitFunError, BitFunResult}; use tokio::process::Command; use super::builtin_clients::builtin_acp_client_preset; use super::config::{AcpClientConfig, AcpRequirementProbeItem}; +use super::remote_shell::{remote_user_shell_command, render_remote_env_assignments, shell_escape}; const REQUIREMENT_PROBE_TIMEOUT: Duration = Duration::from_secs(3); const ADAPTER_DOWNLOAD_TIMEOUT: Duration = Duration::from_secs(120); @@ -156,6 +157,7 @@ pub(crate) async fn probe_remote_executable( ssh_manager: &SSHConnectionManager, connection_id: &str, command: &str, + env: Option<&HashMap>, ) -> AcpRequirementProbeItem { let mut item = AcpRequirementProbeItem { name: command.to_string(), @@ -165,7 +167,9 @@ pub(crate) async fn probe_remote_executable( error: None, }; - let resolve_command = format!("command -v {}", shell_escape(command)); + let env_prefix = render_remote_env_prefix(env); + let resolve_command = + remote_user_shell_command(&format!("{env_prefix}command -v {}", shell_escape(command))); match ssh_manager .execute_command(connection_id, &resolve_command) .await @@ -191,7 +195,8 @@ pub(crate) async fn probe_remote_executable( } if item.installed { - let version_command = format!("{} --version", shell_escape(command)); + let version_command = + remote_user_shell_command(&format!("{env_prefix}{} --version", shell_escape(command))); match ssh_manager .execute_command(connection_id, &version_command) .await @@ -216,6 +221,7 @@ pub(crate) async fn probe_remote_npx_adapter( ssh_manager: &SSHConnectionManager, connection_id: &str, package: &str, + env: Option<&HashMap>, ) -> AcpRequirementProbeItem { let mut item = AcpRequirementProbeItem { name: package.to_string(), @@ -225,9 +231,10 @@ pub(crate) async fn probe_remote_npx_adapter( error: None, }; - let resolve_command = "command -v npx"; + let env_prefix = render_remote_env_prefix(env); + let resolve_command = remote_user_shell_command(&format!("{env_prefix}command -v npx")); match ssh_manager - .execute_command(connection_id, resolve_command) + .execute_command(connection_id, &resolve_command) .await { Ok((stdout, _stderr, exit_code)) if exit_code == 0 => { @@ -306,6 +313,45 @@ pub(crate) async fn install_npm_cli_package(package: &str) -> BitFunResult<()> { } } +pub(crate) async fn install_remote_npm_cli_package( + ssh_manager: &SSHConnectionManager, + connection_id: &str, + package: &str, +) -> BitFunResult<()> { + let command = remote_user_shell_command(&format!("npm install -g {}", shell_escape(package))); + let timeout_ms = u64::try_from(CLI_INSTALL_TIMEOUT.as_millis()).unwrap_or(u64::MAX); + match ssh_manager + .execute_command_with_options( + connection_id, + &command, + SSHCommandOptions { + timeout_ms: Some(timeout_ms), + cancellation_token: None, + }, + ) + .await + { + Ok(output) if output.exit_code == 0 && !output.timed_out && !output.interrupted => Ok(()), + Ok(output) if output.timed_out => Err(BitFunError::service(format!( + "Failed to install remote ACP agent CLI '{}': command timed out", + package + ))), + Ok(output) if output.interrupted => Err(BitFunError::service(format!( + "Failed to install remote ACP agent CLI '{}': command was cancelled", + package + ))), + Ok(output) => Err(BitFunError::service(format!( + "Failed to install remote ACP agent CLI '{}': {}", + package, + remote_command_error_summary(&output.stderr, &output.stdout) + ))), + Err(error) => Err(BitFunError::service(format!( + "Failed to install remote ACP agent CLI '{}': {}", + package, error + ))), + } +} + pub(crate) fn resolve_configured_command( command: &str, extra_env: &HashMap, @@ -403,14 +449,15 @@ fn truncate_error(value: String) -> String { format!("{}...", value.chars().take(MAX_LEN).collect::()) } -fn shell_escape(value: &str) -> String { - if value.chars().all(|ch| { - ch.is_ascii_alphanumeric() || matches!(ch, '/' | '.' | '-' | '_' | ':' | '=' | '@') - }) { - value.to_string() - } else { - format!("'{}'", value.replace('\'', "'\\''")) +fn render_remote_env_prefix(env: Option<&HashMap>) -> String { + let Some(env) = env else { + return String::new(); + }; + let assignments = render_remote_env_assignments(env); + if assignments.is_empty() { + return String::new(); } + format!("{} ", assignments.join(" ")) } fn find_executable(command: &str) -> Option { @@ -595,4 +642,18 @@ mod tests { let _ = std::fs::remove_dir_all(&test_dir); assert_eq!(found, Some(executable)); } + + #[test] + fn remote_env_prefix_uses_valid_keys_in_stable_order() { + let env = HashMap::from([ + ("INVALID-NAME".to_string(), "ignored".to_string()), + ("PATH".to_string(), "/remote/bin:/usr/bin".to_string()), + ("ACP_HOME".to_string(), "/tmp/acp home".to_string()), + ]); + + assert_eq!( + render_remote_env_prefix(Some(&env)), + "ACP_HOME='/tmp/acp home' PATH=/remote/bin:/usr/bin " + ); + } } diff --git a/src/web-ui/src/app/App.tsx b/src/web-ui/src/app/App.tsx index ae96de50a..65fa60582 100644 --- a/src/web-ui/src/app/App.tsx +++ b/src/web-ui/src/app/App.tsx @@ -167,8 +167,13 @@ function App() { const { ACPClientAPI } = await import('../infrastructure/api/service-api/ACPClientAPI'); await ACPClientAPI.initializeClients(); log.debug('ACP clients initialized'); - // Requirement probes execute third-party CLIs such as `opencode --version`. - // Keep startup side-effect free; settings and ACP session creation can probe on demand. + void ACPClientAPI.probeClientRequirements() + .then(() => { + log.debug('ACP client requirements probed'); + }) + .catch((error) => { + log.warn('Failed to probe ACP client requirements during startup', error); + }); } catch (error) { log.error('Failed to initialize ACP clients', error); } diff --git a/src/web-ui/src/app/components/NavPanel/sections/workspaces/workspaceAcpMenuClients.test.ts b/src/web-ui/src/app/components/NavPanel/sections/workspaces/workspaceAcpMenuClients.test.ts index 2f9e79e2f..3a26fdb7b 100644 --- a/src/web-ui/src/app/components/NavPanel/sections/workspaces/workspaceAcpMenuClients.test.ts +++ b/src/web-ui/src/app/components/NavPanel/sections/workspaces/workspaceAcpMenuClients.test.ts @@ -42,10 +42,11 @@ describe('loadWorkspaceAcpMenuClients', () => { expect(clients.map(item => item.id)).toEqual(['opencode']); }); - it('uses built-in ACP presets for remote workspaces without requiring local config', async () => { + it('uses local ACP config for remote workspaces and filters by remote requirements', async () => { vi.mocked(ACPClientAPI.getClients).mockResolvedValue([ - client('claude-code', false), + client('claude-code', true), client('custom-remote-only', true), + client('disabled-client', false), ]); vi.mocked(ACPClientAPI.probeClientRequirements).mockResolvedValue([ { @@ -62,9 +63,14 @@ describe('loadWorkspaceAcpMenuClients', () => { notes: ['claude is not available on remote PATH'], }, { - id: 'codex', - tool: { name: 'codex', installed: true }, - adapter: { name: '@zed-industries/codex-acp', installed: true }, + id: 'custom-remote-only', + tool: { name: 'custom-acp', installed: true }, + runnable: true, + notes: [], + }, + { + id: 'disabled-client', + tool: { name: 'disabled-client', installed: true }, runnable: true, notes: [], }, @@ -78,7 +84,75 @@ describe('loadWorkspaceAcpMenuClients', () => { expect(ACPClientAPI.probeClientRequirements).toHaveBeenCalledWith({ remoteConnectionId: 'ssh-1', }); + expect(clients.map(item => item.id)).toEqual(['custom-remote-only']); + }); + + it('refreshes remote requirements when no cached remote snapshot exists', async () => { + vi.mocked(ACPClientAPI.getClients).mockResolvedValue([ + client('custom-remote-only', true), + ]); + vi.mocked(ACPClientAPI.probeClientRequirements) + .mockResolvedValueOnce([]) + .mockResolvedValueOnce([ + { + id: 'custom-remote-only', + tool: { name: 'custom-acp', installed: true }, + runnable: true, + notes: [], + }, + ]); + + const clients = await loadWorkspaceAcpMenuClients({ + remoteWorkspace: true, + remoteConnectionId: 'ssh-1', + }); + + expect(ACPClientAPI.probeClientRequirements).toHaveBeenNthCalledWith(1, { + remoteConnectionId: 'ssh-1', + }); + expect(ACPClientAPI.probeClientRequirements).toHaveBeenNthCalledWith(2, { + remoteConnectionId: 'ssh-1', + force: true, + }); + expect(clients.map(item => item.id)).toEqual(['custom-remote-only']); + }); + + it('refreshes once when cached remote requirements hide every enabled local ACP client', async () => { + vi.mocked(ACPClientAPI.getClients).mockResolvedValue([ + client('codex', true), + ]); + vi.mocked(ACPClientAPI.probeClientRequirements) + .mockResolvedValueOnce([ + { + id: 'codex', + tool: { name: 'codex', installed: true }, + adapter: { name: '@zed-industries/codex-acp', installed: false }, + runnable: false, + notes: ['npx is not available on remote PATH'], + }, + ]) + .mockResolvedValueOnce([ + { + id: 'codex', + tool: { name: 'codex', installed: true }, + adapter: { name: '@zed-industries/codex-acp', installed: true }, + runnable: true, + notes: [], + }, + ]); + + const clients = await loadWorkspaceAcpMenuClients({ + remoteWorkspace: true, + remoteConnectionId: 'ssh-stale', + }); + + expect(ACPClientAPI.probeClientRequirements).toHaveBeenNthCalledWith(1, { + remoteConnectionId: 'ssh-stale', + }); + expect(ACPClientAPI.probeClientRequirements).toHaveBeenNthCalledWith(2, { + remoteConnectionId: 'ssh-stale', + force: true, + }); expect(clients.map(item => item.id)).toEqual(['codex']); - expect(clients[0]?.enabled).toBe(true); }); }); diff --git a/src/web-ui/src/app/components/NavPanel/sections/workspaces/workspaceAcpMenuClients.ts b/src/web-ui/src/app/components/NavPanel/sections/workspaces/workspaceAcpMenuClients.ts index 19d9f6eb7..623497078 100644 --- a/src/web-ui/src/app/components/NavPanel/sections/workspaces/workspaceAcpMenuClients.ts +++ b/src/web-ui/src/app/components/NavPanel/sections/workspaces/workspaceAcpMenuClients.ts @@ -3,31 +3,12 @@ import { type AcpClientInfo, } from '@/infrastructure/api/service-api/ACPClientAPI'; -const REMOTE_ACP_PRESETS = [ - { id: 'opencode', name: 'opencode' }, - { id: 'claude-code', name: 'Claude Code' }, - { id: 'codex', name: 'Codex' }, -] as const; - interface LoadWorkspaceAcpMenuClientsOptions { remoteWorkspace?: boolean; remoteConnectionId?: string; } -function virtualRemoteClient(id: string, name: string): AcpClientInfo { - return { - id, - name, - command: '', - args: [], - enabled: true, - readonly: false, - permissionMode: 'ask', - status: 'configured', - toolName: `acp__${id}__prompt`, - sessionCount: 0, - }; -} +const forcedRemoteRequirementRefreshes = new Set(); export async function loadWorkspaceAcpMenuClients( options: LoadWorkspaceAcpMenuClientsOptions = {} @@ -42,24 +23,41 @@ export async function loadWorkspaceAcpMenuClients( return []; } - const probes = await ACPClientAPI.probeClientRequirements({ + const enabledClients = clients.filter(client => client.enabled); + if (enabledClients.length === 0) { + return []; + } + + let probes = await ACPClientAPI.probeClientRequirements({ remoteConnectionId: options.remoteConnectionId, }); + if (probes.length === 0) { + probes = await ACPClientAPI.probeClientRequirements({ + remoteConnectionId: options.remoteConnectionId, + force: true, + }); + } + let visibleClients = filterRunnableClients(enabledClients, probes); + if ( + visibleClients.length === 0 && + !forcedRemoteRequirementRefreshes.has(options.remoteConnectionId) + ) { + forcedRemoteRequirementRefreshes.add(options.remoteConnectionId); + probes = await ACPClientAPI.probeClientRequirements({ + remoteConnectionId: options.remoteConnectionId, + force: true, + }); + visibleClients = filterRunnableClients(enabledClients, probes); + } + return visibleClients; +} + +function filterRunnableClients( + clients: AcpClientInfo[], + probes: Awaited> +): AcpClientInfo[] { const runnableRemoteIds = new Set( probes.filter(probe => probe.runnable).map(probe => probe.id) ); - const clientsById = new Map(clients.map(client => [client.id, client])); - return REMOTE_ACP_PRESETS - .filter(({ id }) => runnableRemoteIds.has(id)) - .map(({ id, name }) => { - const configured = clientsById.get(id); - if (!configured) { - return virtualRemoteClient(id, name); - } - return { - ...configured, - name: configured.name || name, - enabled: true, - }; - }); + return clients.filter(client => runnableRemoteIds.has(client.id)); } diff --git a/src/web-ui/src/infrastructure/api/service-api/ACPClientAPI.ts b/src/web-ui/src/infrastructure/api/service-api/ACPClientAPI.ts index 0b4bb3725..98a30dbed 100644 --- a/src/web-ui/src/infrastructure/api/service-api/ACPClientAPI.ts +++ b/src/web-ui/src/infrastructure/api/service-api/ACPClientAPI.ts @@ -35,6 +35,7 @@ export interface AcpClientRequirementProbe { export interface AcpClientIdRequest { clientId: string; + remoteConnectionId?: string; } export interface CreateAcpFlowSessionRequest { diff --git a/src/web-ui/src/infrastructure/config/components/AcpAgentsConfig.scss b/src/web-ui/src/infrastructure/config/components/AcpAgentsConfig.scss index e1b7e9058..575a6cb79 100644 --- a/src/web-ui/src/infrastructure/config/components/AcpAgentsConfig.scss +++ b/src/web-ui/src/infrastructure/config/components/AcpAgentsConfig.scss @@ -199,6 +199,12 @@ color: var(--color-success); } + &.is-partial { + border-color: rgba($color-warning, 0.24); + background: rgba($color-warning, 0.1); + color: var(--color-warning); + } + &.is-not_installed { border-color: var(--border-subtle); background: var(--element-bg-subtle); @@ -247,13 +253,94 @@ text-align: center; } + &__remote-list { + display: flex; + flex-direction: column; + gap: $size-gap-3; + } + + &__remote-server { + display: flex; + flex-direction: column; + overflow: hidden; + border: 1px solid var(--border-subtle); + border-radius: $size-radius-base; + background: transparent; + } + + &__remote-head { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: $size-gap-3; + align-items: center; + padding: $size-gap-3; + min-width: 0; + border-bottom: 1px solid var(--border-subtle); + background: transparent; + } + + &__remote-actions { + display: flex; + align-items: center; + justify-content: flex-end; + gap: $size-gap-2; + flex-wrap: wrap; + } + + &__remote-summary { + display: flex; + align-items: center; + gap: $size-gap-2; + flex-wrap: wrap; + margin-top: $size-gap-1; + } + + &__summary-pill { + display: inline-flex; + align-items: center; + min-height: 22px; + padding: 0 8px; + border: 1px solid var(--border-subtle); + border-radius: 999px; + font-size: var(--font-size-xs); + font-weight: $font-weight-medium; + line-height: 1; + white-space: nowrap; + + &.is-success { + border-color: rgba($color-success, 0.24); + background: rgba($color-success, 0.08); + color: var(--color-success); + } + + &.is-warning { + border-color: rgba($color-warning, 0.24); + background: rgba($color-warning, 0.08); + color: var(--color-warning); + } + } + + &__remote-agent-list { + display: flex; + flex-direction: column; + gap: $size-gap-2; + padding: $size-gap-2; + + .bitfun-acp-agents__registry-row--remote { + padding: $size-gap-2 $size-gap-3; + } + } + @media (max-width: 860px) { &__toolbar, - &__registry-row { + &__registry-row, + &__remote-head { grid-template-columns: 1fr; } &__toolbar-actions, + &__remote-actions, + &__remote-head, &__capabilities, &__status-cell, &__confirmation-cell { diff --git a/src/web-ui/src/infrastructure/config/components/AcpAgentsConfig.test.tsx b/src/web-ui/src/infrastructure/config/components/AcpAgentsConfig.test.tsx index 888adbf15..ec5b21c12 100644 --- a/src/web-ui/src/infrastructure/config/components/AcpAgentsConfig.test.tsx +++ b/src/web-ui/src/infrastructure/config/components/AcpAgentsConfig.test.tsx @@ -8,6 +8,9 @@ import AcpAgentsConfig from './AcpAgentsConfig'; const loadJsonConfigMock = vi.hoisted(() => vi.fn()); const getClientsMock = vi.hoisted(() => vi.fn()); const probeClientRequirementsMock = vi.hoisted(() => vi.fn()); +const installClientCliMock = vi.hoisted(() => vi.fn()); +const predownloadClientAdapterMock = vi.hoisted(() => vi.fn()); +const listSavedConnectionsMock = vi.hoisted(() => vi.fn()); const notifyErrorMock = vi.hoisted(() => vi.fn()); const notifySuccessMock = vi.hoisted(() => vi.fn()); const translate = (_key: string, options?: Record & { defaultValue?: string }) => ( @@ -96,7 +99,8 @@ vi.mock('../../api/service-api/ACPClientAPI', () => ({ loadJsonConfig: loadJsonConfigMock, getClients: getClientsMock, probeClientRequirements: probeClientRequirementsMock, - installClientCli: vi.fn(), + installClientCli: installClientCliMock, + predownloadClientAdapter: predownloadClientAdapterMock, saveJsonConfig: vi.fn(), }, })); @@ -107,6 +111,12 @@ vi.mock('../../api/service-api/SystemAPI', () => ({ }, })); +vi.mock('@/features/ssh-remote/sshApi', () => ({ + sshApi: { + listSavedConnections: listSavedConnectionsMock, + }, +})); + vi.mock('@/shared/notification-system', () => ({ useNotification: () => ({ error: notifyErrorMock, @@ -117,6 +127,7 @@ vi.mock('@/shared/notification-system', () => ({ vi.mock('@/shared/utils/logger', () => ({ createLogger: () => ({ error: vi.fn(), + warn: vi.fn(), }), })); @@ -151,7 +162,10 @@ describe('AcpAgentsConfig', () => { sessionCount: 0, toolName: 'acp__opencode__prompt', }]); + listSavedConnectionsMock.mockResolvedValue([]); probeClientRequirementsMock.mockResolvedValue([]); + installClientCliMock.mockResolvedValue(undefined); + predownloadClientAdapterMock.mockResolvedValue(undefined); container = document.createElement('div'); document.body.appendChild(container); @@ -168,7 +182,7 @@ describe('AcpAgentsConfig', () => { vi.clearAllMocks(); }); - it('does not probe external ACP CLIs just by opening the settings tab', async () => { + it('probes requirements when opened and does not treat missing probe data as invalid config', async () => { await act(async () => { root.render(); }); @@ -179,6 +193,152 @@ describe('AcpAgentsConfig', () => { expect(loadJsonConfigMock).toHaveBeenCalledTimes(1); expect(getClientsMock).toHaveBeenCalledTimes(1); - expect(probeClientRequirementsMock).not.toHaveBeenCalled(); + expect(probeClientRequirementsMock).toHaveBeenCalledTimes(1); + expect(container.textContent).not.toContain('registry.configInvalid'); + }); + + it('renders saved remote servers as global agent rows without override controls', async () => { + listSavedConnectionsMock.mockResolvedValue([{ + id: 'huawei-server', + name: 'huawei-server', + host: '119.8.182.138', + port: 22, + username: 'ssh-root', + authType: { type: 'Password' }, + }]); + + await act(async () => { + root.render(); + }); + + await act(async () => { + await Promise.resolve(); + await Promise.resolve(); + }); + + expect(container.textContent).toContain('huawei-server'); + expect(container.textContent).toContain('ssh-root@119.8.182.138'); + expect(container.textContent).toContain('remote.refreshDetection'); + expect(container.textContent).not.toContain('remote.env'); + expect(probeClientRequirementsMock).toHaveBeenCalledWith({ + remoteConnectionId: 'huawei-server', + force: undefined, + }); + }); + + it('configures a preset adapter when the CLI is ready but the ACP layer is missing', async () => { + probeClientRequirementsMock.mockResolvedValue([ + { + id: 'opencode', + tool: { name: 'opencode', installed: true }, + runnable: true, + notes: [], + }, + { + id: 'claude-code', + tool: { name: 'claude', installed: true }, + adapter: { name: '@zed-industries/claude-code-acp', installed: false }, + runnable: false, + notes: [], + }, + { + id: 'codex', + tool: { name: 'codex', installed: true }, + adapter: { name: '@zed-industries/codex-acp', installed: false }, + runnable: false, + notes: [], + }, + ]); + + await act(async () => { + root.render(); + }); + + await act(async () => { + await Promise.resolve(); + await Promise.resolve(); + }); + + const refreshButtons = Array.from(container.querySelectorAll('button')) + .filter(button => button.textContent?.includes('actions.refresh')); + expect(refreshButtons.length).toBeGreaterThan(0); + + await act(async () => { + refreshButtons[0].click(); + await Promise.resolve(); + await Promise.resolve(); + }); + + const configureButtons = Array.from(container.querySelectorAll('button')) + .filter(button => button.textContent?.includes('actions.configureAcp')); + expect(configureButtons.length).toBeGreaterThan(0); + + await act(async () => { + configureButtons[configureButtons.length - 1].click(); + await Promise.resolve(); + }); + + expect(predownloadClientAdapterMock).toHaveBeenCalledWith({ + clientId: 'codex', + }); + }); + + it('installs a missing remote preset CLI on that remote server', async () => { + listSavedConnectionsMock.mockResolvedValue([{ + id: 'huawei-server', + name: 'huawei-server', + host: '119.8.182.138', + port: 22, + username: 'ssh-root', + authType: { type: 'Password' }, + }]); + probeClientRequirementsMock.mockImplementation((options?: { remoteConnectionId?: string }) => { + if (options?.remoteConnectionId === 'huawei-server') { + return Promise.resolve([ + { + id: 'opencode', + tool: { name: 'opencode', installed: true }, + runnable: true, + notes: [], + }, + { + id: 'claude-code', + tool: { name: 'claude', installed: true }, + runnable: true, + notes: [], + }, + { + id: 'codex', + tool: { name: 'codex', installed: false }, + runnable: false, + notes: [], + }, + ]); + } + return Promise.resolve([]); + }); + + await act(async () => { + root.render(); + }); + + await act(async () => { + await Promise.resolve(); + await Promise.resolve(); + }); + + const installButtons = Array.from(container.querySelectorAll('button')) + .filter(button => button.textContent?.includes('actions.installCli')); + expect(installButtons.length).toBeGreaterThan(0); + + await act(async () => { + installButtons[installButtons.length - 1].click(); + await Promise.resolve(); + }); + + expect(installClientCliMock).toHaveBeenCalledWith({ + clientId: 'codex', + remoteConnectionId: 'huawei-server', + }); }); }); diff --git a/src/web-ui/src/infrastructure/config/components/AcpAgentsConfig.tsx b/src/web-ui/src/infrastructure/config/components/AcpAgentsConfig.tsx index 670d44f35..4280297e7 100644 --- a/src/web-ui/src/infrastructure/config/components/AcpAgentsConfig.tsx +++ b/src/web-ui/src/infrastructure/config/components/AcpAgentsConfig.tsx @@ -2,6 +2,7 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useTranslation } from 'react-i18next'; import { Bot, + CircleAlert, Download, ExternalLink, FileJson, @@ -10,6 +11,7 @@ import { RefreshCw, Save, Search, + Server, Terminal, } from 'lucide-react'; import { Button, Input, Select, Textarea } from '@/component-library'; @@ -27,6 +29,8 @@ import { type AcpRequirementProbeItem, } from '../../api/service-api/ACPClientAPI'; import { systemAPI } from '../../api/service-api/SystemAPI'; +import { sshApi } from '@/features/ssh-remote/sshApi'; +import type { SavedConnection } from '@/features/ssh-remote/types'; import { useNotification } from '@/shared/notification-system'; import { createLogger } from '@/shared/utils/logger'; import './AcpAgentsConfig.scss'; @@ -60,21 +64,21 @@ const PRESETS: AcpClientPreset[] = [ { id: 'opencode', name: 'opencode', - description: 'AI coding agent with native ACP support.', + description: 'Native ACP coding agent.', command: 'opencode', args: ['acp'], }, { id: 'claude-code', name: 'Claude Code', - description: 'Claude Code connected through the Zed ACP adapter.', + description: 'Claude Code via the Zed ACP adapter.', command: 'npx', args: ['--yes', '@zed-industries/claude-code-acp@latest'], }, { id: 'codex', name: 'Codex', - description: 'OpenAI Codex CLI connected through the Zed ACP adapter.', + description: 'OpenAI Codex via the Zed ACP adapter.', command: 'npx', args: ['--yes', '@zed-industries/codex-acp@latest'], }, @@ -190,23 +194,79 @@ function requirementTone(item?: AcpRequirementProbeItem): 'ok' | 'error' | 'mute } type RegistryFilter = 'all' | 'installed' | 'not_installed' | 'invalid'; -type AgentRowStatus = 'enabled' | 'ready' | 'not_installed' | 'invalid' | 'checking'; +type AgentRowStatus = 'enabled' | 'ready' | 'partial' | 'not_installed' | 'invalid' | 'checking'; + +type RequirementIssueKind = + | 'none' + | 'cli_missing' + | 'adapter_missing' + | 'connection_failed' + | 'permission_denied' + | 'path_invalid' + | 'version_mismatch' + | 'config_invalid'; + +function classifyRequirementError(error?: string): Exclude { + const lower = error?.toLowerCase() ?? ''; + if (!lower) { + return 'config_invalid'; + } + if ( + lower.includes('permission denied') || + lower.includes('operation not permitted') || + lower.includes('access denied') + ) { + return 'permission_denied'; + } + if ( + lower.includes('ssh') || + lower.includes('connection refused') || + lower.includes('timed out') || + lower.includes('timeout') || + lower.includes('network') || + lower.includes('host key') + ) { + return 'connection_failed'; + } + if ( + lower.includes('version') || + lower.includes('mismatch') || + lower.includes('incompatible') + ) { + return 'version_mismatch'; + } + if ( + lower.includes('not found') || + lower.includes('no such file or directory') || + lower.includes('command -v') || + lower.includes('path') + ) { + return 'path_invalid'; + } + return 'config_invalid'; +} function getAgentRowStatus({ configured, enabled, - runnable, + toolInstalled, + adapterInstalled, + requiresAdapter, probePending, }: { configured: boolean; enabled: boolean; - runnable?: boolean; + toolInstalled?: boolean; + adapterInstalled?: boolean; + requiresAdapter: boolean; probePending: boolean; }): AgentRowStatus { if (probePending) return 'checking'; - if (!configured) return runnable ? 'ready' : 'not_installed'; + if (toolInstalled === false) return 'not_installed'; + if (requiresAdapter && adapterInstalled === false) return 'partial'; + if (!configured) return 'ready'; if (!enabled) return 'invalid'; - return runnable === false ? 'invalid' : 'enabled'; + return 'enabled'; } function CapabilityBadge({ @@ -247,12 +307,14 @@ function CapabilityBadge({ function AgentStatusBadge({ status, label, + title, }: { status: AgentRowStatus; label: string; + title?: string; }) { return ( - + {status === 'checking' && } {label} @@ -266,6 +328,7 @@ const AcpAgentsConfig: React.FC = () => { const [config, setConfig] = useState({ acpClients: {} }); const [clients, setClients] = useState([]); + const [savedConnections, setSavedConnections] = useState([]); const [loading, setLoading] = useState(true); const [saving, setSaving] = useState(false); const [dirty, setDirty] = useState(false); @@ -275,13 +338,26 @@ const AcpAgentsConfig: React.FC = () => { const [requirementProbes, setRequirementProbes] = useState( requirementProbeCache ?? [] ); + const [remoteRequirementProbes, setRemoteRequirementProbes] = useState>({}); + const [probingRemoteRequirements, setProbingRemoteRequirements] = useState>(() => new Set()); const [probingRequirements, setProbingRequirements] = useState(false); const [registrySearch, setRegistrySearch] = useState(''); const [registryFilter, setRegistryFilter] = useState('all'); const [installingClientIds, setInstallingClientIds] = useState>(() => new Set()); + const [installingRemoteClientIds, setInstallingRemoteClientIds] = useState>(() => new Set()); const requirementProbeRequestIdRef = useRef(0); + const loadedRemoteProbeIdsRef = useRef>(new Set()); + const [remoteProbeRefreshNonce, setRemoteProbeRefreshNonce] = useState(0); const clientsById = useMemo(() => new Map(clients.map(client => [client.id, client])), [clients]); + const remoteConnectionRows = useMemo(() => { + return [...savedConnections].sort((left, right) => { + const leftTime = left.lastConnected ?? 0; + const rightTime = right.lastConnected ?? 0; + if (leftTime !== rightTime) return rightTime - leftTime; + return (left.name || left.id).localeCompare(right.name || right.id); + }); + }, [savedConnections]); const probesById = useMemo( () => new Map(requirementProbes.map(probe => [probe.id, probe])), [requirementProbes] @@ -307,7 +383,9 @@ const AcpAgentsConfig: React.FC = () => { const status = getAgentRowStatus({ configured, enabled, - runnable: probe?.runnable === true, + toolInstalled: probe?.tool.installed, + adapterInstalled: probe?.adapter?.installed, + requiresAdapter: Boolean(probe?.adapter || preset.id !== 'opencode'), probePending, }); if (registryFilter === 'installed' && status !== 'enabled' && status !== 'ready') return false; @@ -336,7 +414,9 @@ const AcpAgentsConfig: React.FC = () => { const status = getAgentRowStatus({ configured, enabled, - runnable: requirementProbe?.runnable === true, + toolInstalled: requirementProbe?.tool.installed, + adapterInstalled: requirementProbe?.adapter?.installed, + requiresAdapter: Boolean(requirementProbe?.adapter), probePending, }); if (registryFilter === 'installed' && status !== 'enabled' && status !== 'ready') return false; @@ -383,11 +463,50 @@ const AcpAgentsConfig: React.FC = () => { } }, [notifyError, t]); + const refreshRemoteRequirementProbes = useCallback(async ( + connectionId: string, + options: { force?: boolean; notifyOnError?: boolean } = {} + ) => { + const normalizedConnectionId = connectionId.trim(); + if (!normalizedConnectionId) return; + if (!options.force && loadedRemoteProbeIdsRef.current.has(normalizedConnectionId)) return; + + setProbingRemoteRequirements(prev => { + const next = new Set(prev); + next.add(normalizedConnectionId); + return next; + }); + try { + const nextRequirementProbes = await ACPClientAPI.probeClientRequirements({ + remoteConnectionId: normalizedConnectionId, + force: options.force, + }); + loadedRemoteProbeIdsRef.current.add(normalizedConnectionId); + setRemoteRequirementProbes(prev => ({ + ...prev, + [normalizedConnectionId]: nextRequirementProbes, + })); + } catch (error) { + log.error('Failed to probe remote ACP agent requirements', error); + if (options.notifyOnError ?? true) { + notifyError(error instanceof Error ? error.message : String(error), { + title: t('notifications.probeFailed'), + }); + } + } finally { + setProbingRemoteRequirements(prev => { + const next = new Set(prev); + next.delete(normalizedConnectionId); + return next; + }); + } + }, [notifyError, t]); + const loadConfig = useCallback(async ( options: { showLoading?: boolean; refreshRequirements?: boolean } = {} ) => { const showLoading = options.showLoading ?? true; - const refreshRequirements = options.refreshRequirements ?? false; + const refreshRequirements = options.refreshRequirements ?? true; try { if (showLoading) { setLoading(true); @@ -396,6 +515,10 @@ const AcpAgentsConfig: React.FC = () => { ACPClientAPI.loadJsonConfig(), ACPClientAPI.getClients(), ]); + const nextSavedConnections = await sshApi.listSavedConnections().catch((error) => { + log.warn('Failed to load saved SSH connections for ACP remote overrides', error); + return [] as SavedConnection[]; + }); const parsed = normalizeConfigValue(JSON.parse(rawConfig || '{}')); setConfig(parsed); setJsonConfig(formatConfig(parsed)); @@ -408,6 +531,7 @@ const AcpAgentsConfig: React.FC = () => { ) ); setClients(nextClients); + setSavedConnections(nextSavedConnections); setDirty(false); if (refreshRequirements) { void refreshRequirementProbes({ notifyOnError: false }); @@ -438,6 +562,13 @@ const AcpAgentsConfig: React.FC = () => { }; }, [loadConfig]); + useEffect(() => { + if (loading) return; + for (const connection of remoteConnectionRows) { + void refreshRemoteRequirementProbes(connection.id, { notifyOnError: false }); + } + }, [loading, refreshRemoteRequirementProbes, remoteConnectionRows, remoteProbeRefreshNonce]); + const patchClientConfig = (clientId: string, patch: Partial) => { setConfig(prev => { const preset = PRESET_BY_ID.get(clientId); @@ -446,6 +577,7 @@ const AcpAgentsConfig: React.FC = () => { if (!current) return prev; const next = { + ...prev, acpClients: { ...prev.acpClients, [clientId]: { @@ -460,22 +592,60 @@ const AcpAgentsConfig: React.FC = () => { setDirty(true); }; - const installPresetClient = async (preset: AcpClientPreset) => { - setInstallingClientIds(prev => new Set(prev).add(preset.id)); + const installPresetClient = async ( + preset: AcpClientPreset, + options: { remoteConnectionId?: string } = {} + ) => { + const remoteConnectionId = options.remoteConnectionId?.trim(); + const installKey = remoteConnectionId ? `${remoteConnectionId}:${preset.id}` : preset.id; + const setInstalling = remoteConnectionId ? setInstallingRemoteClientIds : setInstallingClientIds; + setInstalling(prev => new Set(prev).add(installKey)); + try { + await ACPClientAPI.installClientCli({ + clientId: preset.id, + remoteConnectionId, + }); + if (remoteConnectionId) { + loadedRemoteProbeIdsRef.current.delete(remoteConnectionId); + await refreshRemoteRequirementProbes(remoteConnectionId, { force: true, notifyOnError: false }); + } else { + requirementProbeCache = null; + await refreshRequirementProbes({ force: true, notifyOnError: false }); + } + notifySuccess(t('notifications.installSuccess')); + } catch (error) { + log.error('Failed to install ACP agent CLI', error); + notifyError(error instanceof Error ? error.message : String(error), { + title: t('notifications.installFailed'), + }); + } finally { + setInstalling(prev => { + const next = new Set(prev); + next.delete(installKey); + return next; + }); + } + }; + + const configurePresetClient = async (preset: AcpClientPreset) => { + const installKey = preset.id; + setInstallingClientIds(prev => new Set(prev).add(installKey)); try { - await ACPClientAPI.installClientCli({ clientId: preset.id }); + await ACPClientAPI.predownloadClientAdapter({ + clientId: preset.id, + }); requirementProbeCache = null; await refreshRequirementProbes({ force: true, notifyOnError: false }); - notifySuccess(t('notifications.downloadSuccess')); + notifySuccess(t('notifications.configureSuccess')); } catch (error) { - log.error('Failed to download ACP agent CLI', error); + log.error('Failed to predownload ACP adapter', error); notifyError(error instanceof Error ? error.message : String(error), { - title: t('notifications.downloadFailed'), + title: t('notifications.configureFailed'), }); } finally { setInstallingClientIds(prev => { const next = new Set(prev); - next.delete(preset.id); + next.delete(installKey); return next; }); } @@ -509,6 +679,9 @@ const AcpAgentsConfig: React.FC = () => { setDirty(false); requirementProbeCache = null; setRequirementProbes([]); + loadedRemoteProbeIdsRef.current.clear(); + setRemoteRequirementProbes({}); + setRemoteProbeRefreshNonce(prev => prev + 1); notifySuccess(t('notifications.saveSuccess')); } catch (error) { log.error('Failed to save ACP agent config', error); @@ -523,6 +696,7 @@ const AcpAgentsConfig: React.FC = () => { const addPresetClient = async (preset: AcpClientPreset) => { const nextClient = defaultConfigForPreset(preset); const next = { + ...config, acpClients: { ...config.acpClients, [preset.id]: nextClient, @@ -572,14 +746,118 @@ const AcpAgentsConfig: React.FC = () => { { value: 'invalid', label: t('registry.filters.configInvalid') }, ], [t]); - const getStatusLabel = useCallback((status: AgentRowStatus) => { + const getIssueKind = useCallback((args: { + probe?: AcpClientRequirementProbe; + requiresAdapter: boolean; + }): RequirementIssueKind => { + const { probe, requiresAdapter } = args; + if (!probe) return 'config_invalid'; + + const toolIssue = classifyRequirementError(probe.tool.error); + if (toolIssue !== 'config_invalid') { + return toolIssue; + } + + if (!probe.tool.installed) { + return 'cli_missing'; + } + + if (requiresAdapter) { + if (probe.adapter?.error) { + const adapterIssue = classifyRequirementError(probe.adapter.error); + if (adapterIssue !== 'config_invalid') { + return adapterIssue; + } + } + if (probe.adapter && !probe.adapter.installed) { + return 'adapter_missing'; + } + } + + return probe.runnable ? 'none' : 'config_invalid'; + }, []); + + const getStatusLabel = useCallback((args: { + status: AgentRowStatus; + issueKind: RequirementIssueKind; + probe?: AcpClientRequirementProbe; + requiresAdapter: boolean; + }) => { + const { status, issueKind, probe, requiresAdapter } = args; if (status === 'enabled') return t('registry.enabled'); if (status === 'ready') return t('registry.ready'); - if (status === 'not_installed') return t('registry.notInstalled'); + if (status === 'partial') return t('registry.partial'); if (status === 'checking') return t('registry.checking'); + + if (issueKind === 'connection_failed') return t('registry.connectionFailed'); + if (issueKind === 'permission_denied') return t('registry.permissionDenied'); + if (issueKind === 'path_invalid') return t('registry.pathInvalid'); + if (issueKind === 'version_mismatch') return t('registry.versionMismatch'); + if (issueKind === 'adapter_missing' || (requiresAdapter && probe?.adapter && !probe.adapter.installed)) { + return t('registry.acpMissing'); + } + if (issueKind === 'cli_missing' || probe?.tool.installed === false) { + return t('registry.cliMissing'); + } return t('registry.configInvalid'); }, [t]); + const getStatusTitle = useCallback((args: { + status: AgentRowStatus; + issueKind: RequirementIssueKind; + probe?: AcpClientRequirementProbe; + requiresAdapter: boolean; + }) => { + const { status, issueKind, probe, requiresAdapter } = args; + const lines: string[] = []; + if (status === 'enabled') { + lines.push(t('registry.enabled')); + } else if (status === 'ready') { + lines.push(t('registry.ready')); + } else if (status === 'partial') { + lines.push(t('registry.partialDetail')); + } else if (status === 'checking') { + lines.push(t('registry.checking')); + } + + if (issueKind === 'connection_failed') { + lines.push(t('registry.connectionFailedDetail')); + } else if (issueKind === 'permission_denied') { + lines.push(t('registry.permissionDeniedDetail')); + } else if (issueKind === 'path_invalid') { + lines.push(t('registry.pathInvalidDetail')); + } else if (issueKind === 'version_mismatch') { + lines.push(t('registry.versionMismatchDetail')); + } else if (issueKind === 'adapter_missing' || (requiresAdapter && probe?.adapter && !probe.adapter.installed)) { + lines.push(t('registry.acpMissingDetail')); + } else if (issueKind === 'cli_missing' || probe?.tool.installed === false) { + lines.push(t('registry.cliMissingDetail')); + } else if (status === 'invalid') { + lines.push(t('registry.configInvalidDetail')); + } + + if (probe?.tool.path) { + lines.push(`${t('registry.toolPath')}: ${probe.tool.path}`); + } + if (probe?.tool.version) { + lines.push(`${t('registry.toolVersion')}: ${probe.tool.version}`); + } + if (probe?.tool.error) { + lines.push(probe.tool.error); + } + if (probe?.adapter?.error) { + lines.push(probe.adapter.error); + } + if (probe?.notes.length) { + lines.push(...probe.notes); + } + return lines.filter(Boolean).join('\n'); + }, [t]); + + const getRemoteSummary = useCallback((available: number, total: number) => { + return t('remote.summary', { available, total }); + }, [t]); + const openLearnMore = useCallback(() => { void systemAPI.openExternal('https://agentclientprotocol.com/get-started/introduction').catch((error) => { log.error('Failed to open ACP documentation', error); @@ -589,6 +867,23 @@ const AcpAgentsConfig: React.FC = () => { }); }, [notifyError, t]); + const remoteAgentIds = useMemo(() => { + const ids = new Set([ + ...PRESETS.map(preset => preset.id), + ...Object.keys(config.acpClients), + ]); + return Array.from(ids).sort((left, right) => { + const leftPresetIndex = PRESETS.findIndex(preset => preset.id === left); + const rightPresetIndex = PRESETS.findIndex(preset => preset.id === right); + if (leftPresetIndex !== -1 || rightPresetIndex !== -1) { + if (leftPresetIndex === -1) return 1; + if (rightPresetIndex === -1) return -1; + return leftPresetIndex - rightPresetIndex; + } + return left.localeCompare(right); + }); + }, [config.acpClients]); + return ( { const hasConfigEntry = Boolean(config.acpClients[preset.id]); const configured = hasConfigEntry; const enabled = clientConfig.enabled; - const runnable = requirementProbe?.runnable === true; - const status = getAgentRowStatus({ configured, enabled, runnable, probePending }); + const requiresAdapter = preset.id !== 'opencode' || Boolean(requirementProbe?.adapter); + const issueKind = getIssueKind({ probe: requirementProbe, requiresAdapter }); + const status = getAgentRowStatus({ + configured, + enabled, + toolInstalled: requirementProbe?.tool.installed, + adapterInstalled: requirementProbe?.adapter?.installed, + requiresAdapter, + probePending, + }); + const statusLabel = getStatusLabel({ + status, + issueKind, + probe: requirementProbe, + requiresAdapter, + }); + const statusTitle = getStatusTitle({ + status, + issueKind, + probe: requirementProbe, + requiresAdapter, + }); const installing = installingClientIds.has(preset.id); + const configuring = installingClientIds.has(preset.id); + const showSelect = hasConfigEntry && (status === 'enabled' || status === 'ready'); + const canInstallCli = status === 'not_installed' && issueKind !== 'connection_failed'; + const canConfigureAcp = !requiresAdapter + ? false + : issueKind === 'adapter_missing' || (status === 'partial' && issueKind === 'config_invalid'); + const canViewError = status === 'invalid' + || issueKind === 'connection_failed' + || issueKind === 'permission_denied' + || issueKind === 'path_invalid' + || issueKind === 'version_mismatch'; return (
@@ -737,10 +1063,10 @@ const AcpAgentsConfig: React.FC = () => { />
- +
- {hasConfigEntry ? ( + {showSelect ? ( patchClientConfig(clientId, { - permissionMode: normalizePermissionMode(value), - })} - size="small" - /> + {status === 'enabled' || status === 'ready' ? ( + patchClientConfig(row.clientId, { + permissionMode: normalizePermissionMode(value), + })} + size="small" + /> + ) : row.preset ? ( + + ) : null + ) : canViewError ? ( + + ) : ( + null + )} +
+ + ); + })} + + + ); + })} + + )} +
diff --git a/src/web-ui/src/locales/en-US/settings/acp-agents.json b/src/web-ui/src/locales/en-US/settings/acp-agents.json index 3ee10c0f0..ff2990ecb 100644 --- a/src/web-ui/src/locales/en-US/settings/acp-agents.json +++ b/src/web-ui/src/locales/en-US/settings/acp-agents.json @@ -4,6 +4,9 @@ "actions": { "add": "Add", "download": "Download", + "installCli": "Install CLI", + "configureAcp": "Configure ACP", + "viewError": "View error", "save": "Save", "refresh": "Refresh", "learnMore": "Learn More", @@ -21,7 +24,7 @@ }, "registry": { "title": "Agents", - "description": "Installed ACP agents with ready dependencies appear in the workspace action menu.", + "description": "Installed agents appear in the workspace action menu. Remote workspaces reuse the same list and check the remote host before showing status.", "searchPlaceholder": "Search agents...", "filterLabel": "ACP agent filter", "filters": { @@ -32,17 +35,42 @@ "configInvalid": "Invalid config" }, "enabled": "Enabled", - "ready": "Ready", + "ready": "Available", + "partial": "Partially available", "installed": "Installed", "notInstalled": "Not Installed", "configInvalid": "Invalid config", - "cliRequired": "Install CLI", "checking": "Checking", + "cliMissing": "CLI missing", + "acpMissing": "ACP missing", + "pathInvalid": "Path invalid", + "versionMismatch": "Version mismatch", + "connectionFailed": "Connection failed", + "permissionDenied": "Permission denied", + "partialDetail": "The CLI is ready, but the ACP adapter is not configured.", + "cliMissingDetail": "No CLI command was found on PATH.", + "acpMissingDetail": "The ACP adapter is not available yet.", + "pathInvalidDetail": "The command path could not be resolved.", + "versionMismatchDetail": "The detected version does not match the expected runtime.", + "connectionFailedDetail": "The remote host could not be reached.", + "permissionDeniedDetail": "The command could not run because of a permission problem.", + "configInvalidDetail": "The current ACP configuration cannot be used as-is.", + "toolPath": "Path", + "toolVersion": "Version", "empty": "No matching ACP agents." }, "clients": { "loading": "Loading ACP agents…" }, + "remote": { + "title": "Remote Servers", + "description": "Saved SSH servers reuse the same ACP agent list and probe each remote host automatically.", + "empty": "No saved SSH servers.", + "noAgents": "Add an ACP agent before checking remote servers.", + "refreshDetection": "Refresh detection", + "summary": "{{available}} / {{total}} available", + "issueSummary": "{{count}} issue(s)" + }, "fields": { "name": "Name", "command": "Command", @@ -72,6 +100,10 @@ "openLinkFailed": "Failed to open link", "invalidJson": "Invalid ACP JSON", "invalidEnv": "Invalid environment value", + "installSuccess": "ACP CLI installed", + "installFailed": "Failed to install ACP CLI", + "configureSuccess": "ACP adapter prepared", + "configureFailed": "Failed to prepare ACP adapter", "downloadSuccess": "ACP agent CLI downloaded", "downloadFailed": "Failed to download ACP agent CLI", "predownloadSuccess": "ACP adapter downloaded", diff --git a/src/web-ui/src/locales/zh-CN/settings/acp-agents.json b/src/web-ui/src/locales/zh-CN/settings/acp-agents.json index b3a6e5f3d..2bca2e993 100644 --- a/src/web-ui/src/locales/zh-CN/settings/acp-agents.json +++ b/src/web-ui/src/locales/zh-CN/settings/acp-agents.json @@ -4,6 +4,9 @@ "actions": { "add": "添加", "download": "下载", + "installCli": "安装 CLI", + "configureAcp": "配置 ACP", + "viewError": "查看错误", "save": "保存", "refresh": "刷新", "learnMore": "了解更多", @@ -21,7 +24,7 @@ }, "registry": { "title": "Agent 列表", - "description": "安装完成且依赖就绪后,ACP Agent 会出现在工作区操作菜单里。", + "description": "已安装的 Agent 会出现在工作区操作菜单里。远程工作区会复用同一份列表,并先在远端主机检测状态。", "searchPlaceholder": "搜索 Agent...", "filterLabel": "ACP Agent 筛选", "filters": { @@ -32,17 +35,42 @@ "configInvalid": "配置异常" }, "enabled": "已启用", - "ready": "依赖就绪", + "ready": "可用", + "partial": "部分可用", "installed": "已安装", "notInstalled": "未安装", "configInvalid": "配置异常", - "cliRequired": "需安装 CLI", "checking": "检测中", + "cliMissing": "CLI 未安装", + "acpMissing": "ACP 未配置", + "pathInvalid": "路径无效", + "versionMismatch": "版本异常", + "connectionFailed": "连接失败", + "permissionDenied": "权限不足", + "partialDetail": "CLI 已就绪,但 ACP 适配器还未配置。", + "cliMissingDetail": "未检测到对应命令,请确认已安装并加入 PATH。", + "acpMissingDetail": "ACP 适配器暂不可用。", + "pathInvalidDetail": "命令路径无法解析,请确认路径有效。", + "versionMismatchDetail": "检测到的版本与当前运行环境不匹配。", + "connectionFailedDetail": "远端主机当前无法连接。", + "permissionDeniedDetail": "命令无法执行,可能是权限不足。", + "configInvalidDetail": "当前 ACP 配置暂时无法直接使用。", + "toolPath": "路径", + "toolVersion": "版本", "empty": "没有匹配的 ACP Agent。" }, "clients": { "loading": "正在加载 ACP Agent…" }, + "remote": { + "title": "远程服务器", + "description": "已保存的 SSH 服务器会复用同一份 ACP Agent 列表,并自动检测每台远端主机的状态。", + "empty": "没有已保存的 SSH 服务器。", + "noAgents": "请先添加 ACP Agent,再检测远程服务器。", + "refreshDetection": "刷新检测", + "summary": "{{available}} / {{total}} 可用", + "issueSummary": "{{count}} 个异常" + }, "fields": { "name": "名称", "command": "命令", @@ -72,6 +100,10 @@ "openLinkFailed": "打开链接失败", "invalidJson": "ACP JSON 无效", "invalidEnv": "环境变量无效", + "installSuccess": "ACP CLI 已安装", + "installFailed": "安装 ACP CLI 失败", + "configureSuccess": "ACP 适配器已就绪", + "configureFailed": "配置 ACP 适配器失败", "downloadSuccess": "ACP Agent CLI 已下载", "downloadFailed": "下载 ACP Agent CLI 失败", "predownloadSuccess": "ACP 适配器已下载", diff --git a/src/web-ui/src/locales/zh-TW/settings/acp-agents.json b/src/web-ui/src/locales/zh-TW/settings/acp-agents.json index b229dd41f..59a7f895a 100644 --- a/src/web-ui/src/locales/zh-TW/settings/acp-agents.json +++ b/src/web-ui/src/locales/zh-TW/settings/acp-agents.json @@ -4,6 +4,9 @@ "actions": { "add": "添加", "download": "下載", + "installCli": "安裝 CLI", + "configureAcp": "配置 ACP", + "viewError": "查看錯誤", "save": "保存", "refresh": "刷新", "learnMore": "了解更多", @@ -21,7 +24,7 @@ }, "registry": { "title": "Agent 列表", - "description": "安裝完成且依賴就緒後,ACP Agent 會出現在工作區操作菜單裡。", + "description": "已安裝的 Agent 會出現在工作區操作菜單裡。遠端工作區會複用同一份列表,並先在遠端主機檢測狀態。", "searchPlaceholder": "搜尋 Agent...", "filterLabel": "ACP Agent 篩選", "filters": { @@ -32,17 +35,42 @@ "configInvalid": "配置異常" }, "enabled": "已啟用", - "ready": "依賴就緒", + "ready": "可用", + "partial": "部分可用", "installed": "已安裝", "notInstalled": "未安裝", "configInvalid": "配置異常", - "cliRequired": "需安裝 CLI", "checking": "檢測中", + "cliMissing": "CLI 未安裝", + "acpMissing": "ACP 未配置", + "pathInvalid": "路徑無效", + "versionMismatch": "版本異常", + "connectionFailed": "連線失敗", + "permissionDenied": "權限不足", + "partialDetail": "CLI 已就緒,但 ACP 適配器尚未配置。", + "cliMissingDetail": "未檢測到對應命令,請確認已安裝並加入 PATH。", + "acpMissingDetail": "ACP 適配器暫不可用。", + "pathInvalidDetail": "命令路徑無法解析,請確認路徑有效。", + "versionMismatchDetail": "偵測到的版本與目前執行環境不匹配。", + "connectionFailedDetail": "遠端主機目前無法連線。", + "permissionDeniedDetail": "命令無法執行,可能是權限不足。", + "configInvalidDetail": "目前 ACP 設定暫時無法直接使用。", + "toolPath": "路徑", + "toolVersion": "版本", "empty": "沒有匹配的 ACP Agent。" }, "clients": { "loading": "正在加載 ACP Agent…" }, + "remote": { + "title": "遠端伺服器", + "description": "已保存的 SSH 伺服器會複用同一份 ACP Agent 列表,並自動檢測每台遠端主機的狀態。", + "empty": "沒有已保存的 SSH 伺服器。", + "noAgents": "請先添加 ACP Agent,再檢測遠端伺服器。", + "refreshDetection": "刷新檢測", + "summary": "{{available}} / {{total}} 可用", + "issueSummary": "{{count}} 個異常" + }, "fields": { "name": "名稱", "command": "命令", @@ -72,6 +100,10 @@ "openLinkFailed": "打開鏈接失敗", "invalidJson": "ACP JSON 無效", "invalidEnv": "環境變量無效", + "installSuccess": "ACP CLI 已安裝", + "installFailed": "安裝 ACP CLI 失敗", + "configureSuccess": "ACP 適配器已就緒", + "configureFailed": "配置 ACP 適配器失敗", "downloadSuccess": "ACP Agent CLI 已下載", "downloadFailed": "下載 ACP Agent CLI 失敗", "predownloadSuccess": "ACP 適配器已下載",