feat(sandbox): pluggable SandboxBackend + Alibaba OpenSandbox adapter (#645)
This commit is contained in:
@@ -90,6 +90,34 @@ allow_shell = true
|
||||
approval_policy = "on-request" # on-request | untrusted | never
|
||||
sandbox_mode = "workspace-write" # read-only | workspace-write | danger-full-access | external-sandbox
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────────
|
||||
# External Sandbox Backend (pluggable remote execution)
|
||||
# ─────────────────────────────────────────────────────────────────────────────────
|
||||
# When sandbox_backend is set to "opensandbox", all exec_shell calls are
|
||||
# routed through an external OpenSandbox-compatible HTTP API instead of
|
||||
# spawning a local process. The backend sends `POST {sandbox_url}/v1/sandbox/run`
|
||||
# with `{"cmd": "...", "env": {...}}` and expects
|
||||
# `{"stdout": "...", "stderr": "...", "exit_code": 0}`.
|
||||
#
|
||||
# sandbox_backend = "none" # "none" (default) or "opensandbox"
|
||||
# sandbox_url = "http://localhost:8080" # OpenSandbox-compatible API base URL
|
||||
# sandbox_api_key = "YOUR_API_KEY" # Optional Bearer token sent with requests
|
||||
#
|
||||
# Env-var overrides:
|
||||
# DEEPSEEK_SANDBOX_BACKEND → sandbox_backend
|
||||
# DEEPSEEK_SANDBOX_URL → sandbox_url
|
||||
# DEEPSEEK_SANDBOX_API_KEY → sandbox_api_key
|
||||
#
|
||||
# Example OpenSandbox setup:
|
||||
#
|
||||
# sandbox_backend = "opensandbox"
|
||||
# sandbox_url = "http://localhost:8080"
|
||||
# sandbox_api_key = "sk-opensandbox-secret"
|
||||
#
|
||||
# The backend uses a 30-second HTTP timeout. Background, interactive, and
|
||||
# TTY modes are not supported with external backends — all commands run
|
||||
# synchronously via HTTP.
|
||||
|
||||
# auto_allow entries match by command prefix, not raw string.
|
||||
# See command_safety.rs for the prefix dictionary.
|
||||
#
|
||||
|
||||
@@ -726,6 +726,14 @@ pub struct Config {
|
||||
pub allow_shell: Option<bool>,
|
||||
pub approval_policy: Option<String>,
|
||||
pub sandbox_mode: Option<String>,
|
||||
/// External sandbox backend: `"none"` or `"opensandbox"`.
|
||||
/// When set, exec_shell routes commands through the backend's HTTP API
|
||||
/// instead of spawning a local process.
|
||||
pub sandbox_backend: Option<String>,
|
||||
/// Base URL for the external sandbox backend (default: `"http://localhost:8080"`).
|
||||
pub sandbox_url: Option<String>,
|
||||
/// Optional API key for the external sandbox backend (sent as Bearer token).
|
||||
pub sandbox_api_key: Option<String>,
|
||||
pub managed_config_path: Option<String>,
|
||||
pub requirements_path: Option<String>,
|
||||
pub max_subagents: Option<usize>,
|
||||
@@ -1742,6 +1750,15 @@ fn apply_env_overrides(config: &mut Config) {
|
||||
if let Ok(value) = std::env::var("DEEPSEEK_SANDBOX_MODE") {
|
||||
config.sandbox_mode = Some(value);
|
||||
}
|
||||
if let Ok(value) = std::env::var("DEEPSEEK_SANDBOX_BACKEND") {
|
||||
config.sandbox_backend = Some(value);
|
||||
}
|
||||
if let Ok(value) = std::env::var("DEEPSEEK_SANDBOX_URL") {
|
||||
config.sandbox_url = Some(value);
|
||||
}
|
||||
if let Ok(value) = std::env::var("DEEPSEEK_SANDBOX_API_KEY") {
|
||||
config.sandbox_api_key = Some(value);
|
||||
}
|
||||
if let Ok(value) = std::env::var("DEEPSEEK_MANAGED_CONFIG_PATH") {
|
||||
config.managed_config_path = Some(value);
|
||||
}
|
||||
@@ -1999,6 +2016,9 @@ fn merge_config(base: Config, override_cfg: Config) -> Config {
|
||||
allow_shell: override_cfg.allow_shell.or(base.allow_shell),
|
||||
approval_policy: override_cfg.approval_policy.or(base.approval_policy),
|
||||
sandbox_mode: override_cfg.sandbox_mode.or(base.sandbox_mode),
|
||||
sandbox_backend: override_cfg.sandbox_backend.or(base.sandbox_backend),
|
||||
sandbox_url: override_cfg.sandbox_url.or(base.sandbox_url),
|
||||
sandbox_api_key: override_cfg.sandbox_api_key.or(base.sandbox_api_key),
|
||||
managed_config_path: override_cfg
|
||||
.managed_config_path
|
||||
.or(base.managed_config_path),
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
//! Pluggable sandbox backend abstraction.
|
||||
//!
|
||||
//! External sandbox backends route shell command execution to a remote service
|
||||
//! (e.g. Alibaba OpenSandbox) instead of spawning a local process. This is
|
||||
//! complementary to the OS-level sandbox module (Seatbelt / Landlock / Windows)
|
||||
//! — the external backend *replaces* local execution entirely when configured.
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use anyhow::Result;
|
||||
use async_trait::async_trait;
|
||||
|
||||
/// Output from a sandbox backend execution.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct SandboxOutput {
|
||||
/// Standard output from the command.
|
||||
pub stdout: String,
|
||||
/// Standard error from the command.
|
||||
pub stderr: String,
|
||||
/// Exit code (0 for success).
|
||||
pub exit_code: i32,
|
||||
}
|
||||
|
||||
/// The kind of external sandbox backend.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum SandboxKind {
|
||||
/// No external sandbox — execute commands locally.
|
||||
None,
|
||||
/// Alibaba OpenSandbox remote execution.
|
||||
OpenSandbox,
|
||||
}
|
||||
|
||||
impl SandboxKind {
|
||||
/// Parse a sandbox backend name from config (case-insensitive).
|
||||
#[must_use]
|
||||
pub fn parse(value: &str) -> Option<Self> {
|
||||
match value.trim().to_ascii_lowercase().as_str() {
|
||||
"none" | "" => Some(Self::None),
|
||||
"opensandbox" | "open-sandbox" | "open_sandbox" => Some(Self::OpenSandbox),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Human-readable label.
|
||||
#[must_use]
|
||||
pub fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::None => "none",
|
||||
Self::OpenSandbox => "opensandbox",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Abstract interface for an external sandbox backend.
|
||||
///
|
||||
/// Implementations send commands to a remote execution environment and return
|
||||
/// structured output. The trait is `Send + Sync` so it can be stored in an
|
||||
/// `Arc` and shared across async tasks.
|
||||
#[async_trait]
|
||||
pub trait SandboxBackend: Send + Sync {
|
||||
/// Execute a shell command and return its output.
|
||||
///
|
||||
/// `cmd` is the full shell command string (e.g. `"ls -la"`).
|
||||
/// `env` contains additional environment variables to set.
|
||||
async fn exec(&self, cmd: &str, env: &HashMap<String, String>) -> Result<SandboxOutput>;
|
||||
}
|
||||
|
||||
use crate::config::Config;
|
||||
|
||||
/// Create the configured sandbox backend from config.
|
||||
///
|
||||
/// Returns `None` when no external sandbox backend is configured (i.e. the
|
||||
/// `sandbox_backend` key is absent, empty, or `"none"`). When `"opensandbox"`
|
||||
/// is set, constructs an [`OpenSandboxBackend`] using `sandbox_url` and
|
||||
/// `sandbox_api_key`.
|
||||
pub fn create_backend(config: &Config) -> Result<Option<Box<dyn SandboxBackend>>> {
|
||||
let kind = config
|
||||
.sandbox_backend
|
||||
.as_deref()
|
||||
.and_then(SandboxKind::parse)
|
||||
.unwrap_or(SandboxKind::None);
|
||||
|
||||
match kind {
|
||||
SandboxKind::None => Ok(None),
|
||||
SandboxKind::OpenSandbox => {
|
||||
let base_url = config
|
||||
.sandbox_url
|
||||
.clone()
|
||||
.unwrap_or_else(|| "http://localhost:8080".to_string());
|
||||
let api_key = config.sandbox_api_key.clone();
|
||||
let backend = super::opensandbox::OpenSandboxBackend::new(base_url, api_key, 30);
|
||||
Ok(Some(Box::new(backend)))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -25,6 +25,8 @@
|
||||
//! // exec_env.command now contains the sandboxed command
|
||||
//! ```
|
||||
|
||||
pub mod backend;
|
||||
pub mod opensandbox;
|
||||
pub mod policy;
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
//! Alibaba OpenSandbox backend adapter.
|
||||
//!
|
||||
//! Sends shell commands to an OpenSandbox-compatible HTTP API for remote
|
||||
//! execution. The API endpoint is `POST {base_url}/v1/sandbox/run` with
|
||||
//! JSON body `{"cmd": "...", "env": {...}}` and expects a JSON response
|
||||
//! `{"stdout": "...", "stderr": "...", "exit_code": 0}`.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::time::Duration;
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use async_trait::async_trait;
|
||||
use serde::Deserialize;
|
||||
use serde::Serialize;
|
||||
|
||||
use super::backend::{SandboxBackend, SandboxOutput};
|
||||
|
||||
/// Request body sent to the OpenSandbox `/v1/sandbox/run` endpoint.
|
||||
#[derive(Debug, Serialize)]
|
||||
struct SandboxRunRequest {
|
||||
/// Full shell command to execute.
|
||||
cmd: String,
|
||||
/// Environment variables to set in the sandbox.
|
||||
env: HashMap<String, String>,
|
||||
}
|
||||
|
||||
/// Response body from the OpenSandbox `/v1/sandbox/run` endpoint.
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct SandboxRunResponse {
|
||||
/// Standard output from the command.
|
||||
stdout: String,
|
||||
/// Standard error from the command.
|
||||
stderr: String,
|
||||
/// Exit code (0 for success).
|
||||
exit_code: i32,
|
||||
}
|
||||
|
||||
/// An OpenSandbox-compatible remote execution backend.
|
||||
///
|
||||
/// Constructed with a base URL (e.g. `"http://localhost:8080"`), an optional
|
||||
/// API key sent as a `Bearer` token, and a timeout in seconds.
|
||||
pub struct OpenSandboxBackend {
|
||||
base_url: String,
|
||||
api_key: Option<String>,
|
||||
timeout_secs: u64,
|
||||
client: reqwest::Client,
|
||||
}
|
||||
|
||||
impl OpenSandboxBackend {
|
||||
/// Create a new OpenSandbox backend.
|
||||
///
|
||||
/// `base_url` should be the root of the OpenSandbox API (e.g.
|
||||
/// `"http://localhost:8080"`). `api_key` is optional and sent as
|
||||
/// `Authorization: Bearer <key>` when set. `timeout_secs` controls the
|
||||
/// HTTP request timeout.
|
||||
#[must_use]
|
||||
pub fn new(base_url: String, api_key: Option<String>, timeout_secs: u64) -> Self {
|
||||
let client = reqwest::Client::builder()
|
||||
.timeout(Duration::from_secs(timeout_secs))
|
||||
.build()
|
||||
.expect("reqwest::Client::builder should not fail with default settings");
|
||||
|
||||
Self {
|
||||
base_url,
|
||||
api_key,
|
||||
timeout_secs,
|
||||
client,
|
||||
}
|
||||
}
|
||||
|
||||
/// Build the full URL for the sandbox run endpoint.
|
||||
fn run_url(&self) -> String {
|
||||
format!("{}/v1/sandbox/run", self.base_url.trim_end_matches('/'))
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl SandboxBackend for OpenSandboxBackend {
|
||||
async fn exec(
|
||||
&self,
|
||||
cmd: &str,
|
||||
env: &HashMap<String, String>,
|
||||
) -> Result<SandboxOutput> {
|
||||
let request_body = SandboxRunRequest {
|
||||
cmd: cmd.to_string(),
|
||||
env: env.clone(),
|
||||
};
|
||||
|
||||
let mut req = self
|
||||
.client
|
||||
.post(self.run_url())
|
||||
.json(&request_body);
|
||||
|
||||
if let Some(ref api_key) = self.api_key {
|
||||
req = req.bearer_auth(api_key);
|
||||
}
|
||||
|
||||
let response = req
|
||||
.send()
|
||||
.await
|
||||
.context("Failed to reach OpenSandbox endpoint")?;
|
||||
|
||||
let status = response.status();
|
||||
if !status.is_success() {
|
||||
let body = response.text().await.unwrap_or_default();
|
||||
anyhow::bail!(
|
||||
"OpenSandbox returned HTTP {}: {}",
|
||||
status.as_u16(),
|
||||
body
|
||||
);
|
||||
}
|
||||
|
||||
let parsed: SandboxRunResponse = response
|
||||
.json()
|
||||
.await
|
||||
.context("Failed to parse OpenSandbox response")?;
|
||||
|
||||
Ok(SandboxOutput {
|
||||
stdout: parsed.stdout,
|
||||
stderr: parsed.stderr,
|
||||
exit_code: parsed.exit_code,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1679,6 +1679,107 @@ impl ToolSpec for ExecShellTool {
|
||||
std::collections::HashMap::new()
|
||||
};
|
||||
|
||||
// Route through external sandbox backend when configured.
|
||||
if let Some(backend) = &context.sandbox_backend {
|
||||
if interactive {
|
||||
return Ok(ToolResult::error(
|
||||
"Interactive mode is not supported with external sandbox backends.",
|
||||
));
|
||||
}
|
||||
if background {
|
||||
return Ok(ToolResult::error(
|
||||
"Background mode is not supported with external sandbox backends.",
|
||||
));
|
||||
}
|
||||
if tty {
|
||||
return Ok(ToolResult::error(
|
||||
"TTY mode is not supported with external sandbox backends.",
|
||||
));
|
||||
}
|
||||
|
||||
let started = std::time::Instant::now();
|
||||
let backend_result = backend.exec(command, &extra_env).await;
|
||||
|
||||
let result = match backend_result {
|
||||
Ok(output) => {
|
||||
let (stdout, stdout_meta) = truncate_with_meta(&output.stdout);
|
||||
let (stderr, stderr_meta) = truncate_with_meta(&output.stderr);
|
||||
ShellResult {
|
||||
task_id: None,
|
||||
status: if output.exit_code == 0 {
|
||||
ShellStatus::Completed
|
||||
} else {
|
||||
ShellStatus::Failed
|
||||
},
|
||||
exit_code: Some(output.exit_code),
|
||||
stdout,
|
||||
stderr,
|
||||
duration_ms: u64::try_from(started.elapsed().as_millis())
|
||||
.unwrap_or(u64::MAX),
|
||||
stdout_len: stdout_meta.original_len,
|
||||
stderr_len: stderr_meta.original_len,
|
||||
stdout_omitted: stdout_meta.omitted,
|
||||
stderr_omitted: stderr_meta.omitted,
|
||||
stdout_truncated: stdout_meta.truncated,
|
||||
stderr_truncated: stderr_meta.truncated,
|
||||
sandboxed: true,
|
||||
sandbox_type: Some("opensandbox".to_string()),
|
||||
sandbox_denied: false,
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
return Ok(ToolResult::error(format!(
|
||||
"Sandbox backend error: {e}"
|
||||
)));
|
||||
}
|
||||
};
|
||||
|
||||
// Build result (reuse the existing output rendering below).
|
||||
let stdout_summary = summarize_output(&result.stdout);
|
||||
let stderr_summary = summarize_output(&result.stderr);
|
||||
let summary = if !stderr_summary.is_empty() {
|
||||
stderr_summary.clone()
|
||||
} else {
|
||||
stdout_summary.clone()
|
||||
};
|
||||
let output = if result.stdout.is_empty() && result.stderr.is_empty() {
|
||||
"(no output)".to_string()
|
||||
} else if result.stderr.is_empty() {
|
||||
result.stdout.clone()
|
||||
} else {
|
||||
format!("{}\n\nSTDERR:\n{}", result.stdout, result.stderr)
|
||||
};
|
||||
|
||||
let metadata = json!({
|
||||
"exit_code": result.exit_code,
|
||||
"status": format!("{:?}", result.status),
|
||||
"duration_ms": result.duration_ms,
|
||||
"sandboxed": true,
|
||||
"sandbox_type": "opensandbox",
|
||||
"sandbox_denied": false,
|
||||
"task_id": result.task_id,
|
||||
"stdout_len": result.stdout_len,
|
||||
"stderr_len": result.stderr_len,
|
||||
"stdout_truncated": result.stdout_truncated,
|
||||
"stderr_truncated": result.stderr_truncated,
|
||||
"stdout_omitted": result.stdout_omitted,
|
||||
"stderr_omitted": result.stderr_omitted,
|
||||
"summary": summary,
|
||||
"stdout_summary": stdout_summary,
|
||||
"stderr_summary": stderr_summary,
|
||||
"safety_level": format!("{:?}", safety.level),
|
||||
"interactive": false,
|
||||
"canceled": false,
|
||||
"sandbox_backend": "opensandbox",
|
||||
});
|
||||
|
||||
return Ok(ToolResult {
|
||||
content: output,
|
||||
success: result.status == ShellStatus::Completed,
|
||||
metadata: Some(metadata),
|
||||
});
|
||||
}
|
||||
|
||||
let result = if interactive {
|
||||
let mut manager = context
|
||||
.shell_manager
|
||||
|
||||
@@ -16,6 +16,7 @@ use tokio_util::sync::CancellationToken;
|
||||
use crate::features::Features;
|
||||
use crate::lsp::LspManager;
|
||||
use crate::network_policy::NetworkPolicyDecider;
|
||||
use crate::sandbox::backend::SandboxBackend;
|
||||
use crate::tools::shell::{SharedShellManager, new_shared_shell_manager};
|
||||
#[allow(unused_imports)]
|
||||
pub use deepseek_tools::{
|
||||
@@ -107,6 +108,10 @@ pub struct ToolContext {
|
||||
/// Cancellation token for the active engine turn. Tools that may wait on
|
||||
/// external work should observe this so UI cancel can interrupt them.
|
||||
pub cancel_token: Option<CancellationToken>,
|
||||
/// Optional external sandbox backend for shell execution.
|
||||
/// When set, exec_shell routes commands through this instead of spawning
|
||||
/// a local process.
|
||||
pub sandbox_backend: Option<std::sync::Arc<dyn SandboxBackend>>,
|
||||
/// Path to the user memory file. `None` when the user-memory feature
|
||||
/// (#489) is disabled — tools that read or write the file should
|
||||
/// short-circuit on `None` rather than fall back to a workspace-local
|
||||
@@ -153,6 +158,7 @@ impl ToolContext {
|
||||
network_policy: None,
|
||||
runtime: RuntimeToolServices::default(),
|
||||
cancel_token: None,
|
||||
sandbox_backend: None,
|
||||
memory_path: None,
|
||||
lsp_manager: None,
|
||||
large_output_router: None,
|
||||
@@ -185,6 +191,7 @@ impl ToolContext {
|
||||
network_policy: None,
|
||||
runtime: RuntimeToolServices::default(),
|
||||
cancel_token: None,
|
||||
sandbox_backend: None,
|
||||
memory_path: None,
|
||||
lsp_manager: None,
|
||||
large_output_router: None,
|
||||
@@ -217,6 +224,7 @@ impl ToolContext {
|
||||
network_policy: None,
|
||||
runtime: RuntimeToolServices::default(),
|
||||
cancel_token: None,
|
||||
sandbox_backend: None,
|
||||
memory_path: None,
|
||||
lsp_manager: None,
|
||||
large_output_router: None,
|
||||
@@ -245,6 +253,14 @@ impl ToolContext {
|
||||
self
|
||||
}
|
||||
|
||||
/// Attach an external sandbox backend for remote shell execution.
|
||||
#[must_use]
|
||||
#[allow(dead_code)]
|
||||
pub fn with_sandbox_backend(mut self, backend: std::sync::Arc<dyn SandboxBackend>) -> Self {
|
||||
self.sandbox_backend = Some(backend);
|
||||
self
|
||||
}
|
||||
|
||||
/// Set the user's trusted external paths (loaded from
|
||||
/// `~/.deepseek/workspace-trust.json`). See [`Self::resolve_path`] for
|
||||
/// how the list is consulted.
|
||||
|
||||
Reference in New Issue
Block a user