561 lines
19 KiB
Rust
561 lines
19 KiB
Rust
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<AcpLaunchConfig, String> {
|
|
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<AcpLaunchConfig, String> {
|
|
self.resolve_launch_config_with(executable_on_path)
|
|
}
|
|
|
|
fn resolve_launch_config_with(
|
|
self,
|
|
mut resolve: impl FnMut(&str) -> Option<PathBuf>,
|
|
) -> Result<AcpLaunchConfig, String> {
|
|
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<String>,
|
|
/// 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<String, String>,
|
|
/// 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<AuthMethodId>,
|
|
}
|
|
|
|
impl AcpLaunchConfig {
|
|
/// Creates launch settings for an executable.
|
|
#[must_use]
|
|
pub fn new(command: impl Into<PathBuf>) -> 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<I, S>(mut self, args: I) -> Self
|
|
where
|
|
I: IntoIterator<Item = S>,
|
|
S: Into<String>,
|
|
{
|
|
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<String>, value: impl Into<String>) -> 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<AuthMethodId>) -> 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<Path>) -> 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<Self, String> {
|
|
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<Item = (OsString, OsString)>,
|
|
explicit_environment: &BTreeMap<String, String>,
|
|
) -> BTreeMap<String, String> {
|
|
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<PathBuf> {
|
|
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<Item = PathBuf>,
|
|
executable_extensions: &[String],
|
|
) -> Option<PathBuf> {
|
|
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<String> {
|
|
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<String> {
|
|
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::<Vec<_>>()
|
|
});
|
|
|
|
if configured.is_empty() {
|
|
DEFAULT_WINDOWS_EXECUTABLE_EXTENSIONS
|
|
.iter()
|
|
.map(ToString::to_string)
|
|
.collect()
|
|
} else {
|
|
configured
|
|
}
|
|
}
|
|
|
|
#[cfg(not(windows))]
|
|
fn platform_executable_extensions() -> Vec<String> {
|
|
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<dyn PermissionHandler>,
|
|
}
|
|
|
|
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<dyn PermissionHandler>) -> 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", &"<permission handler>")
|
|
.finish()
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
#[path = "config_tests.rs"]
|
|
mod tests;
|