1use serde::{Deserialize, Serialize};
4use thiserror::Error;
5
6#[derive(Debug, Error)]
8#[non_exhaustive]
9pub enum ConfigError {
10 #[error("failed to read config from {path}: {source}")]
12 Read {
13 path: String,
14 source: std::io::Error,
15 },
16 #[error("failed to parse config from {path}: {source}")]
18 Parse {
19 path: String,
20 source: toml::de::Error,
21 },
22 #[error("config validation error: {0}")]
24 Validation(String),
25}
26
27#[derive(Debug, Clone, Deserialize)]
28#[non_exhaustive]
29pub struct ClawboxConfig {
30 #[serde(default)]
32 pub server: ServerConfig,
33 #[serde(default)]
35 pub sandbox: SandboxConfig,
36 #[serde(default)]
38 pub proxy: ProxyConfig,
39 #[serde(default)]
41 pub credentials: CredentialsConfig,
42 #[serde(default)]
44 pub logging: LoggingConfig,
45 #[serde(default)]
47 pub containers: ContainerConfig,
48 #[serde(default)]
50 pub container_policy: ContainerPolicy,
51 #[serde(default)]
52 pub tools: ToolsConfig,
53 #[serde(default)]
54 pub images: ImagesConfig,
55}
56
57#[derive(Clone, Deserialize)]
58#[non_exhaustive]
59pub struct ServerConfig {
60 #[serde(default = "default_host")]
62 pub host: String,
63 #[serde(default = "default_port")]
65 pub port: u16,
66 #[serde(default = "default_token")]
68 pub auth_token: String,
69 #[serde(default)]
72 pub unix_socket: Option<String>,
73 #[serde(default = "default_max_concurrent_executions")]
75 pub max_concurrent_executions: usize,
76}
77
78impl Default for ServerConfig {
79 fn default() -> Self {
80 Self {
81 host: default_host(),
82 port: default_port(),
83 auth_token: default_token(),
84 unix_socket: None,
85 max_concurrent_executions: default_max_concurrent_executions(),
86 }
87 }
88}
89
90fn default_max_concurrent_executions() -> usize {
91 10
92}
93
94impl std::fmt::Debug for ServerConfig {
95 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
96 f.debug_struct("ServerConfig")
97 .field("host", &self.host)
98 .field("port", &self.port)
99 .field("auth_token", &"[REDACTED]")
100 .field("unix_socket", &self.unix_socket)
101 .field("max_concurrent_executions", &self.max_concurrent_executions)
102 .finish()
103 }
104}
105
106fn default_host() -> String {
107 "127.0.0.1".into()
108}
109fn default_port() -> u16 {
110 9800
111}
112fn default_token() -> String {
113 use rand::RngCore;
114 let mut bytes = [0u8; 32];
115 rand::rng().fill_bytes(&mut bytes);
116 bytes.iter().map(|b| format!("{b:02x}")).collect()
117}
118
119#[derive(Debug, Clone, Deserialize)]
120#[non_exhaustive]
121pub struct SandboxConfig {
122 #[serde(default = "default_tool_dir")]
124 pub tool_dir: String,
125 #[serde(default = "default_fuel")]
127 pub default_fuel: u64,
128 #[serde(default = "default_timeout")]
130 pub default_timeout_ms: u64,
131 #[serde(default = "default_watch_tools")]
133 pub watch_tools: bool,
134}
135
136impl Default for SandboxConfig {
137 fn default() -> Self {
138 Self {
139 tool_dir: default_tool_dir(),
140 default_fuel: default_fuel(),
141 default_timeout_ms: default_timeout(),
142 watch_tools: default_watch_tools(),
143 }
144 }
145}
146
147fn default_tool_dir() -> String {
148 "./tools/wasm".into()
149}
150fn default_fuel() -> u64 {
151 100_000_000
152}
153fn default_timeout() -> u64 {
154 30_000
155}
156fn default_watch_tools() -> bool {
157 true
158}
159
160#[derive(Debug, Clone, Deserialize)]
161#[non_exhaustive]
162pub struct ProxyConfig {
163 #[serde(default = "default_max_response")]
165 pub max_response_bytes: usize,
166 #[serde(default = "default_timeout")]
168 pub default_timeout_ms: u64,
169}
170
171impl Default for ProxyConfig {
172 fn default() -> Self {
173 Self {
174 max_response_bytes: default_max_response(),
175 default_timeout_ms: default_timeout(),
176 }
177 }
178}
179
180fn default_max_response() -> usize {
181 1_048_576
182}
183
184#[derive(Debug, Clone, Deserialize)]
185#[non_exhaustive]
186pub struct CredentialsConfig {
187 #[serde(default = "default_store_path")]
189 pub store_path: String,
190}
191
192impl Default for CredentialsConfig {
193 fn default() -> Self {
194 Self {
195 store_path: default_store_path(),
196 }
197 }
198}
199
200fn default_store_path() -> String {
201 "~/.clawbox/credentials.enc".into()
202}
203
204#[derive(Debug, Clone, Deserialize)]
205#[non_exhaustive]
206pub struct LoggingConfig {
207 #[serde(default = "default_format")]
209 pub format: String,
210 #[serde(default = "default_level")]
212 pub level: String,
213 #[serde(default = "default_audit_dir")]
215 pub audit_dir: String,
216}
217
218impl Default for LoggingConfig {
219 fn default() -> Self {
220 Self {
221 format: default_format(),
222 level: default_level(),
223 audit_dir: default_audit_dir(),
224 }
225 }
226}
227
228fn default_format() -> String {
229 "json".into()
230}
231fn default_level() -> String {
232 "info".into()
233}
234fn default_audit_dir() -> String {
235 "./audit".into()
236}
237
238impl ClawboxConfig {
239 pub fn load(path: &str) -> Result<Self, ConfigError> {
240 let contents = std::fs::read_to_string(path).map_err(|source| ConfigError::Read {
241 path: path.to_string(),
242 source,
243 })?;
244 let config: Self = toml::from_str(&contents).map_err(|source| ConfigError::Parse {
245 path: path.to_string(),
246 source,
247 })?;
248 Ok(config)
249 }
250
251 pub fn default_config() -> Self {
253 Self {
254 server: ServerConfig::default(),
255 sandbox: SandboxConfig::default(),
256 proxy: ProxyConfig::default(),
257 credentials: CredentialsConfig::default(),
258 logging: LoggingConfig::default(),
259 containers: ContainerConfig::default(),
260 container_policy: ContainerPolicy::default(),
261 tools: ToolsConfig::default(),
262 images: ImagesConfig::default(),
263 }
264 }
265}
266
267#[derive(Debug, Clone, Deserialize)]
268#[non_exhaustive]
269pub struct ContainerConfig {
270 #[serde(default = "default_max_containers")]
272 pub max_containers: usize,
273 #[serde(default = "default_workspace_root")]
275 pub workspace_root: String,
276}
277
278impl Default for ContainerConfig {
279 fn default() -> Self {
280 Self {
281 max_containers: default_max_containers(),
282 workspace_root: default_workspace_root(),
283 }
284 }
285}
286
287fn default_max_containers() -> usize {
288 10
289}
290fn default_workspace_root() -> String {
291 "~/.clawbox/workspaces".into()
292}
293
294#[derive(Debug, Clone, Serialize, Deserialize)]
299#[non_exhaustive]
300pub struct ContainerPolicy {
301 #[serde(default)]
303 pub network_allowlist: Vec<String>,
304 #[serde(default)]
306 pub allowed_credentials: Vec<String>,
307 #[serde(default = "default_allowed_policies")]
310 pub allowed_policies: Vec<String>,
311}
312
313fn default_allowed_policies() -> Vec<String> {
314 vec!["wasm_only".into(), "container".into()]
315}
316
317impl Default for ContainerPolicy {
318 fn default() -> Self {
319 Self {
320 network_allowlist: Vec::new(),
321 allowed_credentials: Vec::new(),
322 allowed_policies: default_allowed_policies(),
323 }
324 }
325}
326
327pub fn expand_tilde(path: &str) -> std::path::PathBuf {
329 if let Some(rest) = path.strip_prefix("~/")
330 && let Ok(home) = std::env::var("HOME")
331 {
332 return std::path::PathBuf::from(home).join(rest);
333 }
334 std::path::PathBuf::from(path)
335}
336
337impl ClawboxConfig {
338 pub fn apply_env_overrides(&mut self) {
340 if let Ok(host) = std::env::var("CLAWBOX_HOST") {
341 self.server.host = host;
342 }
343 if let Ok(port) = std::env::var("CLAWBOX_PORT")
344 && let Ok(p) = port.parse::<u16>()
345 {
346 self.server.port = p;
347 }
348 if let Ok(token) = std::env::var("CLAWBOX_AUTH_TOKEN") {
349 self.server.auth_token = token;
350 }
351 if let Ok(tool_dir) = std::env::var("CLAWBOX_TOOL_DIR") {
352 self.sandbox.tool_dir = tool_dir;
353 }
354 if let Ok(level) = std::env::var("CLAWBOX_LOG_LEVEL") {
355 self.logging.level = level;
356 }
357 }
358
359 pub fn validate(&self) -> Result<(), ConfigError> {
361 if self.server.port == 0 {
362 return Err(ConfigError::Validation(
363 "invalid port 0 in [server]: must be 1-65535".into(),
364 ));
365 }
366 if self.server.auth_token.is_empty() {
367 return Err(ConfigError::Validation(
368 "auth_token in [server] must not be empty".into(),
369 ));
370 }
371 if self.sandbox.default_fuel == 0 {
372 return Err(ConfigError::Validation(
373 "default_fuel in [sandbox] must be positive".into(),
374 ));
375 }
376 if self.sandbox.default_timeout_ms == 0 {
377 return Err(ConfigError::Validation(
378 "default_timeout_ms in [sandbox] must be positive".into(),
379 ));
380 }
381 if self.server.max_concurrent_executions == 0 {
382 return Err(ConfigError::Validation(
383 "max_concurrent_executions in [server] must be positive".into(),
384 ));
385 }
386 Ok(())
387 }
388
389 pub fn expand_paths(&mut self) {
391 self.sandbox.tool_dir = expand_tilde(&self.sandbox.tool_dir)
392 .to_string_lossy()
393 .into_owned();
394 self.credentials.store_path = expand_tilde(&self.credentials.store_path)
395 .to_string_lossy()
396 .into_owned();
397 self.logging.audit_dir = expand_tilde(&self.logging.audit_dir)
398 .to_string_lossy()
399 .into_owned();
400 self.containers.workspace_root = expand_tilde(&self.containers.workspace_root)
401 .to_string_lossy()
402 .into_owned();
403 }
404}
405
406#[derive(Debug, Clone, Deserialize, Serialize)]
408#[non_exhaustive]
409pub struct ToolsConfig {
410 #[serde(default = "default_tool_language")]
412 pub default_language: String,
413}
414
415impl Default for ToolsConfig {
416 fn default() -> Self {
417 Self {
418 default_language: default_tool_language(),
419 }
420 }
421}
422fn default_tool_language() -> String {
423 "rust".into()
424}
425
426#[derive(Debug, Clone, Deserialize, Serialize)]
428#[non_exhaustive]
429#[derive(Default)]
430pub struct ImagesConfig {
431 #[serde(default)]
433 pub templates: std::collections::HashMap<String, ImageTemplate>,
434}
435
436#[derive(Debug, Clone, Deserialize, Serialize)]
438#[non_exhaustive]
439pub struct ImageTemplate {
440 pub image: String,
442 #[serde(default)]
444 pub description: String,
445 #[serde(default)]
447 pub network_allowlist: Vec<String>,
448 #[serde(default)]
450 pub credentials: Vec<String>,
451 #[serde(default)]
453 pub command: Option<Vec<String>>,
454 #[serde(default)]
456 pub max_lifetime_ms: Option<u64>,
457}
458
459#[cfg(test)]
460mod config_tests {
461 use super::*;
462 use serial_test::serial;
463
464 #[test]
465 fn test_default_config_is_valid() {
466 let config = ClawboxConfig::default_config();
467 assert!(config.validate().is_ok());
468 }
469
470 #[test]
471 fn test_validation_catches_zero_fuel() {
472 let mut config = ClawboxConfig::default_config();
473 config.sandbox.default_fuel = 0;
474 assert!(config.validate().is_err());
475 }
476
477 #[test]
478 fn test_validation_catches_empty_token() {
479 let mut config = ClawboxConfig::default_config();
480 config.server.auth_token = String::new();
481 assert!(config.validate().is_err());
482 }
483
484 #[test]
485 fn test_expand_tilde() {
486 let home = std::env::var("HOME").unwrap_or_else(|_| "/home/test".into());
487 let expanded = expand_tilde("~/foo/bar");
488 assert_eq!(
489 expanded,
490 std::path::PathBuf::from(format!("{home}/foo/bar"))
491 );
492
493 let no_tilde = expand_tilde("/abs/path");
494 assert_eq!(no_tilde, std::path::PathBuf::from("/abs/path"));
495 }
496
497 #[test]
498 #[serial]
499 fn test_env_overrides() {
500 let mut config = ClawboxConfig::default_config();
501 unsafe { std::env::set_var("CLAWBOX_PORT", "9999") };
502 config.apply_env_overrides();
503 assert_eq!(config.server.port, 9999);
504 unsafe { std::env::remove_var("CLAWBOX_PORT") };
505 }
506
507 #[test]
508 fn test_default_allowed_policies_are_snake_case() {
509 let policies = default_allowed_policies();
510 assert!(policies.contains(&"wasm_only".to_string()));
511 assert!(policies.contains(&"container".to_string()));
512 }
513
514 #[test]
515 fn test_tools_config_default() {
516 let config = ToolsConfig::default();
517 assert_eq!(config.default_language, "rust");
518 }
519
520 #[test]
521 fn test_tools_config_parsing() {
522 let toml = r#"
523
524 default_language = "js"
525 "#;
526 let tools: ToolsConfig = toml::from_str(toml).unwrap();
527 assert_eq!(tools.default_language, "js");
528 }
529
530 #[test]
531 fn test_images_config_default() {
532 let config = ImagesConfig::default();
533 assert!(config.templates.is_empty());
534 }
535
536 #[test]
537 fn test_image_template_parsing() {
538 let toml = r#"
539 [templates.example]
540 image = "alpine:latest"
541 description = "Test template"
542 network_allowlist = ["example.com"]
543 credentials = ["GITHUB_TOKEN"]
544 command = ["/bin/sh"]
545 max_lifetime_ms = 60000
546 "#;
547 let images: ImagesConfig = toml::from_str(toml).unwrap();
548 let template = images.templates.get("example").unwrap();
549 assert_eq!(template.image, "alpine:latest");
550 assert_eq!(template.description, "Test template");
551 assert_eq!(template.network_allowlist, vec!["example.com"]);
552 assert_eq!(template.credentials, vec!["GITHUB_TOKEN"]);
553 assert_eq!(template.command, Some(vec!["/bin/sh".to_string()]));
554 assert_eq!(template.max_lifetime_ms, Some(60000));
555 }
556
557 #[test]
558 fn test_image_template_minimal() {
559 let toml = r#"
560 [templates.minimal]
561 image = "busybox"
562 "#;
563 let images: ImagesConfig = toml::from_str(toml).unwrap();
564 let template = images.templates.get("minimal").unwrap();
565 assert_eq!(template.image, "busybox");
566 assert!(template.description.is_empty());
567 assert!(template.network_allowlist.is_empty());
568 assert!(template.credentials.is_empty());
569 assert_eq!(template.command, None);
570 assert_eq!(template.max_lifetime_ms, None);
571 }
572}