use std::collections::HashMap;
use std::io::Write as _;
use std::process::Command;
use std::time::Duration;
use serde::Deserialize;
use tracing::debug;
use crate::error::SkillError;
use crate::runtime::{SkillExecutor, SkillInput, SkillOutput};
#[derive(Debug, Deserialize)]
struct ExternalArgs {
command: String,
#[serde(default)]
args: Vec<String>,
#[serde(default)]
env: HashMap<String, String>,
#[serde(default)]
cwd: Option<String>,
#[serde(default)]
stdin: Option<String>,
}
#[derive(Debug)]
pub struct ExternalRuntime {
timeout: Duration,
#[cfg(feature = "sandboxed")]
registry: Option<std::sync::Arc<tokio::sync::RwLock<synwire_sandbox::ProcessRegistry>>>,
}
impl Default for ExternalRuntime {
fn default() -> Self {
Self {
timeout: Duration::from_secs(30),
#[cfg(feature = "sandboxed")]
registry: None,
}
}
}
impl ExternalRuntime {
pub fn new() -> Self {
Self::default()
}
pub const fn with_timeout_secs(timeout_secs: u64) -> Self {
Self {
timeout: Duration::from_secs(timeout_secs),
#[cfg(feature = "sandboxed")]
registry: None,
}
}
#[cfg(feature = "sandboxed")]
pub const fn with_registry(
registry: std::sync::Arc<tokio::sync::RwLock<synwire_sandbox::ProcessRegistry>>,
) -> Self {
Self {
timeout: Duration::from_secs(30),
registry: Some(registry),
}
}
#[cfg(feature = "sandboxed")]
pub const fn with_timeout_and_registry(
timeout_secs: u64,
registry: std::sync::Arc<tokio::sync::RwLock<synwire_sandbox::ProcessRegistry>>,
) -> Self {
Self {
timeout: Duration::from_secs(timeout_secs),
registry: Some(registry),
}
}
fn spawn_and_wait(
&self,
ext_args: &ExternalArgs,
) -> Result<(String, String, Option<i32>), SkillError> {
let mut cmd = Command::new(&ext_args.command);
let _ = cmd.args(&ext_args.args);
let _ = cmd.envs(&ext_args.env);
if let Some(ref cwd) = ext_args.cwd {
let _ = cmd.current_dir(cwd);
}
if ext_args.stdin.is_some() {
let _ = cmd.stdin(std::process::Stdio::piped());
}
let _ = cmd.stdout(std::process::Stdio::piped());
let _ = cmd.stderr(std::process::Stdio::piped());
let mut child = cmd.spawn()?;
#[cfg(feature = "sandboxed")]
self.register_process(&child, ext_args);
if let Some(ref stdin_data) = ext_args.stdin {
if let Some(ref mut stdin_handle) = child.stdin {
let _ = stdin_handle.write_all(stdin_data.as_bytes());
}
drop(child.stdin.take());
}
let deadline = std::time::Instant::now() + self.timeout;
#[allow(unused_variables)]
let pid = child.id();
let output = loop {
if let Some(status) = child.try_wait()? {
let mut stdout = String::new();
let mut stderr = String::new();
if let Some(ref mut out) = child.stdout {
let _ = std::io::Read::read_to_string(out, &mut stdout)?;
}
if let Some(ref mut err) = child.stderr {
let _ = std::io::Read::read_to_string(err, &mut stderr)?;
}
#[cfg(feature = "sandboxed")]
self.mark_exited(pid, status.code().unwrap_or(-1));
break (stdout, stderr, status.code());
} else if std::time::Instant::now() >= deadline {
let _ = child.kill();
let _ = child.wait();
#[cfg(feature = "sandboxed")]
self.mark_exited(pid, -1);
return Err(SkillError::Runtime {
runtime: "external".to_owned(),
message: format!(
"process '{}' timed out after {} seconds",
ext_args.command,
self.timeout.as_secs()
),
});
}
std::thread::sleep(Duration::from_millis(50));
};
Ok(output)
}
#[cfg(feature = "sandboxed")]
fn register_process(&self, child: &std::process::Child, ext_args: &ExternalArgs) {
if let Some(ref registry) = self.registry {
let record = synwire_sandbox::ProcessRecord::new(
child.id(),
&ext_args.command,
ext_args.args.clone(),
);
if let Ok(mut reg) = registry.try_write() {
if let Err(e) = reg.insert(record) {
tracing::warn!(
error = %e,
pid = child.id(),
"failed to register process in sandbox registry"
);
}
} else {
tracing::debug!(
pid = child.id(),
"could not acquire registry write lock; skipping registration"
);
}
}
}
#[cfg(feature = "sandboxed")]
fn mark_exited(&self, pid: u32, code: i32) {
if let Some(ref registry) = self.registry
&& let Ok(mut reg) = registry.try_write()
{
reg.mark_exited(pid, code);
}
}
}
impl SkillExecutor for ExternalRuntime {
fn execute(&self, input: SkillInput) -> Result<SkillOutput, SkillError> {
let ext_args: ExternalArgs = serde_json::from_value(input.args).map_err(|e| {
SkillError::InvalidManifest(format!("invalid external runtime args: {e}"))
})?;
debug!(
command = %ext_args.command,
args = ?ext_args.args,
timeout_secs = self.timeout.as_secs(),
"spawning external process"
);
let (stdout, stderr, exit_code) = self.spawn_and_wait(&ext_args)?;
Ok(SkillOutput {
result: serde_json::json!({
"stdout": stdout,
"stderr": stderr,
"exit_code": exit_code,
}),
})
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used)]
mod tests {
use super::*;
use crate::runtime::SkillInput;
#[test]
fn echo_captures_stdout() {
let runtime = ExternalRuntime::new();
let input = SkillInput {
args: serde_json::json!({
"command": "echo",
"args": ["hello"]
}),
};
let output = runtime.execute(input).expect("echo should succeed");
let stdout = output.result["stdout"]
.as_str()
.expect("stdout should be a string");
assert_eq!(stdout.trim(), "hello");
assert_eq!(output.result["exit_code"], 0);
}
#[test]
fn missing_command_field_returns_error() {
let runtime = ExternalRuntime::new();
let input = SkillInput {
args: serde_json::json!({}),
};
let err = runtime
.execute(input)
.expect_err("missing command should fail");
assert!(
matches!(err, SkillError::InvalidManifest(_)),
"expected InvalidManifest, got {err}"
);
}
#[test]
fn stdin_is_forwarded() {
let runtime = ExternalRuntime::new();
let input = SkillInput {
args: serde_json::json!({
"command": "cat",
"stdin": "piped data"
}),
};
let output = runtime.execute(input).expect("cat should succeed");
let stdout = output.result["stdout"]
.as_str()
.expect("stdout should be a string");
assert_eq!(stdout, "piped data");
}
#[test]
fn nonexistent_command_returns_io_error() {
let runtime = ExternalRuntime::new();
let input = SkillInput {
args: serde_json::json!({
"command": "/nonexistent/binary/xyz"
}),
};
let err = runtime
.execute(input)
.expect_err("nonexistent binary should fail");
assert!(
matches!(err, SkillError::Io(_)),
"expected Io error, got {err}"
);
}
#[cfg(feature = "sandboxed")]
#[test]
fn sandboxed_echo_registers_process() {
let registry = std::sync::Arc::new(tokio::sync::RwLock::new(
synwire_sandbox::ProcessRegistry::new(None),
));
let runtime = ExternalRuntime::with_registry(std::sync::Arc::clone(®istry));
let input = SkillInput {
args: serde_json::json!({
"command": "echo",
"args": ["tracked"]
}),
};
let output = runtime
.execute(input)
.expect("sandboxed echo should succeed");
let stdout = output.result["stdout"]
.as_str()
.expect("stdout should be a string");
assert_eq!(stdout.trim(), "tracked");
let reg = registry.try_read().expect("should acquire read lock");
assert!(reg.all().count() >= 1);
drop(reg);
}
}