Skip to main content

garudust_core/
config.rs

1use std::collections::HashMap;
2use std::path::{Path, PathBuf};
3use std::sync::OnceLock;
4
5use serde::{Deserialize, Serialize};
6
7static DOTENV_VARS: OnceLock<HashMap<String, String>> = OnceLock::new();
8
9/// Load ~/.garudust/.env once per process into an in-memory map.
10/// Never writes to process environment, so secrets are not visible to subprocesses.
11fn load_dotenv_once(path: &Path) -> &'static HashMap<String, String> {
12    DOTENV_VARS.get_or_init(|| {
13        let mut map = HashMap::new();
14        let Ok(content) = std::fs::read_to_string(path) else {
15            return map;
16        };
17        for line in content.lines() {
18            let line = line.trim();
19            if line.is_empty() || line.starts_with('#') {
20                continue;
21            }
22            if let Some((k, v)) = line.split_once('=') {
23                let k = k.trim().to_string();
24                let v = v.trim().trim_matches('"').trim_matches('\'').to_string();
25                map.insert(k, v);
26            }
27        }
28        map
29    })
30}
31
32/// Read an env var: real environment takes priority, dotenv map is fallback.
33fn env_or_dotenv(key: &str, dotenv: &HashMap<String, String>) -> Option<String> {
34    std::env::var(key)
35        .ok()
36        .filter(|v| !v.is_empty())
37        .or_else(|| dotenv.get(key).filter(|v| !v.is_empty()).cloned())
38}
39
40#[derive(Debug, Clone, Serialize, Deserialize)]
41pub struct AgentConfig {
42    #[serde(skip)]
43    pub home_dir: PathBuf,
44    pub model: String,
45    pub max_iterations: u32,
46    pub tool_delay_ms: u64,
47    pub provider: String,
48    pub base_url: Option<String>,
49    #[serde(skip)]
50    pub api_key: Option<String>,
51    pub compression: CompressionConfig,
52    pub network: NetworkConfig,
53    #[serde(default)]
54    pub mcp_servers: Vec<McpServerConfig>,
55    #[serde(default)]
56    pub max_concurrent_requests: Option<usize>,
57    #[serde(default)]
58    pub security: SecurityConfig,
59    #[serde(default)]
60    pub memory_expiry: MemoryExpiryConfig,
61    /// Inject a memory-save reminder every N tool-use iterations within a task.
62    /// 0 = disabled. Default: 5.
63    #[serde(default = "default_nudge_interval")]
64    pub nudge_interval: u32,
65    /// Max retry attempts on transient LLM API errors (429, 5xx, network). 0 = disabled.
66    #[serde(default = "default_llm_max_retries")]
67    pub llm_max_retries: u32,
68    /// Base delay in milliseconds for exponential backoff between retries.
69    #[serde(default = "default_llm_retry_base_ms")]
70    pub llm_retry_base_ms: u64,
71    /// Platform-level access controls (whitelist, mention gate, session isolation).
72    #[serde(default)]
73    pub platform: PlatformConfig,
74    /// Minimum tool-use iterations that trigger an automatic skill-reflection pass after a task.
75    /// The agent reviews the conversation and calls write_skill if the workflow is reusable.
76    /// Set to 0 to disable. Default: 5.
77    #[serde(default = "default_auto_skill_threshold")]
78    pub auto_skill_threshold: u32,
79    /// Timeout in seconds for a single LLM API call (chat or stream). 0 = no timeout. Default: 120.
80    #[serde(default = "default_llm_timeout_secs")]
81    pub llm_timeout_secs: u64,
82    /// Timeout in seconds applied to every non-terminal tool dispatch. 0 = no timeout. Default: 60.
83    #[serde(default = "default_tool_timeout_secs")]
84    pub tool_timeout_secs: u64,
85    /// Drain window in seconds for graceful shutdown — server waits this long for in-flight
86    /// requests to complete before forcing exit. Default: 30.
87    #[serde(default = "default_shutdown_timeout_secs")]
88    pub shutdown_timeout_secs: u64,
89}
90
91fn default_nudge_interval() -> u32 {
92    5
93}
94fn default_auto_skill_threshold() -> u32 {
95    5
96}
97fn default_llm_max_retries() -> u32 {
98    3
99}
100fn default_llm_retry_base_ms() -> u64 {
101    1000
102}
103fn default_llm_timeout_secs() -> u64 {
104    120
105}
106fn default_tool_timeout_secs() -> u64 {
107    60
108}
109fn default_shutdown_timeout_secs() -> u64 {
110    30
111}
112
113/// Per-category retention policy for memory entries.
114/// `None` means the category never expires.
115/// `preference` and `skill` default to `None` — they represent durable knowledge.
116#[derive(Debug, Clone, Serialize, Deserialize)]
117pub struct MemoryExpiryConfig {
118    /// Max age in days for `fact` entries. Default: 90.
119    #[serde(default = "default_fact_days")]
120    pub fact_days: Option<u32>,
121    /// Max age in days for `project` entries. Default: 30.
122    #[serde(default = "default_project_days")]
123    pub project_days: Option<u32>,
124    /// Max age in days for `other` entries. Default: 60.
125    #[serde(default = "default_other_days")]
126    pub other_days: Option<u32>,
127    /// `preference` entries never expire by default.
128    #[serde(default)]
129    pub preference_days: Option<u32>,
130    /// `skill` entries never expire by default.
131    #[serde(default)]
132    pub skill_days: Option<u32>,
133}
134
135#[allow(clippy::unnecessary_wraps)]
136fn default_fact_days() -> Option<u32> {
137    Some(90)
138}
139#[allow(clippy::unnecessary_wraps)]
140fn default_project_days() -> Option<u32> {
141    Some(30)
142}
143#[allow(clippy::unnecessary_wraps)]
144fn default_other_days() -> Option<u32> {
145    Some(60)
146}
147
148impl Default for MemoryExpiryConfig {
149    fn default() -> Self {
150        Self {
151            fact_days: default_fact_days(),
152            project_days: default_project_days(),
153            other_days: default_other_days(),
154            preference_days: None,
155            skill_days: None,
156        }
157    }
158}
159
160/// Terminal execution sandbox mode.
161#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
162#[serde(rename_all = "lowercase")]
163pub enum TerminalSandbox {
164    /// Direct host execution (default). Hardline blocks still apply.
165    #[default]
166    None,
167    /// Wrap every command in `docker run --rm` with hardened flags.
168    Docker,
169}
170
171/// Security-related settings grouped together (mirrors CompressionConfig / NetworkConfig pattern).
172#[derive(Debug, Clone, Serialize, Deserialize)]
173pub struct SecurityConfig {
174    /// Bearer token required on /chat* endpoints. None = open (warn at startup).
175    #[serde(skip)]
176    pub gateway_api_key: Option<String>,
177
178    /// Allowed root paths for read_file tool. Defaults to cwd + home.
179    #[serde(default)]
180    pub allowed_read_paths: Vec<PathBuf>,
181
182    /// Allowed root paths for write_file tool. Defaults to cwd only.
183    #[serde(default)]
184    pub allowed_write_paths: Vec<PathBuf>,
185
186    /// Command approval mode: "auto" | "smart" | "deny". Default "smart".
187    #[serde(default = "default_approval_mode")]
188    pub approval_mode: String,
189
190    /// Per-IP rate limit in requests/minute. None = disabled.
191    #[serde(default)]
192    pub rate_limit_rpm: Option<u32>,
193
194    /// Terminal execution sandbox. Default "none" (direct host execution).
195    #[serde(default)]
196    pub terminal_sandbox: TerminalSandbox,
197
198    /// Docker image used when `terminal_sandbox = docker`. Default "ubuntu:24.04".
199    #[serde(default = "default_sandbox_image")]
200    pub terminal_sandbox_image: String,
201
202    /// Extra `docker run` flags appended after the hardened defaults.
203    /// Example: `["--network=none", "--memory=512m", "--cpus=0.5"]`
204    #[serde(default)]
205    pub terminal_sandbox_opts: Vec<String>,
206}
207
208fn default_approval_mode() -> String {
209    "smart".to_string()
210}
211
212fn default_sandbox_image() -> String {
213    "ubuntu:24.04".to_string()
214}
215
216impl Default for SecurityConfig {
217    fn default() -> Self {
218        Self {
219            gateway_api_key: None,
220            allowed_read_paths: Vec::new(),
221            allowed_write_paths: Vec::new(),
222            approval_mode: default_approval_mode(),
223            rate_limit_rpm: None,
224            terminal_sandbox: TerminalSandbox::None,
225            terminal_sandbox_image: default_sandbox_image(),
226            terminal_sandbox_opts: Vec::new(),
227        }
228    }
229}
230
231/// Platform-level access and behaviour controls (whitelist, mention gate, session isolation).
232#[derive(Debug, Clone, Serialize, Deserialize)]
233pub struct PlatformConfig {
234    /// User IDs allowed to send messages to the agent.
235    /// Empty list means everyone is allowed.
236    #[serde(default)]
237    pub allowed_user_ids: Vec<String>,
238
239    /// Only respond in group chats when the bot is @mentioned.
240    /// Private / DM chats always get a response regardless of this flag.
241    #[serde(default)]
242    pub require_mention: bool,
243
244    /// Bot username used for @mention detection (without the @).
245    /// Example: set to "mybot" so @mybot triggers a response.
246    #[serde(default)]
247    pub bot_username: String,
248
249    /// Give each user their own conversation session (default: true).
250    /// Set to false only when you want all users in a channel to share one session.
251    /// Not applied to the webhook platform — webhook callers control session routing via payload.
252    #[serde(default = "default_true")]
253    pub session_per_user: bool,
254}
255
256fn default_true() -> bool {
257    true
258}
259
260impl Default for PlatformConfig {
261    fn default() -> Self {
262        Self {
263            allowed_user_ids: Vec::new(),
264            require_mention: false,
265            bot_username: String::new(),
266            session_per_user: true,
267        }
268    }
269}
270
271#[derive(Debug, Clone, Serialize, Deserialize)]
272pub struct McpServerConfig {
273    pub name: String,
274    pub command: String,
275    #[serde(default)]
276    pub args: Vec<String>,
277}
278
279impl Default for AgentConfig {
280    fn default() -> Self {
281        let cwd = std::env::current_dir().unwrap_or_default();
282        let home = dirs::home_dir().unwrap_or_default();
283        Self {
284            home_dir: Self::garudust_dir(),
285            model: "anthropic/claude-sonnet-4-6".into(),
286            max_iterations: 90,
287            tool_delay_ms: 0,
288            provider: "openrouter".into(),
289            base_url: None,
290            api_key: None,
291            compression: CompressionConfig::default(),
292            network: NetworkConfig::default(),
293            mcp_servers: Vec::new(),
294            max_concurrent_requests: None,
295            security: SecurityConfig {
296                gateway_api_key: None,
297                allowed_read_paths: vec![cwd.clone(), home],
298                allowed_write_paths: vec![cwd],
299                approval_mode: default_approval_mode(),
300                rate_limit_rpm: None,
301                terminal_sandbox: TerminalSandbox::None,
302                terminal_sandbox_image: default_sandbox_image(),
303                terminal_sandbox_opts: Vec::new(),
304            },
305            memory_expiry: MemoryExpiryConfig::default(),
306            nudge_interval: default_nudge_interval(),
307            llm_max_retries: default_llm_max_retries(),
308            llm_retry_base_ms: default_llm_retry_base_ms(),
309            platform: PlatformConfig::default(),
310            auto_skill_threshold: default_auto_skill_threshold(),
311            llm_timeout_secs: default_llm_timeout_secs(),
312            tool_timeout_secs: default_tool_timeout_secs(),
313            shutdown_timeout_secs: default_shutdown_timeout_secs(),
314        }
315    }
316}
317
318impl AgentConfig {
319    /// Canonical ~/.garudust directory.
320    pub fn garudust_dir() -> PathBuf {
321        dirs::home_dir()
322            .unwrap_or_else(|| PathBuf::from("/tmp"))
323            .join(".garudust")
324    }
325
326    /// Load config from ~/.garudust/config.yaml + ~/.garudust/.env + environment.
327    ///
328    /// Priority (highest first):
329    ///   1. Environment variables already set in the shell
330    ///   2. ~/.garudust/.env  (set if not already present in env)
331    ///   3. ~/.garudust/config.yaml
332    ///   4. Built-in defaults
333    pub fn load() -> Self {
334        let home_dir = Self::garudust_dir();
335
336        // Load dotenv values into memory (never calls set_var — secrets stay out of process env)
337        let env_file = home_dir.join(".env");
338        let dotenv = load_dotenv_once(&env_file);
339
340        // Load config.yaml (non-secret settings)
341        let yaml_path = home_dir.join("config.yaml");
342        let mut config: AgentConfig = if yaml_path.exists() {
343            let src = std::fs::read_to_string(&yaml_path).unwrap_or_default();
344            serde_yaml::from_str(&src).unwrap_or_default()
345        } else {
346            AgentConfig::default()
347        };
348
349        config.home_dir = home_dir;
350
351        // Populate default security paths if they came back empty from YAML
352        if config.security.allowed_read_paths.is_empty() {
353            let cwd = std::env::current_dir().unwrap_or_default();
354            let home = dirs::home_dir().unwrap_or_default();
355            config.security.allowed_read_paths = vec![cwd.clone(), home];
356            config.security.allowed_write_paths = vec![cwd];
357        }
358
359        // Apply env/dotenv overrides (real env takes priority over dotenv)
360        if let Some(k) = env_or_dotenv("ANTHROPIC_API_KEY", dotenv) {
361            config.api_key = Some(k);
362            config.provider = "anthropic".into();
363        } else if let Some(k) = env_or_dotenv("OPENROUTER_API_KEY", dotenv) {
364            config.api_key = Some(k);
365        } else if let Some(url) = env_or_dotenv("OLLAMA_BASE_URL", dotenv) {
366            config.provider = "ollama".into();
367            config.base_url = Some(url);
368        } else if let Some(url) = env_or_dotenv("VLLM_BASE_URL", dotenv) {
369            config.provider = "vllm".into();
370            config.base_url = Some(url);
371            if let Some(k) = env_or_dotenv("VLLM_API_KEY", dotenv) {
372                config.api_key = Some(k);
373            }
374        }
375        if let Some(m) = env_or_dotenv("GARUDUST_MODEL", dotenv) {
376            config.model = m;
377        }
378        if let Some(u) = env_or_dotenv("GARUDUST_BASE_URL", dotenv) {
379            config.base_url = Some(u);
380        }
381        if let Some(k) = env_or_dotenv("GARUDUST_API_KEY", dotenv) {
382            config.security.gateway_api_key = Some(k);
383        }
384        if let Some(v) = env_or_dotenv("GARUDUST_RATE_LIMIT", dotenv) {
385            if let Ok(n) = v.parse::<u32>() {
386                config.security.rate_limit_rpm = Some(n);
387            }
388        }
389        if let Some(mode) = env_or_dotenv("GARUDUST_APPROVAL_MODE", dotenv) {
390            config.security.approval_mode = mode;
391        }
392        if let Some(sandbox) = env_or_dotenv("GARUDUST_TERMINAL_SANDBOX", dotenv) {
393            config.security.terminal_sandbox = match sandbox.to_lowercase().as_str() {
394                "docker" => TerminalSandbox::Docker,
395                _ => TerminalSandbox::None,
396            };
397        }
398        if let Some(image) = env_or_dotenv("GARUDUST_SANDBOX_IMAGE", dotenv) {
399            config.security.terminal_sandbox_image = image;
400        }
401
402        config
403    }
404
405    /// Save non-secret settings to ~/.garudust/config.yaml.
406    pub fn save_yaml(&self) -> std::io::Result<()> {
407        std::fs::create_dir_all(&self.home_dir)?;
408        let yaml = serde_yaml::to_string(self).map_err(std::io::Error::other)?;
409        std::fs::write(self.home_dir.join("config.yaml"), yaml)
410    }
411
412    /// Write or update a KEY=VALUE line in ~/.garudust/.env.
413    pub fn set_env_var(home_dir: &Path, key: &str, value: &str) -> std::io::Result<()> {
414        std::fs::create_dir_all(home_dir)?;
415        let env_path = home_dir.join(".env");
416        let existing = if env_path.exists() {
417            std::fs::read_to_string(&env_path)?
418        } else {
419            String::new()
420        };
421
422        let prefix = format!("{key}=");
423        let mut lines: Vec<String> = existing
424            .lines()
425            .filter(|l| !l.starts_with(&prefix))
426            .map(String::from)
427            .collect();
428        lines.push(format!("{key}={value}"));
429
430        std::fs::write(&env_path, lines.join("\n") + "\n")
431    }
432}
433
434// ── Sub-configs ──────────────────────────────────────────────────────────────
435
436#[derive(Debug, Clone, Serialize, Deserialize)]
437pub struct CompressionConfig {
438    pub enabled: bool,
439    pub threshold_fraction: f32,
440    pub model: Option<String>,
441}
442
443impl Default for CompressionConfig {
444    fn default() -> Self {
445        Self {
446            enabled: true,
447            threshold_fraction: 0.8,
448            model: None,
449        }
450    }
451}
452
453#[derive(Debug, Clone, Serialize, Deserialize, Default)]
454pub struct NetworkConfig {
455    pub force_ipv4: bool,
456    pub proxy: Option<String>,
457}