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