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",
107 "~/.claude:/root/.claude:rw",
108 "~/.ssh:/root/.ssh:ro",
109 "~/.config/gh:/home/minion/.config/gh:ro",
111 "~/.claude:/home/minion/.claude:rw",
112 "~/.claude.json:/home/minion/.claude.json:ro",
113 ];
114
115 pub fn image(&self) -> &str {
116 self.image.as_deref().unwrap_or(Self::DEFAULT_IMAGE)
117 }
118
119 pub const PROXIED_SECRETS: &'static [&'static str] = &["ANTHROPIC_API_KEY"];
121
122 pub fn effective_env(&self) -> Vec<String> {
124 if self.env.is_empty() {
125 Self::AUTO_ENV.iter().map(|s| (*s).to_string()).collect()
126 } else {
127 self.env.clone()
128 }
129 }
130
131 pub fn effective_env_with_proxy(&self) -> Vec<String> {
134 self.effective_env()
135 .into_iter()
136 .filter(|k| !Self::PROXIED_SECRETS.contains(&k.as_str()))
137 .collect()
138 }
139
140 pub fn effective_volumes(&self) -> Vec<String> {
143 let home = std::env::var("HOME").unwrap_or_else(|_| "/root".to_string());
144 let raw = if self.volumes.is_empty() {
145 Self::AUTO_VOLUMES.iter().map(|s| (*s).to_string()).collect::<Vec<_>>()
146 } else {
147 self.volumes.clone()
148 };
149 raw.into_iter()
150 .map(|v| v.replace('~', &home))
151 .filter(|v| {
152 let host_path = v.split(':').next().unwrap_or("");
154 std::path::Path::new(host_path).exists()
155 })
156 .collect()
157 }
158
159 pub fn effective_exclude(&self) -> Vec<String> {
161 if self.exclude.is_empty() {
162 Self::AUTO_EXCLUDE.iter().map(|s| (*s).to_string()).collect()
163 } else {
164 self.exclude.clone()
165 }
166 }
167
168 fn parse_string_list(
170 mapping: &serde_yaml::Mapping,
171 key: &str,
172 ) -> Vec<String> {
173 mapping
174 .get(serde_yaml::Value::String(key.into()))
175 .and_then(|v| v.as_sequence())
176 .map(|seq| {
177 seq.iter()
178 .filter_map(|v| v.as_str().map(String::from))
179 .collect()
180 })
181 .unwrap_or_default()
182 }
183
184 pub fn from_global_config(config: &HashMap<String, serde_yaml::Value>) -> Self {
186 let sandbox = match config.get("sandbox") {
187 Some(serde_yaml::Value::Mapping(m)) => m,
188 _ => return Self::default(),
189 };
190
191 let enabled = sandbox
192 .get(serde_yaml::Value::String("enabled".into()))
193 .and_then(|v| v.as_bool())
194 .unwrap_or(false);
195
196 let image = sandbox
197 .get(serde_yaml::Value::String("image".into()))
198 .and_then(|v| v.as_str())
199 .map(String::from);
200
201 let workspace = sandbox
202 .get(serde_yaml::Value::String("workspace".into()))
203 .and_then(|v| v.as_str())
204 .map(String::from);
205
206 let (allow, deny) = match sandbox.get(serde_yaml::Value::String("network".into())) {
207 Some(serde_yaml::Value::Mapping(net)) => {
208 (Self::parse_string_list(net, "allow"), Self::parse_string_list(net, "deny"))
209 }
210 _ => (vec![], vec![]),
211 };
212
213 let (cpus, memory) = match sandbox.get(serde_yaml::Value::String("resources".into())) {
214 Some(serde_yaml::Value::Mapping(res)) => {
215 let cpus = res
216 .get(serde_yaml::Value::String("cpus".into()))
217 .and_then(|v| v.as_f64());
218 let memory = res
219 .get(serde_yaml::Value::String("memory".into()))
220 .and_then(|v| v.as_str())
221 .map(String::from);
222 (cpus, memory)
223 }
224 _ => (None, None),
225 };
226
227 let env = Self::parse_string_list(sandbox, "env");
228 let volumes = Self::parse_string_list(sandbox, "volumes");
229 let exclude = Self::parse_string_list(sandbox, "exclude");
230 let dns = Self::parse_string_list(sandbox, "dns");
231
232 Self {
233 enabled,
234 image,
235 workspace,
236 network: NetworkConfig { allow, deny },
237 resources: ResourceConfig { cpus, memory },
238 env,
239 volumes,
240 exclude,
241 dns,
242 }
243 }
244}
245
246#[cfg(test)]
247mod tests {
248 use super::*;
249
250 #[test]
251 fn default_image() {
252 let cfg = SandboxConfig::default();
253 assert_eq!(cfg.image(), "minion-sandbox:latest");
254 }
255
256 #[test]
257 fn custom_image_override() {
258 let cfg = SandboxConfig {
259 image: Some("node:20".to_string()),
260 ..Default::default()
261 };
262 assert_eq!(cfg.image(), "node:20");
263 }
264
265 #[test]
266 fn from_global_config_parses_all_fields() {
267 let yaml = r#"
268sandbox:
269 enabled: true
270 image: "rust:1.80"
271 workspace: "/app"
272 network:
273 allow:
274 - "api.anthropic.com"
275 deny:
276 - "0.0.0.0/0"
277 resources:
278 cpus: 2.0
279 memory: "4g"
280 env:
281 - ANTHROPIC_API_KEY
282 - GH_TOKEN
283 - CUSTOM_SECRET
284 volumes:
285 - "~/.config/gh:/root/.config/gh:ro"
286 - "~/.claude:/root/.claude:ro"
287 exclude:
288 - node_modules
289 - target
290 - .git/objects
291 dns:
292 - "8.8.8.8"
293 - "1.1.1.1"
294"#;
295 let map: HashMap<String, serde_yaml::Value> = serde_yaml::from_str(yaml).unwrap();
296 let cfg = SandboxConfig::from_global_config(&map);
297
298 assert!(cfg.enabled);
299 assert_eq!(cfg.image(), "rust:1.80");
300 assert_eq!(cfg.workspace.as_deref(), Some("/app"));
301 assert_eq!(cfg.network.allow, ["api.anthropic.com"]);
302 assert_eq!(cfg.network.deny, ["0.0.0.0/0"]);
303 assert_eq!(cfg.resources.cpus, Some(2.0));
304 assert_eq!(cfg.resources.memory.as_deref(), Some("4g"));
305 assert_eq!(cfg.env, ["ANTHROPIC_API_KEY", "GH_TOKEN", "CUSTOM_SECRET"]);
306 assert_eq!(cfg.volumes.len(), 2);
307 assert_eq!(cfg.exclude, ["node_modules", "target", ".git/objects"]);
308 assert_eq!(cfg.dns, ["8.8.8.8", "1.1.1.1"]);
309 }
310
311 #[test]
312 fn from_global_config_empty_returns_default() {
313 let map: HashMap<String, serde_yaml::Value> = HashMap::new();
314 let cfg = SandboxConfig::from_global_config(&map);
315 assert!(!cfg.enabled);
316 assert!(cfg.image.is_none());
317 }
318
319 #[test]
320 fn effective_env_uses_auto_when_empty() {
321 let cfg = SandboxConfig::default();
322 let env = cfg.effective_env();
323 assert!(env.contains(&"ANTHROPIC_API_KEY".to_string()));
324 assert!(env.contains(&"GH_TOKEN".to_string()));
325 }
326
327 #[test]
328 fn effective_env_uses_explicit_when_set() {
329 let cfg = SandboxConfig {
330 env: vec!["MY_CUSTOM_KEY".to_string()],
331 ..Default::default()
332 };
333 let env = cfg.effective_env();
334 assert_eq!(env, vec!["MY_CUSTOM_KEY"]);
335 assert!(!env.contains(&"ANTHROPIC_API_KEY".to_string()));
336 }
337
338 #[test]
339 fn effective_volumes_filters_nonexistent_paths() {
340 let cfg = SandboxConfig {
341 volumes: vec![
342 "/nonexistent/path/abc123:/container/path:ro".to_string(),
343 ],
344 ..Default::default()
345 };
346 let vols = cfg.effective_volumes();
347 assert!(vols.is_empty(), "should filter out non-existent host paths");
348 }
349}