Skip to main content

clawbox_server/
config.rs

1//! Configuration loading for clawbox.
2
3use serde::{Deserialize, Serialize};
4use thiserror::Error;
5
6/// Errors from configuration loading.
7#[derive(Debug, Error)]
8#[non_exhaustive]
9pub enum ConfigError {
10    /// Failed to read the configuration file.
11    #[error("failed to read config from {path}: {source}")]
12    Read {
13        path: String,
14        source: std::io::Error,
15    },
16    /// Failed to parse the configuration file.
17    #[error("failed to parse config from {path}: {source}")]
18    Parse {
19        path: String,
20        source: toml::de::Error,
21    },
22    /// Configuration value is invalid.
23    #[error("config validation error: {0}")]
24    Validation(String),
25}
26
27#[derive(Debug, Clone, Deserialize)]
28#[non_exhaustive]
29pub struct ClawboxConfig {
30    /// HTTP server configuration (host, port, auth).
31    #[serde(default)]
32    pub server: ServerConfig,
33    /// WASM sandbox configuration (fuel, timeouts, tool directory).
34    #[serde(default)]
35    pub sandbox: SandboxConfig,
36    /// Outbound HTTP proxy configuration (response limits, timeouts).
37    #[serde(default)]
38    pub proxy: ProxyConfig,
39    /// Credential store configuration (encryption, storage path).
40    #[serde(default)]
41    pub credentials: CredentialsConfig,
42    /// Logging and audit trail configuration.
43    #[serde(default)]
44    pub logging: LoggingConfig,
45    /// Container management configuration (limits, workspace).
46    #[serde(default)]
47    pub containers: ContainerConfig,
48    /// Server-side container security policy.
49    #[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    /// Address to bind the HTTP listener to.
61    #[serde(default = "default_host")]
62    pub host: String,
63    /// TCP port for the HTTP listener.
64    #[serde(default = "default_port")]
65    pub port: u16,
66    /// Bearer token required for API authentication.
67    #[serde(default = "default_token")]
68    pub auth_token: String,
69    /// Path for Unix domain socket listener (same-machine fast path).
70    /// If set, the server listens on both TCP and this socket.
71    #[serde(default)]
72    pub unix_socket: Option<String>,
73    /// Maximum number of concurrent tool executions.
74    #[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    /// Directory containing WASM tool modules.
123    #[serde(default = "default_tool_dir")]
124    pub tool_dir: String,
125    /// Default fuel limit for WASM execution.
126    #[serde(default = "default_fuel")]
127    pub default_fuel: u64,
128    /// Default execution timeout in milliseconds.
129    #[serde(default = "default_timeout")]
130    pub default_timeout_ms: u64,
131    /// Whether to watch the tool directory for hot-reload.
132    #[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    /// Maximum response body size in bytes.
164    #[serde(default = "default_max_response")]
165    pub max_response_bytes: usize,
166    /// Default proxy request timeout in milliseconds.
167    #[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    /// Path to the encrypted credential store file.
188    #[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    /// Log output format (`json` or `text`).
208    #[serde(default = "default_format")]
209    pub format: String,
210    /// Minimum log level (`trace`, `debug`, `info`, `warn`, `error`).
211    #[serde(default = "default_level")]
212    pub level: String,
213    /// Directory for audit log files.
214    #[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    /// Create a default config (useful for tests).
252    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    /// Maximum number of concurrent containers.
271    #[serde(default = "default_max_containers")]
272    pub max_containers: usize,
273    /// Root directory for container workspace mounts.
274    #[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/// Server-side policy for container capabilities.
295///
296/// These settings override any client-requested values, preventing
297/// authenticated clients from escalating their own privileges.
298#[derive(Debug, Clone, Serialize, Deserialize)]
299#[non_exhaustive]
300pub struct ContainerPolicy {
301    /// Server-side network allowlist for containers (overrides client requests).
302    #[serde(default)]
303    pub network_allowlist: Vec<String>,
304    /// Credentials containers are allowed to request.
305    #[serde(default)]
306    pub allowed_credentials: Vec<String>,
307    /// Sandbox policies that containers are allowed to use.
308    /// Defaults to [WasmOnly, Container] — excludes ContainerDirect.
309    #[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
327/// Expand leading `~/` in a path to the user's home directory.
328pub 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    /// Apply environment variable overrides to the loaded configuration.
339    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    /// Validate configuration values. Returns actionable error messages.
360    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    /// Expand tilde in all path-like config fields.
390    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/// Configuration for tool management.
407#[derive(Debug, Clone, Deserialize, Serialize)]
408#[non_exhaustive]
409pub struct ToolsConfig {
410    /// Default language for scaffolding new tools (rust, js, ts).
411    #[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/// Configuration for container image templates.
427#[derive(Debug, Clone, Deserialize, Serialize)]
428#[non_exhaustive]
429#[derive(Default)]
430pub struct ImagesConfig {
431    /// Named image templates for easy container spawning.
432    #[serde(default)]
433    pub templates: std::collections::HashMap<String, ImageTemplate>,
434}
435
436/// A named Docker image template with pre-configured capabilities.
437#[derive(Debug, Clone, Deserialize, Serialize)]
438#[non_exhaustive]
439pub struct ImageTemplate {
440    /// Docker image name and tag.
441    pub image: String,
442    /// Human-readable description of this template.
443    #[serde(default)]
444    pub description: String,
445    /// Network allowlist for this template.
446    #[serde(default)]
447    pub network_allowlist: Vec<String>,
448    /// Credentials this template can access.
449    #[serde(default)]
450    pub credentials: Vec<String>,
451    /// Override command for the container.
452    #[serde(default)]
453    pub command: Option<Vec<String>>,
454    /// Maximum container lifetime in milliseconds.
455    #[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}