返回 CodeWhale
backend.rs
根目录 / crates / tui / src / sandbox / backend.rs
1 //! Pluggable sandbox backend abstraction.
2 //!
3 //! External sandbox backends route shell command execution to a remote service
4 //! (e.g. Alibaba OpenSandbox) instead of spawning a local process. This is
5 //! complementary to the OS-level sandbox module (Seatbelt / opt-in bubblewrap)
6 //! — the external backend *replaces* local execution entirely when configured.
7
8 use std::collections::HashMap;
9
10 use anyhow::Result;
11 use async_trait::async_trait;
12
13 /// Output from a sandbox backend execution.
14 #[derive(Debug, Clone)]
15 pub struct SandboxOutput {
16 /// Standard output from the command.
17 pub stdout: String,
18 /// Standard error from the command.
19 pub stderr: String,
20 /// Exit code (0 for success).
21 pub exit_code: i32,
22 }
23
24 /// The kind of external sandbox backend.
25 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
26 pub enum SandboxKind {
27 /// No external sandbox — execute commands locally.
28 None,
29 /// Alibaba OpenSandbox remote execution.
30 OpenSandbox,
31 /// Configured backend is unavailable; execution is refused.
32 Unsupported,
33 }
34
35 impl SandboxKind {
36 /// Parse a sandbox backend name from config (case-insensitive).
37 #[must_use]
38 pub fn parse(value: &str) -> Option<Self> {
39 match value.trim().to_ascii_lowercase().as_str() {
40 "none" | "" => Some(Self::None),
41 "opensandbox" | "open-sandbox" | "open_sandbox" => Some(Self::OpenSandbox),
42 _ => None,
43 }
44 }
45
46 /// Human-readable label.
47 #[must_use]
48 pub fn as_str(self) -> &'static str {
49 match self {
50 Self::None => "none",
51 Self::OpenSandbox => "opensandbox",
52 Self::Unsupported => "unsupported",
53 }
54 }
55 }
56
57 /// Abstract interface for an external sandbox backend.
58 ///
59 /// Implementations send commands to a remote execution environment and return
60 /// structured output. The trait is `Send + Sync` so it can be stored in an
61 /// `Arc` and shared across async tasks.
62 #[async_trait]
63 pub trait SandboxBackend: Send + Sync {
64 /// Backend identity used by tool receipts.
65 fn kind(&self) -> SandboxKind;
66 /// Execute a shell command and return its output.
67 ///
68 /// `cmd` is the full shell command string (e.g. `"ls -la"`).
69 /// `env` contains additional environment variables to set.
70 async fn exec(&self, cmd: &str, env: &HashMap<String, String>) -> Result<SandboxOutput>;
71 }
72
73 use crate::config::Config;
74
75 /// Create the configured sandbox backend from config.
76 ///
77 /// Returns `None` when no external sandbox backend is configured (i.e. the
78 /// `sandbox_backend` key is absent, empty, or `"none"`). When `"opensandbox"`
79 /// is set, constructs an [`OpenSandboxBackend`](super::opensandbox::OpenSandboxBackend) using `sandbox_url` and
80 /// `sandbox_api_key`.
81 pub fn create_backend(config: &Config) -> Result<Option<Box<dyn SandboxBackend>>> {
82 let Some(kind) = SandboxKind::parse(config.sandbox_backend.as_deref().unwrap_or("none")) else {
83 // Old or misspelled remote settings must never select local execution.
84 return Ok(Some(Box::new(UnsupportedBackend)));
85 };
86
87 match kind {
88 SandboxKind::None => Ok(None),
89 SandboxKind::Unsupported => Ok(Some(Box::new(UnsupportedBackend))),
90 SandboxKind::OpenSandbox => {
91 let base_url = config
92 .sandbox_url
93 .clone()
94 .unwrap_or_else(|| "http://localhost:8080".to_string());
95 let api_key = config.sandbox_api_key.clone();
96 let backend = super::opensandbox::OpenSandboxBackend::new(base_url, api_key, 30)?;
97 Ok(Some(Box::new(backend)))
98 }
99 }
100 }
101
102 /// A configured execution boundary that is no longer supported. Keep it present
103 /// in the tool context so every shell call is refused instead of running locally.
104 struct UnsupportedBackend;
105
106 #[async_trait]
107 impl SandboxBackend for UnsupportedBackend {
108 fn kind(&self) -> SandboxKind {
109 SandboxKind::Unsupported
110 }
111 async fn exec(&self, _cmd: &str, _env: &HashMap<String, String>) -> Result<SandboxOutput> {
112 anyhow::bail!(
113 "Unsupported sandbox_backend setting. Choose opensandbox, or explicitly set none for local execution."
114 )
115 }
116 }
117
118 #[cfg(test)]
119 mod tests {
120 use super::*;
121
122 #[tokio::test]
123 async fn unsupported_backend_refuses_execution_instead_of_falling_back_to_local() {
124 for name in ["shannon", "shannonnet", "shannon-net", "levee", "unknown"] {
125 let config = Config {
126 sandbox_backend: Some(name.into()),
127 ..Config::default()
128 };
129 let backend = create_backend(&config)
130 .unwrap()
131 .expect("retain execution boundary");
132 let error = backend
133 .exec("echo must-not-run", &HashMap::new())
134 .await
135 .unwrap_err();
136 assert!(error.to_string().contains("Unsupported sandbox_backend"));
137 }
138 for name in [None, Some("none"), Some("")] {
139 let config = Config {
140 sandbox_backend: name.map(str::to_owned),
141 ..Config::default()
142 };
143 assert!(create_backend(&config).unwrap().is_none());
144 }
145 }
146 }
147
147 lines RUST