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