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