#![cfg(unix)]
use adk_core::{ReadonlyContext, Tool, ToolContext};
use adk_devtools::{DevToolset, Workspace};
use serde_json::{Value, json};
use std::sync::Arc;
use std::time::Duration;
mod common;
use common::TestCtx;
async fn run_bash(
workspace: Workspace,
command: &str,
timeout_secs: Option<u64>,
) -> adk_core::Result<Value> {
let toolset = DevToolset::new(workspace);
let readonly_ctx: Arc<dyn ReadonlyContext> = Arc::new(TestCtx);
let tools = adk_core::Toolset::tools(&toolset, readonly_ctx).await.unwrap();
let bash: &Arc<dyn Tool> =
tools.iter().find(|tool| tool.name() == "bash").expect("the toolset must expose bash");
let mut args = json!({ "command": command });
if let Some(secs) = timeout_secs {
args["timeout_secs"] = json!(secs);
}
let ctx: Arc<dyn ToolContext> = Arc::new(TestCtx);
bash.execute(ctx, args).await
}
fn temp_workspace() -> (tempfile::TempDir, Workspace) {
let dir = tempfile::tempdir().unwrap();
let workspace = Workspace::new(dir.path());
(dir, workspace)
}
#[tokio::test]
async fn a_command_cannot_read_an_inherited_secret() {
unsafe {
std::env::set_var("ADK_TEST_PROVIDER_KEY", "super-secret-value");
}
let (_dir, workspace) = temp_workspace();
let result = run_bash(workspace, "env", None).await.expect("env must run");
let stdout = result["stdout"].as_str().unwrap_or_default();
assert!(
!stdout.contains("super-secret-value"),
"a model-directed command read an inherited credential: {stdout}"
);
assert!(
!stdout.contains("ADK_TEST_PROVIDER_KEY"),
"the variable name leaked even without its value: {stdout}"
);
unsafe {
std::env::remove_var("ADK_TEST_PROVIDER_KEY");
}
}
#[tokio::test]
async fn allowlisted_variables_still_reach_the_command() {
let path = std::env::var("PATH").expect("the test environment must provide PATH");
let (_dir, workspace) = temp_workspace();
let result = run_bash(workspace, "printf '%s\\n' \"$PATH\"", None).await.expect("must run");
assert_eq!(result["exit_code"], json!(0));
assert_eq!(result["stdout"], json!(format!("{path}\n")));
}
#[tokio::test]
async fn inheriting_the_environment_is_available_but_opt_in() {
unsafe {
std::env::set_var("ADK_TEST_OPT_IN", "visible");
}
let dir = tempfile::tempdir().unwrap();
let workspace = Workspace::new(dir.path()).inherit_env(true);
let result = run_bash(workspace, "echo \"v=$ADK_TEST_OPT_IN\"", None).await.expect("must run");
assert!(
result["stdout"].as_str().unwrap_or_default().contains("v=visible"),
"opting in must actually pass the environment through"
);
unsafe {
std::env::remove_var("ADK_TEST_OPT_IN");
}
}
#[tokio::test]
async fn a_timeout_kills_processes_the_command_started() {
let dir = tempfile::tempdir().unwrap();
let marker = dir.path().join("grandchild.pid");
let workspace = Workspace::new(dir.path());
let command = format!("(sleep 30 & echo $! > {}) ; sleep 30", marker.to_string_lossy());
let result = run_bash(workspace, &command, Some(1)).await;
assert!(result.is_err(), "the command must time out");
tokio::time::sleep(Duration::from_millis(300)).await;
let pid: i32 = std::fs::read_to_string(&marker)
.expect("the background process must have recorded its pid")
.trim()
.parse()
.expect("a numeric pid");
let alive = unsafe { libc::kill(pid, 0) } == 0;
assert!(!alive, "a descendant (pid {pid}) survived the timeout");
}
#[tokio::test]
async fn a_command_that_finishes_is_unaffected() {
let (_dir, workspace) = temp_workspace();
let result = run_bash(workspace, "echo hello", None).await.expect("must run");
assert_eq!(result["exit_code"], json!(0));
assert!(result["stdout"].as_str().unwrap_or_default().contains("hello"));
}
const TRUNCATION_MARKER: &str = "\n…[truncated]";
#[tokio::test]
async fn a_cap_inside_a_multi_byte_character_does_not_panic() {
let dir = tempfile::tempdir().unwrap();
let workspace = Workspace::new(dir.path()).max_output_bytes(4);
let result = run_bash(workspace, "printf '€€€'", None).await.expect("must run");
assert_eq!(result["stdout"], json!(format!("€{TRUNCATION_MARKER}")));
assert_eq!(result["truncated"], json!(true));
assert_eq!(result["exit_code"], json!(0));
}
#[tokio::test]
async fn more_than_a_mebibyte_of_multi_byte_output_is_capped() {
let (_dir, workspace) = temp_workspace();
let cap = workspace.max_output();
let command = "yes '€€€€€€€€€€€€€€€€€€€€' | head -c 1500000";
let result = run_bash(workspace, command, None).await.expect("must run");
let stdout = result["stdout"].as_str().expect("stdout must be a string");
let kept = stdout.strip_suffix(TRUNCATION_MARKER).expect("the output must be marked truncated");
assert_eq!(result["truncated"], json!(true));
assert!(kept.len() <= cap, "kept {} bytes over a {cap}-byte cap", kept.len());
assert!(kept.len() > cap - 4, "the cap discarded more than a partial character");
assert!(
kept.chars().all(|c| c == '€' || c == '\n'),
"the cut produced a replacement or partial character"
);
assert_eq!(result["exit_code"], json!(0));
}
fn child_has_exited(pid: i32) -> bool {
let reaped = unsafe { libc::waitpid(pid, std::ptr::null_mut(), libc::WNOHANG) };
reaped == pid || reaped == -1
}
#[tokio::test]
async fn dropping_the_call_kills_the_shell_and_its_children() {
let dir = tempfile::tempdir().unwrap();
let shell_marker = dir.path().join("shell.pid");
let child_marker = dir.path().join("child.pid");
let workspace = Workspace::new(dir.path());
let command = format!(
"echo $$ > {}; sleep 30 & echo $! > {}; wait",
shell_marker.to_string_lossy(),
child_marker.to_string_lossy()
);
let call = run_bash(workspace, &command, Some(60));
let outcome = tokio::time::timeout(Duration::from_secs(1), call).await;
assert!(outcome.is_err(), "the command must still be running when the call is dropped");
tokio::time::sleep(Duration::from_millis(300)).await;
let read_pid = |path: &std::path::Path| -> i32 {
std::fs::read_to_string(path)
.expect("the command must have recorded its pid")
.trim()
.parse()
.expect("a numeric pid")
};
let shell = read_pid(&shell_marker);
let child = read_pid(&child_marker);
assert!(child_has_exited(shell), "the shell (pid {shell}) survived the dropped call");
let child_alive = unsafe { libc::kill(child, 0) } == 0;
assert!(!child_alive, "a descendant (pid {child}) survived the dropped call");
}