Add ACP agent backend and terminal controls
This commit is contained in:
@@ -0,0 +1,17 @@
|
||||
[package]
|
||||
name = "galaxy_acp"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
publish = false
|
||||
|
||||
[dependencies]
|
||||
agent-client-protocol.workspace = true
|
||||
async-channel.workspace = true
|
||||
async-io.workspace = true
|
||||
futures.workspace = true
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
thiserror.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile.workspace = true
|
||||
@@ -0,0 +1,469 @@
|
||||
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};
|
||||
|
||||
/// Pinned version of the official Codex ACP adapter.
|
||||
pub const CODEX_ACP_NPM_VERSION: &str = "1.1.7";
|
||||
|
||||
/// Pinned version of OpenCode used by the built-in ACP launch preset.
|
||||
pub const OPENCODE_NPM_VERSION: &str = "1.18.9";
|
||||
|
||||
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(vec![
|
||||
"--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.
|
||||
///
|
||||
/// OpenCode's native binary is preferred when installed. The Codex adapter
|
||||
/// uses `npx` when available and can run through Bun's Node compatibility
|
||||
/// mode. OpenCode's npm wrapper requires Node during 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 (command, args) = if let Some(command) = resolve("npx") {
|
||||
(
|
||||
command,
|
||||
vec![
|
||||
"--yes".to_owned(),
|
||||
format!("@agentclientprotocol/codex-acp@{CODEX_ACP_NPM_VERSION}"),
|
||||
],
|
||||
)
|
||||
} else if let Some(command) = resolve("bunx") {
|
||||
(
|
||||
command,
|
||||
vec![
|
||||
"--bun".to_owned(),
|
||||
format!("@agentclientprotocol/codex-acp@{CODEX_ACP_NPM_VERSION}"),
|
||||
],
|
||||
)
|
||||
} else {
|
||||
return Err(
|
||||
"Codex ACP requires npx or bunx; install Node.js/npm or Bun, or configure a custom ACP executable"
|
||||
.to_owned(),
|
||||
);
|
||||
};
|
||||
Ok(AcpLaunchConfig::new(command)
|
||||
.args(args)
|
||||
.preferred_auth_method("chat-gpt")
|
||||
.env("DEFAULT_AUTH_REQUEST", r#"{"methodId":"chat-gpt"}"#)
|
||||
.env("INITIAL_AGENT_MODE", "read-only"))
|
||||
}
|
||||
Self::OpenCode => {
|
||||
if let Some(command) = resolve("opencode") {
|
||||
return Ok(AcpLaunchConfig::new(command).args(["acp"]));
|
||||
}
|
||||
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 the opencode executable or npx; install OpenCode or 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;
|
||||
@@ -0,0 +1,273 @@
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::time::Duration;
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn codex_preset_is_version_pinned() {
|
||||
let launch = AcpAgentPreset::Codex.launch_config();
|
||||
|
||||
assert_eq!(launch.command, PathBuf::from("npx"));
|
||||
assert_eq!(
|
||||
launch.args,
|
||||
vec![
|
||||
"--yes",
|
||||
&format!("@agentclientprotocol/codex-acp@{CODEX_ACP_NPM_VERSION}")
|
||||
]
|
||||
);
|
||||
assert_eq!(
|
||||
launch.env.get("DEFAULT_AUTH_REQUEST").map(String::as_str),
|
||||
Some(r#"{"methodId":"chat-gpt"}"#)
|
||||
);
|
||||
assert_eq!(
|
||||
launch.env.get("INITIAL_AGENT_MODE").map(String::as_str),
|
||||
Some("read-only")
|
||||
);
|
||||
assert_eq!(
|
||||
launch
|
||||
.preferred_auth_method
|
||||
.as_ref()
|
||||
.map(ToString::to_string),
|
||||
Some("chat-gpt".to_owned())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn opencode_preset_is_version_pinned() {
|
||||
let launch = AcpAgentPreset::OpenCode.launch_config();
|
||||
|
||||
assert_eq!(launch.command, PathBuf::from("npx"));
|
||||
assert_eq!(
|
||||
launch.args,
|
||||
vec![
|
||||
"--yes",
|
||||
&format!("opencode-ai@{OPENCODE_NPM_VERSION}"),
|
||||
"acp"
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolved_opencode_prefers_the_native_executable() {
|
||||
let launch = AcpAgentPreset::OpenCode
|
||||
.resolve_launch_config_with(|command| match command {
|
||||
"opencode" => Some(PathBuf::from("/opt/bin/opencode")),
|
||||
"npx" => Some(PathBuf::from("/opt/bin/npx")),
|
||||
_ => None,
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(launch.command, PathBuf::from("/opt/bin/opencode"));
|
||||
assert_eq!(launch.args, vec!["acp"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolved_codex_falls_back_to_bun_compatibility_mode() {
|
||||
let resolve = |command: &str| (command == "bunx").then(|| PathBuf::from("/opt/bin/bunx"));
|
||||
let codex = AcpAgentPreset::Codex
|
||||
.resolve_launch_config_with(resolve)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(codex.command, PathBuf::from("/opt/bin/bunx"));
|
||||
assert_eq!(
|
||||
codex.args,
|
||||
vec![
|
||||
"--bun".to_owned(),
|
||||
format!("@agentclientprotocol/codex-acp@{CODEX_ACP_NPM_VERSION}")
|
||||
]
|
||||
);
|
||||
assert_eq!(
|
||||
codex.env.get("INITIAL_AGENT_MODE").map(String::as_str),
|
||||
Some("read-only")
|
||||
);
|
||||
assert_eq!(
|
||||
codex
|
||||
.preferred_auth_method
|
||||
.as_ref()
|
||||
.map(ToString::to_string),
|
||||
Some("chat-gpt".to_owned())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolved_presets_explain_missing_launchers() {
|
||||
let error = AcpAgentPreset::Codex
|
||||
.resolve_launch_config_with(|_| None)
|
||||
.unwrap_err();
|
||||
|
||||
assert!(error.contains("requires npx or bunx"));
|
||||
|
||||
let opencode_error = AcpAgentPreset::OpenCode
|
||||
.resolve_launch_config_with(|command| {
|
||||
(command == "bunx").then(|| PathBuf::from("/opt/bin/bunx"))
|
||||
})
|
||||
.unwrap_err();
|
||||
assert!(opencode_error.contains("requires the opencode executable or npx"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn launch_config_is_a_comparable_fingerprint() {
|
||||
let first = AcpLaunchConfig::new("/usr/bin/npx")
|
||||
.args(["agent", "acp"])
|
||||
.env("TOKEN", "first")
|
||||
.preferred_auth_method("browser");
|
||||
let same = first.clone();
|
||||
let different = first.clone().env("TOKEN", "second");
|
||||
let different_auth = first.clone().preferred_auth_method("api-key");
|
||||
|
||||
assert_eq!(first, same);
|
||||
assert_ne!(first, different);
|
||||
assert_ne!(first, different_auth);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn child_environment_clears_credentials_and_preserves_runtime_paths() {
|
||||
let parent = [
|
||||
("PATH", "/usr/bin"),
|
||||
("HOME", "/Users/test"),
|
||||
("XDG_CONFIG_HOME", "/Users/test/.config"),
|
||||
("OPENAI_API_KEY", "secret-openai-key"),
|
||||
("AWS_SECRET_ACCESS_KEY", "secret-aws-key"),
|
||||
("HTTPS_PROXY", "https://user:secret@example.com"),
|
||||
("GALAXY_INTERNAL_SECRET", "secret-galaxy-value"),
|
||||
("XDG_AGENT_TOKEN", "secret-xdg-value"),
|
||||
("LC_AGENT_TOKEN", "secret-locale-value"),
|
||||
]
|
||||
.map(|(name, value)| (OsString::from(name), OsString::from(value)));
|
||||
let explicit = BTreeMap::from([
|
||||
("INITIAL_AGENT_MODE".to_owned(), "read-only".to_owned()),
|
||||
(
|
||||
"DEFAULT_AUTH_REQUEST".to_owned(),
|
||||
r#"{"methodId":"chat-gpt"}"#.to_owned(),
|
||||
),
|
||||
]);
|
||||
|
||||
let overrides = sanitized_environment_overrides(parent, &explicit);
|
||||
|
||||
assert!(!overrides.contains_key("PATH"));
|
||||
assert!(!overrides.contains_key("HOME"));
|
||||
assert!(!overrides.contains_key("XDG_CONFIG_HOME"));
|
||||
assert_eq!(
|
||||
overrides.get("OPENAI_API_KEY").map(String::as_str),
|
||||
Some("")
|
||||
);
|
||||
assert_eq!(
|
||||
overrides.get("AWS_SECRET_ACCESS_KEY").map(String::as_str),
|
||||
Some("")
|
||||
);
|
||||
assert_eq!(overrides.get("HTTPS_PROXY").map(String::as_str), Some(""));
|
||||
assert_eq!(
|
||||
overrides.get("GALAXY_INTERNAL_SECRET").map(String::as_str),
|
||||
Some("")
|
||||
);
|
||||
assert_eq!(
|
||||
overrides.get("XDG_AGENT_TOKEN").map(String::as_str),
|
||||
Some("")
|
||||
);
|
||||
assert_eq!(
|
||||
overrides.get("LC_AGENT_TOKEN").map(String::as_str),
|
||||
Some("")
|
||||
);
|
||||
assert_eq!(
|
||||
overrides.get("INITIAL_AGENT_MODE").map(String::as_str),
|
||||
Some("read-only")
|
||||
);
|
||||
assert_eq!(
|
||||
overrides.get("DEFAULT_AUTH_REQUEST").map(String::as_str),
|
||||
Some(r#"{"methodId":"chat-gpt"}"#)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_agent_environment_wins_over_scrubbing() {
|
||||
let parent = [(
|
||||
OsString::from("AGENT_AUTH_TOKEN"),
|
||||
OsString::from("parent-secret"),
|
||||
)];
|
||||
let explicit = BTreeMap::from([("AGENT_AUTH_TOKEN".to_owned(), "explicit-value".to_owned())]);
|
||||
|
||||
let overrides = sanitized_environment_overrides(parent, &explicit);
|
||||
|
||||
assert_eq!(
|
||||
overrides.get("AGENT_AUTH_TOKEN").map(String::as_str),
|
||||
Some("explicit-value")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn configured_commands_are_resolved_before_launch() {
|
||||
let executable = std::env::current_exe().unwrap();
|
||||
let launch = AcpLaunchConfig::new(&executable).resolve_command().unwrap();
|
||||
|
||||
assert_eq!(launch.command, executable);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn executable_resolution_uses_platform_extensions_in_order() {
|
||||
let temp_dir = tempfile::TempDir::new().unwrap();
|
||||
let command_path = temp_dir.path().join("npx.cmd");
|
||||
std::fs::write(&command_path, "@echo off\r\n").unwrap();
|
||||
|
||||
let resolved = find_executable_in_directories(
|
||||
"npx",
|
||||
[temp_dir.path().to_owned()],
|
||||
&[".exe".to_owned(), ".cmd".to_owned()],
|
||||
);
|
||||
|
||||
assert_eq!(resolved, Some(command_path));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn executable_resolution_does_not_append_extensions_to_explicit_extension() {
|
||||
let temp_dir = tempfile::TempDir::new().unwrap();
|
||||
std::fs::write(temp_dir.path().join("agent.exe.cmd"), "@echo off\r\n").unwrap();
|
||||
|
||||
let resolved = find_executable_in_directories(
|
||||
"agent.exe",
|
||||
[temp_dir.path().to_owned()],
|
||||
&[".cmd".to_owned()],
|
||||
);
|
||||
|
||||
assert_eq!(resolved, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn windows_path_extensions_are_parsed_in_declared_order() {
|
||||
let extensions = windows_executable_extensions(Some(std::ffi::OsStr::new(".COM;.EXE; .CMD;")));
|
||||
|
||||
assert_eq!(extensions, vec![".COM", ".EXE", ".CMD"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn windows_path_extensions_use_standard_fallback_when_missing_or_empty() {
|
||||
let expected = vec![".COM", ".EXE", ".BAT", ".CMD"];
|
||||
|
||||
assert_eq!(windows_executable_extensions(None), expected);
|
||||
assert_eq!(
|
||||
windows_executable_extensions(Some(std::ffi::OsStr::new(" ; "))),
|
||||
expected
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn codex_path_sets_the_adapter_environment_variable() {
|
||||
let launch = AcpLaunchConfig::new("npx").codex_path(Path::new("/opt/codex"));
|
||||
|
||||
assert_eq!(
|
||||
launch.env.get("CODEX_PATH").map(String::as_str),
|
||||
Some("/opt/codex")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn initialization_timeout_is_configurable_and_bounded_by_default() {
|
||||
let default = AcpManagerConfig::new(AcpLaunchConfig::new("agent"));
|
||||
let custom = AcpManagerConfig::new(AcpLaunchConfig::new("agent"))
|
||||
.initialization_timeout(Duration::from_secs(2))
|
||||
.authentication_timeout(Duration::from_secs(3));
|
||||
|
||||
assert_eq!(default.initialization_timeout, Duration::from_secs(30));
|
||||
assert_eq!(default.authentication_timeout, Duration::from_secs(5 * 60));
|
||||
assert_eq!(custom.initialization_timeout, Duration::from_secs(2));
|
||||
assert_eq!(custom.authentication_timeout, Duration::from_secs(3));
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
use agent_client_protocol::schema::v1::{
|
||||
ContentBlock, Cost, RequestPermissionRequest, SessionId, StopReason, ToolCallId, ToolCallStatus,
|
||||
};
|
||||
|
||||
use crate::PermissionDecision;
|
||||
|
||||
/// Visible events produced while Galaxy drives an ACP turn.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
#[non_exhaustive]
|
||||
pub enum AcpEvent {
|
||||
/// A new or restored ACP session is ready.
|
||||
SessionStarted {
|
||||
/// Agent-owned session identifier.
|
||||
session_id: SessionId,
|
||||
/// Whether this agent advertised `session/load`.
|
||||
can_load: bool,
|
||||
/// Whether this agent advertised the Codex `_session/steering`
|
||||
/// extension.
|
||||
can_steer: bool,
|
||||
},
|
||||
/// A streamed text fragment from the agent.
|
||||
AgentText {
|
||||
/// Markdown-capable text fragment.
|
||||
text: String,
|
||||
},
|
||||
/// A streamed reasoning fragment from the agent.
|
||||
AgentThought {
|
||||
/// Thought text fragment.
|
||||
text: String,
|
||||
},
|
||||
/// A non-text content block from an agent message or thought.
|
||||
AgentContent {
|
||||
/// ACP content block.
|
||||
content: ContentBlock,
|
||||
/// `true` when this block came from an agent-thought update.
|
||||
thought: bool,
|
||||
},
|
||||
/// Content supplied by the user while a prompt is already running.
|
||||
///
|
||||
/// ACP agents use this update to echo accepted live steering input. Galaxy
|
||||
/// can render it as a user message without exposing hidden system context.
|
||||
UserContent {
|
||||
/// ACP content block accepted by the agent.
|
||||
content: ContentBlock,
|
||||
},
|
||||
/// A tool call began.
|
||||
ToolCall {
|
||||
/// ACP tool-call identifier.
|
||||
id: ToolCallId,
|
||||
/// Human-readable title.
|
||||
title: String,
|
||||
/// Current execution status.
|
||||
status: ToolCallStatus,
|
||||
/// Bounded, control-sequence-free output suitable for Galaxy's tool pane.
|
||||
output: Option<String>,
|
||||
},
|
||||
/// A tool call changed.
|
||||
ToolCallUpdate {
|
||||
/// ACP tool-call identifier.
|
||||
id: ToolCallId,
|
||||
/// New title, when supplied by the agent.
|
||||
title: Option<String>,
|
||||
/// New status, when supplied by the agent.
|
||||
status: Option<ToolCallStatus>,
|
||||
/// Bounded, control-sequence-free output supplied by this update.
|
||||
output: Option<String>,
|
||||
},
|
||||
/// Context-window or cost information changed.
|
||||
Usage {
|
||||
/// Tokens currently in context.
|
||||
used: u64,
|
||||
/// Total context-window size.
|
||||
size: u64,
|
||||
/// Optional cumulative cost.
|
||||
cost: Option<Cost>,
|
||||
},
|
||||
/// The agent asked for permission.
|
||||
PermissionRequested {
|
||||
/// Original request, suitable for rendering in Galaxy.
|
||||
request: RequestPermissionRequest,
|
||||
},
|
||||
/// Galaxy's permission hook resolved a request.
|
||||
PermissionResolved {
|
||||
/// Session to which the permission applies.
|
||||
session_id: SessionId,
|
||||
/// Decision returned by the hook.
|
||||
decision: PermissionDecision,
|
||||
},
|
||||
/// The prompt turn completed.
|
||||
Finished {
|
||||
/// ACP stop reason.
|
||||
stop_reason: StopReason,
|
||||
},
|
||||
/// A recoverable or terminal runtime error for this turn.
|
||||
Error {
|
||||
/// Safe, user-presentable error description.
|
||||
message: String,
|
||||
},
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "events_tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,65 @@
|
||||
use agent_client_protocol::schema::v1::{ContentBlock, SessionId, StopReason, TextContent};
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn session_started_exposes_agent_capabilities() {
|
||||
let event = AcpEvent::SessionStarted {
|
||||
session_id: SessionId::new("session-1"),
|
||||
can_load: true,
|
||||
can_steer: false,
|
||||
};
|
||||
|
||||
assert_eq!(
|
||||
event,
|
||||
AcpEvent::SessionStarted {
|
||||
session_id: SessionId::new("session-1"),
|
||||
can_load: true,
|
||||
can_steer: false,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn finished_preserves_the_protocol_stop_reason() {
|
||||
let event = AcpEvent::Finished {
|
||||
stop_reason: StopReason::Cancelled,
|
||||
};
|
||||
|
||||
assert_eq!(
|
||||
event,
|
||||
AcpEvent::Finished {
|
||||
stop_reason: StopReason::Cancelled,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn user_content_preserves_live_steering_input() {
|
||||
let content = ContentBlock::Text(TextContent::new("stop at 75 seconds"));
|
||||
let event = AcpEvent::UserContent {
|
||||
content: content.clone(),
|
||||
};
|
||||
|
||||
assert_eq!(event, AcpEvent::UserContent { content });
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tool_events_preserve_display_safe_output() {
|
||||
let event = AcpEvent::ToolCall {
|
||||
id: "tool-1".into(),
|
||||
title: "Run tests".to_owned(),
|
||||
status: agent_client_protocol::schema::v1::ToolCallStatus::Completed,
|
||||
output: Some("42 tests passed".to_owned()),
|
||||
};
|
||||
|
||||
assert_eq!(
|
||||
event,
|
||||
AcpEvent::ToolCall {
|
||||
id: "tool-1".into(),
|
||||
title: "Run tests".to_owned(),
|
||||
status: agent_client_protocol::schema::v1::ToolCallStatus::Completed,
|
||||
output: Some("42 tests passed".to_owned()),
|
||||
}
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
//! Runtime support for driving ACP agents from Galaxy.
|
||||
//!
|
||||
//! This crate deliberately contains no Galaxy UI or application-model code. It
|
||||
//! owns the ACP subprocess and translates the stable ACP v1 stream into a small
|
||||
//! event surface that the app can consume.
|
||||
|
||||
mod config;
|
||||
mod events;
|
||||
mod permissions;
|
||||
mod runtime;
|
||||
|
||||
pub use agent_client_protocol::schema::v1::{
|
||||
ContentBlock, Cost, ImageContent, McpServer, McpServerHttp, McpServerStdio, PermissionOptionId,
|
||||
SessionId, StopReason, TextContent, ToolCallId, ToolCallStatus,
|
||||
};
|
||||
pub use config::{
|
||||
AcpAgentPreset, AcpLaunchConfig, AcpManagerConfig, CODEX_ACP_NPM_VERSION, OPENCODE_NPM_VERSION,
|
||||
};
|
||||
pub use events::AcpEvent;
|
||||
pub use permissions::{
|
||||
AcpPermissionPolicy, DenyByDefaultPermissionHandler, PermissionContext, PermissionDecision,
|
||||
PermissionHandler,
|
||||
};
|
||||
pub use runtime::{
|
||||
AcpRuntimeError, AcpSessionHandle, AcpSessionManager, AcpSteeringOutcome, AcpTurnRequest,
|
||||
};
|
||||
@@ -0,0 +1,133 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use agent_client_protocol::schema::v1::{
|
||||
PermissionOptionId, PermissionOptionKind, RequestPermissionOutcome, RequestPermissionRequest,
|
||||
SelectedPermissionOutcome, ToolKind,
|
||||
};
|
||||
use futures::future::{BoxFuture, FutureExt as _};
|
||||
|
||||
/// Galaxy permissions that may be granted to an ACP agent for one turn.
|
||||
///
|
||||
/// This deliberately grants only categories marked `AlwaysAllow` by the
|
||||
/// active Galaxy execution profile. Interactive permissions remain denied
|
||||
/// until Galaxy can surface the agent's permission choices in its own UI.
|
||||
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
|
||||
pub struct AcpPermissionPolicy {
|
||||
/// Permit file and data reads, including searches.
|
||||
pub read: bool,
|
||||
/// Permit edits, deletes, and moves.
|
||||
pub edit: bool,
|
||||
/// Permit command and code execution.
|
||||
pub execute: bool,
|
||||
/// Permit fetching external data.
|
||||
pub fetch: bool,
|
||||
/// Permit uncategorized tools, including MCP tools.
|
||||
pub other: bool,
|
||||
}
|
||||
|
||||
impl AcpPermissionPolicy {
|
||||
fn allows(self, kind: ToolKind) -> bool {
|
||||
match kind {
|
||||
ToolKind::Read | ToolKind::Search => self.read,
|
||||
ToolKind::Edit | ToolKind::Delete | ToolKind::Move => self.edit,
|
||||
ToolKind::Execute => self.execute,
|
||||
ToolKind::Think => true,
|
||||
ToolKind::Fetch => self.fetch,
|
||||
ToolKind::SwitchMode => false,
|
||||
ToolKind::Other => self.other,
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Information supplied to Galaxy's permission hook.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct PermissionContext {
|
||||
/// The original ACP permission request.
|
||||
pub request: RequestPermissionRequest,
|
||||
/// Whether this turn was explicitly launched in Galaxy's autonomous
|
||||
/// execution mode.
|
||||
pub auto_approve: bool,
|
||||
/// Category permissions inherited from the active Galaxy profile.
|
||||
pub policy: AcpPermissionPolicy,
|
||||
}
|
||||
|
||||
/// A decision returned by a [`PermissionHandler`].
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub enum PermissionDecision {
|
||||
/// Select the first one-shot (then persistent) allow option.
|
||||
Allow,
|
||||
/// Select the first one-shot (then persistent) reject option.
|
||||
Deny,
|
||||
/// Select a specific option advertised by the agent.
|
||||
Select(PermissionOptionId),
|
||||
/// Report that the permission interaction was cancelled.
|
||||
Cancel,
|
||||
}
|
||||
|
||||
/// Host hook used to resolve ACP permission requests.
|
||||
pub trait PermissionHandler: Send + Sync {
|
||||
/// Returns a permission decision without blocking the ACP dispatch loop.
|
||||
fn decide(&self, context: PermissionContext) -> BoxFuture<'static, PermissionDecision>;
|
||||
}
|
||||
|
||||
/// Safe default permission hook.
|
||||
///
|
||||
/// Requests are rejected unless the individual turn explicitly opts into
|
||||
/// automatic approval.
|
||||
#[derive(Debug, Default)]
|
||||
pub struct DenyByDefaultPermissionHandler;
|
||||
|
||||
impl PermissionHandler for DenyByDefaultPermissionHandler {
|
||||
fn decide(&self, context: PermissionContext) -> BoxFuture<'static, PermissionDecision> {
|
||||
async move {
|
||||
let kind = context.request.tool_call.fields.kind.unwrap_or_default();
|
||||
if context.auto_approve || context.policy.allows(kind) {
|
||||
PermissionDecision::Allow
|
||||
} else {
|
||||
PermissionDecision::Deny
|
||||
}
|
||||
}
|
||||
.boxed()
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn outcome_for_decision(
|
||||
request: &RequestPermissionRequest,
|
||||
decision: PermissionDecision,
|
||||
) -> RequestPermissionOutcome {
|
||||
let selected = match decision {
|
||||
PermissionDecision::Allow => request
|
||||
.options
|
||||
.iter()
|
||||
.find(|option| option.kind == PermissionOptionKind::AllowOnce)
|
||||
.map(|option| option.option_id.clone()),
|
||||
PermissionDecision::Deny => request
|
||||
.options
|
||||
.iter()
|
||||
.find(|option| option.kind == PermissionOptionKind::RejectOnce)
|
||||
.or_else(|| {
|
||||
request
|
||||
.options
|
||||
.iter()
|
||||
.find(|option| option.kind == PermissionOptionKind::RejectAlways)
|
||||
})
|
||||
.map(|option| option.option_id.clone()),
|
||||
PermissionDecision::Select(option_id) => request
|
||||
.options
|
||||
.iter()
|
||||
.find(|option| option.option_id == option_id)
|
||||
.map(|option| option.option_id.clone()),
|
||||
PermissionDecision::Cancel => None,
|
||||
};
|
||||
|
||||
selected.map_or(RequestPermissionOutcome::Cancelled, |option_id| {
|
||||
RequestPermissionOutcome::Selected(SelectedPermissionOutcome::new(option_id))
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) type SharedPermissionHandler = Arc<dyn PermissionHandler>;
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "permissions_tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,126 @@
|
||||
use agent_client_protocol::schema::v1::{
|
||||
PermissionOption, PermissionOptionKind, RequestPermissionOutcome, RequestPermissionRequest,
|
||||
ToolCallUpdate, ToolCallUpdateFields, ToolKind,
|
||||
};
|
||||
|
||||
use super::*;
|
||||
|
||||
fn permission_request() -> RequestPermissionRequest {
|
||||
permission_request_for(ToolKind::Other)
|
||||
}
|
||||
|
||||
fn permission_request_for(kind: ToolKind) -> RequestPermissionRequest {
|
||||
RequestPermissionRequest::new(
|
||||
"session-1",
|
||||
ToolCallUpdate::new(
|
||||
"tool-1",
|
||||
ToolCallUpdateFields::new().title("Run command").kind(kind),
|
||||
),
|
||||
vec![
|
||||
PermissionOption::new(
|
||||
"allow-always",
|
||||
"Always allow",
|
||||
PermissionOptionKind::AllowAlways,
|
||||
),
|
||||
PermissionOption::new("allow-once", "Allow once", PermissionOptionKind::AllowOnce),
|
||||
PermissionOption::new(
|
||||
"reject-always",
|
||||
"Always reject",
|
||||
PermissionOptionKind::RejectAlways,
|
||||
),
|
||||
PermissionOption::new(
|
||||
"reject-once",
|
||||
"Reject once",
|
||||
PermissionOptionKind::RejectOnce,
|
||||
),
|
||||
],
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_handler_denies_interactive_turns() {
|
||||
let request = permission_request();
|
||||
let decision =
|
||||
futures::executor::block_on(DenyByDefaultPermissionHandler.decide(PermissionContext {
|
||||
request,
|
||||
auto_approve: false,
|
||||
policy: AcpPermissionPolicy::default(),
|
||||
}));
|
||||
|
||||
assert_eq!(decision, PermissionDecision::Deny);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_handler_allows_only_explicit_autonomous_turns() {
|
||||
let request = permission_request();
|
||||
let decision =
|
||||
futures::executor::block_on(DenyByDefaultPermissionHandler.decide(PermissionContext {
|
||||
request,
|
||||
auto_approve: true,
|
||||
policy: AcpPermissionPolicy::default(),
|
||||
}));
|
||||
|
||||
assert_eq!(decision, PermissionDecision::Allow);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_handler_honors_only_the_matching_profile_category() {
|
||||
let policy = AcpPermissionPolicy {
|
||||
read: true,
|
||||
..Default::default()
|
||||
};
|
||||
let read =
|
||||
futures::executor::block_on(DenyByDefaultPermissionHandler.decide(PermissionContext {
|
||||
request: permission_request_for(ToolKind::Read),
|
||||
auto_approve: false,
|
||||
policy,
|
||||
}));
|
||||
let execute =
|
||||
futures::executor::block_on(DenyByDefaultPermissionHandler.decide(PermissionContext {
|
||||
request: permission_request_for(ToolKind::Execute),
|
||||
auto_approve: false,
|
||||
policy,
|
||||
}));
|
||||
|
||||
assert_eq!(read, PermissionDecision::Allow);
|
||||
assert_eq!(execute, PermissionDecision::Deny);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn allow_and_deny_prefer_one_shot_options() {
|
||||
let request = permission_request();
|
||||
|
||||
let allow = outcome_for_decision(&request, PermissionDecision::Allow);
|
||||
let deny = outcome_for_decision(&request, PermissionDecision::Deny);
|
||||
|
||||
assert_eq!(
|
||||
allow,
|
||||
RequestPermissionOutcome::Selected(SelectedPermissionOutcome::new("allow-once"))
|
||||
);
|
||||
assert_eq!(
|
||||
deny,
|
||||
RequestPermissionOutcome::Selected(SelectedPermissionOutcome::new("reject-once"))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn allow_never_silently_selects_a_persistent_option() {
|
||||
let mut request = permission_request();
|
||||
request
|
||||
.options
|
||||
.retain(|option| option.kind != PermissionOptionKind::AllowOnce);
|
||||
|
||||
let outcome = outcome_for_decision(&request, PermissionDecision::Allow);
|
||||
|
||||
assert_eq!(outcome, RequestPermissionOutcome::Cancelled);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_selected_option_is_cancelled() {
|
||||
let outcome = outcome_for_decision(
|
||||
&permission_request(),
|
||||
PermissionDecision::Select(PermissionOptionId::new("not-advertised")),
|
||||
);
|
||||
|
||||
assert_eq!(outcome, RequestPermissionOutcome::Cancelled);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,788 @@
|
||||
use std::future::Future;
|
||||
use std::path::PathBuf;
|
||||
use std::pin::Pin;
|
||||
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::task::{Context, Poll};
|
||||
use std::time::Duration;
|
||||
|
||||
use agent_client_protocol::schema::v1::{
|
||||
AuthMethod, AuthMethodAgent, AuthMethodId, ContentBlock, ContentChunk, InitializeResponse,
|
||||
McpServer, McpServerStdio, SessionId, SessionUpdate, TextContent, ToolCall, ToolCallStatus,
|
||||
ToolCallUpdate, ToolCallUpdateFields, UsageUpdate,
|
||||
};
|
||||
use agent_client_protocol::schema::ProtocolVersion;
|
||||
|
||||
use super::*;
|
||||
|
||||
fn pending_turn(turn_id: u64, cwd: &str, session_id: Option<&str>) -> PendingTurn {
|
||||
let mut request = AcpTurnRequest::text("conversation", PathBuf::from(cwd), "hello");
|
||||
request.session_id = session_id.map(SessionId::new);
|
||||
let (events, _receiver) = async_channel::unbounded();
|
||||
PendingTurn {
|
||||
turn_id,
|
||||
request,
|
||||
events,
|
||||
}
|
||||
}
|
||||
|
||||
fn advertised_auth_method(id: &'static str) -> AuthMethod {
|
||||
AuthMethod::Agent(AuthMethodAgent::new(id, id))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn authentication_is_skipped_when_agent_advertises_no_methods() {
|
||||
let request = authentication_request(&[], None).unwrap();
|
||||
|
||||
assert_eq!(request, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn authentication_uses_first_advertised_method_by_default() {
|
||||
let methods = [
|
||||
advertised_auth_method("recommended"),
|
||||
advertised_auth_method("alternative"),
|
||||
];
|
||||
let request = authentication_request(&methods, None).unwrap().unwrap();
|
||||
|
||||
assert_eq!(request.method_id, AuthMethodId::new("recommended"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn authentication_uses_explicit_preference_instead_of_advertised_order() {
|
||||
let methods = [
|
||||
advertised_auth_method("api-key"),
|
||||
advertised_auth_method("chat-gpt"),
|
||||
];
|
||||
let request = authentication_request(&methods, Some(&AuthMethodId::new("chat-gpt")))
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(request.method_id, AuthMethodId::new("chat-gpt"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn authentication_rejects_preference_that_agent_did_not_advertise() {
|
||||
let methods = [
|
||||
advertised_auth_method("api-key"),
|
||||
advertised_auth_method("chat-gpt"),
|
||||
];
|
||||
let error = authentication_request(&methods, Some(&AuthMethodId::new("missing"))).unwrap_err();
|
||||
|
||||
assert!(error
|
||||
.to_string()
|
||||
.contains("preferred authentication method"));
|
||||
assert!(error.to_string().contains("api-key, chat-gpt"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn turn_validation_requires_absolute_paths() {
|
||||
let request = AcpTurnRequest::text("conversation", "relative", "hello");
|
||||
|
||||
assert!(matches!(
|
||||
request.validate(),
|
||||
Err(AcpRuntimeError::InvalidTurn(_))
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn turn_validation_rejects_relative_mcp_commands() {
|
||||
let request = AcpTurnRequest::text("conversation", PathBuf::from("/workspace"), "hello")
|
||||
.mcp_servers(vec![McpServer::Stdio(McpServerStdio::new(
|
||||
"server",
|
||||
"relative-command",
|
||||
))]);
|
||||
|
||||
assert!(matches!(
|
||||
request.validate(),
|
||||
Err(AcpRuntimeError::InvalidTurn(_))
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn persisted_session_restore_is_rejected_when_not_advertised() {
|
||||
let requested = SessionId::new("persisted");
|
||||
|
||||
assert_eq!(
|
||||
restorable_session_id(Some(&requested), true),
|
||||
Ok(Some(requested.clone()))
|
||||
);
|
||||
let error = restorable_session_id(Some(&requested), false).unwrap_err();
|
||||
assert!(error
|
||||
.to_string()
|
||||
.contains("does not advertise session/load"));
|
||||
assert_eq!(restorable_session_id(None, false), Ok(None));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn successful_session_load_does_not_create_a_replacement() {
|
||||
let load_calls = Arc::new(AtomicUsize::new(0));
|
||||
let create_calls = Arc::new(AtomicUsize::new(0));
|
||||
let load_count = Arc::clone(&load_calls);
|
||||
let create_count = Arc::clone(&create_calls);
|
||||
|
||||
let result = futures::executor::block_on(open_session(
|
||||
Some(SessionId::new("persisted")),
|
||||
move |_| {
|
||||
load_count.fetch_add(1, Ordering::Relaxed);
|
||||
futures::future::ready(Ok(()))
|
||||
},
|
||||
move || {
|
||||
create_count.fetch_add(1, Ordering::Relaxed);
|
||||
futures::future::ready(Ok(SessionId::new("replacement")))
|
||||
},
|
||||
))
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(result, SessionId::new("persisted"));
|
||||
assert_eq!(load_calls.load(Ordering::Relaxed), 1);
|
||||
assert_eq!(create_calls.load(Ordering::Relaxed), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn failed_session_load_is_visible_and_does_not_create_a_replacement() {
|
||||
let load_calls = Arc::new(AtomicUsize::new(0));
|
||||
let create_calls = Arc::new(AtomicUsize::new(0));
|
||||
let load_count = Arc::clone(&load_calls);
|
||||
let create_count = Arc::clone(&create_calls);
|
||||
|
||||
let error = futures::executor::block_on(open_session(
|
||||
Some(SessionId::new("expired")),
|
||||
move |_| {
|
||||
load_count.fetch_add(1, Ordering::Relaxed);
|
||||
futures::future::ready(Err(agent_client_protocol::Error::new(
|
||||
-32000,
|
||||
"unknown session",
|
||||
)))
|
||||
},
|
||||
move || {
|
||||
create_count.fetch_add(1, Ordering::Relaxed);
|
||||
futures::future::ready(Ok(SessionId::new("fresh")))
|
||||
},
|
||||
))
|
||||
.unwrap_err();
|
||||
|
||||
assert!(error.to_string().contains("unknown session"));
|
||||
assert_eq!(load_calls.load(Ordering::Relaxed), 1);
|
||||
assert_eq!(create_calls.load(Ordering::Relaxed), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn new_session_failure_is_returned_when_no_persisted_session_exists() {
|
||||
let error = futures::executor::block_on(open_session(
|
||||
None,
|
||||
|_| futures::future::ready(Ok(())),
|
||||
|| {
|
||||
futures::future::ready(Err(agent_client_protocol::Error::new(
|
||||
-32001,
|
||||
"new session failed",
|
||||
)))
|
||||
},
|
||||
))
|
||||
.unwrap_err();
|
||||
|
||||
assert!(error.to_string().contains("new session failed"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn steering_support_uses_codex_extension_metadata() {
|
||||
let mut steering = serde_json::Map::new();
|
||||
steering.insert("supported".to_owned(), serde_json::Value::Bool(true));
|
||||
let mut meta = serde_json::Map::new();
|
||||
meta.insert("steering".to_owned(), serde_json::Value::Object(steering));
|
||||
let response = InitializeResponse::new(ProtocolVersion::V1).meta(meta);
|
||||
|
||||
assert!(supports_steering(&response));
|
||||
assert!(!supports_steering(&InitializeResponse::new(
|
||||
ProtocolVersion::V1
|
||||
)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn streamed_text_and_thoughts_are_visible_events() {
|
||||
let text = event_from_session_update(SessionUpdate::AgentMessageChunk(ContentChunk::new(
|
||||
ContentBlock::Text(TextContent::new("answer")),
|
||||
)));
|
||||
let thought = event_from_session_update(SessionUpdate::AgentThoughtChunk(ContentChunk::new(
|
||||
ContentBlock::Text(TextContent::new("reasoning")),
|
||||
)));
|
||||
|
||||
assert_eq!(
|
||||
text,
|
||||
Some(AcpEvent::AgentText {
|
||||
text: "answer".to_owned()
|
||||
})
|
||||
);
|
||||
assert_eq!(
|
||||
thought,
|
||||
Some(AcpEvent::AgentThought {
|
||||
text: "reasoning".to_owned()
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn streamed_user_content_is_a_visible_steering_event() {
|
||||
let content = ContentBlock::Text(TextContent::new("stop after this step"));
|
||||
let event = event_from_session_update(SessionUpdate::UserMessageChunk(ContentChunk::new(
|
||||
content.clone(),
|
||||
)));
|
||||
|
||||
assert_eq!(event, Some(AcpEvent::UserContent { content }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tool_and_usage_updates_are_visible_events() {
|
||||
let tool = event_from_session_update(SessionUpdate::ToolCall(
|
||||
ToolCall::new("tool-1", "Run tests").status(ToolCallStatus::InProgress),
|
||||
));
|
||||
let update = event_from_session_update(SessionUpdate::ToolCallUpdate(ToolCallUpdate::new(
|
||||
"tool-1",
|
||||
ToolCallUpdateFields::new()
|
||||
.title("Tests passed")
|
||||
.status(ToolCallStatus::Completed),
|
||||
)));
|
||||
let usage =
|
||||
event_from_session_update(SessionUpdate::UsageUpdate(UsageUpdate::new(400, 200_000)));
|
||||
|
||||
assert_eq!(
|
||||
tool,
|
||||
Some(AcpEvent::ToolCall {
|
||||
id: "tool-1".into(),
|
||||
title: "Run tests".to_owned(),
|
||||
status: ToolCallStatus::InProgress,
|
||||
output: None,
|
||||
})
|
||||
);
|
||||
assert_eq!(
|
||||
update,
|
||||
Some(AcpEvent::ToolCallUpdate {
|
||||
id: "tool-1".into(),
|
||||
title: Some("Tests passed".to_owned()),
|
||||
status: Some(ToolCallStatus::Completed),
|
||||
output: None,
|
||||
})
|
||||
);
|
||||
assert_eq!(
|
||||
usage,
|
||||
Some(AcpEvent::Usage {
|
||||
used: 400,
|
||||
size: 200_000,
|
||||
cost: None,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tool_content_is_sanitized_before_becoming_visible_output() {
|
||||
let tool = event_from_session_update(SessionUpdate::ToolCall(
|
||||
ToolCall::new("tool-1", "Run tests").content(vec![ToolCallContent::from(
|
||||
ContentBlock::Text(TextContent::new(
|
||||
"\u{1b}[31m42 tests passed\u{1b}[0m\0\u{202e}",
|
||||
)),
|
||||
)]),
|
||||
));
|
||||
|
||||
assert_eq!(
|
||||
tool,
|
||||
Some(AcpEvent::ToolCall {
|
||||
id: "tool-1".into(),
|
||||
title: "Run tests".to_owned(),
|
||||
status: ToolCallStatus::Pending,
|
||||
output: Some("42 tests passed".to_owned()),
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn raw_tool_output_uses_display_text_and_agent_truncation_metadata() {
|
||||
let update = event_from_session_update(SessionUpdate::ToolCallUpdate(ToolCallUpdate::new(
|
||||
"tool-1",
|
||||
ToolCallUpdateFields::new().raw_output(serde_json::json!({
|
||||
"output": "first lines",
|
||||
"metadata": {
|
||||
"truncated": true
|
||||
}
|
||||
})),
|
||||
)));
|
||||
|
||||
assert_eq!(
|
||||
update,
|
||||
Some(AcpEvent::ToolCallUpdate {
|
||||
id: "tool-1".into(),
|
||||
title: None,
|
||||
status: None,
|
||||
output: Some("first lines\n[output truncated by ACP agent]".to_owned()),
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn terminal_output_extension_metadata_becomes_visible_output() {
|
||||
let meta: Meta = serde_json::from_value(serde_json::json!({
|
||||
"terminal_output": {
|
||||
"data": "\u{1b}[32mApplying migrations\u{1b}[0m\n",
|
||||
"terminal_id": "terminal-1"
|
||||
}
|
||||
}))
|
||||
.unwrap();
|
||||
let update = event_from_session_update(SessionUpdate::ToolCallUpdate(
|
||||
ToolCallUpdate::new("tool-1", ToolCallUpdateFields::new()).meta(meta),
|
||||
));
|
||||
|
||||
assert_eq!(
|
||||
update,
|
||||
Some(AcpEvent::ToolCallUpdate {
|
||||
id: "tool-1".into(),
|
||||
title: None,
|
||||
status: None,
|
||||
output: Some("Applying migrations\n".to_owned()),
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn terminal_exit_metadata_avoids_replaying_aggregated_raw_output() {
|
||||
let meta: Meta = serde_json::from_value(serde_json::json!({
|
||||
"terminal_exit": {
|
||||
"exit_code": 0,
|
||||
"signal": null,
|
||||
"terminal_id": "terminal-1"
|
||||
}
|
||||
}))
|
||||
.unwrap();
|
||||
let update = event_from_session_update(SessionUpdate::ToolCallUpdate(
|
||||
ToolCallUpdate::new(
|
||||
"tool-1",
|
||||
ToolCallUpdateFields::new()
|
||||
.status(ToolCallStatus::Completed)
|
||||
.raw_output(serde_json::json!({
|
||||
"formatted_output": "already streamed",
|
||||
"exit_code": 0
|
||||
})),
|
||||
)
|
||||
.meta(meta),
|
||||
));
|
||||
|
||||
assert_eq!(
|
||||
update,
|
||||
Some(AcpEvent::ToolCallUpdate {
|
||||
id: "tool-1".into(),
|
||||
title: None,
|
||||
status: Some(ToolCallStatus::Completed),
|
||||
output: Some("[terminal exited: code 0]".to_owned()),
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn visible_tool_output_is_utf8_safe_and_bounded() {
|
||||
let long_output = "🚀".repeat(MAX_VISIBLE_TOOL_OUTPUT_BYTES);
|
||||
let update = event_from_session_update(SessionUpdate::ToolCallUpdate(ToolCallUpdate::new(
|
||||
"tool-1",
|
||||
ToolCallUpdateFields::new().content(vec![ToolCallContent::from(ContentBlock::Text(
|
||||
TextContent::new(long_output),
|
||||
))]),
|
||||
)));
|
||||
let Some(AcpEvent::ToolCallUpdate {
|
||||
output: Some(output),
|
||||
..
|
||||
}) = update
|
||||
else {
|
||||
panic!("expected a visible tool-call update");
|
||||
};
|
||||
|
||||
assert!(output.is_char_boundary(output.len()));
|
||||
assert!(output.len() <= MAX_VISIBLE_TOOL_OUTPUT_BYTES);
|
||||
assert!(output.ends_with(TOOL_OUTPUT_TRUNCATION_MARKER));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn load_replay_is_suppressed_until_the_session_is_ready() {
|
||||
let router = EventRouter::default();
|
||||
let session_id = SessionId::new("persisted");
|
||||
let (events, receiver) = async_channel::unbounded();
|
||||
router.set_route(
|
||||
session_id.clone(),
|
||||
EventRoute {
|
||||
turn_id: 1,
|
||||
events,
|
||||
auto_approve: false,
|
||||
permission_policy: AcpPermissionPolicy::default(),
|
||||
},
|
||||
);
|
||||
router.suppress_replay(session_id.clone());
|
||||
|
||||
router.on_session_notification(SessionNotification::new(
|
||||
session_id.clone(),
|
||||
SessionUpdate::AgentMessageChunk(ContentChunk::new(ContentBlock::Text(TextContent::new(
|
||||
"old answer",
|
||||
)))),
|
||||
));
|
||||
assert!(matches!(
|
||||
receiver.try_recv(),
|
||||
Err(async_channel::TryRecvError::Empty)
|
||||
));
|
||||
|
||||
router.finish_replay(&session_id);
|
||||
router.on_session_notification(SessionNotification::new(
|
||||
session_id,
|
||||
SessionUpdate::AgentMessageChunk(ContentChunk::new(ContentBlock::Text(TextContent::new(
|
||||
"new answer",
|
||||
)))),
|
||||
));
|
||||
assert_eq!(
|
||||
receiver.try_recv(),
|
||||
Ok(AcpEvent::AgentText {
|
||||
text: "new answer".to_owned(),
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn queued_spec_changes_rotate_sessions_without_losing_fifo_order() {
|
||||
let first = pending_turn(1, "/workspace/first", None);
|
||||
let mut state = ConversationState::new(first);
|
||||
state.ready = true;
|
||||
state.session_id = Some(SessionId::new("old-session"));
|
||||
|
||||
let mut second = pending_turn(2, "/workspace/second", Some("old-session"));
|
||||
second.request.mcp_servers = vec![McpServer::Stdio(McpServerStdio::new(
|
||||
"galaxy",
|
||||
"/usr/bin/galaxy",
|
||||
))];
|
||||
let mut third = pending_turn(3, "/workspace/second", Some("old-session"));
|
||||
third.request.mcp_servers = second.request.mcp_servers.clone();
|
||||
state.queued.push_back(second);
|
||||
state.queued.push_back(third);
|
||||
|
||||
state.active.take();
|
||||
assert!(state.activate_next());
|
||||
assert_eq!(state.session_id, None);
|
||||
assert!(!state.ready);
|
||||
let second = state.active.as_ref().unwrap();
|
||||
assert_eq!(second.pending.turn_id, 2);
|
||||
assert_eq!(second.pending.request.session_id, None);
|
||||
assert_eq!(second.phase, TurnPhase::Opening);
|
||||
|
||||
state.ready = true;
|
||||
state.session_id = Some(SessionId::new("new-session"));
|
||||
state.active.take();
|
||||
assert!(state.activate_next());
|
||||
assert_eq!(state.session_id, Some(SessionId::new("new-session")));
|
||||
let third = state.active.as_ref().unwrap();
|
||||
assert_eq!(third.pending.turn_id, 3);
|
||||
assert_eq!(third.phase, TurnPhase::Prompting);
|
||||
assert!(state.queued.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn terminal_actor_errors_are_visible_to_active_and_queued_turns() {
|
||||
let (active_events, active_receiver) = async_channel::unbounded();
|
||||
let mut active = pending_turn(1, "/workspace", None);
|
||||
active.events = active_events;
|
||||
let (queued_events, queued_receiver) = async_channel::unbounded();
|
||||
let mut queued = pending_turn(2, "/workspace", None);
|
||||
queued.events = queued_events;
|
||||
let mut state = ConversationState::new(active);
|
||||
state.queued.push_back(queued);
|
||||
let conversations = HashMap::from([("conversation".to_owned(), state)]);
|
||||
|
||||
fail_conversations(&conversations, "protocol dispatch failed");
|
||||
|
||||
assert_eq!(
|
||||
active_receiver.try_recv(),
|
||||
Ok(AcpEvent::Error {
|
||||
message: "protocol dispatch failed".to_owned(),
|
||||
})
|
||||
);
|
||||
assert_eq!(
|
||||
queued_receiver.try_recv(),
|
||||
Ok(AcpEvent::Error {
|
||||
message: "protocol dispatch failed".to_owned(),
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn steering_outcome_uses_the_codex_wire_values() {
|
||||
assert_eq!(
|
||||
serde_json::to_value(AcpSteeringOutcome::Injected).unwrap(),
|
||||
serde_json::json!("injected")
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::from_value::<AcpSteeringOutcome>(serde_json::json!("startedNewTurn")).unwrap(),
|
||||
AcpSteeringOutcome::StartedNewTurn
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn implicit_steering_turn_requires_immediate_teardown() {
|
||||
assert!(!steering_became_untracked(AcpSteeringOutcome::Injected));
|
||||
assert!(steering_became_untracked(
|
||||
AcpSteeringOutcome::StartedNewTurn
|
||||
));
|
||||
assert!(!steering_became_untracked(AcpSteeringOutcome::Failed));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn untracked_steering_command_preserves_the_result_acknowledgement() {
|
||||
let (ack, acknowledgement) = oneshot::channel();
|
||||
let command = Command::AbortUntrackedSteering {
|
||||
conversation_key: "conversation-1".to_owned(),
|
||||
turn_id: 42,
|
||||
result: Ok(AcpSteeringOutcome::StartedNewTurn),
|
||||
ack,
|
||||
};
|
||||
let Command::AbortUntrackedSteering { result, ack, .. } = command else {
|
||||
panic!("expected an immediate untracked-steering abort");
|
||||
};
|
||||
let _ = ack.send(result);
|
||||
|
||||
assert_eq!(
|
||||
futures::executor::block_on(acknowledgement)
|
||||
.unwrap()
|
||||
.unwrap(),
|
||||
AcpSteeringOutcome::StartedNewTurn
|
||||
);
|
||||
}
|
||||
|
||||
struct PendingConnection {
|
||||
dropped: Arc<AtomicBool>,
|
||||
}
|
||||
|
||||
impl Future for PendingConnection {
|
||||
type Output = Result<(), AcpRuntimeError>;
|
||||
|
||||
fn poll(self: Pin<&mut Self>, _ctx: &mut Context<'_>) -> Poll<Self::Output> {
|
||||
Poll::Pending
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for PendingConnection {
|
||||
fn drop(&mut self) {
|
||||
self.dropped.store(true, Ordering::Release);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn initialization_timeout_cancels_the_connection_future() {
|
||||
let dropped = Arc::new(AtomicBool::new(false));
|
||||
let (_initialized_tx, initialized_rx) = oneshot::channel();
|
||||
let (_authenticated_tx, authenticated_rx) = oneshot::channel();
|
||||
let result = futures::executor::block_on(supervise_connection_readiness(
|
||||
PendingConnection {
|
||||
dropped: Arc::clone(&dropped),
|
||||
},
|
||||
initialized_rx,
|
||||
authenticated_rx,
|
||||
Duration::from_millis(10),
|
||||
Duration::from_secs(1),
|
||||
));
|
||||
|
||||
assert!(matches!(
|
||||
result,
|
||||
Err(AcpRuntimeError::InitializationTimeout(_))
|
||||
));
|
||||
assert!(dropped.load(Ordering::Acquire));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn authentication_timeout_cancels_the_initialized_connection_future() {
|
||||
let dropped = Arc::new(AtomicBool::new(false));
|
||||
let (initialized_tx, initialized_rx) = oneshot::channel();
|
||||
let (_authenticated_tx, authenticated_rx) = oneshot::channel();
|
||||
let _ = initialized_tx.send(());
|
||||
let result = futures::executor::block_on(supervise_connection_readiness(
|
||||
PendingConnection {
|
||||
dropped: Arc::clone(&dropped),
|
||||
},
|
||||
initialized_rx,
|
||||
authenticated_rx,
|
||||
Duration::from_secs(1),
|
||||
Duration::from_millis(10),
|
||||
));
|
||||
|
||||
assert!(matches!(
|
||||
result,
|
||||
Err(AcpRuntimeError::AuthenticationTimeout(_))
|
||||
));
|
||||
assert!(dropped.load(Ordering::Acquire));
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn hung_agent_initialization_closes_queued_turns_and_cancellation() {
|
||||
let manager = AcpSessionManager::spawn(
|
||||
AcpManagerConfig::new(AcpLaunchConfig::new("/bin/sh").args(["-c", "exec sleep 30"]))
|
||||
.initialization_timeout(Duration::from_millis(50)),
|
||||
)
|
||||
.unwrap();
|
||||
let (handle, events) = manager
|
||||
.run_turn(AcpTurnRequest::text(
|
||||
"conversation",
|
||||
PathBuf::from("/workspace"),
|
||||
"hello",
|
||||
))
|
||||
.unwrap();
|
||||
|
||||
let cancellation = futures::executor::block_on(async {
|
||||
match future::select(
|
||||
Box::pin(handle.cancel()),
|
||||
Box::pin(async_io::Timer::after(Duration::from_secs(2))),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Either::Left((result, _)) => result,
|
||||
Either::Right((_, _)) => panic!("cancellation remained blocked after init timeout"),
|
||||
}
|
||||
});
|
||||
let event = futures::executor::block_on(async {
|
||||
match future::select(
|
||||
Box::pin(events.recv()),
|
||||
Box::pin(async_io::Timer::after(Duration::from_secs(2))),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Either::Left((result, _)) => result.unwrap(),
|
||||
Either::Right((_, _)) => panic!("queued turn was not failed after init timeout"),
|
||||
}
|
||||
});
|
||||
|
||||
assert!(matches!(
|
||||
cancellation,
|
||||
Err(AcpRuntimeError::RuntimeClosed(_))
|
||||
));
|
||||
assert!(matches!(
|
||||
event,
|
||||
AcpEvent::Error { message } if message.contains("did not initialize")
|
||||
));
|
||||
assert!(!manager.is_alive());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn manager_liveness_is_observable() {
|
||||
let (command_tx, _command_rx) = async_channel::unbounded();
|
||||
let manager = AcpSessionManager {
|
||||
inner: Arc::new(ManagerInner {
|
||||
command_tx,
|
||||
launch: AcpLaunchConfig::new("agent"),
|
||||
alive: AtomicBool::new(true),
|
||||
terminal_error: Mutex::new(None),
|
||||
}),
|
||||
};
|
||||
|
||||
assert!(manager.is_alive());
|
||||
manager.inner.alive.store(false, Ordering::Release);
|
||||
assert!(!manager.is_alive());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn process_tree_teardown_allows_supported_platforms() {
|
||||
assert!(validate_process_tree_teardown(true).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn process_tree_teardown_fails_closed_on_unsupported_platforms() {
|
||||
assert!(matches!(
|
||||
validate_process_tree_teardown(false),
|
||||
Err(AcpRuntimeError::ProcessTreeTeardownUnsupported)
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dropping_the_last_manager_requests_worker_shutdown() {
|
||||
let (command_tx, command_rx) = async_channel::unbounded();
|
||||
let manager = AcpSessionManager {
|
||||
inner: Arc::new(ManagerInner {
|
||||
command_tx,
|
||||
launch: AcpLaunchConfig::new("agent"),
|
||||
alive: AtomicBool::new(true),
|
||||
terminal_error: Mutex::new(None),
|
||||
}),
|
||||
};
|
||||
|
||||
drop(manager);
|
||||
|
||||
assert!(matches!(command_rx.try_recv(), Ok(Command::Shutdown)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn session_handle_cancel_targets_its_exact_turn_and_waits_for_ack() {
|
||||
let (command_tx, command_rx) = async_channel::unbounded();
|
||||
let manager = AcpSessionManager {
|
||||
inner: Arc::new(ManagerInner {
|
||||
command_tx,
|
||||
launch: AcpLaunchConfig::new("agent"),
|
||||
alive: AtomicBool::new(true),
|
||||
terminal_error: Mutex::new(None),
|
||||
}),
|
||||
};
|
||||
let handle = AcpSessionHandle {
|
||||
manager,
|
||||
conversation_key: "conversation-1".to_owned(),
|
||||
turn_id: 42,
|
||||
};
|
||||
|
||||
let acknowledge = async move {
|
||||
let command = command_rx.recv().await.unwrap();
|
||||
let Command::Cancel {
|
||||
conversation_key,
|
||||
turn_id,
|
||||
ack,
|
||||
} = command
|
||||
else {
|
||||
panic!("cancel must not be translated into another command");
|
||||
};
|
||||
assert_eq!(conversation_key, "conversation-1");
|
||||
assert_eq!(turn_id, 42);
|
||||
let _ = ack.send(Ok(()));
|
||||
};
|
||||
let (result, ()) =
|
||||
futures::executor::block_on(futures::future::join(handle.cancel(), acknowledge));
|
||||
|
||||
assert!(result.is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn steering_uses_its_typed_command_and_preserves_unsupported_error() {
|
||||
let (command_tx, command_rx) = async_channel::unbounded();
|
||||
let manager = AcpSessionManager {
|
||||
inner: Arc::new(ManagerInner {
|
||||
command_tx,
|
||||
launch: AcpLaunchConfig::new("agent"),
|
||||
alive: AtomicBool::new(true),
|
||||
terminal_error: Mutex::new(None),
|
||||
}),
|
||||
};
|
||||
let handle = AcpSessionHandle {
|
||||
manager,
|
||||
conversation_key: "conversation-1".to_owned(),
|
||||
turn_id: 7,
|
||||
};
|
||||
let prompt = vec![ContentBlock::Text(TextContent::new("stop after this step"))];
|
||||
|
||||
let respond = async move {
|
||||
let command = command_rx.recv().await.unwrap();
|
||||
let Command::Steer {
|
||||
conversation_key,
|
||||
turn_id,
|
||||
prompt,
|
||||
ack,
|
||||
} = command
|
||||
else {
|
||||
panic!("steering must never fall back to a concurrent prompt");
|
||||
};
|
||||
assert_eq!(conversation_key, "conversation-1");
|
||||
assert_eq!(turn_id, 7);
|
||||
assert_eq!(
|
||||
prompt,
|
||||
vec![ContentBlock::Text(TextContent::new("stop after this step"))]
|
||||
);
|
||||
let _ = ack.send(Err(AcpRuntimeError::SteeringUnsupported));
|
||||
};
|
||||
let (result, ()) =
|
||||
futures::executor::block_on(futures::future::join(handle.steer(prompt), respond));
|
||||
|
||||
assert!(matches!(result, Err(AcpRuntimeError::SteeringUnsupported)));
|
||||
}
|
||||
@@ -11,6 +11,7 @@ chrono.workspace = true
|
||||
clap = { workspace = true, features = ["derive", "env"] }
|
||||
cfg-if = { workspace = true }
|
||||
humantime.workspace = true
|
||||
instant.workspace = true
|
||||
jaq-all.workspace = true
|
||||
serde = { workspace = true, features = ["derive"] }
|
||||
serde_json.workspace = true
|
||||
|
||||
@@ -6,7 +6,7 @@ use local_control::protocol::{
|
||||
ControlError, DirectionParams, EmptyParams, ErrorCode, FileOpenParams, KeyParams,
|
||||
KeyValueParams, PageQueryParams, QueryParams, RenameParams, RequestEnvelope, ResizeParams,
|
||||
SettingListParams, TabActivateParams, TabActivationMode, TabCloseMode, TabCloseParams,
|
||||
TabCreateParams, TextParams, ThemeNameParams,
|
||||
TabCreateParams, TerminalExecuteParams, TerminalInterruptParams, TextParams, ThemeNameParams,
|
||||
};
|
||||
use local_control::selection::select_instance;
|
||||
use serde::Serialize;
|
||||
@@ -19,7 +19,7 @@ use crate::local_control::{
|
||||
InputCommand, InstanceCommand, KeybindingCommand, PaneCommand, SessionCommand, SettingCommand,
|
||||
SurfaceCommand, SurfaceOpenCommand, SurfaceOpenToggleCommand, SurfaceQueryCommand,
|
||||
SurfaceSettingsCommand, SurfaceToggleCommand, TabActivateArgs, TabCloseArgs, TabColorCommand,
|
||||
TabCommand, TargetArgs, ThemeCommand, WindowCommand,
|
||||
TabCommand, TargetArgs, TerminalCommand, ThemeCommand, WindowCommand,
|
||||
};
|
||||
|
||||
pub(super) fn run_surface_command(
|
||||
@@ -509,6 +509,36 @@ pub(super) fn run_input_command(
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn run_terminal_command(
|
||||
command: TerminalCommand,
|
||||
output_format: OutputFormat,
|
||||
) -> Result<(), ControlError> {
|
||||
match command {
|
||||
TerminalCommand::Status(args) => run_action_with_params(
|
||||
args,
|
||||
ActionKind::TerminalStatus,
|
||||
EmptyParams {},
|
||||
output_format,
|
||||
),
|
||||
TerminalCommand::Execute(args) => run_action_with_params(
|
||||
args.target,
|
||||
ActionKind::TerminalExecute,
|
||||
TerminalExecuteParams {
|
||||
command: args.command,
|
||||
},
|
||||
output_format,
|
||||
),
|
||||
TerminalCommand::Interrupt(args) => run_action_with_params(
|
||||
args.target,
|
||||
ActionKind::TerminalInterrupt,
|
||||
TerminalInterruptParams {
|
||||
block_id: args.block_id,
|
||||
},
|
||||
output_format,
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn run_theme_command(
|
||||
command: ThemeCommand,
|
||||
output_format: OutputFormat,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,988 @@
|
||||
use std::cell::RefCell;
|
||||
use std::collections::VecDeque;
|
||||
use std::io::Cursor;
|
||||
|
||||
use clap::Parser as _;
|
||||
use local_control::protocol::{PaneSelector, PaneTarget};
|
||||
use serde_json::json;
|
||||
|
||||
use super::*;
|
||||
use crate::local_control::{ControlArgs, ControlCommand};
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
struct Invocation {
|
||||
action: ActionKind,
|
||||
target: TargetSelector,
|
||||
params: Value,
|
||||
}
|
||||
|
||||
struct RecordingInvoker {
|
||||
calls: RefCell<Vec<Invocation>>,
|
||||
result: Result<Value, ControlError>,
|
||||
}
|
||||
|
||||
impl RecordingInvoker {
|
||||
fn succeeding(result: Value) -> Self {
|
||||
Self {
|
||||
calls: RefCell::new(Vec::new()),
|
||||
result: Ok(result),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ActionInvoker for RecordingInvoker {
|
||||
fn invoke(
|
||||
&self,
|
||||
action: ActionKind,
|
||||
target: TargetSelector,
|
||||
params: Value,
|
||||
) -> Result<Value, ControlError> {
|
||||
self.calls.borrow_mut().push(Invocation {
|
||||
action,
|
||||
target,
|
||||
params,
|
||||
});
|
||||
self.result.clone()
|
||||
}
|
||||
}
|
||||
|
||||
struct SequencedInvoker {
|
||||
calls: RefCell<Vec<Invocation>>,
|
||||
results: RefCell<VecDeque<Result<Value, ControlError>>>,
|
||||
}
|
||||
|
||||
impl SequencedInvoker {
|
||||
fn new(results: impl IntoIterator<Item = Result<Value, ControlError>>) -> Self {
|
||||
Self {
|
||||
calls: RefCell::new(Vec::new()),
|
||||
results: RefCell::new(results.into_iter().collect()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ActionInvoker for SequencedInvoker {
|
||||
fn invoke(
|
||||
&self,
|
||||
action: ActionKind,
|
||||
target: TargetSelector,
|
||||
params: Value,
|
||||
) -> Result<Value, ControlError> {
|
||||
self.calls.borrow_mut().push(Invocation {
|
||||
action,
|
||||
target,
|
||||
params,
|
||||
});
|
||||
self.results
|
||||
.borrow_mut()
|
||||
.pop_front()
|
||||
.expect("test invoker has a response for every call")
|
||||
}
|
||||
}
|
||||
|
||||
fn terminal_status(block_id: &str, running_for_ms: Option<u64>) -> Value {
|
||||
let is_running = running_for_ms.is_some();
|
||||
json!({
|
||||
"action": ActionKind::TerminalStatus,
|
||||
"session_id": "session_1",
|
||||
"active_block_id": block_id,
|
||||
"is_executing": is_running,
|
||||
"is_command_pending": false,
|
||||
"is_long_running": is_running,
|
||||
"is_agent_in_control": false,
|
||||
"is_idle": !is_running,
|
||||
"running_for_ms": running_for_ms,
|
||||
"command_summary": "cargo test",
|
||||
})
|
||||
}
|
||||
|
||||
fn initialize(session: &mut McpSession<'_>) -> Value {
|
||||
process(
|
||||
session,
|
||||
json!({
|
||||
"jsonrpc": "2.0",
|
||||
"id": 1,
|
||||
"method": "initialize",
|
||||
"params": {
|
||||
"protocolVersion": "2025-06-18",
|
||||
"capabilities": {},
|
||||
"clientInfo": {
|
||||
"name": "galaxy-cli-test",
|
||||
"version": "1.0.0",
|
||||
},
|
||||
},
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
fn process(session: &mut McpSession<'_>, request: Value) -> Value {
|
||||
session
|
||||
.process_line(&request.to_string())
|
||||
.expect("request produces a response")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn initialize_negotiates_protocol_and_advertises_tools() {
|
||||
let invoker = RecordingInvoker::succeeding(json!({}));
|
||||
let mut session = McpSession::new(&invoker, TargetSelector::default());
|
||||
|
||||
let response = initialize(&mut session);
|
||||
|
||||
assert_eq!(response["jsonrpc"], json!("2.0"));
|
||||
assert_eq!(response["id"], json!(1));
|
||||
assert_eq!(response["result"]["protocolVersion"], json!("2025-06-18"));
|
||||
assert_eq!(
|
||||
response["result"]["capabilities"]["tools"]["listChanged"],
|
||||
json!(false)
|
||||
);
|
||||
assert_eq!(
|
||||
response["result"]["serverInfo"]["name"],
|
||||
json!("galaxy-control")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stdio_transport_emits_one_line_per_request_and_skips_notifications() {
|
||||
let invoker = RecordingInvoker::succeeding(json!({}));
|
||||
let requests = [
|
||||
json!({
|
||||
"jsonrpc": "2.0",
|
||||
"id": 1,
|
||||
"method": "initialize",
|
||||
"params": {
|
||||
"protocolVersion": "2025-06-18",
|
||||
"capabilities": {},
|
||||
"clientInfo": {
|
||||
"name": "galaxy-cli-test",
|
||||
"version": "1.0.0",
|
||||
},
|
||||
},
|
||||
}),
|
||||
json!({
|
||||
"jsonrpc": "2.0",
|
||||
"method": "notifications/initialized",
|
||||
}),
|
||||
json!({
|
||||
"jsonrpc": "2.0",
|
||||
"id": 2,
|
||||
"method": "tools/list",
|
||||
"params": {},
|
||||
}),
|
||||
]
|
||||
.into_iter()
|
||||
.map(|request| format!("{request}\n"))
|
||||
.collect::<String>();
|
||||
let mut output = Vec::new();
|
||||
|
||||
serve_stdio(
|
||||
&invoker,
|
||||
TargetSelector::default(),
|
||||
McpMode::Catalog,
|
||||
Cursor::new(requests),
|
||||
&mut output,
|
||||
)
|
||||
.expect("stdio session succeeds");
|
||||
|
||||
let responses = String::from_utf8(output).expect("responses are UTF-8");
|
||||
let responses = responses.lines().collect::<Vec<_>>();
|
||||
assert_eq!(responses.len(), 2);
|
||||
assert_eq!(
|
||||
serde_json::from_str::<Value>(responses[0]).expect("initialize response parses")["id"],
|
||||
json!(1)
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::from_str::<Value>(responses[1]).expect("tools response parses")["id"],
|
||||
json!(2)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stdio_transport_bounds_and_drains_an_oversized_line_before_the_next_request() {
|
||||
let oversized = [vec![b'x'; MAX_REQUEST_BYTES + 32], vec![b'\n']].concat();
|
||||
let initialize = format!(
|
||||
"{}\n",
|
||||
json!({
|
||||
"jsonrpc": "2.0",
|
||||
"id": 7,
|
||||
"method": "initialize",
|
||||
"params": {
|
||||
"protocolVersion": "2025-06-18",
|
||||
"capabilities": {},
|
||||
"clientInfo": {
|
||||
"name": "galaxy-cli-test",
|
||||
"version": "1.0.0",
|
||||
},
|
||||
},
|
||||
})
|
||||
);
|
||||
let input = [oversized, initialize.into_bytes()].concat();
|
||||
|
||||
let mut reader = Cursor::new(input.clone());
|
||||
let mut bounded_line = Vec::new();
|
||||
assert_eq!(
|
||||
read_bounded_request_line(&mut reader, &mut bounded_line)
|
||||
.expect("oversized line is drained"),
|
||||
RequestLineRead::Oversized
|
||||
);
|
||||
assert_eq!(bounded_line.len(), MAX_REQUEST_BYTES);
|
||||
assert_eq!(
|
||||
read_bounded_request_line(&mut reader, &mut bounded_line)
|
||||
.expect("next request remains readable"),
|
||||
RequestLineRead::Complete
|
||||
);
|
||||
assert!(bounded_line.len() < MAX_REQUEST_BYTES);
|
||||
|
||||
let invoker = RecordingInvoker::succeeding(json!({}));
|
||||
let mut output = Vec::new();
|
||||
serve_stdio(
|
||||
&invoker,
|
||||
TargetSelector::default(),
|
||||
McpMode::Catalog,
|
||||
Cursor::new(input),
|
||||
&mut output,
|
||||
)
|
||||
.expect("stdio session recovers after oversized input");
|
||||
|
||||
let responses = String::from_utf8(output).expect("responses are UTF-8");
|
||||
let responses = responses
|
||||
.lines()
|
||||
.map(|line| serde_json::from_str::<Value>(line).expect("response parses"))
|
||||
.collect::<Vec<_>>();
|
||||
assert_eq!(responses.len(), 2);
|
||||
assert_eq!(responses[0]["error"]["code"], json!(INVALID_REQUEST));
|
||||
assert_eq!(responses[1]["id"], json!(7));
|
||||
assert_eq!(
|
||||
responses[1]["result"]["protocolVersion"],
|
||||
json!("2025-06-18")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tools_list_exposes_capability_and_allowlisted_invocation_schemas() {
|
||||
let invoker = RecordingInvoker::succeeding(json!({}));
|
||||
let mut session = McpSession::new(&invoker, TargetSelector::default());
|
||||
initialize(&mut session);
|
||||
|
||||
let response = process(
|
||||
&mut session,
|
||||
json!({
|
||||
"jsonrpc": "2.0",
|
||||
"id": "tools",
|
||||
"method": "tools/list",
|
||||
"params": {},
|
||||
}),
|
||||
);
|
||||
let tools = response["result"]["tools"]
|
||||
.as_array()
|
||||
.expect("tools is an array");
|
||||
|
||||
assert_eq!(tools.len(), 2);
|
||||
assert_eq!(tools[0]["name"], json!(CAPABILITIES_TOOL));
|
||||
assert_eq!(
|
||||
tools[0]["inputSchema"]["additionalProperties"],
|
||||
json!(false)
|
||||
);
|
||||
assert_eq!(tools[0]["annotations"]["readOnlyHint"], json!(true));
|
||||
assert_eq!(tools[1]["name"], json!(INVOKE_TOOL));
|
||||
assert_eq!(tools[1]["annotations"]["destructiveHint"], json!(true));
|
||||
|
||||
let actions = tools[1]["inputSchema"]["properties"]["action"]["enum"]
|
||||
.as_array()
|
||||
.expect("action enum is an array");
|
||||
assert!(actions.contains(&json!("app.active")));
|
||||
assert!(actions.contains(&json!("input.insert")));
|
||||
assert!(actions.contains(&json!("terminal.status")));
|
||||
assert!(actions.contains(&json!("terminal.execute")));
|
||||
assert!(actions.contains(&json!("terminal.interrupt")));
|
||||
assert!(tools[1]["inputSchema"]["allOf"].is_array());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn agent_safe_tools_expose_only_pane_pinned_terminal_operations() {
|
||||
let invoker = RecordingInvoker::succeeding(json!({}));
|
||||
let target = TargetSelector {
|
||||
pane: Some(PaneTarget::Id {
|
||||
id: PaneSelector("pane_123".to_owned()),
|
||||
}),
|
||||
..TargetSelector::default()
|
||||
};
|
||||
let mut session = McpSession::agent_safe(&invoker, target);
|
||||
let initialized = initialize(&mut session);
|
||||
assert!(
|
||||
initialized["result"]["instructions"]
|
||||
.as_str()
|
||||
.is_some_and(|instructions| instructions.contains(TERMINAL_INTERRUPT_AT_TOOL))
|
||||
);
|
||||
|
||||
let response = process(
|
||||
&mut session,
|
||||
json!({
|
||||
"jsonrpc": "2.0",
|
||||
"id": "tools",
|
||||
"method": "tools/list",
|
||||
"params": {},
|
||||
}),
|
||||
);
|
||||
let tools = response["result"]["tools"]
|
||||
.as_array()
|
||||
.expect("tools is an array");
|
||||
let names = tools
|
||||
.iter()
|
||||
.map(|tool| tool["name"].as_str().expect("tool name"))
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
assert_eq!(
|
||||
names,
|
||||
[
|
||||
TERMINAL_STATUS_TOOL,
|
||||
TERMINAL_EXECUTE_TOOL,
|
||||
TERMINAL_INTERRUPT_TOOL,
|
||||
TERMINAL_INTERRUPT_AT_TOOL,
|
||||
]
|
||||
);
|
||||
assert_eq!(tools[0]["annotations"]["readOnlyHint"], json!(true));
|
||||
assert_eq!(tools[1]["annotations"]["destructiveHint"], json!(true));
|
||||
assert_eq!(tools[2]["inputSchema"]["required"], json!(["block_id"]));
|
||||
assert_eq!(
|
||||
tools[3]["inputSchema"]["required"],
|
||||
json!(["block_id", "target_running_for_ms"])
|
||||
);
|
||||
assert_eq!(
|
||||
tools[3]["inputSchema"]["properties"]["target_running_for_ms"]["maximum"],
|
||||
json!(MAX_INTERRUPT_AT_RUNNING_FOR_MS)
|
||||
);
|
||||
assert!(!names.contains(&CAPABILITIES_TOOL));
|
||||
assert!(!names.contains(&INVOKE_TOOL));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tools_call_discovers_capabilities_through_authenticated_action() {
|
||||
let capability_data = json!({
|
||||
"action": "capability.list",
|
||||
"capabilities": [{
|
||||
"kind": "app.active",
|
||||
"name": "app.active",
|
||||
"implementation_status": "implemented",
|
||||
"target_scope": "instance",
|
||||
"parameter_spec": "none",
|
||||
"result_spec": "active_target",
|
||||
}],
|
||||
});
|
||||
let invoker = RecordingInvoker::succeeding(capability_data.clone());
|
||||
let mut session = McpSession::new(&invoker, TargetSelector::default());
|
||||
initialize(&mut session);
|
||||
|
||||
let response = process(
|
||||
&mut session,
|
||||
json!({
|
||||
"jsonrpc": "2.0",
|
||||
"id": 2,
|
||||
"method": "tools/call",
|
||||
"params": {
|
||||
"name": CAPABILITIES_TOOL,
|
||||
"arguments": {},
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
assert_eq!(response["result"]["isError"], json!(false));
|
||||
assert_eq!(response["result"]["structuredContent"], capability_data);
|
||||
assert_eq!(
|
||||
invoker.calls.borrow().as_slice(),
|
||||
&[Invocation {
|
||||
action: ActionKind::CapabilityList,
|
||||
target: TargetSelector::default(),
|
||||
params: json!({}),
|
||||
}]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tools_call_invokes_catalog_action_with_default_target() {
|
||||
let default_target = TargetSelector {
|
||||
pane: Some(PaneTarget::Id {
|
||||
id: PaneSelector("pane_123".to_owned()),
|
||||
}),
|
||||
..TargetSelector::default()
|
||||
};
|
||||
let invoker = RecordingInvoker::succeeding(json!({
|
||||
"action": "input.insert",
|
||||
"ok": true,
|
||||
}));
|
||||
let mut session = McpSession::new(&invoker, default_target.clone());
|
||||
initialize(&mut session);
|
||||
|
||||
let response = process(
|
||||
&mut session,
|
||||
json!({
|
||||
"jsonrpc": "2.0",
|
||||
"id": 3,
|
||||
"method": "tools/call",
|
||||
"params": {
|
||||
"name": INVOKE_TOOL,
|
||||
"arguments": {
|
||||
"action": "input.insert",
|
||||
"params": {
|
||||
"text": "status",
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
assert_eq!(response["result"]["isError"], json!(false));
|
||||
assert_eq!(
|
||||
invoker.calls.borrow().as_slice(),
|
||||
&[Invocation {
|
||||
action: ActionKind::InputInsert,
|
||||
target: default_target,
|
||||
params: json!({ "text": "status" }),
|
||||
}]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tools_call_preserves_terminal_interrupt_block_guard() {
|
||||
let default_target = TargetSelector {
|
||||
pane: Some(PaneTarget::Id {
|
||||
id: PaneSelector("pane_123".to_owned()),
|
||||
}),
|
||||
..TargetSelector::default()
|
||||
};
|
||||
let invoker = RecordingInvoker::succeeding(json!({
|
||||
"action": "terminal.interrupt",
|
||||
"ok": true,
|
||||
}));
|
||||
let mut session = McpSession::new(&invoker, default_target.clone());
|
||||
initialize(&mut session);
|
||||
|
||||
let response = process(
|
||||
&mut session,
|
||||
json!({
|
||||
"jsonrpc": "2.0",
|
||||
"id": 4,
|
||||
"method": "tools/call",
|
||||
"params": {
|
||||
"name": INVOKE_TOOL,
|
||||
"arguments": {
|
||||
"action": "terminal.interrupt",
|
||||
"params": {
|
||||
"block_id": "session_1-42",
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
assert_eq!(response["result"]["isError"], json!(false));
|
||||
assert_eq!(
|
||||
invoker.calls.borrow().as_slice(),
|
||||
&[Invocation {
|
||||
action: ActionKind::TerminalInterrupt,
|
||||
target: default_target,
|
||||
params: json!({ "block_id": "session_1-42" }),
|
||||
}]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn agent_safe_terminal_calls_cannot_override_the_delegated_target() {
|
||||
let target = TargetSelector {
|
||||
pane: Some(PaneTarget::Id {
|
||||
id: PaneSelector("delegated_pane".to_owned()),
|
||||
}),
|
||||
..TargetSelector::default()
|
||||
};
|
||||
let invoker = RecordingInvoker::succeeding(json!({ "ok": true }));
|
||||
let mut session = McpSession::agent_safe(&invoker, target.clone());
|
||||
initialize(&mut session);
|
||||
|
||||
for (id, name, arguments, action, params) in [
|
||||
(
|
||||
1,
|
||||
TERMINAL_STATUS_TOOL,
|
||||
json!({}),
|
||||
ActionKind::TerminalStatus,
|
||||
json!({}),
|
||||
),
|
||||
(
|
||||
2,
|
||||
TERMINAL_EXECUTE_TOOL,
|
||||
json!({ "command": "cargo test" }),
|
||||
ActionKind::TerminalExecute,
|
||||
json!({ "command": "cargo test" }),
|
||||
),
|
||||
(
|
||||
3,
|
||||
TERMINAL_INTERRUPT_TOOL,
|
||||
json!({ "block_id": "block-42" }),
|
||||
ActionKind::TerminalInterrupt,
|
||||
json!({ "block_id": "block-42" }),
|
||||
),
|
||||
] {
|
||||
let response = process(
|
||||
&mut session,
|
||||
json!({
|
||||
"jsonrpc": "2.0",
|
||||
"id": id,
|
||||
"method": "tools/call",
|
||||
"params": {
|
||||
"name": name,
|
||||
"arguments": arguments,
|
||||
},
|
||||
}),
|
||||
);
|
||||
assert_eq!(response["result"]["isError"], json!(false));
|
||||
let call = invoker
|
||||
.calls
|
||||
.borrow()
|
||||
.last()
|
||||
.cloned()
|
||||
.expect("recorded call");
|
||||
assert_eq!(
|
||||
call,
|
||||
Invocation {
|
||||
action,
|
||||
target: target.clone(),
|
||||
params,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
let generic_call = process(
|
||||
&mut session,
|
||||
json!({
|
||||
"jsonrpc": "2.0",
|
||||
"id": 4,
|
||||
"method": "tools/call",
|
||||
"params": {
|
||||
"name": INVOKE_TOOL,
|
||||
"arguments": {
|
||||
"action": "pane.close",
|
||||
"target": {
|
||||
"pane": {
|
||||
"type": "id",
|
||||
"id": "other_pane",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
assert_eq!(generic_call["error"]["code"], json!(INVALID_PARAMS));
|
||||
assert_eq!(invoker.calls.borrow().len(), 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn agent_safe_terminal_execute_rejects_recognized_ssh_launches() {
|
||||
let target = TargetSelector {
|
||||
pane: Some(PaneTarget::Id {
|
||||
id: PaneSelector("delegated_pane".to_owned()),
|
||||
}),
|
||||
..TargetSelector::default()
|
||||
};
|
||||
let invoker = RecordingInvoker::succeeding(json!({ "ok": true }));
|
||||
let mut session = McpSession::agent_safe(&invoker, target);
|
||||
initialize(&mut session);
|
||||
|
||||
for (id, command) in [
|
||||
(1, "ssh user@example.com"),
|
||||
(2, "cd /tmp && sudo -u root ssh user@example.com"),
|
||||
(3, "bash -lc 'ssh user@example.com'"),
|
||||
] {
|
||||
let response = process(
|
||||
&mut session,
|
||||
json!({
|
||||
"jsonrpc": "2.0",
|
||||
"id": id,
|
||||
"method": "tools/call",
|
||||
"params": {
|
||||
"name": TERMINAL_EXECUTE_TOOL,
|
||||
"arguments": { "command": command },
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
assert_eq!(response["result"]["isError"], json!(true));
|
||||
assert!(
|
||||
response["result"]["content"][0]["text"]
|
||||
.as_str()
|
||||
.is_some_and(|text| text.contains("cannot start a recognized SSH"))
|
||||
);
|
||||
}
|
||||
assert!(invoker.calls.borrow().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn interrupt_at_waits_for_the_exact_block_then_uses_the_guarded_interrupt() {
|
||||
let target = TargetSelector {
|
||||
pane: Some(PaneTarget::Id {
|
||||
id: PaneSelector("delegated_pane".to_owned()),
|
||||
}),
|
||||
..TargetSelector::default()
|
||||
};
|
||||
let invoker = SequencedInvoker::new([
|
||||
Ok(terminal_status("block-42", Some(50))),
|
||||
Ok(terminal_status("block-42", Some(75))),
|
||||
Ok(json!({
|
||||
"action": ActionKind::TerminalInterrupt,
|
||||
"ok": true,
|
||||
"block_id": "block-42",
|
||||
})),
|
||||
]);
|
||||
|
||||
let result = terminal_interrupt_at(&invoker, target.clone(), "block-42", 75, |_| {});
|
||||
|
||||
assert_eq!(result["isError"], json!(false));
|
||||
assert_eq!(result["structuredContent"]["interrupted"], json!(true));
|
||||
assert_eq!(
|
||||
result["structuredContent"]["observed_running_for_ms"],
|
||||
json!(75)
|
||||
);
|
||||
assert_eq!(
|
||||
invoker.calls.borrow().as_slice(),
|
||||
&[
|
||||
Invocation {
|
||||
action: ActionKind::TerminalStatus,
|
||||
target: target.clone(),
|
||||
params: json!({}),
|
||||
},
|
||||
Invocation {
|
||||
action: ActionKind::TerminalStatus,
|
||||
target: target.clone(),
|
||||
params: json!({}),
|
||||
},
|
||||
Invocation {
|
||||
action: ActionKind::TerminalInterrupt,
|
||||
target,
|
||||
params: json!({ "block_id": "block-42" }),
|
||||
},
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn interrupt_at_returns_without_interrupting_when_block_stops_or_changes() {
|
||||
for (status, reason) in [
|
||||
(
|
||||
terminal_status("replacement-block", Some(70)),
|
||||
"block_changed",
|
||||
),
|
||||
(terminal_status("block-42", None), "block_stopped"),
|
||||
] {
|
||||
let target = TargetSelector {
|
||||
pane: Some(PaneTarget::Id {
|
||||
id: PaneSelector("delegated_pane".to_owned()),
|
||||
}),
|
||||
..TargetSelector::default()
|
||||
};
|
||||
let invoker = SequencedInvoker::new([Ok(status)]);
|
||||
|
||||
let result = terminal_interrupt_at(&invoker, target, "block-42", 75, |_| {});
|
||||
|
||||
assert_eq!(result["isError"], json!(false));
|
||||
assert_eq!(result["structuredContent"]["interrupted"], json!(false));
|
||||
assert_eq!(result["structuredContent"]["reason"], json!(reason));
|
||||
assert_eq!(invoker.calls.borrow().len(), 1);
|
||||
assert_eq!(invoker.calls.borrow()[0].action, ActionKind::TerminalStatus);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn interrupt_at_tool_dispatches_and_validates_the_duration_cap() {
|
||||
let target = TargetSelector {
|
||||
pane: Some(PaneTarget::Id {
|
||||
id: PaneSelector("delegated_pane".to_owned()),
|
||||
}),
|
||||
..TargetSelector::default()
|
||||
};
|
||||
let invoker = SequencedInvoker::new([
|
||||
Ok(terminal_status("block-42", Some(75))),
|
||||
Ok(json!({
|
||||
"action": ActionKind::TerminalInterrupt,
|
||||
"ok": true,
|
||||
"block_id": "block-42",
|
||||
})),
|
||||
]);
|
||||
let mut session = McpSession::agent_safe(&invoker, target);
|
||||
initialize(&mut session);
|
||||
|
||||
let response = process(
|
||||
&mut session,
|
||||
json!({
|
||||
"jsonrpc": "2.0",
|
||||
"id": 1,
|
||||
"method": "tools/call",
|
||||
"params": {
|
||||
"name": TERMINAL_INTERRUPT_AT_TOOL,
|
||||
"arguments": {
|
||||
"block_id": "block-42",
|
||||
"target_running_for_ms": 75,
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
assert_eq!(response["result"]["isError"], json!(false));
|
||||
assert_eq!(
|
||||
response["result"]["structuredContent"]["interrupted"],
|
||||
json!(true)
|
||||
);
|
||||
assert_eq!(invoker.calls.borrow().len(), 2);
|
||||
|
||||
let capped_invoker = RecordingInvoker::succeeding(json!({ "ok": true }));
|
||||
let mut capped_session = McpSession::agent_safe(&capped_invoker, TargetSelector::default());
|
||||
initialize(&mut capped_session);
|
||||
let response = process(
|
||||
&mut capped_session,
|
||||
json!({
|
||||
"jsonrpc": "2.0",
|
||||
"id": 2,
|
||||
"method": "tools/call",
|
||||
"params": {
|
||||
"name": TERMINAL_INTERRUPT_AT_TOOL,
|
||||
"arguments": {
|
||||
"block_id": "block-42",
|
||||
"target_running_for_ms": MAX_INTERRUPT_AT_RUNNING_FOR_MS + 1,
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
assert_eq!(response["result"]["isError"], json!(true));
|
||||
assert!(capped_invoker.calls.borrow().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malformed_json_rpc_requests_fail_without_invoking_local_control() {
|
||||
let invoker = RecordingInvoker::succeeding(json!({}));
|
||||
let mut session = McpSession::new(&invoker, TargetSelector::default());
|
||||
|
||||
let parse_error = session
|
||||
.process_line("{not json")
|
||||
.expect("parse error produces response");
|
||||
assert_eq!(parse_error["error"]["code"], json!(PARSE_ERROR));
|
||||
assert_eq!(parse_error["id"], Value::Null);
|
||||
|
||||
let invalid_request = session
|
||||
.process_line("[]")
|
||||
.expect("invalid request produces response");
|
||||
assert_eq!(invalid_request["error"]["code"], json!(INVALID_REQUEST));
|
||||
|
||||
let before_initialize = process(
|
||||
&mut session,
|
||||
json!({
|
||||
"jsonrpc": "2.0",
|
||||
"id": 4,
|
||||
"method": "tools/list",
|
||||
}),
|
||||
);
|
||||
assert_eq!(
|
||||
before_initialize["error"]["code"],
|
||||
json!(SERVER_NOT_INITIALIZED)
|
||||
);
|
||||
|
||||
initialize(&mut session);
|
||||
let bad_call = process(
|
||||
&mut session,
|
||||
json!({
|
||||
"jsonrpc": "2.0",
|
||||
"id": 5,
|
||||
"method": "tools/call",
|
||||
"params": {
|
||||
"name": INVOKE_TOOL,
|
||||
"arguments": {
|
||||
"action": "input.insert",
|
||||
"instance": "inst_other",
|
||||
"params": {
|
||||
"text": "must not run",
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
assert_eq!(bad_call["result"]["isError"], json!(true));
|
||||
assert_eq!(
|
||||
bad_call["result"]["structuredContent"]["error"]["code"],
|
||||
json!("invalid_arguments")
|
||||
);
|
||||
assert!(invoker.calls.borrow().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mcp_subcommand_accepts_pid_and_target_selectors() {
|
||||
let args = ControlArgs::try_parse_from([
|
||||
"galaxyctrl",
|
||||
"mcp",
|
||||
"--pid",
|
||||
"4321",
|
||||
"--window",
|
||||
"window_1",
|
||||
"--pane",
|
||||
"pane_2",
|
||||
])
|
||||
.expect("mcp arguments parse");
|
||||
let ControlCommand::Mcp(mcp) = args.command else {
|
||||
panic!("expected mcp command");
|
||||
};
|
||||
|
||||
assert_eq!(mcp.target.pid, Some(4321));
|
||||
assert_eq!(mcp.target.window.as_deref(), Some("window_1"));
|
||||
assert_eq!(mcp.target.pane.as_deref(), Some("pane_2"));
|
||||
assert!(!mcp.agent_safe);
|
||||
assert!(!mcp.allow_terminal_execute);
|
||||
assert!(!mcp.allow_terminal_interrupt);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn agent_safe_permissions_are_explicit_hidden_capabilities() {
|
||||
let args = ControlArgs::try_parse_from([
|
||||
"galaxyctrl",
|
||||
"mcp",
|
||||
"--pid",
|
||||
"4321",
|
||||
"--window",
|
||||
"window_1",
|
||||
"--tab",
|
||||
"tab_1",
|
||||
"--pane",
|
||||
"pane_2",
|
||||
"--agent-safe",
|
||||
"--allow-terminal-execute",
|
||||
"--allow-terminal-interrupt",
|
||||
])
|
||||
.expect("agent-safe MCP arguments parse");
|
||||
let ControlCommand::Mcp(mcp) = args.command else {
|
||||
panic!("expected mcp command");
|
||||
};
|
||||
|
||||
assert!(mcp.agent_safe);
|
||||
assert!(mcp.allow_terminal_execute);
|
||||
assert!(mcp.allow_terminal_interrupt);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn agent_safe_read_only_mode_does_not_advertise_or_run_mutations() {
|
||||
let target = TargetSelector {
|
||||
pane: Some(PaneTarget::Id {
|
||||
id: PaneSelector("delegated_pane".to_owned()),
|
||||
}),
|
||||
..TargetSelector::default()
|
||||
};
|
||||
let invoker = RecordingInvoker::succeeding(json!({ "ok": true }));
|
||||
let mut session = McpSession::agent_safe_read_only(&invoker, target);
|
||||
initialize(&mut session);
|
||||
|
||||
let listed = process(
|
||||
&mut session,
|
||||
json!({
|
||||
"jsonrpc": "2.0",
|
||||
"id": 1,
|
||||
"method": "tools/list",
|
||||
"params": {},
|
||||
}),
|
||||
);
|
||||
let tools = listed["result"]["tools"].as_array().expect("tools array");
|
||||
assert_eq!(tools.len(), 1);
|
||||
assert_eq!(tools[0]["name"], json!(TERMINAL_STATUS_TOOL));
|
||||
|
||||
for (id, name, arguments) in [
|
||||
(2, TERMINAL_EXECUTE_TOOL, json!({ "command": "cargo test" })),
|
||||
(
|
||||
3,
|
||||
TERMINAL_INTERRUPT_TOOL,
|
||||
json!({ "block_id": "block-42" }),
|
||||
),
|
||||
(
|
||||
4,
|
||||
TERMINAL_INTERRUPT_AT_TOOL,
|
||||
json!({
|
||||
"block_id": "block-42",
|
||||
"target_running_for_ms": 75,
|
||||
}),
|
||||
),
|
||||
] {
|
||||
let response = process(
|
||||
&mut session,
|
||||
json!({
|
||||
"jsonrpc": "2.0",
|
||||
"id": id,
|
||||
"method": "tools/call",
|
||||
"params": {
|
||||
"name": name,
|
||||
"arguments": arguments,
|
||||
},
|
||||
}),
|
||||
);
|
||||
assert_eq!(response["error"]["code"], json!(INVALID_PARAMS));
|
||||
}
|
||||
assert!(invoker.calls.borrow().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn agent_safe_mcp_requires_one_exact_pane_or_session() {
|
||||
let exact_pane = TargetArgs {
|
||||
window: Some("window_1".to_owned()),
|
||||
tab: Some("tab_1".to_owned()),
|
||||
pane: Some("Pane Terminal (42)".to_owned()),
|
||||
..TargetArgs::default()
|
||||
};
|
||||
assert!(validate_agent_safe_target(&exact_pane).is_ok());
|
||||
|
||||
let exact_session = TargetArgs {
|
||||
window: Some("window_1".to_owned()),
|
||||
tab: Some("tab_1".to_owned()),
|
||||
session: Some("session_1".to_owned()),
|
||||
..TargetArgs::default()
|
||||
};
|
||||
assert!(validate_agent_safe_target(&exact_session).is_ok());
|
||||
|
||||
for invalid in [
|
||||
TargetArgs::default(),
|
||||
TargetArgs {
|
||||
window: Some("window_1".to_owned()),
|
||||
tab: Some("tab_1".to_owned()),
|
||||
pane: Some("active".to_owned()),
|
||||
..TargetArgs::default()
|
||||
},
|
||||
TargetArgs {
|
||||
window: Some("window_1".to_owned()),
|
||||
tab: Some("tab_1".to_owned()),
|
||||
pane: Some("pane_1".to_owned()),
|
||||
session: Some("session_1".to_owned()),
|
||||
..TargetArgs::default()
|
||||
},
|
||||
TargetArgs {
|
||||
pane: Some("pane_1".to_owned()),
|
||||
window: Some("window_1".to_owned()),
|
||||
..TargetArgs::default()
|
||||
},
|
||||
TargetArgs {
|
||||
window: Some("active".to_owned()),
|
||||
tab: Some("tab_1".to_owned()),
|
||||
pane: Some("pane_1".to_owned()),
|
||||
..TargetArgs::default()
|
||||
},
|
||||
] {
|
||||
let error = validate_agent_safe_target(&invalid).expect_err("target is rejected");
|
||||
assert_eq!(error.code, ErrorCode::InvalidSelector);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn instance_selection_is_pinned_explicitly_and_rejects_ambiguity() {
|
||||
let mut one =
|
||||
InstanceRecord::for_current_process(None, "dev", "dev.galaxy.Galaxy", None, Vec::new());
|
||||
one.instance_id = InstanceId("inst_one".to_owned());
|
||||
one.pid = 100;
|
||||
let mut two = one.clone();
|
||||
two.instance_id = InstanceId("inst_two".to_owned());
|
||||
two.pid = 200;
|
||||
let records = vec![one, two];
|
||||
|
||||
let pinned = pin_instance(&records, &InstanceSelector::Pid(200)).expect("pid pins instance");
|
||||
assert_eq!(pinned.instance_id.0, "inst_two");
|
||||
|
||||
let error = pin_instance(&records, &InstanceSelector::Active)
|
||||
.expect_err("unqualified selection is ambiguous");
|
||||
assert_eq!(error.code, ErrorCode::AmbiguousInstance);
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
//! Command-line interface for controlling a running local Galaxy app.
|
||||
mod commands;
|
||||
mod completions;
|
||||
mod mcp;
|
||||
mod output;
|
||||
mod selectors;
|
||||
use std::ffi::OsString;
|
||||
@@ -12,9 +13,10 @@ use commands::{
|
||||
run_action_catalog_command, run_app_command, run_appearance_command, run_capability_command,
|
||||
run_file_command, run_input_command, run_instance_command, run_keybinding_command,
|
||||
run_pane_command, run_session_command, run_setting_command, run_surface_command,
|
||||
run_tab_command, run_theme_command, run_window_command,
|
||||
run_tab_command, run_terminal_command, run_theme_command, run_window_command,
|
||||
};
|
||||
use completions::generate_completions_to_stdout;
|
||||
use mcp::run_mcp_server;
|
||||
use output::write_control_error;
|
||||
|
||||
use crate::agent::OutputFormat;
|
||||
@@ -144,6 +146,9 @@ impl ControlArgs {
|
||||
/// Top-level `galaxyctrl` command groups.
|
||||
#[derive(Debug, Clone, Subcommand)]
|
||||
pub enum ControlCommand {
|
||||
/// Serve the allowlisted Galaxy Control catalog over MCP stdio.
|
||||
Mcp(McpArgs),
|
||||
|
||||
/// Inspect local Galaxy app instances.
|
||||
#[command(subcommand)]
|
||||
Instance(InstanceCommand),
|
||||
@@ -176,6 +181,10 @@ pub enum ControlCommand {
|
||||
#[command(subcommand)]
|
||||
Input(InputCommand),
|
||||
|
||||
/// Inspect, execute, and interrupt commands in existing terminal sessions.
|
||||
#[command(subcommand)]
|
||||
Terminal(TerminalCommand),
|
||||
|
||||
/// Inspect and change Galaxy themes.
|
||||
#[command(subcommand)]
|
||||
Theme(ThemeCommand),
|
||||
@@ -390,6 +399,19 @@ pub enum InputCommand {
|
||||
Replace(TextTargetArgs),
|
||||
}
|
||||
|
||||
/// Commands that control the active command in an existing terminal session.
|
||||
#[derive(Debug, Clone, Subcommand)]
|
||||
pub enum TerminalCommand {
|
||||
/// Inspect the current active command block.
|
||||
Status(TargetArgs),
|
||||
|
||||
/// Submit a command, but only when the target terminal is idle.
|
||||
Execute(TerminalExecuteArgs),
|
||||
|
||||
/// Interrupt the expected active command block.
|
||||
Interrupt(TerminalInterruptArgs),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Subcommand)]
|
||||
pub enum SurfaceCommand {
|
||||
/// List available and unavailable Galaxy surfaces.
|
||||
@@ -590,6 +612,29 @@ pub struct TargetArgs {
|
||||
pub session: Option<String>,
|
||||
}
|
||||
|
||||
/// Options for serving Galaxy Control over MCP stdio.
|
||||
#[derive(Debug, Clone, Args)]
|
||||
pub struct McpArgs {
|
||||
#[command(flatten)]
|
||||
pub target: TargetArgs,
|
||||
|
||||
/// Expose only pane-pinned terminal tools suitable for an external agent.
|
||||
#[arg(long = "agent-safe", hide = true)]
|
||||
pub agent_safe: bool,
|
||||
|
||||
/// Permit an agent-safe MCP client to execute a command in the delegated terminal.
|
||||
#[arg(long = "allow-terminal-execute", hide = true, requires = "agent_safe")]
|
||||
pub allow_terminal_execute: bool,
|
||||
|
||||
/// Permit an agent-safe MCP client to interrupt the delegated terminal.
|
||||
#[arg(
|
||||
long = "allow-terminal-interrupt",
|
||||
hide = true,
|
||||
requires = "agent_safe"
|
||||
)]
|
||||
pub allow_terminal_interrupt: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Args)]
|
||||
pub struct TabCreateArgs {
|
||||
#[arg(long = "type", value_enum)]
|
||||
@@ -679,6 +724,25 @@ pub struct TextTargetArgs {
|
||||
pub target: TargetArgs,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Args)]
|
||||
pub struct TerminalExecuteArgs {
|
||||
/// Command text to submit to the target terminal.
|
||||
pub command: String,
|
||||
|
||||
#[command(flatten)]
|
||||
pub target: TargetArgs,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Args)]
|
||||
pub struct TerminalInterruptArgs {
|
||||
/// Exact active block ID returned by `terminal status`.
|
||||
#[arg(long = "block-id")]
|
||||
pub block_id: String,
|
||||
|
||||
#[command(flatten)]
|
||||
pub target: TargetArgs,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Args)]
|
||||
pub struct PageQueryArgs {
|
||||
#[arg(long = "page")]
|
||||
@@ -900,6 +964,7 @@ fn run_exit_code(args: ControlArgs) -> u8 {
|
||||
fn run_inner(args: ControlArgs) -> Result<(), local_control::protocol::ControlError> {
|
||||
let output_format = args.output_format;
|
||||
match args.command {
|
||||
ControlCommand::Mcp(args) => run_mcp_server(args),
|
||||
ControlCommand::Instance(command) => run_instance_command(command, output_format),
|
||||
ControlCommand::App(command) => run_app_command(command, output_format),
|
||||
ControlCommand::Capability(command) => run_capability_command(command, output_format),
|
||||
@@ -909,6 +974,7 @@ fn run_inner(args: ControlArgs) -> Result<(), local_control::protocol::ControlEr
|
||||
ControlCommand::Pane(command) => run_pane_command(command, output_format),
|
||||
ControlCommand::Session(command) => run_session_command(command, output_format),
|
||||
ControlCommand::Input(command) => run_input_command(command, output_format),
|
||||
ControlCommand::Terminal(command) => run_terminal_command(command, output_format),
|
||||
ControlCommand::Theme(command) => run_theme_command(command, output_format),
|
||||
ControlCommand::Appearance(command) => run_appearance_command(command, output_format),
|
||||
ControlCommand::Setting(command) => run_setting_command(command, output_format),
|
||||
|
||||
@@ -36,6 +36,37 @@ fn parses_typed_create_and_setting_list_params() {
|
||||
assert_eq!(args.namespace.as_deref(), Some("editor"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_race_safe_terminal_commands() {
|
||||
let args = ControlArgs::try_parse_from([
|
||||
"galaxyctrl",
|
||||
"terminal",
|
||||
"execute",
|
||||
"sleep 10",
|
||||
"--session",
|
||||
"session_1",
|
||||
])
|
||||
.expect("terminal execute parses");
|
||||
let ControlCommand::Terminal(TerminalCommand::Execute(args)) = args.command else {
|
||||
panic!("expected terminal execute command");
|
||||
};
|
||||
assert_eq!(args.command, "sleep 10");
|
||||
assert_eq!(args.target.session.as_deref(), Some("session_1"));
|
||||
|
||||
let args = ControlArgs::try_parse_from([
|
||||
"galaxyctrl",
|
||||
"terminal",
|
||||
"interrupt",
|
||||
"--block-id",
|
||||
"session_1-42",
|
||||
])
|
||||
.expect("terminal interrupt parses");
|
||||
let ControlCommand::Terminal(TerminalCommand::Interrupt(args)) = args.command else {
|
||||
panic!("expected terminal interrupt command");
|
||||
};
|
||||
assert_eq!(args.block_id, "session_1-42");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_conflicting_instance_selectors() {
|
||||
let err = ControlArgs::try_parse_from([
|
||||
@@ -240,8 +271,9 @@ fn generated_bash_completions_include_readonly_commands() {
|
||||
assert!(!completions.contains("stubs-only"));
|
||||
assert!(completions.contains("window"));
|
||||
assert!(completions.contains("input"));
|
||||
assert!(completions.contains("terminal"));
|
||||
assert!(completions.contains("block-id"));
|
||||
assert!(completions.contains("completions"));
|
||||
assert!(!completions.contains("block"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -479,6 +511,24 @@ fn retained_action_examples() -> Vec<(ActionKind, Vec<&'static str>)> {
|
||||
ActionKind::InputReplace,
|
||||
vec!["galaxyctrl", "input", "replace", "hello"],
|
||||
),
|
||||
(
|
||||
ActionKind::TerminalStatus,
|
||||
vec!["galaxyctrl", "terminal", "status"],
|
||||
),
|
||||
(
|
||||
ActionKind::TerminalExecute,
|
||||
vec!["galaxyctrl", "terminal", "execute", "cargo test"],
|
||||
),
|
||||
(
|
||||
ActionKind::TerminalInterrupt,
|
||||
vec![
|
||||
"galaxyctrl",
|
||||
"terminal",
|
||||
"interrupt",
|
||||
"--block-id",
|
||||
"session_1-42",
|
||||
],
|
||||
),
|
||||
(ActionKind::ThemeList, vec!["galaxyctrl", "theme", "list"]),
|
||||
(ActionKind::ThemeGet, vec!["galaxyctrl", "theme", "get"]),
|
||||
(
|
||||
@@ -615,6 +665,7 @@ fn retained_action_examples() -> Vec<(ActionKind, Vec<&'static str>)> {
|
||||
|
||||
fn parsed_action_kind(command: &ControlCommand) -> Option<ActionKind> {
|
||||
match command {
|
||||
ControlCommand::Mcp(_) => None,
|
||||
ControlCommand::Instance(command) => match command {
|
||||
InstanceCommand::List => Some(ActionKind::InstanceList),
|
||||
InstanceCommand::Inspect(_) => Some(ActionKind::InstanceInspect),
|
||||
@@ -679,6 +730,11 @@ fn parsed_action_kind(command: &ControlCommand) -> Option<ActionKind> {
|
||||
InputCommand::Insert(_) => Some(ActionKind::InputInsert),
|
||||
InputCommand::Replace(_) => Some(ActionKind::InputReplace),
|
||||
},
|
||||
ControlCommand::Terminal(command) => match command {
|
||||
TerminalCommand::Status(_) => Some(ActionKind::TerminalStatus),
|
||||
TerminalCommand::Execute(_) => Some(ActionKind::TerminalExecute),
|
||||
TerminalCommand::Interrupt(_) => Some(ActionKind::TerminalInterrupt),
|
||||
},
|
||||
ControlCommand::Theme(command) => match command {
|
||||
ThemeCommand::List(_) => Some(ActionKind::ThemeList),
|
||||
ThemeCommand::Get(_) => Some(ActionKind::ThemeGet),
|
||||
|
||||
@@ -19,6 +19,13 @@ fn local_child_harnesses_are_local_only_by_default() {
|
||||
assert!(!DOGFOOD_FLAGS.contains(&FeatureFlag::LocalClaudeCodexChildHarnesses));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn acp_backend_is_enabled_for_dogfood() {
|
||||
assert!(DOGFOOD_FLAGS.contains(&FeatureFlag::AgentClientProtocol));
|
||||
assert!(!PREVIEW_FLAGS.contains(&FeatureFlag::AgentClientProtocol));
|
||||
assert!(!RELEASE_FLAGS.contains(&FeatureFlag::AgentClientProtocol));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dogfood_flags_do_not_enable_upstream_hosted_services() {
|
||||
for flag in [
|
||||
|
||||
@@ -79,6 +79,9 @@ pub enum FeatureFlag {
|
||||
/// Warp Agent Mode.
|
||||
AgentMode,
|
||||
|
||||
/// Enables local Agent Client Protocol (ACP) conversation backends.
|
||||
AgentClientProtocol,
|
||||
|
||||
/// Whether the user is part of the Warp Alpha Program (AI Trusted Testers).
|
||||
/// This is enabled automatically for local and dev builds.
|
||||
/// Collect conversation and input autodetection data for agent mode.
|
||||
@@ -937,6 +940,7 @@ pub const LOCAL_FLAGS: &[FeatureFlag] = &[FeatureFlag::LocalClaudeCodexChildHarn
|
||||
/// Features enabled for the development team. The expectation is that, over
|
||||
/// time, these will move on to PREVIEW_FLAGS before being launched.
|
||||
pub const DOGFOOD_FLAGS: &[FeatureFlag] = &[
|
||||
FeatureFlag::AgentClientProtocol,
|
||||
FeatureFlag::ToggleBootstrapBlock,
|
||||
FeatureFlag::RemoveAutosuggestionDuringTabCompletions,
|
||||
FeatureFlag::ResizeFix,
|
||||
|
||||
@@ -12,6 +12,7 @@ chrono.workspace = true
|
||||
rand.workspace = true
|
||||
serde = { workspace = true, features = ["derive"] }
|
||||
serde_json.workspace = true
|
||||
shell-words = "1.1.0"
|
||||
thiserror.workspace = true
|
||||
uuid.workspace = true
|
||||
[target.'cfg(not(target_family = "wasm"))'.dependencies]
|
||||
|
||||
@@ -51,6 +51,8 @@ pub enum ActionParameterSpec {
|
||||
TabActivate,
|
||||
TabClose,
|
||||
TabCreate,
|
||||
TerminalExecute,
|
||||
TerminalInterrupt,
|
||||
Text,
|
||||
ThemeName,
|
||||
}
|
||||
@@ -73,6 +75,7 @@ pub enum ActionResultSpec {
|
||||
SurfaceList,
|
||||
TargetList,
|
||||
TargetMetadata,
|
||||
TerminalStatus,
|
||||
ThemeList,
|
||||
ThemeState,
|
||||
}
|
||||
@@ -231,6 +234,12 @@ define_action_catalog! {
|
||||
InputReplace => { name: "input.replace", status: Implemented, target: Input, params: Text, result: Acknowledgement },
|
||||
}
|
||||
|
||||
terminal {
|
||||
TerminalStatus => { name: "terminal.status", status: Implemented, target: Session, params: None, result: TerminalStatus },
|
||||
TerminalExecute => { name: "terminal.execute", status: Implemented, target: Session, params: TerminalExecute, result: Acknowledgement },
|
||||
TerminalInterrupt => { name: "terminal.interrupt", status: Implemented, target: Session, params: TerminalInterrupt, result: Acknowledgement },
|
||||
}
|
||||
|
||||
theme {
|
||||
ThemeList => { name: "theme.list", status: Implemented, target: Appearance, params: None, result: ThemeList },
|
||||
ThemeGet => { name: "theme.get", status: Implemented, target: Appearance, params: None, result: ThemeState },
|
||||
|
||||
@@ -8,6 +8,7 @@ pub mod catalog;
|
||||
pub mod client;
|
||||
pub mod discovery;
|
||||
pub mod protocol;
|
||||
pub mod remote_command;
|
||||
pub mod selection;
|
||||
pub mod selectors;
|
||||
|
||||
|
||||
@@ -173,6 +173,23 @@ pub struct TabCreateParams {
|
||||
pub shell: Option<String>,
|
||||
}
|
||||
|
||||
/// Parameters for submitting a command to an idle terminal session.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct TerminalExecuteParams {
|
||||
pub command: String,
|
||||
}
|
||||
|
||||
/// Parameters for interrupting the current command in a terminal session.
|
||||
///
|
||||
/// `block_id` is required as a compare-and-swap guard so a delayed request
|
||||
/// cannot interrupt a newer command.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct TerminalInterruptParams {
|
||||
pub block_id: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct TextParams {
|
||||
@@ -301,6 +318,24 @@ pub struct SurfaceListResult {
|
||||
pub surfaces: Vec<SurfaceSummary>,
|
||||
}
|
||||
|
||||
/// Snapshot of the active command block in a terminal session.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct TerminalStatusResult {
|
||||
pub action: ActionKind,
|
||||
pub session_id: String,
|
||||
pub active_block_id: String,
|
||||
pub is_executing: bool,
|
||||
pub is_command_pending: bool,
|
||||
pub is_long_running: bool,
|
||||
pub is_agent_in_control: bool,
|
||||
pub is_idle: bool,
|
||||
/// Elapsed wall-clock time for the active running command.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub running_for_ms: Option<u64>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub command_summary: Option<String>,
|
||||
}
|
||||
|
||||
/// Typed success payloads for catalog actions that need stable structured data.
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(tag = "type", rename_all = "snake_case")]
|
||||
|
||||
@@ -54,6 +54,12 @@ fn strict_params_deny_unknown_fields() {
|
||||
params: serde_json::json!({ "unexpected": true }),
|
||||
};
|
||||
assert!(action.params_as::<EmptyParams>().is_err());
|
||||
|
||||
let action = Action {
|
||||
kind: ActionKind::TerminalInterrupt,
|
||||
params: serde_json::json!({ "block_id": "block_1", "force": true }),
|
||||
};
|
||||
assert!(action.params_as::<TerminalInterruptParams>().is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -169,8 +175,40 @@ fn removed_cloud_agent_tab_type_is_not_deserialized() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn catalog_has_exactly_77_retained_actions() {
|
||||
assert_eq!(ActionKind::ALL.len(), 77);
|
||||
fn catalog_has_exactly_80_retained_actions() {
|
||||
assert_eq!(ActionKind::ALL.len(), 80);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn terminal_actions_have_race_safe_typed_contracts() {
|
||||
let execute = Action::with_params(
|
||||
ActionKind::TerminalExecute,
|
||||
TerminalExecuteParams {
|
||||
command: "cargo test".to_owned(),
|
||||
},
|
||||
)
|
||||
.expect("terminal.execute params serialize");
|
||||
assert_eq!(
|
||||
execute.params,
|
||||
serde_json::json!({ "command": "cargo test" })
|
||||
);
|
||||
|
||||
let interrupt = Action::with_params(
|
||||
ActionKind::TerminalInterrupt,
|
||||
TerminalInterruptParams {
|
||||
block_id: "session_1-42".to_owned(),
|
||||
},
|
||||
)
|
||||
.expect("terminal.interrupt params serialize");
|
||||
assert_eq!(
|
||||
interrupt.params,
|
||||
serde_json::json!({ "block_id": "session_1-42" })
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
ActionKind::TerminalStatus.metadata().result_spec,
|
||||
ActionResultSpec::TerminalStatus
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -0,0 +1,296 @@
|
||||
//! Conservative recognition of commands that can open an SSH-backed terminal.
|
||||
//!
|
||||
//! This is a narrow transport-boundary guard, not a network sandbox. Commands
|
||||
//! that are otherwise authorized can still access the network through tools
|
||||
//! other than the recognized SSH launch forms below.
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
/// Returns whether a shell command appears capable of opening an SSH-backed
|
||||
/// terminal, including common wrappers and compound command lists.
|
||||
pub fn is_potential_remote_ssh_command(command: &str) -> bool {
|
||||
command_segments(command)
|
||||
.iter()
|
||||
.any(|segment| segment_starts_remote_ssh(segment, 0))
|
||||
}
|
||||
|
||||
fn command_segments(command: &str) -> Vec<String> {
|
||||
#[derive(Clone, Copy, Eq, PartialEq)]
|
||||
enum Quote {
|
||||
None,
|
||||
Single,
|
||||
Double,
|
||||
}
|
||||
|
||||
let mut quote = Quote::None;
|
||||
let mut escaped = false;
|
||||
let mut current = String::new();
|
||||
let mut segments = Vec::new();
|
||||
for character in command.chars() {
|
||||
if escaped {
|
||||
current.push(character);
|
||||
escaped = false;
|
||||
continue;
|
||||
}
|
||||
match quote {
|
||||
Quote::None => match character {
|
||||
'\\' => {
|
||||
current.push(character);
|
||||
escaped = true;
|
||||
}
|
||||
'\'' => {
|
||||
current.push(character);
|
||||
quote = Quote::Single;
|
||||
}
|
||||
'"' => {
|
||||
current.push(character);
|
||||
quote = Quote::Double;
|
||||
}
|
||||
';' | '\n' | '|' | '&' | '(' | ')' | '`' => {
|
||||
push_segment(&mut segments, &mut current);
|
||||
}
|
||||
_ => current.push(character),
|
||||
},
|
||||
Quote::Single => {
|
||||
current.push(character);
|
||||
if character == '\'' {
|
||||
quote = Quote::None;
|
||||
}
|
||||
}
|
||||
Quote::Double => {
|
||||
current.push(character);
|
||||
match character {
|
||||
'\\' => escaped = true,
|
||||
'"' => quote = Quote::None,
|
||||
// Backticks remain command substitutions inside double
|
||||
// quotes, so inspect the enclosed command independently.
|
||||
'`' => push_segment(&mut segments, &mut current),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
push_segment(&mut segments, &mut current);
|
||||
segments
|
||||
}
|
||||
|
||||
fn push_segment(segments: &mut Vec<String>, current: &mut String) {
|
||||
if !current.trim().is_empty() {
|
||||
segments.push(std::mem::take(current));
|
||||
} else {
|
||||
current.clear();
|
||||
}
|
||||
}
|
||||
|
||||
fn segment_starts_remote_ssh(segment: &str, depth: usize) -> bool {
|
||||
if depth > 4 {
|
||||
return false;
|
||||
}
|
||||
let tokens = shell_words::split(segment).unwrap_or_else(|_| {
|
||||
segment
|
||||
.split_whitespace()
|
||||
.map(|token| token.trim_matches(['\'', '"', '`', '(', ')']).to_owned())
|
||||
.filter(|token| !token.is_empty())
|
||||
.collect()
|
||||
});
|
||||
command_tokens_start_remote_ssh(&tokens, depth)
|
||||
}
|
||||
|
||||
fn command_tokens_start_remote_ssh(tokens: &[String], depth: usize) -> bool {
|
||||
let mut index = skip_assignments(tokens, 0);
|
||||
loop {
|
||||
let Some(executable) = tokens.get(index).map(|token| executable_name(token)) else {
|
||||
return false;
|
||||
};
|
||||
match executable {
|
||||
"command" => {
|
||||
index += 1;
|
||||
if tokens
|
||||
.get(index)
|
||||
.is_some_and(|option| matches!(option.as_str(), "-v" | "-V"))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
index = skip_flag_only_options(tokens, index);
|
||||
}
|
||||
"env" => {
|
||||
index = skip_env_prefix(tokens, index + 1);
|
||||
}
|
||||
"sudo" => {
|
||||
index = skip_sudo_prefix(tokens, index + 1);
|
||||
}
|
||||
"exec" | "nohup" | "setsid" | "time" => {
|
||||
index = skip_flag_only_options(tokens, index + 1);
|
||||
}
|
||||
"timeout" => {
|
||||
index = skip_timeout_prefix(tokens, index + 1);
|
||||
}
|
||||
"{" => {
|
||||
index += 1;
|
||||
}
|
||||
_ => break,
|
||||
}
|
||||
index = skip_assignments(tokens, index);
|
||||
}
|
||||
|
||||
let executable = executable_name(&tokens[index]);
|
||||
if executable == "ssh" {
|
||||
return true;
|
||||
}
|
||||
if executable == "gcloud" {
|
||||
return tokens[index + 1..]
|
||||
.windows(2)
|
||||
.any(|pair| pair[0] == "compute" && pair[1] == "ssh");
|
||||
}
|
||||
if executable == "eb" {
|
||||
return tokens[index + 1..]
|
||||
.first()
|
||||
.is_some_and(|command| command == "ssh");
|
||||
}
|
||||
if executable == "doctl" {
|
||||
return tokens[index + 1..]
|
||||
.windows(2)
|
||||
.any(|pair| pair[0] == "compute" && pair[1] == "ssh");
|
||||
}
|
||||
if matches!(executable, "sh" | "bash" | "dash" | "zsh" | "ksh" | "fish") {
|
||||
return shell_command_payload(tokens, index + 1)
|
||||
.is_some_and(|payload| segment_starts_remote_ssh(payload, depth + 1));
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
fn executable_name(token: &str) -> &str {
|
||||
Path::new(token)
|
||||
.file_name()
|
||||
.and_then(|name| name.to_str())
|
||||
.unwrap_or(token)
|
||||
}
|
||||
|
||||
fn skip_assignments(tokens: &[String], mut index: usize) -> usize {
|
||||
while tokens.get(index).is_some_and(|token| is_assignment(token)) {
|
||||
index += 1;
|
||||
}
|
||||
index
|
||||
}
|
||||
|
||||
fn is_assignment(token: &str) -> bool {
|
||||
let Some((name, _)) = token.split_once('=') else {
|
||||
return false;
|
||||
};
|
||||
let mut characters = name.chars();
|
||||
characters
|
||||
.next()
|
||||
.is_some_and(|character| character == '_' || character.is_ascii_alphabetic())
|
||||
&& characters.all(|character| character == '_' || character.is_ascii_alphanumeric())
|
||||
}
|
||||
|
||||
fn skip_flag_only_options(tokens: &[String], mut index: usize) -> usize {
|
||||
while let Some(option) = tokens.get(index) {
|
||||
if option == "--" {
|
||||
return index + 1;
|
||||
}
|
||||
if !option.starts_with('-') || option == "-" {
|
||||
break;
|
||||
}
|
||||
index += 1;
|
||||
}
|
||||
index
|
||||
}
|
||||
|
||||
fn skip_env_prefix(tokens: &[String], mut index: usize) -> usize {
|
||||
while let Some(option) = tokens.get(index) {
|
||||
if option == "--" {
|
||||
index += 1;
|
||||
break;
|
||||
}
|
||||
if matches!(
|
||||
option.as_str(),
|
||||
"-u" | "--unset" | "-C" | "--chdir" | "-S" | "--split-string"
|
||||
) {
|
||||
index = (index + 2).min(tokens.len());
|
||||
continue;
|
||||
}
|
||||
if option.starts_with('-') && option != "-" {
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
break;
|
||||
}
|
||||
skip_assignments(tokens, index)
|
||||
}
|
||||
|
||||
fn skip_sudo_prefix(tokens: &[String], mut index: usize) -> usize {
|
||||
while let Some(option) = tokens.get(index) {
|
||||
if option == "--" {
|
||||
return index + 1;
|
||||
}
|
||||
if matches!(
|
||||
option.as_str(),
|
||||
"-u" | "--user"
|
||||
| "-g"
|
||||
| "--group"
|
||||
| "-h"
|
||||
| "--host"
|
||||
| "-p"
|
||||
| "--prompt"
|
||||
| "-C"
|
||||
| "--chdir"
|
||||
| "-R"
|
||||
| "--chroot"
|
||||
| "-r"
|
||||
| "--role"
|
||||
| "-t"
|
||||
| "--type"
|
||||
) {
|
||||
index = (index + 2).min(tokens.len());
|
||||
continue;
|
||||
}
|
||||
if option.starts_with('-') && option != "-" {
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
break;
|
||||
}
|
||||
index
|
||||
}
|
||||
|
||||
fn skip_timeout_prefix(tokens: &[String], mut index: usize) -> usize {
|
||||
while let Some(option) = tokens.get(index) {
|
||||
if option == "--" {
|
||||
index += 1;
|
||||
break;
|
||||
}
|
||||
if matches!(option.as_str(), "-k" | "--kill-after" | "-s" | "--signal") {
|
||||
index = (index + 2).min(tokens.len());
|
||||
continue;
|
||||
}
|
||||
if option.starts_with('-') && option != "-" {
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
break;
|
||||
}
|
||||
// The first non-option is timeout's duration, not its child executable.
|
||||
(index + usize::from(index < tokens.len())).min(tokens.len())
|
||||
}
|
||||
|
||||
fn shell_command_payload(tokens: &[String], mut index: usize) -> Option<&str> {
|
||||
while let Some(option) = tokens.get(index) {
|
||||
if option == "--" {
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
if option.starts_with('-') && option.contains('c') {
|
||||
return tokens.get(index + 1).map(String::as_str);
|
||||
}
|
||||
if !option.starts_with('-') {
|
||||
return None;
|
||||
}
|
||||
index += 1;
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "remote_command_tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,38 @@
|
||||
use super::is_potential_remote_ssh_command;
|
||||
|
||||
#[test]
|
||||
fn recognizes_direct_wrapped_and_compound_ssh_launches() {
|
||||
for command in [
|
||||
"ssh user@example.com",
|
||||
"/usr/bin/ssh -T git@example.com",
|
||||
"command ssh user@example.com",
|
||||
"env GALAXY_TEST=1 ssh user@example.com",
|
||||
"sudo ssh user@example.com",
|
||||
"sudo -u root /usr/bin/ssh user@example.com",
|
||||
"cd /tmp && ssh user@example.com",
|
||||
"printf done; sudo -n ssh user@example.com",
|
||||
"bash -lc 'ssh user@example.com'",
|
||||
"timeout 10 ssh user@example.com",
|
||||
"gcloud compute ssh --zone us-central1-a instance",
|
||||
"eb ssh environment",
|
||||
"doctl compute ssh droplet-action",
|
||||
] {
|
||||
assert!(is_potential_remote_ssh_command(command), "{command}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ignores_ssh_text_and_non_session_utilities() {
|
||||
for command in [
|
||||
"",
|
||||
"cargo test",
|
||||
"echo ssh user@example.com",
|
||||
"printf '%s' 'ssh user@example.com'",
|
||||
"GALAXY_TEST=ssh cargo test",
|
||||
"ssh-add ~/.ssh/id_ed25519",
|
||||
"command -v ssh",
|
||||
"bash -lc 'echo ssh user@example.com'",
|
||||
] {
|
||||
assert!(!is_potential_remote_ssh_command(command), "{command}");
|
||||
}
|
||||
}
|
||||
@@ -1020,9 +1020,57 @@ fn is_false(value: &bool) -> bool {
|
||||
!*value
|
||||
}
|
||||
|
||||
/// Backend responsible for executing an agent conversation.
|
||||
///
|
||||
/// Existing persisted conversations predate this field and therefore default
|
||||
/// to Galaxy's native model-provider path.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum AgentBackend {
|
||||
#[default]
|
||||
Provider,
|
||||
Acp(AcpConversationData),
|
||||
}
|
||||
|
||||
impl AgentBackend {
|
||||
pub fn is_provider(&self) -> bool {
|
||||
matches!(self, Self::Provider)
|
||||
}
|
||||
|
||||
/// Copies the backend identity for a locally forked conversation without
|
||||
/// sharing an agent-owned session between two Galaxy conversations.
|
||||
pub fn for_fork(&self) -> Self {
|
||||
match self {
|
||||
Self::Provider => Self::Provider,
|
||||
Self::Acp(acp) => Self::Acp(AcpConversationData {
|
||||
agent_id: acp.agent_id.clone(),
|
||||
launch_fingerprint: acp.launch_fingerprint.clone(),
|
||||
session_id: None,
|
||||
}),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Persisted identity for a local Agent Client Protocol conversation.
|
||||
///
|
||||
/// Process launch details remain device-local settings. The non-secret launch
|
||||
/// fingerprint prevents an agent-owned session ID from being handed to a
|
||||
/// different executable after those settings change.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
|
||||
pub struct AcpConversationData {
|
||||
#[serde(default)]
|
||||
pub agent_id: String,
|
||||
#[serde(default, skip_serializing_if = "String::is_empty")]
|
||||
pub launch_fingerprint: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub session_id: Option<String>,
|
||||
}
|
||||
|
||||
// Serializes to `conversation_data` column in `agent_conversations`.
|
||||
#[derive(Debug, Serialize, Deserialize, Clone, Default)]
|
||||
pub struct AgentConversationData {
|
||||
#[serde(default, skip_serializing_if = "AgentBackend::is_provider")]
|
||||
pub agent_backend: AgentBackend,
|
||||
pub server_conversation_token: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub conversation_usage_metadata: Option<ConversationUsageMetadata>,
|
||||
|
||||
@@ -2,7 +2,28 @@ use std::collections::HashMap;
|
||||
|
||||
use warp_multi_agent_api as api;
|
||||
|
||||
use super::{AgentConversation, AgentConversationData, ModelTokenUsage};
|
||||
use super::{
|
||||
AcpConversationData, AgentBackend, AgentConversation, AgentConversationData, ModelTokenUsage,
|
||||
};
|
||||
|
||||
#[test]
|
||||
fn acp_backend_fork_keeps_agent_identity_but_clears_session() {
|
||||
let source = AgentBackend::Acp(AcpConversationData {
|
||||
agent_id: "codex".to_owned(),
|
||||
launch_fingerprint: "launch-123".to_owned(),
|
||||
session_id: Some("shared-session".to_owned()),
|
||||
});
|
||||
|
||||
assert_eq!(
|
||||
source.for_fork(),
|
||||
AgentBackend::Acp(AcpConversationData {
|
||||
agent_id: "codex".to_owned(),
|
||||
launch_fingerprint: "launch-123".to_owned(),
|
||||
session_id: None,
|
||||
})
|
||||
);
|
||||
assert_eq!(AgentBackend::Provider.for_fork(), AgentBackend::Provider);
|
||||
}
|
||||
|
||||
fn parentless_task(id: &str, message_count: usize) -> api::Task {
|
||||
api::Task {
|
||||
@@ -105,6 +126,7 @@ fn is_restorable_accepts_empty_and_single_task_conversations() {
|
||||
#[test]
|
||||
fn agent_conversation_data_roundtrips_last_event_sequence() {
|
||||
let data = AgentConversationData {
|
||||
agent_backend: Default::default(),
|
||||
server_conversation_token: None,
|
||||
conversation_usage_metadata: None,
|
||||
reverted_action_ids: None,
|
||||
@@ -141,9 +163,45 @@ fn agent_conversation_data_accepts_legacy_orchestration_avatar_id() {
|
||||
assert_eq!(data.orchestration_harness_type.as_deref(), Some("orbit"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn agent_conversation_data_defaults_legacy_rows_to_provider_backend() {
|
||||
let data: AgentConversationData = serde_json::from_str(r#"{"server_conversation_token":null}"#)
|
||||
.expect("legacy rows must deserialize");
|
||||
|
||||
assert_eq!(data.agent_backend, AgentBackend::Provider);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn agent_conversation_data_roundtrips_acp_backend() {
|
||||
let data = AgentConversationData {
|
||||
agent_backend: AgentBackend::Acp(AcpConversationData {
|
||||
agent_id: "codex-acp".to_string(),
|
||||
launch_fingerprint: "launch-123".to_string(),
|
||||
session_id: Some("session-123".to_string()),
|
||||
}),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let json = serde_json::to_string(&data).expect("serialize");
|
||||
let roundtripped: AgentConversationData = serde_json::from_str(&json).expect("deserialize");
|
||||
|
||||
assert_eq!(roundtripped.agent_backend, data.agent_backend);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn agent_conversation_data_omits_default_provider_backend() {
|
||||
let json = serde_json::to_string(&AgentConversationData::default()).expect("serialize");
|
||||
|
||||
assert!(
|
||||
!json.contains("agent_backend"),
|
||||
"provider backend should retain the legacy serialized shape: {json}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn agent_conversation_data_roundtrips_remote_child_marker() {
|
||||
let data = AgentConversationData {
|
||||
agent_backend: Default::default(),
|
||||
server_conversation_token: None,
|
||||
conversation_usage_metadata: None,
|
||||
reverted_action_ids: None,
|
||||
@@ -170,6 +228,7 @@ fn agent_conversation_data_roundtrips_remote_child_marker() {
|
||||
#[test]
|
||||
fn agent_conversation_data_roundtrips_optimistic_root_marker() {
|
||||
let data = AgentConversationData {
|
||||
agent_backend: Default::default(),
|
||||
server_conversation_token: None,
|
||||
conversation_usage_metadata: None,
|
||||
reverted_action_ids: None,
|
||||
@@ -208,6 +267,7 @@ fn agent_conversation_data_deserializes_legacy_payload_without_last_event_sequen
|
||||
#[test]
|
||||
fn agent_conversation_data_skips_serializing_none_last_event_sequence() {
|
||||
let data = AgentConversationData {
|
||||
agent_backend: Default::default(),
|
||||
server_conversation_token: None,
|
||||
conversation_usage_metadata: None,
|
||||
reverted_action_ids: None,
|
||||
@@ -236,6 +296,7 @@ fn agent_conversation_data_skips_serializing_none_last_event_sequence() {
|
||||
#[test]
|
||||
fn agent_conversation_data_roundtrips_pinned() {
|
||||
let data = AgentConversationData {
|
||||
agent_backend: Default::default(),
|
||||
server_conversation_token: None,
|
||||
conversation_usage_metadata: None,
|
||||
reverted_action_ids: None,
|
||||
@@ -262,6 +323,7 @@ fn agent_conversation_data_roundtrips_pinned() {
|
||||
#[test]
|
||||
fn agent_conversation_data_skips_serializing_unpinned() {
|
||||
let data = AgentConversationData {
|
||||
agent_backend: Default::default(),
|
||||
server_conversation_token: None,
|
||||
conversation_usage_metadata: None,
|
||||
reverted_action_ids: None,
|
||||
|
||||
Reference in New Issue
Block a user