Skip to main content

bamboo_agent/core/
config.rs

1//! Configuration management for Bamboo agent
2//!
3//! This module provides unified configuration types and loading logic for the entire
4//! Bamboo agent system. It supports multiple LLM providers, proxy settings,
5//! and JSON configuration format.
6//!
7//! # Configuration File
8//!
9//! Configuration is stored in `config.json` under the unified data directory
10//! (defaults to `~/.bamboo/`). Environment variables can override file values.
11//!
12//! # Example (JSON)
13//!
14//! ```json
15//! {
16//!   "provider": "anthropic",
17//!   "server": {
18//!     "port": 8080,
19//!     "bind": "127.0.0.1"
20//!   },
21//!   "providers": {
22//!     "anthropic": {
23//!       "api_key": "sk-ant-...",
24//!       "model": "claude-3-5-sonnet-20241022"
25//!     },
26//!     "openai": {
27//!       "api_key": "sk-...",
28//!       "base_url": "https://api.openai.com/v1"
29//!     }
30//!   }
31//! }
32//! ```
33//!
34//! # Priority Order
35//!
36//! Configuration values are loaded in this order (later overrides earlier):
37//! 1. Code defaults (hardcoded default values)
38//! 2. Config file values (from `~/.bamboo/config.json`)
39//! 3. Environment variables (e.g., `BAMBOO_PORT`)
40//! 4. CLI arguments (e.g., `--port 9000`)
41//!
42//! # Environment Variables
43//!
44//! - `BAMBOO_DATA_DIR`: Override data directory location
45//! - `BAMBOO_PORT`: Override server port
46//! - `BAMBOO_BIND`: Override server bind address
47//! - `BAMBOO_PROVIDER`: Override default provider
48//! - `BAMBOO_HEADLESS`: Enable headless authentication mode
49//! - `MODEL`: Override default model
50
51use anyhow::{Context, Result};
52use serde::{Deserialize, Serialize};
53use serde_json::Value;
54use std::collections::BTreeMap;
55use std::io::Write;
56use std::path::PathBuf;
57
58use crate::core::keyword_masking::KeywordMaskingConfig;
59use crate::core::model_mapping::{AnthropicModelMapping, GeminiModelMapping};
60
61/// Main configuration structure for Bamboo agent
62///
63/// Contains all settings needed to run the agent, including provider credentials,
64/// proxy settings, model selection, and server configuration.
65#[derive(Debug, Clone, Serialize, Deserialize)]
66pub struct Config {
67    /// HTTP proxy URL (e.g., `http://proxy.example.com:8080`)
68    #[serde(default)]
69    pub http_proxy: String,
70    /// HTTPS proxy URL (e.g., `https://proxy.example.com:8080`)
71    #[serde(default)]
72    pub https_proxy: String,
73    /// Proxy authentication credentials
74    ///
75    /// Note: this is kept in-memory only. On disk we store `proxy_auth_encrypted`.
76    #[serde(skip_serializing)]
77    pub proxy_auth: Option<ProxyAuth>,
78    /// Encrypted proxy authentication credentials (nonce:ciphertext)
79    ///
80    /// This is the at-rest storage representation. When present, Bamboo will
81    /// decrypt it into `proxy_auth` at load time.
82    #[serde(default, skip_serializing_if = "Option::is_none")]
83    pub proxy_auth_encrypted: Option<String>,
84    /// Default model to use (can be overridden per provider)
85    pub model: Option<String>,
86    /// Deprecated: Use `providers.copilot.headless_auth` instead
87    #[serde(default)]
88    pub headless_auth: bool,
89
90    /// Default LLM provider to use (e.g., "anthropic", "openai", "gemini", "copilot")
91    #[serde(default = "default_provider")]
92    pub provider: String,
93
94    /// Provider-specific configurations
95    #[serde(default)]
96    pub providers: ProviderConfigs,
97
98    /// HTTP server configuration
99    #[serde(default)]
100    pub server: ServerConfig,
101
102    /// Global keyword masking configuration.
103    ///
104    /// Previously persisted in `keyword_masking.json` (now unified into `config.json`).
105    #[serde(default)]
106    pub keyword_masking: KeywordMaskingConfig,
107
108    /// Anthropic model mapping configuration.
109    ///
110    /// Previously persisted in `anthropic-model-mapping.json` (now unified into `config.json`).
111    #[serde(default)]
112    pub anthropic_model_mapping: AnthropicModelMapping,
113
114    /// Gemini model mapping configuration.
115    ///
116    /// Previously persisted in `gemini-model-mapping.json` (now unified into `config.json`).
117    #[serde(default)]
118    pub gemini_model_mapping: GeminiModelMapping,
119
120    /// Request preflight hooks.
121    ///
122    /// These hooks can inspect and rewrite outgoing requests before they are sent upstream
123    /// (e.g. image fallback behavior for text-only models).
124    #[serde(default)]
125    pub hooks: HooksConfig,
126
127    /// MCP server configuration.
128    ///
129    /// Previously persisted in `mcp.json` (now unified into `config.json`).
130    // On disk we use the mainstream `mcpServers` key (matching Claude Desktop / MCP ecosystem
131    // conventions). We still accept the legacy `mcp` key for backward compatibility.
132    #[serde(default, rename = "mcpServers", alias = "mcp")]
133    pub mcp: crate::agent::mcp::McpConfig,
134
135    /// Extension fields stored at the root of `config.json`.
136    ///
137    /// This keeps the config forward-compatible and allows unrelated subsystems
138    /// (e.g. setup UI state) to persist their own keys without getting dropped by
139    /// typed (de)serialization.
140    #[serde(default, flatten)]
141    pub extra: BTreeMap<String, Value>,
142}
143
144/// Container for provider-specific configurations
145///
146/// Each field is optional, allowing users to configure only the providers they need.
147#[derive(Debug, Clone, Default, Serialize, Deserialize)]
148pub struct ProviderConfigs {
149    /// OpenAI provider configuration
150    #[serde(skip_serializing_if = "Option::is_none")]
151    pub openai: Option<OpenAIConfig>,
152    /// Anthropic provider configuration
153    #[serde(skip_serializing_if = "Option::is_none")]
154    pub anthropic: Option<AnthropicConfig>,
155    /// Google Gemini provider configuration
156    #[serde(skip_serializing_if = "Option::is_none")]
157    pub gemini: Option<GeminiConfig>,
158    /// GitHub Copilot provider configuration
159    #[serde(skip_serializing_if = "Option::is_none")]
160    pub copilot: Option<CopilotConfig>,
161
162    /// Preserve unknown provider keys (forward compatibility).
163    #[serde(default, flatten)]
164    pub extra: BTreeMap<String, Value>,
165}
166
167/// Request hook configuration.
168#[derive(Debug, Clone, Default, Serialize, Deserialize)]
169pub struct HooksConfig {
170    /// Image fallback behavior for OpenAI-compatible requests (chat/responses).
171    #[serde(default)]
172    pub image_fallback: ImageFallbackHookConfig,
173}
174
175/// When a request contains image parts but the effective provider path is text-only,
176/// we can either:
177/// - error fast (preferred for strict setups), or
178/// - degrade gracefully by replacing images with a placeholder text.
179#[derive(Debug, Clone, Serialize, Deserialize)]
180pub struct ImageFallbackHookConfig {
181    #[serde(default = "default_true_hooks")]
182    pub enabled: bool,
183
184    /// "placeholder" (default) or "error"
185    #[serde(default = "default_image_fallback_mode")]
186    pub mode: String,
187}
188
189impl Default for ImageFallbackHookConfig {
190    fn default() -> Self {
191        Self {
192            enabled: default_true_hooks(),
193            mode: default_image_fallback_mode(),
194        }
195    }
196}
197
198fn default_image_fallback_mode() -> String {
199    "placeholder".to_string()
200}
201
202fn default_true_hooks() -> bool {
203    // Default to disabled so image inputs are preserved unless the user explicitly
204    // opts into fallback rewriting (placeholder/error/ocr).
205    false
206}
207
208/// OpenAI provider configuration
209///
210/// # Example
211///
212/// ```json
213/// "openai": {
214///   "api_key": "sk-...",
215///   "base_url": "https://api.openai.com/v1",
216///   "model": "gpt-4"
217/// }
218/// ```
219#[derive(Debug, Clone, Serialize, Deserialize)]
220pub struct OpenAIConfig {
221    /// OpenAI API key (plaintext, in-memory only).
222    ///
223    /// On disk this is stored as `api_key_encrypted` and hydrated on load.
224    #[serde(default, skip_serializing)]
225    pub api_key: String,
226    /// Encrypted OpenAI API key (nonce:ciphertext).
227    #[serde(default, skip_serializing_if = "Option::is_none")]
228    pub api_key_encrypted: Option<String>,
229    /// Custom API base URL (for Azure or self-hosted deployments)
230    #[serde(skip_serializing_if = "Option::is_none")]
231    pub base_url: Option<String>,
232    /// Default model to use (e.g., "gpt-4", "gpt-3.5-turbo")
233    #[serde(skip_serializing_if = "Option::is_none")]
234    pub model: Option<String>,
235
236    /// Models that must use the OpenAI Responses API upstream (instead of chat/completions).
237    ///
238    /// Example:
239    /// ```json
240    /// "responses_only_models": ["gpt-5.3-codex", "gpt-5*"]
241    /// ```
242    #[serde(default, skip_serializing_if = "Vec::is_empty")]
243    pub responses_only_models: Vec<String>,
244
245    /// Preserve unknown keys under `providers.openai`.
246    #[serde(default, flatten)]
247    pub extra: BTreeMap<String, Value>,
248}
249
250/// Anthropic provider configuration
251///
252/// # Example
253///
254/// ```json
255/// "anthropic": {
256///   "api_key": "sk-ant-...",
257///   "model": "claude-3-5-sonnet-20241022",
258///   "max_tokens": 4096
259/// }
260/// ```
261#[derive(Debug, Clone, Serialize, Deserialize)]
262pub struct AnthropicConfig {
263    /// Anthropic API key (plaintext, in-memory only).
264    ///
265    /// On disk this is stored as `api_key_encrypted` and hydrated on load.
266    #[serde(default, skip_serializing)]
267    pub api_key: String,
268    /// Encrypted Anthropic API key (nonce:ciphertext).
269    #[serde(default, skip_serializing_if = "Option::is_none")]
270    pub api_key_encrypted: Option<String>,
271    /// Custom API base URL
272    #[serde(skip_serializing_if = "Option::is_none")]
273    pub base_url: Option<String>,
274    /// Default model to use (e.g., "claude-3-5-sonnet-20241022")
275    #[serde(skip_serializing_if = "Option::is_none")]
276    pub model: Option<String>,
277    /// Maximum tokens in model response
278    #[serde(skip_serializing_if = "Option::is_none")]
279    pub max_tokens: Option<u32>,
280
281    /// Preserve unknown keys under `providers.anthropic`.
282    #[serde(default, flatten)]
283    pub extra: BTreeMap<String, Value>,
284}
285
286/// Google Gemini provider configuration
287///
288/// # Example
289///
290/// ```json
291/// "gemini": {
292///   "api_key": "AIza...",
293///   "model": "gemini-2.0-flash-exp"
294/// }
295/// ```
296#[derive(Debug, Clone, Serialize, Deserialize)]
297pub struct GeminiConfig {
298    /// Google AI API key (plaintext, in-memory only).
299    ///
300    /// On disk this is stored as `api_key_encrypted` and hydrated on load.
301    #[serde(default, skip_serializing)]
302    pub api_key: String,
303    /// Encrypted Google AI API key (nonce:ciphertext).
304    #[serde(default, skip_serializing_if = "Option::is_none")]
305    pub api_key_encrypted: Option<String>,
306    /// Custom API base URL
307    #[serde(skip_serializing_if = "Option::is_none")]
308    pub base_url: Option<String>,
309    /// Default model to use (e.g., "gemini-2.0-flash-exp")
310    #[serde(skip_serializing_if = "Option::is_none")]
311    pub model: Option<String>,
312
313    /// Preserve unknown keys under `providers.gemini`.
314    #[serde(default, flatten)]
315    pub extra: BTreeMap<String, Value>,
316}
317
318/// GitHub Copilot provider configuration
319///
320/// # Example
321///
322/// ```json
323/// "copilot": {
324///   "enabled": true,
325///   "headless_auth": false,
326///   "model": "gpt-4o"
327/// }
328/// ```
329#[derive(Debug, Clone, Default, Serialize, Deserialize)]
330pub struct CopilotConfig {
331    /// Whether Copilot provider is enabled
332    #[serde(default)]
333    pub enabled: bool,
334    /// Print login URL to console instead of opening browser
335    #[serde(default)]
336    pub headless_auth: bool,
337    /// Default model to use for Copilot (used when clients request the "default" model)
338    #[serde(skip_serializing_if = "Option::is_none")]
339    pub model: Option<String>,
340
341    /// Models that must use the OpenAI Responses API upstream (instead of chat/completions).
342    ///
343    /// This is useful for newer Copilot models that only support Responses-style requests.
344    ///
345    /// Example:
346    /// ```json
347    /// "responses_only_models": ["gpt-5.3-codex", "gpt-5*"]
348    /// ```
349    #[serde(default, skip_serializing_if = "Vec::is_empty")]
350    pub responses_only_models: Vec<String>,
351
352    /// Preserve unknown keys under `providers.copilot`.
353    #[serde(default, flatten)]
354    pub extra: BTreeMap<String, Value>,
355}
356
357/// Returns the default provider name ("anthropic")
358fn default_provider() -> String {
359    "anthropic".to_string()
360}
361
362/// Returns the default server port (8080)
363fn default_port() -> u16 {
364    8080
365}
366
367/// Returns the default bind address (127.0.0.1)
368fn default_bind() -> String {
369    "127.0.0.1".to_string()
370}
371
372/// Returns the default worker count (10)
373fn default_workers() -> usize {
374    10
375}
376
377/// Returns the default data directory (~/.bamboo)
378fn default_data_dir() -> PathBuf {
379    super::paths::bamboo_dir()
380}
381
382/// HTTP server configuration
383#[derive(Debug, Clone, Serialize, Deserialize)]
384pub struct ServerConfig {
385    /// Port to listen on
386    #[serde(default = "default_port")]
387    pub port: u16,
388
389    /// Bind address (127.0.0.1, 0.0.0.0, etc.)
390    #[serde(default = "default_bind")]
391    pub bind: String,
392
393    /// Static files directory (for Docker mode)
394    pub static_dir: Option<PathBuf>,
395
396    /// Worker count for Actix-web
397    #[serde(default = "default_workers")]
398    pub workers: usize,
399
400    /// Preserve unknown keys under `server`.
401    #[serde(default, flatten)]
402    pub extra: BTreeMap<String, Value>,
403}
404
405impl Default for ServerConfig {
406    fn default() -> Self {
407        Self {
408            port: default_port(),
409            bind: default_bind(),
410            static_dir: None,
411            workers: default_workers(),
412            extra: BTreeMap::new(),
413        }
414    }
415}
416
417/// Proxy authentication credentials
418#[derive(Debug, Clone, Serialize, Deserialize)]
419pub struct ProxyAuth {
420    /// Proxy username
421    pub username: String,
422    /// Proxy password
423    pub password: String,
424}
425
426/// Parse a boolean value from environment variable strings
427///
428/// Accepts: "1", "true", "yes", "y", "on" (case-insensitive)
429fn parse_bool_env(value: &str) -> bool {
430    matches!(
431        value.trim().to_ascii_lowercase().as_str(),
432        "1" | "true" | "yes" | "y" | "on"
433    )
434}
435
436impl Default for Config {
437    fn default() -> Self {
438        Self::new()
439    }
440}
441
442impl Config {
443    /// Load configuration from file with environment variable overrides
444    ///
445    /// Configuration loading order:
446    /// 1. Try loading from `config.json` (`{data_dir}/config.json`)
447    /// 2. Migrate old format if detected
448    /// 3. Use defaults
449    /// 4. Apply environment variable overrides (highest priority)
450    ///
451    /// # Environment Variables
452    ///
453    /// - `BAMBOO_PORT`: Override server port
454    /// - `BAMBOO_BIND`: Override bind address
455    /// - `BAMBOO_DATA_DIR`: Override data directory
456    /// - `BAMBOO_PROVIDER`: Override default provider
457    /// - `MODEL`: Default model name
458    /// - `BAMBOO_HEADLESS`: Enable headless authentication mode
459    pub fn new() -> Self {
460        Self::from_data_dir(None)
461    }
462
463    /// Load configuration from a specific data directory
464    ///
465    /// # Arguments
466    ///
467    /// * `data_dir` - Optional data directory path. If None, uses default (~/.bamboo)
468    pub fn from_data_dir(data_dir: Option<PathBuf>) -> Self {
469        // Determine data_dir early (needed to find config file)
470        let data_dir = data_dir
471            .or_else(|| std::env::var("BAMBOO_DATA_DIR").ok().map(PathBuf::from))
472            .unwrap_or_else(default_data_dir);
473
474        let config_path = data_dir.join("config.json");
475
476        let mut config = if config_path.exists() {
477            if let Ok(content) = std::fs::read_to_string(&config_path) {
478                // Try to parse as old format first (for migration)
479                if let Ok(old_config) = serde_json::from_str::<OldConfig>(&content) {
480                    // Check if it has old-only fields (indicating a true old config that needs migration)
481                    let has_old_fields = old_config.http_proxy_auth.is_some()
482                        || old_config.https_proxy_auth.is_some()
483                        || old_config.api_key.is_some()
484                        || old_config.api_base.is_some();
485
486                    if has_old_fields {
487                        log::info!("Migrating old config format to new format");
488                        let migrated = migrate_config(old_config);
489                        // Save migrated config
490                        if let Ok(new_content) = serde_json::to_string_pretty(&migrated) {
491                            let _ = std::fs::write(&config_path, new_content);
492                        }
493                        migrated
494                    } else {
495                        // No old fields, so try to parse as new Config
496                        // OldConfig successfully parsed common fields like http_proxy, model, provider, etc.
497                        // Try Config, but if it fails (e.g., due to syntax errors), use OldConfig values
498                        match serde_json::from_str::<Config>(&content) {
499                            Ok(mut config) => {
500                                config.hydrate_proxy_auth_from_encrypted();
501                                config.hydrate_provider_api_keys_from_encrypted();
502                                config.hydrate_mcp_secrets_from_encrypted();
503                                config
504                            }
505                            Err(_) => {
506                                // Config parse failed, but OldConfig worked, so preserve those values
507                                migrate_config(old_config)
508                            }
509                        }
510                    }
511                } else {
512                    // Couldn't parse as OldConfig, try as Config
513                    serde_json::from_str::<Config>(&content)
514                        .map(|mut config| {
515                            config.hydrate_proxy_auth_from_encrypted();
516                            config.hydrate_provider_api_keys_from_encrypted();
517                            config.hydrate_mcp_secrets_from_encrypted();
518                            config
519                        })
520                        .unwrap_or_else(|_| Self::create_default())
521                }
522            } else {
523                Self::create_default()
524            }
525        } else {
526            Self::create_default()
527        };
528
529        // Decrypt encrypted proxy auth into in-memory plaintext form.
530        config.hydrate_proxy_auth_from_encrypted();
531        // Decrypt encrypted provider API keys into in-memory plaintext form.
532        config.hydrate_provider_api_keys_from_encrypted();
533        // Decrypt encrypted MCP secrets into in-memory plaintext form.
534        config.hydrate_mcp_secrets_from_encrypted();
535
536        // Legacy: `data_dir` is no longer a persisted config field. The data directory is
537        // derived from runtime (BAMBOO_DATA_DIR or ~/.bamboo).
538        config.extra.remove("data_dir");
539
540        // Best-effort migration from legacy sidecar config files into unified config.json.
541        // This keeps config state globally unified going forward without breaking existing installs.
542        config.migrate_legacy_sidecar_configs(&data_dir);
543
544        // Apply environment variable overrides (highest priority)
545        if let Ok(port) = std::env::var("BAMBOO_PORT") {
546            if let Ok(port) = port.parse() {
547                config.server.port = port;
548            }
549        }
550
551        if let Ok(bind) = std::env::var("BAMBOO_BIND") {
552            config.server.bind = bind;
553        }
554
555        // Note: BAMBOO_DATA_DIR already handled above
556        if let Ok(provider) = std::env::var("BAMBOO_PROVIDER") {
557            config.provider = provider;
558        }
559
560        if let Ok(model) = std::env::var("MODEL") {
561            config.model = Some(model);
562        }
563
564        if let Ok(headless) = std::env::var("BAMBOO_HEADLESS") {
565            config.headless_auth = parse_bool_env(&headless);
566        }
567
568        config
569    }
570
571    /// Populate `proxy_auth` (plaintext) from `proxy_auth_encrypted` if present.
572    ///
573    /// Many parts of the code rely on `proxy_auth` being hydrated in-memory so
574    /// we can re-encrypt deterministically on save without ever persisting
575    /// plaintext credentials.
576    pub fn hydrate_proxy_auth_from_encrypted(&mut self) {
577        if self.proxy_auth.is_some() {
578            return;
579        }
580
581        // Backward compatibility:
582        // Older Bodhi/Tauri builds persisted proxy auth as per-scheme encrypted fields:
583        // `http_proxy_auth_encrypted` / `https_proxy_auth_encrypted`.
584        //
585        // Those live under `extra` (flatten) in the unified config. Seed the new
586        // `proxy_auth_encrypted` field so the rest of the code can stay uniform.
587        if self
588            .proxy_auth_encrypted
589            .as_deref()
590            .map(|s| s.trim().is_empty())
591            .unwrap_or(true)
592        {
593            let legacy = self
594                .extra
595                .get("https_proxy_auth_encrypted")
596                .and_then(|v| v.as_str())
597                .or_else(|| {
598                    self.extra
599                        .get("http_proxy_auth_encrypted")
600                        .and_then(|v| v.as_str())
601                })
602                .map(|s| s.trim())
603                .filter(|s| !s.is_empty())
604                .map(|s| s.to_string());
605
606            if let Some(legacy) = legacy {
607                self.proxy_auth_encrypted = Some(legacy);
608            }
609        }
610
611        let Some(encrypted) = self.proxy_auth_encrypted.as_deref() else {
612            return;
613        };
614
615        match crate::core::encryption::decrypt(encrypted) {
616            Ok(decrypted) => match serde_json::from_str::<ProxyAuth>(&decrypted) {
617                Ok(auth) => {
618                    self.proxy_auth = Some(auth);
619                    // Once hydrated successfully, drop legacy keys so a future save writes only
620                    // the canonical `proxy_auth_encrypted` field.
621                    self.extra.remove("http_proxy_auth_encrypted");
622                    self.extra.remove("https_proxy_auth_encrypted");
623                }
624                Err(e) => log::warn!("Failed to parse decrypted proxy auth JSON: {}", e),
625            },
626            Err(e) => log::warn!("Failed to decrypt proxy auth: {}", e),
627        }
628    }
629
630    /// Refresh `proxy_auth_encrypted` from the current in-memory `proxy_auth`.
631    ///
632    /// This is used both when persisting the config to disk and when generating
633    /// API responses that should never include plaintext proxy credentials.
634    pub fn refresh_proxy_auth_encrypted(&mut self) -> Result<()> {
635        // Keep on-disk representation fully derived from the in-memory plaintext:
636        // - Some(auth)  => always (re-)encrypt and store `proxy_auth_encrypted`
637        // - None        => remove `proxy_auth_encrypted`
638        let Some(auth) = self.proxy_auth.as_ref() else {
639            self.proxy_auth_encrypted = None;
640            return Ok(());
641        };
642
643        let auth_str = serde_json::to_string(auth).context("Failed to serialize proxy auth")?;
644        let encrypted =
645            crate::core::encryption::encrypt(&auth_str).context("Failed to encrypt proxy auth")?;
646        self.proxy_auth_encrypted = Some(encrypted);
647        Ok(())
648    }
649
650    pub fn hydrate_provider_api_keys_from_encrypted(&mut self) {
651        if let Some(openai) = self.providers.openai.as_mut() {
652            if openai.api_key.trim().is_empty() {
653                if let Some(encrypted) = openai.api_key_encrypted.as_deref() {
654                    match crate::core::encryption::decrypt(encrypted) {
655                        Ok(value) => openai.api_key = value,
656                        Err(e) => log::warn!("Failed to decrypt OpenAI api_key: {}", e),
657                    }
658                }
659            }
660        }
661
662        if let Some(anthropic) = self.providers.anthropic.as_mut() {
663            if anthropic.api_key.trim().is_empty() {
664                if let Some(encrypted) = anthropic.api_key_encrypted.as_deref() {
665                    match crate::core::encryption::decrypt(encrypted) {
666                        Ok(value) => anthropic.api_key = value,
667                        Err(e) => log::warn!("Failed to decrypt Anthropic api_key: {}", e),
668                    }
669                }
670            }
671        }
672
673        if let Some(gemini) = self.providers.gemini.as_mut() {
674            if gemini.api_key.trim().is_empty() {
675                if let Some(encrypted) = gemini.api_key_encrypted.as_deref() {
676                    match crate::core::encryption::decrypt(encrypted) {
677                        Ok(value) => gemini.api_key = value,
678                        Err(e) => log::warn!("Failed to decrypt Gemini api_key: {}", e),
679                    }
680                }
681            }
682        }
683    }
684
685    pub fn refresh_provider_api_keys_encrypted(&mut self) -> Result<()> {
686        if let Some(openai) = self.providers.openai.as_mut() {
687            let api_key = openai.api_key.trim();
688            openai.api_key_encrypted = if api_key.is_empty() {
689                None
690            } else {
691                Some(
692                    crate::core::encryption::encrypt(api_key)
693                        .context("Failed to encrypt OpenAI api_key")?,
694                )
695            };
696        }
697
698        if let Some(anthropic) = self.providers.anthropic.as_mut() {
699            let api_key = anthropic.api_key.trim();
700            anthropic.api_key_encrypted = if api_key.is_empty() {
701                None
702            } else {
703                Some(
704                    crate::core::encryption::encrypt(api_key)
705                        .context("Failed to encrypt Anthropic api_key")?,
706                )
707            };
708        }
709
710        if let Some(gemini) = self.providers.gemini.as_mut() {
711            let api_key = gemini.api_key.trim();
712            gemini.api_key_encrypted = if api_key.is_empty() {
713                None
714            } else {
715                Some(
716                    crate::core::encryption::encrypt(api_key)
717                        .context("Failed to encrypt Gemini api_key")?,
718                )
719            };
720        }
721
722        Ok(())
723    }
724
725    pub fn hydrate_mcp_secrets_from_encrypted(&mut self) {
726        for server in self.mcp.servers.iter_mut() {
727            match &mut server.transport {
728                crate::agent::mcp::TransportConfig::Stdio(stdio) => {
729                    if stdio.env_encrypted.is_empty() {
730                        continue;
731                    }
732
733                    // Avoid borrow-checker gymnastics by iterating a cloned map.
734                    for (key, encrypted) in stdio.env_encrypted.clone() {
735                        let should_hydrate = stdio
736                            .env
737                            .get(&key)
738                            .map(|v| v.trim().is_empty())
739                            .unwrap_or(true);
740                        if !should_hydrate {
741                            continue;
742                        }
743
744                        match crate::core::encryption::decrypt(&encrypted) {
745                            Ok(value) => {
746                                stdio.env.insert(key, value);
747                            }
748                            Err(e) => log::warn!("Failed to decrypt MCP stdio env var: {}", e),
749                        }
750                    }
751                }
752                crate::agent::mcp::TransportConfig::Sse(sse) => {
753                    for header in sse.headers.iter_mut() {
754                        if !header.value.trim().is_empty() {
755                            continue;
756                        }
757                        let Some(encrypted) = header.value_encrypted.as_deref() else {
758                            continue;
759                        };
760                        match crate::core::encryption::decrypt(encrypted) {
761                            Ok(value) => header.value = value,
762                            Err(e) => log::warn!("Failed to decrypt MCP SSE header value: {}", e),
763                        }
764                    }
765                }
766            }
767        }
768    }
769
770    pub fn refresh_mcp_secrets_encrypted(&mut self) -> Result<()> {
771        for server in self.mcp.servers.iter_mut() {
772            match &mut server.transport {
773                crate::agent::mcp::TransportConfig::Stdio(stdio) => {
774                    stdio.env_encrypted.clear();
775                    for (key, value) in &stdio.env {
776                        let encrypted =
777                            crate::core::encryption::encrypt(value).with_context(|| {
778                                format!("Failed to encrypt MCP stdio env var '{key}'")
779                            })?;
780                        stdio.env_encrypted.insert(key.clone(), encrypted);
781                    }
782                }
783                crate::agent::mcp::TransportConfig::Sse(sse) => {
784                    for header in sse.headers.iter_mut() {
785                        let configured = !header.value.trim().is_empty();
786                        header.value_encrypted = if !configured {
787                            None
788                        } else {
789                            Some(
790                                crate::core::encryption::encrypt(&header.value).with_context(
791                                    || {
792                                        format!(
793                                            "Failed to encrypt MCP SSE header '{}'",
794                                            header.name
795                                        )
796                                    },
797                                )?,
798                            )
799                        };
800                    }
801                }
802            }
803        }
804
805        Ok(())
806    }
807
808    /// Create a default configuration without loading from file
809    fn create_default() -> Self {
810        Config {
811            http_proxy: String::new(),
812            https_proxy: String::new(),
813            proxy_auth: None,
814            proxy_auth_encrypted: None,
815            model: None,
816            headless_auth: false,
817            provider: default_provider(),
818            providers: ProviderConfigs::default(),
819            server: ServerConfig::default(),
820            keyword_masking: KeywordMaskingConfig::default(),
821            anthropic_model_mapping: AnthropicModelMapping::default(),
822            gemini_model_mapping: GeminiModelMapping::default(),
823            hooks: HooksConfig::default(),
824            mcp: crate::agent::mcp::McpConfig::default(),
825            extra: BTreeMap::new(),
826        }
827    }
828
829    /// Get the full server address (bind:port)
830    pub fn server_addr(&self) -> String {
831        format!("{}:{}", self.server.bind, self.server.port)
832    }
833
834    /// Save configuration to disk
835    pub fn save(&self) -> Result<()> {
836        self.save_to_dir(default_data_dir())
837    }
838
839    /// Save configuration to disk under the provided data directory.
840    ///
841    /// Configuration is always stored as `{data_dir}/config.json`.
842    pub fn save_to_dir(&self, data_dir: PathBuf) -> Result<()> {
843        let path = data_dir.join("config.json");
844
845        if let Some(parent) = path.parent() {
846            std::fs::create_dir_all(parent)
847                .with_context(|| format!("Failed to create config dir: {:?}", parent))?;
848        }
849
850        let mut to_save = self.clone();
851        // Never persist `data_dir` into config.json (data dir is runtime-derived).
852        to_save.extra.remove("data_dir");
853        to_save.refresh_proxy_auth_encrypted()?;
854        to_save.refresh_provider_api_keys_encrypted()?;
855        let content =
856            serde_json::to_string_pretty(&to_save).context("Failed to serialize config to JSON")?;
857        write_atomic(&path, content.as_bytes())
858            .with_context(|| format!("Failed to write config file: {:?}", path))?;
859
860        Ok(())
861    }
862
863    fn migrate_legacy_sidecar_configs(&mut self, data_dir: &std::path::Path) {
864        let mut changed = false;
865
866        // keyword_masking.json -> config.keyword_masking
867        if self.keyword_masking.entries.is_empty() {
868            let path = data_dir.join("keyword_masking.json");
869            if path.exists() {
870                match std::fs::read_to_string(&path) {
871                    Ok(content) => match serde_json::from_str::<KeywordMaskingConfig>(&content) {
872                        Ok(km) => {
873                            self.keyword_masking = km;
874                            changed = true;
875                            let _ = backup_legacy_file(&path);
876                        }
877                        Err(e) => log::warn!(
878                            "Failed to migrate keyword_masking.json into config.json: {}",
879                            e
880                        ),
881                    },
882                    Err(e) => {
883                        log::warn!("Failed to read keyword_masking.json for migration: {}", e)
884                    }
885                }
886            }
887        }
888
889        // anthropic-model-mapping.json -> config.anthropic_model_mapping
890        if self.anthropic_model_mapping.mappings.is_empty() {
891            let path = data_dir.join("anthropic-model-mapping.json");
892            if path.exists() {
893                match std::fs::read_to_string(&path) {
894                    Ok(content) => match serde_json::from_str::<AnthropicModelMapping>(&content) {
895                        Ok(mapping) => {
896                            self.anthropic_model_mapping = mapping;
897                            changed = true;
898                            let _ = backup_legacy_file(&path);
899                        }
900                        Err(e) => log::warn!(
901                            "Failed to migrate anthropic-model-mapping.json into config.json: {}",
902                            e
903                        ),
904                    },
905                    Err(e) => log::warn!(
906                        "Failed to read anthropic-model-mapping.json for migration: {}",
907                        e
908                    ),
909                }
910            }
911        }
912
913        // gemini-model-mapping.json -> config.gemini_model_mapping
914        if self.gemini_model_mapping.mappings.is_empty() {
915            let path = data_dir.join("gemini-model-mapping.json");
916            if path.exists() {
917                match std::fs::read_to_string(&path) {
918                    Ok(content) => match serde_json::from_str::<GeminiModelMapping>(&content) {
919                        Ok(mapping) => {
920                            self.gemini_model_mapping = mapping;
921                            changed = true;
922                            let _ = backup_legacy_file(&path);
923                        }
924                        Err(e) => log::warn!(
925                            "Failed to migrate gemini-model-mapping.json into config.json: {}",
926                            e
927                        ),
928                    },
929                    Err(e) => log::warn!(
930                        "Failed to read gemini-model-mapping.json for migration: {}",
931                        e
932                    ),
933                }
934            }
935        }
936
937        // mcp.json -> config.mcp
938        if self.mcp.servers.is_empty() {
939            let path = data_dir.join("mcp.json");
940            if path.exists() {
941                match std::fs::read_to_string(&path) {
942                    Ok(content) => {
943                        match serde_json::from_str::<crate::agent::mcp::McpConfig>(&content) {
944                            Ok(mcp) => {
945                                self.mcp = mcp;
946                                changed = true;
947                                let _ = backup_legacy_file(&path);
948                            }
949                            Err(e) => {
950                                log::warn!("Failed to migrate mcp.json into config.json: {}", e)
951                            }
952                        }
953                    }
954                    Err(e) => log::warn!("Failed to read mcp.json for migration: {}", e),
955                }
956            }
957        }
958
959        // permissions.json -> config.extra["permissions"]
960        if !self.extra.contains_key("permissions") {
961            let path = data_dir.join("permissions.json");
962            if path.exists() {
963                match std::fs::read_to_string(&path) {
964                    Ok(content) => match serde_json::from_str::<Value>(&content) {
965                        Ok(value) => {
966                            self.extra.insert("permissions".to_string(), value);
967                            changed = true;
968                            let _ = backup_legacy_file(&path);
969                        }
970                        Err(e) => {
971                            log::warn!("Failed to migrate permissions.json into config.json: {}", e)
972                        }
973                    },
974                    Err(e) => log::warn!("Failed to read permissions.json for migration: {}", e),
975                }
976            }
977        }
978
979        if changed {
980            if let Err(e) = self.save_to_dir(data_dir.to_path_buf()) {
981                log::warn!(
982                    "Failed to persist unified config.json after migration: {}",
983                    e
984                );
985            }
986        }
987    }
988}
989
990fn write_atomic(path: &std::path::Path, content: &[u8]) -> std::io::Result<()> {
991    let Some(parent) = path.parent() else {
992        return std::fs::write(path, content);
993    };
994
995    std::fs::create_dir_all(parent)?;
996
997    // Write to a temp file in the same directory then rename to ensure atomic replace.
998    // (Rename is atomic on Unix when source/dest are on the same filesystem.)
999    let file_name = path
1000        .file_name()
1001        .and_then(|s| s.to_str())
1002        .unwrap_or("config.json");
1003    let tmp_name = format!(".{}.tmp.{}", file_name, std::process::id());
1004    let tmp_path = parent.join(tmp_name);
1005
1006    {
1007        let mut file = std::fs::File::create(&tmp_path)?;
1008        file.write_all(content)?;
1009        file.sync_all()?;
1010    }
1011
1012    std::fs::rename(&tmp_path, path)?;
1013    Ok(())
1014}
1015
1016fn backup_legacy_file(path: &std::path::Path) -> std::io::Result<()> {
1017    let Some(parent) = path.parent() else {
1018        return Ok(());
1019    };
1020    let Some(name) = path.file_name().and_then(|s| s.to_str()) else {
1021        return Ok(());
1022    };
1023    let backup = parent.join(format!("{name}.migrated.bak"));
1024    if backup.exists() {
1025        return Ok(());
1026    }
1027    std::fs::rename(path, backup)?;
1028    Ok(())
1029}
1030
1031/// Legacy configuration format for backward compatibility
1032///
1033/// This struct is used to migrate old configuration files to the new format.
1034/// It supports the previous single-provider model.
1035#[derive(Debug, Clone, Serialize, Deserialize)]
1036struct OldConfig {
1037    #[serde(default)]
1038    http_proxy: String,
1039    #[serde(default)]
1040    https_proxy: String,
1041    #[serde(default)]
1042    http_proxy_auth: Option<ProxyAuth>,
1043    #[serde(default)]
1044    https_proxy_auth: Option<ProxyAuth>,
1045    api_key: Option<String>,
1046    api_base: Option<String>,
1047    model: Option<String>,
1048    #[serde(default)]
1049    headless_auth: bool,
1050    // Also capture new fields so we don't lose them during fallback
1051    #[serde(default = "default_provider")]
1052    provider: String,
1053    #[serde(default)]
1054    server: ServerConfig,
1055    #[serde(default)]
1056    providers: ProviderConfigs,
1057    #[serde(default)]
1058    data_dir: Option<PathBuf>,
1059
1060    #[serde(default)]
1061    keyword_masking: KeywordMaskingConfig,
1062    #[serde(default)]
1063    anthropic_model_mapping: AnthropicModelMapping,
1064    #[serde(default)]
1065    gemini_model_mapping: GeminiModelMapping,
1066    #[serde(default)]
1067    mcp: crate::agent::mcp::McpConfig,
1068
1069    /// Preserve unknown root keys for forward compatibility.
1070    #[serde(default, flatten)]
1071    extra: BTreeMap<String, Value>,
1072}
1073
1074/// Migrate old configuration format to new multi-provider format
1075///
1076/// Converts the legacy single-provider configuration to the new structure
1077/// with explicit provider configurations.
1078fn migrate_config(old: OldConfig) -> Config {
1079    // Log warning about deprecated fields
1080    if old.api_key.is_some() {
1081        log::warn!(
1082            "api_key is no longer used. CopilotClient automatically manages authentication."
1083        );
1084    }
1085    if old.api_base.is_some() {
1086        log::warn!(
1087            "api_base is no longer used. CopilotClient automatically manages API endpoints."
1088        );
1089    }
1090
1091    let proxy_auth = old.https_proxy_auth.or(old.http_proxy_auth);
1092    let proxy_auth_encrypted = proxy_auth
1093        .as_ref()
1094        .and_then(|auth| serde_json::to_string(auth).ok())
1095        .and_then(|auth_str| crate::core::encryption::encrypt(&auth_str).ok());
1096
1097    Config {
1098        http_proxy: old.http_proxy,
1099        https_proxy: old.https_proxy,
1100        // Use https_proxy_auth if available, otherwise fallback to http_proxy_auth
1101        proxy_auth,
1102        proxy_auth_encrypted,
1103        model: old.model,
1104        headless_auth: old.headless_auth,
1105        provider: old.provider,
1106        providers: old.providers,
1107        server: old.server,
1108        keyword_masking: old.keyword_masking,
1109        anthropic_model_mapping: old.anthropic_model_mapping,
1110        gemini_model_mapping: old.gemini_model_mapping,
1111        hooks: HooksConfig::default(),
1112        mcp: old.mcp,
1113        extra: old.extra,
1114    }
1115}
1116
1117#[cfg(test)]
1118mod tests {
1119    use super::*;
1120    use std::ffi::OsString;
1121    use std::path::PathBuf;
1122    use std::sync::{Mutex, OnceLock};
1123    use std::time::{SystemTime, UNIX_EPOCH};
1124
1125    struct EnvVarGuard {
1126        key: &'static str,
1127        previous: Option<OsString>,
1128    }
1129
1130    impl EnvVarGuard {
1131        fn set(key: &'static str, value: &str) -> Self {
1132            let previous = std::env::var_os(key);
1133            std::env::set_var(key, value);
1134            Self { key, previous }
1135        }
1136
1137        fn unset(key: &'static str) -> Self {
1138            let previous = std::env::var_os(key);
1139            std::env::remove_var(key);
1140            Self { key, previous }
1141        }
1142    }
1143
1144    impl Drop for EnvVarGuard {
1145        fn drop(&mut self) {
1146            match &self.previous {
1147                Some(value) => std::env::set_var(self.key, value),
1148                None => std::env::remove_var(self.key),
1149            }
1150        }
1151    }
1152
1153    struct TempHome {
1154        path: PathBuf,
1155    }
1156
1157    impl TempHome {
1158        fn new() -> Self {
1159            let nanos = SystemTime::now()
1160                .duration_since(UNIX_EPOCH)
1161                .expect("clock should be after unix epoch")
1162                .as_nanos();
1163            let path = std::env::temp_dir().join(format!(
1164                "chat-core-config-test-{}-{}",
1165                std::process::id(),
1166                nanos
1167            ));
1168            std::fs::create_dir_all(&path).expect("failed to create temp home dir");
1169            Self { path }
1170        }
1171
1172        fn set_config_json(&self, content: &str) {
1173            // Treat `path` as the Bamboo data dir and write `config.json` into it.
1174            // Tests should prefer BAMBOO_DATA_DIR over HOME to avoid global env contention.
1175            std::fs::create_dir_all(&self.path).expect("failed to create config dir");
1176            std::fs::write(self.path.join("config.json"), content)
1177                .expect("failed to write config.json");
1178        }
1179    }
1180
1181    impl Drop for TempHome {
1182        fn drop(&mut self) {
1183            let _ = std::fs::remove_dir_all(&self.path);
1184        }
1185    }
1186
1187    fn env_lock() -> &'static Mutex<()> {
1188        static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
1189        LOCK.get_or_init(|| Mutex::new(()))
1190    }
1191
1192    /// Acquire the environment lock, recovering from poison if a previous test failed
1193    fn env_lock_acquire() -> std::sync::MutexGuard<'static, ()> {
1194        env_lock().lock().unwrap_or_else(|poisoned| {
1195            // Lock was poisoned by a previous test failure - recover it
1196            poisoned.into_inner()
1197        })
1198    }
1199
1200    #[test]
1201    fn parse_bool_env_true_values() {
1202        for value in ["1", "true", "TRUE", " yes ", "Y", "on"] {
1203            assert!(parse_bool_env(value), "value {value:?} should be true");
1204        }
1205    }
1206
1207    #[test]
1208    fn parse_bool_env_false_values() {
1209        for value in ["0", "false", "no", "off", "", "  "] {
1210            assert!(!parse_bool_env(value), "value {value:?} should be false");
1211        }
1212    }
1213
1214    #[test]
1215    fn config_new_ignores_http_proxy_env_vars() {
1216        let _lock = env_lock_acquire();
1217        let temp_home = TempHome::new();
1218        temp_home.set_config_json(
1219            r#"{
1220  "http_proxy": "",
1221  "https_proxy": ""
1222}"#,
1223        );
1224
1225        let _http_proxy = EnvVarGuard::set("HTTP_PROXY", "http://env-proxy.example.com:8080");
1226        let _https_proxy = EnvVarGuard::set("HTTPS_PROXY", "http://env-proxy.example.com:8443");
1227
1228        let config = Config::from_data_dir(Some(temp_home.path.clone()));
1229
1230        assert!(
1231            config.http_proxy.is_empty(),
1232            "config should ignore HTTP_PROXY env var"
1233        );
1234        assert!(
1235            config.https_proxy.is_empty(),
1236            "config should ignore HTTPS_PROXY env var"
1237        );
1238    }
1239
1240    #[test]
1241    fn config_new_loads_config_when_proxy_fields_omitted() {
1242        let _lock = env_lock_acquire();
1243        let temp_home = TempHome::new();
1244        temp_home.set_config_json(
1245            r#"{
1246  "model": "gpt-4"
1247}"#,
1248        );
1249
1250        let _http_proxy = EnvVarGuard::unset("HTTP_PROXY");
1251        let _https_proxy = EnvVarGuard::unset("HTTPS_PROXY");
1252
1253        let config = Config::from_data_dir(Some(temp_home.path.clone()));
1254
1255        assert_eq!(
1256            config.model.as_deref(),
1257            Some("gpt-4"),
1258            "config should load model from config file even when proxy fields are omitted"
1259        );
1260        assert!(config.http_proxy.is_empty());
1261        assert!(config.https_proxy.is_empty());
1262    }
1263
1264    #[test]
1265    fn config_new_ignores_proxy_env_vars_when_proxy_fields_omitted() {
1266        let _lock = env_lock_acquire();
1267        let temp_home = TempHome::new();
1268        temp_home.set_config_json(
1269            r#"{
1270  "model": "gpt-4"
1271}"#,
1272        );
1273
1274        let _http_proxy = EnvVarGuard::set("HTTP_PROXY", "http://env-proxy.example.com:8080");
1275        let _https_proxy = EnvVarGuard::set("HTTPS_PROXY", "http://env-proxy.example.com:8443");
1276
1277        let config = Config::from_data_dir(Some(temp_home.path.clone()));
1278
1279        assert_eq!(config.model.as_deref(), Some("gpt-4"));
1280        assert!(
1281            config.http_proxy.is_empty(),
1282            "config should keep http_proxy empty when field is omitted"
1283        );
1284        assert!(
1285            config.https_proxy.is_empty(),
1286            "config should keep https_proxy empty when field is omitted"
1287        );
1288    }
1289
1290    #[test]
1291    fn config_migrates_old_format_to_new() {
1292        let _lock = env_lock_acquire();
1293        let temp_home = TempHome::new();
1294
1295        // Create config with old format
1296        temp_home.set_config_json(
1297            r#"{
1298  "http_proxy": "http://proxy.example.com:8080",
1299  "https_proxy": "http://proxy.example.com:8443",
1300  "http_proxy_auth": {
1301    "username": "http_user",
1302    "password": "http_pass"
1303  },
1304  "https_proxy_auth": {
1305    "username": "https_user",
1306    "password": "https_pass"
1307  },
1308  "api_key": "old_key",
1309  "api_base": "https://old.api.com",
1310  "model": "gpt-4",
1311  "headless_auth": true
1312}"#,
1313        );
1314
1315        let config = Config::from_data_dir(Some(temp_home.path.clone()));
1316
1317        // Verify migration
1318        assert_eq!(config.http_proxy, "http://proxy.example.com:8080");
1319        assert_eq!(config.https_proxy, "http://proxy.example.com:8443");
1320
1321        // Should use https_proxy_auth (higher priority)
1322        assert!(config.proxy_auth.is_some());
1323        let auth = config.proxy_auth.unwrap();
1324        assert_eq!(auth.username, "https_user");
1325        assert_eq!(auth.password, "https_pass");
1326
1327        // Model and headless_auth should be preserved
1328        assert_eq!(config.model.as_deref(), Some("gpt-4"));
1329        assert!(config.headless_auth);
1330
1331        // api_key and api_base are no longer in Config
1332    }
1333
1334    #[test]
1335    fn config_migrates_only_http_proxy_auth() {
1336        let _lock = env_lock_acquire();
1337        let temp_home = TempHome::new();
1338
1339        // Create config with only http_proxy_auth
1340        temp_home.set_config_json(
1341            r#"{
1342  "http_proxy": "http://proxy.example.com:8080",
1343  "http_proxy_auth": {
1344    "username": "http_user",
1345    "password": "http_pass"
1346  }
1347}"#,
1348        );
1349
1350        let config = Config::from_data_dir(Some(temp_home.path.clone()));
1351
1352        // Should fallback to http_proxy_auth when https_proxy_auth is absent
1353        assert!(
1354            config.proxy_auth.is_some(),
1355            "proxy_auth should be migrated from http_proxy_auth"
1356        );
1357        let auth = config.proxy_auth.unwrap();
1358        assert_eq!(auth.username, "http_user");
1359        assert_eq!(auth.password, "http_pass");
1360    }
1361
1362    #[test]
1363    fn test_server_config_defaults() {
1364        let _lock = env_lock_acquire();
1365        let temp_home = TempHome::new();
1366
1367        let config = Config::from_data_dir(Some(temp_home.path.clone()));
1368        assert_eq!(config.server.port, 8080);
1369        assert_eq!(config.server.bind, "127.0.0.1");
1370        assert_eq!(config.server.workers, 10);
1371        assert!(config.server.static_dir.is_none());
1372    }
1373
1374    #[test]
1375    fn test_server_addr() {
1376        let mut config = Config::default();
1377        config.server.port = 9000;
1378        config.server.bind = "0.0.0.0".to_string();
1379        assert_eq!(config.server_addr(), "0.0.0.0:9000");
1380    }
1381
1382    #[test]
1383    fn test_env_var_overrides() {
1384        let _lock = env_lock_acquire();
1385        let temp_home = TempHome::new();
1386
1387        let _port = EnvVarGuard::set("BAMBOO_PORT", "9999");
1388        let _bind = EnvVarGuard::set("BAMBOO_BIND", "192.168.1.1");
1389        let _provider = EnvVarGuard::set("BAMBOO_PROVIDER", "openai");
1390
1391        let config = Config::from_data_dir(Some(temp_home.path.clone()));
1392        assert_eq!(config.server.port, 9999);
1393        assert_eq!(config.server.bind, "192.168.1.1");
1394        assert_eq!(config.provider, "openai");
1395    }
1396
1397    #[test]
1398    fn test_config_save_and_load() {
1399        let _lock = env_lock_acquire();
1400        let temp_home = TempHome::new();
1401
1402        let mut config = Config::from_data_dir(Some(temp_home.path.clone()));
1403        config.server.port = 9000;
1404        config.server.bind = "0.0.0.0".to_string();
1405        config.provider = "anthropic".to_string();
1406
1407        // Save
1408        config
1409            .save_to_dir(temp_home.path.clone())
1410            .expect("Failed to save config");
1411
1412        // Load again
1413        let loaded = Config::from_data_dir(Some(temp_home.path.clone()));
1414
1415        // Verify
1416        assert_eq!(loaded.server.port, 9000);
1417        assert_eq!(loaded.server.bind, "0.0.0.0");
1418        assert_eq!(loaded.provider, "anthropic");
1419    }
1420
1421    #[test]
1422    fn config_decrypts_proxy_auth_from_encrypted_field() {
1423        let _lock = env_lock_acquire();
1424        let temp_home = TempHome::new();
1425
1426        // Use a stable encryption key so this test doesn't depend on host identifiers.
1427        let key_guard = crate::core::encryption::set_test_encryption_key([
1428            0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d,
1429            0x0e, 0x0f, 0x10, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, 0x18, 0x19, 0x1a, 0x1b,
1430            0x1c, 0x1d, 0x1e, 0x1f,
1431        ]);
1432
1433        let auth = ProxyAuth {
1434            username: "user".to_string(),
1435            password: "pass".to_string(),
1436        };
1437        let auth_str = serde_json::to_string(&auth).expect("serialize proxy auth");
1438        let encrypted = crate::core::encryption::encrypt(&auth_str).expect("encrypt proxy auth");
1439
1440        temp_home.set_config_json(&format!(
1441            r#"{{
1442  "http_proxy": "http://proxy.example.com:8080",
1443  "proxy_auth_encrypted": "{encrypted}"
1444}}"#
1445        ));
1446        let config = Config::from_data_dir(Some(temp_home.path.clone()));
1447        let loaded_auth = config.proxy_auth.expect("proxy auth should be hydrated");
1448        assert_eq!(loaded_auth.username, "user");
1449        assert_eq!(loaded_auth.password, "pass");
1450        drop(key_guard);
1451    }
1452
1453    #[test]
1454    fn config_decrypts_proxy_auth_from_legacy_scheme_encrypted_fields() {
1455        let _lock = env_lock_acquire();
1456        let temp_home = TempHome::new();
1457
1458        // Use a stable encryption key so this test doesn't depend on host identifiers.
1459        let key_guard = crate::core::encryption::set_test_encryption_key([
1460            0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d,
1461            0x0e, 0x0f, 0x10, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, 0x18, 0x19, 0x1a, 0x1b,
1462            0x1c, 0x1d, 0x1e, 0x1f,
1463        ]);
1464
1465        let auth = ProxyAuth {
1466            username: "user".to_string(),
1467            password: "pass".to_string(),
1468        };
1469        let auth_str = serde_json::to_string(&auth).expect("serialize proxy auth");
1470        let encrypted = crate::core::encryption::encrypt(&auth_str).expect("encrypt proxy auth");
1471
1472        // Simulate older Bodhi/Tauri persisted config keys.
1473        temp_home.set_config_json(&format!(
1474            r#"{{
1475  "http_proxy": "http://proxy.example.com:8080",
1476  "http_proxy_auth_encrypted": "{encrypted}",
1477  "https_proxy_auth_encrypted": "{encrypted}"
1478}}"#
1479        ));
1480
1481        let config = Config::from_data_dir(Some(temp_home.path.clone()));
1482        let loaded_auth = config.proxy_auth.expect("proxy auth should be hydrated");
1483        assert_eq!(loaded_auth.username, "user");
1484        assert_eq!(loaded_auth.password, "pass");
1485        drop(key_guard);
1486    }
1487
1488    #[test]
1489    fn config_save_encrypts_proxy_auth_and_load_hydrates_plaintext() {
1490        let _lock = env_lock_acquire();
1491        let temp_home = TempHome::new();
1492
1493        // Use a stable encryption key so this test doesn't depend on host identifiers.
1494        let key_guard = crate::core::encryption::set_test_encryption_key([
1495            0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d,
1496            0x0e, 0x0f, 0x10, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, 0x18, 0x19, 0x1a, 0x1b,
1497            0x1c, 0x1d, 0x1e, 0x1f,
1498        ]);
1499
1500        let mut config = Config::from_data_dir(Some(temp_home.path.clone()));
1501        config.proxy_auth = Some(ProxyAuth {
1502            username: "user".to_string(),
1503            password: "pass".to_string(),
1504        });
1505        config
1506            .save_to_dir(temp_home.path.clone())
1507            .expect("save should encrypt proxy auth");
1508
1509        let content =
1510            std::fs::read_to_string(temp_home.path.join("config.json")).expect("read config.json");
1511        assert!(
1512            content.contains("proxy_auth_encrypted"),
1513            "config.json should store encrypted proxy auth"
1514        );
1515        assert!(
1516            !content.contains("\"proxy_auth\""),
1517            "config.json should not store plaintext proxy_auth"
1518        );
1519
1520        let loaded = Config::from_data_dir(Some(temp_home.path.clone()));
1521        let loaded_auth = loaded.proxy_auth.expect("proxy auth should be hydrated");
1522        assert_eq!(loaded_auth.username, "user");
1523        assert_eq!(loaded_auth.password, "pass");
1524        drop(key_guard);
1525    }
1526
1527    #[test]
1528    fn config_save_encrypts_provider_api_keys_and_does_not_persist_plaintext() {
1529        let _lock = env_lock_acquire();
1530        let temp_home = TempHome::new();
1531
1532        // Use a stable encryption key so this test doesn't depend on host identifiers.
1533        let key_guard = crate::core::encryption::set_test_encryption_key([
1534            0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d,
1535            0x0e, 0x0f, 0x10, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, 0x18, 0x19, 0x1a, 0x1b,
1536            0x1c, 0x1d, 0x1e, 0x1f,
1537        ]);
1538
1539        let mut config = Config::from_data_dir(Some(temp_home.path.clone()));
1540        config.provider = "openai".to_string();
1541        config.providers.openai = Some(OpenAIConfig {
1542            api_key: "sk-test-provider-key".to_string(),
1543            api_key_encrypted: None,
1544            base_url: None,
1545            model: None,
1546            responses_only_models: vec![],
1547            extra: Default::default(),
1548        });
1549
1550        config
1551            .save_to_dir(temp_home.path.clone())
1552            .expect("save should encrypt provider api keys");
1553
1554        let content =
1555            std::fs::read_to_string(temp_home.path.join("config.json")).expect("read config.json");
1556        assert!(
1557            content.contains("\"api_key_encrypted\""),
1558            "config.json should store encrypted provider keys"
1559        );
1560        assert!(
1561            !content.contains("\"api_key\""),
1562            "config.json should not store plaintext provider keys"
1563        );
1564
1565        let loaded = Config::from_data_dir(Some(temp_home.path.clone()));
1566        let openai = loaded
1567            .providers
1568            .openai
1569            .expect("openai config should be present");
1570        assert_eq!(openai.api_key, "sk-test-provider-key");
1571
1572        drop(key_guard);
1573    }
1574
1575    #[test]
1576    fn config_save_persists_mcp_servers_in_mainstream_format() {
1577        let _lock = env_lock_acquire();
1578        let temp_home = TempHome::new();
1579
1580        let mut config = Config::from_data_dir(Some(temp_home.path.clone()));
1581
1582        let mut env = std::collections::HashMap::new();
1583        env.insert("TOKEN".to_string(), "supersecret".to_string());
1584
1585        config.mcp.servers = vec![
1586            crate::agent::mcp::McpServerConfig {
1587                id: "stdio-secret".to_string(),
1588                name: None,
1589                enabled: true,
1590                transport: crate::agent::mcp::TransportConfig::Stdio(
1591                    crate::agent::mcp::StdioConfig {
1592                        command: "echo".to_string(),
1593                        args: vec![],
1594                        cwd: None,
1595                        env,
1596                        env_encrypted: std::collections::HashMap::new(),
1597                        startup_timeout_ms: 5000,
1598                    },
1599                ),
1600                request_timeout_ms: 5000,
1601                healthcheck_interval_ms: 1000,
1602                reconnect: crate::agent::mcp::ReconnectConfig::default(),
1603                allowed_tools: vec![],
1604                denied_tools: vec![],
1605            },
1606            crate::agent::mcp::McpServerConfig {
1607                id: "sse-secret".to_string(),
1608                name: None,
1609                enabled: true,
1610                transport: crate::agent::mcp::TransportConfig::Sse(crate::agent::mcp::SseConfig {
1611                    url: "http://localhost:8080/sse".to_string(),
1612                    headers: vec![crate::agent::mcp::HeaderConfig {
1613                        name: "Authorization".to_string(),
1614                        value: "Bearer token123".to_string(),
1615                        value_encrypted: None,
1616                    }],
1617                    connect_timeout_ms: 5000,
1618                }),
1619                request_timeout_ms: 5000,
1620                healthcheck_interval_ms: 1000,
1621                reconnect: crate::agent::mcp::ReconnectConfig::default(),
1622                allowed_tools: vec![],
1623                denied_tools: vec![],
1624            },
1625        ];
1626
1627        config
1628            .save_to_dir(temp_home.path.clone())
1629            .expect("save should persist MCP servers");
1630
1631        let content =
1632            std::fs::read_to_string(temp_home.path.join("config.json")).expect("read config.json");
1633        assert!(
1634            content.contains("\"mcpServers\""),
1635            "config.json should store MCP servers under the mainstream 'mcpServers' key"
1636        );
1637        assert!(
1638            content.contains("supersecret"),
1639            "config.json should persist MCP stdio env in mainstream format"
1640        );
1641        assert!(
1642            content.contains("Bearer token123"),
1643            "config.json should persist MCP SSE headers in mainstream format"
1644        );
1645        assert!(
1646            !content.contains("\"env_encrypted\""),
1647            "config.json should not persist legacy env_encrypted fields"
1648        );
1649        assert!(
1650            !content.contains("\"value_encrypted\""),
1651            "config.json should not persist legacy value_encrypted fields"
1652        );
1653
1654        let loaded = Config::from_data_dir(Some(temp_home.path.clone()));
1655        let stdio = loaded
1656            .mcp
1657            .servers
1658            .iter()
1659            .find(|s| s.id == "stdio-secret")
1660            .expect("stdio server should exist");
1661        match &stdio.transport {
1662            crate::agent::mcp::TransportConfig::Stdio(stdio) => {
1663                assert_eq!(
1664                    stdio.env.get("TOKEN").map(|s| s.as_str()),
1665                    Some("supersecret")
1666                );
1667            }
1668            _ => panic!("Expected stdio transport"),
1669        }
1670
1671        let sse = loaded
1672            .mcp
1673            .servers
1674            .iter()
1675            .find(|s| s.id == "sse-secret")
1676            .expect("sse server should exist");
1677        match &sse.transport {
1678            crate::agent::mcp::TransportConfig::Sse(sse) => {
1679                assert_eq!(sse.headers[0].value, "Bearer token123");
1680            }
1681            _ => panic!("Expected SSE transport"),
1682        }
1683    }
1684}