use async_trait::async_trait;
use serde::Deserialize;
use serde_json::json;
use std::time::Duration;
use edgecrab_types::{ToolError, ToolSchema};
use crate::describe_execution_filesystem;
use crate::registry::{ToolContext, ToolHandler};
use crate::tools::backend_pool::{get_or_create_backend, resolve_workdir};
#[cfg(test)]
use crate::tools::backends::ExecutionBackend;
use crate::tools::backends::redact_output;
use crate::tools::checkpoint::ensure_checkpoint;
fn validate_backend_workdir_visibility(
backend: &crate::tools::backends::BackendKind,
cwd: &str,
) -> Result<(), ToolError> {
let cwd_path = std::path::Path::new(cwd);
if !cwd_path.is_absolute() {
return Ok(());
}
match backend {
crate::tools::backends::BackendKind::Modal
| crate::tools::backends::BackendKind::Daytona => {
if cwd_path.exists() {
let cfg = crate::config_ref::AppConfigRef {
terminal_backend: backend.clone(),
..Default::default()
};
let fs = describe_execution_filesystem(&cfg, cwd_path);
let allowed_roots = fs.file_roots_display();
return Err(ToolError::ExecutionFailed {
tool: "terminal".into(),
message: format!(
"The {} backend cannot access host workspace path '{}'. File tools in this session are rooted at {}. Use the local or docker backend for local files, or sync the workspace into the remote sandbox first.",
backend, cwd, allowed_roots
),
});
}
}
_ => {}
}
Ok(())
}
fn terminal_result_header(
backend: &crate::tools::backends::BackendKind,
cwd: &str,
exit_code: i32,
) -> String {
let status = if exit_code == 0 { "success" } else { "error" };
format!(
"[terminal_result status={status} backend={} cwd={} exit_code={exit_code}]",
backend, cwd
)
}
pub async fn cleanup_all_backends() -> usize {
crate::tools::backend_pool::cleanup_all_backends().await
}
pub async fn cleanup_backend_for_task(task_id: &str) -> bool {
crate::tools::backend_pool::cleanup_backend_for_task(task_id).await
}
pub async fn cleanup_inactive_backends(max_idle: Duration) -> usize {
crate::tools::backend_pool::cleanup_inactive_backends(max_idle).await
}
pub struct TerminalTool;
#[derive(Deserialize)]
struct Args {
command: String,
#[serde(default = "default_timeout")]
timeout_seconds: u64,
workdir: Option<String>,
}
fn default_timeout() -> u64 {
120
}
#[async_trait]
impl ToolHandler for TerminalTool {
fn name(&self) -> &'static str {
"terminal"
}
fn toolset(&self) -> &'static str {
"terminal"
}
fn emoji(&self) -> &'static str {
"💻"
}
fn schema(&self) -> ToolSchema {
ToolSchema {
name: "terminal".into(),
description: "Execute a shell command and return combined stdout+stderr output. \
Commands run in a persistent bash shell, so state like environment \
variables, `cd`, and shell functions persist across consecutive calls. \
Use `workdir` to override the working directory for a single call. \
For long-running processes (servers, watchers) use `run_process` instead. \
The exit code is appended to the output as `exit code: N`; non-zero \
means the command failed."
.into(),
parameters: json!({
"type": "object",
"properties": {
"command": {
"type": "string",
"description": "Shell command to execute. Multi-line scripts are supported."
},
"timeout_seconds": {
"type": "integer",
"description": "Maximum seconds to wait for the command to finish (default: 120, max: 600)."
},
"workdir": {
"type": "string",
"description": "Override working directory for this command only. \
Absolute paths are used as-is; relative paths are \
resolved from the task working directory."
}
},
"required": ["command"]
}),
strict: None,
}
}
async fn execute(
&self,
args: serde_json::Value,
ctx: &ToolContext,
) -> Result<String, ToolError> {
let args: Args = serde_json::from_value(args).map_err(|e| ToolError::InvalidArgs {
tool: "terminal".into(),
message: e.to_string(),
})?;
if is_destructive_command(&args.command) {
ensure_checkpoint(
ctx,
&format!(
"before terminal: {}",
&args.command[..args.command.len().min(80)]
),
);
}
if let Some(reasons) = crate::approval_runtime::command_approval_reasons(ctx, &args.command)
{
crate::approval_runtime::request_command_approval(ctx, &args.command, reasons).await?;
}
crate::command_interaction::guard_terminal_command(
&args.command,
&ctx.config.terminal_backend,
)?;
let timeout = Duration::from_secs(args.timeout_seconds.min(600));
let cwd = resolve_workdir(ctx, args.workdir.as_deref());
validate_backend_workdir_visibility(&ctx.config.terminal_backend, &cwd)?;
let backend = get_or_create_backend(ctx).await?;
let (effective_command, sudo_prefix) =
crate::tools::backends::transform_sudo(&args.command);
let final_command = if let Some(prefix) = sudo_prefix {
let escaped = prefix.trim_end_matches('\n').replace('\'', "'\\''");
format!(
"{{ printf '%s\\n' '{}'; }} | {}",
escaped, effective_command
)
} else {
effective_command
};
const MAX_RETRIES: u32 = 3;
let mut attempt = 0u32;
let exec_output = loop {
let result = backend
.execute(&final_command, &cwd, timeout, ctx.cancel.clone())
.await;
if let Err(ref e) = result {
if attempt < MAX_RETRIES && is_backend_retryable(e) {
let wait = Duration::from_secs(1u64 << (attempt + 1)); tracing::warn!(
task_id = %ctx.task_id,
attempt = attempt + 1,
wait_secs = wait.as_secs(),
error = %e,
"terminal backend error; retrying",
);
attempt += 1;
tokio::time::sleep(wait).await;
continue;
}
}
match result {
Ok(out) => break out,
Err(e) => return Err(e),
}
};
crate::command_interaction::rewrite_terminal_exec_result(
&args.command,
&ctx.config.terminal_backend,
timeout,
&exec_output,
)?;
let max_stdout = ctx.config.max_terminal_output;
let max_stderr = ctx.config.max_terminal_output / 4;
let mut result = exec_output.format(max_stdout, max_stderr);
result = strip_ansi_escapes::strip_str(&result);
result = redact_output(&result);
let header =
terminal_result_header(&ctx.config.terminal_backend, &cwd, exec_output.exit_code);
result = if result.is_empty() {
header
} else {
format!("{header}\n{result}")
};
Ok(result)
}
}
fn is_destructive_command(cmd: &str) -> bool {
const DESTRUCTIVE_PREFIXES: &[&str] = &[
"rm ",
"rm\t",
"rmdir ",
"mv ",
"mv\t",
"shred ",
"truncate ",
"git reset",
"git clean",
"git checkout",
"git restore",
"git rebase",
"git merge",
"git cherry-pick",
"git revert",
"sed -i",
"sed -i'",
"awk -i",
];
let has_overwrite_redirect = cmd.contains(" > ")
|| cmd.starts_with('>')
|| cmd.contains("\t> ")
|| (cmd.contains('>') && !cmd.contains(">>"));
let cmd_lower = cmd.to_lowercase();
DESTRUCTIVE_PREFIXES.iter().any(|p| {
cmd_lower.starts_with(p)
|| cmd_lower.contains(&format!("; {p}"))
|| cmd_lower.contains(&format!("&& {p}"))
}) || has_overwrite_redirect
}
fn is_backend_retryable(err: &ToolError) -> bool {
matches!(err, ToolError::Unavailable { .. } | ToolError::Other(_))
}
inventory::submit!(&TerminalTool as &dyn ToolHandler);
#[cfg(test)]
mod tests {
use super::*;
use crate::registry::{ApprovalRequest, ApprovalResponse};
use crate::tools::backend_pool::{
BackendCacheEntry, backend_cache, now_epoch_secs, prepare_backend_config,
};
use edgecrab_types::ToolError;
use std::sync::Arc;
use std::sync::atomic::{AtomicUsize, Ordering};
use tempfile::TempDir;
use tokio_util::sync::CancellationToken;
static TERMINAL_TEST_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
struct FakeBackend {
kind: crate::tools::backends::BackendKind,
cleanup_calls: Arc<AtomicUsize>,
}
#[async_trait]
impl ExecutionBackend for FakeBackend {
async fn execute(
&self,
_command: &str,
_cwd: &str,
_timeout: Duration,
_cancel: CancellationToken,
) -> Result<crate::tools::backends::ExecOutput, ToolError> {
Ok(crate::tools::backends::ExecOutput {
stdout: String::new(),
stderr: String::new(),
exit_code: 0,
})
}
async fn cleanup(&self) -> Result<(), ToolError> {
self.cleanup_calls.fetch_add(1, Ordering::Relaxed);
Ok(())
}
fn kind(&self) -> crate::tools::backends::BackendKind {
self.kind.clone()
}
}
fn ctx_in(dir: &std::path::Path) -> ToolContext {
let mut ctx = ToolContext::test_context();
ctx.task_id = format!("terminal-test-{}", uuid::Uuid::new_v4().simple());
ctx.cwd = dir.to_path_buf();
ctx
}
#[tokio::test]
#[allow(clippy::await_holding_lock)]
async fn terminal_echo() {
let _guard = TERMINAL_TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let dir = TempDir::new().expect("tmpdir");
let ctx = ctx_in(dir.path());
let result = TerminalTool
.execute(json!({"command": "echo hello world"}), &ctx)
.await
.expect("terminal");
assert!(result.contains("hello world"));
let _ = cleanup_backend_for_task(&ctx.task_id).await;
}
#[tokio::test]
#[allow(clippy::await_holding_lock)]
async fn terminal_exit_code() {
let _guard = TERMINAL_TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let dir = TempDir::new().expect("tmpdir");
let ctx = ctx_in(dir.path());
let result = TerminalTool
.execute(json!({"command": "exit 42"}), &ctx)
.await
.expect("terminal");
assert!(result.contains("exit code: 42"));
let _ = cleanup_backend_for_task(&ctx.task_id).await;
}
#[tokio::test]
#[allow(clippy::await_holding_lock)]
async fn terminal_cwd_respected() {
let _guard = TERMINAL_TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let dir = TempDir::new().expect("tmpdir");
std::fs::write(dir.path().join("marker.txt"), "found").expect("write");
let ctx = ctx_in(dir.path());
let result = TerminalTool
.execute(json!({"command": "cat marker.txt"}), &ctx)
.await
.expect("terminal");
assert!(result.contains("found"));
let _ = cleanup_backend_for_task(&ctx.task_id).await;
}
#[test]
fn prepare_backend_config_mounts_workspace_for_docker() {
let _guard = TERMINAL_TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let dir = TempDir::new().expect("tmpdir");
let mut ctx = ctx_in(dir.path());
ctx.config.terminal_backend = crate::tools::backends::BackendKind::Docker;
let cfg = prepare_backend_config(&ctx);
let mount = cfg
.docker
.workspace_mount
.as_ref()
.expect("docker workspace mount");
assert_eq!(mount.container_path, "/workspace");
assert_eq!(
std::path::Path::new(&mount.host_path),
dir.path(),
"task cwd should be the mounted host workspace"
);
}
#[test]
fn modal_backend_rejects_host_workspace_paths() {
let _guard = TERMINAL_TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let dir = TempDir::new().expect("tmpdir");
let err = validate_backend_workdir_visibility(
&crate::tools::backends::BackendKind::Modal,
&dir.path().to_string_lossy(),
)
.expect_err("modal should reject host cwd");
assert!(
err.to_string()
.contains("cannot access host workspace path")
);
assert!(
err.to_string()
.contains("File tools in this session are rooted at")
);
}
#[test]
fn terminal_result_header_is_machine_readable() {
let _guard = TERMINAL_TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let header = terminal_result_header(
&crate::tools::backends::BackendKind::Docker,
"/workspace/demo",
0,
);
assert_eq!(
header,
"[terminal_result status=success backend=docker cwd=/workspace/demo exit_code=0]"
);
}
#[tokio::test]
#[allow(clippy::await_holding_lock)]
async fn terminal_requests_approval_for_dangerous_command() {
let _guard = TERMINAL_TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let dir = TempDir::new().expect("tmpdir");
let mut ctx = ctx_in(dir.path());
let (approval_tx, mut approval_rx) =
tokio::sync::mpsc::unbounded_channel::<ApprovalRequest>();
ctx.approval_tx = Some(approval_tx);
let approver = tokio::spawn(async move {
let request = approval_rx.recv().await.expect("approval request");
assert!(request.full_command.contains("rm -rf"));
let _ = request.response_tx.send(ApprovalResponse::Once);
});
let result = TerminalTool
.execute(json!({"command": "rm -rf /tmp/edgecrab-danger-test"}), &ctx)
.await
.expect("terminal");
approver.await.expect("approver task");
assert!(result.contains("exit code: 0"));
let _ = cleanup_backend_for_task(&ctx.task_id).await;
}
#[tokio::test]
#[allow(clippy::await_holding_lock)]
async fn terminal_rejects_tty_ui_commands() {
let _guard = TERMINAL_TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let dir = TempDir::new().expect("tmpdir");
let ctx = ctx_in(dir.path());
let err = TerminalTool
.execute(json!({"command": "vim Cargo.toml"}), &ctx)
.await
.expect_err("tty ui should be rejected");
let ToolError::CapabilityDenied { message, code, .. } = err else {
panic!("expected capability denied");
};
assert_eq!(code, "non_interactive_terminal_required");
assert!(message.contains("interactive terminal UI"));
}
#[tokio::test]
#[allow(clippy::await_holding_lock)]
async fn cleanup_inactive_backends_removes_idle_entries() {
let _guard = TERMINAL_TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let _ = cleanup_all_backends().await;
let cleanup_calls = Arc::new(AtomicUsize::new(0));
let backend: Arc<dyn ExecutionBackend> = Arc::new(FakeBackend {
kind: crate::tools::backends::BackendKind::Local,
cleanup_calls: cleanup_calls.clone(),
});
let entry = Arc::new(BackendCacheEntry::new(backend));
entry
.last_used_epoch_secs
.store(now_epoch_secs().saturating_sub(600), Ordering::Relaxed);
backend_cache().insert("idle-test".into(), entry);
let cleaned = cleanup_inactive_backends(Duration::from_secs(300)).await;
assert_eq!(cleaned, 1);
assert_eq!(cleanup_calls.load(Ordering::Relaxed), 1);
assert!(!backend_cache().contains_key("idle-test"));
}
#[tokio::test]
#[allow(clippy::await_holding_lock)]
async fn cleanup_inactive_backends_preserves_in_use_entries() {
let _guard = TERMINAL_TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let _ = cleanup_all_backends().await;
let cleanup_calls = Arc::new(AtomicUsize::new(0));
let backend: Arc<dyn ExecutionBackend> = Arc::new(FakeBackend {
kind: crate::tools::backends::BackendKind::Local,
cleanup_calls: cleanup_calls.clone(),
});
let held_clone = backend.clone();
let entry = Arc::new(BackendCacheEntry::new(backend));
entry
.last_used_epoch_secs
.store(now_epoch_secs().saturating_sub(600), Ordering::Relaxed);
backend_cache().insert("busy-test".into(), entry);
let cleaned = cleanup_inactive_backends(Duration::from_secs(300)).await;
assert_eq!(cleaned, 0);
assert_eq!(cleanup_calls.load(Ordering::Relaxed), 0);
assert!(backend_cache().contains_key("busy-test"));
drop(held_clone);
let _ = cleanup_all_backends().await;
}
}