Skip to main content

openclaw_core/config/
mod.rs

1//! Configuration loading and validation.
2//!
3//! Supports JSON5 format for compatibility with existing `OpenClaw` config.
4//! Config location: `~/.openclaw/openclaw.json`
5
6use serde::{Deserialize, Serialize};
7use std::collections::HashMap;
8use std::path::{Path, PathBuf};
9use thiserror::Error;
10
11/// Configuration errors.
12#[derive(Error, Debug)]
13pub enum ConfigError {
14    /// IO error reading config file.
15    #[error("IO error: {0}")]
16    Io(#[from] std::io::Error),
17
18    /// JSON5 parsing error.
19    #[error("Parse error: {0}")]
20    Parse(#[from] json5::Error),
21
22    /// Config validation error.
23    #[error("Validation error: {0}")]
24    Validation(String),
25
26    /// Missing required field.
27    #[error("Missing required field: {0}")]
28    MissingField(String),
29}
30
31/// Main configuration structure.
32///
33/// Matches the existing `OpenClaw` JSON5 config schema.
34#[derive(Debug, Clone, Serialize, Deserialize)]
35#[serde(rename_all = "camelCase")]
36#[derive(Default)]
37pub struct Config {
38    /// Gateway configuration.
39    #[serde(default)]
40    pub gateway: GatewayConfig,
41
42    /// Agent configurations by ID.
43    #[serde(default)]
44    pub agents: HashMap<String, AgentConfig>,
45
46    /// Channel configurations.
47    #[serde(default)]
48    pub channels: ChannelsConfig,
49
50    /// Provider configurations.
51    #[serde(default)]
52    pub providers: ProvidersConfig,
53
54    /// Global settings.
55    #[serde(default)]
56    pub settings: GlobalSettings,
57}
58
59impl Config {
60    /// Load configuration from the default location.
61    ///
62    /// # Errors
63    ///
64    /// Returns error if config cannot be loaded or parsed.
65    pub fn load_default() -> Result<Self, ConfigError> {
66        let path = Self::default_path();
67        if path.exists() {
68            Self::load(&path)
69        } else {
70            Ok(Self::default())
71        }
72    }
73
74    /// Load configuration from a specific path.
75    ///
76    /// # Errors
77    ///
78    /// Returns error if file cannot be read or parsed.
79    pub fn load(path: &Path) -> Result<Self, ConfigError> {
80        let content = std::fs::read_to_string(path)?;
81        let config: Self = json5::from_str(&content)?;
82        config.validate()?;
83        Ok(config)
84    }
85
86    /// Save configuration to a path.
87    ///
88    /// # Errors
89    ///
90    /// Returns error if serialization or file write fails.
91    pub fn save(&self, path: &Path) -> Result<(), ConfigError> {
92        // Ensure parent directory exists
93        if let Some(parent) = path.parent() {
94            std::fs::create_dir_all(parent)?;
95        }
96
97        let content = serde_json::to_string_pretty(self)
98            .map_err(|e| ConfigError::Validation(e.to_string()))?;
99        std::fs::write(path, content)?;
100        Ok(())
101    }
102
103    /// Get the default config file path.
104    #[must_use]
105    pub fn default_path() -> PathBuf {
106        Self::state_dir().join("openclaw.json")
107    }
108
109    /// Get the `OpenClaw` state directory.
110    ///
111    /// Uses `OPENCLAW_STATE_DIR` env var if set, otherwise `~/.openclaw`.
112    #[must_use]
113    pub fn state_dir() -> PathBuf {
114        if let Ok(dir) = std::env::var("OPENCLAW_STATE_DIR") {
115            PathBuf::from(dir)
116        } else if let Some(home) = dirs::home_dir() {
117            home.join(".openclaw")
118        } else {
119            PathBuf::from(".openclaw")
120        }
121    }
122
123    /// Get the credentials directory.
124    #[must_use]
125    pub fn credentials_dir() -> PathBuf {
126        Self::state_dir().join("credentials")
127    }
128
129    /// Get the sessions directory.
130    #[must_use]
131    pub fn sessions_dir() -> PathBuf {
132        Self::state_dir().join("sessions")
133    }
134
135    /// Get the agents directory.
136    #[must_use]
137    pub fn agents_dir() -> PathBuf {
138        Self::state_dir().join("agents")
139    }
140
141    /// Validate the configuration.
142    fn validate(&self) -> Result<(), ConfigError> {
143        // Validate gateway port
144        if self.gateway.port == 0 {
145            return Err(ConfigError::Validation(
146                "Gateway port cannot be 0".to_string(),
147            ));
148        }
149
150        // Validate agent configs
151        for (id, agent) in &self.agents {
152            if agent.model.is_empty() {
153                return Err(ConfigError::Validation(format!(
154                    "Agent '{id}' has empty model"
155                )));
156            }
157        }
158
159        Ok(())
160    }
161
162    /// Get agent config by ID, falling back to default.
163    #[must_use]
164    pub fn get_agent(&self, id: &str) -> AgentConfig {
165        self.agents
166            .get(id)
167            .cloned()
168            .unwrap_or_else(AgentConfig::default)
169    }
170}
171
172/// Gateway server configuration.
173#[derive(Debug, Clone, Serialize, Deserialize)]
174#[serde(rename_all = "camelCase")]
175pub struct GatewayConfig {
176    /// Port to listen on.
177    #[serde(default = "default_port")]
178    pub port: u16,
179
180    /// Bind address mode.
181    #[serde(default)]
182    pub mode: BindMode,
183
184    /// Enable CORS.
185    #[serde(default = "default_true")]
186    pub cors: bool,
187
188    /// Request timeout in seconds.
189    #[serde(default = "default_timeout")]
190    pub timeout_secs: u64,
191}
192
193impl Default for GatewayConfig {
194    fn default() -> Self {
195        Self {
196            port: default_port(),
197            mode: BindMode::default(),
198            cors: true,
199            timeout_secs: default_timeout(),
200        }
201    }
202}
203
204const fn default_port() -> u16 {
205    18789
206}
207
208const fn default_timeout() -> u64 {
209    300
210}
211
212const fn default_true() -> bool {
213    true
214}
215
216/// Gateway bind mode.
217#[derive(Debug, Clone, Default, Serialize, Deserialize)]
218#[serde(rename_all = "lowercase")]
219pub enum BindMode {
220    /// Bind to localhost only.
221    #[default]
222    Local,
223    /// Bind to all interfaces.
224    Public,
225    /// Custom bind address.
226    Custom(String),
227}
228
229/// Agent configuration.
230#[derive(Debug, Clone, Serialize, Deserialize)]
231#[serde(rename_all = "camelCase")]
232pub struct AgentConfig {
233    /// Model to use (e.g., "claude-3-5-sonnet-20241022").
234    #[serde(default = "default_model")]
235    pub model: String,
236
237    /// Provider to use.
238    #[serde(default = "default_provider")]
239    pub provider: String,
240
241    /// System prompt.
242    #[serde(default)]
243    pub system_prompt: Option<String>,
244
245    /// Maximum tokens in response.
246    #[serde(default = "default_max_tokens")]
247    pub max_tokens: u32,
248
249    /// Temperature for sampling.
250    #[serde(default = "default_temperature")]
251    pub temperature: f32,
252
253    /// Enabled tools.
254    #[serde(default)]
255    pub tools: Vec<String>,
256
257    /// Allowlist patterns for this agent.
258    #[serde(default)]
259    pub allowlist: Vec<AllowlistEntry>,
260}
261
262impl Default for AgentConfig {
263    fn default() -> Self {
264        Self {
265            model: default_model(),
266            provider: default_provider(),
267            system_prompt: None,
268            max_tokens: default_max_tokens(),
269            temperature: default_temperature(),
270            tools: vec![],
271            allowlist: vec![],
272        }
273    }
274}
275
276fn default_model() -> String {
277    "claude-3-5-sonnet-20241022".to_string()
278}
279
280fn default_provider() -> String {
281    "anthropic".to_string()
282}
283
284const fn default_max_tokens() -> u32 {
285    4096
286}
287
288const fn default_temperature() -> f32 {
289    0.7
290}
291
292/// Allowlist entry for agent access control.
293#[derive(Debug, Clone, Serialize, Deserialize)]
294#[serde(rename_all = "camelCase")]
295pub struct AllowlistEntry {
296    /// Channel pattern (e.g., "telegram", "*").
297    pub channel: String,
298
299    /// Peer ID pattern (e.g., "123456789", "*").
300    pub peer_id: String,
301
302    /// Optional label for this entry.
303    #[serde(default)]
304    pub label: Option<String>,
305}
306
307/// Channel configurations.
308#[derive(Debug, Clone, Default, Serialize, Deserialize)]
309#[serde(rename_all = "camelCase")]
310pub struct ChannelsConfig {
311    /// Telegram channel config.
312    #[serde(default)]
313    pub telegram: Option<TelegramConfig>,
314
315    /// Discord channel config.
316    #[serde(default)]
317    pub discord: Option<DiscordConfig>,
318
319    /// Slack channel config.
320    #[serde(default)]
321    pub slack: Option<SlackConfig>,
322
323    /// Signal channel config.
324    #[serde(default)]
325    pub signal: Option<SignalConfig>,
326
327    /// Matrix channel config.
328    #[serde(default)]
329    pub matrix: Option<MatrixConfig>,
330}
331
332/// Telegram channel configuration.
333#[derive(Debug, Clone, Serialize, Deserialize)]
334#[serde(rename_all = "camelCase")]
335pub struct TelegramConfig {
336    /// Bot token.
337    pub bot_token: Option<String>,
338
339    /// Enable webhook mode.
340    #[serde(default)]
341    pub webhook: bool,
342
343    /// Webhook URL (if webhook mode).
344    #[serde(default)]
345    pub webhook_url: Option<String>,
346}
347
348/// Discord channel configuration.
349#[derive(Debug, Clone, Serialize, Deserialize)]
350#[serde(rename_all = "camelCase")]
351pub struct DiscordConfig {
352    /// Bot token.
353    pub bot_token: Option<String>,
354
355    /// Application ID.
356    pub application_id: Option<String>,
357}
358
359/// Slack channel configuration.
360#[derive(Debug, Clone, Serialize, Deserialize)]
361#[serde(rename_all = "camelCase")]
362pub struct SlackConfig {
363    /// Bot token.
364    pub bot_token: Option<String>,
365
366    /// App token (for socket mode).
367    pub app_token: Option<String>,
368}
369
370/// Signal channel configuration.
371#[derive(Debug, Clone, Serialize, Deserialize)]
372#[serde(rename_all = "camelCase")]
373pub struct SignalConfig {
374    /// Phone number.
375    pub phone_number: Option<String>,
376}
377
378/// Matrix channel configuration.
379#[derive(Debug, Clone, Serialize, Deserialize)]
380#[serde(rename_all = "camelCase")]
381pub struct MatrixConfig {
382    /// Homeserver URL.
383    pub homeserver: Option<String>,
384
385    /// User ID.
386    pub user_id: Option<String>,
387
388    /// Access token.
389    pub access_token: Option<String>,
390}
391
392/// Provider configurations.
393#[derive(Debug, Clone, Default, Serialize, Deserialize)]
394#[serde(rename_all = "camelCase")]
395pub struct ProvidersConfig {
396    /// Anthropic configuration.
397    #[serde(default)]
398    pub anthropic: Option<AnthropicConfig>,
399
400    /// `OpenAI` configuration.
401    #[serde(default)]
402    pub openai: Option<OpenAIConfig>,
403
404    /// Ollama configuration.
405    #[serde(default)]
406    pub ollama: Option<OllamaConfig>,
407}
408
409/// Anthropic provider configuration.
410#[derive(Debug, Clone, Serialize, Deserialize)]
411#[serde(rename_all = "camelCase")]
412pub struct AnthropicConfig {
413    /// API key (prefer credential store).
414    pub api_key: Option<String>,
415
416    /// Base URL override.
417    #[serde(default)]
418    pub base_url: Option<String>,
419}
420
421/// `OpenAI` provider configuration.
422#[derive(Debug, Clone, Serialize, Deserialize)]
423#[serde(rename_all = "camelCase")]
424pub struct OpenAIConfig {
425    /// API key (prefer credential store).
426    pub api_key: Option<String>,
427
428    /// Base URL override.
429    #[serde(default)]
430    pub base_url: Option<String>,
431
432    /// Organization ID.
433    #[serde(default)]
434    pub org_id: Option<String>,
435}
436
437/// Ollama provider configuration.
438#[derive(Debug, Clone, Serialize, Deserialize)]
439#[serde(rename_all = "camelCase")]
440pub struct OllamaConfig {
441    /// Base URL.
442    #[serde(default = "default_ollama_url")]
443    pub base_url: String,
444}
445
446fn default_ollama_url() -> String {
447    "http://localhost:11434".to_string()
448}
449
450/// Global settings.
451#[derive(Debug, Clone, Serialize, Deserialize)]
452#[serde(rename_all = "camelCase")]
453#[derive(Default)]
454pub struct GlobalSettings {
455    /// Enable debug logging.
456    #[serde(default)]
457    pub debug: bool,
458
459    /// Log format.
460    #[serde(default)]
461    pub log_format: LogFormat,
462
463    /// Telemetry enabled.
464    #[serde(default)]
465    pub telemetry: bool,
466}
467
468/// Log format.
469#[derive(Debug, Clone, Default, Serialize, Deserialize)]
470#[serde(rename_all = "lowercase")]
471pub enum LogFormat {
472    /// Human-readable format.
473    #[default]
474    Pretty,
475    /// JSON format.
476    Json,
477}
478
479#[cfg(test)]
480mod tests {
481    use super::*;
482    use tempfile::tempdir;
483
484    #[test]
485    fn test_default_config() {
486        let config = Config::default();
487        assert_eq!(config.gateway.port, 18789);
488    }
489
490    #[test]
491    fn test_config_roundtrip() {
492        let temp = tempdir().unwrap();
493        let path = temp.path().join("config.json");
494
495        let mut config = Config::default();
496        config.agents.insert(
497            "test".to_string(),
498            AgentConfig {
499                model: "gpt-4".to_string(),
500                ..Default::default()
501            },
502        );
503
504        config.save(&path).unwrap();
505
506        let loaded = Config::load(&path).unwrap();
507        assert_eq!(loaded.agents.get("test").unwrap().model, "gpt-4");
508    }
509
510    #[test]
511    fn test_json5_parsing() {
512        let json5_content = r#"{
513            // This is a comment
514            gateway: {
515                port: 8080,
516            },
517            agents: {
518                default: {
519                    model: "claude-3-5-sonnet-20241022",
520                    // trailing comma
521                },
522            },
523        }"#;
524
525        let config: Config = json5::from_str(json5_content).unwrap();
526        assert_eq!(config.gateway.port, 8080);
527    }
528
529    #[test]
530    fn test_config_validation() {
531        let mut config = Config::default();
532        config.gateway.port = 0;
533
534        let result = config.validate();
535        assert!(result.is_err());
536    }
537
538    #[test]
539    fn test_state_dir() {
540        let dir = Config::state_dir();
541        assert!(dir.to_str().unwrap().contains("openclaw"));
542    }
543}