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