feat(stardomain): 实现完整的沙箱执行引擎(local + Docker 双模式)
local 模式通过 tokio::process 真实执行命令,Docker 模式通过 bollard 创建容器运行, 两种模式均支持超时强杀、输出捕获和策略过滤。同时提供同步和异步 Python 接口。 Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Generated
+1
@@ -1081,6 +1081,7 @@ dependencies = [
|
|||||||
"anyhow",
|
"anyhow",
|
||||||
"bollard",
|
"bollard",
|
||||||
"clap",
|
"clap",
|
||||||
|
"futures-util",
|
||||||
"pyo3",
|
"pyo3",
|
||||||
"pyo3-async-runtimes",
|
"pyo3-async-runtimes",
|
||||||
"regex",
|
"regex",
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ pyo3 = { version = "0.28", features = ["extension-module"] }
|
|||||||
pyo3-async-runtimes = { version = "0.28", features = ["tokio-runtime"] }
|
pyo3-async-runtimes = { version = "0.28", features = ["tokio-runtime"] }
|
||||||
tokio = { version = "1", features = ["rt", "rt-multi-thread", "macros", "process", "time"] }
|
tokio = { version = "1", features = ["rt", "rt-multi-thread", "macros", "process", "time"] }
|
||||||
bollard = "0.18"
|
bollard = "0.18"
|
||||||
|
futures-util = "0.3"
|
||||||
serde = { version = "1.0", features = ["derive"] }
|
serde = { version = "1.0", features = ["derive"] }
|
||||||
serde_json = "1.0"
|
serde_json = "1.0"
|
||||||
serde_yaml = "0.9"
|
serde_yaml = "0.9"
|
||||||
|
|||||||
@@ -8,25 +8,34 @@
|
|||||||
* http://www.apache.org/licenses/LICENSE-2.0
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
*/
|
*/
|
||||||
|
|
||||||
/// Docker integration layer.
|
use std::time::Duration;
|
||||||
/// Uses bollard to manage container lifecycle for sandbox execution.
|
use bollard::Docker;
|
||||||
|
use bollard::container::{
|
||||||
|
Config, CreateContainerOptions, StartContainerOptions,
|
||||||
|
WaitContainerOptions, LogsOptions, RemoveContainerOptions,
|
||||||
|
};
|
||||||
|
use bollard::models::HostConfig;
|
||||||
|
use futures_util::StreamExt;
|
||||||
|
use tokio::time::timeout;
|
||||||
|
|
||||||
|
use crate::sandbox::SandboxResult;
|
||||||
|
|
||||||
pub struct DockerBackend {
|
pub struct DockerBackend {
|
||||||
pub image: String,
|
pub image: String,
|
||||||
pub network_disabled: bool,
|
pub network_disabled: bool,
|
||||||
pub memory_limit: u64,
|
pub memory_limit: i64,
|
||||||
pub cpu_period: u64,
|
pub cpu_period: i64,
|
||||||
pub cpu_quota: u64,
|
pub cpu_quota: i64,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for DockerBackend {
|
impl Default for DockerBackend {
|
||||||
fn default() -> Self {
|
fn default() -> Self {
|
||||||
DockerBackend {
|
DockerBackend {
|
||||||
image: "stardomain-runtime:latest".to_string(),
|
image: "python:3.13-slim".to_string(),
|
||||||
network_disabled: false,
|
network_disabled: false,
|
||||||
memory_limit: 512 * 1024 * 1024, // 512MB
|
memory_limit: 512 * 1024 * 1024,
|
||||||
cpu_period: 100_000,
|
cpu_period: 100_000,
|
||||||
cpu_quota: 50_000, // 50% of one core
|
cpu_quota: 50_000,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -41,4 +50,162 @@ impl DockerBackend {
|
|||||||
}
|
}
|
||||||
backend
|
backend
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn run_command(
|
||||||
|
&self,
|
||||||
|
command: &str,
|
||||||
|
workspace: &str,
|
||||||
|
timeout_secs: u64,
|
||||||
|
) -> SandboxResult {
|
||||||
|
let docker = match Docker::connect_with_local_defaults() {
|
||||||
|
Ok(d) => d,
|
||||||
|
Err(e) => return SandboxResult::error(&format!("Docker 连接失败: {}", e)),
|
||||||
|
};
|
||||||
|
|
||||||
|
let container_name = format!("stardomain_{}", uuid_short());
|
||||||
|
|
||||||
|
let host_config = HostConfig {
|
||||||
|
memory: Some(self.memory_limit),
|
||||||
|
cpu_period: Some(self.cpu_period),
|
||||||
|
cpu_quota: Some(self.cpu_quota),
|
||||||
|
network_mode: if self.network_disabled {
|
||||||
|
Some("none".to_string())
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
},
|
||||||
|
binds: Some(vec![format!("{}:/workspace", workspace)]),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
|
||||||
|
let config = Config {
|
||||||
|
image: Some(self.image.clone()),
|
||||||
|
cmd: Some(vec![
|
||||||
|
"sh".to_string(),
|
||||||
|
"-c".to_string(),
|
||||||
|
command.to_string(),
|
||||||
|
]),
|
||||||
|
working_dir: Some("/workspace".to_string()),
|
||||||
|
host_config: Some(host_config),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
|
||||||
|
let create_opts = CreateContainerOptions {
|
||||||
|
name: &container_name,
|
||||||
|
platform: None,
|
||||||
|
};
|
||||||
|
if let Err(e) = docker.create_container(Some(create_opts), config).await {
|
||||||
|
return SandboxResult::error(&format!("创建容器失败: {}", e));
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Err(e) = docker
|
||||||
|
.start_container(
|
||||||
|
&container_name,
|
||||||
|
None::<StartContainerOptions<String>>,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
let _ = cleanup(&docker, &container_name).await;
|
||||||
|
return SandboxResult::error(&format!("启动容器失败: {}", e));
|
||||||
|
}
|
||||||
|
|
||||||
|
let wait_fut = wait_for_container(&docker, &container_name);
|
||||||
|
let result = match timeout(Duration::from_secs(timeout_secs), wait_fut).await
|
||||||
|
{
|
||||||
|
Ok(r) => r,
|
||||||
|
Err(_) => {
|
||||||
|
let _ = docker
|
||||||
|
.kill_container(
|
||||||
|
&container_name,
|
||||||
|
None::<bollard::container::KillContainerOptions<String>>,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
let logs = collect_logs(&docker, &container_name).await;
|
||||||
|
let _ = cleanup(&docker, &container_name).await;
|
||||||
|
return SandboxResult {
|
||||||
|
stdout: logs.0,
|
||||||
|
stderr: logs.1,
|
||||||
|
exit_code: -1,
|
||||||
|
killed_by_timeout: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let logs = collect_logs(&docker, &container_name).await;
|
||||||
|
let _ = cleanup(&docker, &container_name).await;
|
||||||
|
|
||||||
|
SandboxResult {
|
||||||
|
stdout: logs.0,
|
||||||
|
stderr: logs.1,
|
||||||
|
exit_code: result,
|
||||||
|
killed_by_timeout: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn run_python(
|
||||||
|
&self,
|
||||||
|
code: &str,
|
||||||
|
workspace: &str,
|
||||||
|
timeout_secs: u64,
|
||||||
|
) -> SandboxResult {
|
||||||
|
let escaped = code.replace('\'', "'\\''");
|
||||||
|
let cmd = format!("python3 -c '{}'", escaped);
|
||||||
|
self.run_command(&cmd, workspace, timeout_secs).await
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn wait_for_container(docker: &Docker, name: &str) -> i32 {
|
||||||
|
let opts = WaitContainerOptions { condition: "not-running" };
|
||||||
|
let mut stream = docker.wait_container(name, Some(opts));
|
||||||
|
if let Some(Ok(response)) = stream.next().await {
|
||||||
|
response.status_code as i32
|
||||||
|
} else {
|
||||||
|
-1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn collect_logs(docker: &Docker, name: &str) -> (String, String) {
|
||||||
|
let opts = LogsOptions::<String> {
|
||||||
|
stdout: true,
|
||||||
|
stderr: true,
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
let mut stream = docker.logs(name, Some(opts));
|
||||||
|
let mut stdout = String::new();
|
||||||
|
let mut stderr = String::new();
|
||||||
|
|
||||||
|
while let Some(Ok(output)) = stream.next().await {
|
||||||
|
match output {
|
||||||
|
bollard::container::LogOutput::StdOut { message } => {
|
||||||
|
stdout.push_str(&String::from_utf8_lossy(&message));
|
||||||
|
}
|
||||||
|
bollard::container::LogOutput::StdErr { message } => {
|
||||||
|
stderr.push_str(&String::from_utf8_lossy(&message));
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
(stdout, stderr)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn cleanup(
|
||||||
|
docker: &Docker,
|
||||||
|
name: &str,
|
||||||
|
) -> Result<(), bollard::errors::Error> {
|
||||||
|
docker
|
||||||
|
.remove_container(
|
||||||
|
name,
|
||||||
|
Some(RemoveContainerOptions {
|
||||||
|
force: true,
|
||||||
|
..Default::default()
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
fn uuid_short() -> String {
|
||||||
|
use std::time::{SystemTime, UNIX_EPOCH};
|
||||||
|
let t = SystemTime::now()
|
||||||
|
.duration_since(UNIX_EPOCH)
|
||||||
|
.unwrap_or_default();
|
||||||
|
format!("{:x}{:x}", t.as_secs(), t.subsec_nanos())
|
||||||
|
}
|
||||||
@@ -19,14 +19,25 @@ pub mod policy;
|
|||||||
pub mod sandbox;
|
pub mod sandbox;
|
||||||
|
|
||||||
use pyo3::prelude::*;
|
use pyo3::prelude::*;
|
||||||
|
|
||||||
use sandbox::SandboxResult;
|
use sandbox::SandboxResult;
|
||||||
|
|
||||||
|
fn get_runtime() -> &'static tokio::runtime::Runtime {
|
||||||
|
use std::sync::OnceLock;
|
||||||
|
static RT: OnceLock<tokio::runtime::Runtime> = OnceLock::new();
|
||||||
|
RT.get_or_init(|| {
|
||||||
|
tokio::runtime::Builder::new_multi_thread()
|
||||||
|
.enable_all()
|
||||||
|
.build()
|
||||||
|
.expect("Failed to create tokio runtime")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
#[pyclass]
|
#[pyclass]
|
||||||
struct Sandbox {
|
struct Sandbox {
|
||||||
mode: String,
|
mode: String,
|
||||||
workspace: String,
|
workspace: String,
|
||||||
timeout: u64,
|
timeout: u64,
|
||||||
|
#[allow(dead_code)]
|
||||||
memory_limit_mb: u64,
|
memory_limit_mb: u64,
|
||||||
policy_name: String,
|
policy_name: String,
|
||||||
}
|
}
|
||||||
@@ -34,26 +45,102 @@ struct Sandbox {
|
|||||||
#[pymethods]
|
#[pymethods]
|
||||||
impl Sandbox {
|
impl Sandbox {
|
||||||
#[new]
|
#[new]
|
||||||
#[pyo3(signature = (mode="sandbox", workspace="/tmp/stardomain_ws", timeout=30, memory_limit_mb=512, policy="agent_exec"))]
|
#[pyo3(signature = (mode="local", workspace="/tmp/stardomain_ws", timeout=30, memory_limit_mb=512, policy="agent_exec"))]
|
||||||
fn new(mode: &str, workspace: &str, timeout: u64, memory_limit_mb: u64, policy: &str) -> Self {
|
fn new(mode: &str, workspace: &str, timeout: u64, memory_limit_mb: u64, policy: &str) -> PyResult<Self> {
|
||||||
Sandbox {
|
std::fs::create_dir_all(workspace).map_err(|e| {
|
||||||
|
PyErr::new::<pyo3::exceptions::PyIOError, _>(format!("无法创建工作目录: {}", e))
|
||||||
|
})?;
|
||||||
|
Ok(Sandbox {
|
||||||
mode: mode.to_string(),
|
mode: mode.to_string(),
|
||||||
workspace: workspace.to_string(),
|
workspace: workspace.to_string(),
|
||||||
timeout,
|
timeout,
|
||||||
memory_limit_mb,
|
memory_limit_mb,
|
||||||
policy_name: policy.to_string(),
|
policy_name: policy.to_string(),
|
||||||
}
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
fn run_command(&self, command: &str) -> PyResult<SandboxResult> {
|
fn run_command(&self, command: &str) -> PyResult<SandboxResult> {
|
||||||
let policy = policy::get_policy(&self.policy_name);
|
let pol = policy::get_policy(&self.policy_name);
|
||||||
policy::filter::validate_command(command, &policy)?;
|
policy::filter::validate_command(command, &pol)?;
|
||||||
Ok(SandboxResult::stub(command))
|
|
||||||
|
let rt = get_runtime();
|
||||||
|
match self.mode.as_str() {
|
||||||
|
"sandbox" | "docker" => {
|
||||||
|
let backend = docker::DockerBackend::with_policy(&self.policy_name);
|
||||||
|
Ok(rt.block_on(backend.run_command(command, &self.workspace, self.timeout)))
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
Ok(rt.block_on(sandbox::executor::run_local(command, &self.workspace, self.timeout)))
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn run_python(&self, code: &str) -> PyResult<SandboxResult> {
|
fn run_python(&self, code: &str) -> PyResult<SandboxResult> {
|
||||||
let policy = policy::get_policy(&self.policy_name);
|
let pol = policy::get_policy(&self.policy_name);
|
||||||
policy::filter::validate_python(code, &policy)?;
|
policy::filter::validate_python(code, &pol)?;
|
||||||
Ok(SandboxResult::stub(code))
|
|
||||||
|
let rt = get_runtime();
|
||||||
|
match self.mode.as_str() {
|
||||||
|
"sandbox" | "docker" => {
|
||||||
|
let backend = docker::DockerBackend::with_policy(&self.policy_name);
|
||||||
|
Ok(rt.block_on(backend.run_python(code, &self.workspace, self.timeout)))
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
Ok(rt.block_on(sandbox::executor::run_python_local(code, &self.workspace, self.timeout)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn run_command_async<'py>(&self, py: Python<'py>, command: String) -> PyResult<Bound<'py, PyAny>> {
|
||||||
|
let pol = policy::get_policy(&self.policy_name);
|
||||||
|
policy::filter::validate_command(&command, &pol)?;
|
||||||
|
|
||||||
|
let mode = self.mode.clone();
|
||||||
|
let workspace = self.workspace.clone();
|
||||||
|
let timeout = self.timeout;
|
||||||
|
let policy_name = self.policy_name.clone();
|
||||||
|
|
||||||
|
pyo3_async_runtimes::tokio::future_into_py(py, async move {
|
||||||
|
let result = match mode.as_str() {
|
||||||
|
"sandbox" | "docker" => {
|
||||||
|
let backend = docker::DockerBackend::with_policy(&policy_name);
|
||||||
|
backend.run_command(&command, &workspace, timeout).await
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
sandbox::executor::run_local(&command, &workspace, timeout).await
|
||||||
|
}
|
||||||
|
};
|
||||||
|
Ok(result)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn run_python_async<'py>(&self, py: Python<'py>, code: String) -> PyResult<Bound<'py, PyAny>> {
|
||||||
|
let pol = policy::get_policy(&self.policy_name);
|
||||||
|
policy::filter::validate_python(&code, &pol)?;
|
||||||
|
|
||||||
|
let mode = self.mode.clone();
|
||||||
|
let workspace = self.workspace.clone();
|
||||||
|
let timeout = self.timeout;
|
||||||
|
let policy_name = self.policy_name.clone();
|
||||||
|
|
||||||
|
pyo3_async_runtimes::tokio::future_into_py(py, async move {
|
||||||
|
let result = match mode.as_str() {
|
||||||
|
"sandbox" | "docker" => {
|
||||||
|
let backend = docker::DockerBackend::with_policy(&policy_name);
|
||||||
|
backend.run_python(&code, &workspace, timeout).await
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
sandbox::executor::run_python_local(&code, &workspace, timeout).await
|
||||||
|
}
|
||||||
|
};
|
||||||
|
Ok(result)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[pymodule]
|
||||||
|
fn stardomain(m: &Bound<'_, PyModule>) -> PyResult<()> {
|
||||||
|
m.add_class::<Sandbox>()?;
|
||||||
|
m.add_class::<SandboxResult>()?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|||||||
@@ -8,16 +8,54 @@
|
|||||||
* http://www.apache.org/licenses/LICENSE-2.0
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
*/
|
*/
|
||||||
|
|
||||||
/// Executor: responsible for running commands either locally or in Docker.
|
use std::time::Duration;
|
||||||
/// This is a stub — actual Docker execution will be implemented later.
|
use tokio::process::Command;
|
||||||
pub struct Executor;
|
use tokio::time::timeout;
|
||||||
|
|
||||||
impl Executor {
|
use crate::sandbox::SandboxResult;
|
||||||
pub fn run_local(_command: &str) -> (String, String, i32) {
|
|
||||||
("".to_string(), "".to_string(), 0)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn run_docker(_command: &str) -> (String, String, i32) {
|
pub async fn run_local(
|
||||||
("".to_string(), "[stardomain] Docker execution not yet implemented".to_string(), 1)
|
command: &str,
|
||||||
|
workspace: &str,
|
||||||
|
timeout_secs: u64,
|
||||||
|
) -> SandboxResult {
|
||||||
|
let fut = Command::new("sh")
|
||||||
|
.arg("-c")
|
||||||
|
.arg(command)
|
||||||
|
.current_dir(workspace)
|
||||||
|
.output();
|
||||||
|
|
||||||
|
match timeout(Duration::from_secs(timeout_secs), fut).await {
|
||||||
|
Ok(Ok(output)) => SandboxResult {
|
||||||
|
stdout: String::from_utf8_lossy(&output.stdout).to_string(),
|
||||||
|
stderr: String::from_utf8_lossy(&output.stderr).to_string(),
|
||||||
|
exit_code: output.status.code().unwrap_or(-1),
|
||||||
|
killed_by_timeout: false,
|
||||||
|
},
|
||||||
|
Ok(Err(e)) => SandboxResult {
|
||||||
|
stdout: String::new(),
|
||||||
|
stderr: format!("执行失败: {}", e),
|
||||||
|
exit_code: -1,
|
||||||
|
killed_by_timeout: false,
|
||||||
|
},
|
||||||
|
Err(_) => SandboxResult {
|
||||||
|
stdout: String::new(),
|
||||||
|
stderr: "执行超时".to_string(),
|
||||||
|
exit_code: -1,
|
||||||
|
killed_by_timeout: true,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn run_python_local(
|
||||||
|
code: &str,
|
||||||
|
workspace: &str,
|
||||||
|
timeout_secs: u64,
|
||||||
|
) -> SandboxResult {
|
||||||
|
let cmd = format!("python3 -c {}", shell_escape(code));
|
||||||
|
run_local(&cmd, workspace, timeout_secs).await
|
||||||
|
}
|
||||||
|
|
||||||
|
fn shell_escape(s: &str) -> String {
|
||||||
|
format!("'{}'", s.replace('\'', "'\\''"))
|
||||||
|
}
|
||||||
|
|||||||
@@ -35,4 +35,13 @@ impl SandboxResult {
|
|||||||
killed_by_timeout: false,
|
killed_by_timeout: false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn error(msg: &str) -> Self {
|
||||||
|
SandboxResult {
|
||||||
|
stdout: String::new(),
|
||||||
|
stderr: msg.to_string(),
|
||||||
|
exit_code: -1,
|
||||||
|
killed_by_timeout: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user