Add ACP agent backend and terminal controls

This commit is contained in:
2026-07-30 07:25:11 -05:00
parent dbfa8bcd48
commit ad24374f6d
84 changed files with 12151 additions and 157 deletions
+1
View File
@@ -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]
+9
View File
@@ -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 },
+1
View File
@@ -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;
+35
View File
@@ -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")]
+40 -2
View File
@@ -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]
+296
View File
@@ -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}");
}
}