Skip to main content

rustyclaw_core/
config.rs

1use anyhow::Result;
2use serde::{Deserialize, Serialize};
3use std::collections::HashMap;
4use std::path::PathBuf;
5
6use crate::memory_flush::MemoryFlushConfig;
7use crate::workspace_context::WorkspaceContextConfig;
8
9#[derive(Debug, Clone, Serialize, Deserialize)]
10pub struct ModelProvider {
11    /// Provider id (e.g. "anthropic", "openai", "google", "ollama", "custom")
12    pub provider: String,
13    /// Default model name (e.g. "claude-sonnet-4-20250514")
14    pub model: Option<String>,
15    /// API base URL (only required for custom/proxy providers)
16    pub base_url: Option<String>,
17}
18
19/// Sandbox configuration for agent isolation.
20#[derive(Debug, Clone, Serialize, Deserialize, Default)]
21pub struct SandboxConfig {
22    /// Sandbox mode: "none", "path", "bwrap", "landlock"
23    #[serde(default)]
24    pub mode: String,
25    /// Additional paths to deny (beyond credentials dir)
26    #[serde(default)]
27    pub deny_paths: Vec<PathBuf>,
28    /// Paths to allow in strict mode
29    #[serde(default)]
30    pub allow_paths: Vec<PathBuf>,
31}
32
33#[derive(Debug, Clone, Serialize, Deserialize)]
34pub struct Config {
35    /// Root state directory (e.g. `~/.rustyclaw`).
36    /// All other paths are derived from this unless explicitly overridden.
37    pub settings_dir: PathBuf,
38    /// Path to SOUL.md file (default: `<workspace_dir>/SOUL.md`)
39    pub soul_path: Option<PathBuf>,
40    /// Skills directory (default: `<workspace_dir>/skills`)
41    pub skills_dir: Option<PathBuf>,
42    /// Agent workspace directory (default: `<settings_dir>/workspace`)
43    pub workspace_dir: Option<PathBuf>,
44    /// Credentials directory (default: `<settings_dir>/credentials`)
45    pub credentials_dir: Option<PathBuf>,
46    /// Messenger configurations
47    pub messengers: Vec<MessengerConfig>,
48    /// Whether to use secrets storage
49    pub use_secrets: bool,
50    /// Gateway WebSocket URL for the TUI to connect to
51    #[serde(default)]
52    pub gateway_url: Option<String>,
53    /// Selected model provider and default model
54    #[serde(default)]
55    pub model: Option<ModelProvider>,
56    /// Whether the secrets vault is encrypted with a user password
57    /// (as opposed to an auto-generated key file).
58    #[serde(default)]
59    pub secrets_password_protected: bool,
60    /// Whether TOTP two-factor authentication is enabled for the vault.
61    #[serde(default)]
62    pub totp_enabled: bool,
63    /// Whether the agent is allowed to access secrets on behalf of the user.
64    #[serde(default)]
65    pub agent_access: bool,
66    /// User-chosen name for this agent instance (shown in TUI title,
67    /// authenticator app labels, etc.).  Defaults to "RustyClaw".
68    #[serde(default = "Config::default_agent_name")]
69    pub agent_name: String,
70    /// Number of blank lines inserted between messages in the TUI.
71    /// Set to 0 for compact output, 1 (default) for comfortable spacing.
72    #[serde(default = "Config::default_message_spacing")]
73    pub message_spacing: u16,
74    /// Number of spaces a tab character occupies in the TUI.
75    /// Defaults to 5.
76    #[serde(default = "Config::default_tab_width")]
77    pub tab_width: u16,
78    /// Sandbox configuration for agent isolation.
79    #[serde(default)]
80    pub sandbox: SandboxConfig,
81    /// ClawHub registry URL (default: `https://registry.clawhub.dev/api/v1`).
82    #[serde(default)]
83    pub clawhub_url: Option<String>,
84    /// ClawHub API token for publishing / authenticated downloads.
85    #[serde(default)]
86    pub clawhub_token: Option<String>,
87    /// System prompt for the agent (used for messenger conversations).
88    #[serde(default)]
89    pub system_prompt: Option<String>,
90    /// Messenger polling interval in milliseconds (default: 2000).
91    #[serde(default)]
92    pub messenger_poll_interval_ms: Option<u32>,
93    /// Per-tool permission overrides. Tools not listed here default to Allow.
94    #[serde(default)]
95    pub tool_permissions: HashMap<String, crate::tools::ToolPermission>,
96    /// Path to TLS certificate file (PEM) for WSS gateway connections.
97    #[serde(default)]
98    pub tls_cert: Option<PathBuf>,
99    /// Path to TLS private key file (PEM) for WSS gateway connections.
100    #[serde(default)]
101    pub tls_key: Option<PathBuf>,
102    /// Pre-compaction memory flush configuration.
103    #[serde(default)]
104    pub memory_flush: MemoryFlushConfig,
105    /// Workspace context injection configuration.
106    #[serde(default)]
107    pub workspace_context: WorkspaceContextConfig,
108}
109
110/// Configuration for a messenger backend.
111#[derive(Debug, Clone, Default, Serialize, Deserialize)]
112pub struct MessengerConfig {
113    /// Display name for this messenger instance.
114    #[serde(default)]
115    pub name: String,
116    /// Messenger type: telegram, discord, signal, matrix, webhook.
117    #[serde(default)]
118    pub messenger_type: String,
119    /// Whether this messenger is enabled.
120    #[serde(default = "default_true")]
121    pub enabled: bool,
122    /// Path to external config file (optional).
123    #[serde(default)]
124    pub config_path: Option<PathBuf>,
125    /// Bot/API token (Telegram, Discord).
126    #[serde(default)]
127    pub token: Option<String>,
128    /// Webhook URL (for webhook messenger).
129    #[serde(default)]
130    pub webhook_url: Option<String>,
131    /// Matrix homeserver URL.
132    #[serde(default)]
133    pub homeserver: Option<String>,
134    /// Matrix user ID (@user:homeserver).
135    #[serde(default)]
136    pub user_id: Option<String>,
137    /// Password (Matrix).
138    #[serde(default)]
139    pub password: Option<String>,
140    /// Access token (Matrix).
141    #[serde(default)]
142    pub access_token: Option<String>,
143    /// Phone number (Signal).
144    #[serde(default)]
145    pub phone: Option<String>,
146    /// Allowed chat IDs/channels (whitelist).
147    #[serde(default)]
148    pub allowed_chats: Vec<String>,
149    /// Allowed user IDs (whitelist).
150    #[serde(default)]
151    pub allowed_users: Vec<String>,
152}
153
154fn default_true() -> bool {
155    true
156}
157
158impl Default for Config {
159    fn default() -> Self {
160        let home_dir = dirs::home_dir().unwrap_or_else(|| PathBuf::from("."));
161        Self {
162            settings_dir: home_dir.join(".rustyclaw"),
163            soul_path: None,
164            skills_dir: None,
165            workspace_dir: None,
166            credentials_dir: None,
167            messengers: Vec::new(),
168            use_secrets: true,
169            gateway_url: None,
170            model: None,
171            secrets_password_protected: false,
172            totp_enabled: false,
173            agent_access: false,
174            agent_name: Self::default_agent_name(),
175            message_spacing: Self::default_message_spacing(),
176            tab_width: Self::default_tab_width(),
177            sandbox: SandboxConfig::default(),
178            clawhub_url: None,
179            clawhub_token: None,
180            system_prompt: None,
181            messenger_poll_interval_ms: None,
182            tool_permissions: HashMap::new(),
183            tls_cert: None,
184            tls_key: None,
185            memory_flush: MemoryFlushConfig::default(),
186            workspace_context: WorkspaceContextConfig::default(),
187        }
188    }
189}
190
191impl Config {
192    fn default_agent_name() -> String {
193        "RustyClaw".to_string()
194    }
195
196    fn default_message_spacing() -> u16 {
197        1
198    }
199
200    fn default_tab_width() -> u16 {
201        5
202    }
203
204    // ── Derived path helpers (mirrors openclaw layout) ───────────
205
206    /// Agent workspace directory — holds SOUL.md, skills/, etc.
207    /// Default: `<settings_dir>/workspace`
208    pub fn workspace_dir(&self) -> PathBuf {
209        self.workspace_dir
210            .clone()
211            .unwrap_or_else(|| self.settings_dir.join("workspace"))
212    }
213
214    /// Credentials directory — holds secrets vault, key file, OAuth tokens.
215    /// Default: `<settings_dir>/credentials`
216    pub fn credentials_dir(&self) -> PathBuf {
217        self.credentials_dir
218            .clone()
219            .unwrap_or_else(|| self.settings_dir.join("credentials"))
220    }
221
222    /// Default agent directory — per-agent state (sessions, etc.).
223    /// Default: `<settings_dir>/agents/main`
224    pub fn agent_dir(&self) -> PathBuf {
225        self.settings_dir.join("agents").join("main")
226    }
227
228    /// Sessions directory for the default agent.
229    pub fn sessions_dir(&self) -> PathBuf {
230        self.agent_dir().join("sessions")
231    }
232
233    /// Path to SOUL.md — inside the workspace.
234    pub fn soul_path(&self) -> PathBuf {
235        self.soul_path
236            .clone()
237            .unwrap_or_else(|| self.workspace_dir().join("SOUL.md"))
238    }
239
240    /// Skills directory — inside the workspace.
241    pub fn skills_dir(&self) -> PathBuf {
242        self.skills_dir
243            .clone()
244            .unwrap_or_else(|| self.workspace_dir().join("skills"))
245    }
246
247    /// Returns all skills directories in priority order (lowest to highest).
248    /// Order: bundled OpenClaw → user OpenClaw → user RustyClaw
249    pub fn skills_dirs(&self) -> Vec<PathBuf> {
250        let mut dirs = Vec::new();
251
252        // OpenClaw bundled skills (npm global install)
253        let openclaw_bundled = PathBuf::from("/usr/lib/node_modules/openclaw/skills");
254        if openclaw_bundled.exists() {
255            dirs.push(openclaw_bundled);
256        }
257
258        // OpenClaw user skills
259        if let Some(home) = dirs::home_dir() {
260            let openclaw_user = home.join(".openclaw/workspace/skills");
261            if openclaw_user.exists() {
262                dirs.push(openclaw_user);
263            }
264        }
265
266        // RustyClaw user skills (highest priority)
267        dirs.push(self.skills_dir());
268
269        dirs
270    }
271
272    /// Logs directory.
273    pub fn logs_dir(&self) -> PathBuf {
274        self.settings_dir.join("logs")
275    }
276
277    /// Ensure the entire directory skeleton exists on disk.
278    pub fn ensure_dirs(&self) -> Result<()> {
279        let dirs = [
280            self.settings_dir.clone(),
281            self.workspace_dir(),
282            self.credentials_dir(),
283            self.agent_dir(),
284            self.sessions_dir(),
285            self.skills_dir(),
286            self.logs_dir(),
287        ];
288        for d in &dirs {
289            std::fs::create_dir_all(d)?;
290        }
291        Ok(())
292    }
293
294    // ── Load / save ─────────────────────────────────────────────────
295
296    /// Load configuration from file, with OpenClaw compatibility
297    pub fn load(path: Option<PathBuf>) -> Result<Self> {
298        let config_path = if let Some(p) = path {
299            p
300        } else {
301            let home_dir = dirs::home_dir().unwrap_or_else(|| PathBuf::from("."));
302            home_dir.join(".rustyclaw").join("config.toml")
303        };
304
305        if config_path.exists() {
306            let content = std::fs::read_to_string(&config_path)?;
307            let mut config: Config = toml::from_str(&content)?;
308            // Migrate legacy flat layout if detected.
309            config.migrate_legacy_layout()?;
310            Ok(config)
311        } else {
312            Ok(Config::default())
313        }
314    }
315
316    /// Save configuration to file
317    pub fn save(&self, path: Option<PathBuf>) -> Result<()> {
318        let config_path = if let Some(p) = path {
319            p
320        } else {
321            self.settings_dir.join("config.toml")
322        };
323
324        if let Some(parent) = config_path.parent() {
325            std::fs::create_dir_all(parent)?;
326        }
327
328        let content = toml::to_string_pretty(self)?;
329        std::fs::write(&config_path, content)?;
330        Ok(())
331    }
332
333    // ── Legacy migration ────────────────────────────────────────────
334
335    /// Detect the pre-restructure flat layout and move files into the
336    /// new openclaw-compatible directory hierarchy.
337    fn migrate_legacy_layout(&mut self) -> Result<()> {
338        let old_secrets = self.settings_dir.join("secrets.json");
339        let old_key = self.settings_dir.join("secrets.key");
340        let old_soul = self.settings_dir.join("SOUL.md");
341        let old_skills = self.settings_dir.join("skills");
342
343        // Only migrate if at least one legacy file exists AND the new
344        // directories have not been created yet.
345        let new_creds = self.credentials_dir();
346        let new_workspace = self.workspace_dir();
347
348        let has_legacy = old_secrets.exists() || old_soul.exists();
349        let already_migrated =
350            new_creds.join("secrets.json").exists() || new_workspace.join("SOUL.md").exists();
351
352        if !has_legacy || already_migrated {
353            return Ok(());
354        }
355
356        eprintln!("Migrating ~/.rustyclaw to new directory layout…");
357
358        // Create target dirs.
359        std::fs::create_dir_all(&new_creds)?;
360        std::fs::create_dir_all(&new_workspace)?;
361
362        // Move secrets vault → credentials/
363        if old_secrets.exists() {
364            let dest = new_creds.join("secrets.json");
365            std::fs::rename(&old_secrets, &dest)?;
366            eprintln!("  secrets.json → credentials/secrets.json");
367        }
368        if old_key.exists() {
369            let dest = new_creds.join("secrets.key");
370            std::fs::rename(&old_key, &dest)?;
371            eprintln!("  secrets.key  → credentials/secrets.key");
372        }
373
374        // Move SOUL.md → workspace/
375        if old_soul.exists() {
376            let dest = new_workspace.join("SOUL.md");
377            std::fs::rename(&old_soul, &dest)?;
378            eprintln!("  SOUL.md      → workspace/SOUL.md");
379        }
380
381        // Move skills/ → workspace/skills/
382        if old_skills.exists() && old_skills.is_dir() {
383            let dest = new_workspace.join("skills");
384            if !dest.exists() {
385                std::fs::rename(&old_skills, &dest)?;
386                eprintln!("  skills/      → workspace/skills/");
387            }
388        }
389
390        // Update any explicit paths in config that pointed at the old locations.
391        if self.soul_path.as_ref() == Some(&self.settings_dir.join("SOUL.md")) {
392            self.soul_path = None; // let the helper derive it
393        }
394        if self.skills_dir.as_ref() == Some(&self.settings_dir.join("skills")) {
395            self.skills_dir = None;
396        }
397
398        // Persist the updated config so we don't migrate again.
399        self.save(None)?;
400
401        eprintln!("Migration complete.");
402        Ok(())
403    }
404}