From deb75ead3b5a62f9312af8164b1345f621019e8a Mon Sep 17 00:00:00 2001 From: wgqqqqq Date: Fri, 15 May 2026 16:15:21 +0800 Subject: [PATCH 1/3] fix: reuse local ACP config for remote workspaces --- src/crates/acp/src/client/builtin_clients.rs | 72 --------- src/crates/acp/src/client/manager.rs | 140 ++++++++++++------ src/crates/acp/src/client/mod.rs | 1 + .../acp/src/client/remote_capability_store.rs | 8 + src/crates/acp/src/client/remote_shell.rs | 84 +++++++++++ src/crates/acp/src/client/requirements.rs | 44 ++++-- src/web-ui/src/app/App.tsx | 9 +- .../workspaceAcpMenuClients.test.ts | 86 ++++++++++- .../workspaces/workspaceAcpMenuClients.ts | 68 +++++---- .../components/AcpAgentsConfig.test.tsx | 5 +- .../config/components/AcpAgentsConfig.tsx | 10 +- .../locales/en-US/settings/acp-agents.json | 2 +- .../locales/zh-CN/settings/acp-agents.json | 2 +- .../locales/zh-TW/settings/acp-agents.json | 2 +- 14 files changed, 355 insertions(+), 178 deletions(-) create mode 100644 src/crates/acp/src/client/remote_shell.rs 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..0e9e12ee6 100644 --- a/src/crates/acp/src/client/manager.rs +++ b/src/crates/acp/src/client/manager.rs @@ -33,17 +33,14 @@ 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, @@ -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 configs = self.load_configs().await?; + let mut ids = configs.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 = configs.get(&id); + let spec = acp_requirement_spec(&id, config); + let tool = probe_remote_executable( + &ssh_manager, + remote_connection_id, + spec.tool_command, + config.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.map(|config| &config.env), + ) + .await, ), None => None, }; @@ -769,6 +780,7 @@ impl AcpClientService { self.config_service .set_config(CONFIG_PATH, canonical_value) .await?; + self.remote_capability_store.clear().await?; self.initialize_all().await } @@ -1533,7 +1545,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 +1558,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,16 +1596,15 @@ 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 is_remote = remote_connection_id.is_some(); + let mut used_remote_default = false; let mut config = self .load_configs() .await? .remove(client_id) .or_else(|| { - if remote_connection_id.is_some() { + if is_remote { + used_remote_default = true; default_config_for_builtin_client(client_id) } else { None @@ -1612,9 +1612,15 @@ impl AcpClientService { }) .ok_or_else(|| BitFunError::NotFound(format!("ACP client not found: {}", client_id)))?; - if remote_builtin { + if used_remote_default { config.enabled = true; } + 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!( "ACP client is disabled: {}", @@ -1622,7 +1628,7 @@ impl AcpClientService { ))); } - if remote_connection_id.is_some() { + if is_remote { ensure_remote_client_supported(client_id, workspace_path)?; } @@ -1634,7 +1640,7 @@ impl AcpClientService { } fn ensure_remote_client_supported( - client_id: &str, + _client_id: &str, workspace_path: Option<&str>, ) -> BitFunResult<()> { if workspace_path @@ -1646,14 +1652,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 +2098,27 @@ 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'\\''" + )); + } } 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..aedae55f2 100644 --- a/src/crates/acp/src/client/requirements.rs +++ b/src/crates/acp/src/client/requirements.rs @@ -10,6 +10,7 @@ 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 => { @@ -403,14 +410,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 +603,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/config/components/AcpAgentsConfig.test.tsx b/src/web-ui/src/infrastructure/config/components/AcpAgentsConfig.test.tsx index 888adbf15..d64538da7 100644 --- a/src/web-ui/src/infrastructure/config/components/AcpAgentsConfig.test.tsx +++ b/src/web-ui/src/infrastructure/config/components/AcpAgentsConfig.test.tsx @@ -168,7 +168,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 +179,7 @@ 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'); }); }); diff --git a/src/web-ui/src/infrastructure/config/components/AcpAgentsConfig.tsx b/src/web-ui/src/infrastructure/config/components/AcpAgentsConfig.tsx index 670d44f35..8573bc3f4 100644 --- a/src/web-ui/src/infrastructure/config/components/AcpAgentsConfig.tsx +++ b/src/web-ui/src/infrastructure/config/components/AcpAgentsConfig.tsx @@ -307,7 +307,7 @@ const AcpAgentsConfig: React.FC = () => { const status = getAgentRowStatus({ configured, enabled, - runnable: probe?.runnable === true, + runnable: probe?.runnable, probePending, }); if (registryFilter === 'installed' && status !== 'enabled' && status !== 'ready') return false; @@ -336,7 +336,7 @@ const AcpAgentsConfig: React.FC = () => { const status = getAgentRowStatus({ configured, enabled, - runnable: requirementProbe?.runnable === true, + runnable: requirementProbe?.runnable, probePending, }); if (registryFilter === 'installed' && status !== 'enabled' && status !== 'ready') return false; @@ -387,7 +387,7 @@ const AcpAgentsConfig: React.FC = () => { 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); @@ -710,7 +710,7 @@ const AcpAgentsConfig: React.FC = () => { const hasConfigEntry = Boolean(config.acpClients[preset.id]); const configured = hasConfigEntry; const enabled = clientConfig.enabled; - const runnable = requirementProbe?.runnable === true; + const runnable = requirementProbe?.runnable; const status = getAgentRowStatus({ configured, enabled, runnable, probePending }); const installing = installingClientIds.has(preset.id); @@ -783,7 +783,7 @@ const AcpAgentsConfig: React.FC = () => { const requirementProbe = probesById.get(clientId); const probePending = probingRequirements && !requirementProbe; - const runnable = requirementProbe?.runnable === true; + const runnable = requirementProbe?.runnable; const status = getAgentRowStatus({ configured: true, enabled: clientConfig.enabled !== false, 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..218dcf28d 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 @@ -21,7 +21,7 @@ }, "registry": { "title": "Agents", - "description": "Installed ACP agents with ready dependencies appear in the workspace action menu.", + "description": "Installed ACP agents with ready dependencies appear in the workspace action menu. Remote workspaces reuse this local config and check commands on the remote host.", "searchPlaceholder": "Search agents...", "filterLabel": "ACP agent filter", "filters": { 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..412331ec2 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 @@ -21,7 +21,7 @@ }, "registry": { "title": "Agent 列表", - "description": "安装完成且依赖就绪后,ACP Agent 会出现在工作区操作菜单里。", + "description": "安装完成且依赖就绪后,ACP Agent 会出现在工作区操作菜单里。远程工作区会复用这里的本地配置,并在远端主机检测命令。", "searchPlaceholder": "搜索 Agent...", "filterLabel": "ACP Agent 筛选", "filters": { 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..2d983a1ad 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 @@ -21,7 +21,7 @@ }, "registry": { "title": "Agent 列表", - "description": "安裝完成且依賴就緒後,ACP Agent 會出現在工作區操作菜單裡。", + "description": "安裝完成且依賴就緒後,ACP Agent 會出現在工作區操作菜單裡。遠端工作區會複用這裡的本地配置,並在遠端主機檢測命令。", "searchPlaceholder": "搜尋 Agent...", "filterLabel": "ACP Agent 篩選", "filters": { From 487d52758e2d133a15ccf194aefd0db9310350fa Mon Sep 17 00:00:00 2001 From: wgqqqqq Date: Mon, 18 May 2026 09:36:02 +0800 Subject: [PATCH 2/3] feat: update ACP agents config --- src/apps/desktop/src/api/acp_client_api.rs | 4 +- src/crates/acp/src/client/config.rs | 24 ++ src/crates/acp/src/client/manager.rs | 185 +++++++-- src/crates/acp/src/client/mod.rs | 4 +- src/crates/acp/src/client/requirements.rs | 41 +- .../api/service-api/ACPClientAPI.ts | 1 + .../config/components/AcpAgentsConfig.scss | 47 ++- .../components/AcpAgentsConfig.test.tsx | 103 ++++- .../config/components/AcpAgentsConfig.tsx | 353 +++++++++++++++++- .../locales/en-US/settings/acp-agents.json | 8 + .../locales/zh-CN/settings/acp-agents.json | 8 + .../locales/zh-TW/settings/acp-agents.json | 8 + 12 files changed, 740 insertions(+), 46 deletions(-) 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/config.rs b/src/crates/acp/src/client/config.rs index fc9ffdd22..6c28c2659 100644 --- a/src/crates/acp/src/client/config.rs +++ b/src/crates/acp/src/client/config.rs @@ -7,6 +7,8 @@ use serde::{Deserialize, Serialize}; pub struct AcpClientConfigFile { #[serde(default)] pub acp_clients: HashMap, + #[serde(default, skip_serializing_if = "HashMap::is_empty")] + pub remote_overrides: HashMap, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -27,6 +29,28 @@ pub struct AcpClientConfig { pub permission_mode: AcpClientPermissionMode, } +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +#[serde(rename_all = "camelCase")] +pub struct AcpRemoteOverrideConfig { + #[serde(default)] + pub env: HashMap, + #[serde(default)] + pub clients: HashMap, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +#[serde(rename_all = "camelCase")] +pub struct AcpRemoteClientOverride { + #[serde(default)] + pub command: Option, + #[serde(default)] + pub args: Option>, + #[serde(default)] + pub env: HashMap, + #[serde(default)] + pub enabled: Option, +} + #[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "snake_case")] pub enum AcpClientPermissionMode { diff --git a/src/crates/acp/src/client/manager.rs b/src/crates/acp/src/client/manager.rs index 0e9e12ee6..a84c66fe3 100644 --- a/src/crates/acp/src/client/manager.rs +++ b/src/crates/acp/src/client/manager.rs @@ -36,15 +36,16 @@ use tokio_util::compat::{TokioAsyncReadCompatExt, TokioAsyncWriteCompatExt}; use super::builtin_clients::{builtin_client_ids, default_config_for_builtin_client}; use super::config::{ AcpClientConfig, AcpClientConfigFile, AcpClientInfo, AcpClientPermissionMode, - AcpClientRequirementProbe, AcpClientStatus, RemoteAcpClientRequirementSnapshot, + AcpClientRequirementProbe, AcpClientStatus, AcpRemoteClientOverride, AcpRemoteOverrideConfig, + 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; @@ -359,24 +360,31 @@ impl AcpClientService { BitFunError::service("SSH manager is not available for remote ACP".to_string()) })?; - let configs = self.load_configs().await?; - let mut ids = configs.keys().cloned().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()); } } + if let Some(remote_override) = config_file.remote_overrides.get(remote_connection_id) { + for id in remote_override.clients.keys() { + if !ids.iter().any(|candidate| candidate == id) { + ids.push(id.clone()); + } + } + } ids.sort(); let mut probes = Vec::with_capacity(ids.len()); for id in ids { - let config = configs.get(&id); - let spec = acp_requirement_spec(&id, config); + 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.map(|config| &config.env), + config.as_ref().map(|config| &config.env), ) .await; let adapter = match spec.adapter { @@ -385,7 +393,7 @@ impl AcpClientService { &ssh_manager, remote_connection_id, adapter.package, - config.map(|config| &config.env), + config.as_ref().map(|config| &config.env), ) .await, ), @@ -443,9 +451,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", @@ -453,7 +469,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( @@ -1377,7 +1403,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 { @@ -1597,24 +1627,13 @@ impl AcpClientService { .filter(|value| !value.is_empty()) .map(ToOwned::to_owned); let is_remote = remote_connection_id.is_some(); - let mut used_remote_default = false; - let mut config = self - .load_configs() - .await? - .remove(client_id) - .or_else(|| { - if is_remote { - used_remote_default = true; - default_config_for_builtin_client(client_id) - } else { - None - } - }) - .ok_or_else(|| BitFunError::NotFound(format!("ACP client not found: {}", client_id)))?; + 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 used_remote_default { - config.enabled = true; - } if config.command.trim().is_empty() { return Err(BitFunError::config(format!( "ACP client command is empty: {}", @@ -1639,6 +1658,64 @@ impl AcpClientService { } } +fn resolve_config_for_client( + config_file: &AcpClientConfigFile, + client_id: &str, + remote_connection_id: Option<&str>, +) -> Option { + let mut config = config_file + .acp_clients + .get(client_id) + .cloned() + .or_else(|| { + remote_connection_id.and_then(|_| default_config_for_builtin_client(client_id)) + })?; + if let Some(connection_id) = remote_connection_id { + apply_remote_override( + &mut config, + config_file.remote_overrides.get(connection_id), + client_id, + ); + } + Some(config) +} + +fn apply_remote_override( + config: &mut AcpClientConfig, + remote_override: Option<&AcpRemoteOverrideConfig>, + client_id: &str, +) { + let Some(remote_override) = remote_override else { + return; + }; + + config.env.extend(remote_override.env.clone()); + if let Some(client_override) = remote_override.clients.get(client_id) { + apply_remote_client_override(config, client_override); + } +} + +fn apply_remote_client_override( + config: &mut AcpClientConfig, + client_override: &AcpRemoteClientOverride, +) { + if let Some(command) = client_override + .command + .as_deref() + .map(str::trim) + .filter(|command| !command.is_empty()) + { + config.command = command.to_string(); + } + if let Some(args) = client_override.args.as_ref() { + config.args = args.clone(); + } + config.env.extend(client_override.env.clone()); + if let Some(enabled) = client_override.enabled { + config.enabled = enabled; + } +} + fn ensure_remote_client_supported( _client_id: &str, workspace_path: Option<&str>, @@ -2121,4 +2198,50 @@ mod tests { "cd '\\''/srv/my repo'\\'' && exec env PATH=/remote/bin:/usr/bin custom-acp --stdio '\\''with space'\\''" )); } + + #[test] + fn resolves_remote_client_config_with_remote_overrides() { + 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, + }, + )]), + remote_overrides: HashMap::from([( + "huawei-server".to_string(), + AcpRemoteOverrideConfig { + env: HashMap::from([("REMOTE".to_string(), "1".to_string())]), + clients: HashMap::from([( + "codex".to_string(), + AcpRemoteClientOverride { + command: Some("codex".to_string()), + args: Some(vec!["acp".to_string()]), + env: HashMap::from([("CLIENT".to_string(), "1".to_string())]), + enabled: Some(false), + }, + )]), + }, + )]), + }; + + let resolved = resolve_config_for_client(&config_file, "codex", Some("huawei-server")) + .expect("config"); + + assert_eq!(resolved.command, "codex"); + assert_eq!(resolved.args, vec!["acp"]); + assert_eq!(resolved.env.get("BASE").map(String::as_str), Some("1")); + assert_eq!(resolved.env.get("REMOTE").map(String::as_str), Some("1")); + assert_eq!(resolved.env.get("CLIENT").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 58959c3e9..9ac351a55 100644 --- a/src/crates/acp/src/client/mod.rs +++ b/src/crates/acp/src/client/mod.rs @@ -13,8 +13,8 @@ mod tool_card_bridge; pub use config::{ AcpClientConfig, AcpClientConfigFile, AcpClientInfo, AcpClientPermissionMode, - AcpClientRequirementProbe, AcpClientStatus, AcpRequirementProbeItem, - RemoteAcpClientRequirementSnapshot, + AcpClientRequirementProbe, AcpClientStatus, AcpRemoteClientOverride, AcpRemoteOverrideConfig, + AcpRequirementProbeItem, RemoteAcpClientRequirementSnapshot, }; pub use manager::{ AcpClientPermissionResponse, AcpClientService, CreateAcpFlowSessionRecordResponse, diff --git a/src/crates/acp/src/client/requirements.rs b/src/crates/acp/src/client/requirements.rs index aedae55f2..8789628a2 100644 --- a/src/crates/acp/src/client/requirements.rs +++ b/src/crates/acp/src/client/requirements.rs @@ -4,7 +4,7 @@ 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; @@ -313,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, 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..e03a0be52 100644 --- a/src/web-ui/src/infrastructure/config/components/AcpAgentsConfig.scss +++ b/src/web-ui/src/infrastructure/config/components/AcpAgentsConfig.scss @@ -247,13 +247,58 @@ text-align: center; } + &__remote-list { + display: flex; + flex-direction: column; + } + + &__remote-server { + display: flex; + flex-direction: column; + border-bottom: 1px solid var(--border-subtle); + + &:last-child { + border-bottom: 0; + } + } + + &__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; + } + + &__remote-actions { + display: flex; + align-items: center; + justify-content: flex-end; + gap: $size-gap-2; + flex-wrap: wrap; + } + + &__remote-agent-list { + display: flex; + flex-direction: column; + border-top: 1px solid var(--border-subtle); + + .bitfun-acp-agents__registry-row { + padding-left: $size-gap-3; + padding-right: $size-gap-3; + } + } + @media (max-width: 860px) { &__toolbar, - &__registry-row { + &__registry-row, + &__remote-head { grid-template-columns: 1fr; } &__toolbar-actions, + &__remote-actions, &__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 d64538da7..795f3fbfd 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,8 @@ 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 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 +98,7 @@ vi.mock('../../api/service-api/ACPClientAPI', () => ({ loadJsonConfig: loadJsonConfigMock, getClients: getClientsMock, probeClientRequirements: probeClientRequirementsMock, - installClientCli: vi.fn(), + installClientCli: installClientCliMock, saveJsonConfig: vi.fn(), }, })); @@ -107,6 +109,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 +125,7 @@ vi.mock('@/shared/notification-system', () => ({ vi.mock('@/shared/utils/logger', () => ({ createLogger: () => ({ error: vi.fn(), + warn: vi.fn(), }), })); @@ -151,7 +160,9 @@ describe('AcpAgentsConfig', () => { sessionCount: 0, toolName: 'acp__opencode__prompt', }]); + listSavedConnectionsMock.mockResolvedValue([]); probeClientRequirementsMock.mockResolvedValue([]); + installClientCliMock.mockResolvedValue(undefined); container = document.createElement('div'); document.body.appendChild(container); @@ -182,4 +193,94 @@ describe('AcpAgentsConfig', () => { expect(probeClientRequirementsMock).toHaveBeenCalledTimes(1); expect(container.textContent).not.toContain('registry.configInvalid'); }); + + it('renders saved remote servers as agent rows without low-level override fields', 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.configureJson'); + expect(container.textContent?.split('remote.configureJson').length).toBe(2); + expect(container.textContent).toContain('remote.refresh'); + expect(container.textContent).not.toContain('remote.env'); + expect(probeClientRequirementsMock).toHaveBeenCalledWith({ + remoteConnectionId: 'huawei-server', + force: undefined, + }); + }); + + it('downloads a missing remote preset 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 downloadButtons = Array.from(container.querySelectorAll('button')) + .filter(button => button.textContent?.includes('actions.download')); + expect(downloadButtons.length).toBeGreaterThan(0); + + await act(async () => { + downloadButtons[downloadButtons.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 8573bc3f4..1f4582b7a 100644 --- a/src/web-ui/src/infrastructure/config/components/AcpAgentsConfig.tsx +++ b/src/web-ui/src/infrastructure/config/components/AcpAgentsConfig.tsx @@ -10,6 +10,7 @@ import { RefreshCw, Save, Search, + Server, Terminal, } from 'lucide-react'; import { Button, Input, Select, Textarea } from '@/component-library'; @@ -27,6 +28,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'; @@ -45,6 +48,19 @@ interface AcpClientConfig { interface AcpClientConfigFile { acpClients: Record; + remoteOverrides: Record; +} + +interface AcpRemoteOverrideConfig { + env: Record; + clients: Record; +} + +interface AcpRemoteClientOverride { + command?: string; + args?: string[]; + env?: Record; + enabled?: boolean; } interface AcpClientPreset { @@ -148,7 +164,37 @@ function normalizeConfigValue(value: unknown): AcpClientConfigFile { }; } - return { acpClients }; + return { acpClients, remoteOverrides: normalizeRemoteOverrides(candidate.remoteOverrides) }; +} + +function normalizeRemoteOverrides(value: unknown): Record { + if (!value || typeof value !== 'object' || Array.isArray(value)) return {}; + const remoteOverrides: Record = {}; + for (const [connectionId, rawOverride] of Object.entries(value as Record)) { + if (!rawOverride || typeof rawOverride !== 'object' || Array.isArray(rawOverride)) continue; + const item = rawOverride as Record; + remoteOverrides[connectionId] = { + env: normalizeEnvObject(item.env), + clients: normalizeRemoteClientOverrides(item.clients), + }; + } + return remoteOverrides; +} + +function normalizeRemoteClientOverrides(value: unknown): Record { + if (!value || typeof value !== 'object' || Array.isArray(value)) return {}; + const clients: Record = {}; + for (const [clientId, rawOverride] of Object.entries(value as Record)) { + if (!rawOverride || typeof rawOverride !== 'object' || Array.isArray(rawOverride)) continue; + const item = rawOverride as Record; + clients[clientId] = { + command: typeof item.command === 'string' ? item.command : undefined, + args: Array.isArray(item.args) ? item.args.map(String) : undefined, + env: normalizeEnvObject(item.env), + enabled: typeof item.enabled === 'boolean' ? item.enabled : undefined, + }; + } + return clients; } function normalizeEnvObject(value: unknown): Record { @@ -264,8 +310,9 @@ const AcpAgentsConfig: React.FC = () => { const { error: notifyError, success: notifySuccess } = useNotification(); const jsonEditorRef = useRef(null); - const [config, setConfig] = useState({ acpClients: {} }); + const [config, setConfig] = useState({ acpClients: {}, remoteOverrides: {} }); 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 +322,38 @@ 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 clientsById = useMemo(() => new Map(clients.map(client => [client.id, client])), [clients]); + const remoteConnectionRows = useMemo(() => { + const byId = new Map(savedConnections.map(connection => [connection.id, connection])); + for (const connectionId of Object.keys(config.remoteOverrides)) { + if (!byId.has(connectionId)) { + byId.set(connectionId, { + id: connectionId, + name: connectionId, + host: '', + port: 22, + username: '', + authType: { type: 'Password' }, + }); + } + } + return Array.from(byId.values()).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); + }); + }, [config.remoteOverrides, savedConnections]); const probesById = useMemo( () => new Map(requirementProbes.map(probe => [probe.id, probe])), [requirementProbes] @@ -383,6 +455,45 @@ 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 } = {} ) => { @@ -396,6 +507,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 +523,7 @@ const AcpAgentsConfig: React.FC = () => { ) ); setClients(nextClients); + setSavedConnections(nextSavedConnections); setDirty(false); if (refreshRequirements) { void refreshRequirementProbes({ notifyOnError: false }); @@ -438,6 +554,13 @@ const AcpAgentsConfig: React.FC = () => { }; }, [loadConfig]); + useEffect(() => { + if (loading) return; + for (const connection of remoteConnectionRows) { + void refreshRemoteRequirementProbes(connection.id, { notifyOnError: false }); + } + }, [loading, refreshRemoteRequirementProbes, remoteConnectionRows]); + const patchClientConfig = (clientId: string, patch: Partial) => { setConfig(prev => { const preset = PRESET_BY_ID.get(clientId); @@ -446,6 +569,7 @@ const AcpAgentsConfig: React.FC = () => { if (!current) return prev; const next = { + ...prev, acpClients: { ...prev.acpClients, [clientId]: { @@ -460,12 +584,26 @@ 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 }); - requirementProbeCache = null; - await refreshRequirementProbes({ force: true, notifyOnError: false }); + 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.downloadSuccess')); } catch (error) { log.error('Failed to download ACP agent CLI', error); @@ -473,9 +611,9 @@ const AcpAgentsConfig: React.FC = () => { title: t('notifications.downloadFailed'), }); } finally { - setInstallingClientIds(prev => { + setInstalling(prev => { const next = new Set(prev); - next.delete(preset.id); + next.delete(installKey); return next; }); } @@ -493,6 +631,7 @@ const AcpAgentsConfig: React.FC = () => { }, ]) ), + remoteOverrides: baseConfig.remoteOverrides, }); const saveConfig = async (nextConfig = config, options: { mergeEnvDrafts?: boolean } = {}) => { @@ -509,6 +648,8 @@ const AcpAgentsConfig: React.FC = () => { setDirty(false); requirementProbeCache = null; setRequirementProbes([]); + loadedRemoteProbeIdsRef.current.clear(); + setRemoteRequirementProbes({}); notifySuccess(t('notifications.saveSuccess')); } catch (error) { log.error('Failed to save ACP agent config', error); @@ -523,6 +664,7 @@ const AcpAgentsConfig: React.FC = () => { const addPresetClient = async (preset: AcpClientPreset) => { const nextClient = defaultConfigForPreset(preset); const next = { + ...config, acpClients: { ...config.acpClients, [preset.id]: nextClient, @@ -589,6 +731,32 @@ const AcpAgentsConfig: React.FC = () => { }); }, [notifyError, t]); + const openJsonEditor = useCallback(() => { + setShowJsonEditor(true); + requestAnimationFrame(() => { + jsonEditorRef.current?.focus(); + }); + }, []); + + const remoteAgentIdsForConnection = useCallback((connectionId: string) => { + const remoteOverride = config.remoteOverrides[connectionId]; + const ids = new Set([ + ...PRESETS.map(preset => preset.id), + ...Object.keys(config.acpClients), + ...Object.keys(remoteOverride?.clients ?? {}), + ]); + 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, config.remoteOverrides]); + return ( { )} + + + {remoteConnectionRows.length === 0 ? ( +
{t('remote.empty')}
+ ) : ( +
+ {remoteConnectionRows.map(connection => { + const remoteOverride = config.remoteOverrides[connection.id] ?? { env: {}, clients: {} }; + const hostLabel = [connection.username, connection.host] + .filter(Boolean) + .join('@'); + const remoteProbes = remoteRequirementProbes[connection.id] ?? []; + const remoteProbesById = new Map(remoteProbes.map(probe => [probe.id, probe])); + const probingRemote = probingRemoteRequirements.has(connection.id); + const remoteAgentIds = remoteAgentIdsForConnection(connection.id); + + return ( +
+
+
+ + + +
+ + {connection.name || connection.id} + +

+ {hostLabel || connection.id} +

+
+
+
+ + +
+
+
+ {remoteAgentIds.map(clientId => { + const preset = PRESET_BY_ID.get(clientId); + const clientConfig = config.acpClients[clientId]; + const clientOverride = remoteOverride.clients[clientId] ?? {}; + const requirementProbe = remoteProbesById.get(clientId); + const probePending = probingRemote && !requirementProbe; + const hasConfigEntry = Boolean(clientConfig); + const hasRemoteOverride = Boolean(remoteOverride.clients[clientId]); + const effectiveConfig = clientConfig ?? (preset ? defaultConfigForPreset(preset) : undefined); + const enabled = clientOverride.enabled ?? effectiveConfig?.enabled ?? true; + const runnable = requirementProbe?.runnable; + const missingRemoteTool = requirementProbe?.tool.installed === false; + const status = getAgentRowStatus({ + configured: hasConfigEntry || hasRemoteOverride, + enabled, + runnable, + probePending, + }); + const displayName = effectiveConfig?.name || preset?.name || clientId; + const description = preset?.description ?? + (effectiveConfig ? [effectiveConfig.command, ...effectiveConfig.args].join(' ') : clientId); + const installingRemote = installingRemoteClientIds.has(`${connection.id}:${clientId}`); + + return ( +
+
+ + + +
+ {displayName} +

{description}

+
+
+
+ } + item={requirementProbe?.tool} + label={t('requirements.tool')} + installedText={t('requirements.installed')} + missingText={t('requirements.missing')} + checking={probePending} + checkingText={t('requirements.checking')} + /> + {requirementProbe?.adapter && ( + } + item={requirementProbe.adapter} + label={t('requirements.adapter')} + installedText={t('requirements.installed')} + missingText={t('requirements.missing')} + checking={probePending} + checkingText={t('requirements.checking')} + /> + )} +
+
+ +
+
+ {preset && (status === 'not_installed' || missingRemoteTool) ? ( + + ) : hasConfigEntry && clientConfig ? ( + { })} size="small" /> - ) : status === 'not_installed' ? ( + ) : canInstallCli ? ( + ) : canConfigureAcp ? ( + + ) : canViewError ? ( + ) : ( + ) : null}
); @@ -1014,14 +1235,61 @@ const AcpAgentsConfig: React.FC = () => { ) : (
{remoteConnectionRows.map(connection => { - const remoteOverride = config.remoteOverrides[connection.id] ?? { env: {}, clients: {} }; const hostLabel = [connection.username, connection.host] .filter(Boolean) .join('@'); const remoteProbes = remoteRequirementProbes[connection.id] ?? []; const remoteProbesById = new Map(remoteProbes.map(probe => [probe.id, probe])); + const remoteProbeLoaded = Object.prototype.hasOwnProperty.call( + remoteRequirementProbes, + connection.id + ); const probingRemote = probingRemoteRequirements.has(connection.id); - const remoteAgentIds = remoteAgentIdsForConnection(connection.id); + const remoteRows = remoteAgentIds.map(clientId => { + const preset = PRESET_BY_ID.get(clientId); + const clientConfig = config.acpClients[clientId]; + const requirementProbe = remoteProbesById.get(clientId); + const probePending = probingRemote || !remoteProbeLoaded || !requirementProbe; + const hasConfigEntry = Boolean(clientConfig); + const effectiveConfig = clientConfig ?? (preset ? defaultConfigForPreset(preset) : undefined); + const enabled = effectiveConfig?.enabled ?? true; + const requiresAdapter = Boolean(requirementProbe?.adapter || preset?.id !== 'opencode'); + const issueKind = getIssueKind({ probe: requirementProbe, requiresAdapter }); + const status = getAgentRowStatus({ + configured: hasConfigEntry, + enabled, + toolInstalled: requirementProbe?.tool.installed, + adapterInstalled: requirementProbe?.adapter?.installed, + requiresAdapter, + probePending, + }); + const displayName = effectiveConfig?.name || preset?.name || clientId; + const description = preset?.description ?? + (effectiveConfig ? [effectiveConfig.command, ...effectiveConfig.args].join(' ') : clientId); + const installingRemote = installingRemoteClientIds.has(`${connection.id}:${clientId}`); + + return { + clientId, + preset, + clientConfig, + requirementProbe, + probePending, + hasConfigEntry, + enabled, + requiresAdapter, + issueKind, + status, + displayName, + description, + installingRemote, + }; + }); + const availableCount = remoteRows.filter(row => row.status === 'enabled' || row.status === 'ready').length; + const issueCount = remoteRows.filter(row => ( + row.status === 'partial' || + row.status === 'not_installed' || + row.status === 'invalid' + )).length; return (
@@ -1037,17 +1305,19 @@ const AcpAgentsConfig: React.FC = () => {

{hostLabel || connection.id}

+
+ + {getRemoteSummary(availableCount, remoteRows.length)} + + {issueCount > 0 && ( + + {t('remote.issueSummary', { count: issueCount })} + + )} +
-
- {remoteAgentIds.map(clientId => { - const preset = PRESET_BY_ID.get(clientId); - const clientConfig = config.acpClients[clientId]; - const clientOverride = remoteOverride.clients[clientId] ?? {}; - const requirementProbe = remoteProbesById.get(clientId); - const probePending = probingRemote && !requirementProbe; - const hasConfigEntry = Boolean(clientConfig); - const hasRemoteOverride = Boolean(remoteOverride.clients[clientId]); - const effectiveConfig = clientConfig ?? (preset ? defaultConfigForPreset(preset) : undefined); - const enabled = clientOverride.enabled ?? effectiveConfig?.enabled ?? true; - const runnable = requirementProbe?.runnable; - const missingRemoteTool = requirementProbe?.tool.installed === false; - const status = getAgentRowStatus({ - configured: hasConfigEntry || hasRemoteOverride, - enabled, - runnable, - probePending, + {remoteRows.map(row => { + const statusLabel = getStatusLabel({ + status: row.status, + issueKind: row.issueKind, + probe: row.requirementProbe, + requiresAdapter: row.requiresAdapter, }); - const displayName = effectiveConfig?.name || preset?.name || clientId; - const description = preset?.description ?? - (effectiveConfig ? [effectiveConfig.command, ...effectiveConfig.args].join(' ') : clientId); - const installingRemote = installingRemoteClientIds.has(`${connection.id}:${clientId}`); + const statusTitle = getStatusTitle({ + status: row.status, + issueKind: row.issueKind, + probe: row.requirementProbe, + requiresAdapter: row.requiresAdapter, + }); + const canInstallCli = row.preset && row.status === 'not_installed' && row.issueKind === 'cli_missing'; + const canViewError = row.status === 'invalid' || row.status === 'partial' + || row.issueKind === 'connection_failed' + || row.issueKind === 'permission_denied' + || row.issueKind === 'path_invalid' + || row.issueKind === 'version_mismatch' + || row.issueKind === 'adapter_missing'; return ( -
+
- {displayName} -

{description}

+ {row.displayName} +

{row.description}

} - item={requirementProbe?.tool} + item={row.requirementProbe?.tool} label={t('requirements.tool')} installedText={t('requirements.installed')} missingText={t('requirements.missing')} - checking={probePending} + checking={row.probePending} checkingText={t('requirements.checking')} /> - {requirementProbe?.adapter && ( + {row.requirementProbe?.adapter && ( } - item={requirementProbe.adapter} + item={row.requirementProbe.adapter} label={t('requirements.adapter')} installedText={t('requirements.installed')} missingText={t('requirements.missing')} - checking={probePending} + checking={row.probePending} checkingText={t('requirements.checking')} /> )}
- +
- {preset && (status === 'not_installed' || missingRemoteTool) ? ( + {canInstallCli ? ( - ) : hasConfigEntry && clientConfig ? ( - 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 c56e7ae3d..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. Remote workspaces reuse this local config and check commands on the remote host.", + "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,12 +35,28 @@ "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": { @@ -45,11 +64,12 @@ }, "remote": { "title": "Remote Servers", - "description": "Each saved SSH server reuses the ACP agent config above and checks requirements on the remote host. Use JSON remoteOverrides only when a server needs different runtime details.", + "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 configuring remote overrides.", - "configureJson": "Configure JSON", - "refresh": "Refresh remote" + "noAgents": "Add an ACP agent before checking remote servers.", + "refreshDetection": "Refresh detection", + "summary": "{{available}} / {{total}} available", + "issueSummary": "{{count}} issue(s)" }, "fields": { "name": "Name", @@ -80,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 594779a85..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,12 +35,28 @@ "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": { @@ -45,11 +64,12 @@ }, "remote": { "title": "远程服务器", - "description": "每台已保存的 SSH 服务器会复用上面的 ACP Agent 配置,并在远端检测依赖。需要为某台服务器覆盖运行细节时,通过 JSON 编辑 remoteOverrides。", + "description": "已保存的 SSH 服务器会复用同一份 ACP Agent 列表,并自动检测每台远端主机的状态。", "empty": "没有已保存的 SSH 服务器。", - "noAgents": "请先添加 ACP Agent,再配置远程覆盖。", - "configureJson": "配置 JSON", - "refresh": "刷新远端" + "noAgents": "请先添加 ACP Agent,再检测远程服务器。", + "refreshDetection": "刷新检测", + "summary": "{{available}} / {{total}} 可用", + "issueSummary": "{{count}} 个异常" }, "fields": { "name": "名称", @@ -80,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 de537bbab..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,12 +35,28 @@ "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": { @@ -45,11 +64,12 @@ }, "remote": { "title": "遠端伺服器", - "description": "每台已保存的 SSH 伺服器會複用上面的 ACP Agent 配置,並在遠端檢測依賴。需要為某台伺服器覆蓋運行細節時,通過 JSON 編輯 remoteOverrides。", + "description": "已保存的 SSH 伺服器會複用同一份 ACP Agent 列表,並自動檢測每台遠端主機的狀態。", "empty": "沒有已保存的 SSH 伺服器。", - "noAgents": "請先添加 ACP Agent,再配置遠端覆蓋。", - "configureJson": "配置 JSON", - "refresh": "刷新遠端" + "noAgents": "請先添加 ACP Agent,再檢測遠端伺服器。", + "refreshDetection": "刷新檢測", + "summary": "{{available}} / {{total}} 可用", + "issueSummary": "{{count}} 個異常" }, "fields": { "name": "名稱", @@ -80,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 適配器已下載",