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    #[serde(default)]
48    pub messengers: Vec<MessengerConfig>,
49    /// Whether to use secrets storage
50    pub use_secrets: bool,
51    /// Gateway WebSocket URL for the TUI to connect to
52    #[serde(default)]
53    pub gateway_url: Option<String>,
54    /// Selected model provider and default model
55    #[serde(default)]
56    pub model: Option<ModelProvider>,
57    /// Whether the secrets vault is encrypted with a user password
58    /// (as opposed to an auto-generated key file).
59    #[serde(default)]
60    pub secrets_password_protected: bool,
61    /// Whether TOTP two-factor authentication is enabled for the vault.
62    #[serde(default)]
63    pub totp_enabled: bool,
64    /// Whether the agent is allowed to access secrets on behalf of the user.
65    #[serde(default)]
66    pub agent_access: bool,
67    /// User-chosen name for this agent instance (shown in TUI title,
68    /// authenticator app labels, etc.).  Defaults to "RustyClaw".
69    #[serde(default = "Config::default_agent_name")]
70    pub agent_name: String,
71    /// Number of blank lines inserted between messages in the TUI.
72    /// Set to 0 for compact output, 1 (default) for comfortable spacing.
73    #[serde(default = "Config::default_message_spacing")]
74    pub message_spacing: u16,
75    /// Number of spaces a tab character occupies in the TUI.
76    /// Defaults to 5.
77    #[serde(default = "Config::default_tab_width")]
78    pub tab_width: u16,
79    /// Sandbox configuration for agent isolation.
80    #[serde(default)]
81    pub sandbox: SandboxConfig,
82    /// ClawHub registry URL (default: `https://registry.clawhub.dev/api/v1`).
83    #[serde(default)]
84    pub clawhub_url: Option<String>,
85    /// ClawHub API token for publishing / authenticated downloads.
86    #[serde(default)]
87    pub clawhub_token: Option<String>,
88    /// System prompt for the agent (used for messenger conversations).
89    #[serde(default)]
90    pub system_prompt: Option<String>,
91    /// Messenger polling interval in milliseconds (default: 2000).
92    #[serde(default)]
93    pub messenger_poll_interval_ms: Option<u32>,
94    /// Per-tool permission overrides. Tools not listed here default to Allow.
95    #[serde(default)]
96    pub tool_permissions: HashMap<String, crate::tools::ToolPermission>,
97    /// Path to TLS certificate file (PEM) for WSS gateway connections.
98    #[serde(default)]
99    pub tls_cert: Option<PathBuf>,
100    /// Path to TLS private key file (PEM) for WSS gateway connections.
101    #[serde(default)]
102    pub tls_key: Option<PathBuf>,
103    /// Pre-compaction memory flush configuration.
104    #[serde(default)]
105    pub memory_flush: MemoryFlushConfig,
106    /// Workspace context injection configuration.
107    #[serde(default)]
108    pub workspace_context: WorkspaceContextConfig,
109    /// Native memory coprocessor (mnemo) configuration.
110    #[serde(default)]
111    pub mnemo: Option<crate::mnemo::MnemoConfig>,
112}
113
114/// Configuration for a messenger backend.
115#[derive(Debug, Clone, Default, Serialize, Deserialize)]
116pub struct MessengerConfig {
117    /// Display name for this messenger instance.
118    #[serde(default)]
119    pub name: String,
120    /// Messenger type: telegram, discord, signal, matrix, webhook.
121    #[serde(default)]
122    pub messenger_type: String,
123    /// Whether this messenger is enabled.
124    #[serde(default = "default_true")]
125    pub enabled: bool,
126    /// Path to external config file (optional).
127    #[serde(default)]
128    pub config_path: Option<PathBuf>,
129    /// Bot/API token (Telegram, Discord).
130    #[serde(default)]
131    pub token: Option<String>,
132    /// Webhook URL (for webhook messenger).
133    #[serde(default)]
134    pub webhook_url: Option<String>,
135    /// Matrix homeserver URL.
136    #[serde(default)]
137    pub homeserver: Option<String>,
138    /// Matrix user ID (@user:homeserver).
139    #[serde(default)]
140    pub user_id: Option<String>,
141    /// Password (Matrix).
142    #[serde(default)]
143    pub password: Option<String>,
144    /// Access token (Matrix).
145    #[serde(default)]
146    pub access_token: Option<String>,
147    /// Phone number (Signal).
148    #[serde(default)]
149    pub phone: Option<String>,
150    /// Allowed chat IDs/channels (whitelist).
151    #[serde(default)]
152    pub allowed_chats: Vec<String>,
153    /// Allowed user IDs (whitelist).
154    #[serde(default)]
155    pub allowed_users: Vec<String>,
156
157    // ── Slack-specific ─────────────────────────────────────────────────
158    /// Slack app-level token for Socket Mode (optional).
159    #[serde(default)]
160    pub app_token: Option<String>,
161    /// Default channel to listen on (Slack).
162    #[serde(default)]
163    pub default_channel: Option<String>,
164
165    // ── IRC-specific ───────────────────────────────────────────────────
166    /// IRC server hostname.
167    #[serde(default)]
168    pub server: Option<String>,
169    /// Port number (IRC, BlueBubbles, etc.).
170    #[serde(default)]
171    pub port: Option<u16>,
172    /// Nickname (IRC).
173    #[serde(default)]
174    pub nick: Option<String>,
175    /// IRC channels to join.
176    #[serde(default)]
177    pub irc_channels: Vec<String>,
178    /// Whether to use TLS (IRC).
179    #[serde(default)]
180    pub use_tls: Option<bool>,
181
182    // ── Google Chat-specific ───────────────────────────────────────────
183    /// Service account credentials path (Google Chat).
184    #[serde(default)]
185    pub credentials_path: Option<String>,
186    /// Google Chat spaces to listen on.
187    #[serde(default)]
188    pub spaces: Vec<String>,
189
190    // ── Teams-specific ─────────────────────────────────────────────────
191    /// Bot Framework app ID (Teams).
192    #[serde(default)]
193    pub app_id: Option<String>,
194    /// Bot Framework app password (Teams).
195    #[serde(default)]
196    pub app_password: Option<String>,
197
198    // ── DM pairing security ────────────────────────────────────────────
199    /// Pairing code required before accepting DMs from unknown users.
200    #[serde(default)]
201    pub pairing_code: Option<String>,
202    /// Whether DM pairing is required for this messenger.
203    #[serde(default)]
204    pub require_pairing: bool,
205    /// List of already-paired user IDs.
206    #[serde(default)]
207    pub paired_users: Vec<String>,
208
209    // ── DM configuration (Matrix, etc.) ────────────────────────────────
210    /// DM handling configuration.
211    #[serde(default)]
212    pub dm: Option<DmConfig>,
213}
214
215/// DM (Direct Message) configuration for messengers.
216#[derive(Debug, Clone, Default, Serialize, Deserialize)]
217pub struct DmConfig {
218    /// Whether DMs are enabled.
219    #[serde(default)]
220    pub enabled: bool,
221    /// DM policy: "allowlist" (only allow_from users), "open" (anyone), "pairing" (require code).
222    #[serde(default)]
223    pub policy: Option<String>,
224    /// List of user IDs allowed to send DMs (for "allowlist" policy).
225    #[serde(default)]
226    pub allow_from: Vec<String>,
227}
228
229fn default_true() -> bool {
230    true
231}
232
233impl Default for Config {
234    fn default() -> Self {
235        let home_dir = dirs::home_dir().unwrap_or_else(|| PathBuf::from("."));
236        Self {
237            settings_dir: home_dir.join(".rustyclaw"),
238            soul_path: None,
239            skills_dir: None,
240            workspace_dir: None,
241            credentials_dir: None,
242            messengers: Vec::new(),
243            use_secrets: true,
244            gateway_url: None,
245            model: None,
246            secrets_password_protected: false,
247            totp_enabled: false,
248            agent_access: false,
249            agent_name: Self::default_agent_name(),
250            message_spacing: Self::default_message_spacing(),
251            tab_width: Self::default_tab_width(),
252            sandbox: SandboxConfig::default(),
253            clawhub_url: None,
254            clawhub_token: None,
255            system_prompt: None,
256            messenger_poll_interval_ms: None,
257            tool_permissions: HashMap::new(),
258            tls_cert: None,
259            tls_key: None,
260            memory_flush: MemoryFlushConfig::default(),
261            workspace_context: WorkspaceContextConfig::default(),
262            mnemo: None,
263        }
264    }
265}
266
267impl Config {
268    fn default_agent_name() -> String {
269        "RustyClaw".to_string()
270    }
271
272    fn default_message_spacing() -> u16 {
273        1
274    }
275
276    fn default_tab_width() -> u16 {
277        5
278    }
279
280    // ── Derived path helpers (mirrors openclaw layout) ───────────
281
282    /// Agent workspace directory — holds SOUL.md, skills/, etc.
283    /// Default: `<settings_dir>/workspace`
284    pub fn workspace_dir(&self) -> PathBuf {
285        self.workspace_dir
286            .clone()
287            .unwrap_or_else(|| self.settings_dir.join("workspace"))
288    }
289
290    /// Credentials directory — holds secrets vault, key file, OAuth tokens.
291    /// Default: `<settings_dir>/credentials`
292    pub fn credentials_dir(&self) -> PathBuf {
293        self.credentials_dir
294            .clone()
295            .unwrap_or_else(|| self.settings_dir.join("credentials"))
296    }
297
298    /// Default agent directory — per-agent state (sessions, etc.).
299    /// Default: `<settings_dir>/agents/main`
300    pub fn agent_dir(&self) -> PathBuf {
301        self.settings_dir.join("agents").join("main")
302    }
303
304    /// Sessions directory for the default agent.
305    pub fn sessions_dir(&self) -> PathBuf {
306        self.agent_dir().join("sessions")
307    }
308
309    /// Path to SOUL.md — inside the workspace.
310    pub fn soul_path(&self) -> PathBuf {
311        self.soul_path
312            .clone()
313            .unwrap_or_else(|| self.workspace_dir().join("SOUL.md"))
314    }
315
316    /// Skills directory — inside the workspace.
317    pub fn skills_dir(&self) -> PathBuf {
318        self.skills_dir
319            .clone()
320            .unwrap_or_else(|| self.workspace_dir().join("skills"))
321    }
322
323    /// Returns all skills directories in priority order (lowest to highest).
324    /// Order: bundled OpenClaw → user OpenClaw → user RustyClaw
325    pub fn skills_dirs(&self) -> Vec<PathBuf> {
326        let mut dirs = Vec::new();
327
328        // OpenClaw bundled skills (npm global install)
329        let openclaw_bundled = PathBuf::from("/usr/lib/node_modules/openclaw/skills");
330        if openclaw_bundled.exists() {
331            dirs.push(openclaw_bundled);
332        }
333
334        // OpenClaw user skills
335        if let Some(home) = dirs::home_dir() {
336            let openclaw_user = home.join(".openclaw/workspace/skills");
337            if openclaw_user.exists() {
338                dirs.push(openclaw_user);
339            }
340        }
341
342        // RustyClaw user skills (highest priority)
343        dirs.push(self.skills_dir());
344
345        dirs
346    }
347
348    /// Logs directory.
349    pub fn logs_dir(&self) -> PathBuf {
350        self.settings_dir.join("logs")
351    }
352
353    /// Ensure the entire directory skeleton exists on disk.
354    pub fn ensure_dirs(&self) -> Result<()> {
355        let dirs = [
356            self.settings_dir.clone(),
357            self.workspace_dir(),
358            self.credentials_dir(),
359            self.agent_dir(),
360            self.sessions_dir(),
361            self.skills_dir(),
362            self.logs_dir(),
363        ];
364        for d in &dirs {
365            std::fs::create_dir_all(d)?;
366        }
367        Ok(())
368    }
369
370    // ── Load / save ─────────────────────────────────────────────────
371
372    /// Load configuration from file, with OpenClaw compatibility
373    pub fn load(path: Option<PathBuf>) -> Result<Self> {
374        let config_path = if let Some(p) = path {
375            p
376        } else {
377            let home_dir = dirs::home_dir().unwrap_or_else(|| PathBuf::from("."));
378            home_dir.join(".rustyclaw").join("config.toml")
379        };
380
381        if config_path.exists() {
382            let content = std::fs::read_to_string(&config_path)?;
383            let config: Config = match toml::from_str(&content) {
384                Ok(c) => c,
385                Err(e) => {
386                    eprintln!("ERROR: Failed to parse config: {}", e);
387                    return Err(e.into());
388                }
389            };
390            let mut config = config;
391            // Migrate legacy flat layout if detected.
392            config.migrate_legacy_layout()?;
393            Ok(config)
394        } else {
395            Ok(Config::default())
396        }
397    }
398
399    /// Save configuration to file
400    pub fn save(&self, path: Option<PathBuf>) -> Result<()> {
401        let config_path = if let Some(p) = path {
402            p
403        } else {
404            self.settings_dir.join("config.toml")
405        };
406
407        if let Some(parent) = config_path.parent() {
408            std::fs::create_dir_all(parent)?;
409        }
410
411        let content = toml::to_string_pretty(self)?;
412        std::fs::write(&config_path, content)?;
413        Ok(())
414    }
415
416    // ── Legacy migration ────────────────────────────────────────────
417
418    /// Detect the pre-restructure flat layout and move files into the
419    /// new openclaw-compatible directory hierarchy.
420    fn migrate_legacy_layout(&mut self) -> Result<()> {
421        let old_secrets = self.settings_dir.join("secrets.json");
422        let old_key = self.settings_dir.join("secrets.key");
423        let old_soul = self.settings_dir.join("SOUL.md");
424        let old_skills = self.settings_dir.join("skills");
425
426        // Only migrate if at least one legacy file exists AND the new
427        // directories have not been created yet.
428        let new_creds = self.credentials_dir();
429        let new_workspace = self.workspace_dir();
430
431        let has_legacy = old_secrets.exists() || old_soul.exists();
432        let already_migrated =
433            new_creds.join("secrets.json").exists() || new_workspace.join("SOUL.md").exists();
434
435        if !has_legacy || already_migrated {
436            return Ok(());
437        }
438
439        eprintln!("Migrating ~/.rustyclaw to new directory layout…");
440
441        // Create target dirs.
442        std::fs::create_dir_all(&new_creds)?;
443        std::fs::create_dir_all(&new_workspace)?;
444
445        // Move secrets vault → credentials/
446        if old_secrets.exists() {
447            let dest = new_creds.join("secrets.json");
448            std::fs::rename(&old_secrets, &dest)?;
449            eprintln!("  secrets.json → credentials/secrets.json");
450        }
451        if old_key.exists() {
452            let dest = new_creds.join("secrets.key");
453            std::fs::rename(&old_key, &dest)?;
454            eprintln!("  secrets.key  → credentials/secrets.key");
455        }
456
457        // Move SOUL.md → workspace/
458        if old_soul.exists() {
459            let dest = new_workspace.join("SOUL.md");
460            std::fs::rename(&old_soul, &dest)?;
461            eprintln!("  SOUL.md      → workspace/SOUL.md");
462        }
463
464        // Move skills/ → workspace/skills/
465        if old_skills.exists() && old_skills.is_dir() {
466            let dest = new_workspace.join("skills");
467            if !dest.exists() {
468                std::fs::rename(&old_skills, &dest)?;
469                eprintln!("  skills/      → workspace/skills/");
470            }
471        }
472
473        // Update any explicit paths in config that pointed at the old locations.
474        if self.soul_path.as_ref() == Some(&self.settings_dir.join("SOUL.md")) {
475            self.soul_path = None; // let the helper derive it
476        }
477        if self.skills_dir.as_ref() == Some(&self.settings_dir.join("skills")) {
478            self.skills_dir = None;
479        }
480
481        // Persist the updated config so we don't migrate again.
482        self.save(None)?;
483
484        eprintln!("Migration complete.");
485        Ok(())
486    }
487}