Add ACP agent backend and terminal controls
This commit is contained in:
@@ -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}");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user