use std::collections::BTreeMap; use std::ffi::OsString; use std::fmt; use std::path::{Path, PathBuf}; use std::sync::Arc; use std::time::Duration; use agent_client_protocol::schema::v1::AuthMethodId; use agent_client_protocol::AcpAgentConfig; use crate::{DenyByDefaultPermissionHandler, PermissionHandler}; /// Version of the official Codex ACP adapter supported by the built-in setup. pub const CODEX_ACP_NPM_VERSION: &str = "1.1.14"; /// Pinned version of OpenCode used by the built-in ACP launch preset. pub const OPENCODE_NPM_VERSION: &str = "1.18.9"; /// A known ACP client that can be selected in Galaxy settings. #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub struct AcpKnownAgent { pub id: &'static str, pub name: &'static str, pub description: &'static str, pub command: &'static str, pub args: &'static [&'static str], } /// Curated ACP Registry catalog. Launch commands are intentionally local-only: /// Galaxy never installs or downloads an agent on the user's behalf. pub const KNOWN_ACP_AGENTS: &[AcpKnownAgent] = &[ AcpKnownAgent { id: "codex", name: "Codex", description: "OpenAI's coding assistant", command: "codex", args: &[], }, AcpKnownAgent { id: "opencode", name: "OpenCode", description: "Open source coding agent", command: "opencode", args: &["acp"], }, AcpKnownAgent { id: "claude-acp", name: "Claude Agent", description: "Anthropic's coding agent", command: "claude-agent-acp", args: &[], }, AcpKnownAgent { id: "gemini", name: "Gemini CLI", description: "Google's coding agent", command: "gemini", args: &["--acp"], }, AcpKnownAgent { id: "cline", name: "Cline", description: "Autonomous coding agent", command: "cline", args: &["--acp"], }, AcpKnownAgent { id: "cursor", name: "Cursor", description: "Cursor's coding agent", command: "cursor-agent", args: &["acp"], }, AcpKnownAgent { id: "github-copilot-cli", name: "GitHub Copilot", description: "GitHub's AI pair programmer", command: "copilot", args: &["--acp"], }, AcpKnownAgent { id: "goose", name: "Goose", description: "Block's open source AI agent", command: "goose", args: &["acp"], }, AcpKnownAgent { id: "auggie", name: "Auggie CLI", description: "Augment Code's coding agent", command: "auggie", args: &["--acp"], }, ]; pub fn known_acp_agents() -> &'static [AcpKnownAgent] { KNOWN_ACP_AGENTS } /// Resolve a registry-listed agent from the local PATH. pub fn resolve_known_acp_agent(agent_id: &str) -> Result { let agent = known_acp_agents() .iter() .find(|agent| agent.id.eq_ignore_ascii_case(agent_id.trim())) .ok_or_else(|| format!("Unknown ACP agent: {agent_id:?}"))?; let command = executable_on_path(agent.command).ok_or_else(|| { format!( "{} is not installed or could not be found on PATH (expected `{}`). Install it or choose Custom.", agent.name, agent.command ) })?; Ok(AcpLaunchConfig::new(command).args(agent.args.iter().copied())) } const DEFAULT_CANCELLATION_GRACE_PERIOD: Duration = Duration::from_secs(5); const DEFAULT_INITIALIZATION_TIMEOUT: Duration = Duration::from_secs(30); const DEFAULT_AUTHENTICATION_TIMEOUT: Duration = Duration::from_secs(5 * 60); #[cfg(any(windows, test))] const DEFAULT_WINDOWS_EXECUTABLE_EXTENSIONS: &[&str] = &[".COM", ".EXE", ".BAT", ".CMD"]; /// A built-in, version-pinned ACP agent launch preset. #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub enum AcpAgentPreset { /// The official adapter around the OpenAI Codex app server. Codex, /// OpenCode's native `acp` command. OpenCode, } impl AcpAgentPreset { /// Builds the pinned launch configuration for this preset. #[must_use] pub fn launch_config(self) -> AcpLaunchConfig { match self { Self::Codex => AcpLaunchConfig::new("npx") .args([ "--yes".to_owned(), format!("@agentclientprotocol/codex-acp@{CODEX_ACP_NPM_VERSION}"), ]) .preferred_auth_method("chat-gpt") // The adapter owns the browser OAuth flow and persists credentials in // Codex's normal auth store. Galaxy only selects the advertised ACP // method; it never receives or stores ChatGPT tokens. .env("DEFAULT_AUTH_REQUEST", r#"{"methodId":"chat-gpt"}"#) // Codex ACP otherwise defaults to workspace-write mode, where // in-sandbox edits and commands can bypass ACP permission // requests. Start read-only so every mutation is mediated by // Galaxy's execution profile (or explicit Run to Completion). .env("INITIAL_AGENT_MODE", "read-only"), Self::OpenCode => AcpLaunchConfig::new("npx").args(vec![ "--yes".to_owned(), format!("opencode-ai@{OPENCODE_NPM_VERSION}"), "acp".to_owned(), ]), } } /// Resolves the best available executable for this preset. /// /// NPM-backed presets run through npx when available. Codex still requires a /// locally installed Codex CLI because the ACP adapter delegates to it via /// CODEX_PATH rather than downloading a second Codex installation. pub fn resolve_launch_config(self) -> Result { self.resolve_launch_config_with(executable_on_path) } fn resolve_launch_config_with( self, mut resolve: impl FnMut(&str) -> Option, ) -> Result { match self { Self::Codex => { let Some(codex) = resolve("codex") else { return Err( "Codex ACP requires the locally installed codex CLI; install Codex or configure a custom ACP executable" .to_owned(), ); }; let launch = if let Some(adapter) = resolve("codex-acp") { AcpLaunchConfig::new(adapter) } else if let Some(npx) = resolve("npx") { AcpLaunchConfig::new(npx).args([ "--yes".to_owned(), format!("@agentclientprotocol/codex-acp@{CODEX_ACP_NPM_VERSION}"), ]) } else { return Err( "Codex ACP requires either a local codex-acp executable or npx; install the ACP adapter, install Node.js/npm, or configure a custom ACP executable" .to_owned(), ); }; Ok(launch .preferred_auth_method("chat-gpt") .env("DEFAULT_AUTH_REQUEST", r#"{"methodId":"chat-gpt"}"#) .env("INITIAL_AGENT_MODE", "read-only") .codex_path(codex)) } Self::OpenCode => { if let Some(command) = resolve("npx") { return Ok(AcpLaunchConfig::new(command).args([ "--yes".to_owned(), format!("opencode-ai@{OPENCODE_NPM_VERSION}"), "acp".to_owned(), ])); } Err( "OpenCode ACP requires npx because its ACP adapter is distributed as an NPM package; install Node.js/npm or configure a custom ACP executable" .to_owned(), ) } } } } /// Comparable ACP agent launch settings. /// /// Galaxy can retain this value as a fingerprint and restart its manager when /// the selected executable, arguments, environment, or authentication method /// changes. #[derive(Clone, Debug, Eq, PartialEq)] pub struct AcpLaunchConfig { /// Executable to spawn. pub command: PathBuf, /// Arguments passed to the executable. pub args: Vec, /// Environment variables added to or overridden in the child process. /// /// Galaxy clears inherited values outside a small runtime allowlist before /// applying these explicit overrides. pub env: BTreeMap, /// Authentication method to select when the agent advertises more than one. /// /// When unset, Galaxy selects the first method advertised by the agent. pub preferred_auth_method: Option, } impl AcpLaunchConfig { /// Creates launch settings for an executable. #[must_use] pub fn new(command: impl Into) -> Self { Self { command: command.into(), args: Vec::new(), env: BTreeMap::new(), preferred_auth_method: None, } } /// Replaces the arguments passed to the executable. #[must_use] pub fn args(mut self, args: I) -> Self where I: IntoIterator, S: Into, { self.args = args.into_iter().map(Into::into).collect(); self } /// Adds or overrides an environment variable in the child process. #[must_use] pub fn env(mut self, name: impl Into, value: impl Into) -> Self { self.env.insert(name.into(), value.into()); self } /// Selects a specific authentication method from the agent's advertised /// methods. #[must_use] pub fn preferred_auth_method(mut self, method_id: impl Into) -> Self { self.preferred_auth_method = Some(method_id.into()); self } /// Points the Codex adapter at a particular Codex executable. #[must_use] pub fn codex_path(self, path: impl AsRef) -> Self { self.env("CODEX_PATH", path.as_ref().to_string_lossy()) } /// Resolves a configured executable through `PATH` and returns a clear /// startup error before an ACP worker is created. pub fn resolve_command(mut self) -> Result { let command = if self.command.components().count() > 1 || self.command.is_absolute() { self.command .is_file() .then(|| self.command.clone()) .ok_or_else(|| { format!( "ACP executable does not exist or is not a file: {}", self.command.display() ) })? } else { let command = self.command.to_string_lossy(); executable_on_path(&command) .ok_or_else(|| format!("ACP executable was not found on PATH: {command}"))? }; self.command = command; Ok(self) } pub(crate) fn to_agent_config(&self) -> AcpAgentConfig { AcpAgentConfig::new(self.command.clone()) .args(self.args.clone()) .envs(sanitized_environment_overrides( std::env::vars_os(), &self.env, )) } } fn sanitized_environment_overrides( parent_environment: impl IntoIterator, explicit_environment: &BTreeMap, ) -> BTreeMap { let mut overrides = BTreeMap::new(); for (name, _) in parent_environment { let Some(name) = name.to_str() else { continue; }; if !may_inherit_environment_variable(name) { overrides.insert(name.to_owned(), String::new()); } } overrides.extend(explicit_environment.clone()); overrides } fn may_inherit_environment_variable(name: &str) -> bool { let name = name.to_ascii_uppercase(); matches!( name.as_str(), "PATH" | "HOME" | "USER" | "LOGNAME" | "SHELL" | "TMPDIR" | "TMP" | "TEMP" | "LANG" | "LANGUAGE" | "LC_ALL" | "LC_ADDRESS" | "LC_COLLATE" | "LC_CTYPE" | "LC_IDENTIFICATION" | "LC_MEASUREMENT" | "LC_MESSAGES" | "LC_MONETARY" | "LC_NAME" | "LC_NUMERIC" | "LC_PAPER" | "LC_TELEPHONE" | "LC_TIME" | "TZ" | "TERM" | "COLORTERM" | "NO_COLOR" | "FORCE_COLOR" | "DISPLAY" | "WAYLAND_DISPLAY" | "XAUTHORITY" | "DBUS_SESSION_BUS_ADDRESS" | "SSL_CERT_FILE" | "SSL_CERT_DIR" | "NODE_EXTRA_CA_CERTS" | "NODE_PATH" | "NPM_CONFIG_PREFIX" | "BUN_INSTALL" | "SYSTEMROOT" | "WINDIR" | "COMSPEC" | "PATHEXT" | "PROGRAMDATA" | "PROGRAMFILES" | "PROGRAMFILES(X86)" | "COMMONPROGRAMFILES" | "COMMONPROGRAMFILES(X86)" | "APPDATA" | "LOCALAPPDATA" | "USERPROFILE" | "HOMEDRIVE" | "HOMEPATH" | "NUMBER_OF_PROCESSORS" | "PROCESSOR_ARCHITECTURE" | "PROCESSOR_IDENTIFIER" | "XDG_CACHE_HOME" | "XDG_CONFIG_DIRS" | "XDG_CONFIG_HOME" | "XDG_DATA_DIRS" | "XDG_DATA_HOME" | "XDG_RUNTIME_DIR" | "XDG_STATE_HOME" | "__CF_USER_TEXT_ENCODING" ) } fn executable_on_path(command: &str) -> Option { let path = Path::new(command); if path.components().count() > 1 || path.is_absolute() { return path.is_file().then(|| path.to_owned()); } let path = std::env::var_os("PATH")?; let executable_extensions = platform_executable_extensions(); find_executable_in_directories( command, std::env::split_paths(&path), &executable_extensions, ) } fn find_executable_in_directories( command: &str, directories: impl IntoIterator, executable_extensions: &[String], ) -> Option { for directory in directories { let candidate = directory.join(command); if candidate.is_file() { return Some(candidate); } if Path::new(command).extension().is_none() { for extension in executable_extensions { let candidate = directory.join(format!("{command}{extension}")); if candidate.is_file() { return Some(candidate); } } } } None } #[cfg(windows)] fn platform_executable_extensions() -> Vec { windows_executable_extensions(std::env::var_os("PATHEXT").as_deref()) } #[cfg(any(windows, test))] fn windows_executable_extensions(path_extensions: Option<&std::ffi::OsStr>) -> Vec { let configured = path_extensions.map_or_else(Vec::new, |path_extensions| { let path_extensions = path_extensions.to_string_lossy(); path_extensions .split(';') .map(str::trim) .filter(|extension| !extension.is_empty()) .map(str::to_owned) .collect::>() }); if configured.is_empty() { DEFAULT_WINDOWS_EXECUTABLE_EXTENSIONS .iter() .map(ToString::to_string) .collect() } else { configured } } #[cfg(not(windows))] fn platform_executable_extensions() -> Vec { Vec::new() } /// Configuration for an [`AcpSessionManager`](crate::AcpSessionManager). #[derive(Clone)] pub struct AcpManagerConfig { /// Comparable settings used to launch the ACP agent. pub launch: AcpLaunchConfig, /// Programmatic client name sent during ACP initialization. pub client_name: String, /// Client version sent during ACP initialization. pub client_version: String, /// How long a cancelled prompt may remain active before its subprocess is /// torn down. pub cancellation_grace_period: Duration, /// Maximum time allowed for process startup and ACP initialization. /// /// Timing out drops the connection future, which tears down the ACP child /// process and its process group. pub initialization_timeout: Duration, /// Maximum time allowed for an agent-owned authentication flow. /// /// Browser login is intentionally given more time than process startup, /// but remains bounded so an abandoned flow cannot strand queued turns or /// cancellation requests indefinitely. pub authentication_timeout: Duration, /// Permission hook. The supplied default denies requests unless a turn /// explicitly opts into automatic approval. pub permission_handler: Arc, } impl AcpManagerConfig { /// Creates a manager configuration with safe permission defaults. #[must_use] pub fn new(launch: AcpLaunchConfig) -> Self { Self { launch, client_name: "galaxy".to_owned(), client_version: env!("CARGO_PKG_VERSION").to_owned(), cancellation_grace_period: DEFAULT_CANCELLATION_GRACE_PERIOD, initialization_timeout: DEFAULT_INITIALIZATION_TIMEOUT, authentication_timeout: DEFAULT_AUTHENTICATION_TIMEOUT, permission_handler: Arc::new(DenyByDefaultPermissionHandler), } } /// Creates a configuration from a pinned built-in preset. #[must_use] pub fn preset(preset: AcpAgentPreset) -> Self { Self::new(preset.launch_config()) } /// Replaces the permission decision hook. #[must_use] pub fn permission_handler(mut self, handler: Arc) -> Self { self.permission_handler = handler; self } /// Replaces the process-startup and ACP-initialization timeout. #[must_use] pub fn initialization_timeout(mut self, timeout: Duration) -> Self { self.initialization_timeout = timeout; self } /// Replaces the agent-owned authentication timeout. #[must_use] pub fn authentication_timeout(mut self, timeout: Duration) -> Self { self.authentication_timeout = timeout; self } } impl fmt::Debug for AcpManagerConfig { fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { formatter .debug_struct("AcpManagerConfig") .field("launch", &self.launch) .field("client_name", &self.client_name) .field("client_version", &self.client_version) .field("cancellation_grace_period", &self.cancellation_grace_period) .field("initialization_timeout", &self.initialization_timeout) .field("authentication_timeout", &self.authentication_timeout) .field("permission_handler", &"") .finish() } } #[cfg(test)] #[path = "config_tests.rs"] mod tests;