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