use std::path::PathBuf;
use std::time::Duration;
use crate::core::error::AgnosaiError;
use tokio::io::AsyncWriteExt;
use tokio::process::Command;
#[derive(Debug)]
#[non_exhaustive]
pub struct PythonResult {
pub stdout: String,
pub stderr: String,
pub exit_code: i32,
pub timed_out: bool,
}
pub struct PythonSandbox {
python_path: PathBuf,
timeout: Duration,
work_dir: Option<PathBuf>,
}
impl PythonSandbox {
pub fn new() -> Self {
Self {
python_path: PathBuf::from("python3"),
timeout: Duration::from_secs(30),
work_dir: None,
}
}
pub fn with_config(python_path: PathBuf, timeout: Duration) -> Self {
Self {
python_path,
timeout,
work_dir: None,
}
}
pub fn with_work_dir(mut self, dir: PathBuf) -> Self {
self.work_dir = Some(dir);
self
}
#[tracing::instrument(skip_all)]
pub async fn execute_script(
&self,
script: &str,
input: &str,
) -> crate::core::Result<PythonResult> {
use std::process::Stdio;
let mut cmd = Command::new(&self.python_path);
cmd.arg("-c")
.arg(script)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.kill_on_drop(true);
for var in crate::sandbox::SANITIZED_ENV_VARS {
cmd.env_remove(var);
}
if let Some(ref dir) = self.work_dir {
cmd.current_dir(dir);
}
let mut child = cmd
.spawn()
.map_err(|e| AgnosaiError::Sandbox(format!("failed to spawn python process: {e}")))?;
if let Some(mut stdin) = child.stdin.take() {
stdin.write_all(input.as_bytes()).await.map_err(|e| {
AgnosaiError::Sandbox(format!("failed to write to python stdin: {e}"))
})?;
}
match tokio::time::timeout(self.timeout, child.wait_with_output()).await {
Ok(Ok(output)) => {
let exit_code = output.status.code().unwrap_or(-1);
Ok(PythonResult {
stdout: String::from_utf8_lossy(&output.stdout).into_owned(),
stderr: String::from_utf8_lossy(&output.stderr).into_owned(),
exit_code,
timed_out: false,
})
}
Ok(Err(e)) => Err(AgnosaiError::Sandbox(format!(
"python process I/O error: {e}"
))),
Err(_) => {
Ok(PythonResult {
stdout: String::new(),
stderr: String::new(),
exit_code: -1,
timed_out: true,
})
}
}
}
pub async fn execute_tool(
&self,
tool_source: &str,
tool_name: &str,
parameters: &serde_json::Value,
) -> crate::core::Result<PythonResult> {
let wrapper_script = r#"
import sys, json, traceback
input_data = json.loads(sys.stdin.read())
tool_source = input_data["__tool_source__"]
try:
exec(tool_source)
tool = create_tool()
result = tool.execute(input_data["parameters"])
print(json.dumps({"result": result, "success": True, "error": None}))
except Exception as e:
print(json.dumps({"result": None, "success": False, "error": str(e)}))
sys.exit(1)
"#;
let input = serde_json::json!({
"__tool_source__": tool_source,
"tool_name": tool_name,
"parameters": parameters,
});
let input_str = serde_json::to_string(&input)?;
self.execute_script(wrapper_script, &input_str).await
}
}
impl Default for PythonSandbox {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
mod tests {
use super::*;
async fn python3_available() -> bool {
Command::new("python3")
.arg("--version")
.output()
.await
.is_ok()
}
#[tokio::test]
async fn test_echo_script() {
if !python3_available().await {
eprintln!("python3 not found, skipping test");
return;
}
let sandbox = PythonSandbox::new();
let result = sandbox
.execute_script("print(input())", "hello world")
.await
.expect("execute_script should succeed");
assert_eq!(result.exit_code, 0);
assert!(!result.timed_out);
assert_eq!(result.stdout.trim(), "hello world");
}
#[tokio::test]
async fn test_json_roundtrip() {
if !python3_available().await {
eprintln!("python3 not found, skipping test");
return;
}
let sandbox = PythonSandbox::new();
let script = r#"
import sys, json
data = json.loads(sys.stdin.read())
data["processed"] = True
print(json.dumps(data))
"#;
let input = serde_json::json!({"key": "value", "count": 42});
let input_str = serde_json::to_string(&input).unwrap();
let result = sandbox
.execute_script(script, &input_str)
.await
.expect("execute_script should succeed");
assert_eq!(result.exit_code, 0);
assert!(!result.timed_out);
let output: serde_json::Value =
serde_json::from_str(result.stdout.trim()).expect("should be valid JSON");
assert_eq!(output["key"], "value");
assert_eq!(output["count"], 42);
assert_eq!(output["processed"], true);
}
#[tokio::test]
async fn test_timeout() {
if !python3_available().await {
eprintln!("python3 not found, skipping test");
return;
}
let sandbox = PythonSandbox::with_config(PathBuf::from("python3"), Duration::from_secs(1));
let result = sandbox
.execute_script("import time; time.sleep(10)", "")
.await
.expect("execute_script should return Ok even on timeout");
assert!(result.timed_out);
assert_eq!(result.exit_code, -1);
}
#[tokio::test]
async fn test_nonzero_exit_code() {
if !python3_available().await {
eprintln!("python3 not found, skipping test");
return;
}
let sandbox = PythonSandbox::new();
let result = sandbox
.execute_script(
"import sys; print('error output', file=sys.stderr); sys.exit(42)",
"",
)
.await
.expect("execute_script should succeed even with non-zero exit");
assert_eq!(result.exit_code, 42);
assert!(!result.timed_out);
assert!(result.stderr.contains("error output"));
}
#[tokio::test]
async fn test_execute_tool() {
if !python3_available().await {
eprintln!("python3 not found, skipping test");
return;
}
let sandbox = PythonSandbox::new();
let tool_source = r#"
class MyTool:
def execute(self, params):
return {"doubled": params["value"] * 2}
def create_tool():
return MyTool()
"#;
let params = serde_json::json!({"value": 21});
let result = sandbox
.execute_tool(tool_source, "my_tool", ¶ms)
.await
.expect("execute_tool should succeed");
assert_eq!(result.exit_code, 0);
assert!(!result.timed_out);
let output: serde_json::Value =
serde_json::from_str(result.stdout.trim()).expect("should be valid JSON");
assert_eq!(output["success"], true);
assert_eq!(output["result"]["doubled"], 42);
assert!(output["error"].is_null());
}
}