//! Python subprocess agent runtime. //! //! When an agent manifest specifies `module = "python:path/to/script.py"`, //! the kernel delegates to this runtime instead of the LLM-based agent loop. //! //! Communication protocol (stdin/stdout JSON lines): //! //! **Input** (sent to Python script's stdin): //! ```json //! {"type": "message", "agent_id": "...", "message": "...", "context": {...}} //! ``` //! //! **Output** (read from Python script's stdout): //! ```json //! {"type": "response", "text": "...", "tool_calls": [...]} //! ``` //! //! The Python SDK (`openfang_sdk.py`) provides a helper to handle this protocol. use std::path::Path; use std::process::Stdio; use std::time::Duration; use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; use tokio::process::Command; use tracing::{debug, error, warn}; /// Error type for Python runtime operations. #[derive(Debug, thiserror::Error)] pub enum PythonError { #[error("Script not found: {0}")] ScriptNotFound(String), #[error("Python not found: {0}")] PythonNotFound(String), #[error("Spawn failed: {0}")] SpawnFailed(String), #[error("IO error: {0}")] Io(String), #[error("Timeout after {0}s")] Timeout(u64), #[error("Script error: {0}")] ScriptError(String), #[error("Invalid response: {0}")] InvalidResponse(String), } /// Result of running a Python agent script. #[derive(Debug, Clone)] pub struct PythonResult { /// The text response from the script. pub response: String, /// Exit code of the process. pub exit_code: Option, } /// Configuration for the Python runtime. #[derive(Debug, Clone)] pub struct PythonConfig { /// Path to the Python interpreter (default: "python3" or "python"). pub interpreter: String, /// Maximum execution time in seconds. pub timeout_secs: u64, /// Working directory for the script. pub working_dir: Option, /// Specific env vars to pass through (capability-gated, not secrets). pub allowed_env_vars: Vec, } impl Default for PythonConfig { fn default() -> Self { Self { interpreter: find_python_interpreter(), timeout_secs: 120, working_dir: None, allowed_env_vars: Vec::new(), } } } /// Validate that a Python script path is safe to execute. pub fn validate_script_path(path: &str) -> Result<(), PythonError> { let p = std::path::Path::new(path); for component in p.components() { if matches!(component, std::path::Component::ParentDir) { return Err(PythonError::ScriptNotFound(format!( "Path traversal denied: {path}" ))); } } match p.extension().and_then(|e| e.to_str()) { Some("py") => Ok(()), _ => Err(PythonError::ScriptNotFound(format!( "Script must be a .py file: {path}" ))), } } /// Find the Python interpreter on this system. fn find_python_interpreter() -> String { // Try python3 first, then python for cmd in &["python3", "python"] { if std::process::Command::new(cmd) .arg("--version") .stdout(Stdio::null()) .stderr(Stdio::null()) .status() .is_ok() { return cmd.to_string(); } } "python3".to_string() // default, will fail with helpful message } /// Extract the script path from a module string like "python:path/to/script.py". pub fn parse_python_module(module: &str) -> Option<&str> { module.strip_prefix("python:") } /// Run a Python agent script with the given message. /// /// Returns the script's text response. pub async fn run_python_agent( script_path: &str, agent_id: &str, message: &str, context: &serde_json::Value, config: &PythonConfig, ) -> Result { // SECURITY: Validate script path (no traversal, must be .py) validate_script_path(script_path)?; // Validate script exists if !Path::new(script_path).exists() { return Err(PythonError::ScriptNotFound(script_path.to_string())); } debug!("Running Python agent: {script_path}"); // Build the input JSON let input = serde_json::json!({ "type": "message", "agent_id": agent_id, "message": message, "context": context, }); let input_line = serde_json::to_string(&input).map_err(|e| PythonError::Io(e.to_string()))?; // Spawn the Python process let mut cmd = Command::new(&config.interpreter); cmd.arg(script_path) .stdin(Stdio::piped()) .stdout(Stdio::piped()) .stderr(Stdio::piped()); if let Some(ref wd) = config.working_dir { cmd.current_dir(wd); } // SECURITY: Wipe inherited environment. Prevents credential leakage. cmd.env_clear(); // Re-add ONLY safe, required vars cmd.env("OPENFANG_AGENT_ID", agent_id); cmd.env("OPENFANG_MESSAGE", message); // PATH — needed to find python stdlib / system tools if let Ok(path) = std::env::var("PATH") { cmd.env("PATH", path); } // HOME — needed for Python packages, pip cache if let Ok(home) = std::env::var("HOME") { cmd.env("HOME", home); } #[cfg(windows)] { for var in &[ "USERPROFILE", "SYSTEMROOT", "APPDATA", "LOCALAPPDATA", "COMSPEC", ] { if let Ok(val) = std::env::var(var) { cmd.env(var, val); } } } // Python-specific if let Ok(pp) = std::env::var("PYTHONPATH") { cmd.env("PYTHONPATH", pp); } if let Ok(venv) = std::env::var("VIRTUAL_ENV") { cmd.env("VIRTUAL_ENV", venv); } // Agent-specific allowed vars (from manifest capabilities) for var in &config.allowed_env_vars { if let Ok(val) = std::env::var(var) { cmd.env(var, val); } } let mut child = cmd.spawn().map_err(|e| { if e.kind() == std::io::ErrorKind::NotFound { PythonError::PythonNotFound(format!( "Python interpreter '{}' not found. Install Python 3 or set the interpreter path.", config.interpreter )) } else { PythonError::SpawnFailed(e.to_string()) } })?; // Write input to stdin if let Some(mut stdin) = child.stdin.take() { stdin .write_all(input_line.as_bytes()) .await .map_err(|e| PythonError::Io(e.to_string()))?; stdin .write_all(b"\n") .await .map_err(|e| PythonError::Io(e.to_string()))?; drop(stdin); // Close stdin to signal EOF } // Read output with timeout let timeout = Duration::from_secs(config.timeout_secs); let result = tokio::time::timeout(timeout, async { let stdout = child .stdout .take() .ok_or_else(|| PythonError::Io("Failed to capture stdout".to_string()))?; let stderr = child .stderr .take() .ok_or_else(|| PythonError::Io("Failed to capture stderr".to_string()))?; let mut stdout_reader = BufReader::new(stdout); let mut stderr_reader = BufReader::new(stderr); let mut stdout_lines = Vec::new(); let mut stderr_text = String::new(); // Read all stdout lines let mut line = String::new(); loop { line.clear(); match stdout_reader.read_line(&mut line).await { Ok(0) => break, Ok(_) => stdout_lines.push(line.trim_end().to_string()), Err(e) => { warn!("Python stdout read error: {e}"); break; } } } // Read stderr let mut stderr_line = String::new(); loop { stderr_line.clear(); match stderr_reader.read_line(&mut stderr_line).await { Ok(0) => break, Ok(_) => { stderr_text.push_str(&stderr_line); } Err(_) => break, } } let status = child .wait() .await .map_err(|e| PythonError::Io(e.to_string()))?; if !stderr_text.is_empty() { debug!("Python stderr: {stderr_text}"); } Ok::<(Vec, String, Option), PythonError>(( stdout_lines, stderr_text, status.code(), )) }) .await; match result { Ok(Ok((stdout_lines, stderr_text, exit_code))) => { if exit_code != Some(0) { return Err(PythonError::ScriptError(format!( "Script exited with code {:?}. Stderr: {}", exit_code, stderr_text.trim() ))); } // Try to parse the last JSON line as a response let response = parse_python_output(&stdout_lines)?; Ok(PythonResult { response, exit_code, }) } Ok(Err(e)) => Err(e), Err(_) => { // Timeout — kill the process let _ = child.kill().await; error!("Python script timed out after {}s", config.timeout_secs); Err(PythonError::Timeout(config.timeout_secs)) } } } /// Parse the output from a Python agent script. /// /// Looks for a JSON response line in the output. If found, extracts the "text" field. /// If no JSON response, returns all stdout as plain text. fn parse_python_output(lines: &[String]) -> Result { // Look for JSON response (last line that parses as JSON with "type":"response") for line in lines.iter().rev() { if let Ok(json) = serde_json::from_str::(line) { if json["type"].as_str() == Some("response") { if let Some(text) = json["text"].as_str() { return Ok(text.to_string()); } } } } // Fallback: return all stdout as plain text let text = lines.join("\n"); if text.is_empty() { return Err(PythonError::InvalidResponse( "Script produced no output".to_string(), )); } Ok(text) } /// Check if a module string refers to a Python script. pub fn is_python_module(module: &str) -> bool { module.starts_with("python:") } #[cfg(test)] mod tests { use super::*; #[test] fn test_parse_python_module() { assert_eq!( parse_python_module("python:scripts/agent.py"), Some("scripts/agent.py") ); assert_eq!( parse_python_module("python:./research.py"), Some("./research.py") ); assert_eq!(parse_python_module("builtin:chat"), None); assert_eq!(parse_python_module("wasm:skill.wasm"), None); } #[test] fn test_is_python_module() { assert!(is_python_module("python:test.py")); assert!(!is_python_module("builtin:chat")); assert!(!is_python_module("wasm:skill.wasm")); } #[test] fn test_parse_python_output_json() { let lines = vec![ "Loading model...".to_string(), r#"{"type": "response", "text": "Hello from Python!"}"#.to_string(), ]; let result = parse_python_output(&lines).unwrap(); assert_eq!(result, "Hello from Python!"); } #[test] fn test_parse_python_output_plain() { let lines = vec!["Hello from Python!".to_string(), "Line two".to_string()]; let result = parse_python_output(&lines).unwrap(); assert_eq!(result, "Hello from Python!\nLine two"); } #[test] fn test_parse_python_output_empty() { let lines: Vec = vec![]; let result = parse_python_output(&lines); assert!(result.is_err()); } #[test] fn test_python_config_default() { let config = PythonConfig::default(); assert!(config.interpreter == "python3" || config.interpreter == "python"); assert_eq!(config.timeout_secs, 120); assert!(config.allowed_env_vars.is_empty()); } #[test] fn test_validate_script_path() { assert!(validate_script_path("scripts/agent.py").is_ok()); assert!(validate_script_path("../../etc/passwd").is_err()); assert!(validate_script_path("agent.sh").is_err()); assert!(validate_script_path("/bin/bash").is_err()); assert!(validate_script_path("test.py").is_ok()); } #[tokio::test] async fn test_run_python_missing_script() { let config = PythonConfig::default(); let result = run_python_agent( "/nonexistent/script.py", "test-agent", "hello", &serde_json::json!({}), &config, ) .await; assert!(matches!(result, Err(PythonError::ScriptNotFound(_)))); } }