feat(sandbox): pluggable SandboxBackend + Alibaba OpenSandbox adapter (#645)

This commit is contained in:
Hunter Bown
2026-05-05 00:16:34 -05:00
7 changed files with 386 additions and 0 deletions
+28
View File
@@ -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.
#
+20
View File
@@ -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),
+95
View File
@@ -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)))
}
}
}
+2
View File
@@ -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")]
+124
View File
@@ -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,
})
}
}
+101
View File
@@ -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
View File
@@ -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.