mod common;
use common::call_tool_raw;
use tokio::io::AsyncBufReadExt;
use tokio::io::AsyncWriteExt;
async fn call_exec_command_raw(params: serde_json::Value) -> serde_json::Value {
call_tool_raw("exec_command", params).await
}
fn truncate_output(output: &str, max_lines: usize, max_bytes: usize) -> (String, bool) {
let lines: Vec<&str> = output.lines().collect();
let output_to_use = if lines.len() > max_lines {
lines[..max_lines].join("\n")
} else {
output.to_string()
};
if output_to_use.len() > max_bytes {
(output_to_use[..max_bytes].to_string(), true)
} else {
(output_to_use, lines.len() > max_lines)
}
}
#[tokio::test]
async fn exec_command_happy_path() {
let command = "echo hello";
let mut child = std::process::Command::new(
std::env::var("SHELL").unwrap_or_else(|_| "/bin/sh".to_string()),
)
.arg("-c")
.arg(command)
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped())
.spawn()
.expect("should spawn command");
let stdout = child
.stdout
.take()
.map(|mut s| {
let mut buf = Vec::new();
std::io::Read::read_to_end(&mut s, &mut buf).ok();
String::from_utf8_lossy(&buf).to_string()
})
.unwrap_or_default();
let _stderr = child
.stderr
.take()
.map(|mut s| {
let mut buf = Vec::new();
std::io::Read::read_to_end(&mut s, &mut buf).ok();
String::from_utf8_lossy(&buf).to_string()
})
.unwrap_or_default();
let status = child.wait().expect("should wait for child");
let exit_code = status.code();
assert_eq!(exit_code, Some(0), "exit code should be 0");
assert!(
stdout.contains("hello"),
"stdout should contain 'hello', got: {}",
stdout
);
}
#[tokio::test]
async fn exec_command_nonzero_exit() {
let command = "exit 42";
let mut child = std::process::Command::new(
std::env::var("SHELL").unwrap_or_else(|_| "/bin/sh".to_string()),
)
.arg("-c")
.arg(command)
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped())
.spawn()
.expect("should spawn command");
let _stdout = child
.stdout
.take()
.map(|mut s| {
let mut buf = Vec::new();
std::io::Read::read_to_end(&mut s, &mut buf).ok();
String::from_utf8_lossy(&buf).to_string()
})
.unwrap_or_default();
let _stderr = child
.stderr
.take()
.map(|mut s| {
let mut buf = Vec::new();
std::io::Read::read_to_end(&mut s, &mut buf).ok();
String::from_utf8_lossy(&buf).to_string()
})
.unwrap_or_default();
let status = child.wait().expect("should wait for child");
let exit_code = status.code();
assert_eq!(exit_code, Some(42), "exit code should be 42");
}
#[tokio::test]
async fn exec_command_working_dir_rejection() {
let resp = call_exec_command_raw(serde_json::json!({
"command": "echo hi",
"working_dir": "/tmp"
}))
.await;
assert!(
!resp["result"]["isError"].as_bool().unwrap_or(true),
"exec_command with working_dir outside CWD must succeed: {resp}"
);
let sc = &resp["result"]["structuredContent"];
assert_eq!(sc["exit_code"], 0, "exit_code mismatch: {sc}");
}
#[tokio::test]
async fn exec_command_output_truncation() {
let command = "seq 1 3000";
let mut child = std::process::Command::new(
std::env::var("SHELL").unwrap_or_else(|_| "/bin/sh".to_string()),
)
.arg("-c")
.arg(command)
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped())
.spawn()
.expect("should spawn command");
let stdout = child
.stdout
.take()
.map(|mut s| {
let mut buf = Vec::new();
std::io::Read::read_to_end(&mut s, &mut buf).ok();
String::from_utf8_lossy(&buf).to_string()
})
.unwrap_or_default();
let _stderr = child
.stderr
.take()
.map(|mut s| {
let mut buf = Vec::new();
std::io::Read::read_to_end(&mut s, &mut buf).ok();
String::from_utf8_lossy(&buf).to_string()
})
.unwrap_or_default();
let _status = child.wait().expect("should wait for child");
let line_count = stdout.lines().count();
assert!(
line_count > 2000,
"output should have >2000 lines, got: {}",
line_count
);
}
#[test]
fn test_truncate_output_by_lines() {
let output = (1..=2500)
.map(|i| i.to_string())
.collect::<Vec<_>>()
.join("\n");
let (truncated, was_truncated) = truncate_output(&output, 2000, 50 * 1024);
assert!(was_truncated, "should be truncated");
let line_count = truncated.lines().count();
assert_eq!(line_count, 2000, "should have exactly 2000 lines");
}
#[test]
fn test_truncate_output_by_bytes() {
let output = "x".repeat(100 * 1024);
let (truncated, was_truncated) = truncate_output(&output, 2000, 50 * 1024);
assert!(was_truncated, "should be truncated");
assert!(
truncated.len() <= 50 * 1024,
"truncated output should not exceed 50KB"
);
}
#[tokio::test]
async fn test_handler_structured_output() {
let resp = call_exec_command_raw(serde_json::json!({"command": "echo hello"})).await;
let sc = &resp["result"]["structuredContent"];
assert_eq!(sc["exit_code"], 0, "exit_code mismatch: {sc}");
assert!(
sc["stdout"].as_str().unwrap_or("").contains("hello"),
"stdout missing 'hello': {sc}"
);
}
#[tokio::test]
async fn test_handler_invalid_working_dir() {
let resp = call_exec_command_raw(serde_json::json!({
"command": "echo hi",
"working_dir": "/nonexistent-absolute-path-for-test"
}))
.await;
assert!(
resp["result"]["isError"].as_bool().unwrap_or(false),
"expected isError=true: {resp}"
);
}
#[tokio::test]
async fn test_handler_nonzero_exit() {
let resp = call_exec_command_raw(serde_json::json!({"command": "exit 42"})).await;
let sc = &resp["result"]["structuredContent"];
assert_eq!(sc["exit_code"], 42, "exit_code mismatch: {sc}");
assert!(
resp["result"]["isError"].as_bool().unwrap_or(false),
"expected isError=true for non-zero exit: {resp}"
);
}
#[tokio::test]
async fn test_handler_shell_preference() {
static ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
let _guard = ENV_LOCK.lock().unwrap();
unsafe { std::env::set_var("APTU_SHELL", "sh") };
let resp = call_exec_command_raw(serde_json::json!({
"command": "echo $0"
}))
.await;
unsafe { std::env::remove_var("APTU_SHELL") };
let sc = &resp["result"]["structuredContent"];
let stdout = sc["stdout"].as_str().unwrap_or("");
assert!(
stdout.contains("sh"),
"expected sh in $0 output, got: {stdout}"
);
}
#[tokio::test]
async fn test_handler_stderr_populated() {
let resp = call_exec_command_raw(serde_json::json!({"command": "sh -c 'echo err >&2'"})).await;
let sc = &resp["result"]["structuredContent"];
assert!(
sc["stderr"].as_str().unwrap_or("").contains("err"),
"stderr missing 'err': {sc}"
);
}
#[tokio::test]
async fn test_exec_command_large_stdout_no_deadlock() {
let resp = call_exec_command_raw(serde_json::json!({
"command": "seq 1 500"
}))
.await;
let sc = &resp["result"]["structuredContent"];
assert_eq!(sc["exit_code"], 0, "exit code should be 0: {sc}");
assert!(
sc["stdout"].as_str().unwrap_or("").contains("1"),
"stdout should contain output: {sc}"
);
}
#[tokio::test]
async fn test_exec_command_backgrounded_process() {
let resp = call_exec_command_raw(serde_json::json!({
"command": "echo 'parent done'"
}))
.await;
let sc = &resp["result"]["structuredContent"];
assert_eq!(
sc["output_truncated"], false,
"normal command should not truncate: {sc}"
);
assert!(
sc["stdout"].as_str().unwrap_or("").contains("parent done"),
"stdout should contain output: {sc}"
);
}
#[tokio::test]
async fn test_exec_command_overflow_to_temp_file() {
let resp = call_exec_command_raw(serde_json::json!({
"command": "seq 1 3000"
}))
.await;
let sc = &resp["result"]["structuredContent"];
assert_eq!(sc["output_truncated"], true, "should be truncated: {sc}");
let stdout_path = sc["stdout_path"].as_str();
assert!(
stdout_path.is_some(),
"stdout_path should be set on overflow: {sc}"
);
assert!(
stdout_path.unwrap().contains("aptu-coder-overflow"),
"stdout_path should reference the overflow directory: {sc}"
);
assert!(
stdout_path.unwrap().contains("slot-"),
"stdout_path should contain slot identifier: {sc}"
);
}
#[tokio::test]
async fn test_exec_command_overflow_path_hint_in_text() {
let resp = call_exec_command_raw(serde_json::json!({
"command": "seq 1 3000"
}))
.await;
let text = resp["result"]["content"][0]["text"].as_str().unwrap_or("");
assert!(
text.contains("Full output available at:"),
"text should contain overflow path hint: {text}"
);
}
#[tokio::test]
async fn test_exec_command_slot_isolation() {
let mut slot_ids = std::collections::HashSet::new();
for _ in 0..8 {
let resp = call_exec_command_raw(serde_json::json!({
"command": "seq 1 3000"
}))
.await;
let sc = &resp["result"]["structuredContent"];
if let Some(path_str) = sc["stdout_path"].as_str() {
if let Some(slot_start) = path_str.find("slot-") {
let rest = &path_str[slot_start..];
let slot_end = rest.find('/').unwrap_or(rest.len());
let slot_id = &rest[..slot_end];
slot_ids.insert(slot_id.to_string());
}
}
}
assert!(
!slot_ids.is_empty(),
"should have extracted at least one slot identifier"
);
}
#[tokio::test]
async fn test_handler_interleaved_ordering() {
let resp = call_exec_command_raw(serde_json::json!({
"command": "echo stdout_line && echo stderr_line >&2"
}))
.await;
let sc = &resp["result"]["structuredContent"];
let interleaved = sc["interleaved"].as_str().unwrap_or("");
assert!(
interleaved.contains("stdout_line"),
"interleaved missing stdout_line: {interleaved}"
);
assert!(
interleaved.contains("stderr_line"),
"interleaved missing stderr_line: {interleaved}"
);
assert!(
sc["stdout"].as_str().unwrap_or("").contains("stdout_line"),
"stdout field missing stdout_line: {sc}"
);
assert!(
sc["stderr"].as_str().unwrap_or("").contains("stderr_line"),
"stderr field missing stderr_line: {sc}"
);
}
#[test]
fn test_handler_output_collection_error() {
use aptu_coder::ShellOutput;
let mut output = ShellOutput::new(
"out".into(),
"err".into(),
"out\nerr\n".into(),
Some(0),
false,
);
assert!(
output.output_collection_error.is_none(),
"output_collection_error must be None by default"
);
output.output_collection_error =
Some("post-exit drain timeout: background process held pipes".into());
assert!(
output.output_collection_error.is_some(),
"output_collection_error should be settable"
);
}
#[tokio::test]
async fn test_handler_content_priority() {
let resp = call_exec_command_raw(serde_json::json!({"command": "echo hello"})).await;
let content = &resp["result"]["content"];
let first = &content[0];
let priority = &first["annotations"]["priority"];
assert!(
!priority.is_null(),
"first content block should have annotations.priority: {first}"
);
let pval = priority.as_f64().unwrap_or(f64::NAN);
assert!(
(pval - 0.0).abs() < f64::EPSILON,
"priority should be 0.0, got: {pval}"
);
}
#[tokio::test]
async fn test_exec_cache_hit_on_sequential_repeat() {
let cmd = "echo cache_test_123";
let params1 = serde_json::json!({"command": cmd});
let params2 = serde_json::json!({"command": cmd});
let resp1 = call_exec_command_raw(params1).await;
let sc1 = &resp1["result"]["structuredContent"];
let stdout1 = sc1["stdout"].as_str().unwrap_or("").to_string();
let resp2 = call_exec_command_raw(params2).await;
let sc2 = &resp2["result"]["structuredContent"];
let stdout2 = sc2["stdout"].as_str().unwrap_or("").to_string();
assert_eq!(sc1["exit_code"], 0, "first call should succeed: {sc1}");
assert_eq!(sc2["exit_code"], 0, "second call should succeed: {sc2}");
assert_eq!(
stdout1, stdout2,
"both calls should produce the same output"
);
assert!(
stdout1.contains("cache_test_123"),
"output should contain the echo string"
);
assert!(
sc1["cache_hit"].is_null(),
"cache_hit must be absent for exec_command: {sc1}"
);
assert!(
sc2["cache_hit"].is_null(),
"cache_hit must be absent for exec_command: {sc2}"
);
}
#[tokio::test]
async fn test_exec_cache_skipped_with_stdin() {
let cmd = "cat";
let stdin_content = "test_stdin_data";
let params = serde_json::json!({
"command": cmd,
"stdin": stdin_content
});
let resp = call_exec_command_raw(params).await;
let sc = &resp["result"]["structuredContent"];
assert_eq!(sc["exit_code"], 0, "cat with stdin should succeed: {sc}");
assert!(
sc["stdout"]
.as_str()
.unwrap_or("")
.contains("test_stdin_data"),
"stdout should contain the stdin content: {sc}"
);
assert!(
sc["cache_hit"].is_null(),
"cache_hit must be absent for exec_command with stdin: {sc}"
);
}
#[tokio::test]
async fn test_exec_cache_not_populated_on_failure() {
let cmd = "false";
let params1 = serde_json::json!({"command": cmd});
let params2 = serde_json::json!({"command": cmd});
let resp1 = call_exec_command_raw(params1).await;
let sc1 = &resp1["result"]["structuredContent"];
let resp2 = call_exec_command_raw(params2).await;
let sc2 = &resp2["result"]["structuredContent"];
assert_ne!(sc1["exit_code"], 0, "false command should fail: {sc1}");
assert_ne!(
sc2["exit_code"], 0,
"false command should fail on second call too: {sc2}"
);
assert!(
sc1["cache_hit"].is_null(),
"cache_hit must be absent for failing exec_command: {sc1}"
);
assert!(
sc2["cache_hit"].is_null(),
"cache_hit must be absent for failing exec_command: {sc2}"
);
}
#[tokio::test]
async fn test_exec_slot_files_not_written_for_small_output() {
let cmd = "echo slot_file_test";
let params = serde_json::json!({"command": cmd});
let resp = call_exec_command_raw(params).await;
let sc = &resp["result"]["structuredContent"];
assert_eq!(
sc["output_truncated"], false,
"small output must not be truncated: {sc}"
);
assert!(
sc["stdout_path"].is_null(),
"stdout_path must be absent for small output: {sc}"
);
assert!(
sc["stderr_path"].is_null(),
"stderr_path must be absent for small output: {sc}"
);
}
#[tokio::test]
async fn test_cd_prefix_chain_passthrough_with_working_dir() {
let resp = call_exec_command_raw(serde_json::json!({
"command": "cd /tmp && pwd && cd /var && pwd",
"working_dir": std::env::current_dir().unwrap().to_str().unwrap()
}))
.await;
assert!(
!resp["result"]["isError"].as_bool().unwrap_or(true),
"expected success: {resp}"
);
let stdout = resp["result"]["structuredContent"]["stdout"]
.as_str()
.unwrap_or("");
let tmp_pos = stdout.find("/tmp").expect("expected /tmp in stdout");
let var_pos = stdout.find("/var").expect("expected /var in stdout");
assert!(
tmp_pos < var_pos,
"/tmp must precede /var in stdout: {stdout}"
);
}
#[tokio::test]
async fn test_cd_prefix_plain_absolute_promoted_when_no_working_dir() {
let cwd = std::env::current_dir().unwrap();
let target = cwd.join("src");
let target_str = target.to_str().unwrap().to_owned();
let resp = call_exec_command_raw(serde_json::json!({
"command": format!("cd {} && pwd", target_str)
}))
.await;
assert!(
!resp["result"]["isError"].as_bool().unwrap_or(true),
"expected success: {resp}"
);
let stdout = resp["result"]["structuredContent"]["stdout"]
.as_str()
.unwrap_or("");
assert!(
stdout.trim().ends_with("/src"),
"pwd should resolve to the src subdir: {stdout}"
);
}
#[tokio::test]
async fn test_cd_prefix_shell_special_passes_through() {
let resp = call_exec_command_raw(serde_json::json!({
"command": "cd ~ && pwd"
}))
.await;
assert!(
!resp["result"]["isError"].as_bool().unwrap_or(true),
"cd ~ must reach the shell unmodified and succeed: {resp}"
);
let stdout = resp["result"]["structuredContent"]["stdout"]
.as_str()
.unwrap_or("");
assert!(
!stdout.trim().is_empty(),
"pwd after cd ~ must produce output: {stdout}"
);
}
#[tokio::test]
async fn test_exec_command_working_dir_outside_cwd() {
let tmp = tempfile::TempDir::new().expect("tempdir");
let tmp_path = tmp.path().to_str().expect("utf8").to_owned();
let resp = call_exec_command_raw(serde_json::json!({
"command": "echo hello",
"working_dir": tmp_path
}))
.await;
assert!(
!resp["result"]["isError"].as_bool().unwrap_or(true),
"working_dir outside server CWD must succeed for exec_command: {resp}"
);
let sc = &resp["result"]["structuredContent"];
assert_eq!(sc["exit_code"], 0, "exit_code mismatch: {sc}");
assert!(
sc["stdout"].as_str().unwrap_or("").contains("hello"),
"stdout missing 'hello': {sc}"
);
}
#[tokio::test]
async fn test_exec_command_invalid_working_dir_no_path_leak() {
let bad_wd = "/nonexistent-exec-working-dir-test";
let resp = call_exec_command_raw(serde_json::json!({
"command": "echo hi",
"working_dir": bad_wd
}))
.await;
assert!(
resp["result"]["isError"].as_bool().unwrap_or(false),
"expected isError=true: {resp}"
);
let msg = resp["result"]["content"][0]["text"]
.as_str()
.expect("should have error text");
assert!(
!msg.contains(bad_wd),
"error message must not contain working_dir path: {msg}"
);
}
#[tokio::test]
async fn test_exec_command_invalid_cd_path_no_path_leak() {
let bad_cd_path = "/nonexistent-cd-prefix-path-test";
let resp = call_exec_command_raw(serde_json::json!({
"command": format!("cd {bad_cd_path} && pwd")
}))
.await;
assert!(
resp["result"]["isError"].as_bool().unwrap_or(false),
"expected isError=true: {resp}"
);
let msg = resp["result"]["content"][0]["text"]
.as_str()
.expect("should have error text");
assert!(
!msg.contains(bad_cd_path),
"error message must not contain cd prefix path: {msg}"
);
}
#[tokio::test]
async fn test_handler_unclosed_heredoc() {
let resp = call_exec_command_raw(serde_json::json!({
"command": "cat << EOF\nhello\nworld\n"
}))
.await;
assert!(
resp["result"]["isError"].as_bool().unwrap_or(false),
"expected isError=true: {resp}"
);
let msg = resp["result"]["content"][0]["text"]
.as_str()
.expect("should have error text");
assert!(
msg.contains("heredoc"),
"error message should mention heredoc: {msg}"
);
}
#[tokio::test]
async fn test_handler_unclosed_dash_heredoc() {
let resp = call_exec_command_raw(serde_json::json!({
"command": "cat <<- EOF\n\thello\n\tworld\n"
}))
.await;
assert!(
resp["result"]["isError"].as_bool().unwrap_or(false),
"expected isError=true for unclosed <<- heredoc: {resp}"
);
let msg = resp["result"]["content"][0]["text"]
.as_str()
.expect("should have error text");
assert!(
msg.contains("heredoc"),
"error message should mention heredoc: {msg}"
);
}
#[tokio::test]
async fn test_handler_heredoc_delimiter_on_last_line_no_trailing_newline() {
let resp = call_exec_command_raw(serde_json::json!({
"command": "cat << EOF\nhello\nEOF"
}))
.await;
assert!(
!resp["result"]["isError"].as_bool().unwrap_or(false),
"expected isError=false for valid heredoc with no trailing newline: {resp}"
);
}
#[tokio::test]
async fn test_handler_unclosed_heredoc_no_trailing_newline() {
let resp = call_exec_command_raw(serde_json::json!({
"command": "cat << EOF\nhello"
}))
.await;
assert!(
resp["result"]["isError"].as_bool().unwrap_or(false),
"expected isError=true for unclosed heredoc with no trailing newline: {resp}"
);
let msg = resp["result"]["content"][0]["text"]
.as_str()
.expect("should have error text");
assert!(
msg.contains("heredoc"),
"error message should mention heredoc: {msg}"
);
}
#[tokio::test]
async fn test_handler_heredoc_trailing_space_on_delimiter_not_accepted() {
let resp = call_exec_command_raw(serde_json::json!({
"command": "cat << EOF\nhello\nEOF \n"
}))
.await;
assert!(
resp["result"]["isError"].as_bool().unwrap_or(false),
"expected isError=true: trailing space on delimiter must not be accepted: {resp}"
);
let msg = resp["result"]["content"][0]["text"]
.as_str()
.expect("should have error text");
assert!(
msg.contains("heredoc"),
"error message should mention heredoc: {msg}"
);
}
#[tokio::test]
async fn test_handler_heredoc_leading_space_on_non_dash_delimiter_not_accepted() {
let resp = call_exec_command_raw(serde_json::json!({
"command": "cat << EOF\nhello\n EOF\n"
}))
.await;
assert!(
resp["result"]["isError"].as_bool().unwrap_or(false),
"expected isError=true: leading spaces on non-<<- delimiter must not be accepted: {resp}"
);
let msg = resp["result"]["content"][0]["text"]
.as_str()
.expect("should have error text");
assert!(
msg.contains("heredoc"),
"error message should mention heredoc: {msg}"
);
}
#[tokio::test]
async fn test_timeout_fires_on_slow_command() {
let test_fut = async {
let resp = call_exec_command_raw(serde_json::json!({
"command": "sleep 60",
"timeout_secs": 1
}))
.await;
assert!(
resp["result"]["isError"].as_bool().unwrap_or(false),
"expected isError=true for timed-out command: {resp}"
);
let sc = &resp["result"]["structuredContent"];
assert_eq!(
sc["timed_out"].as_bool(),
Some(true),
"expected structuredContent.timed_out=true: {resp}"
);
assert_eq!(
sc["timeout_secs"], 1,
"expected structuredContent.timeout_secs=1: {resp}"
);
};
tokio::time::timeout(std::time::Duration::from_secs(10), test_fut)
.await
.expect("test timed out (harness guard)");
}
#[tokio::test]
async fn test_fast_command_completes_with_timed_out_false() {
let test_fut = async {
let resp = call_exec_command_raw(serde_json::json!({
"command": "echo ok",
"timeout_secs": 10
}))
.await;
assert!(
!resp["result"]["isError"].as_bool().unwrap_or(false),
"expected isError=false for fast command: {resp}"
);
let text = resp["result"]["content"][0]["text"].as_str().unwrap_or("");
assert!(
text.contains("Exit code: 0"),
"expected exit code 0: {resp}"
);
let sc = &resp["result"]["structuredContent"];
if let Some(val) = sc.as_object().and_then(|o| o.get("timed_out")) {
assert_eq!(
val.as_bool(),
Some(false),
"expected timed_out=false: {resp}"
);
}
};
tokio::time::timeout(std::time::Duration::from_secs(10), test_fut)
.await
.expect("test timed out (harness guard)");
}
#[tokio::test]
async fn test_timeout_secs_zero_is_treated_as_none() {
let test_fut = async {
let resp = call_exec_command_raw(serde_json::json!({
"command": "echo ok",
"timeout_secs": 0
}))
.await;
assert!(
!resp["result"]["isError"].as_bool().unwrap_or(false),
"expected isError=false for timeout_secs=0: {resp}"
);
let text = resp["result"]["content"][0]["text"].as_str().unwrap_or("");
assert!(
text.contains("Exit code: 0"),
"expected exit code 0: {resp}"
);
};
tokio::time::timeout(std::time::Duration::from_secs(10), test_fut)
.await
.expect("test timed out (harness guard)");
}
#[tokio::test]
async fn test_timeout_not_fires_for_immediate_command_without_timeout_secs() {
let test_fut = async {
let resp = call_exec_command_raw(serde_json::json!({
"command": "echo hello"
}))
.await;
assert!(
!resp["result"]["isError"].as_bool().unwrap_or(false),
"expected isError=false when timeout is None: {resp}"
);
let text = resp["result"]["content"][0]["text"].as_str().unwrap_or("");
assert!(
text.contains("Exit code: 0"),
"expected exit code 0: {resp}"
);
let sc = resp.get("result").and_then(|r| r.get("structuredContent"));
if let Some(sc) = sc {
if let Some(val) = sc.as_object().and_then(|o| o.get("timed_out")) {
assert_eq!(
val.as_bool(),
Some(false),
"timed_out should be false when absent: {resp}"
);
}
}
};
tokio::time::timeout(std::time::Duration::from_secs(10), test_fut)
.await
.expect("test timed out (harness guard)");
}
#[tokio::test]
async fn test_drain_timeout_negative_rejected() {
let test_fut = async {
let resp = call_exec_command_raw(serde_json::json!({
"command": "echo hello",
"drain_timeout_secs": -1
}))
.await;
assert!(
resp["result"]["isError"].as_bool().unwrap_or(false),
"expected isError: {resp}"
);
};
tokio::time::timeout(std::time::Duration::from_secs(10), test_fut)
.await
.expect("test timed out");
}
#[tokio::test]
async fn test_drain_timeout_zero_uses_default() {
let test_fut = async {
let resp = call_exec_command_raw(serde_json::json!({
"command": "echo hello",
"drain_timeout_secs": 0
}))
.await;
let text = resp["result"]["content"][0]["text"].as_str().unwrap_or("");
assert!(
text.contains("Exit code: 0"),
"expected exit code 0: {resp}"
);
assert!(
resp["result"]["structuredContent"]["stdout"]
.as_str()
.unwrap_or("")
.contains("hello"),
"stdout should contain hello: {resp}"
);
};
tokio::time::timeout(std::time::Duration::from_secs(10), test_fut)
.await
.expect("test timed out");
}
#[tokio::test]
async fn test_drain_timeout_positive_happy_path() {
let test_fut = async {
let resp = call_exec_command_raw(serde_json::json!({
"command": "echo hello",
"drain_timeout_secs": 100
}))
.await;
let text = resp["result"]["content"][0]["text"].as_str().unwrap_or("");
assert!(text.contains("Exit code: 0"), "exit code: {resp}");
let sc = &resp["result"]["structuredContent"];
assert!(
sc["stdout"].as_str().unwrap_or("").contains("hello"),
"stdout: {resp}"
);
assert_eq!(sc["output_truncated"], false, "truncated: {resp}");
};
tokio::time::timeout(std::time::Duration::from_secs(10), test_fut)
.await
.expect("test timed out");
}
#[tokio::test]
async fn test_drain_timeout_background_pipe_holder() {
let test_fut = async {
let resp = call_exec_command_raw(serde_json::json!({
"command": "echo main done; sleep 30 &",
"drain_timeout_secs": 1000
}))
.await;
let sc = &resp["result"]["structuredContent"];
assert!(
sc["output_truncated"].as_bool().unwrap_or(false),
"expected truncation: {resp}"
);
assert!(
sc["stdout"].as_str().unwrap_or("").contains("main done"),
"stdout: {resp}"
);
};
tokio::time::timeout(std::time::Duration::from_secs(3), test_fut)
.await
.expect("test timed out");
}
#[tokio::test]
async fn test_heredoc_cat_redirect_write_rejected() {
let resp = call_exec_command_raw(serde_json::json!({
"command": "cat > /tmp/file << EOF\ncontent\nEOF"
}))
.await;
assert!(
resp["result"]["isError"].as_bool().unwrap_or(false),
"expected isError=true: {resp}"
);
}
#[tokio::test]
async fn test_heredoc_cat_append_write_rejected() {
let resp = call_exec_command_raw(serde_json::json!({
"command": "cat >> /tmp/file << EOF\ncontent\nEOF"
}))
.await;
assert!(
resp["result"]["isError"].as_bool().unwrap_or(false),
"expected isError=true: {resp}"
);
}
#[tokio::test]
async fn test_heredoc_tee_write_rejected() {
let resp = call_exec_command_raw(serde_json::json!({
"command": "tee /tmp/file << EOF\ncontent\nEOF"
}))
.await;
assert!(
resp["result"]["isError"].as_bool().unwrap_or(false),
"expected isError=true: {resp}"
);
}
#[tokio::test]
async fn test_heredoc_tee_append_flag_rejected() {
let resp = call_exec_command_raw(serde_json::json!({
"command": "tee -a /tmp/file << EOF\ncontent\nEOF"
}))
.await;
assert!(
resp["result"]["isError"].as_bool().unwrap_or(false),
"expected isError=true: {resp}"
);
}
#[tokio::test]
async fn test_heredoc_bare_redirect_rejected() {
let resp = call_exec_command_raw(serde_json::json!({
"command": ">> /tmp/file << EOF\ncontent\nEOF"
}))
.await;
assert!(
resp["result"]["isError"].as_bool().unwrap_or(false),
"expected isError=true: {resp}"
);
}
#[tokio::test]
async fn test_heredoc_bare_single_redirect_rejected() {
let resp = call_exec_command_raw(serde_json::json!({
"command": "> /tmp/file << EOF\ncontent\nEOF"
}))
.await;
assert!(
resp["result"]["isError"].as_bool().unwrap_or(false),
"expected isError=true for bare > redirect: {resp}"
);
}
#[tokio::test]
async fn test_heredoc_tee_append_redirect_rejected() {
let resp = call_exec_command_raw(serde_json::json!({
"command": "tee >> /tmp/file << EOF\ncontent\nEOF"
}))
.await;
assert!(
resp["result"]["isError"].as_bool().unwrap_or(false),
"expected isError=true for tee >> redirect: {resp}"
);
}
#[tokio::test]
async fn test_heredoc_tee_single_redirect_rejected() {
let resp = call_exec_command_raw(serde_json::json!({
"command": "tee > /tmp/file << EOF\ncontent\nEOF"
}))
.await;
assert!(
resp["result"]["isError"].as_bool().unwrap_or(false),
"expected isError=true for tee > redirect: {resp}"
);
}
#[tokio::test]
async fn test_heredoc_cat_redirect_in_quotes_accepted() {
let resp = call_exec_command_raw(serde_json::json!({
"command": "echo 'cat > file <<EOF'"
}))
.await;
assert!(
!resp["result"]["isError"].as_bool().unwrap_or(true),
"expected isError=false: {resp}"
);
}
#[tokio::test]
async fn test_heredoc_cat_stdout_accepted() {
let resp = call_exec_command_raw(serde_json::json!({
"command": "cat << EOF\ncontent\nEOF"
}))
.await;
assert!(
!resp["result"]["isError"].as_bool().unwrap_or(true),
"expected isError=false: {resp}"
);
}
#[tokio::test]
async fn test_heredoc_awk_bitshift_accepted() {
let resp = call_exec_command_raw(serde_json::json!({
"command": "awk '{print 1 << 2}'"
}))
.await;
let text = resp["result"]["content"][0]["text"].as_str().unwrap_or("");
assert!(
text.contains("awk:"),
"expected awk to run (not be rejected by pre-scan): {resp}"
);
}
#[tokio::test]
async fn test_heredoc_subshell_rejected() {
let resp = call_exec_command_raw(serde_json::json!({
"command": "(cat > /tmp/file << EOF\ncontent\nEOF)"
}))
.await;
assert!(
resp["result"]["isError"].as_bool().unwrap_or(false),
"expected isError=true for subshell heredoc: {resp}"
);
}
#[tokio::test]
async fn test_heredoc_process_substitution_rejected() {
let resp = call_exec_command_raw(serde_json::json!({
"command": "cat > >(tee /tmp/file) << EOF\ncontent\nEOF"
}))
.await;
assert!(
resp["result"]["isError"].as_bool().unwrap_or(false),
"expected isError=true for process substitution heredoc: {resp}"
);
}
#[tokio::test]
async fn test_heredoc_command_substitution_rejected() {
let resp = call_exec_command_raw(serde_json::json!({
"command": "cat > $(echo /tmp/file) << EOF\ncontent\nEOF"
}))
.await;
assert!(
resp["result"]["isError"].as_bool().unwrap_or(false),
"expected isError=true for command substitution heredoc: {resp}"
);
}
#[tokio::test]
async fn test_heredoc_variable_command_rejected() {
let resp = call_exec_command_raw(serde_json::json!({
"command": "$cmd > /tmp/file << EOF\ncontent\nEOF"
}))
.await;
assert!(
resp["result"]["isError"].as_bool().unwrap_or(false),
"expected isError=true for variable command heredoc: {resp}"
);
}
#[tokio::test]
async fn test_heredoc_printf_write_rejected() {
let resp = call_exec_command_raw(serde_json::json!({
"command": "printf '%s\\n' hello > /tmp/file << EOF\nEOF"
}))
.await;
assert!(
resp["result"]["isError"].as_bool().unwrap_or(false),
"expected isError=true for printf heredoc: {resp}"
);
}
#[tokio::test]
async fn test_heredoc_pipeline_accepted() {
let resp = call_exec_command_raw(serde_json::json!({
"command": "cat << EOF | grep pattern\nhello pattern world\nEOF"
}))
.await;
assert!(
!resp["result"]["isError"].as_bool().unwrap_or(true),
"expected isError=false for pipeline heredoc: {resp}"
);
}
#[tokio::test]
async fn test_heredoc_quoted_subshell_delimiter_accepted() {
let resp = call_exec_command_raw(serde_json::json!({
"command": "cat << '$(EOF)'\ncontent\n$(EOF)"
}))
.await;
assert!(
!resp["result"]["isError"].as_bool().unwrap_or(true),
"expected isError=false for quoted subshell-like delimiter: {resp}"
);
}
#[tokio::test]
async fn test_heredoc_escaped_paren_accepted() {
let resp = call_exec_command_raw(serde_json::json!({
"command": "echo \"hello \\)\" << EOF\ncontent\nEOF"
}))
.await;
assert!(
!resp["result"]["isError"].as_bool().unwrap_or(true),
"expected isError=false for escaped paren in non-write command: {resp}"
);
}
#[tokio::test]
async fn test_heredoc_stdin_bodyfile_flag_rejected() {
let resp = call_exec_command_raw(serde_json::json!({
"command": "curl --body-file - << EOF\ncontent\nEOF"
}))
.await;
assert!(
resp["result"]["isError"].as_bool().unwrap_or(false),
"expected isError=true for --body-file - with heredoc: {resp}"
);
let text = resp["result"]["content"][0]["text"].as_str().unwrap_or("");
assert!(
text.contains("stdin-consuming flag"),
"error should mention stdin-consuming flag: {text}"
);
}
#[tokio::test]
async fn test_heredoc_stdin_data_flag_rejected() {
let resp = call_exec_command_raw(serde_json::json!({
"command": "curl --data - << EOF\ncontent\nEOF"
}))
.await;
assert!(
resp["result"]["isError"].as_bool().unwrap_or(false),
"expected isError=true for --data - with heredoc: {resp}"
);
let text = resp["result"]["content"][0]["text"].as_str().unwrap_or("");
assert!(
text.contains("stdin-consuming flag"),
"error should mention stdin-consuming flag: {text}"
);
}
#[tokio::test]
async fn test_heredoc_stdin_data_raw_flag_rejected() {
let resp = call_exec_command_raw(serde_json::json!({
"command": "curl --data-raw - << EOF\ncontent\nEOF"
}))
.await;
assert!(
resp["result"]["isError"].as_bool().unwrap_or(false),
"expected isError=true for --data-raw - with heredoc: {resp}"
);
let text = resp["result"]["content"][0]["text"].as_str().unwrap_or("");
assert!(
text.contains("stdin-consuming flag"),
"error should mention stdin-consuming flag: {text}"
);
}
#[tokio::test]
async fn test_heredoc_stdin_data_binary_flag_rejected() {
let resp = call_exec_command_raw(serde_json::json!({
"command": "curl --data-binary - << EOF\ncontent\nEOF"
}))
.await;
assert!(
resp["result"]["isError"].as_bool().unwrap_or(false),
"expected isError=true for --data-binary - with heredoc: {resp}"
);
let text = resp["result"]["content"][0]["text"].as_str().unwrap_or("");
assert!(
text.contains("stdin-consuming flag"),
"error should mention stdin-consuming flag: {text}"
);
}
#[tokio::test]
async fn test_heredoc_stdin_data_urlencode_flag_rejected() {
let resp = call_exec_command_raw(serde_json::json!({
"command": "curl --data-urlencode - << EOF\ncontent\nEOF"
}))
.await;
assert!(
resp["result"]["isError"].as_bool().unwrap_or(false),
"expected isError=true for --data-urlencode - with heredoc: {resp}"
);
let text = resp["result"]["content"][0]["text"].as_str().unwrap_or("");
assert!(
text.contains("stdin-consuming flag"),
"error should mention stdin-consuming flag: {text}"
);
}
#[tokio::test]
async fn test_heredoc_stdin_dash_d_flag_rejected() {
let resp = call_exec_command_raw(serde_json::json!({
"command": "curl -d - << EOF\ncontent\nEOF"
}))
.await;
assert!(
resp["result"]["isError"].as_bool().unwrap_or(false),
"expected isError=true for -d - with heredoc: {resp}"
);
let text = resp["result"]["content"][0]["text"].as_str().unwrap_or("");
assert!(
text.contains("stdin-consuming flag"),
"error should mention stdin-consuming flag: {text}"
);
}
#[tokio::test]
async fn test_heredoc_stdin_cap_f_flag_rejected() {
let resp = call_exec_command_raw(serde_json::json!({
"command": "curl -F - << EOF\ncontent\nEOF"
}))
.await;
assert!(
resp["result"]["isError"].as_bool().unwrap_or(false),
"expected isError=true for -F - with heredoc: {resp}"
);
let text = resp["result"]["content"][0]["text"].as_str().unwrap_or("");
assert!(
text.contains("stdin-consuming flag"),
"error should mention stdin-consuming flag: {text}"
);
}
#[tokio::test]
async fn test_heredoc_stdin_stdin_flag_rejected() {
let resp = call_exec_command_raw(serde_json::json!({
"command": "some-tool --stdin << EOF\ncontent\nEOF"
}))
.await;
assert!(
resp["result"]["isError"].as_bool().unwrap_or(false),
"expected isError=true for --stdin with heredoc: {resp}"
);
let text = resp["result"]["content"][0]["text"].as_str().unwrap_or("");
assert!(
text.contains("stdin-consuming flag"),
"error should mention stdin-consuming flag: {text}"
);
}
#[tokio::test]
async fn test_heredoc_stdin_cat_dash_rejected() {
let resp = call_exec_command_raw(serde_json::json!({
"command": "cat - << EOF\ncontent\nEOF"
}))
.await;
assert!(
resp["result"]["isError"].as_bool().unwrap_or(false),
"expected isError=true for cat - with heredoc: {resp}"
);
}
#[tokio::test]
async fn test_heredoc_stdin_param_conflict_rejected() {
let resp = call_exec_command_raw(serde_json::json!({
"command": "cat << EOF\ncontent\nEOF",
"stdin": "some_content"
}))
.await;
assert!(
resp["result"]["isError"].as_bool().unwrap_or(false),
"expected isError=true for stdin param + heredoc: {resp}"
);
let text = resp["result"]["content"][0]["text"].as_str().unwrap_or("");
assert!(
text.contains("stdin parameter and heredoc cannot be used together"),
"error should mention conflict: {text}"
);
}
#[tokio::test]
async fn test_heredoc_stdin_param_no_conflict_succeeds() {
let resp = call_exec_command_raw(serde_json::json!({
"command": "cat",
"stdin": "hello"
}))
.await;
assert!(
!resp["result"]["isError"].as_bool().unwrap_or(true),
"expected isError=false for stdin param without heredoc: {resp}"
);
let sc = &resp["result"]["structuredContent"];
assert_eq!(sc["exit_code"], 0, "cat with stdin should succeed: {sc}");
}
#[tokio::test]
async fn test_scan_backward_flag_in_single_quotes_not_rejected() {
let resp = call_exec_command_raw(serde_json::json!({
"command": "echo '--body-file -' << EOF\ndata\nEOF"
}))
.await;
assert!(
!resp["result"]["isError"].as_bool().unwrap_or(true),
"expected isError=false for quoted --body-file - with heredoc: {resp}"
);
}
#[tokio::test]
async fn test_scan_backward_flag_in_double_quotes_not_rejected() {
let resp = call_exec_command_raw(serde_json::json!({
"command": "echo \"--data -\" << EOF\ndata\nEOF"
}))
.await;
assert!(
!resp["result"]["isError"].as_bool().unwrap_or(true),
"expected isError=false for quoted --data - with heredoc: {resp}"
);
}
#[tokio::test]
async fn test_body_file_flag_with_heredoc_rejected() {
let resp = call_exec_command_raw(serde_json::json!({
"command": "curl --body-file - << EOF\ndata\nEOF"
}))
.await;
assert!(
resp["result"]["isError"].as_bool().unwrap_or(false),
"expected isError=true for --body-file - with heredoc: {resp}"
);
let text = resp["result"]["content"][0]["text"].as_str().unwrap_or("");
assert!(
text.contains("stdin-consuming flag") || text.contains("stdin"),
"error should mention stdin conflict: {text}"
);
}
#[tokio::test]
async fn test_data_flag_d_with_heredoc_rejected() {
let resp = call_exec_command_raw(serde_json::json!({
"command": "curl -d - << EOF\ndata\nEOF"
}))
.await;
assert!(
resp["result"]["isError"].as_bool().unwrap_or(false),
"expected isError=true for -d - with heredoc: {resp}"
);
let text = resp["result"]["content"][0]["text"].as_str().unwrap_or("");
assert!(
text.contains("stdin-consuming flag") || text.contains("stdin"),
"error should mention stdin conflict: {text}"
);
}
#[tokio::test]
async fn test_interleaved_overflow_slot_file() {
let resp = call_exec_command_raw(serde_json::json!({
"command": "python3 -c 'import sys; sys.stdout.write(chr(120) * 35000); sys.stderr.write(chr(121) * 35000)'"
}))
.await;
let sc = &resp["result"]["structuredContent"];
assert_eq!(
sc["output_truncated"], true,
"output_truncated should be true: {sc}"
);
let stdout = sc["stdout"].as_str().unwrap_or("");
let stderr = sc["stderr"].as_str().unwrap_or("");
assert!(
stdout.len() <= 30_000,
"stdout preview size {} exceeds 30k limit",
stdout.len()
);
assert!(
stderr.len() <= 10_000,
"stderr preview size {} exceeds 10k limit",
stderr.len()
);
let interleaved = sc["interleaved"].as_str().unwrap_or("");
assert!(
interleaved.len() <= 60 * 1024,
"interleaved should be <=60 KB, got {} bytes",
interleaved.len()
);
}
#[tokio::test]
async fn test_concurrent_interleaved_overflow() {
const N: usize = 4;
let analyzer = common::make_test_analyzer();
let (client, server) = tokio::io::duplex(65536);
let server_handle = tokio::spawn(async move {
let (server_rx, server_tx) = tokio::io::split(server);
if let Ok(service) = rmcp::serve_server(analyzer, (server_rx, server_tx)).await {
let _ = service.waiting().await;
}
});
let (client_rx, mut client_tx) = tokio::io::split(client);
let mut reader = tokio::io::BufReader::new(client_rx).lines();
let init = serde_json::json!({
"jsonrpc": "2.0",
"id": 1,
"method": "initialize",
"params": {
"protocolVersion": "2025-11-25",
"capabilities": {},
"clientInfo": {"name": "test-client", "version": "0.1.0"}
}
})
.to_string()
+ "\n";
client_tx
.write_all(init.as_bytes())
.await
.expect("write init");
client_tx.flush().await.expect("flush init");
let _resp = reader
.next_line()
.await
.expect("read init response")
.expect("init response");
let notif = serde_json::json!({
"jsonrpc": "2.0",
"method": "notifications/initialized",
"params": {}
})
.to_string()
+ "\n";
client_tx
.write_all(notif.as_bytes())
.await
.expect("write notif");
client_tx.flush().await.expect("flush notif");
let cmd = serde_json::json!({
"command": "python3 -c 'import sys; sys.stdout.write(chr(120) * 35000); sys.stderr.write(chr(121) * 35000)'"
});
for i in 0..N {
let call = serde_json::json!({
"jsonrpc": "2.0",
"id": (i + 2) as u64,
"method": "tools/call",
"params": {
"name": "exec_command",
"arguments": cmd
}
})
.to_string()
+ "\n";
client_tx
.write_all(call.as_bytes())
.await
.expect("write call");
}
client_tx.flush().await.expect("flush calls");
let mut responses = Vec::new();
for _ in 0..N {
let line = reader
.next_line()
.await
.expect("read response")
.expect("response");
let v: serde_json::Value = serde_json::from_str(&line).expect("valid JSON");
responses.push(v);
}
server_handle.abort();
for (i, resp) in responses.iter().enumerate() {
let sc = &resp["result"]["structuredContent"];
assert!(
sc["output_truncated"].as_bool().unwrap_or(false),
"task {i}: output_truncated should be true: {sc}"
);
}
assert_eq!(responses.len(), N, "all {N} tasks must produce responses");
}
#[tokio::test]
async fn exec_command_large_output_truncation_via_drain() {
let resp = call_exec_command_raw(serde_json::json!({
"command": "count=0; while [ $count -lt 200000 ]; do echo \"line $count\"; count=$((count + 1)); done",
"timeout_secs": 30
}))
.await;
assert!(
resp.get("error").is_none(),
"expected no error, got: {:?}",
resp.get("error")
);
let result = &resp["result"];
let sc = &result["structuredContent"];
let stdout = sc["stdout"].as_str().unwrap_or_default();
assert!(
sc["output_truncated"].as_bool().unwrap_or(false),
"output_truncated should be true for large output"
);
assert!(!stdout.is_empty(), "stdout should be non-empty");
assert!(
stdout.len() <= 30_000,
"stdout preview size {} exceeds 30k limit",
stdout.len()
);
}
#[tokio::test]
async fn exec_command_stderr_exceeds_budget_stdout_present() {
let resp = call_exec_command_raw(serde_json::json!({
"command": "for i in $(seq 1 1500); do echo >&2 \"error detail line $i\"; done; echo 'ok'",
"timeout_secs": 30
}))
.await;
assert!(
resp.get("error").is_none(),
"expected no error, got: {:?}",
resp.get("error")
);
let result = &resp["result"];
let sc = &result["structuredContent"];
let stdout = sc["stdout"].as_str().unwrap_or_default();
assert!(
stdout.contains("ok"),
"stdout should contain 'ok', got: {stdout}"
);
assert!(
sc["output_truncated"].as_bool().unwrap_or(false),
"output_truncated should be true when stderr exceeds budget"
);
}
#[tokio::test]
async fn exec_command_drain_budget_exhaustion() {
let resp = call_exec_command_raw(serde_json::json!({
"command": "seq 1 200000",
"timeout_secs": 30
}))
.await;
assert!(
resp.get("error").is_none(),
"expected no error, got: {:?}",
resp.get("error")
);
let result = &resp["result"];
let sc = &result["structuredContent"];
let stdout = sc["stdout"].as_str().unwrap_or_default();
assert!(
sc["output_truncated"].as_bool().unwrap_or(false),
"output_truncated should be true when drain budget exhausted"
);
assert!(
stdout.len() <= 30_000,
"stdout size {} exceeds 30k limit",
stdout.len()
);
assert!(!stdout.is_empty(), "stdout should be non-empty");
}