1#![allow(dead_code)]
3
4use std::collections::HashMap;
5
6use serde::{Deserialize, Serialize};
7
8#[derive(Debug, Clone, Default, Serialize, Deserialize)]
10pub struct NetworkConfig {
11 pub allow: Vec<String>,
13 pub deny: Vec<String>,
15}
16
17#[derive(Debug, Clone, Default, Serialize, Deserialize)]
19pub struct ResourceConfig {
20 pub cpus: Option<f64>,
22 pub memory: Option<String>,
24}
25
26#[derive(Debug, Clone, Default, PartialEq, Eq)]
28pub enum SandboxMode {
29 #[default]
31 Disabled,
32 FullWorkflow,
34 AgentOnly,
36 Devbox,
38}
39
40#[derive(Debug, Clone, Default, Serialize, Deserialize)]
42pub struct SandboxConfig {
43 pub enabled: bool,
44 pub image: Option<String>,
46 pub workspace: Option<String>,
48 pub network: NetworkConfig,
49 pub resources: ResourceConfig,
50 pub env: Vec<String>,
54 pub volumes: Vec<String>,
57 pub exclude: Vec<String>,
60 pub dns: Vec<String>,
63}
64
65impl SandboxConfig {
66 pub const DEFAULT_IMAGE: &'static str = "minion-sandbox:latest";
68
69 pub const AUTO_ENV: &'static [&'static str] = &[
73 "ANTHROPIC_API_KEY",
74 "OPENAI_API_KEY",
75 "GH_TOKEN",
76 "GITHUB_TOKEN",
77 ];
78
79 pub const AUTO_EXCLUDE: &'static [&'static str] = &[
84 "target",
85 "node_modules",
86 "dist",
87 "build",
88 "__pycache__",
89 ".next",
90 ".nuxt",
91 "vendor",
92 ".tox",
93 ".venv",
94 "venv",
95 ];
96
97 pub const AUTO_VOLUMES: &'static [&'static str] = &[
105 "~/.config/gh:/root/.config/gh:ro",
106 "~/.claude:/root/.claude:rw",
107 "~/.ssh:/root/.ssh:ro",
108 ];
109
110 pub fn image(&self) -> &str {
111 self.image.as_deref().unwrap_or(Self::DEFAULT_IMAGE)
112 }
113
114 pub fn effective_env(&self) -> Vec<String> {
116 if self.env.is_empty() {
117 Self::AUTO_ENV.iter().map(|s| (*s).to_string()).collect()
118 } else {
119 self.env.clone()
120 }
121 }
122
123 pub fn effective_volumes(&self) -> Vec<String> {
126 let home = std::env::var("HOME").unwrap_or_else(|_| "/root".to_string());
127 let raw = if self.volumes.is_empty() {
128 Self::AUTO_VOLUMES.iter().map(|s| (*s).to_string()).collect::<Vec<_>>()
129 } else {
130 self.volumes.clone()
131 };
132 raw.into_iter()
133 .map(|v| v.replace('~', &home))
134 .filter(|v| {
135 let host_path = v.split(':').next().unwrap_or("");
137 std::path::Path::new(host_path).exists()
138 })
139 .collect()
140 }
141
142 pub fn effective_exclude(&self) -> Vec<String> {
144 if self.exclude.is_empty() {
145 Self::AUTO_EXCLUDE.iter().map(|s| (*s).to_string()).collect()
146 } else {
147 self.exclude.clone()
148 }
149 }
150
151 fn parse_string_list(
153 mapping: &serde_yaml::Mapping,
154 key: &str,
155 ) -> Vec<String> {
156 mapping
157 .get(serde_yaml::Value::String(key.into()))
158 .and_then(|v| v.as_sequence())
159 .map(|seq| {
160 seq.iter()
161 .filter_map(|v| v.as_str().map(String::from))
162 .collect()
163 })
164 .unwrap_or_default()
165 }
166
167 pub fn from_global_config(config: &HashMap<String, serde_yaml::Value>) -> Self {
169 let sandbox = match config.get("sandbox") {
170 Some(serde_yaml::Value::Mapping(m)) => m,
171 _ => return Self::default(),
172 };
173
174 let enabled = sandbox
175 .get(serde_yaml::Value::String("enabled".into()))
176 .and_then(|v| v.as_bool())
177 .unwrap_or(false);
178
179 let image = sandbox
180 .get(serde_yaml::Value::String("image".into()))
181 .and_then(|v| v.as_str())
182 .map(String::from);
183
184 let workspace = sandbox
185 .get(serde_yaml::Value::String("workspace".into()))
186 .and_then(|v| v.as_str())
187 .map(String::from);
188
189 let (allow, deny) = match sandbox.get(serde_yaml::Value::String("network".into())) {
190 Some(serde_yaml::Value::Mapping(net)) => {
191 (Self::parse_string_list(net, "allow"), Self::parse_string_list(net, "deny"))
192 }
193 _ => (vec![], vec![]),
194 };
195
196 let (cpus, memory) = match sandbox.get(serde_yaml::Value::String("resources".into())) {
197 Some(serde_yaml::Value::Mapping(res)) => {
198 let cpus = res
199 .get(serde_yaml::Value::String("cpus".into()))
200 .and_then(|v| v.as_f64());
201 let memory = res
202 .get(serde_yaml::Value::String("memory".into()))
203 .and_then(|v| v.as_str())
204 .map(String::from);
205 (cpus, memory)
206 }
207 _ => (None, None),
208 };
209
210 let env = Self::parse_string_list(sandbox, "env");
211 let volumes = Self::parse_string_list(sandbox, "volumes");
212 let exclude = Self::parse_string_list(sandbox, "exclude");
213 let dns = Self::parse_string_list(sandbox, "dns");
214
215 Self {
216 enabled,
217 image,
218 workspace,
219 network: NetworkConfig { allow, deny },
220 resources: ResourceConfig { cpus, memory },
221 env,
222 volumes,
223 exclude,
224 dns,
225 }
226 }
227}
228
229#[cfg(test)]
230mod tests {
231 use super::*;
232
233 #[test]
234 fn default_image() {
235 let cfg = SandboxConfig::default();
236 assert_eq!(cfg.image(), "minion-sandbox:latest");
237 }
238
239 #[test]
240 fn custom_image_override() {
241 let cfg = SandboxConfig {
242 image: Some("node:20".to_string()),
243 ..Default::default()
244 };
245 assert_eq!(cfg.image(), "node:20");
246 }
247
248 #[test]
249 fn from_global_config_parses_all_fields() {
250 let yaml = r#"
251sandbox:
252 enabled: true
253 image: "rust:1.80"
254 workspace: "/app"
255 network:
256 allow:
257 - "api.anthropic.com"
258 deny:
259 - "0.0.0.0/0"
260 resources:
261 cpus: 2.0
262 memory: "4g"
263 env:
264 - ANTHROPIC_API_KEY
265 - GH_TOKEN
266 - CUSTOM_SECRET
267 volumes:
268 - "~/.config/gh:/root/.config/gh:ro"
269 - "~/.claude:/root/.claude:ro"
270 exclude:
271 - node_modules
272 - target
273 - .git/objects
274 dns:
275 - "8.8.8.8"
276 - "1.1.1.1"
277"#;
278 let map: HashMap<String, serde_yaml::Value> = serde_yaml::from_str(yaml).unwrap();
279 let cfg = SandboxConfig::from_global_config(&map);
280
281 assert!(cfg.enabled);
282 assert_eq!(cfg.image(), "rust:1.80");
283 assert_eq!(cfg.workspace.as_deref(), Some("/app"));
284 assert_eq!(cfg.network.allow, ["api.anthropic.com"]);
285 assert_eq!(cfg.network.deny, ["0.0.0.0/0"]);
286 assert_eq!(cfg.resources.cpus, Some(2.0));
287 assert_eq!(cfg.resources.memory.as_deref(), Some("4g"));
288 assert_eq!(cfg.env, ["ANTHROPIC_API_KEY", "GH_TOKEN", "CUSTOM_SECRET"]);
289 assert_eq!(cfg.volumes.len(), 2);
290 assert_eq!(cfg.exclude, ["node_modules", "target", ".git/objects"]);
291 assert_eq!(cfg.dns, ["8.8.8.8", "1.1.1.1"]);
292 }
293
294 #[test]
295 fn from_global_config_empty_returns_default() {
296 let map: HashMap<String, serde_yaml::Value> = HashMap::new();
297 let cfg = SandboxConfig::from_global_config(&map);
298 assert!(!cfg.enabled);
299 assert!(cfg.image.is_none());
300 }
301
302 #[test]
303 fn effective_env_uses_auto_when_empty() {
304 let cfg = SandboxConfig::default();
305 let env = cfg.effective_env();
306 assert!(env.contains(&"ANTHROPIC_API_KEY".to_string()));
307 assert!(env.contains(&"GH_TOKEN".to_string()));
308 }
309
310 #[test]
311 fn effective_env_uses_explicit_when_set() {
312 let cfg = SandboxConfig {
313 env: vec!["MY_CUSTOM_KEY".to_string()],
314 ..Default::default()
315 };
316 let env = cfg.effective_env();
317 assert_eq!(env, vec!["MY_CUSTOM_KEY"]);
318 assert!(!env.contains(&"ANTHROPIC_API_KEY".to_string()));
319 }
320
321 #[test]
322 fn effective_volumes_filters_nonexistent_paths() {
323 let cfg = SandboxConfig {
324 volumes: vec![
325 "/nonexistent/path/abc123:/container/path:ro".to_string(),
326 ],
327 ..Default::default()
328 };
329 let vols = cfg.effective_volumes();
330 assert!(vols.is_empty(), "should filter out non-existent host paths");
331 }
332}