diff --git a/config.example.toml b/config.example.toml index de0222e7..038690cc 100644 --- a/config.example.toml +++ b/config.example.toml @@ -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. # diff --git a/crates/tui/src/config.rs b/crates/tui/src/config.rs index 6072c177..d7831c5f 100644 --- a/crates/tui/src/config.rs +++ b/crates/tui/src/config.rs @@ -726,6 +726,14 @@ pub struct Config { pub allow_shell: Option, pub approval_policy: Option, pub sandbox_mode: Option, + /// 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, + /// Base URL for the external sandbox backend (default: `"http://localhost:8080"`). + pub sandbox_url: Option, + /// Optional API key for the external sandbox backend (sent as Bearer token). + pub sandbox_api_key: Option, pub managed_config_path: Option, pub requirements_path: Option, pub max_subagents: Option, @@ -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), diff --git a/crates/tui/src/sandbox/backend.rs b/crates/tui/src/sandbox/backend.rs new file mode 100644 index 00000000..c3eb9afc --- /dev/null +++ b/crates/tui/src/sandbox/backend.rs @@ -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 { + 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) -> Result; +} + +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>> { + 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))) + } + } +} diff --git a/crates/tui/src/sandbox/mod.rs b/crates/tui/src/sandbox/mod.rs index b6678783..3d9a9314 100644 --- a/crates/tui/src/sandbox/mod.rs +++ b/crates/tui/src/sandbox/mod.rs @@ -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")] diff --git a/crates/tui/src/sandbox/opensandbox.rs b/crates/tui/src/sandbox/opensandbox.rs new file mode 100644 index 00000000..884c9e50 --- /dev/null +++ b/crates/tui/src/sandbox/opensandbox.rs @@ -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, +} + +/// 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, + 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 ` when set. `timeout_secs` controls the + /// HTTP request timeout. + #[must_use] + pub fn new(base_url: String, api_key: Option, 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, + ) -> Result { + 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, + }) + } +} diff --git a/crates/tui/src/tools/shell.rs b/crates/tui/src/tools/shell.rs index d46c1727..b622e701 100644 --- a/crates/tui/src/tools/shell.rs +++ b/crates/tui/src/tools/shell.rs @@ -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 diff --git a/crates/tui/src/tools/spec.rs b/crates/tui/src/tools/spec.rs index 745c781c..052fb9c4 100644 --- a/crates/tui/src/tools/spec.rs +++ b/crates/tui/src/tools/spec.rs @@ -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, + /// 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>, /// 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) -> 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.