Files
openfang/crates/openfang-runtime/src/python_runtime.rs
T
jaberjaber23 5692c96494 Initial commit — OpenFang Agent Operating System
Open-source Agent OS built in Rust.

- 14 crates, 1,767+ tests, zero clippy warnings
- 7 autonomous Hands (Clip, Lead, Collector, Predictor, Researcher, Twitter, Browser)
- 16 security systems (WASM sandbox, Merkle audit trail, taint tracking, Ed25519 signing, SSRF protection, secret zeroization, HMAC-SHA256 mutual auth, and more)
- 30 pre-built agents across 4 performance tiers
- 40 channel adapters (Telegram, Discord, Slack, WhatsApp, Teams, and 35 more)
- 38 built-in tools + MCP client/server + A2A protocol
- 26 LLM providers with intelligent routing and cost tracking
- 60+ bundled skills with FangHub marketplace
- Tauri 2.0 native desktop app
- 140+ REST/WS/SSE API endpoints with Alpine.js dashboard
- OpenAI-compatible /v1/chat/completions endpoint
- One-command install, production-ready
2026-02-26 01:00:27 +03:00

426 lines
13 KiB
Rust

//! 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<i32>,
}
/// 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<String>,
/// Specific env vars to pass through (capability-gated, not secrets).
pub allowed_env_vars: Vec<String>,
}
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<PythonResult, PythonError> {
// 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>, String, Option<i32>), 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<String, PythonError> {
// 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::<serde_json::Value>(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<String> = 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(_))));
}
}