Skip to main content

bamboo_agent/core/
config.rs

1//! Configuration management for Bamboo agent
2//!
3//! This module provides unified configuration types and loading logic for the entire
4//! Bamboo agent system. It supports multiple LLM providers, proxy settings,
5//! and JSON configuration format.
6//!
7//! # Configuration File
8//!
9//! Configuration is stored in `config.json` under the unified data directory
10//! (defaults to `~/.bamboo/`). Environment variables can override file values.
11//!
12//! # Example (JSON)
13//!
14//! ```json
15//! {
16//!   "provider": "anthropic",
17//!   "server": {
18//!     "port": 8080,
19//!     "bind": "127.0.0.1"
20//!   },
21//!   "providers": {
22//!     "anthropic": {
23//!       "api_key": "sk-ant-...",
24//!       "model": "claude-3-5-sonnet-20241022"
25//!     },
26//!     "openai": {
27//!       "api_key": "sk-...",
28//!       "base_url": "https://api.openai.com/v1"
29//!     }
30//!   }
31//! }
32//! ```
33//!
34//! # Priority Order
35//!
36//! Configuration values are loaded in this order (later overrides earlier):
37//! 1. Code defaults (hardcoded default values)
38//! 2. Config file values (from `~/.bamboo/config.json`)
39//! 3. Environment variables (e.g., `BAMBOO_PORT`)
40//! 4. CLI arguments (e.g., `--port 9000`)
41//!
42//! # Environment Variables
43//!
44//! - `BAMBOO_DATA_DIR`: Override data directory location
45//! - `BAMBOO_PORT`: Override server port
46//! - `BAMBOO_BIND`: Override server bind address
47//! - `BAMBOO_PROVIDER`: Override default provider
48//! - `BAMBOO_HEADLESS`: Enable headless authentication mode
49//! - `MODEL`: Override default model
50
51use anyhow::{Context, Result};
52use serde::{Deserialize, Serialize};
53use serde_json::Value;
54use std::collections::BTreeMap;
55use std::io::Write;
56use std::path::PathBuf;
57
58use crate::core::keyword_masking::KeywordMaskingConfig;
59use crate::core::model_mapping::{AnthropicModelMapping, GeminiModelMapping};
60
61/// Main configuration structure for Bamboo agent
62///
63/// Contains all settings needed to run the agent, including provider credentials,
64/// proxy settings, model selection, and server configuration.
65#[derive(Debug, Clone, Serialize, Deserialize)]
66pub struct Config {
67    /// HTTP proxy URL (e.g., `http://proxy.example.com:8080`)
68    #[serde(default)]
69    pub http_proxy: String,
70    /// HTTPS proxy URL (e.g., `https://proxy.example.com:8080`)
71    #[serde(default)]
72    pub https_proxy: String,
73    /// Proxy authentication credentials
74    ///
75    /// Note: this is kept in-memory only. On disk we store `proxy_auth_encrypted`.
76    #[serde(skip_serializing)]
77    pub proxy_auth: Option<ProxyAuth>,
78    /// Encrypted proxy authentication credentials (nonce:ciphertext)
79    ///
80    /// This is the at-rest storage representation. When present, Bamboo will
81    /// decrypt it into `proxy_auth` at load time.
82    #[serde(default, skip_serializing_if = "Option::is_none")]
83    pub proxy_auth_encrypted: Option<String>,
84    /// Default model to use (can be overridden per provider)
85    pub model: Option<String>,
86    /// Deprecated: Use `providers.copilot.headless_auth` instead
87    #[serde(default)]
88    pub headless_auth: bool,
89
90    /// Default LLM provider to use (e.g., "anthropic", "openai", "gemini", "copilot")
91    #[serde(default = "default_provider")]
92    pub provider: String,
93
94    /// Provider-specific configurations
95    #[serde(default)]
96    pub providers: ProviderConfigs,
97
98    /// HTTP server configuration
99    #[serde(default)]
100    pub server: ServerConfig,
101
102    /// Data directory path (defaults to ~/.bamboo)
103    #[serde(default = "default_data_dir")]
104    pub data_dir: PathBuf,
105
106    /// Global keyword masking configuration.
107    ///
108    /// Previously persisted in `keyword_masking.json` (now unified into `config.json`).
109    #[serde(default)]
110    pub keyword_masking: KeywordMaskingConfig,
111
112    /// Anthropic model mapping configuration.
113    ///
114    /// Previously persisted in `anthropic-model-mapping.json` (now unified into `config.json`).
115    #[serde(default)]
116    pub anthropic_model_mapping: AnthropicModelMapping,
117
118    /// Gemini model mapping configuration.
119    ///
120    /// Previously persisted in `gemini-model-mapping.json` (now unified into `config.json`).
121    #[serde(default)]
122    pub gemini_model_mapping: GeminiModelMapping,
123
124    /// MCP server configuration.
125    ///
126    /// Previously persisted in `mcp.json` (now unified into `config.json`).
127    #[serde(default)]
128    pub mcp: crate::agent::mcp::McpConfig,
129
130    /// Extension fields stored at the root of `config.json`.
131    ///
132    /// This keeps the config forward-compatible and allows unrelated subsystems
133    /// (e.g. setup UI state) to persist their own keys without getting dropped by
134    /// typed (de)serialization.
135    #[serde(default, flatten)]
136    pub extra: BTreeMap<String, Value>,
137}
138
139/// Container for provider-specific configurations
140///
141/// Each field is optional, allowing users to configure only the providers they need.
142#[derive(Debug, Clone, Default, Serialize, Deserialize)]
143pub struct ProviderConfigs {
144    /// OpenAI provider configuration
145    #[serde(skip_serializing_if = "Option::is_none")]
146    pub openai: Option<OpenAIConfig>,
147    /// Anthropic provider configuration
148    #[serde(skip_serializing_if = "Option::is_none")]
149    pub anthropic: Option<AnthropicConfig>,
150    /// Google Gemini provider configuration
151    #[serde(skip_serializing_if = "Option::is_none")]
152    pub gemini: Option<GeminiConfig>,
153    /// GitHub Copilot provider configuration
154    #[serde(skip_serializing_if = "Option::is_none")]
155    pub copilot: Option<CopilotConfig>,
156
157    /// Preserve unknown provider keys (forward compatibility).
158    #[serde(default, flatten)]
159    pub extra: BTreeMap<String, Value>,
160}
161
162/// OpenAI provider configuration
163///
164/// # Example
165///
166/// ```json
167/// "openai": {
168///   "api_key": "sk-...",
169///   "base_url": "https://api.openai.com/v1",
170///   "model": "gpt-4"
171/// }
172/// ```
173#[derive(Debug, Clone, Serialize, Deserialize)]
174pub struct OpenAIConfig {
175    /// OpenAI API key
176    pub api_key: String,
177    /// Custom API base URL (for Azure or self-hosted deployments)
178    #[serde(skip_serializing_if = "Option::is_none")]
179    pub base_url: Option<String>,
180    /// Default model to use (e.g., "gpt-4", "gpt-3.5-turbo")
181    #[serde(skip_serializing_if = "Option::is_none")]
182    pub model: Option<String>,
183
184    /// Preserve unknown keys under `providers.openai`.
185    #[serde(default, flatten)]
186    pub extra: BTreeMap<String, Value>,
187}
188
189/// Anthropic provider configuration
190///
191/// # Example
192///
193/// ```json
194/// "anthropic": {
195///   "api_key": "sk-ant-...",
196///   "model": "claude-3-5-sonnet-20241022",
197///   "max_tokens": 4096
198/// }
199/// ```
200#[derive(Debug, Clone, Serialize, Deserialize)]
201pub struct AnthropicConfig {
202    /// Anthropic API key
203    pub api_key: String,
204    /// Custom API base URL
205    #[serde(skip_serializing_if = "Option::is_none")]
206    pub base_url: Option<String>,
207    /// Default model to use (e.g., "claude-3-5-sonnet-20241022")
208    #[serde(skip_serializing_if = "Option::is_none")]
209    pub model: Option<String>,
210    /// Maximum tokens in model response
211    #[serde(skip_serializing_if = "Option::is_none")]
212    pub max_tokens: Option<u32>,
213
214    /// Preserve unknown keys under `providers.anthropic`.
215    #[serde(default, flatten)]
216    pub extra: BTreeMap<String, Value>,
217}
218
219/// Google Gemini provider configuration
220///
221/// # Example
222///
223/// ```json
224/// "gemini": {
225///   "api_key": "AIza...",
226///   "model": "gemini-2.0-flash-exp"
227/// }
228/// ```
229#[derive(Debug, Clone, Serialize, Deserialize)]
230pub struct GeminiConfig {
231    /// Google AI API key
232    pub api_key: String,
233    /// Custom API base URL
234    #[serde(skip_serializing_if = "Option::is_none")]
235    pub base_url: Option<String>,
236    /// Default model to use (e.g., "gemini-2.0-flash-exp")
237    #[serde(skip_serializing_if = "Option::is_none")]
238    pub model: Option<String>,
239
240    /// Preserve unknown keys under `providers.gemini`.
241    #[serde(default, flatten)]
242    pub extra: BTreeMap<String, Value>,
243}
244
245/// GitHub Copilot provider configuration
246///
247/// # Example
248///
249/// ```json
250/// "copilot": {
251///   "enabled": true,
252///   "headless_auth": false,
253///   "model": "gpt-4o"
254/// }
255/// ```
256#[derive(Debug, Clone, Default, Serialize, Deserialize)]
257pub struct CopilotConfig {
258    /// Whether Copilot provider is enabled
259    #[serde(default)]
260    pub enabled: bool,
261    /// Print login URL to console instead of opening browser
262    #[serde(default)]
263    pub headless_auth: bool,
264    /// Default model to use for Copilot (used when clients request the "default" model)
265    #[serde(skip_serializing_if = "Option::is_none")]
266    pub model: Option<String>,
267
268    /// Preserve unknown keys under `providers.copilot`.
269    #[serde(default, flatten)]
270    pub extra: BTreeMap<String, Value>,
271}
272
273/// Returns the default provider name ("anthropic")
274fn default_provider() -> String {
275    "anthropic".to_string()
276}
277
278/// Returns the default server port (8080)
279fn default_port() -> u16 {
280    8080
281}
282
283/// Returns the default bind address (127.0.0.1)
284fn default_bind() -> String {
285    "127.0.0.1".to_string()
286}
287
288/// Returns the default worker count (10)
289fn default_workers() -> usize {
290    10
291}
292
293/// Returns the default data directory (~/.bamboo)
294fn default_data_dir() -> PathBuf {
295    super::paths::bamboo_dir()
296}
297
298/// HTTP server configuration
299#[derive(Debug, Clone, Serialize, Deserialize)]
300pub struct ServerConfig {
301    /// Port to listen on
302    #[serde(default = "default_port")]
303    pub port: u16,
304
305    /// Bind address (127.0.0.1, 0.0.0.0, etc.)
306    #[serde(default = "default_bind")]
307    pub bind: String,
308
309    /// Static files directory (for Docker mode)
310    pub static_dir: Option<PathBuf>,
311
312    /// Worker count for Actix-web
313    #[serde(default = "default_workers")]
314    pub workers: usize,
315
316    /// Preserve unknown keys under `server`.
317    #[serde(default, flatten)]
318    pub extra: BTreeMap<String, Value>,
319}
320
321impl Default for ServerConfig {
322    fn default() -> Self {
323        Self {
324            port: default_port(),
325            bind: default_bind(),
326            static_dir: None,
327            workers: default_workers(),
328            extra: BTreeMap::new(),
329        }
330    }
331}
332
333/// Proxy authentication credentials
334#[derive(Debug, Clone, Serialize, Deserialize)]
335pub struct ProxyAuth {
336    /// Proxy username
337    pub username: String,
338    /// Proxy password
339    pub password: String,
340}
341
342/// Configuration file name
343const CONFIG_FILE_PATH: &str = "config.toml";
344
345/// Parse a boolean value from environment variable strings
346///
347/// Accepts: "1", "true", "yes", "y", "on" (case-insensitive)
348fn parse_bool_env(value: &str) -> bool {
349    matches!(
350        value.trim().to_ascii_lowercase().as_str(),
351        "1" | "true" | "yes" | "y" | "on"
352    )
353}
354
355impl Default for Config {
356    fn default() -> Self {
357        Self::new()
358    }
359}
360
361impl Config {
362    /// Load configuration from file with environment variable overrides
363    ///
364    /// Configuration loading order:
365    /// 1. Try loading from `config.json` (data_dir/config.json)
366    /// 2. Migrate old format if detected
367    /// 3. Fallback to `config.toml` in current directory
368    /// 4. Use defaults
369    /// 5. Apply environment variable overrides (highest priority)
370    ///
371    /// # Environment Variables
372    ///
373    /// - `BAMBOO_PORT`: Override server port
374    /// - `BAMBOO_BIND`: Override bind address
375    /// - `BAMBOO_DATA_DIR`: Override data directory
376    /// - `BAMBOO_PROVIDER`: Override default provider
377    /// - `MODEL`: Default model name
378    /// - `BAMBOO_HEADLESS`: Enable headless authentication mode
379    pub fn new() -> Self {
380        Self::from_data_dir(None)
381    }
382
383    /// Load configuration from a specific data directory
384    ///
385    /// # Arguments
386    ///
387    /// * `data_dir` - Optional data directory path. If None, uses default (~/.bamboo)
388    pub fn from_data_dir(data_dir: Option<PathBuf>) -> Self {
389        // Determine data_dir early (needed to find config file)
390        let data_dir = data_dir
391            .or_else(|| std::env::var("BAMBOO_DATA_DIR").ok().map(PathBuf::from))
392            .unwrap_or_else(default_data_dir);
393
394        let config_path = data_dir.join("config.json");
395
396        let mut config = if config_path.exists() {
397            if let Ok(content) = std::fs::read_to_string(&config_path) {
398                // Try to parse as old format first (for migration)
399                if let Ok(old_config) = serde_json::from_str::<OldConfig>(&content) {
400                    // Check if it has old-only fields (indicating a true old config that needs migration)
401                    let has_old_fields = old_config.http_proxy_auth.is_some()
402                        || old_config.https_proxy_auth.is_some()
403                        || old_config.api_key.is_some()
404                        || old_config.api_base.is_some();
405
406                    if has_old_fields {
407                        log::info!("Migrating old config format to new format");
408                        let migrated = migrate_config(old_config);
409                        // Save migrated config
410                        if let Ok(new_content) = serde_json::to_string_pretty(&migrated) {
411                            let _ = std::fs::write(&config_path, new_content);
412                        }
413                        migrated
414                    } else {
415                        // No old fields, so try to parse as new Config
416                        // OldConfig successfully parsed common fields like http_proxy, model, provider, etc.
417                        // Try Config, but if it fails (e.g., due to syntax errors), use OldConfig values
418                        match serde_json::from_str::<Config>(&content) {
419                            Ok(mut config) => {
420                                config.hydrate_proxy_auth_from_encrypted();
421                                config
422                            }
423                            Err(_) => {
424                                // Config parse failed, but OldConfig worked, so preserve those values
425                                migrate_config(old_config)
426                            }
427                        }
428                    }
429                } else {
430                    // Couldn't parse as OldConfig, try as Config
431                    serde_json::from_str::<Config>(&content)
432                        .map(|mut config| {
433                            config.hydrate_proxy_auth_from_encrypted();
434                            config
435                        })
436                        .unwrap_or_else(|_| Self::create_default())
437                }
438            } else {
439                Self::create_default()
440            }
441        } else {
442            // Fallback to legacy config.toml
443            if std::path::Path::new(CONFIG_FILE_PATH).exists() {
444                if let Ok(content) = std::fs::read_to_string(CONFIG_FILE_PATH) {
445                    if let Ok(old_config) = toml::from_str::<OldConfig>(&content) {
446                        migrate_config(old_config)
447                    } else {
448                        Self::create_default()
449                    }
450                } else {
451                    Self::create_default()
452                }
453            } else {
454                Self::create_default()
455            }
456        };
457
458        // Ensure data_dir is set correctly
459        config.data_dir = data_dir;
460        // Decrypt encrypted proxy auth into in-memory plaintext form.
461        config.hydrate_proxy_auth_from_encrypted();
462
463        // Best-effort migration from legacy sidecar config files into unified config.json.
464        // This keeps config state globally unified going forward without breaking existing installs.
465        config.migrate_legacy_sidecar_configs();
466
467        // Apply environment variable overrides (highest priority)
468        if let Ok(port) = std::env::var("BAMBOO_PORT") {
469            if let Ok(port) = port.parse() {
470                config.server.port = port;
471            }
472        }
473
474        if let Ok(bind) = std::env::var("BAMBOO_BIND") {
475            config.server.bind = bind;
476        }
477
478        // Note: BAMBOO_DATA_DIR already handled above
479        if let Ok(provider) = std::env::var("BAMBOO_PROVIDER") {
480            config.provider = provider;
481        }
482
483        if let Ok(model) = std::env::var("MODEL") {
484            config.model = Some(model);
485        }
486
487        if let Ok(headless) = std::env::var("BAMBOO_HEADLESS") {
488            config.headless_auth = parse_bool_env(&headless);
489        }
490
491        config
492    }
493
494    /// Populate `proxy_auth` (plaintext) from `proxy_auth_encrypted` if present.
495    ///
496    /// Many parts of the code rely on `proxy_auth` being hydrated in-memory so
497    /// we can re-encrypt deterministically on save without ever persisting
498    /// plaintext credentials.
499    pub fn hydrate_proxy_auth_from_encrypted(&mut self) {
500        if self.proxy_auth.is_some() {
501            return;
502        }
503
504        let Some(encrypted) = self.proxy_auth_encrypted.as_deref() else {
505            return;
506        };
507
508        match crate::core::encryption::decrypt(encrypted) {
509            Ok(decrypted) => match serde_json::from_str::<ProxyAuth>(&decrypted) {
510                Ok(auth) => self.proxy_auth = Some(auth),
511                Err(e) => log::warn!("Failed to parse decrypted proxy auth JSON: {}", e),
512            },
513            Err(e) => log::warn!("Failed to decrypt proxy auth: {}", e),
514        }
515    }
516
517    /// Refresh `proxy_auth_encrypted` from the current in-memory `proxy_auth`.
518    ///
519    /// This is used both when persisting the config to disk and when generating
520    /// API responses that should never include plaintext proxy credentials.
521    pub fn refresh_proxy_auth_encrypted(&mut self) -> Result<()> {
522        // Keep on-disk representation fully derived from the in-memory plaintext:
523        // - Some(auth)  => always (re-)encrypt and store `proxy_auth_encrypted`
524        // - None        => remove `proxy_auth_encrypted`
525        let Some(auth) = self.proxy_auth.as_ref() else {
526            self.proxy_auth_encrypted = None;
527            return Ok(());
528        };
529
530        let auth_str = serde_json::to_string(auth).context("Failed to serialize proxy auth")?;
531        let encrypted =
532            crate::core::encryption::encrypt(&auth_str).context("Failed to encrypt proxy auth")?;
533        self.proxy_auth_encrypted = Some(encrypted);
534        Ok(())
535    }
536
537    /// Create a default configuration without loading from file
538    fn create_default() -> Self {
539        Config {
540            http_proxy: String::new(),
541            https_proxy: String::new(),
542            proxy_auth: None,
543            proxy_auth_encrypted: None,
544            model: None,
545            headless_auth: false,
546            provider: default_provider(),
547            providers: ProviderConfigs::default(),
548            server: ServerConfig::default(),
549            data_dir: default_data_dir(),
550            keyword_masking: KeywordMaskingConfig::default(),
551            anthropic_model_mapping: AnthropicModelMapping::default(),
552            gemini_model_mapping: GeminiModelMapping::default(),
553            mcp: crate::agent::mcp::McpConfig::default(),
554            extra: BTreeMap::new(),
555        }
556    }
557
558    /// Get the full server address (bind:port)
559    pub fn server_addr(&self) -> String {
560        format!("{}:{}", self.server.bind, self.server.port)
561    }
562
563    /// Save configuration to disk
564    pub fn save(&self) -> Result<()> {
565        let path = self.data_dir.join("config.json");
566
567        if let Some(parent) = path.parent() {
568            std::fs::create_dir_all(parent)
569                .with_context(|| format!("Failed to create config dir: {:?}", parent))?;
570        }
571
572        let mut to_save = self.clone();
573        to_save.refresh_proxy_auth_encrypted()?;
574        let content =
575            serde_json::to_string_pretty(&to_save).context("Failed to serialize config to JSON")?;
576        write_atomic(&path, content.as_bytes())
577            .with_context(|| format!("Failed to write config file: {:?}", path))?;
578
579        Ok(())
580    }
581
582    fn migrate_legacy_sidecar_configs(&mut self) {
583        let data_dir = self.data_dir.clone();
584        let mut changed = false;
585
586        // keyword_masking.json -> config.keyword_masking
587        if self.keyword_masking.entries.is_empty() {
588            let path = data_dir.join("keyword_masking.json");
589            if path.exists() {
590                match std::fs::read_to_string(&path) {
591                    Ok(content) => match serde_json::from_str::<KeywordMaskingConfig>(&content) {
592                        Ok(km) => {
593                            self.keyword_masking = km;
594                            changed = true;
595                            let _ = backup_legacy_file(&path);
596                        }
597                        Err(e) => log::warn!(
598                            "Failed to migrate keyword_masking.json into config.json: {}",
599                            e
600                        ),
601                    },
602                    Err(e) => {
603                        log::warn!("Failed to read keyword_masking.json for migration: {}", e)
604                    }
605                }
606            }
607        }
608
609        // anthropic-model-mapping.json -> config.anthropic_model_mapping
610        if self.anthropic_model_mapping.mappings.is_empty() {
611            let path = data_dir.join("anthropic-model-mapping.json");
612            if path.exists() {
613                match std::fs::read_to_string(&path) {
614                    Ok(content) => match serde_json::from_str::<AnthropicModelMapping>(&content) {
615                        Ok(mapping) => {
616                            self.anthropic_model_mapping = mapping;
617                            changed = true;
618                            let _ = backup_legacy_file(&path);
619                        }
620                        Err(e) => log::warn!(
621                            "Failed to migrate anthropic-model-mapping.json into config.json: {}",
622                            e
623                        ),
624                    },
625                    Err(e) => log::warn!(
626                        "Failed to read anthropic-model-mapping.json for migration: {}",
627                        e
628                    ),
629                }
630            }
631        }
632
633        // gemini-model-mapping.json -> config.gemini_model_mapping
634        if self.gemini_model_mapping.mappings.is_empty() {
635            let path = data_dir.join("gemini-model-mapping.json");
636            if path.exists() {
637                match std::fs::read_to_string(&path) {
638                    Ok(content) => match serde_json::from_str::<GeminiModelMapping>(&content) {
639                        Ok(mapping) => {
640                            self.gemini_model_mapping = mapping;
641                            changed = true;
642                            let _ = backup_legacy_file(&path);
643                        }
644                        Err(e) => log::warn!(
645                            "Failed to migrate gemini-model-mapping.json into config.json: {}",
646                            e
647                        ),
648                    },
649                    Err(e) => log::warn!(
650                        "Failed to read gemini-model-mapping.json for migration: {}",
651                        e
652                    ),
653                }
654            }
655        }
656
657        // mcp.json -> config.mcp
658        if self.mcp.servers.is_empty() {
659            let path = data_dir.join("mcp.json");
660            if path.exists() {
661                match std::fs::read_to_string(&path) {
662                    Ok(content) => {
663                        match serde_json::from_str::<crate::agent::mcp::McpConfig>(&content) {
664                            Ok(mcp) => {
665                                self.mcp = mcp;
666                                changed = true;
667                                let _ = backup_legacy_file(&path);
668                            }
669                            Err(e) => {
670                                log::warn!("Failed to migrate mcp.json into config.json: {}", e)
671                            }
672                        }
673                    }
674                    Err(e) => log::warn!("Failed to read mcp.json for migration: {}", e),
675                }
676            }
677        }
678
679        // permissions.json -> config.extra["permissions"]
680        if !self.extra.contains_key("permissions") {
681            let path = data_dir.join("permissions.json");
682            if path.exists() {
683                match std::fs::read_to_string(&path) {
684                    Ok(content) => match serde_json::from_str::<Value>(&content) {
685                        Ok(value) => {
686                            self.extra.insert("permissions".to_string(), value);
687                            changed = true;
688                            let _ = backup_legacy_file(&path);
689                        }
690                        Err(e) => {
691                            log::warn!("Failed to migrate permissions.json into config.json: {}", e)
692                        }
693                    },
694                    Err(e) => log::warn!("Failed to read permissions.json for migration: {}", e),
695                }
696            }
697        }
698
699        if changed {
700            if let Err(e) = self.save() {
701                log::warn!(
702                    "Failed to persist unified config.json after migration: {}",
703                    e
704                );
705            }
706        }
707    }
708}
709
710fn write_atomic(path: &std::path::Path, content: &[u8]) -> std::io::Result<()> {
711    let Some(parent) = path.parent() else {
712        return std::fs::write(path, content);
713    };
714
715    std::fs::create_dir_all(parent)?;
716
717    // Write to a temp file in the same directory then rename to ensure atomic replace.
718    // (Rename is atomic on Unix when source/dest are on the same filesystem.)
719    let file_name = path
720        .file_name()
721        .and_then(|s| s.to_str())
722        .unwrap_or("config.json");
723    let tmp_name = format!(".{}.tmp.{}", file_name, std::process::id());
724    let tmp_path = parent.join(tmp_name);
725
726    {
727        let mut file = std::fs::File::create(&tmp_path)?;
728        file.write_all(content)?;
729        file.sync_all()?;
730    }
731
732    std::fs::rename(&tmp_path, path)?;
733    Ok(())
734}
735
736fn backup_legacy_file(path: &std::path::Path) -> std::io::Result<()> {
737    let Some(parent) = path.parent() else {
738        return Ok(());
739    };
740    let Some(name) = path.file_name().and_then(|s| s.to_str()) else {
741        return Ok(());
742    };
743    let backup = parent.join(format!("{name}.migrated.bak"));
744    if backup.exists() {
745        return Ok(());
746    }
747    std::fs::rename(path, backup)?;
748    Ok(())
749}
750
751/// Legacy configuration format for backward compatibility
752///
753/// This struct is used to migrate old configuration files to the new format.
754/// It supports the previous single-provider model.
755#[derive(Debug, Clone, Serialize, Deserialize)]
756struct OldConfig {
757    #[serde(default)]
758    http_proxy: String,
759    #[serde(default)]
760    https_proxy: String,
761    #[serde(default)]
762    http_proxy_auth: Option<ProxyAuth>,
763    #[serde(default)]
764    https_proxy_auth: Option<ProxyAuth>,
765    api_key: Option<String>,
766    api_base: Option<String>,
767    model: Option<String>,
768    #[serde(default)]
769    headless_auth: bool,
770    // Also capture new fields so we don't lose them during fallback
771    #[serde(default = "default_provider")]
772    provider: String,
773    #[serde(default)]
774    server: ServerConfig,
775    #[serde(default)]
776    providers: ProviderConfigs,
777    #[serde(default)]
778    data_dir: Option<PathBuf>,
779
780    #[serde(default)]
781    keyword_masking: KeywordMaskingConfig,
782    #[serde(default)]
783    anthropic_model_mapping: AnthropicModelMapping,
784    #[serde(default)]
785    gemini_model_mapping: GeminiModelMapping,
786    #[serde(default)]
787    mcp: crate::agent::mcp::McpConfig,
788
789    /// Preserve unknown root keys for forward compatibility.
790    #[serde(default, flatten)]
791    extra: BTreeMap<String, Value>,
792}
793
794/// Migrate old configuration format to new multi-provider format
795///
796/// Converts the legacy single-provider configuration to the new structure
797/// with explicit provider configurations.
798fn migrate_config(old: OldConfig) -> Config {
799    // Log warning about deprecated fields
800    if old.api_key.is_some() {
801        log::warn!(
802            "api_key is no longer used. CopilotClient automatically manages authentication."
803        );
804    }
805    if old.api_base.is_some() {
806        log::warn!(
807            "api_base is no longer used. CopilotClient automatically manages API endpoints."
808        );
809    }
810
811    let proxy_auth = old.https_proxy_auth.or(old.http_proxy_auth);
812    let proxy_auth_encrypted = proxy_auth
813        .as_ref()
814        .and_then(|auth| serde_json::to_string(auth).ok())
815        .and_then(|auth_str| crate::core::encryption::encrypt(&auth_str).ok());
816
817    Config {
818        http_proxy: old.http_proxy,
819        https_proxy: old.https_proxy,
820        // Use https_proxy_auth if available, otherwise fallback to http_proxy_auth
821        proxy_auth,
822        proxy_auth_encrypted,
823        model: old.model,
824        headless_auth: old.headless_auth,
825        provider: old.provider,
826        providers: old.providers,
827        server: old.server,
828        data_dir: old.data_dir.unwrap_or_else(default_data_dir),
829        keyword_masking: old.keyword_masking,
830        anthropic_model_mapping: old.anthropic_model_mapping,
831        gemini_model_mapping: old.gemini_model_mapping,
832        mcp: old.mcp,
833        extra: old.extra,
834    }
835}
836
837#[cfg(test)]
838mod tests {
839    use super::*;
840    use std::ffi::OsString;
841    use std::path::PathBuf;
842    use std::sync::{Mutex, OnceLock};
843    use std::time::{SystemTime, UNIX_EPOCH};
844
845    struct EnvVarGuard {
846        key: &'static str,
847        previous: Option<OsString>,
848    }
849
850    impl EnvVarGuard {
851        fn set(key: &'static str, value: &str) -> Self {
852            let previous = std::env::var_os(key);
853            std::env::set_var(key, value);
854            Self { key, previous }
855        }
856
857        fn unset(key: &'static str) -> Self {
858            let previous = std::env::var_os(key);
859            std::env::remove_var(key);
860            Self { key, previous }
861        }
862    }
863
864    impl Drop for EnvVarGuard {
865        fn drop(&mut self) {
866            match &self.previous {
867                Some(value) => std::env::set_var(self.key, value),
868                None => std::env::remove_var(self.key),
869            }
870        }
871    }
872
873    struct TempHome {
874        path: PathBuf,
875    }
876
877    impl TempHome {
878        fn new() -> Self {
879            let nanos = SystemTime::now()
880                .duration_since(UNIX_EPOCH)
881                .expect("clock should be after unix epoch")
882                .as_nanos();
883            let path = std::env::temp_dir().join(format!(
884                "chat-core-config-test-{}-{}",
885                std::process::id(),
886                nanos
887            ));
888            std::fs::create_dir_all(&path).expect("failed to create temp home dir");
889            Self { path }
890        }
891
892        fn set_config_json(&self, content: &str) {
893            // Treat `path` as the Bamboo data dir and write `config.json` into it.
894            // Tests should prefer BAMBOO_DATA_DIR over HOME to avoid global env contention.
895            std::fs::create_dir_all(&self.path).expect("failed to create config dir");
896            std::fs::write(self.path.join("config.json"), content)
897                .expect("failed to write config.json");
898        }
899    }
900
901    impl Drop for TempHome {
902        fn drop(&mut self) {
903            let _ = std::fs::remove_dir_all(&self.path);
904        }
905    }
906
907    fn env_lock() -> &'static Mutex<()> {
908        static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
909        LOCK.get_or_init(|| Mutex::new(()))
910    }
911
912    /// Acquire the environment lock, recovering from poison if a previous test failed
913    fn env_lock_acquire() -> std::sync::MutexGuard<'static, ()> {
914        env_lock().lock().unwrap_or_else(|poisoned| {
915            // Lock was poisoned by a previous test failure - recover it
916            poisoned.into_inner()
917        })
918    }
919
920    #[test]
921    fn parse_bool_env_true_values() {
922        for value in ["1", "true", "TRUE", " yes ", "Y", "on"] {
923            assert!(parse_bool_env(value), "value {value:?} should be true");
924        }
925    }
926
927    #[test]
928    fn parse_bool_env_false_values() {
929        for value in ["0", "false", "no", "off", "", "  "] {
930            assert!(!parse_bool_env(value), "value {value:?} should be false");
931        }
932    }
933
934    #[test]
935    fn config_new_ignores_http_proxy_env_vars() {
936        let _lock = env_lock_acquire();
937        let temp_home = TempHome::new();
938        temp_home.set_config_json(
939            r#"{
940  "http_proxy": "",
941  "https_proxy": ""
942}"#,
943        );
944
945        let _http_proxy = EnvVarGuard::set("HTTP_PROXY", "http://env-proxy.example.com:8080");
946        let _https_proxy = EnvVarGuard::set("HTTPS_PROXY", "http://env-proxy.example.com:8443");
947
948        let config = Config::from_data_dir(Some(temp_home.path.clone()));
949
950        assert!(
951            config.http_proxy.is_empty(),
952            "config should ignore HTTP_PROXY env var"
953        );
954        assert!(
955            config.https_proxy.is_empty(),
956            "config should ignore HTTPS_PROXY env var"
957        );
958    }
959
960    #[test]
961    fn config_new_loads_config_when_proxy_fields_omitted() {
962        let _lock = env_lock_acquire();
963        let temp_home = TempHome::new();
964        temp_home.set_config_json(
965            r#"{
966  "model": "gpt-4"
967}"#,
968        );
969
970        let _http_proxy = EnvVarGuard::unset("HTTP_PROXY");
971        let _https_proxy = EnvVarGuard::unset("HTTPS_PROXY");
972
973        let config = Config::from_data_dir(Some(temp_home.path.clone()));
974
975        assert_eq!(
976            config.model.as_deref(),
977            Some("gpt-4"),
978            "config should load model from config file even when proxy fields are omitted"
979        );
980        assert!(config.http_proxy.is_empty());
981        assert!(config.https_proxy.is_empty());
982    }
983
984    #[test]
985    fn config_new_ignores_proxy_env_vars_when_proxy_fields_omitted() {
986        let _lock = env_lock_acquire();
987        let temp_home = TempHome::new();
988        temp_home.set_config_json(
989            r#"{
990  "model": "gpt-4"
991}"#,
992        );
993
994        let _http_proxy = EnvVarGuard::set("HTTP_PROXY", "http://env-proxy.example.com:8080");
995        let _https_proxy = EnvVarGuard::set("HTTPS_PROXY", "http://env-proxy.example.com:8443");
996
997        let config = Config::from_data_dir(Some(temp_home.path.clone()));
998
999        assert_eq!(config.model.as_deref(), Some("gpt-4"));
1000        assert!(
1001            config.http_proxy.is_empty(),
1002            "config should keep http_proxy empty when field is omitted"
1003        );
1004        assert!(
1005            config.https_proxy.is_empty(),
1006            "config should keep https_proxy empty when field is omitted"
1007        );
1008    }
1009
1010    #[test]
1011    fn config_migrates_old_format_to_new() {
1012        let _lock = env_lock_acquire();
1013        let temp_home = TempHome::new();
1014
1015        // Create config with old format
1016        temp_home.set_config_json(
1017            r#"{
1018  "http_proxy": "http://proxy.example.com:8080",
1019  "https_proxy": "http://proxy.example.com:8443",
1020  "http_proxy_auth": {
1021    "username": "http_user",
1022    "password": "http_pass"
1023  },
1024  "https_proxy_auth": {
1025    "username": "https_user",
1026    "password": "https_pass"
1027  },
1028  "api_key": "old_key",
1029  "api_base": "https://old.api.com",
1030  "model": "gpt-4",
1031  "headless_auth": true
1032}"#,
1033        );
1034
1035        let config = Config::from_data_dir(Some(temp_home.path.clone()));
1036
1037        // Verify migration
1038        assert_eq!(config.http_proxy, "http://proxy.example.com:8080");
1039        assert_eq!(config.https_proxy, "http://proxy.example.com:8443");
1040
1041        // Should use https_proxy_auth (higher priority)
1042        assert!(config.proxy_auth.is_some());
1043        let auth = config.proxy_auth.unwrap();
1044        assert_eq!(auth.username, "https_user");
1045        assert_eq!(auth.password, "https_pass");
1046
1047        // Model and headless_auth should be preserved
1048        assert_eq!(config.model.as_deref(), Some("gpt-4"));
1049        assert!(config.headless_auth);
1050
1051        // api_key and api_base are no longer in Config
1052    }
1053
1054    #[test]
1055    fn config_migrates_only_http_proxy_auth() {
1056        let _lock = env_lock_acquire();
1057        let temp_home = TempHome::new();
1058
1059        // Create config with only http_proxy_auth
1060        temp_home.set_config_json(
1061            r#"{
1062  "http_proxy": "http://proxy.example.com:8080",
1063  "http_proxy_auth": {
1064    "username": "http_user",
1065    "password": "http_pass"
1066  }
1067}"#,
1068        );
1069
1070        let config = Config::from_data_dir(Some(temp_home.path.clone()));
1071
1072        // Should fallback to http_proxy_auth when https_proxy_auth is absent
1073        assert!(
1074            config.proxy_auth.is_some(),
1075            "proxy_auth should be migrated from http_proxy_auth"
1076        );
1077        let auth = config.proxy_auth.unwrap();
1078        assert_eq!(auth.username, "http_user");
1079        assert_eq!(auth.password, "http_pass");
1080    }
1081
1082    #[test]
1083    fn test_server_config_defaults() {
1084        let _lock = env_lock_acquire();
1085        let temp_home = TempHome::new();
1086
1087        let config = Config::from_data_dir(Some(temp_home.path.clone()));
1088        assert_eq!(config.server.port, 8080);
1089        assert_eq!(config.server.bind, "127.0.0.1");
1090        assert_eq!(config.server.workers, 10);
1091        assert!(config.server.static_dir.is_none());
1092    }
1093
1094    #[test]
1095    fn test_server_addr() {
1096        let mut config = Config::default();
1097        config.server.port = 9000;
1098        config.server.bind = "0.0.0.0".to_string();
1099        assert_eq!(config.server_addr(), "0.0.0.0:9000");
1100    }
1101
1102    #[test]
1103    fn test_env_var_overrides() {
1104        let _lock = env_lock_acquire();
1105        let temp_home = TempHome::new();
1106
1107        let _port = EnvVarGuard::set("BAMBOO_PORT", "9999");
1108        let _bind = EnvVarGuard::set("BAMBOO_BIND", "192.168.1.1");
1109        let _provider = EnvVarGuard::set("BAMBOO_PROVIDER", "openai");
1110
1111        let config = Config::from_data_dir(Some(temp_home.path.clone()));
1112        assert_eq!(config.server.port, 9999);
1113        assert_eq!(config.server.bind, "192.168.1.1");
1114        assert_eq!(config.provider, "openai");
1115    }
1116
1117    #[test]
1118    fn test_config_save_and_load() {
1119        let _lock = env_lock_acquire();
1120        let temp_home = TempHome::new();
1121
1122        let mut config = Config::from_data_dir(Some(temp_home.path.clone()));
1123        config.server.port = 9000;
1124        config.server.bind = "0.0.0.0".to_string();
1125        config.provider = "anthropic".to_string();
1126
1127        // Save
1128        config.save().expect("Failed to save config");
1129
1130        // Load again
1131        let loaded = Config::from_data_dir(Some(temp_home.path.clone()));
1132
1133        // Verify
1134        assert_eq!(loaded.server.port, 9000);
1135        assert_eq!(loaded.server.bind, "0.0.0.0");
1136        assert_eq!(loaded.provider, "anthropic");
1137    }
1138
1139    #[test]
1140    fn config_decrypts_proxy_auth_from_encrypted_field() {
1141        let _lock = env_lock_acquire();
1142        let temp_home = TempHome::new();
1143
1144        // Use a stable encryption key so this test doesn't depend on host identifiers.
1145        let key_guard = crate::core::encryption::set_test_encryption_key([
1146            0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d,
1147            0x0e, 0x0f, 0x10, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, 0x18, 0x19, 0x1a, 0x1b,
1148            0x1c, 0x1d, 0x1e, 0x1f,
1149        ]);
1150
1151        let auth = ProxyAuth {
1152            username: "user".to_string(),
1153            password: "pass".to_string(),
1154        };
1155        let auth_str = serde_json::to_string(&auth).expect("serialize proxy auth");
1156        let encrypted = crate::core::encryption::encrypt(&auth_str).expect("encrypt proxy auth");
1157
1158        temp_home.set_config_json(&format!(
1159            r#"{{
1160  "http_proxy": "http://proxy.example.com:8080",
1161  "proxy_auth_encrypted": "{encrypted}"
1162}}"#
1163        ));
1164        let config = Config::from_data_dir(Some(temp_home.path.clone()));
1165        let loaded_auth = config.proxy_auth.expect("proxy auth should be hydrated");
1166        assert_eq!(loaded_auth.username, "user");
1167        assert_eq!(loaded_auth.password, "pass");
1168        drop(key_guard);
1169    }
1170
1171    #[test]
1172    fn config_save_encrypts_proxy_auth_and_load_hydrates_plaintext() {
1173        let _lock = env_lock_acquire();
1174        let temp_home = TempHome::new();
1175
1176        // Use a stable encryption key so this test doesn't depend on host identifiers.
1177        let key_guard = crate::core::encryption::set_test_encryption_key([
1178            0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d,
1179            0x0e, 0x0f, 0x10, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, 0x18, 0x19, 0x1a, 0x1b,
1180            0x1c, 0x1d, 0x1e, 0x1f,
1181        ]);
1182
1183        let mut config = Config::from_data_dir(Some(temp_home.path.clone()));
1184        config.proxy_auth = Some(ProxyAuth {
1185            username: "user".to_string(),
1186            password: "pass".to_string(),
1187        });
1188        config.save().expect("save should encrypt proxy auth");
1189
1190        let content =
1191            std::fs::read_to_string(temp_home.path.join("config.json")).expect("read config.json");
1192        assert!(
1193            content.contains("proxy_auth_encrypted"),
1194            "config.json should store encrypted proxy auth"
1195        );
1196        assert!(
1197            !content.contains("\"proxy_auth\""),
1198            "config.json should not store plaintext proxy_auth"
1199        );
1200
1201        let loaded = Config::from_data_dir(Some(temp_home.path.clone()));
1202        let loaded_auth = loaded.proxy_auth.expect("proxy auth should be hydrated");
1203        assert_eq!(loaded_auth.username, "user");
1204        assert_eq!(loaded_auth.password, "pass");
1205        drop(key_guard);
1206    }
1207}