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 `${HOME}/.bamboo/`). Environment variables can override file values.
11//!
12//! # Example (JSON)
13//!
14//! ```json
15//! {
16//!   "provider": "anthropic",
17//!   "server": {
18//!     "port": 9562,
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 `${HOME}/.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
50use anyhow::{Context, Result};
51use serde::{Deserialize, Serialize};
52use serde_json::Value;
53use std::collections::{BTreeMap, BTreeSet, HashMap};
54use std::io::Write;
55use std::path::PathBuf;
56use std::sync::RwLock;
57
58use crate::agent::tools::normalize_tool_ref;
59use crate::core::keyword_masking::KeywordMaskingConfig;
60use crate::core::model_mapping::{AnthropicModelMapping, GeminiModelMapping};
61use crate::core::ReasoningEffort;
62
63/// A user-managed environment variable that is injected into Bash tool processes.
64///
65/// Secret entries are encrypted at rest: `value` is empty on disk and populated
66/// in memory after hydration from `value_encrypted`.
67#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
68pub struct EnvVarEntry {
69    /// Variable name (must match `^[A-Za-z_][A-Za-z0-9_]*$`).
70    pub name: String,
71    /// Plaintext value – populated in memory after hydration.
72    /// For `secret=true` entries this field is empty on disk.
73    #[serde(default)]
74    pub value: String,
75    /// Whether this variable contains sensitive data (token, password, etc.).
76    #[serde(default)]
77    pub secret: bool,
78    /// Encrypted ciphertext (only present on disk for secret entries).
79    #[serde(default, skip_serializing_if = "Option::is_none")]
80    pub value_encrypted: Option<String>,
81    /// Optional human-readable description.
82    #[serde(default, skip_serializing_if = "Option::is_none")]
83    pub description: Option<String>,
84}
85
86/// Main configuration structure for Bamboo agent
87///
88/// Contains all settings needed to run the agent, including provider credentials,
89/// proxy settings, model selection, and server configuration.
90#[derive(Debug, Clone, Serialize, Deserialize)]
91pub struct Config {
92    /// HTTP proxy URL (e.g., `http://proxy.example.com:8080`)
93    #[serde(default)]
94    pub http_proxy: String,
95    /// HTTPS proxy URL (e.g., `https://proxy.example.com:8080`)
96    #[serde(default)]
97    pub https_proxy: String,
98    /// Proxy authentication credentials
99    ///
100    /// Note: this is kept in-memory only. On disk we store `proxy_auth_encrypted`.
101    #[serde(skip_serializing)]
102    pub proxy_auth: Option<ProxyAuth>,
103    /// Encrypted proxy authentication credentials (nonce:ciphertext)
104    ///
105    /// This is the at-rest storage representation. When present, Bamboo will
106    /// decrypt it into `proxy_auth` at load time.
107    #[serde(default, skip_serializing_if = "Option::is_none")]
108    pub proxy_auth_encrypted: Option<String>,
109    /// Deprecated: Use `providers.copilot.headless_auth` instead
110    #[serde(default)]
111    pub headless_auth: bool,
112
113    /// Default LLM provider to use (e.g., "anthropic", "openai", "gemini", "copilot")
114    #[serde(default = "default_provider")]
115    pub provider: String,
116
117    /// Provider-specific configurations
118    #[serde(default)]
119    pub providers: ProviderConfigs,
120
121    /// HTTP server configuration
122    #[serde(default)]
123    pub server: ServerConfig,
124
125    /// Global keyword masking configuration.
126    ///
127    /// Previously persisted in `keyword_masking.json` (now unified into `config.json`).
128    #[serde(default)]
129    pub keyword_masking: KeywordMaskingConfig,
130
131    /// Anthropic model mapping configuration.
132    ///
133    /// Previously persisted in `anthropic-model-mapping.json` (now unified into `config.json`).
134    #[serde(default)]
135    pub anthropic_model_mapping: AnthropicModelMapping,
136
137    /// Gemini model mapping configuration.
138    ///
139    /// Previously persisted in `gemini-model-mapping.json` (now unified into `config.json`).
140    #[serde(default)]
141    pub gemini_model_mapping: GeminiModelMapping,
142
143    /// Request preflight hooks.
144    ///
145    /// These hooks can inspect and rewrite outgoing requests before they are sent upstream
146    /// (e.g. image fallback behavior for text-only models).
147    #[serde(default)]
148    pub hooks: HooksConfig,
149
150    /// Global tool toggles.
151    ///
152    /// Any tool listed in `disabled` is omitted from the tool schemas sent to the LLM.
153    #[serde(default, skip_serializing_if = "ToolsConfig::is_empty")]
154    pub tools: ToolsConfig,
155
156    /// User-managed environment variables injected into Bash tool processes.
157    ///
158    /// Secret entries are encrypted at rest; plaintext values are hydrated in memory.
159    #[serde(default, skip_serializing_if = "Vec::is_empty")]
160    pub env_vars: Vec<EnvVarEntry>,
161
162    /// MCP server configuration.
163    ///
164    /// Previously persisted in `mcp.json` (now unified into `config.json`).
165    // On disk we use the mainstream `mcpServers` key (matching Claude Desktop / MCP ecosystem
166    // conventions). We still accept the legacy `mcp` key for backward compatibility.
167    #[serde(default, rename = "mcpServers", alias = "mcp")]
168    pub mcp: crate::agent::mcp::McpConfig,
169
170    /// Extension fields stored at the root of `config.json`.
171    ///
172    /// This keeps the config forward-compatible and allows unrelated subsystems
173    /// (e.g. setup UI state) to persist their own keys without getting dropped by
174    /// typed (de)serialization.
175    #[serde(default, flatten)]
176    pub extra: BTreeMap<String, Value>,
177}
178
179/// Container for provider-specific configurations
180///
181/// Each field is optional, allowing users to configure only the providers they need.
182#[derive(Debug, Clone, Default, Serialize, Deserialize)]
183pub struct ProviderConfigs {
184    /// OpenAI provider configuration
185    #[serde(skip_serializing_if = "Option::is_none")]
186    pub openai: Option<OpenAIConfig>,
187    /// Anthropic provider configuration
188    #[serde(skip_serializing_if = "Option::is_none")]
189    pub anthropic: Option<AnthropicConfig>,
190    /// Google Gemini provider configuration
191    #[serde(skip_serializing_if = "Option::is_none")]
192    pub gemini: Option<GeminiConfig>,
193    /// GitHub Copilot provider configuration
194    #[serde(skip_serializing_if = "Option::is_none")]
195    pub copilot: Option<CopilotConfig>,
196
197    /// Preserve unknown provider keys (forward compatibility).
198    #[serde(default, flatten)]
199    pub extra: BTreeMap<String, Value>,
200}
201
202/// Request hook configuration.
203#[derive(Debug, Clone, Default, Serialize, Deserialize)]
204pub struct HooksConfig {
205    /// Image fallback behavior for OpenAI-compatible requests (chat/responses).
206    #[serde(default)]
207    pub image_fallback: ImageFallbackHookConfig,
208}
209
210/// Request override configuration for provider-specific HTTP behavior.
211///
212/// Overrides are merged in this order (later wins):
213/// 1. `common`
214/// 2. `endpoints[endpoint]`
215/// 3. matching `rules` (sorted by specificity)
216#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
217pub struct RequestOverridesConfig {
218    /// Overrides applied to all endpoints.
219    #[serde(default, skip_serializing_if = "RequestScopeOverride::is_empty")]
220    pub common: RequestScopeOverride,
221    /// Endpoint-specific overrides (`chat_completions`, `responses`, `messages`, etc.).
222    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
223    pub endpoints: BTreeMap<String, RequestScopeOverride>,
224    /// Model-conditional overrides.
225    #[serde(default, skip_serializing_if = "Vec::is_empty")]
226    pub rules: Vec<ModelRequestRule>,
227}
228
229/// A conditional override rule matching a model pattern.
230#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
231pub struct ModelRequestRule {
232    /// Model pattern (exact: `gpt-4o`, prefix wildcard: `gpt-5*`).
233    pub model_pattern: String,
234    /// Optional endpoint constraint.
235    #[serde(default, skip_serializing_if = "Option::is_none")]
236    pub endpoint: Option<String>,
237    /// Overrides applied when this rule matches.
238    #[serde(default, skip_serializing_if = "RequestScopeOverride::is_empty")]
239    pub scope: RequestScopeOverride,
240}
241
242/// Request overrides applied in a specific scope.
243#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
244pub struct RequestScopeOverride {
245    /// Extra or overridden HTTP headers.
246    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
247    pub headers: BTreeMap<String, TemplateExpr>,
248    /// JSON body patch operations.
249    #[serde(default, skip_serializing_if = "Vec::is_empty")]
250    pub body_patch: Vec<BodyPatch>,
251}
252
253impl RequestScopeOverride {
254    pub fn is_empty(&self) -> bool {
255        self.headers.is_empty() && self.body_patch.is_empty()
256    }
257}
258
259/// Body patch operation.
260#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
261pub struct BodyPatch {
262    /// Target path (`foo.bar.0` or `/foo/bar/0`).
263    pub path: String,
264    /// Operation type.
265    #[serde(default)]
266    pub op: BodyPatchOp,
267    /// Value for `set` operation.
268    #[serde(default, skip_serializing_if = "Option::is_none")]
269    pub value: Option<PatchValue>,
270}
271
272/// Supported body patch operations.
273#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)]
274#[serde(rename_all = "snake_case")]
275pub enum BodyPatchOp {
276    #[default]
277    Set,
278    Remove,
279}
280
281/// Body patch value: either a template expression or a raw JSON value.
282#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
283#[serde(untagged)]
284pub enum PatchValue {
285    Template(TemplateExpr),
286    Json(Value),
287}
288
289/// String template expression used by headers/body patch values.
290#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
291#[serde(untagged)]
292pub enum TemplateExpr {
293    /// Shorthand literal value.
294    Literal(String),
295    /// Structured template expression.
296    Structured(TemplateExprSpec),
297}
298
299/// Structured template expression.
300#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
301#[serde(tag = "type", rename_all = "snake_case")]
302pub enum TemplateExprSpec {
303    /// Literal string value.
304    Literal { value: String },
305    /// Reference a value from Bamboo env vars.
306    EnvRef {
307        name: String,
308        #[serde(default, skip_serializing_if = "Option::is_none")]
309        fallback: Option<String>,
310    },
311    /// Generate a runtime value.
312    Generated { generator: GeneratedValue },
313    /// Format string with placeholders (`{env:NAME}`, `{uuid}`, `{unix_ms}`).
314    Format { template: String },
315}
316
317/// Supported generated value kinds.
318#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
319#[serde(rename_all = "snake_case")]
320pub enum GeneratedValue {
321    Uuid,
322    UnixMs,
323}
324
325/// Global tool toggle configuration.
326#[derive(Debug, Clone, Default, Serialize, Deserialize)]
327pub struct ToolsConfig {
328    /// Tool names that are disabled globally.
329    #[serde(default, skip_serializing_if = "Vec::is_empty")]
330    pub disabled: Vec<String>,
331}
332
333impl ToolsConfig {
334    fn is_empty(&self) -> bool {
335        self.disabled.is_empty()
336    }
337}
338
339/// When a request contains image parts but the effective provider path is text-only,
340/// we can either:
341/// - error fast (preferred for strict setups), or
342/// - degrade gracefully by replacing images with a placeholder text.
343#[derive(Debug, Clone, Serialize, Deserialize)]
344pub struct ImageFallbackHookConfig {
345    #[serde(default = "default_true_hooks")]
346    pub enabled: bool,
347
348    /// "placeholder" (default) or "error"
349    #[serde(default = "default_image_fallback_mode")]
350    pub mode: String,
351}
352
353impl Default for ImageFallbackHookConfig {
354    fn default() -> Self {
355        Self {
356            enabled: default_true_hooks(),
357            mode: default_image_fallback_mode(),
358        }
359    }
360}
361
362fn default_image_fallback_mode() -> String {
363    "placeholder".to_string()
364}
365
366fn default_true_hooks() -> bool {
367    // Default to disabled so image inputs are preserved unless the user explicitly
368    // opts into fallback rewriting (placeholder/error/ocr).
369    false
370}
371
372/// OpenAI provider configuration
373///
374/// # Example
375///
376/// ```json
377/// "openai": {
378///   "api_key": "sk-...",
379///   "base_url": "https://api.openai.com/v1",
380///   "model": "gpt-4"
381/// }
382/// ```
383#[derive(Debug, Clone, Serialize, Deserialize)]
384pub struct OpenAIConfig {
385    /// OpenAI API key (plaintext, in-memory only).
386    ///
387    /// On disk this is stored as `api_key_encrypted` and hydrated on load.
388    #[serde(default, skip_serializing)]
389    pub api_key: String,
390    /// Encrypted OpenAI API key (nonce:ciphertext).
391    #[serde(default, skip_serializing_if = "Option::is_none")]
392    pub api_key_encrypted: Option<String>,
393    /// Custom API base URL (for Azure or self-hosted deployments)
394    #[serde(skip_serializing_if = "Option::is_none")]
395    pub base_url: Option<String>,
396    /// Default model to use (e.g., "gpt-4", "gpt-3.5-turbo")
397    #[serde(skip_serializing_if = "Option::is_none")]
398    pub model: Option<String>,
399    /// Fast/cheap model for lightweight tasks (title generation and summarization).
400    /// Falls back to `model` when not set.
401    #[serde(default, skip_serializing_if = "Option::is_none")]
402    pub fast_model: Option<String>,
403    /// Vision-capable model for image understanding tasks.
404    /// Falls back to `model` when not set.
405    #[serde(default, skip_serializing_if = "Option::is_none")]
406    pub vision_model: Option<String>,
407    /// Default reasoning effort for OpenAI requests.
408    #[serde(skip_serializing_if = "Option::is_none")]
409    pub reasoning_effort: Option<ReasoningEffort>,
410
411    /// Models that must use the OpenAI Responses API upstream (instead of chat/completions).
412    ///
413    /// Example:
414    /// ```json
415    /// "responses_only_models": ["gpt-5.3-codex", "gpt-5*"]
416    /// ```
417    #[serde(default, skip_serializing_if = "Vec::is_empty")]
418    pub responses_only_models: Vec<String>,
419    /// Optional request overrides (headers/body patches/model rules).
420    #[serde(default, skip_serializing_if = "Option::is_none")]
421    pub request_overrides: Option<RequestOverridesConfig>,
422
423    /// Preserve unknown keys under `providers.openai`.
424    #[serde(default, flatten)]
425    pub extra: BTreeMap<String, Value>,
426}
427
428/// Anthropic provider configuration
429///
430/// # Example
431///
432/// ```json
433/// "anthropic": {
434///   "api_key": "sk-ant-...",
435///   "model": "claude-3-5-sonnet-20241022",
436///   "max_tokens": 4096
437/// }
438/// ```
439#[derive(Debug, Clone, Serialize, Deserialize)]
440pub struct AnthropicConfig {
441    /// Anthropic API key (plaintext, in-memory only).
442    ///
443    /// On disk this is stored as `api_key_encrypted` and hydrated on load.
444    #[serde(default, skip_serializing)]
445    pub api_key: String,
446    /// Encrypted Anthropic API key (nonce:ciphertext).
447    #[serde(default, skip_serializing_if = "Option::is_none")]
448    pub api_key_encrypted: Option<String>,
449    /// Custom API base URL
450    #[serde(skip_serializing_if = "Option::is_none")]
451    pub base_url: Option<String>,
452    /// Default model to use (e.g., "claude-3-5-sonnet-20241022")
453    #[serde(skip_serializing_if = "Option::is_none")]
454    pub model: Option<String>,
455    /// Fast/cheap model for lightweight tasks (title generation, mermaid fix, summarization).
456    /// Falls back to `model` when not set.
457    #[serde(default, skip_serializing_if = "Option::is_none")]
458    pub fast_model: Option<String>,
459    /// Vision-capable model for image understanding tasks.
460    /// Falls back to `model` when not set.
461    #[serde(default, skip_serializing_if = "Option::is_none")]
462    pub vision_model: Option<String>,
463    /// Maximum tokens in model response
464    #[serde(skip_serializing_if = "Option::is_none")]
465    pub max_tokens: Option<u32>,
466    /// Default reasoning effort for Anthropic requests.
467    #[serde(skip_serializing_if = "Option::is_none")]
468    pub reasoning_effort: Option<ReasoningEffort>,
469    /// Optional request overrides (headers/body patches/model rules).
470    #[serde(default, skip_serializing_if = "Option::is_none")]
471    pub request_overrides: Option<RequestOverridesConfig>,
472
473    /// Preserve unknown keys under `providers.anthropic`.
474    #[serde(default, flatten)]
475    pub extra: BTreeMap<String, Value>,
476}
477
478/// Google Gemini provider configuration
479///
480/// # Example
481///
482/// ```json
483/// "gemini": {
484///   "api_key": "AIza...",
485///   "model": "gemini-2.0-flash-exp"
486/// }
487/// ```
488#[derive(Debug, Clone, Serialize, Deserialize)]
489pub struct GeminiConfig {
490    /// Google AI API key (plaintext, in-memory only).
491    ///
492    /// On disk this is stored as `api_key_encrypted` and hydrated on load.
493    #[serde(default, skip_serializing)]
494    pub api_key: String,
495    /// Encrypted Google AI API key (nonce:ciphertext).
496    #[serde(default, skip_serializing_if = "Option::is_none")]
497    pub api_key_encrypted: Option<String>,
498    /// Custom API base URL
499    #[serde(skip_serializing_if = "Option::is_none")]
500    pub base_url: Option<String>,
501    /// Default model to use (e.g., "gemini-2.0-flash-exp")
502    #[serde(skip_serializing_if = "Option::is_none")]
503    pub model: Option<String>,
504    /// Fast/cheap model for lightweight tasks (title generation, mermaid fix, summarization).
505    /// Falls back to `model` when not set.
506    #[serde(default, skip_serializing_if = "Option::is_none")]
507    pub fast_model: Option<String>,
508    /// Vision-capable model for image understanding tasks.
509    /// Falls back to `model` when not set.
510    #[serde(default, skip_serializing_if = "Option::is_none")]
511    pub vision_model: Option<String>,
512    /// Default reasoning effort for Gemini requests.
513    #[serde(skip_serializing_if = "Option::is_none")]
514    pub reasoning_effort: Option<ReasoningEffort>,
515    /// Optional request overrides (headers/body patches/model rules).
516    #[serde(default, skip_serializing_if = "Option::is_none")]
517    pub request_overrides: Option<RequestOverridesConfig>,
518
519    /// Preserve unknown keys under `providers.gemini`.
520    #[serde(default, flatten)]
521    pub extra: BTreeMap<String, Value>,
522}
523
524/// GitHub Copilot provider configuration
525///
526/// # Example
527///
528/// ```json
529/// "copilot": {
530///   "enabled": true,
531///   "headless_auth": false,
532///   "model": "gpt-4o"
533/// }
534/// ```
535#[derive(Debug, Clone, Default, Serialize, Deserialize)]
536pub struct CopilotConfig {
537    /// Whether Copilot provider is enabled
538    #[serde(default)]
539    pub enabled: bool,
540    /// Print login URL to console instead of opening browser
541    #[serde(default)]
542    pub headless_auth: bool,
543    /// Default model to use for Copilot (used when clients request the "default" model)
544    #[serde(skip_serializing_if = "Option::is_none")]
545    pub model: Option<String>,
546    /// Fast/cheap model for lightweight tasks (title generation, mermaid fix, summarization).
547    /// Falls back to `model` when not set.
548    #[serde(default, skip_serializing_if = "Option::is_none")]
549    pub fast_model: Option<String>,
550    /// Vision-capable model for image understanding tasks.
551    /// Falls back to `model` when not set.
552    #[serde(default, skip_serializing_if = "Option::is_none")]
553    pub vision_model: Option<String>,
554    /// Default reasoning effort for Copilot requests.
555    #[serde(skip_serializing_if = "Option::is_none")]
556    pub reasoning_effort: Option<ReasoningEffort>,
557
558    /// Models that must use the OpenAI Responses API upstream (instead of chat/completions).
559    ///
560    /// This is useful for newer Copilot models that only support Responses-style requests.
561    ///
562    /// Example:
563    /// ```json
564    /// "responses_only_models": ["gpt-5.3-codex", "gpt-5*"]
565    /// ```
566    #[serde(default, skip_serializing_if = "Vec::is_empty")]
567    pub responses_only_models: Vec<String>,
568    /// Optional request overrides (headers/body patches/model rules).
569    #[serde(default, skip_serializing_if = "Option::is_none")]
570    pub request_overrides: Option<RequestOverridesConfig>,
571
572    /// Preserve unknown keys under `providers.copilot`.
573    #[serde(default, flatten)]
574    pub extra: BTreeMap<String, Value>,
575}
576
577/// Returns the default provider name ("anthropic")
578fn default_provider() -> String {
579    "anthropic".to_string()
580}
581
582/// Returns the default server port (9562)
583fn default_port() -> u16 {
584    9562
585}
586
587/// Returns the default bind address (127.0.0.1)
588fn default_bind() -> String {
589    "127.0.0.1".to_string()
590}
591
592/// Returns the default worker count (10)
593fn default_workers() -> usize {
594    10
595}
596
597/// Returns the default data directory (`BAMBOO_DATA_DIR` or `${HOME}/.bamboo`)
598fn default_data_dir() -> PathBuf {
599    super::paths::bamboo_dir()
600}
601
602/// HTTP server configuration
603#[derive(Debug, Clone, Serialize, Deserialize)]
604pub struct ServerConfig {
605    /// Port to listen on
606    #[serde(default = "default_port")]
607    pub port: u16,
608
609    /// Bind address (127.0.0.1, 0.0.0.0, etc.)
610    #[serde(default = "default_bind")]
611    pub bind: String,
612
613    /// Static files directory (for Docker mode)
614    pub static_dir: Option<PathBuf>,
615
616    /// Worker count for Actix-web
617    #[serde(default = "default_workers")]
618    pub workers: usize,
619
620    /// Preserve unknown keys under `server`.
621    #[serde(default, flatten)]
622    pub extra: BTreeMap<String, Value>,
623}
624
625impl Default for ServerConfig {
626    fn default() -> Self {
627        Self {
628            port: default_port(),
629            bind: default_bind(),
630            static_dir: None,
631            workers: default_workers(),
632            extra: BTreeMap::new(),
633        }
634    }
635}
636
637/// Proxy authentication credentials
638#[derive(Debug, Clone, Serialize, Deserialize)]
639pub struct ProxyAuth {
640    /// Proxy username
641    pub username: String,
642    /// Proxy password
643    pub password: String,
644}
645
646/// Parse a boolean value from environment variable strings
647///
648/// Accepts: "1", "true", "yes", "y", "on" (case-insensitive)
649fn parse_bool_env(value: &str) -> bool {
650    matches!(
651        value.trim().to_ascii_lowercase().as_str(),
652        "1" | "true" | "yes" | "y" | "on"
653    )
654}
655
656impl Default for Config {
657    fn default() -> Self {
658        Self::new()
659    }
660}
661
662/// Global cache of user-managed env vars for injection into child processes.
663///
664/// Updated whenever the config is loaded or reloaded via [`Config::publish_env_vars`].
665static ENV_VARS_CACHE: std::sync::LazyLock<RwLock<HashMap<String, String>>> =
666    std::sync::LazyLock::new(|| RwLock::new(HashMap::new()));
667
668impl Config {
669    /// Load configuration from file with environment variable overrides
670    ///
671    /// Configuration loading order:
672    /// 1. Try loading from `config.json` (`{data_dir}/config.json`)
673    /// 2. Use defaults
674    /// 3. Apply environment variable overrides (highest priority)
675    ///
676    /// # Environment Variables
677    ///
678    /// - `BAMBOO_PORT`: Override server port
679    /// - `BAMBOO_BIND`: Override bind address
680    /// - `BAMBOO_DATA_DIR`: Override data directory
681    /// - `BAMBOO_PROVIDER`: Override default provider
682    /// - `BAMBOO_HEADLESS`: Enable headless authentication mode
683    pub fn new() -> Self {
684        Self::from_data_dir(None)
685    }
686
687    /// Load configuration from a specific data directory
688    ///
689    /// # Arguments
690    ///
691    /// * `data_dir` - Optional data directory path. If None, uses default (`BAMBOO_DATA_DIR` or `${HOME}/.bamboo`)
692    pub fn from_data_dir(data_dir: Option<PathBuf>) -> Self {
693        // Determine data_dir early (needed to find config file)
694        let data_dir = data_dir
695            .or_else(|| std::env::var("BAMBOO_DATA_DIR").ok().map(PathBuf::from))
696            .unwrap_or_else(default_data_dir);
697
698        let config_path = data_dir.join("config.json");
699
700        let mut config = if config_path.exists() {
701            if let Ok(content) = std::fs::read_to_string(&config_path) {
702                serde_json::from_str::<Config>(&content)
703                    .map(|mut config| {
704                        config.hydrate_proxy_auth_from_encrypted();
705                        config.hydrate_provider_api_keys_from_encrypted();
706                        config.hydrate_mcp_secrets_from_encrypted();
707                        config.hydrate_env_vars_from_encrypted();
708                        config.normalize_tool_settings();
709                        config
710                    })
711                    .unwrap_or_else(|e| {
712                        tracing::warn!("Failed to parse config.json ({}), using defaults", e);
713                        Self::create_default()
714                    })
715            } else {
716                Self::create_default()
717            }
718        } else {
719            Self::create_default()
720        };
721
722        // Decrypt encrypted proxy auth into in-memory plaintext form.
723        config.hydrate_proxy_auth_from_encrypted();
724        // Decrypt encrypted provider API keys into in-memory plaintext form.
725        config.hydrate_provider_api_keys_from_encrypted();
726        // Decrypt encrypted MCP secrets into in-memory plaintext form.
727        config.hydrate_mcp_secrets_from_encrypted();
728        // Decrypt encrypted env vars into in-memory plaintext form.
729        config.hydrate_env_vars_from_encrypted();
730        config.normalize_tool_settings();
731
732        // Legacy: `data_dir` is no longer a persisted config field. The data directory is
733        // derived from runtime (BAMBOO_DATA_DIR or `${HOME}/.bamboo`).
734        config.extra.remove("data_dir");
735
736        // Apply environment variable overrides (highest priority)
737        if let Ok(port) = std::env::var("BAMBOO_PORT") {
738            if let Ok(port) = port.parse() {
739                config.server.port = port;
740            }
741        }
742
743        if let Ok(bind) = std::env::var("BAMBOO_BIND") {
744            config.server.bind = bind;
745        }
746
747        // Note: BAMBOO_DATA_DIR already handled above
748        if let Ok(provider) = std::env::var("BAMBOO_PROVIDER") {
749            config.provider = provider;
750        }
751
752        if let Ok(headless) = std::env::var("BAMBOO_HEADLESS") {
753            config.headless_auth = parse_bool_env(&headless);
754        }
755
756        // Publish env vars to the global cache so Bash tools can inject them.
757        config.publish_env_vars();
758
759        config
760    }
761
762    /// Get the effective default model for the currently active provider.
763    ///
764    /// Note: for most providers this is a required config value (returns None when absent).
765    /// Copilot has a built-in fallback when no model is configured.
766    pub fn get_model(&self) -> Option<String> {
767        match self.provider.as_str() {
768            "openai" => self.providers.openai.as_ref().and_then(|c| c.model.clone()),
769            "anthropic" => self
770                .providers
771                .anthropic
772                .as_ref()
773                .and_then(|c| c.model.clone()),
774            "gemini" => self.providers.gemini.as_ref().and_then(|c| c.model.clone()),
775            "copilot" => Some(
776                self.providers
777                    .copilot
778                    .as_ref()
779                    .and_then(|c| c.model.clone())
780                    .unwrap_or_else(|| "gpt-4o".to_string()),
781            ),
782            _ => None,
783        }
784    }
785
786    /// Get the fast/cheap model for the currently active provider.
787    ///
788    /// Used for lightweight tasks like title generation and summarization.
789    /// Falls back to `get_model()` when no fast_model is configured.
790    pub fn get_fast_model(&self) -> Option<String> {
791        let fast = match self.provider.as_str() {
792            "openai" => self
793                .providers
794                .openai
795                .as_ref()
796                .and_then(|c| c.fast_model.clone()),
797            "anthropic" => self
798                .providers
799                .anthropic
800                .as_ref()
801                .and_then(|c| c.fast_model.clone()),
802            "gemini" => self
803                .providers
804                .gemini
805                .as_ref()
806                .and_then(|c| c.fast_model.clone()),
807            "copilot" => self
808                .providers
809                .copilot
810                .as_ref()
811                .and_then(|c| c.fast_model.clone()),
812            _ => None,
813        };
814        fast.or_else(|| self.get_model())
815    }
816
817    /// Get the vision-capable model for the currently active provider.
818    ///
819    /// Used for image understanding tasks.
820    /// Falls back to `get_model()` when no vision_model is configured.
821    pub fn get_vision_model(&self) -> Option<String> {
822        let vision = match self.provider.as_str() {
823            "openai" => self
824                .providers
825                .openai
826                .as_ref()
827                .and_then(|c| c.vision_model.clone()),
828            "anthropic" => self
829                .providers
830                .anthropic
831                .as_ref()
832                .and_then(|c| c.vision_model.clone()),
833            "gemini" => self
834                .providers
835                .gemini
836                .as_ref()
837                .and_then(|c| c.vision_model.clone()),
838            "copilot" => self
839                .providers
840                .copilot
841                .as_ref()
842                .and_then(|c| c.vision_model.clone()),
843            _ => None,
844        };
845        vision.or_else(|| self.get_model())
846    }
847
848    /// Get the default reasoning effort for the currently active provider.
849    pub fn get_reasoning_effort(&self) -> Option<ReasoningEffort> {
850        match self.provider.as_str() {
851            "openai" => self
852                .providers
853                .openai
854                .as_ref()
855                .and_then(|c| c.reasoning_effort),
856            "anthropic" => self
857                .providers
858                .anthropic
859                .as_ref()
860                .and_then(|c| c.reasoning_effort),
861            "gemini" => self
862                .providers
863                .gemini
864                .as_ref()
865                .and_then(|c| c.reasoning_effort),
866            "copilot" => self
867                .providers
868                .copilot
869                .as_ref()
870                .and_then(|c| c.reasoning_effort),
871            _ => None,
872        }
873    }
874
875    /// Get normalized disabled tool names.
876    pub fn disabled_tool_names(&self) -> BTreeSet<String> {
877        self.tools
878            .disabled
879            .iter()
880            .map(|name| name.trim())
881            .filter(|name| !name.is_empty())
882            .map(|name| normalize_tool_ref(name).unwrap_or_else(|| name.to_string()))
883            .collect()
884    }
885
886    /// Normalize tool settings (trim / dedupe / sort).
887    pub fn normalize_tool_settings(&mut self) {
888        self.tools.disabled = self.disabled_tool_names().into_iter().collect();
889    }
890
891    /// Populate `proxy_auth` (plaintext) from `proxy_auth_encrypted` if present.
892    ///
893    /// Many parts of the code rely on `proxy_auth` being hydrated in-memory so
894    /// we can re-encrypt deterministically on save without ever persisting
895    /// plaintext credentials.
896    pub fn hydrate_proxy_auth_from_encrypted(&mut self) {
897        if self.proxy_auth.is_some() {
898            return;
899        }
900
901        // Backward compatibility:
902        // Older Bodhi/Tauri builds persisted proxy auth as per-scheme encrypted fields:
903        // `http_proxy_auth_encrypted` / `https_proxy_auth_encrypted`.
904        //
905        // Those live under `extra` (flatten) in the unified config. Seed the new
906        // `proxy_auth_encrypted` field so the rest of the code can stay uniform.
907        if self
908            .proxy_auth_encrypted
909            .as_deref()
910            .map(|s| s.trim().is_empty())
911            .unwrap_or(true)
912        {
913            let legacy = self
914                .extra
915                .get("https_proxy_auth_encrypted")
916                .and_then(|v| v.as_str())
917                .or_else(|| {
918                    self.extra
919                        .get("http_proxy_auth_encrypted")
920                        .and_then(|v| v.as_str())
921                })
922                .map(|s| s.trim())
923                .filter(|s| !s.is_empty())
924                .map(|s| s.to_string());
925
926            if let Some(legacy) = legacy {
927                self.proxy_auth_encrypted = Some(legacy);
928            }
929        }
930
931        let Some(encrypted) = self.proxy_auth_encrypted.as_deref() else {
932            return;
933        };
934
935        match crate::core::encryption::decrypt(encrypted) {
936            Ok(decrypted) => match serde_json::from_str::<ProxyAuth>(&decrypted) {
937                Ok(auth) => {
938                    self.proxy_auth = Some(auth);
939                    // Once hydrated successfully, drop legacy keys so a future save writes only
940                    // the canonical `proxy_auth_encrypted` field.
941                    self.extra.remove("http_proxy_auth_encrypted");
942                    self.extra.remove("https_proxy_auth_encrypted");
943                }
944                Err(e) => tracing::warn!("Failed to parse decrypted proxy auth JSON: {}", e),
945            },
946            Err(e) => tracing::warn!("Failed to decrypt proxy auth: {}", e),
947        }
948    }
949
950    /// Refresh `proxy_auth_encrypted` from the current in-memory `proxy_auth`.
951    ///
952    /// This is used both when persisting the config to disk and when generating
953    /// API responses that should never include plaintext proxy credentials.
954    pub fn refresh_proxy_auth_encrypted(&mut self) -> Result<()> {
955        // Keep on-disk representation fully derived from the in-memory plaintext:
956        // - Some(auth)  => always (re-)encrypt and store `proxy_auth_encrypted`
957        // - None        => remove `proxy_auth_encrypted`
958        let Some(auth) = self.proxy_auth.as_ref() else {
959            self.proxy_auth_encrypted = None;
960            return Ok(());
961        };
962
963        let auth_str = serde_json::to_string(auth).context("Failed to serialize proxy auth")?;
964        let encrypted =
965            crate::core::encryption::encrypt(&auth_str).context("Failed to encrypt proxy auth")?;
966        self.proxy_auth_encrypted = Some(encrypted);
967        Ok(())
968    }
969
970    pub fn hydrate_provider_api_keys_from_encrypted(&mut self) {
971        if let Some(openai) = self.providers.openai.as_mut() {
972            if openai.api_key.trim().is_empty() {
973                if let Some(encrypted) = openai.api_key_encrypted.as_deref() {
974                    match crate::core::encryption::decrypt(encrypted) {
975                        Ok(value) => openai.api_key = value,
976                        Err(e) => tracing::warn!("Failed to decrypt OpenAI api_key: {}", e),
977                    }
978                }
979            }
980        }
981
982        if let Some(anthropic) = self.providers.anthropic.as_mut() {
983            if anthropic.api_key.trim().is_empty() {
984                if let Some(encrypted) = anthropic.api_key_encrypted.as_deref() {
985                    match crate::core::encryption::decrypt(encrypted) {
986                        Ok(value) => anthropic.api_key = value,
987                        Err(e) => tracing::warn!("Failed to decrypt Anthropic api_key: {}", e),
988                    }
989                }
990            }
991        }
992
993        if let Some(gemini) = self.providers.gemini.as_mut() {
994            if gemini.api_key.trim().is_empty() {
995                if let Some(encrypted) = gemini.api_key_encrypted.as_deref() {
996                    match crate::core::encryption::decrypt(encrypted) {
997                        Ok(value) => gemini.api_key = value,
998                        Err(e) => tracing::warn!("Failed to decrypt Gemini api_key: {}", e),
999                    }
1000                }
1001            }
1002        }
1003    }
1004
1005    pub fn refresh_provider_api_keys_encrypted(&mut self) -> Result<()> {
1006        if let Some(openai) = self.providers.openai.as_mut() {
1007            let api_key = openai.api_key.trim();
1008            openai.api_key_encrypted = if api_key.is_empty() {
1009                None
1010            } else {
1011                Some(
1012                    crate::core::encryption::encrypt(api_key)
1013                        .context("Failed to encrypt OpenAI api_key")?,
1014                )
1015            };
1016        }
1017
1018        if let Some(anthropic) = self.providers.anthropic.as_mut() {
1019            let api_key = anthropic.api_key.trim();
1020            anthropic.api_key_encrypted = if api_key.is_empty() {
1021                None
1022            } else {
1023                Some(
1024                    crate::core::encryption::encrypt(api_key)
1025                        .context("Failed to encrypt Anthropic api_key")?,
1026                )
1027            };
1028        }
1029
1030        if let Some(gemini) = self.providers.gemini.as_mut() {
1031            let api_key = gemini.api_key.trim();
1032            gemini.api_key_encrypted = if api_key.is_empty() {
1033                None
1034            } else {
1035                Some(
1036                    crate::core::encryption::encrypt(api_key)
1037                        .context("Failed to encrypt Gemini api_key")?,
1038                )
1039            };
1040        }
1041
1042        Ok(())
1043    }
1044
1045    pub fn hydrate_mcp_secrets_from_encrypted(&mut self) {
1046        for server in self.mcp.servers.iter_mut() {
1047            match &mut server.transport {
1048                crate::agent::mcp::TransportConfig::Stdio(stdio) => {
1049                    if stdio.env_encrypted.is_empty() {
1050                        continue;
1051                    }
1052
1053                    // Avoid borrow-checker gymnastics by iterating a cloned map.
1054                    for (key, encrypted) in stdio.env_encrypted.clone() {
1055                        let should_hydrate = stdio
1056                            .env
1057                            .get(&key)
1058                            .map(|v| v.trim().is_empty())
1059                            .unwrap_or(true);
1060                        if !should_hydrate {
1061                            continue;
1062                        }
1063
1064                        match crate::core::encryption::decrypt(&encrypted) {
1065                            Ok(value) => {
1066                                stdio.env.insert(key, value);
1067                            }
1068                            Err(e) => tracing::warn!("Failed to decrypt MCP stdio env var: {}", e),
1069                        }
1070                    }
1071                }
1072                crate::agent::mcp::TransportConfig::Sse(sse) => {
1073                    for header in sse.headers.iter_mut() {
1074                        if !header.value.trim().is_empty() {
1075                            continue;
1076                        }
1077                        let Some(encrypted) = header.value_encrypted.as_deref() else {
1078                            continue;
1079                        };
1080                        match crate::core::encryption::decrypt(encrypted) {
1081                            Ok(value) => header.value = value,
1082                            Err(e) => {
1083                                tracing::warn!("Failed to decrypt MCP SSE header value: {}", e)
1084                            }
1085                        }
1086                    }
1087                }
1088            }
1089        }
1090    }
1091
1092    pub fn refresh_mcp_secrets_encrypted(&mut self) -> Result<()> {
1093        for server in self.mcp.servers.iter_mut() {
1094            match &mut server.transport {
1095                crate::agent::mcp::TransportConfig::Stdio(stdio) => {
1096                    stdio.env_encrypted.clear();
1097                    for (key, value) in &stdio.env {
1098                        let encrypted =
1099                            crate::core::encryption::encrypt(value).with_context(|| {
1100                                format!("Failed to encrypt MCP stdio env var '{key}'")
1101                            })?;
1102                        stdio.env_encrypted.insert(key.clone(), encrypted);
1103                    }
1104                }
1105                crate::agent::mcp::TransportConfig::Sse(sse) => {
1106                    for header in sse.headers.iter_mut() {
1107                        let configured = !header.value.trim().is_empty();
1108                        header.value_encrypted = if !configured {
1109                            None
1110                        } else {
1111                            Some(
1112                                crate::core::encryption::encrypt(&header.value).with_context(
1113                                    || {
1114                                        format!(
1115                                            "Failed to encrypt MCP SSE header '{}'",
1116                                            header.name
1117                                        )
1118                                    },
1119                                )?,
1120                            )
1121                        };
1122                    }
1123                }
1124            }
1125        }
1126
1127        Ok(())
1128    }
1129
1130    // ── Env vars encryption ─────────────────────────────────────────────
1131
1132    /// Decrypt secret env vars into in-memory plaintext after loading config.
1133    pub fn hydrate_env_vars_from_encrypted(&mut self) {
1134        for entry in &mut self.env_vars {
1135            if !entry.secret {
1136                continue;
1137            }
1138            if !entry.value.trim().is_empty() {
1139                // Already has plaintext (e.g. in-memory update).
1140                continue;
1141            }
1142            let Some(encrypted) = &entry.value_encrypted else {
1143                continue;
1144            };
1145            match crate::core::encryption::decrypt(encrypted) {
1146                Ok(value) => entry.value = value,
1147                Err(e) => tracing::warn!("Failed to decrypt env var '{}': {}", entry.name, e),
1148            }
1149        }
1150    }
1151
1152    /// Re-encrypt secret env vars before persisting to disk.
1153    pub fn refresh_env_vars_encrypted(&mut self) -> Result<()> {
1154        for entry in &mut self.env_vars {
1155            if entry.secret && !entry.value.trim().is_empty() {
1156                entry.value_encrypted = Some(
1157                    crate::core::encryption::encrypt(&entry.value)
1158                        .with_context(|| format!("Failed to encrypt env var '{}'", entry.name))?,
1159                );
1160            } else if !entry.secret {
1161                entry.value_encrypted = None;
1162            }
1163        }
1164        Ok(())
1165    }
1166
1167    /// Clear plaintext values for secrets before serialization to disk.
1168    pub fn sanitize_env_vars_for_disk(&mut self) {
1169        for entry in &mut self.env_vars {
1170            if entry.secret {
1171                entry.value = String::new();
1172            }
1173        }
1174    }
1175
1176    /// Build a flat map of all env vars with non-empty values (for process injection).
1177    pub fn env_vars_as_map(&self) -> HashMap<String, String> {
1178        self.env_vars
1179            .iter()
1180            .filter(|e| !e.value.trim().is_empty())
1181            .map(|e| (e.name.clone(), e.value.clone()))
1182            .collect()
1183    }
1184
1185    /// Update the global env vars cache (called on config load / reload).
1186    pub fn publish_env_vars(&self) {
1187        let map = self.env_vars_as_map();
1188        if let Ok(mut guard) = ENV_VARS_CACHE.write() {
1189            *guard = map;
1190        }
1191    }
1192
1193    /// Read the current env vars snapshot (called by Bash tool at process spawn time).
1194    pub fn current_env_vars() -> HashMap<String, String> {
1195        ENV_VARS_CACHE
1196            .read()
1197            .map(|guard| guard.clone())
1198            .unwrap_or_default()
1199    }
1200
1201    /// Create a default configuration without loading from file
1202    fn create_default() -> Self {
1203        Config {
1204            http_proxy: String::new(),
1205            https_proxy: String::new(),
1206            proxy_auth: None,
1207            proxy_auth_encrypted: None,
1208            headless_auth: false,
1209            provider: default_provider(),
1210            providers: ProviderConfigs::default(),
1211            server: ServerConfig::default(),
1212            keyword_masking: KeywordMaskingConfig::default(),
1213            anthropic_model_mapping: AnthropicModelMapping::default(),
1214            gemini_model_mapping: GeminiModelMapping::default(),
1215            hooks: HooksConfig::default(),
1216            tools: ToolsConfig::default(),
1217            env_vars: Vec::new(),
1218            mcp: crate::agent::mcp::McpConfig::default(),
1219            extra: BTreeMap::new(),
1220        }
1221    }
1222
1223    /// Get the full server address (bind:port)
1224    pub fn server_addr(&self) -> String {
1225        format!("{}:{}", self.server.bind, self.server.port)
1226    }
1227
1228    /// Save configuration to disk
1229    pub fn save(&self) -> Result<()> {
1230        self.save_to_dir(default_data_dir())
1231    }
1232
1233    /// Save configuration to disk under the provided data directory.
1234    ///
1235    /// Configuration is always stored as `{data_dir}/config.json`.
1236    pub fn save_to_dir(&self, data_dir: PathBuf) -> Result<()> {
1237        let path = data_dir.join("config.json");
1238
1239        if let Some(parent) = path.parent() {
1240            std::fs::create_dir_all(parent)
1241                .with_context(|| format!("Failed to create config dir: {:?}", parent))?;
1242        }
1243
1244        let mut to_save = self.clone();
1245        // Never persist `data_dir` into config.json (data dir is runtime-derived).
1246        to_save.extra.remove("data_dir");
1247        // Root-level `model` is deprecated; do not persist it.
1248        to_save.extra.remove("model");
1249        to_save.refresh_proxy_auth_encrypted()?;
1250        to_save.refresh_provider_api_keys_encrypted()?;
1251        to_save.refresh_env_vars_encrypted()?;
1252        to_save.sanitize_env_vars_for_disk();
1253        to_save.normalize_tool_settings();
1254        let content =
1255            serde_json::to_string_pretty(&to_save).context("Failed to serialize config to JSON")?;
1256        write_atomic(&path, content.as_bytes())
1257            .with_context(|| format!("Failed to write config file: {:?}", path))?;
1258
1259        Ok(())
1260    }
1261}
1262
1263fn write_atomic(path: &std::path::Path, content: &[u8]) -> std::io::Result<()> {
1264    let Some(parent) = path.parent() else {
1265        return std::fs::write(path, content);
1266    };
1267
1268    std::fs::create_dir_all(parent)?;
1269
1270    // Write to a temp file in the same directory then rename to ensure atomic replace.
1271    // (Rename is atomic on Unix when source/dest are on the same filesystem.)
1272    let file_name = path
1273        .file_name()
1274        .and_then(|s| s.to_str())
1275        .unwrap_or("config.json");
1276    let tmp_name = format!(".{}.tmp.{}", file_name, std::process::id());
1277    let tmp_path = parent.join(tmp_name);
1278
1279    {
1280        let mut file = std::fs::File::create(&tmp_path)?;
1281        file.write_all(content)?;
1282        file.sync_all()?;
1283    }
1284
1285    std::fs::rename(&tmp_path, path)?;
1286    Ok(())
1287}
1288
1289#[cfg(test)]
1290mod tests {
1291    use super::*;
1292    use std::ffi::OsString;
1293    use std::path::PathBuf;
1294    use std::sync::{Mutex, OnceLock};
1295    use std::time::{SystemTime, UNIX_EPOCH};
1296
1297    struct EnvVarGuard {
1298        key: &'static str,
1299        previous: Option<OsString>,
1300    }
1301
1302    impl EnvVarGuard {
1303        fn set(key: &'static str, value: &str) -> Self {
1304            let previous = std::env::var_os(key);
1305            std::env::set_var(key, value);
1306            Self { key, previous }
1307        }
1308
1309        fn unset(key: &'static str) -> Self {
1310            let previous = std::env::var_os(key);
1311            std::env::remove_var(key);
1312            Self { key, previous }
1313        }
1314    }
1315
1316    impl Drop for EnvVarGuard {
1317        fn drop(&mut self) {
1318            match &self.previous {
1319                Some(value) => std::env::set_var(self.key, value),
1320                None => std::env::remove_var(self.key),
1321            }
1322        }
1323    }
1324
1325    struct TempHome {
1326        path: PathBuf,
1327    }
1328
1329    impl TempHome {
1330        fn new() -> Self {
1331            let nanos = SystemTime::now()
1332                .duration_since(UNIX_EPOCH)
1333                .expect("clock should be after unix epoch")
1334                .as_nanos();
1335            let path = std::env::temp_dir().join(format!(
1336                "chat-core-config-test-{}-{}",
1337                std::process::id(),
1338                nanos
1339            ));
1340            std::fs::create_dir_all(&path).expect("failed to create temp home dir");
1341            Self { path }
1342        }
1343
1344        fn set_config_json(&self, content: &str) {
1345            // Treat `path` as the Bamboo data dir and write `config.json` into it.
1346            // Tests should prefer BAMBOO_DATA_DIR over HOME to avoid global env contention.
1347            std::fs::create_dir_all(&self.path).expect("failed to create config dir");
1348            std::fs::write(self.path.join("config.json"), content)
1349                .expect("failed to write config.json");
1350        }
1351    }
1352
1353    impl Drop for TempHome {
1354        fn drop(&mut self) {
1355            let _ = std::fs::remove_dir_all(&self.path);
1356        }
1357    }
1358
1359    fn env_lock() -> &'static Mutex<()> {
1360        static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
1361        LOCK.get_or_init(|| Mutex::new(()))
1362    }
1363
1364    /// Acquire the environment lock, recovering from poison if a previous test failed
1365    fn env_lock_acquire() -> std::sync::MutexGuard<'static, ()> {
1366        env_lock().lock().unwrap_or_else(|poisoned| {
1367            // Lock was poisoned by a previous test failure - recover it
1368            poisoned.into_inner()
1369        })
1370    }
1371
1372    #[test]
1373    fn parse_bool_env_true_values() {
1374        for value in ["1", "true", "TRUE", " yes ", "Y", "on"] {
1375            assert!(parse_bool_env(value), "value {value:?} should be true");
1376        }
1377    }
1378
1379    #[test]
1380    fn parse_bool_env_false_values() {
1381        for value in ["0", "false", "no", "off", "", "  "] {
1382            assert!(!parse_bool_env(value), "value {value:?} should be false");
1383        }
1384    }
1385
1386    #[test]
1387    fn config_new_ignores_http_proxy_env_vars() {
1388        let _lock = env_lock_acquire();
1389        let temp_home = TempHome::new();
1390        temp_home.set_config_json(
1391            r#"{
1392  "http_proxy": "",
1393  "https_proxy": ""
1394}"#,
1395        );
1396
1397        let _http_proxy = EnvVarGuard::set("HTTP_PROXY", "http://env-proxy.example.com:8080");
1398        let _https_proxy = EnvVarGuard::set("HTTPS_PROXY", "http://env-proxy.example.com:8443");
1399
1400        let config = Config::from_data_dir(Some(temp_home.path.clone()));
1401
1402        assert!(
1403            config.http_proxy.is_empty(),
1404            "config should ignore HTTP_PROXY env var"
1405        );
1406        assert!(
1407            config.https_proxy.is_empty(),
1408            "config should ignore HTTPS_PROXY env var"
1409        );
1410    }
1411
1412    #[test]
1413    fn config_new_loads_config_when_proxy_fields_omitted() {
1414        let _lock = env_lock_acquire();
1415        let temp_home = TempHome::new();
1416        temp_home.set_config_json(
1417            r#"{
1418  "provider": "openai",
1419  "providers": {
1420    "openai": {
1421      "api_key": "sk-test",
1422      "model": "gpt-4o"
1423    }
1424  }
1425}"#,
1426        );
1427
1428        let _http_proxy = EnvVarGuard::unset("HTTP_PROXY");
1429        let _https_proxy = EnvVarGuard::unset("HTTPS_PROXY");
1430
1431        let config = Config::from_data_dir(Some(temp_home.path.clone()));
1432
1433        assert_eq!(
1434            config
1435                .providers
1436                .openai
1437                .as_ref()
1438                .and_then(|c| c.model.as_deref()),
1439            Some("gpt-4o"),
1440            "config should load provider model from config file even when proxy fields are omitted"
1441        );
1442        assert!(config.http_proxy.is_empty());
1443        assert!(config.https_proxy.is_empty());
1444    }
1445
1446    #[test]
1447    fn config_new_ignores_proxy_env_vars_when_proxy_fields_omitted() {
1448        let _lock = env_lock_acquire();
1449        let temp_home = TempHome::new();
1450        temp_home.set_config_json(
1451            r#"{
1452  "provider": "openai",
1453  "providers": {
1454    "openai": {
1455      "api_key": "sk-test",
1456      "model": "gpt-4o"
1457    }
1458  }
1459}"#,
1460        );
1461
1462        let _http_proxy = EnvVarGuard::set("HTTP_PROXY", "http://env-proxy.example.com:8080");
1463        let _https_proxy = EnvVarGuard::set("HTTPS_PROXY", "http://env-proxy.example.com:8443");
1464
1465        let config = Config::from_data_dir(Some(temp_home.path.clone()));
1466
1467        assert_eq!(
1468            config
1469                .providers
1470                .openai
1471                .as_ref()
1472                .and_then(|c| c.model.as_deref()),
1473            Some("gpt-4o")
1474        );
1475        assert!(
1476            config.http_proxy.is_empty(),
1477            "config should keep http_proxy empty when field is omitted"
1478        );
1479        assert!(
1480            config.https_proxy.is_empty(),
1481            "config should keep https_proxy empty when field is omitted"
1482        );
1483    }
1484
1485    #[test]
1486    fn normalize_tool_settings_trims_dedupes_canonicalizes_and_sorts() {
1487        let mut config = Config::default();
1488        config.tools.disabled = vec![
1489            "  read_file  ".to_string(),
1490            "".to_string(),
1491            "read_file".to_string(),
1492            "bash".to_string(),
1493            "default::getCurrentDir".to_string(),
1494        ];
1495
1496        config.normalize_tool_settings();
1497
1498        assert_eq!(config.tools.disabled, vec!["Bash", "GetCurrentDir", "Read"]);
1499    }
1500
1501    #[test]
1502    fn config_load_reads_disabled_tools_as_canonical_names() {
1503        let _lock = env_lock_acquire();
1504        let temp_home = TempHome::new();
1505        temp_home.set_config_json(
1506            r#"{
1507  "tools": {
1508    "disabled": ["bash", " read_file ", "bash", "default::getCurrentDir"]
1509  }
1510}"#,
1511        );
1512
1513        let config = Config::from_data_dir(Some(temp_home.path.clone()));
1514        assert_eq!(config.tools.disabled, vec!["Bash", "GetCurrentDir", "Read"]);
1515        assert!(config.disabled_tool_names().contains("Bash"));
1516        assert!(config.disabled_tool_names().contains("Read"));
1517        assert!(config.disabled_tool_names().contains("GetCurrentDir"));
1518    }
1519
1520    #[test]
1521    fn test_server_config_defaults() {
1522        let _lock = env_lock_acquire();
1523        let temp_home = TempHome::new();
1524
1525        let config = Config::from_data_dir(Some(temp_home.path.clone()));
1526        assert_eq!(config.server.port, 9562);
1527        assert_eq!(config.server.bind, "127.0.0.1");
1528        assert_eq!(config.server.workers, 10);
1529        assert!(config.server.static_dir.is_none());
1530    }
1531
1532    #[test]
1533    fn test_server_addr() {
1534        let mut config = Config::default();
1535        config.server.port = 9000;
1536        config.server.bind = "0.0.0.0".to_string();
1537        assert_eq!(config.server_addr(), "0.0.0.0:9000");
1538    }
1539
1540    #[test]
1541    fn test_env_var_overrides() {
1542        let _lock = env_lock_acquire();
1543        let temp_home = TempHome::new();
1544
1545        let _port = EnvVarGuard::set("BAMBOO_PORT", "9999");
1546        let _bind = EnvVarGuard::set("BAMBOO_BIND", "192.168.1.1");
1547        let _provider = EnvVarGuard::set("BAMBOO_PROVIDER", "openai");
1548
1549        let config = Config::from_data_dir(Some(temp_home.path.clone()));
1550        assert_eq!(config.server.port, 9999);
1551        assert_eq!(config.server.bind, "192.168.1.1");
1552        assert_eq!(config.provider, "openai");
1553    }
1554
1555    #[test]
1556    fn test_config_save_and_load() {
1557        let _lock = env_lock_acquire();
1558        let temp_home = TempHome::new();
1559
1560        let mut config = Config::from_data_dir(Some(temp_home.path.clone()));
1561        config.server.port = 9000;
1562        config.server.bind = "0.0.0.0".to_string();
1563        config.provider = "anthropic".to_string();
1564
1565        // Save
1566        config
1567            .save_to_dir(temp_home.path.clone())
1568            .expect("Failed to save config");
1569
1570        // Load again
1571        let loaded = Config::from_data_dir(Some(temp_home.path.clone()));
1572
1573        // Verify
1574        assert_eq!(loaded.server.port, 9000);
1575        assert_eq!(loaded.server.bind, "0.0.0.0");
1576        assert_eq!(loaded.provider, "anthropic");
1577    }
1578
1579    #[test]
1580    fn config_decrypts_proxy_auth_from_encrypted_field() {
1581        let _lock = env_lock_acquire();
1582        let temp_home = TempHome::new();
1583
1584        // Use a stable encryption key so this test doesn't depend on host identifiers.
1585        let key_guard = crate::core::encryption::set_test_encryption_key([
1586            0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d,
1587            0x0e, 0x0f, 0x10, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, 0x18, 0x19, 0x1a, 0x1b,
1588            0x1c, 0x1d, 0x1e, 0x1f,
1589        ]);
1590
1591        let auth = ProxyAuth {
1592            username: "user".to_string(),
1593            password: "pass".to_string(),
1594        };
1595        let auth_str = serde_json::to_string(&auth).expect("serialize proxy auth");
1596        let encrypted = crate::core::encryption::encrypt(&auth_str).expect("encrypt proxy auth");
1597
1598        temp_home.set_config_json(&format!(
1599            r#"{{
1600  "http_proxy": "http://proxy.example.com:8080",
1601  "proxy_auth_encrypted": "{encrypted}"
1602}}"#
1603        ));
1604        let config = Config::from_data_dir(Some(temp_home.path.clone()));
1605        let loaded_auth = config.proxy_auth.expect("proxy auth should be hydrated");
1606        assert_eq!(loaded_auth.username, "user");
1607        assert_eq!(loaded_auth.password, "pass");
1608        drop(key_guard);
1609    }
1610
1611    #[test]
1612    fn config_decrypts_proxy_auth_from_legacy_scheme_encrypted_fields() {
1613        let _lock = env_lock_acquire();
1614        let temp_home = TempHome::new();
1615
1616        // Use a stable encryption key so this test doesn't depend on host identifiers.
1617        let key_guard = crate::core::encryption::set_test_encryption_key([
1618            0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d,
1619            0x0e, 0x0f, 0x10, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, 0x18, 0x19, 0x1a, 0x1b,
1620            0x1c, 0x1d, 0x1e, 0x1f,
1621        ]);
1622
1623        let auth = ProxyAuth {
1624            username: "user".to_string(),
1625            password: "pass".to_string(),
1626        };
1627        let auth_str = serde_json::to_string(&auth).expect("serialize proxy auth");
1628        let encrypted = crate::core::encryption::encrypt(&auth_str).expect("encrypt proxy auth");
1629
1630        // Simulate older Bodhi/Tauri persisted config keys.
1631        temp_home.set_config_json(&format!(
1632            r#"{{
1633  "http_proxy": "http://proxy.example.com:8080",
1634  "http_proxy_auth_encrypted": "{encrypted}",
1635  "https_proxy_auth_encrypted": "{encrypted}"
1636}}"#
1637        ));
1638
1639        let config = Config::from_data_dir(Some(temp_home.path.clone()));
1640        let loaded_auth = config.proxy_auth.expect("proxy auth should be hydrated");
1641        assert_eq!(loaded_auth.username, "user");
1642        assert_eq!(loaded_auth.password, "pass");
1643        drop(key_guard);
1644    }
1645
1646    #[test]
1647    fn config_save_encrypts_proxy_auth_and_load_hydrates_plaintext() {
1648        let _lock = env_lock_acquire();
1649        let temp_home = TempHome::new();
1650
1651        // Use a stable encryption key so this test doesn't depend on host identifiers.
1652        let key_guard = crate::core::encryption::set_test_encryption_key([
1653            0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d,
1654            0x0e, 0x0f, 0x10, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, 0x18, 0x19, 0x1a, 0x1b,
1655            0x1c, 0x1d, 0x1e, 0x1f,
1656        ]);
1657
1658        let mut config = Config::from_data_dir(Some(temp_home.path.clone()));
1659        config.proxy_auth = Some(ProxyAuth {
1660            username: "user".to_string(),
1661            password: "pass".to_string(),
1662        });
1663        config
1664            .save_to_dir(temp_home.path.clone())
1665            .expect("save should encrypt proxy auth");
1666
1667        let content =
1668            std::fs::read_to_string(temp_home.path.join("config.json")).expect("read config.json");
1669        assert!(
1670            content.contains("proxy_auth_encrypted"),
1671            "config.json should store encrypted proxy auth"
1672        );
1673        assert!(
1674            !content.contains("\"proxy_auth\""),
1675            "config.json should not store plaintext proxy_auth"
1676        );
1677
1678        let loaded = Config::from_data_dir(Some(temp_home.path.clone()));
1679        let loaded_auth = loaded.proxy_auth.expect("proxy auth should be hydrated");
1680        assert_eq!(loaded_auth.username, "user");
1681        assert_eq!(loaded_auth.password, "pass");
1682        drop(key_guard);
1683    }
1684
1685    #[test]
1686    fn config_save_encrypts_provider_api_keys_and_does_not_persist_plaintext() {
1687        let _lock = env_lock_acquire();
1688        let temp_home = TempHome::new();
1689
1690        // Use a stable encryption key so this test doesn't depend on host identifiers.
1691        let key_guard = crate::core::encryption::set_test_encryption_key([
1692            0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d,
1693            0x0e, 0x0f, 0x10, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, 0x18, 0x19, 0x1a, 0x1b,
1694            0x1c, 0x1d, 0x1e, 0x1f,
1695        ]);
1696
1697        let mut config = Config::from_data_dir(Some(temp_home.path.clone()));
1698        config.provider = "openai".to_string();
1699        config.providers.openai = Some(OpenAIConfig {
1700            api_key: "sk-test-provider-key".to_string(),
1701            api_key_encrypted: None,
1702            base_url: None,
1703            model: None,
1704            fast_model: None,
1705            vision_model: None,
1706            reasoning_effort: None,
1707            responses_only_models: vec![],
1708            request_overrides: None,
1709            extra: Default::default(),
1710        });
1711
1712        config
1713            .save_to_dir(temp_home.path.clone())
1714            .expect("save should encrypt provider api keys");
1715
1716        let content =
1717            std::fs::read_to_string(temp_home.path.join("config.json")).expect("read config.json");
1718        assert!(
1719            content.contains("\"api_key_encrypted\""),
1720            "config.json should store encrypted provider keys"
1721        );
1722        assert!(
1723            !content.contains("\"api_key\""),
1724            "config.json should not store plaintext provider keys"
1725        );
1726
1727        let loaded = Config::from_data_dir(Some(temp_home.path.clone()));
1728        let openai = loaded
1729            .providers
1730            .openai
1731            .expect("openai config should be present");
1732        assert_eq!(openai.api_key, "sk-test-provider-key");
1733
1734        drop(key_guard);
1735    }
1736
1737    #[test]
1738    fn config_save_persists_mcp_servers_in_mainstream_format() {
1739        let _lock = env_lock_acquire();
1740        let temp_home = TempHome::new();
1741
1742        let mut config = Config::from_data_dir(Some(temp_home.path.clone()));
1743
1744        let mut env = std::collections::HashMap::new();
1745        env.insert("TOKEN".to_string(), "supersecret".to_string());
1746
1747        config.mcp.servers = vec![
1748            crate::agent::mcp::McpServerConfig {
1749                id: "stdio-secret".to_string(),
1750                name: None,
1751                enabled: true,
1752                transport: crate::agent::mcp::TransportConfig::Stdio(
1753                    crate::agent::mcp::StdioConfig {
1754                        command: "echo".to_string(),
1755                        args: vec![],
1756                        cwd: None,
1757                        env,
1758                        env_encrypted: std::collections::HashMap::new(),
1759                        startup_timeout_ms: 5000,
1760                    },
1761                ),
1762                request_timeout_ms: 5000,
1763                healthcheck_interval_ms: 1000,
1764                reconnect: crate::agent::mcp::ReconnectConfig::default(),
1765                allowed_tools: vec![],
1766                denied_tools: vec![],
1767            },
1768            crate::agent::mcp::McpServerConfig {
1769                id: "sse-secret".to_string(),
1770                name: None,
1771                enabled: true,
1772                transport: crate::agent::mcp::TransportConfig::Sse(crate::agent::mcp::SseConfig {
1773                    url: "http://localhost:8080/sse".to_string(),
1774                    headers: vec![crate::agent::mcp::HeaderConfig {
1775                        name: "Authorization".to_string(),
1776                        value: "Bearer token123".to_string(),
1777                        value_encrypted: None,
1778                    }],
1779                    connect_timeout_ms: 5000,
1780                }),
1781                request_timeout_ms: 5000,
1782                healthcheck_interval_ms: 1000,
1783                reconnect: crate::agent::mcp::ReconnectConfig::default(),
1784                allowed_tools: vec![],
1785                denied_tools: vec![],
1786            },
1787        ];
1788
1789        config
1790            .save_to_dir(temp_home.path.clone())
1791            .expect("save should persist MCP servers");
1792
1793        let content =
1794            std::fs::read_to_string(temp_home.path.join("config.json")).expect("read config.json");
1795        assert!(
1796            content.contains("\"mcpServers\""),
1797            "config.json should store MCP servers under the mainstream 'mcpServers' key"
1798        );
1799        assert!(
1800            content.contains("supersecret"),
1801            "config.json should persist MCP stdio env in mainstream format"
1802        );
1803        assert!(
1804            content.contains("Bearer token123"),
1805            "config.json should persist MCP SSE headers in mainstream format"
1806        );
1807        assert!(
1808            !content.contains("\"env_encrypted\""),
1809            "config.json should not persist legacy env_encrypted fields"
1810        );
1811        assert!(
1812            !content.contains("\"value_encrypted\""),
1813            "config.json should not persist legacy value_encrypted fields"
1814        );
1815
1816        let loaded = Config::from_data_dir(Some(temp_home.path.clone()));
1817        let stdio = loaded
1818            .mcp
1819            .servers
1820            .iter()
1821            .find(|s| s.id == "stdio-secret")
1822            .expect("stdio server should exist");
1823        match &stdio.transport {
1824            crate::agent::mcp::TransportConfig::Stdio(stdio) => {
1825                assert_eq!(
1826                    stdio.env.get("TOKEN").map(|s| s.as_str()),
1827                    Some("supersecret")
1828                );
1829            }
1830            _ => panic!("Expected stdio transport"),
1831        }
1832
1833        let sse = loaded
1834            .mcp
1835            .servers
1836            .iter()
1837            .find(|s| s.id == "sse-secret")
1838            .expect("sse server should exist");
1839        match &sse.transport {
1840            crate::agent::mcp::TransportConfig::Sse(sse) => {
1841                assert_eq!(sse.headers[0].value, "Bearer token123");
1842            }
1843            _ => panic!("Expected SSE transport"),
1844        }
1845    }
1846
1847    // ── Env vars lifecycle tests ──────────────────────────────
1848
1849    #[test]
1850    fn env_vars_as_map_includes_only_non_empty_values() {
1851        let mut config = Config::default();
1852        config.env_vars = vec![
1853            EnvVarEntry {
1854                name: "A".to_string(),
1855                value: "val_a".to_string(),
1856                secret: false,
1857                value_encrypted: None,
1858                description: None,
1859            },
1860            EnvVarEntry {
1861                name: "B".to_string(),
1862                value: "".to_string(), // empty → should be excluded
1863                secret: true,
1864                value_encrypted: None,
1865                description: None,
1866            },
1867            EnvVarEntry {
1868                name: "C".to_string(),
1869                value: "  ".to_string(), // whitespace-only → excluded
1870                secret: false,
1871                value_encrypted: None,
1872                description: None,
1873            },
1874            EnvVarEntry {
1875                name: "D".to_string(),
1876                value: "val_d".to_string(),
1877                secret: true,
1878                value_encrypted: Some("enc".to_string()),
1879                description: Some("desc".to_string()),
1880            },
1881        ];
1882
1883        let map = config.env_vars_as_map();
1884        assert_eq!(map.len(), 2);
1885        assert_eq!(map.get("A"), Some(&"val_a".to_string()));
1886        assert_eq!(map.get("D"), Some(&"val_d".to_string()));
1887        assert!(!map.contains_key("B"));
1888        assert!(!map.contains_key("C"));
1889    }
1890
1891    #[test]
1892    fn sanitize_env_vars_for_disk_clears_secret_plaintext() {
1893        let mut config = Config::default();
1894        config.env_vars = vec![
1895            EnvVarEntry {
1896                name: "PLAIN".to_string(),
1897                value: "visible".to_string(),
1898                secret: false,
1899                value_encrypted: None,
1900                description: None,
1901            },
1902            EnvVarEntry {
1903                name: "SECRET".to_string(),
1904                value: "hidden_value".to_string(),
1905                secret: true,
1906                value_encrypted: Some("enc_data".to_string()),
1907                description: None,
1908            },
1909        ];
1910
1911        config.sanitize_env_vars_for_disk();
1912
1913        assert_eq!(config.env_vars[0].value, "visible"); // plain kept
1914        assert_eq!(config.env_vars[1].value, ""); // secret cleared
1915    }
1916
1917    #[test]
1918    fn sanitize_env_vars_for_disk_preserves_encrypted() {
1919        let mut config = Config::default();
1920        config.env_vars = vec![
1921            EnvVarEntry {
1922                name: "OPEN".to_string(),
1923                value: "val".to_string(),
1924                secret: false,
1925                value_encrypted: None,
1926                description: None,
1927            },
1928            EnvVarEntry {
1929                name: "HIDDEN".to_string(),
1930                value: "real_secret".to_string(),
1931                secret: true,
1932                value_encrypted: Some("enc".to_string()),
1933                description: None,
1934            },
1935        ];
1936
1937        config.sanitize_env_vars_for_disk();
1938
1939        // Plain value untouched
1940        assert_eq!(config.env_vars[0].value, "val");
1941        // Secret plaintext cleared, but encrypted preserved
1942        assert_eq!(config.env_vars[1].value, "");
1943        assert_eq!(config.env_vars[1].value_encrypted.as_deref(), Some("enc"));
1944    }
1945
1946    #[test]
1947    fn refresh_env_vars_encrypted_round_trip() {
1948        let mut config = Config::default();
1949        config.env_vars = vec![
1950            EnvVarEntry {
1951                name: "TOKEN".to_string(),
1952                value: "my-secret-token".to_string(),
1953                secret: true,
1954                value_encrypted: None,
1955                description: Some("A token".to_string()),
1956            },
1957            EnvVarEntry {
1958                name: "PLAIN_VAR".to_string(),
1959                value: "hello".to_string(),
1960                secret: false,
1961                value_encrypted: None,
1962                description: None,
1963            },
1964        ];
1965
1966        // Encrypt
1967        config
1968            .refresh_env_vars_encrypted()
1969            .expect("encryption should succeed");
1970
1971        // Secret should now have encrypted value
1972        assert!(config.env_vars[0].value_encrypted.is_some());
1973        // Plain should have no encrypted value
1974        assert!(config.env_vars[1].value_encrypted.is_none());
1975
1976        // Save encrypted value for later comparison
1977        let encrypted = config.env_vars[0].value_encrypted.clone().unwrap();
1978        assert_ne!(encrypted, "my-secret-token"); // shouldn't be plaintext
1979
1980        // Clear plaintext (simulating disk write)
1981        config.sanitize_env_vars_for_disk();
1982        assert_eq!(config.env_vars[0].value, "");
1983
1984        // Hydrate (simulating disk read)
1985        config.hydrate_env_vars_from_encrypted();
1986        assert_eq!(config.env_vars[0].value, "my-secret-token");
1987        assert_eq!(config.env_vars[1].value, "hello"); // plain untouched
1988    }
1989
1990    #[test]
1991    fn publish_and_current_env_vars_round_trip() {
1992        let mut config = Config::default();
1993        config.env_vars = vec![EnvVarEntry {
1994            name: "TEST_PUBLISH".to_string(),
1995            value: "pub_value".to_string(),
1996            secret: false,
1997            value_encrypted: None,
1998            description: None,
1999        }];
2000
2001        config.publish_env_vars();
2002        let map = Config::current_env_vars();
2003        assert_eq!(map.get("TEST_PUBLISH"), Some(&"pub_value".to_string()));
2004    }
2005
2006    #[test]
2007    fn hydrate_skips_non_secret_entries() {
2008        let mut config = Config::default();
2009        config.env_vars = vec![EnvVarEntry {
2010            name: "PLAIN".to_string(),
2011            value: "original".to_string(),
2012            secret: false,
2013            value_encrypted: Some("should-be-ignored".to_string()),
2014            description: None,
2015        }];
2016
2017        config.hydrate_env_vars_from_encrypted();
2018        // Non-secret entry should keep its original value
2019        assert_eq!(config.env_vars[0].value, "original");
2020    }
2021
2022    #[test]
2023    fn default_config_has_empty_env_vars() {
2024        let config = Config::default();
2025        assert!(config.env_vars.is_empty());
2026    }
2027
2028    #[test]
2029    fn serde_round_trip_with_env_vars() {
2030        let mut config = Config::default();
2031        config.env_vars = vec![
2032            EnvVarEntry {
2033                name: "KEY1".to_string(),
2034                value: "val1".to_string(),
2035                secret: false,
2036                value_encrypted: None,
2037                description: Some("First key".to_string()),
2038            },
2039            EnvVarEntry {
2040                name: "KEY2".to_string(),
2041                value: "".to_string(), // on-disk secret has no plaintext
2042                secret: true,
2043                value_encrypted: Some("enc123".to_string()),
2044                description: None,
2045            },
2046        ];
2047
2048        let json = serde_json::to_string(&config).unwrap();
2049        let restored: Config = serde_json::from_str(&json).unwrap();
2050
2051        assert_eq!(restored.env_vars.len(), 2);
2052        assert_eq!(restored.env_vars[0].name, "KEY1");
2053        assert_eq!(restored.env_vars[0].value, "val1");
2054        assert!(!restored.env_vars[0].secret);
2055        assert_eq!(restored.env_vars[1].name, "KEY2");
2056        assert!(restored.env_vars[1].secret);
2057        assert_eq!(
2058            restored.env_vars[1].value_encrypted.as_deref(),
2059            Some("enc123")
2060        );
2061    }
2062}